Figma的TypeScript之旅
2024 年 5 月 20 日

我们长期以来一直在 Skew 中编写我们移动渲染架构的核心部分,这是我们发明的一种自定义编程语言,旨在从我们的播放引擎中挤出更多性能。以下是我们如何自动将 Skew 迁移到 TypeScript 而不会在开发中造成任何中断。

Skew 在 Figma 创立初期就作为一个副产品开始。当时,Skew 在 Figma 中起到了至关重要的作用:为我们的原型查看器构建支持网页和移动端的功能。最初只是为了快速启动这个项目,最终演变成了一个完整的编译成 JavaScript 的编程语言,可以实现更先进的优化和更快的编译时间。随着多年来在原型查看器中积累了越来越多的 Skew 代码,我们慢慢意识到,这对新员工来说难以上手,无法轻松与我们的其他代码库集成,并且在 F 之外缺少开发者生态系统。 Sigma。扩展的痛苦变得超过了最初的优势。

我们最近完成了将Figma所有Skew代码迁移到TypeScript的工作,这是网络行业的标准语言。TypeScript对团队来说是一次革命性的变革,它实现了:

  • 通过静态导入和本地包管理实现与内部和外部代码的流畅集成
  • 由大量开发者社区构建的工具,如代码检查器、打包工具和静态分析器
  • 现代JavaScript功能,比如async/await (opens new window)和更灵活的类型系统
  • 为新开发者提供无缝入职体验,减少其他团队的摩擦

(这里应该有一张Skew代码片段的图片) 左:Skew 代码片段。

对应于此 Skew 代码的 TypeScript 代码。 这种迁移最近才成为可能,原因如下:

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

WebAssembly 受益于广泛的移动支持和性能的提升 #

当我们首次构建 Figma 的移动代码库时,移动浏览器不支持 WebAssembly。 Skew 这些优化的使用,最初带来了一些关键好处:经典的编译器优化,比如常量折叠和虚拟化,以及生成具有真实整数操作的 JavaScript 代码等网页特定的优化。随着时间的推移,我们花费在这些优化上的时间越长,越难以证明。幸运的是,到2018年,WebAssembly获得了广泛的移动设备支持,并根据我们的测试,在2020年可靠地实现了移动设备性能。 改用我们长期培养的语言。例如,在2020年,基准数据显示,在Safari浏览器中使用TypeScript加载Figma原型将减慢将近一倍,这是一个阻碍,因为Safari是(并且仍然*)iOS上唯一允许的浏览器引擎。

几年后,WebAssembly获得了广泛的移动支持,我们用我们的C++引擎中对应的组件替换了Skew引擎的许多核心组件。由于我们替换的组件是最热门的代码路径,比如文件加载,如果我们转向TypeScript,我们不会损失太多性能。这种经验让我们相信我们可以放弃Skew优化编译器的优势。

在Figma早期,我们无法证明分配资源执行自动迁移是合理的,因为我们当时正以尽可能快的速度用一个小团队建设。随着原型和手机团队的扩大,我们的Skew引擎的核心组件被C++引擎的相应组件取代。 将移动团队合并到更大的组织中为我们提供了进行这样做所需的资源。

当我们在2020年第一次原型化这个迁移时,我们的基准测试显示使用TypeScript性能几乎会慢近一倍。一旦我们看到WebAssembly支持足够好,并将移动引擎的核心移至C++,我们在公司的“创客周”期间修复了我们的旧原型。我们展示了一个通过了所有测试的工作迁移。尽管存在成千上万的开发者体验问题和非致命类型错误,我们有一个大致计划安全地迁移所有我们的Skew代码。

我们的目标很简单:将整个代码库转换为TypeScript。虽然我们可以手动重写每个文件,但我们无法承受中断开发速度以重写整个代码库。更重要的是,我们希望避免运行时错误和性能下降对我们的用户造成影响。 我们最终自动化了这种迁移,这并不是一个快速的过程。不像从另一种“带类型的JavaScript”语言转换到TypeScript那样,Skew实际上有一些语义上的差异,这让我们对立即切换到TypeScript感到不安。例如,TypeScript只有在我们导入文件后才初始化命名空间和类,这意味着如果我们以意外的顺序导入文件,就可能遇到运行时错误。相比之下,Skew在加载时将每个符号都提供给代码库的其余部分,因此这些运行时错误不会成为问题。

Evan表示他从这次经验中学到了一些东西,用于制作网页打包工具esbuild (opens new window)

我们选择逐步推出一个由TypeScript生成的新代码包,以减少对开发人员工作流程的干扰。我们开发了一个Skew到TypeScript的转译器,它可以将Skew代码作为输入,并输出生成的TypeScript代码,借鉴了Evan Wallace的工作,Figma的前身。 我们的CTO,多年前开始。

我们保持了原始的构建流程不变,开发了转译器,并将TypeScript代码提交到GitHub,向开发人员展示新代码库的外观。

阶段1:编写Skew,构建Skew #

我们开发了TypeScript转译器,与我们原始的Skew流水线并行。

感谢您的耐心合作! 开发一个 TypeScript 转换器,与我们原始的 Skew pipeline 并行

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

将生产流量部署到我们的TypeScript编译器从Skew源代码生成的TypeScript代码库中。 部署生产流量到由Skew源代码生成的TypeScript编译器生成的TypeScript代码库

第三阶段:编写TypeScript,构建TypeScript #

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

这是一个很好的方法。完全控制Skew编译器的工作方式意味着我们可以使用它使第一阶段变得更容易;我们可以自由添加和修改Skew编译器的部分,以满足我们的需求。我们逐步推出也带来了回报。例如,在我们推出TypeScript时,我们内部发现了我们的智能动画功能出现了故障。我们的分步发布策略使得我们可以更好地管理这些问题,并逐步解决它们。 我们的方法使我们能够快速关闭部署,修复故障,并重新考虑如何继续我们的部署计划。

我们还提前通知了切换到使用TypeScript。一个星期五晚上,我们合并了所有必要的更改,以删除自动生成过程,并使我们所有的持续集成作业直接运行TypeScript文件。

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

编译器的后端负责将此数据结构转换为目标输出,可能是机器码。 转换器是一种特殊类型的编译器,其后端生成人类可读的代码,而不是混乱的类似机器的代码;在我们的情况下,后端需要获取Skew IR并生成可读的TypeScript。

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

  • 数组解构的性能问题: 放弃JavaScript数组解构带来了高达25%的性能提升。
  • Skew的“去虚拟化”优化: 我们在处理Skew的“去虚拟化”优化时采取了额外的步骤。 在 TypeScript 中,初始化顺序很重要:与 Skew 不同,TypeScript 中的符号顺序很重要,因此我们的转译器需要生成符合这种顺序的代码。

数组解构引起的性能问题 #

在研究一些样本原型中 Skew 和 TypeScript 的离线性能差异时,我们注意到 TypeScript 中的帧速率较低。经过大量调查,我们发现问题的根本原因是数组解构 - 原来 JavaScript 中的数组解构速度相当慢。

要完成类似于 const [a, b] = function_that_returns_an_array() 这样的操作,JavaScript 会构造一个迭代器来遍历数组,而不是直接从数组中索引,这会导致速度变慢。我们正是在这样做以从 JavaScript 的 arguments 关键字中检索参数,导致性能变慢。 在某些测试用例中出现了性能问题。解决方案很简单:我们生成了直接索引参数数组的代码,而不是解构,从而将每帧的延迟提高了高达25%!

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

另一个问题是 TypeScript 和 Skew 处理类方法的行为不一致,在我们推出 Smart Animate 时导致了上述的故障。Skew 编译器进行了一种称为虚拟化的操作,即在某些条件下,函数会被从类中提取出来作为性能优化,并被提升为全局函数:

JavaScript

myObject.myFunc(a, b)
// 变成...
myFunc(myObject, a, b)

这种优化发生在 Skew 中,但 TypeScript 中并没有。Smart Animate 故障发生的原因是 myObject 为空,我们看到了不同的行为。 面对这种问题,开发者们发现虚拟化的调用会正常运行,但未虚拟化的调用会导致空指针异常。这让我们担心是否还有其他类似的调用点存在相同问题。

为了消除我们的担忧,我们在所有可能参与虚拟化的函数中添加了日志记录,以查看这个问题是否曾在生产中发生过。在短暂启用此日志记录后,我们分析了日志并修复了所有存在问题的调用点,让我们对TypeScript代码的健壮性更有信心。

第三个问题是我们遇到的各种语言对初始化顺序的处理方式。在Skew中,您可以在代码的任何位置声明变量、类和命名空间以及函数定义,而不必关心它们声明的顺序。然而,在TypeScript中,初始化全局变量或类定义的顺序确实很重要。 将静态类变量的初始化放在类定义之前是编译时错误。

我们初始版本的转译器通过生成不使用命名空间的TypeScript代码来解决此问题,有效地将每个函数展开到全局范围。这保持了与Skew类似的行为,但生成的代码并不易读。我们重新设计了转译器的部分部分,以便按正确顺序生成TypeScript代码,以提高清晰度和准确性,并为可读性添加了TypeScript命名空间。

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

在整个过程中,开发人员的生产力始终是我们首要关注的问题。我们希望将迁移到TypeScript尽可能简单,这意味着尽一切可能避免停机时间,并创建无缝的调试体验。

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

但浏览器本身只能理解JavaScript,而断点实际上是设置在Skew或TypeScript中。在源代码中设置一个断点时,它如何知道在编译后的JavaScript捆绑包中何处停止?源映射登场。 在编译代码以及源代码之间建立联系的方式是通过源映射。让我们通过这个简单的例子来看一下Skew代码:

纯文本

def helper() {
  return [1, 3, 4, 5];
}

def myFunc(myInt int) int {
  var arrayOfInts List<int> = helper();
  return arrayOfInts[0] + 1;
}

这段代码可能会被编译并压缩成以下JavaScript代码:

JavaScript

function c(){return [1,3,4,5];}function myFunc(a){let b=c();return b[0]+1;}

这种语法很难阅读。源映射将生成的JavaScript代码的部分映射回源代码的具体部分(在我们的例子中是Skew)。两段代码之间的源映射将显示以下映射:

  • helper → c
  • myInt → a
  • arrayOfInts → b

源映射通常会有文件扩展名.map。一个源映射文件将与最终的JavaScript捆绑在一起,这样,给定JavaScript文件中的代码位置,我们的JavaScript捆绑的源映射会告诉我们:

  • 这个Skew文件是哪个 JavaScript source maps for TypeScript migration](Diagram of generating new JavaScript source maps for TypeScript migration)
  1. Generate TypeScript source maps: We first needed to generate source maps for our TypeScript code. This allowed us to map the TypeScript code back to JavaScript during debugging.

  2. Bundle TypeScript with esbuild: Next, we bundled our TypeScript code with esbuild. This step ensured that our TypeScript code was transformed into JavaScript, ready for deployment.

  3. Generate new Skew to JavaScript source maps: Finally, we generated new source maps that mapped the Skew code to the JavaScript code generated by esbuild. This step was crucial for developers to debug their TypeScript code effectively.

By following these steps, we were able to seamlessly migrate from Skew to TypeScript without compromising the debugging experience for our developers. 使用我们的新构建过程创建sourcemaps

我们的新构建过程支持sourcemaps,这是一种将压缩过的JavaScript文件映射回原始源代码的技术。当调试时,sourcemaps对于定位错误和调试代码非常有用。

要启用sourcemaps,请确保在项目构建过程中包含以下配置:

{
  "sourceMap": true
}

一旦构建过程中包含了sourcemaps,您将能够在浏览器的开发者工具中看到原始源代码,而不是压缩过的代码。这将大大简化调试过程,并帮助您更轻松地识别和解决问题。

在新的构建过程中使用sourcemaps,使开发过程更加高效和可靠。希望这些指南能够帮助您成功实现这一目标。 步骤1:生成一个 TypeScript → JavaScript 源映射 ts-to-js.map。当生成 JavaScript 捆绑包时,esbuild 可以自动生成此映射。

步骤2:为每个 Skew 源文件生成一个 Skew → TypeScript 源映射。如果我们将文件命名为 file.sk,则转换器将命名源映射为 file.map。通过模拟 Skew 编译器的 Skew → JavaScript 后端创建源映射的方式,我们在我们的 TypeScript 转换器中实现了这一点。

步骤3:将这些源映射组合在一起,生成一个从 Skew 到 JavaScript 的映射。为此,我们实现了以下内容。 在我们的构建过程中,我们使用以下逻辑:

对于ts-to-js.map中的每个条目E

  • 确定此条目映射到的TypeScript文件并打开其源映射fileX.map
  • 查找此源映射fileX.map中的E的TypeScript代码位置,以获取对应Skew文件fileX.sk中的代码位置。
  • 将此作为新条目添加到我们的最终源映射中:来自E的JavaScript代码位置与Skew代码位置的组合。

有了我们的最终源映射,我们现在可以将我们的新JavaScript捆绑映射到Skew,而不会干扰开发者体验。

案例研究:条件编译 #

在Skew中,顶级“if”语句允许进行条件代码编译,并且我们使用通过传递给Skew编译器的“defines”选项的编译时常量来指定条件。我们可以使用这个功能为给定代码库定义多个构建目标,从而在代码的不同部分中捆绑不同的内容,因此我们可以为不同的需求定义不同的捆绑包。 使用相同代码库的不同方式。例如,一个捆绑包变体可以是部署给用户的实际捆绑包,另一个可能仅用于单元测试。这使我们能够指定某些函数或类在调试或发布版本中使用不同的实现。

为了更明确,以下的Skew代码为TEST构建定义了一个不同的实现:

纯文本

if BUILD == "TEST" {
  class HTTPRequest {
    def send(body string) HTTPResponse {
      # 仅用于测试的实现...
    }

    def testOnlyFunction {
      console.log("hi!")
    }
  }
} else {
  class HTTPRequest {
    def send(body string) HTTPResponse {
      # 真实的实现...
    }
  }
}

当将BUILD: "TEST"定义传递给Skew编译器时,这将编译为以下JavaScript:

JavaScript

function HTTPRequest() {}
HTTPRequest.prototype.send = function(body) {
  // 仅用于测试的实现...
}

HTTPRequest.prototype.testOnlyFunction = fun
``` ```typescript
// 在 esbuild 步骤中定义的值
declare const BUILD: string

class HTTPRequest {
  send(body: string): HTTPResponse {
    if (BUILD == "TEST") {
      // 仅在测试环境下实现...
    } else {
      // 真实实现...
    }
  }
  
  testOnlyFunction() {
    if (BUILD == "TEST") {
      console.log("hi!")
    } else {
      throw new Error("意外调用测试功能")
    }
  }
}
``` ```javascript
function HTTPRequest() {}
HTTPRequest.prototype.send = function(body) {
  // test-only implementation...
}
HTTPRequest.prototype.testOnlyFunction = function(body) {
  console.log("hi!")
}

很遗憾,我们的最终捆绑包现在略有增大。一些符号最初只在一个编译时模式中可用,现在在所有模式中都存在。例如,我们只在构建模式 BUILD 设置为 "TEST" 时使用 testOnlyFunction,但在此更改后,该函数始终出现在最终捆绑包中。在我们的测试中,我们发现捆绑包大小的增加是可以接受的。然而,我们仍然能够通过摇树算法 (opens new window)删除未导出的顶级符号。

TypeScript下的原型开发新时代 #

通过将所有Skew代码迁移到TypeScript,我们成功地将Skew原型开发工具转换为了TypeScript。


我们希望充分利用迁移到TypeScript带来的所有好处,因此我们的工作并未就此结束。我们正在探索许多未来可能性:与我们代码库的其余部分集成,更轻松的包管理,以及直接使用来自活跃的TypeScript生态系统的新功能。我们对TypeScript的不同方面学到了很多东西,比如导入解析、模块系统和JavaScript代码生成,我们迫不及待地想把这些知识应用到实践中。 感谢Andrew Chan,Ben Drebing和Eddie Shiang对这个项目的贡献。如果您对这样的工作感兴趣,请[加入我们在Figma工作](https://www.figma.com/careers/#job-openings)!