Figma的TypeScript之旅
2024 年 5 月 4 日

Figma的TypeScript之旅 | Figma博客

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

Skew始于Figma早期的一个副产品。当时,Skew在Figma中起到了至关重要的作用:在web和移动端支持我们的原型查看器。最初只是为了快速启动这个项目,却演变成了一个完整的编译为JavaScript的编程语言,使得更高级的优化和更快的编译时间成为可能。然而,随着多年来我们在原型查看器中的Skew代码不断累积,我们慢慢意识到新员工难以上手,无法轻松与我们的代码库集成,并且在F外缺乏开发者生态系统。 我们最近完成了将Figma的所有Skew代码迁移到TypeScript,这是Web行业的标准语言。TypeScript对团队来说是一个巨大的改变,并实现了以下功能:

  • 通过静态导入和本地包管理实现与内部和外部代码的流畅集成
  • 庞大的开发者社区,构建了像代码检查工具、打包工具和静态分析器等工具
  • 现代JavaScript特性,如async/await和更灵活的类型系统
  • 为新开发人员提供无缝入职,并降低其他团队的摩擦力

这些改变使得我们能更好地管理和扩展我们的代码,而不会受到以前的痛苦影响。 0f6b2HvMXThNGUutVBKwaexJ7dxxMdMTJkYI9b5XoUOuac3xmPE/BTaEJkulaXN3LaVbbtR5oVUZkKeCCGQUiSlhPMR7RMmTi9swIg9hF0qOO+o10rbFu73O4/nk7auzMvCVArOOYwxaGNQIq9FiUbtKEENitP+U2vThdbZ3t+8XGht4bbdaK0xz3MfP+fc01lrUer9Yk7WHq/uQmMIwRNi6D3tgp19VO99l/VxnUMp1bf962z2cvcXrRGsFqRv7O9b+8e7w/4EAt4ey+16P6gAAAAASUVORK5CYII=)

左侧:Skew 代码片段。

对应于此 Skew 代码的 TypeScript 代码。 最近这种迁移才变得可能,主要有以下三个原因:

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

当我们首次构建Figma的移动代码库时,移动浏览器不支持WebAssembly。 在过去,我们发现使用 Skew 时有一些关键优点:经典的编译器优化,例如常量折叠和虚拟化,以及生成具有真实整数运算的 JavaScript 代码等网络特定优化。随着时间的推移,这些优化变得越来越难以证明其有效性。在我们使用 Skew 过程中,其他性能改进也在逐渐赶上 Skew 的优化水平。fortuantely,到2018年,WebAssembly已在移动端获得广泛支持并在2020年可靠性移动性能。此时,无法以高性能方式加载大包。这意味着我们无法使用主要的 C++ 引擎代码(需要编译为 WebAssembly)。与此同时,TypeScript 还处于初级阶段;与 Skew 相比,它不如 Skew 显而易见,Skew 具有静态类型和更严格的类型系统,这允许进行高级编译器优化。 从我们长期培育的语言中脱离出来。例如,2020年的基准数据显示,在Safari中使用TypeScript加载Figma原型将慢近一倍,这是一个阻碍,因为Safari是iOS上唯一允许的浏览器引擎。

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

在Figma早期,我们无法证明将资源转移到自动迁移上因为我们尽可能快地用一支小团队建立。扩大原型设计和移动团队增长。 将移动团队整合到更大的组织中为我们提供了进行此操作所需的资源。

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

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

Evan表示,他从这次经验中汲取了一些启示,用来开发Web打包工具esbuild (opens new window)

我们选择逐渐推出生成的TypeScript代码包,以减少对开发人员工作流程的干扰。我们开发了一个Skew到TypeScript的转换器,它可以将Skew代码作为输入,并输出生成的TypeScript代码,这是基于Evan Wallace和Figma的工作。 我们的CTO几年前开始了这个项目。

我们保持了原始构建过程不变,开发了转换器,并将TypeScript代码提交到GitHub,以展示开发人员新代码库的样子。

第一阶段:编写Skew,构建Skew

在我们的原始Skew流程旁边开发了一个TypeScript转换器。 开发一个TypeScript转译器,与我们原始的Skew流水线一起

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

将生产流量转移到Skew源代码生成的TypeScript代码库中。 从Skew源代码生成的TypeScript编译器生成的TypeScript代码库中推出生产流量

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

每个人都经历了TypeScript构建过程之后,我们需要开始将TypeScript代码库的生产流量进行推出。 把Typescript代码库作为开发人员的真相来源。在确定没有人合并代码的时候,我们切断了自动生成过程,并从代码库中删除了Skew代码,有效地要求开发人员使用TypeScript编写代码。 将切换到使用Typescript代码库作为开发人员的真实来源

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

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

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

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

编译器的后端负责将这段代码转换为... 转译器是一种特殊类型的编译器,其后端生成可读的代码,而不是混乱的类似机器的代码;在我们的情况下,后端需要接收 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的“虚函数优化”,请查看此文章 (opens new window)

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

JavaScript

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

这种优化在Skew中发生,但在TypeScript中却没有。Smart Animate的故障是因为myObject为null,导致了不同的行为。 在这个过程中,虚函数调用会正常运行,但非虚函数调用会导致空指针异常。这让我们担心是否有其他类似的调用点存在同样的问题。

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

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

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

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

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

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

但是浏览器本身只能理解JavaScript,而断点实际上是在Skew或TypeScript中设置的。在给定源代码中的断点时,它如何知道在编译后的JavaScript包中何处停止?这就是源映射的作用。 aps 的作用是让浏览器知道如何将编译的代码与源代码链接在一起。让我们通过这个 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 in TypeScript migration](Diagram of generating new JavaScript source maps in TypeScript migration)
  1. Generating TypeScript Source Maps: We first needed to ensure that TypeScript was generating source maps during compilation. This involved configuring the TypeScript compiler to output source maps along with the compiled JavaScript code. These source maps would later be used in the bundling process to create accurate mappings.

  2. Bundling TypeScript Code: Once TypeScript generated the source maps, we then needed to bundle the TypeScript code using esbuild. During this step, the source maps generated by TypeScript were utilized to create mappings between the TypeScript code and the final bundled JavaScript code.

  3. Generating New JavaScript Source Maps: After bundling with esbuild, we needed to generate new JavaScript source maps that accurately reflected the TypeScript to JavaScript conversion. This process ensured that when developers set breakpoints in the Skew code, the browser could correctly map them to the corresponding JavaScript code.

By following these steps, we successfully migrated our infrastructure to TypeScript while maintaining the ability for developers to debug their code effectively during the transition phase. This approach allowed us to seamlessly transition to TypeScript without compromising the debugging experience for our developers. 使用我们的新构建流程生成sourcemaps

我们的新构建流程现在支持生成sourcemaps。sourcemaps是一种文件,它将压缩、混淆的JavaScript代码映射回原始源代码。这对于调试和排除故障非常有用,因为它允许开发人员在生产环境中调试他们的代码。

要启用sourcemaps,只需在构建过程中包含一个额外的标志。这个标志将指示构建流程生成sourcemaps并将它们与生成的文件一起输出。这样,当您在浏览器的开发者工具中查看JavaScript代码时,将会自动加载和显示原始源代码,而不是混淆的代码。

希望这个新特性能够帮助您更轻松地调试和优化您的JavaScript代码。如果您遇到任何问题或需要帮助,请随时联系我们的支持团队。感谢您使用我们的产品! 第1步:生成一个TypeScript → JavaScript源映射ts-to-js.map。使用esbuild生成JavaScript捆绑包时,可以自动生成此映射。

第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中的代码位置。
  • 将此作为我们最终源映射中的新条目:JavaScript代码位置从E中合并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 {
      // 真实实现...
    }
  }
}

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

JavaScript

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

HTTPRequest.prototype.testOnlyFunction = fun
``` 在TypeScript中,条件编译不是一个功能。相反,我们必须在类型检查之后的构建步骤中执行条件编译,作为捆绑步骤的一部分,使用esbuild的“定义”和死代码消除功能。因此,这些定义不再能影响类型检查,意味着像上面示例中的`testOnlyFunction`方法仅在`BUILD: "TEST"`构建中定义的代码在TypeScript中无法存在。

我们通过将上述Skew代码转换为以下TypeScript代码来解决这个问题:

JavaScript

```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("意外调用了仅用于测试的函数")
    }
  }
}
``` ```typescript
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,现在可以直接编译到 JavaScript 了。 通过将关键代码库现代化为TypeScript,我们在Figma取得了进展。我们不仅为其与内部和外部代码更轻松地集成铺平了道路,而且开发人员的工作效率也因此得到了提高。最初在Skew中编写代码库是一个明智的决定,考虑到当时Figma的需求和能力。然而,技术不断改进,我们学会了永远不要怀疑它们成熟的速度。尽管在过去TypeScript可能不是正确的选择,但现在绝对是。

我们希望获得迁移到TypeScript的所有好处,因此我们的工作不会止步于此。我们正在探索许多未来的可能性:与我们代码库的其余部分集成、更容易的包管理以及直接使用来自活跃的TypeScript生态系统的新功能。我们学到了许多关于TypeScript不同方面的知识,比如导入解析、模块系统和JavaScript代码生成,我们迫不及待地想要将这些经验应用到实践中。 感谢Andrew Chan,Ben Drebing和Eddie Shiang对这个项目的贡献。如果你对这样的工作感兴趣,请在Figma与我们一起工作 (opens new window)