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

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

准备阶段

AST 上下文

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 结构来收集模块信息。

收集模块信息
  1. 动态依赖

    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. 静态依赖

    与上述一样,当 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 导出的变量。

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

    总结

    在实例化 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 阶段起作用。

在 AST 节点中创建作用域

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 做准备。

创建作用域

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

函数作用域

当遇到函数声明语句时,会在 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 树的过程中,就可以检测到函数体中变量声明的重复问题。

实例化 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,来看看是如何实现的吧。

AST 节点副作用判定体系

上述 includeStatements 流程中,判断 statement 是否需要保留在最终产物中的核心方法是 shouldBeIncluded

ts
class NodeBase {
  shouldBeIncluded(context: InclusionContext): boolean {
    return this.included || (!context.brokenFlow && this.hasEffects(createHasEffectsContext()));
  }
}

hasEffects() 是一个递归下降的分析过程,默认实现递归检查所有子节点:

ts
class NodeBase {
  hasEffects(context: HasEffectsContext): boolean {
    if (!this.deoptimized) this.applyDeoptimizations();
    for (const key of childNodeKeys[this.type]) {
      const value = (this as GenericEsTreeNode)[key];
      if (value === null) continue;
      if (Array.isArray(value)) {
        for (const child of value) {
          if (child?.hasEffects(context)) return true;
        }
      } else if (value.hasEffects(context)) return true;
    }
    return false;
  }
}

rollup 为不同的 AST 节点类型覆写了 hasEffects() 方法,实现了精细化的副作用判定。以下按照判定结果的确定性分为三类进行分析。

恒定有副作用的节点

以下节点类型的 hasEffects() 始终返回 true,无论上下文如何均会被保留。

ThrowStatement
ts
// src/ast/nodes/ThrowStatement.ts
hasEffects(): boolean {
  return true;
}

throw 语句改变了程序的控制流,属于不可消除的副作用。

js
throw new Error('x'); // 始终保留
DebuggerStatement
ts
// src/ast/nodes/DebuggerStatement.ts
hasEffects(): boolean {
  return true;
}

debugger 语句会中断程序执行。

js
// 输入
debugger;
const unused = 1;

// Rollup 输出
debugger;
// unused 被移除,但 debugger 始终保留
AwaitExpression
ts
// src/ast/nodes/AwaitExpression.ts
hasEffects(): boolean {
  return true;
}

await 暂停执行并触发微任务调度,rollup 保守地认为它始终有副作用。

js
// 输入
export async function main() {
  await Promise.resolve();
  const unused = 42;
  return 1;
}

// Rollup 输出
async function main() {
  await Promise.resolve();
  return 1;
}
// await 表达式始终保留,unused 被移除
export { main };
ImportExpression(动态 import
ts
// src/ast/nodes/ImportExpression.ts
hasEffects(): boolean {
  return true;
}

import() 触发模块加载(网络请求或文件 I/O),属于运行时副作用。

js
// 输入
const unused = import('./module');

// Rollup 输出
import('./module');
// 即使 unused 未被使用,import() 调用也会被保留(副作用)
// 但赋值目标 unused 被移除
ForOfStatement
ts
// src/ast/nodes/ForOfStatement.ts
hasEffects(): boolean {
  if (!this.deoptimized) this.applyDeoptimizations();
  // Placeholder until proper Symbol.Iterator support
  return true;
}

for...of 会调用 Symbol.iterator 协议,rollup 目前尚未实现对迭代器协议的精确追踪,因此保守地标记为有副作用。源码中的注释也明确说明这是一个临时的占位实现。

js
// 输入
const arr = [1, 2, 3];
for (const item of arr) {
  // 空循环体
}
const unused = 1;

// Rollup 输出
const arr = [1, 2, 3];
for (const item of arr) {
}
// for...of 始终保留(可能调用自定义 Symbol.iterator)
// unused 被移除
UnknownNode
ts
// src/ast/nodes/UnknownNode.ts
hasEffects(): boolean {
  return true;
}

无法识别的节点类型,保守处理以确保正确性。

恒定无副作用的节点

以下节点类型的 hasEffects() 始终返回 false,不会产生任何副作用。

ImportDeclaration
ts
// src/ast/nodes/ImportDeclaration.ts
hasEffects(): boolean {
  return false;
}

import 声明本身不产生副作用,模块的副作用在模块级别通过 moduleSideEffects 单独处理。

js
import { foo } from './bar'; // 始终无副作用
ExportAllDeclaration
ts
// src/ast/nodes/ExportAllDeclaration.ts
hasEffects(): boolean {
  return false;
}

export * from './foo' 是纯声明性语句,不产生运行时行为。

js
export * from './utils';
// 声明本身无副作用,是否保留取决于 utils 模块是否有被使用的导出
ArrowFunctionExpression
ts
// src/ast/nodes/ArrowFunctionExpression.ts
hasEffects(): boolean {
  return false;
}

箭头函数的声明本身无副作用,只有调用时才可能产生副作用。这是一个关键区分 —— 函数声明和函数调用的副作用判定是分开的。

js
const fn = () => console.log('hi'); // 声明无副作用
fn();                                // 调用有副作用(取决于 callee 分析)
EmptyStatementTemplateElementMetaProperty

这些节点不产生任何运行时行为,始终返回 false

条件性副作用节点

以下节点类型的副作用判定取决于其子节点、上下文配置或运行时语义,是 rollup tree-shaking 精度的核心所在。

CallExpression — 函数调用

函数调用是最常见的副作用来源,rollup 的判定逻辑包含四个层次:

ts
// src/ast/nodes/CallExpression.ts
hasEffects(context: HasEffectsContext): boolean {
  if (!this.deoptimized) this.applyDeoptimizations();
  for (const argument of this.arguments) {
    if (argument.hasEffects(context)) return true;       // ① 参数有副作用
  }
  if (this.annotationPure) {
    return false;                                         // ② #__PURE__ 注解
  }
  return (
    this.callee.hasEffects(context) ||                    // ③ callee 本身有副作用
    this.callee.hasEffectsOnInteractionAtPath(            // ④ 调用 callee 有副作用
      EMPTY_PATH, this.interaction, context
    )
  );
}

判定优先级

  1. 参数副作用:先检查每个参数表达式是否有副作用。例如 foo(bar()) 中,若 bar() 有副作用,则整个调用有副作用。
  2. #__PURE__ 注解:若调用标记了 /*#__PURE__*/,跳过后续检查,直接认为无副作用。
  3. callee 自身副作用:检查 callee 表达式是否有副作用(如计算属性访问)。
  4. 调用交互副作用:通过 hasEffectsOnInteractionAtPath 递归分析函数体内是否有副作用。

示例 1#__PURE__ 注解:

js
const result = /*#__PURE__*/ createApp();
// annotationPure = true → 无副作用 → 若 result 未被使用,整个调用可移除

示例 2 — 已知无副作用的函数体:

js
function add(a, b) { return a + b; }
add(1, 2);
// hasEffectsOnInteractionAtPath 分析函数体 → return a + b 无副作用 → 整个调用无副作用

示例 3 — 未知函数:

js
const fn = getHandler();
fn();
// fn 的值无法静态确定 → 保守认为有副作用

对应的 #__PURE__ 注解收集逻辑在 CallExpression.initialise() 中:

ts
// src/ast/nodes/CallExpression.ts
initialise() {
  super.initialise();
  if (
    this.annotations &&
    (this.scope.context.options.treeshake as NormalizedTreeshakingOptions).annotations
  ) {
    this.annotationPure = this.annotations.some(comment => comment.type === 'pure');
  }
}
FunctionBase.hasEffectsOnInteractionAtPath — 函数被调用时的副作用

CallExpressioncallee 是一个可追踪的函数时,rollup 会深入分析函数体来判断调用是否有副作用:

ts
// src/ast/nodes/shared/FunctionBase.ts
hasEffectsOnInteractionAtPath(
  path: ObjectPath,
  interaction: NodeInteraction,
  context: HasEffectsContext
): boolean {
  if (path.length > 0 || interaction.type !== INTERACTION_CALLED) {
    return this.getObjectEntity().hasEffectsOnInteractionAtPath(path, interaction, context);
  }
  if (this.hasCachedEffects) {
    return true;
  }
  // async 函数:检查返回值的 .then 是否有副作用
  if (this.async) {
    const { propertyReadSideEffects } = this.scope.context.options.treeshake;
    const returnExpression = this.scope.getReturnExpression();
    if (
      returnExpression.hasEffectsOnInteractionAtPath(
        ['then'], NODE_INTERACTION_UNKNOWN_CALL, context
      ) ||
      (propertyReadSideEffects && (
        propertyReadSideEffects === 'always' ||
        returnExpression.hasEffectsOnInteractionAtPath(
          ['then'], NODE_INTERACTION_UNKNOWN_ACCESS, context
        )
      ))
    ) {
      this.hasCachedEffects = true;
      return true;
    }
  }
  // 检查每个参数的解构是否有副作用
  const { propertyReadSideEffects } = this.scope.context.options.treeshake;
  for (let index = 0; index < this.params.length; index++) {
    const parameter = this.params[index];
    if (
      parameter.hasEffects(context) ||
      (propertyReadSideEffects &&
        parameter.hasEffectsWhenDestructuring(
          context, EMPTY_PATH, interaction.args[index + 1] || UNDEFINED_EXPRESSION
        ))
    ) {
      this.hasCachedEffects = true;
      return true;
    }
  }
  return false;
}

这段逻辑有几个值得关注的点:

  1. async 函数的隐式副作用async 函数返回 Promiserollup 会检查返回值的 .then 访问或调用是否有副作用。若返回值存在自定义 then 方法(thenable 对象),则认定有副作用。
  2. 参数解构副作用:若参数包含解构模式(如 function({ a })),且 propertyReadSideEffectstrue,则检查解构操作是否可能触发 getter。
  3. 缓存机制hasCachedEffects 用于缓存副作用判定结果,对于已经确认有副作用的函数不再重复分析。

示例 1 — 纯函数调用可消除:

js
// 输入
function add(a, b) { return a + b; }
const unused = add(1, 2);

// Rollup 输出
// (空,整段代码被移除)
// add 函数体只有 return a + b,无副作用
// unused 未被使用 → add(1, 2) 调用也可移除

示例 2async 函数返回 thenable 对象:

js
// 输入
async function fetchData() {
  return { then() { console.log('thenable'); } };
}
fetchData();

// Rollup 输出
async function fetchData() {
  return { then() { console.log('thenable'); } };
}
fetchData();
// 返回值具有 then 方法 → Promise 决议时会调用 → 有副作用 → 保留

示例 3 — 参数解构触发 getter:

js
// 输入(propertyReadSideEffects: true)
function process({ name }) { return name; }
process({ name: 'test' });

// Rollup 输出
// (空,整段代码被移除)
// 参数 { name } 解构的来源是对象字面量 { name: 'test' },属性读取无副作用

// 但如果:
function process({ name }) { return name; }
process(unknownObj);
// unknownObj 不可追踪 → 解构可能触发 getter → 有副作用 → 保留
AssignmentExpression — 赋值表达式
ts
// src/ast/nodes/AssignmentExpression.ts
hasEffects(context: HasEffectsContext): boolean {
  const { deoptimized, isConstReassignment, left, operator, right } = this;
  if (!deoptimized) this.applyDeoptimizations();
  // MemberExpressions do not access the property before assignments if the
  // operator is '='.
  return (
    isConstReassignment ||                                         // ① const 重新赋值
    right.hasEffects(context) ||                                   // ② 右侧有副作用
    left.hasEffectsAsAssignmentTarget(context, operator !== '=') || // ③ 赋值目标有副作用
    this.left.hasEffectsWhenDestructuring?.(context, EMPTY_PATH, right) // ④ 解构有副作用
  );
}

示例 1 — 简单赋值无副作用:

js
let x;
x = 42; // right(42) 无副作用,left(x) 是本地变量 → 无副作用

示例 2 — 对象属性赋值有副作用:

js
obj.foo = bar; // left 是 MemberExpression → 可能触发 setter → 有副作用

示例 3 — 复合赋值操作符:

js
x += getValue();
// operator !== '=' → hasEffectsAsAssignmentTarget 的 checkAccess = true
// 需要先读取 x 的值(可能触发 getter)→ 有副作用

示例 4const 重新赋值:

ts
// src/ast/nodes/AssignmentExpression.ts
initialise(): void {
  super.initialise();
  if (this.left instanceof Identifier) {
    const variable = this.scope.variables.get(this.left.name);
    if (variable?.kind === 'const') {
      this.isConstReassignment = true;
      // ...
    }
  }
  this.left.setAssignedValue(this.right);
}

rollup 在初始化阶段就检测 const 变量的重新赋值,标记为 isConstReassignment = true,运行时必然抛出 TypeError,因此始终有副作用。

MemberExpression — 属性访问
ts
// src/ast/nodes/MemberExpression.ts
hasEffects(context: HasEffectsContext): boolean {
  if (!this.deoptimized) this.applyDeoptimizations();
  return (
    this.property.hasEffects(context) ||   // ① 计算属性有副作用
    this.object.hasEffects(context) ||      // ② 对象本身有副作用
    this.hasAccessEffect(context)           // ③ 属性访问有副作用
  );
}

其中 hasAccessEffect 的逻辑是核心:

ts
// src/ast/nodes/MemberExpression.ts
private hasAccessEffect(context: HasEffectsContext) {
  const { propertyReadSideEffects } = this.scope.context.options.treeshake;
  return (
    !(this.variable || this.isUndefined) &&
    propertyReadSideEffects &&
    (propertyReadSideEffects === 'always' ||
      this.object.hasEffectsOnInteractionAtPath(
        [this.getDynamicPropertyKey()], this.accessInteraction, context
      ))
  );
}

关键配置项treeshake.propertyReadSideEffects

  • variable 已绑定(如 namespace 导入),则跳过属性访问副作用检查。
  • propertyReadSideEffectsfalse,则假设所有属性读取无副作用。
  • propertyReadSideEffects'always',则所有未绑定变量的属性读取都有副作用。
  • 默认值 true 时,通过 hasEffectsOnInteractionAtPath 递归判断对象在该属性路径上的读取是否有副作用。

示例 1propertyReadSideEffects: true(默认):

js
const x = unknownObj.foo;
// unknownObj 不可追踪 → 可能存在 getter/Proxy → 有副作用

示例 2 — 已知 namespace 变量:

js
import * as ns from './foo';
ns.bar;
// variable 已在 bind 阶段绑定 → hasAccessEffect 被跳过 → 无副作用
IfStatement — 分支消除
ts
// src/ast/nodes/IfStatement.ts
hasEffects(context: HasEffectsContext): boolean {
  if (this.test.hasEffects(context)) {
    return true;
  }
  const testValue = this.getTestValue();
  if (typeof testValue === 'symbol') {
    // 无法确定值 → 两个分支都检查
    const { brokenFlow } = context;
    if (this.consequent.hasEffects(context)) return true;
    const consequentBrokenFlow = context.brokenFlow;
    context.brokenFlow = brokenFlow;
    if (this.alternate === null) return false;
    if (this.alternate.hasEffects(context)) return true;
    context.brokenFlow = context.brokenFlow && consequentBrokenFlow;
    return false;
  }
  // 可确定值 → 只检查会执行的分支
  return testValue
    ? this.consequent.hasEffects(context)
    : !!this.alternate?.hasEffects(context);
}

rollup 通过 getTestValue() 尝试在编译时确定条件表达式的值:

ts
// src/ast/nodes/IfStatement.ts
private getTestValue(): LiteralValueOrUnknown {
  if (this.testValue === unset) {
    return (this.testValue = tryCastLiteralValueToBoolean(
      this.test.getLiteralValueAtPath(EMPTY_PATH, SHARED_RECURSION_TRACKER, this)
    ));
  }
  return this.testValue;
}

若条件值可以在编译时确定,则只分析会执行的分支。否则两个分支都需要分析。同时 rollup 在分析分支时维护了 brokenFlow 上下文,用于追踪 breakcontinuereturn 等控制流中断的场景。

示例 1 — 编译时常量条件(DCE):

js
if (false) { console.log('dead'); }
// testValue = false → 只检查 alternate(null)→ 无副作用 → 整条语句可移除

示例 2 — 替换后的编译时常量:

js
// 经过 replace 插件替换后
if ("production" !== "production") { enableDevTools(); }
// test 值为 false → 只检查 alternate → 无副作用

示例 3 — 运行时条件:

js
if (userInput) { sideEffect(); }
// testValue 无法确定 → 两个分支都检查 → sideEffect() 有副作用 → true
ConditionalExpression — 三元表达式

IfStatement 采用相同的分支优化逻辑:

ts
// src/ast/nodes/ConditionalExpression.ts
hasEffects(context: HasEffectsContext): boolean {
  if (this.test.hasEffects(context)) return true;
  const usedBranch = this.getUsedBranch();
  if (!usedBranch) {
    return this.consequent.hasEffects(context) || this.alternate.hasEffects(context);
  }
  return usedBranch.hasEffects(context);
}

通过 getUsedBranch() 尝试确定使用的分支:

ts
// src/ast/nodes/ConditionalExpression.ts
private getUsedBranch() {
  if (this.isBranchResolutionAnalysed) {
    return this.usedBranch;
  }
  this.isBranchResolutionAnalysed = true;
  const testValue = tryCastLiteralValueToBoolean(
    this.test.getLiteralValueAtPath(EMPTY_PATH, SHARED_RECURSION_TRACKER, this)
  );
  return typeof testValue === 'symbol'
    ? null
    : (this.usedBranch = testValue ? this.consequent : this.alternate);
}

示例

js
const x = true ? pureFunction() : sideEffect();
// usedBranch = consequent → 只分析 pureFunction()
LogicalExpression — 逻辑短路
ts
// src/ast/nodes/LogicalExpression.ts
hasEffects(context: HasEffectsContext): boolean {
  if (this.left.hasEffects(context)) {
    return true;
  }
  if (this.getUsedBranch() !== this.left) {
    return this.right.hasEffects(context);
  }
  return false;
}

rollup 会根据逻辑操作符(||&&??)和左侧的编译时值来确定短路行为:

ts
// src/ast/nodes/LogicalExpression.ts
private getUsedBranch() {
  if (!this.isBranchResolutionAnalysed) {
    this.isBranchResolutionAnalysed = true;
    const leftValue = this.left.getLiteralValueAtPath(EMPTY_PATH, SHARED_RECURSION_TRACKER, this);
    const booleanOrUnknown = tryCastLiteralValueToBoolean(leftValue);
    if (
      typeof booleanOrUnknown === 'symbol' ||
      (this.operator === '??' && typeof leftValue === 'symbol')
    ) {
      return null;
    } else {
      this.usedBranch =
        (this.operator === '||' && booleanOrUnknown) ||
        (this.operator === '&&' && !booleanOrUnknown) ||
        (this.operator === '??' && leftValue != null)
          ? this.left
          : this.right;
    }
  }
  return this.usedBranch;
}

示例 1|| 短路:

js
const x = true || sideEffect();
// left = true, operator = '||' → usedBranch = left → 不检查 right → 无副作用

示例 2&& 传播:

js
const x = condition && sideEffect();
// condition 不确定 → usedBranch = null → 两侧都检查 → sideEffect() 有副作用
UnaryExpression — 一元操作符
ts
// src/ast/nodes/UnaryExpression.ts
hasEffects(context: HasEffectsContext): boolean {
  if (!this.deoptimized) this.applyDeoptimizations();
  if (this.operator === 'typeof' && this.argument instanceof Identifier) return false;
  return (
    this.argument.hasEffects(context) ||
    (this.operator === 'delete' &&
      this.argument.hasEffectsOnInteractionAtPath(
        EMPTY_PATH, NODE_INTERACTION_UNKNOWN_ASSIGNMENT, context
      ))
  );
}

三个关键规则:

  1. typeof + Identifiertypeof undeclaredVar 不会抛出 ReferenceError,因此直接返回 false
  2. delete 操作delete obj.prop 修改了对象结构,通过赋值交互检查判定为有副作用。
  3. 其他操作符!+-void~):只递归检查操作数。

示例 1typeof 特殊处理:

js
typeof undeclaredVar; // typeof + Identifier → false

示例 2delete 操作:

js
delete obj.prop; // delete → 检查赋值交互 → 有副作用

示例 3void 操作:

js
void someExpression; // 递归检查 someExpression
BinaryExpression — 隐式类型转换
ts
// src/ast/nodes/BinaryExpression.ts
hasEffects(context: HasEffectsContext): boolean {
  // support some implicit type coercion runtime errors
  if (
    this.operator === '+' &&
    this.parent instanceof ExpressionStatement &&
    this.left.getLiteralValueAtPath(EMPTY_PATH, SHARED_RECURSION_TRACKER, this) === ''
  ) {
    return true;
  }
  return super.hasEffects(context);
}

rollup'' + obj 模式进行了特殊处理:当 + 操作符的左侧为空字符串且整个表达式作为 ExpressionStatement 使用时,可能调用 obj.toString()obj[Symbol.toPrimitive](),因此认定为有副作用。

js
'' + obj; // 可能触发隐式类型转换 → 有副作用
1 + 2;    // 字面量运算 → 递归检查子节点 → 无副作用
TryStatement — try/catch 去优化
ts
// src/ast/nodes/TryStatement.ts
hasEffects(context: HasEffectsContext): boolean {
  return (
    ((this.scope.context.options.treeshake as NormalizedTreeshakingOptions).tryCatchDeoptimization
      ? this.block.body.length > 0          // 有内容即认为有副作用
      : this.block.hasEffects(context)) ||   // 否则正常递归分析
    !!this.finalizer?.hasEffects(context)
  );
}

tryCatchDeoptimizationtrue(默认)时,只要 try 块非空就认为有副作用。这是因为 try 块中的代码若抛出异常,catch 块会改变控制流,rollup 难以精确追踪异常传播路径。

js
try {
  pureFunctionCall(); // 即使函数体无副作用,try 块非空 → 有副作用
} catch (e) {}

同时在 include 阶段,tryCatchDeoptimization 还会影响 try 块内的 include 策略:

ts
// src/ast/nodes/TryStatement.ts
include(context: InclusionContext, includeChildrenRecursively: IncludeChildren): void {
  const tryCatchDeoptimization = (
    this.scope.context.options.treeshake as NormalizedTreeshakingOptions
  )?.tryCatchDeoptimization;
  // ...
  if (!this.directlyIncluded || !tryCatchDeoptimization) {
    this.included = true;
    this.directlyIncluded = true;
    this.block.include(
      context,
      tryCatchDeoptimization ? INCLUDE_PARAMETERS : includeChildrenRecursively
    );
    // ...
  }
  // ...
}

tryCatchDeoptimizationtrue 时,try 块中的语句以 INCLUDE_PARAMETERS 模式进行 include,这意味着在 try 块中被使用的函数参数不会被优化掉。这也与前面 Try Catch Deoptimization 配置项所展示的行为一致:try 语句中使用的参数会被保留。

VariableDeclarator — 变量声明
ts
// src/ast/nodes/VariableDeclarator.ts
hasEffects(context: HasEffectsContext): boolean {
  const initEffect = this.init?.hasEffects(context);
  this.id.markDeclarationReached();
  return (
    initEffect ||                               // ① 初始化表达式有副作用
    this.isUsingDeclaration ||                  // ② using 声明
    this.isAsyncUsingDeclaration ||             // ③ await using 声明
    this.id.hasEffects(context) ||              // ④ 声明模式有副作用
    ((this.scope.context.options.treeshake as NormalizedTreeshakingOptions)
      .propertyReadSideEffects &&
      this.id.hasEffectsWhenDestructuring(      // ⑤ 解构有副作用
        context, EMPTY_PATH, this.init || UNDEFINED_EXPRESSION
      ))
  );
}

示例 1 — 简单声明:

js
const x = 42; // init 为字面量 42 无副作用,id 为 Identifier → 无副作用

示例 2 — 函数调用初始化:

js
const config = getConfig(); // getConfig() 有副作用 → 有副作用

示例 3using 声明(TC39 Explicit Resource Management):

js
using handle = getResource();
// isUsingDeclaration = true → 有副作用(运行时会调用 Symbol.dispose)

示例 4 — 解构赋值与 propertyReadSideEffects

js
const { a } = unknownObj;
// propertyReadSideEffects = true → hasEffectsWhenDestructuring 检查属性读取 → 有副作用
ClassNode — class 声明
ts
// src/ast/nodes/shared/ClassNode.ts
hasEffects(context: HasEffectsContext): boolean {
  if (!this.deoptimized) this.applyDeoptimizations();
  const initEffect = this.superClass?.hasEffects(context) || this.body.hasEffects(context);
  this.id?.markDeclarationReached();
  return initEffect || super.hasEffects(context) || checkEffectForNodes(this.decorators, context);
}

class 声明的副作用来源有三个方面:

  1. extends 表达式:若父类是函数调用结果(如 class Foo extends getBase()),则有副作用。
  2. 类体内容:静态属性初始化、计算属性名等可能有副作用。
  3. 装饰器:装饰器本质上是函数调用,因此有副作用。

示例 1 — 纯 class 声明:

js
class Foo {
  method() { return 1; }
}
// 无 superClass,body(MethodDefinition)无副作用 → 无副作用

示例 2 — 有 extends:

js
class Bar extends getBase() { }
// superClass = getBase() → 函数调用 → 有副作用

示例 3 — 装饰器:

js
@decorator
class Qux { }
// decorator 是函数调用 → checkEffectForNodes(this.decorators, context) → 有副作用

交互级副作用判定(hasEffectsOnInteractionAtPath

rollup 不仅判断节点自身的副作用,还判断对节点进行特定操作时是否有副作用。这是一个独立于 hasEffects() 的分析维度,通过 hasEffectsOnInteractionAtPath 方法实现。

三种交互类型定义在 NodeInteractions 中:

  • INTERACTION_ACCESSED:属性读取(可能触发 getter)
  • INTERACTION_CALLED:函数调用
  • INTERACTION_ASSIGNED:赋值(可能触发 setter)

这种设计使得 rollup 能够区分声明和使用:

js
function pure() { return 1; }   // 声明 → hasEffects = false
pure();                          // 调用 → hasEffectsOnInteractionAtPath(CALLED)
                                 //        → 递归分析函数体 → return 1 无副作用

MemberExpression 的交互级判定为例:

ts
// src/ast/nodes/MemberExpression.ts
hasEffectsOnInteractionAtPath(
  path: ObjectPath,
  interaction: NodeInteraction,
  context: HasEffectsContext
): boolean {
  if (this.variable) {
    return this.variable.hasEffectsOnInteractionAtPath(path, interaction, context);
  }
  if (this.isUndefined) {
    return true;
  }
  if (path.length < MAX_PATH_DEPTH) {
    return this.object.hasEffectsOnInteractionAtPath(
      [this.getDynamicPropertyKey(), ...path], interaction, context
    );
  }
  return true;
}

当路径深度未超过 MAX_PATH_DEPTH 时,rollup 会将属性键拼接到路径中,递归委托给对象表达式判断。超过深度限制则保守返回 true,防止无限递归。

具体示例

示例 1 — 调用交互(INTERACTION_CALLED):

js
// 输入
function greet() { return 'hello'; }
const obj = { greet };
obj.greet();

// Rollup 输出
// (空,整段代码被移除)
// obj.greet() → MemberExpression.hasEffectsOnInteractionAtPath(['greet'], CALLED)
//             → 追踪到 ObjectEntity → 找到 greet 函数 → 分析函数体 → 无副作用

示例 2 — 属性读取交互(INTERACTION_ACCESSED):

js
// 输入(propertyReadSideEffects: true)
const obj = { get name() { console.log('accessed'); return 'x'; } };
const unused = obj.name;

// Rollup 输出
const obj = { get name() { console.log('accessed'); return 'x'; } };
obj.name;
// obj.name 读取触发 getter → getter 内有 console.log → 有副作用 → 保留

示例 3 — 赋值交互(INTERACTION_ASSIGNED):

js
// 输入
const obj = { set value(v) { console.log('set', v); } };
obj.value = 42;

// Rollup 输出
const obj = { set value(v) { console.log('set', v); } };
obj.value = 42;
// obj.value = 42 触发 setter → setter 内有 console.log → 有副作用 → 保留

延迟去优化机制(applyDeoptimizations

在上述多个节点的 hasEffects 实现中都能看到开头的 if (!this.deoptimized) this.applyDeoptimizations(); 调用。这是 rollup 的一个关键优化:延迟去优化(lazy deoptimization)

applyDeoptimizations 的默认实现:

ts
// src/ast/nodes/shared/Node.ts
applyDeoptimizations() {
  this.deoptimized = true;
  for (const key of childNodeKeys[this.type]) {
    const value = (this as GenericEsTreeNode)[key];
    if (value === null) continue;
    if (Array.isArray(value)) {
      for (const child of value) {
        child?.deoptimizePath(UNKNOWN_PATH);
      }
    } else {
      value.deoptimizePath(UNKNOWN_PATH);
    }
  }
  this.scope.context.requestTreeshakingPass();
}

不同节点类型覆写了 applyDeoptimizations 以实现特定的去优化逻辑:

节点类型applyDeoptimizations 的关键行为
CallExpression将参数信息传播给 callee(deoptimizeArgumentsOnInteractionAtPath
AssignmentExpression将右侧值传播给左侧目标(deoptimizeAssignment
MemberExpressionpropertyReadSideEffectstrue,通知对象属性被访问
IdentifierBase标记变量被使用,触发所在模块的执行标记
ForOfStatement去优化左侧声明和右侧表达式的路径
ClassNode去优化所有非静态方法的返回值
UnaryExpressiondelete去优化被删除的路径

所有 applyDeoptimizations 的最后都会调用 this.scope.context.requestTreeshakingPass(),即 this.graph.needsTreeshakingPass = true。这意味着去优化过程可能改变其他节点的副作用判定结果,因此需要额外的 tree-shaking 轮次来处理这些变化。

IdentifierBase 为例:

ts
// src/ast/nodes/shared/IdentifierBase.ts
applyDeoptimizations() {
  this.deoptimized = true;
  if (this.variable instanceof LocalVariable) {
    // When accessing a variable from a module without side effects, this
    // means we use an export of that module and therefore need to potentially
    // include it in the bundle.
    if (!this.variable.module.isExecuted) {
      markModuleAndImpureDependenciesAsExecuted(this.variable.module);
    }
    this.variable.consolidateInitializers();
    this.scope.context.requestTreeshakingPass();
  }
  if (this.isVariableReference) {
    this.variable!.addUsedPlace(this);
    this.scope.context.requestTreeshakingPass();
  }
}

当一个标识符首次被分析到时,若其引用了一个来自标记为无副作用模块的变量,rollup 会将该模块及其不纯的依赖标记为已执行(isExecuted = true),从而在下一轮 tree-shaking 中分析这些新激活模块的副作用语句。

端到端示例 — 多轮 tree-shaking 中的延迟去优化:

js
// utils.js(sideEffects: false)
export function pure() { return 1; }
export function impure() { console.log('effect'); }

// main.js
import { pure } from './utils';
const result = pure();

tree-shaking 过程:

  1. 第 1 轮:分析 main.js 的顶层语句

    • import { pure } from './utils'hasEffects = false(ImportDeclaration 始终无副作用)
    • const result = pure() → 检查 pure() 是否有副作用
      • pure 标识符首次被分析 → 触发 applyDeoptimizations
      • utils.js 标记为 sideEffects: false,但 pure 被使用 → markModuleAndImpureDependenciesAsExecuted(utils.js)
      • requestTreeshakingPass() → 请求下一轮
    • result 未被使用 → pure() 函数体无副作用 → 整条语句不保留
  2. 第 2 轮utils.js 现在标记为 isExecuted = true

    • 检查 utils.js 中的顶层语句
    • pureimpure 均为函数声明 → 无副作用
    • 无新增的 include → 收敛
js
// Rollup 输出
// (空,整段代码被移除)
// result 未被使用,pure() 无副作用,impure() 未被引用

对比 — 若 result 被使用:

js
// main.js
import { pure } from './utils';
export const result = pure();

// Rollup 输出
function pure() { return 1; }
const result = pure();
export { result };
// result 被导出(使用),pure() 需要保留
// impure 仍然被移除(未引用)

设计取舍总结

决策选择权衡
未知节点有副作用(UnknownNode保证正确性,牺牲少量 tree-shake 机会
for...of始终有副作用正确但保守,未来可改进 Iterator 协议追踪
try-catch默认去优化安全处理运行时异常,可通过配置关闭
属性读取默认可能有副作用防止 getter/Proxy 带来的隐性副作用
#__PURE__ / #__NO_SIDE_EFFECTS__用户手动标注将判定权交给用户,弥补静态分析的局限性
分支消除编译时常量才优化保证正确性,与 DCE(Dead Code Elimination)互补
延迟去优化按需触发减少不必要的分析开销,但增加了多轮 tree-shaking 的可能性
交互级分析区分读取/调用/赋值提升精度(如函数声明无副作用但调用可能有),增加实现复杂度

rollup 的副作用判定本质上是一个保守的抽象解释器:宁可多保留代码(false positive),也不误删有副作用的逻辑(false negative)。用户可以通过 #__PURE__#__NO_SIDE_EFFECTS__sideEffects: falsetreeshake 的各项配置来手动放宽约束,在正确性和产物体积之间取得平衡。

树摇流程

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 关键字、句末添加分号等。

贡献者

页面历史

Discuss

根据 CC BY-SA 4.0 许可证发布。 (ca50eaa)