关于前端:Rollup源码模块打包与TreeShaking

重点剖析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();
}

分为三个步骤:

  1. 创立依赖图
  2. 排序模块
  3. 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到这里就完结了,次要流程蕴含:

  1. 解析文件门路
  2. 实例化模块并收集
  3. 将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完结,次要流程蕴含:

  1. 检测循环依赖
  2. 排序依赖图中所有模块
  3. 按排序后的程序为各模块的ast绑定变量

Tree-Shaking

开始看Tree-Shaking的实现前,先简略介绍两个重要的办法:graph.includeStatementsnode.hasEffects

  1. includeStatements:从办法名来看就晓得这个办法的作用和Tree-Shaking的概念是相同的,rollup中所有node的included属性(代表这个节点是否应该被bundle蕴含)初始状态都是false。换句话说,默认所有节点都是不被蕴含的,这个办法实际上是在标记哪些节点应该被蕴含,而不是应该哪些节点应该被删除。
  2. 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源码相比还是更好了解一些,很适宜用来学习打包器的工作原理。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理