前端开发
首发于前端开发
【React/Redux】深入理解React服务端渲染

【React/Redux】深入理解React服务端渲染

在我的博客上观看:

【React/Redux】深入理解React服务端渲染 - dtysky|一个行者的轨迹

在上一篇文章【React/Redux/Router/Immutable】React最佳实践的正确食用姿势中,已经论述了React最佳实践的前端部分,但在最后也已说明,那种基本实现对SEO并不友好,并且由于首屏渲染依赖于ajax所以在JS禁用状态下基本也就废了,所以我们需要利用服务端渲染(Server side rendering)来对首屏进行优化。虽然React官方提供服务端渲染的API,React-router也支持,但在渲染依赖于ajax请求的状况下仍然聊胜于无,但这并不是无解的,和Redux的合作便可以相对完美地解决这个问题。

接下来将以我的博客为例,论述一下如何正确进行React的服务端渲染,这一部分的代码基本都在server/server.bin.js中。

问题

如果没有服务端渲染,那么一个React架构的SPA的渲染是这样的:

客户端请求资源 -> 返回index.html模板 -> 请求js文件并加载 -> React执行,挂载组件 -> 进入路由 -> 一些根据组件生命周期而做的初始化操作 -> 渲染组件

在这种模式下,一开始客户端只会收到一个只有架子、没有内容的Html文件,等待js文件加载完毕后才会执行后续操作并渲染出期望中的页面。这也就是说,无论是哪个路由下的页面,一开始都会先进入index.html这个入口,然后再客户端进行接下来的渲染,这对于大部分用户确实是没有什么问题,但对于部分场景和搜索引擎就不是这样了。

某些场景

主要是JS被禁用的场景,比如是微信,在某些情况下,你在微信分享的页面可能会被判定为广告页面,在不做认证(备案)的情况下,JS脚本会被禁用,这会使得后续的渲染都无法执行。

SEO

SEO相较前面是更为严重和急迫的问题,搜索引擎这个绝大多数网站的主要流量入口,如果不加以重视,除非网站是故意不想被曝光,否则是一笔很大的损失。而根据前面的介绍不难得知,如果每次都将index.html作为入口,利用JS和ajax来渲染,搜索引擎是基本无法获得你的真正页面内容的,除了下面这个粗暴的解决方案。

一个粗暴的解决方案

这也是我上次重构最终选择的方案,对于一些搜索引擎(谷歌明确支持,必应似乎近期也支持了,百度万年技术最差不用等了),有着明确的对ajax页面的一个折中解决方案,我们可以在index.html的head里加上这么一句:


<meta name="fragment" content="!">

搜索引擎解析出这个meta信息后,便会在原始url后面加上一个?_escaped_fragment_来再次发起请求,这时候我们只需要在服务端备好另一套SEO专用的页面返回给其即可。这种方法虽然有效,但本质上还是要求我们另外准备一套界面,并且这一套和原来的代码还不能复用。比如,我在那时候就是专门写了一套jade模板来做SEO:


// Express.js的中间件
function SEO(req, res, next) {
    const escapedFragment = req.query._escaped_fragment_;
    if (escapedFragment) {
        const parsedUrl = url.parse(req.url);
        const rdPath = `/${jade}/parsedUrl.pathname`;
        logInfo("Redirect: from", req.path, "to ", rdPath);
        return res.redirect(rdPath);
    }
    return next();
}

这相当于将工作量翻了两倍。不仅如此,这还会使得server更加复杂,对于我这个Blog项目,这种妥协似乎还勉强可以接受,但对于一个复杂一些的网站就不是这样了,我们必须用一种新的方法来用最少的代码解决这个问题。

React服务端渲染

React+React-router+Redux便是解决这个问题的一套相对的完美的方案,虽然我们还是要对一些渲染逻辑做修改,但相较别的方案,仍然好得多。

ReactDom

React提供了一个API用于将虚拟DOM树在服务端环境下进行渲染,这个API是ReactDom/server中的renderToString。这个方法接受一个虚拟DOM树,并返回一个渲染后的HTML字符串,例如我有一个根级组件Root,我便可以使用下列语句得到结果:


import {renderToString} from 'ReactDom/server';
const markup = renderToString(
    <Root />
);

markup即为渲染后的结果。renderToString这个方法是服务端渲染的基础,但如果只是单纯这样使用,那么基本等于将React作为一个复杂很多的模板语言来写而已,因为这个渲染并不会理会任何的ajax请求,也不会根据url来做任何路由,它只会在第一次render方法调用后结束。这也就是说render方法之后的所有生命周期函数都不会被触发,再一次服务端渲染中,只有constructor、componentWillMount和render会被各触发一次,并且在期间使用setState也是没有意义的。

这显然不是我们期望的,为了愉快得满足我们的须有,这里有两个问题需要解决:

  1. 路由。
  2. ajax请求。

React-router

正如上一篇文章所言,路由问题可以交由React-router解决。它提供了一系列方法来在服务端捕获请求参数,并和renderToString结合来渲染出路由最终对应的组件,其基本原理是通过一个match方法来根据客户端请求的url解析出已经定义好的routes的props,解析结果是error(错误)、redirectLocation(如果有重定向)和renderPorps(正常状况下解析到的props),我们只需要根据这些参数来执行对应的操作即可:


import {match, RouterContext} from 'react-router';
import routes from '../src/routes';
match({routes, location: req.url}, (error, redirectLocation, renderProps) => {
    if (error) {
        logError('Match routes failed: ', error, error.stack);
        return res.status(500).sendFile(config.error50xFile);
    }
    if (redirectLocation) {
        logError('Match routes redirectLocation: ', redirectLocation);
        return res.redirect(302, `${redirectLocation.pathname}${redirectLocation.search}`);
    }
    if (renderProps) {
        return render(req, res, renderProps);
    }
    logError('Miss match routes: ', error, error.stack);
    return res.status(404).sendFile(config.error404File);
});

除了前两种特殊情况和最后没有匹配到执行的404响应,一般情况下我们都会进入到render这个方法中根据renderProps来进行正常状况下的响应。renderProps的结构如下(以blog项目,并以当前这个页面的路由为例):


{
    routes: [
        {path: '/', component: APP, ......}
        {path: 'article/:name', components: {content: Article}}
    ],
    params: [name: Skill-2016_10_11_a'],
    location: {
        pathname: '/article/Skill-2016_10_09_a',
        ......
    },
    components: [
        {
            [Function: Connect] displayName: 'Connect(APP)',
            ......
        },
        {content: Article}
    ],
    history: {......},
    router: {......},
    matchContext: {......}
}

这里面对我们有用的信息很多,但如果只是单纯用React-router配合来做服务端渲染,我们并不用了解这些,而是直接把renderProps传给待渲染的组件即可:


import {renderToString} from 'react-dom/server';
import {match, RouterContext} from 'react-router';
const markup = renderToString(
    <RouterContext {...renderProps} />
);

通过使用RouterContext,我们便得到了和客户端渲染时相似的Router组件,将其作为根组件并传入renderProps,再调用renderToString,一次具有路由的服务端渲染便完成。在此例中,它最终会返回主要区域是Article组件的真实DOM。

通过和React-router的结合,我们解决了第一个、也是相对容易的问题,但这仍然无法解决ajax调用的问题,这时候,Redux就该出场了。

Redux

Redux配合React进行服务端渲染的教程已经有许多,但这些教程大都不过是官网那篇例子的搬运,例子中所述方法的基本思想如下:

基本思想

首先要明确我们真正的问题是什么,由于服务端渲染只会走一遍生命周期,并且在第一次render后便会停止,所以想要真正渲染出最终的页面,我们必须在第一次渲染前就将状态准备好我们期望中的状态,这也就是说,我们必须要有一次超前的ajax请求实现获取状态,然后来根据这个状态渲染:


function serverSideRender(req, res) {
    request.get(url)
    .then(response => normalRender(res, response))
    .catch(err => render500(res, err))
}

在自己定义的normalRender这个方法里,我们可以通过Redux提供的createStore方法的第二个参数来进行创建带有初始状态的store,然后将这个状态送入根组件,并执行后续的渲染:


function normalRender(res, response) {
    // 假设response的响应体中就包含了所有状态信息
    const initState = response.body;
    const store = createStore(reducers, initState);
    const markup = renderToString(
        <Provider store={store}>
            <APP />
        </Provider>
    );
    // 将markup和最终的state塞到模板内渲染,这个模板取决于使用的模板引擎,也可以直接字符串替换
    return res.render(template, {
        markup,
        finalState: store.getState().toJSON()
    });
}

这个方法以响应结果为初始化状态渲染DOM,并将渲染后的结果塞入模板,值得注意的是,渲染参数里面有个finalState,这是初次渲染后、store的最终状态,我们需要将其序列化后强制写到返回的HTML的script标签中,将其赋予一个、例如叫initState的变量中,这样最终返回的HTML结构如下:


<html>
    <head>
        ......
        <script>window.initState = {{finalState}}</script>
    </head>
    <body>
        <div id="react-container"><div>{{markup}}</div></div>
    </body>
</html>

window.initState便拥有了我们服务端渲染后的状态,如此,客户端便有了一个途径来根据这个状态来初始化客户端的store,并接续接下来的操作,这实质上是完成了服务端和客户端之间状态的对接


const store = createStore(reducers, window.initState);

特别注意,在将markup写入模板时我在React顶层DOM中又插入了一层div,这是为了解决一个特殊的警告,详细请看这里:Warning: React attempted to reuse markup in a container but the checksum was invalid

要注意,我们可以延续状态,并非说Redux可以直接帮我们解决重复渲染的问题,无论如何,在每次重新执行初始渲染的时候,组件的生命周期还是会走一遍,如果阻止重复ajax请求、重复渲染,就是我们要在逻辑里自己把握的事情了,比如,我们的state里如果有一个字段是list,而这个list在这个应用的一次访问中只会初始化一次,那么我们便可以在初始化它的action中这么写:


function getList(currentList) {
    if (!currentList.isEmpty()) {
        return;
    }
}

那么list便不会被二次初始化,也不会进行进一步的无效渲染。

这样,官方建议的服务端渲染便完成。但这其实有另外两个问题,一个是路由的问题,还有一个,按照这种说法,我们难道要写另一套专门的逻辑来在渲染前初始化状态?一个两个状态还好,但一般SPA都不会只有这么点,而且这种ajax请求我们一般都会写在专门的action中,并在action成功或失败后交给ruducer,这些代码难道不能复用吗?客户端和服务端的同构化开发就不能实现吗?

当然可以。

路由

和React-router的结合实际上不是Redux的问题,而是React-router自身的问题,其核心在于如何根据匹配后的renderProps来获取用于初始化状态的信息,在上面一章我们已经拿到了renderProps的详细信息,接下来便可以妥善地利用这些信息。

虽然如何利用它们取决于项目的设计,但一个显而易见的策略是利用匹配后的params来作为根据进行ajax请求来获取初始数据:


const {params} = renderProps;
const {type, name} = params;
request.get(`${backendHost}/${type}/${name}`)
......

这只是一个例子,表明如何根据信息来获取响应数据来初始化状态。

在这个Blog项目中,我采用了一些特别的方法来进行这一步的操作。在我定义一个和路由相关的组件,比如Article时,我会为其赋予静态变量type:


export default class Article extends Base {
    static type = 'article';
    ......
}

再加上上面分析的内容,我们可以从renderProps.components[1].content(content是该路由对应的组件,在此处是Article,请见上一篇文章)中拿到这个type:


const {type} = renderProps.components[1].content;

然后根据这个type进行接下来的操作即可。

进阶

有了路由和基础的服务端渲染,要解决的问题便只剩同构化开发、避免多余代码的同构开发问题。这个问题解决的核心在于——store在渲染过程中是可变的,并且在服务端渲染进行过程中,有一个componentWillMount的阶段可供我们和客户端平等使用。这二者是接下来内容的基础。

由于componentWillMount会在服务端渲染过程中执行,并且执行时this.props中的内容已经根据路由信息被修改,我们可以将ajax请求放入其中:


componentWillMount() {
    const {dispatch, params} = this.props;
    const {type, name} = params;
    dispatch(getList(type, name));
}

我们可以在其中执行任意个ajax请求,由于store是可改变的,所以在请求结束后、reducer生效后将会我们便可以取到完整的、初始化过的store。也就是说,我们只要如此写好组件的生命周期,而后进行正常的服务端渲染,然后只需等待便可以得到一个初始化后的store,随后只需要将这个store作为初始化数据送入模板即可。

这听起来是不是很简单?确实很简单,但仍然有一点需要注意——ajax请求时异步的,一次服务端渲染结束后,并不代表ajax请求就结束了,我们仍然需要一些方法来确定所哟请求确实结束,这里就需要一点设计了——我们可以在store中创建一个state专门用于表示初始化是否结束,并配以相应的action和reducer来修改它:


componentWillMount() {
    const {dispatch, params} = this.props;
    const {type, name} = params;
    dispatch(getList(type, name))
        .then(() => dispatch({type: actionTypes.init.all.successful}))
        .catch(() => dispatch({type: actionTypes.init.all.failed}));
}

如此,我们便可以不断检查store.getState().state.initDone来确定所有请求是否结束:


function responseWithCheck(res, store, renderProps) {
    setImmediate(() => {
        if (!store.getState().state.get('initDone')) {
            return setImmediate(responseWithCheck, res, store, renderProps);
        }
        ......
        return res.render(......);
    });
}

setImmediate方法用于设置一个定时任务,此定时任务将在下一次mainLoop中被触发。

这样,我们便可以在基本不添加多余逻辑的情况下复用客户端代码。但这样还是有问题没有被解决,我们送到客户端的仍然只是一堆状态加上等待初始化的页面,所以还需要更近一步。

二次渲染

想要让服务端直接渲染出初始化store后的完整页面,方法很简单,只需要把第一次渲染后的finalState作为初始状态进行二次渲染即可:


const store = createStore(reducers, finalState);
const markup = renderToString(
    <Provider store={store}>
        <APP />
    </Provider>
);

如果设计足够规范,做了上述getList那样的检查,第二次渲染时是不会进行再次请求的,而是直接根据finalState渲染出真正的首屏页面。

store的dispatch方法在服务端同样可以使用,我们可以直接用store.dispatch来触发某些action来满足一些特别的需求。

至此,一个完整的、SEO友好的服务端渲染便已完成,但这样还是会有问题——客户端的方便是以服务端的消耗为代价的,客户端的每次请求都会导致服务端重新进行两次渲染和伴随的若干次ajax请求,这并不是我们期望的,所以这里就要用到Memory cache来缓存store和渲染后的页面,至于我的blog中是如何实现的,将在下一篇文章详细说明。

编辑于 2016-10-11

文章被以下专栏收录