动态加载,减少首屏资源加载量
Webpack 默认会将同一个 Entry 下的所有模块全部打包成一个产物文件 —— 包括那些与页面关键渲染路径无关的代码,从而影响首屏渲染性能。例如:
import someBigMethod from "./someBigMethod";
document.getElementById("someButton").addEventListener("click", () => {
someBigMethod();
});
逻辑上,直到点击页面的 someButton 按钮时才会调用 someBigMethod 方法,因此这部分代码没必要出现在首屏资源列表中,此时我们可以使用 Webpack 的动态加载功能将该模块更改为异步导入,修改上述代码:
document.getElementById("someButton").addEventListener("click", async () => {
// 使用 `import("module")` 动态加载模块
const someBigMethod = await import("./someBigMethod");
someBigMethod();
});
动态加载有几个副作用:
- 让产物文件变多,在 HTTP1.x 环境下请求次数变多
- 在客户端注入体积为 2.5K 的用于支持动态加载特性的 Runtime
因此,多数情况下我们没必要为小模块使用动态加载能力。以下是 Vue 项目下的页面级别的动态加载。
import { createRouter, createWebHashHistory } from "vue-router";
const Home = () => import("./Home.vue");
const Foo = () => import(/* webpackChunkName: "sub-pages" */ "./Foo.vue");
const Bar = () => import(/* webpackChunkName: "sub-pages" */ "./Bar.vue");
// 基础页面
const routes = [
{ path: "/bar", name: "Bar", component: Bar },
{ path: "/foo", name: "Foo", component: Foo },
{ path: "/", name: "Home", component: Home },
];
const router = createRouter({
history: createWebHashHistory(),
routes,
});
export default router;
webpackChunkName 用于指定该异步模块的 Chunk 名称,相同 Chunk 名称的模块最终会打包在一起,这一特性能帮助开发者将一些关联度较高,或比较细碎的模块合并到同一个产物文件,能够用于管理最终产物数量。
使用 Hash 名称,利用 HTTP 缓存优化,减少应用体积
提示:Hash 是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数,不同明文计算出的摘要值不同,所以常常被用作内容唯一标识。
Webpack 提供了一种模板字符串(Template String)能力,用于根据构建情况动态拼接产物文件名称(output.filename),规则稍微有点复杂,但从性能角度看,比较值得关注的是其中的几个 Hash 占位符,包括:
[fullhash]:整个项目的内容 Hash 值,项目中任意模块变化都会产生新的fullhash;[chunkhash]:产物对应 Chunk 的 Hash,Chunk 中任意模块变化都会产生新的chunkhash;[contenthash]:产物内容 Hash 值,仅当产物内容发生变化时才会产生新的contenthash,因此实用性较高。
用法很简单,只需要在 output.filename 值中插入相应占位符即可,如 "[name]-[contenthash].js"。我们来看个完整例子,假设对于下述源码结构:
src/
├── index.css
├── index.js
└── foo.js
之后,使用下述配置:
module.exports = {
// ...
entry: { index: "./src/index.js", foo: "./src/foo.js" },
output: {
filename: "[name]-[contenthash].js",
path: path.resolve(__dirname, "dist"),
},
plugins: [new MiniCssExtractPlugin({ filename: "[name]-[contenthash].css" })],
};
提示:也可以通过占位符传入 Hash 位数,如
[contenthash:7],即可限定生成的 Hash 长度。
可以看到每个产物文件名都会带上一段由产物内容计算出的唯一 Hash 值,文件内容不变,Hash 也不会变化,这就很适合用作 HTTP 持久缓存 资源:
# HTTP Response header
Cache-Control: max-age=31536000
此时,产物文件不会被重复下载,一直到文件内容发生变化,引起 Hash 变化生成不同 URL 路径之后,才需要请求新的资源文件,能有效提升网络性能,因此,生产环境下应尽量使用 [contenthash] 生成有版本意义的文件名。
注意,异步模块的变更会影响主模块名称的 hash 值。
理论上讲,异步模块和主模块已经拆包,内容不会相互影响,但是主模块是通过地址值找到异步模块。当异步模块变更时,异步模块的地址值也更新了,进而导致主模块的内容发生变化,而主模块的 Hash 名称又跟内容强相关,最后导致主模块名称的 Hash 值也发生了变化。
此时可以用 optimization.runtimeChunk将异步模块相关代码抽取出单独的 Runtime Chunk,这样做之后,异步模块的变更只会影响 Runtime Chunk 内容,不再影响主模块。
module.exports = {
entry: { index: "./src/index.js" },
mode: "development",
devtool: false,
output: {
filename: "[name]-[contenthash].js",
path: path.resolve(__dirname, "dist")
},
// 将运行时代码抽取到 `runtime` 文件中
optimization: { runtimeChunk: { name: "runtime" } },
};
使用外置依赖 externals,节省打包流量
externals 的主要作用是将部分模块排除在 Webpack 打包系统之外,例如:
module.exports = {
// ...
externals: {
react: "React",
lodash: "_",
},
plugins: [
new HtmlWebpackPlugin({
templateContent: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Webpack App</title>
<script defer crossorigin src="//unpkg.com/react@18/umd/react.development.js"></script>
<script defer crossorigin src="//unpkg.com/lodash@4.17.21/lodash.min.js"></script>
</head>
<body>
<div id="app" />
</body>
</html>
`,
}),
],
};
示例中,externals 声明了 react 与 lodash 两个外置依赖,并在后续的 html-webpack-plugin 模板中注入这两个模块的 CDN 引用,以此构成完整 Web 应用。
虽然结果上看浏览器还是得消耗这部分流量,但结合 CDN 系统特性,一是能够就近获取资源,缩短网络通讯链路;二是能够将资源分发任务前置到节点服务器,减轻原服务器 QPS 负担;三是用户访问不同站点能共享同一份 CDN 资源副本。所以网络性能效果往往会比重复打包好很多。
使用 Tree-Shaking 删除多余模块导出
Tree-Shaking 是基于 ES Module 规范的 Dead Code Elimination 技术,它会在运行过程中静态分析模块之间的导入导出,判断哪些模块导出值没有被其它模块使用 —— 相当于模块层面的 Dead Code,并将其删除。
在 Webpack 中,启动 Tree Shaking 功能必须同时满足两个条件:
-
配置
optimization.usedExports为true,标记模块导入导出列表; -
启动代码优化功能,可以通过如下方式实现:
- 配置
mode = production - 配置
optimization.minimize = true - 提供
optimization.minimizer数组
- 配置
使用 Scope Hoistring 合并模块
默认情况下 Webpack 会将模块打包成一个个单独的函数,Scope Hoisting 用于 将符合条件的多个模块合并到同一个函数空间 中,从而减少产物体积,优化性能。
Webpack 提供了三种开启 Scope Hoisting 的方法:
- 使用
mode = 'production'开启生产模式; - 使用
optimization.concatenateModules配置项; - 直接使用
ModuleConcatenationPlugin插件。
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');
module.exports = {
// 方法1: 将 `mode` 设置为 production,即可开启
mode: "production",
// 方法2: 将 `optimization.concatenateModules` 设置为 true
optimization: {
concatenateModules: true,
usedExports: true,
providedExports: true,
},
// 方法3: 直接使用 `ModuleConcatenationPlugin` 插件
plugins: [new ModuleConcatenationPlugin()]
};
三种方法最终都会调用 ModuleConcatenationPlugin 完成模块分析与合并操作。
与 Tree-Shaking 类似,Scope Hoisting 底层基于 ES Module 方案的 静态特性,推断模块之间的依赖关系,并进一步判断模块与模块能否合并,因此在以下场景下会失效:
- 非 ESM 模块
遇到 AMD、CMD 一类模块时,由于导入导出内容的动态性,Webpack 无法确保模块合并后不会产生意料之外的副作用,因此会关闭 Scope Hoisting 功能。这一问题在导入 NPM 包尤其常见,许多框架都会自行打包后再上传到 NPM,并且默认导出的是兼容性更佳的 CommonJS 包,因而无法使用 Scope Hoisting 功能,此时可通过 mainFileds 属性尝试引入框架的 ESM 版本:
module.exports = {
resolve: {
// 优先使用 jsnext:main 中指向的 ES6 模块化语法的文件
mainFields: ['jsnext:main', 'browser', 'main']
},
};
- 模块被多个 Chunk 引用
如果一个模块被多个 Chunk 同时引用,为避免重复打包,Scope Hoisting 同样会失效,例如:
// common.js
export default "common"
// async.js
import common from './common';
// index.js
import common from './common';
import("./async");
示例中,入口 index.js 与异步模块 async.js 同时依赖 common.js 文件,common.js 无法被合并入任一 Chunk,而是作为生成为单独的作用域,
监控产物体积
Webpack 提供了一套 性能监控方案,当构建生成的产物体积超过阈值时抛出异常警告,以此帮助我们时刻关注资源体积,避免因项目迭代增长带来过大的网络传输,用法:
module.exports = {
// ...
performance: {
// 设置所有产物体积阈值
maxAssetSize: 172 * 1024,
// 设置 entry 产物体积阈值
maxEntrypointSize: 244 * 1024,
// 报错方式,支持 `error` | `warning` | false
hints: "error",
// 过滤需要监控的文件类型
assetFilter: function (assetFilename) {
return assetFilename.endsWith(".js");
},
},
};
那么我们应该设置多大的阈值呢?这取决于项目具体场景,不过,一个比较好的 经验法则 是确保 关键路径 资源体积始终小于 170KB,超过这个体积就应该使用上面介绍的若干方法做好裁剪优化。