浅谈 React 服务端渲染与同构

May 28, 2020

一、什么是服务端渲染:

服务端渲染(SSR)区别于客户端渲染(CSR),当用户访问一个网址的时候,客户端渲染是服务端返回一部分 HTML 结构,通过执行 JavaScript 将余下的 HTML 渲染到页面上,而服务端渲染则是在服务端生成完整的 HTML 结构返回给浏览器。 以 React 为例:在客户端渲染时一般 html 的 body 中只有一个 id 为 root 的标签,页面在浏览器上渲染出来后,通过执行 JavaScript 将页面内容插入到这个标签中。而服务端渲染则在服务器端将页面内容插入到这个标签中,返回给浏览器的时候就已经有完整的页面结构了。

由此我们可以看到服务端渲染具有如下优势:

  1. 首屏渲染快:返回的 html 是完整的,不需要再发其他请求来不断地向页面填充内容,也不会在渲染过程中产生白屏或抖动,用户体验会好很多
  2. 有利于 SEO:React 客户端渲染页面中只有一个 root 标签,搜索引擎很难爬取页面内容,而服务端渲染能给搜索引擎更多的数据,因此能获得更高的权重和排名

服务端渲染的这些优势,伴随着一定的代价:

  1. 项目架构变得复杂,维护成本提高:客户端渲染时前端工程师只关注 JS,而做服务端渲染的话,则不仅需要关注同构部分的代码,还要关注 Node 服务端的代码;不仅要保证同构代码的正常运行,还要保证 Node 服务稳定。前端的 Node 服务部署上去之后,需要付出时间精力来进行服务器的运维
  2. 服务端执行 react 性能消耗较大,对 Node SSR 服务器性能损耗更多,对网速、并发数都是考验

二、什么是同构:

用同一份代码在服务端和客户端分别执行一次。代码在服务端执行时渲染了页面,在客户端执行时接管了页面交互。

这样做是因为事件绑定处理是浏览器的行为,服务端代码只能生成 html string,而客户端代码执行的时候使用 ReactDOM.hydrate 进行事件绑定。

三、怎样实现?

看完了上面这些概念,我们知道看了 SSR 的优势,那么如何用代码实现呢?

一个最简单的,基于 Koa 的 SSR 实现如下:

const Koa = require('koa');
const app = new Koa();
app.use(ctx => {
  ctx.body = `
    <html>
      <head>
          <title>title</title>
      </head>
      <body>
          hello SSR
      </body>
    </html>
  `;
});
app.listen(3000);

网页上展示的内容都是服务器端返回的,这样只做到了服务器端渲染,没有做同构处理,当页面复杂一些,有一些交互的时候就不行了。

引入 React 同构之后,一次完整的 React 同构 SSR 主要流程如下:

具体怎样实现呢?下面讲下几个关键步骤和一些细节:

1. 初始化目录与入口文件

源代码放到 /src 目录下,新建 /src/containers/Home.js 作为首页,containers 文件夹下放同构的组件,客户端和服务器端的入口文件都会引用这些组件来拼成页面:

👇 /src/containers/Home.js

import React, { Component } from 'react';
class Home extends Component {
    render() {
        return (
            <div>Home page.</div>
        );
    }
}
export default Home;

在 /src 目录下新建 client 和 server 目录,分别存放客户端和服务器异构的代码

👇 /src/client/index.js 客户端使用 containers 目录下的组件进行 React 组件渲染

import React from 'react';
import Home from './containers/Home'
const App = () => {
    return <Home />;
};
ReactDom.hydrate(<App />, document.getElementById('root'));

👇 /src/client/index.js 服务器端起服务,同时使用 containers 目录下的组件,使用 react-dom/server 提供的 renderToString 方法渲染页面

import Koa from 'koa';
const app = Koa();
import Home from './containers/Home';
import { renderToString } from 'react-dom/server';

const content = renderToString(<Home />);

app.use(async ctx => {
  const htmlStr = `
    <html>
        <head>
            <title>title</title>
        </head>
        <body>
            <div id="root">${content}</div>
            <script src="/index.js"></script>
        </body>
    </html>
  `);
  ctx.body = htmlStr;
});

app.listen('3000', function() {
    console.log('started!');
});

2. 环境搭建

安装依赖(react、react-dom、webpack、babel等),使用 webpack 进行打包。

从上面的流程图可以看出,我们至少需要打包两份代码:一份是服务器端代码,用于 Node 端起服务、渲染页面;另一份是浏览器端拿到 html 文件后请求的客户端代码,用于接管页面交互。

👇 webpack.base.js 导出公用的 webpack 配置

rules: [{
    test: /\.js?$/,
    loader: 'babel-loader',
    exclude: /node_modules/,
    options: {
        presets: ['@babel/preset-react', ['@babel/preset-env', {
            targets: {
                browsers: ['last 2 versions']
            }
        }]]
    }
}]

👇 webpack.client.js 导出客户端的配置,入口文件为 /src/client/index.js,导出到 public 目录下

const path = require('path');
const merge = require('webpack-merge');
const base = require('./webpack.base');

const clientConfig = {
    mode: 'development',
    entry: './src/client/index.js',
    output: {
        filename: 'index.js',
        path: path.resolve(__dirname, 'public')
    }
};
module.exports = merge(base, clientConfig);

// 在server 端返回的 html 模板文件中引入导出的 index.js
// 这样浏览器请求页面的时候会下载客户端打包的 react 代码,进行页面渲染,接管页面交互
`
  <script src="/index.js"></script>
`

👇 webpack.server.js 导出服务端的配置,入口文件为 /src/server/index.js,导出到 dist 目录下

const path = require('path');
const nodeExternals = require('webpack-node-externals');
const merge = require('webpack-merge');
const config = require('./webpack.base');

const serverConfig = {
    target: 'node',
    mode: 'development',
    entry: './src/server/index.js',
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist')
    },
    externals: [nodeExternals()]
};
module.exports = merge(config, serverConfig);

注意配置中的 target 为 node 是告诉 webpack 打包的对象是 node 端的代码,这样一些原生模块webpack 就不会做处理。

配置中的 webpack 的 externals 属性是告诉 webpack 在打包过程中,遇到 externals 中声明的模块不用处理。

使用时调 node 执行 /dist/bundle.js 即可起服务器,监听端口,返回服务器端渲染的页面了。

关于 Node 端代码打包,有兴趣的同学可以看小爝的这篇文章:https://zhuanlan.zhihu.com/p/20782320

3. 数据脱水和注水

服务端渲染只发生在第一次进入页面的时候,再跳转的时候不是服务器端跳转,而是 JS 控制跳转(react 代码接管)。

因此首次渲染应该在服务器端发送请求,将结果渲染到页面上返回,否则客户端渲染页面后再请求数据,页面会有空白。

服务器端发送请求后,应该将请求的结果存起来,交给客户端直接使用,客户端不应该再发请求了。以 react-redux 为例,请求回来的数据存放到 store 中,渲染时依据 store 中的数据,渲染具体做法是:

  1. 服务器端渲染的时候,给 window 挂一个 state = store.getState() (数据注水)

    class Home extends React.Component {
    // 同构部分代码将服务器端发的请求独立出来(我们在这里命名为 fetching 方法)
    static fetching = ({dispatch}) => {
        return dispatch(getListAction());
    }
    render()...
    }

    服务端代码使用 react-router-config 匹配到所渲染路由的组件

    import { matchRoutes } from 'react-router-config';
    app.use(async(ctx) => {
        const matchedRoutes = matchRoutes(routes, ctx.request.path)
        const store = getStore();
        const promises = [];
        // 遍历路由匹配到的组件中的 fetching 方法
        matchedRoutes.forEach(item => {
            if (item.route.fetching) {
                // 代码优化:多个 promise 其中某个有错误, 走不到 all 里,而是直接进 catch,其他的请求会中断
                // 因此用 Promise 再包一层,让每个 promise 请求的 catch 都 resolve 回来,保证一定会走到 all 里
                const promise = new Promise((resolve, reject) => {
                    item.route.fetching(store).then(resolve).catch(resolve);
                })
                promises.push(promise);
            }
        })
    
        // 函数执行后将返回数据存到 store
        await Promise.all(promises).catch(error => {
            ctx.app.emit('error', err, ctx);
        })
    
        // 服务端渲染时候使用这个 store
        const content = renderToString(
            <Provider store={store}>
                ...
            </Provider>
        );
    
        // 数据注水:服务端渲染的 store 存到 window.context 下面
        ctx.body = `
            <html>
                <body>
                    <div id="root">${content}</div>
                    <script>
                        window.context = { state: ${JSON.stringify(store.getState())} }
                    </script>
                    <script src="/index.js" ></script>
                </body>
            </html>
        `;
        ctx.body = await render(ctx);
    });
  2. 客户端渲染的时候,从 window.context 拿出数据直接使用 (数据脱水)

    // 数据脱水:客户端渲染初始化的时候,将 window.context 里的数据(即 SSR 请求返回的数据) 脱水,取出来,作为客户端渲染 store 中的初始值
    export const getClientStore = () => {
        const defaultState = window.context.state;
        return createStore(reducer, defaultState);
    };

客户端渲染的时候不走 fetching 方法,在 componentDidMount 的时候判断 list 是否有值,有值的话不发请求。

从其他页面跳转过来的时候,不走服务端渲染,因此 list 没有值,这时正常发送请求。

class Home extends React.Component {
    // 服务端渲染时不会执行 componentDidMount 声明周期
    componentDidMount() {
        if (!this.props.list.length) {
            this.props.getHomeList();
        }
    }
    render()...
}

4. 服务端请求带上 cookie

服务器端发起请求的时候,不会像浏览器端请求那样携带 cookie 等信息

这时候我们可以在 axios 的配置中加上 headers cookie,其他浏览器端的信息也可以通过这种形式引入

axios.create({
    headers: {
        cookie: req.get('cookie') || ''
    }
});

5. CSS 服务器端渲染

服务器端渲染不只包含 DOM 结构和页面交互,还包扩 CSS,如果服务器端渲染的时候不渲染样式,那么页面加载的时候会有抖动,举个例子,src/containers/Home 组件下引入 CSS,给 body 设置背景色,打包后页面首次渲染出来是没有背景色的,执行 index.js 时 JS 将 CSS 模块的代码插入到 style 标签中,第二次渲染时才会有背景色,打开控制台的 Performance 面板可以看到渲染的详细过程:

为了解决这个问题,我们需要在服务器端渲染的时候将 CSS 代码插入到 style 标签中返回。

在 webpack.client.js 中配置 style-loader && css-loader 可以支持引入 css 模块,但是在 webpack.server.js 中这样配置则 build 时会报错:

webpack:///./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js?:93
  var style = document.createElement('style');
              ^
ReferenceError: document is not defined

这是因为服务端环境没有全局变量 window,将 style-loader 替换成 isomorphic-style-loader 这个模块可以帮我们获取到所有模块的 CSS 代码,使用 insertCss 方法将这些代码插入到 style 标签中。

6. meta tags 渲染

使用 React-helmet 将 <Helmet> 标签插入到组件中,即可修改 html 中 head 部分内容

<Helmet>
    <title>Another Title</title>
</Helmet>

7. 403 404 处理

react-router-dom 模块 import { Redirect } from ‘react-router-dom’; 服务端渲染时,使用 react-router-config 的 renderRoutes 方法,可以自动获取路由下的重定向情况,并赋值到 context 上,组件里引入 react-router-dom,提供的 <Redirect /> 可以被 renderRoutes 获取到:

const context = {};
const content = renderToString(
    <Provider store={store}>
        <StaticRouter location={ctx.request.path} context={context}>
            <div>
                {renderRoutes(routes)}
            </div>
        </StaticRouter>
    </Provider>
);

// 获取到路由重定向时
if (context.action === 'REPLACE') {
    ctx.status = 301;
    ctx.redirect('/');
} else if (context.NOT_FOUND) { // 在 404 页面设置 flag,SSR 时即可拿到 404 的 flag
    ctx.status = 404;
}

在 route 配置中,所有配置项的末尾配置 NotFound 组件:

class NotFound extends React.Component {
    componentWillMount() {
        const { staticContext } = this.props;
        // 在 404 页面设置 flag,SSR 时即可拿到 404 的 flag
        staticContext && (staticContext.NOT_FOUND = true);
    }
    render() {
        return <div>404!</div>;
    }
}

// route 配置:
export default [{
    path: '/',
    component: APP,
    routes: [
        ...allRoutes, // 所有路由
        {
            component: NotFound,
        }
    ]
}];