ReactJS 服务端同构实践【QQ音乐web团队】

作者: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 社区上也有这方面的相关讨论(点击阅读原文查看)。这块也是拭目以待。

原文发布于微信公众号 - 腾讯大讲堂(TX_DJT)

原文发表时间:2016-05-13

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏分布式系统和大数据处理

HTML5触摸界面设计与开发

首先讲了移动端和电脑端的一些不同,讲到了viewport的概念和相关的虚拟像素、媒体查询,借助媒体查询来实现横屏、竖屏的区别显示。

2143
来自专栏腾讯开源的专栏

【开源公告】高性能的图片框架 LKImageKit 正式开源

LKImageKit LKImageKit 是一个高性能的图片框架,包括了图片控件,图片下载、内存缓存、磁盘缓存、图片解码、图片处理等一系列能力。合理的架构和...

3774
来自专栏Python爬虫与算法进阶

如何在电脑上多开微信?(windows)

新媒体管家 在电脑上多开微信,在工作中很常见,今天来介绍一种简单的方法。(windows下) 这个问题在百度和知乎上都有许多回答,很多都是:长按Enter 电脑...

3868
来自专栏蔡述雄的专栏

包学会之浅入浅出Vue.js:开学篇

Vue 是国人写的,技术文档也妥妥的是中文,想到这我就有学习的动力。

46.5K61
来自专栏思考的代码世界

Python网络数据采集之采集JavaScript|第09天

客户端脚本语言是运行在浏览器而非服务器上的语言。客户端语言成功的前提是浏览器拥有正确地解释和执行这类语言的能力。

4306
来自专栏微信小程序开发

微信小程序开发常见问题(四)

知晓程序员,专注微信小程序开发的程序员! 一、小程序不同页面之间的传值方式 a、URL传值 这种方式最常用,比如: wx.navigateTo({ url...

4065
来自专栏古时的风筝

如何应用Font Awesome矢量字体图标

Font Awesome 是一套专门为 Twitter Boostrap 设计的图标字体库。这套图标字体集几乎囊括了网页中可能用到的所有图标,除了包括 Twit...

2276
来自专栏IMWeb前端团队

bigpipe性能优化

本文作者:IMWeb moonye 原文出处:IMWeb社区 未经同意,禁止转载 背景 当前网速越来越快,但是随着网页内容越来越丰富,其实我们打开网页...

25610
来自专栏张戈的专栏

Infinity New Tab:重新定义你的Chrome新标签页

Infinity new tab 是一款实用又清新的 Chrome(谷歌浏览器)新标签页功能扩展,可以完美替代默认的新标签页。受插件作者邀请,我特意安装体验了一...

34512
来自专栏小程序之家

如何入门小程序开发

在上一篇教程中,我们教大家使用微信官方Demo快速搭建了一个小相册,并学会了如何安装开发者工具,如何创建小程序,如何做服务端配置。并利用腾讯云COS实现相册上传...

8.9K8

扫码关注云+社区