背景
历史
传统的前端开发模式是基于 JavaScript(ES5)、HTML、CSS,最终将 JS、CSS、图片等静态资源通过 script、link 等标签插入到 HTML 中进行部署。随着应用的复杂度提升就愈发难以维护,标签的插入位置、顺序,如果保证代码的复用性,如何删除未使用的代码,还需要担心变量的污染等问题。
随着 NodeJS 的提出,加上 RequireJS、UMD 等方案的完善,模块化的开发概念开始逐渐流行,我们可以将代码拆分为数个模块开发,最终在生产环境时将其组合起来即可。而后,Babel、TypeScript、CoffeeScript 等工具的出现可以协助我们在开发阶段绕过 ES5 自身的缺陷编写出高质量的代码,Less、Sass、Stylus 等工具,为页面样式开发引入逻辑运算、数学运算、嵌套、继承等结构化语言特性,等等。
这些工程化工具能不同程度地弥补浏览器、语言、规范本身的设计缺陷,但随着工具的发展也出现一个问题:如何管理这些工具与工程背后的工程化逻辑?我们需要一套足够开放,能融合诸多工程化工具,彻底抹平开发与生产环境差异的一体化工程方案,这也正是 Webpack 需要解决的问题。
Webpack 做了什么?
将具有依赖关系的模块合并打包为 JS、CSS 等浏览器兼容的静态资源:
在 Webpack 概念中,所有资源文件都被统一视为模块(Module) ,以相同的的加载、解析、管理、合并流程处理,借助 Loader、Plugin 将资源的差异处理逻辑抛出交由社区实现。凭借设计上的强开放性,可以轻松接入社区的一系列工程化工具,这些工程又补充了 Webpack 的工程能力使其成为了一个一大统的资源处理框架,满足现代 Web 工程在效率、质量、性能等多方面的诉求,也能应用于小程序、客户端、SSR 等场景,即使在当前众多构建方案内卷的时代,依旧是使用最广泛的构建工具之一。
简易打包器
webpack的核心是现代 JavaScript 应用程序的静态模块打包器。 当 webpack 处理您的应用程序时,它会在内部从一个或多个入口点 构建一个依赖关系图,然后将您的项目需要的每个模块组合到一个或多个bundle中,这些 bundle 是为您的内容提供服务的静态资产。
Webpack 就可以理解为一个模块打包器,一个最简单的打包器流程为:
- 从入口模块分析依赖。
- 构造模块依赖图。
- 把所有代码合并。
我们可以按照这个流程实现一个:
从入口模块分析依赖
借助 babel 生成的 ast 结构,可以很方便地获取到引入的文件。
Webpack 使用 acorn 分析的 AST 结构。
acorn 是基于 estree 标准实现的底层 JS Parser 并提供了插件机制,espree(eslint)、babel parser 都是基于 acorn 做的扩展
javascript
const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')
function step1(filename) {
// 读入文件
const content = fs.readFileSync(filename, 'utf-8');
// 生成 AST
const ast = parser.parse(content, {
sourceType: 'unambiguous',
});
const dependencies = {};
// 遍历AST抽象语法树
traverse(ast, {
// 获取通过import引入的模块
ImportDeclaration({ node }) {
// 处理引入的模块路径为绝对路径
const importFilePath = path.resolve(path.dirname(filename), node.source.value);
dependencies[node.source.value] = importFilePath;
},
});
// 通过@babel/core和@babel/preset-env进行代码的转换
const { code } = babel.transformFromAst(ast, null, {
presets: ['@babel/preset-env'],
});
return {
filename, // 该文件名
dependencies, // 该文件所依赖的模块集合(键值对存储)
code, // 转换后的代码
};
}
循环遍历,构造模块依赖图
javascript
function step2(entry) {
// 入口文件的依赖关系
const entryModule = step1(entry);
// 所有模块的依赖关系
const graphArray = [entryModule];
for (const module of graphArray) {
const { dependencies } = module;
for (const dependency in dependencies) {
// 将入口模块及其所有相关的模块添加到数组中,进行后续遍历
graphArray.push(step1(dependency));
}
}
// 接下来生成图谱
const graph = {};
graphArray.forEach(item => {
graph[item.filename] = {
dependencies: item.dependencies,
code: item.code,
};
});
return graph;
}
合并输出产物
javascript
function step3(entry) {
const graph = JSON.stringify(step2(entry));
return `
(function(graph) {
function require(module) {
function localRequire(relativePath) {
return require(graph[module].dependencies[relativePath]);
}
var exports = {};
(function(require, exports, code) {
eval(code);
})(localRequire, exports, graph[module].code);
return exports;
}
require('${entry}')
})(${graph})`;
}
Loader
所有资源文件统一视为模块,但 Webpack 本身不能处理非 JS 文件。
Loader 的作用就是预处理文件,将文件的内容转译之后输出标准的 JS 文本,Webpack 通过 acorn 就可以解析并生成 AST,进而分析此模块的依赖关系。
比如图片资源,最终需要被处理为 export default "data:image/png;base64,xxx"
或 export default "https://xxx"
详细的 Loader 设计:juejin.cn/post/699275...
Plugin
Webpack 整个编译流程通过两个对象维护:
- Compiler:负责整体的编译流程,只存在一个。
- Compilation:负责单次的编译流程,当文件发生变更需要重新编译时,会创建一个新的 Compilation 对象。
Webpack 使用 Tapable(github.com/webpack/tap...
Tapabel 内部定义了如下钩子:
javascript
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require("tapable");
Webpack 抛出 hooks 按照编译流程注册了对应的钩子:
Compiler 完整 hooks:webpack.js.org/api/compile...
Compilation 完整 hooks:webpack.js.org/api/compila...
插件本质上就是一个实现了 apply 方法的类:
javascript
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';
class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
compiler.hooks.run.tap(pluginName, compilation => {
console.log('webpack 构建正在启动!');
});
}
}
module.exports = ConsoleLogOnBuildWebpackPlugin;
构建流程
- 初始化阶段
- 通过 CLI 或读取 webpack.config.js 文件生成相应的配置,创建 Compiler 实例。
- 遍历用户传入的 plugins,执行 apply 方法。
- 触发 WebpackOptionsApply,根据 webpack 配置加载内部插件。
- 环境初始化
- 执行 compiler.run / compiler.watch 方法。
javascript
const createCompiler = rawOptions => {
const options = getNormalizedWebpackOptions(rawOptions);
applyWebpackOptionsBaseDefaults(options);
// 创建 Compiler 实例
const compiler = new Compiler(options.context, options);
// 初始化
new NodeEnvironmentPlugin({
infrastructureLogging: options.infrastructureLogging
}).apply(compiler);
// 遍历用户传入的插件并执行 apply 方法
if (Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
if (typeof plugin === "function") {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler);
}
}
}
applyWebpackOptionsDefaults(options);
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();
// 加载内部插件
new WebpackOptionsApply().process(options, compiler);
compiler.hooks.initialize.call();
return compiler;
};
- 构建阶段
- 从入口文件开始,
- 调用 NormalModule.build() 方法,其中会执行对应的 loader,将原始代码进行 loader 进行转义,通常是 JS 文本。
- 调用 parser.pase ,使用 acorn 将 JS 文本解析为 AST。
- 通过 AST 结构分析依赖,对依赖的模块进行信息收集。
- 递归收集所有依赖的模块信息,维护依赖关系图 moduleGraph。
- 生成代码
-
调用
compilation.seal()
方法 -
根据 moduleGraph 生成 ChunkGraph 实例。
-
将 modules 按照配置组合成 chunks,chunk 与最终的输出资源一一对应。(默认情况下一个入口对应一个资源,通过动态引入的模块单独为一个资源)
-
调用 renderBootstrap 方法生成最终的代码,所有代码包裹在一个 IIFE 中。
-
思考
Webpack 为什么那么慢?
- JS 语言劣势,单线程开发,没有高效利用多核
- 代码构建
- AST 未能高效利用:构建依赖关系图时使用 acorn,转译代码时使用的是 loader:babel-loader、ts-loader...,重复生成 AST 结构
- 代码压缩
- terser 速度比较慢
如何提高构建速度?
Webpack5 给出的答案:
- 本地持久化缓存:webpack.js.org/configurati...
- 模块联邦:webpack.js.org/concepts/mo...
- 延迟编译(实验功能):webpack.js.org/configurati...
- ESM 格式产物(实验功能):webpack.js.org/configurati...
- 第三方包资源编译并部署在 CDN 侧,某些第三方服务平台: esm.sh/#docs
- 构建时将 externals 第三方包排除打包范围,改为远端资源链接
- 只编译业务代码,第三方包资源在运行时加载,省去第三方包编译的时间
- 使用 esbuild-loader、swc-loader 替代 babel-loader、ts-loader 转译 JS、TS 代码
- 使用 esbuild、swc 压缩(实验阶段)
未来构建方向
No bundle
将文件打包成一个 bundle 需要等待比较长时间的资源合并阶段,随着项目复杂度的增大,冷启动和热更新的速度都会比较慢。
Vite 一类的工具在开发阶段采取 no bundle 不打包的形式,资源模块之间通过原生 es module 加载,并且基于 esbuild 进行资源的预构建,整体启动、更新耗时比 Webpack 少很多。
存在的问题:
- 尽管原生 ESM 得到了广泛支持,但嵌套导入会导致额外的网络往返,在生产环境中发布未打包的 ESM 页面性能影响严重(即使使用 HTTP / 2)。
- 开发、生产环境不一致。由于 esbuild 针对构建的关键能力仍未成熟(代码分隔、CSS 处理),vite 生产模式基于 rollup 打包,而开发模式是基于 esbuild,没有抹平开发与生产环境的差异。
Bundle
工具链使用 Rust 编写:turbo.build/pack/docs
优势:速度快
劣势:
- 生态差
- 插件系统不完善,与 webpack 不兼容