构建工具-从模块化到webpack
模块化方案
CJS (commonjs)
commonjs
是 Node 中的模块规范,通过 require
及 exports
进行导入导出 (进一步延伸的话,module.exports
属于 commonjs2
)
同时,webpack 也对 cjs
模块得以解析,因此 cjs
模块可以运行在 node 环境及 webpack 环境下的,但不能在浏览器中直接使用。但如果你写前端项目在 webpack 中,也可以理解为它在浏览器和 Node 都支持。
比如,著名的全球下载量前十 10 的模块 ms (opens new window)只支持 commonjs
,但并不影响它在前端项目中使用(通过 webpack),但是你想通过 cdn 的方式直接在浏览器中引入,估计就会出问题了
// sum.js
exports.sum = (x, y) => x + y;
// index.js
const { sum } = require("./sum.js");
由于 cjs
为动态加载,可直接 require
一个变量
require(`./${a}`);
ESM (es module)
esm
是 tc39 对于 ESMAScript 的模块话规范,正因是语言层规范,因此在 Node 及 浏览器中均会支持。
它使用 import/export
进行模块导入导出.
// sum.js
export const sum = (x, y) => x + y;
// index.js
import { sum } from "./sum";
esm
为静态导入,正因如此,可在编译期进行 Tree Shaking,减少 js 体积。
如果需要动态导入,tc39 为动态加载模块定义了 API: import(module)
。可将以下代码粘贴到控制台执行
const ms = await import("https://cdn.skypack.dev/ms@latest");
ms.default(1000);
esm 是未来的趋势,目前一些 CDN 厂商,前端构建工具均致力于 cjs 模块向 esm 的转化,比如 skypack
、 snowpack
、vite
等。
目前,在浏览器与 node.js 中均原生支持 esm。
- cjs 模块输出的是一个值的拷贝,esm 输出的是值的引用
- cjs 模块是运行时加载,esm 是编译时加载
示例: array-uniq(opens new window)
UMD
一种兼容 cjs
与 amd
的模块,既可以在 node/webpack 环境中被 require
引用,也可以在浏览器中直接用 CDN 被 script.src
引入。
(function (root, factory) {
if (typeof define === "function" && define.amd) {
// AMD
define(["jquery"], factory);
} else if (typeof exports === "object") {
// CommonJS
module.exports = factory(require("jquery"));
} else {
// 全局变量
root.returnExports = factory(root.jQuery);
}
})(this, function ($) {
// ...
});
示例: react-table (opens new window), antd(opens new window)
这三种模块方案大致如此,部分 npm package 也会同时打包出 commonjs/esm/umd 三种模块化格式,供不同需求的业务使用,比如 antd (opens new window)。
AST及其应用
AST简介
AST
是 Abstract Syntax Tree
的简称,是前端工程化绕不过的一个名词。它涉及到工程化诸多环节的应用,比如:
- 如何将 Typescript 转化为 Javascript (typescript)
- 如何将 SASS/LESS 转化为 CSS (sass/less)
- 如何将 ES6+ 转化为 ES5 (babel)
- 如何将 Javascript 代码进行格式化 (eslint/prettier)
- 如何识别 React 项目中的 JSX (babel)
- GraphQL、MDX、Vue SFC 等等
而在语言转换的过程中,实质上就是对其 AST 的操作,核心步骤就是 AST 三步走
- Code -> AST (Parse)
- AST -> AST (Transform)
- AST -> Code (Generate)
以下是一段代码,及其对应的 AST
// Code
const a = 4
// AST
{
"type": "Program",
"start": 0,
"end": 11,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 11,
"declarations": [
{
"type": "VariableDeclarator",
"start": 6,
"end": 11,
"id": {
"type": "Identifier",
"start": 6,
"end": 7,
"name": "a"
},
"init": {
"type": "Literal",
"start": 10,
"end": 11,
"value": 4,
"raw": "4"
}
}
],
"kind": "const"
}
],
"sourceType": "module"
}
不同的语言拥有不同的解析器,比如 Javascript 的解析器和 CSS 的解析器就完全不同。
对相同的语言,也存在诸多的解析器,也就会生成多种 AST,如 babel
与 espree
。
在 AST Explorer (opens new window)中,列举了诸多语言的解析器(Parser),及转化器(Transformer)。
AST 的生成
AST 的生成这一步骤被称为解析(Parser),而该步骤也有两个阶段: 词法分析(Lexical Analysis)和语法分析(Syntactic Analysis)
词法分析 (Lexical Analysis)
词法分析用以将代码转化为 Token
流,维护一个关于 Token 的数组
// Code
a = 3
// Token
[
{ type: { ... }, value: "a", start: 0, end: 1, loc: { ... } },
{ type: { ... }, value: "=", start: 2, end: 3, loc: { ... } },
{ type: { ... }, value: "3", start: 4, end: 5, loc: { ... } },
...
]
词法分析后的 Token 流也有诸多应用,如:
- 代码检查,如 eslint 判断是否以分号结尾,判断是否含有分号的 token
- 语法高亮,如 highlight/prism 使之代码高亮
- 模板语法,如 ejs 等模板也离不开
语法分析 (Syntactic Analysis)
语法分析将 Token 流转化为结构化的 AST,方便操作
{
"type": "Program",
"start": 0,
"end": 5,
"body": [
{
"type": "ExpressionStatement",
"start": 0,
"end": 5,
"expression": {
"type": "AssignmentExpression",
"start": 0,
"end": 5,
"operator": "=",
"left": {
"type": "Identifier",
"start": 0,
"end": 1,
"name": "a"
},
"right": {
"type": "Literal",
"start": 4,
"end": 5,
"value": 3,
"raw": "3"
}
}
}
],
"sourceType": "module"
}
实践
可通过自己写一个解析器,将语言 (DSL) 解析为 AST 进行练手,以下两个示例是不错的选择
- 解析简单的 HTML 为 AST
- 解析 Marktodwn List 为 AST
或可参考一个最简编译器的实现 the super tiny compiler
webpack运行时runtime
Webpack Runtime
webpack
的 runtime,也就是 webpack 最后生成的代码,做了以下三件事:
__webpack_modules__
: 维护一个所有模块的数组。将入口模块解析为 AST,根据 AST 深度优先搜索所有的模块,并构建出这个模块数组。每个模块都由一个包裹函数(module, module.exports, __webpack_require__)
对模块进行包裹构成。__webpack_require__(moduleId)
: 手动实现加载一个模块。对已加载过的模块进行缓存,对未加载过的模块,执行 id 定位到__webpack_modules__
中的包裹函数,执行并返回module.exports
,并缓存__webpack_require__(0)
: 运行第一个模块,即运行入口模块
另外,当涉及到多个 chunk 的打包方式中,比如 code spliting
,webpack 中会有 jsonp
加载 chunk 的运行时代码。
以下是 webpack runtime
的最简代码
/******/ var __webpack_modules__ = [
,
/* 0 */ /* 1 */
/***/ (module) => {
module.exports = (...args) => args.reduce((x, y) => x + y, 0);
/***/
},
/******/
];
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/
}
/******/ // Create a new module (and put it into the cache)
/******/ var module = (__webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {},
/******/
});
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](
module,
module.exports,
__webpack_require__
);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/
}
/******/
/************************************************************************/
var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk.
(() => {
const sum = __webpack_require__(1);
sum(3, 8);
})();
对 webpack runtime
做进一步的精简,代码如下
const __webpack_modules__ = [() => {}];
const __webpack_require__ = (id) => {
const module = { exports: {} };
const m = __webpack_modules__[id](module, __webpack_require__);
return module.exports;
};
__webpack_require__(0);
使用动画表示 Webpack 的输入输出:
Rollup
在 Rollup 中,并不会将所有模块置于 modules
中使用 Module Wrapper 进行维护,它仅仅将所有模块铺平展开。
试举一例:
// index.js
import name from "./name";
console.log(name);
// name.js
const name = "shanyue";
export default name;
在打包后,直接把所有模块平铺展开即可,可见实时示例(opens new window)
// output.js
const name = "shanyue";
console.log(name);
对于 Rollup 这种方案,当两个模块中发生变量冲突如何解决?很简单,直接重新命名,看示例:
使用动画表示 Rollup 的输入输出:
运行时 Chunk 加载分析
一个 webpack
的运行时,包括最重要的两个数据结构:
__webpack_modules__
: 维护一个所有模块的数组。将入口模块解析为 AST,根据 AST 深度优先搜索所有的模块,并构建出这个模块数组。每个模块都由一个包裹函数(module, module.exports, __webpack_require__)
对模块进行包裹构成。__webpack_require__(moduleId)
: 手动实现加载一个模块。对已加载过的模块进行缓存,对未加载过的模块,根据 id 定位到__webpack_modules__
中的包裹函数,执行并返回module.exports
,并缓存。
code spliting
在 webpack 中,通过 import()
可实现 code spliting。假设我们有以下文件:
// 以下为 index.js 内容
import("./sum").then((m) => {
m.default(3, 4);
});
// 以下为 sum.js 内容
const sum = (x, y) => x + y;
export default sum;
我们将使用以下简单的 webpack
配置进行打包,具体示例可参考 node-examples:code-spliting(opens new window)
{
entry: './index.js',
mode: 'none',
output: {
filename: '[name].[contenthash].js',
chunkFilename: 'chunk.[name].[id].[contenthash].js',
path: path.resolve(__dirname, 'dist/deterministic'),
clean: true
},
optimization: {
moduleIds: 'deterministic',
chunkIds: 'deterministic'
}
}
运行时解析
通过观察打包后的文件 dist/deterministic/main.xxxxxx.js
,可以发现: 使用 import()
加载数据时,以上代码将被 webpack
编译为以下代码
__webpack_require__
.e(/* import() | sum */ 644)
.then(__webpack_require__.bind(__webpack_require__, 709))
.then((m) => {
m.default(3, 4);
});
此时 644
为 chunkId,观察 chunk.sum.xxxx.js
文件,以下为 sum
函数所构建而成的 chunk:
"use strict";
(self["webpackChunk"] = self["webpackChunk"] || []).push([
[644],
{
/***/ 709: /***/ (
__unused_webpack_module,
__webpack_exports__,
__webpack_require__
) => {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ default: () => __WEBPACK_DEFAULT_EXPORT__,
/* harmony export */
});
const sum = (x, y) => x + y;
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = sum;
/***/
},
},
]);
以下两个数据结构是加载 chunk
的关键:
__webpack_require__.e
: 加载 chunk。该函数将使用document.createElement('script')
异步加载 chunk 并封装为 Promise。self["webpackChunk"].push
: JSONP cllaback,收集 modules 至__webpack_modules__
,并将__webpack_require__.e
的 Promise 进行 resolve。
实际上,在 webpack
中可配置 output.chunkLoading
来选择加载 chunk 的方式,比如选择通过 import()
的方式进行加载。(由于在生产环境需要考虑 import 的兼容性,目前还是 JSONP 方式较多)
{
entry: './index.js',
mode: 'none',
output: {
filename: 'main.[contenthash].js',
chunkFilename: '[name].chunk.[chunkhash].js',
path: path.resolve(__dirname, 'dist/import'),
clean: true,
// 默认为 `jsonp`
chunkLoading: 'import'
}
})
加载 json、image 等非 Javascript 资源
在前端中,网页只能加载 javascript
脚本资源,即便在 node,也只能加载 javascript
与 json
资源。那类似 webpack
、rollup
及 vite
这类工具是如何加载图片、JSON 资源的呢?
在 webpack
等打包工具中,号称一切皆是模块。
当 webpack
在这类打包器中,需要加载 JSON 等非 Javascript 资源时,则通过模块加载器(loader
)将它们转化为模块的形式。
以 JSON 为例:
// user.json 中内容
{
"id": 10086,
"name": "shanyue",
"github": "https://github.com/shfshanyue"
}
在现代前端中,我们把它视为 module
时,使用 import
引入资源。
import user from "./user.json";
而我们的打包器,如 webpack
与 rollup
,将通过以下方式来加载 JSON 资源。
这样它将被视为普通的一副 Javascript
// 实际上的 user.json 被编译为以下内容
export default {
id: 10086,
name: "shanyue",
github: "https://github.com/shfshanyue",
};
在 webpack 中通过 loader 处理此类非 JS 资源,以下为一个 json-loader
的示例:
mini-code:json-loader (opens new window)中可见最小实现及示例。
module.exports = function (source) {
const json = typeof source === "string" ? source : JSON.stringify(source);
return `module.exports = ${json}`;
};
那图片是如何处理的呢?
import mainImage from "main.png";
<img src={mainImage} />;
更简单,它将替换为它自身的路径。示例如下
export default `$PUBLIC_URL/assets/image/main.png`;
那如何加载一个 CSS 脚本呢?此处涉及到各种 DOM API,以及如何将它抽成一个 .css
文件,复杂很多,下一篇介绍。
打包器(webpack/rollup) 如何加载 style 样式资源
在打包器,比如 webpack 中,需要借用 loader
将非 JS 资源转化成可识别为 Javascript 的 module。
现状
在 webpack 中,处理 css 稍微比较费劲,需要借用两个 loader 来做成这件事情:
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: ["style-loader", "css-loader"],
},
],
},
};
- css-loader (opens new window): 处理 CSS 中的
url
与@import
,并将其视为模块引入,此处是通过 postcss 来解析处理,postcss 对于工程化中 css 处理的影响力可见一斑。 - style-loader (opens new window): 将样式注入到 DOM 中
@import url(./basic.css);
.bg {
background: url(./shanyue.png);
}
原理
如果说现代前端中 Javascript 与 CSS 是其中最重要的两种资源,那么 Babel
与 PostCSS
就是前端工程化中最有影响力的两个编译器。
css-loader
的原理就是 postcss,借用 postcss-value-parser
解析 CSS 为 AST,并将 CSS 中的 url()
与 @import
解析为模块。
style-loader
用以将 CSS 注入到 DOM 中,原理为使用 DOM API 手动构建 style
标签,并将 CSS 内容注入到 style
中。
在其源码实现中,借用了许多运行时代码 style loader runtime (opens new window),而最简单的实现仅仅需要几行代码:
module.exports = function (source) {
return `
function injectCss(css) {
const style = document.createElement('style')
style.appendChild(document.createTextNode(css))
document.head.appendChild(style)
}
injectCss(\`${source}\`)
`;
};
使用 DOM API 加载 CSS 资源,由于 CSS 需要在 JS 资源加载完后通过 DOM API 控制加载,容易出现页面抖动,在线上低效且性能低下。且对于 SSR 极度不友好。
由于性能需要,在线上通常需要单独加载 CSS 资源,这要求打包器能够将 CSS 打包,此时需要借助于 mini-css-extract-plugin (opens new window)将 CSS 单独抽离出来。
深入 webpack 中如何抽离 CSS 的源码有助于加深对 webpack 的理解。
将打包后的 js 资源注入 html 中
如果最终打包生成的 main.js
既没有做 code spliting,也没有做 hash
化路径。大可以通过在 index.html
中手动控制 JS 资源。
<body>
<script src="main.js" defer />
</body>
但往往事与愿违:
main.js
即我们最终生成的文件带有 hash 值,如main.8a9b3c.js
。- 由于长期缓存优化的需要,入口文件不仅只有一个,还包括由第三方模块打包而成的
verdor.js
,同样带有 hash。 - 脚本地址同时需要注入
publicPath
,而在生产环境与测试环境的 publicPath 并不一致
因此需要有一个插件自动做这种事情。在 webpack 的世界里,它是 html-webpak-plugin (opens new window),在 rollup 的世界里,它是 @rollup/plugin-html (opens new window)。
而注入的原理为当打包器已生成 entryPoint 文件资源后,获得其文件名及 publicPath
,并将其注入到 html 中
以 html-webpack-plugin
为例,它在 compilation
处理资源的 processAssets
获得其打包生成的资源。伪代码如下,可在 mini-node:html-webpack-plugin (opens new window)获得源码并运行示例。
class HtmlWebpackPlugin {
constructor(options) {
this.options = options || {};
}
apply(compiler) {
const webpack = compiler.webpack;
compiler.hooks.thisCompilation.tap("HtmlWebpackPlugin", (compilation) => {
// compilation 是 webpack 中最重要的对象,文档见 [compilation-object](https://webpack.js.org/api/compilation-object/#compilation-object-methods)
compilation.hooks.processAssets.tapAsync(
{
name: "HtmlWebpackPlugin",
// processAssets 处理资源的时机,此阶段为资源已优化后,更多阶段见文档
// https://webpack.js.org/api/compilation-hooks/#list-of-asset-processing-stages
stage: webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE,
},
(compilationAssets, callback) => {
// compilationAssets 将得到所有生成的资源,如各个 chunk.js、各个 image、css
// 获取 webpac.output.publicPath 选项,(PS: publicPath 选项有可能是通过函数设置)
const publicPath = getPublicPath(compilation);
// 本示例仅仅考虑单个 entryPoint 的情况
// compilation.entrypoints 可获取入口文件信息
const entryNames = Array.from(compilation.entrypoints.keys());
// entryPoint.getFiles() 将获取到该入口的所有资源,并能够保证加载顺序!!!如 runtime-chunk -> main-chunk
const assets = entryNames
.map((entryName) =>
compilation.entrypoints.get(entryName).getFiles()
)
.flat();
const scripts = assets.map((src) => publicPath + src);
const content = html({
title: this.options.title || "Demo",
scripts,
});
// emitAsset 用以生成资源文件,也是最重要的一步
compilation.emitAsset(
"index.html",
new webpack.sources.RawSource(content)
);
callback();
}
);
});
}
}
webpack 中什么是 HMR,原理是什么
HMR,Hot Module Replacement,热模块替换,见名思意,即无需刷新在内存环境中即可替换掉过旧模块。与 Live Reload 相对应。
PS: Live Reload,当代码进行更新后,在浏览器自动刷新以获取最新前端代码。
在 webpack 的运行时中 __webpack__modules__
用以维护所有的模块。
而热模块替换的原理,即通过 chunk
的方式加载最新的 modules
,找到 __webpack__modules__
中对应的模块逐一替换,并删除其上下缓存。
其精简数据结构用以下代码表示:
// webpack 运行时代码
const __webpack_modules = [
(module, exports, __webpack_require__) => {
__webpack_require__(0);
},
() => {
console.log("这是一号模块");
},
];
// HMR Chunk 代码
// JSONP 异步加载的所需要更新的 modules,并在 __webpack_modules__ 中进行替换
self["webpackHotUpdate"](0, {
1: () => {
console.log("这是最新的一号模块");
},
});
其下为更具体更完整的流程,每一步都涉及众多,有兴趣的可阅读 webpack-dev-server
及开发环境 webpack 运行时的源码。
webpack-dev-server
将打包输出 bundle 使用内存型文件系统控制,而非真实的文件系统。此时使用的是 memfs (opens new window)模拟 node.jsfs
API- 每当文件发生变更时,
webpack
将会重新编译,webpack-dev-server
将会监控到此时文件变更事件,并找到其对应的module
。此时使用的是 chokidar (opens new window)监控文件变更 webpack-dev-server
将会把变更模块通知到浏览器端,此时使用websocket
与浏览器进行交流。此时使用的是 ws(opens new window)- 浏览器根据
websocket
接收到 hash,并通过 hash 以 JSONP 的方式请求更新模块的 chunk - 浏览器加载 chunk,并使用新的模块对旧模块进行热替换,并删除其缓存
构建性能优化
使用 speed-measure-webpack-plugin (opens new window)可评估每个 loader/plugin 的执行耗时。
更快的 loader: swc
在 webpack
中耗时最久的当属负责 AST 转换的 loader。
当 loader 进行编译时的 AST 操作均为 CPU 密集型任务,使用 Javascript 性能低下,此时可采用高性能语言 rust 编写的 swc
。
比如 Javascript 转化由 babel
转化为更快的 swc (opens new window)。
module: {
rules: [
{
test: /\.m?js$/,
exclude: /(node_modules)/,
use: {
loader: "swc-loader",
},
},
];
}
持久化缓存: cache
webpack5
内置了关于缓存的插件,可通过 cache 字段 (opens new window)配置开启。
它将 Module
、Chunk
、ModuleChunk
等信息序列化到磁盘中,二次构建避免重复编译计算,编译速度得到很大提升。
{
cache: {
type: "filesystem";
}
}
如对一个 JS 文件配置了 eslint
、typescript
、babel
等 loader
,他将有可能执行五次编译,被五次解析为 AST
acorn
: 用以依赖分析,解析为acorn
的 ASTeslint-parser
: 用以 lint,解析为espree
的 ASTtypescript
: 用以 ts,解析为typescript
的 ASTbabel
: 用以转化为低版本,解析为@babel/parser
的 ASTterser
: 用以压缩混淆,解析为acorn
的 AST
而当开启了持久化缓存功能,最耗时的 AST 解析将能够从磁盘的缓存中获取,再次编译时无需再次进行解析 AST。
得益于持久化缓存,二次编译甚至可得到与 Unbundle 的 vite 等相近的开发体验
在 webpack4 中,可使用 cache-loader (opens new window)仅仅对 loader
进行缓存。需要注意的是该 loader 目前已是 @depcrated
状态。
module.exports = {
module: {
rules: [
{
test: /\.ext$/,
use: ["cache-loader", ...loaders],
include: path.resolve("src"),
},
],
},
};
多进程: thread-loader
thread-loader (opens new window)为官方推荐的开启多进程的 loader
,可对 babel 解析 AST 时开启多线程处理,提升编译的性能。
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: "thread-loader",
options: {
workers: 8,
},
},
"babel-loader",
],
},
],
},
};
在 webpack4
中,可使用 happypack plugin (opens new window),但需要注意的是 happypack
已经久不维护了。