Webpack: 构建优化

概述

前面章节我们已经详细探讨 Webpack 中如何借助若干工具分析构建性能,以及如何使用缓存与多进程能力提升构建性能的基本方法与实现原理,这两种方法都能通过简单的配置,极大提升大型项目的编译效率。

除此之外,还可以通过一些普适、细碎的最佳实践,减少编译范围、编译步骤提升性能,包括:

  • 使用最新版本 Webpack、Node;
  • 配置 resolve 控制资源搜索范围;
  • 针对 npm 包设置 module.noParse 跳过编译步骤;
  • 等等。

下面我们一一展开,解释每条最佳实践以及背后的逻辑。

使用最新版本

始终使用最新 Webpack 版本,这算的上是性价比最高的优化手段之一了!从 Webpack V3,到 V4,再到最新的 V5 版本,虽然构建功能在不断叠加增强,但性能反而不断得到优化提升,这得益于 Webpack 开发团队始终重视构建性能,在各个大版本之间不厌其烦地重构核心实现,例如:

  • V3 到 V4 重写 Chunk 依赖逻辑,将原来的父子树状关系调整为 ChunkGroup 表达的有序图关系,提升代码分包效率;
  • V4 到 V5 引入 cache 功能,支持将模块、模块关系图、产物等核心要素持久化缓存到硬盘,减少重复工作。

其次,新版本通常还会引入更多性能工具,例如 Webpack5 的 cache(持久化缓存)、lazyCompilation(按需编译,下面展开介绍) 等。因此,开发者应该保持时刻更新 Webpack 以及 Node、NPM or Yarn 等基础环境,尽量使用最新稳定版本完成构建工作。

使用 lazyCompilation

Webpack 5.17.0 之后引入实验特性 lazyCompilation,用于实现 entry 或异步引用模块的按需编译,这是一个非常实用的新特性!

试想一个场景,你的项目中有一个入口(entry)文件及若干按路由划分的异步模块,Webpack 启动后会立即将这些入口与异步模块全部一次性构建好 ------ 即使页面启动后实际上只是访问了其中一两个异步模块, 这些花在异步模块构建的时间着实是一种浪费!lazyCompilation 的出现正是为了解决这一问题。用法很简单:

js 复制代码
// webpack.config.js
module.exports = {
  // ...
  experiments: {
    lazyCompilation: true,
  },
};

启动 lazyCompilation 后,代码中通过异步引用语句如 import('./xxx') 导入的模块(以及未被访问到的 entry)都不会被立即编译,而是直到页面正式请求该模块资源(例如切换到该路由)时才开始构建,效果与 Vite 相似,能够极大提升冷启速度。

此外,lazyCompilation 支持如下参数:

  • backend: 设置后端服务信息,一般保持默认值即可;
  • entries:设置是否对 entry 启动按需编译特性;
  • imports:设置是否对异步模块启动按需编译特性;
  • test:支持正则表达式,用于声明对那些异步模块启动按需编译特性。

不过,lazyCompilation 还处于实验阶段,无法保证稳定性,接口形态也可能发生变更,建议只在开发环境使用。

约束 Loader 执行范围

Loader 组件用于将各式文件资源转换为可被 Webpack 理解、构建的标准 JavaScript 代码,正是这一特性支撑起 Webpack 强大的资源处理能力。不过,Loader 在执行内容转换的过程中可能需要比较密集的 CPU 运算,如 babel-loadereslint-loadervue-loader 等,需要反复执行代码到 AST,AST 到代码的转换。

因此开发者可以根据实际场景,使用 module.rules.includemodule.rules.exclude 等配置项,限定 Loader 的执行范围 ------ 通常可以排除 node_module 文件夹,如:

js 复制代码
// webpack.config.js
module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: ["babel-loader", "eslint-loader"],
      },
    ],
  },
};

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

此外,excludeinclude 还支持类似 MongoDB 参数风格的值,也就是通过 and/not/or 属性配置组合过滤逻辑,如:

js 复制代码
const path = require("path");
module.exports = {
  // ...
  module: {
    rules: [{
      test: /\.js$/,
      exclude: {
        and: [/node_modules/],
        not: [/node_modules\/lodash/]
      },
      use: ["babel-loader", "eslint-loader"]
    }],
  }
};
  • 提示:详情可查阅 官网

上述配置的逻辑是:过滤 node_modules 文件夹中除 lodash 外的所有文件。使用这种能力,我们可以适当将部分需要转译处理的 NPM 包(例如代码中包含 ES6 语法)纳入 Loader 处理范围中。

使用 noParse 跳过文件编译

有不少 NPM 库已经提前做好打包处理(文件合并、Polyfill、ESM 转 CJS 等),不需要二次编译就可以直接放在浏览器上运行,例如:

  • Vue2 的 node_modules/vue/dist/vue.runtime.esm.js 文件;
  • React 的 node_modules/react/umd/react.production.min.js 文件;
  • Lodash 的 node_modules/lodash/lodash.js 文件。

对我们来说,这些资源文件都是独立、内聚的代码片段,没必要重复做代码解析、依赖分析、转译等操作,此时可以使用 module.noParse 配置项跳过这些资源,例如:

js 复制代码
// webpack.config.js
module.exports = {
  //...
  module: {
    noParse: /lodash|react/,
  },
};
  • 提示: noParse 支持正则、函数、字符串、字符串数组等参数形式,具体可查阅官网

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

  • 由于跳过了前置的 AST 分析动作,构建过程无法发现文件中可能存在的语法错误,需要到运行(或 Terser 做压缩)时才能发现问题,所以必须确保 noParse 的文件内容正确性;
  • 由于跳过了依赖分析的过程,所以文件中,建议不要包含 import/export/require/define 等模块导入导出语句 ------ 换句话说,noParse 文件不能存在对其它文件的依赖,除非运行环境支持这种模块化方案;
  • 由于跳过了内容分析过程,Webpack 无法标记该文件的导出值,也就无法实现 Tree-shaking。

综上,建议在使用 noParse 配置 NPM 库前,先检查 NPM 库默认导出的资源满足要求,例如 React@18 默认定义的导出文件是 index.js

js 复制代码
// react package.json
{
  "name": "react",
  // ...
  "main": "index.js"
}

node_module/react/index.js 文件包含了模块导入语句 require

js 复制代码
// node_module/react/index.js
'use strict';

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}

此时,真正有效的代码被包含在 react.development.js(或 react.production.min.js)中,但 Webpack 只会打包这段 index.js 内容,也就造成了产物中实际上并没有真正包含 React。针对这个问题,我们可以先找到适用的代码文件,然后用 resolve.alias 配置项重定向到该文件:

js 复制代码
// webpack.config.js
module.exports = {
  // ...
  module: {
    noParse: /react|lodash/,
  },
  resolve: {
    alias: {
      react: path.join(
        __dirname,
        process.env.NODE_ENV === "production"
          ? "./node_modules/react/cjs/react.production.min.js"
          : "./node_modules/react/cjs/react.development.js"
      ),
    },
  },
};
  • 提示:使用 externals 也能将部分依赖放到构建体系之外,实现与 noParse 类似的效果,详情可查阅官网

开发模式禁用产物优化

Webpack 提供了许多产物优化功能,例如:Tree-Shaking、SplitChunks、Minimizer 等,这些能力能够有效减少最终产物的尺寸,提升生产环境下的运行性能,但这些优化在开发环境中意义不大,反而会增加构建器的负担(都是性能大户)。

因此,开发模式下建议关闭这一类优化功能,具体措施:

  • 确保 mode='development'mode = 'none',关闭默认优化策略;
  • optimization.minimize 保持默认值或 false,关闭代码压缩;
  • optimization.concatenateModules 保持默认值或 false,关闭模块合并;
  • optimization.splitChunks 保持默认值或 false,关闭代码分包;
  • optimization.usedExports 保持默认值或 false,关闭 Tree-shaking 功能;
  • ......

最终,建议开发环境配置如:

js 复制代码
module.exports = {
  // ...
  mode: "development",
  optimization: {
    removeAvailableModules: false,
    removeEmptyChunks: false,
    splitChunks: false,
    minimize: false,
    concatenateModules: false,
    usedExports: false,
  },
};

最小化 watch 监控范围

watch 模式下(通过 npx webpack --watch 命令启动),Webpack 会持续监听项目目录中所有代码文件,发生变化时执行 rebuild 命令。

不过,通常情况下前端项目中部分资源并不会频繁更新,例如 node_modules ,此时可以设置 watchOptions.ignored 属性忽略这些文件,例如:

js 复制代码
// webpack.config.js
module.exports = {
  //...
  watchOptions: {
    ignored: /node_modules/
  },
};

跳过 TS 类型检查

JavaScript 本身是一门弱类型语言,这在多人协作项目中经常会引起一些不必要的类型错误,影响开发效率。随前端能力与职能范围的不断扩展,前端项目的复杂性与协作难度也在不断上升,TypeScript 所提供的静态类型检查能力也就被越来越多人所采纳。

不过,类型检查涉及 AST 解析、遍历以及其它非常消耗 CPU 的操作,会给工程化流程带来比较大的性能负担,因此我们可以选择关闭 ts-loader 的类型检查功能:

js 复制代码
module.exports = {
  // ...
  module: {
    rules: [{
      test: /\.ts$/,
      use: [
        {
          loader: 'ts-loader',
          options: {
            // 设置为"仅编译",关闭类型检查
            transpileOnly: true
          }
        }
      ],
    }],
  }
};

有同学可能会问:"没有类型检查,那还用 TypeScript 干嘛?",很简单,我们可以:

  1. 可以借助编辑器的 TypeScript 插件实现代码检查;

  2. 使用 fork-ts-checker-webpack-plugin 插件将类型检查能力剥离到 子进程 执行,例如:

    js 复制代码
    const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
    
    module.exports = {
      // ...
      module: {
        rules: [{
          test: /\.ts$/,
          use: [
            {
              loader: 'ts-loader',
              options: {
                transpileOnly: true
              }
            }
          ],
        }, ],
      },
      plugins:[
        // fork 出子进程,专门用于执行类型检查
        new ForkTsCheckerWebpackPlugin()
      ]
    };

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

优化 ESLint 性能

ESLint 能帮助我们极低成本发现代码风格问题,维护代码质量,但若使用不当 ------ 例如在开发模式下使用 eslint-loader 实现实时代码检查,会带来比较高昂且不必要的性能成本,我们可以选择其它更聪明的方式接入 ESLint。

例如,使用新版本组件 eslint-webpack-plugin 替代旧版 eslint-loader,两者差异在于,eslint-webpack-plugin 在模块构建完毕(compilation.hooks.succeedModule 钩子)后执行检查,不会阻断文件加载流程,性能更优,用法:

  1. 安装依赖:

    csharp 复制代码
    yarn add -D eslint-webpack-plugin
  2. 添加插件:

    js 复制代码
    const ESLintPlugin = require('eslint-webpack-plugin');
    module.exports = {
      // ...
      plugins: [new ESLintPlugin(options)],
      // ...
    };

或者,可以选择在特定条件、场景下执行 ESLint,减少对构建流程的影响,如:

  • 使用编辑器插件完成 ESLint 检查、错误提示、自动 Fix,如 VS Code 的 dbaeumer.vscode-eslint 插件;
  • 使用 husky,仅在代码提交前执行 ESLint 代码检查;
  • 仅在 production 构建中使用 ESLint,能够有效提高开发阶段的构建效率。

慎用 source-map

source-map 是一种将经过编译、压缩、混淆的代码映射回源码的技术,它能够帮助开发者迅速定位到更有意义、更结构化的源码中,方便调试。不过,source-map 操作本身也有很大构建性能开销,建议读者根据实际场景慎重选择最合适的 source-map 方案。

针对 source-map 功能,Webpack 提供了 devtool 选项,可以配置 evalsource-mapcheap-source-map 等值,不考虑其它因素的情况下,最佳实践:

  • 开发环境使用 eval ,确保最佳编译速度;

  • 生产环境使用 source-map,获取最高质量。

  • 参考:webpack.js.org/configurati...

设置 resolve 缩小搜索范围

Webpack 默认提供了一套同时兼容 CMD、AMD、ESM 等模块化方案的资源搜索规则 ------ enhanced-resolve,它能将各种模块导入语句准确定位到模块对应的物理资源路径。例如:

  • import 'lodash' 这一类引入 NPM 包的语句会被 enhanced-resolve 定位到对应包体文件路径 node_modules/lodash/index.js
  • import './a' 这类不带文件后缀名的语句,则可能被定位到 ./a.js 文件;
  • import '@/a' 这类化名路径的引用,则可能被定位到 $PROJECT_ROOT/src/a.js 文件

需要注意,这类增强资源搜索体验的特性背后涉及许多 IO 操作,本身可能引起较大的性能消耗,开发者可根据实际情况调整 resolve 配置,缩小资源搜索范围,包括:

1. resolve.extensions 配置:

例如,当模块导入语句未携带文件后缀时,如 import './a' ,Webpack 会遍历 resolve.extensions 项定义的后缀名列表,尝试在 './a' 路径追加后缀名,搜索对应物理文件

在 Webpack5 中,resolve.extensions 默认值为 ['.js', '.json', '.wasm'] ,这意味着 Webpack 在针对不带后缀名的引入语句时,可能需要执行三次判断逻辑才能完成文件搜索,针对这种情况,可行的优化措施包括:

  • 修改 resolve.extensions 配置项,减少匹配次数;

  • 代码中尽量补齐文件后缀名;

  • 设置 resolve.enforceExtension = true ,强制要求开发者提供明确的模块后缀名,不过这种做法侵入性太强,不太推荐。

2. resolve.modules 配置:

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

在一个依赖管理良好的系统中,我们通常会尽量将 NPM 包安装在有限层级内,因此 Webpack 这一逐层查找的逻辑大多数情况下实用性并不高,开发者可以通过修改 resolve.modules 配置项,主动关闭逐层搜索功能,例如:

js 复制代码
// webpack.config.js
const path = require('path');

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

3. resolve.mainFiles 配置:

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

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

总结

  • Webpack 在应对大型项目场景时通常会面临比较大的性能挑战,也因此非常值得我们投入精力去学习如何分析、优化构建性能,除了缓存、多进程构建这一类大杀器之外,还可以通过控制构建范围、能力等方式尽可能减少各个环节的耗时,包括文中介绍的:
    • 使用最新 Webpack、Node 版本
    • 约束 Loader 执行范围
    • 使用 noParse 跳过文件编译等
  • 如果下次再遇到性能问题,建议可以先试着分析哪些环节占用时长更多,然后有针对性的实施各项优化
  • 可以思考,除了上述各项优化外,还存在哪些有效措施?可以往 Webpack 的构建流程、组件等方向思考
相关推荐
F2E_Zhangmo1 小时前
vue长列表,虚拟滚动
前端·javascript·vue.js
安冬的码畜日常1 小时前
【D3.js in Action 3 精译】1.2.2 可缩放矢量图形(三)
开发语言·前端·javascript·信息可视化·数据可视化
LiamHong_1 小时前
简单了解 HTTP 请求方法
前端·后端·http·学习方法·web
蓝胖子的多啦A梦2 小时前
【问题已解决】Vue管理后台,点击登录按钮,会发起两次网络请求(竟然是vscode Compile Hero编译插件导致的)
前端·vue.js·vscode
Lsx-codeShare2 小时前
Nginx Http缓存的必要性!启发式缓存有什么弊端?
前端·javascript·nginx·http·缓存·webpack
南七小僧2 小时前
Android Studio 解决AAPT: error: file failed to compile
前端·javascript·vue.js
陈琦鹏2 小时前
zoom缩放问题(关于ElementPlus、Echarts、Vue3draggable等组件偏移问题)
前端·javascript·echarts
盏茶作酒2 小时前
解决expand-change第一次展开无数据显示与实现
开发语言·前端·javascript
Wang's Blog2 小时前
Webpack: 开发 PWA、Node、Electron 应用
前端·webpack·electron
Code blocks2 小时前
小试牛刀-区块链代币锁仓(Web页面)
前端·区块链·智能合约