手动分包
基本原理
手动分包的总体思路是:先打包公共模块,然后再打包业务代码。
打包公共模块
公共模块会被打包成为动态链接库(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;
}
})
打包公共模块
打包公共模块是一个独立的打包过程
package.json
{
"name": "webpack",
"version": "1.0.0",
"private": true,
"scripts": {
"dll": "webpack --config webpack.dll.config.js",
"build": "webpack"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"html-webpack-plugin": "^5.6.3",
"webpack": "^5.96.1",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"jquery": "^3.7.1",
"lodash": "^4.17.21"
}
}
webpack.dll.config.js
const webpack = require("webpack")
const path = require("path")
module.exports = {
mode: "production",
entry: {
lodash: "lodash",
jquery: "jquery",
},
output: {
filename: "dll/[name].js", // 不需要用[hash]
library: "[name]", // 导出的全局变量名
},
plugins: [
new webpack.DllPlugin({
path: path.resolve(__dirname, "dll", "[name].manifest.json"), // 资源清单的保存位置
name: "[name]" // 资源清单中,暴露的变量名
})
]
}
运行 npm run dll
命令,可以看到在根目录下生成了 dll
目录,目录下有 lodash 和 jquery 的资源清单文件;同时在 dist/dll
目录下也可以看到打包好的 lodash 和 jquery 文件。
打包业务代码
package.json
{
"name": "webpack",
"version": "1.0.0",
"private": true,
"scripts": {
"dll": "webpack --config webpack.dll.config.js",
"build": "webpack"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"html-webpack-plugin": "^5.6.3",
"webpack": "^5.96.1",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"jquery": "^3.7.1",
"lodash": "^4.17.21"
}
}
webpack.config.js
const path = require("path")
const webpack = require("webpack")
const HtmlWebpackPlugin = require("html-webpack-plugin")
module.exports = {
mode: "development",
context: path.resolve(__dirname, "src"),
entry: {
index: "./index"
},
output: {
filename: "[name].[contenthash:5].js",
clean: {
keep: /dll\//, // 保留 'dll/' 下的资源
},
},
plugins: [
new HtmlWebpackPlugin({
title: "webpack-手动分包",
}),
new webpack.DllReferencePlugin({
manifest: require("./dll/jquery.manifest.json"), // 应用 jquery 的资源清单
}),
new webpack.DllReferencePlugin({
manifest: require("./dll/lodash.manifest.json"), // 应用 lodash 的资源清单
}),
],
}
index.js
import $ from "jquery"
import _ from "lodash"
console.log($);
console.log(_);
运行 npm run build
命令,发现打包的文件中还是包含了 lodash 和 jquery,这是为什么呢?检查打包的文件发现:
dist/index.06f97.js
(function(modules){
//...
})({
"./index.js": (
__unused_webpack_module,
__webpack_exports__,
__webpack_require__
) => {
// ...
},
"../node_modules/jquery/dist/jquery.js": function (module, exports) {
// ...
},
"../node_modules/lodash/lodash.js": function (module, exports, __webpack_require__) {
// ...
}
})
发现 ../node_modules/jquery/dist/jquery.js
、../node_modules/lodash/lodash.js
与
dll/jquery.manifest.json
{
"name": "jquery",
"content": {
"./node_modules/jquery/dist/jquery.js": { "id": 692, "buildMeta": {} }
}
}
dll/lodash.manifest.json
{
"name": "lodash",
"content": {
"./node_modules/lodash/lodash.js": { "id": 543, "buildMeta": {} }
}
}
中的 ./node_modules/jquery/dist/jquery.js
、./node_modules/lodash/lodash.js
对不上,这是因为使用了 context
导致的,更改 webpack.config.js
文件:
const path = require("path")
const webpack = require("webpack")
const HtmlWebpackPlugin = require("html-webpack-plugin")
module.exports = {
// ...
// context: path.resolve(__dirname, "src"),
entry: {
index: "./src/index"
}
// ...
}
再次运行 npm run build
命令,发现结果文件中已不再包含 lodash 和 jquery。
使用公共模块
webpack.config.js
const path = require("path")
const webpack = require("webpack")
const HtmlWebpackPlugin = require("html-webpack-plugin")
module.exports = {
mode: "development",
// context: path.resolve(__dirname, "src"),
entry: {
index: "./src/index"
},
output: {
filename: "[name].[contenthash:5].js",
clean: {
keep: /dll\//, // 保留 'dll/' 下的资源
},
},
plugins: [
new HtmlWebpackPlugin({
title: "webpack-手动分包",
template: "./index.ejs", // 使用 ejs 模板语法
templateParameters: {
scripts: ["./dll/jquery.js", "./dll/lodash.js"], // 将公共模块插入页面
}
}),
new webpack.DllReferencePlugin({
manifest: require("./dll/jquery.manifest.json"), // 应用 jquery 的资源清单
}),
new webpack.DllReferencePlugin({
manifest: require("./dll/lodash.manifest.json"), // 应用 lodash 的资源清单
}),
],
}
index.ejs
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<% if (scripts) { %>
<% for (item of scripts) { %>
<script src="<%= item %>"></script>
<% } %>
<% } %>
</head>
<body></body>
</html>
总结
手动打包的过程:
- 开启
output.library
暴露公共模块 - 用
DllPlugin
创建资源清单 - 用
DllReferencePlugin
使用资源清单
手动打包的注意事项:
- 资源清单不参与运行,可以不放到打包目录中。
- 记得手动引入公共 JS,以及避免被删除。
- 不要对小型的公共 JS 库使用。
优点:
- 极大提升自身模块的打包速度。
- 极大的缩小了自身文件体积。
- 有利于浏览器缓存第三方库的公共代码。
缺点:
- 使用非常繁琐。
- 如果第三方库中都导入了相同的依赖,则第三方库打包后都会包含重复的代码,效果不太理想。
自动分包
基本原理
不同与手动分包,自动分包是从实际的角度出发,从一个更加宏观的角度来控制分包,而一般不对具体哪个包要分出去进行控制。
因此使用自动分包,不仅非常方便,而且更加贴合实际的开发需要。
要控制自动分包,关键是要配置一个合理的分包策略。
有了分包策略之后,不需要额外安装任何插件,webpack 会自动的按照策略进行分包。
实际上,webpack 在内部是使用
SplitChunksPlugin
进行分包的,过去有一个库CommonsChunkPlugin
也可以实现分包,不过由于该库某些地方并不完善,到了webpack4
之后,已被SplitChunksPlugin
取代。
从分包流程中至少可以看出以下几点:
- 分包策略至关重要,它决定了如何分包。
- 分包时,webpack 开启了一个新的 chunk,对分离的模块进行打包。
- 打包结果中,公共的部分被提取出来形成了一个单独的文件,它是新 chunk 的产物。
分包策略的基本配置
webpack 提供了 optimization
配置项,用于配置一些优化信息,其中 splitChunks
是分包策略的配置
module.exports = {
optimization: {
splitChunks: {
// 分包策略
}
}
}
默认值
开箱即用的 SplitChunksPlugin
对于大部分用户来说非常友好。
默认情况下,它只会影响到按需加载的 chunks(动态导入的模块),因为修改 initial chunks 会影响到项目的 HTML 文件中的脚本标签。
webpack 将根据以下默认条件自动拆分 chunks:
- 新的 chunk 可以被共享,或者模块来自于
node_modules
文件夹 - 新的 chunk 体积大于 20kb(在进行 min+gz 之前的体积)
- 当按需加载 chunks 时,并行请求的最大数量小于或等于 30
- 当加载初始化页面时,并发请求的最大数量小于或等于 30
当尝试满足最后两个条件时,最好使用较大的 chunks。
splitChunks.chunks
类型:string | function (chunk)
,默认值:async
-
'all'
对于所有的 chunk 都要应用分包策略。
-
'async'
仅针对异步 chunk 应用分包策略。
-
'initial'
仅针对普通 chunk 应用分包策略。
-
function (chunk)
函数返回
boolean
值,决定是否对该 chunk 进行分包。webpack.config.js
const path = require("path") const HtmlWebpackPlugin = require("html-webpack-plugin") module.exports = { mode: "production", context: path.resolve(__dirname, "src"), entry: { index: "./index", main: "./main", }, output: { filename: "[name].[contenthash:5].js", clean: true, }, plugins: [ new HtmlWebpackPlugin({ title: "webpack-自动分包", }), ], optimization: { splitChunks: { chunks(chunk) { return true // 都返回 true 相当于 'all' }, }, }, }
splitChunks.minChunks
类型:number
,默认值:1
模块被 minChunks
个 chunk 共享才需要拆分成 chunk,优先级可能受其他设置影响。
splitChunks.minSize
类型:number
,默认值:20000
生成 chunk 的最小体积(以 bytes 为单位),超过这个体积会被拆分。
可以看到 index.js + jquery 大小是 279 KiB,没有达到 400000 个字节,没有被拆分;而 main.js + lodash 大小是 531 KiB,超过 400000 个字节,所以被拆分。
splitChunks.minSizeReduction
类型:number
如果分割成一个 chunk 并没有减少主 chunk(bundle)的给定的 minSizeReduction
字节数,它将不会被分割,即使它满足 splitChunks.minSize
。
为了生成 chunk,
splitChunks.minSizeReduction
与splitChunks.minSize
都需要被满足。
可以看到 index.js 如果拆分体积可以减少 279 KiB,没有达到 400000 个字节,即使达到 minSize
,也没有拆分;再看 main.js 如果拆分体积可以减少 531 KiB,所以被拆分。
splitChunks.maxSize
类型:number
,默认值: 0
使用 maxSize
告诉 webpack 尝试将大于 maxSize
个字节的 chunk 分割成较小的部分。 这些较小的部分在体积上至少为 minSize
(仅次于 maxSize
)。maxSize
只是一个提示,当模块大于 maxSize
或者拆分不符合 minSize
时可能会被违反。
maxSize
比maxInitialRequest/maxAsyncRequests
具有更高的优先级。实际优先级是maxInitialRequest/maxAsyncRequests < maxSize < minSize
。
设置
maxSize
的值会同时设置maxAsyncSize
和maxInitialSize
的值。
splitChunks.cacheGroups
类型:object
缓存组可以继承和/或覆盖来自 splitChunks.*
的任何选项。但是 test
、priority
和 reuseExistingChunk
只能在缓存组级别上进行配置。将它们设置为 false
以禁用任何默认缓存组。splitChunks
自带两个缓存组:
module.exports = {
//...
optimization: {
splitChunks: {
//...
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
reuseExistingChunk: true,
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
};
cacheGroups.{cacheGroup}.priority
类型:number
,默认值:-20
一个模块可以属于多个缓存组。优化将优先考虑具有更高 priority
(优先级)的缓存组。默认组的优先级为负,以允许自定义组获得更高的优先级(自定义组的默认值为 0
)。
cacheGroups.{cacheGroup}.reuseExistingChunk
类型:boolean
,默认值:true
如果当前 chunk 包含已经从 main bundle 中分离出来的模块,它将被重用,而不是生成新的 module。这可能会影响块的结果文件名。
cacheGroups.{cacheGroup}.type
类型:function|RegExp|string
允许按模块类型将模块分配给缓存组处理。
webpack.config.js
module.exports = {
//...
optimization: {
splitChunks: {
cacheGroups: {
json: {
type: 'json',
},
},
},
},
};
cacheGroups.{cacheGroup}.test
类型:function (module, { chunkGraph, moduleGraph }) => boolean | RegExp | string
控制此缓存组选择的模块。省略它会选择所有模块。它可以匹配绝对模块资源路径或 chunk 名称。匹配 chunk 名称时,将选择 chunk 中的所有模块。
cacheGroups.{cacheGroup}.filename
类型:string | function (pathData, assetInfo) => string
仅在初始 chunk 时才允许覆盖文件名。 也可以使用 output.filename
中的所有占位符。
cacheGroups.{cacheGroup}.enforce
类型:boolean
,默认值:false
告诉 webpack 忽略 splitChunks.minSize
、splitChunks.minChunks
、splitChunks.maxAsyncRequests
和 splitChunks.maxInitialRequests
选项,并始终为此缓存组创建 chunk。
示例
首先我们先按以下图片初始化项目
然后打包,可以看到在没有使用缓存组去提取 CSS 代码,只用 MiniCssExtractPlugin
插件去分离 CSS 代码,公共代码会被重复引用:
然后我们修改 webpack.config.js
使用缓存组去提取公共代码:
可以看到公共的 CSS 已经被拆分成独立的文件:
原理
自动分包的原理其实并不复杂,主要经过以下步骤:
- 检查每个 chunk 编译的结果
- 根据分包策略,找到那些满足策略的模块
- 根据分包策略,生成新的 chunk 打包这些模块(代码有所变化)
- 把打包出去的模块从原始包中移除,并修正原始包代码
在代码层面,有以下变动
- 分包的代码中,加入一个全局变量,类型为数组,其中包含公共模块的代码
- 原始包的代码中,使用数组中的公共代码
代码压缩
为什么要进行代码压缩
减少代码体积;破坏代码的可读性,提升破解成本;
什么时候要进行代码压缩
生产环境
使用什么压缩工具
目前最流行的代码压缩工具主要有两个:UglifyJs
和 Terser
。
UglifyJs
是一个传统的代码压缩工具,已存在多年,曾经是前端应用的必备工具,但由于它不支持 ES6
语法,所以目前的流行度已有所下降。
Terser
是一个新起的代码压缩工具,支持 ES6+
语法,因此被很多构建工具内置使用。webpack
安装后会内置 Terser
,当启用生产环境后即可用其进行代码压缩。
因此,我们选择 Terser
。
关于副作用 side effect
副作用:函数运行过程中,可能会对外部环境造成影响的功能
如果函数中包含以下代码,该函数叫做副作用函数:
- 异步代码
- localStorage
- 对外部数据的修改
如果一个函数没有副作用,同时,函数的返回结果仅依赖参数,则该函数叫做纯函数(pure function)
Terser
在 Terser
的官网可尝试它的压缩效果,Terser官网:https://terser.org/
我们可以通过 /*#__PURE__*/
注释来帮助 terser。这个注释的作用是标记此语句没有副作用。这样一个简单的改变就能够 tree-shake 下面的代码了:
var Button$1 = /*#__PURE__*/ withAppProvider()(Button);
这将允许删除这段代码。但是除此之外,引入的内容可能仍然存在副作用的问题,因此需要对其进入评估。
为了解决这个问题,我们需要在 package.json
中添加 "sideEffects"
属性。
它与 /*#__PURE__*/
类似,但是作用于模块层面,而非代码语句的层面。"sideEffects"
属性的意思是:“如果没有使用被标记为无副作用的模块的直接导出,那么捆绑器会跳过对此模块的副作用评估”。
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
。
可以看到使用 commonjs 方式导出的 lodash ,tree shaking 并不起作用;而使用 esmodule 导出的 lodash-es,并且使用 import {xxx} from "xxx"
方式导入,tree shaking 就会删除 dead code ,极大的减少代码体积:
作用域分析
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
无能为力。
动态导入
webpack 提供了 ECMAScript 提案的 import()
语法实现动态导入。让我们先尝试以下。
打开 dist/index.html
打开控制台,然后点击按钮,可以看到 lodash 通过网络实现了动态加载;并且更重要的是 tree shaking 起作用了,lodash 代码被极大的压缩了。
预获取/预加载模块
Webpack v4.6.0+ 增加了对预获取(prefetch)和预加载(preload)的支持。
在声明 import
时,使用下面这些内置指令,可以让 webpack 输出“resource hint”,来告知浏览器:
- prefetch(预获取):将来某些导航下可能需要的资源
- preload(预加载):当前导航下可能需要资源
预获取
下面这个预获取的简单示例中,有一个按钮,然后在点击后按需加载 lodash,只需在 import()
中添加 /* webpackPrefetch: true */
即可。
打包完成后。打开 dist/index.html
页面,在控制台中可以看到在父 chunk 完成加载时,会将 <link rel="prefetch" as="script" href="http://127.0.0.1:5500/dist/185.34d25.js">
追加到页面头部,指示浏览器在闲置时间预获取 185.34d25.js
文件。
预加载
想要使用预加载,只需在 import()
中添加 /* webpackPreload: true */
即可。
预获取与预加载的区别
- 预加载 chunk 会在父 chunk 加载时,以并行方式开始加载。预获取 chunk 会在父 chunk 加载结束后开始加载。
- 预加载 chunk 具有中等优先级,并立即下载。预获取 chunk 在浏览器闲置时下载。
- 预加载 chunk 会在父 chunk 中立即请求,用于当下时刻。预获取 chunk 会用于未来的某个时刻。
- 浏览器支持程度不同。
gzip
gzip 是一种压缩文件的算法
B/S结构中的压缩传输
优点:传输效率可能得到大幅提升
缺点:服务器的压缩需要时间,客户端的解压需要时间
使用 webpack 进行预压缩
使用 compression-webpack-plugin
插件对打包结果进行预压缩,可以移除服务器的压缩时间。
安装
npm i -D compression-webpack-plugin
使用
const HtmlWebpackPlugin = require("html-webpack-plugin")
const CompressionWebpackPlugin = require('compression-webpack-plugin')
module.exports = {
mode: "production",
entry: { index: "./src/index" },
output: {
filename: "[name].[contenthash:5].js",
clean: true,
},
plugins: [
new HtmlWebpackPlugin({
title: "webpack-treeShaking",
template: "./index.html"
}),
new CompressionWebpackPlugin()
]
}
更多关于 compression-webpack-plugin
的配置信心请查看文档。
输效率可能得到大幅提升
缺点:服务器的压缩需要时间,客户端的解压需要时间
使用 webpack 进行预压缩
使用 compression-webpack-plugin
插件对打包结果进行预压缩,可以移除服务器的压缩时间。
[外链图片转存中…(img-SWI0oElW-1738589697670)]
安装
npm i -D compression-webpack-plugin
使用
const HtmlWebpackPlugin = require("html-webpack-plugin")
const CompressionWebpackPlugin = require('compression-webpack-plugin')
module.exports = {
mode: "production",
entry: { index: "./src/index" },
output: {
filename: "[name].[contenthash:5].js",
clean: true,
},
plugins: [
new HtmlWebpackPlugin({
title: "webpack-treeShaking",
template: "./index.html"
}),
new CompressionWebpackPlugin()
]
}
更多关于 compression-webpack-plugin
的配置信心请查看文档。