作者:calvin 腾讯 QQ音乐 数字音乐部 工程师
最近在项目中接入了 ReactJS 并在服务端做了同构直出。关于 ReactJS 服务端同构业界已经有不少分享,这篇文章会主要注重实践的内容,把实现细节和遇到的问题整理后进行一些分享。
首先我们来看一下同构(isomorphic)是什么?
对于前端实现来讲,同构可以理解为同一个组件或逻辑只编写一次,前后端可以共用。简单的说,由于服务端 NodeJS 环境的存在,对于服务端同构,就是维护一套业务代码,可以分别在服务端和前端运行。
组件同构示意图
我们这次进行的同构,选型采用了 React + Redux + React-Router + Webpack 几个库和工具来实现,下面来看一下实现的细节:
1. React Server Rendering
对于 React 来说,在服务端主要通过 ReactDOMServer 中的几个 API 来工作。使用 renderToString() 方法就可以将相应的组件树生成 HTML String(和前端调用 ReactDOM.render() 类似,不过结果从产生元素挂载 DOM 变成了直接产生 HTML )。
ReactDOMServer.renderToString()
这样就简单达到组件的复用。服务端生成 HTML 直出返回到前端,用户访问时首屏内容就直接可见。
前端执行时依然在内存中 render 出节点,但会通过对根节点(已有直出内容)进行校验判断是否需要继续做 DOM diff。这样在内容相同的情况下,减少了首屏 DOM 操作,也提前了可交互时间。具体流程如下:
React Server Rendering 流程
服务端渲染时的差异:
在 Server Rendering 时,和前端相比组件没有完整的生命周期,只会走到 componentWillMount(因为不存在挂载之后的变化)。所以实际上组件只有一次 render,我们就需要提前取完业务数据再去执行,保证 render 出来是有数据的状态。
考虑到方便前后端调用相同的代码。一种比较方便的方法是把拉取数据的逻辑写到 React Class 的静态方法上(组件外部也能调用),在服务端时前置执行,在前端时在 componentDidMount 时执行。
拉取数据放到静态方法中方便调用
服务端提前执行相应的 fetchData
2. 数据层 - Redux
Redux 是一个从 Flux 架构演化的,非常简洁设计精致的数据层管理库。关于 Redux 的详细理念可以看官网文档(http://redux.js.org)。
这里使用 Redux 主要的好处是与视图解耦,通过 Store 操作/访问数据,另外 Reducer 每次生成新的 State,这样 Immutable 的数据便于驱动组件 update 和对比数据的变化。大致的工作流程如下图。
Redux 工作流程
由于 Redux 使用一个单一的 Store 数据树来记录数据的特点,在服务端渲染时做起来也很容易。只要在最后直出时把当前 State 的 JSON 输出到前端,在前端时使用其数据初始化 Store,就完成了数据的传递和共用。
Redux Server Rendering
前端使用直出的 State 初始化 Store
3. 路由层 - React Router
在路由层我们使用了 React-Router。使用同一份路由配置,配合 Webpack 的 Code Splitting 功能,相应的页面模块,前端声明自动分片打包按需加载,服务端则直接引用。
React-Router 路由配置
在服务端初始化路由时,要先使用当前的 location 来 match 出首屏的路由。因为在 match 过程中要处理重定向和404等。
确认好路由后(再拉取完数据),就可以通过拿到的路由信息(renderProps),render 相应的页面返回。
服务端 match 路由
这里还需要注意以下几个问题:
1. 路由上的重定向不一定要302浪费请求,可以直接重新match。
2. 尽量前置重定向(写到路由的 onEnter 里)。 除非需要拉取数据进行判断,不要在路由确定之后(例如组件中 willMount)再重定向。因为在拿到路由配置之后就要根据相应的页面去拉数据了。这之后再重定向就比较浪费。
3. 避免前端路由上的按需加载与首屏直出冲突。首屏时如果有按需加载,要先加载好页面模块再 render 页面(例如也先对路由 match 一遍让它提前执行 getComponents() ),否则如果前端首屏 render 先输出了空白 container,就干掉了直出的节点。
除了刚刚提到的按需加载干掉了首屏,还会有一种错误的效果会导致干掉直出内容,就是前后端路由不一致。效果如下图:
前后端路由不一致,直出内容白费
这种情况一般会在前端使用 hash 做路由时候发生:hash 不会传到服务端,如果用户改变路径后手动刷新页面,这时服务端使用的路由和前端就不一致。
要避免这种情况,理想的方案是使用 History API 。但是如果你的页面有一些 Native Webview 场景,就要小心一些 Webview 的坑:例如微信 JSSDK 的校验会受 pushState 影响失效(微信会认为此时的页面已经改变),导致分享、支付时会需要重新设置或刷新页面。但在微信 Andorid 6.2 版本以前又有监听的BUG 所以直接无法使用。
微信部分版本不支持 History API
另外据了解在 iOS Webview 的 shouldStartLoadWithRequest 中可能监听不到 pushState 产生的变化,导致客户端同学依赖这个方法设计的后退、左滑等某些路径相关操作可能出现问题。因此要先做好测试和调研。
以上是实现方面的内容,下面是一些关于构建方面的处理。
模块共用:
由于使用了 Webpack 打包 ,在模块引用和处理上做起来就特别方便。前后端都直接使用 CommonJS 的写法,或者 ES6 Modules(交给 Babel 转换)都可以。相关的配置可以参考 Webpack 文档。
Build 服务端的时候要注意配置 target 为 node,libraryTarget 为 commonjs2,产出适合 Node 端运行的代码。
server 端 build - output 配置
注意这里默认产出的代码还是会打成一个 bundle(除了 node 核心模块不会去打包)。如果有不需要打包的库(比如 .node 的原生模块)可以配置 extenals 选项指定不打包的模块,最后将会以 require 的形式生成(配置都可以在Webpack 手册中查到)。
头尾模版共用:
前后端使用的模板都是一样的,只是生成的步骤不同。前端 build 时生成一个静态页,同时给服务端生成一个模版 function(使用 ES6 templates 可以把内容方便的套成一个模板 function )。
模板生成 - 前端静态 / 后端function
服务端返回时把产出的结果塞到模版中返回就可以了。这样做的好处还有一个是可以保留一个静态页面作为直出挂掉时的一个容灾方案。具体的 build 过程如下:
模板 build 过程
按需加载:
关于按需加载,可以使用 Webpack 的 require.ensure,把需要按需加载的模块放到一个 ensure 函数块里。Webpack 将对声明的依赖自动进行分片打包。在运行时执行到相应代码的时候才会加载相应的 chunk。
通过 Webpack 做按需加载
关于平台区分:
之前提到,同构一般只是在组件和逻辑编写上共用(包括组件、 Reducer Action / Reducer 等等业务和数据的处理逻辑),这覆盖到了绝大部分的日常业务代码。但根据平台不同最后基础层面还是会有部分区别。
举个例子,比如一个拉取数据的请求,在前端最后可能是 AJAX ,后端就是 http.request(如果没有直接使用 isomorphic-fetch 这样的库的话)。
这种情况下,可以在前后端分别封装基础库代码来抹平调用上的差异(前后端通过 resolve.alias 配置使用不同的文件)。如果业务逻辑中还有少量要区分平台的代码,可以用 Webpack define plugin 来实现:设置一个环境变量来标识环境,编写分支。变量在编译时会替换为指定的值(一般为 true/false )。
通过 define 环境变量进行平台区分
因为替换后运行时的结果是恒等的,最后经过 Uglify 后不可达代码也可以被消除。所以也不用担心这样写分支代码会增加前端 bundle 包大小。
总结:
接下来看一下我们接入之后,直出和不直出的效果对比:
不直出 VS. 直出
明显看到少了白屏和初始化的部分,可交互时间也得到了提前。由于在服务端端提前拉取了数据,也避免了前端因为数据变化产生二次修改(例如第二红框处)。
最后关于性能方面,我们在线上做了压测。结果发现服务端渲染有很大的性能瓶颈。跑完所有业务逻辑的情况下,如果不进行 renderToString() 直接返回,8核 16G 的服务器,TPS 能达到 2400,加了 renderToString() 后 TPS 直接降到 900 多,CPU 就跑满了。打出的 v8 log 里看了下也是非常多的调用栈。
React 大量调用导致 CPU 处理能力下降
因此最后得出的结论是 React Server Rendering 调用栈、计算量比较多,阻塞导致占用了 CPU 资源,使并发处理能力下降。
这块可以通过减少首屏组件的复杂程度、减少 render() 方法内的计算量来减轻,但是觉得要解决根本问题还是需要在 React 上。比如是否能有某种缓存机制,因为在运行时实际上同个页面多个请求进来,有可能最后返回的内容(或部分)是一致的,但每次都是一个完整的 render 过程,也没有类似前端 ShouldComponentUpdate 之类的跳过策略。
另外之前也有看到 VueJS 2.0 的 Features 里有提到使用 Stream 来做流式 render。在 React 社区上也有这方面的相关讨论(点击阅读原文查看)。这块也是拭目以待。