提前声明,该文章为一年前做内部大前端领域分享的内容,部分内容可能已经出现变化,请自行甄别。
Figma 是目前最成功的矢量图形编辑工具之一,它提供了基于web形式的图形编辑界面,让用户不需要额外安装或下载工具即可直接共享、协作和编辑其图形内容。这里对Fiama的内容不做太多的赘述,主要分享下Figma的插件是如何实现的。
Figma的插件是如何工作的
Figma 插件只是封装在 iFrame 中的 Web 应用程序,通过发布和接收消息与 Figma 进行通信。
由于这种架构,需要在 Figma 沙箱内捆绑和开发插件才能访问 Figma 设计文件的内容。
为了插件的执行性能,Fiama采用了一种执行模型,其中插件代码在主线程中的沙箱(code sanbox)内运行。沙箱是一个最小的 JavaScript 环境,内部不能公开访问浏览器的 API。因此可以在组件内部的代码里直接使用所有标准 JavaScript ES6+,包括标准类型、JSON 和 Promise API、Uint8Array 等二进制类型等。Figma还提供了功能最小集的控制台 API 。不过浏览器 API(如 XMLHttpRequest 和 DOM)不能直接在沙箱中访问。
要使用浏览器 API(例如显示 UI),插件内部需要创建一个包含 <script>
标签的 <iframe>
内容,可以通过 Figma.showUI()
来完成。在此 <iframe>
内部,可以编写任何 HTML/JavaScript 并访问任何浏览器 API。
主线程可以访问 Figma 的 “scene” 信息(构成Figma文档的层次结构),但不能访问浏览器API。相反,iframe 可以访问浏览器 API,但不能访问 Figma 场景。主线程和iframe可以通过消息传递的方式进行通信。
如果插件被限制了外部的网络访问,Figma会强制对该插件实施额外的网络安全。当网络访问受到限制时,如果插件尝试访问插件清单中未指定的域,Figma 会阻止该尝试并返回内容安全策略 (CSP) 错误。域访问的强制执行仅限于插件发出的请求,例如对公共 REST API 的 Fetch API 请求。如果插件在 iframe 中渲染站点页面,则网络访问限制仅直接适用于网站的域。网络访问限制不会影响该网站所需的资源。例如,假设插件的frames限制对Figma.com 的访问,插件中其他域的内容访问都会被阻止,但 figma.com 仍然能够加载外部资源,如 Google Analytics 的脚本。当插件完成其工作时,它必须调用 Figma.closePlugin() 来告诉Figma它已完成。否则,插件将无限期运行,并且用户将看到“正在运行 [您的插件名称]” 提示条,直到他们关闭浏览器选项卡。
Figma 插件的构成
插件 Manifest
每个插件必须定义一个描述该插件的manifest.json
文件。如果使用“创建新插件”对话框,Figma 会自动创建一个简单的 manifest.json
。开发者可以扩展此生成的 manifest.json
以利用其他功能,例如,为插件声明一个子菜单。
{
"name": "MyPlugin",
"id": "737805260747778092",
"api": "1.0.0",
"main": "code.js",
"ui": "ui.html"
}
name: string
插件的名称,这个名称会出现在菜单列表中
id: string
插件用来发布和更新的唯一标识 ID。该 ID 将由 Figma 分配,通常使用“创建新插件”功能可以获得插件 ID,该功能将生成具有新 ID 的 manifest.json。此外也可以在发布插件时获取新的插件 ID。
api: string
插件使用的 Figma API 版本。建议尽可能使用最新的 API 版本。Figma 不会自动升级插件的 api 版本。
main: string
存放插件代码文件的相对路径,相当于这个插件的执行入口文件。
ui: string
插件的 UI 渲染文件,用于指定可通过 Figma.showUI 在 iframe 模式中使用的 HTML 文件。
- 如果指定单个字符串,则这是 HTML 文件的相对文件路径,该文件的内容将通过常量 html 在 Javascript 代码中作为字符串提供。
- 如果指定了映射,则映射的每个条目都将在 uiFiles 中可用
插件文件
code.js
使用 figma.showUI() 方法来将 manifest.json 中的 ui 字段指定的 ui.html 绑定的全局变量 __html__
渲染到 Figma 的工作区中 。
"use strict";
figma.showUI(__html__, { themeColors: !0, width: 232, height: 208 });
ui.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
</head>
<body>
<div>
hello my first figma plugin
</div>
<script>
</script>
</body>
</html>
插件中的三方代码隔离
可以看出 Figma 将插件入口分为了 main 与 ui 两部分, main 中包含了插件实际运行时的逻辑,而 ui 则是一个插件的 HTML 片段。即 UI 与逻辑分离。安装一个Color Search 插件后观察页面结构可以发现 main 中的 js 文件被包裹在一个 iframe 里加载到页面上,关于 main 入口的沙箱机制后文中有详细的阐述。而 ui 中的 HTML 最终也被包裹在一个 iframe 里渲染出来,这将有效的避免插件 UI 层 CSS 代码导致全局样式污染。
Figma 官方博客中对其插件的沙箱机制做了详细的阐述。起初他们尝试的方案是 iframe,一个浏览器自带的沙箱环境。将插件代码由 iframe 包裹起来,由于 iframe 天然的限制,这将确保插件代码无法操作 Figma 主界面上下文,同时也可以只开放一份白名单 API 供插件调用。乍一看似乎解决了问题,但由于 iframe 中的插件脚本只能通过 postMessage 与主线程通信,这导致插件中的任何 API 调用都必须被包装为一个异步 async/await 的方法,这无疑对 Figma 的目标用户非专业前端开发者的设计师不够友好。其次对于较大的文档,postMessage 通信序列化的性能成本过高,甚至会导致内存泄漏。
Figma 团队选择回到浏览器主线程,但直接将第三方代码运行在主线程,由此引发的安全问题是不可避免的。最终他们发现了一个尚在 stage3 阶段的草案 Realm API。Realm 旨在创建一个领域对象,用于隔离第三方 JavaScript 作用域的 API。
前文中 Figma 插件被 iframe 所包裹的插件 main 入口即包含了一个被 Realm 接管的作用域,你可以认为是类似这段示例代码中的一份 白名单 API,毕竟维护一份白名单比屏蔽黑名单实现起来更简洁。但事实上由于 JavaScript 的原型式继承,插件仍然可以通过 console.log 方法的原型链访问到外部对象,理想的解决方案是将这些白名单 API 在 Realm 上下文中包装一次,从而彻底隔离原型链。
Duktape 是一个由 C++ 实现的用于嵌入式设备的 JavaScript 解释器,它不支持任何浏览器 API,自然地它可以被编译到 WebAssembly,Figma 团队将 Duktape 嵌入到 Realm 上下文中,插件最终通过 Duktape 解释执行。这样可以安全的实现插件所需 API,且不用担心插件会通过原型链访问到沙箱外部。
这是最终 Figma 的插件方案,它运行在主线程,不需要担心 postMessage 通信带来的传输损耗。多了一次 Duktape 解释执行的消耗,但得益于 WebAssembly 出色的性能,这部分消耗并不是很大。另外 Figma 还保留了最初的 iframe ,允许插件可以自行创建 iframe ,并在其中插入任意 JavaScript ,同时它可以与沙箱中的 JavaScript 脚本通过 postMessage 相互通信。
Figma 插件示例
首先使用脚手架进行插件代码的初始化,当前存在许多不错的样板代码可供选择,主要的差异还是在前端框架的选择和编译架构的使用上:
- React
- Vue
- Svelte
这里选择使用Svelte进行初始化:
npx degit thomas-lowry/figsvelte figma-plugin
cd figma-plugin
npm install
目录结构如下:
tree -I node_modules -L 3
.
├── LICENSE
├── README.md
├── package-lock.json
├── package.json
├── public
│ ├── code.js
│ ├── index.html
│ └── manifest.json
├── rollup.config.js
├── src
│ ├── PluginUI.svelte
│ ├── build
│ │ └── bundle.js
│ ├── code.ts
│ ├── logo.svg
│ ├── main.js
│ └── template.html
└── tsconfig.json
3 directories, 15 files
插件示例1:插入自定义图形
点击按钮时,会在工作区插入对应数量的图形元素。
核心代码
manifest.json:
{
"name": "My Create Shapes",
"api": "1.0.0",
"main": "code.js",
"ui": "index.html",
"editorType": [
"figma",
"dev"
],
"capabilities": [
"inspect"
]
}
ui.html:
<script>
function createShapes() {
window.parent.postMessage({ pluginMessage: {
'type': 'create-shapes',
'count': count,
'shape': selectedShape.value
} }, '*');
}
</script>
<Button onclick="createShapes">Create shapes</Button>
main.js:
figma.showUI(__html__, { themeColors: true, width: 232, height: 208 });
figma.ui.onmessage = ({ type, count, shape }) => {
if (type === 'create-shapes') {
const nodes = [];
for (let i = 0; i < count; i++) {
var shape;
if (shape === 'rectangle') {
shape = figma.createRectangle();
} else if (shape === 'triangle') {
shape = figma.createPolygon();
} else {
shape = figma.createEllipse();
}
shape.x = i * 150;
shape.fills = [{ type: 'SOLID', color: { r: 1, g: 0.5, b: 0 } }];
figma.currentPage.appendChild(shape);
nodes.push(shape);
}
figma.currentPage.selection = nodes;
figma.viewport.scrollAndZoomIntoView(nodes);
}
figma.closePlugin();
};
插件示例2:使用unsplash插入随机图片
核心代码
manifest.json:
{
"name": "Figma Unsplash Plugin Example",
"id": "1056880989752387690",
"api": "1.0.0",
"main": "dist/code.js",
"editorType": ["figma"],
"ui": "dist/ui.html"
}
ui.html:
<script>
async function addImage() {
const imageBytes = await getImage()
window.parent.postMessage({
pluginMessage: {
type: "ADD_IMAGE",
payload: {
imageBytes,
width,
height,
},
},
}, "*")
}
</script>
<Button {disabled} on:click={addImage}>Add Image</Button>
main.js:
function createImage({ imageBytes, width = 800, height = 800 }: IPayload): void {
const image = figma.createImage(imageBytes)
const imageHash = image.hash
// An image in Figma is not a layer, but a fill type for a shape
const fill: ImagePaint = {
type: "IMAGE",
imageHash,
scaleMode: "FILL",
}
const rect = figma.createRectangle()
rect.resize(width, height)
rect.x = figma.viewport.center.x - Math.round(width / 2)
rect.y = figma.viewport.center.y - Math.round(height / 2)
rect.name = `Random Unsplash Image`
rect.fills = [fill]
figma.currentPage.appendChild(rect)
figma.currentPage.selection = [rect]
figma.viewport.scrollAndZoomIntoView([rect])
}
figma.ui.onmessage = (msg: IMessage) => {
const { type, payload } = msg
if (type === `ADD_IMAGE`) {
if (payload.imageBytes) {
const { imageBytes, width, height } = payload
createImage({ imageBytes, width, height })
}
}
figma.closePlugin()
}
代码生成插件
figma中代码生成的三种模式:
模式名称 | 效果 | 说明 |
---|---|---|
default | 默认模式 | |
inspect | dev开发模式下的 检查 模式,该模式下插件会在右侧的边栏中进行渲染。 | |
codegen | dev开发模式下的 代码生成模式,插件根据figma的scene nodes生成的代码片段会在右侧展示。 |
代码生成插件需要先修改manifest.json中的字段声明:
{
"name": "My Figma to Code",
"id": "842128343887142055",
"api": "1.0.0",
"main": "apps/plugin/dist/code.js",
"ui": "apps/plugin/dist/index.html",
"editorType": [
"figma",
"dev"
],
"capabilities": [
"inspect",
"codegen"
],
"codegenLanguages": [
{
"label": "HTML",
"value": "html"
}
]
}
然后根据figma的mode类型,返回不同的代码内容:
function standardMode() {
}
function codegenMode() {
figma.codegen.on("generate", ({ language, node }) => {
// figma scene nodes
console.log(node);
return [
{
title: `Code`,
code: '<div class="test"> hello my D2C plugin </div>',
language: "HTML",
},
{
title: `Text Styles`,
code: '.test { color: red }',
language: "HTML",
},
];
});
}
switch (figma.mode) {
case "default":
case "inspect":
standardMode();
break;
case "codegen":
codegenMode();
break;
default:
break;
}
在codegen模式下选择改插件,即可得到生成的代码展示效果。
自己写一个FigmaToCode插件
常见的代码生成思路:
- 在 figma 本地生成
- 通过 figma.currentPage.selection 获取当前选择的内容,生成对应的代码
- 在服务器端生成
- 将 figma 设计稿绑定到后台,后台通过figma的API获取到完整的设计稿内容
- 前端将选中的内容ID发送给后台,后台完成转码,然后返回给figma插件
这里采用figma本地生成代码片段的方式,参考Figma to code插件仿写一个自己的代码生成插件。支持对选中的scene nodes节点生成对应的html、flutter和swiftUI等语言的代码:
代码生成流程如下:
社区插件
anima app
- 转换效果最好
- 由于网络原因,设计稿的导入速度速度非常慢
- 支持二次编排
- 有自己的工作台
locofy
- 转换效果可以精准还原
- 网络原因,速度慢
- 有自己的工作台,可以进行组件编排
- 生成的代码的只读的,不能修改
semi design
- 只支持react技术栈
- 不能很好编排组件
思考
- 只要是结构化的数据就能transform为对应的数据结构。
- 生成的代码完全依赖图层结构,代码结构和图层结构冲突怎么办?
- 这个过程如何引入智能化?让大模型帮我们完成代码的转换。
- 怎么和现有基于现有 git + CI/CD 的工作流打通?