重点剖析 rollup 源码中模块打包与 Tree-Shaking 的实现细节,举荐理解打包器基本功能后再浏览。不会介绍 rollup 的个性以及 Tree-Shaking、ast 的概念,版本为 2.52.0。
前言
在开始之前,先介绍一下打包过程中负责解决外围逻辑的几个类:
- Graph:治理模块关系的依赖图,它能够获取到本次参加打包的所有模块。能够对模块进行排序、检测循环援用、检测无用代码。
- Node:代表 ast 上的单个节点,各类型节点全副是 Node 的子类,根本都重写了 Node 的一些办法,这点很要害,前面会常常看到递归操作 ast 上的所有节点,每个 node 调用的办法很可能只是名字雷同,逻辑不同。换句话说,就是设计模式中的组合模式。负责查看本身是否有副作用(sideEffects,代表是否应该被 bundle 蕴含)、在 scope 中申明变量、查找变量、调用 module 的办法收集依赖等。
- Module:模块,保留着文件源代码,能够收集导入、导出的值、查找变量所在作用域等,根本都是被 node、graph 应用。
- Scope:作用域,供 graph、module、node 应用。variables 属性保留本人外部的所有变量,children 和 parent 属性也是 scope,组成作用域链。
- ModuleLoader:供 graph 应用,负责获取文件门路、读取内容等,最初实例化 Module 类。
从 rollup 的启动入口开始
rollupInternal
函数,次要是对传入的 options 参数做校验、转换,调用插件改写 options 等,与本篇主题无关,后续无关逻辑将略过。
之后实例化 Graph 并调用 graph.build,本篇提及的所有外围逻辑都在 build 中执行。
async build(): Promise<void> {await this.generateModuleGraph();
this.sortModules();
this.includeStatements();}
分为三个步骤:
- 创立依赖图
- 排序模块
- Tree-Shaking
创立依赖图
入口在 graph.generateModuleGraph
moduleLoader.addEntryModules 外部会遍历 inputs 数组执行 moduleLoader.loadEntryModule。
await this.moduleLoader.addEntryModules(normalizeEntryModules(this.options.input), true))
unresolvedEntryModules.map(({ id, importer}): Promise<Module> => this.loadEntryModule(id, true, importer, null)
)
loadEntryModule 会调用resolveId
(负责解析门路,rollup 中的 id 就是指模块门路),咱们平时开发写的 import 语句个别不会带文件扩展名,所以 resolveId 次要负责的是拼接扩展名后尝试查找文件。
找到文件后调用 moduleLoader.fetchModule
筹备创立模块,先尝试在 graph.modulesById
获取 id 对应的 module,如果没有曾经创立的缓存,才实例化 Module。
private async fetchModule({ id, meta, moduleSideEffects, syntheticNamedExports}: ResolvedId,
importer: string | undefined,
isEntry: boolean
): Promise<Module> {
// 查找缓存
const existingModule = this.modulesById.get(id);
if (existingModule instanceof Module) {return existingModule;}
const module: Module = new Module();
this.modulesById.set(id, module);
// ...
return module;
}
实例化 Module 后在 modulesById 设置缓存,避免这个模块后续被其余模块援用时反复创立。
调用 module.setSource
读取文件内容后调用 acorn 将模块文件内容转换为 ast。
// 省略了很多代码
setSource() {
this.astContext = {// 一些办法}
this.scope = new ModuleScope(this.graph.scope, this.astContext);
this.ast = new Program(ast, { context: this.astContext, type: 'Module'}, this.scope);
}
创立 astContext,这个对象须要重点关注,代表 ast 节点所在模块上下文,领有一些将 ast 节点信息收集到 module 的办法,
依赖、导出收集都是基于它实现的。
实例化 ModuleScope
(模块作用域) 和Program
(代表 ast 上的 program 节点,Node 子类的一种),astContext 会被传给这些实例。
实例化 Program 的过程就是将整个 acorn 解析的原 ast 转换成 node 实例组成的树。
下文内容倡议对照 ast 构造来看,不便了解各类型节点代表的含意。
先看一下所有节点的基类:Node 的构造函数
constructor(
esTreeNode: GenericEsTreeNode,
parent: Node | {context: AstContext; type: string},
parentScope: ChildScope
) {this.createScope(parentScope); // 赋值节点所在作用域
this.context = parent.context; // 赋值 context 属性保障任意层节点都能够应用 astContex
this.parseNode(esTreeNode); // 转换 ast
this.initialise(); // 节点初始化逻辑}
createScope 外部在尝试创立 this.scope,有些类型的节点会产生作用域,比方 FunctionNode。
如果某个节点创立了变量,要放在 this.scope 中。
parseNode 会将这个节点除子节点以外的属性赋值给 node 实例,子节点会先转换为 node 实例再赋值,
parseNode(esTreeNode: GenericEsTreeNode): void {
// 省略了一些代码
for (const [key, value] of Object.entries(esTreeNode)) {if (typeof value !== 'object' || value === null) {
// 赋值属性
this[key] = value;
} else if (Array.isArray(value)) {this[key] = [];
for (const child of value) {this[key].push(
child === null
? null
// 实例化子节点再赋值,nodeConstructors 蕴含各类型节点构造函数
// 联合上文 constructor 处调用 parseNode 能够看出实际上是在递归地执行 parseNode
: new this.context.nodeConstructors[child.type](child, this, this.scope)
);
}
}
}
** 开篇提到过很多 Node 子类都重写了父类的办法,比方 initialise。
结构各节点的流程比拟长,这里只说波及本篇内容的要害类型节点 initialise 逻辑:**
-
ImportDeclaration
调用context.addImport
将 ImportDeclaration.source(这个就是 import 的门路)放进module.sources
,
sources 属性会在后续被用来创立依赖模块实例,之后赋值module.importDescriptions
,这个对象的 keys 是变量名,后续会被用来查找导入的变量。
module 属性临时为 null,之后会被赋值为依赖模块实例。
这个过程就实现了依赖收集。this.importDescriptions[specifier.local.name] = { module: null as never, // filled in later name, source, start: specifier.start };
- VariableDeclaration
调用this.scope.addDeclaration
将本身放到scope.variables
,后续一些应用变量的节点比方 CallExpression(函数调用) 的参数就会通过 scope 查找这个变量。 - ExportNamedDeclaration
调用context.addExport
将本身放到module.exports
,实现收集导出。
一个模块的 ast 操作完结后,fetchModule 执行还未完结。
private async fetchModule({ id, meta, moduleSideEffects, syntheticNamedExports}: ResolvedId,
importer: string | undefined,
isEntry: boolean
): Promise<Module> {
// 查找缓存
const existingModule = this.modulesById.get(id);
if (existingModule instanceof Module) {return existingModule;}
const module: Module = new Module();
this.modulesById.set(id, module);
await this.addModuleSource(id, importer, module); // 这里在执行下面说的 module.setSource
await this.fetchStaticDependencies(module); // 当初执行到 fetchStaticDependencies,从名字能够看出是在创立依赖模块
module.linkImports();
return module;
}
moduleLoader.fetchStaticDependencies
这一步就是遍历之前收集的 module.sources
,顺次执行fetchModule
。
换句话说,递归对 sources 执行同样的逻辑:实例化 module,转换 ast,直到以后模块没有依赖时完结。
private async fetchStaticDependencies(module: Module): Promise<void> {
for (const dependency of await Promise.all(
Array.from(module.sources, async source =>
// fetchResolvedDependency 最初还会调用 fetchModule
this.fetchResolvedDependency(
source,
module.id,
(module.resolvedIds = // 依赖模块实例保留在 import 模块的 resolvedIds 中
module.resolvedIds ||
this.handleResolveId(await this.resolveId(source, module.id, EMPTY_OBJECT),
source,
module.id
))
)
)
)) {
// 被导入模块实例化后会被收集到导入模块的 dependencies 属性中
module.dependencies.add(dependency);
dependency.importers.push(module.id);
}
}
调用module.linkImports
,遍历之前收集的module.importDescriptions
,在 resolvedIds 中查找对应的模块实例并赋值之前为 null 的 moudle 属性。
generateModuleGraph 到这里就完结了,次要流程蕴含:
- 解析文件门路
- 实例化模块并收集
- 将 ast 转换为 node 实例组成的树,转换过程中创立 scope(链)、依赖收集、导出收集、在 scope 中申明变量等。
排序模块
private sortModules() {const { orderedModules, cyclePaths} = analyseModuleExecution(this.entryModules);
for (const cyclePath of cyclePaths) {// 打印正告}
this.modules = orderedModules;
for (const module of this.modules) {
// 绑定变量
module.bindReferences();}
}
analyseModuleExecution 函数逻辑比较简单,就是对模块进行排序,顺便做循环依赖检测,逻辑还是很清晰的,间接贴代码。
export function analyseModuleExecution(entryModules: Module[]): {cyclePaths: string[][];
orderedModules: Module[];} {const cyclePaths: string[][] = [];
const analysedModules = new Set<Module | ExternalModule>();
const parents = new Map<Module | ExternalModule, Module | null>();
const orderedModules: Module[] = [];
const analyseModule = (module: Module | ExternalModule) => {if (module instanceof Module) {for (const dependency of module.dependencies) {if (parents.has(dependency)) {
// 一个模块在递归未完结时被援用就判断有循环依赖
if (!analysedModules.has(dependency)) {cyclePaths.push(getCyclePath(dependency as Module, module, parents));
}
continue;
}
// 先将本身放到 parents 汇合中,之后递归执行 analyseModule
parents.set(dependency, module);
analyseModule(dependency);
}
orderedModules.push(module);
}
// 解析所有 dep 后放到 analysedModules 中
analysedModules.add(module);
};
for (const curEntry of entryModules) {if (!parents.has(curEntry)) {parents.set(curEntry, null);
analyseModule(curEntry);
}
}
return {cyclePaths, orderedModules};
}
次要逻辑就是递归调用 analyseModule,利用 parents 和 analysedModules 这两个汇合来检测循环援用。
举例说明:a->b->c->a(箭头代表导入),解析 c 模块时,a 模块在 parents 中,但不在 analysedModules 中(递归未出栈,此时还在执行 c 的 analyse 逻辑),断定有循环依赖会打印正告日志。
orderedModules 依照 analysedModules 的出栈程序:a->b->c 排序为 c ->b->a。
之后遍历排序好的模块,顺次调用 module.bindReferences。
这个办法就是为一些应用到变量的节点如 CallExpression 绑定变量 (或者说确认变量所在的作用域),比方 import 语句对应的变量应该从另一个 module 的 scope 中查找。
能够看出对模块进行排序的目标就是从内到外为各节点绑定变量,绑定后再供导入本身的模块应用。
// dep
export const a = 1;
// entry
import {a} from './dep';
console.log(a);
这个示例有 3 个 Identifiera
,别离对应 import、export、函数参数,Identifier 类型满足 this.variable 为 null 就会查找对应变量。
bind 流程如下:
ImportDeclaration 会进行为子节点执行 bind。
dep 模块的 a 是 ExportNamedDeclaration,variable 不为 null,
因为在实例化 VariableDeclaration 过程中调用了 addDeclaration(上文有提及)。
所以只有作为参数的 a 才满足需要查找的条件。
查找变量的代码比拟多,而且每种 Scope 类型查找形式有所区别,这里就不全副贴出来了,对细节感兴趣的话能够从各 Scope 的 findVariable 办法作为入口查看,
还是比拟容易看懂的。
特地阐明一下查找导入的变量过程 (源码在module.traceVariable
):获取 importDescriptions[variableName].module 找到找到变量所在的模块,
再读取这个模块的 exports[variableName]。
// ModuleScope 查找变量的优先级:// 以后执行所处的作用域 > 在缓存中获取曾经找到的全局变量 > 模块作用域内本地变量 > 模块导入的变量 > scope.parent 链查找。findVariable(name: string): Variable {const knownVariable = this.variables.get(name) || this.accessedOutsideVariables.get(name);
if (knownVariable) {return knownVariable;}
const variable = this.context.traceVariable(name) || this.parent.findVariable(name);
// 看似全局变量看似优先级很高,但如果在模块本地就能找到同名变量的话是不会设置缓存的
if (variable instanceof GlobalVariable) {this.accessedOutsideVariables.set(name, variable);
}
return variable;
}
sortModules 完结,次要流程蕴含:
- 检测循环依赖
- 排序依赖图中所有模块
- 按排序后的程序为各模块的 ast 绑定变量
Tree-Shaking
开始看 Tree-Shaking 的实现前,先简略介绍两个重要的办法:graph.includeStatements
和node.hasEffects
- includeStatements:从办法名来看就晓得这个办法的作用和 Tree-Shaking 的概念是相同的,rollup 中所有 node 的 included 属性 (代表这个节点是否应该被 bundle 蕴含) 初始状态都是 false。换句话说,默认所有节点都是不被蕴含的,这个办法实际上是在标记哪些节点应该被蕴含,而不是应该哪些节点应该被删除。
- hasEffects:判断一个节点是否应该被 bundle 蕴含就是通过 node.hasEffects,各类型节点根本都重写了此办法,比方 ImportDeclaration 就间接视为无副作用间接返回 false。
外部还会从多个方面判断是否有副作用,别离应用这些办法:
- hasEffectsWhenCalledAtPath
- hasEffectsWhenAccessedAtPath
- hasEffectsWhenAssignedAtPath
判断节点是否副作用的根本要点就是调用了全局函数(console.log、setTimeout 等)、批改了全局变量。
开始前举荐看一下重构 tree-shaking 的 PR,尽管年代比拟长远但核心思想根本没有变动。
hasEffects 过程非常复杂并且集体认为代码可读性较低,所以举例简略例子来阐明流程:
import {a} from './dep';
function f() {console.log(a);
}
f();
这段示例代码执行到 CallExpression(f 函数的调用)时 this.callee(代表被调用的函数,Identifier 类型节点).hasEffectsWhenCalledAtPath 就会返回 true。
过程是遍历 FunctionDeclaration 的 body 节点,顺次调用 hasEffects,因为函数体内调用了 console.log,所以认定 f 有副作用。
// graph.includeStatements 外围逻辑,省略了很多代码
do {
this.needsTreeshakingPass = false;
for (const module of this.modules) {module.include();
}
} while (this.needsTreeshakingPass);
// module.include
include(): void {const context = createInclusionContext();
if (this.ast!.shouldBeIncluded(context)) this.ast!.include(context, false);
}
理解 includeStatements 和 hasEffects 之后咱们开始看 tree-shaking:
shouldBeIncluded 外部会遍历各节点调用 node.hasEffects,如果任一子节点 hasEffects 则返回 true(其实就是 node.children.some(hasEffects))。
它的目标只是确认该模块是否应该被 bundle 蕴含,如果返回 true 就从 Program 节点开始执行 node.include。
include 外部将节点本身 included 属性设为 true,代表这个节点须要被打包,再遍历子节点执行if (node.hasEffects()) node.include();
,深度遍历执行。
这个过程中如果有节点产生新节点的话就要将 graph.needsTreeshakingPass
赋值为 true,保障在执行完结后持续 graph.includeStatements 中的 while 循环。
举个例子阐明这一步的目标:
// dep
export let a = 1;
a = 2;
// entry
import {a} from './dep';
console.log(a);
CallExpression.include 过程中会调用 this.callee.includeCallArguments(context, this.arguments);
遍历所有参数执行 arg.include。
这时参数 a
被标记 included,没问题,但 dep 模块中对 a 进行赋值的 AssignmentExpression 节点也应该保留,这时之前设为 true 的 needsTreeshakingPass 就派上用场了。
在新一轮循环中,因为被赋值的 a
节点在上次循环中 included 被设为 true,所以 AssignmentExpression.hasEffects 也返回 true。示意这个节点对一个有副作用的节点进行了赋值,所以 AssignmentExpression 节点最终也会标记为 included。
tree-shaking 完结,之后的 generateBundle 就是调用各节点的 render 办法,依据 included 属性决定是否须要写入节点对应的代码。标记好各节点 included 后续流程根本没什么可说的。
总结
尽管 rollup 的源码没有正文导致看起来很累,但和简单的 webpack 源码相比还是更好了解一些,很适宜用来学习打包器的工作原理。