跳过正文
  1. 前端工程体验优化实战/

打包耗时减少43%:现代构建工具的魔力

hujiacheng
作者
hujiacheng
Front-end Developer / Strive To Become Better
目录
工程化与流程 - 这篇文章属于一个选集。
§ 1: 本文

开发体验和用户体验密切相关。

试想,一个前端项目,连他的维护者在开发时都感到麻烦甚至痛苦,又怎么能给用户带来卓越的体验呢?

所以要想优化用户体验,必须同步优化开发体验,必须不懈地解决前端项目开发时的痛点,减少工作中的阻碍,让开发者提高工作效率,改善工作体验。

接下来的几节内容,我们将一起学习优化开发体验的五大方向,相信大家一定会有所收获。


WebpackBabel为代表的构建工具是现代前端工程的核心组成部分,担负着为前端应用打包模块、编译代码、资源管理和开发环境增强等众多重要功能。

但长久以来,对这些工具的抱怨也不绝于耳,其中编译打包耗时漫长是核心痛点之一。

因此,多年来业界尝试了许多传统的优化方案,致力于减少构建耗时,我们先来大致了解一下。

1. 传统构建耗时优化方案
#

Webpack为例,传统优化构建耗时的方案主要有以下4类:

  1. 多线程并行编译:例如使用Happypackthread-loader
  2. 拆分模块为动态链接:例如 DLL Plugin
  3. 增加编译打包结果的缓存:例如Webpack 自带的 cache 功能
  4. 甚至改造项目逻辑:实现只对部分代码执行打包编译,从而减少构建工作量,减少耗时。

下面我们具体了解一下这些方案细节和优缺点。

1. 多线程并行编译加速构建
#

第一类优化方案是利用Node.js可以多进程和线程池的机制,在编译打包时开启多个子进程child_process或线程池worker_thread分担运算任务,从而提高运算效率,减少构建耗时。

这一类方案的代表工具主要有:

  • Happypack:通过提供happypack/loaderHappyPackplugin,承接Webpack模块的打包编译工作,利用Node.js的child_process模块和现代计算机的多核处理器能力,创建多个子进程并发运行,分发打包编译运算任务,提高运算效率,减少构建耗时。

注:Happypack已经不再维护,5年没有更新了,是相当陈旧的优化工具,不推荐使用。

  • thread-loader:通过提供thread-loader,将耗时较长的指定loader的计算任务,分发到包含多个线程(worker_thread)的线程池中并行处理,提高运算效率,减少构建耗时。

示例配置如下:

// webpack.config.js
const path = require('path');

module.exports = {
  module: {
    rules: [
      {
        test: /.js$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'thread-loader',
            options: {
              // 指定使用的子线程数量
              workers: 4, 
            },
          },
          // 指定耗时较长的 babel-loader 在线程池中并行运算
          'babel-loader', 
        ],
      },
    ],
  },

  // ...其他配置...
};

注:Node.js child_processworker_thread异同简介

相同点:两者都是利用现代计算机的多核处理器硬件,将计算任务分发给多个CPU核心执行,致力于提高运行性能的原生模块。

不同点:

  • child_process进程,通过childProcess.send('Hello from parent!') API用专用的IPC channel机制实现进程间通信,有独立的内存和资源。
  • worker_thread线程,通过worker.postMessage()worker.on('message')API进行消息通信,和进程共享内存和其他资源。

2. 拆分模块为动态链接
#

第二类传统优化方案是将部分模块的导入方式改为动态链接,即提前打包部分代码模块,保存产物文件(通常还会有一个清单,记录产物文件的内容、导出变量、路径等信息)。

在后续构建时,直接复用这部分产物文件,从而跳过对其编译打包,减少构建的工作量,加速构建。

这一类方案的代表工具是 DLL Plugin,具体来说,它包含2个插件,分别是:

1. DllPlugin
#

用于在一份独立的Webpack配置中,指定部分模块,打包成动态链接产物文件。用法示例如下:

// dll.config.js
const { DllPlugin } = require('webpack');
const { DIST_DLL, reactRuntimeName } = require('./constants');

module.exports = {
  mode: 'production',
  entry: {
    [reactRuntimeName]: ['react', 'react-dom', 'react-router'],
  },
  output: {
    path: DIST_DLL,
    filename: '[name].dll.js',
    library: '[name]_[fullhash]',
  },
  plugins: [
    new DllPlugin({
      // path: path.join(DIST, '../dist/[name]-manifest.json'),
      path: `${DIST_DLL}/[name]-manifest.json`,
      // path: DIST,
      name: '[name]_[fullhash]',
    }),
  ],
};

另外,我们还可以声明一个新的命令:"build-dll": "cross-env NODE_ENV=production webpack --config ./webpack/dll.config.js",,指定以这份DLL专用配置运行Webpack构建,构建完成后就会生成出2份文件,分别是:

  1. reactRuntime.dll.js:动态链接产物文件。内容包含了我们在入口(entry)通过[reactRuntimeName]指定的模块,编译打包后的代码,在此处产物文件包含的3个模块是: ['react', 'react-dom', 'react-router']
  2. reactRuntime-manifest.json:动态链接产物上下文信息清单。内容是产物文件中各模块的内容,具体来说包括模块入口文件路径("./node_modules/react/index.js")、导出变量名(exports)等,示例内容如下:
{
  "name": "reactRuntime_d037847a67b9692c59ef",
  "content": {
    "./node_modules/react/index.js": {
      "id": 294,
      "buildMeta": { "exportsType": "dynamic", "defaultObject": "redirect" },
      "exports": [
        "Children",
        "Component",
        "Fragment",
        "Profiler",
        "PureComponent"
        // ...
      ]
    },
    "./node_modules/react-router/esm/react-router.js": {
      "id": 369,
      "buildMeta": { "exportsType": "namespace" },
      "exports": ["MemoryRouter", "Prompt", "Redirect", "Route", "Router"]
    },
    "./node_modules/react-dom/index.js": {
      "id": 935,
      "buildMeta": { "exportsType": "dynamic", "defaultObject": "redirect" },
      "exports": [
        "__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED",
        "createPortal",
        "findDOMNode",
        "flushSync",
        "hydrate"
        // ...
      ]
    }
  }
}

有了这2个文件,我们就可以在后续的打包构建中,直接跳过对 ['react', 'react-dom', 'react-router']这3个模块的打包编译,直接复用动态链接产物文件,从而减少构建工作量,减少耗时。

所以我们还需要另一个插件声明,从哪里复用、哪些动态链接产物文件。

2. DllReferencePlugin
#

用于指定复用动态链接产物文件的上下文信息,从而在打包构建时,跳过打包编译这些动态链接库。使用示例请参考:

// common.config.js
module.exports = {
  plugins: [
    new DllReferencePlugin({
      manifest: require(DIST_DLL + `/${reactRuntimeName}-manifest.json`), // eslint-disable-line
    }),
    // ...
}

完整代码示例请参考《feat: 使用 DLLPlugin 加速构建》:https://github.com/JuniorTour/fe-optimization-demo/pull/8

3. 优化效果
#

上述示例中我们将'react', 'react-dom', 'react-router'3个模块改为动态链接库后,大约能让构建耗时减少约13%,具体优化效果数据如下:

对比项 / npm run build 耗时 (单位:毫秒ms)优化前 (DLLPlugin)优化后 (DLLPlugin)差异
平均值32385.527962.1-4423.4 ms (-13.66% )
中位数3190627889.5-4016.5 ms (-12.59% )
最大值3777529622-8153 ms (-21.58% )
最小值3061427005-3609 ms (-11.79% )
  • 数据来源:本地环境运行npm run build10次,记录每次耗时,统计得出。

目前 DLL Plugin 已经是Webpack 5 的官方插件之一,有较好的支持力度,虽然优化方案已经发明多年,但是历久弥新,在2024年的今天仍然可以使用,并且有一定优化效果。

3. 增加编译打包缓存
#

第三类优化方案是使用缓存来加速Webpack构建,常用的工具有:

  1. hard-source-webpack-plugin:这个插件将代码模块打包编译的结果,存储在本地文件系统中。当再次运行构建时,插件会直接复用本地存储的缓存,从而对代码模块跳过打包编译,加快构建,减少耗时。

但是这个插件已经多年不再更新维护,目前不推荐使用。建议考虑下一个工具:

  1. Webpack 自带的 cache 功能:Webpack在5.0版本后,进一步强化了自带的cache功能,增加了更多精细的控制选项,也可以实现将构建结果存储在本地文件系统中,并在后续构建时智能复用,减少构建耗时。配置也非常简单,对于绝大多数前端项目来说,只需要一行配置即可生效:
// webpack.config.js
module.exports = {
  //...
  cache: { type: 'filesystem' }
}

但是缓存的缺点是,对于第一次运行构建,也就是冷启动时,没有优化效果。

这对持续集成CI环境是一个较大的扣分项,因为CI环境每次运行构建,往往都是从零开始的新环境,没有缓存可以复用,也就无法享受优化效果。

4. 拆分项目入口
#

第四类传统优化方案更加彻底,是通过改造打包编译的入口(entry)  配置,或者直接改造项目业务逻辑,对代码模块进行拆分,只编译打包部分代码模块,从根本上减少构建的工作量,节省构建耗时。

例如我们有一个多页面的前端应用,Webpack入口配置如下:

module.exports = {
  entry: {
    pageOne: './src/pageOne/index.js',
    pageTwo: './src/pageTwo/index.js',
    pageThree: './src/pageThree/index.js',
  },
};

为了彻底减少构建的工作量,我们可以通过动态改变配置,实现只对1个页面进行打包编译,代码示例如下:

const ALL_ENTRIES = {
  pageOne: './src/pageOne/index.js',
  pageTwo: './src/pageTwo/index.js',
  pageThree: './src/pageThree/index.js',
};

// TARGET_PAGE_ENTRY 是运行构建命令 npm run dev 时传入的变量
const targetPageEntry = process.env.TARGET_PAGE_ENTRY || 'pageOne';

module.exports = {
  entry: {
    [targetPageEntry]: ALL_ENTRIES[targetPageEntry],
  },
};

使用这个配置,搭配一套交互式的命令行工具,我们就可以实现,只对指定的1个页面进行打包编译,大幅减少构建工作量,从而减少构建耗时。

当然,这一方案的缺点也显而易见。

该方案一般只能用于开发环境,节省开发工程师的等待时间,仍然无法对CI环境的全量构建,产生优化效果,优化效果并不全面。

总结
#

总的来说,这4类优化方案大都是隔靴搔痒,并没有真正触及提高打包编译运行速度的核心逻辑。很多方案和工具更是已经被业界所抛弃,不再维护。

但是近年来,这一痛点的解决有了新的曙光,因为前端领域涌现了一批基于RustGo等新兴编程语言的现代构建工具,例如:

有赖于这些新工具的出现,前端项目打包编译耗时漫长的痛点有了新的解决方案。

2. ESBuild简介
#

ESBuild是基于Go语言实现的前端编译打包工具,创始于2020年初,目前仍在热火朝天更新维护中,它集成了Babel的代码编译能力和Webpack的模块打包能力。

ESBuild追求极致的编译打包速度,致力于在最短的时间内,将使用新版ES规范语法的JavaScript代码,转换成兼容性更好的旧版ES规范代码,同时支持将多个JS模块文件合并为1个产物文件。

我们先来通过一个简单的示例,认识一下ESBuild的能力。

1. 编译打包功能示例
#

例子非常简单,我们有2个JS文件,分别是:

  1. 入口文件:index.js
  2. 模块文件:module.js

内容如下:

import { module } from './module.js';

console.log(module);

export const arrowFunction = () => 0;

const optionalChainingExample = a?.prop;
if (optionalChainingExample ?? 0) {
  console.log('ES2020 空值合并语法 nullish coalesing');
}

入口文件index.js中,我们引入了模块文件module.js中的module变量(import { module } from './module.js';)。

此外,还声明了一个使用ES2015语法的箭头函数arrowFunction,使用了关键字是?.的ES2020新语法可选链(Optional Chaining)  和关键字是??的ES2020的新语法空值合并(Nullish Coalescing)  。

l另一个模块文件module.js则只是简单的导出了一个常量module=1

// esbuild-demo\module.js
export const module = 1;

为了尽可能多的支持用户使用的浏览器版本,我们通常会:

  • 使用Babel,把ES5以后的新语法源代码,编译为使用ES5语法的代码,用于确保生产环境较好的浏览器兼容性。
  • 并搭配Webpack,解析遍历使用了ES Module语法importexport关键字的各个模块文件,再打包成1个或多个产物文件。

ESBuild同时拥有上述编译打包2项能力,对上述代码示例,只需要使用如下命令行:

npx esbuild index.js --bundle --outfile=out.js --target=es2015

ESBuild就会帮我们生成一个产物文件out.js,其内容如下:

(() => {
  // module.js
  var module = 1;

  // index.js
  console.log(module);
  var arrowFunction = () => 0;
  var optionalChainingExample = a == null ? void 0 : a.prop;
  if (optionalChainingExample != null ? optionalChainingExample : 0) {
    console.log("ES2020 \u7A7A\u503C\u5408\u5E76\u8BED\u6CD5 nullish coalesing");
  }
})();

其内容是index.js和所有其导入的模块代码(目前只有module.js),并且代码使用的语法会被编译成我们通过--target指定的ES2015语法。

这就是ESBuild的2项核心能力:

  1. 编译代码
  2. 打包模块

并且,据ESBuild官网宣称,其打包耗时仅为Webpack@51/100。另据第三方测试ESBuild编译耗时约为Babel1/10

也就是说,ESBuild不仅囊括了WebpackBabel的能力,并且性能还更加优异。

2. 缺点
#

不过作为新工具,ESBuild也不可避免地存在一些缺点,主要有:

  1. 编译能力兼容性优先不强:目前仅支持编译到ES6(ES2015)语法,并不支持编译为ES5语法
  2. 功能较少:缺乏代码分割 CodeSplit、模块懒加载等核心功能,无法平滑替代Webpack等构建工具。
  3. 生态不成熟:社区贡献的插件、配套工具仍然较少。

综上所述,如果能将ESBuild应用到前端项目中,就能显著减少打包编译耗时,加速构建,进而改善开发体验。

可是ESBuild的各种缺点巨大的迁移成本,又导致我们不敢将其应用到仍然需要ES5语法的生产环境中。

不过不用担心,我们这次也有秘籍,可以规避ESBuild缺点,享受到使用ESBuild收益

3. 示例:为前端项目引入ESBuild
#

下面我们就再次以优化示例项目:fe-optimization-demo为例,演示在基于Webpack的项目中使用ESBuild,同时规避其缺点的步骤。

因为ESBuild尚在开发完善之中,缺少代码分割Code Split等核心功能,且仅支持将JS代码编译为ES6以上版本的语法,功能特性不足以完全替代Webpack,无法全面应用到生产环境。

所以我们的秘籍就是:只在 DEV 开发环境使用ESBuild,享受其高性能、快速编译JS的能力,替代Babel,同时仍保留Webpack作为打包工具。

从而在不产生破坏性改动,不影响项目正常运行的前提下,优化开发体验。

完整代码示例请参考《feat: use ESBuild》:https://github.com/JuniorTour/fe-optimization-demo/pull/9

1. 安装依赖esbuild-loader
#

首先,我们需要安装将ESBuild适配到Webpack生态中的开源库 esbuild-loader :github.com/privatenumb…

npm install --save-dev esbuild-loader

2. 改造开发环境 Webpack 配置
#

然后,改造已有的Webpack配置文件,只针对开发环境,将原来用于编译JavaScript文件的babel-loader替换为esbuild-loader

请看改造代码:

首先,我们新增一个IS_DEVELOPMENT变量,根据环境参数,判断当前的NODE_ENV是否是开发环境'development'

// webpack\constants.js
const IS_DEVELOPMENT =
  !process.env.NODE_ENV || process.env.NODE_ENV === 'development';
console.log(`IS_DEVELOPMENT=${IS_DEVELOPMENT}`);

exports.IS_DEVELOPMENT = IS_DEVELOPMENT;

接下来,改造 Webpack 配置:

// webpack\common.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const { SRC, FAVICON, IS_DEVELOPMENT } = require('./constants');

const BABEL_LOADER_NAME = `babel-loader`;

const config = {
  entry: ['react-hot-loader/patch', './index.tsx'],
  // ...
  module: {
    rules: [
      {
        test: /.ts(x)?$/,
        use: [BABEL_LOADER_NAME],
      },
      // ...
    ],
  },
};

if (IS_DEVELOPMENT) {
  config.resolve.plugins = [
    // 解决 esbuild-loader 不支持 tsconfig.json 中自定义路径 path 的问题:
    // 背景信息:https://github.com/esbuild-kit/esbuild-loader/commit/270cda404b9aa76e826499fc90e9783193f146cc
    new TsconfigPathsPlugin(),
  ];

  const compilerIndex = config.module.rules.findIndex(
    (rule) => rule.use?.[0] === BABEL_LOADER_NAME,
  );

  if (compilerIndex >= 0) {
    config.module.rules[compilerIndex] = {
      test: /.[jt]sx?$/,
      exclude: /node_modules/,
      loader: 'esbuild-loader',
      options: {
        loader: 'tsx', // 开启对 JSX 的支持
        target: 'es2015', // 设置编译目标为 ES2015 语法
      },
    };
  } else {
    console.error(`Not Found babel-loader, esbuild-loader not work.`);
  }
}

module.exports = config;

在这段代码中,我们使用IS_DEVELOPMENT判断当前运行的是不是npm run dev命令,如果是的话,就将默认的babel-loader替换成esbuild-loader,并添加TsconfigPathsPlugin修复细节问题。

从而实现:

  • 在执行npm run dev时,使用esbuild-loader编译代码,享受开发环境的高性能快速编译。
  • 在执行npm run build时,使用babel-loader编译代码,确保生产环境的浏览器兼容性。

3. 修复细节问题
#

最后,在具体实践中,还要注意对ESBuild暂不支持的各类编译打包特性,进行针对性的处理修复,例如:

注:近几年风生水起的新兴构建共建 Vite,其构建逻辑,就类似上述示例,也会在DEV环境使用ESBuild,而在生产环境改为使用Rollup&&Babel

4. 评估优化效果
#

使用ESBuild的优化效果,我们可以通过多次重复运行编译打包命令,统计其耗时。

仍然以上述改造为例,在优化前后,笔者分别运行了10次npm run dev,统计其耗时,得到了如下数据:

对比项 / npm run dev耗时 (单位:毫秒ms)优化前 (babel-loader)优化后 (esbuild-loader)差异
平均值6113.93468.3-2645.6 ms (-43%)
中位数6186.53385.5-2801 ms (-45%)
最大值63793962-2417 ms (-38%)
最小值57663192-2574 ms (-45%)
  • 测试环境:
  • Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz 2.60 GHz
  • Windows 10 系统

经过对比数据可以看出,npm run dev命令执行打包编译的耗时平均值从优化前使用babel-loader时的 6113.9 毫秒,下降到了优化后使用esbuild-loader的 3468.3毫秒,减少了2645.6 ms (43%)  ,优化效果十分显著。

工程化与流程 - 这篇文章属于一个选集。
§ 1: 本文

相关文章