TemPad Dev 是一个浏览器扩展,它让普通用户在 Figma 的只读页面中也能查看和复制设计元素的样式。由于 Inspect 面板在推出 Dev Mode 以后被调整到付费墙后了,而且只读模式下我们也无法运行 Figma 插件,所以我们希望通过浏览器扩展,为只读模式用户提供基础的设计交付能力。
在实现过程中,我们遇到了不少限制与挑战。在这里我想简单回顾一下,和大家简单分享一下里面涉及的一些有意思的问题,以及我们解决问题的思路和方法。
基础流程:选区与样式读取
Figma 的画布是使用 WebGL 通过 <canvas> 渲染的,所以我们无法直接从 DOM 上得到用户的交互信息比如当前选区。这一切都需要依赖 Figma 在每个页面上的 figma 全局对象。Figma 在官方文档上提示我们,这个 API 是在每个 Figma 的页面上都存在的:

^↑ developers.figma.com/docs/plugin...^
在 TemPad Dev 诞生前,设计工程师 Hal 就在他的 PoC 项目 Figma viewer chrome plugin 中实现了最基本的 D2C 流程。米其林在逃主厨 zouhang 也开发了 fubukicss-tool 项目,提供了更为完善的 CSS、UnoCSS 支持。
类似的,参考他们的实现,TemPad 的核心流程其实也很简单:当用户在画布中选择某个节点时,通过 figma.currentPage.selection 获取当前选区。随后通过 selection[0].getAsyncCSS() 就可以异步调用 Figma 的 WASM 模块,得到包含该节点样式信息的、以标准 CSS 属性和值构成的 JavaScript 对象。我们可以直接将其序列化,展示到我们想展示的地方。在 TemPad Dev 中,我们会以 CSS 源码和 JavaScript 对象两种方式输出,方便用户分别在样式代码或者 JSX 中进行使用。
Inspect 面板:原生的交互体验
我们希望 Inspect 面板可以自然融入整个设计交付的流程中,就像是原生支持这个功能。如果让面板界面直接采用 Figma 本身的设计语言,让使用者感受不到这里有一个额外的第三方插件在工作,整个流程就能丝滑许多。
基于这样的概念,我们除了保持和 Figma 一样的设计以外,还直接在开发 TemPad Dev 面板时全面使用了 Figma 页面上自带的全局 CSS 变量,比如 --color-border、--color-text、--color-bg-brand 等等。 这样做除了可以保持样式的统一融入,还额外"免费"地自动支持了暗色模式。当用户切换至暗色模式时,由于 Figma 自身的这些变量值会自动变化,扩展的界面也随之适配,无需额外逻辑。

^↑ 自动支持暗色模式^
除了直接可见的扩展面板,我们希望如 tooltip 或者 toast 提示也能和 Figma 保持一致。通过检查 Figma 的各种 tooltip 的触发元素我们发现,它们均带有 data-tooltip-type、data-tooltip 属性。过往的开发经验告诉我们,这个功能是通过事件代理的方式实现的,所以当我们为 TemPad Dev 自身的交互元素添加这些属性时,也可以调起原生的 tooltip。实际情况也是如此。

^↑ 调起 Figma 原生的 tooltip^
对于 toast 提示,Figma 在 Plugin API 直接提供了 figma.notify() 方法,我们可以直接调起使用。
Inspect 面板最重要的部分无疑是代码片段了。除了基础的尺寸、字号等样式以外,如何能和 Figma 原生的代码区块保持一致的代码高亮规则呢?我们发现,Figma 在前端使用的是 Prism 库进行的代码高亮,而 Prism 会在页面的全局命名空间暴露 Prism 对象以及它的所有 API,所以我们直接调用它不仅可以直接和 Figma 原生代码区块保持相同的代码高亮,甚至还避免了自己打包加载一个代码高亮库,节省了扩展的体积。同时我们还可以直接从 jsDelivr 这样的服务直接加载额外的 Prism 语言支持包,来增强全局的 Prism,从而可以扩展支持更多 Figma 本身没有处理高亮的语言,比如 SASS(SCSS)、Less、Stylus 等。
关于如何伴随 Figma 从 UI2 迁移到 UI3 之类的过程,在这里就不再赘述了。
深度选择与测量模式
在只读模式下,Figma 选择图层的交互并没有像在 Dev Mode 下那样进行过优化。我们无法直接选中嵌套层级最深处的图层,而是需要通过每次双击下钻一层的方式逐层向下直到选到所需要的图层。而许多开发者并不知道的是,Figma 提供一个快捷方式,可以按住 ⌘ 点击鼠标来直接选中最深层节点。同时,测量不同元素间的距离,也需要用户按住 ⌥ 并移动鼠标。这些操作首先并不被开发者广泛了解,也需要额外按键才能调起,不像 Dev Mode 中那样被特别优化过。这也侧面说明,Figma 真的知道开发者们要的是什么,但是因为有 Dev Mode,所以任何提升交付效率的功能,都得藏到 Dev Mode 里。
那么我们有没有什么办法可以提升这方面的体验呢?一个很自然的想法是:我们想办法让 Figma 认为用户正在按下 ⌘ 或 ⌥ 键,那么鼠标移动时就能达到相同的效果。但毕竟我们无法真正为用户按下快捷键,那要如何做到这一点呢?我们转变一下思路,很容易想象这一功能要如何实现:监听 mousemove 事件,当事件触发时,去检测 event.metaKey 和 event.altKey 的值,在 true 时就触发相应的功能。那么实现方案也就呼之欲出了:我们使用 Reflect.defineProperty 来为 MouseEvent.prototype 的 metaKey 和 altKey 挂载一个强制返回 true 的 getter,这样我们就可以骗过 Figma 的事件处理器,模拟用户按键了。
ts
const metaKey = Reflect.getOwnPropertyDescriptor(MouseEvent.prototype, 'metaKey')!
const altKey = Reflect.getOwnPropertyDescriptor(MouseEvent.prototype, 'altKey')!
export function setLockMetaKey(lock: boolean) {
if (lock) {
Reflect.defineProperty(MouseEvent.prototype, 'metaKey', {
get: () => true
})
} else {
Reflect.defineProperty(MouseEvent.prototype, 'metaKey', metaKey)
}
}
export function setLockAltKey(lock: boolean) {
if (lock) {
Reflect.defineProperty(MouseEvent.prototype, 'altKey', {
get: () => true
})
} else {
Reflect.defineProperty(MouseEvent.prototype, 'altKey', altKey)
}
}
不过,永远锁定修饰键会破坏许多原生交互。例如按住空格可以拖动画布、按住 ⌘ 滚动滚轮可以缩放视图。如果这些键被强制为按下状态,用户的正常操作可能发生异常。为此我们一旦检测到用户鼠标移出画布、正在拖动画布或滚动页面,便暂时恢复修饰键的真实状态 。当操作结束后,再自动进行恢复。我们把这两个功能做成了设置,用户可以根据需要选择启用。
至此,TemPad Dev 最基础的功能流程就都能跑通了。但如同上面说的,这些方案得以生效,都依赖于官方在只读页面提供 Plugin API,既 window.figma 对象。我们在开发 TemPad Dev 的过程中,也曾担心 Figma 为了售卖 Dev Mode,剥夺使用者在只读页面访问 window.figma 的能力。但又转念一想,对于这么拳头的 Dev Mode 功能,Figma 不至于没信心到专门针对我们这种通过调用 Plugin API 就能实现的小工具。但是我们错了。
Quirks Mode:另辟蹊径
2024 年 3 月,Figma 在只读页面中移除了 window.figma,导致 TemPad Dev 无法通过官方 API 获取节点属性。为了让用户可以继续使用,我们只能暂时建议用户通过"Duplicate to your drafts"功能把设计稿复制成自己可编辑的版本,从而可以继续使用 Plugin API。但是这个操作非常繁琐,而且复制出来的草稿将无法自动同步上游设计稿的后续改动,可能造成很大的沟通障碍。
我在 Twitter 以及 Figma 的论坛上发帖(Figma removed window.figma on view-only pages today),对他们的这一举动提出质疑。过了不久,Figma 的设计运营分别在 Twitter 和论坛都对我进行了答复,表示移除 window.figma 是一个意外,并且承诺几周以后就会修复。

^↑ Figma 的设计运营 Tom Lowry 的答复^
然而,两周过去,官方运营人员在 Figma 论坛上的口径从"未来几周"修改为了"未来几个月",实质上就意味着会无限期搁置。社区关注度上来了大方承诺,等热度过去了长期搁置,只能说他们是懂互联网的。
如前文所述,复制到草稿的解决方法短期内可以解决燃眉之急,但是长期来看依然很不理想。为了寻找替代途径,我们开始寻找替代的方案。通过在浏览器的全局对象中逐个检查 window 上暴露的非原生字段,我们发现了一个名为 DebuggingHelpers 的对象,其中有一个 logSelected() 会用一种自定义的格式输出包含节点大部分属性的文本 。尽管这些文本并非公开标准格式,只是一种内部序列化的字符串,但它为我们提供了一个突破口:在不依赖 Plugin API 的情况下仍然能够访问部分样式信息。大致的日志格式如下:
arduino
logging node state for 12:34
{
name: <ImmutableString: "Button / Primary">
type: <NodeType: E::TEXT>
parent-index: <ImmutableString: "0:567;12:34;">
x: <float: 120.5>
y: <float: 48>
size: <TVector2<float>: VectorF(80, 24)>
text-auto-resize: <TextAutoResize: E::WIDTH_AND_HEIGHT>
align-x: <AlignX: E::LEFT>
align-y: <AlignY: E::TOP>
fill-paint-data: <Immutable<PaintData>: PaintData(
SolidPaint(rgba(34, 197, 94, 1), opacity 1),
GradientPaint(<stops...>, opacity 1)
)>
stroke-paint-data: <Immutable<PaintData>: PaintData()>
blend-mode: <BlendMode: E::NORMAL>
effect-data: <Immutable<EffectData>: EffectData[2]>
transform: <AffineTransformF: AffineTransformF(1, 0, 0, 0, 1, 0)>
plugin-data: <PluginData: [
{"pluginID":"1126010039932614529","key":"foo","value":"bar"},
{"pluginID":"","key":"my-namespace-myKey","value":"baz"}
]>
}
通过解析日志内容,理论上我们可以大致还原出来当前被选中的节点的样式信息。
但是,日志并不包含全部设计信息,而且 Figma 节点的属性值与 CSS 需要的也并非一一对应。通过一系列猜测和测试,我们最终还是可以保证正确解析出大多数的 CSS 样式信息。对于文本节点,日志输出的字体名称并非 font-family 所需要的,我们会根据字体名称进行一系列猜测和转换,并使用启发式方法获取字重。
模式名称 "Quirks" 来自浏览器历史上的"怪异模式":当一个页面缺少标准声明时,浏览器会进入一种兼容模式渲染页面。TemPad Dev 的 Quirks Mode 也承担着类似使命------当官方 Plugin API 无法使用时,通过解析调试日志的方式尽可能还原样式。由于这一模式依赖的是未公开接口,功能难免有限,但在 API 缺失的情况下依然可以提供基本的样式查看能力。
在相当长的一段时间内,Quirks Mode 都很好地完成了它的历史使命:在 window.figma 不可用时,保证了 TemPad Dev 基本的可用性。2025 年 4 月,Figma 在 DebuggingHelpers 中移除了 logSelected() 方法,Quirks Mode 也随即正式失效。
插件机制:不止 CSS
众所周知,样式编写的方式有一百种。所以我们提供了一种在 Chrome 扩展中加载、执行 JavaScript 插件的机制,让用户可以在标准的 TemPad Dev 代码区块中以自己需要的方式输出并消费代码。

^↑ 安装 TemPad Dev 插件进行组件代码生成^
要实现一个插件机制,我们主要要解决下面几个问题:
- 运行机制与环境
- 插件 API
- 发布与安装
这三个问题几乎决定了整个插件系统的形态。
运行机制与环境
TemPad Dev 将插件代码传入 Web Worker 中,以和主线程隔离的方式运行,暴露给它的仅是必要的上下文:选中节点的数据、getAsyncCSS() 已经生成的 CSS 对象以及通过白名单方式允许访问的部分全局 API。在这个环境中插件无法主动与主线程通信、无法访问 DOM、无法发起网络请求。
TemPad Dev 在代码生成主流程完成后,在将代码输出到 Inspect 面板中前,我们会对 Worker 中的代码生成模块发送节点数据和 CSS 对象,在等待插件对输出进行转换后,再渲染到代码区块中进行展示。
理论上我们还可以提供一个运行时间监控并且主动杀死超时的 Worker 并暂时禁用相关的插件,来进一步进行管控,但目前看来暂时还没有进一步引入复杂度的必要。
插件 API
TemPad Dev 插件的核心 API 非常简单:
ts
export function definePlugin(plugin: Plugin): Plugin {
return plugin
}
definePlugin() 是一个提供了完整插件类型定义的 identity function,用户只需要定义一个 Plugin 类型的对象作为入参,就能完成一个 TemPad Dev 插件的开发。
在插件对象中,用户可以定义 transform、transformVariable 和 transformPx 这几个钩子介入处理流程,重写 CSS 字符串、变量、转换像素单位或组件结构,而无需关心底层实现。通过这些简单的转换钩子,已经有社区开发者提供了 UnoCSS、Tailwind、React Native 等基于 CSS 的格式转换插件。
一个最简单的 Stylus 转换插件只要简单的几行代码就能实现:
ts
import { definePlugin } from '@tempad-dev/plugins'
export default definePlugin({
name: 'My Plugin',
code: {
css: {
title: 'Stylus', // Custom code block title
lang: 'stylus', // Custom syntax highlighting language
transform({ style }) {
return Object.entries(style)
.map(([key, value]) => `${key} ${value}`)
.join('\n')
}
},
js: false // Hides the built-in JavaScript code block
}
})
对于组件的代码生成,Figma Dev Mode 支持一套重编译期的方案 Code Connect,可以帮助开发者关联设计系统中的 Figma 组件和前端组件库实现。其工具链比较复杂,通过静态分析 TypeScript 编写的如下的连接代码来理解转换逻辑,把 Figma 组件的变体和前端组件的 props 做映射:
ts
import figma from '@figma/code-connect/react'
figma.connect(Button, 'https://...', {
props: {
label: figma.string('Text Content'),
disabled: figma.boolean('Disabled'),
size: figma.enum('Size', {
Large: 'large',
Medium: 'medium',
Small: 'small',
}),
},
example: ({ disabled, label, size }) => {
return (
<Button size={size} disabled={disabled}>
<Text>{label}</Text>
</Button>
)
},
})
TemPad Dev 采取了另一种方案,让插件开发者编写从 Figma 组件到 VDOM 的映射,在插件运行时来进行转换:
ts
import { h } from '@tempad-dev/plugins'
export function Button(component) {
const {
Label,
Disabled,
Size
} = component.properties
return h(
'Button',
{
size: Size.toLowerCase(),
disabled: Disabled,
},
[Label]
)
}
这样,我们通过简洁的桥接模块,就可以完成组件级别的代码生成了。关于 Code Connect 使用的编译期静态模板 + CLI 的方案和 TemPad Dev 的插件运行时方案的比较,我在这里就不详细展开了。有兴趣的朋友可以自行了解,也欢迎找我讨论。
发布与安装
如果有人开发了一个插件,要如何发布并让其他用户安装使用呢?我们希望整套方案足够轻量级,不为 TemPad Dev 本身带来维护负担,而且又要方便插件开发者维护和发布。几经权衡,我们采取了如下的方案:
- 插件的产物形态,是一个有 named export plugin 的 ESM 文件,插件开发者直接编写一个单文件插件,也可以使用任何打包器或者编译工具产出这样一个插件文件;
- TemPad Dev 在插件配置中,可以通过一个可公开访问、允许跨域请求的 URL,来下载插件文件;
- 在 TemPad Dev 的仓库中有一个插件索引文件,插件的开发者可以向 TemPad Dev 仓库发起 Pull request 来修改这个文件,把自己开发的插件"注册"到官方目录中,被 TemPad Dev 仓库收录的插件会有一个名称,在用户安装插件时就可以使用如
@pluginName这样的标记来加载插件代码。
本着一切从简的原则,我们没有引入版本号的概念,也不会自动更新,用户可以手动通过更新按钮重新拉取插件的最新版本。可公开访问且支持跨域请求的服务很多,通常来说 GitHub raw 文件或者 Gist 就已经足够方便插件开发者上传维护了,只需要把打包编译后的插件文件也同步进代码库即可。 这种设计让发布和分发完全去中心化,同时保持安装的易用性。
生态与应用
目前 TemPad Dev 的插件目录中已经包含了多个社区贡献的 UnoCSS、TailwindCSS 和 React Native 插件,也有官方(我)实现的基于 Kong Design System(我目前供职的公司使用的设计系统)和 Nuxt UI 实现的组件级别代码生成插件。
NuxtLabs 的创始人 Sebastien Chopin 在 VueJS Amsterdam 2025 大会的演讲中也对 TemPad Dev 的 Nuxt UI 插件进行了展示。

^↑ 在 VueJS Amsterdam 2025 上的展示^
脚本重写:柳暗花明
Figma 关于"恢复 window.figma 访问"的承诺至今都没有兑现。Quirks Mode 在 window.figma 被移除后短时间内缓解了问题,但它也有明显局限:除了前文所述的一些限制以外,调试日志并未输出组件级信息,无法满足组件级代码生成的需求。所以,在 TemPad Dev 插件支持 transformComponent 钩子之前,我们还是得找到让 Plugin API 重新"复活"的办法。
经过调试我们发现,无论是编辑模式还是只读模式,Figma 在前端加载的仍然是同一套 JavaScript 资源;只是只读场景在创建 window.figma 之前走了另一条逻辑分支。所以,我们只要能想办法拦截并重写相关判断条件,就可以在只读模式下把 Plugin API 构建起来,帮助 Figma 完成他们的承诺。
如何在浏览器扩展中完成对特定脚本的重写呢?在 Manifest V2 时代,扩展可以使用 chrome.webRequest API 以阻塞方式拦截并修改网络请求。但随着 Chrome 扩展架构升级到 Manifest V3,阻塞式的 webRequestBlocking 权限不再可用,官方建议使用声明式的 declarativeNetRequest(DNR)规则。
在 DNR 规则下,我们也无法直接去动态修改 request body,而只能声明式地进行过滤、取消、重定向、修改 HTTP 头等操作。而且 Figma 前端是 webpack 构建的(现已切换到 Rspack),生成的多数 chunk 文件都使用了纯 hash 字符串作为文件名,我们并不能通过文件路径判断一个文件是否是我们需要关心的。那我们要如何找到需要替换的文件,拦截并且替换呢?
加载器策略
答案是,把所有可能包含需要改写代码的文件全部重定向到同一个固定的 rewrite.js 文件。看到这里你可能会有疑惑:全都重定向到一个固定文件,要怎么执行 Figma app 原来的程序逻辑?
事实上,我们可以通过如下的流程加载并执行所有重定向的脚本:
- 通过
document.currentScript.src得到当前正在执行的<script>元素获取原始请求地址; fetch对应的脚本内容;- 按规则替换逻辑分支和检测语句;
- 最后用
new Function()执行修正过的脚本。
实施了这一套方案以后,我们终于成功地让 window.figma 在只读模式下重见天日。
抽象规则、热更新与自动检测
为了应对 Figma 频繁的前端构建更新,我们把替换逻辑抽象成规则集------每条规则定义要匹配的模式和替换模板。每次因为改版或者打包造成替换规则失效时,只要经过快速的单步调试和修改规则,就能较快地修复问题。为了避免用户在修复前的窗口期发现 TemPad Dev 的 Inspect 面板不渲染而产生困惑,我们添加了错误提示,将用户导流到我们的 Discord 频道和 GitHub repo,方便我们快速发现和解决问题。

^↑ 在错误警告视图对用户进行引导^
同时,我们利用 GitHub Actions 中,定时检查我们的替换规则对 Figma 当前版本是否生效,一旦失效我们就可以快速监控到。
由于从扩展发版到用户更新还是需要比较长的时间,我们利用 browser.declarativeNetRequest.updateDynamicRules() 来热更新 DNR 规则文件,并且把加载器文件也从扩展中剥离,并且最终决定把这两个文件都托管在 GitHub Pages 上。这可以让我们通过一个简单的 GitHub Actions 工作流就完成自动更新部署,而且不依赖第三方服务,响应也能包含正确的 Content-Type 头(对比 GitHub Raw 就不行)。
来源检测
2025 年 8 月,部分用户反馈 TemPad Dev 提示暂不可用,但是我们的 Actions 任务并没有显示任何异常。原来 Figma 灰度发布了一个改动,导致有部分用户遇到 TemPad Dev 无法读取到 window.figma 的情况。但是奇怪的是,这些用户在浏览器控制台通过 window.figma 可以正常访问 Plugin API。这是怎么回事呢?
我的第一感觉是我们被 Figma 精确狙击了,猜测可能是通过如下的方式:
ts
const figma = { /* Plugin API */ }
Object.defineProperty(window, 'figma', {
enumerable: false,
configurable: false,
get() {
if (document.currentScript?.src?.startsWith('chrome-extension://')) {
return undefined
}
return figma
}
})
找能够复现的朋友帮忙查看,果然发现一段类似的代码,只不过用了 Error().stack 来检查脚本来源:

^↑ Figma 说好的恢复 window.figma 访问变成了进一步限制^
里面有两个看起来怪怪的字符串:dispnf.fyufotjpo;00 和 np{.fyufotjpo;0。原来 Figma 为了不让人发现他们专门针对浏览器扩展做了限制,还贴心地把 chrome-extension:// 和 moz-extension:// 在 ASCII 表上偏移了一位 。但由于我们有重写脚本的能力,所以只要再扩展一下规则,就可以让扩展继续正常工作。
处理异步 chunk
前面的一系列策略使得 TemPad Dev 可以在只读视图下正常运行起来了,但是我们发现浏览器控制台时有 chunk loading error 类的报错;而且一些可能是核心流程依赖异步 chunk 的功能比如 Prototype 和 Slides 都会直接无法使用。我们仔细思考了当前策略,发现这是因为在把异步 chunk 重定向到加载器时,加载/执行/回调的时序与原有逻辑不一致。
当我们的加载器加载完成时,浏览器会立刻认为被重定向的这个 <script> 元素已经加载并执行。而 webpack / Rspack runtime 在 onload 触发后,就可以马上执行依赖这个 chunk 的模块逻辑。问题在于,我们的加载器 fetch、替换、执行脚本,这个过程也是异步的。所以 runtime 认为请求的异步 chunk 中的模块已经执行完毕,而实际上此时加载器仍在执行,从而导致引用错误甚至页面崩溃。
有没有不走 DNR 重定向到加载器,也能重写脚本的办法呢?我不知道大家有没有了解过类似 qiankun 这种微前端框架的工作原理,它会通过重写 Element.prototype 上的 appendChild 和 insertBefore 等方法,拦截所有插入 <script> 的操作,通过 fetch 加载 src 指定的脚本文件,然后用 new Function() 运行,来把 window 指定到自己创建的 Proxy 对象上,以构建一个沙箱环境。
我们这里也可以用类似的方案,唯一的区别是执行前先应用我们的转换规则。虽然这种方法对内联在 HTML 里的 <script> 无效,但我们只需要处理异步脚本,刚好和 DNR 重定向各司其职;而我们指定的 DNR 规则只对 JS 资源请求生效,刚好也能避开 fetch 请求,从而避免重复处理。
应用了这一套静态重定向 + 拦截动态插入混合的策略以后,chunk loading error 的问题就完美解决了。
最后
本文分享了 TemPad Dev 在实现只读视图 Inspect 能力过程中用到的一些技术细节和思路。如果能读到这里,你一定是一个依然抱有好奇心的开发者。我相信除了所有人都在讨论的 AI 之外,我们也有别的东西可以探讨和分享。希望 TemPad Dev 和这篇文章本身能对你有所帮助。
GitHub · Chrome Web Store · Discord
这个扩展的名字为什么叫 TemPad Dev? 这个名字的灵感来自漫威漫画中时间管理局(Time Variance Authority)所使用的设备------TemPad。那个装置能穿梭时间线、访问任何分支世界。彼时我正在百度做一些 Design to Code(D2C)方面的探索,我们最初开发了一个名为 TemPad 的 Figma 插件给设计师使用,它可以将 React/Vue 组件在 web 端真实渲染后插入 Figma 设计稿,以保证设计与实现的一致性。这个名字寓意让它成为设计和开发这两个世界之间的"时空桥"。TemPad Figma 插件负责生产端,而 TemPad Dev 则是面向开发者的消费端。
本文原载于知乎:zhuanlan.zhihu.com/p/196340231...