开发体验和用户体验密切相关。
试想,一个前端项目,连他的维护者在开发时都感到麻烦甚至痛苦,又怎么能给用户带来卓越的体验呢?
所以要想优化用户体验,必须同步优化开发体验,必须不懈地解决前端项目开发时的痛点,减少工作中的阻碍,让开发者提高工作效率,改善工作体验。
接下来的几节内容,我们将一起学习优化开发体验的五大方向,相信大家一定会有所收获。
以Webpack, Babel为代表的构建工具是现代前端工程的核心组成部分,担负着为前端应用打包模块、编译代码、资源管理和开发环境增强等众多重要功能。
但长久以来,对这些工具的抱怨也不绝于耳,其中编译打包耗时漫长是核心痛点之一。
因此,多年来业界尝试了许多传统的优化方案,致力于减少构建耗时,我们先来大致了解一下。
1. 传统构建耗时优化方案#
以Webpack为例,传统优化构建耗时的方案主要有以下4类:
- 多线程并行编译:例如使用Happypack、thread-loader。
- 拆分模块为动态链接:例如 DLL Plugin。
- 增加编译打包结果的缓存:例如Webpack 自带的 cache 功能。
- 甚至改造项目逻辑:实现只对部分代码执行打包编译,从而减少构建工作量,减少耗时。
下面我们具体了解一下这些方案细节和优缺点。
1. 多线程并行编译加速构建#
第一类优化方案是利用Node.js可以多进程和线程池的机制,在编译打包时开启多个子进程child_process或线程池worker_thread分担运算任务,从而提高运算效率,减少构建耗时。
这一类方案的代表工具主要有:
- Happypack:通过提供
happypack/loader和HappyPackplugin,承接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_process和worker_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份文件,分别是:
reactRuntime.dll.js:动态链接产物文件。内容包含了我们在入口(entry)通过[reactRuntimeName]指定的模块,编译打包后的代码,在此处产物文件包含的3个模块是:['react', 'react-dom', 'react-router']。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.5 | 27962.1 | -4423.4 ms (-13.66% ) |
| 中位数 | 31906 | 27889.5 | -4016.5 ms (-12.59% ) |
| 最大值 | 37775 | 29622 | -8153 ms (-21.58% ) |
| 最小值 | 30614 | 27005 | -3609 ms (-11.79% ) |
- 数据来源:本地环境运行
npm run build10次,记录每次耗时,统计得出。
目前 DLL Plugin 已经是Webpack 5 的官方插件之一,有较好的支持力度,虽然优化方案已经发明多年,但是历久弥新,在2024年的今天仍然可以使用,并且有一定优化效果。
3. 增加编译打包缓存#
第三类优化方案是使用缓存来加速Webpack构建,常用的工具有:
- hard-source-webpack-plugin:这个插件将代码模块打包编译的结果,存储在本地文件系统中。当再次运行构建时,插件会直接复用本地存储的缓存,从而对代码模块跳过打包编译,加快构建,减少耗时。
但是这个插件已经多年不再更新维护,目前不推荐使用。建议考虑下一个工具:
- 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类优化方案大都是隔靴搔痒,并没有真正触及提高打包编译运行速度的核心逻辑。很多方案和工具更是已经被业界所抛弃,不再维护。
但是近年来,这一痛点的解决有了新的曙光,因为前端领域涌现了一批基于Rust,Go等新兴编程语言的现代构建工具,例如:
SWC:swc.rs/ESBuild:esbuild.github.io/
有赖于这些新工具的出现,前端项目打包编译耗时漫长的痛点有了新的解决方案。
2. ESBuild简介#
ESBuild是基于Go语言实现的前端编译打包工具,创始于2020年初,目前仍在热火朝天更新维护中,它集成了Babel的代码编译能力和Webpack的模块打包能力。
ESBuild追求极致的编译打包速度,致力于在最短的时间内,将使用新版ES规范语法的JavaScript代码,转换成兼容性更好的旧版ES规范代码,同时支持将多个JS模块文件合并为1个产物文件。
我们先来通过一个简单的示例,认识一下ESBuild的能力。
1. 编译打包功能示例#
例子非常简单,我们有2个JS文件,分别是:
- 入口文件:
index.js - 模块文件:
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语法import和export关键字的各个模块文件,再打包成1个或多个产物文件。
ESBuild同时拥有上述编译和打包2项能力,对上述代码示例,只需要使用如下命令行:
npx esbuild index.js --bundle --outfile=out.js --target=es2015ESBuild就会帮我们生成一个产物文件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项核心能力:
- 编译代码
- 打包模块
并且,据ESBuild官网宣称,其打包耗时仅为Webpack@5的1/100。另据第三方测试,ESBuild编译耗时约为Babel的1/10。
也就是说,ESBuild不仅囊括了Webpack和Babel的能力,并且性能还更加优异。
2. 缺点#
不过作为新工具,ESBuild也不可避免地存在一些缺点,主要有:
- 编译能力兼容性优先不强:目前仅支持编译到ES6(ES2015)语法,并不支持编译为ES5语法。
- 功能较少:缺乏代码分割 CodeSplit、模块懒加载等核心功能,无法平滑替代
Webpack等构建工具。 - 生态不成熟:社区贡献的插件、配套工具仍然较少。
综上所述,如果能将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-loader2. 改造开发环境 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暂不支持的各类编译打包特性,进行针对性的处理修复,例如:
esbuild-loader不支持tsconfig.json中自定义路径path:引入TsconfigPathsPlugin作为解决方案,代码示例请参考:https://github.com/JuniorTour/fe-optimization-demo/blob/193018cf25cc47d71eb3b054407ed9d478b93654/webpack/common.config.js#L42- 选择合适的编译目标
target:如果只是用于开发环境,我们使用默认的'es2015',就能满足我们绝大多数前端开发者开发环境的需求了。如果还需要支持Node.js等环境,请参考 target 选项文档 调整配置。
注:近几年风生水起的新兴构建共建 Vite,其构建逻辑,就类似上述示例,也会在DEV环境使用
ESBuild,而在生产环境改为使用Rollup&&Babel。
4. 评估优化效果#
使用ESBuild的优化效果,我们可以通过多次重复运行编译打包命令,统计其耗时。
仍然以上述改造为例,在优化前后,笔者分别运行了10次npm run dev,统计其耗时,得到了如下数据:
对比项 / npm run dev耗时 (单位:毫秒ms) | 优化前 (babel-loader) | 优化后 (esbuild-loader) | 差异 |
|---|---|---|---|
| 平均值 | 6113.9 | 3468.3 | -2645.6 ms (-43%) |
| 中位数 | 6186.5 | 3385.5 | -2801 ms (-45%) |
| 最大值 | 6379 | 3962 | -2417 ms (-38%) |
| 最小值 | 5766 | 3192 | -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%) ,优化效果十分显著。
