当产品需要为每篇内容自动生成社交分享图时,传统做法往往是在后端拼接 SVG 模板,手动控制颜色、字体、间距。这种方式不仅写起来很痛苦,后续修改样式也极不灵活。而如果能把前端擅长的开发体验——用原子化类名快速搭建 UI——直接引入到图片生成环节,就能极大提升效率。UnoCSS2Image 就是这样一个方案:它允许我们用 UnoCSS 的类名描述样式,然后在 Node.js 环境中解析这些类名并最终渲染为真正的图片。本文将从零开始,逐步实现一个轻量级的图片生成服务,并深入其中的关键技术点。

UnoCSS 核心原理回顾:为什么它能成为图片生成的基础
UnoCSS 是一种按需生成的原子化 CSS 引擎。与传统的 Tailwind 等预先生成完整样式表不同,UnoCSS 在解析 HTML(或其他模板文件)时,只提取实际用到的类名,然后根据预设规则生成对应的 CSS 声明。这种特性使其输出极度精简,非常适合在服务端场景中使用——毕竟我们不希望把一大堆永远用不到的样式注入到图片生成流程里。
它的工作流程可以概括为:首先,扫描源文件(在图片生成场景下就是传入的 HTML 字符串),提取出所有形如 text-red-500、flex、p-4 的类名。然后,每个类名会命中预先定义好的规则(preset),规则描述了如何将这个类名转换成 CSS 属性和值。最后,所有规则匹配出的 CSS 被合并、去重,输出为一串内联样式表。正是因为这种“只生成使用到的样式”的设计,UnoCSS 才能无缝嵌入到 Node.js 的运行时逻辑里,而不会带来巨大的开销。
当然,默认的 UnoCSS 配置是为前端打包工具设计的,如果在纯 Node.js 环境下使用,我们需要调整扫描器和提取器的行为。幸运的是,UnoCSS 提供了核心引擎 createGenerator,可以直接在内存中完成类名到 CSS 的转换,这为我们的图片生成器铺平了道路。
架构设计:从 HTML 片段到最终图片的流水线
整个 UnoCSS2Image 的处理过程可以分为四个主要阶段:输入解析、样式生成、HTML 渲染为矢量图、矢量图转成位图。输入解析负责接收用户提交的 HTML 片段(例如一个带类名的 <div>),并做一些基本的清洗。样式生成阶段则调用 UnoCSS 的生成器,扫描 HTML 里的所有类名,产出相应的 CSS 代码。这一步的结果将直接以内联 <style> 标签的方式回插到原 HTML 中,或者通过 style 属性附着在每个元素上。
第三阶段需要将携带完整样式的 HTML 渲染成一种矢量格式。这里我们选择了 Satori 而不是 Puppeteer 这种无头浏览器。Satori 是 Vercel 开源的一套轻量级方案,它能够解析 HTML 子集并输出 SVG,同时支持 Flexbox 和部分 CSS 属性,启动速度极快且资源占用极低,特别适合请求驱动的图片生成场景。最后,通过 sharp 这个高性能图像处理库,我们把 SVG 转换成 PNG、JPEG 或者 WebP,并可以指定宽度、高度、质量等参数,最终返回给客户端。
这种架构最大的优势是无需启动浏览器进程,内存友好,单次转换延迟可以控制在 100ms 以内。同时,由于每一步都是独立的纯函数,很容易实现缓存:例如我们可以对「HTML 片段 + UnoCSS 预设」的组合计算哈希值,将生成的图片缓存到文件系统或 CDN,后续相同请求直接返回已缓存的图片。
编码实现:串联 UnoCSS、Satori 与 sharp
开始写代码之前,需要安装必要的包:@unocss/core、satori、sharp,以及一个预设包,比如 @unocss/preset-uno。下面是一个核心函数的实现骨架。
import { createGenerator } from '@unocss/core'
import presetUno from '@unocss/preset-uno'
import satori from 'satori'
import sharp from 'sharp'
// 初始化 UnoCSS 生成器(只做一次)
const generator = createGenerator({
presets: [presetUno()],
})
export async function htmlToImage(html: string, width = 1200, height = 630) {
// 1. 提取类名并生成 CSS
const { css } = await generator.generate(html, {
minify: true,
})
// 2. 将 CSS 注入到 HTML 中
const styledHtml = `<div style="display:flex;flex-direction:column;...">${html}</div>`
// 更可靠的做法是把 css 放进 <style> 标签,这里简化处理
// 3. Satori 渲染 SVG
const svg = await satori(styledHtml, {
width,
height,
// Satori 要求将 HTML 转为 React-like 对象,要用 preact 或类似转换
// 这里需要配合 satori-html 将 HTML 字符串转为 VNode
fonts: [
{ name: 'Inter', data: interFontBuffer, weight: 400 },
],
})
// 4. sharp 转成 PNG
const pngBuffer = await sharp(Buffer.from(svg))
.png()
.toBuffer()
return pngBuffer
}
上面的代码是一个极度简化的示意,生产环境里还需要处理字体加载、Satori 对 HTML 字符串的解析(通常借助 satori-html 这类库将 HTML 转为 VNode),以及更稳健的内联样式注入策略。这里有两个关键细节:第一,generator.generate 返回的 css 字符串已经是纯 CSS,我们可以把它塞进 <style>${css}</style> 再拼接到 HTML 头部。第二,Satori 对 CSS 的支持并非 100% 覆盖,推荐只使用它能识别的属性(如 flexbox、color、background、border 等),而我们用 UnoCSS 生成的样式本身就多为这类原子属性,因此兼容性很好。
对于动态字体,可以将 .ttf 文件读成 ArrayBuffer 传入 fonts 配置。为了支持中文,可以加载思源黑体等。如果希望图片呈现漂亮的渐变或圆角,UnoCSS 类名如 bg-gradient-to-r、rounded-lg 都能被正确解析为 CSS,Satori 也都能忠实渲染。这让我们可以用写前端组件的方式设计图片布局,体验非常顺畅。
性能优化与缓存策略
图片生成服务对延迟很敏感,尤其是在高并发场景下。第一个优化点是重用 UnoCSS 生成器实例。因为生成器在初始化时会编译预设和规则,这个步骤比较耗时。我们应当在应用启动时就创建好它,而不是每次请求都重新创建。同样,Satori 的字体对象也应该只加载一次。
第二个关键优化是缓存。由于 UnoCSS 生成的内容完全由输入 HTML 和预设决定,可以对输入字符串做哈希,将生成的 PNG Buffer 存入本地磁盘或 Redis。当相同的请求再次到来时,直接返回缓存,跳过所有生成步骤。也可以使用 CDN 配合 ETag 做浏览器缓存。对于内容变化不频繁的 OG 图片(例如文章标题不变时),这种做法能削减 90% 以上的计算量。
第三个方向是减少 SVG 到 PNG 的转换开销。如果客户端能接受 SVG 格式,直接返回 SVG 可以省去 sharp 的步骤,加载速度也更快。但在大多数社交平台(如 Twitter、Facebook)仍然要求 PNG 格式,所以可以在服务端根据请求头 Accept 进行格式协商,灵活返回 SVG 或 PNG。另外,对生成的 CSS 做缓存同样有意义:如果同一个页面模板多次使用,只是文本内容变化,我们可以预先生成模板的静态样式,每次只替换文字部分,这可以通过缓存 <style> 块实现。
最后提醒一点:如果使用 Satori 遇到了未支持的 CSS 属性,不会报错,而是直接忽略,这可能导致图片布局错乱。因此强烈建议在调试时先生成 SVG 文件查看效果,确认无误后再部署到生产。整体来说,这套方案已经在很多个人博客和轻量级产品中验证过,稳定可靠。