原文:www.figma.com/blog/how-we…git
在 Figma,咱们最近解决了迄今为止最大的工程挑战之一:支持插件。 咱们的插件 API 使第三方开发人员能够直接在基于浏览器的设计工具中运行代码,所以团队可使 Figma 适应本身的工做流程。他们能够用可访问性检查器测量对比度,用翻译应用程序转换语言,进口商能够用内容填充设计,以及其余需求。
程序员
咱们必须仔细设计该插件的功能。在整个软件历史中,有不少第三方扩展对平台产生负面影响的例子。在某些状况下,他们拖慢了工具的运行速度,在其余状况下,每当平台有新版本发布时,插件就会中断。咱们但愿在可控范围内,用户对 Figma 有更好的插件体验。github
此外,咱们但愿确保插件对用户而言是安全的,所以不能简单地使用 eval(PLUGIN_CODE)——不安全的典型定义! 可是,本质上运行插件能够归结为 eval。算法
更具挑战性的是,Figma 创建在一个很是规的堆栈上,有一些其余工具没有的限制。其中,设计编辑器基于 WebGL 和 WebAssembly,部分用户界面用 Typescript&React 实现,能够多人同时编辑一个文件。咱们依赖于浏览器技术的支持,同时也受到它们的限制。编程
这篇博客将引导你实现一个完美的插件解决方案。最终,咱们的工做归结为一个问题:如何安全地、稳定地、高性能地运行插件? api
咱们考虑了不少不一样路线的方法,进行了数周的讨论、原型制做和头脑风暴。这篇博客仅关注其中构成核心路径的三种尝试。跨域
在最初几周的研究中,咱们发现了许多有趣的尝试,如 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)结束。 注意:
咱们花了大概一个月时间构建起来,还邀请了一些 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 进行序列化。鉴于大多数插件都涉及诸如“在个人选择中交换两个项目”之类的快速操做,这将使插件没法使用。
增量或者延迟加载数据也不现实,由于:
总而言之,因为 Figma 文档可能包含大量互相依赖的数据,<inline-iframe>方案不适合咱们。
简单的方案行不通,咱们从新开始,花了两周时间认真考虑更多奇特的想法。可是大多数方法都有一个或多个主要缺陷:
最终咱们得出的结论是,须要找到一种能够直接操做 document 的方法。编写插件应该像设计师在自动化动做,所以应该容许插件运行在主线程上。
在第二次尝试以前,咱们须要从新审视容许插件运行在主线程上的含义,咱们起初没有考虑它,由于知道可能很危险,在主线程上运行听起来很像 eval(UNSAFE_CODE)。
在主线程上运行的好处是插件能够:
可是,如今咱们遇到了如下问题:
咱们决定放弃对(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 这种动态解释语言的方式。
对于像咱们这样的小型创业公司来讲,实现 JavaScript 太繁重了,为了验证这种方法,咱们使用 Duktape(一种 C++ 编写的轻量级 JavaScript 解释器),将其编译为 WebAssembly。
咱们在上面运行了标准 JavaScript 测试套件 test262,它经过了全部 ES5 测试,一些不重要的测试除外。使用 Duktape 运行插件代码,须要调用已编译解释器的 eval 函数。
这种方法的特性以下:
(注:几个月后,Fabrice Bellard 发布了 QuickJS,它自己就支持 ES6。)
如今,编译一个 JavaScript 解释器! 做为程序员你可能会想到:
太赞了!
或者
真的吗?已有 JavaScript 引擎的浏览器中的 JavaScript 引擎?接下来是什么,浏览器中的操做系统吗?
有些怀疑是对的!除非必要,最好避免从新实现浏览器。咱们已经花费了不少精力实现整个渲染系统,作到了必不可少的性能和跨浏览器支持,可是咱们仍然尽可能不从新发明轮子。
这不是咱们最终采用的方法,有一个更好的方法。可是,覆盖这一点很重要,由于这是理解咱们最终的沙箱模型的一个步骤,该模型更为复杂。
尽管咱们有一种编译 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 的限制。
当 <inline-iframe> 与父 document 同源时:
1. 它拥有全部全局变量的副本,如 Object.prototype
2. 能够从父 document 访问这些全局变量。
将这些全局变量放入 Proxy 对象的白名单,这样插件就能够访问到。 最后,这个新的 <inline-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 的<inline-iframe>来解决这个问题。插件能够建立 <inline-iframe> 并在其中放置任意的 HTML 和 Javascript。
与咱们最初尝试使用 <inline-iframe> 不一样的是,如今插件由两部分组成:
1. Realms 沙箱内,运行在主线程上,能够访问 Figma document 的部分。
2. 运行在 <inline-iframe> 内,能够访问浏览器 API 的部分。
这两部分能够经过消息传递通讯。这种结构比起在同一个环境中运行两个部分,会使浏览器 API 用起来更加繁琐。 可是,鉴于当前的浏览器技术,这是咱们能作到的最好方法了。咱们发布测试版两个月以来,它并无阻止开发人员建立出色的插件。
咱们可能走了一段弯路,但最终找到了在 Figma 中实现插件的可行方案。Realm shim 使咱们可以隔离第三方代码,同时在相似浏览器的环境中运行。
这对咱们来讲是最好的解决方案,但可能并不适用于每一个公司或平台。若是你须要隔离第三方代码,则值得评估一下是否存在与咱们类似的性能或 API 工程学方面的问题,若是没有,那么使用 iframe 隔离代码就足够了,简单老是好的。咱们但愿保持简单!
最后,咱们很是关注最终的用户体验——插件的用户将发现它们稳定可靠,具有基本 Javascript 知识的开发人员也可以建立。
在基于浏览器的设计工具的团队中工做,最让人激动的事情之一就是,可以遇到不少未知领域,而且创造解决此类技术难题的新方法。若是您喜欢这些工程冒险之旅,请查看咱们博客的其他部分获取更多信息。