凤蝶服务端渲染经验总结

发布时间: 2016-07-23

凤蝶从 2.0 开始使用 react ,使用服务端已经有 1 年多了。这个过程积累不少经验,也遇到不少坑,在这里总结下。

原因

很多人都会疑惑,为什么要用服务器端渲染,这样会不会影响服务器性能,会不会很麻烦?

首先来看一个对比:


这样对比区别还是很明显的,第一张 gif 是用了服务端渲染,第二个关闭了服务端渲染。当习惯了页面立即出现,第二种就有点不能忍受了(或者只能加一个 loading 状态了)。

最初做凤蝶 2.0 的时候,只是想体验一下 React 的服务器渲染。这种功能到底有没有用,到底好不好,用过才知道,cmsmng是内部产品,我觉得可以尝试一下。

凤蝶这种模式之外还有一种单页面应用的模式,这种相对而言更火一些,前端路由,整个应用一个页面完成。但 cmsmng 编辑页面 js 有压缩有 1.2M(gzip 300k) ,如果把整个凤蝶都达成一个包,这个 js 会非常大,维护页面之间的状态会是一件非常麻烦的事情。

对于前面两个问题,这里首先给出一个结论:

  1. 服务器渲染对网页渲染速度没有性能影响,只是字符串拼接而已

  2. 服务器渲染的开发过程是基本不需要意识到服务器渲染存在的

React 同构基本原理

首先 React 组件需要支持服务器渲染的,React 组件本身是虚拟 DOM ,所以只要没有使用 document, window 之类的全局变量,服务器是可以直接渲染出页面结构的。

假如我们页面结构是这样的:

<div class="home"></div>

第一步,服务器执行组件 render ,输出到 DOM .home 里面。一般的服务器输出是类似于这样的 html 结构

<div class="home" data-reactid=".18zugokn8qo" data-react-checksum="2115963774">foo</div>

在 chair 中可以通过一个 filter 来实现,这里 react 这个 filter 会输出上面 html 字符串

<div class="home">{{entry | react | safe}}</div>

第二步,在浏览器端把组件 render 到同样的 DOM 中

React.render(<Home />, document.querySelector('.home'));

第三步,由 react 来处理了。首先,整个页面的基本 html 结构已经出来了,所以这时候页面是可以看到了。下面来看 React 是如何处理的

  // 首先在浏览器渲染得到页面的 html 结构
  var markup = ReactReconciler.mountComponent(componentInstance, rootID, transaction, context);
  // 判断页面渲染结构是否 dom 中已有结构一样
  ReactMarkupChecksum.canReuseMarkup(markup, rootElement)
  // dom 结构对比
  canReuseMarkup: function (markup, element) {
    // ReactMarkupChecksum.CHECKSUM_ATTR_NAME = 'data-react-checksum'
    var existingChecksum = element.getAttribute(ReactMarkupChecksum.CHECKSUM_ATTR_NAME);
    existingChecksum = existingChecksum && parseInt(existingChecksum, 10);
    var markupChecksum = adler32(markup);
    return markupChecksum === existingChecksum;
  }

上面的逻辑就是在有服务器渲染的情况,浏览器渲染逻辑,首先在前端计算组件基本结构(markup),再校验服务器和浏览器输出字符串结构是否一致(canReuseMarkup),通过adler32算法对比校验码。如果一致,后面对 DOM 操作就直接退出了,否则在开发环境会对找到 diff 位置,并且在控制台提醒。

所以如果前后端逻辑一直,渲染出来的结果应该也是一致的,这时候浏览器第一次会使用服务器渲染的结果,这时候 react 组件中绑定的事件还是可以执行的,因为事件都绑定到 document.body 上。

探索之路

React 完成了第一步,支持服务端和浏览器同构渲染,但后面还有不少路需要走。

基于 React 基本原理,我们完成第一版。首先约定文件规则

  • 每个页面一个入口文件

  • 页面入口文件包含页面中 react 部分所有逻辑

通过这样,我们可以在服务器端和浏览器都 render 这个页面的入口文件就好了。

在服务器是这样渲染的

 // layout.html
 <div id="container" class="main-box">{{entry | react(context) | safe }}</div>
 <script>
  var context = {{context | stringify | safe}}
 </script>
 
 // filter.js
 exports.react = function(page, context) {
  var Page = require('./assets/pages/' + page + '.jsx');
  return React.renderToString(React.createElement(Page, context || {}));
 };

浏览器中

var React = require('react');
var Home = require('../pages/home.jsx');
var el = document.querySelector('.main-box');
 
React.render(<Home {...context}/>, el);

上面可以看到,浏览器和服务器 render 的都是同一个 Page 的组件,并且把 context 作为 props 传递过来,两边的数据也是同一份。服务器直接 require 了 jsx 文件,那么在服务器上支持 jsx 还需要 register 支持。

第一版还是有很多问题的,首先服务器跑了 node-jsx 来渲染 jsx ,这种方式还是很可能有坑的,同时会有性能问题,需要在 node 转换 jsx 为 js 。但这个并不是最大的问题,在服务器测试,速度差距并不大。最大的问题在于如何处理数据。

数据处理

第一套方式直接通过 props 把数据传递过去,这种方式做个 demo 是没问题的,但一个应用数据通常是用 flux 模式管理的。数据一般都是一个 store ,现在如何把数据传递给 store 呢?

第一版我们用的是 roof ,当时 roof 为了支持服务器渲染,使用了一种比较 hack 的方式支持了。之所以说是 hack ,是因为 roof 是全局的 store ,页面中所有用到 store 的地方都是通过 require 来实现的。这种模式在浏览器没问题,但服务器导致每个用户进来,用的都是同一个全局的 store ,两个人并发访问可能拿到了同一份数据。

当时的解决方案是,每次请求进来,生成一个随机的 uuid ,然后服务器渲染的时候往全局的 store 里增加一个 key 为 uuid 的数据。也就是服务器的 store 实际上是所有用户的数据,同时 服务器渲染的时候给子组件增加一个 叫 roofServerRenderingKey 的 childContextTypes ,这个可以透传到所有子组件,roof 内部再包装一层,在服务器渲染的时候通过 roofServerRenderingKey 从 store 里面取数据,渲染完成后删除 store 里这个 key 。

凤蝶 2.4 的时候,数据层改为了 redux ,redux 的 store 是一个通过函数构造的,数据是函数的参数,作为初始化数据,因为数据不是全局变量了,所以就很简单解决了最初遇到的问题。现在流行的 flux 类库,基本都是函数式的,不再使用全局 store ,这个问题已经很好处理了。redux 刚出来的时候,看到他的方案天然支持服务器渲染,还是非常惊艳的。

jsx 加载问题

在服务器上加载 jsx 还是不是很好。凤蝶实现方式是区分环境的

  • 开发环境通过 babel(老版的 react 使用 node-react) 加载 jsx

  • 线上、开发机在构建过程自动把 jsx 转换为 js

  • 所有 jsx 使用都去掉后缀,比如 require('./foo.jsx') 统一改成 require('./foo')

通过这样,jsx 里都是 require 没有后缀的文件,当在本地开发的时候,jsx 没有转换 js ,而且开启了 babel/register,本地开发没有问题。到开发或者线上服务器,构建过程自动把 jsx 转换为 js ,这时候会首先找到 js 文件,不开启 babel/register 也没问题。

第二步通过新增 scripts.prebuild

    "prebuild": "gulp --gulpfile config/gulpfile.js",
    "build": "chair-react",

这样 chair 在服务器构建过程中,会首先调用 gulp 完成 jsx 转换为 js 的任务。这里 gulpfile 文件路径有点尴尬,因为构建的时候只有 config/app 目录才会拷贝过去,所以如果放其他目录就找不到了。

gulp.task('babel', function() {
  return gulp.src('../app/assets/**/*.jsx')
  .pipe(antd())
  .pipe(babel({
    presets: ['es2015'],
    plugins: [['antd'], 'transform-react-jsx'],
  }))
  .pipe(gulp.dest('../app/assets/'));
});

同时我们不希望 jsx 转换的 js 被提交到源码中,这种自动生成的代码如果提交会导致 commit 很丑、很乱,所以再增加一个清理构建结果的任务

gulp.task('clean:jsx', function() {
  glob
  .sync('../app/assets/**/*.jsx')
  .map(function(filename) {
    const jsfile = filename.replace(/.jsx\$/, '.js');
    if (fs.existsSync(jsfile)) {
      fs.unlinkSync(jsfile);
    }
  });
});

这种在服务器构建前自动转换 jsx 的方式同样用在 typescript 转换过程中。本地开发不需要 watch ,自动加载最新的,线上服务器执行转换后的 js ,不用担心性能问题。但毕竟 register 转换和 gulp 两者转换不是同一套代码,这中间有可能会有坑,凤蝶暂时还没遇到过。

经验

基本上凤蝶服务器渲染就是上面描述的方式,后面开发的过程中,基本上不需要再考虑服务器渲染,正常开发就可以了。我们在配置中增加了开启和关闭服务器渲染的配置,如果后面不想继续做服务器渲染,直接关闭配置就行。

最后整理一下遇到的坑

最初服务器渲染失败的时候,页面会直接进入 500 。这对调试非常不方便,服务器渲染很容易就失败了,比如 React 中组件中有点问题,这时候进入 500 就没法用浏览器的错误调试功能了。

解决方案是在服务器渲染中增加一个 try catch,如果服务器渲染失败,直接返回空字符串,到浏览器会重新渲染,这可以避免调试麻烦的问题。

require 加载顺序问题。有一段时间服务器渲染总是报错,错误也很诡异,但浏览器没问题。后来桐杰排查了很久,发现后某个 jsx 文件有关,但把这个文件改个名字正常了。最后才定位到这个文件有一个同名的 less 文件存在。

支持 react 渲染,在开发环境同时开启了 jsx,less,css 三种加载扩展,因为 jsx 中会 require css 或者 less ,node 对于 less 和 css 直接输出空字符串就行。jsx 是通过 require('./foo') 这种模式加载的,不能加后缀 jsx ,因为在服务器渲染加载的应该是 js 。这时候如果有一个同名的 jsx 和 less 文件,在 node 会首先加载 less 文件,所以服务器渲染到这个组件的时候挂了,而浏览器没问题(webpack 会首先加载 jsx)。

这种情况只能避免同文件夹下同名的 jsx 和 less 文件出现,就是别把 css 和 jsx 放一起就好了。凤蝶默认情况都是分开的,但偶尔一次有人放一起还同名了,触发了这样一个诡异的问题。或者 node 支持配置 require 优先级也行,没找到类似的 API 。

assgin 顺序问题。这个是 react 写 props 的时候,经常会这样写 {...props} ,编译后用的是 assign 实现的。但 nodejs 和浏览器渲染后属性的顺序不一致,输出的 dom 属性也就是不一样了。到前台就会报服务器渲染不一致的异常,这时候浏览器还是会重新渲染一遍的。这个问题在新版 react 的解决了,官方文档上有专门提到这个问题。

总得来说,使用服务器渲染经验来看,凤蝶这种后台管理类页面还是适合服务器渲染的。开发过程不需要考虑页面加载中状态问题,也没有闪屏问题。并且这种模式下我们会有意识的让整个页面可以在服务器跑起来,这样写测试用例的时候就可以在 node 对大部分逻辑写测试用例了。

坑也还是有的,这个成本是否值得,这个得大家自己判断了。

未解决的问题

现在凤蝶的方案,还有几个问题没解决。

react-router 本身也是支持服务器渲染的,但具体如何把服务器渲染和前端路由整合起来,这一点凤蝶还是没有尝试过的。如果要把前端路由和后端路由整合起来,感觉复杂度要高很多,有兴趣的可以尝试下。

css-module 支持,css-moudle 把 css 中选择器进行转换,然后会在模板中拿到选择器的 map 。这一套凤蝶还没用,如果用上的话,less 或者 css 文件 require 之后需要返回一个对象,这个在服务器渲染如何处理还需要研究下。

bigpipe 这种 facebook 很早提出过的渲染机制,如何和 react 服务器渲染整合,凤蝶也是没有尝试的。因为凤蝶页面一般都是一屏,所以也没必要。