Figma
mobile 引擎的演变:换掉我们编译的自定义编程语言
原文 by Brandon LinSoftware Engineer
我们长期以来一直在使用Skew
编写 mobile 渲染架构的核心部分,Skew
是我们发明的自定义编程语言,旨在从我们的播放引擎中挤出额外的性能。以下是我们如何在一天开发都不中断的情况下自动将Skew
迁移到TypeScript
。
Skew 最初是 Figma
早期的一个副项目。当时,Skew
满足了 Figma
的一项关键需求:构建我们的原型查看器,同时支持 web
和 mobile
端。开始是采用快速启动的思想,慢慢形成了一种完整的以编译成 JavaScript
为目标的编程语言,还实现了更高级的优化和更快的编译时间。但随着多年来我们在原型查看器中的 Skew
中积累了越来越多的代码,我们慢慢意识到新员工很难上手,无法轻松地与我们的其他代码库集成,并且缺少 Figma
之外的开发人员生态。扩展它的痛苦逐渐超过了它最初的优势。
我们最近完成了 Figma
中所有 Skew
代码到 TypeScript
(web 工业/行业标准语言)的迁移。 TypeScript
对团队来说是一个巨大的变化,它可以:
- 通过静态导入和本机包管理简化与内部和外部代码的集成
- 庞大的开发者社区,构建了
linter
、捆绑器和静态分析器等工具 - 现代
JavaScript
功能,如async/await
和更灵活的类型系统 - 新开发者无缝入职并减少其他团队的摩擦
Skew 代码对应的 Typescript
代码。" title="右:与此 Skew
代码对应的 Typescript
代码。">
这种迁移最近才成为可能,原因有以下三个:
- 更多 mobile 浏览器开始支持
WebAssembly
- 我们用
C++
引擎中的相应组件替换了Skew
引擎的许多核心组件,这意味着如果我们转向TypeScript
,我们不会损失太多性能 - 团队的成长使我们能够分配资源来专注于开发人员体验
WebAssembly 获得了广泛的 mobile 支持并提高了性能
当我们第一次构建 Figma
的 mobile
代码库时,mobile
浏览器还不支持 WebAssembly
,并且无法以高性能方式加载大型包。这意味着无法使用我们的主要 C++
引擎代码(需要编译为 WebAssembly
)。与此同时,TypeScript
还处于起步阶段;与 Skew
相比,它不是明显的选择,Skew
具有静态类型和更严格的类型系统,允许高级编译器优化。幸运的是,WebAssembly
到 2018
年获得了广泛的 mobile
支持,根据我们的测试,到 2020
年,WebAssembly
获得了可靠的 mobile
性能。
其他性能改进赶上了 Skew
的优化
当我们第一次开始使用 Skew
时,有一些关键的好处:经典的编译器优化,例如常量折叠和去虚拟化,以及特定于 Web
的优化,例如使用实际整数运算生成 JavaScript
代码。我们花在这些优化上的时间越长,就越难证明长时间偏离主流语言的合理性。例如,在 2020
年,基准测试表明,在 Safari
中使用 TypeScript
加载 Figma
原型的速度几乎是原来的两倍,这是一个阻碍因素,因为 Safari
曾经(并且仍然是)iOS
上唯一允许的浏览器引擎。
在iOS 17.4
中,Apple
向欧盟用户的其他浏览器引擎开放了其系统。WebKit
仍然是世界各地其他用户的唯一浏览器引擎。
在 WebAssembly
获得广泛的 mobile 支持几年后,我们用 C++
引擎中的相应组件替换了 Skew
引擎的许多核心组件。由于我们替换的组件是最热门的代码路径(例如文件加载),因此如果我们转向 TypeScript
,我们不会损失太多性能。这次经验让我们相信我们可以放弃 Skew
优化编译器的优势。
Figma
的prototype 和 mobile 团队
不断壮大
在 Figma
的早年,我们无法证明转移资源来执行自动迁移是合理的,因为我们是一个尽可能小步快跑的小团队。将prototype 和 mobile 团队
扩张成更大的组织为我们提供了这样做的资源。
转换代码库
当我们在 2020
年首次对这种迁移进行原型设计时,我们的基准测试显示,使用 TypeScript
时性能会慢近两倍。当我们看到 WebAssembly
支持足够好并将移动引擎的核心转移到 C++
后,我们在公司创客周期间修复了旧原型。我们演习了通过所有测试的迁移工作。尽管有数以千计的开发者体验问题和非致命的类型报错,但我们还是制定了一个粗略的计划来安全地迁移所有 Skew
代码。
我们的目标很简单:将整个代码库转换为 TypeScript
。虽然我们可以手动重写每个文件,但我们不能中断开发人员重写整个代码库的速度。更重要的是,我们希望避免用户出现运行时错误和性能下降。虽然我们最终实现了迁移的自动化,但这并不是一个快速的转变。与从另一种“JavaScript-with-types”(带类型的 js)语言迁移到 TypeScript
不同,Skew
具有实际的语义差异,这让我们对立即切换到 TypeScript
感到不舒服。例如,TypeScript
仅在导入文件后初始化命名空间和类,这意味着如果我们以意外的顺序导入文件,可能会遇到运行时错误。相比之下,Skew
在加载时使每个符号在运行时可供代码库的其余部分使用,因此这些运行时错误不会成为问题。
Evan 表示,他从这次经历中吸取了一些教训,于是制作了 Web 捆绑器 esbuild。
我们选择逐步推出由 TypeScript
生成的新代码包,以便将对开发人员工作流程的干扰降至最低。我们开发了一个 Skew-to-TypeScript
转译器,可以将 Skew
代码作为输入并输出生成的 TypeScript
代码,该编译器建立在 Figma
前首席技术官 Evan Wallace
几年前开始的工作基础上。
第 1 阶段:写入 Skew
、构建 Skew
我们保持原始构建过程不变,开发了转译器,并将 TypeScript
代码签入 GitHub
,向开发人员展示新代码库的外观。
Skew 管道一起开发 Typescript
转译器" title="与我们原来的 Skew
管道一起开发 Typescript
转译器">
第 2 阶段:编写 Skew
,构建 TypeScript
一旦我们生成了通过所有单元测试的 TypeScript
包,我们就开始推出生产流量以直接从 TypeScript
代码库进行构建。在此阶段,开发人员仍然编写 Skew
,我们的转译器将他们的代码转译为 TypeScript
,并更新了 GitHub
中的 TypeScript
代码。此外,我们继续修复生成代码中的类型错误;即使存在类型错误,TypeScript
仍然可以生成有效的包!
第 3 阶段:编写 TypeScript、构建 TypeScript
一旦每个人都完成了 TypeScript
构建过程,我们就需要使 TypeScript
代码成为开发的真实来源。在确定没有人合并代码的时间点后,我们切断了自动生成过程,并从代码库中删除了 Skew
代码,事实上开始要求开发人员使用 TypeScript
编写代码。
Typescript 代码库作为开发人员的事实来源" title="进行切换以使用 Typescript
代码库作为开发人员的事实来源">
这是一个可靠的方法。完全控制 Skew
编译器的工作意味着我们可以使用它来使第一阶段变得更加容易;我们可以完全自由地添加和修改 Skew
编译器的某些部分来满足我们的需求。我们的逐步推出也最终带来了红利。例如,当我们推出 TypeScript
时,我们在内部发现了智能动画功能的损坏。我们的封闭方法使我们能够快速关闭部署、修复故障,并重新考虑如何继续我们的部署计划。
我们还充分通知了使用 TypeScript
的转换。在周五晚上,我们合并了所有必要的更改,以删除自动生成过程,并使我们所有的持续集成作业直接运行 TypeScript
文件。
关于我们的转译器工作的说明
如果您不知道编译器是如何工作的,这里有一个鸟瞰图:编译器本身由前端和后端组成。前端负责解析和理解输入代码并执行类型检查和语法检查等操作。然后,前端将此代码转换为中间表示(IR),这是一种完全捕获原始输入代码的原始语义和逻辑的数据结构,但其结构化使我们无需担心重新解析代码。
编译器的后端负责将这个 IR
转换成各种不同的语言。例如,在像 C
这样的语言中,一个后端通常会生成汇编/机器代码,而在 Skew
编译器中,后端会生成损坏和缩小的 JavaScript。
转译器是一种特殊类型的编译器,其后端生成人类可读的代码,而不是损坏的类似机器的代码;在我们的例子中,后端需要采用Skew IR
并生成人类可读的TypeScript
。
一开始编写转译器的过程相对简单:我们从 JavaScript
后端借鉴了很多灵感,根据我们在 IR
中遇到的信息生成适当的代码。在最后,我们遇到了几个更难以追踪和处理的问题:
- 数组解构的性能问题:放弃
JavaScript
数组解构可带来高达25%
的性能提升。 Skew
的“去虚拟化”优化:我们在推出期间采取了额外的步骤,以确保去虚拟化(一种编译器优化)不会破坏我们的代码库的行为。TypeScript
中的初始化顺序很重要:TypeScript
中的符号顺序与Skew
不同,因此我们的转译器需要生成遵循此顺序的代码。
数组解构的性能问题
在调查一些示例原型中 Skew
和 TypeScript
之间的离线性能差异时,我们注意到 TypeScript
的帧速率较低。经过大量调查,我们发现根本原因是数组解构——事实证明,这在 JavaScript
中相当慢。
为了完成像 const [a, b] = function_that_returns_an_array()
这样的操作,JavaScript
会构造一个迭代器来迭代数组,而不是直接从数组中索引,这样速度较慢。我们这样做是为了从 JavaScript 的 arguments
关键字检索参数,从而导致某些测试用例的性能下降。解决方案很简单:我们生成代码来直接索引参数数组而不是解构,并将每帧延迟提高了 25%
!
Skew
的“去虚拟化”优化
查看这篇文章以了解有关去虚拟化的更多信息。
另一个问题是 TypeScript
和 Skew
处理类方法的行为不同,这导致了我们在推出期间 Smart Animate
出现上述损坏。 Skew
编译器执行称为去虚拟化的操作,即在某些条件下,将函数从类中拉出作为性能优化并提升为全局函数:
myObject.myFunc(a, b)
// becomes...
myFunc(myObject, a, b)
这种优化发生在 Skew
中,但不会发生在 TypeScript
中。 Smart Animate
损坏的发生是因为 myObject
为 null
,并且我们看到了不同的行为 - 去虚拟化调用可以正常运行,但非去虚拟化调用将导致 null 访问异常。这让我们担心是否还有其他此类调用点也存在同样的问题。
为了减轻我们的担忧,我们添加了所有参与去虚拟化的函数的日志记录,以查看生产中是否出现过这个问题。在短暂启用此日志记录后,我们分析了日志并修复了所有有问题的调用站点,使我们对 TypeScript
代码的稳健性更加有信心。
TypeScript
中的初始化顺序很重要
我们遇到的第三个问题是每种语言如何遵循初始化顺序。在 Skew
中,您可以在代码中的任何位置声明变量、类和命名空间以及函数定义,并且它不会关心它们的声明顺序。然而,在 TypeScript
中,首先初始化全局变量还是先初始化类定义确实很重要。在类定义之前初始化静态类变量是一个编译时错误。
我们最初版本的转译器通过在不使用命名空间的情况下生成 TypeScript
代码来解决这个问题,从而有效地将每个函数扁平化到全局范围内。这保持了与 Skew
类似的行为,但生成的代码可读性不太好。为了清晰和准确,我们重新设计了转译器的部分内容,以正确的顺序发出 TypeScript
代码,并重新添加了 TypeScript
命名空间以提高可读性。
尽管存在这些挑战,我们最终还是构建了一个转译器,它通过了所有单元测试,并生成了与 Skew
性能相匹配的编译 TypeScript
代码。我们选择在 Skew
源代码中手动修复一些小问题,或者切换到 TypeScript
,而不是对转译器编写新的修改来修复它们。虽然所有修复都存在于转译器中是理想的选择,但现实情况是,有些更改不值得自动化,我们可以通过这种方式修复某些问题来加快进度。
案例研究:让开发人员对source maps
感到满意
在整个过程中,开发人员的生产力始终是最重要的。我们希望尽可能轻松地迁移到 TypeScript
,这意味着尽一切努力避免停机并创建无缝的调试体验。
Web
开发人员主要使用现代 Web
浏览器提供的调试器进行调试;您在源代码中设置了一个断点,当代码到达此点时,浏览器将暂停,开发人员可以检查浏览器 JavaScript
引擎的状态。在我们的例子中,开发人员希望在 Skew
或 TypeScript
中设置断点(取决于我们处于项目的哪个阶段)。
但浏览器本身只能理解 JavaScript
,而断点实际上是在 Skew
或 TypeScript
中设置的。给定源代码中的断点,它如何知道在已编译的 JavaScript
包中停止的位置?进入:source maps
,一种浏览器将编译的代码链接到源代码的方式。让我们看一个包含 Skew
代码的简单示例:
def helper() {
return [1, 3, 4, 5];
}
def myFunc(myInt int) int {
var arrayOfInts List<int> = helper();
return arrayOfInts[0] + 1;
}
该代码可能会被编译并缩小为以下 JavaScript:
function c() {
return [1, 3, 4, 5]
}
function myFunc(a) {
let b = c()
return b[0] + 1
}
这种语法很难阅读。source maps
将生成的 JavaScript
部分映射回源代码的特定部分(在我们的例子中为 Skew
)。代码片段之间的source maps
将显示以下之间的映射:
helper → c
myInt → a
arrayOfInts → b
查看这篇有关source maps
的文章,了解有关生成、理解和调试source maps
的更多技术细节。
source maps
通常具有文件扩展名 .map
。一个source maps
文件将与最终的 JavaScript
包关联,这样,给定 JavaScript
文件中的代码位置,JavaScript
包的source maps
将告诉我们:
- 这部分
JavaScript
来自Skew
文件 - 此
Skew
文件中与这部分JavaScript
相对应的代码位置
每当开发人员在 Skew
中设置调试器断点时,浏览器只需反转此source maps
,查找此 Skew
行对应的 JavaScript
部分,并在那里设置断点。
以下是我们如何将其应用到 TypeScript
迁移中:我们原始的基础设施生成了用于调试的 JavaScript
source maps
的 Skew
。然而,在迁移的第 2 阶段,我们的捆绑包生成管道完全不同,先生成 TypeScript
,然后使用 esbuild 进行捆绑。如果我们尝试使用原始基础设施中的相同source maps
,我们将在 JavaScript
和 Skew
代码之间得到不正确的映射,并且开发人员将无法在我们处于此阶段时调试他们的代码。
我们需要使用新的构建过程生成新的source maps
。这涉及三项工作,如下所示:
source maps的图表" title="使用我们的新构建流程生成新source maps
的图表">
步骤 1:生成 TypeScript
→ JavaScript
source maps
ts-to-js.map
。 esbuild
在生成 JavaScript
包时可以自动生成此映射。
步骤 2:为每个 Skew
源文件生成 Skew
→ TypeScript
source maps
。如果我们将文件命名为 file.sk
,编译器会将source maps
命名为 file.map
。通过模拟 Skew
编译器的 Skew
→ JavaScript
后端如何创建source maps
,我们在 TypeScript
转译器中实现了这一点。
步骤 3:将这些source maps
组合在一起,生成从 Skew
到 JavaScript
的映射。为此,我们在构建过程中实现了以下逻辑:
对于 ts-to-js.map
中的每个条目 E
:
- 确定此条目映射到哪个
TypeScript
文件并打开其source maps
fileX.map
。 - 从此
source maps
fileX.map
中的E
查找TypeScript
代码位置,以获取相应Skew
文件fileX.sk
中的代码位置。 - 将其添加为最终
source maps
中的新条目:E
中的JavaScript
代码位置与Skew
代码位置相结合。
有了最终的source maps
,我们现在可以将新的 JavaScript
包映射到 Skew
,而不会影响开发人员的体验。
案例研究:条件编译
在 Skew
中,顶级“if”语句允许条件代码编译,并且我们通过传递给 Skew
编译器的“defines”选项使用编译时常量指定条件。我们可以使用它来为给定的代码库定义多个构建目标,这些目标捆绑在代码的不同部分中,因此我们可以为使用同一代码库的不同方式提供不同的捆绑包。例如,一个捆绑包变体可能是部署给用户的实际捆绑包,而另一个捆绑包变体可能仅用于单元测试。这允许我们指定某些函数或类在调试或发布版本中使用不同的实现。
更明确地说,以下 Skew
代码为 TEST
构建定义了不同的实现:
if BUILD == "TEST" {
class HTTPRequest {
def send(body string) HTTPResponse { # test-only implementation...
}
def testOnlyFunction {
console.log("hi!")
}
}
} else {
class HTTPRequest {
def send(body string) HTTPResponse { # real implementation...
}
}
}
当将 BUILD: "TEST"
定义传递给 Skew
编译器时,这将编译为以下 JavaScript:
function HTTPRequest() {}
HTTPRequest.prototype.send = function (body) {
// test-only implementation...
}
HTTPRequest.prototype.testOnlyFunction = function (body) {
console.log("hi!")
}
但是,条件编译不是 TypeScript
的一部分。相反,我们必须在类型检查后在构建步骤中执行条件编译,作为使用 esbuild
的“定义”和死代码消除功能的捆绑步骤的一部分。因此,这些定义不再影响类型检查,这意味着像上面示例中仅在 BUILD: "TEST"
构建中定义方法 testOnlyFunction
的代码不能存在于 Typescript
中。
我们通过将上面的 Skew
代码转换为以下 TypeScript
代码来解决这个问题:
// Value defined during esbuild step
declare const BUILD: string
class HTTPRequest {
send(body: string): HTTPResponse {
if (BUILD == "TEST") {
// test-only implementation...
} else {
// real implementation...
}
}
testOnlyFunction() {
if (BUILD == "TEST") {
console.log("hi!")
} else {
throw new Error("Unexpected call to test-only function")
}
}
}
这与原始 Skew
代码直接编译的 JavaScript
产物相同:
function HTTPRequest() {}
HTTPRequest.prototype.send = function (body) {
// test-only implementation...
}
HTTPRequest.prototype.testOnlyFunction = function (body) {
console.log("hi!")
}
不幸的是,我们的最终包现在稍大一些。一些最初仅在一种编译时模式中可用的符号现在出现在所有模式中。例如,当构建模式 BUILD
设置为 "TEST"
时,我们仅使用 testOnlyFunction
,但在此更改之后,该函数始终存在于最终包中。在我们的测试中,我们发现捆绑包大小的增加是可以接受的。不过,我们仍然能够通过树摇删除未导出的顶级符号。
原型开发的新时代,现在在 TypeScript 中
通过将所有 Skew
代码迁移到 TypeScript
,我们对 Figma
的关键代码库进行了现代化改造。我们不仅为其更轻松地与内部和外部代码集成铺平了道路,而且开发人员的工作效率也因此提高。考虑到 Figma
当时的需求和功能,最初使用 Skew
编写代码库是一个不错的决定。然而,技术在不断进步,我们学会了永远不要怀疑它们成熟的速度。尽管 TypeScript
在当时可能不是正确的选择,但现在绝对是正确的选择。
我们希望获得迁移到 TypeScript
的所有好处,因此我们的工作并不止于此。我们正在探索许多未来的可能性:与我们的代码库的其余部分集成,显着简化包管理,以及直接使用活跃的 TypeScript
生态系统中的新功能。我们学到了很多有关 TypeScript
不同方面的知识,例如导入解析、模块系统和 JavaScript 代码生成,我们迫不及待地想充分利用这些知识。
最后
我们要感谢 Andrew Chan
、Ben Drebing
和 Eddie Shiang
对此项目的贡献。如果这样的工作对您有吸引力,请来 Figma
与我们一起工作!
本文由mdnice多平台发布
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。