中后台 React 应用构建与渲染优化记录

date
Dec 24, 2018
slug
react-app-optimize
status
Published
tags
webpack
build
summary
type
Post
实习期间在公司开始负责一个中后台 React 单页应用的业务开发与维护。由于专注业务的快速迭代,这个应用的基础设施「年久失修」,承载了上百了页面的它存在着渲染速度慢、构建速度慢等问题。最近开始着手去优化这个应用的性能,在这篇文章中做一些记录。

优化方向

一个 React 应用的性能优化可以分以下几点看
  1. 构建性能。指构建速度的快慢。
  1. 网络响应性能。一般指资源返回的速度,与 CDN、DNS 和网络性能相关。
  1. 页面渲染性能。涉及到 Bundle 资源大小及图片大小。
  1. 页面运行时性能 。例如长列表、滚动事件的性能优化
这从优化从工程化角度入手,主要专注两个方面:构建性能页面渲染性能

问题分析

在着手优化之前,我们需要分析在构建过程中哪几个环节影响了构建性能和渲染性能。

Bundle size 分析

Steve Souders 的「性能黄金法则」中提到,只有10%~20%的最终用户响应时间花在了下载HTML文档上,其余的80%~90%时间花在了下载页面中的所有组件上(JavaScript、CSS、图片)。
影响页面渲染的主要因素即是 Bundle 大小。所以我首先开始分析 Webpack 的构建模块组成。这里我用到了 webpack-bundle-size-analyzer 这个工具,它能见把 Bundle 的内容生成一个可视化交互式 Treemap。
notion image
它能给到我们以下信息:
  1. 构建出来的 Bundle 中包含了哪些内容
  1. 哪些模块占据的空间最大
  1. 哪些模块是不应该被打包构建的,哪些模块是功能重复可精简的

构建速度分析

这里我使用了 speed-measure-webpack-plugin 对 Webpack 进行了构建速度分析。它能够测算 Webpack 各个步骤的构建速度,并在命令行输出测算信息:
notion image
通过这些信息我们就能够知道 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,并采用 DllPluginDllReferencePlugin 实现,
// 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 Splittin

目前应用的 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:
  1. @babel/preset-env 配置 modules: false 防止将 ES Module 转换为其他模块标准
  1. 修改老代码中的 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大小具体数字变化也缺失了……)
 

© Sytone 2021