Rollup源码:模块打包与Tree-Shaking

灼目闪光 -
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.includeStatementsnode.hasEffects

includeStatements:从方法名来看就知道这个方法的作用和Tree-Shaking的概念是相反的,rollup中所有node的included属性(代表这个节点是否应该被bundle包含)初始状态都是false。换句话说,默认所有节点都是不被包含的,这个方法实际上是在标记哪些节点应该被包含,而不是应该哪些节点应该被删除。hasEffects:判断一个节点是否应该被bundle包含就是通过node.hasEffects,各类型节点基本都重写了此方法,比如ImportDeclaration就直接视为无副作用直接返回false。
内部还会从多个方面判断是否有副作用,分别使用这些方法:hasEffectsWhenCalledAtPathhasEffectsWhenAccessedAtPathhasEffectsWhenAssignedAtPath

判断节点是否副作用的基本要点就是调用了全局函数(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源码相比还是更好理解一些,很适合用来学习打包器的工作原理。

特别申明:本文内容来源网络,版权归原作者所有,如有侵权请立即与我们联系(cy198701067573@163.com),我们将及时处理。

Tags 标签

前端javascripttypescriptnode.js

扩展阅读

加个好友,技术交流

1628738909466805.jpg