凤蝶服务端渲染经验总结二

前言

以前写过一篇凤蝶服务端渲染经验总结,前面一篇文章主要介绍凤蝶实践经验、react 同构原理。

本文将更近一步,引入 react-router ,并且对服务器渲染 react 给出性能分析。

引入 react-router 是因为开始使用 dva ,dva 默认引入了 react-router ,一开始觉得前端路由对于凤蝶意义不大,一开始就直接使用没有 router 的版本 dva/mobile 。

看了下 react-router 的入门文档,看起来非常赞,值得一试。于是觉得可以尝试一下,引入了 react-router ,同时还保留服务器渲染。

进行性能压测,是因为虽然凤蝶服务器渲染使用有不少时间了,但对于服务器渲染性能到底如何,这个一直没有准确的数据,我感觉还可以,但这不能说服所有人(包括我自己),于是觉得应该有准确的数据,确定服务器渲染性能到底如何。

dva + chair

首先,介绍第一部分,如何将 dva 引入 chair 。我写了一个小的 demo 。

第一步,定义公共的 createApp

chair 中可以直接用 dva ,但如何和服务器渲染结合使用,应该还很少人尝试过。最关键的如何在服务器处理前端路由,参考了云谦给的 demo dva-boilerplate-isomorphic 。首先定义一个公共的 createApp 方法

const dva = require('dva').default;
const React = require('react');
const { Router } = require('dva/router');

module.exports = function(Page, context, history) {
  const app = dva({ initialState: context, history });
  app.model() // ...
  app.router(() => React.createElement(Router, { history }, Page.routes));
  return app;
};

这个 createApp 返回一个 dva 的 app ,这个函数构建一个前后端通用的 app ,服务器和浏览器渲染使用的是同一个 Page 对象和 context 数据对象,唯一的区别是 history 差异。

在浏览器,直接使用 browserHistory

const { browserHistory } = require('dva/router');

在服务器端,通过createMemoryHistory 创建一个 mock 的 history 对象:

const { createMemoryHistory } = require('dva/router');
const history = createMemoryHistory();
history.push(this.ctx.url);

这里直接 mock 了一个 history ,和官方推荐使用 match 有些不一样。因为 match 是异步的,而在 nunjucks 中渲染则是同步的,所以如果要改成 match 会更麻烦一些

第二步,服务器渲染

在服务器中,我们增加一个 helper ,然后在模板中使用

<div id="ReactApp">{{ helper.react(ReactMain, context) | safe }}</div>

这里 ReactMain 就是对于的每个页面,通过 helper.react 在服务器端把 react 页面渲染为 html 。

helper 是这样实现的

  react(ReactMain, context) {
    const app = this.ctx.app;
    // 后端渲染可以关闭
    if (!app.config.reactIsomorphic) {
      return '';
    }
    try {
      const start = Date.now();
      const Page = require('../views/' + ReactMain);
      const history = createMemoryHistory();
      history.push(this.ctx.url);
      const html = ReactDOMServer.renderToString(createApp(Page.default, context, history, true).start()());
      this.ctx.logger.info('react render finish %sms', Date.now() - start);
      return html;
    } catch (err) {
      // 渲染失败直接返回空,恢复前台渲染
      this.ctx.logger.info(err);
      return '';
    }
  },

首先增加一个配置项,可以随时关闭或者开启服务器渲染。整个渲染在 try catch 中执行,防止服务渲染失败导致页面 500 ,并且在渲染过程中记录渲染消耗的时间。

浏览器渲染和上面的步骤差不多,但要简单很多,不需要处理异常、配置项逻辑。

const { browserHistory } = require('dva/router');
require('babel-polyfill');

let Page = require('../../app/views/{{ ReactMain }}');
createApp(Page, window.context, browserHistory).start('#ReactApp');

服务器和浏览器引入的 Page 对象都是同一个,在 app/views/\${PAGE_NAME}.jsx 中。

第三步,写页面

Page 对象因为需要支持服务器渲染,所以不包含任何 dom 相关的内容。对于 dva 来说,Page 需要返回两个部分数据

  • models 页面用到的模型

  • routes 路由规则

比如,我的写 demo 中,页面比较简单,只有 /home 和 /users 两个路由

  <Route path="/" component={HomePage}>
    <IndexRoute component={Welcome} />
    <Route path="/home" component={Welcome} />
    <Route path="/users" component={Users} />
  </Route>

页面中和普通的 dva 使用类似,需要对路由进行监听,比如页面切换到 /users 需要载入用户数据,这时候从需要从服务器取数据。因为这和页面直接服务器渲染使用的数据是一样的,所以可以把异步请求和页面渲染写在一个 controller 中

exports.users = function* () {
  const data = []; // load data
  if (this.isAjax) return this.body = data;
  yield this.render('index.jsx', {
    context: { users: data, ctoken: this.getCookie('ctoken') },
  });
};

然后,前端路由和服务器渲染就可以一起玩了。最后处理一下构建流程,在服务器渲染的时候我们不希望直接渲染 jsx ,需要在打包过程中把 jsx 转换为 js 。相关说明在上面一篇文章有介绍,这里就不多说了,或者看下 demo 就会明白了。

下面来说说性能问题。

react 渲染性能测试

性能测试主要分为几个步骤

  • 记录运行时间

  • 对渲染过程进行 cpu profile ,分析具体执行过程

  • 性能压测

首先第一步只是简单记录了每次渲染耗费的时间,在上面的 demo 中 helper.react 渲染前后有打点,记录时间。

第一感觉页面渲染的时间第一次会特别慢

GET /activity/detail/17212] status 200 (proxy: 330ms, view: 2701ms, rt: 3711ms)
GET /activity/home] status 200 (view: 1341ms, rt: 2225ms)
GET /activity/home] status 200 (view: 79ms, rt: 918ms)
GET /activity/detail/17212] status 200 (proxy: 230ms, view: 86ms, rt: 852ms)

可能是因为本地渲染的是 jsx ,需要进行 babel 转换。于是首先在本地把 jsx 全部转换为 js (tnpm run postbuild),并且关闭 jsx require 的支持(删除 config.local.js 里面的 babel-core/register),继续试验。结果性能并没有太大的区别,第一次渲染时间没有减少。

问题大概出在页面渲染过程中 require 解析过程,但如何确定,需要通过 cpu profile 来验证。

cpu profile 结果

首先面临的问题是如果对一个 node 应用进行收集 cpu profile 。查了很多资料,node 自带的 --prof 参数只能启动时候收集所有的 profile 记录,而且结果是纯文本,信息量少,使用意义不大。

alinode 有自带 cpu profile 功能,但这个功能只能在线上运行,而线上环境有 6 个进程,访问页面的时候无法确定是哪个进程,所以没法定点 cpu profile ,这个对于线下应用不是很友好。

内网搜索 node cpu profile ,找到朴灵的一篇文章有提到 node-profiler,以前听过相关分享,但这个功能是修改 node 实现的,最新版本是 0.12 ,现在凤蝶已经是 6.x ,node-profiler 是无法运行起来的。

只能再继续 google 搜索,找找现有的其他方案。想来最靠谱还是 chrome 自带的 cpu profile 功能了,收集、图片查看全都有,非常方便,如果这个也可以用对 node 应用进行 cpu profile 就好了。搜索了很久,就在我要绝望的时候发现了一个仓库 devtool ,看介绍

Runs Node.js programs inside Chrome DevTools (using Electron). This allows you to profile, debug ...

chair 自带的 debug 就是基于 Electron,上面那个仓库可以,那 iron-node 应该也可以直接 profile 吧。然后尝试了一下 tnpm run debug ,直接启动应用,打开的 Electron 窗口果然有 Profiles 面板。找了好久,原来最好用的 profile 工具就躺着我的代码里面啊,终于可以开心的 profile 了

第一步,直接本地访问凤蝶编辑页面,页面 react 渲染耗时 2s ,得到 cpu profile 信息如下


react 渲染耗时 1800ms ,其中 jsx 转换耗时 900ms 。

第二步,直接本地去掉 jsx 渲染,对第一次渲染进行 profile ,react 渲染耗时 900ms


可以明确,首次渲染耗时主要在 js 加载过程。第一加载过程一共需要加载 287 个文件。

第三步,加上预加载,测试页面渲染。这时候首次渲染时间控制到了 100ms 。


现在可以看到大部分时间都是在 react 渲染上了,最多的是 validate ,这个是对 PropTypes 进行校验,这个相关功能只在开发环境才会有的,所以要更接近线上性能,需要使用 production 模式运行,这时候我使用上面的 demo 应用来进行 profile 了,因为凤蝶暂时无法直接以线上环境模式在本地运行。

第四步,直接以 production 模式运行 NODE_ENV=production tnpm run debug ,react 渲染耗时 36ms


这个应该是页面在生成环境渲染耗时了。可以看到,在服务器上 react 渲染一个带路由,引入 372 个依赖文件的页面大概耗时是 20ms 上下。通过 wrk 对本地压测,这个页面渲染耗时稳定在 20~40ms 之间。

这样看起来性能还算好,凤蝶本地无法以 production 模式测试。本地渲染耗时大概是 50 ~ 100ms ,这个数据应该不是很准,后来通过压测,时间大概也是 20 ~50ms 之间。那么 react 渲染对整个应用影响到底有多大,进入下一阶段,性能压测。

压测

压测主要分三个场景

  1. cmsmng 编辑页面,带 react 服务器渲染

  2. cmsmng 编辑页面,无带 react 服务器渲染

  3. 上面 demo 代码压测,纯 react 服务器渲染

压测报告结果全都在 loadmanager 平台上。这里列出几组关健数据

场景

qps

rt

rt(max)

rt(min)

cmsmng 带 react 渲染

25.92

735

4560

328

cmsmng 无 react 渲染

31.47

614

4689

260

demo 纯 react 渲染

131.03

131

565

23

基于这个压测结果,我们可以看到,第一凤蝶实际业务场景下 qps 是非常低的,但 react 服务器渲染功能对 qps 影响不是非常明显,去掉服务器渲染,qps 只增涨了 5 。

第二,react 服务器渲染的性能不算好,相比于纯模板,比如 nunjucks 要差不少。复杂一些的页面渲染耗时大概在 20 ~ 50 ms 之间,这导致应用的 qps 会在 130 一下。与 basement.render 对比,这个是纯 nunjucks 模板渲染,应用 qps 可以在 300 的样子。所以使用 react 服务器渲染的应用,qps 肯定不高,毕竟 nunjucks 渲染一个函数搞定了,而 react 渲染动不动就是 300+ 依赖,复杂度差不少。

如果是高并发的应用,不推荐使用 react 服务器渲染,如果一定要用,一定得做好压测和 cpu profile 分析,尽量优化性能。react 现有的模式,也不是很适合处理高并发的应用,react 渲染在前端 20ms 应该不算什么,但这对应服务器来说也不算少了。

服务器渲染的性能和组件的复杂度相关,比如最简单的 react-router-tutorial 里面的例子,qps 可以达到 2000 ,但是加上一个 antd 的 table ,就立刻降到 300 不到了,如果再加一个更复杂的 schema-editor (使用了大量的 antd 组件) ,qps 只剩下 70 了。

关键在于 react 组件一般是无需考虑渲染性能的,所以当引入一堆组件的时候,也不可能对这些逐一进行性能优化,这些组件本来就是为浏览器而生,一共 20ms 的渲染时间比起其他页面渲染耗时基本不算什么。

结语

本文介绍了凤蝶对应 chair + react + dva 服务器渲染的经验总结,有需求尝试的可以直接参考文章中给出的 demo 体验。

另外,基于 react 服务器渲染性能进行了 cpu profile 和压测。通过 cpu profile 可以确定,在生成环境 react 渲染还是比较耗 cpu 的,一般复杂的页面都需要 20~50ms 的运算。高并发的应用如果要用 react 服务器渲染,需要重点考虑如何优化 react 渲染性能,比如可以优化 react 自身,或者重新实现一套 react 服务器渲染,支持缓存、或者预编译功能。

总体上,react 服务渲染性能没有想象的差,也不是很好,像凤蝶这种后台应用,可以放心用。