Figma: 如何在 Web 上构建一个插件系统

527次阅读  |  发布于2年以前

在 Figma,我们最近解决了迄今为止最大的工程挑战之一:支持插件。我们的插件 API 使第三方开发人员可以直接在基于浏览器的设计工具中运行代码,因此团队可以使 Figma 适应自己的工作流程。他们可以用可访问性检查器测量对比度,用翻译应用程序转换语言,进口商可以用内容填充设计,以及其他需求。

我们必须仔细设计该插件的功能。在整个软件历史中,有很多第三方扩展对平台产生负面影响的例子。在某些情况下,他们拖慢了工具的运行速度,在其他情况下,每当平台有新版本发布时,插件就会中断。我们希望在可控范围内,用户对 Figma 有更好的插件体验。

此外,我们希望确保插件对用户而言是安全的,因此不能简单地使用 eval(PLUGIN_CODE)——不安全的典型定义!但是,本质上运行插件可以归结为 eval。

更具挑战性的是,Figma 建立在一个非常规的堆栈上,有一些其他工具没有的限制。其中,设计编辑器基于 WebGL 和 WebAssembly,部分用户界面用 Typescript&React 实现,可以多人同时编辑一个文件。我们依赖于浏览器技术的支持,同时也受到它们的限制。

这篇博客将引导你实现一个完美的插件解决方案。最终,我们的工作归结为一个问题:如何安全地、稳定地、高性能地运行插件?

我们考虑了很多不同路线的方法,进行了数周的讨论、原型制作和头脑风暴。这篇博客仅关注其中构成核心路径的三种尝试。

尝试1:沙箱

在最初几周的研究中,我们发现了许多有趣的尝试,如 code-to-code 的转换,但是,大多数未经生产环境应用程序验证,存在一定的风险。

最后我们尝试了最接近标准沙箱的方法:标签,运行第三方代码的应用中有用到,如 CodePen。

不是普通的 HTML 标签,要了解为什么它是安全的,有必要考虑一下需要保证哪些特性。通常用于将一个网站嵌入另一个网站,例如yelp.com 中嵌入的 Google Map。

在这里,你不会希望 Yelp 仅通过嵌入就能读取 Google 网站中的内容(可能有私人的用户信息),同样地,也不希望 Google 读取 Yelp 网站中的内容。

这意味着与的通信受到浏览器的严格限制。如果的 origin 与容器不同(例如 yelp.com 与 google.com),则它们是完全隔离的,与通信的唯一方法是消息传递。这些消息都是纯字符串,收到消息后,网站可以自行处理或者忽略。HTML 规范允许浏览器将作为单独的进程实现。

了解了的工作原理后,我们可以在每次插件运行时创建一个新的,将代码嵌入中来实现插件,插件可以在内执行任何所需的操作。但是只有通过明确的白名单消息,它才能与 Figma document 交互,并且的 origin 为 null,任何往 figma.com 发出的请求都会被浏览器的跨域资源共享策略拒绝。

实际上,充当了插件的沙箱,沙箱的安全性由浏览器供应商保证,他们花了多年时间寻找并修复沙箱中的漏洞。

采用沙箱模型的插件将使用我们添加到沙箱中的 API,大致如下所示:

const scene = await figma.loadScene() // gets data from the main thread
scene.selection[0].width *= 2
scene.createNode({
  type: 'RECTANGLE',
  x: 10, y: 20,
  ...
})
await figma.updateScene() // flush changes back, to the main thread

关键在于插件通过调用 loadScene(发送消息给 Figma 获取 document 的副本)进行初始化,并以调用 updateScene(将插件所做的更改发回给 Figma)结束。注意:

  1. 我们获取 document 的副本,而不是每次读写属性都使用消息传递。消息传递的开销约为每个往返0.1ms,这样每秒只能处理1000条左右的消息。
  2. 不直接使用 postMessage,因为使用起来很麻烦。

我们花了大概一个月时间构建起来,还邀请了一些 Alpha 测试人员,很快就发现了两个主要缺陷:

1. async/await 对用户不够友好

我们得到的第一个反馈是,用户在使用 async/await 时遇到了麻烦。在这种方法中,这是不可避免的。消息传递从根本上讲是一种异步操作,JavaScript 无法对异步操作进行同步的阻塞调用,至少需要使用 await 关键字将所有调用函数标记为异步。总的来说,async/await 仍然是一个相当新的 JavaScript 功能,并且需要对并发性有所解。这是个问题,因为我们预计许多插件开发人员都对 JavaScript 熟悉,但可能没有接受过正规的 CS 教育。

如果只需要在插件开始时使用一次 await,结束时使用一次 await,那还不错。我们只是告诉开发人员,即使不太了解它的功能,也要始终在 loadScene 和 updateScene 使用 await。

问题是某些 API 调用需要大量复杂的逻辑计算,更改一个 layer 上的属性有时会导致多个 layer 更新,例如调整 frame 的大小将递归地应用于子元素上。

这些行为通常是精细复杂的算法,为插件重新实现它们是个坏主意。我们编译好的 WebAssembly 也存在同样的逻辑,因此不太好重用。而且,如果不在插件的沙箱中运行这些逻辑,插件将读取过时的数据。

虽然下面这样是可控的:

await figma.loadScene()
... do stuff ...
await figma.updateScene()

即便是有经验的工程师,也可能很快变得难以处理:

await figma.loadScene()
... do stuff ...
await figma.updateScene()
await figma.loadScene()
... do stuff ...
await figma.updateScene()
await figma.loadScene()
... do stuff ...
await figma.updateScene()

2.复制 scene 代价很昂贵

方法的第二个问题是,在发送给插件前需要序列化大部分document。事实证明,用户可能在 Figma 中创建非常大的文档,以至于达到内存限制。例如,Microsoft 的设计系统文件,需要花费14秒才能对 document 进行序列化。鉴于大多数插件都涉及诸如在我的选择中交换两个项目之类的快速操作,这将使插件无法使用。

增量或者延迟加载数据也不现实,因为:

  1. 可能需要数月时间重构核心产品
  2. 任何需要等待数据到达的 API 都将是异步的。

总而言之,由于 Figma 文档可能包含大量互相依赖的数据,方案不适合我们。

回到画板和主线程

简单的方案行不通,我们重新开始,花了两周时间认真考虑更多奇特的想法。但是大多数方法都有一个或多个主要缺陷:

  1. API 太难用(如使用 REST API 或类似 GraphQL 的方法访问 document)
  2. 依赖浏览器供应商已删除或试验中的功能(如同步 xhr + service worker, shared buffers)
  3. 需要大量的研究或重构应用,可能要花费数月时间,甚至无法验证能否正常工作 (例如,在 iframe 中加载 Figma 的副本,然后通过 CRDTs 进行同步,通过交叉编译的生成器在 JavaScript 中侵入绿色线程?)

最终我们得出的结论是,需要找到一种可以直接操作 document 的方法。编写插件应该像设计师在自动化动作,因此应该允许插件运行在主线程上。

在第二次尝试之前,我们需要重新审视允许插件运行在主线程上的含义,我们起初没有考虑它,因为知道可能很危险,在主线程上运行听起来很像 eval(UNSAFE_CODE)。

在主线程上运行的好处是插件可以:

  1. 直接修改 document 而不是副本,消除了加载时间的问题。
  2. 运行复杂的组件更新和约束逻辑,无需两份代码。
  3. 进行同步 API 调用,加载或刷新不会造成混淆。
  4. 用更直观的方式编写:插件只是自动执行用户原本可以使用 UI 手动执行的操作。

但是,现在我们遇到了以下问题:

  1. 插件可能会挂起,且无法中断。
  2. 插件可以向 figma.com 发送网络请求。
  3. 插件可以访问和修改全局状态。包括修改 UI,在 API 外部建立对内部应用状态的依赖,或进行彻头彻尾的恶意操作,例如更改 ({}).proto 的值,这会使所有 JavaScript 对象都中毒。

我们决定放弃对(1)的要求,当插件冻结时,会影响 Figma 被感知的稳定性。但是,我们的插件模型在明确的用户操作下可以正常运行。在插件运行时更改 UI,冻结总是会归因于插件。这也意味着插件不能 破坏 document。

eval 很危险意味着什么?

为了解决插件能够发送网络请求并访问全局状态的问题,首先需要正确理解 随意的eval JavaScript 代码是危险的 的含义。

如果 JavaScript 变量只能进行类似 7 * 24 * 60 * 60的算术运算(简称SimpleScript),执行 eval 是很安全的。向 SimpleScript 添加一些功能,例如变量赋值和if 语句,使其更像一种编程语言,仍然是非常安全的。添加函数求值,就有了 lambda 演算和图灵完整性。

换句话说,JavaScript 不一定是危险的。在最简单的情况下,它只是算术运算的一种扩展方式,当它访问输入和输出时比较危险,包括网络、DOM 等,危险的是这些浏览器 API。

API 都是全局变量,所以隐藏全局变量!

从理论上讲,隐藏全局变量听起来不错,但是仅通过隐藏它们来保证安全是困难的。比如,你可能考虑删除 window 对象上的所有属性,或将其设置为 null,但是代码仍然可以访问诸如 ({}).constructor 之类的全局变量。寻找所有可能泄漏全局变量的方式非常具有挑战性。

相反,我们需要一种更强大的沙箱,在这些沙箱里,全局变量首先就不存在。

说到先前仅支持算术运算的 SimpleScript 示例,编写算术求值程序是 CS 101的一个简单练习,在该程序的任何合理实现中,SimpleScript 都不能执行算术运算之外的任何操作。

现在,扩展 SimpleScript 支持更多的语言功能,直到它变成 JavaScript ,这样的程序称为解释器,这是运行 JavaScript 这种动态解释语言的方式。

尝试2:将 JavaScript 解释器编译为 WebAssembly

对于像我们这样的小型创业公司来说,实现 JavaScript 太繁重了,为了验证这种方法,我们使用 Duktape(一种 C++ 编写的轻量级 JavaScript 解释器),将其编译为 WebAssembly。

我们在上面运行了标准 JavaScript 测试套件 test262,它通过了所有 ES5 测试,一些不重要的测试除外。使用 Duktape 运行插件代码,需要调用已编译解释器的 eval 函数。

这种方法的特性如下:

  1. 解释器运行在主线程中,意味着可以创建基于主线程的 API。
  2. 容易推理出是安全的。Duktape 不支持任何浏览器 API,此外,它作为 WebAssembly 运行,而 WebAssembly 本身是一个沙箱环境,无法访问浏览器 API。换句话说,默认情况下,插件代码只能通过明确列入白名单的 API 与外界通信。
  3. 比常规 JavaScript 慢,因为该解释器不是 JIT 的,但这没关系。
  4. 需要浏览器编译一个中等大小的 WASM 二进制文件,需要一定的成本。
  5. 浏览器调试工具默认情况下不可用,我们花了一天时间为解释器实现一个控制台,说明至少可以调试插件。
  6. Duktape 仅支持 ES5,但是使用 Babel 这样的工具交叉编译较新的 JavaScript 版本已成为网络社区的常规操作。

(注:几个月后,Fabrice Bellard 发布了 QuickJS,它本身就支持 ES6。)

现在,编译一个 JavaScript 解释器!作为程序员你可能会想到:

太赞了!

或者

真的吗?已有 JavaScript 引擎的浏览器中的 JavaScript 引擎?接下来是什么,浏览器中的操作系统吗?

有些怀疑是对的!除非必要,最好避免重新实现浏览器。我们已经花费了很多精力实现整个渲染系统,做到了必不可少的性能和跨浏览器支持,但是我们仍然尽量不重新发明轮子。

这不是我们最终采用的方法,有一个更好的方法。但是,覆盖这一点很重要,因为这是理解我们最终的沙箱模型的一个步骤,该模型更为复杂。

尝试3:Realms

尽管我们有一种编译 JS 解释器的好方法,但还有另外一种工具,由 Agoric 创造的称为 Realms shim 的技术。该技术将创建沙箱和支持插件作为潜在用例,Realms API 大大致如下:

let g = window; // outer global
let r = new Realm(); // realm object

let f = r.evaluate("(function() { return 17 })");

f() === 17 // true

Reflect.getPrototypeOf(f) === g.Function.prototype // false
Reflect.getPrototypeOf(f) === r.global.Function.prototype // true

实际上,可以使用已有的(尽管鲜为人知的)JavaScript 功能来实现该技术,沙箱可以隐藏全局变量,shim 起作用的核心大致如下:

function simplifiedEval(scopeProxy, userCode) {
  'use strict'
  with (scopeProxy) {
    eval(userCode)
  }
}

这是用于演示的简化版本,实际版本中还有一些细微差异,但是,它展示了难题的关键部分:with 语句 和 Proxy 对象。

with(obj) 创建了一个新的作用域,在该作用域内可以使用 obj 的属性来解析变量。在下例中,我们可以从 Math 对象的属性中解析出变量 PI,cos 和 sin ,而 console 是从全局作用域解析的,它不是 Math 的属性。

with (Math) {
  a = PI * r * r
  x = r * cos(PI)
  y = r * sin(PI)
  console.log(x,  y)
}

Proxy 对象是 JavaScript 对象最动态的形式。

尝试访问以下 proxy 上的任何属性(白名单中的除外),将返回 undefined。

const scopeProxy = new Proxy(whitelist, {
  get(target, prop) {
    // here, target === whitelist
    if (prop in target) {
      return target[prop]
    }
    return undefined
  }
}

现在,将这个 proxy 作为 with 的参数,它将截获所有变量解析,永远不会使用全局作用域:

with (proxy) {
  document // undefined!
  eval("xhr") // undefined!
}

好吧,仍然可以通过 ({}).constructor 这样的表达式访问某些全局变量。此外,沙箱确实需要访问某些全局变量,如 Object,它常出现在合法的 JavaScript 代码(如 Object.keys )中。

为了使插件能够访问全局变量又不弄乱 window 对象,Realms 沙箱创建了一个同源 iframe 来实例化所有这些全局变量的副本。这个 iframe 与尝试1中的版本不同,同源 iframe 不受 CORS 的限制。

iframe 与父 document 同源时:

  1. 它拥有所有全局变量的副本,如 Object.prototype
  2. 可以从父 document 访问这些全局变量。

将这些全局变量放入 Proxy 对象的白名单,这样插件就可以访问到。最后,这个新的iframe 带有一个 eval 函数的副本,与现有的 eval 函数有一个重要区别:即便是只能通过 ({}).constructor 这样的语法访问的内置值,也会解析为 iframe 中的副本

这种使用 Realms 的沙箱方法具有许多不错的特性:

但是它安全吗?

使用 Realms 安全地实现 API

我们对 Realms 的沙箱功能感到满意。尽管比 JavaScript 解释器方法包含更多微妙之处,它仍然可以作为白名单,其实现规模较小且易于审核,并且是由网络社区中德高望重的成员创建的。

但是,使用 Realms 并不是故事的结局,这仅仅是一个沙箱,插件无法执行任何操作,我们仍然需要实现提供 API 的插件。这些 API 也要保证安全,因为大多数插件确实需要显示 UI 并发送网络请求(例如,使用 Google 表格中的数据填充设计)。

考虑到默认情况下沙箱是不包含 console 对象的,毕竟 console 是浏览器 API,而不是 JavaScript 的功能,可以将其作为全局变量传递到沙箱。

realm.evaluate(USER_CODE, { log: console.log })

或者将原始值隐藏在函数中,这样沙箱就无法修改:

realm.evaluate(USER_CODE, { log: (...args) => { console.log(...args) } })

不幸的是,这是一个安全漏洞。即使在第二个例子中,匿名函数也是在 realm 之外创建的,然后直接提供给了 realm,这意味着插件可以沿着 log 函数的原型链到达沙箱外。

实现 console.log 的正确方法是将其包装在 realm 内创建的函数中,下面是一个简化的示例(实际上,也有必要转换 realms 抛出的所有异常)。

// Create a factory function in the target realm. 
// The factory return a new function holding a closure.
const safeLogFactory = realm.evaluate(`
        (function safeLogFactory(unsafeLog) { 
                return function safeLog(...args) {
                        unsafeLog(...args);
                }
        })
`);

// Create a safe function
const safeLog = safeLogFactory(console.log);

// Test it, abort if unsafe
const outerIntrinsics = safeLog instanceof Function;
const innerIntrinsics = realm.evaluate(`log instanceof Function`, { log: safeLog });
if (outerIntrinsics || !innerIntrinsics) throw new TypeError(); 

// Use it
realm.evaluate(`log("Hello outside world!")`, { log: safeLog });

通常,沙箱永远不能直接访问在沙箱外部创建的对象,因为它们可以访问全局作用域。同样重要的是,API 必须谨慎对待来自沙箱内部的对象,它们有可能与沙箱外部的对象混在一起。

这带来了一个问题。尽管可以创建安全的 API,但让开发人员每次向 API 添加新功能时,都担心难以捉摸的对象源语义是不可行的。该如何解决这个问题呢?

一个解释器一个API

问题在于,直接基于 Realms 创建 Figma API 会使每个 API 端点都需要审核,包括输入和输出值,这范围太大了。

尽管 Realms 沙箱中的代码使用相同的 JavaScript 引擎运行(为我们提供了便利的工具),仍然可以伪装成受到 WebAssembly 方法的限制。

考虑一下 Duktape,尝试2中编译为 WebAssembly 的 JavaScript 解释器。主线程 JavaScript 代码不可能直接保存沙箱中对象的引用,毕竟在沙箱中,WebAssembly 管理着自己的堆和这些堆中所有的 JavaScript 对象,实际上,Duktape 甚至可能不使用与浏览器引擎相同的内存来实现 JavaScript 对象!

结果,只有通过低阶操作(例如从虚拟机中复制整数和字符串)才能为 Duktape 实现API,可以在解释器内部保留对象或函数的引用,但只能作为不透明的控制代码。

这样的接口如下所示:

// vm == virtual machine == interpreter
export interface LowLevelJavascriptVm {
  typeof(handle: VmHandle): string

  getNumber(handle: VmHandle): number
  getString(handle: VmHandle): string

  newNumber(value: number): VmHandle
  newString(value: string): VmHandle
  newObject(prototype?: VmHandle): VmHandle
  newFunction(name: string, value: (this: VmHandle, ...args: VmHandle[]) => VmHandle): VmHandle

  // For accessing properties of objects
  getProp(handle: VmHandle, key: string | VmHandle): VmHandle
  setProp(handle: VmHandle, key: string | VmHandle, value: VmHandle): void
  defineProp(handle: VmHandle, key: string | VmHandle, descriptor: VmPropertyDescriptor): void

  callFunction(func: VmHandle, thisVal: VmHandle, ...args: VmHandle[]): VmCallResult
  evalCode(code: string): VmCallResult
}

export interface VmPropertyDescriptor {
  configurable?: boolean
  enumerable?: boolean
  get?: (this: VmHandle) => VmHandle
  set?: (this: VmHandle, value: VmHandle) => void
}

请注意,这是实现 API 用到的接口,但它或多或少 1:1 映射到 Duktape 的解释器 API。毕竟,Duktape(和类似的虚拟机)是专门为嵌入式设计的,且允许嵌入程序与 Duktape 通信。

使用此接口,对象 { x: 10,y: 10 } 可以这样传递给沙箱:

let vm: LowLevelJavascriptVm = createVm()
let jsVector = { x: 10, y: 10 }
let vmVector = vm.createObject()
vm.setProp(vmVector, "x", vm.newNumber(jsVector.x))
vm.setProp(vmVector, "y", vm.newNumber(jsVector.y))

Figma 节点对象 opacity 属性的 API 如下所示:

vm.defineProp(vmNodePrototype, 'opacity', {
  enumerable: true,
  get: function(this: VmHandle) {
    return vm.newNumber(getNode(vm, this).opacity)
  },
  set: function(this: VmHandle, val: VmHandle) {
    getNode(vm, this).opacity = vm.getNumber(val)
    return vm.undefined
  }
})

使用 Realms 沙箱同样可以很好地实现这个底层接口,这样实现的代码量是相对少的(我们的例子中大约 500 行代码)。然后就是仔细审核代码,一旦完成,便可以基于这些接口创建新的 API,而不用担心沙盒相关的安全性问题。在文献中,这称为膜模式。

本质上,这是将 JavaScript 解释器和 Realms 沙箱都视为 运行 JavaScript 的某些独立环境。

在沙箱上创建底层抽象还有一个关键,尽管我们对 Realms 的安全性充满信心,但在安全性方面再小心也不为过。我们意识到 Realms 可能存在未被发现的漏洞,某天会变成需要处理的问题,这就是接下来我们讨论编译解释器(甚至不会用到)的原因。API 是通过实现可互换接口实现的,所以使用解释器仍然是备选方案,可以在不重新实现任何 API 或不破坏任何现有插件的情况下使用它。

插件丰富的功能

现在,我们有了可以安全运行任意插件的沙箱和允许插件操作 Figma document 的 API,这已经开启了很多可能性。

但是,我们最初的问题是为设计工具构建一个插件系统,大部分这样的插件都有创建 UI 的功能,需要某种形式的网络访问。更一般地说,我们希望插件尽可能多地利用浏览器和 JavaScript 生态系统。

像前面 console.log 的例子那样,我们可以每次小心地暴露一个安全的受限版本的浏览器 API。但是,浏览器 API(尤其是 DOM)的范围很大,甚至比 JavaScript 本身还要大。这样的尝试可能由于过于严格而无法使用,或者可能存在安全漏洞。

我们再次引入 origin 为 null 的iframe来解决这个问题。插件可以创建iframe并在其中放置任意的 HTML 和 Javascript。

与我们最初尝试使用 iframe 不同的是,现在插件由两部分组成:

  1. Realms 沙箱内,运行在主线程上,可以访问 Figma document 的部分。
  2. 运行在iframe内,可以访问浏览器 API 的部分。

这两部分可以通过消息传递通信。这种结构比起在同一个环境中运行两个部分,会使浏览器 API 用起来更加繁琐。但是,鉴于当前的浏览器技术,这是我们能做到的最好方法了。我们发布测试版两个月以来,它并没有阻止开发人员创建出色的插件。

结论

我们可能走了一段弯路,但最终找到了在 Figma 中实现插件的可行方案。Realm shim 使我们能够隔离第三方代码,同时在类似浏览器的环境中运行。

这对我们来说是最好的解决方案,但可能并不适用于每个公司或平台。如果你需要隔离第三方代码,则值得评估一下是否存在与我们相似的性能或 API 工程学方面的问题,如果没有,那么使用 iframe 隔离代码就足够了,简单总是好的。我们希望保持简单!

最后,我们非常关注最终的用户体验——插件的用户将发现它们稳定可靠,具备基本 Javascript 知识的开发人员也能够创建。

在基于浏览器的设计工具的团队中工作,最让人激动的事情之一就是,能够遇到很多未知领域,并且创造解决此类技术难题的新方法。如果您喜欢这些工程冒险之旅,请查看我们博客的 其余部分获取更多信息。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8