[译]TurboPack Tree shaking 工作记录

date
Apr 1, 2023
slug
kdy-turbopack-tree-shaking-work-record
status
Published
tags
summary
type
Post
from: https://kdy1.github.io/post/2023/3/turbopack-build/
以前曾经说过要多留下记录,所以就写了这篇文章。做了很多各式各样的工作,但是却没有可以用文字来表达的内容。
 
PR https://github.com/vercel/turbo/pull/3338 是一项相当大的工作。GitHub 上显示的最终 commit 数量为 250 个,但我在中间重新提交了 170多个 commit 并删除了它们。这是因为中途需求发生了变化,使得这些代码变得没用了。
 
这次 PR 的目的有点特殊。目的是实现比一般的 Tree Shaking 更先进的 Tree Shaking。
 
import { upper } from "module";
export let foobar = "foo";
export const foo = foobar;
const bar = "bar";
foobar += bar;
let foobarCopy = foobar;
foobar += "foo";
console.log(foobarCopy);
foobarCopy += "Unused";
function internal() {
  return upper(foobar);
}
export function external1() {
  return internal() + foobar;
}
export function external2() {
  foobar += ".";
}
在上面的代码中,正常的 tree shaking 并没有移除语句 foobarCopy += "Unused";foobarCopy 变量本身被使用了,但 foobarCopy += "Unused"; 修改后的值并没有被使用,因此我们可以安全的删除 foobarCopy += "Unused"; 语句。 为了达成这样的效果,我需要一个 Control Flow Graph(以下简称CFG)来识别上述情形,进而删除掉语句 foobarCopy += "Unused";。但是在Rust里将JavaScript还原成CFG是一件很困难的事情。即便能够保留AST结构来生成CFG也对我来说太难了,因为无法存储引用并且不能更改AST类型的内存布局,因此必须为每个 AST 节点创建专用类型。这是一个会严重降低性能的行为,所以我必须在不创建 CFG 的情况下,实现 CFG 的一些特性。

我并没有在一开始就把所有技术细节想清楚,而是一边写一边想。所以我努力工作了,但后来发现有一些问题。我按照文档中写的基于 Id 进行拆分,但是如果这样做,很难将 Id 更改为 ModuleItem 的索引。所以我和团队成员进行了讨论,决定彻底改变设计。然后@sokra 写了一个非常详细的设计文档。
 
设计更改后,我使用 GitHub Copilot 很快的实现了分析器。文档非常详细,所以我复制了其中的描述, GitHub Copilot 很好的理解了这些描述。分析器的作用是根据 Read/Write 分析每个 ModuleItem 引用了哪些变量,然后将一些 ModuleItem 划分为更小的逻辑虚拟节点。例如,在
import { upper } from "module";
上面的代码中,它被拆分成一个 import "module" 节点和一个 upper 变量绑定节点。在这样做之后,我使用了 Graphviz 来将结果进行可视化,以验证我的做法是否正确,并请求了 @sokra 进行审查。不过 GitHub 支持 Mermaid 渲染,因此我认为在 Markdown 文件中使用 Mermaid 来渲染图表会更好一些,于是就重新以 Mermaid 方式进行渲染并提出复核要求。我对 AST 进行分析时有几处错误,修正之后再通过图表结果验证,然后不断循环直到真正将 Module 分割开来才能真正开始工作。
 
上面讲了将 Module 拆分成虚拟的节点,但是除此之外,还需要一个真正去拆分模块的功能。你可以认为 Module 是 ModulePart 组成的,每个模块包含需要执行的代码(Module Evaluation)和变量导出导入的代码。而为了避免重复的代码,任何被多个 ModulePart 引用的代码都必须是一个单独的 Module。鉴于我浅薄的 CS 方面的知识,在处理和图相关的数据结构的时候,有点吃力。我在性能优化的方面花了相当不必要的时间,如果我开始没有陷入追求性能的牛角尖,整个任务会容易很多。最后我使用了一套并不特别好、性能略差、但是在代码编写阅读上简单明了的方案。
 
ModulePart 给人的理解,看起来像这样
 
// export "foobar"

import { foobar } from "X1";

export { foobar };
 
这是一个名叫 foobar 的道出组,以下是虚拟组。
 
// X1

import { mut foobar } from "X2";
import weak "X3";

foobar += "foo";

export { mut foobar }
// X2

import { mut foobar } from "X3";

const bar = "bar";
foobar += "bar;
let foobarCopy = foobar;
// X3

let foobar = "foo";
const foo = foobar;

export { mut foobar, foo }
这样一来,你就可以把它分解开来,然后只加载你需要的模块,并在以后提供实际的模块使用信息。当我第一次看到这份文件时,我很害怕,因为它太恶心了,但它确实有效。其本质上是要分析变量并处理AST,但对于实现了 SWC minifier的我来说,这是个轻松的工作。

在我完成了上述设计的原型后,剩下的就是将这个特性其集成到 TurboPack 的架构上。这实际上是最难的部分。因为 TurboPack 的架构相当独特,即使熟练掌握了 Rust ,这也是一个挑战。
但同样,@sokra 详细解释了我需要做的事情。比如应该声明哪些类型,这些类型应该实现哪些 trait ,但分块处理是我的专长。考虑到我不太了解这些 trait 是怎样使用的,所以我克隆了另一个 turbo 仓库并执行 cargo doc --document-private-items来理解大概意思。然后就努力去实现这些 trait
 
中间遇到了一些问题,这些问题需要我修改 esm_resolver 等通用函数、 turbopack-core 中的代码,但我不清楚那些代码允许修改,那些不允许。这使得我经常需要停下手头工作在 Slack 中咨询问题。最后被告知可以修改,然后就修改了相关的函数签名,然后努力让让它们可以在一起正常工作。
 
在确认一切工作正常后,我请求进行代码审查。说起来容易,但我吃了不少苦头。在代码审查中,有很多代码被要求修改,其中大部分是关于API设计的。由于不是我的项目,所以我采用的方式是在原来枚举上增加新的成员,而不是新增一个 type 的参数,但审查代码的人想要新增一个 API 来完成相关工作。也有很多代码风格审查意见,但是因为对方总是以友好的姿态留出建议,所以这些修改请求并没有什么难度。不过由于 commit 数量众多,Github 在 Load more items 之前无法显示出审查信息 , 这对需要浏览反馈的人来说是非常烦人的。
 
尽管如此,在来来回回的沟通之后,两个团队成员同意了我的 PR ,并在确认了 next-dev-tests 在开启了这个 tree shaking 后能够正常工作后,他们说准备合并这个 PR。在等待合并的时候,发生了合并冲突,所以做了一次 rebase,最终今天合并了这个 PR。我本来想继续做相关的工作,但决定还是等到和经理 1 对 1 沟通之后再做,所以我今天在看 SWC 的问题。

工作感受?来来回回的沟通非常令人沮丧,这是一项相当艰巨的工作,虽然一开始很棘手,但是最终完成了,所以心情非常好。
Vercel 工作建议(原文是 "Vercel 은" ,🤷不知道怎么翻译)
要习惯异步通信。
我不知道是不是因为我是韩国人,还是因为我没有耐心,但我不喜欢因为时差而要等上一天。公司整体来说还是很好的,就只有时差问题不太如意。幸运的是,我的主要负责 SWC 方面的工作,通常都能独立完成任务,所以不怎么会遇到时差的问题。
 

© 何云飞 2021 - 2024