Figma mobile 引擎的演变:换掉我们编译的自定义编程语言

原文 by Brandon LinSoftware Engineer

我们长期以来一直在使用 Skew 编写 mobile 渲染架构的核心部分,Skew 是我们发明的自定义编程语言,旨在从我们的播放引擎中挤出额外的性能。以下是我们如何在一天开发都不中断的情况下自动将 Skew 迁移到 TypeScript

Skew 最初是 Figma 早期的一个副项目。当时,Skew 满足了 Figma 的一项关键需求:构建我们的原型查看器,同时支持 webmobile端。开始是采用快速启动的思想,慢慢形成了一种完整的以编译成 JavaScript 为目标的编程语言,还实现了更高级的优化和更快的编译时间。但随着多年来我们在原型查看器中的 Skew 中积累了越来越多的代码,我们慢慢意识到新员工很难上手,无法轻松地与我们的其他代码库集成,并且缺少 Figma 之外的开发人员生态。扩展它的痛苦逐渐超过了它最初的优势。

我们最近完成了 Figma 中所有 Skew 代码到 TypeScript(web 工业/行业标准语言)的迁移。 TypeScript 对团队来说是一个巨大的变化,它可以:

  • 通过静态导入和本机包管理简化与内部和外部代码的集成
  • 庞大的开发者社区,构建了 linter、捆绑器和静态分析器等工具
  • 现代 JavaScript 功能,如 async/await 和更灵活的类型系统
  • 新开发者无缝入职并减少其他团队的摩擦

左:<code>Skew</code> 代码片段。

右:与此 <code loading=Skew 代码对应的 Typescript 代码。" title="右:与此 Skew 代码对应的 Typescript 代码。">

这种迁移最近才成为可能,原因有以下三个:

  • 更多 mobile 浏览器开始支持 WebAssembly
  • 我们用 C++ 引擎中的相应组件替换了 Skew 引擎的许多核心组件,这意味着如果我们转向 TypeScript,我们不会损失太多性能
  • 团队的成长使我们能够分配资源来专注于开发人员体验

WebAssembly 获得了广泛的 mobile 支持并提高了性能

当我们第一次构建 Figmamobile 代码库时,mobile 浏览器还不支持 WebAssembly,并且无法以高性能方式加载大型包。这意味着无法使用我们的主要 C++ 引擎代码(需要编译为 WebAssembly)。与此同时,TypeScript 还处于起步阶段;与 Skew 相比,它不是明显的选择,Skew 具有静态类型和更严格的类型系统,允许高级编译器优化。幸运的是,WebAssembly2018 年获得了广泛的 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 优化编译器的优势。

Figmaprototype 和 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,向开发人员展示新代码库的外观。

与我们原来的 <code loading=Skew 管道一起开发 Typescript 转译器" title="与我们原来的 Skew 管道一起开发 Typescript 转译器">

第 2 阶段:编写 Skew,构建 TypeScript

一旦我们生成了通过所有单元测试的 TypeScript 包,我们就开始推出生产流量以直接从 TypeScript 代码库进行构建。在此阶段,开发人员仍然编写 Skew,我们的转译器将他们的代码转译为 TypeScript,并更新了 GitHub 中的 TypeScript 代码。此外,我们继续修复生成代码中的类型错误;即使存在类型错误,TypeScript 仍然可以生成有效的包!

将生产流量推出到我们的 TypeScript 代码库,用 TypeScript 编译器从 Skew 源代码生成的

第 3 阶段:编写 TypeScript、构建 TypeScript

一旦每个人都完成了 TypeScript 构建过程,我们就需要使 TypeScript 代码成为开发的真实来源。在确定没有人合并代码的时间点后,我们切断了自动生成过程,并从代码库中删除了 Skew 代码,事实上开始要求开发人员使用 TypeScript 编写代码。

进行切换以使用 <code loading=Typescript 代码库作为开发人员的事实来源" title="进行切换以使用 Typescript 代码库作为开发人员的事实来源">

这是一个可靠的方法。完全控制 Skew 编译器的工作意味着我们可以使用它来使第一阶段变得更加容易;我们可以完全自由地添加和修改 Skew 编译器的某些部分来满足我们的需求。我们的逐步推出也最终带来了红利。例如,当我们推出 TypeScript 时,我们在内部发现了智能动画功能的损坏。我们的封闭方法使我们能够快速关闭部署、修复故障,并重新考虑如何继续我们的部署计划。

我们还充分通知了使用 TypeScript 的转换。在周五晚上,我们合并了所有必要的更改,以删除自动生成过程,并使我们所有的持续集成作业直接运行 TypeScript 文件。

关于我们的转译器工作的说明

如果您不知道编译器是如何工作的,这里有一个鸟瞰图:编译器本身由前端和后端组成。前端负责解析和理解输入代码并执行类型检查和语法检查等操作。然后,前端将此代码转换为中间表示(IR),这是一种完全捕获原始输入代码的原始语义和逻辑的数据结构,但其结构化使我们无需担心重新解析代码。

编译器的后端负责将这个 IR 转换成各种不同的语言。例如,在像 C 这样的语言中,一个后端通常会生成汇编/机器代码,而在 Skew 编译器中,后端会生成损坏和缩小的 JavaScript。

转译器是一种特殊类型的编译器,其后端生成人类可读的代码,而不是损坏的类似机器的代码;在我们的例子中,后端需要采用 Skew IR 并生成人类可读的 TypeScript

一开始编写转译器的过程相对简单:我们从 JavaScript 后端借鉴了很多灵感,根据我们在 IR 中遇到的信息生成适当的代码。在最后,我们遇到了几个更难以追踪和处理的问题:

  • 数组解构的性能问题:放弃 JavaScript 数组解构可带来高达 25% 的性能提升。
  • Skew 的“去虚拟化”优化:我们在推出期间采取了额外的步骤,以确保去虚拟化(一种编译器优化)不会破坏我们的代码库的行为。
  • TypeScript 中的初始化顺序很重要:TypeScript 中的符号顺序与 Skew 不同,因此我们的转译器需要生成遵循此顺序的代码。

数组解构的性能问题

在调查一些示例原型中 SkewTypeScript 之间的离线性能差异时,我们注意到 TypeScript 的帧速率较低。经过大量调查,我们发现根本原因是数组解构——事实证明,这在 JavaScript 中相当慢。

为了完成像 const [a, b] = function_that_returns_an_array() 这样的操作,JavaScript 会构造一个迭代器来迭代数组,而不是直接从数组中索引,这样速度较慢。我们这样做是为了从 JavaScript 的 arguments 关键字检索参数,从而导致某些测试用例的性能下降。解决方案很简单:我们生成代码来直接索引参数数组而不是解构,并将每帧延迟提高了 25%

Skew 的“去虚拟化”优化

查看这篇文章以了解有关去虚拟化的更多信息。

另一个问题是 TypeScriptSkew 处理类方法的行为不同,这导致了我们在推出期间 Smart Animate 出现上述损坏。 Skew 编译器执行称为去虚拟化的操作,即在某些条件下,将函数从类中拉出作为性能优化并提升为全局函数:

myObject.myFunc(a, b)
// becomes...
myFunc(myObject, a, b)

这种优化发生在 Skew 中,但不会发生在 TypeScript 中。 Smart Animate 损坏的发生是因为 myObjectnull,并且我们看到了不同的行为 - 去虚拟化调用可以正常运行,但非去虚拟化调用将导致 null 访问异常。这让我们担心是否还有其他此类调用点也存在同样的问题。

为了减轻我们的担忧,我们添加了所有参与去虚拟化的函数的日志记录,以查看生产中是否出现过这个问题。在短暂启用此日志记录后,我们分析了日志并修复了所有有问题的调用站点,使我们对 TypeScript 代码的稳健性更加有信心。

TypeScript 中的初始化顺序很重要

我们遇到的第三个问题是每种语言如何遵循初始化顺序。在 Skew 中,您可以在代码中的任何位置声明变量、类和命名空间以及函数定义,并且它不会关心它们的声明顺序。然而,在 TypeScript 中,首先初始化全局变量还是先初始化类定义确实很重要。在类定义之前初始化静态类变量是一个编译时错误。

我们最初版本的转译器通过在不使用命名空间的情况下生成 TypeScript 代码来解决这个问题,从而有效地将每个函数扁平化到全局范围内。这保持了与 Skew 类似的行为,但生成的代码可读性不太好。为了清晰和准确,我们重新设计了转译器的部分内容,以正确的顺序发出 TypeScript 代码,并重新添加了 TypeScript 命名空间以提高可读性。

尽管存在这些挑战,我们最终还是构建了一个转译器,它通过了所有单元测试,并生成了与 Skew 性能相匹配的编译 TypeScript 代码。我们选择在 Skew 源代码中手动修复一些小问题,或者切换到 TypeScript,而不是对转译器编写新的修改来修复它们。虽然所有修复都存在于转译器中是理想的选择,但现实情况是,有些更改不值得自动化,我们可以通过这种方式修复某些问题来加快进度。

案例研究:让开发人员对source maps感到满意

在整个过程中,开发人员的生产力始终是最重要的。我们希望尽可能轻松地迁移到 TypeScript,这意味着尽一切努力避免停机并创建无缝的调试体验。

Web 开发人员主要使用现代 Web 浏览器提供的调试器进行调试;您在源代码中设置了一个断点,当代码到达此点时,浏览器将暂停,开发人员可以检查浏览器 JavaScript 引擎的状态。在我们的例子中,开发人员希望在 SkewTypeScript 中设置断点(取决于我们处于项目的哪个阶段)。

但浏览器本身只能理解 JavaScript,而断点实际上是在 SkewTypeScript 中设置的。给定源代码中的断点,它如何知道在已编译的 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 mapsSkew。然而,在迁移的第 2 阶段,我们的捆绑包生成管道完全不同,先生成 TypeScript,然后使用 esbuild 进行捆绑。如果我们尝试使用原始基础设施中的相同source maps,我们将在 JavaScriptSkew 代码之间得到不正确的映射,并且开发人员将无法在我们处于此阶段时调试他们的代码。

我们需要使用新的构建过程生成新的source maps。这涉及三项工作,如下所示:

使用我们的新构建流程生成新<code loading=source maps的图表" title="使用我们的新构建流程生成新source maps的图表">

步骤 1:生成 TypeScriptJavaScript source maps ts-to-js.mapesbuild 在生成 JavaScript 包时可以自动生成此映射。

步骤 2:为每个 Skew 源文件生成 SkewTypeScript source maps。如果我们将文件命名为 file.sk ,编译器会将source maps命名为 file.map 。通过模拟 Skew 编译器的 SkewJavaScript 后端如何创建source maps,我们在 TypeScript 转译器中实现了这一点。

步骤 3:将这些source maps组合在一起,生成从 SkewJavaScript 的映射。为此,我们在构建过程中实现了以下逻辑:

对于 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 ChanBen DrebingEddie Shiang 对此项目的贡献。如果这样的工作对您有吸引力,请来 Figma 与我们一起工作

本文由mdnice多平台发布


tcdona
461 声望32 粉丝

may the money keep with you —— monkey


« 上一篇
重学 react