[译]如何在 Web 上构建一个插件系统

原文:www.figma.com/blog/how-we…git

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

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

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

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

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

咱们考虑了不少不一样路线的方法,进行了数周的讨论、原型制做和头脑风暴。这篇博客仅关注其中构成核心路径的三种尝试。跨域


尝试1:<inline-iframe>沙箱

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

最后咱们尝试了最接近标准沙箱的方法:<inline-iframe> 标签,运行第三方代码的应用中有用到,如 CodePen。安全

<inline-iframe> 不是普通的 HTML 标签,要了解为何它是安全的,有必要考虑一下须要保证哪些特性。<inline-iframe> 一般用于将一个网站嵌入另外一个网站,例如yelp.com 中嵌入的 Google Map。bash

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

这意味着与 <inline-iframe> 的通讯受到浏览器的严格限制。 若是 <inline-iframe> 的 origin 与容器不一样(例如 yelp.com 与 google.com),则它们是彻底隔离的,与 <inline-iframe> 通讯的惟一方法是消息传递。这些消息都是纯字符串,收到消息后,网站能够自行处理或者忽略。HTML 规范容许浏览器将 <inline-iframe> 做为单独的进程实现。

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

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

采用沙箱模型的插件将使用咱们添加到沙箱中的 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 代价很昂贵

<inline-iframe>方法的第二个问题是,在发送给插件前须要序列化大部分document。事实证实,用户可能在 Figma 中建立很是大的文档,以致于达到内存限制。例如,Microsoft 的设计系统文件,须要花费14秒才能对 document 进行序列化。鉴于大多数插件都涉及诸如“在个人选择中交换两个项目”之类的快速操做,这将使插件没法使用。

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

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

总而言之,因为 Figma 文档可能包含大量互相依赖的数据,<inline-iframe>方案不适合咱们。


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

  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 对象最动态的形式。

  • 最基本的 JavaScript 对象经过属性访问 obj.x 返回一个值。
  • 更高级的 JavaScript 对象能够有 getter 属性。
  • Proxy 经过执行 get 方法来拦截属性的访问。

尝试访问如下 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 的限制。

当 <inline-iframe> 与父 document 同源时:

1. 它拥有全部全局变量的副本,如 Object.prototype

2. 能够从父 document 访问这些全局变量。


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

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

  • 它运行在主线程上。
  • 速度很快,由于仍然使用浏览器的 JavaScript JIT 来执行代码。
  • 可使用浏览器开发者工具

可是它安全吗?

使用 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 的<inline-iframe>来解决这个问题。插件能够建立 <inline-iframe> 并在其中放置任意的 HTML 和 Javascript。

与咱们最初尝试使用 <inline-iframe> 不一样的是,如今插件由两部分组成:

1. Realms 沙箱内,运行在主线程上,能够访问 Figma document 的部分。

2. 运行在 <inline-iframe> 内,能够访问浏览器 API 的部分。

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


结论

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

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

最后,咱们很是关注最终的用户体验——插件的用户将发现它们稳定可靠,具有基本 Javascript 知识的开发人员也可以建立。

在基于浏览器的设计工具的团队中工做,最让人激动的事情之一就是,可以遇到不少未知领域,而且创造解决此类技术难题的新方法。若是您喜欢这些工程冒险之旅,请查看咱们博客的其他部分获取更多信息。

相关文章
相关标签/搜索