Skip to content

每日一报

TLA 的详细讲解与实现(ReadMe 的扩展与思考)

我们以如下代码为用例来探索一下各个构建工具在处理 TLA 时的表现:

js
// TLA.js
import { a } from './a';
import { b } from './b';
import { sleep } from './utils';

await sleep(1000);
console.log(a, b);
console.timeEnd('TLA');
js
// a.js
import { sleep } from './utils';

console.time('TLA');

await sleep(1000);

export const a = 124;
js
// b.js
import { sleep } from './utils';

await sleep(1000);

export const b = 124;
js
// utils.js
export const sleep = time =>
  new Promise(resolve => {
    setTimeout(resolve, time);
  });

对于上述例子来说,若通过 ESM Bundler(例如:RollupEsbuild)产物见如下:

Rollup 产物

js
// rollup-esm-output.js
const sleep = time =>
  new Promise(resolve => {
    setTimeout(resolve, time);
  });

console.time('TLA');

await sleep(1000);

const a = 124;

await sleep(1000);

const b = 124;

await sleep(1000);
console.log(a, b);
console.timeEnd('TLA');

Esbuild 产物

js
// utils.js
var sleep = time =>
  new Promise(resolve => {
    setTimeout(resolve, time);
  });

// a.js
console.time('TLA');
await sleep(1e3);
var a = 124;

// b.js
await sleep(1e3);
var b = 124;

// TLA.js
await sleep(1e3);
console.log(a, b);
console.timeEnd('TLA');

Bun:

bun 会原封不动的将 TLA 编译到产物中去,同样也没有考虑兼容性,只考虑了现代浏览器的运行:

js
// utils.js
var sleep = time =>
  new Promise(resolve => {
    setTimeout(resolve, time);
  });

// a.js
console.time('TLA');
await sleep(1000);
var a = 124;

// b.js
await sleep(1000);
var b = 124;

// index.mjs
await sleep(1000);
console.log(a, b);
console.timeEnd('TLA');

可以看出对于一般 ESM Bundler(例如:RollupEsbuildBun)来说,最终产物仅仅只是做到按照依赖顺序进行平铺处理,没有专门针对 TLAES2022 新特性的运行时处理,最后输出的产物并没有做到并发加载 async module。仅仅只是串行加载 async module,这改变了代码原始的语义。

根据提案我们可以将上述包含 TLA 模块进行如下方式转译:

js
// TLA.js
import { _TLAPromise as _TLAPromise_1, a } from './a';
import { _TLAPromise as _TLAPromise_2, b } from './b';
import { sleep } from './utils';
Promise.all([_TLAPromise_1(), _TLAPromise_2()])
  .then(async () => {
    await sleep(1000);
    console.log(a, b);
    console.timeEnd('TLA');
  })
  .catch(e => {
    console.log(e);
  });
js
// a.js
import { sleep } from './utils';
console.time('TLA');
export const _TLAPromise = async () => {
  await sleep(1000);
};

export const a = 124;
js
// b.js
import { sleep } from './utils';

export const _TLAPromise = async () => {
  await sleep(1000);
};

export const b = 124;

转译后通过 ESM Bundler(例如:RollupEsbuildBun)进行打包后的产物。

Rollup 转译后的产物:

js
const sleep = time =>
  new Promise(resolve => {
    setTimeout(resolve, time);
  });

console.time('TLA');
const _TLAPromise$1 = async () => {
  await sleep(1000);
};

const a = 124;

const _TLAPromise = async () => {
  await sleep(1000);
};

const b = 124;

Promise.all([_TLAPromise$1(), _TLAPromise()])
  .then(async () => {
    await sleep(1000);
    console.log(a, b);
    console.timeEnd('TLA');
  })
  .catch(e => {
    console.log(e);
  });

Esbuild 转译后的产物:

js
// utils.js
var sleep = time =>
  new Promise(resolve => {
    setTimeout(resolve, time);
  });

// a.js
console.time('TLA');
var _TLAPromise = async () => {
  await sleep(1e3);
};
var a = 124;

// b.js
var _TLAPromise2 = async () => {
  await sleep(1e3);
};
var b = 124;

// TLA.js
Promise.all([_TLAPromise(), _TLAPromise2()])
  .then(async () => {
    await sleep(1e3);
    console.log(a, b);
    console.timeEnd('TLA');
  })
  .catch(e => {
    console.log(e);
  });

Bun 转译后的产物:

js
// utils.js
var sleep = time =>
  new Promise(resolve => {
    setTimeout(resolve, time);
  });

// a.js
var promise = async () => {
  await sleep(1000);
};
var a = 124;
var _TLAPromise = promise;

// b.js
var promise2 = async () => {
  await sleep(1000);
};
var b = 124;
var _TLAPromise2 = promise2;

// TLA.js
console.time('TLA');
Promise.all([_TLAPromise(), _TLAPromise2()])
  .then(() => {
    console.log(a, b);
    console.timeEnd('TLA');
  })
  .catch(e => {
    console.log(e);
  });

此时 ESM Bundle 处理 TLA 模块遵循 TLA 规范。这就是 vite-plugin-top-level-await 插件所要做的事情,暂时缓解了 ESM Bundle 无法正确处理 TLA 规范的问题。

哪些工具实现了 TLA 规范

  1. webpack:最早实现 TLA 规范的构建工具是 webpack,仅需确保 experiments.topLevelAwait 配置项为 true(从 webpack 版本 5.83.0 开始,默认启用此功能),且 TLAESM 模块,那么就可以正常编译 TLA
  2. Node:在 ESM 项目中实现了 TLA。但本质上 Node 的执行与一般 ESM Bundler 不一样,并没有做打包处理,执行与浏览器有点相类似。
  3. 浏览器
ToolchainEnvironmentTimingSummary
tscNode.jsnode esm/a.js 0.03s user 0.01s system 4% cpu 1.047 totalb、c 的执行是并行
tscChrometracing-chrome-tscb、c 的执行是并行
es bundleNode.jsnode out.js 0.03s user 0.01s system 2% cpu 1.546 totalb、c 的执行是串行
es bundleChrometracing-chrome-esbundleb、c 的执行是串行
Webpack (iife)Node.jsnode dist/main.js 0.03s user 0.01s system 3% cpu 1.034 totalb、c 的执行是并行
Webpack (iife)Chrometracing-chrome-webpackb、c 的执行是并行

总结

虽然 Rollup / esbuild / bun 等工具可以将包含 TLA 的模块成功编译成 es bundle,但是其语义是不符合 TLA 规范的语义的,现有简单的打包策略,会导致原本可以并行执行的模块变成了同步执行。只有 Webpack 通过编译到 iife,再加上复杂的 Webpack TLA Runtime,来模拟了 TLA 的语义,也就是说,在打包这件事上,Webpack 看起来是唯一一个能够正确模拟 TLA 语义的 Bundler。

webpack 实现 TLA 规范的原理

我们通过 webpack 来构建 TLA 模块,配置如下:

js
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

export default {
  entry: './src/TLA.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist')
  },
  mode: 'production',
  experiments: {
    // 从 `webpack` 版本 5.83.0 开始,默认启用此功能
    topLevelAwait: true
  },
  optimization: {
    minimize: false
  }
};

可以看到输出的产物信息 webpack-tla-output.js(包含代码注释)。

webpack 实现 TLA 原理总结

TLA 模块具有传染性,TLA 模块的所有依赖方模块及依赖方的所有祖先模块也均传染为 TLA 模块。TLA 模块执行时,与往常一样(require or import)一样会 DFS 所有的 子依赖模块。不同的是对于 TLA 模块会通过 __webpack_require__.a 来进行特殊初始化,确保当所有的子 TLA 模块均 resolve 完成后才会执行当前模块的 resolve 操作,当前模块 resolve 完成后就可以继续执行当前模块后续的内容。

细心观察 webpack 实现 TLA 的产物,是模拟类似以下的 流程

js
import { a } from './a.mjs';
import { b } from './b.mjs';
import { c } from './c.mjs';

console.log(a, b, c);
js
import { a, promise as aPromise } from './a.mjs';
import { b, promise as bPromise } from './b.mjs';
import { c, promise as cPromise } from './c.mjs';

export const promise = Promise.all([aPromise, bPromise, cPromise]).then(
  () => {
    console.log(a, b, c);
  }
);

TLA 模块向上暴露 promise,目的是为了让依赖方模块了解子 TLA 模块是否完成顶层 await 操作,通过 Promise.all 确保执行当前模块的时候,所有子 TLA 模块均 resolve 完成。

各个构建工具的 Chunk 生成算法

Rollup

Chunk 合并算法

概论介绍

  • 依赖入口点: 入口模块集合 沿着依赖路径加载到 当前模块,则 入口模块集合当前模块依赖入口点
  • 副作用
    • 指那些可能影响全局状态的操作,如全局函数调用、全局变量修改或可能抛出的错误。
    • 算法将代码块分为纯粹的(无副作用)和非纯粹的(有副作用)。
  • 相关副作用:
    • 加载一个代码块时必然已经触发的所有副作用。
    • 是所有依赖该代码块的入口点所加载的代码块的交集。
  • 依赖副作用:
    • 直接加载一个代码块时触发的副作用。
    • 包括该代码块自身的副作用及其直接依赖的副作用。

举个例子:

在上述图中:

  • X 和 Y 是入口点
  • 红色的块 (A, B, D, F, G, H) 表示有副作用的代码块
  • 绿色的块 (C, E) 表示纯代码块(无副作用)

现在,让我们分析一下代码块 A 的相关副作用:

  1. A 的依赖入口点:

    • A 可以通过入口点 X 或 Y 被加载。所以 A 的依赖入口点是 ({X, Y})。
  2. 确定相关副作用:

    • 当通过 X 加载 A 时,会加载: A, B, C, F, G
    • 当通过 Y 加载 A 时,会加载: A, D, E, F, H
  3. 相关副作用的计算:

    • A 的相关副作用是在任何可能的加载路径中(X 或 Y)都会出现的有副作用的代码块的集合。
    • 这是上述两种加载路径的交集: ({A, F})。

因此,A 的相关副作用是 ({A, F})。这意味着无论何时 A 被加载,F 也一定会被加载,并且它们的副作用一定会被触发。

  1. 算法目标: 尝试通过合并小代码块到其他代码块中来消除小代码块。

  2. 合并的安全性考虑: 合并必须确保不会触发不应该被触发的副作用(全局函数调用、全局变量修改或可能抛出的错误等)。

  3. 合并规则:

    1. 如果代码块 A依赖副作用 是另一个代码块 B相关副作用子集, 则可以合并。
    2. 如果代码块 A依赖入口点 是代码块 B子集, 且 A依赖副作用B相关副作用 的子集, 则可以合并。
  4. 合并算法的两个阶段

    1. 第一轮:
      • 尝试将小代码块 A 合并到其他代码块 B 中,条件是 A依赖入口点B 的子集,且 A依赖副作用B相关副作用 的子集。
    2. 第二轮:
      • 对于剩余的小代码块,寻找符合规则(3-1)的任意合并机会,从最小的代码块开始。
  5. 额外考虑

    合并时还需考虑避免加载过多额外代码。理想情况是小代码块的 依赖入口点 是另一个代码块 依赖入口点 的子集,这样可以确保在加载小代码块时,另一个代码块已经在内存中了。

让我们分析以下例子:

  1. 代码块定义:

    • A, B, D, F: 有副作用
    • C, E: 纯代码块(无副作用)
  2. 依赖关系:

    • X -> A, B, C
    • Y -> A, D, E
    • A -> F

现在,让我们逐个分析每个代码块的相关概念:

  1. A:

    • 依赖入口点:{X, Y}
    • 相关副作用:{A, F}
    • 依赖副作用:{A, F}
  2. B:

    • 依赖入口点:{X}
    • 相关副作用:{B}
    • 依赖副作用:{B}
  3. C:

    • 依赖入口点:{X}
    • 相关副作用:{}(纯代码块)
    • 依赖副作用:{}
  4. D:

    • 依赖入口点:{Y}
    • 相关副作用:{D}
    • 依赖副作用:{D}
  5. E:

    • 依赖入口点:{Y}
    • 相关副作用:{}(纯代码块)
    • 依赖副作用:{}
  6. F:

    • 依赖入口点:{X, Y}(因为A依赖F,而X和Y都依赖A)
    • 相关副作用:{A, F}
    • 依赖副作用:{F}

现在,让我们考虑可能的合并:

  1. 合并F到A:

    • 可以安全合并,因为F的依赖入口点({X, Y})与A的依赖入口点({X, Y})相同
    • F的依赖副作用({F})是A的相关副作用({A, F})的子集
  2. 合并B到A:

    • 不能安全合并,因为虽然B的依赖入口点({X})是A的依赖入口点({X, Y})的子集
    • 但B的依赖副作用({B})不是A的相关副作用({A, F})的子集
  3. 合并C到B:

    • 可以安全合并,因为C的依赖入口点({X})与B的依赖入口点({X})相同
    • C是纯代码块,没有副作用,所以它的依赖副作用({})必定是B的相关副作用({B})的子集
  4. 合并E到D:

    • 可以安全合并,原因与合并C到B类似
  5. 合并D到A:

    • 不能安全合并,因为虽然D的依赖入口点({Y})是A的依赖入口点({X, Y})的子集
    • 但D的依赖副作用({D})不是A的相关副作用({A, F})的子集

合并结果:

  1. 我们可以安全地将F合并到A中,因为F总是随A加载,且F的副作用已经包含在A的相关副作用中。
  2. 我们可以安全地将C合并到B中,以及将E合并到D中,因为它们是纯代码块,不会引入新的副作用。
  3. 我们不能将B或D合并到A中,因为这可能会在只需要A的场景下错误地触发B或D的副作用。

总结

通常情况下,算法试图在保证应用正确性(特别是关于副作用)的同时,通过智能地合并小的代码块来优化整体加载性能。

核心讲解

生成 chunk 的核心逻辑:

ts
async function generateChunks(
  bundle: OutputBundleWithPlaceholders,
  getHashPlaceholder: HashPlaceholderGenerator
): Promise<Chunk[]> {
  const {
    experimentalMinChunkSize,
    inlineDynamicImports,
    manualChunks,
    preserveModules
  } = this.outputOptions;
  const manualChunkAliasByEntry =
    typeof manualChunks === 'object'
      ? await this.addManualChunks(manualChunks)
      : this.assignManualChunks(manualChunks);
  const snippets = getGenerateCodeSnippets(this.outputOptions);
  const includedModules = getIncludedModules(this.graph.modulesById);
  const inputBase = commondir(
    getAbsoluteEntryModulePaths(includedModules, preserveModules)
  );
  const externalChunkByModule = getExternalChunkByModule(
    this.graph.modulesById,
    this.outputOptions,
    inputBase
  );
  const chunks: Chunk[] = [];
  const chunkByModule = new Map<Module, Chunk>();
  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);
  }
  for (const chunk of chunks) {
    chunk.link();
  }
  const facades: Chunk[] = [];
  for (const chunk of chunks) {
    facades.push(...chunk.generateFacades());
  }
  return [...chunks, ...facades];
}

举一个例子,假设依赖图关系如下:

rollup 配置:

js
// rollup.config.js
export default {
  input: {
    X: './X.js',
    Y: './Y.js'
  },
  output: [
    {
      dir: 'dist/esm',
      format: 'es',
      entryFileNames: '[name].js',
      manualChunks: {
        common1: ['D'],
        common2: ['C', 'F']
      }
    }
  ],
  treeshake: false,
  plugins: []
};

可以看到生成chunk总共分为以下几个部分

  1. 处理手动分块逻辑,对于对象和函数的两种不同的传参方式,进行不同的处理
ts
const manualChunkAliasByEntry =
  typeof manualChunks === 'object'
    ? await this.addManualChunks(manualChunks)
    : this.assignManualChunks(manualChunks);

addManualChunks 为例子,可以看到实现逻辑如下:

ts
async function addManualChunks(
  manualChunks: Record<string, readonly string[]>
): Promise<Map<Module, string>> {
  const manualChunkAliasByEntry = new Map<Module, string>();
  const chunkEntries = await Promise.all(
    Object.entries(manualChunks).map(async ([alias, files]) => ({
      alias,
      entries: await this.graph.moduleLoader.addAdditionalModules(
        files,
        true
      )
    }))
  );
  for (const { alias, entries } of chunkEntries) {
    for (const entry of entries) {
      addModuleToManualChunk(alias, entry, manualChunkAliasByEntry);
    }
  }
  return manualChunkAliasByEntry;
}

Contributors

Changelog

Discuss

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