React服务端渲染,在React官方文档中只有ReactDOMServer一篇,简单的介绍了renderToString()renderToStaticMarkup()两个方法,但也说明了React服务端渲染的本质——将编译后的React组件当做模板函数执行,并输出文本字符串完成HTML文档生成。

核心的实现围绕renderToString()方法的成功调用,步骤如下:

1. 调用renderToString()方法

const React = require('react');
const ReactDOMServer = require('react-dom/server');
const App = require('./app/App.js').default;
const html = ReactDOMServer.renderToString(React.createElement(App, data));

2. 在Node服务器中运行

React也是JavaScript,HTTP请求生成HTML文档最简单的办法是将其在Node中运行,直接使用成熟的Express来完成。

npm i express --save

const express = require('express');
const app = express();
app.get('/', function (req, res) {
    // 下面代码可以将渲染的结果直接输出,但不符合正式使用要求
    res.send(html);
});

app.listen(3000, function () {
    console.log('Example app listening on port 3000!')
});

3. 让Node服务器支持JSX

Node服务器目前不支持JSX(尚不是标准语法),React组件中的JSX需要经过处理才能运行,主要有两种方法:

1. 编译预处理;
2. 实时处理,使用require的hook,require的调用时先编译处理为标准JavaScript再执行。

方法1繁琐一些,直接使用方法2来完成。编译转化库跟浏览器端保持一致,使用babel-register。再presets中配置es2015react-app来支持import&exportJSX

npm i --save babel-register
npm i --save babel-preset-es2015

require("babel-register")({
    presets: ["es2015", "react-app"],

    // Optional ignore regex - if any filenames **do** match this regex then they
    // aren't compiled.
    ignore: /(.css|.less)$/,

    // Optional only regex - if any filenames **don't** match this regex then they
    // aren't compiled
    only: /src/,

    // Setting this will remove the currently hooked extensions of .es6, `.es`, `.jsx`
    // and .js so you'll have to add them back if you want them to be used again.
    extensions: ['.js'],

    // Setting this to false will disable the cache.
    cache: true
});

NODE_ENV=development node index.js

现在已经可以引用App组件了,但会存在错误。

4. 忽略组件中的CSS和图片等非JS资源引用

为了便于维护,一般将组件相关的CSS就近引用,但这部分代码对Node来说是无效代码,将其在上述的预处理过程中忽略。

注意

此时,忽略后的图片引用如import logo from './logo.svg';时,logo的值为{}。需要继续优化服务器端静态资源的处理(限制静态资源的使用方式也是个好办法,或者写个对应CDN的钩子)

npm i --save ignore-styles

require('ignore-styles');

至此,已经有了正确的html输出。但格式不符合使用要求,继续调整。

5. 引入模板,规范化HTML的使用

express支持很多中模板,选择偏好的Hogan

npm i --save consolidate
npm i --save hogan.js

const engines = require('consolidate');
app.set('views', './views')
app.engine('html', engines.hogan);

res.render('index.html', { html: html, data: JSON.stringify(data) });

6. 完成index.html视图

为了方便开发,将开发服务器合并到express。需要开发过程中完成index.html的实时更新(主要是JS和CSS文件),并将相关的数据输出即可,服务器输出代码如下。

<script>
    window.__INITIAL_STATE__ = {{& data }};
</script>

<div id="root">
    {{& html }}
</div>

此时,代码修改完成后,刷新浏览器即可。

7. 配置静态资源

直接使用express的static中间件(开发环境,线上部署使用对应的CDN)

app.use(express.static('build'));

8. 服务器端支持fetch

代码在服务器端渲染的时候,Node环境需要作为HTTP Client访问服务接口。为了保持和客户端代码的同构,使用同样的fetch方法。

服务器端使用fetch和客户端存在一些差异,详见LIMITS.md

需要注意的是,服务器端fetch完成之后才能继续render,fetch是异步操作,render是同步操作。

npm i --save node-fetch

// polyfills
global.fetch = require('node-fetch');

9. 引入Redux

状态(数据,Props、State)+ 组件(模板) = 视图

随着应用程序的复杂度增加,将状态和组件分离是常见的做法,比如MVC和Flux的设计模式。将在浏览器端运行的JS代码复用——同构到服务端之后,原来的应用程序架构依然适用,客户端的状态处理和服务器端同构。

npm install --save redux
npm install --save react-redux
npm install --save redux-thunk

Redux中典型的状态更新属于同步模式,引入redux-thunk之后,异步的Action被包装为promise。

10. 引入React Router

npm install --save react-router

React Router提供了StaticRouter(在React Router内部保持应用程序的location状态,适用于不需要更新浏览器地址栏的测试环境,或者无法更改浏览器端地址栏的服务端环境); BrowserRouter和HashRouter等适用于浏览器环境的工具; 以及matchPath方法来进行路径的匹配计算。

原则上讲,引入路由后,路由中的URI代表整个页面上所有的资源,所以页面上所有的数据(状态)都可以根据URI中的信息直接或间接获取到。有两部分状态需要处理,一部分是路由的状态,一部分是应用的状态数据。获取到数据后,将其填入模板,即可完成模板的渲染。

分析React中的典型状态和渲染

在典型的客户端React应用程序中,最终页面的渲染完成要经过以下5个主要过程:

  1. 在组件(Component)中的componentWillMount方法中执行容器(Container)组件中的异步获取数据方法;
  2. 随后执行render方法,此时虽然尚未获得数据,当渲染出第一版页面;
  3. 数据从服务器端返回,组件props更新;
  4. 重新调用render方法,呈现最终页面;
  5. 随着用户的交互(比如输入新的条件,重新执行步骤1、3、4;跳转新的URL地址,重新执行步骤1、2、3、4)。这个步骤在浏览器端独立发起,和步骤4中的最终页面是两个阶段的状态。
注意:

客户端必须发起请求,后续的操作和页面请求都需要单独发送请求,服务器预置的数据不包含这一部分。

服务器端渲染的目的是完成步骤4中的最终页面。而服务器端渲染使用的renderToString()方法本质上是一个同步函数,如果将上述代码直接用在服务器端,得到的是步骤2中的第一版页面(未填充数据)。此时需要想办法将步骤1中在componentWillMount方法中执行的获取数据方法前置到renderToString()方法执行前。

获取数据的方法一般在容器组件中定义,作为props传递到组件中调用。如果能将props中调用的方法在之前获取到就可以解决这个问题。初步想到的方法如下,典型的代码如下所示:

import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';

import Detail from '../component/Detail';
import {
    fetchDetail,
} from '../action';

function getInitData(dispatch, params) {
    const title = params.title;
    return () => dispatch(fetchDetail(title));
}

function mapStateToProps(state, ownProps) {
    const title = decodeURIComponent(ownProps.match.params.title);
    const entity = state.data.entities.find(item => item.title === title) || {}; 
    
    return Object.assign({
        isLoading: state.data.isDetailFetching,
    }, entity);
}

function mapDispatchToProps(dispatch, ownProps) {
    const title = ownProps.match.params.title;
    return {
        fetch: getInitData(dispatch, ownProps.match.params),
    };  
}

const DetailContainer = withRouter(connect(mapStateToProps, mapDispatchToProps)(Detail));
DetailContainer.getInitData = getInitData;

export default DetailContainer;

约定每个容器组件中的getInitData方法用来获取组件中所使用的初始化数据。将原来mapDispatchToProps中的初始化逻辑独立出来。并将这个函数通过容器组件的静态方法暴露出来,以便于在能获取到容器组件的地方,就能使用该初始化方法。至此,将原来在组件中使用的方法暴露在了路由组件中。

接下来需要思考的问题是,路由的匹配。React Router提供了matchPath方法来匹配路径,路径在客户端指地址栏中的URL,在服务端所指的就是req.url,即请求发送到服务器的URL。

可以将React Router进行如下改进,以方便将路由的配置暴露出来:

const config = [
    { exact: true, path: '/', component: Home },
    { exact: true, path: '/:title', component: Detail },
];

const routes = (
    <Switch>
        {
            config.map((item, index) => (<Route key={index} {...item} />))
        }
        <Redirect from='*' to='/'/>
    </Switch>
);

export {
    config,
    routes as default,
};

从逻辑上讲,路由解析后被渲染组件中的componentWillMount方法中获取到的数据和提前解析路由,并主动使用相同的方法获取到的数据一致。

服务器端需要处理如下:

app.get('/*', function (req, res) {
    // 简单解决node-fetch host问题
    app.locals.host = req.headers.host;

    // store必须是fresh的,以避免前后请求间的干扰
    const store = configureStore();
    const context = {};

    // inside a request
    const promises = []
    // use `some` to imitate `<Switch>` behavior of selecting only
    // the first to match
    routerConfig.some(route => {
        // use `matchPath` here
        const match = matchPath(req.url, route);
        console.log('match', match);
        if (match) {
            promises.push(route.component.getInitData(store.dispatch, match.params)());
        }
        return match;
    });

    Promise.all(promises).then(data => {
        // do something w/ the data so the client
        // can access it then render the app
        const props = store.getState();
        console.log('store.getState()', props);
        const html = ReactDOMServer.renderToString(React.createElement(Root, {
            store: store,
            isClient: false,
            location: req.url,
            context: context,
        }));
        console.log('html', html);
        res.render('index.html', { html: html, props: JSON.stringify(props) });
    });
});

将匹配到的路径中容器组件获取到,并使用其返回的thunk组装到Promise.all中,随后处理服务器端渲染。

注意:

componentWillMount方法中异步获取数据的方法迁移到componentDidMount方法中更合适。原因有两个:

  1. 避免服务器端请求重复发送。如果处理不当,容易给服务器造成双倍的压力。componentWillMount会在服务器端执行,如果里面有数据请求相关的操作,会造成再次请求数据。将其放置在componentDidMount中,但在首次加载完成页面后,有可能会再次发送请求,需要在请求加载时做合适的判断)。
  2. 从时机上讲,虽然前者在后者之前执行,但后者的执行时机并没有太大差异。却可以明确的表明:异步数据的获取在客户端执行。 componentWillMount适合执行同步的状态更新。

11. ReactDOM.hydrate方法的使用

该方法是React 16版本中添加的新方法,专门用来配合服务器端渲染在客户端调用,以达到更好的性能。

ReactDOM.hydrate(<Root
    store={store}
    isClient
/>, document.getElementById('root'));

提醒

React 16中还提供了粉笔等价于renderToStringrenderToStaticMarkup方法的renderToNodeStreamrenderToStaticNodeStream方法。

Todo

  1. 热更新;
  2. 代码分割;
  3. 服务器部署(包括服务器HTTP Client相关的DNS解析)。

源码

react-server-render

参考

  1. ReactDOM
  2. ReactDOMServer
  3. React Without JSX
  4. React Top-Level API .createElement
  5. Redux Server Rendering
  6. React Router Server Rendering
  7. Babel register
  8. ignore-styles
  9. Express
  10. consolidate.js
  11. Hogan.js
  12. MUSTACHE MANUAL
  13. Mustache.js 使用简介
  14. node-fetch;
  15. isomorphic-fetch;