如何用
vue
3 开发 figma 插件?下面本篇文章给大家介绍一下figma插件原理,记录下使用
vue
3开发fimga插件的过程,并附有开箱即用的代码,希望对大家有所帮助!
用 Vue 3 开发 Figma 插件
Figma 是一款当下流行的设计工具,越来越多的设计团队开始从 Sketch 转向 Figma。Figma 最大的特点是
使用Web技术开发
,实现了完全的
跨平台
。 Figma 插件也是使用 Web 技术开发,只要会
、
、
就能动手写一个 Figma 插件。
Figma 插件原理
Figma 架构简介
介绍 Fimga 插件之前,我们先来了解一下 Fimga 的技术架构。
立即学习
“
前端免费学习笔记(深入)
”;
Figma 整体是用
React
开发的,核心的画布区是一块
,使用
WebGL
来渲染。并且画布引擎部分使用的是
WebAssembly
,这就是 Figma 能够如此流畅的原因。桌面端的Figma App 使用了
Electron
——一个使用Web技术开发桌面应用的框架。Electron 类似于一个浏览器,内部运行的其实还是一个Web应用。
Figma 插件原理
在Web端开发一套安全、可靠的插件系统,
无疑是最直接的方案。
是标准的W3C规范,在浏览器上已经经过多年应用,它的特点是:
安全,天然沙箱隔离环境,iframe内页面无法操作主框架;
可靠,兼容性非常好,且经过了多年市场的检验;
但是它也有明显的缺点:与主框架通信只能通过
的方式,通信效率非常低。如果要在插件里操作一个画布元素,首先要将元素的节点信息从主框架拷贝到
中,然后在
中操作完再更新节点信息给主框架。这涉及到大量通信,而且对于复杂的设计稿节点信息是非常巨大的,可能超过通信的限制。
为了保证操作画布的能力,必须回到主线程。插件在主线程运行的问题主要在于安全性上,于是Figma的开发人员在主线程实现了一个
沙箱环境,使用了
Realm API
。沙箱中只能运行纯粹的 js 代码和Figma提供的API,无法访问浏览器API(例如网络、存储等),这样就保证了安全性。
感兴趣的同学推荐阅读官方团队写的
《How to build a plugin system on the web and also sleep well at night》
,详细介绍了 Figma 插件方案的选择过程,读来获益良多。
经过综合考虑,Figma 将插件分成两个部分:插件UI运行在
中,操作画布的代码运行在主线程的隔离沙箱中。UI线程和主线程通过
通信。
插件配置文件
中分别配置
字段指向加载到主线程的
文件,
字段配置加载到
中的
文件。打开插件时,主线程调用
方法加载
。
写一个最简单的 Figma 插件
为了了解插件的运行过程,我们先写一个最简单的 Figma 插件。功能很简单:可以加减正方形色块。
安装Figma桌面端
首先要
下载
并安装好 Figma 桌面端。
编写插件的启动文件 manifest.json
新建一个代码工程,在根目录中新建
文件,内容如下:
编写UI代码
根目录新建
,
编辑main js 代码
根目录新建
,内容如下:
启动插件
Figma桌面APP,画布任意地方右键打开菜单,
->
->
,选择前面创建的
文件路径,即可成功导入插件。
然后通过右键,
->
->
(插件名),就可以打开插件。
测试点击按钮,功能正常。只不过页面上还未出现色块(别着急)。
通过
->
->
可以打开调试控制台。可以看到我们打印的日志。
操作画布
前面讲了,画布代码是运行在主线程的,为了执行效率,插件要操作画布内容也只能在主线程执行,即在
中。
中暴露了顶级对象
,封装了用来操作画布的一系列API,具体可以去看
官网文档
。我们用
来创建一个矩形。主线程需要通过
监听来自UI线程的事件,从而做出响应。修改后的
代码如下:
同时要修改
中的部分代码,通过
给主线程发送事件:
重新启动插件,再试验一下,发现已经可以成功加减色块了。
使用 Vue 3 开发 Figma 插件
通过前面的例子,我们已经清楚 Figma 插件的运行原理。但是用这种“原生”的
、
来编写代码非常低效的。我们完全可以用最新的Web技术来编写代码,只要打包产物包括一个运行在主框架的
文件和一个给
运行的
文件即可。我决定尝试使用
来开发插件。(学习视频分享:
vuejs教程
)
关于
Vue 3
就不多做介绍了,懂的都懂,不懂的看到这里可以先去学习一下再来。这里的重点不在于用什么框架(改成用vue 2、react过程也差不多),而在于构建工具。
Vite 启动一个新项目
Vite
是Vue的作者开发的新一代构建工具,也是 Vue 3推荐的构建工具。
我们先建一个
+
的模板项目。
然后通过浏览器打开
就能看到页面。
移植上述demo代码
我们把前面的插件demo移植到 Vue 3 中。
代码修改如下:
我们在
目录存放运行在主线程沙箱中的js代码。新建
,内容如下:
上述代码中缺少
的 ts 类型声明,所以我们需要安装一下。
修改
,添加
,这样 ts 代码就不会报错了。同时也要加上
,解决类型声明冲突问题。
Vue-router参考手册
Vue-router参考手册下载
下载
修改构建配置
Figma 插件需要的构建产物有:
文件作为插件配置
作为UI代码
作为主线程js代码
在 public 目录中添加 manifest.json 文件
目录中的文件都会负责到构建产物
目录下。
vite.config.ts 中增加构建入口
默认情况下
会用
作为构建入口,里面用到的资源会被打包构建。我们还需要一个入口,用来构建主线程 js 代码。
执行
,安装
的类型声明,以便在 ts 中使用
API。
的
中增加
。默认情况下输出产物会带上文件
,所以也要修改
配置:
运行构建
执行
,
目录会有构建产物。然后我们按照前面的步骤,将
目录添加为 Figma 插件。
->
->
,选择
文件路径。
启动插件......怎么插件里一片空白?好在 Figma 里面有 devtools 调试工具,我们打开瞧一瞧。
可以看到,我们的
已经成功加载,但是 js 代码没加载所以页面空白。js、css 等资源是通过相对路径引用的,而我们的
中的
是一个
格式内容,在寻找 js 资源的时候因为没有域名,所以找不到资源。
解决办法也很简单,我们给资源加上域名,然后本地起一个静态资源服务器就行了。修改
,加上
重新构建代码
。然后启动静态资源服务器
。通过浏览器访问
可以看到内容。
然后重新打开 Figma 插件看看。果然,插件已经正常了!
Figma 加载插件只需要 index.html 和 code.js ,其他资源都可以通过网络加载。这意味着我们可以将 js、css 资源放在服务端,实现插件的热更?不知道发布插件的时候会不会有限制,这个我还没试过。
开发模式
我们已经能成功通过 Vue 3 来构建 Figma 插件了,但是我不想每次修改代码都要构建一遍,我们需要能够自动构建代码的开发模式。
vite 自动的
dev
模式是启动了一个服务,没有构建产物(而且没有类似webpack里面的
配置),所以无法使用。
watch 模式
的 build 命令有
watch
模式,可以监听文件改动然后自动执行
。我们只需要修改
,
里新增
。
这种方式虽然修改代码后会自动编译,但是每次还是要关闭插件并重新打开才能看到更新。这样写UI还是太低效了,能不能在插件里实现
(模块热重载)功能呢?
dev 模式
vite dev 的问题在于没有构建产物。
是运行在 Fimga 主线程沙箱中的,这部分是无法热重载的,所以可以利用
实现来编译。需要热重载的是
以及相应的 js 、css 资源。
先来看一下
模式下的 html 资源有什么内容:
理论上来说,我们只需要把这个 html 手动写入到
目录就行,热重载的时候 html 文件不需要修改。直接写入的话会遇到资源是相对路径的问题,所以要么把资源路径都加上域名(
),或者使用
React
0标签。
手动生成 html 文件
对比上面的 html 代码和根目录的
文件,发现只是增加了一个
。所以我们可以自己解析
,然后插入相应这个标签,以及一个
标签。解析 HTML 我们用
。
同时 vite 提供了
React
1,所以我们可以代码组织起来,写一个 js 脚本来启动开发模式。新建文件
,完整内容如下:
执行
,然后在 Figma 中重启插件。试试修改一下 Vue 代码,发现插件内容自动更新了!
最后在
中新建一个修改一下dev的内容为
就可以了。
通过请求来获取 HTML
前面通过自己生产
的方式有很大的弊端:万一后续 vite 更新后修改了默认 html 的内容,那我们的脚本也要跟着修改。有没有更健壮的方式呢?我想到可以通过请求
来获取 html 内容,然后写入本地。话不多说,修改后代码如下:
结语
Figma 基于Web平台的特性使之能成为真正跨平台的设计工具,只要有浏览器就能使用。同时也使得开发插件变得非常简单,非专业人士经过简单的学习也可以上手开发一个插件。而Web社区有数量庞大的开发者,相信 Figma 的插件市场也会越来越繁荣。
本文通过一个例子,详细讲述了使用 Vue 3 开发 Figma 插件的过程,并且完美解决了开发模式下热重载的问题。我将模板代码提交到了 Git 仓库中,需要的同学可以直接下载使用:
React
2。
开发 Figma 插件还会遇到一些其他问题,例如如何进行网络请求、本地存储等,有空再继续分享我的实践心得。
本文转载自:https://juejin.cn/post/7084639146915921956
作者:大料园
(学习视频分享:
React
3)
htmljscsscanvasiframeiframepostMessage(STRING)iframeiframejsiframepostMessagemanifest.jsonmainjsuiiframehtmlfigma.showUI()iframemanifest.json{
"name": "simple-demo",
"api": "1.0.0",
"main": "main.js",
"ui": "index.html",
"editorType": [
"figjam",
"figma"
]
}index.html
Demo
Figma 插件 Demo
当前色块数量:0
main.jsconsole.log('from code 2');
figma.showUI(__html__, {
width: 400,
height: 400,
});PluginsDevelopmentImport plugin from manifest...manifest.jsonPluginsDevelopmentsimple-demoPluginsDevelopmentOpen consolemain.jsmain.jsfigmafigma.createRectangle()figma.ui.onmessagemain.jsconsole.log('figma plugin code runs!')
figma.showUI(__html__, {
width: 400,
height: 400,
});
const nodes = [];
figma.ui.onmessage = (msg) => {=
if (msg.type === "add-block") {
const rect = figma.createRectangle();
rect.x = nodes.length * 150;
rect.fills = [{ type: "SOLID", color: { r: 1, g: 0.5, b: 0 } }];
figma.currentPage.appendChild(rect);
nodes.push(rect);
} else if (msg.type === "sub-block") {
const rect = nodes.pop();
if (rect) {
rect.remove();
}
}
figma.viewport.scrollAndZoomIntoView(nodes);
};index.htmlparent.postMessagefunction addBlock() {
console.log('add');
var num = +blockNumEle.innerText;
num += 1;
blockNumEle.innerText = num;
parent.postMessage({ pluginMessage: { type: 'add-block' } }, '*')
}
function subBlock() {
console.log('substract');
var num = +blockNumEle.innerText;
if (num === 0) return;
num -= 1;
blockNumEle.innerText = num;
parent.postMessage({ pluginMessage: { type: 'sub-block' } }, '*')
}jshtmljsiframehtmlVue 3VueTypeScriptnpm init vite@latest figma-plugin-vue3 --template vue-ts
cd figma-plugin-vue3
npm install
npm run devhttp://localhost:3000src/App.vue
Figma 插件 Demo
当前色块数量:{{ num }}
src/workersrc/worker/code.tsconsole.log('figma plugin code runs!')
figma.showUI(__html__, {
width: 400,
height: 400,
});
const nodes: RectangleNode[] = [];
figma.ui.onmessage = (msg) => {
if (msg.type === "add-block") {
const rect = figma.createRectangle();
rect.x = nodes.length * 150;
rect.fills = [{ type: "SOLID", color: { r: 1, g: 0.5, b: 0 } }];
figma.currentPage.appendChild(rect);
nodes.push(rect);
} else if (msg.type === "sub-block") {
const rect = nodes.pop();
if (rect) {
rect.remove();
}
}
figma.viewport.scrollAndZoomIntoView(nodes);
};figmanpm i -D @figma/plugin-typingstsconfig.jsontypeRoots"skipLibCheck": true{
"compilerOptions": {
// ...
"skipLibCheck": true,
"typeRoots": [
"./node_modules/@types",
"./node_modules/@figma"
]
},
}manifest.jsonindex.htmlcode.jspublicdist{
"name": "figma-plugin-vue3",
"api": "1.0.0",
"main": "code.js",
"ui": "index.html",
"editorType": [
"figjam",
"figma"
]
}viteindex.htmlnpm i -D @types/nodeNode.jsNode.jsvite.config.tsbuild.rollupOptionsinputhashoutputimport { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
build: {
sourcemap: 'inline',
rollupOptions: {
input:{
main: resolve(__dirname, 'index.html'),
code: resolve(__dirname, 'src/worker/code.ts'),
},
output: {
entryFileNames: '[name].js',
},
},
},
})npm run builddistdistPluginsDevelopmentImport plugin from manifest...dist/manifest.jsonindex.htmliframesrcbase64vite.config.tsbase: 'http://127.0.0.1:3000'import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
base: 'http://127.0.0.1:3000',
build: {
sourcemap: 'inline',
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
code: resolve(__dirname, 'src/worker/code.ts'),
},
output: {
entryFileNames: '[name].js',
},
},
},
preview: {
port: 3000,
},
})npm run buildnpm run previewhttp://localhost:3000/writeToDiskvitebuildpackage.jsonscripts"watch": "vite build --watch"npm run watch
# 同时要在另一个终端里启动静态文件服务
npm run previewHMRcode.jsvite build --watchindex.htmlnpm run devdisthttp://localhost:3000index.htmlindex.htmljsdomconst JSDOM = require('jsdom');
const fs = require('fs');
// 生成 html 文件
function genIndexHtml(sourceHTMLPath, targetHTMLPath) {
const htmlContent = fs.readFileSync(sourceHTMLPath, 'utf-8');
const dom = new JSDOM(htmlContent);
const { document } = dom.window;
const script = document.createElement('script');
script.setAttribute('type', 'module');
script.setAttribute('src', '/@vite/client');
dom.window.document.head.insertBefore(script, document.head.firstChild);
const base = document.createElement('base');
base.setAttribute('href', 'http://127.0.0.1:3000/');
dom.window.document.head.insertBefore(base, document.head.firstChild);
const result = dom.serialize();
fs.writeFileSync(targetHTMLPath, result);
}scripts/dev.jsconst { JSDOM } = require('jsdom');
const fs = require('fs');
const path = require('path');
const vite = require('vite');
const rootDir = path.resolve(__dirname, '../');
function dev() {
const htmlPath = path.resolve(rootDir, 'index.html');
const targetHTMLPath = path.resolve(rootDir, 'dist/index.html');
genIndexHtml(htmlPath, targetHTMLPath);
buildMainCode();
startDevServer();
}
// 生成 html 文件
function genIndexHtml(sourceHTMLPath, targetHTMLPath) {
const htmlContent = fs.readFileSync(sourceHTMLPath, 'utf-8');
const dom = new JSDOM(htmlContent);
const {
document
} = dom.window;
const script = document.createElement('script');
script.setAttribute('type', 'module');
script.setAttribute('src', '/@vite/client');
dom.window.document.head.insertBefore(script, document.head.firstChild);
const base = document.createElement('base');
base.setAttribute('href', 'http://127.0.0.1:3000/');
dom.window.document.head.insertBefore(base, document.head.firstChild);
const result = dom.serialize();
fs.writeFileSync(targetHTMLPath, result);
}
// 构建 code.js 入口
async function buildMainCode() {
const config = vite.defineConfig({
configFile: false, // 关闭默认使用的配置文件
build: {
emptyOutDir: false, // 不要清空 dist 目录
lib: { // 使用库模式构建
entry: path.resolve(rootDir, 'src/worker/code.ts'),
name: 'code',
formats: ['es'],
fileName: (format) => `code.js`,
},
sourcemap: 'inline',
watch: {},
},
});
return vite.build(config);
}
// 开启 devServer
async function startDevServer() {
const config = vite.defineConfig({
configFile: path.resolve(rootDir, 'vite.config.ts'),
root: rootDir,
server: {
hmr: {
host: '127.0.0.1', // 必须加上这个,否则 HMR 会报错
},
port: 3000,
},
build: {
emptyOutDir: false, // 不要清空 dist 目录
watch: {}, // 使用 watch 模式
}
});
const server = await vite.createServer(config);
await server.listen()
server.printUrls()
}
dev();node scripts/dev.jspackage.json"dev": "node scripts/dev.js"index.htmldevServerconst { JSDOM } = require('jsdom');
const fs = require('fs');
const path = require('path');
const vite = require('vite');
const axios = require('axios');
const rootDir = path.resolve(__dirname, '../');
async function dev() {
// const htmlPath = path.resolve(rootDir, 'index.html');
const targetHTMLPath = path.resolve(rootDir, 'dist/index.html');
await buildMainCode();
await startDevServer();
// 必须放到 startDevServer 后面执行
await genIndexHtml(targetHTMLPath);
}
// 生成 html 文件
async function genIndexHtml(/* sourceHTMLPath,*/ targetHTMLPath) {
const htmlContent = await getHTMLfromDevServer();
const dom = new JSDOM(htmlContent);
// ...
const result = dom.serialize();
fs.writeFileSync(targetHTMLPath, result);
}
// ...
// 通过请求 devServer 获取HTML
async function getHTMLfromDevServer () {
const rsp = await axios.get('http://localhost:3000/index.html');
return rsp.data;
}
dev();