设计实践

用 TypeScript 写一个 Figma 插件“批量重命名图层”

用 TypeScript 写一个 Figma 插件“批量重命名图层”:选中一堆图层,按模板(如 icon/{name}-{nn})或查找替换批量改名,面板里实时预览改名前后的对照,按画布位置从左到右、从上到下编号,改错了 Ctrl+Z 一步撤销。

BINBHO · 图文教程

用 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

项目资源

用途 地址
插件开发入门 https://developers.figma.com/docs/plugins/
插件快速开始 https://developers.figma.com/docs/plugins/plugin-quickstart-guide/
manifest 字段 https://developers.figma.com/docs/plugins/manifest/
插件与界面通信 https://developers.figma.com/docs/plugins/creating-ui/
figma 全局对象 API https://developers.figma.com/docs/plugins/api/figma/
插件类型定义 https://github.com/figma/plugin-typings
esbuild https://esbuild.github.io/

建议先跑 npm test 和 npm run build,再在 Figma 桌面版里导入清单,选三个矩形试一次模板命名。

确认预览和撤销都正常后,再拿真实的图标文件使用,并按自己团队的命名规范调整默认模板。