打不垮我的,将使我更加坚强 --尼采
大家好,我是柒八九。
好久没更文了,其实这段时间,一直没闲着。在准备一些比较重要的东西。忙着跑步,忙着学习,忙着xx。 总之就是,一直在忙着,从未停歇。
虽然,这段时间,没有文章的发布,其实,在私底下,已经有不下10篇的文章已经起手了。等再润色一下,就会和大家见面。
这是,我之前学习总结,后期会逐步给大家免费分享。敬请期待。
好了,闲话少叙。
今天,我们来谈谈关于-- Webpack的打包优化。
你能所学到的知识点
- Webpack
Loader
和Plugin
的区别- Webpack 生命周期
- Webpack编译阶段提效
- 减少执行编译的模块
- 提升单个模块构建的速度
- 并行构建以提升总体效率
- Webpack打包阶段提效
- 以提升当前任务工作效率为目标的方案
- 压缩 Chunk 产物代码
- 以提升后续环节工作效率为目标的方案
Code Splitting
Tree Shaking
Scope Hoisting
(作用域提升)sideEffects
- 缓存优化
Webpack Loader vs Plugin
loader
是文件加载器,能够加载资源文件,并对这些文件进行一些处理,诸如编译、压缩等,最终一起打包到指定的文件中
plugin
赋予了webpack
各种灵活的功能,例如打包优化、资源管理、环境变量注入等,目的是解决 loader 无法实现的其他事
两者在运行时机上的区别
loader
运行在打包文件之前plugins
在整个编译周期都起作用
对于 loader
,实质是一个转换器,将A文件进行编译形成B文件,操作的是文件,比如将A.scss
或A.less
转变为B.css
,单纯的文件转换过程。
在 Webpack
运行的生命周期中会广播出许多事件,Plugin
可以监听这些事件,在合适的时机通过Webpack提供的 API改变输出结果。
Webpack 生命周期
Webpack
工作流程中最核心的两个模块
Compiler
Compilation
它们都扩展自 Tapable
类,用于实现工作流程中的生命周期划分,以便在不同的生命周期节点上注册和调用插件,其中所暴露出来的生命周期节点称为Hook
(俗称钩子)。
Compiler Hooks
构建器实例的生命周期可以分为 3 个阶段
- 初始化阶段
- 构建过程阶段
- 产物生成阶段
初始化阶段
environment
- 在创建完
compiler
实例且执行了配置内定义的插件的apply
方法后触发
afterEnvironment
- 在创建完
compiler
实例且执行了配置内定义的插件的apply
方法后触发
entryOption
- 执行
EntryOptions
插件
afterPlugins
afterResolvers
- 解析了
resolver
配置后触发
构建过程阶段
normalModuleFactory
- 在两类模块工厂创建后触发
contextModuleFactory
- 在两类模块工厂创建后触发
beforeRun
run
beforeCompile
compile
thisCompilation
make
- 最耗时
- 会执行模块编译到优化的完整过程
产物生成阶段
shouldEmit、emit、assetEmitted、afterEmit
- 在构建完成后,处理产物的过程中触发
failed、done
- 在达到最终结果状态时触发
Compilation Hook
构建过程实例的生命周期分为两个阶段:
- 构建阶段
- 优化阶段
Webpack编译阶段提效
真正影响整个构建效率的是
Compilation
实例的处理过程
- 编译模块
- 优化处理
要提升编译阶段的构建效率,大致可以分为三个方向
- 减少执行编译的模块
- 提升单个模块构建的速度
- 并行构建以提升总体效率
优化前的准备工作
准备基于时间的分析工具 - SMP
需要一类插件,来帮助我们统计项目构建过程中在编译阶段的耗时情况
speed-measure-webpack-plugin
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin"); const smp = new SpeedMeasurePlugin(); const webpackConfig = smp.wrap({ plugins: [new MyPlugin(), new MyOtherPlugin()], }); 复制代码
准备基于产物内容的分析工具 - WBA
找出对产物包体积影响最大的包的构成,从而找到那些冗余的、可以被优化的依赖项。不仅能减小最后的包体积大小,也能提升构建模块时的效率webpack-bundle-analyzer
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; module.exports = { plugins: [ new BundleAnalyzerPlugin() ] } 复制代码
编译模块阶段所耗的时间是从单个入口点开始,编译每个模块的时间的总和
减少执行编译的模块(4个)
IgnorePlugin
(国际化包)- 按需引入类库模块 (工具类库)
DllPlugin
Externals
IgnorePlugin
有的依赖包,除了项目所需的模块内容外,还会附带一些多余的模块
Webpack 提供的 IgnorePlugin
,即可在构建模块时直接剔除那些需要被排除的模块,从而提升构建模块的速度,并减少产物体积。
new webpack.IgnorePlugin({ resourceRegExp: /^\.\/locale$/, contextRegExp: /moment$/, }); 复制代码
- resourceRegExp
- 指定需要剔除的文件(夹)
- contextRegExp (可选)
- 特定目录
- 任何以 'moment' 结尾的目录中匹配 './locale' 的任何
require
语句都将被忽略
除了 moment
包以外,其他一些带有国际化模块的依赖包,都可以应用这一优化方式。
按需引入类库模块
减少执行模块的方式是按需引入,一般适用于工具类库性质的依赖包的优化
优化处理
- 定向引入
- 效果最佳的方式是在导入声明时只导入依赖包内的特定模块
- 使用插件
babel-plugin-lodash
babel-plugin-import
- 适用于
antd
,antd-mobil
,lodash
{ "plugins": [["import",{ "libraryName": "lodash", "libraryDirectory": "", "camel2DashComponentName": false, // default: true }]] } 复制代码
注意点
Tree Shaking
,这一特性也能减少产物包的体积,但是 Tree Shaking
需要相应导入的依赖包使用 ES6
模块化,而 lodash
还是基于 CommonJS
,需要替换为 lodash-es
才能生效
Tree Shaking
是在优化阶段生效,Tree Shaking
并不能减少模块编译阶段的构建时间。
DllPlugin
它的核心思想是将项目依赖的框架等模块单独构建打包,与普通构建流程区分开。
事先把常用但又构建时间长的代码提前打包好(例如 react
、react-dom
),取个名字叫 dll
。后面再打包的时候就跳过原来的未打包代码,直接用 dll
。这样一来,构建时间就会缩短,提高 webpack
打包速度。
两个配置文件
webpack.dll.config.js
module.exports = { entry: { vendor: ['react', 'react-dom'], }, output: { filename: '[name].dll.js', path: path.join(__dirname, 'dll'), publicPath: '/dll', library: '[name]_dll', }, plugins: [ new webpack.DllPlugin({ context: __dirname, name: '[name]_dll', path: path.join(__dirname, 'dll' + '/[name]_manifest.json'), }), ], } 复制代码
new webpack.DllPlugin
- 生成manifest.json
文件,供DllReferencePlugin
指向依赖模块位置 - 将公共模块 react/react-dom
抽离到项目中dll文件下
webpack.app.config.js
plugins: [ new webpack.DllReferencePlugin({ context: __dirname, manifest: require('./dll/vendor_manifest.json'), }), ], 复制代码
new webpack.DllReferencePlugin
- 引用
manifest.json
文件,寻找依赖模块
webpack 4 有着比 dll 更好的打包性能,所以在最新版的cra中已经将dll剔除。
Externals
Webpack 配置中的 externals
和 DllPlugin
解决的是同一类问题。将依赖的框架等模块从构建过程中移除。
Externals
和 DllPlugin
区别
- 配置方面
externals
更简单DllPlugin
需要独立的配置文件
DllPlugin
包含了依赖包的独立构建流程,而externals
配置中不包含依赖框架的生成方式,通常使用已传入 CDN 的依赖包externals
配置的依赖包需要单独指定依赖模块的加载方式:全局对象、CommonJS
、AMD
等- 在引用依赖包的子模块时,
DllPlugin
无须更改,而externals
则会将子模块打入项目包中
使用范例
module.exports = { //... externals: [ { // String react: 'react', // Object lodash: { commonjs: 'lodash', amd: 'lodash', root: '_', // indicates global variable }, // [string] subtract: ['./math', 'subtract'], }, // Function function ({ context, request }, callback) { if (/^yourregex$/.test(request)) { return callback(null, 'commonjs ' + request); } callback(); }, // Regex /^(jquery|\$)$/i, ], }; 复制代码
提升单个模块构建的速度
在保持构建模块数量不变的情况下,提升单个模块构建的速度。
常用的方式有
include/exclude
noParse
Source Map
- TypeScript 编译优化
- Resolve
通过减少构建单个模块时的一些处理逻辑来提升速度
include/exclude
Webpack -loader
配置中的 include/exclude
,是常用的优化特定模块构建速度的方式之一
include
的用途是只对符合条件的模块使用指定Loader
进行转换处理exclude
则相反,不对特定条件的模块使用该Loader
例如不使用 babel-loader 处理 node_modules 中的模块 使用范例
module.exports = { ...... module: { rules: [ { test: /\.js$/, include: /src/ exclude: /node_modules/, use: ['babel-loader'], }, ], }, } 复制代码
注意点
- 通过
include/exclude
排除的模块,并非不进行编译,而是使用Webpack
默认的 js 模块编译器进行编译 - 在一个
loader
中的include
与exclude
配置存在冲突的情况下,优先使用exclude
的配置,而忽略冲突的include
部分的配置
noParse
Webpack 配置中的 module.noParse
则是在 include/exclude
的基础上,进一步省略了使用默认 js 模块编译器进行编译的时间
使用范例
module.exports = { ...... module: { noParse: /jquery|lodash/, rules: [ { test: /\.js$/, use: ['babel-loader'], }, ], }, } 复制代码
Source Map
对于生产环境的代码构建而言,会根据项目实际情况判断是否开启 Source Map
在开启 Source Map
的情况下,优先选择与源文件分离的类型 --例如 "source-map"
TypeScript 编译优化
Webpack 中编译 TS 有两种方式
- 使用
ts-loader
- 使用
babel-loader
在使用 ts-loader
时,由于 ts-loader
默认在编译前进行类型检查,因此编译时间往往比较慢
通过加上配置项 transpileOnly: true
,可以在编译时忽略类型检查
module.exports = { ...... module: { rules: [ { test: /\.ts$/, use: { loader: 'ts-loader', options: { transpileOnly: true, }, }, }, ], }, } 复制代码
babel-loader
则需要单独安装 @babel/preset-typescript
来支持编译 TS,配合 ForkTsCheckerWebpackPlugin
使用类型检查功能
module.exports = { ...... module: { rules: [ { test: /\.ts$/, use: ['babel-loader'], }, ], }, plugins: [ new TSCheckerPlugin({ typescript: { diagnosticOptions: { semantic: true, syntactic: true, }, }, }), ], } 复制代码
Resolve
Webpack
中的 resolve
配置制定的是在构建时指定查找模块文件的规则
resolve.modules
- 指定查找模块的目录范围
resolve.extensions
- 指定查找模块的文件类型范围
resolve.mainField
- 指定查找模块的
package.json
中主文件的属性名
resolve.symlinks
- 指定在查找模块时是否处理软连接
这些规则在处理每个模块时都会有所应用,因此尽管对小型项目的构建速度来说影响不大,对于大型的模块众多的项目而言,使用默认配置和增加了大量无效范围后,构建时长的变化。
并行构建以提升总体效率
并行构建的方案早在 Webpack 2
时代已经出现,适用于大项目。 使用方式
- HappyPack
- thread-loader
- parallel-webpack
HappyPack 与 thread-loader
两种工具的本质作用相同,都作用于模块编译的 Loader
上,用于在特定 Loader 的编译过程中。
开启多进程的方式加速编译
module.exports = { module: { rules: [ { test: /\.js$/, include: path.resolve('src'), use: [ 'thread-loader', ’babel-loader‘ ], }, ], }, }; 复制代码
parallel-webpack
并发构建的第二种场景是针对与多配置构建。
Webpack
的配置文件可以是一个包含多个子配置对象的数组,在执行这类多配置构建时,默认串行执行
var path = require('path'); module.exports = [ { entry: './pageA.js', output: { path: path.resolve(__dirname, './dist'), filename: 'pageA.bundle.js' } }, { entry: './pageB.js', output: { path: path.resolve(__dirname, './dist'), filename: 'pageB.bundle.js' } }]; 复制代码
通过 parallel-webpack
,就能实现相关配置的并行处理
"build:parallel": "parallel-webpack --config webpack.parallel.config.js" 复制代码
Webpack打包阶段提效
Webpack
构建流程中的第二个阶段,也就是从代码优化到生成产物阶段的效率提升问题
优化阶段可以分为两个不同的方向
- 针对某些任务
- 使用效率更高的工具或配置项
- 从而提升当前任务的工作效率
- 提升特定任务的优化效果
- 以减少传递给下一任务的数据量
- 从而提升后续环节的工作效率
以提升当前任务工作效率为目标的方案
一般在项目的优化阶段,主要耗时的任务有两个
- 生成
ChunkAssets
- 即根据
Chunk
信息生成 Chunk 的产物代码 - 主要在
Webpack
引擎内部的模块中处理
- 优化手段较少
- 主要集中在利用缓存方面
- 优化 Assets
- 即压缩 Chunk 产物代
面向 JS 的压缩工具
Webpack 4 中内置了 TerserWebpackPlugin
作为默认的 JS 压缩工具--基于 Terser
。之前的版本,需要单独引入,早期主要使用的是 UglifyJSWebpackPlugin
-- 基于 UglifyJS
。两者在压缩效率与质量方面差别不大,但 Terser
整体上略胜一筹
Terser 和 UglifyJS 插件中的效率优化
Terser
原本是 Fork
自 uglify-es
的项目,其绝大部分的 API 和参数都与 uglify-es 和 uglify-js@3 兼容。
以 Terser 为例来分析其中的优化方向
npm install terser-webpack-plugin --save-dev 复制代码
TerserWebpackPlugin
中,对执行效率产生影响的配置主要分为 3 个方面
Cache
选项
- 默认开启
- 使用缓存能够极大程度上提升再次构建时的工作效率
Parallel
选项
- 默认开启
- 并发选项在大多数情况下能够提升该插件的工作效率
- 适用大项目
terserOptions
选项
- 即
Terser
工具中的minify
选项集合 - 主要看其中的
compress
和mangle
选项 compress
参数的作用
- 执行特定的压缩策略
- 例如省略变量赋值的语句,从而将变量的值直接替换到引入变量的位置上,减小代码体积
- 在需要对压缩阶段的效率进行优化的情况下,可以优先选择设置该参数
mangle
参数的作用
- 对源代码中的变量与函数名称进行压缩
- 当compress参数为 false 时,压缩阶段的效率有明显提升,同时对压缩的质量影响较小
案例使用
module.exports = { optimization: { minimize: true, minimizer: [ new TerserPlugin({ cache: false, terserOptions: { compress: false, mangle: false, }, }), ], }, }; 复制代码
压缩代码是在 optimizeChunkAssets 阶段
面向 CSS 的压缩工具
CSS 同样有3种压缩工具可供选择
OptimizeCSSAssetsPlugin
- CRA中使用
OptimizeCSSNanoPlugin
- vue-cli
CssMinimizerWebpackPlugin
- 2020 年
Webpack
社区新发布的 CSS 压缩插件
它们都基于 cssnano
实现,压缩质量方面没有什么差别。
在压缩效率方面,最新发布的 MiniCssExtractPlugin
,它支持缓存和多进程,默认开启多进程。这是另外两个工具不具备的。
MiniCssExtractPlugin
对于 CSS
文件的打包,一般我们会使用 style-loader
进行处理,这种处理方式最终的打包结果就是 CSS
代码会内嵌到 JS 代码中
MiniCssExtractPlugin
是一个可以将 CSS
代码从打包结果中提取出来的插件。
// ./webpack.config.js const MiniCssExtractPlugin = require('mini-css-extract-plugin') module.exports = { module: { rules: [ { test: /\.css$/, use: [ // 'style-loader', // 将样式通过 style 标签注入 MiniCssExtractPlugin.loader, 'css-loader' ] } ] }, plugins: [ new MiniCssExtractPlugin() ] } 复制代码
将这个插件添加到配置对象的 plugins
数组中,使用 MiniCssExtractPlugin
中提供的 loader
去替换掉 style-loader
,以此来捕获到所有的样式。打包过后,样式就会存放在独立的文件中,直接通过 link
标签引入页面
CssMinimizerWebpackPlugin (webpack 5)
使用了 MiniCssExtractPlugin
过后,样式就被提取到单独的 CSS 文件中了,样式文件并没有被压缩。Webpack
内置的压缩插件仅仅是针对 JS 文件的压缩,其他资源文件的压缩都需要额外的插件。
// ./webpack.config.js const MiniCssExtractPlugin = require('mini-css-extract-plugin') const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); module.exports = { optimization: { minimizer: [ new CssMinimizerPlugin() ] }, module: { rules: [ { test: /\.css$/, use: [ MiniCssExtractPlugin.loader, 'css-loader' ] } ] }, plugins: [ new MiniCssExtractPlugin() ] } 复制代码
文档中的这个插件并不是配置在 plugins
数组中的,而是添加到了 optimization
对象中的 minimizer
属性中。
如果我们配置到
plugins
属性中,那么这个插件在任何情况下都会工作,而配置到 minimizer 中,就只会在 minimize 特性开启时才工作 ---optimization.minimize: true
原本可以自动压缩的 JS,现在却不能压缩了,因为设置了 minimizer
。Webpack
认为我们需要使用自定义压缩器插件,那内部的 JS 压缩器就会被覆盖掉。必须手动再添加回来
内置的 JS 压缩插件叫作 terser-webpack-plugin
,手动添加这个模块到 minimizer
配置当中。
// ./webpack.config.js const MiniCssExtractPlugin = require('mini-css-extract-plugin') const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); const TerserWebpackPlugin = require('terser-webpack-plugin') module.exports = { optimization: { minimize: true, minimizer: [ new TerserWebpackPlugin(), new CssMinimizerPlugin() ] }, module: { rules: [ { test: /\.css$/, use: [ MiniCssExtractPlugin.loader, 'css-loader' ] } ] }, plugins: [ new MiniCssExtractPlugin() ] } 复制代码
以提升后续环节工作效率为目标的方案
通过对本环节的处理减少后续环节处理内容,以便提升后续环节的工作效率
Code Splitting
Tree Shaking
Scope Hoisting
(作用域提升)sideEffects
Code Splitting(分块打包)
Code Splitting--通过把项目中的资源模块按照我们设计的规则打包到不同的 bundle
中
- 降低应用的启动成本
- 提高响应速度
Webpack 实现分包的方式主要有两种
- 根据业务不同配置多个打包入口,输出多个打包结果
- 结合
ES Modules
的动态导入(Dynamic Imports
)特性,按需加载模块
多入口打包
多入口打包一般适用于传统的多页应用程序,最常见的划分规则就是:一个页面对应一个打包入口,对于不同页面间公用的部分,再提取到公共的结果中
├── dist ├── src │ ├── common │ │ ├── fetch.js │ │ └── global.css │ ├── album.css │ ├── album.html │ ├── album.js │ ├── index.css │ ├── index.html │ └── index.js ├── package.json └── webpack.config.js 复制代码
有两个页面,分别是 index
和 album
- index.js 负责实现 index 页面功能逻辑
- album.js 负责实现 album 页面功能逻辑
- global.css 是公用的样式文件
- fetch.js 是一个公用的模块,负责请求 API
配置文件
// ./webpack.config.js const HtmlWebpackPlugin = require('html-webpack-plugin') module.exports = { entry: { index: './src/index.js', album: './src/album.js' }, output: { filename: '[name].bundle.js' // [name] 是入口名称 }, // ... 其他配置 plugins: [ new HtmlWebpackPlugin({ title: 'Multi Entry', template: './src/index.html', filename: 'index.html' }), new HtmlWebpackPlugin({ title: 'Multi Entry', template: './src/album.html', filename: 'album.html' }) ] } 复制代码
- 一般
entry
属性中只会配置一个打包入口。
- 如果需要配置多个入口,可以把
entry
定义成一个对象。 entry
是定义为对象而不是数组,如果是数组的话就是把多个文件打包到一起,还是一个入口。- 这个对象中一个属性就是一个入口,属性名称就是这个入口的名称,值就是这个入口对应的文件路径。
- 输出文件名 - 使用
[name]
这种占位符来输出动态的文件名 -[name]
最终会被替换为入口的名称 - 通过
html-webpack-plugin
- 分别为index
和album
页面生成了对应的 HTML 文件
分包加载
输出 HTML 的插件,默认这个插件会自动注入所有的打包结果。如果需要指定所使用的 bundle
,通过 HtmlWebpackPlugin
的 chunks
属性来设置
每个打包入口都会形成一个独立的 chunk(块)
// ./webpack.config.js const HtmlWebpackPlugin = require('html-webpack-plugin') module.exports = { entry: { index: './src/index.js', album: './src/album.js' }, output: { filename: '[name].bundle.js' // [name] 是入口名称 }, // ... 其他配置 plugins: [ new HtmlWebpackPlugin({ title: 'Multi Entry', template: './src/index.html', filename: 'index.html', chunks: ['index'] // 指定使用 index.bundle.js }), new HtmlWebpackPlugin({ title: 'Multi Entry', template: './src/album.html', filename: 'album.html', chunks: ['album'] // 指定使用 album.bundle.js }) ] } 复制代码
提取公共模块
需要把这些公共的模块提取到一个单独的 bundle 中
优化配置中开启 splitChunks
功能
// ./webpack.config.js module.exports = { entry: { index: './src/index.js', album: './src/album.js' }, output: { filename: '[name].bundle.js' // [name] 是入口名称 }, optimization: { splitChunks: { // 自动提取所有公共模块到单独 bundle chunks: 'all' } } // ... 其他配置 } 复制代码
将它设置为 all
,表示所有公共模块都可以被提取
动态导入
Code Splitting
更常见的实现方式还是结合 ES Modules 的动态导入特性,从而实现按需加载。
一般我们常说的按需加载指的是加载数据或者加载图片,这里所说的按需加载,指的是在应用运行过程中,需要某个资源模块时,才去加载这个模块。
- 极大地降低了应用启动时需要加载的资源体积
- 提高了应用的响应速度
- 节省了带宽和流量
Webpack
中支持使用动态导入的方式实现模块的按需加载,而且所有动态导入的模块都会被自动提取到单独的 bundle 中,从而实现分包
├── src │ ├── album │ │ ├── album.css │ │ └── album.js │ ├── common │ │ ├── fetch.js │ │ └── global.css │ ├── posts │ │ ├── posts.css │ │ └── posts.js │ ├── index.html │ └── index.js ├── package.json └── webpack.config.js 复制代码
文章列表对应的是这里的 posts
组件,而相册列表对应的是 album
组件
在打包入口(index.js)中同时导入了这两个模块,然后根据页面锚点的变化决定显示哪个组件
// ./src/index.js // import posts from './posts/posts' // import album from './album/album' const update = () => { const hash = window.location.hash || '#posts' const mainElement = document.querySelector('.main') mainElement.innerHTML = '' if (hash === '#posts') { // mainElement.appendChild(posts()) import('./posts/posts').then(({ default: posts }) => { mainElement.appendChild(posts()) }) } else if (hash === '#album') { // mainElement.appendChild(album()) import('./album/album').then(({ default: album }) => { mainElement.appendChild(album()) }) } } window.addEventListener('hashchange', update) update() 复制代码
为了动态导入模块,可以将 import
关键字作为函数调用。当以这种方式使用时,import
函数返回一个 Promise
对象.
- 在需要使用组件的地方通过
import
函数导入指定路径 - 方法返回的是一个
Promise
Promise
的then
方法中能够拿到模块对象
由于这里的 posts 和 album 模块是以默认成员导出,需要解构模块对象中的 default,先拿到导出成员,然后再正常使用这个导出成员。
import('./album/album').then(({ default: album }) => { mainElement.appendChild(album()) }) 复制代码
魔法注释
默认通过动态导入产生的 bundle
文件,它的 name
就是一个序号。如果需要给这些 bundle
命名的话,就可以使用 Webpack 所特有的魔法注释去实现
import(/* webpackChunkName: 'posts' */'./posts/posts') .then(({ default: posts }) => { mainElement.appendChild(posts()) }) 复制代码
所谓魔法注释,就是在 import
函数的形式参数位置,添加一个行内注释,注释有一个特定的格式---webpackChunkName:’xxx‘
,就可以给分包的 chunk
起名字
如果 chunkName
相同的话,那相同的 chunkName
最终就会被打包到一起,借助这个特点,就可以根据自己的实际情况,灵活组织动态加载的模块了。
Tree Shaking
Tree-shaking
最早是 Rollup
中推出的一个特性,Webpack 从 2.0 过后开始支持这个特性。使用 Webpack
生产模式打包的优化过程中,自动开启这个功能 --- npx webpack --mode=production
其他模式开启 Tree Shaking
配置对象中添加一个 optimization
属性,该属性用来集中配置 Webpack 内置优化功能,它的值也是一个对象,在 optimization
对象中先开启一个 usedExports
选项,表示在输出结果中只导出外部使用了的成员
module.exports = { // ... 其他配置项 optimization: { // 模块只导出被使用的成员 usedExports: true } } 复制代码
对于未引用代码,如果我们开启压缩代码功能,就可以自动压缩掉这些没有用到的代码.
module.exports = { // ... 其他配置项 optimization: { // 模块只导出被使用的成员 usedExports: true, // 压缩输出结果 minimize: true } } 复制代码
Tree-shaking 的实现,整个过程用到了 Webpack 的两个优化功能
usedExports
- 打包结果中只导出外部用到的成员
minimize
- 压缩打包结果
把代码看成一棵大树
usedExports
的作用就是标记树上哪些是枯树枝、枯树叶minimize
的作用就是负责把枯树枝、枯树叶摇
结合 babel-loader 的问题
Tree-shaking
实现的前提是 ES Modules
,最终交给 Webpack
打包的代码,必须是使用 ES Modules
的方式来组织的模块化
Webpack 在打包所有的模块代码之前
- 先是将模块根据配置交给不同的
Loader
处理 - 最后再将
Loader
处理的结果打包到一起
为了更好的兼容性,会选择使用 babel-loader
去转换我们源代码中的一些 ECMAScript
的新特性,Babel
在转换 JS 代码时,很有可能处理掉代码中的 ES Modules
部分,把它们转换成 CommonJS
的方式。
babel-loader
(低版本)
我们为 Babel
配置的都是一个 preset
(预设插件集合),而不是某些具体的插件。
目前市面上使用最多的 @babel/preset-env
,这个预设里面就有转换 ES Modules
的插件。使用这个预设时,代码中的 ES Modules
部分就会被转换成 CommonJS
方式。Webpack 再去打包时,拿到的就是以 CommonJS
方式组织的代码了,所以 Tree-shaking
不能生效
module.exports = { module: { rules: [ { test: /\.js$/, use: { loader: 'babel-loader', options: { presets: [ ['@babel/preset-env'] ] } } } ] }, optimization: { usedExports: true } } 复制代码
最新版本(8.x)的 babel-loader
自动帮我们关闭了对 ES Modules 转换的插件,经过 babel-loader
处理后的代码默认仍然是 ES Modules
。那 Webpack
最终打包得到的还是 ES Modules
代码。Tree-shaking
自然也就可以正常工作了
最新版本的 babel-loader
并不会导致 Tree-shaking
失效,确保babel-loader
能使用Tree-shaking
。最简单的办法就是在配置中将 @babel/preset-env
的 modules
属性设置为 false
。确保不会转换 ES Modules
,也就确保了 Tree-shaking
的前提
module.exports = { module: { rules: [ { test: /\.js$/, use: { loader: 'babel-loader', options: { presets: [ ['@babel/preset-env', { modules: 'false' }] ] } } } ] }, optimization: { usedExports: true } } 复制代码
Scope Hoisting (作用域提升)
Webpack 3.0 中添加的一个特性,使用 concatenateModules
选项继续优化输出
普通打包只是将一个模块最终放入一个单独的函数中,如果模块很多,就意味着在输出结果中会有很多的模块函数。concatenateModules
配置的作用,尽可能将所有模块合并到一起输出到一个函数中,既提升了运行效率,又减少了代码的体积。
module.exports = { // ... 其他配置项 optimization: { // 模块只导出被使用的成员 usedExports: true, // 尽可能合并每一个模块到一个函数中 concatenateModules: true, } } 复制代码
bundle.js
中就不再是一个模块对应一个函数了,而是把所有的模块都放到了一个函数中
sideEffects
Webpack 4 中新增了一个 sideEffects
特性,允许通过配置标识我们的代码是否有副作用,从而提供更大的压缩空间。
模块的副作用指的就是模块执行的时候除了导出成员,是否还做了其他的事情,特性一般只有去开发一个 npm 模块时才会用到。
Tree-shaking
只能移除没有用到的代码成员,而想要完整移除没有用到的模块,那就需要开启 sideEffects
特性了,在 optimization
中开启 sideEffects
特性
// ./webpack.config.js
module.exports = { mode: 'none', optimization: { sideEffects: true } } 复制代码
这个特性在 production
模式下同样会自动开启
Webpack 缓存优化
利用缓存数据来加速构建过程的处理。
在初次构建的压缩代码过程中,就将这一阶段的结果写入了缓存目录(node_modules/.cache/插件名/
)中有缓存。
当再次构建进行到压缩代码阶段时,即可对比读取已有缓存。
- 编译阶段的缓存优化
- 优化打包阶段的缓存优化
编译阶段的缓存优化
编译过程的耗时点主要在使用不同加载器(Loader)来编译模块的过程
Babel-loader
Babel-loader
是绝大部分项目中会使用到的 JS/JSX/TS
编译器
与缓存相关的设置主要有
cacheDirectory
- 默认为
false
,即不开启缓存 - 当值为
true
时开启缓存并使用默认缓存目录
./node_modules/.cache/babel-loader/
- 也可以指定其他路径值作为缓存目录
cacheIdentifier
- 用于计算缓存标识符
- 默认使用
Babel
相关依赖包的版本babelrc
配置文件的内容- 环境变量
- 与模块内容
- 一起参与计算缓存标识符
cacheCompression
- 默认为 true
- 将缓存内容压缩为 gz 包以减小缓存目录的体积
- 在设为
false
的情况下将跳过压缩和解压的过程,从而提升这一阶段的速度
Cache-loader
在编译过程中利用缓存的第二种方式是使用 --- Cache-loader
在使用时,需要将 cache-loader
添加到对构建效率影响较大的 Loader
(如 babel-loader 等)之前
module: { rules: [ { test: /\.js$/, use: ['cache-loader', 'babel-loader'], }, ], } 复制代码
使用 cache-loader
后,比使用 babel-loader
的开启缓存选项后的构建时间更短
主要原因是 babel-loader
中的缓存信息较少,而 cache-loader
中存储的 Buffer 形式的数据处理效率更高。
优化打包阶段的缓存优化
生成 ChunkAsset
时的缓存优化
在 Webpack 4 中,生成 ChunkAsset
过程中的缓存优化是受限制的:
- 只有在
watch
模式下 - 且配置中开启
cache
时(development 模式下自动开启),才能在这一阶段执行缓存的逻辑
在 Webpack 4 中,缓存插件是基于内存的,只有在 watch
模式下才能在内存中获取到相应的缓存数据对象
代码压缩时的缓存优化
对于 JS 的压缩TerserWebpackPlugin
/UglifyJSPlugin
都是支持缓存设置的。
对于 CSS 的压缩,目前最新发布的 CSSMinimizerWebpackPlugin
支持且默认开启缓存,其他的插件如 OptimizeCSSAssetsPlugin
和 OptimizeCSSNanoPlugin
目前还不支持使用缓存
使用缓存注意点
如何最大程度地让缓存命中,成为我们选择缓存方案后首先要考虑的
缓存标识符发生变化导致的缓存失效,支持缓存的 Loader
和插件中,会根据一些固定字段的值加上所处理的模块或 Chunk 的数据 hash 值来生成对应缓存的标识符。一旦其中的值发生变化,对应缓存标识符就会发生改变,意味着对应工具中,所有之前的缓存都将失效。需要尽可能少地变更会影响到缓存标识符生成的字段
优化打包阶段的缓存失效,尽管在模块编译阶段每个模块是单独执行编译的。但是当进入到代码压缩环节时,各模块已经被组织到了相关联的 Chunk
中,N个模块最后只生成了一个 Chunk。任何一个模块发生变化都会导致整个 Chunk
的内容发生变化,而使之前保存的缓存失效。
优化方案
尽可能地把那些不变的处理成本高昂的模块打入单独的 Chunk 中,Webpack
中的分包配置——splitChunks
。使用 splitChunks
优化缓存利用率。
好处
- 合并通用依赖
- 提升构建缓存利用率
- 提升资源访问的缓存利用率
- 资源懒加载
CI/CD 中的缓存目录问题
自动化集成的系统中,项目的构建空间会在每次构建执行完毕后,立即回收清理。在这种情况下,默认的项目构建缓存目录(node_mo dules/.cache)将无法留存。导致即使项目中开启了缓存设置,也无法享受缓存的便利性,反而因为需要写入缓存文件而浪费额外的时间
如果需要使用缓存,则需要根据对应平台的规范,将缓存设置到公共缓存目录下
缓存的便利性本质在于用磁盘空间换取构建时间,需要考虑对缓存区域的定期清理
后记
分享是一种态度。
参考资料:
- 效率工程化
- Webpack官网
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。