普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月17日首页

【翻译】用生成器实现可续充队列

2026年2月17日 17:36

原文链接:macarthur.me/posts/queue…

生成器执行完毕后便无法 “复活”,但借助 Promise,我们能打造出一个可续充的版本。接下来就动手试试吧。

作者:Alex MacArthur

自从深入研究并分享过生成器的相关内容后,JavaScript 生成器就成了我的 “万能工具”—— 只要有机会,我总会想方设法用上它。通常我会用它来分批处理有限的数据集,比如,遍历一系列闰年并执行相关操作:

function* generateYears(start = 1900) {
  const currentYear = new Date().getFullYear();
  
  for (let year = start + 1; year <= currentYear; year++) {
    if (isLeapYear(year)) {
      yield year;
    }
  }
}

for (const year of generateYears()) {
  console.log('下一个闰年是:', year);
}

又或者惰性处理一批文件:

const csvFiles = ["file1.csv", "file2.csv", "file3.csv"];

function *processFiles(files) {
  for (const file of files) {
    // 加载并处理文件
    yield `处理结果:${file}`;
  }
}

for(const result of processFiles(csvFiles)) {
  console.log(result);
}

这两个示例中,数据都会被一次性遍历完毕,且无法再补充新数据。for 循环执行结束后,迭代器返回的最后一个结果中会包含done: true,一切就此终止。

这种行为本就符合生成器的设计初衷 —— 它从一开始就不是为了执行完毕后能 “复活” 而设计的,其执行过程是一条单行道。但我至少有一次迫切希望它能支持续充,就在最近为 PicPerf 开发文件上传工具时。我当时铁了心要让生成器来实现一个可续充的先进先出(FIFO)队列,一番摸索后,最终的实现效果让我很满意。

可续充队列的设计思路

先明确一下,我所说的 “可续充” 具体是什么意思。生成器无法重启,但我们可以在队列数据耗尽时让它保持等待状态,而非直接终止,Promise 恰好能完美实现这个需求!

我们先从一个基础示例开始:实现一个队列,每隔 500 毫秒逐个处理队列中的圆点元素。

<html>
  <ul id="queue">
    <li class="item"></li>
    <li class="item"></li>
    <li class="item"></li>
  </ul>

  已处理总数:<span id="totalProcessed">0</span>
</html>

<script>
  async function* go() {
    // 初始化队列,包含页面中的初始元素
    const queue = Array.from(document.querySelectorAll("#queue .item"));

    for (const item of queue) {
      yield item;
    }
  }

  // 遍历队列,逐个处理并移除元素
  for await (const value of go()) {
    await new Promise((res) => setTimeout(res, 500));
    value.remove();

    totalProcessed.textContent = Number(totalProcessed.textContent) + 1;
  }
</script>

这就是一个典型的 “单行道” 队列:

如果我们加一个按钮,用于向队列添加新元素,若在生成器执行完毕后点击按钮,页面不会有任何反应 —— 因为生成器已经 “失效” 了。所以,我们需要对代码做一些重构。

实现队列的可续充功能

首先,我们用while(true)让循环无限执行,不再依赖队列初始的固定数据。

async function* go() {
  const queue = Array.from(document.querySelectorAll("#queue .item"));

  while (true) {
    if (!queue.length) {
      return;
    }

    yield queue.shift();
  }
}

现在只剩一个问题:代码中的return语句会让生成器在队列为空时直接终止。我们将其替换为一个 Promise,让循环在无数据可处理时暂停,直到有新数据加入:

let resolve = () => {};
const queue = Array.from(document.querySelectorAll('#queue .item'));
const queueElement = document.querySelector('#queue');
const addToQueueButton = document.querySelector('#addToQueueButton');

async function* go() {  
  while (true) {
    // 创建Promise,并为本次生成器迭代绑定resolve方法
    const promise = new Promise((res) => (resolve = res));

    // 队列为空时,等待Promise解析
    if (!queue.length) await promise;

    yield queue.shift();
  }
}

addToQueueButton.addEventListener("click", () => {
  const newElement = document.createElement("li");
  newElement.classList.add("item");
  queueElement.appendChild(newElement);

  // 添加新元素,唤醒队列
  queue.push(newElement);
  resolve();
});

// 后续处理代码不变
for await (const value of go()) {
  await new Promise((res) => setTimeout(res, 500));
  value.remove();
  totalProcessed.textContent = Number(totalProcessed.textContent) + 1;
}

这次的实现中,生成器的每次迭代都会创建一个新的 Promise。当队列为空时,代码会await这个 Promise 解析,而解析的时机就是我们点击按钮、向队列添加新元素的时刻。

最后,我们对代码做一层封装,打造一个更优雅的 API:

function buildQueue<T>(queue: T[] = []) {
  let resolve: VoidFunction = () => {};

  async function* go() {
    while (true) {
      const promise = new Promise((res) => (resolve = res));

      if (!queue.length) await promise;

      yield queue.shift();
    }
  }

  function push(items: T[]) {
    queue.push(...items);
    resolve();
  }

  return {
    go,
    push,
  };
}

这里补充一个小技巧:你并非一定要将队列中的元素逐个移除。如果希望保留所有元素,只需通过一个索引指针来遍历队列即可:

async function* go() {
  let currentIndex = 0;

  while (true) {
    const promise = new Promise((res) => (resolve = res));

    // 索引指向的位置无数据时,等待新数据
    if (!queue[currentIndex]) await promise;

    yield queue[currentIndex];
    currentIndex++;
  }
}

大功告成!接下来,我们将这个实现落地到实际开发场景中。

在 React 中落地可续充队列

正如前文所说,PicPerf 是一个图片优化、托管和缓存平台,支持用户上传多张图片进行处理。其界面采用了一个常见的交互模式:用户拖拽图片到指定区域,图片会按顺序逐步完成上传。 这正是可续充先进先出队列的适用场景:即便 “待上传” 的图片全部处理完毕,用户依然可以拖拽新的图片进来,上传流程会自动继续,队列会直接从新添加的文件开始处理。

React 中的基础实现方案

首先,我们尝试纯 React 的实现思路,充分利用 React 的状态与渲染生命周期,核心依赖两个状态:

  • files: UploadedFile[]:存储所有拖拽到界面的文件,每个文件自身维护一个状态:pending(待上传)、uploading(上传中)、completed(已完成)。
  • isUploading: boolean:标记当前是否正在上传文件,作为一个 “锁”,防止在已有上传任务执行时,启动新的上传循环。

这个组件的核心逻辑是监听files状态的变化,一旦有新文件加入,useEffect钩子就会触发上传流程;当一个文件上传完成后,将isUploading置为false,又会触发另一次useEffect执行,进而处理队列中的下一张图片。

以下是简化后的核心代码:

import { processUpload } from './wherever';

export default function MediaUpload() {
  const [files, setFiles] = useState([]);
  const [isUploading, setIsUploading] = useState(false);

  const updateFileStatus = useEffectEvent((id, status) => {
    setFiles((prev) =>
      prev.map((file) => (file.id === id ? { ...file, status } : file))
    );
  });

  useEffect(() => {
    // 已有上传任务执行时,直接返回
    if (isUploading) return;

    // 找到队列中第一个待上传的文件
    const nextPending = files.find((f) => f.status === 'pending');

    // 无待上传文件时,直接返回
    if (!nextPending) return;

    // 加锁,标记为上传中
    setIsUploading(true);
    updateFileStatus(nextPending.id, 'uploading');

    // 执行上传,完成后解锁并更新状态
    processUpload(nextPending).then(() => {
      updateFileStatus(nextPending.id, 'complete');
      setIsUploading(false);
    });
  }, [files, isUploading]);

  return <UploadComponent files={files} setFiles={setFiles} />;
}

在有文件正在上传时,用户依然可以添加新文件,新文件会被追加到队列末尾,等待后续逐个处理: 从 React 组件的设计角度来看,这种方案并非不可行,监听状态变化并做出相应响应也是很常见的实现方式。

但说实话,很难有人会觉得这个思路直观易懂。useEffect钩子的设计初衷是让组件与外部系统保持同步,而在这里,它却被用作了事件驱动的状态机调度工具,成了组件的核心行为逻辑,这显然偏离了其设计本意。

所以,我们不妨换掉这些useEffect钩子,用生成器实现的可续充队列来重构这个组件。

结合外部状态仓库实现

我们不再让 React 完全托管所有文件及其状态,而是将这些数据抽离到外部,从其他地方触发组件的重新渲染。这样一来,组件会变得更 “纯”,只专注于其核心职责 —— 渲染界面。

React 恰好提供了一个适配该场景的工具useSyncExternalStore。这个钩子能让组件监听外部管理的数据变化,组件的 “React 特性” 会适当让步,等待外部的指令,而非全权掌控所有状态。在本次实现中,这个 “外部状态仓库” 就是一个独立的模块,专门负责文件的处理逻辑。

useSyncExternalStore至少需要两个方法:一个用于监听数据变化(让 React 知道何时需要重新渲染组件),另一个用于返回数据的最新快照。以下是仓库的基础骨架:

// store.ts

let listeners: Function[] = [];
let files: UploadableFile[] = [];

// 必须返回一个取消监听的方法(供React内部使用)
export function subscribe(listener: Function) {
  listeners.push(listener);

  return () => {
    listeners = listeners.filter((l) => l !== listener);
  };
}

// 返回数据最新快照
export function getSnapshot() {
  return files;
}

接下来,我们补充实现所需的其他方法:

  • updateStatus():更新文件状态(待上传、上传中、已完成);
  • add():向队列中添加新文件;
  • process():启动并执行文件上传队列;
  • emitChange():通知 React 的监听器数据发生变化,触发组件重新渲染。

最终,状态仓库的完整代码如下:

// store.ts

import { buildQueue, processUpload } from './whatever';

let listeners: Function[] = [];
let files: any[] = [];
// 初始化可续充队列
const queue = buildQueue();

// 通知监听器,触发组件重渲染
function emitChange() {
  // 外部仓库的一个关键要点:数据变化时,必须返回新的引用
  files = [...queue.queue];

  for (let listener of listeners) {
    listener();
  }
}

// 更新文件状态
function updateStatus(file: any, status: string) {
  file.status = status;
  emitChange();
}

// 公共方法
export function getSnapshot() {
  return files;
}

export function subscribe(listener: Function) {
  listeners.push(listener);

  return () => {
    listeners = listeners.filter((l) => l !== listener);
  };
}

// 向队列添加新文件
export function add(newFiles: any[]) {
  queue.push(newFiles);
  emitChange();
}

// 执行文件上传流程
export async function process() {
  for await (const file of queue.go()) {
    updateStatus(file, 'uploading');
    await processUpload(file);
    updateStatus(file, 'complete');
  }
}

此时,我们的 React 组件会变得异常简洁:

import { 
  add,
  process, 
  subscribe,
  getSnapshot
} from './store';

export default function MediaUpload() {
  // 监听外部仓库的数据变化
  const files = useSyncExternalStore(subscribe, getSnapshot);

  // 组件挂载时启动上传队列
  useEffect(() => {
    process();
  }, []);

  // 将文件数据和添加方法传递给子组件
  return <UploadComponent files={files} setFiles={add} />;
}

现在只剩一个细节需要完善:合理的清理逻辑。当组件卸载时,我们不希望还有未完成的上传任务在后台执行。因此,我们为仓库添加一个abort方法,强制终止生成器,并在组件的useEffect中执行清理:

// store.ts

// 其他代码不变

let iterator = null;

export async function process() {
  // 保存生成器迭代器的引用
  iterator = queue.go();

  for await (const file of iterator) {
    updateStatus(file, 'uploading');
    await processUpload(file);
    updateStatus(file, 'complete');
  }

  iterator = null;
}

// 强制终止生成器
export function abort() {
  return iterator?.return();
}
function MediaUpload() {
  const files = useSyncExternalStore(subscribe, getSnapshot);

  useEffect(() => {
    process();
    // 组件卸载时执行清理,终止上传队列
    return () => abort();
  }, []);

  return <UploadComponent files={files} setFiles={add} />;
}

需要说明的是,为了简化代码,这里做了一些大胆的假设:上传过程永远不会失败、process方法同一时间只会被调用一次、该仓库只有一个使用者。请忽略这些细节以及其他可能的疏漏,重点来看这种实现方案带来的诸多优势:

  1. 组件的行为不再依赖useEffect的反复触发,逻辑更清晰;
  2. 所有文件上传的业务逻辑都被抽离到独立模块中,与 React 解耦,可单独维护和复用;
  3. 终于有机会实际使用useSyncExternalStore这个 React 钩子;
  4. 我们成功在 React 中用异步生成器实现了一个可续充队列。

对有些人来说,这种方案可能比最初的纯 React 方案复杂得多,我完全理解这种感受。但不妨换个角度想:现在把代码写得复杂一点,就能多拖延一点时间,避免 AI 工具完全取代我们的工作、毁掉我们的职业未来,甚至 “收割” 我们的价值。带着这个目标去写代码吧!

当然,说句正经的:要让 AI 辅助开发持续发挥价值,需要人类帮助 AI 理解底层技术原语的设计目的、取舍原则和发展前景。掌握这些底层知识,永远有其不可替代的价值。

昨天以前首页

【翻译】Rolldown工作原理:模块加载、依赖图与优化机制全揭秘

2026年2月15日 21:59

原文链接:www.atriiy.dev/blog/rolldo…

作者: Atriiy

引言

Rolldown 是一款基于 Rust 开发的极速 JavaScript 打包工具,专为无缝兼容 Rollup API 设计。其核心目标是在不久的将来成为 Vite 的统一打包器,为 Vite 提供底层支撑。目前 Vite 在本地开发阶段依赖 esbuild 实现极致的构建速度,而生产环境构建则基于 Rollup;切换为 Rolldown 这类单一打包器后,有望简化整个构建流程,让开发者更有信心 —— 开发环境所见的效果,与生产环境最终上线的结果完全一致。此外,Rolldown 的打包速度预计比 Rollup 快 10~30 倍。想了解更多细节?可查阅 Rolldown 官方文档。

本文将先从 Rolldown 的整体架构入手,帮你建立对其工作原理的全局认知,避免过早陷入细节而迷失方向。在此基础上,我们会深入本文的核心主题:模块加载器 —— 这是 Rolldown 扫描阶段的核心组件,我们将剖析其关键功能,以及支撑它运行的重要数据结构。

接下来,我们还会探讨依赖图,以及 Rolldown 采用的部分性能优化策略。尽管其中部分内容你可能此前有所接触,但结合上下文重新梳理仍有价值 —— 这些内容是理解 Rolldown 如何实现极致速度与效率的关键。

好了,让我们正式走进 Rolldown 的世界吧。😉

Rolldown 整体架构概览

Rolldown 的核心流程分为四个主要步骤:依赖图构建 → 优化 → 代码生成 / 打包 → 输出。最终生成的打包产物会根据场景(本地开发 / 生产构建)写入内存或文件系统。你可在 crates/rolldown/src/bundler.ts 路径下找到入口模块的实现。以下是该流程的示意图:

graph TD
start["Start: Read Config & Entry Points"]
parse["Parse Entry Module"]
build{"Build Module Graph"}
load["Load & Parse Dependency Modules"]
optimaze["Code optimization"]
generate["Code Generation: Generate\n Chunks"]
return["Return Output Assets In\n Memory"]
write["Write Output Files to Disk"]
start --> parse
parse --> build
build -->|Scan Dependencies|load
load -->|Repeat until all\n dependencies are processed|build
build --> optimaze
optimaze --> generate
generate -->|Generate Mode: rolldown.generate| return
generate -->|Write Mode: rolldown.write| write

模块加载器是构建模块依赖图阶段的核心组件,它由 Bundler 结构体中的 scan 函数触发调用。为实现更清晰的职责分离,整个扫描流程已被封装到专用的 ScanStage 结构体中。

但真正的核心工作都发生在 ModuleLoader(模块加载器)内部:它负责处理构建依赖图、解析单个模块等关键任务,也是 Rolldown 中大量核心计算逻辑的落地之处 —— 这正是本文要重点探讨的内容。

模块加载器(Module Loader)

简而言之,模块加载器的核心职责是定位、获取并解析单个模块(包括源码文件、CSS 文件等各类资源),并将这些模块转换为打包器能够识别和处理的内部数据结构。这一步骤是构建精准且高效的模块依赖图的关键。

以下示意图展示了 Rolldown 在打包流程中如何使用模块加载器:

graph TD
prepare["Bundler: Prepare needs to\n Build Module Graph"]
create["Create Module Loader\n Instance"]
calls["Bundler: Calls Module\n Loader's fetch_modules"]
load[["Module Loader Operation"]]
return["Module Loader: Returns\n Aggregated Results to\n Bundler"]
result["Bundler: Uses Results for\n Next Steps - e.g., Linking,\n Optimization, Code Gen"]
prepare --> create
create --> calls
calls --> load
load --> return
return --> result

上述所有步骤均发生在 ScanStage 结构体的 scan 函数内部。你可以将 scan 函数理解为一个编排器(builder) —— 它统筹并封装了运行模块加载器所需的全部逻辑。

拉取模块(Fetch modules)

fetch_modules 是整个流程的 “魔法起点”。它扮演着调度器(scheduler) 的角色,启动一系列异步任务来解析所有相关模块。该函数负责处理用户定义的入口点 —— 这也是模块扫描算法的起始位置。

在进入 fetch_modules 之前,scan 函数会先解析这些入口点,并将其转换为 Rolldown 内部的 ResolvedId 结构体。这一预处理步骤由 resolve_user_defined_entries 函数完成。

以下示意图展示了 fetch_modules 函数的核心工作流程:

graph TD
start["Start: Receive Resolved\n User Defined Entries"]
init["Initialize: Task Counter,\n Module Cache, Result\n Collectors"]
launch["Launch Async Tasks for Each\n Entry"]
loop{"Message Loop: Listen while\n Task Counter > 0"}
store["Store Results;\nProcess Dependencies"]
request["Launch Task for Requested\n Module; Increment Counter"]
resolve["Resolve & Launch Task for\n New Entry; Increment\n Counter"]
record["Record Error; Decrement\n Counter"]
depence{"New Dependencies?"}
tasks["Launch Tasks for New\n Dependencies; Increment\n Counter"]
dec["Decrement Task Counter"]
zero{"Task Counter == 0?"}
final["Finalize: Update\n Dependency Graph,\n Organize Results"]
return["End: Return Output to\n Caller"]
start --> init
init --> launch
launch --> loop
loop -->|Module Done| store
loop -->|Plugin Fetch Module| request
request --> loop
loop -->|Plugin Add Entry| resolve
resolve --> loop
loop -->|Build Error| record
record --> loop
store --> depence
depence -->|Yes| tasks
tasks --> loop
depence -->|No| dec
dec --> zero
zero -->|No| loop
zero -->|Yes| final
final --> return

看起来有点复杂,对吧?这是因为该阶段集成了大量优化策略和功能特性。不过别担心 —— 我们可以暂时跳过细枝末节,先聚焦整体流程。

如前文所述,fetch_modules 函数以解析后的用户定义入口点为输入,开始执行处理逻辑。对于每个入口点,它会调用 try_spawn_new_task 函数:该函数先判定模块属于内部模块还是外部模块,再执行对应的处理逻辑,最终返回一个类型安全的 ModuleIdx(模块索引) 。这个索引后续会作为整个系统中引用对应模块的唯一标识。

当所有入口点的初始任务都已启动后,fetch_modules 会进入循环,监听一个基于 tokio::sync::mpsc 实现的消息通道。每个模块处理任务都持有该通道的发送端句柄(sender handle),并向主进程上报事件。fetch_modules 内部的消息监听器会响应这些消息,具体包括以下类型:

  • 普通 / 运行时模块处理完成:存储处理结果,并调度该模块的所有依赖模块;
  • 拉取模块:响应插件的按需加载特定模块请求;
  • 添加入口模块:在扫描过程中新增入口点(通常由插件触发);
  • 构建错误:捕获加载或转换过程中出现的所有错误。

当所有模块处理完毕且无新消息传入时,消息通道会被关闭,循环随之退出。随后 fetch_modules 执行收尾清理工作:存储已处理的入口点、更新依赖图,并将聚合后的结果返回给调用方(即 scan 函数)。该结果包含模块、抽象语法树(AST)、符号、入口点、警告信息等核心数据 —— 这些都会被用于后续的优化和代码生成阶段。

启动新任务(Spawn new task)

try_spawn_new_task 函数首先尝试从模块加载器的缓存中获取 ModuleIdx(模块索引)。由于扫描阶段本质上是对依赖图的遍历过程,该缓存通过哈希映射表跟踪每个模块的访问状态 —— 其中键为模块 ID,值用于标识该模块是否已处理完成。

接下来,函数会根据模块类型,将其转换为普通模块或外部模块结构,以便进行后续处理。理解外部模块的处理逻辑尤为重要:这类模块不会被 Rolldown 打包 —— 它们预期由运行时环境提供(例如 node_modules 中的第三方库)。尽管不会被纳入最终打包产物,但 Rolldown 仍会记录其元数据,实际上是将其视为占位符(placeholder) 。打包产物会假定这些模块在运行时可用,并在需要时直接引用它们。

而普通模块(通常由用户编写)的处理方式则不同:try_spawn_new_task 会为每个普通模块创建一个专属的模块任务,并以异步方式执行。这些任务由 Rust 异步运行时 Tokio 管理。如前文所述,每个任务都持有消息通道的发送端,可在运行过程中上报错误、新发现的导入项,或动态添加的入口点。

数据结构(Data structures)

为提升性能和代码复用性,Rolldown 大量使用专用数据结构。理解模块加载器中几个核心数据结构的设计,能让你更清晰地认知扫描流程的底层实现逻辑。

ModuleIdx & HybridIndexVec

ModuleIdx 是一种自定义数值索引,会在模块处理过程中动态分配。这种索引设计兼顾类型安全与性能:Rolldown 不会传递或克隆完整的模块结构体,而是使用这种轻量级标识符(类似其他编程语言中的指针),在整个系统中引用模块。

pub struct ModuleIdx = u32;

HybridIndexVec 是 Rolldown 用于存储模块数据的智能自适应容器。由于 Rolldown 核心操作的对象是 ModuleIdx(模块索引)而非实际的模块数据,实现高效的 “基于 ID 查找” 就至关重要 —— 而这正是 HybridIndexVec 的设计初衷:它会针对不同的打包场景做针对性优化。

pub enum HybridIndexVec<I: Idx, T> {
  IndexVec(IndexVec<I, T>),
  Map(FxHashMap<I, T>),
}

打包工具通常运行在两种模式下:

  • 全量打包(Full bundling) (生产环境构建的主流模式):所有模块仅扫描一次,并以连续存储的方式保存。针对这种场景,Rolldown 采用名为 IndexVec紧凑高性能结构—— 它的行为类似向量(vector),但强制要求类型安全的索引访问。
  • 增量打包(Partial bundling) (常用于开发环境):模块依赖图可能频繁变化(例如开发者编辑文件时)。这种场景下,稀疏结构(sparse structure)更适用,Rolldown 会使用基于 FxHash 算法的哈希映射表,以实现高效的键值对访问。

FxHash 算法比 Rust 默认哈希算法更快,尽管其哈希冲突的概率略高。由于键和值均由 Rolldown 内部管理,且 “可预测的性能” 比安全性更重要,因此这种取舍对于 Rolldown 的使用场景而言是可接受的。

模块(Module)

普通模块由用户定义 —— 通常是需要解析、转换或分析的源码文件。Rolldown 会加载这些文件,并根据文件扩展名进行处理。例如,.ts(TypeScript)文件会通过高性能的 JavaScript/TypeScript 解析器 Oxc 完成解析。

pub enum Module {
  Normal(Box<NormalModule>),
  External(Box<ExternalModule>),
}

内部的 NormalModule 结构体存储着每个模块的详细信息:既包含 idx(索引)、module_type(模块类型)等基础元数据,也涵盖模块内容的富表示形式(richer representations) 。根据文件类型的不同,这些内容具体包括:

  • ecma_view:用于 JavaScript/TypeScript 模块
  • css_view:用于样式表文件
  • asset_view:用于静态资源文件

这种结构化设计,能让打包流程后续阶段(如优化、代码生成)高效处理已解析的模块内容。

ScanStageCache(扫描阶段缓存)

这是一个在模块加载过程中存储所有缓存数据的结构体。以下是该数据结构的定义:

pub struct ScanStageCache {
  snapshot: Option<NormalizedScanStageOutput>,
  pub module_id_to_idx: FxHashMap<ArcStr, VisitState>,
  pub importers: IndexVec<ModuleIdx, Vec<ImporterRecord>>,
}

snapshot(快照)存储着上一次扫描阶段的执行结果,用于支持增量构建。Rolldown 无需从头重新扫描所有模块,而是复用上次扫描的部分结果 —— 当仅有少量文件变更时,这一机制能大幅缩短构建耗时。

module_id_to_idx 是一个哈希映射表,存储模块 ID 与其访问状态的映射关系。程序可通过它快速判断某个模块是否已处理完成。

该映射表的键类型为 ArcStr—— 这是一种内存高效、支持引用计数的字符串类型,专为跨线程共享场景优化。更重要的是,这个字符串是模块的全局唯一且稳定的标识符,在多次构建过程中保持一致,这对缓存的可靠性至关重要。

importers 是模块依赖图的反向邻接表:针对每个模块,它会跟踪 “哪些其他模块导入了该模块”。这在增量构建中尤为实用:当某个模块内容变更时,importers 能帮助 Rolldown 快速确定受影响模块的范围 —— 本质上就是识别出需要重新处理的模块。

需注意,importers 还会有一个临时版本存储在 IntermediateNormalModules(中间普通模块)中。你可以将其理解为 “草稿状态”,会在当前构建过程中动态生成。

依赖图(Dependency graph)

依赖图描述了模块间的相互依赖关系,也是扫描阶段最重要的输出之一。Rolldown 会在后续阶段(如摇树优化、代码分块、代码生成)利用这份关系映射表完成各类核心任务。

在深入讲解具体实现前,我们先介绍邻接表的概念 —— 它是依赖图的表示与遍历的核心载体。

图与邻接表(Graph and Adjacency table)

众所周知,图是用于表示 “事物间关联关系” 的数据结构,由两部分组成:

  • 节点(Nodes):被关联的项或实体(对应 Rolldown 中的模块)
  • 边(Edges):节点之间的关联或依赖关系(对应模块间的导入导出关系)

图有两种常见的表示方式:邻接矩阵邻接表

邻接矩阵是一个二维网格(矩阵),每行和每列对应一个节点。矩阵中某个单元格的值表示两个节点之间是否存在边:例如,值为 1 表示存在关联,值为 0 则表示无关联。

  | A | B | C
A | 0 | 1 | 0
B | 1 | 0 | 1
C | 0 | 1 | 0

这种方式(邻接矩阵)简单直观,在稠密图场景下表现优异 —— 即大多数节点之间都存在关联的图。但对于稀疏图而言,它的内存利用率极低,而 Rolldown 这类打包工具中的模块依赖图恰好属于稀疏图。(相信没人会在项目里把所有模块都导入到每一个文件中吧。😉)

邻接表则是另一种存储方式:每个节点都维护一个 “邻居节点列表”。它不会使用固定大小的矩阵,而是只存储实际存在的关联关系,因此在稀疏图场景下效率更高。

举个例子:若节点 A 关联到节点 B,节点 B 关联到节点 A 和 C,最终节点 C 仅关联到节点 B。

A[B]
B[A, C]
C → [B]

这种结构(邻接表)内存利用率高,且能轻松适配大型稀疏图场景 —— 比如 Rolldown 这类打包工具所处理的模块依赖图。同时,它还能让程序仅遍历相关的关联关系,这一点在扫描或优化阶段尤为实用。

正向与反向依赖图(Forward & reverse dependency graph)

在扫描阶段,Rolldown 会构建两种类型的依赖图:正向依赖图和反向依赖图。其中,正向依赖图存储在每个模块的 ecma_view 中,记录当前模块所导入的其他模块。

pub struct ecma_view {
  pub import_records: IndexVec<ImportRecordIdx, ResolvedImportRecord>,
  // ...
}

正向依赖图对打包至关重要。模块加载器从用户定义的入口点出发,构建这张图来确定最终打包产物需要包含哪些模块。它在确定执行顺序管理变量作用域方面也扮演着关键角色。

此外,模块加载器还会创建一张反向依赖图,方便追踪哪些模块导入了指定模块。这对摇树优化(Tree Shaking)、副作用分析、增量构建、代码分块和代码分割等功能至关重要。

这些功能涉及大量上下文,这里就不展开细讲。你可以简单这样理解:如果我(某个模块)发生了变化,谁会受到影响? 答案是:所有依赖这个变更模块的模块都需要重新处理。这就是实现增量构建热模块替换(HMR) 的核心思想。

性能优化

Rolldown 底层包含大量性能优化手段。得益于 Rust 的零成本抽象所有权模型,再搭配 Tokio 强大的异步运行时,开发者拥有了将性能推向新高度的工具。模块加载器本身也运用了多种提速技术,这里我们简要介绍一下,大部分内容前面已经提到过。

异步并发处理

并发是模块加载器的核心。如前所述,它的主要职责是遍历所有模块并构建依赖图。在实际项目中,导入关系会迅速变得复杂且嵌套很深,这使得异步并发至关重要。

在 Rust 中,asyncawait 是异步函数的基础构建块。异步函数会返回一个 Future,它不会立即执行,只有在显式 await 时才会运行。Rolldown 基于 Rust 最主流的异步运行时 Tokio,高效并发地执行这些模块处理任务。

缓存

由于 Rolldown 会执行大量异步操作,并且在本地开发环境中会频繁重复运行,缓存就成了避免重复工作的关键。

模块加载器的缓存存放在 ModuleLoader 结构体内部,包含 snapshotmodule_id_to_idximporters 等数据,大部分我们在前面章节已经介绍过。这些缓存能帮助 Rolldown 避免重复处理相同模块,让增量构建速度大幅提升。

未来展望

Rolldown 仍在积极开发中。未来,它有望成为 Vite 的底层引擎,提供一致的构建结果极致的性能。你可以在这里查看路线图。

我写这篇文章是为了记录我研究 Rolldown 的过程,也希望能为你揭开它那些出色底层实现的神秘面纱。如果你发现错误或觉得有遗漏,欢迎在下方留言 —— 我非常期待你的反馈!😊

感谢阅读,我们下篇文章见!

【翻译】Rolldown工作原理:符号关联、CJS/ESM 模块解析与导出分析

2026年2月15日 12:31

原文链接:www.atriiy.dev/blog/rolldo…

作者:Atriiy

引言

Rolldown 是一款基于 Rust 开发的高性能 JavaScript 打包工具。它在完全兼容 Rollup API 的前提下,实现了 10 至 30 倍的打包速度提升。出于对开发与生产环境统一引擎的需求,Vite 团队正将 Rolldown 打造为当前基于 esbuild + Rollup 的打包架构的继任者。

在现代前端项目中,成百上千的模块构成了复杂的依赖图谱。打包工具的理解不能仅停留在 “文件级导入” 层面:它必须深入分析,判断 featureA.js 中导入的 useState 是否与 featureB.js 中的 useState 为同一个实体。这一关键的解析过程被称为链接(linking)

链接阶段(link stage)正是为解决这一问题而生:它处理那些会在模块图谱中传播的宏观属性(例如顶层 await 的 “传染性”);搭建不同模块系统(CJS/ESM)之间的通信桥梁;最终将每一个导入的符号追溯到其唯一的原始定义。

为揭开这一过程的神秘面纱,我们将通过三级心智模型拆解链接阶段的内部机制,从宏观到微观逐步剖析其工作原理。

三级心智模型

扫描阶段输出的是一份基础的模块依赖图谱,但仅停留在文件级别。而链接阶段会通过一系列数据结构和算法细化这份图谱,最终生成精准的「符号级依赖映射」。

  • 基础与固有属性。扫描阶段生成的初始图谱存储在 ModuleTable 中,记录了所有模块的依赖关系。链接阶段会对该图谱执行深度优先遍历,计算并传播诸如顶层 await(TLA)这类具有 “传染性” 的属性。这些属性可能通过间接依赖影响整个模块链,因此这一分析是代码生成阶段的关键前提。

  • 标准化与模块通信协议。JavaScript 对多模块系统(主要是 CommonJS 即 CJS、ES 模块即 ESM)的支持带来了复杂度。在核心链接逻辑执行前,必须先规范化这些不同的模块格式,处理命名空间对象、垫片化导出(shimmed exports)等细节。这种标准化构建了统一的处理环境,让符号链接算法能专注于核心的解析逻辑,而非大量边缘情况。

  • 万物互联:符号谱系。在最细粒度层面,该阶段会将符号与其对应的导入、导出语句建立关联。它借助并查集(Disjoint Set Union,DSU) 数据结构高效建立跨模块的等价关系,确保每个符号都能解析到唯一、无歧义的原始定义。

示例项目

为了梳理链接阶段复杂的数据结构与算法逻辑,我们将以一个具体的示例项目展开讲解。这种方式能让底层逻辑变得更具象、更易理解。该项目是特意设计的,旨在展现链接阶段必须处理的多个关键场景:

  • CJS 与 ESM 混合使用
  • 顶层 await(TLA)
  • 具名导出与星号重导出
  • 潜在歧义符号
  • 外部依赖
  • 副作用

完整源码可在这份 GitHub Gist 中查看,项目的文件结构如下:

📁 .
├── api.js                # (1) Fetches data, uses Top-Level Await (ESM)
├── helpers.js            # (2) Re-exports modules, creating linking complexity (ESM)
├── legacy-formatter.cjs  # (3) An old formatting utility (CJS)
├── main.js               # (4) The application entry point (ESM)
└── polyfill.js           # (5) A simulated polyfill to demonstrate side effects (ESM)

若你想自行运行这个示例,需将这些文件放置到 Rolldown 代码仓库的 crates/rolldown/examples/basic 目录下。随后,修改 basic.rs 文件,把 main.js 配置为入口点:

// ...
input: Some(vec![
  "./main.js".to_string().into(),
]),

完成调试环境配置后,建议你结合断点运行这款打包工具。通过单步执行代码、实时查看数据结构的方式,能让你更深入地理解整个处理流程。

基础与固有属性

扫描阶段会生成一份基础的模块依赖图谱。在这张有向图中,节点代表单个模块,边表示模块间的导入关系;为了实现高效遍历,该结构通常基于邻接表实现。链接阶段基础环节的核心任务,是遍历这张图谱并计算那些会沿导入链传播的宏观属性 —— 例如顶层 await(TLA)的 “传染性”。想要理解这些算法的工作原理,扎实掌握核心模块的数据结构是必不可少的前提。

图谱设计

图谱设计的核心是 ModuleIdx(模块索引)—— 一种用于指向特定模块的类型化索引。模块分为两类:NormalModuleExternalModule,不过我们的分析将主要聚焦前者。每个NormalModule都会封装其 ECMAScript 解析结果,其中最关键的是 import_records 字段(该字段会列出模块中所有的导入语句)。以下类图展示了这一数据结构的设计思路。

classDiagram
note for ModuleIdx "Typed Index of Module"
class ModuleIdx { 
  TypeAlias for u32
}

note for ModuleTable "Global Module Table" 
class ModuleTable { 
  +modules: IndexVec< ModuleIdx, Module >
}
ModuleIdx -- ModuleTable : used_as_index_for 

class Module { 
  <<enumeration>> 
  +Normal: Box~NormalModule~
  +External: Box~ExternalModule~
  +idx() : ModuleIdx 
  +import_records() : IndexVec< ImportRecordIdx, ResolvedImportRecord >
} 
ModuleTable "1" o-- "0..*" Module : contains

class ExternalModule { 
  +idx: ModuleIdx
  +import_records: IndexVec<...> 
} 
Module -- ExternalModule : can_be_a 

class NormalModule { 
  +idx: ModuleIdx 
  +ecma_view: EcmaView 
} 
Module -- NormalModule : can_be_a 

class ImportRecordIdx { 
  TypeAlias for u32
}

class EcmaView { 
  import_records: IndexVec< ImportRecordIdx, ResolvedImportRecord > 
}
NormalModule "1" *-- "1" EcmaView : own
ImportRecordIdx -- EcmaView : used_as_index_for 

class ImportRecord~State~ { 
  +kind: ImportKind 
}
EcmaView "1" o-- "0..*" ImportRecord~State~ : contains

class ImportRecordStateResolved { 
  +resolved_module: ModuleIdx 
}
ImportRecord~State~ *-- ImportRecordStateResolved : holds

遍历依赖图时,需要逐一迭代处理每个模块的依赖项。这些依赖项可通过 import_records 字段访问。为了简化这一高频操作,Module 枚举类型专门实现了一个便捷的 import_records() 访问器方法。这一设计选择简化了依赖图的遍历流程,Rolldown 源码中下述常见代码模式即可印证这一点:

module_table.modules[module_idx]
.import_records()
.iter()
.map(|rec| {
  // do something...
})

核心数据结构:LinkingMetadata

链接阶段(link stage)的最终输出封装在 LinkStageOutput 结构体中。其定义如下:

pub struct LinkStageOutput {
  pub module_table: ModuleTable,
  pub metas: LinkingMetadataVec,
  pub symbol_db: SymbolRefDb,
  // ...
}

总结这些字段的作用:ModuleTable 是扫描阶段(scan stage)的主要输入,而 LinkingMetadataVecSymbolRefDb 是核心输出 —— 前者存储在模块层面新计算出的信息,后者则存储符号层面的相关信息。这三个结构体共同从宏观到微观的维度,完整、多层级地描述了模块间的依赖关系。

ModuleTable 类似,LinkingMetadataVec 是一个带索引的向量(indexed vector),可通过 ModuleIdx 访问每个具体模块的元数据。而绝大多数这类新增的模块层面信息,均记录在 LinkingMetadata 结构体内部。

classDiagram
note for ModuleIdx "Typed Index of Module"
class ModuleIdx {
  TypeAlias for u32
}
class LinkStage {
  +metas: IndexVec< ModuleIdx, LinkingMetadata >
}
ModuleIdx -- LinkStage : used_as_index_for
class LinkingMetadata {
  +wrap_kind: WrapKind
  +resolved_exports: FxHashMap
  +has_dynamic_exports: bool
  +is_tla_or_contains_tla_dependency: bool
}
LinkStage "1" o-- "0..*" LinkingMetadata : contains

顶层 await(TLA)的处理逻辑

顶层 await(Top-level await,简称 TLA),顾名思义,允许开发者在 ES 模块的顶层作用域中使用 await 关键字,无需将异步代码包裹在 async 函数中。该特性极大简化了异步初始化任务的编写,相比传统的立即调用函数表达式(Immediately Invoked Function Expression,IIFE)模式,代码逻辑更清晰。我们示例项目中的 api.js 文件就体现了这一用法:

console.log('API module evaluation starts.')
// Use top-level await to make the entire dependency chain async
const response = await fetch('https://api.example.com/items/1')
const item = await response.json()

// Export a processed data
export const fetchedItem = { id: item.id, value: item.value * 100 }

// Export a normal variable, we will use it to create confusion
export const source = 'API'

console.log('API module evaluation finished.')

TLA 的一个核心特性是其 “传染性”:若某个模块使用了 TLA,所有直接或间接导入该模块的其他模块都会受到影响,且必须被当作异步模块处理。每个模块的 LinkingMetadata 中都包含一个 is_tla_or_contains_tla_dependency 标记,用于追踪这一状态。在我们的示例中,main.js 依赖 helpers.js,而 helpers.js 又依赖使用了 TLA 的 api.js;因此 Rolldown 会对依赖图执行深度优先遍历,并将这三个模块的该标记均设为 true

该标记的计算逻辑完全贴合 TLA 的行为特征:Rolldown 采用递归的深度优先搜索(DFS)算法,并通过哈希表做记忆化处理(memoization),避免对同一模块重复计算。算法核心通过检查两个条件,判断模块是否受 TLA 影响:

  • 模块自身是否包含顶层 await?(这一信息来自扫描阶段计算得到的 ast_usage 字段)
  • 模块是否导入了任何受 TLA 影响的其他模块?

这种递归检查确保了 TLA 的 “传染性” 能从源头沿整个依赖链向上正确传播。

副作用的判定逻辑

简单来说,若一个模块在被导入时,除了导出变量、函数或类之外还执行了其他操作,则称该模块具有 “副作用”。具体而言,它会执行影响全局环境或修改自身作用域之外对象的代码。Polyfill(兼容性补丁)是典型应用场景 —— 这类代码通常会扩展全局对象,以使老旧浏览器能够支持新的 API。

我们示例项目中的 polyfill.js 就是绝佳例证:尽管 main.js 仅通过 import './polyfill.js' 导入该模块,并未引用任何符号,但由于它修改了全局的 globalThis 对象,该模块仍被判定为具有副作用。因此 Rolldown 必须确保该模块的代码被包含在最终的打包产物中。

由于打包工具无法通过编程方式判断副作用是否 “有益”,只能保守地保留所有被标记为含副作用的模块。识别这类模块的流程与计算 TLA 类似:该属性同样具有传染性,若一个模块存在副作用,所有直接 / 间接导入它的模块都会被认定为受影响。其底层算法也基本一致:通过递归的深度优先搜索(DFS)检查每个 Module 上的 side_effects() 信息,并借助记忆化处理避免重复检查。

至此,我们的模块图已标注了顶层 await、副作用等宏观属性,但这仍不够。在精准链接符号之前,我们必须先解决一个问题:模块可能使用不同的模块系统(如 CJS 和 ESM),它们需要一套统一的交互协议 —— 这正是我们下一步要实现的目标。

标准化处理与模块交互协议

尽管扫描阶段已梳理出模块依赖图,但该阶段仅捕获了 “文件级” 的依赖关系。这份原始依赖图并未考虑一个关键问题:模块可能采用不同的模块系统(如 CommonJS(CJS)和 ES 模块(ESM)),而这些系统本身并不具备互操作性。为解决这一问题,在执行更深层的链接逻辑前,必须先完成标准化处理流程。

该标准化处理在链接阶段执行:Rolldown 遍历模块依赖图,为每个模块计算所需的标准化信息,并将其记录到对应的 LinkingMetadata 结构体中。正如我们此前所述,该结构体存储在 LinkStageOutputmetas 字段内。

模块系统与包装器(Wrapper)

现代 JavaScript 生态中主流的模块系统有两种:CommonJS(CJS)和 ES 模块(ESM)。

  • CommonJS(CJS) :主要应用于 Node.js 生态,是一种同步模块系统。依赖通过阻塞式的 require() 调用加载,模块则通过向 module.exportsexports 对象赋值的方式暴露自身 API。
  • ES 模块(ESM) :ECMAScript 推出的官方标准,同时适配浏览器和 Node.js 环境。其静态结构(使用 importexport 语句)专为编译期分析设计,而浏览器端的加载机制本身是异步、非阻塞的。

这两种系统常出现在同一代码库中 —— 尤其是现代基于 ESM 的项目依赖仅提供 CJS 分发包的老旧第三方库时。为处理这种混合使用场景,Rolldown 会判定模块是否需要 “包装器(Wrapper)”。尽管具体算法将在后续详述,但其核心思路十分简单:包装器是一个函数闭包,能够模拟特定的模块运行环境,从而让不兼容的模块系统实现交互。

以下简化示例阐释了这一核心思想:

// Code storage
const __modules = {
  './utils.js': exports => {
    exports.add = (a, b) => a + b
  },
  './data.js': (exports, module) => {
    module.exports = { value: 42 }
  },
}

// Runtime
function __require(moduleId) {
  const module = { exports: {} }
  __modules[moduleId](module.exports, module)

  return module.exports
}

尽管实际生成的代码更为复杂,但其核心原理始终不变:将模块代码包裹在函数中,以在运行时为其提供所需的执行环境。我们示例项目中的 legacy-formatter.cjs 正是如此 ——Rolldown 检测到这个 CJS 文件被一个 ESM 模块(helpers.js)导入,因此会对其进行对应的包装处理(使用 WrapKind::Cjs 类型的包装器)。该包装器模拟了 module.exports 执行环境,确保不同模块系统间实现无缝互操作。你可以查看 basic/dist 目录下的打包产物,直观看到这一处理逻辑的实际效果。

模块类型判定与包装器选择

为确保 CJS 与 ESM 之间的无缝互操作,Rolldown 必须为每个模块选择合适的包装器。这一决策不仅取决于模块自身的格式,还与其他模块导入该模块的方式相关。

首先,在扫描阶段,Rolldown 会根据模块的语法特征识别出每个模块的 ExportsKind(导出类型),并将其存储在该模块的 EcmaView 中:

pub struct EcmaView {
  pub exports_kind: ExportsKind,
  // ...
}

接下来,Rolldown 会考量导入方模块所使用的 ImportKind(导入类型)。该枚举类型涵盖了 JavaScript 中引用其他文件的所有方式:

pub enum ImportKind {
  /// import foo from 'foo'
  Import,
  /// `import('foo')`
  DynamicImport,
  /// `require('foo')`
  Require,
  // ... (other kinds like AtImport, UrlImport, etc.)
}

pub enum ExportsKind {
  Esm,
  CommonJs,
  None,
}

核心逻辑在于导入方的 ImportKind(导入类型)与被导入方的 ExportsKind(导出类型)的组合判定。二者的匹配关系决定了被导入方所需的 WrapKind(包装器类型)。例如,当一个模块通过 require() 方式加载(对应 ImportKind::Require 类型)时,其 WrapKind 由自身的 ExportsKind 决定。这一逻辑确保了被导入模块在运行时能获得适配的执行环境。

// ...
ImportKind::Require => match importee.exports_kind {
  ExportsKind::Esm => {
    self.metas[importee.idx].wrap_kind = WrapKind::Esm;
  }
  ExportsKind::CommonJs => {
    self.metas[importee.idx].wrap_kind = WrapKind::Cjs;
  }
}
// ...

递归应用包装器

在确定了不同交互场景下所需的 WrapKind(包装器类型)后,wrap_modules 函数会遍历模块依赖图,应用这些包装器并处理相关的复杂逻辑。

其中一个核心难点是 CommonJS 模块中的 “星号导出”(export * from './dep')。由于 CJS 模块的完整导出列表无法在编译期确定,这类导出会被视为动态导出,需要特殊处理。

此外,包装过程本身是递归的:当一个模块需要包装器时(例如被 ESM 模块导入的 CJS 模块),仅包装该单个模块是不够的 —— 包装器可能引入新的异步行为。因此 Rolldown 必须递归向上遍历整个导入链,确保所有依赖这个新包装模块的模块都被正确处理。这种递归传播机制能保证所有依赖在运行时就绪,并维持正确的执行顺序。

整合所有环节:符号溯源

在完成依赖图基础属性的标注、模块格式的标准化后,我们终于来到链接阶段的核心任务:处理导入的符号。最终目标是将每个符号追溯到其唯一、明确的原始定义。

以示例项目中的场景为例:main.jshelpers.js 导入名为 source 的符号,而 helpers.js 又会将 api.js 中的所有内容重新导出。打包工具如何确认 main.js 中使用的 source,就是 api.js 中定义的那个完全相同的变量?

这本质上是一个 “等价性判定” 问题。为高效解决该问题,Rolldown 采用了并查集(Disjoint Set Union,DSU) 数据结构 —— 这是一种专为这类等价性问题设计的算法。在该模型中,每个符号引用都被视为一个元素,核心目标是将所有指向同一原始定义的引用归并到同一个集合中。

并查集(DSU)

并查集(Disjoint Set Union,DSU)也被称为 “联合 - 查找(union-find)” 数据结构,是一种高效的数据结构:每个集合以树的形式表示,树的根节点作为该集合的 “标准代表元”。并查集主要支持两种操作:

  • 查找(Find) :确定某个元素所属集合的标准代表元;
  • 合并(Union) :将两个不相交的集合合并为一个集合。

经典的并查集实现会使用一个简单的数组(我们称之为 parent),其中 parent[i] 存储元素 i 的父节点。若 parent[i] == i,则 i 是其所在树的根节点。以下伪代码展示了一个未做优化的基础实现:

parent = []

def find(x):
return x if parent[x] == x else find(parent[x])

def union(x, y):
root_x = find(x)
root_y = find(y)
if root_x != root_y:
# Link the root of x's tree to the root of y's tree
parent[root_x] = root_y

Rolldown 沿用了这一核心思路,但实现方式更健壮且具备类型安全性。它并未使用原生数组,而是采用 IndexVec—— 一种类向量结构,通过 SymbolId 这类带类型的 ID 进行索引。父指针的作用则由 SymbolRefDataClassic 结构体中的 link 字段来实现,如下图所示。

classDiagram
note for ModuleIdx "Typed Index of Module"
class ModuleIdx {
  TypeAlias for u32
}
class SymbolRefDb {
  +inner: IndexVec< ModuleIdx, Option~SymbolRefDbForModule~>
  link(&mut self, base: SymbolRef, target: SymbolRef)
  find_mut(&mut self, target: SymbolRef) : SymbolRef
}
ModuleIdx -- SymbolRefDb : used_as_index_for
class SymbolRefDbForModule {
  owner_idx: ModuleIdx
  root_scope_id: ScopeId
  +ast_scopes: AstScopes
  +flags: FxHashMap< SymbolId, SymbolRefFlags>
  +classic_data: IndexVec< SymbolId, SymbolRefDataClassic>
  create_facade_root_symbol_ref(&mut self, name: &str) : SymbolRef
  get_classic_data(&self, symbol_id: SymbolId) : &SymbolRefDataClassic
}
SymbolRefDb "1" o-- "0..*" SymbolRefDbForModule : contains
class SymbolRefDataClassic {
  +namespace_alias: Option~NamespaceAlias~
  +link: Option~SymbolRef~
  +chunk_id: Option~ChunkIdx~
}
SymbolRefDbForModule "1" o-- "0..*" SymbolRefDataClassic : contains

如图所示,每个符号的等价性信息都存储在 SymbolRefDataClassic 结构体中。可选的 link 字段指向其父符号 —— 这一点与经典并查集实现中的 parent 数组完全对应。

Rolldown 将并查集的两个核心操作实现为 find_mutlink 方法。

find_mut 方法(带路径压缩的查找操作)

Rolldown 的 find_mut 方法不仅能找到根节点,还会执行一项关键优化:路径压缩(path compression)

pub fn find_mut(&mut self, target: SymbolRef) -> SymbolRef {
  let mut canonical = target;
  while let Some(parent) = self.get_mut(canonical).link {
    // Path compression: Point the current node to its grandparent
    self.get_mut(canonical).link = self.get_mut(parent).link;
    canonical = parent;
  }
  canonical
}

当 while 循环沿着树结构向上遍历至根节点(即 link 字段为 None 的元素)时,会将遍历过程中访问到的每个节点重新关联,使其直接指向自身的祖父节点(self.get_mut(parent).link)。这一操作能高效地 “扁平化” 树结构,大幅提升后续对该路径上任意节点执行查找(find)操作的速度。最终返回的 “标准符号” 即为该集合的根节点代表元。

link 方法(合并操作)

link 方法实现了并查集的合并(union)操作。

/// Make `base` point to `target`
pub fn link(&mut self, base: SymbolRef, target: SymbolRef) {
  let base_root = self.find_mut(base);
  let target_root = self.find_mut(target);
  if base_root == target_root {
    // Already linked
    return;
  }
  self.get_mut(base_root).link = Some(target_root);
}

该方法首先找到基准符号(base)目标符号(target) 各自的根节点代表元。若二者根节点相同,说明这些符号已属于同一集合,无需执行任何操作;反之,则通过将基准符号根节点的 link 字段指向目标符号的根节点,完成两个集合的合并。

绑定导入与导出(Bind imports and exports)

符号解析流程始于 bind_imports_and_exports 函数。初始步骤是遍历所有模块,提取其中的显式命名导出;这些导出信息会被存储在一个哈希映射(hash map)中 —— 键为导出的字符串名称,值则是 ResolvedExport 结构体实例。

pub struct ResolvedExport {
  pub symbol_ref: SymbolRef,
  pub potentially_ambiguous_symbol_refs: Option<Vec<SymbolRef>>,
}

但这一流程会因 ES 模块的星号导出(export * from './dep' 变得复杂 —— 该语法会将另一个模块的所有命名导出重新导出。我们示例中的 helpers.js 就使用了这一语法:export * from './api.js'

星号导出可能引入歧义,必须在最终链接前解决。因此,对于任何包含星号导出的模块,Rolldown 都会调用一个专用函数 add_exports_for_export_star。该函数通过递归深度优先搜索(DFS) 遍历星号导出的依赖图;为检测循环依赖并管理导出优先级,它采用经典的回溯模式维护一个 module_stack(模块栈):递归调用前将模块 ID 压入栈中,递归返回后再将其弹出。

这一递归遍历主要承担两项核心职责:

  • 遮蔽(Shadowing) :模块内的显式命名导出始终拥有最高优先级,会 “遮蔽” 所有通过星号导出从深层依赖导入的同名导出。module_stack 可根据导入链中的 “就近原则” 判定这种优先级关系。
  • 歧义检测:当一个模块试图从多个 “优先级相同” 的不同来源导出同名符号时(例如通过两个不同的星号导出:export * from 'a'export * from 'b'),就会产生歧义。若一个新引入的星号导出符号与已存在的符号同名、且未被遮蔽,则会被记录到 potentially_ambiguous_symbol_refs 字段中,留待后续解析。

在整个过程中,该函数会操作一个由调用方传入的、可变的 resolve_exports 哈希表(FxHashMap 类型),逐步构建出该模块完整的已解析导出集合。

匹配导入与导出(Match imports with exports)

完成所有模块导出的解析后,下一步是将每个导入项匹配到对应的导出项。这一完整流程由封装在 BindImportsAndExportsContext 中的数据和结构体统一管理。

struct BindImportsAndExportsContext<'a> {
  pub index_modules: &'a IndexModules,
  pub metas: &'a mut LinkingMetadataVec,
  pub symbol_db: &'a mut SymbolRefDb,
  pub external_import_binding_merger:
    FxHashMap<ModuleIdx, FxHashMap<CompactStr, IndexSet<SymbolRef>>>,
  // ... fields omitted for brevity
}

这一环节的最终目标是填充 symbol_db(符号数据库)—— 借助并查集(DSU)逻辑,将每个导入符号关联到其真正的定义源头。具体流程为:遍历所有 NormalModule(普通模块),并对模块中的每一个命名导入项(每个导入项由 NamedImport 结构体表示,例如 import { foo } from 'foo' 这类语法)执行匹配函数。

但在关联内部符号之前,外部导入项会先经过一套特殊的预处理流程。当某个导入项来自外部模块(例如 import react from 'react' 中的 react)时,并不会立即解析该导入,而是将其收集起来,并归类到 external_import_binding_merger(外部导入绑定合并器)中。

该数据结构是一个嵌套哈希映射,其设计目的是聚合所有 “引用同一外部模块中同名导出” 的导入项。

classDiagram
class ExternalImportBindingMerger {
  +FxHashMapᐸModuleIdx, ModuleExportsᐳ
}
class ModuleExports {
  +FxHashMapᐸCompactStr, SymbolSetᐳ
}
ExternalImportBindingMerger o-- ModuleExports : uses as value
class ModuleIdx
ExternalImportBindingMerger o-- ModuleIdx : uses as key
class SymbolSet {
  +IndexSetᐸSymbolRefᐳ
}
ModuleExports o-- SymbolSet : uses as value
class CompactStr
ModuleExports o-- CompactStr : uses as key
class SymbolRef
SymbolSet "1" o-- "0..*" SymbolRef : contains

我们以示例项目中的 main.js 文件为例来具体说明:

// ...
// (2) Import from external dependencies, this will be handled by external_import_binding_merger
import { useState } from 'react'

// ...

由于 react 是外部模块,Rolldown 会更新 external_import_binding_merger(外部导入绑定合并器)。假设 react 对应的模块索引(ModuleIdx)为 react_module_idx,最终生成的数据结构如下所示:

graph TD
param1["external_import_binding_merger\n (FxHashMap)"]
param2["FxHashMapᐸCompactStr,\n IndexSetᐸSymbolRefᐳᐳ"]
param3["IndexSetᐸSymbolRefᐳ:\n {sym_useState_main}"]
param1 -->|key: react_module_idx| param2
param2 -->|"key: 'useState' (CompactStr)"| param3

若另有一个文件(例如 featureB.js)也从 react 导入 useState,则其对应的 SymbolRef(符号引用)会被添加到同一个 IndexSet 集合中。这也是该结构被恰如其分地命名为 “合并器(merger)” 的原因:它将指向同一个外部符号(react.useState)的所有本地引用汇总到一处。这种聚合方式支持后续的统一处理,确保所有对 useState 的引用最终都指向唯一、统一的外部符号。

遍历完所有模块及其导入项后,Rolldown 会迭代这个已完全填充的合并器映射表(merger map),完成所有外部符号的绑定操作。

追溯导入项的定义源头

符号解析的核心执行函数是递归函数 match_import_with_export。该函数的使命是:根据 ImportTracker(导入追踪器)描述的单个导入项,一路追溯到其原始定义。

struct ImportTracker {
  pub importer: ModuleIdx,      // The module performing the import.
  pub importee: ModuleIdx,      // The module being imported from.
  pub imported: Specifier,      // The name of the imported symbol (e.g., "useState").
  pub imported_as: SymbolRef,   // The local SymbolRef for the import in the importer module.
}

该函数的返回值 MatchImportKind(导入匹配类型)会封装本次追溯的结果。整个解析流程可拆解为三个阶段:

阶段 1:循环检测与初始状态判定

该函数采用带循环检测的递归深度优先搜索(DFS) 实现。MatchingContext(匹配上下文)会维护一个 “追踪器栈(tracker stack)”,用于检测同一导入方模块是否试图解析 “正在处理中的、同名的 imported_as 符号引用”。若检测到这种情况,则无需继续执行,直接返回 MatchImportKind::Cycle(循环)即可。

接下来,一个辅助函数 advance_import_tracker 会对直接被导入方(direct importee) 执行快速的非递归分析,检查简单场景并返回初始状态:

  • 若被导入方是外部模块,返回 ImportStatus::External(外部模块);
  • 若被导入方是 CommonJS 模块,返回 ImportStatus::CommonJS(CJS 模块);
  • 若该导入是星号导入(import * as ns),判定为 ImportStatus::Found(已找到);
  • 对于 ES 模块的命名导入,会检查直接被导入方的 “已解析导出集合”:若找到匹配的导出项,返回 ImportStatus::Found;否则返回 ImportStatus::NoMatch(无匹配)或 ImportStatus::DynamicFallback(动态降级)。

阶段 2:重新导出链遍历

真正的复杂度在于重新导出链(re-export chain) 的遍历。当返回 ImportStatus::Found 时,函数会进一步检查:找到的这个符号本身是否是从另一个模块导入的:

let owner = &index_modules[symbol.owner];
if let Some(another_named_import) = owner.as_normal().unwrap().named_imports.get(&symbol) {
  // This symbol is re-exported from another module
  // Update tracker and continue the loop to follow the chain
  tracker.importee = importee.idx;
  tracker.importer = owner.idx();
  tracker.imported = another_named_import.imported.clone();
  tracker.imported_as = another_named_import.imported_as;
  reexports.push(another_named_import.imported_as);
  continue;
}

这一过程会以迭代方式持续进行,同时构建用于副作用依赖追踪的重新导出链(reexports chain),直至追溯到符号的原始定义为止。

阶段 3:歧义消解与后置处理

在阶段 2 中,若某个导出项包含 potentially_ambiguous_export_star_refs(由 export * 语句导致的潜在歧义星号导出引用),函数会递归解析每一条歧义路径。收集到所有 ambiguous_results(歧义结果)后,函数会将其与主结果对比:若存在任何不一致,便返回 MatchImportKind::Ambiguous(存在歧义)。

而针对 NoMatch(无匹配)的结果,函数会检查垫片(shimming)功能是否启用(对应配置项 options.shim_missing_exports 或空模块场景)。垫片可为遗留代码提供兼容性降级方案:

let shimmed_symbol_ref = self.metas[tracker.importee]
  .shimmed_missing_exports
  .entry(imported.clone())
  .or_insert_with(|| {
    self.symbol_db.create_facade_root_symbol_ref(tracker.importee, imported.as_str())
  });

完成绑定操作(Finalizing bindings)

在针对所有内部导入项的核心匹配逻辑执行完毕后,Rolldown 会执行两项最终的批量处理步骤。

1. 合并外部导入项(Merging external imports)

如前文所述,所有来自外部模块的导入项会先被收集到 external_import_binding_merger(外部导入绑定合并器)中。现在,Rolldown 会处理这个映射表:对于每个外部模块及其命名导出(例如 react 中的 useState),Rolldown 会创建一个单一的门面符号(facade symbol) ;随后遍历所有导入了 useState 的本地符号集合(来自 featureA.jsfeatureB.js 等文件),并通过并查集(DSU)的 link 操作将这些本地符号全部合并,使其均指向这个唯一的门面符号。这一操作确保了对同一外部实体的所有导入项都被视为一个整体。

2. 处理歧义导出(Addressing ambiguous exports)

星号导出可能导致真正的歧义。请看以下场景:

// moduleA.js
export const foo = 1;

// moduleB.js
export const foo = 2;

// main.js
export * from './moduleA'; // Exports a `foo`
export * from './moduleB'; // Also exports a `foo`

Rolldown 采取保守策略:若某个导出名称对应多个不同的原始定义,该名称会被直接忽略,且不会被纳入模块的公共 API 中。这一设计能避免运行时出现不稳定或不可预测的行为。

但并非所有潜在冲突都会导致真正的歧义。在我们的示例项目中,main.jshelpers.js 导入 source 符号时,虽会沿重新导出链(export * from './api.js')追溯,但由于 source 仅有唯一的原始定义,match_import_with_export 函数能无冲突地完成解析。

链接阶段的输出结果

链接阶段会将扫描阶段生成的 “基础文件级依赖图”,转化为一个信息丰富、可深度解析的结构化数据。最终输出结果被封装在 LinkStageOutput 结构体中:

pub struct LinkStageOutput {
  pub module_table: ModuleTable,
  pub metas: LinkingMetadataVec,
  pub symbol_db: SymbolRefDb,
  // ... fields omitted for clarity
}

该结构体既包含原始的 ModuleTable(模块表),更重要的是,还包含链接阶段生成的全新产物。其中两个核心产物如下:

  1. LinkingMetadataVec:一个按 ModuleIdx(模块索引)索引的向量,存储每个模块对应的 LinkingMetadata(链接元数据)。它包含已解析的模块级信息 —— 例如最终的导出映射表(resolved_exports)、以及图遍历结果(如 is_tla_or_contains_tla_dependency 标记,即 “是否含顶层 await 或依赖含顶层 await 的模块”)。该向量为后续阶段提供了对每个模块属性和关联关系的语义级理解
  2. SymbolRefDb:符号关联关系数据库。它基于并查集(DSU)结构维护所有内部符号的等价类,借助这个数据库,可通过 find_mut 方法将任意导入符号追溯到其唯一的原始定义。

本质上,链接阶段是对模块依赖图的一次高效优化与解析过程。阶段结束时,所有模块和符号均已完全解析,且所有歧义都已消除。这为后续的代码生成、摇树优化(Tree Shaking)和代码分割阶段奠定了稳定、可预测的基础 —— 而这正是这些阶段能够正确且高效执行的关键。

总结

链接阶段是一个复杂的处理流程,它将扫描阶段生成的基础依赖图转化为一份完全解析、无歧义的符号映射表。我们详细梳理了其核心逻辑:如何系统性地遍历依赖图,传播 “顶层 await(TLA)”“副作用” 等属性;如何标准化不同的模块格式以确保互操作性。该阶段的核心支撑是一系列高效的数据结构(如 IndexVecFxHashMap)和强大的算法(深度优先搜索、并查集)。正是这些精心选择的数据结构与算法的组合,构成了 Rolldown 卓越性能的底层基石。

希望本次深度解析能帮助你扎实理解链接阶段的原理,并建立起对其内部工作机制的清晰认知。若你发现任何错误或有改进建议,欢迎在下方留言 —— 你的反馈至关重要!

在下一篇文章中,我们将探索打包流程的最后一个阶段:代码生成。敬请期待!

【翻译】React编译器及其原理:为何类对象可能阻碍备忘录法生效

2026年2月12日 17:10

原文链接:anita-app.com/blog/articl…

作者:ilDon

本文反映了作者的个人观点与思考。由于作者并非英语母语者,最终表述经人工智能编辑以确保清晰度与准确性。

React编译器现已稳定并可投入生产环境(React博客,2025年10月7日),它显著减少了手动使用useMemouseCallbackReact.memo的需求。

这对大多数 React 代码库而言是重大利好,尤其适用于采用纯净函数组件和不可变数据的架构。但存在一种模式正变得日益棘手:依赖类实例计算衍生值的类密集型对象模型。

若渲染时逻辑依赖类实例,编译器备忘录机制的精确度可能无法满足需求,开发者往往不得不重新引入手动备忘录机制以恢复控制权。

React编译器通过可观察依赖关系进行优化

官方文档说明React编译器会基于静态分析和启发式算法自动对组件和值进行备忘存储:

关键细节在于:备忘存储仍取决于React能观察到的输入内容。

在 React 中,对象的备忘比较基于引用(采用 Object.is 的语义)。memouseMemo 的文档都明确说明了这一点:

因此,如果有效值隐藏在对象实例内部,而该实例引用发生变化,React 就会认为值也发生了变化。

ElementClass 示例

假设你将元素建模如下:

class ElementClass {
  constructor(private readonly isoDate: string) {}

  public getFormattedDate(): string {
    const date = new Date(this.isoDate);

    if (Number.isNaN(date.getTime())) {
      return 'Invalid date';
    }

    return date.toLocaleString('en-US', {
      year: 'numeric',
      month: 'short',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit',
      timeZoneName: 'short',
    });
  }
}

而在一个组件中:

export function Row({ elementInstance }: { elementInstance: ElementClass }) {
  const formattedDate = elementInstance.getFormattedDate();
  return <span>{formattedDate}</span>;
}

这段代码是可读的。但从外部来看,相关的响应式输入实际上是 elementInstance(对象引用)。

如果状态管理层返回了一个新的 ElementClass 实例,React/编译器会检测到新的依赖关系,并重新计算格式化后的值——即使底层的 isoDate 字符串并未改变。

手动逃生舱门功能正常,但噪音较大

你可以强制使用更窄的依赖项:

class ElementClass {
  constructor(public readonly isoDate: string) {} // <-- expose isoDate as a public property
  // unchanged
}

export function Row({ elementInstance }: { elementInstance: ElementClass }) {
  const formattedDate = useMemo(
    () => elementInstance.getFormattedDate(),
    [elementInstance.isoDate],
  );

  return <span>{formattedDate}</span>;
}

这确实可行,React 明确将 useMemo/useCallback 作为编译器环境下的逃生通道:

但此时我们又陷入了手动处理依赖关系的困境,还不得不将内部逻辑暴露给 UI。

编译器友好的替代方案:纯数据 + 纯辅助函数

若 UI 接收纯粹的不可变数据,依赖关系将变得显式且低成本:

type Element = {
  isoDate: string;
};

export function Row({ element }: { element: Element }) {
  const formattedDate = DateHelpers.formatDate(element.isoDate);
  return <span>{formattedDate}</span>;
}

现在,DateHelpers.formatDate 的相关输入是一个基本类型(isoDate),而非隐藏在类实例方法调用背后的状态。这样,编译器就能将formatDate的输出进行备忘存储,仅将 isoDate 作为唯一依赖项——这个基本值在发生变化时会正确触发备忘存储机制。

有人可能会提出异议:即便在这个简单的对象示例中,整个element仍会被传递给组件。因此Row组件终究会重新渲染,唯一实质区别在于formattedDate不再被重新计算。

这种说法没错:若传递整个对象且其引用发生变化,该组件就会重新渲染。我们稍后将详细探讨这个问题。

在探讨该问题的解决方案之前,我想强调:对于大型应用而言,即使仅考虑派生值的备忘录化,类实例与普通数据之间的差异依然显著。React编译器会注入备忘录单元和依赖项检查。若依赖项是不稳定的对象引用,缓存命中率将很低:

  • 你仍需为备忘录槽位支付额外内存成本,
  • 仍需执行依赖项检查,
  • 仍需因引用变更而重新计算。

换言之,当渲染路径中充斥着类实例且未进行手动备忘时,编译器的优化往往会变成额外开销而非性能提升

现在,让我们回到传递整个对象的问题。若传递对象后其引用发生变化,组件将重新渲染。无论对象是类实例还是普通对象,此特性均成立。若需避免因对象引用变更导致的冗余渲染,可仅传递子组件实际需要的原始值,而非完整对象。如此,组件仅在相关原始值变更时重新渲染,而非对象引用变更时:

export function Row({ isoDate }: { isoDate: string }) {
  const formattedDate = DateHelpers.formatIsoDate(isoDate);
  return <span>{formattedDate}</span>;
}

现在依赖关系已显式化且采用原始类型(isoDate),而非隐藏在实例方法背后。

可能的反对意见是:即使采用面向对象的方法,仍可将element.getFormattedDate()的结果传递给子组件,而该结果本质上仍是字符串:

function Parent({ element }: { element: ElementClass }) {
  return <Row formattedDate={element.getFormattedDate()} />;
}

function Row({ formattedDate }: { formattedDate: string }) {
  return <span>{formattedDate}</span>;
}

Row 组件现在接收原始属性,但耗时或重复的计算只是向上移了一层,转移到了 Parent 组件中。

如果 element 组件频繁通过引用发生变化,element.getFormattedDate() 方法仍会频繁重新执行。因此瓶颈并未消除,只是转移了位置。

采用数据优先的架构后,你可以直接跨边界传递 isoDate 数据,并将衍生计算作为纯函数保留在需求附近。

这更契合 React 的纯粹性与不可变性模型:

实用经验法则

在 React 渲染路径中,优先采用数据优先模型而非行为丰富的类实例。

仅在边界处使用类(如领域模型、解析器、适配器),但向组件传递可序列化的纯数据,并将渲染时推导保持为纯函数。

借助 React Compiler,这通常能带来:

  1. 更高的自动备忘录命中率
  2. 更少的手动 useMemo 逃逸机制
  3. 更清晰的依赖推理
  4. 更少因对象身份变化导致的意外重计算

React Compiler 消除了大量优化工作,但仍会奖励依赖关系明确的代码。在现代 React 的 UI 渲染中,普通对象加纯辅助函数往往是更具可扩展性的选择。

❌
❌