Rollup源码:模块打包与Tree-Shaking
重点分析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[source] = // 依赖模块实例保存在import模块的resolvedIds中
module.resolvedIds[source] ||
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源码相比还是更好理解一些,很适合用来学习打包器的工作原理。
转载自:https://segmentfault.com/a/1190000041395067