最近,我们团队完成了Figma的插件API的开发工作,这样第三方开发人员就可以直接在基于浏览器的设计工具中运行代码了。这给第三方开发人员带来便利的同时,也给我们带来了许多严峻的挑战,比如,如何确保插件中运行的代码不会带来安全问题?
更头痛的是,我们的软件是建立在非常规的堆栈之上的,因此面临许多工具所没有的约束。我们的设计编辑器是建立在WebGL和WebAssembly的基础之上的,其中一些用户界面是利用Typescript&React来实现的。并且,我们的软件支持多人同时编辑文件。在这个过程中,浏览器技术为我们提供了很大的支持,同时,也带来了许多的限制。
这篇文章将带您了解我们对完美插件解决方案的探索过程。最终,我们的问题可以归结为一点:如何安全、稳定和高效地运行插件?以下是我们面临的重要约束的简要概述:
安全性:插件只有在显式启动时才能访问文件。插件应该被限制在当前文件中。插件不能像figma.com那样进行调用。插件不能访问对方的数据,除非是自愿提供的。插件不能篡改Figma UI及其行为来误导用户(例如网络钓鱼)。
稳定性:插件不能降低Figma的速度,使其无法使用。插件不能破坏我们产品中的关键不变量,比如让每个人在查看同一个文件时总是看到相同内容的属性。为了查看文件,不需要管理跨设备/用户的插件安装。对Figma产品或内部api的修改不会破坏现有的插件。
易于开发:插件应该易于开发,以支持充满活力的生态系统。我们的大多数用户都是设计师,可能对JavaScript经验不多。开发人员应该能够使用现有的调试工具。
性能:插件应该运行得足够快,以支持大多数常见的场景,例如搜索文档、生成图表等等。
尝试#1:<inline-iframe>沙箱方法
在我们最初几周的研究工作中,我们尝试了多种第三方代码沙箱,其中一些使用了诸如代码到代码之间的转换之类的技术。然而,大多数沙箱都没有在生产应用程序中经过长时间的历练,因此,使用这些沙箱肯定存在一定的风险。
最后,作为我们的第一次尝试,我们使用了最接近标准沙箱解决方案的一种方法:<inline-iframe>标签。该方法适用于需要运行第三方代码的应用程序,如CodePen。
需要注意的是,这里的<inline-iframe>并不是我们平常使用的HTML标签。要理解<inline-iframe>方法为什么能够提供安全性,就必须先来了解一下它提供了哪些特性。一般来说,<inline-iframe>通常用于将一个网站嵌入到另一个网站中。例如,在下图中,您可以看到yelp.com网站中嵌入了google.com/maps,这样就可以为用户提供地图功能了。
在这里,我们当然不希望因Yelp嵌入谷歌地图功能就能读取Google网站内的内容,因为那里可能存有用户的私人信息。同样,您也不希望谷歌因此而获得了访问Yelp网站内的内容的权限。
这意味着<inline-iframe>之间的通信应该受到浏览器的严格限制。当<inline-iframe>的源不同于其容器的源(如yelp.com与google.com)时,它们应该是完全隔离的。同时,与<inline-iframe>进行通信的唯一方法是通过消息传递。实际上,这些消息就是一些纯字符串。收到消息后,每个网站都即可以对这些消息采取相应的行动,也可以对它们置之不理。
事实上,它们是如此独立,以至于HTML规范允许浏览器将<inline-iframe>实现为单独的进程,只要他们喜欢的话。
既然了解了<inline-iframe>的工作原理,我们就可以通过在每次插件运行时创建一个新的<inline-iframe>,并将插件的代码粘贴在<inline-iframe>中来实现插件,这样,插件可以在<inline-iframe>中做任何想做的事情了。但是,除非消息通过了显式的白名单检测,否则,它无法与Figma文档进行交互。<inline-iframe>也是一种特殊的null源,这意味着向figma.com发送请求的尝试都会被浏览器的跨源资源共享策略所拒绝。
实际上,<inline-iframe>在这里充当了插件的沙箱的角色,而浏览器供应商则为我们提供了沙箱的安全保证,毕竟他们多年来一直在忙着搜索和修复沙箱中的各种漏洞。
使用这个沙箱模型的实际插件将使用我们添加到沙箱中的一个应用程序接口,具体如下所示:
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发送消息以获取文档的副本)来进行初始化,并通过调用updateScene(将插件所致修改发送回Figma)作为其结束的。请注意:
我们是通过获取文档的副本,而不是使用消息传递来完成属性的读取和写入操作的。传递消息时,每次往返需耗时0.1ms,这样的话,每秒只能传递1000条左右的消息。
我们不会让插件直接使用postMessage,因为这样做很麻烦。
决定采用这种方法后,我们大约用了一个月的时间构建好了相应的API。当时来看,马上就大功告成了,我们甚至邀请了一些alpha测试人员。然而,我们很快就发现,这种方法存在两大缺陷。
问题1:async/await关键字对用户来说不够友好
我们得到的第一反馈是,人们讨厌使用async/await关键字——但是在这种方法中,这是不可避免的。消息传递本质上就是异步操作,而在JavaScript中是没有办法对异步操作进行同步阻塞式的调用的。对于这种方法来说,我们不仅需要使用await关键字,同时还需要将所有调用函数标签为async。综上所述,异步/等待仍然是一个比较新颖的JavaScript功能,要想玩转它,需要对并发性概念有相当深入的理解——很明显,这对于我们的插件开发人员来说,要求过于高了。
不过,如果只需要在插件开头和结尾处各使用一次await和await关键字的话,情况就没有那么糟糕了。我们只需要告知开发人员始终将await、loadScene和updateScene搭配使用即可,即使他们不太了解它们的作用的话也影响不大。
问题是某些API调用需要运行许多复杂的逻辑。例如,有时更改某图层上的单个属性后,必须同时更新其他多个图层。例如,调整frame的大小后,需要递归地将约束应用于其子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:复制操作的开销过大
<inline-iframe>方法的第二个问题是,在将文档的大部分内容发送到插件之前,需要先对其进行序列化。
事实证明,人们有时会在Figma软件中创建非常非常大的文档,甚至达到内存的上限。例如,对于微软的设计系统文件(去年我们花了一个月时间对其进行优化)来说,将文档序列化并将其发送给插件就需要花费14秒时间——这些还是发生在插件运行之前。鉴于大多数插件都涉及快速的操作,例如“交换选中的两个对象”,这将使插件的可用性作废。
以增量方式加载数据或延后加载数据也不是一个好的选择,因为:
1.要想重构核心产品,至少需要花上几个月的时间。
2.所有需要等待尚未到达的数据的API,都是异步的。
总之,因为Figma文档可能包含大量相互依赖的数据,所以<inline-iframe>对我们来说是不可取的。
主线程方法
由于排除了<inline-iframe>方法,我们不得不另觅他途。此后的两个星期中,我们又尝试了多种方法,但是或多或少存在某些不可接受的缺陷:
API难以使用(例如,需要使用REST API或类似GraphQL的方法访问文档)
需要借助于浏览器供应商已放弃或正打算放弃的浏览器功能(例如,同步xhr请求+Service Worker、共享缓冲区)
需要大量的研究工作或重新构建我们的应用程序,这可能需要花费几个月的时间来验证其可行与否(例如,通过CRDT,利用iframe+sync方式加载Figma的副本等)
最终,我们终于得出结论:必须找到一种方法来创建一个模型,其中插件可以直接操作文档。这样,编写插件在一定程度上就是实现手动操作的自动化,为此,我们必须允许插件在主线程上运行。
(未完待续)