用 TypeScript 写一个 Figma 插件“批量重命名图层”:选中一堆图层,按模板(如
icon/{name}-{nn})或查找替换批量改名,面板里实时预览改名前后的对照,按画布位置从左到右、从上到下编号,改错了 Ctrl+Z 一步撤销。
先看清成品
成品是一个可在 Figma 桌面版加载的插件项目。本文代码已实际编译:TypeScript 类型检查通过,esbuild 打包出 2.3 KB 的
dist/code.js;命名规则的 3 个单元测试全部通过;还用一个模拟的figma对象在 Node 里运行了插件主线程代码,三个乱序摆放的图层按位置被正确命名为icon-01-24、icon-02-24、icon-03-32。在真实 Figma 里加载和点击界面的过程没有在本文环境中操作。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 模板命名 | {name} 原名、{n} 序号、{nn} 两位序号、{w} {h} 尺寸 |
| 查找替换 | 支持普通文本和正则表达式 |
| 实时预览 | 显示前 50 个图层的改名对照 |
| 位置排序 | 按画布位置编号,而不是选择顺序 |
| 单元测试 | 命名逻辑独立于 Figma 测试 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Figma 桌面版 | 最新版 | 加载本地开发插件需要桌面版 |
| Node.js | 20 或更高 | 编译 TypeScript |
| TypeScript | 5.x | 类型检查 |
| esbuild | 0.28.x | 把多个文件打包成插件需要的单个 code.js |
| @figma/plugin-typings | 1.x | 本文使用 1.142.0 |
第一步:插件由哪些部分组成?
Figma 插件分两部分:
| 部分 | 文件 | 能做什么 |
|---|---|---|
| 主线程 | dist/code.js |
访问 figma 对象,读写图层 |
| 界面 | ui.html |
普通网页,画面板,不能直接访问图层 |
两部分通过 postMessage 互相发消息。
1. 项目结构
batch-rename/
├── manifest.json
├── ui.html
├── src/
│ ├── code.ts # 主线程
│ ├── rename.ts # 命名规则(纯函数)
│ └── rename.test.ts
├── tsconfig.json # 插件代码的类型检查
├── tsconfig.test.json # 测试代码单独编译
└── package.json
2. 让 AI 写初稿(可选)
帮我写一个 Figma 插件(TypeScript),批量重命名选中的图层:
- 规则逻辑写在独立的 rename.ts 纯函数里,支持模板 {name} {n} {nn} {w} {h} 和查找替换(可选正则)
- code.ts 按图层的 y、x 坐标排序后编号,选区变化时把预览发给 UI
- ui.html 提供模式切换、输入框、预览表格和“应用”按钮,使用 Figma 主题颜色变量
- 用 esbuild 打包成单个 dist/code.js
第二步:配置文件怎么写?
1. manifest.json
{
"name": "批量重命名图层",
"id": "000000000000000000",
"api": "1.0.0",
"main": "dist/code.js",
"ui": "ui.html",
"editorType": ["figma"],
"documentAccess": "dynamic-page",
"networkAccess": { "allowedDomains": ["none"] }
}
| 字段 | 作用 |
|---|---|
main |
主线程代码 |
ui |
面板网页 |
editorType |
只在 Figma 设计文件里运行 |
documentAccess: "dynamic-page" |
按需加载页面,新插件要求使用 |
networkAccess |
不访问任何网络,用户可以放心 |
2. package.json
{
"name": "batch-rename",
"private": true,
"scripts": {
"build": "tsc -p . --noEmit && esbuild src/code.ts --bundle --target=es2017 --outfile=dist/code.js",
"watch": "esbuild src/code.ts --bundle --target=es2017 --outfile=dist/code.js --watch",
"test": "tsc -p tsconfig.test.json && node --test build-test/rename.test.js"
},
"devDependencies": {
"@figma/plugin-typings": "^1.0.0",
"@types/node": "^22.0.0",
"typescript": "^5.9.0",
"esbuild": "^0.28.2"
}
}
3. tsconfig.json 和 tsconfig.test.json
{
"compilerOptions": {
"target": "ES2017",
"lib": ["ES2017"],
"strict": true,
"types": ["@figma/plugin-typings"],
"noEmit": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"types": ["node"],
"outDir": "build-test"
},
"files": ["src/rename.ts", "src/rename.test.ts"]
}
插件代码只加载 Figma 类型,测试代码只加载 Node 类型,原因见后面的“出问题时该怎么调”。
第三步:核心代码怎么写?
1. src/rename.ts
// 纯函数:根据规则计算新名字,不依赖 Figma,可以在 Node 里测试
export interface Rule {
mode: "pattern" | "replace";
pattern?: string; // 例如 "icon/{name}-{n}",可用 {name} {n} {nn} {w} {h}
find?: string;
replace?: string;
useRegex?: boolean;
start?: number; // 序号起始值
}
export interface LayerInfo {
name: string;
width: number;
height: number;
}
export function pad(n: number, width: number): string {
const s = String(n);
return s.length >= width ? s : "0".repeat(width - s.length) + s;
}
export function slug(name: string): string {
return name.trim().toLowerCase().replace(/\s+/g, "-");
}
export function newName(layer: LayerInfo, index: number, rule: Rule): string {
if (rule.mode === "replace") {
if (!rule.find) return layer.name;
if (rule.useRegex) return layer.name.replace(new RegExp(rule.find, "g"), rule.replace ?? "");
return layer.name.split(rule.find).join(rule.replace ?? "");
}
const n = (rule.start ?? 1) + index;
return (rule.pattern ?? "{name}")
.replace(/\{name\}/g, slug(layer.name))
.replace(/\{nn\}/g, pad(n, 2))
.replace(/\{n\}/g, String(n))
.replace(/\{w\}/g, String(Math.round(layer.width)))
.replace(/\{h\}/g, String(Math.round(layer.height)));
}
export function preview(layers: LayerInfo[], rule: Rule): { from: string; to: string }[] {
return layers.map((l, i) => ({ from: l.name, to: newName(l, i, rule) }));
}
2. src/code.ts
import { newName, preview, Rule } from "./rename";
figma.showUI(__html__, { width: 360, height: 420, themeColors: true });
// 按画布上的位置排序(先上后下、先左后右),序号才符合直觉
function selected(): SceneNode[] {
return [...figma.currentPage.selection].sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y));
}
const info = (n: SceneNode) => ({ name: n.name, width: n.width, height: n.height });
function sendPreview(rule: Rule) {
const nodes = selected();
figma.ui.postMessage({ type: "preview", rows: preview(nodes.map(info), rule).slice(0, 50), total: nodes.length });
}
let lastRule: Rule = { mode: "pattern", pattern: "{name}-{nn}", start: 1 };
figma.on("selectionchange", () => sendPreview(lastRule));
figma.ui.onmessage = (msg: { type: string; rule: Rule }) => {
lastRule = msg.rule;
if (msg.type === "preview") return sendPreview(msg.rule);
if (msg.type === "apply") {
const nodes = selected();
if (nodes.length === 0) return figma.notify("请先选中要重命名的图层");
try {
nodes.forEach((node, i) => (node.name = newName(info(node), i, msg.rule)));
} catch (e) {
return figma.notify(`规则有误:${(e as Error).message}`, { error: true });
}
figma.notify(`已重命名 ${nodes.length} 个图层,可按 Ctrl/Cmd+Z 撤销`);
sendPreview(msg.rule);
}
};
sendPreview(lastRule);
| 要点 | 说明 |
|---|---|
figma.showUI(__html__) |
__html__ 是 Figma 注入的 ui.html 内容 |
| 按 y、x 排序 | 选择顺序常常是乱的,按位置更符合直觉 |
selectionchange |
换选区时自动刷新预览 |
figma.notify |
在画布底部显示提示 |
| 一次性改完 | Figma 把一次运行里的修改合并,一次 Ctrl+Z 就能撤销 |
3. ui.html
<style>
body { font: 12px system-ui, sans-serif; margin: 12px; color: var(--figma-color-text); background: var(--figma-color-bg); }
label { display: block; margin: 8px 0 4px; }
input, select { width: 100%; box-sizing: border-box; padding: 6px; }
table { width: 100%; margin-top: 10px; border-collapse: collapse; }
td { padding: 3px 4px; border-bottom: 1px solid var(--figma-color-border); word-break: break-all; }
button { margin-top: 10px; width: 100%; padding: 8px; }
.hint { color: var(--figma-color-text-secondary); }
</style>
<label>模式
<select id="mode"><option value="pattern">按模板命名</option><option value="replace">查找替换</option></select>
</label>
<div id="patternBox">
<label>模板 <span class="hint">{name} 原名 {n} 序号 {nn} 两位序号 {w}×{h} 尺寸</span><input id="pattern" value="{name}-{nn}"></label>
<label>序号起始<input id="start" type="number" value="1"></label>
</div>
<div id="replaceBox" hidden>
<label>查找<input id="find"></label>
<label>替换为<input id="replace"></label>
<label><input id="regex" type="checkbox" style="width:auto"> 使用正则表达式</label>
</div>
<button id="apply">应用到选中图层</button>
<div id="count" class="hint"></div>
<table id="rows"></table>
<script>
const $ = (id) => document.getElementById(id);
const rule = () => ({
mode: $("mode").value, pattern: $("pattern").value, start: Number($("start").value) || 1,
find: $("find").value, replace: $("replace").value, useRegex: $("regex").checked,
});
const send = (type) => parent.postMessage({ pluginMessage: { type, rule: rule() } }, "*");
document.querySelectorAll("input,select").forEach((el) => el.addEventListener("input", () => {
$("patternBox").hidden = $("mode").value !== "pattern";
$("replaceBox").hidden = $("mode").value !== "replace";
send("preview");
}));
$("apply").onclick = () => send("apply");
onmessage = (e) => {
const m = e.data.pluginMessage;
if (m.type !== "preview") return;
$("count").textContent = m.total ? `已选中 ${m.total} 个图层(预览前 50 个)` : "请在画布上选中图层";
const td = (s) => { const d = document.createElement("td"); d.textContent = s; return d; };
$("rows").replaceChildren(...m.rows.map((r) => { const tr = document.createElement("tr"); tr.append(td(r.from), td("→"), td(r.to)); return tr; }));
};
</script>
用 textContent 而不是 innerHTML 填表格,图层名里有 < 等字符也不会出问题。
第四步:怎样编译、测试和加载?
1. 编译
npm install
npm run build
2. 在 Figma 里加载
打开 Figma 桌面版和任意设计文件,菜单 插件 → 开发 → 从清单导入插件,选择项目里的 manifest.json。之后在 插件 → 开发 里就能运行。
修改代码后重新执行 npm run build(或保持 npm run watch 运行),再在 Figma 里重新运行插件即可。
注意:
manifest.json里的id是占位值。本地开发可以直接用;要发布到 Figma 社区时,在发布流程里获取正式 ID 并替换。
3. 运行单元测试
npm test
本文实际输出:
ok 1 - 模板:原名转小写短横线、两位序号、尺寸
ok 2 - 查找替换:普通文本和正则
ok 3 - pad 补零
# pass 3
# fail 0
4. 在 Figma 里检查结果
| 操作 | 期望 |
|---|---|
| 选中 3 个图标后运行插件 | 面板显示“已选中 3 个图层”和预览 |
模板填 icon/{name}-{nn} |
预览实时变化 |
| 点“应用” | 图层面板里的名字全部改变,底部提示已重命名 |
| 按 Ctrl/Cmd+Z | 一次全部恢复 |
查找模式填 copy,替换为空 |
去掉复制图层名字里的 “ copy” |
出问题时该怎么调?
1. 编译报 Cannot redeclare block-scoped variable 'fetch'
本文第一次编译时遇到:
node_modules/@figma/plugin-typings/index.d.ts(27,9): error TS2451: Cannot redeclare block-scoped variable 'fetch'.
node_modules/@types/node/web-globals/fetch.d.ts(19,14): error TS2451: Cannot redeclare block-scoped variable 'fetch'.
Figma 类型和 Node 类型都定义了 fetch、console 等全局变量,放在同一个编译配置里就会冲突。解决办法就是本文的两份 tsconfig:插件代码用 "types": ["@figma/plugin-typings"],测试代码用 "types": ["node"]。
2. Figma 里报 require is not defined
直接用 tsc 编译多个文件时,code.js 里会出现 require("./rename"),而插件运行环境不支持模块加载。用 esbuild 的 --bundle 把所有代码打包进一个文件。
3. 正则写错导致插件报错
例如查找里填了一个 (。代码里用 try/catch 包住了改名过程,会在画布底部提示“规则有误”。
4. 面板显示“请在画布上选中图层”
插件只处理当前页面的选区。确认已经选中图层,或者重新点一下图层触发选区变化。
常见问题怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 菜单里没有“从清单导入插件” | 用的是网页版 | 安装 Figma 桌面版 |
| 改代码后没变化 | 没重新打包 | npm run watch 保持运行 |
| 名字里中文被删掉 | slug 只保留空格转换 |
本文的 slug 不删中文;如果你改了正则,注意保留 |
| 选中组内图层编号乱 | 坐标是相对父级的 | 只选同一层级的图层 |
| 想改组件实例里的图层名 | 实例内部受组件约束 | 到主组件里修改 |
| 面板颜色和 Figma 主题不一致 | 没开 themeColors |
showUI 里设置 themeColors: true |
项目资源
建议先跑 npm test 和 npm run build,再在 Figma 桌面版里导入清单,选三个矩形试一次模板命名。
确认预览和撤销都正常后,再拿真实的图标文件使用,并按自己团队的命名规范调整默认模板。