• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

初探 Turbo Engine 的构建理念

武飞扬头像
Hai
帮助5

简述

来源: webpack 作者 Tobias Koppers 在 JS Nation JSNation 2023 上的 coding live 作品 The Core of Turbopack Explained (Live Coding)

目的:解决 webpack 因大量查找缓存、验证失效缓存导致的开销过大问题

本文章通过模拟一个最简构建过程来初探 Turbo Engine 的一些原理。

如何工作

Turbo Engine 会将某些功能标记为To be remember。当这些函数被调用时,它会记住它们被调用的内容以及它们返回的内容。然后它将其保存在缓存中。

一个简单的 bundle 过程可以描述为读取源码,对源码进行构建,拼接构建产物形成最终产物 如下所示

学新通 图出自 Turbopack 官网

修改 sdk.ts 的代码时,我们需要再次构建,再次拼接产物。此时,对于 api.ts 文件并未改动,我们将从缓存中读取 api.ts 的内容再拼接即可。从而减少了对于 api.ts 的读取与构建产生的开销。

学新通 图出自 Turbopack 官网

对于一个具有成千上百个文件的巨应用,构建工具需要对每个文件进行路径解析、文件读取、源码构建。所有巧用缓存(记住每次函数的调用结果)将可节省大量开销。

这种解决方法能让 Turbopack 以极快的速度进行应用程序的增量构建,当文件改动,devServer 将快速接管并进行构建响应。

关于缓存

Turbo Engine 将这些内存缓存在运行内存中,而不是在硬盘内存中,这意味着,当启动 devServer 时,构建缓存将一直存在。直到关闭/停止 devServer 时,这些内存将被回收。

原理解释

最简构建过程

  1. 读取入口文件源码、读取被拼接的模块 Header (/*_ @copyright the-core-of-turbopack-explained _/)
  2. 拼接构建模块
  3. 输出构建产物
  4. 解析入口文件的依赖

第 1,2,3 步我们统称为 copy 构建阶段,而后执行第 4 步 getImporter 构建阶段,如发现存在依赖文件,则递归执行此构建过程

const main = task("main", () => {
  const baseDir = path.join(process.cwd(), "demo");
  const outDir = path.join(process.cwd(), "demo", "dist");
  const entry = path.join(baseDir, "/index.js");
  const header = path.join(process.cwd(), "demo", "header.js");
  copyGraph(baseDir, outDir, entry, header);
  log.enable && printCentered(`🔥🔥 首次构建完成 🔥🔥`);
});

// 构建主流程
const copyGraph = task("copyGraph", (baseDir, outDir, entry, header) => {
  const relPath = path.relative(baseDir, entry);
  const output = path.join(outDir, relPath);
  copy(output, header, entry);
  const importers = getImporter(entry);
  if (importers?.length > 0)
    for (const importer of importers) {
      copyGraph(baseDir, outDir, importer, header);
    }
});

// 解析依赖文件的路径
const getImporter = task("getImporter", (entry) => {
  const ast = parse(read(entry), {
    sourceType: "module",
    ecmaVersion: "latest",
  });
  return (
    ast.body
      .filter((node) => node.type === "ImportDeclaration")
      .map((node) => node.source.value)
      .filter((value) => {
        return value.startsWith("./");
      })
      .map((value) => path.join(path.dirname(entry), value)) ?? []
  );
});

// 输出构建产物
const copy = task("copy", (output, header, entry) => {
  write(output, read(header)   read(entry));
});

每个构建阶段都使用了 task 函数进行包裹,该函数的主要目的是对「相同入参与函数名」函数的执行结果进行缓存。

const taskCache = new TupleMap();
export let currentTask = null;

export const task = (name, f) => {
  return (...args) => {
    let task = taskCache.get([name, ...args]) ?? null;
    if (!task) {
      task = {
        name,
        args,
        f,
        result: undefined,
        dependentTasks: new Set(),
      };
      task.result = wrapCurrentTask(task, () => logged(name, f, args));
      taskCache.set([name, ...args], task);
    }
    currentTask && task.dependentTasks.add(currentTask);
    return task.result;
  };
};

关键函数与变量

logged 函数仅做日志打点用,在此不做过多解释

currentTask 变量用来记录当前所处哪个构建阶段,以方便记录各个构建阶段之间的依赖关系。

currentTask 变量记录还依赖 withCurrentTask 方法,具体操作为: 当前构建阶段的函数执行完毕后,会还原 currentTask 为其父级构建阶段,然后通过 task.dependentTasks.add(currentTask) 来记录当前构建阶段的父级构建阶段。

// wrap current task to be able to track dependent tasks
const wrapCurrentTask = (task, f) => {
  const oldTask = currentTask;
  currentTask = task;
  try {
    return f();
  } finally {
    currentTask = oldTask;
  }
};

依据此,即可构建出一个完整的 taskCache,如下图所示

学新通

缓存节点属性解释

  • name: 构建阶段名,Map 的 key 之一
  • f: 构建执行函数
  • args: 执行函数的入参,Map 的 key 之一
  • dependentTasks: 代表该构建阶段函数的父级集合

现在我们已实现第一个功能,即官网中提及的 Function-level caching

  1. 对于重复路径的文件读取,我们可以直接从缓存中读取,而不用再次读取硬盘文件。
  2. 对于重复路径的依赖文件解析,我们可以直接从缓存中读取,而不用再次使用 parser 进行依赖解析。
  3. ...

但是,我们还需要解决一个问题,那就是如何判断文件是否改动,以及文件改动后,让缓存失效。

思考

  • 如何判断文件改动:我们简单通过 fs 模块的 watch 函数进行文件监听。
  • 如何使缓存失效:当文件改动后,再次进行文件读取 read 工作,并且执行冒泡操作,即递归执行父级构建阶段的函数,getImporter, write...,同时进行缓存的更新。

如何实现

先创建一个缓存失效函数,目的是重新执行 read 函数阶段,使缓存失效,更新 task.result,如果 task.result 发生变化,则递归执行父级构建阶段的函数。

export const invalidate = (task) => {
  const oldTaskResult = task.result;
  // recalculate task
  logged(
    task.name,
    () => {
      task.result = wrapCurrentTask(task, () => {
        return task.f(...task.args);
      });
    },
    task.args
  );
  // invalidate dependent tasks
  if (JSON.stringify(oldTaskResult) !== JSON.stringify(task.result)) {
    if (task.dependentTasks.size > 0) {
      for (const dependentTask of task.dependentTasks) {
        invalidate(dependentTask);
      }
    }
  } else {
    log.enable && printCentered(`🔥🔥 ${task.name} no change 🔥🔥`);
  }
};

currentTask 作为入参传入 invalidate 缓存失效函数中,即可明确 该入口文件 & 该构建阶段 的父级构建链,从而实现冒泡/增量构建中的一环。

export const getInvalidator = () => {
  if (!currentTask) log.enable && console.log(">>>>>no task");
  // need to cache by another variable because currentTask is changing
  const task = currentTask;
  return () => invalidate(task);
};

下一步,让缓存失效函数与文件监听函数进行关联,当文件改动时,执行缓存失效函数。

借助『以 filepath 作为 key 的缓存 invalidatorCache 』来记录每个文件的 read 构建阶段的缓存失效函数。文件改动后,通过 invalidatorCache 找到对应的缓存失效函数,执行缓存失效函数来实现目标。

export const read = task("read", (entry) => {
  const invalidator = getInvalidator();
  // 注册当前文件的缓存失效函数
  invalidatorCache.set(entry, invalidator);
  return readFileSync(entry, "utf8", (err) => {
    console.error(">>>>>>>read error", err);
  });
});
watch(process.cwd(), { recursive: true }, (eventType, filename) => {
  if (!filename) return;
  filename = resolve(filename);
  // 找到对应的缓存失效函数
  const invalidator = invalidatorCache.get(filename);
  invalidator &&
    setTimeout(() => {
      invalidatorCache.delete(filename);
      log.enable && console.time("file change");
      // 执行缓存失效函数
      invalidator();
      log.enable && console.timeEnd("file change");
    }, 100);
});

通过以上几个函数,我们即可做到 文件改动 => 使缓存失效 => 增量构建。

下面我们以一个 demo 来解释一下全链路

// test/index.js
import { foo as foo1 } from "./1.js";
import { foo as foo2 } from "./2.js";
import { foo as foo3 } from "./3.js";
import { foo as foo4 } from "./4.js";
import { foo as foo5 } from "./5.js";
import { foo as foo6 } from "./6.js";
import { foo as foo7 } from "./7.js";
import { foo as foo8 } from "./8.js";
import { foo as foo9 } from "./9.js";
import { foo as foo10 } from "./10.js";
import { foo as foo11 } from "./11.js";
import { foo as foo12 } from "./12.js";

第一次执行构建过程,流程如下 学新通

./1.js 文件改动时,执行如下

  • 重读取 ./1.js 文件,发现 task.result 与之前结果不一致,更新缓存,进行冒泡,执行父级构建链
  • 执行 copy 构建阶段,writeFile 输出构建结果,task.result 为 undefined,与之前结果一致,结束执行
  • 执行 getImporter 构建阶段,task.result 为 [] 空数组,JSON.stringify 后,与之前结果一致,结束执行

index.js 文件改动,如新增依赖 import 语句

import { foo as foo14 } from "./14.js"`
  • 重读取 ./index.js 文件,发现 task.result 与之前结果不一致,更新缓存,进行冒泡,执行父级构建链
  • 执行 copy 构建阶段,writeFile 输出构建结果,task.result 为 undefined,与之前结果一致,结束执行。
  • 执行 getImporter 构建阶段,task.result 数组新增一项,与之前结果不一致,进行冒泡。
  • 执行 ./index.jscopyGraph 构建阶段,./1.js./13.js 的构建阶段皆被缓存,不再执行。
  • 执行 ./14.jscopyGraph 构建阶段,未命中缓存,执行 copygetImporter 构建阶段,并进行 task 缓存。
  • ./index.jscopyGraph 构建阶段的task.result 为 undefined,与之前结果一致,结束执行。

结语

欢迎 🔗 Fork 或 ❤️ Star 本仓库 the-core-of-turbopack-explained 共同探讨与学习。

参考

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanhfgafbc
系列文章
更多 icon
同类精品
更多 icon
继续加载