Skip to main content

提升编译性能

1. 缩小资源搜索范围

  • 使用 enhanced-resolve 缩小资源搜索范围;

    参考:https://github.com/webpack/enhanced-resolve

  • 修改 resolve.extensions 减少匹配次数,代码中尽量补齐文件后缀名;

  • resolve.modules 配置

    当 Webpack 遇到 import 'lodash' 这样的 npm 包导入语句时,会尝试先当前项目的 node_modules 搜索资源,如果找不到则按目录层级尝试逐级向上查找 node_modules 目录,如果依然找不到则最终尝试在全局 node_modules 中搜索。

    在一个依赖管理执行的比较良好的业务系统中,我们通常会尽量保持 node_modules 资源的高度内聚,控制在有限的一两个层级上,因此 Webpack 这一逐层查找的逻辑大多数情况下实用性并不高,开发者可以通过修改 resolve.modules 配置项,主动关闭逐层搜索功能:

    module.exports = {
    //...
    resolve: {
    modules: [path.resolve(__dirname, 'node_modules')],
    },
    };
    tip

    注意该配置仅适用于 npm、yarn 等扁平化 node_modules 结构,由于 pnpm 是非扁平结构,启用该配置后会导致 Webpack 寻址出错,所有依赖的间接依赖都无法解析,只能配置 shamefully-hoist=true 提升到根目录解决。限制依赖搜索范围,最合理的解决方案是用 react-dev-utils 提供的 ModuleScopePlugin。实际上如果扁平化 node_modules 结构,一律都到根目录 node_modules 搜索也不合理,存在安装一个依赖的不同版本的情况,此时仍会嵌套安装,如果都到根目录搜索,只能解析到一个版本,容易出现版本不兼容问题。

    个人理解这边应该是限制模块解析范围到项目根目录。由于依赖管理复杂度,存在大量间接依赖,确实是无法避免逐层查找的问题,例如安装同一个依赖的不同版本,第二个依赖会被安装在某个依赖下的 node_modules 下,而不是项目根目录的 node_modules 下。另外还存在 peerDependencies 问题,例如 antd 依赖 react,但是 antd 本身不会安装 react,需要到项目根目录下 node_modules 查找。

  • resolve.mainFiles 配置

    resolve.extensions 类似,resolve.mainFiles 配置项用于定义文件夹默认文件名,例如对于 import './dir' 请求,假设 resolve.mainFiles = ['index', 'home'] ,Webpack 会按依次测试 ./dir/index./dir/home 文件是否存在。

    因此,实际项目中应控制 resolve.mainFiles 数组数量,减少匹配次数。

  • 使用 Rule.oneOf,一旦 loader 匹配成功就退出匹配,减少不必要的匹配次数

    参考:https://webpack.js.org/configuration/module/#ruleoneof

  • 注意 includeexclude 条件问题

    const path = require('path');

    module.exports = {
    //...
    module: {
    rules: [
    {
    test: /\.css$/,
    include: [
    // will include any paths relative to the current directory starting with `app/styles`
    // e.g. `app/styles.css`, `app/styles/styles.css`, `app/stylesheet.css`
    path.resolve(__dirname, 'app/styles'),
    // add an extra slash to only include the content of the directory `vendor/styles/`
    path.join(__dirname, 'vendor/styles/'),
    ],
    },
    ],
    },
    };

    https://webpack.js.org/configuration/module/#condition

    实际上 exclude 还支持 andnot 表达式:

    module.exports = {
    // ...
    module: {
    rules: [
    {
    test: /\.(js|ts)x?$/,
    exclude: {
    and: [/node_modules/], // Exclude libraries in node_modules ...
    not: [
    // Except for a few of them that needs to be transpiled because they use modern syntax
    /unfetch/,
    /d3-array|d3-scale/,
    /@hapi[\\/]joi-date/,
    ]
    },
    use: ["babel-loader"],
    }
    ]
    }
    }

    下面这段是一个实际业务工程的配置:

    module.exports = {
    module: {
    rules: [
    {
    test: /\.(js|mjs|jsx|ts|tsx)$/,
    // 解决 `react-hot-toast` 用模板字符串写 CSS 样式,导致无法压缩的问题
    // 这里修改 exclude 让 babel-loader 处理 `react-hot-toast`
    // 然后在 babel 配置里面加上 `@babel/plugin-transform-template-literals` 插件编译模板字符串
    // 因为默认 target 配置不会降级到 ES5,需要手动配置插件
    exclude: [{ and: [/node_modules/], not: [/react-hot-toast/] }],
    loader: require.resolve("babel-loader"),
    },
    ]
    }
    }

2. 跳过文件编译

有不少 npm 包默认提供了提前打包好,不需要做二次编译的资源版本,例如:

  • Vue 包的 node_modules/vue/dist/vue.runtime.esm.js 文件
  • React 包的 node_modules/react/umd/react.production.min.js 文件

对使用方来说,这些资源版本都是高度独立、内聚的代码片段,没必要重复做依赖解析、代码转译操作,此时可以使用 module.noParse 配置项跳过这些 npm 包:

module.exports = {
//...
module: {
noParse: /vue|lodash|react/,
},
};

配置该属性后,任何匹配该选项的包都会跳过耗时的分析过程,直接打包进 chunk,提升编译速度

tip

配置后,所有匹配该正则的文件都会跳过前置的依赖分析动作,直接将内容合并进 Chunk,从而提升构建速度。不过,使用 noParse 时需要注意:

  • 由于跳过了前置的 AST 分析动作,构建过程无法发现文件中可能存在的语法错误,需要到运行(或 Terser 做压缩)时才能发现问题,所以必须确保 noParse 的文件内容正确性;
  • 由于跳过了依赖分析的过程,所以文件中,建议不要包含 import/export/require/define 等模块导入导出语句 —— 换句话说,noParse 文件不能存在对其它文件的依赖,除非运行环境支持这种模块化方案;
  • 由于跳过了内容分析过程,Webpack 无法标记该文件的导出值,也就无法实现 Tree-shaking;
  • 补充一点,noParseexternals 比较类似,Webpack 都不进行依赖分析,注意如果 noParse 的文件同时还命中了 loader 规则,仍然会调用 loader 链进行构建。

业务项目使用 Webpack 打包,对于前端框架等基础性模块,配置 noParse 可以提升构建速度,但是注意不是所有第三方库都可以 noParse,例如一些组件库,对于 peerDependencies 是不打包的,在构建产物中会保留 import 语句,也就是说需要在业务项目中进行打包。

如果第三方库将自身 peerDependencies 打包会出现什么问题?例如开发一个组件库,用到了 antd 的 Table 组件,如果该组件库将 Table 打包进去,业务项目引入该组件库,如果业务项目也用到了 antd 的 Table 组件,则会导致业务项目最终构建产物中存在两份 Table 组件的代码,导致出现模块冗余问题。

3. 最小化 Loader 作用范围

Loader 组件用于将各式文件资源转换为可被 JavaScript 理解、运行的代码片段,正是这一特性支撑起 Webpack 强大的资源处理能力。不过,Loader 在执行内容转换的过程可能需要做大量的 CPU 运算操作,例如 babel-loader、eslint-loader、vue-loader 等,因此开发者有必要根据实际需求,通过 module.rules.includemodule.rules.exclude 等配置项限定 Loader 的执行范围:

module.exports = {
// ...
module: {
rules: [{
test: /\.js$/,
exclude: /node_modules/,
// include: path.join(__dirname, './src'),
use: ['babel-loader', 'eslint-loader']
}]
}
};

示例配置 exclude: /node_modules/ 属性后,Webpack 在处理 node_modules 中的 js 文件时会直接跳过这个 rule 项,不会为这些文件执行后续的 Loader

tip

一般来说,第三方库在发包的时候都会对源码进行构建,例如对 TS、JSX 语法进行语法转换,对 ES2015+ 语法进行编译兼容,然后整体再打包为一个 chunk(需要排除掉 peerDependencies),这样业务项目引入该库,就不需要使用 Babel 进行耗时的编译转换了。

关于编译兼容这块需要注意一个问题,第三方库只建议进行语法转换,但不建议引入 polyfill。如第三方库单独引入 polyfill,业务项目引入该库打包的时候,很可能会导致模块冗余问题,因此建议只在业务项目中配置 useBuiltIns: "entry",在入口文件全量引入 polyfill。

4. 最小化 watch 监控范围

在 watch 模式下(通过 npx webpack --watch 命令启动),Webpack 会持续监听项目所有代码文件,发生变化时重新构建最新产物。不过,通常情况下前端项目中某些资源并不会频繁更新,例如 node_modules ,此时可以设置 watchOptions.ignored 属性忽略这些文件:

module.exports = {
//...
watchOptions: {
ignored: /node_modules/
},
};

5. 跳过 TS 类型检查

编译 TS 有以下几种方案:

  • 使用 ts-loader 调用 tsc 进行编译、类型检查(适合小项目)
  • 使用 ts-loader 调用 tsc 进行编译,但不进行类型检查(配置 transpileOnly: true),使用 fork-ts-checker-webpack-plugin 在单独的进程中进行类型检查
  • 使用 babel-loader 调用 Babel 进行编译(需要安装 @babel/preset-typescript),可以复用 Babel 的 AST,使用 fork-ts-checker-webpack-plugin 在单独的进程中进行类型检查

类型检查涉及 AST 解析、遍历以及其它非常消耗 CPU 的操作,会给工程化流程引入性能负担,必要时开发者可选择关闭编译主进程中的类型检查功能,同步用 fork-ts-checker-webpack-plugin 插件将其剥离到单独进程执行,例如对于 ts-loader

const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');

module.exports = {
// ...
module: {
rules: [
{
test: /\.jsx?$/, // 不仅编译 TS 文件,同时兼容 JS 文件
exclude: /node_modules/,
loader: resolve('babel-loader'),
options: babelConfig,
},
{
test: /\.tsx?$/,
use: [
{
loader: resolve('babel-loader'),
options: babelConfig,
},
{
loader: resolve('ts-loader'),
options: {
// 调用 tsc 进行编译,但不进行类型检查
// 应用 ForkTsCheckerWebpackPlugin 插件会自动开启该配置
transpileOnly: true,
},
},
],
},
]
},
plugins:[
new ForkTsCheckerWebpackPlugin()
]
};

这样,既可以获得 Typescript 静态类型检查能力,又能提升整体编译速度

6. 选择更加快速的 hash 函数

在 webpack 中,默认使用 md4 hash 函数,它将基于模块内容以及一系列元信息生成摘要信息。对于 hash 算法的一部分可参考 NormalModule 的 hash 函数。

_initBuildHash(compilation) {
const hash = createHash(compilation.outputOptions.hashFunction);
if (this._source) {
hash.update("source");
this._source.updateHash(hash);
}
hash.update("meta");
hash.update(JSON.stringify(this.buildMeta));
this.buildInfo.hash = /** @type {string} */ (hash.digest("hex"));
}

https://github.com/webpack/webpack/blob/main/lib/NormalModule.js

选择一个更加快速的 hash 函数,即可减少 CPU 消耗,并提升打包速度。比如将默认的 md4 换成 xxhash64。Webpack v5.54.0+ 支持 xxhash64 作为更快的哈希算法。

https://webpack.js.org/configuration/output/#outputhashfunction

在 Webpack 中,可通过 output.hashFuction 来配置 hash 函数。

module.exports = {
entry: './index.js',
mode: 'none',
output: {
filename: 'main.[contenthash:6].xxhash64.js',
hashFunction: 'xxhash64'
}
}

Tip:这是面试问题如何提升 webpack 打包速度,八股文不常有的答案