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

## 前言

以前写过一篇[凤蝶服务端渲染经验总结](https://www.yuque.com/eward/bwgd4h/hpq4w3)，前面一篇文章主要介绍凤蝶实践经验、react 同构原理。

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

引入 react-router 是因为开始使用 [dva](https://github.com/dvajs/dva) ，dva 默认引入了 react-router ，一开始觉得前端路由对于凤蝶意义不大，一开始就直接使用没有 router 的版本 `dva/mobile` 。

看了下 react-router 的[入门文档](https://github.com/reactjs/react-router-tutorial)，看起来非常赞，值得一试。于是觉得可以尝试一下，引入了 react-router ，同时还保留服务器渲染。

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

## dva + chair

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

### 第一步，定义公共的 createApp

chair 中可以直接用 dva ，但如何和服务器渲染结合使用，应该还很少人尝试过。最关键的如何在服务器处理前端路由，参考了云谦给的 demo [dva-boilerplate-isomorphic](https://github.com/sorrycc/dva-boilerplate-isomorphic) 。首先定义一个公共的 [createApp](http://gitlab.alipay-inc.com/hanwen.sah/react-isomorphic-demo/blob/master/app/common/createApp.js) 方法

```javascript
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

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

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

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

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

### 第二步，服务器渲染

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

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

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

helper 是这样实现的

```javascript
  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 ，并且在渲染过程中记录渲染消耗的时间。

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

```javascript
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` 两个路由

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

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

```javascript
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 渲染前后有打点，记录时间。

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

```sh
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](https://www.npmjs.com/package/devtool) ，看介绍

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

chair 自带的 [debug](http://www.atatech.org/articles/37952) 就是基于 Electron，上面那个仓库可以，那 **iron-node 应该也可以直接 profile** 吧。然后尝试了一下 `tnpm run debug` ，直接启动应用，打开的 Electron 窗口果然有 Profiles 面板。找了好久，原来最好用的 profile 工具就躺着我的代码里面啊，终于可以开心的 profile 了

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

 ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS85NDg5MDE3NC1jNDc1LTRjMDgtOGMwYi1iMDcwZTk2ZTFmYWYvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzODQwMCwiZXhwIjoxNzkwNTI0ODAwfQ.eI0p6haqZeU-QLa1uB4n-8M4JfQYfx8OnTc2pGU_tP8)

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

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

 ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS9lZTU0Y2M2OS1hMWFjLTQ3MGEtOTExOC1lNDgwOTc5MGQ5ODIvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzODQwMCwiZXhwIjoxNzkwNTI0ODAwfQ.3jO8A5tEbVErIqNKh5qTrmcNshiGZm7sOFUBlRxlrTY)

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

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

 ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS80ZTFlOTQ3Ny0wZWYxLTQ0MmYtYmM2Ny00NGY0ZTA3YjMyMjYvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzODQwMCwiZXhwIjoxNzkwNTI0ODAwfQ.xiSOWiLOEtYwdaoAf6OnX3Ym2CWAMZsUzJkQyV7ktKY)

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

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

 ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS9lM2E5YzkzNC01N2RlLTQ4NTktOGRiMC03ZTA1YTNkYjRlZjMvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzODQwMCwiZXhwIjoxNzkwNTI0ODAwfQ.kNaofw6Ts4JBpQYiFcAvU292xnCIFN24cEd-efMbVdg)

这个应该是页面在生成环境渲染耗时了。可以看到，在服务器上 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](https://www.npmjs.com/package/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 服务渲染性能没有想象的差，也不是很好，像凤蝶这种后台应用，可以放心用。

---

**Documents**

- [基于实践谈谈软件架构设计](https://iling.fun/s/blog/doc/5z65lqo5a6e6le16lci6lci6l2v5lu25p625p6e6k66k6h-tsGrQnNSNu)
- [rspack 使用体验过程](https://iling.fun/s/blog/doc/rspack-u8S4Mtjkfz)
- [E2E test with Playwright](https://iling.fun/s/blog/doc/e2e-test-with-playwright-JLFKAvWQ0U)
- [Vim everywhere](https://iling.fun/s/blog/doc/vim-everywhere-v6WZyLzGA1)
- [滴答 FaaS 研发平台小结](https://iling.fun/s/blog/doc/faas-XQxhEO3e5r)
- [Cod 服务编排之路](https://iling.fun/s/blog/doc/cod-0CFsOfiX20)
- [Cod 起源和用武之地](https://iling.fun/s/blog/doc/cod-1aAA2rVJZY)
- [2019 年度总结](https://iling.fun/s/blog/doc/2019-RhbTD2SiCn)
- [凤蝶区块历史及其问题排查记录](https://iling.fun/s/blog/doc/5yek6j225yy65z2x5y6g5yy5yk5yw26zeu6aky5o6s5pl6k6w5b2v-WoaaJ9QHpq)
- [如何用单元测试](https://iling.fun/s/blog/doc/5aac5l2v55so5y2v5ywd5rwl6kv-3zwG1cJvTn)
- [小钱袋 Node 应用开发经验总结](https://iling.fun/s/blog/doc/node-QpFfYCuoE0)
- [简单 JS 算数表达式求值算法的实现探索](https://iling.fun/s/blog/doc/js-FXcWkpW2vw)
- [凤蝶可视化编辑实现原理](https://iling.fun/s/blog/doc/5yek6j225yv6keg5yyw57yw6l6r5a6e546w5y6f55cg-ZsBj0CS1ev)
- [凤蝶服务端渲染经验总结二](https://iling.fun/s/blog/doc/5yek6j225pyn5yqh56uv5riy5pt57up6aqm5oc757ut5lqm-a4Kd59d7Td)
- [凤蝶服务端渲染经验总结](https://iling.fun/s/blog/doc/5yek6j225pyn5yqh56uv5riy5pt57up6aqm5oc757ut-GBhTESymbn)
- [Node.js 中 sourcemap 使用问题总结](https://iling.fun/s/blog/doc/nodejs-sourcemap-duex3wDhWt)
- [如何实现一个 mvvm 组件](https://iling.fun/s/blog/doc/mvvm-8TXAVSXe77)
- [webpack 热加载原理探索](https://iling.fun/s/blog/doc/webpack-7Wjn9z1fUB)
- [再谈 vim](https://iling.fun/s/blog/doc/vim-UBf5u57ko7)
- [How to realize velocity template interpreters](https://iling.fun/s/blog/doc/how-to-realize-velocity-template-interpreters-4s1KEznjYR)
- [joycss](https://iling.fun/s/blog/doc/joycss-bGvBvDGF7k)