webpack折腾记(四):性能优化

之前接手的一个旧项目,使用的是roadhog + dva + antd等技术,里面大概有上百个路由文件,其他model、组件等文件也不少,导致整个项目的模块文件非常多,热更新和打包都速度都比较慢,输出代码体积也很大。基于这个问题,本文整理webpack常用的一些优化手段。

<!--more-->

参考:

1. 性能分析确定

面对一个需要优化的webpack项目,我们需要确定性能优化点

  • 打包速度优化,需要定位整个打包流程中耗时较严重的节点
  • 打包结果优化,从代码体积、文件依赖拆分等方面分析打包结果是否合理

1.1. 速度分析

可以通过speed-measure-webpack-plugin 这个插件分析打包过程中每个节点占用的时长

const smp = new SpeedMeasurePlugin();
module.exports = smp.wrap((env) => {
    // .. webpack配置
})

然后打包时就可以看见每个plugin和loader执行的时间,从而定位一些耗时的步骤,方便做优化分析,一般来说耗时操作都位于loaderbabel等文件内容解析等操作上

1.2. 输出分析

可以使用webpack-bundle-analyzer分析打包后的文件依赖,该插件提供了一个可视化浏览打包结果和文件依赖的服务。

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}

1.3. 小结

关于webpack的效率,我们着重于输出结果与打包速度两个方面。我认为

  • 应该优先考虑输出结果,思考如何拆分代码、优化生产环境的加载性能
  • 然后在此基础之上,再考虑如何提高开发环境和打包时的速度

但实际上,代码拆分的策略也会影响打包速度,因此下面的某些方案中会混合这两个点。

2. 几种代码拆分方案

参考:

2.1. SplitChunksPlugin

在某些场景下,我们需要对某些模块进行单独打包:

  • 配置多个entry,如果每个entry都引入了某些公共模块,会导致打包出来的文件体积比较大。
  • 如果是合并打包,则项目代码改动后会导致输出文件的contenthash变化,缓存失效。由于项目依赖的库文件,一般不会轻易改动,但用户却需要加载一个比较大的文件

在weback4之前,可以使用webpack.optimize.CommonsChunkPlugin来指定需要单独打包的公共模块

entry: {
    // ... 入口文件
    vendor:['react'], //第三方库显示声明
    common:['./util'] //公共组件声明为common
},
//***
plugins: [
    new webpack.optimize.CommonsChunkPlugin({
        names:["common", "vendor"],
        filename: "[name].js"
    })  
]

由于拆分公共模块是一个很常见的需求,webpack4中内置了SplitChunksPlugin,我们也可以通过optimization.splitChunks手动配置

optimization: {
     splitChunks: {
         chunks: 'all'
     }
 }

2.2. 异步加载

有两种方式供我们实现动态导入,其本质都是通过插入script标签,然后加载对应的模块

import(xxx).then()

ES的提案,返回以一个promise,导入的模块在then中拿到,注意使用该语法需要配置babel插件@babel/plugin-syntax-dynamic-import

*require.ensure() *

webpack在编译时会静态地解析代码中的require.ensure(),将里面require的模块添加到一个分开的chunk中。

2.3. Externals

通过Externals配置,我们可以很方便地在webpack中使用第三方模块,这样使用CDN等资源加载库文件,减少需要打包的模块,进而提高打包速度、减少代码体积。

externals: {
  'jQuery': 'window.#x27;,
},

使用Externals带来的一个问题是如何管理这些通过script引入的模块,在之前思考过相关的问题:webpack_cdn,得出的一种解决方案是:使用systemJS等包管理工具来控制第三方模块的加载。

2.4. 按需加载

引入颗粒化的模块而非全局模块

我们可能只需要一少部分组件,将整个库文件打包完全没有必要,以Antd为例,我们可以通过手动引入部分模块文件的方式实现按需加载

import DatePicker from 'antd/es/date-picker'; // 加载 JS
import 'antd/es/date-picker/style/css'; // 加载 CSS

如果需要引入多个组件,这种手动的方式就显得比较麻烦,因此可以使用babel-plugin-import插件自动实现这个功能

// babel-plugin-import 会帮助你加载 JS 和 CSS
import { DatePicker } from 'antd';

对于Element-UI而言,可以使用babel-plugin-component实现类似的效果(事实上这个插件是从babel-plugin-import仓库fork的)。

resolve.alias解决库文件中依赖全局模块的问题

上面这种方式只能在我们的项目代码中实现按需加载;在某些库文件中,会依赖于其他某个整体模块。

举个例子,即使配置了上面的babel-plugin-import,在引入一个Button组件之后,也会导致打包整个icons目录,以至于bundle包体积迅速变大,详情可移步issue:antd svg icons体积太大

手动修改库文件很显然不是一个明智的选择。一种变通的方案是:通过resolve.alias修改模块的实际依赖文件。

为了解决上面这个问题,首先新建一个模块文件声明自己使用到的icon,这样可以剔除其他无用的模块

// src/assets/icon.js
// 声明并导出自己需要的图标,相当于重写@ant-design/icons/lib/dist.js的文件
export {
    default as SmileOutline
} from '@ant-design/icons/lib/outline/SmileOutline';

export {
    default as MehOutline
} from '@ant-design/icons/lib/outline/MehOutline';

然后通过在webpack中配置resolve.alias修改@ant-design/icons/lib/dist的实际文件

resolve: {
    alias: {
        '@ant-design/icons/lib/dist#x27;: path.resolve(__dirname, './src/assets/icon.js')
    }
},

2.5. Tree Shaking

上面提到了按需编译,需要我们手动实现或者根据特定的库引入对应的插件实现。如果webpack能识别并移除不必要的依赖不是更好吗?这就是tree shaking 的概念。

tree shaking 原本是 rollup提出的一个概念,基于 ES6 模块的静态依赖机制,可以通过扫描所有 ES6 的 export,找出被 import 的内容并添加到最终代码中。

在webpack中使用tree shaking需要两个步骤

  • 配置babel,使用es6模块,{"presets": ["env", {"modules":false}]}
  • webpack配置开启optimization.usedExports = true(在production模式下是默认开启的,但不太好观测输出代码,因此可以在development中手动开启)

在webpack打包时,会对代码进行标记,把 import & export 标记为 3 类:

  • 使用过的 import 标记为 /* harmony import */,没被使用过的 import 标记为 /* unused harmony export [FuncName] */
  • 被使用过的 export 标记为 /* harmony export ([type]) */,其中 [type] 可能取值为 bindingimmutable

然后在UglifyJsPlugin压缩代码时,会移除unused harmony export相关的代码。

理想是很美好的,但可能会由于各种原因导致tree shaking失效,包括

  • 函数副作用导致无法标记为unused,babel等工具打包后的代码(如IIFE等)往往包含副作用
  • 库文件往往经过打包后输出了一个bundle文件,这种经过babel等工具洗礼后的文件基本与tree shaking无缘

目前比较主流的库会将组件或函数打包到独立的文件或函数,我们就可以通过按需引入的方式声明依赖的模块。

就目前而言,tree shaking距离理想化完全删除无用代码还有一段时间,参考

2.6. polyfill

为了兼容旧平台,我们往往会使用polyfill,常见的有@babel/plugin-transform-runtime插件或babel-polyfill库。

毫无疑问,二者都会增加输出代码的体积,他们的区别在于

  • transform-runtime是按需引入,需要用到哪些polyfill,runtime就自动帮你引入哪些,多个模块使用相同的polyfill,可能会造成重复引入
  • babel-polyfill的引入是全局的,基本能满足所有新接口的polyfill。在小项目中可能会造成体积过大等问题

一般地使用原则是:开发框架和库时为了避免污染全局polyfill,建议使用transform-runtime;开发大型web应用时,建议使用babel-polyfill,这种情况下,建议将库文件通过上面提到的几种方式进行拆分。

3. 打包速度优化

可以从下面的场景中观测到编译速度的影响

  • 在部署到线上,往往会经历完整的installbuild等命令,在发布紧急版本(如修复线上bug),时间是很宝贵的,晚几秒钟都可能会影响用户的使用
  • 在开发环境下,重启环境、热更新的速度都影响着开发效率和体验,如果修改一个文件需要等待数秒钟才能看见改动,开发效率可想可知。

因此,在这个章节我们来研究一下如何提高编译速度。

3.1. 开发环境下按需编译

在开发阶段,如果目录文件较多,会导致热更新比较缓慢。由于在某一个时间段我们可能只会注重某一个或几个关联的页面文件,因此可以通过按需编译的方式较少需要加载的模块,提高编译速度。

一种比较原始的方法是在开发前手动注释暂时不需要引入的路由文件,但这种方式需要在提交代码前取消注释,频繁的切换也比较繁琐

这里介绍另外一种实现按需编译的方法,其原理为:在条件分支中,如果表达式为false,则webpack不会加载对应的模块

if(false){
    var xx = require('xxx')
}
// webpack编译出来的代码为
if(false){ var xx;}

基于这个原理,可以通过webpack.DefinePlugin注入一个全局变量,根据传入的参数修改这个值,实现按需编译的效果

// webpack.config.js
// 开发环境按需编译,生产环境全部打包
new webpack.DefinePlugin({
  CURRENT_PAGE: JSON.stringify(env.pagename)
}),

其中env.pagename可以通过webpack启动命令传入

webpack --env.pagename=shop

然后,在开发环境环境下就可以实现按需编译的效果了

// 业务代码router.js
if(CURRENT_PAGE === 'shop'){
    require('xxx')
}

此外还可以通过配置文件避免这种方案带来的代码侵入问题。

3.2. 缓存编译结果

对于大多数webpack任务而言,loader是性能瓶颈的关键点。每次打包,都会经历完整的loader处理,并输出最后的bundle文件,即使文件内容并没有发生改变。

使用cache-loader,可以将loader编译结果缓存到磁盘上。当再次构建时,如果源文件未发生改变,则不会重新编译。

其使用方式也比较简单:在需要缓存的配置前增加一项loader即可

rules: [
  {
    test: /\.ext$/,
    use: ['cache-loader', ...loaders], // 增加cache-loader
    include: path.resolve('src'),
  },
],

注意,读取和写入缓存都会带来额外的开销,因此最好只对那些昂贵的loader配置缓存。

3.3. 多线程编译

由于NodeJS是单线程运行,因此webpack只能一件一件去处理打包任务,HappyPack可以通过多进程执行JavaScript的方式来实现打包加速。

在默认情况下,我们会在rules中配置各种类型文件的处理loader;使用happypack时,会通过HappyPack插件封装文件loader

exports.plugins = [
  new HappyPack({
    id: 'styles', // 封装原本处理less文件的loader
    threads: 2,
    loaders: [ 'style-loader', 'css-loader', 'less-loader' ]
  })
];

exports.module.rules = [
  {
    test: /\.less$/,
    use: 'happypack/loader?id=styles' // 通过id指定调用HappyPack的loader
  },
]

由于创建子进程以及进程间通信也存在开销,因此最好只对那些比较耗时的loader(如babel等)加上happypack。

关于happypack的具体工作原理,可以参考这篇文章:happy pack 原理解析

3.4. DLLPlugin

SplitChunksPlugin尽管可以将公共的模块单独打包,但仍旧是在同一个构建过程中执行的。换句话说,尽管里面的库文件并没有变化,仍旧会执行打包流程(只是输出的hash相同)。

如果在每次业务代码改动后的打包过程中可以节省打包库文件的过程,应该会得到比较明显的效率提升。DLLPlugin提供了实现这种方案的机制,其核心思想为

  • 通过wepack分别打包库文件和业务文件
  • 通过DLLPluginDllReferencePlugin将两个打包过程关联起来

具体来讲

  • 在独立打包库文件的webpack任务中使用DLLPlugin,会生成一个manifest.json的文件
  • 在打包业务代码的webpack任务中使用DllReferencePlugin,根据指定manifest.jsonDLLPlugin预编译的结果关联起来。

关于dll的具体配置,社区可以查到具体的教程,如webpack编译速度提升之DllPlugin,此处不再赘述。

3.5. webpack4

升级至 webpack4 后,通过搭载 ParallelUglifyPlugin 、happyPack 和 dll 插件,编译速度可以提升181%,整体编译时间减少了将近 2/3,为开发节省了大量编译时间!而且随着项目发展,这种编译提升越来越可观

  • happyPack,运用多核并行处理webpack任务
  • ParallelUglifyPlugin并行通过 UglifyJS 去压缩代码
  • dll预编译不经常改动的库文件

貌似webpack5年底马上就要出来了...

4. 小结

本文首先介绍了定位webpack性能问题的两种方法,然后从代码拆分和打包速度两个方面整理了几种优化的手段,期望webpack最终达到

  • 输出文件体积小、页面文件加载速度快
  • 开发、打包速度快

现在,应该可以回答下面的问题了

如何了解webpack打包性能瓶颈?如何优化webpack打包效率?