Skip to content

Tree Shaking

从官方提供的 文档可知rollup 主要的 tree-shaking 配置项如下:

Annotations

默认值为 true,表示 rollup 会根据注解来判断是否保留某些代码。注解的格式为 @__PURE__#__PURE__@__NO_SIDE_EFFECTS__#__NO_SIDE_EFFECTS__rollup 会根据注解来判断是否保留某些代码。

  1. @__PURE__#__PURE__ 的注释标记特定的函数调用或构造函数调用为无副作用。这意味着 rollup 将执行 tree-shaking 优化,即移除调用,除非返回值在一些未 tree-shaking 优化的代码中被使用。这些注解需要紧跟在调用之前才能生效。以下代码将完全除屑优化,除非将该选项设置为 false,否则它将保持不变。

    js
    /*@__PURE__*/ console.log('side-effect');
    
    class Impure {
      constructor() {
        console.log('side-effect');
      }
    }
    
    /*@__PURE__ There may be additional text in the comment */ new Impure();
  2. @__NO_SIDE_EFFECTS__ 或者 #__NO_SIDE_EFFECTS__ 的注释标记函数声明本身是无副作用的。当一个函数被标记为没有副作用时,所有对该函数的调用都将被认为是没有副作用的。下面的代码将被完全 tree-sharking 优化。

    js
    /*@__NO_SIDE_EFFECTS__*/
    function impure() {
      console.log('side-effect');
    }
    
    /*@__NO_SIDE_EFFECTS__*/
    const impureArrowFn = () => {
      console.log('side-effect');
    };
    
    impure(); // <-- call will be considered as side effect free
    impureArrowFn(); // <-- call will be considered as side effect free

annotationsrollup 中的实现

rollup 内部为 ast 的每一个节点都实现了 ast class node 类,当解析完由原生 swc 生成的 ast 后,会通过所生成的 ast 结构来递归实例化每一个所需的 ast class node。实例后的 ast 树才是 rollup 后续操作的 ast 树,也就是说后续所有操作均是基于实例后的 ast 树。

ts
export default class Module {
  async setSource({
    ast,
    code,
    customTransformCache,
    originalCode,
    originalSourcemap,
    resolvedIds,
    sourcemapChain,
    transformDependencies,
    transformFiles,
    ...moduleOptions
  }: TransformModuleJSON & {
    resolvedIds?: ResolvedIdMap;
    transformFiles?: EmittedFile[] | undefined;
  }): Promise<void> {
    // 省略其他代码
    this.ast = new nodeConstructors[ast.type](
      programParent,
      this.scope
    ).parseNode(ast) as Program;
    // 省略其他代码
  }
}

需要了解的是所有的 ast node class 均继承 NodeBase,同时在 ast node class 的构造函数中,会通过 parseNode 来进一步解析 ast children node class,此时会通过 super.parseNode(esTreeNode); 来调用 NodeBaseparseNode 方法。

ts
export const ANNOTATION_KEY = '_rollupAnnotations';
export const INVALID_ANNOTATION_KEY = '_rollupRemoved';

export default class NodeBase {
  parseNode(esTreeNode: GenericEsTreeNode): this {
    for (const [key, value] of Object.entries(esTreeNode)) {
      if (key.charCodeAt(0) === 95 /* _ */) {
        if (key === ANNOTATION_KEY) {
          this.annotations = value as RollupAnnotation[];
        } else if (key === INVALID_ANNOTATION_KEY) {
          (this as unknown as Program).invalidAnnotations =
            value as RollupAnnotation[];
        }
      }
    }
  }
}

此时就会去收集 ast node 前置的注释,并将其赋值给 this.annotations

compat estree ast

这里需要注意一点,标准的 estree ast 本身并不包含注释。

rollup 通过在 rust 层借助 swc 的能力最终生成的是 compat estree ast,这种 ast 结构包含了 estree ast 所需的所有信息,同时也进一步扩展了一些属性,例如 _rollupAnnotations,会包含 statements 前的注释节点信息,细节可参考 Optimize Ast Compatibility

Manual Pure Functions

默认值为 true,表示 rollup 会根据手动设置的纯函数来判断是否保留某些代码。

Module Side Effects

tree shaking 中扮演重要的角色。当设置为 false 时,对于未引用的其他模块和外部依赖模块,会进行 tree shaking

js
import { foo } from './foo.js';
import { unused } from 'external-a';
import 'external-b';
console.log(42);
js
import { bar } from './bar.js';
console.log(bar);
export const foo = 'foo';
js
export const bar = 'bar';

outputs:

js
// treeshake.moduleSideEffects === true
// import { foo } from './foo.js' is equivalent to import './foo.js';
import 'external-a';
import 'external-b';

const bar = 'bar';
console.log(bar);

console.log(42);
js
console.log(42);

若指定具有副作用的外部或内部依赖模块,若依赖模块的引用没有被使用,则相当于解析为

js
import { a } from 'external-a';
// ==>
import 'external-b';

TIP

模块加载 css 通常会使用

js
import './style.css';

方式进行依赖加载。若 style.css 指定为无副作用,那么 style.css 模块在 tree-shaking 时会被移除,这是不合理的。

因此通常情况下 style.css 会指定具有副作用,以确保 style.css 模块的副作用会在 tree-shaking 时被保留。

若使用了依赖模块的引用变量,则会扫描重新导出模块是否存在副作用的问题取决于变量的重新导出方式。

js
// 输入文件 a.js
import { foo } from './b.js';
import { demo } from 'demo';
console.log(foo, demo);

// 输入文件 b.js
// 直接重新导出将忽略副作用
export { foo } from './c.js';
console.log('this side-effect is ignored');

// 输入文件 c.js
// 非直接重新导出将包含副作用
import { foo } from './d.js';
foo.mutated = true;
console.log('this side-effect and the mutation are retained');
export { foo };

// 输入文件 d.js
console.log('d.js');
export const foo = 42;

也就是说对于直接重新导出的模块且该模块指定为无副作用,那么 rollup 会忽略其副作用,而非直接重新导出的模块,rollup 会保留该模块的副作用,即使该模块指定为无副作用。这与 moduleSideEffects 的默认值为 true 是不一样的,后者会保留所有可达模块的副作用。

js
// treeshake.moduleSideEffects === false 时的输出
import { demo } from 'demo';

console.log('d.js');
const foo = 42;

foo.mutated = true;
console.log('this side-effect and the mutation are retained');

console.log(foo, demo);


// treeshake.moduleSideEffects === true 时的输出
import { demo } from 'demo';

console.log('d.js');
const foo = 42;

foo.mutated = true;
console.log('this side-effect and the mutation are retained');

console.log('this side-effect is ignored');

console.log(foo, demo);

Analysis of rollup's moduleSideEffects mechanism

moduleSideEffects config

moduleSideEffects 配置项可以通过两种方式来告知 rollup

  1. 插件的钩子(resolveIdloadtransform):指定局部模块的副作用,优先级最高,可以覆盖 treeshake.moduleSideEffects 配置项。
  2. 通过 treeshake.moduleSideEffects 配置项:指定全局模块的副作用,优先级最低。默认情况下 treeshake.moduleSideEffects = true,即 rollup 会假设所有模块均具备副作用。

moduleSideEffects 注意事项

moduleSideEffects 本质上和 package.json 中的 sideEffects 字段是等价的,但 rollup 自身并不尊重 package.json 中的 sideEffects 字段,参考:rollup/rollup#2593

官方的 @rollup/plugin-node-resolve 插件会尊重 package.json 中的 sideEffects 字段,但本质上是归类于插件提供的副作用。

js
import { foo } from './side-effect-1.js';
import { bar } from './side-effect-2.js';
import { noSideEffect } from './no-side-effect.js';
console.log(foo, noSideEffect);
js
console.log('side-effect-1');
export const foo = 42;
js
console.log('side-effect-2');
export const bar = 42;
js
console.log('no-side-effect');
export const bar = 42;
The Mechanism of moduleSideEffects

moduleSideEffects 的配置项很常见,会影响模块层面的副作用。由于 rollup 默认情况下假设所有的模块的顶层语句均包含副作用,那么 moduleSideEffects 配置项本质上是为 rollup 假设指定模块的顶层语句是否存在副作用。

入口模块均具备副作用,不会被 sideEffects 的配置项所覆盖,因此入口模块自身的副作用一定会包含在最终的产物中。

当模块不具备副作用时,对于 rollup 来说,会采用 惰性求值 的优化策略,即等到无副作用模块的引用实际被使用到了才会进一步分析并包含自身的副作用。细节上 rollup 在第一遍 tree-shaking 时,并不会包含指定为无副作用模块的副作用,只会包含指定为具有副作用的模块的副作用。在语义分析副作用模块的副作用时,若副作用的语句依赖了指定为无副作用模块的引用变量,认定该无副作用模块被副作用依赖的应用及其相关语句也具备副作用,同时激活无副作用模块的副作用检测。在第二次 tree-shaking 时,会包含其无副作用模块的副作用以及被副作用模块依赖的引用的相关语句。

当模块具备副作用时,那么即使具有副作用的模块暴露出来的引用没有被实际使用但也会进一步分析并包含自身的副作用。

The Impact of sideEffects on vite

vitenode 在运行时并不尊重 sideEffects 相关的配置项。换句话说这些工具并没有在编译阶段做语义分析来实现开发阶段的 tree-shaking 特性,这的确降低了编译的复杂度,缩短编译时间,有助于高效的开发体验。但与此同时也带来了隐性风险,存在因副作用的影响导致开发阶段(不支持 tree-shaking)与生产阶段(配备了 tree-shaking)产物执行不一致的问题。

简单举一个例子,main.js 作为入口模块:

Online Link

js
import { foo } from './foo.js';
import { bar } from './bar.js';

// rollup DCE analysis
if (1 + 1 === 2) {
  console.log(foo);
} else {
  console.log(bar);
}
js
console.log('foo');
export const foo = 42;
js
console.log('bar');
export const bar = 42;

当配置了 treeshake.moduleSideEffectsfalse 时,即默认所有的模块均不具备副作用,那么通过 rolluptree-shaking 优化后,产物如下:

js
console.log('foo');
const foo = 42;

{
  console.log(foo);
}

由于所有的模块均不具备副作用,那么 rollup 会从入口模块开始检索副作用,通过 DCE 优化:

  • true 分支所包含的 foo 引用会因 console.log 具备副作用而保留,而 false 分支所包含的 console.log(bar);DCE 而被优化掉,bar 实际上并不会被用到。
  • false 分支所包含的 console.log(bar);DCE 而被优化掉,bar 实际上并不会被用到。

最终对于主入口模块 main.js 来说,只有 foo 的依赖模块存在实际引用变量,而 bar 依赖模块的引用变量只导入但不引用。若此时 bar 模块具备副作用,那么 bar 模块的顶层副作用语句会被保留,若 bar 模块不具备副作用,那么 bar 模块的顶层副作用语句会被优化掉。

但对于 vite 来说,由于开发阶段不支持 tree-shaking 的工具来说,再加上架构的特殊性,没有足够的上下文完成常见构建工具(webpackvite)的 tree-shaking 细粒度的优化,同时构建工具本身也并不愿意耗费性能对模块进行语义分析而影响用户的开发体验。那么对于 vite 来说,认为所有的模块均具备副作用,因此开发阶段的输出会包含 bar 的顶层副作用语句。

这种隐性的问题会导致 开发阶段生产阶段 产物执行不一致的问题。

有下述几种方案来优化这个问题:

  1. 约定副作用:可以发现大多情况下是因为用户在模块的顶层编写副作用代码而导致上述的问题,那么可以考虑规范副作用的编写,将其封装在函数中,并在需要时显式调用,避免在模块的顶层编写副作用代码。
  2. 一致性测试:对于复杂的副作用模块,可以考虑在开发和生产环境中进行一致性测试,确保两者的行为一致。

Property Read Side Effects

默认值为 true

表示 rollup 会根据属性读取的副作用来判断是否保留某些代码。

Try Catch Deoptimization

默认值为 true

依赖于抛出错误的特征检测工作流,rollup 将默认禁用 try 语句中的 tree-shaking 优化,也就是说 rollup 会保留 try 语句中的所有代码。如果函数参数在 try 语句中被使用,那么该参数也不会被优化处理。

js
function a(d, w) {
  d = d + 1;
  w = w + 1;
  try {
    const b = 1;
    const c = w;
  } catch (_) {}
}
const param1 = 1;
const param2 = 2;
a(param1, param2);

tree-shaking 优化后

js
function a(d, w) {
  w = w + 1;
  try {
    const b = 1;
    const c = w;
  } catch (_) {}
}
const param1 = 1;
const param2 = 2;
a(param1, param2);

上述例子中在 a 函数中,由于 w 参数在 try 语句中被使用,因此 rollup 认定 w 相关的语句具备副作用,而 d 参数在 try 语句外被使用,因此 rollup 会优化掉 d 参数的副作用。

AST Generation

rollup 会在 rust 侧借助 swc 的能力来生成 babel ast,然后再转译为 compat ast,最后通过 arraybuffer 结构将 compat ast 传递给 javascript 侧。实现细节可参考 Native Parser

rollup 内部为每一个 ast node 都实现了类,获取到 estree ast 后,rollup 会根据 estree ast 的结构来递归实例每一个 ast node 类,最终生成一颗完整的 ast 实例。后续 tree-shaking 操作均在 ast 实例上进行。

在构建结束后会 缓存 estree ast,在 watch 模式下,通过使用缓存来复用 estree ast,跳过与 rust 侧的通信,从而提升增量构建的性能。

针对 acorn parser 切换到 swc parser 的改动,可以参考 Native Parser

Preparation

AST Context

rollup 会为每一个模块都实例化 ast,同时也会提供 astContext 对象,协助 rollup 收集模块信息。

ts
this.astContext = {
  addDynamicImport: this.addDynamicImport.bind(this),
  addExport: this.addExport.bind(this),
  addImport: this.addImport.bind(this),
  addImportMeta: this.addImportMeta.bind(this),
  code, // Only needed for debugging
  deoptimizationTracker: this.graph.deoptimizationTracker,
  error: this.error.bind(this),
  fileName, // Needed for warnings
  getExports: this.getExports.bind(this),
  getModuleExecIndex: () => this.execIndex,
  getModuleName: this.basename.bind(this),
  getNodeConstructor: (name: string) =>
    nodeConstructors[name] || nodeConstructors.UnknownNode,
  getReexports: this.getReexports.bind(this),
  importDescriptions: this.importDescriptions,
  includeAllExports: () => this.includeAllExports(true),
  includeDynamicImport: this.includeDynamicImport.bind(this),
  includeVariableInModule: this.includeVariableInModule.bind(this),
  log: this.log.bind(this),
  magicString: this.magicString,
  manualPureFunctions: this.graph.pureFunctions,
  module: this,
  moduleContext: this.context,
  options: this.options,
  requestTreeshakingPass: () => (this.graph.needsTreeshakingPass = true),
  traceExport: (name: string) => this.getVariableForExportName(name)[0],
  traceVariable: this.traceVariable.bind(this),
  usesTopLevelAwait: false
};

this.scope = new ModuleScope(this.graph.scope, this.astContext);
this.namespace = new NamespaceVariable(this.astContext);
const programParent = { context: this.astContext, type: 'Module' };

timeEnd('generate ast', 3);
const astBuffer = await parseAsync(code, false, this.options.jsx !== false);
timeStart('generate ast', 3);
this.ast = convertProgram(astBuffer, programParent, this.scope);

rollup 会通过上述获取到的 compat estree ast 结构来递归实例化 ast node 类,最终生成 ast 实例树。在此期间,rollup 会调用 astContext 对象中的方法来收集当前模块的信息。

例如:

  • 通过上下文对象中的 addDynamicImport 方法收集模块的 dynamic import 信息。
  • 通过上下文对象中的 addImport 方法收集 import 信息。
  • 通过上下文对象中的 addExport 方法收集 exportreexports 的信息。
  • 通过上下文对象中的 addImportMeta 方法收集 import.meta 信息。
  • 通过判断 ast 的结构来判断是否当前模块使用顶层 await 等信息。

来看一下具体是如何通过 compat estree ast 结构来收集模块信息。

Collect Module Info
  1. Dynamic Dependencies

    ImportExpression 实例中执行 initialise 方法时,此时也意味着子 ast node 类均已经完成实例化。

    js
    class ImportExpression extends NodeBase {
      initialise() {
        super.initialise();
        this.scope.context.addDynamicImport(this);
      }
      parseNode(esTreeNode: GenericEsTreeNode): this {
        this.sourceAstNode = esTreeNode.source;
        return super.parseNode(esTreeNode);
      }
    }

    调用 ast 上下文提供的 addDynamicImport 方法,将 ImportExpression 节点信息存储到当前 Module 实例的 dynamicImports 属性中。

    ts
    class Module {
      readonly dynamicImports: DynamicImport[] = [];
    
      private addDynamicImport(node: ImportExpression) {
        let argument: AstNode | string = node.sourceAstNode;
        if (argument.type === NodeType.TemplateLiteral) {
          if (
            (argument as TemplateLiteralNode).quasis.length === 1 &&
            typeof (argument as TemplateLiteralNode).quasis[0].value
              .cooked === 'string'
          ) {
            argument = (argument as TemplateLiteralNode).quasis[0].value
              .cooked!;
          }
        } else if (
          argument.type === NodeType.Literal &&
          typeof (argument as LiteralStringNode).value === 'string'
        ) {
          argument = (argument as LiteralStringNode).value!;
        }
        this.dynamicImports.push({
          argument,
          id: null,
          node,
          resolution: null
        });
      }
    }
  2. Static Dependencies

    与上述一样,当 ImportDeclaration 的子节点都实例化完成,那么会调用 ImportDeclaration 实例的 initialise 方法。

    js
    class ImportDeclaration extends NodeBase {
      initialise(): void {
        super.initialise();
        this.scope.context.addImport(this);
      }
    }

    其中会调用 addImport 方法,将 ImportDeclaration 节点信息存储到当前 Module 实例的 importDescriptions 属性中。

    ts
    class Module {
      readonly importDescriptions = new Map<string, ImportDescription>();
    
      private addImport(node: ImportDeclaration): void {
        const source = node.source.value;
        this.addSource(source, node);
    
        for (const specifier of node.specifiers) {
          const localName = specifier.local.name;
          if (
            this.scope.variables.has(localName) ||
            this.importDescriptions.has(localName)
          ) {
            this.error(
              logRedeclarationError(localName),
              specifier.local.start
            );
          }
    
          const name =
            specifier instanceof ImportDefaultSpecifier
              ? 'default'
              : specifier instanceof ImportNamespaceSpecifier
                ? '*'
                : specifier.imported instanceof Identifier
                  ? specifier.imported.name
                  : specifier.imported.value;
          this.importDescriptions.set(localName, {
            module: null as never, // filled in later
            name,
            source,
            start: specifier.start
          });
        }
      }
    }

    由于 import 的导入方式有多种,包括默认导入、具名导入、命名空间导入,他们的 ast node 是不一样的,因此在 importDescriptions 中存储的方式需要做具体区分。

    • 默认导入对应的 ast nodeImportDefaultSpecifier 节点。

      js
      import demo from 'demo.js';
      
      module.importDescriptions.set('demo', {
        module: null,
        name: 'default',
        source: 'demo.js',
        start: 7
      });

      importDescriptions 中以 default 作为标识符。

    • 具名导入对应的 ast nodeImportSpecifier 节点。

      js
      import { demo } from 'demo.js';
      import { next as demo1 } from 'demo.js';
      
      module.importDescriptions.set('demo', {
        module: null, // filled in later
        name: 'demo',
        source: 'demo.js',
        start: 72
      });
      module.importDescriptions.set('demo1', {
        module: null, // filled in later
        name: 'next',
        source: 'demo.js',
        start: 80
      });

      importDescriptions 中存储 imported.nameimported.value

    • 命名空间导入对应的 ast nodeImportNamespaceSpecifier 节点。

      js
      import * as demo from 'demo.js';
      
      module.importDescriptions.set('demo', {
        module: null,
        name: '*',
        source: 'demo.js',
        start: 36
      });

      importDescriptions 中以 * 作为标识符。

  3. Export Statement And Reexports Statements

    export 涉及到的 ast node 包括如下三种

    • 默认导出(ExportDefaultDeclaration)
    • 具名导出(ExportNamedDeclaration)
    • 命名空间导出(ExportAllDeclaration)
    js
    // case 1: ExportDefaultDeclaration
    export default 'Y';
    // case 2: ExportAllDeclaration
    export * as name from './foo.js';
    // case 3: ExportAllDeclaration
    export * from './foo.js';
    // case 4: ExportNamedDeclaration
    export const a = 123;
    // case 5: ExportNamedDeclaration
    const demoA = 123;
    const demoB = 123;
    export { demoA as _a, demoB as _b };
    // case 6: ExportNamedDeclaration
    export function foo() {
      console.log('foo');
    }
    // case 7: ExportNamedDeclaration
    export { foo as _foo } from './foo.js';

    三种 export ast node 在初始化阶段会调用 addExport 方法,将当前模块的导出信息添加到 Module 实例的 exportsreexportDescriptionsexportAllSources 属性中。

    ts
    class Module {
      private addExport(
        node:
          | ExportAllDeclaration
          | ExportNamedDeclaration
          | ExportDefaultDeclaration
      ): void {
        if (node instanceof ExportDefaultDeclaration) {
          // export default foo;
          this.assertUniqueExportName('default', node.start);
          this.exports.set('default', {
            identifier: node.variable.getAssignedVariableName(),
            localName: 'default'
          });
        } else if (node instanceof ExportAllDeclaration) {
          const source = node.source.value;
          this.addSource(source, node);
          if (node.exported) {
            // export * as name from './other'
            const name =
              node.exported instanceof Literal
                ? node.exported.value
                : node.exported.name;
            this.assertUniqueExportName(name, node.exported.start);
            this.reexportDescriptions.set(name, {
              localName: '*',
              module: null as never, // filled in later,
              source,
              start: node.start
            });
          } else {
            // export * from './other'
            this.exportAllSources.add(source);
          }
        } else if (node.source instanceof Literal) {
          // export { name } from './other'
          const source = node.source.value;
          this.addSource(source, node);
          for (const { exported, local, start } of node.specifiers) {
            const name =
              exported instanceof Literal ? exported.value : exported.name;
            this.assertUniqueExportName(name, start);
            this.reexportDescriptions.set(name, {
              localName: local instanceof Literal ? local.value : local.name,
              module: null as never, // filled in later,
              source,
              start
            });
          }
        } else if (node.declaration) {
          const declaration = node.declaration;
          if (declaration instanceof VariableDeclaration) {
            // export var { foo, bar } = ...
            // export var foo = 1, bar = 2;
            for (const declarator of declaration.declarations) {
              for (const localName of extractAssignedNames(declarator.id)) {
                this.assertUniqueExportName(localName, declarator.id.start);
                this.exports.set(localName, { identifier: null, localName });
              }
            }
          } else {
            // export function foo () {}
            const localName = (declaration.id as Identifier).name;
            this.assertUniqueExportName(localName, declaration.id!.start);
            this.exports.set(localName, { identifier: null, localName });
          }
        } else {
          // export { foo, bar, baz }
          for (const { local, exported } of node.specifiers) {
            // except for reexports, local must be an Identifier
            const localName = (local as Identifier).name;
            const exportedName =
              exported instanceof Identifier
                ? exported.name
                : exported.value;
            this.assertUniqueExportName(exportedName, exported.start);
            this.exports.set(exportedName, { identifier: null, localName });
          }
        }
      }
    }

    可以发现对于 reexportrollup 还特意在 Module 实例中通过 reexportDescriptionsexportAllSources 属性来区分。

    那么上述 export-statement.js 样例解析抽象结果如下:

    js
    // case 1: ExportDefaultDeclaration
    export default foo;
    // case 1 output
    module.exports.set('default', {
      identifier: null,
      localName: 'default'
    });
    
    // case 2: ExportAllDeclaration
    export * as name from './foo.js';
    // case 2 output
    module.reexportDescriptions.set('name', {
      localName: '*',
      module: null, // filled in later
      source: './foo.js',
      start: 72
    });
    
    // case 3: ExportAllDeclaration
    export * from './foo.js';
    // case 3 output
    module.exportAllSources.add('./foo.js');
    
    // case 4: ExportNamedDeclaration
    export const a = 123;
    // case 4 output
    module.exports.set('a', {
      identifier: null,
      localName: 'a'
    });
    
    // case 5: ExportNamedDeclaration
    const demoA = 123;
    const demoB = 123;
    export { demoA as _a, demoB as _b };
    // case 5 output
    module.exports.set('_a', {
      identifier: null,
      localName: 'demoA'
    });
    module.exports.set('_b', {
      identifier: null,
      localName: 'demoB'
    });
    
    // case 6: ExportNamedDeclaration
    export function foo() {
      console.log('foo');
    }
    // case 6 output
    module.exports.set('foo', {
      identifier: null,
      localName: 'foo'
    });
    
    // case 7: ExportNamedDeclaration
    export { foo as _foo } from './foo.js';
    // case 7 output
    module.reexportDescriptions.set('_foo', {
      localName: 'foo',
      module: null, // filled in later
      source: './foo.js',
      start: 289
    });

    Duplicate Declaration Reference Check

    rollup 会在此处执行 语法分析 操作,这样做的原因可参考 Optimize Syntax Analysis 一文讲解,importexport 都会对重复声明的变量名进行校验。

    export 通过 assertUniqueExportName 方法来检测重复声明的变量名。

    ts
    function assertUniqueExportName(name: string, nodeStart: number) {
      if (this.exports.has(name) || this.reexportDescriptions.has(name)) {
        this.error(logDuplicateExportError(name), nodeStart);
      }
    }

    通过检测导出的变量是否已经出现在 Module 实例的 exportsreexportDescriptions 属性中,来判断是否重复声明。

    importaddImport 中,通过检测导入的引用变量是否出现在作用域声明的变量中(scope.variables) 或 Module 实例的 importDescriptions 属性中,来判断是否重复声明。

    ts
    if (
      this.scope.variables.has(localName) ||
      this.importDescriptions.has(localName)
    ) {
      this.error(logRedeclarationError(localName), specifier.local.start);
    }

    也就是说对于 rollup 不允许在同一个模块中重复声明变量,包括顶层作用域声明的变量和 import 导出的变量。

    若出现重复声明引用,则会抛出错误,并终止打包。

    Summary

    在实例化 ast 的过程中记录的 importexport 节点的信息是本质上就是当前模块作用域中使用的 localName 与依赖方模块作用域中所导出的 localName 的映射关系。

    不管是 import 还是 export,依赖模块在收集过程中并没有进行填充,原因也很简单,因为此时该模块对应的依赖模块还没有开始加载,因此还获取不到。

    ts
    this.importDescriptions.set(localName, {
      module: null as never, // filled in later
      name,
      source,
      start: specifier.start
    });
    this.reexportDescriptions.set(name, {
      localName: local instanceof Literal ? local.value : local.name,
      module: null as never, // filled in later,
      source,
      start
    });

    当子依赖模块加载完成后,会调用 linkImports 方法,通过 addModulesToImportDescriptions 方法将子依赖模块的引用填充到 importer 模块的 importDescriptionsreexportDescriptions 中。

    js
    class Module {
      addModulesToImportDescriptions(importDescription) {
        for (const specifier of importDescription.values()) {
          const { id } = this.resolvedIds[specifier.source];
          specifier.module = this.graph.modulesById.get(id);
        }
      }
      linkImports() {
        this.addModulesToImportDescriptions(this.importDescriptions);
        this.addModulesToImportDescriptions(this.reexportDescriptions);
        const externalExportAllModules = [];
        for (const source of this.exportAllSources) {
          const module = this.graph.modulesById.get(
            this.resolvedIds[source].id
          );
          if (module instanceof ExternalModule) {
            externalExportAllModules.push(module);
            continue;
          }
          this.exportAllModules.push(module);
        }
        this.exportAllModules.push(...externalExportAllModules);
      }
    }

    执行 linkImports 方法时,当前模块的所有依赖项模块都已经解析完成,所以可以在 this.graph.modulesById 中获取到当前模块的所有依赖项模块的引用。

    此时当前模块的 importDescriptionsreexportDescriptions 属性对应的所有依赖项模块引用就可以进行填充。exportAllSources 中存储的 source,也通过 this.graph.modulesById 找到对应的依赖项模块,并将其添加到 this.exportAllModules

    通过层层回溯且收集绑定,最终 ast 实例化阶段未填充的依赖项模块已经全部填充完毕。

  4. Import Meta

    import.meta 相关的 ast node 节点是 MetaProperty。这是声明式的节点,由于无法在 ast 实例化阶段判断是否需要 import.meta 节点信息。

    因此节点信息并没有在实例化 ast 时进行处理,而是在 tree shaking 阶段确认需要包含 import.meta 节点后才会通过 addImportMeta 方法收集 import.meta 的信息。

    js
    class MateProperty {
      include() {
        if (!this.included) {
          this.included = true;
          if (this.meta.name === IMPORT) {
            this.scope.context.addImportMeta(this);
            const parent = this.parent;
            const metaProperty = (this.metaProperty =
              parent instanceof MemberExpression &&
              typeof parent.propertyKey === 'string'
                ? parent.propertyKey
                : null);
            if (metaProperty?.startsWith(FILE_PREFIX)) {
              this.referenceId = metaProperty.slice(FILE_PREFIX.length);
            }
          }
        }
      }
    }
    
    class Module {
      addImportMeta(node) {
        this.importMetas.push(node);
      }
    }
  5. TLA

    这是为了标记当前模块是否包含了 Top Level Await。针对 Top Level Await 在不同 bundlers 的实现在 Detailed Explanation And Implementation Of TLA 中已经详细说明了,这里不再赘述。

    Top Level Await 相关的 ast nodeAwaitExpression

    js
    class AwaitExpression extends NodeBase {
      include(context: InclusionContext, includeChildrenRecursively: IncludeChildren): void {
        if (!this.deoptimized) this.applyDeoptimizations();
        if (!this.included) {
          this.included = true;
          checkTopLevelAwait: if (!this.scope.context.usesTopLevelAwait) {
            let parent = this.parent;
            do {
              if (parent instanceof FunctionNode || parent instanceof ArrowFunctionExpression)
                break checkTopLevelAwait;
            } while ((parent = (parent as Node).parent as Node));
            this.scope.context.usesTopLevelAwait = true;
          }
        }
        this.argument.include(context, includeChildrenRecursively);
      }
    }

    import.meta 节点一样,AwaitExpression 节点信息也是在 tree shaking 阶段确认需要包含 AwaitExpression 节点后才会通过 addAwaitExpression 方法收集 AwaitExpression 的信息。

    判断模块中是否包含 AwaitExpression 节点的方式很简单,即往父级遍历,直到遇到 FunctionNodeArrowFunctionExpression 节点为止,若均没有遇到,那么就代表当前模块存在 Top Level Await,那么会在当前模块实例的 usesTopLevelAwait 属性标记为 true

    js
    // usesTopLevelAwait: true
    await 1;
    
    // usesTopLevelAwait: false
    (async () => {
      await 1;
    })();
    
    // usesTopLevelAwait: false
    (function () {
      await 1;
    })();

以上这些信息均在 ast 实例化阶段收集完成。而 includeAllExportsincludeDynamicImportincludeVariableInModule 这些方法在 tree shaking 阶段起作用。

Determine Ast Node Scope

rollup 会为每个 ast node 节点创建一个 scope 作用域。作用域分为全局作用域、函数作用域、模块作用域、参数作用域、catch 作用域、with 作用域。

ts
class Scope {
  readonly children: ChildScope[] = [];
  readonly variables = new Map<string, Variable>();
  hoistedVariables?: Map<string, LocalVariable>;
}
class ChildScope extends Scope {
  constructor(
    readonly parent: Scope,
    readonly context: AstContext
  ) {
    super();
    parent.children.push(this);
  }
}
class ModuleScope extends ChildScope {
  declare parent: GlobalScope;

  constructor(parent: GlobalScope, context: AstContext) {
    super(parent, context);
    this.variables.set(
      'this',
      new LocalVariable(
        'this',
        null,
        UNDEFINED_EXPRESSION,
        context,
        'other'
      )
    );
  }
}
class Module {
  async setSource({
    ast,
    code,
    customTransformCache,
    originalCode,
    originalSourcemap,
    resolvedIds,
    sourcemapChain,
    transformDependencies,
    transformFiles,
    ...moduleOptions
  }: TransformModuleJSON & {
    resolvedIds?: ResolvedIdMap;
    transformFiles?: EmittedFile[] | undefined;
  }): Promise<void> {
    this.scope = new ModuleScope(this.graph.scope, this.astContext);
  }
}

rollup 为模块创建一个 ModuleScope 实例(即模块作用域),继承至 GlobalScope 实例(即全局作用域,也就是 this.graph.scope 的值)。

在初始化阶段会为 ModuleScope 实例添加 this 变量,作为当前模块作用域中第一个变量。

ts
class ModuleScope extends ChildScope {
  declare parent: GlobalScope;

  constructor(parent: GlobalScope, context: AstContext) {
    super(parent, context);
    this.variables.set(
      'this',
      new LocalVariable(
        'this',
        null,
        UNDEFINED_EXPRESSION,
        context,
        'other'
      )
    );
  }
}

接下来通过实例来讲解 scopeast 实例化过程中是如何发挥作用的。

js
const localVariable = 123;

function scopeDemo() {
  const scopeDemoLocalVariable = 345;
  const scopeDemoLocalFunction = function (params) {
    const d = 4445;
    return params + d;
  };
  console.log(scopeDemoLocalFunction(scopeDemoLocalVariable));
}

scopeDemo();
console.log(localVariable);

由于 ast node 均继承于 NodeBase 类,因此实例化 ast node 时会触发 NodeBase 构造函数的执行,为当前的 ast node 节点创建 scope 作用域。

js
class NodeBase extends ExpressionEntity {
  constructor(parent, parentScope) {
    super();
    this.parent = parent;
    this.scope = parentScope;
    this.createScope(parentScope);
  }
  createScope(parentScope) {
    this.scope = parentScope;
  }
}

我们先拿 const localVariable = 123; 这条语句来举例,VariableDeclaration 节点初始化完成后(即 VariableDeclaration 节点的所有 ast node 均已初始化完成),则会调用 VariableDeclaration 节点的 initialise 方法。

ts
class VariableDeclaration extends NodeBase {
  initialise() {
    super.initialise();
    this.isUsingDeclaration =
      this.kind === 'await using' || this.kind === 'using';
    for (const declarator of this.declarations) {
      declarator.declareDeclarator(this.kind, this.isUsingDeclaration);
    }
  }
}

VariableDeclaration 节点的 initialise 方法中,通过 super.initialise() 方法为当前的 VariableDeclaration 节点创建 scope 作用域。

遍历 VariableDeclaration 节点的 declarations 数组(声明的每一个变量,例如 const a = 1, b = 2; 中,declarations 数组中包含两个 VariableDeclarator 节点),调用 VariableDeclarator 节点的 declareDeclarator 方法

ts
class VariableDeclarator extends NodeBase {
  declareDeclarator(kind, isUsingDeclaration) {
    this.isUsingDeclaration = isUsingDeclaration;
    this.id.declare(kind, this.init || UNDEFINED_EXPRESSION);
  }
}

declareDeclarator 方法中会触发 Identifier 节点(this.id)的 declare 方法,其中会通过 this.scope.addDeclaration 方法将 Identifier 节点添加到 scope.variables 中。

由于在 VariableDeclaration 节点创建了作用域且到 Identifier 节点实例化期间均没有产生新的作用域,因此 Identifier 节点的作用域与 VariableDeclaration 节点的作用域一致,即 Identifier 节点的作用域为 ModuleScope

ts
class Identifier extends NodeBase {
  declare(kind, init) {
    let variable;
    const { treeshake } = this.scope.context.options;
    switch (kind) {
      case 'var': {
        variable = this.scope.addDeclaration(
          this,
          this.scope.context,
          init,
          kind
        );
        if (treeshake && treeshake.correctVarValueBeforeDeclaration) {
          // Necessary to make sure the init is deoptimized. We cannot call deoptimizePath here.
          variable.markInitializersForDeoptimization();
        }
        break;
      }
      case 'function': {
        // in strict mode, functions are only hoisted within a scope but not across block scopes
        variable = this.scope.addDeclaration(
          this,
          this.scope.context,
          init,
          kind
        );
        break;
      }
      case 'let':
      case 'const':
      case 'using':
      case 'await using':
      case 'class': {
        variable = this.scope.addDeclaration(
          this,
          this.scope.context,
          init,
          kind
        );
        break;
      }
      case 'parameter': {
        variable = this.scope.addParameterDeclaration(this);
        break;
      }
      /* istanbul ignore next */
      default: {
        /* istanbul ignore next */
        throw new Error(
          `Internal Error: Unexpected identifier kind ${kind}.`
        );
      }
    }
    return [(this.variable = variable)];
  }
}

那么对于 const localVariable = 123; 语句来说,最终会将 localVariable 变量添加到 ModuleScopescope.variables 中。

js
class Scope {
  addDeclaration(identifier, context, init, kind) {
    const name = identifier.name;
    const existingVariable =
      this.hoistedVariables?.get(name) || this.variables.get(name);
    if (existingVariable) {
      const existingKind = existingVariable.kind;
      if (kind === 'var' && existingKind === 'var') {
        existingVariable.addDeclaration(identifier, init);
        return existingVariable;
      }
      context.error(
        parseAst_js.logRedeclarationError(name),
        identifier.start
      );
    }
    const newVariable = new LocalVariable(
      identifier.name,
      identifier,
      init,
      context,
      kind
    );
    this.variables.set(name, newVariable);
    return newVariable;
  }
}
class ModuleScope extends ChildScope {
  addDeclaration(identifier, context, init, kind) {
    if (this.context.module.importDescriptions.has(identifier.name)) {
      context.error(
        parseAst_js.logRedeclarationError(identifier.name),
        identifier.start
      );
    }
    return super.addDeclaration(identifier, context, init, kind);
  }
}

由于模块作用域中包含了已经声明过的变量,因此很容易做 重复性声明的语法检测

当检测到当前模块作用域中声明的变量与 import 导入的变量声明重复,则抛出重复性声明异常。

若非 var 关键字声明的变量重复声明(例如 letconst 等),则抛出异常。检测通过后会根据 Identifierast node 节点信息实例化 LocalVariable 类,并将实例化的变量添加到当前作用域的 scope.variables 中,为后续的 语法分析tree shaking 做准备。

Creating Scope

前面提到的是在顶层语法中创建的作用域,他们所属的是 ModuleScope(模块作用域)。前面也有提及到,作用域不仅仅只有 ModuleScopeGlobalScope,还有 FunctionScopeParameterScopeCatchBodyScopeFunctionBodyScope 等。

Function Scope

当遇到函数声明语句时,会在 FunctionNode 节点中调用 createScope 方法为当前的 FunctionNode 节点创建新的 scope 作用域。

ts
class FunctionNode extends FunctionBase {
  constructor() {
    super(...arguments);
    this.objectEntity = null;
  }
  createScope(parentScope) {
    this.scope = new FunctionScope(parentScope);
    this.constructedEntity = new ObjectEntity(
      Object.create(null),
      OBJECT_PROTOTYPE
    );
    // This makes sure that all deoptimizations of "this" are applied to the
    // constructed entity.
    this.scope.thisVariable.addEntityToBeDeoptimized(
      this.constructedEntity
    );
  }
}
ts
class Scope {
  constructor() {
    this.children = [];
    this.variables = new Map();
  }
}
class ChildScope extends Scope {
  constructor(parent, context) {
    super();
    this.parent = parent;
    this.context = context;
    this.accessedOutsideVariables = new Map();
    parent.children.push(this);
  }
}
class ParameterScope extends ChildScope {
  constructor(parent, isCatchScope) {
    super(parent, parent.context);
    this.parameters = [];
    this.hasRest = false;
    this.bodyScope = isCatchScope
      ? new CatchBodyScope(this)
      : new FunctionBodyScope(this);
  }
}
class ReturnValueScope extends ParameterScope {
  constructor() {
    super(...arguments);
    this.returnExpression = null;
    this.returnExpressions = [];
  }
}
class FunctionScope extends ReturnValueScope {
  constructor(parent) {
    const { context } = parent;
    super(parent, false);
    this.variables.set(
      'arguments',
      (this.argumentsVariable = new ArgumentsVariable(context))
    );
    this.variables.set(
      'this',
      (this.thisVariable = new ThisVariable(context))
    );
  }
}

FunctionScope 中为当前函数节点创建一个继承 ModuleScope 的新的 FunctionScope 作用域。

在新的 FunctionScope 作用域中创建 this 变量和 arguments 变量作为当前作用域中的初始变量。同时为父集 ModuleScope 收集新的函数作用域存储在父集 ModuleScopechildren 数组中。

ts
class ChildScope extends Scope {
  constructor(parent, context) {
    super();
    this.parent = parent;
    this.context = context;
    this.accessedOutsideVariables = new Map();
    parent.children.push(this);
  }
}

注意

函数作用域中声明的变量并不是添加到 FunctionScopescope.variables 中,而是添加到 FunctionScope.bodyScopescope.variables 中。

ts
function functionDeclaration(node: FunctionDeclaration, position, buffer) {
  const { scope } = node;
  const flags = buffer[position];
  node.async = (flags & 1) === 1;
  node.generator = (flags & 2) === 2;
  const annotations = (node.annotations = convertAnnotations(buffer[position + 1], buffer));
  node.annotationNoSideEffects = annotations.some(comment => comment.type === 'noSideEffects');
  const idPosition = buffer[position + 2];
  node.id =
    idPosition === 0 ? null : convertNode(node, scope.parent as ChildScope, idPosition, buffer);
  const parameters = (node.params = convertNodeList(node, scope, buffer[position + 3], buffer));
  scope.addParameterVariables(
    parameters.map(
      parameter => parameter.declare('parameter', UNKNOWN_EXPRESSION) as ParameterVariable[]
    ),
    parameters[parameters.length - 1] instanceof RestElement
  );
  node.body = convertNode(node, scope.bodyScope, buffer[position + 4], buffer);
}

那么 bodyScope 是哪里来的呢?

js
class ParameterScope extends ChildScope {
  constructor(parent, isCatchScope) {
    super(parent, parent.context);
    this.parameters = [];
    this.hasRest = false;
    this.bodyScope = isCatchScope ? new CatchBodyScope(this) : new FunctionBodyScope(this);
  }
}
class ReturnValueScope extends ParameterScope {}

class FunctionScope extends ReturnValueScope {}

可以看到在创建函数作用域的时候会创建 FunctionBodyScope 作为当前函数作用域的 bodyScope,同时 FunctionBodyScope 的父集为 FunctionScope。后续在 函数体中声明的变量 均会添加到 FunctionBodyScopescope.variables 中。

函数表达式的参数会通过 scope.addParameterVariables 方法添加到 FunctionScopescope.variables 中。

ts
function functionExpression(node: FunctionExpression, position, buffer) {
  const { scope } = node;
  const flags = buffer[position];
  node.async = (flags & 1) === 1;
  node.generator = (flags & 2) === 2;
  const annotations = (node.annotations = convertAnnotations(buffer[position + 1], buffer));
  node.annotationNoSideEffects = annotations.some(comment => comment.type === 'noSideEffects');
  const idPosition = buffer[position + 2];
  node.id = idPosition === 0 ? null : convertNode(node, node.idScope, idPosition, buffer);
  const parameters = (node.params = convertNodeList(node, scope, buffer[position + 3], buffer));
  scope.addParameterVariables(
    parameters.map(
      parameter => parameter.declare('parameter', UNKNOWN_EXPRESSION) as ParameterVariable[]
    ),
    parameters[parameters.length - 1] instanceof RestElement
  );
  node.body = convertNode(node, scope.bodyScope, buffer[position + 4], buffer);
}

同时还会将参数添加到函数作用域(FunctionScope)下的函数体作用域(bodyScope,bodyScope.parent 为 FunctionScope)的 hoistedVariables 中作为提示的变量。

ts
class Scope {
  addHoistedVariable(name: string, variable: LocalVariable) {
    (this.hoistedVariables ||= new Map()).set(name, variable);
  }
}
class ParameterScope extends ChildScope {
  addParameterDeclaration(identifier: Identifier): ParameterVariable {
    const { name, start } = identifier;
    const existingParameter = this.variables.get(name);
    if (existingParameter) {
      return this.context.error(logDuplicateArgumentNameError(name), start);
    }
    const variable = new ParameterVariable(name, identifier, this.context);
    this.variables.set(name, variable);
    // We also add it to the body scope to detect name conflicts with local
    // variables. We still need the intermediate scope, though, as parameter
    // defaults are NOT taken from the body scope but from the parameters or
    // outside scope.
    this.bodyScope.addHoistedVariable(name, variable);
    return variable;
  }
}

hoistedVariables 的作用是为了在函数体中检测标识符是否与参数变量声明冲突。

ts
function logRedeclarationError(name: string): RollupLog {
  return {
    code: REDECLARATION_ERROR,
    message: `Identifier "${name}" has already been declared`
  };
}
class FunctionBodyScope extends ChildScope {
  // There is stuff that is only allowed in function scopes, i.e. functions can
  // be redeclared, functions and var can redeclare each other
  addDeclaration(
    identifier: Identifier,
    context: AstContext,
    init: ExpressionEntity,
    kind: VariableKind
  ): LocalVariable {
    const name = identifier.name;
    const existingVariable =
      this.hoistedVariables?.get(name) || (this.variables.get(name) as LocalVariable);
    if (existingVariable) {
      const existingKind = existingVariable.kind;
      if (
        (kind === 'var' || kind === 'function') &&
        (existingKind === 'var' || existingKind === 'function' || existingKind === 'parameter')
      ) {
        existingVariable.addDeclaration(identifier, init);
        return existingVariable;
      }
      context.error(logRedeclarationError(name), identifier.start);
    }
    const newVariable = new LocalVariable(identifier.name, identifier, init, context, kind);
    this.variables.set(name, newVariable);
    return newVariable;
  }
}

可以看到若存在非 varfunction 关键字声明的变量与参数变量同名,则抛出异常: Identifier xxx has already been declared。因此在实例化 ast 树的过程中,就可以检测到函数体中变量声明的重复问题。

Instantiate AST

上述提到 module.astrollup 实现的 ast node 类的实例,结构兼容 estree ast,后续 tree shaking 操作均是在 module.ast 实例上进行。

那么核心问题来了,rollup 是如何将根据 compat estree ast 结构来实例化 rollup 实现的 ast node 类实例呢?

rollup 内部为每一个 ast node 类型实现了对应的 ast node 类,通过 nodeConstructors 来实例化对应的 ast node 类。

ts
import type * as estree from 'estree';
type OmittedEstreeKeys =
  | 'loc'
  | 'range'
  | 'leadingComments'
  | 'trailingComments'
  | 'innerComments'
  | 'comments';
interface AstNodeLocation {
  end: number;
  start: number;
}
type RollupAstNode<T> = Omit<T, OmittedEstreeKeys> & AstNodeLocation;
type AstNode = RollupAstNode<estree.Node>;
interface GenericEsTreeNode extends AstNode {
  [key: string]: any;
}
type AnnotationType = 'pure' | 'noSideEffects';
interface RollupAnnotation {
  start: number;
  end: number;
  type: AnnotationType;
}

const nodeConstructors: Record<string, typeof NodeBase> = {
  ArrayExpression,
  ArrayPattern,
  ArrowFunctionExpression,
  AssignmentExpression,
  AssignmentPattern,
  AwaitExpression,
  BinaryExpression,
  BlockStatement,
  BreakStatement,
  CallExpression,
  CatchClause,
  ChainExpression,
  ClassBody,
  ClassDeclaration,
  ClassExpression,
  ConditionalExpression,
  ContinueStatement,
  DebuggerStatement,
  Decorator,
  DoWhileStatement,
  EmptyStatement,
  ExportAllDeclaration,
  ExportDefaultDeclaration,
  ExportNamedDeclaration,
  ExportSpecifier,
  ExpressionStatement,
  ForInStatement,
  ForOfStatement,
  ForStatement,
  FunctionDeclaration,
  FunctionExpression,
  Identifier,
  IfStatement,
  ImportAttribute,
  ImportDeclaration,
  ImportDefaultSpecifier,
  ImportExpression,
  ImportNamespaceSpecifier,
  ImportSpecifier,
  LabeledStatement,
  Literal,
  LogicalExpression,
  MemberExpression,
  MetaProperty,
  MethodDefinition,
  NewExpression,
  ObjectExpression,
  ObjectPattern,
  PanicError,
  ParseError,
  PrivateIdentifier,
  Program,
  Property,
  PropertyDefinition,
  RestElement,
  ReturnStatement,
  SequenceExpression,
  SpreadElement,
  StaticBlock,
  Super,
  SwitchCase,
  SwitchStatement,
  TaggedTemplateExpression,
  TemplateElement,
  TemplateLiteral,
  ThisExpression,
  ThrowStatement,
  TryStatement,
  UnaryExpression,
  UnknownNode,
  UpdateExpression,
  VariableDeclaration,
  VariableDeclarator,
  WhileStatement,
  YieldExpression
};

class NodeBase {
  parseNode(esTreeNode: GenericEsTreeNode): this {
    for (const [key, value] of Object.entries(esTreeNode)) {
      // Skip properties defined on the class already.
      // This way, we can override this function to add custom initialisation and then call super.parseNode
      // Note: this doesn't skip properties with defined getters/setters which we use to pack wrap booleans
      // in bitfields. Those are still assigned from the value in the esTreeNode.
      if (this.hasOwnProperty(key)) continue;

      if (key.charCodeAt(0) === 95 /* _ */) {
        if (key === ANNOTATION_KEY) {
          this.annotations = value as RollupAnnotation[];
        } else if (key === INVALID_ANNOTATION_KEY) {
          (this as unknown as Program).invalidAnnotations =
            value as RollupAnnotation[];
        }
      } else if (typeof value !== 'object' || value === null) {
        (this as GenericEsTreeNode)[key] = value;
      } else if (Array.isArray(value)) {
        (this as GenericEsTreeNode)[key] = [];
        for (const child of value) {
          (this as GenericEsTreeNode)[key]
            .push(
              child === null ? null : new nodeConstructors[child.type]()
            )(this, this.scope)
            .parseNode(child);
        }
      } else {
        (this as GenericEsTreeNode)[key] = new nodeConstructors[value.type](
          this,
          this.scope
        ).parseNode(value);
      }
    }
    // extend child keys for unknown node types
    childNodeKeys[esTreeNode.type] ||=
      createChildNodeKeysForNode(esTreeNode);
    this.initialise();
    return this;
  }
}

通过 compat estree ast 的结构,通过 DFS 递归实例化每一个 ast node 类。

Ast 的节点操作

getVariableForExportName

注意

generateModuleGraph 阶段会执行实例化 Module 的操作,同时根据 source code 解析对应的 Ast 树。需要注意的是此时模块的依赖项模块还没有开始实例化,因此无法在解析 Ast 树时获取到子依赖模块的引用,所以在 importDescriptionsreexportDescriptions 中收集到的导入变量和重导出变量没有填充依赖模块项的模块引用,处于 未填充状态

当依赖项模块均已经实例化完成后,就会执行 module.linkImports() 方法为 importDescriptionsreexportDescriptions 填充依赖模块项的引用。

js
class Module {
  linkImports(): void {
    this.addModulesToImportDescriptions(this.importDescriptions);
    this.addModulesToImportDescriptions(this.reexportDescriptions);
    const externalExportAllModules: ExternalModule[] = [];
    for (const source of this.exportAllSources) {
      const module = this.graph.modulesById.get(this.resolvedIds[source].id)!;
      if (module instanceof ExternalModule) {
        externalExportAllModules.push(module);
        continue;
      }
      this.exportAllModules.push(module);
    }
    this.exportAllModules.push(...externalExportAllModules);
  }
}

tree-sharking 阶段, importDescriptionsreexportDescriptions 的依赖模块项的模块引用已经填充完毕,因此在 tree-sharking 阶段可以通过以下方式来获取依赖项的 module 引用。

js
// main.js

// export * as d from 'demo.js';
const reexportDeclaration = this.reexportDescriptions.get(name);
const childDependenciesReexportModule = reexportDeclaration.module;

// import { d } from 'demo.js';
const importDescription = this.importDescriptions.get(name);
const childDependenciesImportModule = importDescription.module;

继续分析 getVariableForExportName 方法的实现。

ts
class Module {
  getVariableForExportName(
    name: string,
    {
      importerForSideEffects,
      isExportAllSearch,
      onlyExplicit,
      searchedNamesAndModules
    }: {
      importerForSideEffects?: Module;
      isExportAllSearch?: boolean;
      onlyExplicit?: boolean;
      searchedNamesAndModules?: Map<string, Set<Module | ExternalModule>>;
    } = EMPTY_OBJECT
  ): [variable: Variable | null, indirectExternal?: boolean] {
    if (name[0] === '*') {
      if (name.length === 1) {
        // export * from './other'
        return [this.namespace];
      }
      // export * from 'external'
      const module = this.graph.modulesById.get(
        name.slice(1)
      ) as ExternalModule;
      return module.getVariableForExportName('*');
    }

    // export { foo } from './other'
    const reexportDeclaration = this.reexportDescriptions.get(name);
    if (reexportDeclaration) {
      const [variable] = getVariableForExportNameRecursive(
        reexportDeclaration.module,
        reexportDeclaration.localName,
        importerForSideEffects,
        false,
        searchedNamesAndModules
      );
      if (!variable) {
        return this.error(
          logMissingExport(
            reexportDeclaration.localName,
            this.id,
            reexportDeclaration.module.id
          ),
          reexportDeclaration.start
        );
      }
      if (importerForSideEffects) {
        setAlternativeExporterIfCyclic(
          variable,
          importerForSideEffects,
          this
        );
        if (this.info.moduleSideEffects) {
          getOrCreate(
            importerForSideEffects.sideEffectDependenciesByVariable,
            variable,
            getNewSet<Module>
          ).add(this);
        }
      }
      return [variable];
    }

    const exportDeclaration = this.exports.get(name);
    if (exportDeclaration) {
      if (exportDeclaration === MISSING_EXPORT_SHIM_DESCRIPTION) {
        return [this.exportShimVariable];
      }
      const name = exportDeclaration.localName;
      const variable = this.traceVariable(name, {
        importerForSideEffects,
        searchedNamesAndModules
      })!;
      if (importerForSideEffects) {
        setAlternativeExporterIfCyclic(
          variable,
          importerForSideEffects,
          this
        );
        getOrCreate(
          importerForSideEffects.sideEffectDependenciesByVariable,
          variable,
          getNewSet<Module>
        ).add(this);
      }
      return [variable];
    }

    if (onlyExplicit) {
      return [null];
    }

    if (name !== 'default') {
      const foundNamespaceReexport =
        this.namespaceReexportsByName.get(name) ??
        this.getVariableFromNamespaceReexports(
          name,
          importerForSideEffects,
          searchedNamesAndModules
        );
      this.namespaceReexportsByName.set(name, foundNamespaceReexport);
      if (foundNamespaceReexport[0]) {
        return foundNamespaceReexport;
      }
    }

    if (this.info.syntheticNamedExports) {
      return [
        getOrCreate(
          this.syntheticExports,
          name,
          () =>
            new SyntheticNamedExportVariable(
              this.astContext,
              name,
              this.getSyntheticNamespace()
            )
        )
      ];
    }

    // we don't want to create shims when we are just
    // probing export * modules for exports
    if (!isExportAllSearch && this.options.shimMissingExports) {
      this.shimMissingExport(name);
      return [this.exportShimVariable];
    }
    return [null];
  }
}

可以看到实现上针对不同类型的导出方式获取的方式有些差异化,以下逐一介绍针对不同方式的导出,其获取 Rollup Ast Node 的不同实现方式。

  1. 重导出(reexport)变量的 Rollup Ast 节点获取

    可以看出针对重导出的 Rollup Ast 变量声明节点的获取会调用 getVariableForExportNameRecursive 方法

    ts
    const reexportDeclaration = this.reexportDescriptions.get(name);
    if (reexportDeclaration) {
      const [variable] = getVariableForExportNameRecursive(
        // 重导出依赖模块
        reexportDeclaration.module,
        reexportDeclaration.localName,
        importerForSideEffects,
        false,
        searchedNamesAndModules
      );
      // 其他逻辑省略
    }

    getVariableForExportNameRecursive 方法内部递归调用 重导出的依赖模块getVariableForExportName 方法来检索依赖模块中是否声明了目标变量,若没有则继续递归调用直到找到目标变量的 Rollup Ast Node 节点为止。

    ts
    function getVariableForExportNameRecursive(
      target: Module | ExternalModule,
      name: string,
      importerForSideEffects: Module | undefined,
      isExportAllSearch: boolean | undefined,
      searchedNamesAndModules = new Map<
        string,
        Set<Module | ExternalModule>
      >()
    ): [variable: Variable | null, indirectExternal?: boolean] {
      const searchedModules = searchedNamesAndModules.get(name);
      if (searchedModules) {
        if (searchedModules.has(target)) {
          return isExportAllSearch
            ? [null]
            : error(logCircularReexport(name, target.id));
        }
        searchedModules.add(target);
      } else {
        searchedNamesAndModules.set(name, new Set([target]));
      }
      return target.getVariableForExportName(name, {
        importerForSideEffects,
        isExportAllSearch,
        searchedNamesAndModules
      });
    }
  2. 导出(export)变量的 Rollup Ast 节点获取

    获取 export 的变量的 Rollup Ast Node 节点

    ts
    class Module {
      getVariableForExportName(
        name: string,
        {
          importerForSideEffects,
          isExportAllSearch,
          onlyExplicit,
          searchedNamesAndModules
        }: {
          importerForSideEffects?: Module;
          isExportAllSearch?: boolean;
          onlyExplicit?: boolean;
          searchedNamesAndModules?: Map<
            string,
            Set<Module | ExternalModule>
          >;
        } = EMPTY_OBJECT
      ): [variable: Variable | null, indirectExternal?: boolean] {
        // 其他逻辑省略
        const exportDeclaration = this.exports.get(name);
        if (exportDeclaration) {
          if (exportDeclaration === MISSING_EXPORT_SHIM_DESCRIPTION) {
            return [this.exportShimVariable];
          }
          const name = exportDeclaration.localName;
          const variable = this.traceVariable(name, {
            importerForSideEffects,
            searchedNamesAndModules
          })!;
          if (importerForSideEffects) {
            setAlternativeExporterIfCyclic(
              variable,
              importerForSideEffects,
              this
            );
            getOrCreate(
              importerForSideEffects.sideEffectDependenciesByVariable,
              variable,
              getNewSet<Module>
            ).add(this);
          }
          return [variable];
        }
      }
      // 其他逻辑省略
    }

    可以看到会通过 traceVariable 方法获取目标模块中声明的导出变量。

    js
    class Module {
      traceVariable(
        name: string,
        {
          importerForSideEffects,
          isExportAllSearch,
          searchedNamesAndModules
        }: {
          importerForSideEffects?: Module;
          isExportAllSearch?: boolean;
          searchedNamesAndModules?: Map<string, Set<Module | ExternalModule>>;
        } = EMPTY_OBJECT
      ): Variable | null {
        const localVariable = this.scope.variables.get(name);
        if (localVariable) {
          return localVariable;
        }
    
        const importDescription = this.importDescriptions.get(name);
        if (importDescription) {
          const otherModule = importDescription.module;
    
          if (otherModule instanceof Module && importDescription.name === '*') {
            return otherModule.namespace;
          }
    
          const [declaration] = getVariableForExportNameRecursive(
            otherModule,
            importDescription.name,
            importerForSideEffects || this,
            isExportAllSearch,
            searchedNamesAndModules
          );
    
          if (!declaration) {
            return this.error(
              logMissingExport(importDescription.name, this.id, otherModule.id),
              importDescription.start
            );
          }
    
          return declaration;
        }
        return null;
      }
    }

    但可能需要考虑以下两种特殊情况:

    1. 导出的是当前模块中声明的变量

      js
      // target.js
      export const a = 1;

      这是一个递归出口。对于这种情况直接通过 this.scope.variables.get(name) 获取当前作用域中声明的 Rollup Ast Node 节点即可。

      js
      const localVariable = this.scope.variables.get(name);
      if (localVariable) {
        return localVariable;
      }
    2. 导出的是依赖模块中声明的变量

    js
    // target.js
    import { a } from './other';
    export { a };

    对于这种本质上和重导出的性质一致,因此继续通过 getVariableForExportNameRecursive 方法递归其他模块获取目标模块作用域中包含的变量声明。

    js
    if (importDescription) {
      const otherModule = importDescription.module;
    
      if (otherModule instanceof Module && importDescription.name === '*') {
        return otherModule.namespace;
      }
    
      const [declaration] = getVariableForExportNameRecursive(
        otherModule,
        importDescription.name,
        importerForSideEffects || this,
        isExportAllSearch,
        searchedNamesAndModules
      );
    
      if (!declaration) {
        return this.error(
          logMissingExport(importDescription.name, this.id, otherModule.id),
          importDescription.start
        );
      }
    
      return declaration;
    }

    举一个例子

    js
    // main.js
    export { a } from './other';
    js
    // other.js
    export { a } from './other-next';
    js
    // other-next.js
    import { a } from './other-next-next.js';
    export { a };
    js
    // other-next-next.js
    export const a = 123;

    若在 main.js 中通过 getVariableForExportName 方法获取 a 变量的 Rollup Ast Node 节点时,会递归调用 getVariableForExportNameRecursive 方法,直到找到 a 变量为止。也就是最后会获取到 other-next-next 模块中声明的 a 变量的 Rollup Ast Node 节点,同时该 Rollup Ast Node 节点的 module 属性为 other-next-next 模块的引用。:::

确定 statements 是否具有副作用

提前说明

每一个模块中都会包含 moduleSideEffects 属性(module.info.moduleSideEffects),该属性用于标记当前的模块是否具有副作用,从而影响 Rollup 对模块的 tree-sharking 行为。

ts
interface ModuleOptions {
  attributes: Record<string, string>;
  meta: CustomPluginOptions;
  moduleSideEffects: boolean | 'no-treeshake';
  syntheticNamedExports: boolean | string;
}

moduleSideEffects 值分为以下三种:

  1. moduleSideEffects = true

    表示该模块具有副作用,这是 Rollup 的默认行为,Rollup 认为所有模块都具有副作用。

    ts
    class ModuleLoader {
      constructor(
        private readonly graph: Graph,
        private readonly modulesById: Map<string, Module | ExternalModule>,
        private readonly options: NormalizedInputOptions,
        private readonly pluginDriver: PluginDriver
      ) {
        this.hasModuleSideEffects = options.treeshake
          ? options.treeshake.moduleSideEffects
          : () => true;
      }
      private getResolvedIdWithDefaults(
        resolvedId: NormalizedResolveIdWithoutDefaults | null,
        attributes: Record<string, string>
      ): ResolvedId | null {
        if (!resolvedId) {
          return null;
        }
        const external = resolvedId.external || false;
        return {
          attributes: resolvedId.attributes || attributes,
          external,
          id: resolvedId.id,
          meta: resolvedId.meta || {},
          moduleSideEffects:
            resolvedId.moduleSideEffects ?? this.hasModuleSideEffects(resolvedId.id, !!external),
          resolvedBy: resolvedId.resolvedBy ?? 'rollup',
          syntheticNamedExports: resolvedId.syntheticNamedExports ?? false
        };
      }
    }

    执行 RollupresolveId 插件钩子时,可以通过钩子返回的 moduleSideEffects 属性来指定模块是否有副作用,从而影响 Rollup 对模块的 tree-sharking 行为。钩子没有返回 moduleSideEffects 属性时,则默认模块具有副作用(即 moduleSideEffects = true),当然也可以通过 options.treeshake.moduleSideEffects 属性来指定上述的默认行为。

    tree-sharking 阶段会根据 statements 是否有副作用(hasEffects)执行 tree-sharking 操作。

    ts
    class Module {
      include(): void {
        const context = createInclusionContext();
        if (this.ast!.shouldBeIncluded(context)) this.ast!.include(context, false);
      }
    }
  2. moduleSideEffects = false:表示该模块没有副作用,tree-sharking 阶段会直接删掉该模块。

  3. moduleSideEffects = 'no-treeshake':表示该模块不需要执行 tree-sharking 操作,完全保留该模块的内容及其所有依赖项。

    ts
    class Graph {
      private includeStatements(): void {
        // 省略上方逻辑
        if (this.options.treeshake) {
          let treeshakingPass = 1;
          do {
            timeStart(`treeshaking pass ${treeshakingPass}`, 3);
            this.needsTreeshakingPass = false;
            for (const module of this.modules) {
              if (module.isExecuted) {
                if (module.info.moduleSideEffects === 'no-treeshake') {
                  module.includeAllInBundle();
                } else {
                  module.include();
                }
              }
            }
            if (treeshakingPass === 1) {
              // We only include exports after the first pass to avoid issues with
              // the TDZ detection logic
              for (const module of entryModules) {
                if (module.preserveSignature !== false) {
                  module.includeAllExports(false);
                  this.needsTreeshakingPass = true;
                }
              }
            }
            timeEnd(`treeshaking pass ${treeshakingPass++}`, 3);
          } while (this.needsTreeshakingPass);
        }
        // 省略下方逻辑
      }
    }

    可以看到如果需要执行的模块,若其 moduleSideEffects 属性值设置为 'no-treeshake' 时,Rollup 会通过执行 module.includeAllInBundle() 方法来保留该模块中的所有内容,includeAllInBundle 的详细说明可见下方。

moduleSideEffects 属性值还可以通过插件的 loadtransform 钩子来指定当前模块是否具有副作用,插件执行完后 Rollup 会通过 updateOptions 方法来更新模块的 moduleSideEffects 属性。

ts
class Module {
  updateOptions({
    meta,
    moduleSideEffects,
    syntheticNamedExports
  }: Partial<PartialNull<ModuleOptions>>): void {
    if (moduleSideEffects != null) {
      this.info.moduleSideEffects = moduleSideEffects;
    }
    if (syntheticNamedExports != null) {
      this.info.syntheticNamedExports = syntheticNamedExports;
    }
    if (meta != null) {
      Object.assign(this.info.meta, meta);
    }
  }
}

执行完 生成模块依赖图排序模块执行顺序 之后,会正式进入 tree-sharking 阶段,具体的逻辑实现在 includeStatements 方法中。

js
class Graph {
  async build(): Promise<void> {
    timeStart('generate module graph', 2);
    await this.generateModuleGraph();
    timeEnd('generate module graph', 2);

    timeStart('sort and bind modules', 2);
    this.phase = BuildPhase.ANALYSE;
    this.sortModules();
    timeEnd('sort and bind modules', 2);

    timeStart('mark included statements', 2);
    this.includeStatements();
    timeEnd('mark included statements', 2);

    this.phase = BuildPhase.GENERATE;
  }
}

includeStatements

rollup 以用户的配置项(即 options.input) 和 implictlyLoadedBefore 作为入口,通过 BFS 算法遍历各个入口模块的所有依赖项,遍历到的依赖项模块需要执行(即 module.isExecuted = true)。

ts
function markModuleAndImpureDependenciesAsExecuted(
  baseModule: Module
): void {
  baseModule.isExecuted = true;
  const modules = [baseModule];
  const visitedModules = new Set<string>();
  for (const module of modules) {
    for (const dependency of [
      ...module.dependencies,
      ...module.implicitlyLoadedBefore
    ]) {
      if (
        !(dependency instanceof ExternalModule) &&
        !dependency.isExecuted &&
        (dependency.info.moduleSideEffects ||
          module.implicitlyLoadedBefore.has(dependency)) &&
        !visitedModules.has(dependency.id)
      ) {
        dependency.isExecuted = true;
        visitedModules.add(dependency.id);
        modules.push(dependency);
      }
    }
  }
}
class Graph {
  private includeStatements(): void {
    const entryModules = [
      ...this.entryModules,
      ...this.implicitEntryModules
    ];
    for (const module of entryModules) {
      markModuleAndImpureDependenciesAsExecuted(module);
    }
    // 省略下方逻辑
  }
}

标记完哪些模块需要执行后,则要正式进入 tree-sharking 阶段。

ts
class Graph {
  private includeStatements(): void {
    // 省略上方逻辑
    if (this.options.treeshake) {
      // 省略 tree-sharking 逻辑
    } else {
      for (const module of this.modules) module.includeAllInBundle();
    }
    // 省略下方逻辑
  }
}

根据用户配置项(即 options.treeshake)判断是否开启 tree-sharking。若不需要执行 tree-sharking,则对所有模块执行 includeAllInBundle 方法。先来看一下 includeAllInBundle 方法的逻辑。

ts
class Module {
  includeAllInBundle(): void {
    this.ast!.include(createInclusionContext(), true);
    this.includeAllExports(false);
  }
}

includeAllInBundle 方法做了两件事情,在此之前需要明确一件事情,若 Ast Node 确认为需要包含在最后的产物中则会执行 node.include,后续 ast.render 阶段会跳过这些 未包含Ast 节点,从而达到 tree-sharking 的效果。

  1. 调用 ast.include 方法,将当前模块的 Ast 节点标记为 包含

    ts
    class Program {
      include(
        context: InclusionContext,
        includeChildrenRecursively: IncludeChildren
      ): void {
        this.included = true;
        for (const node of this.body) {
          if (includeChildrenRecursively || node.shouldBeIncluded(context)) {
            node.include(context, includeChildrenRecursively);
          }
        }
      }
    }

    调用 ast.include 的第二个参数 includeChildrenRecursively 标记为 true,意味着在递归模块的 Ast 节点,会递归包含(确认执行 node.include)所有的子孙 Ast Node 节点。若没有这个标记位,那么 Ast Node 是否需要包含在最终的代码中是由 Ast Node 是否存在 副作用 而决定的。

    js
    class NodeBase {
      shouldBeIncluded(context: InclusionContext): boolean {
        return this.included || (!context.brokenFlow && this.hasEffects(createHasEffectsContext()));
      }
    }
  2. 调用 includeAllExports 方法。

includeAllExports 方法主要也做了两件事:

ts
function markModuleAndImpureDependenciesAsExecuted(
  baseModule: Module
): void {
  baseModule.isExecuted = true;
  const modules = [baseModule];
  const visitedModules = new Set<string>();
  for (const module of modules) {
    for (const dependency of [
      ...module.dependencies,
      ...module.implicitlyLoadedBefore
    ]) {
      if (
        !(dependency instanceof ExternalModule) &&
        !dependency.isExecuted &&
        (dependency.info.moduleSideEffects ||
          module.implicitlyLoadedBefore.has(dependency)) &&
        !visitedModules.has(dependency.id)
      ) {
        dependency.isExecuted = true;
        visitedModules.add(dependency.id);
        modules.push(dependency);
      }
    }
  }
}
class Module {
  getReexports(): string[] {
    if (this.transitiveReexports) {
      return this.transitiveReexports;
    }
    // to avoid infinite recursion when using circular `export * from X`
    this.transitiveReexports = [];

    const reexports = new Set(this.reexportDescriptions.keys());

    for (const module of this.exportAllModules) {
      if (module instanceof ExternalModule) {
        reexports.add(`*${module.id}`);
      } else {
        for (const name of [
          ...module.getReexports(),
          ...module.getExports()
        ]) {
          if (name !== 'default') reexports.add(name);
        }
      }
    }
    return (this.transitiveReexports = [...reexports]);
  }
  includeAllExports(includeNamespaceMembers: boolean): void {
    if (!this.isExecuted) {
      markModuleAndImpureDependenciesAsExecuted(this);
      this.graph.needsTreeshakingPass = true;
    }

    for (const exportName of this.exports.keys()) {
      if (
        includeNamespaceMembers ||
        exportName !== this.info.syntheticNamedExports
      ) {
        const variable = this.getVariableForExportName(exportName)[0];
        if (!variable) {
          return error(logMissingEntryExport(exportName, this.id));
        }
        variable.deoptimizePath(UNKNOWN_PATH);
        if (!variable.included) {
          this.includeVariable(variable);
        }
      }
    }

    for (const name of this.getReexports()) {
      const [variable] = this.getVariableForExportName(name);
      if (variable) {
        variable.deoptimizePath(UNKNOWN_PATH);
        if (!variable.included) {
          this.includeVariable(variable);
        }
        if (variable instanceof ExternalVariable) {
          variable.module.reexported = true;
        }
      }
    }

    if (includeNamespaceMembers) {
      this.namespace.setMergedNamespaces(
        this.includeAndGetAdditionalMergedNamespaces()
      );
    }
  }
}
  1. 若模块未标记为已执行,则标记当前模块及其依赖的模块为已执行(即 module.isExecuted = true)。
  2. 遍历当前模块的 exportsreexports,将所有 导出变量重导出变量 对应的 Ast Node 节点标记为 包括 (即 variable.included = true)。

这里有一个有意思的点是通过 getVariableForExportName 来获取导出和重导出变量所对应的 Ast Node,来看看是如何实现的吧。

tree-sharking

tree-sharking 的执行是在 Chunkrender 阶段,此时已经明确了哪些模块需要被执行,哪些 statements 需要保留在最终的产物中。

ts
class Chunk {
  // This method changes properties on the AST before rendering and must not be async
  private renderModules(fileName: string) {
    // 省略其他逻辑
    for (const module of orderedModules) {
      const renderedLength = 0;
      let source: MagicString | undefined;
      if (module.isIncluded() || includedNamespaces.has(module)) {
        const rendered = module.render(renderOptions);
        // 省略其他逻辑
      }
    }
    // 省略其他逻辑
  }
  async render(): Promise<ChunkRenderResult> {
    const {
      accessedGlobals,
      indent,
      magicString,
      renderedSource,
      usedModules,
      usesTopLevelAwait
    } = this.renderModules(preliminaryFileName.fileName);
    // 省略其他逻辑
  }
}

生成 chunks 这篇文章可知,一个 chunk 对应至少一个 module。因此上述的源码逻辑中可以看出 Rollup 会依次遍历 chunk 中所有的 module,然后依次调用每一个模块的 render 方法来生成最终的代码。

注意

chunk 中所包含的 所有模块 是按照模块执行顺序依次排序的,存储在 orderedModules 数组中。

ts
const compareExecIndex = <T extends OrderedExecutionUnit>(
  unitA: T,
  unitB: T
) => (unitA.execIndex > unitB.execIndex ? 1 : -1);
function sortByExecutionOrder(units: OrderedExecutionUnit[]): void {
  units.sort(compareExecIndex);
}
class Bundle {
  private async generateChunks(): Promise<void> {
    // 省略其他逻辑
    for (const { alias, modules } of inlineDynamicImports
      ? [{ alias: null, modules: includedModules }]
      : preserveModules
        ? includedModules.map(module => ({
            alias: null,
            modules: [module]
          }))
        : getChunkAssignments(
            this.graph.entryModules,
            manualChunkAliasByEntry,
            experimentalMinChunkSize,
            this.inputOptions.onLog
          )) {
      sortByExecutionOrder(modules);
      const chunk = new Chunk(
        modules,
        this.inputOptions,
        this.outputOptions,
        this.unsetOptions,
        this.pluginDriver,
        this.graph.modulesById,
        chunkByModule,
        externalChunkByModule,
        this.facadeChunkByModule,
        this.includedNamespaces,
        alias,
        getHashPlaceholder,
        bundle,
        inputBase,
        snippets
      );
      chunks.push(chunk);
    }
    // 省略其他逻辑
  }
  // 省略其他逻辑
}

可以看到 tree-sharking 的执行时机是在 module.render(renderOptions) 函数中。再深入研究一下 module.render(renderOptions) 函数的逻辑。

ts
class Module {
  render(options: RenderOptions): {
    source: MagicString;
    usesTopLevelAwait: boolean;
  } {
    const source = this.magicString.clone();
    this.ast!.render(source, options);
    source.trim();
    const { usesTopLevelAwait } = this.astContext;
    if (
      usesTopLevelAwait &&
      options.format !== 'es' &&
      options.format !== 'system'
    ) {
      return error(logInvalidFormatForTopLevelAwait(this.id, options.format));
    }
    return { source, usesTopLevelAwait };
  }
}

从这里可以了解到 Rollup 中的 TLA 语法并不支持非 ESMsystem 的输出格式。关于 TLA 语法的具体实现方案可以参考 TLA 的详细讲解与实现

除此之外 module.render(renderOptions) 函数中会调用 module.ast.render(source, options) 方法,从而执行 tree-sharking 操作。

this.ast!.render(source, options) 方法的逻辑如下:

ts
class Program {
  render(code: MagicString, options: RenderOptions): void {
    let start = this.start;
    if (code.original.startsWith('#!')) {
      start = Math.min(code.original.indexOf('\n') + 1, this.end);
      code.remove(0, start);
    }
    if (this.body.length > 0) {
      // Keep all consecutive lines that start with a comment
      while (
        code.original[start] === '/' &&
        /[*/]/.test(code.original[start + 1])
      ) {
        const firstLineBreak = findFirstLineBreakOutsideComment(
          code.original.slice(start, this.body[0].start)
        );
        if (firstLineBreak[0] === -1) {
          break;
        }
        start += firstLineBreak[1];
      }
      renderStatementList(this.body, code, start, this.end, options);
    } else {
      super.render(code, options);
    }
  }
}

逻辑很简单,依次 render 当前模块对应的 Astbody 节点,通过 renderStatementList 方法来渲染每一条语句。换句话说 renderStatementList 方法就是 tree-sharking 的核心逻辑所在。

ts
function renderStatementList(
  statements: readonly StatementNode[],
  code: MagicString,
  start: number,
  end: number,
  options: RenderOptions
): void {
  let currentNode, currentNodeStart, currentNodeNeedsBoundaries, nextNodeStart;
  let nextNode = statements[0];
  let nextNodeNeedsBoundaries = !nextNode.included || nextNode.needsBoundaries;
  if (nextNodeNeedsBoundaries) {
    nextNodeStart =
      start +
      findFirstLineBreakOutsideComment(
        code.original.slice(start, nextNode.start)
      )[1];
  }

  for (let nextIndex = 1; nextIndex <= statements.length; nextIndex++) {
    currentNode = nextNode;
    currentNodeStart = nextNodeStart;
    currentNodeNeedsBoundaries = nextNodeNeedsBoundaries;
    nextNode = statements[nextIndex];
    nextNodeNeedsBoundaries =
      nextNode === undefined
        ? false
        : !nextNode.included || nextNode.needsBoundaries;
    if (currentNodeNeedsBoundaries || nextNodeNeedsBoundaries) {
      nextNodeStart =
        currentNode.end +
        findFirstLineBreakOutsideComment(
          code.original.slice(
            currentNode.end,
            nextNode === undefined ? end : nextNode.start
          )
        )[1];
      if (currentNode.included) {
        if (currentNodeNeedsBoundaries) {
          currentNode.render(code, options, {
            end: nextNodeStart,
            start: currentNodeStart
          });
        } else {
          currentNode.render(code, options);
        }
      } else {
        treeshakeNode(currentNode, code, currentNodeStart!, nextNodeStart);
      }
    } else {
      currentNode.render(code, options);
    }
  }
}

renderStatementList 方法中会根据 statement 的 Ast Node 是否需要(node.included) 来决定是否调用 node.render 方法还是 treeshakeNode 方法。

  1. 若节点不需要包裹在最终产物中(即 node.included = false)

    那么就会通过 treeshakeNode 方法来执行删除这块代码的逻辑操作。

    ts
    function treeshakeNode(
      node: Node,
      code: MagicString,
      start: number,
      end: number
    ): void {
      code.remove(start, end);
      node.removeAnnotations(code);
    }
  2. 若节点需要包裹在最终产物中(即 node.included = true)

    那么就会通过 node.render 方法递归 Ast 树并做细节处理。逻辑主要包含对于源代码的细节调整,比如替换变量名、删除未使用的代码块(递归调用 renderStatementList)、去除所需的 export ast 节点对应的源码的 export 关键字、句末添加分号等。

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)