中后台 React 应用构建与渲染优化记录
date
Dec 24, 2018
slug
react-app-optimize
status
Published
tags
webpack
build
summary
type
Post
实习期间在公司开始负责一个中后台 React 单页应用的业务开发与维护。由于专注业务的快速迭代,这个应用的基础设施「年久失修」,承载了上百了页面的它存在着渲染速度慢、构建速度慢等问题。最近开始着手去优化这个应用的性能,在这篇文章中做一些记录。
优化方向
一个 React 应用的性能优化可以分以下几点看
- 构建性能。指构建速度的快慢。
- 网络响应性能。一般指资源返回的速度,与 CDN、DNS 和网络性能相关。
- 页面渲染性能。涉及到 Bundle 资源大小及图片大小。
- 页面运行时性能 。例如长列表、滚动事件的性能优化
这从优化从工程化角度入手,主要专注两个方面:构建性能与页面渲染性能。
问题分析
在着手优化之前,我们需要分析在构建过程中哪几个环节影响了构建性能和渲染性能。
Bundle size 分析
Steve Souders 的「性能黄金法则」中提到,只有10%~20%的最终用户响应时间花在了下载HTML文档上,其余的80%~90%时间花在了下载页面中的所有组件上(JavaScript、CSS、图片)。
影响页面渲染的主要因素即是 Bundle 大小。所以我首先开始分析 Webpack 的构建模块组成。这里我用到了 webpack-bundle-size-analyzer 这个工具,它能见把 Bundle 的内容生成一个可视化交互式 Treemap。
它能给到我们以下信息:
- 构建出来的 Bundle 中包含了哪些内容
- 哪些模块占据的空间最大
- 哪些模块是不应该被打包构建的,哪些模块是功能重复可精简的
构建速度分析
这里我使用了 speed-measure-webpack-plugin 对 Webpack 进行了构建速度分析。它能够测算 Webpack 各个步骤的构建速度,并在命令行输出测算信息:
通过这些信息我们就能够知道 Webpack 在哪个环节耗时较多,从而进行针对性优化。
优化方案
React、ReactDOM 等第三方库走 externals
webpack-bundle-size-analyzer 显示 React、ReactDOM 和 lodash 等第三方库也再 bundle 中。然而每次重新构建时,需要打包的实际上只有我们的业务代码,这些第三方库的存在只会拖累构建性能,所以需要「外置」,从 CDN 引入即可:
module.exports = {
//...
externals: {
react: 'react',
'react-dom': 'react-dom',
lodash: 'lodash',
},
};
预编译大体积资源模块
这个应用中的个别页面中的商场地图绘制依赖于一个地图库,但是它的 JS、JSON 文件特别大,且不会频繁更新。对于这种模块,我决定让它和自己的业务代码分开打包,这样每次重新打包时, webpack 只需要打包项目中的业务代码,而不会再去编译这些大体积第三方库。这里为这些大体积库单独配置了 Webpack,并采用
DllPlugin
和 DllReferencePlugin
实现,// webpack.dll.js
module.exports = {
mode: 'production',
entry: {
vendors: ['vq-map-ut', 'vq-map-ms'], // 地图库
lib: ['moment'], // 其他第三方模块
},
output: {
filename: '[name].dll.js',
path: path.resolve(__dirname, './dll'),
library: '[name]'
},
plugins: [
new webpack.DllPlugin({
name: '[name]',
path: path.resolve(__dirname, './dll/[name].manifest.json')
})
]
}
配置打包命令:
// package.json
"scripts": {
"build:dll": "webpack --config ./webpack.dll.js",
},
React Router Code Splitting
目前应用的 bundle 是在用户访问首屏时全部加载完毕的,这导致首屏渲染特别慢。而用户在访问某个路由时是不需要加载其他路由组件的资源,所以我们基于路由进行了代码切割。
- Dynamic Import 动态导入
这里使用了 ES6 提案中的
import()
语法来在运行时动态加载 ES Module。Webpack 会将 import()
方法看做一个「代码分离点」,这意味着所导入的的模块和它的子模块将被打包成一个独立的 chunk。由于
import()
语法还没被纳入正式语言标准,我们需要使用 @babel/plugin-syntax-dynamic-import
插件来确保 Babel 能够正确解析动态加载语法。- 使用
@loadable/component
动态加载组件
import()
动态导入模块后,会返回一个 Promise。我们还需要使用 @loadable/component
来加载 React Component 进行渲染。import loadable from "@loadable/component";
import React from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
const Dashboard = loadable(() => import('./pages/Dashboard'));
const GoodItemsManage = loadable(() => import('./pages/GoodItemsManage'));
const App = () => (
<Router>
<Switch>
<Route exact path="/" component={Home}/>
<Route path="/goods-item-manage" component={GoodItemsManage}/>
{...}
</Switch>
</Router>
);
发挥 Tree Shaking 的全部效能:
Tree Shaking 用于移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES6 Module 的 静态结构特性。为保证 Tree Shaking 能够正常工作,我们需要确保代码模块都是 ES6 Module:
- @babel/preset-env 配置 modules: false 防止将 ES Module 转换为其他模块标准
- 修改老代码中的 CommonJS 模块形式
充分利用电脑性能:多线程构建
在不开启 Worker 的情况下,Node.js 是通过单线程运行 Webpack 进行构建的。
通过 speed-measure-webpack-plugin 可以看到,webpack 在运行 loader 时耗时是最长的,尤其是 sass-loader(应用使用了 Fusion Design,其样式文件皆为 sass)。这是单线程显得有些捉襟见肘。为开启多线程构建,这里为 babel 转译和 sass 编译使用了 thread-loader。
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: [
"thread-loader",
"babel-loader",
]
},
{
test: /\.scss$/,
use: [
"css-loader",
"thread-loader",
"sass-loader",
]
},
]
}
}
把这个 loader 放置在其他 loader 之前, 放置在这个 loader 之后的 loader 就会在一个单独的 worker 池中运行。
每次 webpack 解析一个模块,thread-loader 会将它及它的依赖分配给 worker 线程中。
优化结果
优化完毕后,分支在预发环境跑了一周,验证OK后上线成功。
整体优化结果:构建时长减少了30%,bundle 大小减少了 66%。(比较遗憾,资源加载速度和首屏渲染速度没有记录,构建时长和 bundle大小具体数字变化也缺失了……)