07月09, 2018

ThinkJS项目中React同构实践

ThinkJS项目中React同构实践

在奇舞团最优秀的服务端框架ThinkJS中想进行React同构我们该怎么弄呢?本篇文章就讲讲360视频云项目在ThinkJS中进行React同构实战。

什么是React同构

什么是同构呢?

答:简单说,同构就是前后端都使用同一套代码进行渲染。 alt

我们为什么要使用同构?

答:因为现在SPA的流行,React或者Vue的项目越来越多的应用在了外网的项目中。但是SPA的页面都是通过js来动态生成的,这样就造成了一些需要SEO的页面无法被搜索引擎更好的抓取。同时,因为React库体积等问题,js很容易就达到2M左右,网络请求 + eval Javascript的消耗,大型项目首屏速度基本都在1秒或者1.5秒开外。这对于首屏速度要求高,或者移动站在弱网条件下同构就显得非常重要了,同构可以使你的首屏在300ms左右就渲染完成,并且减少首屏的请求数量。

如何进行React同构?

React官网 我们就可以轻易的找到,React内置为我们提供了4个方法,分别是

  • renderToString
  • renderToStaticMark
  • renderToNodeStream
  • renderToStaticNodeStream

从方法的命名我们就知道前两个是直接把代码渲染成字符串,后两个是输出一个可读流 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声明周期里发起一个数据请求。 alt 通过上面的图我们可以清楚的了解运行在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));
  }
}

其他注意事项

1、不要使用css in js的方式,要把css以外链的形势引入。

2、服务端渲染时,前端不在使用rander方式进行渲染,使用rander会导致前端二次渲染,我们需要使用React新增的API hydrate来进行,如果过我们使用renderToString方法时,渲染出来的根节点会有data-root的属性,使用hydrate如果检测到了data-root属性就不会再次渲染,只会把一些事件挂在到dom上。

结合ThinkJS遇到的问题

ThinkJS是一款非常好用的NodeJS服务端server框架,那么既然使用了同构,我们就需要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前端代码是不需要压缩的。

问题二:解决服务端不存在的全局变量

在前端,我们经常使用windowdocumentlocation等依赖于浏览器的全局变量,然而在服务端是没有这些全局变量的,在服务端运行自然就会产生大量的错误,因为我们还会引入很多第三方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);
        }
    }
})

性能优化与监控报警

一、性能优化

1、我们之前使用的React版本是0.15.*,此版本没有开源的协议,于是我们升级React版本到16.3,看起来版本跨度之大,但是其实在API层面对老代码几乎没有什么影响,据官方给出的结论,16版本的ssr性能比0.15版本有2-3倍的提升。

2、同时我们把NodeJS的版本从4.2.1升级到了8.9。升级到NodeJS 8以后版本也对SSR的执行效率也有较大的提升。

3、在线上我们一定要开启production mode,也能提升一定的渲染速度。

4、对于热门和相同的URL,比如没有用户差异的新闻列表,我们也可以缓存components,也可以提升渲染性能。

二、监控与报警

1、假设一个场景,我们业务平时的吞吐量是1亿QPS,突然有一个活动导致我们的用户量暴涨10倍,服务端渲染是非常消耗CPU和内存,我们可以在每个请求进来时检测CPU和内存的使用量,如果负载比较高,我们可以优雅降级,把渲染任务降级到客户端进行。

2、由于有了服务端渲染,前端很多代码可能在服务端发生意外,比如setInterval 等等,我们需要持续的监控服务器的内存指标,如果SSR上线之后,内存持续走高,那说明一定有内存泄露。

3、风险控制是必须要有的,首先在最外层LVS,一定要有心跳检测,防止NodeJS处在假死状态,而请求依然在往某个机器分发。在服务器上,我们可以使用PM2来进行cluster的管理,如果某个进程挂掉,PM2可以自动帮你重启进程,发布代码PM2还提供了gracefulReload来帮助平滑重启。

总结:欢迎使用ThinkJS,一款非常好用的服务端框架。

本文链接:http://www.gyblog.cn/post/thinkjs&react.html

-- EOF --

Comments