基于React.Suspense和React.lazy的前端性能优化

avatar
UX @京东
原文链接: mp.weixin.qq.com

React16.6于2018年10月份发布,该版本带来了许多新的特性同时赋予给React更强大的功能。其中最为显著的两个特性是 React.SuspenseReact.lazy 。这两个特性,将React的代码分割和懒加载带到了一个新的高度。使用这两个特性,你可以做到的是在真正需要时才加载该组件的文件。

本文主要介绍我在项目中如何使用 React.SuspenseReact.lazy 以及该特性给我们React开发者带来的好处。

一、为什么要使用代码分割

随着前端技术的不断发展,ES6模块、Babel转换、webpack打包等新技术的出现,前端应用现在完全可以使用模块化的方式完成,便于维护。

通常情况下,我们会将所有的模块打包到一个文件中,当请求网页时加载该文件以展示整个应用。但是,随着网页功能的不断扩展,这便带来了网页加载缓慢、交互卡顿等问题,使用户体验非常糟糕。 导致这一问题的主要原因是,我们在页面加载时,会一次性加载所有代码,无论是当前要用的代码还是之后用到的代码。但是用户在第一次进来时并不会用到所有的功能,因此 code-splitting 即代码分割这个名词出现了。

像webpack便提供了代码分割的功能。webpack中对代码分割的定义如下:

Code splitting is one of the most compelling features of webpack. This feature allows you to split your code into various bundles which can then be loaded on demand or in parallel.

意思就是说可以将代码拆分为多个bundle,同时可以按需或者并行加载。因此,为了提高应用的性能,我们可以尝试如何合理的分割代码并延迟加载。

二、如何分隔代码

1、动态加载

ES6标准引入了import以方便我们静态加载模块。形式如:

 import xxx from xxx.js

尽管import对于我们加载模块很有帮助,但是静态加载模块的方式一定程度上限制了我们来实现异步模块加载。不过,目前动态加载模块的import()语法已处于提案阶段,并且webpack已将他引入并使用。import()提供了基于Promise的API,因此,import()的返回值是一个完成状态或拒绝状态的Promise对象。形式如:

    import(/* webpackChunkName: 'module'*/ "module")

    .then(() => {

    //todo

    })

    .catch(_ => console.log('It is an error'))

webpack在编译时,识别到动态加载的import语法,则webpack会为当前动态加载的模块创建一个单独的bundle。如果你使用的是官方的Create-react-app脚手架或React的服务端渲染框架Next.js,那么可以直接使用动态import语法。如果你的脚手架是你自己配置的webpack,那么你需要按照官方指南来设置,请移步[1]。

2、动态加载React组件

当前最为流行的一种方法是使用 React-loadable [2]库提供的懒加载React组件。它利用import()语法,使用Promise语法加载React组件。同时,React-loadable支持React的服务端渲染。 通常,我们以如下方式实现组件:

    import LazyComponet from 'LazyComponent';

    export default function DemoComponent() {

    return (

    <div>

    <p>demo component</p>

    <AComponent />

    </div>

    )

    }

在上面的例子中,假设 LazyComponetDemoComponent 渲染时我们并不展示。但是因为我们使用import语法将 LazyComponet 导入,所以在编译时会将 LazyComponet 的代码与 DemoComponent 的代码打包到同一个bundle里面。 但是,这并不是我们想要的。所以我们可以通过使用 React-loadable 来懒加载 LazyComponet ,同时将 LazyComponet 的代码单独打包到一个bundle里面。我们可以看一下官网提供的例子:

    import Loadable from 'react-loadable';

    import Loading from './my-loading-component';

    const LoadableComponent = Loadable({

    loader: () => import('./my-component'),

    loading: Loading,

    });

    export default class App extends React.Component {

    render() {

    return <LoadableComponent/>;

    }

    }

从例子中我们可以看到,react-loadable使用动态import()方法,并将导入的组件分配给loader属性。同时,react-loadable提供了一个loading属性,以设置在加载组件时将展示的组件。

三、lazy和suspense的使用

React.lazy and Suspense is not yet available for server-side rendering. If you want to do code-splitting in a server rendered app, we recommend Loadable Components. It has a nice guide for bundle splitting with server-side rendering.

在使用之前,我们需要特别注意的一点是,React官方明确支持,React.lazy和Suspense并不支持服务端渲染。因此,使用服务端渲染的同学,请绕行至 react-loadableloadable-components [3]。

由于我是对原有项目进行的升级,因此,本文以下内容主要针对于老项目升级React最新版所做的工作。

1、代码升级React最新版

如果你的代码是Reactv16,那么可以直接升级到最新版本,当然React16.6已经提供了lazy和suspense方法。如果是v16之前,则按照官方操作迁移。

2、确定原有代码的懒加载组件

首先,按照需求,将非首屏加载的组件确定为懒加载组件,我的项目共确定五个组件可以进行懒加载。修改方式很简单,原有引入组件的方法为:

    import LazyComponent from "../components/LazyComponent ";

修改为:

    const LazyComponent = React.lazy(() =>

    import(/* webpackChunkName: 'lazyComponent'*/ "../components/LazyComponent")

    );

如代码所示:将静态引用组件的代码替换为调用React.lazy(),在lazy()传入一个匿名函数作为参数,在函数中动态引入 lazyComponent 组件。这样在我们渲染这个组件前,浏览器将不会下载 lazyComponent.bundle.js 文件和它的依赖。 其中,import内的webpackChunkName为我们定义的bundle文件名。

如果React要渲染组件时,组件依赖的代码还没下载好,会出现什么情况? <React.Suspense/> 的出现帮我们解决问题。在代码未下载好前,它将会渲染fallback属性传入的值。因此我们的原有代码为:

    return (

    <div>

    <MainComponet />

    <LazyComponent />

    </div>

    )

修改为:

    return (

    <div>

    <MainComponet />

    <React.Suspense fallback={<div>正在加载中</div>}>

    <LazyComponent />

    </React.Suspense>

    </div>

    )

fallback中可以修改为任意的spinner,本次不做过多优化。假如你不使用React.suspense,则React会给出你错误提示,因此记得React.lazy和React.Suspense搭配使用。 此时我们可以从网络请求中看到,动态加载的lazyComponet组件被单独打包到一个bundle里面,然而,在首屏加载的时候,该bundle已经加载到我们的页面中了,这也许并不是我们想要的,我们想要的是当我们需要的时候再加载。接下来我们就控制一下,当我们需要的时候,再加载该文件。

3、通过变量控制加载

原本我选择的五个懒加载组件均属于弹层性质的组件,因此必然会设置一个state来控制该组件的显示与隐藏,因此我们将代码改为:

    return (

    <div>

    <MainComponet />

    {this.state.showLazyComponent && (

    <React.Suspense fallback={<div>正在加载中</div>}>

    <LazyComponent />

    </React.Suspense>

    )}

    </div>

    )

由此,在首屏加载时,并未加载我们的懒加载组件 LazyComponent 所对应的bundle包。等到我们点击需要该组件显示时,页面才去加载该js。这便达到了我们代码分离并懒加载的目的。那么我们这么操作,到底主bundle包的体积减少了吗?接下来我们打包文件看一下。

4、打包文件

优化前打包出来的文件:

优化后打包出来的文件:

app.js 文件变小,随之增加 lazyComponent.js 。当懒加载组件多时,我们便可一定程度上减少首屏加载文件的大小,提高首屏的渲染速度。本实验仅仅抽取一个组件作为示例,如果懒加载的数量较多,足以明显减小app.js的体积。

四、验证优化的有效性

1、利用Puppeteer和Performance API做对比

为了验证前面我所做的优化的有效性,我做了一组对比实验。实验内容为使用puppeteer分别访问优化前和优化后的页面1000次,使用Performance API分别统计五项数据在这1000次访问时的平均值。 实验结果如下图所示,其中:

  • A为request请求平均耗时

  • B为解析dom树耗时平均耗时

  • C为请求完毕至DOM加载平均耗时

  • D为请求开始到domContentLoadedEvent结束平均耗时

  • E为请求开始至load平均耗时

     

折线图无法准确展示数据,因此,附表格数据如下(均为平均耗时):

类别 优化后 优化前

A(request请求)

7.01 7.04

B(解析dom树平均)

30.28 32.59

C(请求完毕至DOM加载)

552.86 582.0

D(请求开始到domContentLoadedEvent结束)

569.13 589.0

E(请求开始至load结束)

1055.59 1126.94

从数据中我们可以看出,优化前后请求时间并没有什么影响,但是总体load的时间明显缩短并马上进入1000ms大关,可见优化后对于首屏的加载速度还是有明显提升。

注:因puppeteer运行1000次的过程中,会出现网络波动的情况,导致有些请求的数据偏大,因此平均值并不能完全体现正常的请求时间。但1000次的平均值足以进行优化前后的请求时间对比。

2、利用Chorme Performance 参数做对比

因为Performance API提供的参数有限,因此我从Chrome浏览器的performance summary中拿到了单次页面请求时的参数。因为是单次数据,因此我们不进行详细的对比。在此列出,只为说明优化前后浏览器渲染时间上哪些部分有提升。 优化前:

优化后:

  • 蓝色:加载(Loading)时间降低

  • 黄色:脚本运算(Scripting)时间降低

  • 紫色:渲染(Rendering)时间降低

  • 绿色:绘制(Painting)时间持平

  • 灰色:其他(Other)时间降低

  • 闲置:浏览器空闲时间降低

另外,我从Network中发现,优化后因为页面解析的相对之前较快,因此主接口的请求时间也相应的提前了一些。

五、总结

从多项数据表明, React.lazyReact.Suspense 的使用一定程度上加快了首屏的渲染速度,使得我们的页面加载更快。 另外,当我们想添加一个新功能而引入一个新依赖时,我们往往会评估该依赖的大小以及引入该依赖会对原有bundle造成多大影响。假如该功能很少被用到,那么我们可以痛快地使用 React.lazyReact.Suspense 来按需加载该功能,而无需牺牲用户体验了。

六、扩展阅读

[1] https://webpack.js.org/guides/code-splitting/ [2] https://github.com/jamiebuilds/react-loadable [3] https://github.com/smooth-code/loadable-components