# 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 按需加载的坑

路由改造完成后,已经可以看效果,同时控制台也能看到按需加载的包。

image-20210214215955835

但是页面效果并不是我们所期望的。

页面显示时会先显示loading...,然后又渲染了对应的组件。

此时查看网页源代码发现并没有具体内容,也就是我们的ssr无效了。

image-20210214220012343

# 处理 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的挂载,否则会出现客户端覆盖服务端渲染的问题。

本节代码已上传

github.com/Bigerfe/koa… (opens new window)

阅读全文