# react ssr 下的路由分割 - 按需渲染
到这里,我们的应用骨架已相对完善,已经可以 用来进行实际项目开发,但是仍然不够,还有优化的空间。
现在的所有业务代码都打包到了一个文件内main.js。
若开发一个真实的项目,在开始阶段页面较少,支持的业务较少,js 代码体积并不会太大,但是随着时间的推移,这个 js 文件会变得越来越大,有可能超过1M。
过大的文件会严重影响页面的加载速度,直接影响用户体验。
# 如何优化
代码全部打包到一个文件内在访问时被全部加载,但用户当前访问的也就一个页面,所以我们只需要当前页面的业务代码就可以了,其他页面的代码是不需要加载的,当用户访问的时候再加载和执行岂不是更好?
所以我们本节开始来实现基于路由的按需渲染。
# 需渲染原理
早期接触过 webpack2 的同学应该都知道require.ensure方法,甚至使用过该方法来实现按需加载。
这个 Api 的作用就是用来实现代码分割,它会单独打包指定的文件,不和主文件打包在一起。
不过后来有了更加规范的方式来实现按需加载-动态导入。
const A = import('./pages/A');
并且在webpack2版本中早就支持了该特性,只需要配置@babel/plugin-syntax-dynamic-import插件便可使用。
也就是说从webpack2开始已经支持了require.ensure和动态导入两种方式来实现按需加载。
这里我们主要介绍下使用动态导入的方式来实现按需加载。
import()只是一个语法糖,当前模块没有加载时,内部会发起一个JSONP请求来加载目标代码模块, 返回值是一个Promise对象,可以在then方法内得到真正的模块。
// pages/a.js
export default class A{
//...
}
import('./pags/a').then({default:A}=>{
//...
})
代码拆分和异步加载逻辑webpack已帮我们完成。
那动态导入怎样和react结合来实现按需加载呢?
# 具体实现
实现按需加载并不复杂,官方也有很多 demo 可以参考。
在react router3下实现按需加载更简单,但是 react router4就完全不同了。
在v3中,路由提供了特定的属性来支持,下面简短的几行代码就达到了按需加载的效果。
const A = (location, cb) => {
require.ensure([], require => {
cb(null, require('../Component/A').default)
},'A')
}
//配置route
<Route path="/a" getComponent={A} />
# react router4 按需渲染
我们需要抽象一个AsyncBundler组件,用于按需加载。
我们为该组件添了一个state mod状态, 表示异步加载(import())完成后得到的组件,并且加载过程增加laoding显示。
该组件还接收一个load props,此属性为Promise类型,用于动态导入其他组件,当AsyncBundler挂载完成后,在componentDidMount事件内执行异步组件的加载,也就是props. load方法,在then方法内得到加载成功的异步组件,同时更新AsyncBundler组件的state.mod,完成渲染。
以下是该组件完整代码
/**
* 容器组件,组件按需加载器
*
* @class Bundle
* @extends {Component}
*/
export default class AsyncBundle extends React.Component {
constructor(props) {
super(props);
this.state = {
mod: null//自身状态
};
}
componentDidMount() {
if (!this.state.mod) {
this.load(this.props);
}
}
load(props) {
this.setState({
mod: null
});
//注意这里,使用Promise对象; mod.default导出默认
props.load().then((mod) => {
this.setState({
mod: mod.default ? mod.default : mod
});
});
}
render() {
return this.state.mod ? this.props.children(this.state.mod) : <LoadingCompoent/>;
}
}
组件的具体用法如下
<AsyncBundle load={()=>import('../pages/a'))}>
{(Comp) => <Comp />}
</AsyncBundle>
为了使用更方便,我们对上面的写法再次进行封装,只需要调用一个方法就可以。
//异步加载组件的高阶函数
import AsyncBundle from './async-bundle';
import React from 'react';
function AsyncLoader (loader) {
function asyncFn(props) {
return <AsyncBundle load={loader}>
{(Comp) => <Comp {...props}/>}
</AsyncBundle>
}
return asyncFn;
}
export default AsyncLoader;
封装后的用法如下,这样使用可以节省不少代码。
AsyncLoader(() => import('../pages/index')),
# 路由改造
我们可以通过/*webpackChunkName:"chunk-index"*/的方式来执行文件名称,默认按照数字来命名.
//组件动态加载容器
import AsyncLoader from './async-loader';
export default [
{
path: ['/','/index'],
component: AsyncLoader(() => import(/*webpackChunkName:"chunk-index"*/'../pages/index')),
exact:true
},
{
path: '/list',
component: AsyncLoader(() => import('../pages/list')),
exact: true
},
{
path: '*',
component: pageNotFound,
exact: true
}
]
# react ssr 按需加载的坑
路由改造完成后,已经可以看效果,同时控制台也能看到按需加载的包。

但是页面效果并不是我们所期望的。
页面显示时会先显示loading...,然后又渲染了对应的组件。
此时查看网页源代码发现并没有具体内容,也就是我们的ssr无效了。

# 处理 ssr 无效问题
路由按需加载后,服务端渲染的组件发生了改变。
组件按需加载仅仅是针对浏览器端的,在服务器端是没必要。由于路由对应的组件外层包裹了一个动态渲染组件,服务端执行时他并没有得到真正的组件,所以ssr直出的内容会显示为一个loading。
比如非按需时会渲染A组件,现在改造成按需渲染此时A外层会包裹AsyncBundle组件,所以在服务端渲染的组件变成了AsyncBundle 容器组件。
其实在服务端根本不需要按需,只需要一个路由的静态配置就可以了。
如何处理呢?
服务端在路由匹配前,将动态化为静态路由(也就是预加载)。
看下转换代码,就明白了。
转为静态路由
//将路由转换为静态路由
async function getStaticRoutes(routes) {
let len = routes.length,
i = 0;
const staticRoutes = [];
for (; i < len; i++) {
let item = routes[i];
if (checkIsAsyncRoute(item.component)) {
staticRoutes.push({
...item,
...{
component: (await item.component().props.load()).default
}//调用下load方法得到返回值即可
});
} else {
staticRoutes.push({
...item
});
}
}
return staticRoutes; //返回静态路由
}
上面方法返回了一个静态配置的路由,之后的匹配和渲染都基于这个静态路由。
这里有个点可以优化一下,把查找的结果缓存起来,没必要每次请求都去转换一次。
看下ssr中间件代码的改造
// src/server/middlewares/react-ssr.js 主要变更代码
//...
//获得静态路由
const staticRoutesList = await getStaticRoutes(routeList);
//查找到的目标路由对象
let matchResult = await matchRoute(path, staticRoutesList);
let { targetRoute, targetMatch } = matchResult;
//....
//staticRouter 也是用静态路由 staticRoutesList
const html = renderToString(<StaticRouter location={path} context={context}>
<App routeList={staticRoutesList}></App>
</StaticRouter>);
查看效果后,ssr 组件直出问题解决。
不过还有问题。。。
这次页面的效果更加神奇了,先显示服务端直出的内容,随后显示loading,然后loading消失,又显示了组件的内容。
# 处理客户端覆盖渲染问题
为什么浏览器接管后,页面还会出现...loading并且一闪而过呢?
这里请大家屏气凝神的想一下,其实很简单。
非按需渲染时是不会出现 loading 的吧,不过这好像是废话。
那么按需的时候出现loading,其实是在等待异步 js 代码的加载, 动态创建 script后,js代码的请求和下载也是需要时间的。
所以呢?
我们应该等这段异步js代码下载完后再去执行渲染是不是就好了呢?
答案是对的!
那如何做呢?
组件查找
客户端渲染前先进行路由查找,得到对应的组件后,调用组件的异步渲染方法load,等待其加载完后,再进行组件的DOM渲染。
具体实现如下
//提取出挂载到 dom 方法
function renderDom(routeList) {
//渲染index
ReactDom.hydrate(<BrowserRouter>
<App routeList={routeList} />
</BrowserRouter>
, document.getElementById('root'))
}
//渲染入口
function clientRender(routeList) {
let initialData = JSON.parse(document.getElementById('ssrTextInitData').value);
//查找路由
let matchResult = matchRoute(document.location.pathname, routeList);
let { targetRoute } = matchResult;
if (targetRoute) {
//设置组件初始化数据
targetRoute.initialData = initialData;
//****等待异步脚本加载完成****
if (targetRoute.component[proConfig.asyncComponentKey]) {
targetRoute.component().props.load().then(res => {
//异步组件加载完成后再渲染页面
console.log('异步组件加完成');
//加载完成再执行 dom 挂载
renderDom(routeList);
});
}
} else {
renderDom(routeList);
}
}
//渲染入口
clientRender(routeList);
到这里,一个完整的react ssr 路由按需加载就完成了,小伙伴们抓紧来试试吧。
# 其他方式实现按需渲染
在上面我们是自己手写的异步组件加载器,当然业界也有很多比较成熟的工具库,原理和我们的实现差不多,只是容错更强,功能更丰富。
下面几个现有库,有兴趣的可以自己试试。
react-async-component
react-loadable //广泛使用
@loadable/component
react-imported-component
react-universal-component
react-loadable 该库是一个轻量级的代码分割组件,用于加载动态导入的组件,而且它考虑了非常多的边界情况,支持预加载、ssr,业内使用度很高。
遗留问题
路由分割后,会导致热更新无效,现在官方也依然存在这个 issue,现在唯一的办法就是牺牲热更新对状态的保存,但不影响模块热替换。
# 小结
这一节我们完成了一个重大的优化,实现了基于路由的按需渲染。
单纯实现组件的按需加载还是很容易的,关键是和react ssr结合后该如何解决出现的各种问题。
要知道在服务端不需要动态导入,服务端只需要处理静态路由即可,所以我们在使用前将动态路由转换为了静态路由。
另外客户端渲染也需要注意,需使用预加载,等异步组件加载完成再进行DOM的挂载,否则会出现客户端覆盖服务端渲染的问题。
本节代码已上传
← 双服务模式热更新 使用高阶组件优化数据同构 →