关键配置,让你的 Webpack 应用性能飙升

60 阅读7分钟

动态加载,减少首屏资源加载量

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,超过这个体积就应该使用上面介绍的若干方法做好裁剪优化。