☰
Figma插件开发:一键导出GIF与编码调优
2026/10/2 5:34:37 网站建设 项目流程

上周有个做动效的朋友来找我,说他把一个 24 帧的加载动画在 Figma 里画完了,Figma 插件开发、导出 GIF 这件事却卡了整整两天:手动画板一个个导出 PNG,再拖到外部工具里合成,改一版颜色就得重来一遍。我听完直接把他的活儿接过来,写了个一键导出 GIF 的小插件,从选区里读帧、编码、下载,全程不到 30 秒。这篇就把这套东西完整拆开讲一遍——Figma 插件开发的骨架怎么搭、GIF 编码放在哪一层、量化参数怎么调、帧率为什么会莫名奇妙变成 12.5 而不是 12,以及我踩过的那些坑。

适合谁看:会一点点 JavaScript、想入门 Figma 插件的设计师;天天和设计稿打交道、需要把动效交付成 GIF 的前端和运营;还有那种「不想装一堆外部软件,就想在设计工具里把活干完」的人。代码我尽量给全,参数我尽量给依据,不玩虚的。

1. 先把需求拆开:Figma 里生成 GIF 到底卡在哪

1.1 一个非常具体的场景

想象一下这个流程:设计师用 Smart Animate 做好了加载动效,一共 24 帧,每帧是一个 200×200 的图标旋转加位移。现在运营要把这个动效放到产品文档里,前端要把它放进 App 的空态页,公众号编辑要插进图文里——这三个场景要的交付物都是 GIF。而 Figma 的原生导出面板里,你翻遍 Format 下拉菜单,只有 PNG、JPG、SVG、PDF 四个选项,没有任何一个是动图格式。

于是大部分人走向了同一条路:选中画板,导出 PNG,重命名,改下一帧,再导出。24 帧就是 24 次重复劳动,任何一个像素的调整都意味着全部重做。更麻烦的是命名——你手动导出的文件会变成Frame 1.png、Frame 1-1.png,顺序全靠自己记,最后在外部的在线合成工具里拖来拖去,帧顺序错了还得重新排。

这就是插件要解决的第一个问题:把「逐帧导出 + 外部合成」压缩成「选中一个 Frame,点一下,拿到 GIF」。它不是一个炫技需求,它省的是每天重复的那二十分钟。

1.2 先搞清楚 Figma 原生能力的边界

动手之前必须弄清楚一件事:Figma 的插件 API 能不能直接吐出一个 GIF?答案是不能。插件里唯一能拿到位图的接口是figma.exportAsync(),它的format参数只接受PNG、JPG、SVG、PDF这几种,返回的是一段Uint8Array。也就是说,Figma 负责帮你把矢量图层栅格化成一张一张的静态图,而「把静态图串成动图」这件事,得插件自己算。

这里有个容易被忽略的细节:GIF 格式本身的编码逻辑(LZW 压缩、调色板、帧延迟控制块)跟 PNG 完全是两套东西,浏览器原生的canvas.toBlob()也只支持image/png和image/jpeg,没有任何一个内置 API 能直接生成动图。所以我们必须引入一个 GIF 编码库,自己把像素数据喂进去。

顺便说一个很多人踩过的误区:Figma 的画布本身是可以放 GIF 的,你把一个 GIF 拖进去它也能显示,在演示模式下还会动。但那是「图片资源」层面的支持,跟「导出」是两条完全不相干的路。你没法反向把它拆成帧,也没法把一个图层序列直接存成 GIF。

能力Figma 是否支持说明
导出 PNG / JPG支持exportAsync原生,带透明度
导出 SVG / PDF支持矢量格式,不适合动效
导出 GIF / WebP 动图不支持需要插件自行编码
画布内放置 GIF支持作为图片填充,演示模式下会播放
读取画布内 GIF 的帧不支持只能整体当作一张位图

1.3 三条技术路线的取舍

要把「一堆 PNG 字节」变成 GIF,业内大致有三条路,我一开始三条都试了,最后只留下一条。

路线 A:主线程导出 PNG,UI 线程用 gifenc 编码。这是最干净的分工。主线程(也就是插件运行的那个沙箱环境)负责调exportAsync拿字节,通过消息通道发给 UI 线程;UI 线程是一个真实的 iframe,有完整的 DOM、canvas、createImageBitmap,把 PNG 解码成像素数组后用 gifenc 编码,最后触发下载。优点是依赖轻、编码快、代码量少,整个库压缩后不到 20KB。

路线 B:UI 线程用 gif.js + Web Worker。gif.js 的思路是把编码丢进 Worker 里,避免卡住 UI。听起来很美,但它需要额外的 worker 脚本文件,而 Figma 的插件 UI 环境对外部资源加载有约束,你得把 worker 脚本内联成 Blob URL 才能跑,构建配置会变得很绕。而且它的编码速度实测比 gifenc 慢一截,透明处理也比较糙。

路线 C:导出 PNG 序列丢给服务端合成。直接排除。一来插件需要声明网络访问白名单,用户看到「这个插件要联网」会犹豫;二来设计稿上传到第三方本身就让人不放心;三来多一次往返,体验更慢。

方案编码速度(24 帧 400×400)依赖体积透明度构建复杂度
gifenc(路线 A)约 0.8~1.5 秒约 15KB支持 1 位透明低
gif.js(路线 B)约 2~4 秒约 40KB + worker支持 1 位透明高
服务端合成(路线 C)取决于网络无看实现中,但需要联网

结论很明确:路线 A。它把「需要 Figma 权限的事情」和「需要浏览器能力的事情」分得清清楚楚,后面调试的时候你会感谢这个分工——因为两边的日志和报错是完全分开的,出问题一眼就能定位在哪一层。

2. 开发环境搭起来:从零到一个能跑的插件壳

2.1 目录结构与 manifest 的关键字段

先别看代码,先看结构。一个最小可用的 Figma 插件只有两个必需文件:一个是主线程入口,一个是 UI 的 HTML。但真写起来,我会拆成下面这样:

gif-maker/ ├── manifest.json ├── package.json ├── build.mjs ├── src/ │ ├── main.ts # 主线程:读选区、导出 PNG、发消息 │ └── ui.ts # UI 线程:解码、编码 GIF、下载 └── dist/ ├── main.js └── ui.html

manifest.json里有几个字段必须写对,写错了插件根本不加载:

{ "name": "GIF Maker", "id": "000000000000000000", "api": "1.0.0", "main": "dist/main.js", "ui": "dist/ui.html", "editorType": ["figma"], "documentAccess": "dynamic-page", "networkAccess": { "allowedDomains": ["none"] } }

注意:documentAccess这个字段是后来加上的,新插件必须声明为dynamic-page,否则在遍历页面节点时会直接报错。改成dynamic-page之后,所有跨页面访问节点的 API 都变成了异步版本,比如要先用await figma.loadAllPagesAsync(),取节点要用await figma.getNodeByIdAsync(id)。如果你看的教程比较老,代码里全是同步写法,那是历史遗留,新建项目别这么写。

networkAccess我也建议直接写"none"。我们这套方案全程本地计算,不发任何请求。用户装插件的时候会看到网络权限说明,写 none 能显著提升信任度——这一点在社区里是实打实影响安装量的。

2.2 构建链路:esbuild 就够用

Figma 要求main指向一个单文件 JS,运行时没有模块解析能力,所以你必须打包。我选 esbuild,理由很简单:配置十行、构建一百毫秒、支持 TypeScript 开箱即用。用 webpack 或者 Vite 也能做,但为了这么小的项目配一堆 loader 和 plugin,属于自找麻烦。

// build.mjs import * as esbuild from 'esbuild'; import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; mkdirSync('dist', { recursive: true }); const common = { bundle: true, format: 'esm', target: 'es2020', minify: process.env.NODE_ENV === 'production', sourcemap: process.env.NODE_ENV !== 'production', }; await esbuild.build({ ...common, entryPoints: ['src/main.ts'], outfile: 'dist/main.js' }); await esbuild.build({ ...common, entryPoints: ['src/ui.ts'], outfile: 'dist/ui.js', format: 'iife', }); // 把 ui.js 内联进 html,避免相对路径失效 const js = readFileSync('dist/ui.js', 'utf8'); const html = `<!DOCTYPE html> <html><head><meta charset="utf-8"><style>/* 样式省略 */</style></head> <body><div id="root"></div><script>${js}<\/script></body></html>`; writeFileSync('dist/ui.html', html);

这里解释一下为什么要手动内联。Figma 打包 UI 的时候,确实会把 HTML 里用相对路径引用的<script src="./ui.js">自动内联进去,但它的解析规则有几个边界情况(比如路径里带..、或者用了绝对路径)会直接失败,表现就是插件面板一片空白,控制台还看不到明显报错。我的做法是自己内联,构建产物永远只有一个 HTML 文件,不给它出错的机会。

提示:format对两个入口的要求不一样。主线程用esm没问题,UI 用esm在某些情况下会因为顶层import被内联到<script>标签里而报语法错误,所以 UI 侧我固定用iife。

2.3 主线程和 UI 线程的消息通路

这是整个插件最容易写错的地方,我把两个方向都写一遍,你照着抄就行。

主线程发给 UI:

// main.ts figma.showUI(__html__, { width: 420, height: 460, themeColors: true }); figma.ui.postMessage({ type: 'frames', width: 400, height: 400, frames: frameBytesArray, // Uint8Array[] });

UI 侧接收,注意外面包了一层pluginMessage:

// ui.ts window.onmessage = (event) => { const msg = event.data.pluginMessage; if (!msg) return; if (msg.type === 'frames') { handleFrames(msg); } };

UI 回传给主线程,方向反过来,外层名字也反过来:

parent.postMessage({ pluginMessage: { type: 'cancel' } }, '*');

主线程接收:

figma.ui.onmessage = (msg) => { if (msg.type === 'cancel') { /* 中断导出循环 */ } };

两个方向的包装名不一样,这是 Figma 的历史设计,很多人第一次写会漏掉pluginMessage这一层,结果就是「消息发出去了但对面收不到」,然后开始怀疑人生。

关于Uint8Array能不能直接传,答案是能,Figma 的postMessage支持 TypedArray 的序列化。但别一次性把 60 帧全塞进去——实测超过 30MB 的消息会让 UI 面板明显卡顿甚至假死。我的做法是分批发送,每批 4 到 8 帧,每条消息带一个batchIndex,UI 侧收齐后再开始编码。这样还能顺便做进度条。

2.4 本地调试与文件重载

调试入口在 Figma 桌面客户端的菜单里:插件 → 开发 → 从 manifest 导入插件。导入之后每次改代码都要重新跑一次,官方没有热重载。我的工作流是开两个终端,一个跑esbuild --watch,一个不管,改完代码在 Figma 里按一遍快捷键重启插件,习惯了其实也就一两秒的事。

日志要分两处看。主线程的console.log出现在 Figma 的插件控制台里;UI 线程的日志在 iframe 自己的控制台里,通常在插件面板上右键能找到检查入口,具体位置不同版本的客户端略有差异,找不到就在 UI 里自己画一个调试区域把信息innerHTML出来,土但有效。

注意:如果你用 Figma 网页版调试,下载行为走的是浏览器的下载目录;用桌面客户端的话会弹系统保存对话框。这两个环境的差异会影响你测试「保存」这一步,别只在网页版测完就发布。

3. 核心实现:把图层序列变成 GIF 字节流

3.1 用 exportAsync 拿到每一帧的像素

核心循环就这几行,但里面藏着两个坑。

const settings: ExportSettings = { format: 'PNG', constraint: { type: 'SCALE', value: 2 }, // 先按 2 倍导出,后面再降采样 }; const bytesList: Uint8Array[] = []; for (const node of frames) { const bytes = await figma.exportAsync(node, settings); bytesList.push(bytes); }

第一个坑是await的写法。上面这段是顺序导出,稳定但慢;很多人第一反应是改成Promise.all(frames.map(...))一把梭,结果几十帧同时发起导出,Figma 的主进程直接被堵住,轻则界面卡死十秒,重则插件被判定超时中断。我实测下来,并发 2 到 3 个是比较舒服的平衡点,速度比纯串行快将近一倍,又不会把客户端拖垮。

第二个坑是「2 倍导出再降采样」这个动作。GIF 只有 256 色,边缘本来就容易出锯齿,直接按目标尺寸导出的话,抗锯齿后的半透明像素在降色时会被强行归类,边缘会显得脏。先用 2 倍尺寸导出,再在 canvas 里用imageSmoothingQuality = 'high'缩回来,等于多做了一次高质量的平均,边缘会干净不少。代价是导出时间和内存翻四倍,所以只在动效边缘比较关键的时候开,纯色块动效没必要。

function chunk<T>(arr: T[], size: number): T[][] { const out: T[][] = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out; } async function exportInBatches(nodes: SceneNode[], concurrency = 2) { const result: Uint8Array[] = new Array(nodes.length); for (const batch of chunk(nodes.map((n, i) => [n, i] as const), concurrency)) { const done = await Promise.all( batch.map(async ([node, idx]) => { const bytes = await figma.exportAsync(node, settings); return [idx, bytes] as const; }) ); for (const [idx, bytes] of done) result[idx] = bytes; figma.ui.postMessage({ type: 'progress', done: result.filter(Boolean).length, total: nodes.length }); } return result; }

用下标回填数组,是为了保证即使并发执行,帧顺序也不会乱。

3.2 帧序列的组织约定与排序

插件没法猜出哪一层是第 1 帧,所以必须有一套约定。我在项目里用了三层兜底策略,按优先级依次尝试:

第一种是按名字里的数字排序。约定帧图层命名为frame_01、frame_02,用正则在名字里抓数字,抓不到就排到最后。这是最可控的方式,我在插件的空状态面板里直接写了提示,告诉用户按这个格式命名。

function frameIndex(name: string): number { const m = name.match(/(\d+)(?!.*\d)/); // 取最后一个数字段 return m ? parseInt(m[1], 10) : Number.MAX_SAFE_INTEGER; }

注意这个正则用的是「最后一个数字段」而不是第一个。因为图层名常常带前缀,比如loading_v2_07,如果取第一个数字就会全部拿到 2,排序直接废掉。

第二种是按 x 坐标从左到右排。适合那种设计师把帧横向铺开、懒得命名的场景。但要注意坐标系是相对于父级的,如果帧分别放在不同的父级下,这个排序会失真,得先用absoluteTransform换算成绝对坐标。

第三种是直接用图层顺序。也就是frame.children的数组顺序。这个顺序等于图层面板里的堆叠顺序,跟视觉顺序经常不一致,所以只作为最后的兜底。

我在实际项目里遇到的真实情况是:设计师的帧图层顺序和命名顺序经常互相矛盾,所以插件跑之前一定要把「检测到了几帧、按什么规则排的序」显示在 UI 上。让人一眼看到解析结果,比出错之后再排查省太多事。

3.3 GIF 编码:为什么选 gifenc

gifenc 的 API 设计很克制,核心就三个函数,但每个参数都值得说清楚。

import { GIFEncoder, quantize, applyPalette } from 'gifenc'; const gif = GIFEncoder(); for (let i = 0; i < frameDatas.length; i++) { const { data, width, height } = frameDatas[i]; // ImageData const palette = quantize(data, 256, { format: 'rgb565' }); const index = applyPalette(data, palette, 'rgb565'); gif.writeFrame(index, width, height, { palette, delay: 80, repeat: i === 0 ? 0 : undefined, // 只在第一帧设置循环次数 dispose: 1, }); } gif.finish(); const gifBytes = gif.bytes(); // Uint8Array,可以直接下发给浏览器

quantize负责把真彩色降成 256 色并生成调色板,applyPalette负责把每个像素映射到调色板索引,writeFrame负责写 GIF 的图形控制扩展和图像数据块。三个步骤拆开的好处是你可以自己控制调色板的复用——这点非常关键,后面讲闪烁的时候会再说。

repeat只在第一帧设置是有讲究的。GIF 的循环次数存在 Netscape 应用扩展块里,这个块通常紧跟在第一帧的头部。你在每帧都传repeat,有些解码器会以最后一帧的值为准,有些会用第一帧的,行为不一致。统一只在第一帧写,最稳。repeat: 0表示无限循环。

dispose是帧处置方式,1表示「保留当前帧」,2表示「还原为背景色」。我们的每一帧都是整幅替换的全帧图,用1就够了,用2反而会让某些解码器多做一次清屏,出现闪黑。

3.4 量化、调色板与抖动怎么调

quantize的第二个参数是最大颜色数,上限 256,这个不用解释。真正需要调的是format选项。

rgb444每个通道 4 位,色彩精度最低但索引计算最快;rgb565是绿通道多一位,人眼对绿色最敏感,所以视觉上更讨巧,也是我默认用的;rgba4444带 alpha 通道,需要透明背景时用这个。注意后两个选项的位数加起来会超过常见的调色板索引范围,gifenc 内部会处理,但你要保证quantize和applyPalette两处传的format完全一致,不一致的话出来的颜色会整片错乱,而且是那种「看着像蒙了层绿纱」的错乱,很难一眼想到是参数不匹配。

再说调色板策略,这是决定成品好坏的分水岭。默认写法是逐帧量化,每帧一张本地调色板。对于色彩变化剧烈的动效,这样每帧颜色最准。但问题在于:如果 24 帧的调色板各不相同,背景那个本该固定的浅灰色在每帧里会被量化到略微不同的值,播放起来整个画面会「呼吸」,也就是俗称的色闪。

解决办法是全局调色板:把所有帧的像素拼成一个大数组,只量化一次,得到一份共享调色板,然后每帧都用applyPalette(data, globalPalette)。

function buildGlobalPalette(frames: Uint8ClampedArray[], maxColors = 256) { const total = frames.reduce((n, f) => n + f.length, 0); const merged = new Uint8ClampedArray(total); let offset = 0; for (const f of frames) { merged.set(f, offset); offset += f.length; } return quantize(merged, maxColors, { format: 'rgb565' }); }

代价是内存。400×400 的帧,一帧 RGBA 是 640KB,60 帧拼起来接近 38MB,量化过程内部还会再翻一倍。所以我在插件里设了个门槛:总像素数超过 2000 万就自动切回逐帧调色板,并在 UI 上提示「为保证稳定性已切换调色板策略」。这个数值不是拍脑袋定的,是老款笔记本上实测的临界点,再往上就明显能听到风扇声了。

抖动这件事我建议直接放弃。GIF 的 256 色限制在纯色 UI 动效上基本不构成问题,加 Floyd–Steinberg 抖动会让编码时间翻三倍,而且抖出来的噪点在高压缩下反而放大体积。只有处理照片类素材的时候才值得考虑,而那本来就不是 Figma 动效的主流场景。

3.5 帧率、尺寸、循环次数的换算

这部分是纯数学,但有个反直觉的细节必须讲清楚:GIF 的帧延迟是以 10 毫秒为单位的。你在writeFrame里传的是毫秒,gifenc 内部会除以 10 再取整,所以你想要的帧率未必能精确实现。

目标帧率理论 delay(ms)实际写入值(厘秒)实际帧率
24 fps41.7425 fps
20 fps50520 fps
16 fps62.5616.7 fps
12 fps83.3812.5 fps
10 fps1001010 fps

看得出来,20 fps 和 10 fps 是「干净」的,12 fps 会变成 12.5 fps,24 fps 会变成 25 fps。这种偏差在播放时肉眼几乎不可见,但如果你的动画要和另一段素材对齐,就会累积出可见的错位。真要精确对齐,就用 10 的整数倍去反推帧率。

尺寸和体积的关系更值得算一笔账。GIF 用的是 LZW 无损压缩,压缩率极度依赖画面复杂度:大面积纯色的帧可能压到每像素 0.05 字节,带渐变和噪点的帧可能到 0.5 字节以上。给你一个粗略的估算公式,用来在 UI 上提示用户:

预估体积 ≈ 宽 × 高 × 帧数 × 每像素字节数 其中 纯色动效取 0.08,普通 UI 动效取 0.15,带渐变/阴影取 0.25

举个例子,600×600、24 帧、带阴影的图标动效:600 × 600 × 24 × 0.25 ≈ 2.16MB。有点大了。降到 400×400 就变成 960KB 左右,肉眼几乎看不出差别。所以我在插件里默认把 GIF 宽度限制在800px,超过就自动等比缩,并在面板上显示实际尺寸和预估体积,让用户自己决定要不要压。

循环次数这块,UI 上给三个选项就够了:无限循环(repeat: 0)、播放 N 次(repeat: N)、只播一次(repeat: 1或者用图层的 delay 设置)。绝大多数动效需要无限循环,但加载动画有时候只播一次就进下一步,这个选择权要留给用户。

3.6 结果落地:下载还是回填画布

编码完成后拿到的是Uint8Array,UI 线程里的下载写法很朴素:

function downloadGif(bytes: Uint8Array, filename = 'animation.gif') { const blob = new Blob([bytes], { type: 'image/gif' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); }

URL.revokeObjectURL那行别省。在插件这种反复运行的环境里,Blob URL 不释放会一直占着内存,跑几次之后 UI 面板就开始变卡。

还有一种把 GIF 贴回画布的思路,用主线程的figma.createImage(bytes)生成一个 image hash,再作为填充物应用到某个矩形上。但这条路的实际表现取决于客户端的解码策略,能不能动、什么时候动都不确定,我不建议把它作为主路径。想放在画布上给同事看,不如直接下载完之后手动拖进来,跟 Figma 原生的图片处理走同一套逻辑,行为可预期。

顺便提一句验证环节。拿到 GIF 之后先别急着交付,浏览器拖进去看一眼。这里有个经典的乌龙:用 macOS 自带的「预览」打开 GIF,它只显示第一帧,静止不动。很多人第一次遇到都以为是自己编码写错了,回头把代码翻了个底朝天。不是文件的问题,是预览这个工具就不支持 GIF 动图播放。想快速对比不同参数的效果,在线工具里拖进去看也很快,做格式转换、帧率微调这类验证都挺方便。

4. 画质与性能调优实战

4.1 帧数一多就卡死:并发与分片

前面提了并发控制在 2 到 3,这里补充几个实测数字,方便你判断自己的瓶颈在哪。

一个 400×400 的图层,exportAsync在SCALE: 1下单帧大约 60 到 120 毫秒;改成SCALE: 2之后,因为渲染面积翻四倍,会涨到 200 到 400 毫秒。24 帧串行导出大概 2 到 5 秒,并发 2 差不多减半。UI 侧的编码耗时相对固定,24 帧 400×400 用 gifenc 大约 0.8 到 1.5 秒,这部分基本不随帧数线性暴涨,因为瓶颈在量化而不是逐帧映射。

真正的卡死点往往出现在「全量并发 + 大尺寸」的组合上。我给插件加了个保护:帧数超过 60 或者单帧像素超过 100 万时,把并发降到 1,同时在 UI 上显示预计耗时。让用户知道要等多久,比让他盯着一个没有反馈的界面猜要好得多。

进度反馈也有讲究。别每导出一帧就postMessage一次,24 帧就是 24 条消息,UI 会一直在重绘。按 10% 的粒度上报就够了,或者干脆每批上报一次。

4.2 内存控制:别把 60 帧 RGBA 全留在堆里

内存这件事在插件里比在网页里更要命,因为 Figma 客户端本身就很吃内存,插件再啃一口,用户就会开始骂人。算一下这笔账:400×400 的一帧 RGBA 是400 × 400 × 4 = 640KB,24 帧是 15.4MB,60 帧就是 38.4MB。这还只是 PNG 解码出来的ImageData,gifenc 内部还会生成索引数组(每帧 1 字节每像素,即 160KB)和调色板,再加上浏览器解码 PNG 时的临时缓冲,实际峰值很容易到原始数据的 2.5 倍。

我的处理方式有三个层次。

第一,用完就断开引用。每帧编码完立刻把imageData和中间数组置为null,别指望垃圾回收能及时介入,插件的生命周期里对象存活时间比你想象得长:

for (let i = 0; i < datas.length; i++) { const d = datas[i]; const palette = applyPaletteAndQuantize(d); writeFrameToGif(d, palette); datas[i] = null; // 及时断开引用 }

第二,走流式路径。gifenc 支持拿到ImageData后立刻编码,不需要把所有帧的解码结果都留在内存里。所以我在 UI 侧收到一批Uint8Array就立刻解码 + 编码 + 丢弃,而不是等全部收完再统一处理。虽然代码稍微绕一点,但峰值内存能压掉一半以上。

第三,设上限并明确告知。我在插件里硬性限制单次最多 120 帧,超过就提示用户分段导出。这个数字是权衡后的结果:120 帧在 10 fps 下是 12 秒的动画,已经覆盖了 99% 的 UI 动效场景。

4.3 透明、蒙版与混合模式的坑

GIF 的透明度只有 1 位,也就是说一个像素要么完全透明,要么完全不透明,没有中间地带。而 Figma 里几乎每一张带抗锯齿的边缘图都有一堆半透明像素。这就带来了一个必然的取舍:你要么接受硬边,要么把这些半透明像素混到某个背景色上。

我的默认策略是在 UI 侧做一次 alpha 阈值处理,低于阈值的直接透明,高于阈值的和指定的背景色做混合:

function flattenAlpha(data: Uint8ClampedArray, bg = [255, 255, 255], threshold = 128) { for (let i = 0; i < data.length; i += 4) { const a = data[i + 3]; if (a < threshold) { data[i] = data[i + 1] = data[i + 2] = 0; data[i + 3] = 0; } else { data[i] = Math.round((data[i] * a + bg[0] * (255 - a)) / 255); data[i + 1] = Math.round((data[i + 1] * a + bg[1] * (255 - a)) / 255); data[i + 2] = Math.round((data[i + 2] * a + bg[2] * (255 - a)) / 255); data[i + 3] = 255; } } }

然后给用户一个背景色选择器,默认白色,可以切成透明或者自定义色。实测下来,纯色背景的动效选择「不透明背景」效果最好,比强行做成透明边缘要干净得多。

蒙版和混合模式方面,exportAsync是渲染成位图再给你的,所以绝大多数图层效果都会被正确烘焙进去。真正会遇到问题的是两类:一是带模糊或大面积阴影的图层,导出的边界可能比预期大一圈或多一圈;二是超出画板范围的内容会被裁掉,如果你需要完整形态,得先把它放进一个足够大的 Frame 里再导出。

如果发现导出的位图带了多余的画板背景或者边距,去调contentsOnly和useAbsoluteBounds这两个参数。想知道它们具体怎么裁,最快的办法是在 Figma 的导出面板里手动导一次同一个图层,把参数对着看,比翻文档快。

4.4 闪烁、色带与边缘锯齿

闪烁前面提过,根源是逐帧换调色板,解法是全局调色板。但还有一种闪烁容易被忽略:帧与帧之间的元素位置发生了亚像素级别的偏移。这在图层本身没问题的情况下出现,通常是设计师在每帧里手动微调了位置,或者用了带奇数值的约束。表现为整个画面在轻微抖动。我的处理是给插件加一个「对齐到整数像素」的开关,在解码后把画布drawImage的目标坐标取整:

ctx.drawImage(bitmap, Math.round(dx), Math.round(dy), width, height);

这一行能解决相当一部分「说不上哪里不对但就是抖」的问题。

色带出现在大面积的平滑渐变上,这是 256 色的硬限制导致的。缓解手段有两个:一是把quantize的format从rgb444换成rgb565,绿色通道多一位,人眼感知上明显更顺;二是如果渐变占了画面主体,可以考虑把渐变的色阶在源文件里改少一点——是的,这是反直觉的,源文件里色阶越少,量化时越不容易出现阶梯状断层。

边缘锯齿则是分辨率问题。前面说的「2 倍导出再降采样」就是针对这个的,配合imageSmoothingQuality = 'high',效果提升很明显:

const c = document.createElement('canvas'); c.width = targetW; c.height = targetH; const ctx = c.getContext('2d'); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(bitmap2x, 0, 0, targetW, targetH);

还有一个跟锯齿无关但同样影响观感的坑:字体回退。如果动效里有文字图层,而导出环境里缺少对应字体,文字会回退成系统默认字体,字重和字宽都会变,动起来就是文字在跳。这种情况在换电脑、用网页版复现设计稿的时候特别容易撞上。稳妥的做法是导出前先确认文字图层的字体在两边都是一致的,实在不放心就把关键文字转成轮廓图层。

5. 踩坑记录与排查速查表

5.1 常见报错与定位思路

下面这张表是我这两个月攒下来的,按「现象」查比按「报错关键词」查更实用。

现象最可能的原因处理方式
插件面板一片空白ui路径错、构建产物没生成、UI 里用了 esm 被内联检查dist/ui.html是否存在,UI 入口改成iife
UI 收不到任何消息漏了pluginMessage这层包装两个方向的包装名必须按规范写
主线程报Cannot read properties of null用同步 API 取节点,documentAccess已改成dynamic-page换成getNodeByIdAsync,先loadAllPagesAsync
导出的帧全是空白图层visible: false,或者不在当前页导出前检查node.visible,必要时先切页面
导出的帧尺寸不一致每帧的图层边界不同统一用一个固定尺寸的 Frame 包住每帧内容
编码速度慢到离谱并发太高、SCALE太大、开了抖动并发降到 2,SCALE降到 1,关抖动
下载的 GIF 打不开忘了gif.finish(),或者gif.bytes()拿早了finish()之后再取字节
在 macOS 预览里不动预览工具不支持 GIF 动图换浏览器打开验证,文件本身没问题
整体色偏、发绿quantize和applyPalette的format不一致两处统一成rgb565
体积大得离谱尺寸大、帧数多、色彩数满额宽度限到 800 以内,帧数砍到 60 以内
播放时画面「呼吸」逐帧调色板导致背景色漂移改用全局调色板,或把背景色固定到调色板里
图层面板顺序和播放顺序对不上兜底用了children顺序改用名字里的数字排序,或按 x 坐标排

再说两个不太像「报错」但很折磨人的情况。

一个是进度条永远停在 90%。原因通常是最后一批帧因为尺寸特殊,编码耗时特别长。解决办法是别用「已处理帧数」算进度,改成「已完成字节数 / 总字节数」,感知上更平滑。

另一个是同一份设计稿,两次导出结果体积差很多。这多半是因为帧序列的解析结果不同——第一次按名字排序拿到了 24 帧,第二次某个图层被重命名了,变成 23 帧。所以插件里一定要把「解析到几帧」显示出来,让它成为一个可见的契约。

5.2 发布前的自检清单

插件写完能跑,和能发布到社区,中间还差一口气。这几条我每次都过一遍:

  • manifest.json里的name和id是否和社区后台登记的一致,networkAccess是否写成了none(我们的方案确实不需要联网)。
  • 所有console.log是否清干净,尤其是那些会打印大量字节数据的调试语句。
  • 空选区、只选中一个图层、选中了 500 个图层,这三种边界情况会不会崩。
  • 中途取消能不能真正中断导出循环,而不是让它在后台继续跑完。
  • 进度、成功、失败三种状态是否有明确提示,别只有一个转圈。
  • 面板文案是否需要多语言。社区面向全球用户,至少准备英文和中文两套。
  • 插件图标和描述。图标是 128×128,描述里把「导出 GIF」这个关键词放进去,搜索命中率会明显提高。
  • 用 Figma 网页版和桌面客户端各跑一遍完整流程,重点测下载这一步。

5.3 几个提升体验的小设计

最后分享几个我自己觉得很值、但一开始完全没想到要做的小功能。

参数记忆。用figma.clientStorage把上次用的尺寸、帧率、循环次数存下来,下次打开插件直接回填。用户改一版动效往往要导出好几轮,每次都要重设一遍参数是最让人烦躁的。

await figma.clientStorage.setAsync('lastSettings', { width, fps, repeat, bg });

自动推断帧序列。选中一个包含多个子图层的 Frame 时,直接把它的子图层当成候选帧,并在 UI 上画一排小缩略图让用户确认顺序。这比让用户手动勾选快太多了。

失败时把第一帧贴回画布。编码出错的时候,很多时候你根本不知道是导出阶段的问题还是编码阶段的问题。如果在异常处理里把导出成功的第一帧作为图片贴到画布上,你一眼就能看出是 Figma 那边没导对,还是后面的编码出了问题。这个调试手段我在排查一个「只有某几帧全黑」的诡异 bug 时救过命——最后发现是那几帧的图层被父级的裁剪遮罩完全盖住了,exportAsync老老实实地导出了全黑。

顺手加个 WebP 分支。浏览器原生的canvas.toBlob('image/webp')能生成静态 WebP,比 PNG 小很多。如果你的场景不要求动图,只是想要一张小体积的预览图,这条路能省掉不少字节。但记住它不产生动图,别指望用它替代 GIF 那一套。

关于后续能扩展的方向,我目前想到两个比较实用的。一个是批量导出:把多个 Frame 各自导成一个 GIF,一次跑完,适合图标库这种场景。另一个是导出 PNG 序列压缩包:有些前端同事不要 GIF,他们要的是逐帧 PNG 自己去控制播放节奏,这时候同一份导出结果直接打包成 zip 就行,成本很低,但能覆盖另一批用户。

我个人在实际操作中的体会是,这个插件真正的难点从来不在 GIF 编码本身——那部分照着 gifenc 的文档写,半小时就能跑通。难的是把设计稿里那些「人一看就懂、机器完全不懂」的约定翻译成代码:哪几层算一帧、按什么排序、透明背景怎么处理、字体不一致怎么办。把这些问题在插件的 UI 上一条条摆出来让用户确认,比在代码里写一堆猜测逻辑要可靠得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询