369 - 《如何开发构建工具》
准备明天周会分享,时间 30m 左右,但想了太多,感觉内容够一本书了。时间有限,先写了一部分,应该能凑够 30m 了。后面的内容找时间慢慢补。
大纲
- 手写一个 Toy Version
- Toy Version
- 构建器的基本流程
- 构建器基础
- 写 Rust 就是写数据结构
- module、module graph、chunk、chunk graph
- JavaScript、TypeScript、JSX
- CSS、CSS Modules、Auto Prefixer、Inline or Extract
- 语法错误和兼容
- AST 的一生
- Resolve
- Alias、Externals、Remote Externals
- Runtime
- Platform(Browser、Node、Worker 等)
- Source Map
- JSON
- Assets 和 Base64
- Minify
- Rust 语言的错误处理
- Binding
- 写 Rust 就是写数据结构
- 构建器进阶
- 产物格式(ESM、CJS、UMD、IIFE 等)
- Worker
- WASM
- Polyfill
- Top Level Await
- SVG
- Hash
- Dead Code Detect
- Circular Dependency Check
- Optimize Package Imports
- 多 Entry
- 产物性能
- 基于 Rust 的插件体系
- Bundless
- 构建器高级
- Hot Module Replacement、Watch Mode
- Tree Shaking
- Code Splitting
- Scope Hoisting
- Constant folding
- 基于 Rust 的构建性能
- 缓存
- 面向用户的插件设计
- 构建器的工程化
- 日志
- 火焰图、puffin
- Benchmark
- Less
- 发包
- 生产级的构建器
- 用 E2E 测试确保稳定性
- CodeMod
- Edge Case 收集
- Tree Shaking 的稳定性
- 监控
- 备选
- Case Cansitive Detect
- Stats
- Manifest
手写一个 Toy Version
Toy Version
注:这部分从 284 - 《手写 Toy Bundler》 复制而来。
早在写 Mako 之前,先写了一个构建器的 Toy Version。代码见 https://github.com/sorrycc/toy-bundler 。
基本思路是这样,
1、resolve config,解析用户配置,包括确定入口文件
2、build,生成模块依赖图谱
3、generate,根据依赖图谱生成代码
build 是以入口文件为起点构建依赖图谱。基本思路如下,从入口文件开始,做 load、parse、transform、analyze_deps 和 resolve,然后把依赖添加到队列里接着跑,直到分析完所有文件为止。
const seen = new Set();
const queue = [entryPoint];
while (queue.length) {
const module = queue.shift()!;
if (seen.has(module)) continue;
seen.add(module);
// 1、load
let content = load();
// 2、parse
let ast = parse(content);
// 3、transform
let { content } = transform(ast);
// 4、analyze
let { deps, exports, imports, ... } = analyze(ast, content);
// 5、resolve
let resolvedDepsMap = resolve(deps, module);
let metaData = { id, content, ast, deps, resolvedDepsMap, imports, exports, ... };
modules.set(module, metaData);
queue.push(...resolvedDepsMap.values());
}
generate 是基于 build 生成的依赖图谱生成最终代码,包含 runtime 的处理、module 转 code 以及将其封成可以在浏览器里跑的版本,以及 tree-shaking、code splitting 等也是在这边处理。
module 渲染逻辑如下,
import foo from './foo';
foo();
↓ ↓ ↓
const foo = require(/* ./foo module id */123);
foo();
↓ ↓ ↓
define(/* current module id */0, (module, exports, require) => {
const foo = require(/* ./foo module id */123);
foo();
});
最后通过一个 runtime 把所有内容拼起来,针对 node、browser 会有不同的 runtime。
const modules = new Map();
const define = (name, moduleFactory) => {
modules.set(name, moduleFactory);
};
const requireModule = (name) => {
const moduleFactory = modules.get(name);
const module = {
exports: {},
};
moduleFactory(module, module.exports, requireModule);
return module.exports;
};
requireModule(/* entry module id */0);
构建器的基本流程
没画 Mako 的图,先以 esbuild 的架构图用于讲解下 Mako 的流程,大差不差。
分两个阶段,1)Build,2)Generate。
Build 阶段的目的是为了构建一个完整的 Module Graph。所以会先收集 Entry,然后以 Entry 为入口找到所有模块,再对每个模块并行做 Load、Parse、Transform、Analyze Deps、Resolve、Add to Module Graph 的过程。
Generate 阶段是真正复杂的部分,很多复杂的功能都是在这个阶段完成。在 Mako 的流程里,会先做 Tree Shaking,再做 Chunk 的 Group 和 Optimize 以生成 Chunk Group,再做一次 transform,再针对每个 Chunk 里的每个模块的 Runtime 代码注入、Ast to Code、压缩、Source Map 生成等。
Transform 简单来说就是针对 AST 的修改。我们会发现,Build 和 Generate 里都有 Transform。Build 里的 Transform 里会做 React 相关、Env Replacer 环境变量替换、Try Resolve 允许 try 语句里 require 模块失败、Define 替换及常量折叠、import() 到 require 的转换以禁用拆包、基于 targets 浏览器等平台版本的 JS 语法转换等;Generate 里的 Transform 会做 ESM 到 CommonJS 的转换,模块里 import 语句 source 依赖的替换等。为啥要拆成两部分做?因为有些事情不能做太早,比如在 T