编者注:一直以来使用 SPA 框架最大的问题是对搜索引擎不友好,虽然 Google 已经支持了抓取 JS 渲染后的页面,不过还是要照顾下国内的各家浏览器。随着 React 和 Vue 的流行,同构的概念慢慢被提出来,它最大的特点是一套代码既能在浏览器端跑,又能在服务端跑。今天我们请来了360视频云的宋光宇同学为我们分享一下他在基于 ThinkJS 的360视频云项目中进行 React 同构实践的一些心得。
本次内容是基于之前分享的文字版,若想看重点的话可以看之前的 PPT:
react 同构实战 - 声享也可以查看之前的分享视频:
react 同构实践-宋光宇-云现场什么是React同构
简单说,同构就是前后端都使用同一套代码进行渲染。因为现在 SPA 的流行,React 或者 Vue 的项目越来越多的应用在了外网的项目中。但是 SPA 的页面都是通过 JS 来动态生成的,这样就造成了一些需要 SEO 的页面无法被搜索引擎更好的抓取。同时,因为 React 库体积等问题,JS 很容易就达到 2M 左右,网络请求 + eval Javascript的消耗,大型项目首屏速度基本都在 1 秒或者 1.5 秒开外。这对于首屏速度要求高,或者移动站在弱网条件下同构就显得非常重要了,同构可以使你的首屏在 300ms 左右就渲染完成,并且减少首屏的请求数量。
如何进行React同构
从 React官网 我们就可以轻易的找到,React内置为我们提供了4个方法,分别是
renderToStringrenderToStaticMarkrenderToNodeStreamrenderToStaticNodeStream
从方法的命名我们就知道前两个是直接把代码渲染成字符串,后两个是输出一个可读流 readableStream。首先介绍一下直播云同构环境,我们的环境基于 Node v8.9 React v16.3 react-router v3 ThinkJS v2 来进行。我们知道了服务端渲染的api之后,我们就摩拳擦掌跃跃欲试了。一个大型项目 react-router 是必不可少的,既然我们使用了 react-router,我们找到了
react-router文档,二话不说先上一段代码
import { renderToString } from 'react-dom/server'
import { match, RoutingContext } from 'react-router'
import routes from './routes'
serve((req, res) => {
match({ routes, location: req.url }, (error, redirectLocation, renderProps) => {
if (error) {
res.send(500, error.message)
} else if (redirectLocation) {
res.redirect(302, redirectLocation.pathname + redirectLocation.search)
} else if (renderProps) {
res.send(200, renderToString(<RoutingContext {...renderProps} />))
} else {
res.send(404, 'Not found')
}
})
})
看起来好像很简单,通过renderToString我们就可以把React代码render成字符串,然后返还给前端,但是在实际中我们很遇到各种各样的问题。
React同构遇到的问题
问题一:既然是同构,如何处理原本在客户端的请求呢?
既然是同构,那肯定需要在服务端先请求数据接口,然后拿到对应的数据在去rander我们的前端代码。在前端大部分是用XMLHttpRequest对象进行请求,但是在node端没有这个对象,这个时候我们可以使用XHR2的第三方库来帮我们包装。但是在这里我更推荐使用axios网络库。
问题二:我们该何时发送请求呢?
在React中,我们可以在 componentDidMount 生命周期里发起一个数据请求。
通过上面的图我们可以清楚的了解运行在 Node 中 React 的生命周期,在服务端渲染的时候我们没有 componentDidMount 这个生命周期,只会执行到 componentWillMount 这个生命周期,那我们可以把请求放在这里面吗?还有我们的请求是异步的,那我们的能在生命周期里用async/await 吗?显而易见,都是否定的。因为React在调用生命周期是同步式的调用,很多新人在这里会掉进坑里。那使用不了async/await,那我们就不能再生命周期里去处理请求,因为请求是异步的,渲染是同步的。那我们该把请求放在哪里呢?我们可以把请求放入componment 的 constructor 里然后在路由 match 中获取所有需要渲染的 componment,然后把所有的请求放入到请求池中,利用 Promise.all 和 ThinkJS async/await 的特性,就可以在渲染组件前获取到所需要的数据,详情可以见下面的代码。
renderProps.components.forEach((item, index)=>{
if(item.fetchData) {
requestPool = requestPool.concat(item.fetchData());
}
});await Promise.all(requestPool).catch(e => {console.log(e)});
问题三:如何处理 Cookie?
我们在前端请求服务端接口的时候,通常我们会注入 cookie,服务端通过 cookie 信息来进行用户的身份校验。那么在同构时候,我们发起请求的环境是 nodejs,不是浏览器,所以我们需要手动的注入 cookie。由于每个用户的身份不一样,所以我们肯定需要动态的获取cookie,然后注入。通常情况下,我们可以在 baseController 里获取用户的 cookie 等信息,然后在渲染页面时候,通过全局挂载的方式把 cookie 注入到我们的同构代码里。
问题四:我们有非常多的页面,我们如何管理不同页面的数据关系和请求呢?
方案其实也比较成熟了,无论是 React 还是 Vue,我们都常用 redux 或者 vuex 来全局的管理我们的数据。我们在渲染前就可以提前的拿到我们的数据,然后根据数据生成我们的 store树,具体代码如下。
通过上一步的请求池 我们已经拿到了我们初始化的数据 然后我们通过 store.getState 拿到获取到的数据赋值给 preloadedState
const preloadedState = externalHandleStore.getState();
在后面把我们把刚刚的变量赋值给window的全局变量 让前端可以获取到
......
<script>window.__INITIAL_STATE__ = ${JSON.stringify(preloadedState)}</script>
// react中 如果数据就把这个数据注入到store里,以保证服务端和前端数据一致性if(window.__INITIAL_STATE__) {
store = createStore(appStore, window.__INITIAL_STATE__, applyMiddleware(thunk));
} else {
store = createStore(appStore, applyMiddleware(thunk));
}
}
其他注意事项
- 不要使用css in js的方式,要把css以外链的形式引入。
- 服务端渲染时,前端不在使用 render 方式进行渲染,使用 render 会导致前端二次渲染,我们需要使用 React 新增的API
hydrate来进行,如果过我们使用renderToString方法时,渲染出来的根节点会有 data-root 的属性,使用hydrate如果检测到了data-root属性就不会再次渲染,只会把一些事件挂在到dom上。
结合ThinkJS遇到的问题
ThinkJS 是一款非常好用的 NodeJS 服务端框架,那么既然使用了同构,我们就需要 NodeJS来作为我们的渲染层帮助我们渲染页面。那在同构的实际使用中会遇到什么问题呢?
编译问题
ThinkJS 是可以使用 ES6 标准书写代码的框架,它默认会把 src 下面的代码通过 babel 进行编译,把 ES6 编译成 ES5 代码。那么我们通常 React 项目也是使用 ES6 来编写,通过 webpack 进行编译。我们在同构时需要把 React 入口文件 import 进来进行解析和渲染,但是React 使用 JSX 编写HTML,ThinkJS 本身是没有引入这些 loader 来解析 JSX 语法,导致报错。但是 ThinkJS 提供了非常丰富的接口让我们可以任意的扩展编译插件。 我们可以在www/development.js 找到compile方法,修改参数引入我们想使用的插件
instance.compile({
log: true,
presets: [['es2015', {'loose': true}], 'stage-1', 'react'],
plugins: ['transform-runtime', 'transform-decorators-legacy']
});
但是按照结构的划分,我们不应该把我们的前端代码放入到src目录,我们一般约定把服务端的业务代码放入在src,于是我们把前端项目放入了平行于src的目录,还是通过webpack进行编译,在服务端直接引入编译后的代码。而且这里要注意的是服务端引入的ssr前端代码是不需要压缩的。
解决服务端不存在的全局变量
在前端,我们经常使用window、document、location等依赖于浏览器的全局变量,然而在服务端是没有这些全局变量的,在服务端运行自然就会产生大量的错误,因为我们还会引入很多第三方 npm 包,所以在处理很多地方还是非常头疼的。这里呢,我建议把一些代码的初始化可以放在componentDidMount这个生命周期来处理,如果一些包import就会执行的话,我们被迫就需要自己伪造一下全局变量例如
const window = {};
异步处理
React 代码体积非常庞大,我们经常会做 code-splitting。但是在服务端,是不会处理这些异步代码的,所以我们可以在router做一下判断,如果在服务端的话就不做代码分隔,很简单直接上代码吧。 我们使用了高阶组件包装了 react-router 的高级写法。然后我们会判断是否是在服务端,如果在服务端我们就直接引用,如果不是的话我们就会使用代码分隔。
import AuthRoute from 'auth-route';
export default AuthRoute({
path: 'cloudlive',
chunkLoader(cb) {
if(_isServer_) {
cb(
require('./home')
);
} else {
require(['./home'], cb);
}
}
})
性能优化与监控报警
性能优化
- 我们之前使用的 React 版本是 0.15.*,此版本没有开源的协议,于是我们升级 React 版本到16.3,看起来版本跨度之大,但是其实在 API 层面对老代码几乎没有什么影响,据官方给出的结论,16 版本的 SSR 性能比 0.15 版本有 2-3 倍的提升。
- 同时我们把 NodeJS 的版本从 4.2.1 升级到了 8.9。升级到 NodeJS 8 以后版本也对 SSR 的执行效率也有较大的提升。
- 在线上我们一定要开启 production mode,也能提升一定的渲染速度。
- 对于热门和相同的URL,比如没有用户差异的新闻列表,我们也可以缓存
components,也可以提升渲染性能。
监控与报警
- 假设一个场景,我们业务平时的吞吐量是 1亿QPS,突然有一个活动导致我们的用户量暴涨 10倍,服务端渲染是非常消耗 CPU 和内存,我们可以在每个请求进来时检测 CPU 和内存的使用量,如果负载比较高,我们可以优雅降级,把渲染任务降级到客户端进行。
- 由于有了服务端渲染,前端很多代码可能在服务端发生意外,比如 setInterval 等等,我们需要持续的监控服务器的内存指标,如果 SSR 上线之后,内存持续走高,那说明一定有内存泄露。
- 风险控制是必须要有的,首先在最外层 LVS,一定要有心跳检测,防止 NodeJS 处在假死状态,而请求依然在往某个机器分发。在服务器上,我们可以使用 PM2 来进行 cluster 的管理,如果某个进程挂掉,PM2 可以自动帮你重启进程,发布代码 PM2 还提供了 gracefulReload 来帮助平滑重启。