【第1803期】如何在 Web 上构建一个插件系统
前言
上周末的这篇新闻【译】WebAssembly 1.0成为W3C推荐标准,也是在浏览器中运行的第四种语言不知道你注意到了吗?今日早读文章由@夏华翻译分享,本文由@刘岩推荐。
正文从这开始~~
在 Figma,我们最近解决了迄今为止最大的工程挑战之一:支持插件。我们的插件 API 使第三方开发人员可以直接在基于浏览器的设计工具中运行代码,因此团队可以使 Figma 适应自己的工作流程。他们可以用可访问性检查器测量对比度,用翻译应用程序转换语言,进口商可以用内容填充设计,以及其他需求。
我们必须仔细设计该插件的功能。在整个软件历史中,有很多第三方扩展对平台产生负面影响的例子。在某些情况下,他们拖慢了工具的运行速度,在其他情况下,每当平台有新版本发布时,插件就会中断。我们希望在可控范围内,用户对 Figma 有更好的插件体验。
此外,我们希望确保插件对用户而言是安全的,因此不能简单地使用 eval(PLUGIN_CODE)——不安全的典型定义!但是,本质上运行插件可以归结为 eval。
更具挑战性的是,Figma 建立在一个非常规的堆栈上,有一些其他工具没有的限制。其中,设计编辑器基于 WebGL 和 WebAssembly,部分用户界面用 Typescript&React 实现,可以多人同时编辑一个文件。我们依赖于浏览器技术的支持,同时也受到它们的限制。
这篇博客将引导你实现一个完美的插件解决方案。最终,我们的工作归结为一个问题:如何安全地、稳定地、高性能地运行插件?
我们考虑了很多不同路线的方法,进行了数周的讨论、原型制作和头脑风暴。这篇博客仅关注其中构成核心路径的三种尝试。
尝试1: <inline-iframe>
沙箱
在最初几周的研究中,我们发现了许多有趣的尝试,如 code-to-code 的转换,但是,大多数未经生产环境应用程序验证,存在一定的风险。
最后我们尝试了最接近标准沙箱的方法:<inline-iframe>
标签,运行第三方代码的应用中有用到,如 CodePen。
<inline-iframe>
不是普通的 HTML 标签,要了解为什么它是安全的,有必要考虑一下需要保证哪些特性。<inline-iframe>
通常用于将一个网站嵌入另一个网站,例如yelp.com 中嵌入的 Google Map。
在这里,你不会希望 Yelp 仅通过嵌入就能读取 Google 网站中的内容(可能有私人的用户信息),同样地,也不希望 Google 读取 Yelp 网站中的内容。
这意味着与 <inline-iframe>
的通信受到浏览器的严格限制。如果 <inline-iframe>
的 origin 与容器不同(例如 yelp.com 与 google.com),则它们是完全隔离的,与通信的唯一方法是消息传递。这些消息都是纯字符串,收到消息后,网站可以自行处理或者忽略。HTML 规范允许浏览器将 <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)结束。注意:
我们获取 document 的副本,而不是每次读写属性都使用消息传递。消息传递的开销约为每个往返0.1ms,这样每秒只能处理1000条左右的消息。
不直接使用 postMessage,因为使用起来很麻烦。
我们花了大概一个月时间构建起来,还邀请了一些 Alpha 测试人员,很快就发现了两个主要缺陷:
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()
复制 scene 代价很昂贵
<inline-iframe>
方法的第二个问题是,在发送给插件前需要序列化大部分document。事实证明,用户可能在 Figma 中创建非常大的文档,以至于达到内存限制。例如,Microsoft 的设计系统文件,需要花费14秒才能对 document 进行序列化。鉴于大多数插件都涉及诸如“在我的选择中交换两个项目”之类的快速操作,这将使插件无法使用。
增量或者延迟加载数据也不现实,因为:
可能需要数月时间重构核心产品
任何需要等待数据到达的 API 都将是异步的。
总而言之,由于 Figma 文档可能包含大量互相依赖的数据, <inline-iframe>
方案不适合我们。
简单的方案行不通,我们重新开始,花了两周时间认真考虑更多奇特的想法。但是大多数方法都有一个或多个主要缺陷:
API 太难用(如使用 REST API 或类似 GraphQL 的方法访问 document)
依赖浏览器供应商已删除或试验中的功能(如同步 xhr + service worker, shared buffers)
需要大量的研究或重构应用,可能要花费数月时间,甚至无法验证能否正常工作 (例如,在 iframe 中加载 Figma 的副本,然后通过 CRDTs 进行同步,通过交叉编译的生成器在 JavaScript 中侵入绿色线程?)
最终我们得出的结论是,需要找到一种可以直接操作 document 的方法。编写插件应该像设计师在自动化动作,因此应该允许插件运行在主线程上。
在第二次尝试之前,我们需要重新审视允许插件运行在主线程上的含义,我们起初没有考虑它,因为知道可能很危险,在主线程上运行听起来很像 eval(UNSAFE_CODE)。
在主线程上运行的好处是插件可以:
直接修改 document 而不是副本,消除了加载时间的问题。
运行复杂的组件更新和约束逻辑,无需两份代码。
进行同步 API 调用,加载或刷新不会造成混淆。
用更直观的方式编写:插件只是自动执行用户原本可以使用 UI 手动执行的操作
但是,现在我们遇到了以下问题:
插件可能会挂起,且无法中断。
插件可以向 figma.com 发送网络请求。
插件可以访问和修改全局状态。包括修改 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 函数。
这种方法的特性如下:
解释器运行在主线程中,意味着可以创建基于主线程的 API。
容易推理出是安全的。Duktape 不支持任何浏览器 API,此外,它作为 WebAssembly 运行,而 WebAssembly 本身是一个沙箱环境,无法访问浏览器 API。换句话说,默认情况下,插件代码只能通过明确列入白名单的 API 与外界通信。
比常规 JavaScript 慢,因为该解释器不是 JIT 的,但这没关系。
需要浏览器编译一个中等大小的 WASM 二进制文件,需要一定的成本。
浏览器调试工具默认情况下不可用,我们花了一天时间为解释器实现一个控制台,说明至少可以调试插件。
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 = newRealm(); // 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 = newProxy(whitelist, {
get(target, prop) {
// here, target === whitelist
if(prop in target) {
return target[prop]
}
returnundefined
}
}
现在,将这个 proxy 作为 with 的参数,它将截获所有变量解析,永远不会使用全局作用域:
with(proxy) {
document // undefined!
eval("xhr") // undefined!
}
好吧,仍然可以通过 ({}).constructor 这样的表达式访问某些全局变量。此外,沙箱确实需要访问某些全局变量,如 Object,它常出现在合法的 JavaScript 代码(如 Object.keys )中。
为了使插件能够访问全局变量又不弄乱 window 对象,Realms 沙箱创建了一个同源 iframe 来实例化所有这些全局变量的副本。这个 iframe 与尝试1中的版本不同,同源 iframe 不受 CORS 的限制。
当 <inline-iframe>
与父 document 同源时:
它拥有所有全局变量的副本,如 Object.prototype
可以从父 document 访问这些全局变量。
将这些全局变量放入 Proxy 对象的白名单,这样插件就可以访问到。最后,这个新的带有一个 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 instanceofFunction;
const innerIntrinsics = realm.evaluate(`log instanceof Function`, { log: safeLog });
if(outerIntrinsics || !innerIntrinsics) thrownewTypeError();
// 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
exportinterfaceLowLevelJavascriptVm{
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
}
exportinterfaceVmPropertyDescriptor{
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>
不同的是,现在插件由两部分组成:
Realms 沙箱内,运行在主线程上,可以访问 Figma document 的部分
运行在 内,可以访问浏览器 API 的部分
这两部分可以通过消息传递通信。这种结构比起在同一个环境中运行两个部分,会使浏览器 API 用起来更加繁琐。但是,鉴于当前的浏览器技术,这是我们能做到的最好方法了。我们发布测试版两个月以来,它并没有阻止开发人员创建出色的插件。
结论
我们可能走了一段弯路,但最终找到了在 Figma 中实现插件的可行方案。Realm shim 使我们能够隔离第三方代码,同时在类似浏览器的环境中运行。
这对我们来说是最好的解决方案,但可能并不适用于每个公司或平台。如果你需要隔离第三方代码,则值得评估一下是否存在与我们相似的性能或 API 工程学方面的问题,如果没有,那么使用 iframe 隔离代码就足够了,简单总是好的。我们希望保持简单!
最后,我们非常关注最终的用户体验——插件的用户将发现它们稳定可靠,具备基本 Javascript 知识的开发人员也能够创建。
在基于浏览器的设计工具的团队中工作,最让人激动的事情之一就是,能够遇到很多未知领域,并且创造解决此类技术难题的新方法。如果您喜欢这些工程冒险之旅,请查看我们博客的其余部分获取更多信息。
关于本文 译者:@夏华 译文:https://juejin.im/post/5d91b9bf518825539312f82e 作者:Rudi Chen 原文:https://www.figma.com/blog/how-we-built-the-figma-plugin-system/#an-api-for-an-interpreter
为你推荐
【第1711期】Webpack优化——将你的构建效率提速翻倍