打算系统的整理一下,webpack 的一些知识点,也是时候结合项目中使用的一些 案例,做一些总结了。
webpack系列 打算从 webpack核心功能 -> 常用扩展 -> CSS 工程化 -> JS 兼容性 -> 性能优化 这几个方面开始记录。
以及结合一些案例,方便大家阅读和实践,以备 开箱即用。
仓库地址:PantherVkin/webpack-note (github.com)
性能优化概述
性能优化主要从下面三个维度入手:
- 构建性能
这里所说的构建性能,是指在开发阶段的构建性能,而不是生产环境的构建性能。
优化的目标,是降低从打包开始,到代码效果呈现所经过的时间。
构建性能会影响开发效率。构建性能越高,开发过程中时间的浪费越少。
- 传输性能
传输性能是指,打包后的JS代码传输到浏览器经过的时间。
在优化传输性能时要考虑到:
-
总传输量:所有需要传输的JS文件的内容加起来,就是总传输量,重复代码越少,总传输量越少
-
文件数量:当访问页面时,需要传输的JS文件数量,文件数量越多,http请求越多,响应速度越慢
-
浏览器缓存:JS文件会被浏览器缓存,被缓存的文件不会再进行传输
- 运行性能
运行性能是指,JS代码在浏览器端的运行速度。
它主要取决于我们如何书写高性能的代码。
构建性能
减少模块解析
- 什么叫做模块解析?
模块解析包括:抽象语法树分析、依赖分析、模块语法替换。
- 不做模块解析会怎样?
如果某个模块不做解析,该模块经过loader处理后的代码就是最终代码。
如果没有loader对该模块进行处理,该模块的源码就是最终打包结果的代码。
如果不对某个模块进行解析,可以缩短构建时间。
- 哪些模块不需要解析?
模块中
无其他依赖:一些已经打包好的第三方库,比如jquery。
- 如何让某个模块不要解析?
- 配置
module.noParse,它是一个正则,被正则匹配到的模块不会解析。
module.exports = {
mode: "development",
module: {
noParse: /test/
}
}
- 完整案例
webpack-note/examples/5.1-减少模块解析 at master · PantherVkin/webpack-note (github.com)
优化loader性能
限制loader的应用范围
对于某些库,不使用loader ?
例如:babel-loader可以转换ES6或更高版本的语法,可是有些库本身就是用ES5语法书写的,不需要转换,使用babel-loader反而会浪费构建时间。
lodash就是这样的一个库。
lodash是在ES5之前出现的库,使用的是ES3语法。
- 通过
module.rule.exclude或module.rule.include,排除或仅包含需要应用loader的场景。
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /lodash/,
use: "babel-loader"
}
]
}
}
- 如果暴力一点,甚至可以排除掉
node_modules目录中的模块,或仅转换src目录的模块。
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
//或
// include: /src/,
use: "babel-loader"
}
]
}
}
这种做法是对loader的范围进行进一步的限制,
和noParse不冲突。
缓存loader的结果
我们可以基于一种假设:
如果某个文件内容不变,经过相同的loader解析后,解析后的结果也不变。
于是,可以将loader的解析结果保存下来,让后续的解析直接使用保存的结果。
cache-loader可以实现这样的功能。
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: ['cache-loader', ...loaders]
},
],
},
};
- loader 的
pitch过程
有趣的是,cache-loader放到最前面,却能够决定后续的loader是否运行。
实际上,loader的运行过程中,还包含一个过程,即pitch。
loaderN.pitch有返回值,则把返回的源代码 交给上一个loader。如果没有返回值,继续 下一个
loader(N+1).pitch。
function loader(source) {
return `new source`
}
loader.pitch = function (filePath) {
// 可返回可不返回
// 如果返回,返回源代码
}
module.exports = loader
cache-loader的原理
同上。
- 完整案例
webpack-note/webpack.config.js at master · PantherVkin/webpack-note (github.com)
为loader的运行开启多线程
thread-loader会开启一个线程池,线程池中包含适量的线程。
它会把后续的loader放到线程池的线程中运行,以提高构建效率。
由于后续的loader会放到新的线程中,所以,后续的loader不能:
- 使用 webpack api 生成文件
- 无法使用自定义的 plugin api
- 无法访问 webpack options
在实际的开发中,可以进行测试,来决定thread-loader放到什么位置。
特别注意,开启和管理线程需要消耗时间,在小型项目中使用thread-loader反而会增加构建时间。
热替换 HMR
当使用webpack-dev-server时,考虑代码改动到效果呈现的过程。
而使用了热替换后,流程发生了变化。
热替换并不能降低构建时间(可能还会稍微增加),但可以降低代码改动到效果呈现的时间。
使用和原理
- 更改配置
module.exports = {
devServer:{
hot:true // 开启HMR
},
plugins:[
// 可选
new webpack.HotModuleReplacementPlugin()
]
}
- 更改代码
// index.js
if(module.hot){ // 是否开启了热更新
module.hot.accept() // 接受热更新
}
首先,这段代码会参与最终运行!
当开启了热更新后,webpack-dev-server会向打包结果中注入module.hot属性。
默认情况下,webpack-dev-server不管是否开启了热更新,当重新打包后,都会调用。location.reload刷新页面。
但如果运行了module.hot.accept(),将改变这一行为。
module.hot.accept()的作用是让webpack-dev-server通过socket管道,把服务器更新的内容发送到浏览器。
然后,将结果交给插件HotModuleReplacementPlugin注入的代码执行。
插件HotModuleReplacementPlugin会根据覆盖原始代码,然后让代码重新执行。
所以,热替换发生在代码运行期。
样式热替换
对于样式也是可以使用热替换的,但需要使用style-loader 。
因为热替换发生时,HotModuleReplacementPlugin只会简单的重新运行模块代码。
因此style-loader的代码一运行,就会重新设置style元素中的样式。
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
mode: "development",
devtool: "source-map",
devServer: {
open: true,
hot: true
},
module:{
rules:[
{test:/\.css$/, use:["style-loader", "css-loader"]}
]
},
plugins: [
new HtmlWebpackPlugin({
template: "./public/index.html"
})
]
};
而mini-css-extract-plugin,由于它生成文件是在构建期间,运行期间并会也无法改动文件,因此它对于热替换是无效的。
传输性能
手动分包
基本原理
- 先单独的打包公共模块
暴露出
全局变量。
公共模块会被打包成为动态链接库(dll Dynamic Link Library),并生成资源清单。
- 根据
入口模块进行正常打包
打包时,如果发现模块中使用了资源清单中描述的模块,则不会形成下面的代码结构。
//源码,入口文件index.js
import $ from "jquery"
import _ from "lodash"
_.isArray($(".red"));
由于资源清单中包含jquery和lodash两个模块,因此打包结果的大致格式是:
(function(modules){
//...
})({
// index.js文件的打包结果并没有变化
"./src/index.js":
function(module, exports, __webpack_require__){
var $ = __webpack_require__("./node_modules/jquery/index.js")
var _ = __webpack_require__("./node_modules/lodash/index.js")
_.isArray($(".red"));
},
// 由于资源清单中存在,jquery的代码并不会出现在这里
"./node_modules/jquery/index.js":
function(module, exports, __webpack_require__){
module.exports = jquery;
},
// 由于资源清单中存在,lodash的代码并不会出现在这里
"./node_modules/lodash/index.js":
function(module, exports, __webpack_require__){
module.exports = lodash;
}
})
打包公共模块
打包公共模块是一个独立的打包过程。
webpack.dll.config.js单独打包公共模块,暴露全局变量名
// webpack.dll.config.js
module.exports = {
mode: "production",
entry: {
jquery: ["jquery"],
lodash: ["lodash"]
},
output: {
filename: "dll/[name].js",
library: "[name]" // 每个bundle 暴露的全局变量名
}
};
- 利用
DllPlugin生成资源清单
// webpack.dll.config.js
const webpack = require("webpack");
const path = require("path");
module.exports = {
mode: "production",
entry: {
jquery: ["jquery"],
lodash: ["lodash"]
},
output: {
filename: "dll/[name].js",
library: "[name]" // 每个bundle暴露的全局变量名
},
plugins: [
new webpack.DllPlugin({
path: path.resolve(__dirname, "dll", "[name].manifest.json"),
name: "[name]"
})
]
};
- 运行后,即可完成公共模块打包
$ npx webpack --config webpack.dll.config.js
使用公共模块
根据入口模块进行正常打包。
- 在
index.html页面中手动引入公共模块
或者使用
CDN。
<script src="./dll/jquery.js"></script>
<script src="./dll/lodash.js"></script>
webpack.config.js重新设置clean-webpack-plugin
如果使用了插件clean-webpack-plugin,为了避免它把公共模块清除,需要做出以下配置。
new CleanWebpackPlugin({
// 要清除的文件或目录
// 排除掉dll目录本身和它里面的文件
cleanOnceBeforeBuildPatterns: ["**/*", '!dll', '!dll/*']
})
目录和文件的匹配规则使用的是globbing patterns
webpack.config.js使用DllReferencePlugin控制打包结果
动态链接库引用插件。
发现资源清单中有的模块,不会打包到最终代码,把这个模块忽略掉,直接导出 全局变量。
module.exports = {
plugins:[
new webpack.DllReferencePlugin({
manifest: require("./dll/jquery.manifest.json")
}),
new webpack.DllReferencePlugin({
manifest: require("./dll/lodash.manifest.json")
})
]
}
- 完整案例
webpack-note/examples/5.3-手动分包 at master · PantherVkin/webpack-note (github.com)
总结
手动打包的过程:
-
开启
output.library暴露公共模块 -
用
DllPlugin创建资源清单 -
用
DllReferencePlugin使用资源清单
手动打包的注意事项:
-
资源清单不参与运行,可以不放到打包目录中
-
记得
手动引入公共JS,以及避免被删除 -
不要对小型的公共JS库使用
优点:
- 极大
提升自身模块的打包速度 - 极大的
缩小了自身文件体积 - 有利于
浏览器缓存第三方库的公共代码
缺点:
-
使用非常繁琐
-
如果第三方库中包含重复代码,则效果不太理想
自动分包
基本原理
不同与手动分包,自动分包是从实际的角度出发,从一个更加宏观的角度来控制分包,而一般不对具体哪个包要分出去进行控制。
因此使用自动分包,不仅非常方便,而且更加贴合实际的开发需要。
要控制自动分包,关键是要配置一个合理的分包策略。
有了分包策略之后,不需要额外安装任何插件,webpack会自动的按照策略进行分包。
实际上,webpack在内部是使用SplitChunksPlugin进行分包的。
从分包流程中至少可以看出以下几点:
-
分包策略至关重要,它决定了如何分包
-
分包时,webpack开启了一个新的chunk,对
分离的模块进行打包 -
打包结果中,公共的部分被提取出来形成了一个单独的文件,它是新chunk的产物
分包策略的配置
webpack提供了optimization配置项,用于配置一些优化信息。
其中splitChunks是分包策略的配置。
module.exports = {
optimization: {
splitChunks: {
// 分包策略
}
}
}
事实上,分包策略有其默认的配置,我们只需要轻微的改动,即可应对大部分分包场景。
chunks
该配置项用于配置需要应用分包策略的chunk。
我们知道,分包是从已有的chunk中分离出新的chunk,那么哪些chunk需要分离呢。
chunks有三个取值,分别是:
all: 对于所有的chunk都要应用分包策略async:【默认】仅针对异步chunk应用分包策略initial:仅针对普通chunk应用分包策略
所以,你只需要配置chunks为all即可。
maxSize
该配置可以控制包的最大字节数。
如果某个包(包括分出来的包)超过了该值,则webpack会尽可能的将其分离成多个包。
但是不要忽略的是,分包的基础单位是模块,如果一个完整的模块超过了该体积,它是无法做到再切割的,因此,尽管使用了这个配置,完全有可能某个包还是会超过这个体积。
另外,该配置看上去很美妙,实际意义其实不大。
因为分包的目的是提取大量的公共代码,从而减少总体积和充分利用浏览器缓存。
虽然该配置可以把一些包进行再切分,但是实际的总体积和传输量并没有发生变化。
如果要进一步减少公共模块的体积,只能是压缩和
tree shaking。
- 分包策略的其他配置
如果不想使用其他配置的默认值,可以手动进行配置:
automaticNameDelimiter:新chunk名称的分隔符,默认值~minChunks:一个模块被多少个chunk使用时,才会进行分包,默认值1minSize:当分包达到多少字节后才允许被真正的拆分,默认值30000
缓存组
之前配置的分包策略是全局的。
而实际上,分包策略是基于缓存组的。
每个缓存组提供一套独有的策略,webpack按照缓存组的优先级依次处理每个缓存组,被缓存组处理过的分包不需要再次分包。
默认情况下,webpack提供了两个缓存组:
module.exports = {
optimization:{
splitChunks: {
//全局配置
cacheGroups: {
// 属性名是缓存组名称,会影响到分包的chunk名
// 属性值是缓存组的配置,缓存组继承所有的全局配置,也有自己特殊的配置
vendors: {
test: /[\\/]node_modules[\\/]/, // 当匹配到相应模块时,将这些模块进行单独打包
priority: -10 // 缓存组优先级,优先级越高,该策略越先进行处理,默认值为0
},
default: {
minChunks: 2, // 覆盖全局配置,将最小chunk引用数改为2
priority: -20, // 优先级
reuseExistingChunk: true // 重用已经被分离出去的chunk
}
}
}
}
}
很多时候,缓存组对于我们来说没什么意义,因为默认的缓存组就已经够用了。
但是我们同样可以利用缓存组来完成一些事情,比如对公共样式的抽离。
module.exports = {
optimization: {
splitChunks: {
chunks: "all",
cacheGroups: {
styles: {
test: /\.css$/, // 匹配样式模块
minSize: 0, // 覆盖默认的最小尺寸,这里仅仅是作为测试
minChunks: 2 // 覆盖默认的最小chunk引用数
}
}
}
},
module: {
rules: [{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, "css-loader"] }]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
template: "./public/index.html",
chunks: ["index"]
}),
new MiniCssExtractPlugin({
filename: "[name].[hash:5].css",
// chunkFilename是配置来自于分割chunk的文件名。
chunkFilename: "common.[hash:5].css"
})
]
}
- 完整案例
webpack-note/examples/5.4-自动分包 at master · PantherVkin/webpack-note (github.com)
配合多页应用
虽然现在单页应用是主流,但免不了还是会遇到多页应用。
由于在多页应用中需要为每个html页面指定需要的chunk,这就造成了问题。
new HtmlWebpackPlugin({
template: "./public/index.html",
chunks: ["index~other", "vendors~index~other", "index"]
})
我们必须手动的指定被分离出去的chunk名称,这不是一种好办法。
幸好html-webpack-plugin的新版本中解决了这一问题。
$ npm i -D html-webpack-plugin@next
做出以下配置即可:
new HtmlWebpackPlugin({
template: "./public/index.html",
chunks: ["index"]
})
它会自动的找到被index分离出去的chunk,并完成引用。
目前这个版本仍处于测试解决,还未正式发布。
原理
自动分包的原理其实并不复杂,主要经过以下步骤:
- 检查每个chunk编译的结果
- 根据
分包策略,找到那些满足策略的模块 - 根据
分包策略,生成新的chunk打包这些模块(代码有所变化) - 把
打包出去的模块从原始包中移除,并修正原始包代码
在代码层面,有以下变动
- 分包的代码中,加入一个全局变量,类型为数组,其中包含公共模块的代码
- 原始包的代码中,使用数组中的公共代码
代码压缩
- 为什么要进行代码压缩?
减少代码体积;破坏代码的可读性,提升破解成本。
- 什么时候要进行代码压缩?
生产环境。
- 使用什么压缩工具?
目前最流行的代码压缩工具主要有两个:UglifyJs和Terser。
UglifyJs是一个传统的代码压缩工具,已存在多年,曾经是前端应用的必备工具,但由于它不支持ES6语法,所以目前的流行度已有所下降。
Terser是一个新起的代码压缩工具,支持ES6+语法,因此被很多构建工具内置使用。webpack安装后会内置Terser,当启用生产环境后即可用其进行代码压缩。
Terser
在Terser的官网可尝试它的压缩效果。
Terser官网:terser.org/
webpack+Terser
webpack自动集成了Terser。
如果你想更改、添加压缩工具,又或者是想对Terser进行配置,使用下面的webpack配置即可。
const TerserPlugin = require('terser-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = {
optimization: {
// 是否要启用压缩,默认情况下,生产环境会自动开启
minimize: true,
minimizer: [ // 压缩时使用的插件,可以有多个
new TerserPlugin(),
new OptimizeCSSAssetsPlugin()
],
},
};
tree shaking
压缩可以移除模块内部的无效代码。
tree shaking 可以移除模块之间的无效代码。
- 背景?
某些模块导出的代码并不一定会被用到。
// myMath.js
export function add(a, b){
console.log("add")
return a+b;
}
export function sub(a, b){
console.log("sub")
return a-b;
}
// index.js
import {add} from "./myMath"
console.log(add(1,2));
tree shaking 用于移除掉不会用到的导出。
- 使用
webpack2开始就支持了tree shaking。
只要是生产环境,tree shaking自动开启。
原理
webpack会从入口模块出发寻找依赖关系。
当解析一个模块时,webpack会根据ES6的模块导入语句来判断,该模块依赖了另一个模块的哪个导出。
webpack之所以选择ES6的模块导入语句,是因为ES6模块有以下特点:
- 导入导出语句只能是顶层语句
- import的模块名只能是字符串常量
- import绑定的变量是不可变的
这些特征都非常有利于分析出稳定的依赖。
在具体分析依赖时,webpack坚持的原则是:保证代码正常运行,然后再尽量tree shaking。
所以,如果你依赖的是一个导出的对象,由于JS语言的动态特性,以及webpack还不够智能,为了保证代码正常运行,它不会移除对象中的任何信息。
因此,我们在编写代码的时候,尽量:
- 使用
export xxx导出,而不使用export default {xxx}导出 - 使用
import {xxx} from "xxx"导入,而不使用import xxx from "xxx"导入
依赖分析完毕后,webpack会根据每个模块每个导出是否被使用,标记其他导出为dead code,然后交给代码压缩工具处理。
代码压缩工具最终移除掉那些dead code代码。
使用第三方库
某些第三方库可能使用的是commonjs的方式导出,比如lodash。
又或者没有提供普通的ES6方式导出。
对于这些库,tree shaking是无法发挥作用的。
因此要寻找这些库的es6版本,好在很多流行但没有使用的ES6的第三方库,都发布了它的ES6版本,比如lodash-es。
作用域分析
tree shaking本身并没有完善的作用域分析,可能导致在一些dead code函数中的依赖仍然会被视为依赖。
插件webpack-deep-scope-plugin(个人开发的)提供了作用域分析,可解决这些问题。
副作用问题
webpack在tree shaking的使用,有一个原则:一定要保证代码正确运行 。
在满足该原则的基础上,再来决定如何tree shaking 。
因此,当webpack无法确定某个模块是否有副作用时,它往往将其视为有副作用 。
因此,某些情况可能并不是我们所想要的 。
//common.js
var n = Math.random();
//index.js
import "./common.js"
虽然我们根本没用有common.js的导出,但webpack担心common.js有副作用,如果去掉会影响某些功能。
如果要解决该问题,就需要标记该文件是没有副作用的。
在package.json中加入sideEffects。
{
"sideEffects": false
}
有两种配置方式:
- false
当前工程中,所有模块都没有副作用。
注意,这种写法会影响到某些css文件的导入。
- 数组
设置哪些文件拥有副作用,例如:
["!src/common.js"],表示只要不是src/common.js的文件,都有副作用。这种方式我们一般不处理,通常是一些第三方库在它们自己的
package.json中标注。
css tree shaking
webpack无法对css完成tree shaking,因为css跟es6没有半毛钱关系。
因此对css的tree shaking需要其他插件完成。
例如:purgecss-webpack-plugin。
注意:
purgecss-webpack-plugin对css module无能为力。
懒加载
- 案例
点击之后加载。
const btn = document.querySelector('button')
btn.onclick = async function () {
//动态加载
//import 是ES6的草案
//浏览器会使用JSOP的方式远程去读取一个js模块
//import()会返回一个promise (* as obj)
// const { chunk } = await import(/* webpackChunkName:"lodash" */"lodash-es");
const {chunk} = await import('./util')
const result = chunk([3, 5, 6, 7, 87], 2)
console.log(result)
}
- 原理
点击后把远程的模块放到 webpackJsonp 数组中,这样主模块就能使用了。
- 完整案例
webpack-note/examples/5.5-懒加载 at master · PantherVkin/webpack-note (github.com)
gzip
gzip是一种压缩文件的算法。
- B/S结构中的压缩传输
优点:传输效率可能得到大幅提升
缺点:服务器的压缩需要时间,客户端的解压需要时间
- 使用webpack进行预压缩
使用compression-webpack-plugin插件对打包结果进行预压缩,可以移除服务器的压缩时间。
const {CleanWebpackPlugin} = require('clean-webpack-plugin')
constCmpressionWebpackPlugin = require('compression-webpack-plugin')
module.exports = {
mode: 'production',
optimization: {
splitChunks: {
chunks: 'all'
}
},
plugins: [
newCleanWebpackPlugin(),
newCmpressionWebpackPlugin({
test: /\.js/,
minRatio: 0.5
})
]
}
打包结果分析
- 安装
$ npm i -D webpack-bundle-analyzer
webpack.config.js
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const WebpackBundleAnalyzer = require("webpack-bundle-analyzer")
.BundleAnalyzerPlugin;
module.exports = {
mode: "production",
optimization: {
splitChunks: {
chunks: "all"
}
},
plugins: [new CleanWebpackPlugin(), new WebpackBundleAnalyzer()]
};