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

35次阅读

共计 9831 个字符,预计需要花费 25 分钟才能阅读完成。

重点剖析 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 = // 依赖模块实例保留在 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 到这里就完结了,次要流程蕴含:

  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 源码相比还是更好了解一些,很适宜用来学习打包器的工作原理。

正文完
 0