Skip to content

output.inlineDynamicImports

概述

inlineDynamicImports: true 将所有动态导入的模块内联到主 chunk 中,生成单个输出文件。本文将从源码层面深入分析这个特性的完整实现原理。

javascript
// rollup.config.js
export default {
  input: 'src/index.js',
  output: {
    file: 'dist/bundle.js',
    format: 'es',
    inlineDynamicImports: true
  }
};

版本与引用说明

本文基于 Rollup 当前源码进行分析,以下引用以文件路径与关键函数名为主。由于源码会演进,行号可能随版本变化,建议以代码片段或函数名进行定位。


核心问题:为什么 Promise.resolve().then(() => namespace) 能替代 import()

这是理解整个特性的关键。让我们从 ECMAScript 规范出发,逐步推导出这个转换的合理性。

ECMAScript 对 import() 的语义要求

根据 ECMAScript 规范,import('./foo.js') 必须满足三个核心语义:

语义要求说明
返回 Promiseimport() 表达式的求值结果必须是一个 Promise 对象
resolve 值为 Module Namespace ObjectPromise resolve 的值必须是目标模块的命名空间对象,包含所有导出
异步执行保证.then() 回调永远不会同步执行,即使模块已被缓存

任何正确的替代方案必须同时满足这三个条件。

详细分析:三个条件如何被满足

条件 1:返回 Promise

javascript
Promise.resolve().then(() => namespace)

这个表达式的求值过程:

  1. Promise.resolve() 创建一个**已完成(fulfilled)**的 Promise
  2. .then(callback) 返回一个新的 Promise
  3. 这个新 Promise 将在 callback 执行后 resolve 为 callback 的返回值

因此,调用者拿到的仍然是一个 Promise,以下代码都能正常工作:

javascript
// await 语法
const module = await import('./foo.js');

// .then() 语法
import('./foo.js').then(m => console.log(m));

// Promise.all 语法
const [a, b] = await Promise.all([import('./a.js'), import('./b.js')]);

条件 2:resolve 值是 Module Namespace Object

Rollup 为每个被动态导入的模块生成一个命名空间变量

javascript
var foo$1 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  foo: foo,
  bar: bar
});

这个对象在 Rollup 的语义下尽量贴近 ECMAScript Module Namespace Object 的行为,但会受到配置与场景影响:

规范要求Rollup 实现
[[Prototype]]null__proto__: null(总是添加)
对象不可扩展、属性不可配置Object.freeze()(仅当 output.freezetrue
Symbol.toStringTag仅当 output.generatedCode.symbols 启用时生成
包含模块的所有命名导出遍历 module.getExportedVariablesByName() 生成属性

注意:这仍然是“普通对象 + 约束”的实现方式,并非语言层面的 Module Namespace Exotic Object;它在可观察行为上做了最小充分模拟。

源码位置src/ast/variables/NamespaceVariable.tsrenderBlock

typescript
renderBlock(options: RenderOptions): string {
  const memberVariables = this.module.getExportedVariablesByName();
  const members: [key: string | null, value: string][] = [...memberVariables.entries()]
    .filter(([name, variable]) => !name.startsWith('*') && variable.included)
    .map(([name, variable]) => {
      // 如果变量被提前引用或可重新赋值,使用 getter 形式
      if (this.referencedEarly || variable.isReassigned || variable === this) {
        return [
          null,
          `get ${stringifyObjectKeyIfNeeded(name)}${_}()${_}{${_}return ${variable.getName(
            getPropertyAccess
          )}${s}${_}}`
        ];
      }
      // 否则直接引用变量
      return [name, variable.getName(getPropertyAccess)];
    });

  // 关键:添加 __proto__: null
  members.unshift([null, `__proto__:${_}null`]);

  let output = getObject(members, { lineBreakIndent: { base: '', t } });

  // 关键:根据配置可选冻结对象(output.freeze 默认 true)
  if (freeze) {
    output = `/*#__PURE__*/Object.freeze(${output})`;
  }

  return `${cnst} ${name}${_}=${_}${output};`;
}

补充

  • 如果存在命名空间合并(mergedNamespaces),会走 mergeNamespaces 帮助函数分支,freeze/Symbol.toStringTag 由该帮助函数处理。
  • Symbol.toStringTag 的注入也依赖 output.generatedCode.symbols 配置,默认开启但可关闭。

关于 getter 形式:当导出变量可能被重新赋值时,使用 getter 确保每次访问都能获取最新值:

javascript
// 如果 foo 可能被重新赋值
var foo$1 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  get foo() { return foo; }  // 使用 getter
});

条件 3:异步执行保证

这是最关键的一点。Promise.resolve().then(cb) 中的 cb 永远不会同步执行

根据 ECMAScript Promise 规范,.then() 的回调总是被调度到 微任务队列(microtask queue) 中执行,即使 Promise 已经 resolved:

javascript
console.log('A');
Promise.resolve().then(() => console.log('B'));
console.log('C');
// 输出顺序: A, C, B

原始 import() 也遵循相同的行为——即使模块已经被缓存,.then() 回调也是异步的。因此时序语义是一致的。

注意:该转换只保证 Promise 与微任务时序,并不等价于原生 import() 的完整加载/解析/执行错误语义。内联后模块在 bundle 初始化时同步执行,抛错时机可能不同(见后文“语义差异”)。

为什么不是其他方案?

方案问题
namespace不是 Promise,所有 .then()await 都会崩溃
Promise.resolve(namespace)可行,但 Rollup 复用 getDirectReturnFunction.then() 形态
new Promise(resolve => resolve(namespace))更冗长,效果完全相同

完整转换流程:从源码到输出

让我们跟踪一个具体例子的完整转换过程。

输入代码

javascript
// main.js
console.log('main: start');
const promise = import('./foo.js');
promise.then(m => console.log(m.foo));
console.log('main: end');

// foo.js
console.log('foo: side effect');
export const foo = 42;

转换流程可视化

inlineDynamicImports 转换流程

8 STEPS
选项校验
校验
检查与多入口、preserveModules、manualChunks 的互斥约束
normalizeOutputOptions.ts:184-216
执行顺序分析
分析
遍历入口模块分配 execIndex,动态导入模块获得更大的 execIndex
executionOrder.ts:22-84
Chunk 分配
转换
所有模块放入单个 chunk,完全绕过 getChunkAssignments() 分块算法
Bundle.ts:181-192
模块排序
转换
按 execIndex 升序排列,确保入口模块代码在前,动态模块代码在后
Bundle.ts:197
命名空间注册
转换
标记同 chunk 内动态导入的目标模块,生成命名空间对象
Chunk.ts:1549-1561
动态导入解析
转换
chunk === this 时调用 setInternalResolution,设置 inlineNamespace
Chunk.ts:1390-1410
代码生成
渲染
import() → Promise.resolve().then(() => namespace)
ImportExpression.ts:196-213
命名空间渲染
渲染
生成 Object.freeze({ __proto__: null, ...exports })
NamespaceVariable.ts:130-184
▸ 最终输出
const promise = Promise.resolve().then(function () { return namespace; });
校验
分析
转换
渲染

阶段 1:选项校验

源码位置src/utils/options/normalizeOutputOptions.tsgetInlineDynamicImports

typescript
const getInlineDynamicImports = (
  config: OutputOptions,
  inputOptions: NormalizedInputOptions
): NormalizedOutputOptions['inlineDynamicImports'] => {
  const inlineDynamicImports = config.inlineDynamicImports || false;
  const { input } = inputOptions;

  // 关键检查:不能与多入口一起使用
  if (inlineDynamicImports && (Array.isArray(input) ? input : Object.keys(input)).length > 1) {
    return error(
      logInvalidOption(
        'output.inlineDynamicImports',
        URL_OUTPUT_INLINEDYNAMICIMPORTS,
        'multiple inputs are not supported when "output.inlineDynamicImports" is true'
      )
    );
  }
  return inlineDynamicImports;
};

为什么有这个限制?

单文件输出无法区分多个入口的边界。如果有多个入口,每个入口都应该有自己的输出文件,这与"所有代码合并到一个文件"的语义矛盾。

互斥检查(同文件 getPreserveModules / getManualChunks)还包括:

  • 不能与 preserveModules 一起使用(一个要单文件,一个要保留模块结构)
  • 不能与 manualChunks 一起使用(手动分块与禁用分块互斥)

阶段 2:模块执行顺序分析

源码位置src/utils/executionOrder.tsanalyseModuleExecution

这是一个关键的预处理步骤。Rollup 需要确定所有模块的执行顺序,以便在输出文件中正确排列代码。

typescript
export function analyseModuleExecution(entryModules: readonly Module[]): {
  cyclePaths: string[][];
  orderedModules: Module[];
} {
  let nextExecIndex = 0;
  const dynamicImports = new Set<Module>();
  const analysedModules = new Set<Module | ExternalModule>();
  const parents = new Map<Module | ExternalModule, Module | null>();
  const orderedModules: Module[] = [];

  const analyseModule = (module: Module | ExternalModule) => {
    if (module instanceof Module) {
      // 1. 先递归处理静态依赖
      for (const dependency of module.dependencies) {
        handleSyncLoadedModule(dependency, module);
      }

      // 2. 隐式要求提前执行的模块
      for (const dependency of module.implicitlyLoadedBefore) {
        dynamicImports.add(dependency);
      }

      // 3. 处理动态导入:TLA 会被当作同步依赖
      for (const { node: { resolution, scope } } of module.dynamicImports) {
        if (resolution instanceof Module) {
          if (scope.context.usesTopLevelAwait) {
            handleSyncLoadedModule(resolution, module);
          } else {
            dynamicImports.add(resolution);
          }
        }
      }

      orderedModules.push(module);
    }

    // 4. 分配 execIndex
    module.execIndex = nextExecIndex++;
    analysedModules.add(module);
  };

  // 第一轮:处理入口模块及其静态依赖
  for (const currentEntry of entryModules) {
    if (!parents.has(currentEntry)) {
      parents.set(currentEntry, null);
      analyseModule(currentEntry);
    }
  }

  // 第二轮:处理动态导入的模块(非 TLA 路径)
  for (const currentEntry of dynamicImports) {
    if (!parents.has(currentEntry)) {
      parents.set(currentEntry, null);
      analyseModule(currentEntry);
    }
  }

  return { cyclePaths, orderedModules };
}

关键洞察通常动态导入模块会在第二轮处理,因此 execIndex大于入口模块及其静态依赖;但如果导入发生在包含 Top‑Level Await 的模块中,目标模块会被视作同步依赖提前处理,从而可能改变排序。

以我们的例子为例(无 TLA 的典型情况)

模块execIndex原因
main.js0入口模块,第一轮处理
foo.js1动态导入目标,第二轮处理

阶段 3:Chunk 分配(核心分支)

源码位置src/Bundle.tsgenerateChunks

这是 inlineDynamicImports 特性的核心实现,仅用一行代码完成:

typescript
const executableModule = inlineDynamicImports
  ? [{ alias: null, modules: includedModules }]  // ← 关键:所有模块放入单个数组元素
  : preserveModules
    ? includedModules.map(module => ({ alias: null, modules: [module] }))
    : getChunkAssignments(
        this.graph.entryModules,
        manualChunkAliasByEntry,
        experimentalMinChunkSize,
        this.inputOptions.onLog,
        typeof manualChunks === 'function',
        onlyExplicitManualChunks
      );

代码解析

  1. includedModules 是所有被包含(tree-shaking 后保留)的模块数组
  2. inlineDynamicImports: true 时,直接将所有模块包装成单个 { alias: null, modules: [...] } 对象
  3. 完全绕过了复杂的 getChunkAssignments() 分块算法
  4. 结果:只会创建一个 Chunk 实例,包含所有模块

对比其他模式

模式行为
inlineDynamicImports: true所有模块 → 1 个 chunk
preserveModules: true每个模块 → 1 个 chunk
默认模式调用 getChunkAssignments() 进行智能分块

阶段 4:模块排序

源码位置src/Bundle.tsgenerateChunkssortByExecutionOrder

typescript
for (const { alias, modules } of executableModule) {
  sortByExecutionOrder(modules);  // ← 关键:按 execIndex 排序
  const chunk = new Chunk(modules, ...);
}

sortByExecutionOrder 的实现非常简单(src/utils/executionOrder.ts):

typescript
const compareExecIndex = <T extends OrderedExecutionUnit>(unitA: T, unitB: T) =>
  unitA.execIndex > unitB.execIndex ? 1 : -1;

export function sortByExecutionOrder(units: OrderedExecutionUnit[]): void {
  units.sort(compareExecIndex);
}

为什么需要排序?

确保模块代码在输出文件中的顺序与原始执行顺序一致。在我们的例子中:

  • main.js(execIndex=0)的代码排在前面
  • foo.js(execIndex=1)的代码排在后面

这保证了:入口模块的同步代码会先执行,动态导入模块的代码在入口代码之后执行

阶段 5:命名空间对象注册

源码位置src/Chunk.tsaddModuleincludedNamespaces 相关逻辑)

当 Chunk 实例被创建后,需要确定哪些模块需要生成命名空间对象:

typescript
for (const {
  node: { included, resolution }
} of module.dynamicImports) {
  if (
    included &&                                    // 动态导入被包含(未被 tree-shake)
    resolution instanceof Module &&                // 目标是内部模块(非外部模块)
    this.chunkByModule.get(resolution) === this && // 目标模块在同一个 chunk 内
    !this.includedNamespaces.has(resolution)       // 尚未注册
  ) {
    this.includedNamespaces.add(resolution);       // 注册到集合中
    this.ensureReexportsAreAvailableForModule(resolution);
  }
}

关键条件this.chunkByModule.get(resolution) === this

这个条件检查动态导入的目标模块是否在同一个 chunk 内。当 inlineDynamicImports: true 时,内部模块通常都在同一个 chunk,因此大多数内部动态导入会命中该分支。但以下情况不会进入该逻辑:

  • 动态导入被 tree‑shaking 排除(included === false
  • 目标为外部模块(resolution 不是内部 Module

includedNamespaces 是一个 Set<Module>,记录了所有需要生成命名空间对象的模块。后续渲染阶段会遍历这个集合生成命名空间变量。

阶段 6:动态导入解析

源码位置src/Chunk.tssetDynamicImportResolutions

typescript
private setDynamicImportResolutions(fileName: string) {
  for (const resolvedDynamicImport of this.getIncludedDynamicImports()) {
    if (resolvedDynamicImport.chunk) {
      const { chunk, facadeChunk, node, resolution } = resolvedDynamicImport;

      if (chunk === this) {
        // 关键分支:目标模块在同一个 chunk 内
        node.setInternalResolution(resolution.namespace);
      } else {
        // 目标模块在不同 chunk 内
        node.setExternalResolution(
          (facadeChunk || chunk).exportMode,
          outputOptions,
          snippets,
          pluginDriver,
          accessedGlobalsByScope,
          `'${(facadeChunk || chunk).getImportPath(fileName)}'`,
          ...
        );
      }
    }
  }
}

核心逻辑

  1. 遍历所有动态导入表达式
  2. 检查目标模块所属的 chunk 是否是当前 chunk(chunk === this
  3. 如果是同一个 chunk → 调用 setInternalResolution(内联)
  4. 如果是不同 chunk → 调用 setExternalResolution(保持动态导入)

inlineDynamicImports: true 时,内部模块都在同一个 chunk,因此内部动态导入会走 setInternalResolution 分支;外部动态导入仍然走 setExternalResolution,并由输出格式/插件钩子决定具体渲染方式。

阶段 7:设置 inlineNamespace

源码位置src/ast/nodes/ImportExpression.tssetInternalResolution

typescript
setInternalResolution(inlineNamespace: NamespaceVariable): void {
  this.inlineNamespace = inlineNamespace;
}

这个方法非常简单,只是将命名空间变量的引用保存到 ImportExpression 节点上。后续渲染阶段会检查这个属性来决定如何生成代码。

阶段 8:代码生成

源码位置src/ast/nodes/ImportExpression.tsrender

typescript
render(code: MagicString, options: RenderOptions): void {
  const {
    snippets: { _, getDirectReturnFunction, getObject, getPropertyAccess },
  } = options;

  if (this.inlineNamespace) {
    // 关键:生成 Promise.resolve().then(() => namespace) 代码
    const [left, right] = getDirectReturnFunction([], {
      functionReturn: true,
      lineBreakIndent: null,
      name: null
    });
    code.overwrite(
      this.start,
      this.end,
      `Promise.resolve().then(${left}${this.inlineNamespace.getName(getPropertyAccess)}${right})`
    );
    return;
  }

  // 正常渲染路径(非内联)...
}

代码解析

  1. this.inlineNamespace 存在时,进入内联渲染分支
  2. getDirectReturnFunction([], {...}) 根据配置生成函数包装器:
    • 如果启用箭头函数:生成 () => 和 ``
    • 否则:生成 function () { return ; }
  3. this.inlineNamespace.getName(getPropertyAccess) 获取命名空间变量的名称(如 foo$1
  4. code.overwrite(start, end, ...) 用新代码替换原始的 import('./foo.js') 表达式

最终生成的代码

javascript
Promise.resolve().then(function () { return foo$1; })
// 或(如果启用箭头函数)
Promise.resolve().then(() => foo$1)

阶段 9:命名空间对象渲染

源码位置src/ast/variables/NamespaceVariable.tsrenderBlock

在 Chunk 渲染过程中,会为 includedNamespaces 中的每个模块调用 renderBlock 方法:

typescript
renderBlock(options: RenderOptions): string {
  const memberVariables = this.module.getExportedVariablesByName();

  // 1. 收集所有导出
  const members: [key: string | null, value: string][] = [...memberVariables.entries()]
    .filter(([name, variable]) => !name.startsWith('*') && variable.included)
    .map(([name, variable]) => {
      // 如果变量被提前引用或可重新赋值,使用 getter
      if (this.referencedEarly || variable.isReassigned || variable === this) {
        return [null, `get ${name}() { return ${variable.getName(...)}; }`];
      }
      return [name, variable.getName(...)];
    });

  // 2. 添加 __proto__: null
  members.unshift([null, `__proto__: null`]);

  // 3. 生成对象字面量
  let output = getObject(members, ...);

  // 4. 添加 Object.freeze(如果启用)
  if (freeze) {
    output = `/*#__PURE__*/Object.freeze(${output})`;
  }

  // 5. 生成完整的变量声明
  return `${cnst} ${name} = ${output};`;
}

最终输出

经过上述所有阶段后,我们的例子会生成以下代码:

javascript
// main.js 的代码(execIndex=0,排在前面)
console.log('main: start');
const promise = Promise.resolve().then(function () { return foo$1; });
promise.then(m => console.log(m.foo));
console.log('main: end');

// foo.js 的代码(execIndex=1,排在后面)
console.log('foo: side effect');
const foo = 42;

// foo.js 的命名空间对象
var foo$1 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  foo: foo
});

备注:实际输出还可能包含 Symbol.toStringTag、命名空间合并 helper、以及是否 Object.freeze 的差异,取决于 output.generatedCode.symbols / output.freeze 等配置与导出结构。


执行顺序深度分析

为什么执行顺序与原生 import() 一致?

这是很多人困惑的地方。让我们详细分析执行过程:

原始代码执行顺序(原生 import()):

1. console.log('main: start')           // main.js 同步代码
2. const promise = import('./foo.js')   // 触发异步加载,返回 Promise
3. promise.then(...)                    // 注册回调到微任务队列
4. console.log('main: end')             // main.js 同步代码继续
5. -- 同步代码执行完毕,处理微任务队列 --
6. console.log('foo: side effect')      // foo.js 模块执行
7. const foo = 42
8. m => console.log(m.foo)              // then 回调执行

内联后代码执行顺序

javascript
// 生成的 bundle
console.log('main: start');
const promise = Promise.resolve().then(function () { return foo$1; });
promise.then(m => console.log(m.foo));
console.log('main: end');

console.log('foo: side effect');
const foo = 42;
var foo$1 = /*#__PURE__*/Object.freeze({ __proto__: null, foo: foo });

执行过程:

1. console.log('main: start')           // 同步执行
2. Promise.resolve()                    // 创建已 resolved 的 Promise
3. .then(function () { return foo$1; }) // 注册回调到微任务队列(注意:此时还没执行!)
4. promise.then(...)                    // 再注册一个回调
5. console.log('main: end')             // 同步执行
6. console.log('foo: side effect')      // 同步执行(foo.js 代码在 main.js 之后)
7. const foo = 42                       // 同步执行
8. var foo$1 = ...                      // 同步执行
9. -- 同步代码执行完毕,处理微任务队列 --
10. function () { return foo$1; }       // 第一个 then 回调执行
11. m => console.log(m.foo)             // 第二个 then 回调执行

在“无异常、无 TLA 介入”的典型场景下,输出一致

main: start
main: end
foo: side effect
42

关键洞察

  1. Promise.resolve().then(cb)cb 不会立即执行,而是被调度到微任务队列
  2. foo.js 的代码虽然在 bundle 中排在后面,但在同步执行阶段就会执行
  3. 当 then 回调在微任务阶段执行时,foo$1 已经被正确初始化
  4. sortByExecutionOrder 确保了 foo.js 代码在 main.js 代码之后,这正是我们需要的
  5. 如果模块初始化抛错或使用 Top‑Level Await,实际时序/异常语义仍可能与原生 import() 存在差异(见下文“语义差异”)

源码中的关键注释:作者的自我反思

源码位置src/utils/executionOrder.tsanalyseModuleExecution 上方注释)

typescript
// This process is currently faulty in so far as it only takes the first entry
// module into account and assumes that dynamic imports are imported in a
// certain order.
// A better algorithm would follow every possible execution path and mark which
// modules are executed before or after which other modules. THen the chunking
// would need to take care that in each chunk, all modules are always executed
// in the same sequence.

翻译

当前这个过程是有缺陷的,因为它只考虑第一个入口模块,并假设动态导入按特定顺序执行。 更好的算法应该追踪每一条可能的执行路径,标记哪些模块在哪些其他模块之前或之后执行。 然后分块逻辑需要确保在每个 chunk 中,所有模块始终按相同的顺序执行。

作者承认的问题

  1. 只考虑第一个入口模块
  2. 假设动态导入按特定顺序执行
  3. 没有追踪所有可能的执行路径

为什么这是可接受的权衡?

  1. 复杂度考量:追踪所有可能的执行路径是一个图遍历问题,复杂度很高
  2. 用户意图明确:使用 inlineDynamicImports 的用户已经明确表示想要单文件输出,放弃了代码分割
  3. 核心语义保留:Promise 的异步性被保留,这是用户代码依赖的关键部分
  4. 实际场景够用:大多数使用场景下,这个简化的算法是足够的

语义差异:哪些场景会有不同行为?

虽然上述分析表明"正常"的动态导入执行顺序是一致的,但仍有多类场景会产生行为差异:

场景 1:条件动态导入

javascript
// main.js
if (shouldLoadFeature) {
  import('./feature.js');
}

// feature.js
console.log('feature: initializing');
initializeHeavyFeature();
行为原生 import()inlineDynamicImports
shouldLoadFeature = falsefeature.js 不会执行feature.js 执行

原因:内联后,feature.js 的代码总是出现在 bundle 中,无论条件是否为真都会执行。

场景 2:延迟动态导入

javascript
// main.js
setTimeout(() => {
  import('./analytics.js');
}, 5000);

// analytics.js
console.log('analytics: loaded');
trackPageView();
行为原生 import()inlineDynamicImports
加载时机5 秒后执行立即执行

原因:内联后,analytics.js 的代码在 bundle 加载时就同步执行了。

场景 3:用户交互触发

javascript
// main.js
button.addEventListener('click', () => {
  import('./editor.js').then(m => m.openEditor());
});

// editor.js
console.log('editor: loading heavy resources');
loadHeavyResources();  // 加载大量资源
export function openEditor() { ... }
行为原生 import()inlineDynamicImports
初始加载不加载 editor.js 资源立即加载所有资源
点击按钮此时才加载 editor.js资源已加载,直接使用

原因:内联后,所有代码在初始加载时就执行,丧失了"按需加载"的能力。

场景 4:模块初始化抛错(错误时序差异)

javascript
// main.js
import('./broken.js').catch(err => console.error('import failed', err));
console.log('after import call');

// broken.js
throw new Error('boom');
行为原生 import()inlineDynamicImports
报错时机Promise 异步拒绝(.catch 可捕获)可能在 bundle 同步执行阶段抛错,提前中断执行
影响范围仅当前动态导入失败,不影响同步代码继续执行可能导致整个初始化流程被同步异常中断

原因:内联后模块代码在初始加载时同步执行,抛错时机可能从“异步拒绝”变为“同步异常”。

场景 5:Top‑Level Await(排序与时序差异)

当动态导入发生在包含 Top‑Level Await 的模块中,Rollup 的执行顺序分析会将目标模块视作同步依赖处理,从而影响模块在 bundle 中的排序(analyseModuleExecution 中对 usesTopLevelAwait 的处理)。这意味着排序与执行时序可能与“动态导入总在第二轮”这一简化假设不同。

总结:什么时候不应该使用 inlineDynamicImports

不适用的场景:

如果你的代码依赖条件判断来避免加载某些模块,inlineDynamicImports 会导致这些模块无论如何都被加载执行,因此不适用。同样,当需要延迟加载以优化首屏性能,或需要按需加载大型功能模块时,内联会破坏代码分割带来的性能优势。如果模块有昂贵的初始化副作用(如网络请求、DOM 操作),内联后这些副作用会在 bundle 加载时立即执行,而非按需触发。此外,若你的错误处理逻辑依赖 import() 的异步拒绝机制来隔离错误,内联后错误可能变为同步抛出,影响整体执行流程。

适用的场景:

Worker 或 Service Worker 的单文件部署是典型的适用场景,因为 Worker 环境处理外部依赖较为复杂,单文件更稳定。库打包为单文件发布时也适合使用,可以简化分发流程。对于不关心代码分割的简单应用,使用 inlineDynamicImports 可以减少输出文件数量,简化部署。需要特别注意的是,使用该选项的前提是动态导入的目标模块不能依赖顶层副作用的执行时机——因为内联后所有模块代码会在 bundle 加载时同步执行,原本"按需触发"的副作用会变为"立即执行",这可能导致意外的初始化顺序或资源竞争问题。


适用场景详解

1. Web Worker / Service Worker

Worker 环境处理外部依赖比较复杂,单文件更稳定:

javascript
// rollup.config.js
export default {
  input: 'src/worker.js',
  output: {
    file: 'dist/worker.js',
    format: 'es',
    inlineDynamicImports: true
  }
};

为什么 Worker 适合单文件?

问题多文件 (chunked)单文件 (inlined)
部署复杂度需部署多个 chunk,维护路径关系只需部署 1 个文件
路径解析import() 路径相对于 Worker 文件位置无运行时路径解析
Service Worker 缓存需手动维护所有 chunk 的缓存列表只需缓存 1 个 URL
CORS 配置所有 chunk 都需要正确的 CORS 头只需配置 1 个文件
CDN 部署chunk 文件名含 hash,每次构建可能变化单一文件,易于管理

Worker 资源加载的浏览器要求

  • 同源策略:Worker 脚本必须与创建它的页面同源
  • MIME 类型:必须是 text/javascriptapplication/javascript
  • Module Worker 中的 import():加载的资源也需要满足同源或 CORS 要求

2. 库打包

发布为单文件 UMD/IIFE 库:

javascript
export default {
  input: 'src/index.js',
  output: {
    file: 'dist/my-library.js',
    format: 'umd',
    name: 'MyLibrary',
    inlineDynamicImports: true
  }
};

3. 简单应用或脚本

不需要代码分割的小型应用。


使用限制

根据源码 src/utils/options/normalizeOutputOptions.tsgetInlineDynamicImports / getPreserveModules / getManualChunks):

限制原因源码位置
不能与多入口一起使用单文件输出无法区分多个入口getInlineDynamicImports
不能与 preserveModules 一起使用语义冲突:一个要单文件,一个要保留模块结构getPreserveModules
不能与 manualChunks 一起使用语义冲突:手动分块与禁用分块互斥getManualChunks

如果要实现这个特性,你需要做什么?

假设你正在实现一个类似的打包器,以下是实现 inlineDynamicImports 特性的关键步骤:

步骤 1:选项校验

typescript
function validateOptions(options: Options) {
  if (options.inlineDynamicImports) {
    if (options.input.length > 1) {
      throw new Error('inlineDynamicImports 不支持多入口');
    }
    if (options.preserveModules) {
      throw new Error('inlineDynamicImports 与 preserveModules 互斥');
    }
    if (options.manualChunks) {
      throw new Error('inlineDynamicImports 与 manualChunks 互斥');
    }
  }
}

步骤 2:执行顺序分析

typescript
// 简化伪代码(保留 TLA 与隐式依赖的关键分支)
function analyzeExecutionOrder(entryModules: Module[]) {
  let execIndex = 0;
  const dynamicImports = new Set<Module>();
  const visited = new Set<Module | ExternalModule>();

  function analyze(module: Module | ExternalModule) {
    if (visited.has(module)) return;
    visited.add(module);

    if (module instanceof Module) {
      for (const dep of module.dependencies) analyze(dep);
      for (const dep of module.implicitlyLoadedBefore) dynamicImports.add(dep);
      for (const { node: { resolution, scope } } of module.dynamicImports) {
        if (resolution instanceof Module) {
          if (scope.context.usesTopLevelAwait) {
            analyze(resolution); // TLA 视为同步依赖
          } else {
            dynamicImports.add(resolution);
          }
        }
      }
    }

    module.execIndex = execIndex++;
  }

  for (const entry of entryModules) analyze(entry);
  for (const dynamicModule of dynamicImports) analyze(dynamicModule);
}

步骤 3:Chunk 分配

typescript
function assignChunks(modules: Module[], options: Options) {
  if (options.inlineDynamicImports) {
    // 所有模块放入单个 chunk
    return [{ modules }];
  } else {
    // 正常分块逻辑...
  }
}

步骤 4:模块排序

typescript
function sortModules(modules: Module[]) {
  modules.sort((a, b) => a.execIndex - b.execIndex);
}

步骤 5:标记需要生成命名空间的模块

typescript
function markNamespaceModules(chunk: Chunk) {
  for (const module of chunk.modules) {
    for (const dynamicImport of module.dynamicImports) {
      const { included, resolution } = dynamicImport;
      if (included && resolution instanceof Module && chunk.modules.includes(resolution)) {
        chunk.includedNamespaces.add(resolution);
      }
    }
  }
}

步骤 6:代码生成

typescript
function renderImportExpression(node: ImportExpression, namespace: NamespaceVariable | null) {
  if (namespace) {
    // 内联模式
    return `Promise.resolve().then(function () { return ${namespace.name}; })`;
  } else {
    // 正常动态导入
    return `import(${node.source})`;
  }
}

function renderNamespace(module: Module) {
  const exports = module.getExports();
  const members = exports.map(e => `${e.name}: ${e.localName}`).join(', ');
  // 根据 output.freeze / generatedCode.symbols 等配置,渲染细节会有所不同
  return `var ${module.namespace.name} = /*#__PURE__*/Object.freeze({ __proto__: null, ${members} });`;
}

源码关键文件索引

文件关键位置/函数作用
src/utils/options/normalizeOutputOptions.tsgetInlineDynamicImports / getPreserveModules选项校验与互斥限制
src/Bundle.tsgenerateChunks核心:单 chunk 分配与排序
src/Chunk.tssetDynamicImportResolutions / addModule动态导入内联判定、命名空间注册
src/ast/nodes/ImportExpression.tsrender / setInternalResolution核心Promise.resolve().then() 生成与内联标记
src/ast/variables/NamespaceVariable.tsrenderBlock核心:命名空间对象渲染
src/utils/executionOrder.tsanalyseModuleExecution / sortByExecutionOrder执行顺序分析与排序

总结

方面详细说明
核心转换import('./x')Promise.resolve().then(function () { return namespace; })
为什么这个转换是正确的在大多数场景满足 import() 的三项核心语义,但命名空间细节受 freeze/symbols 等配置影响
执行顺序保证通过 sortByExecutionOrderexecIndex 排序;TLA/隐式依赖可能改变“第二轮处理”的直觉
语义保留Promise 返回值、.then() 异步性(微任务队列)保留
语义差异条件/延迟/交互触发、错误时序、以及 TLA 相关排序可能与原生 import() 不同
作者态度源码注释承认算法"faulty",但认为是合理的实用性权衡
适用场景Worker 单文件部署、库打包、不需要代码分割的简单应用
不适用场景依赖条件加载、需要延迟加载、需要按需加载大型模块

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