原文:https://www.figma.com/blog/how-we-built-the-figma-plugin-system/
Rudi Chen August 22, 2019
_
Plugins_Engineering.jpeg

注:我们发布这篇文章之后决定改变沙箱的实现,用一种替代方案:编译一个用 C 语言编写的 Javascript 虚拟机(VM)到 WebAssembly。正如你所看到的,它是我们权衡的几种方案之一。
我们是在 Realms shim(我们一开始用的)的一个安全漏洞在私底下被告知我们后,决定实现该替代方案。这个安全漏洞被 Realms shim 团队及时的修复了,在该漏洞公开之前,没有任何证据证明该漏洞被利用过。想了解更多该漏洞和我们解决的办法,它在这里

在 Figma,我们在近期解决了迄今为止最大的工程挑战之一: 支持插件开发。我们的插件 API 可以让第三方开发者直接运行代码在我们的设计工具(基于浏览器开发)里,这样设计团队就可以将 Figma 集成到他们自己的工作流中。他们可以让 accessibility checkers 来度量对比度,translation apps 来转换语言,importers 引入内容来填充设计,和其他他们想干的任何事。

我们知道我们需要非常小心地设计该插件功能。纵观整个软件历史,有非常多的第三方扩展对平台产生负面影响的例子。在某些情况下,它们会拖慢工具(平台);在另一些情况下,一旦平台的版本更新了插件就会崩溃。在某种程度上,我们希望能控制插件,希望用户在 Figma 上能有一个好的插件使用体验。

而且,我们希望确保插件能安全的运行,所以我们知道我们不会简单的 eval(PLUGIN_CODE) ,这是不安全的典型定义。然而, eval 也是插件运行的本质。

为了达到这个挑战,Figma 被建立在一个非传统的带有限制的技术栈之上,这个是以前的工具所没有的。我们的设计编辑器由 WebGLWebAssembly 开发,搭配一些用 Typescript 和 React 开发的用户界面。多人可以同时编辑一个文件。我们得益于浏览器技术,同时也被它们所限制。

这篇博客会带你回顾我们对一个完美的插件解决方案的追求。最终,我们的努力归结到一个问题:怎样让插件安全,稳定,高性能地运行?下面是对我们重要限制的简要总结:

安全
  • 插件只有权限访问显式启动(explicitly launched)的文件
  • 插件被限制在当前的文件
  • 插件不能像 Figma.com 一样发起请求
  • 插件不能访问用户的数据除非用户愿意
  • 插件不能破坏 Figma UI 和误导用户(比如网络钓鱼)

稳定
  • 插件不能拖慢 Figma,以至于让其不可用
  • 插件不能打破产品里的关键不变量(break key invariant)
  • 插件不应该为了打开一个文件而跨过设备/用户去安装
  • Figma 产品内部 API 不应该经常修改,否则会使现有的插件崩溃

开发简单
  • 插件开发应该足够简单,这样才能支撑一个充满活力的生态。我们绝大多数用户是设计师,并且仅有很小一部分人有少量的 Javascript 开发经验。
  • 开发者应该能够使用现有的调试工具

性能
  • 插件应该运行的足够快来支持绝大部分用户用例,比如在文件中搜索一个图层,生成表格等

我们考虑了许多不同的方式来导向各种不同的道路,我们经历了几星期的讨论,原型设计和头脑风暴。这篇文章会关注三种尝试,这三种尝试形成了我们探索的最核心道路。

对于我,这段经历原则上成为了我最满意的思维练习。我有趣的利用了我在课堂上学到的计算机基础知识(很多我以为在真实世界里不会用到的)。

尝试一:沙箱

在我们最初的几个星期,我们发现许多第三方代码沙箱的有趣尝试,一些使用了 代码转换 的技术。然而,许多并没有得到生产型应用的验证,所以带来了一些风险。

最后,我们的第一个尝试选择了一个最接近标准沙箱的方案: <iframe> 。它被用在需要运行第三方代码的应用里,例如 CodePen。

<iframe> 不是常用的 HTML 标签。为了理解为什么 <iframe> 是安全的,有必要去思考哪些安全属性他们需要保证。一个 <iframe> 的典型应用是将一个网站嵌入另一个网站。例如,在下面的截图中,你可以看到 Yelp.com 将 Google.com/maps 嵌入进来提供地图功能。

Yelp_iFrame_Plugins_Eng.gif

在这里,你不希望 Yelp 能读取 Google 网站里面的内容仅仅是因为嵌入了它,因为可能有私人的用户信息在那。类似的,你也不想让 Google 有能力读取 Yelp 里面的内容仅仅是因为被嵌入了。

这意味着 <iframe> 对外的交流被浏览器严格的限制了。当一个 <iframe> 和其容器有不同的源的时候(比如:Yelp.com 和 Google.com),他们被完全隔离了。这时候,唯一和 <iframe> 的交流方式只有 消息传递 。这些消息,不管出于什么意图和目的,都只能是纯字符串。在收到消息后,每个网站都可以选择去处理这些消息,或者忽略它们。

它们是如此的隔离,实际上,HTML 规范允许浏览器通过分离的进程来实现 <iframe> ,如果他们愿意的话。

现在我们知道 <iframe> 是怎样工作的,我们可以每次运行插件的时候创建一个新的 <iframe> ,然后将插件代码粘贴到 <iframe> 里。插件可以在 <iframe> 里做任何事。然而,除非有明确的,在白名单之内的消息,否则它不能和 Figma 文档进行交互。 <iframe> 的源被设置为 null ,意味着任何向 Figma.com 发起的请求都会被 同源策略 拒绝。Plugins_Eng_run.png
实际上, <iframe> 为插件扮演了一个沙箱环境。而且,这些沙箱的安全属性能被浏览器厂商保证,这些厂商花费了多年的时间来寻找和修复沙箱的缺陷。一个使用沙箱模型的插件会使用我们加到沙箱里的 API,大致如下:

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

核心是插件通过调用 loadScene 初始化(向 Figma 发送一条消息获取文档的拷贝),然后通过调用 updateScene 结束(发送插件改变的东西返回给 Figma)。注意:

  • 我们获取了一份文档的拷贝来代替使用消息传递来读取属性。消息传递每次往返的开销为0.1ms,这将只允许每秒大约1000条消息。
  • 我们没有让插件直接使用 postMessage ,因为它用起来很笨重

我们使用该方法开发了大概一个月。我们甚至邀请了一些 alpha 测试者。然而,后面发现该方法有两个主要的缺陷。

1. async/await 用户体验不好

我们得到的第一个反馈是人们在使用 async/await 的时候碰到了麻烦。对于该方法,使用 async/await 是不可避免的。消息传递(Message-passing)本质上是一个异步操作,而且在 JavaScript 中无法对异步操作进行同步阻塞调用。至少,你需要 await 关键字,并且需要将所有函数调用标记为异步的。考虑到所有因素,async/await 还是相当新的 Javascript 特性,需要对并发有深入的了解。这是一个问题,因为我们预计大多数插件开发者只是对 Javascript 有所了解,但是没有正式受过 CS 教育的设计师。

现在,如果只需要在开头和结尾分别使用 await ,也不算太糟。我们只需要告诉开发者总是使用 await 来调用 loadSceneupdateScene ,哪怕他们不知道这究竟干了什么。

问题是一些 API 调用很多复杂的逻辑。改变一个图层的一个简单属性会导致多个图层的更新。例如,调整窗口的大下会递归的将约束应用到它的子窗口。

这些行为通常需要很复杂,微妙的算法。在插件上再重新实现一遍不是一个好主意。这些逻辑也存在编译后的 WebAssembly 二进制文件里,所以不好复用。并且如果我们不在插件的沙箱里运行这些逻辑,插件会读取过期的数据。

虽然这是可管理的:

  1. await figma.loadScene()
  2. ... do stuff ...
  3. await figma.updateScene()

但是会变得很笨拙,哪怕对于有经验的工程师来说:

  1. await figma.loadScene()
  2. ... do stuff ...
  3. await figma.updateScene()
  4. await figma.loadScene()
  5. ... do stuff ...
  6. await figma.updateScene()
  7. await figma.loadScene()
  8. ... do stuff ...
  9. await figma.updateScene()

2. 复制 scene 是昂贵的

使用 iframe 的第二个问题是,在发送它们给插件之前需要序列化大部分文档。

结果是人们会在 Figma 里创建很大的文档,达到了内存限制的程度。例如, Microsoft 的设计系统文件(去年我们花了一个月的时间优化),在插件能运行之前会花费14秒来序列化文档和发送给插件。考虑到大多数插件会需要快捷操作如 在选择中互换两个元素 ,这会让插件变得不可用。

递增或延迟加载数据也不是一个真正的选项,因为:
_

  1. 需要几个月的时间来重构我们的核心产品
  2. 任何需要等待尚未到达数据的 API 都将是异步的

总之,由于 Figma 文档有大量相互依赖关系的数据, <iframe> 不是很适合我们。

回到绘图板和主线程

由于 <iframe> 方法被排除了,我们要重新开始研究。

我们回到绘图板,并花费了两个星期来讨论不同的方法。由于简单的办法不起作用,我们认真考虑了更加奇特的想法。有很多,太多值得写入这篇文章中。

但是大多方法都有一到两个主要的致命缺陷:

  • API 使用难度太大(例如,通过 REST API 或 类 GraphQL 方法访问文档)
  • 依赖浏览器厂商移除的或准备移除的特性(例如, synchronous xhr + service worker, shared buffers)
  • 需要重要的研究性工作或花费几个月时间重构现有应用,在我们真正验证其能否正常工作之前。(例如,在 <iframe> 加载一个 Figma 的拷贝,用 CRDTS 同步操作,通过交叉编译将绿色线程嵌入到 JavaScript 的 generator 中)

在最后一天,我们总结出我们需要找到一种方法,来创建一个模型让插件直接操作文档。编写一个插件应该感觉像是设计师自动完成他们的操作一样。所以我们知道必须让插件运行在主线程上。

在主线程上运行的含义

在我们深入第二个尝试之前,我们需要退一步重新审视在主线程上运行插件意味着什么。毕竟,我们没有从一开始就考虑这么做,因为我们知道那是很危险的。在主线程运行插件非常像 eval(UNSAFE_CODE)

在主线程运行插件的好处:
_

  1. 直接编辑文档而不是拷贝它,排除了加载时间的问题
  2. 运行复杂的组件更新和带限制的逻辑而不需要维护两份代码的拷贝
  3. 在你想用同步API的时候发起同步API调用,不会再有加载和更新被冲掉(loading or flushing updates)的困惑
  4. 更直观的写法:插件只是一些自动化的操作来取代手动操作我们的 UI

然而,我们有以下的问题:
_

  1. 插件会挂起,并且没有方法打断它
  2. 插件可以像 Figma.com 那样发起网络请求
  3. 插件可以访问和修改全局状态。这包括修改我们的 UI,在API外部创建对内部应用程序状态的依赖,做一些完全恶意的事情像改变 ({}).__proto__ 以至于影响每一个新建的,已存在的对象

我们决定不管要求(1)。当插件冻结时,它会可感知地影响 Figma 的稳定性。然而,我们的插件模型的工作原理是,它们只能在显式的用户操作上运行。当插件运行的时候改变 UI,冻结总是归因于插件,也意味着插件不可能‘破坏’文档。

eval 是危险的是什么意思

为了解决插件可以发起网络请求和访问全局变量的问题,我们必须首先理解“eval 任意 Javascript 代码是危险的”的真正含义。

如果有一种 Javascript 变体,我们可以叫 SimpleScript,只有做数学运算的能力,例如 7 * 24 * 60 * 60 ,使用 eval 就非常安全。

你可以给 SimpleScript 加上变量声明和if语句使其像一门真正的编程语言,这也会非常安全。最后,它仍然可以归结为算术运算。再加上函数求值,现在你有了 lambda 演算和图灵完备性。

换言之,Javascript 不需要变得危险。从它最简化的形式来看,只不过是一个被扩展了的做算术运算的方式。真正危险的是当其访问输入/输出的时候,这包括网络访问,DOM 访问等。其实真正危险的是浏览器API。
**
而所有这些 API 都是全局的,所以隐藏这些全局变量吧!

隐藏全局变量

现在,理论上讲隐藏全局变量听起来不错,但是仅仅通过‘隐藏’它们是很难创建一个安全实现的。你或者会想,去掉 window 上的所有属性,或者将它们设置为 null , 但是代码仍然可以通过 ({}).constructor 来访问这些全局属性。要找出所有全局变量可能会泄露的方式是一个很大的挑战。

相反,我们需要更强形式的沙箱,在沙箱里,这些全局变量起初就不存在。

换句话说,Javascript 不需要变得危险

考虑到之前的例子,让 SimpleScript 只支持算术运算。那是一个简单的 CS 101 的练习,写一个算术求值的程序。在任何可行的程序实现中,SimpleScript 不能做算术运算之外的任何事情。

现在,扩展 SimpleScript 来支持更多的语言特性直到其变成 Javascript,这种程序被成为解释器,也就是 Javascript,一种动态解释的语言的运行方式。

尝试二:为 WebAssembly 编译一个 JavaScript 解释器

实现 Javascript 对于我们这种创业公司来说工作量太大。作为替代,为了验证这种方法,我们使用了 Duktape,一个由 C++ 编写的轻量级 Javascript 解释器,并将其编译进了 WebAssembly。

为了验证其能正常工作,我们跑了Javascript 的标准测试套件 test262 。除了几个不重要的,它通过了几乎所有的ES5测试。为了使用 Duktape 来运行插件,我们需要调用编译后引擎的 eval 函数。

这种方法有哪些特性?

  • 引擎运行在主线程,所以我们可以创建基于主线程的 API
  • 它在某种程度上是安全的,这很容易解释。Duktape 不支持任何浏览器 API - 这是一个特点。它作为 WebAssembly 运行在沙箱环境里,不能访问浏览器API。换句话说,插件只能通过显式的白名单API来和外界交流
  • 比常规的 Javascript 要慢,因为该引擎不支持 JIT,但也还好
  • 需要浏览器编译一个中等大小的 WASM 二进制文件,会有一定开销
  • 浏览器默认的调试工具不起作用,但是我们花了一天为该引擎实现了一个控制台,来验证至少其能调试插件
  • Duktape 只支持 ES5,但是在社区里使用 Babel 这样的工具将新版本的 Javascript 编译到 ES5 是很常见的做法

(备注:一个月后,Fabrice Bellard 发布了 QuickJS,能原生支持 ES6。)

现在,编译一个 Js 解释器吧!出于你作为程序员的喜好和审美,你可能会想:

这太棒了!🤩


真的吗?一个已经有 Js 引擎的浏览器中的 Js 引擎?🤨 下一步是什么,在浏览器中的操作系统?

一定程度的怀疑是有益的!能避免重新实现一个浏览器是最好的,除非我们真的需要。我们已经花费了很多的努力去实现一个完整的渲染引擎。这是完全必要的,为了性能和跨浏览器支持,我们很高兴做了这件事,但是我们仍然试图不去重复制造轮子。

最后,我们没有采取这种方法,因为还有一种更好的实现方式。然而,对于理解我们最终的沙箱模型来说,这是非常重要的一步,因为沙箱模型更加复杂。

尝试三:Realms

虽然我们有一个很有希望的方法来编译一个JS解释器,但是还有一个工具值得我们去了解。我们发现了一项技术叫做 Realms shim ,被 Agoric 的人们所创造。

这项技术描述了怎样创建沙箱和潜在的支持插件用例。有前景的描述!Realms API 大致如下:

  1. let g = window; // outer global
  2. let r = new Realm(); // realm object
  3. let f = r.evaluate("(function() { return 17 })");
  4. f() === 17 // true
  5. Reflect.getPrototypeOf(f) === g.Function.prototype // false
  6. Reflect.getPrototypeOf(f) === r.global.Function.prototype // true

这项技术实际上可以使用现有的,尽管不太知名的 JavaScript 功能来实现。该沙箱的一部分是隐藏全局环境,其核心实现大概是这样的:

  1. function simplifiedEval(scopeProxy, userCode) {
  2. 'use strict'
  3. with (scopeProxy) {
  4. eval(userCode)
  5. }
  6. }

这是一个用于演示的简易版本,真实版本有一些细微差别。然而,它展示了拼图的关键部分: withProxy

with(obj) 语句创建了一个变量查找可以被 obj 的属性解析的作用域。在该例子中,我们可以通过 Math 对象的属性 PI , cossin 来解析(resolve the variable:意思就是获取该变量)。另一方面, console 不是 Math 的属性,会从全局作用域解析到。

  1. with (Math) {
  2. a = PI * r * r
  3. x = r * cos(PI)
  4. y = r * sin(PI)
  5. console.log(x, y)
  6. }

Proxy 对象是 Javascript 里最动态的一种对象形式。

  • 最基本的 JavaScript 对象在通过 obj.x 访问属性时返回一个值
  • 更高阶的 Javascript 对象可以有 getter 属性来返回一个求值函数。 obj.x 会调用 getter 函数来得到 x
  • Proxy 通过运行 get 函数来获取任何属性的值

下面的 proxy(为了演示简化了)在访问任何对象上的属性都会返回 undefined,除了白名单在内的属性。

  1. const scopeProxy = new Proxy(whitelist, {
  2. get(target, prop) {
  3. // here, target === whitelist
  4. if (prop in target) {
  5. return target[prop]
  6. }
  7. return undefined
  8. }
  9. }

现在,当你把这个 proxy 作为 with 的一个参数,它会捕获所有变量的访问并且不会再使用全局作用域来解析一个变量:

  1. with (proxy) {
  2. document // undefined!
  3. eval("xhr") // undefined!
  4. }

好吧,差点儿。现在仍然有可能通过 ({}).constructor 来访问全局。而且,沙箱确实需要访问一些全局属性,例如 Object 是一个全局的,经常被合法的 Javascript 代码使用的属性(例如 Object.keys )。

为了让插件能访问这些全局属性,同时不会弄糟 window,Realms 沙箱通过创建一个同源的 <iframe> 实例化了一个这些属性的新的拷贝。这个 iframe 不像我们之前的做法(将其作为沙箱存在),同源的 iframes 不受 CORS 的限制。

相反,当 <iframe> 和父文档被同源地创建时:

  1. 它附带了一个分离的全局属性拷贝,例如 Object.prototype
  2. 这些全局属性可以被父文档访问

Plugins_Eng_grab_ref.png
这些全局属性被放到 Proxy 对象的白名单里,这样插件就可以访问它们。最终,这个新的 <iframe> 附带了一个新的 eval 函数的拷贝,和现有的有一个很重要的区别:一些只能通过 ({}).constructor 才能访问到的内置属性会解析到这些 iframe 的拷贝那。

这种使用 Realms 沙箱有很多的好处:

  • 它运行在主线程
  • 它是很快的,因为它能使用浏览器的 JIT 来解析代码
  • 浏览器的调试工具可以使用

但是还有一个问题存在,它是安全的吗?

安全的使用 Realms 来实现 API

我们对 Realms 的沙箱功能感到很满意,尽管它涉及到比 JavaScript 引擎方式更微妙的东西,它仍然是一个白名单而不是黑名单机制,这会让它的实现变小,可追踪(audited)。它由 web 社区的一些令人尊敬的工程师所创造。

然而,使用 Realms 不是这个故事的结局,因为仅仅是一个沙箱的话插件还是不能做任何事。我们仍需实现让插件能使用的 API 。这些 API 也需要是安全的,因为大多数插件确实需要展示一些UI,发起网络请求(比如,用 Google Sheets 给设计填充一些数据)。

考虑到一些情况,比如,沙箱里默认是没有 console 的。毕竟, console 是一个浏览器 API,不是 Jacascript 特性。将它作为全局属性传递给沙箱是可以的。

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

或许将原始值隐藏在函数内部,这样沙箱就不能修改它了。

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

不幸的是,这是一个安全漏洞。即使是第二种例子,这个匿名函数在 Realms 外部被创建,但是被直接交给了 Realms。这意味着插件能通过浏览 log 函数的原型链接触到沙箱外面。

实现 console.log 的方法是将它包在一个被 realm 创建的函数里。简化了的例子在 这里(在实践中,有必要在 Realms 转换任何抛出的异常)。

  1. // Create a factory function in the target realm.
  2. // The factory return a new function holding a closure.
  3. const safeLogFactory = realm.evaluate(`
  4. (function safeLogFactory(unsafeLog) {
  5. return function safeLog(...args) {
  6. unsafeLog(...args);
  7. }
  8. })
  9. `);
  10. // Create a safe function
  11. const safeLog = safeLogFactory(console.log);
  12. // Test it, abort if unsafe
  13. const outerIntrinsics = safeLog instanceof Function;
  14. const innerIntrinsics = realm.evaluate(`log instanceof Function`, { log: safeLog });
  15. if (outerIntrinsics || !innerIntrinsics) throw new TypeError();
  16. // Use it
  17. realm.evaluate(`log("Hello outside world!")`, { log: safeLog });

在一般情况下,沙箱从不应该对沙箱外的对象有直接的访问,因为它可以访问全局作用域。同样重要的是,API 在操作沙箱内部的对象时也应该很小心,因为它有与沙箱外部对象混合的风险。

这就产生了一个问题,虽然可以构建一个安全的 API ,但让我们的开发人员在每次想要向 API 添加新函数时去担心细微的对象源语义(subtle object origin semantics)是站不住脚的。所以我们要怎么解决这个问题?

解释器的 API

问题是直接在 Realms 上构建 Figma 的 API 使得每个 API 都需要被追踪(audited),包括它的输入和输出。这会创造很大的面(The surface area created is too large)。
Plugins_Eng_audit.png
尽管 Realms 沙箱中的代码使用相同的 JavaScript 引擎运行(给我们提供了方便的工具),假装我们生活在 WebAssembly 方案的限制下仍然有帮助。

对于 Duktape,这个在尝试二中被编译到 WebAssembly 的解释器,让主线程对沙箱内的对象直接保持一个引用是不可能的。毕竟,在沙箱内部,WebAssembly 管理自己的堆并且所有的 JavaScript 对象只是这个堆中的一部分。实际上, Duktape 甚至不会和浏览器引擎使用相同的内存表示法(memory representation)来实现 JavaScript对象!

结果是,为 Duktape 实现一个 API 只能通过低级别的操作,比如在虚拟机中复制整数和字符串。保持对解释器里的一个对象或函数的引用是有可能的,但只是作为一个不透明的句柄(opaque handle)。

这样的接口大致如下:

  1. // vm == virtual machine == interpreter
  2. export interface LowLevelJavascriptVm {
  3. typeof(handle: VmHandle): string
  4. getNumber(handle: VmHandle): number
  5. getString(handle: VmHandle): string
  6. newNumber(value: number): VmHandle
  7. newString(value: string): VmHandle
  8. newObject(prototype?: VmHandle): VmHandle
  9. newFunction(name: string, value: (this: VmHandle, ...args: VmHandle[]) => VmHandle): VmHandle
  10. // For accessing properties of objects
  11. getProp(handle: VmHandle, key: string | VmHandle): VmHandle
  12. setProp(handle: VmHandle, key: string | VmHandle, value: VmHandle): void
  13. defineProp(handle: VmHandle, key: string | VmHandle, descriptor: VmPropertyDescriptor): void
  14. callFunction(func: VmHandle, thisVal: VmHandle, ...args: VmHandle[]): VmCallResult
  15. evalCode(code: string): VmCallResult
  16. }
  17. export interface VmPropertyDescriptor {
  18. configurable?: boolean
  19. enumerable?: boolean
  20. get?: (this: VmHandle) => VmHandle
  21. set?: (this: VmHandle, value: VmHandle) => void
  22. }

注意这是 API 实现需要使用的接口,但是它大概是 1:1 的映射到 Duktape 的 API。毕竟,被精确构建出来的 Duktape(和其他类似的虚拟机),是用于被其他环境嵌入,并且允许嵌入者去和 Duktape 交互的引擎。

使用该接口,一个 {x: 10, y: 10} 的对象会被这样传递给沙箱:

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

一个用于 Figma 节点对象的“不透明度”属性的 API 是这样的:

  1. vm.defineProp(vmNodePrototype, 'opacity', {
  2. enumerable: true,
  3. get: function(this: VmHandle) {
  4. return vm.newNumber(getNode(vm, this).opacity)
  5. },
  6. set: function(this: VmHandle, val: VmHandle) {
  7. getNode(vm, this).opacity = vm.getNumber(val)
  8. return vm.undefined
  9. }
  10. })

使用 Realms 沙箱同样可以很好地实现这种低级的接口。这种实现方式需要相对更少的代码(在我们的案例中大概是500行)。这部分少量代码需要很小心的追踪。然而只要这部分完成后,后面的 API 就可以建立在这个接口之上,而不需要去担心沙箱相关的安全问题了。在学术上,这被叫做 membrane pattern(一种设计模式)。
Plugins_Eng_safe_VM_API.png
本质上,这将 JavaScript 解释器和 Realms 沙箱都视为“运行JavaScript的独立环境”。

在沙箱上创建低级抽象 API 还有一个关键点。虽然我们对于 Realms 的安全性很有自信,但是涉及到安全问题时,格外小心是没有坏处的。我们意识到,Realms 可能存在未知的缺陷,会在未来某天成为我们需要解决的问题。这就是为什么我们有几段文字会谈到我们甚至没采用的编译解释器的方法。因为 API 是通过接口实现的,而该接口的实现可以被替换掉,所以使用 JS 解释器仍然是一个有效的备胎计划,我们可以使用它,而无需重新实现任何 API 或破坏任何现有的插件。

插件的丰富功能

现在我们有了一个安全运行任意插件的沙箱,还有 API 允许这些插件来操作 Figma 文档。这开启了很多的可能性。

然而,我们最开始要解决的问题是为一个设计工具构建一个插件系统。为了更加强大的功能,大多数插件想要创建用户界面的能力,还有很多想要某些形式的网络访问能力。概括起来,我们希望插件能够尽可能多地利用浏览器和 JavaScript 生态系统。

我们可以很小心的,一次一个地暴露安全的,受限制的浏览器 API 版本,就像上面的 console.log 。然而,浏览器 API(特别是DOM)是一个巨大的面(surface area),甚至比 Javascript 本身还要大。这样的尝试要么会限制太多以至于不能使用,要么会有安全问题。

我们通过再次重新介绍的 null-origin<iframe> 来解决这个问题。插件可以创建一个 <iframe> (我们在 Figma 编辑器内展示的模态框)并将任意的 HTML 和 Javascript 放到里面。
Plugins_Eng_sorter.png

和我们最初使用 <iframe> 不同的是,这个插件由两部分组成:

  • 运行在主线程的 Realms 沙箱内,能访问 Figma 文档的部分
  • 运行在 <iframe> 内,能访问浏览器 API 的部分

这两部分能通过消息传递来交互。和同一环境中同时运行两个部分相比,通过这种架构使用浏览器 API 变得有一点单调。然而,考虑到目前的浏览器技术,这已经是我们所能做的最好的了,而且这并没有阻止开发者在开放 beta 版的两个月里创造出令人惊叹的插件。

总结

我们走了一段曲折的路才到这里,但最终我们还是很高兴能找到一个在 Figma 中实现插件的可行方案。Realm shim 允许我们隔离第三方代码,同时仍然允许它在熟悉的类似浏览器的环境中运行。

虽然这对我们来说是最好的解决方案,但它可能并不适合每个公司或平台。如果您需要隔离第三方代码,那么值得评估一下您是否有与我们一样的性能和 API 人机工程学考虑。否则,通过 iframes 隔离代码可能就足够了,而且简单总是好的。我们希望保持简单。

最终,我们非常关心最终的用户体验, 一方面插件的使用者会发现它们是稳定可靠的,另一方面开发者只需要有基础的 Javascript 知识的者就能够开发。实现这些可访问性和质量价值使得我们花费的所有时间都是值得的。

在一个基于浏览器的设计工具的工程团队中工作,最令人兴奋的事情之一是我们遇到了很多未知的领域,我们开始创造新的方法来处理这样的技术挑战。如果你觉得这类工程冒险很有趣,请查看我们博客的其他内容。