动态 OG 图:Next / Satori 与体积坑
产品文档里每一页标题都不同,运营却只准备了一张固定的 1200×630 主视觉。Slack 里展开时,二十条链接长得一模一样——没人知道点的是哪一节。
这时候你需要的不是「再换一张漂亮一点的 PNG」,而是按 URL / 标题在请求时生成卡片图。工程主流是 Next 的 opengraph-image + Satori(next/og);运营主流是 Bannerbear、Placid 一类模板 API。两条路都能出图,成本和翻车点完全不同。
何时真的需要动态图
先问:静态一张图是不是就够?
| 场景 | 静态图通常够 | 值得上动态图 |
|---|---|---|
| 营销首页、单一落地页 | ✅ | 很少 |
| 博客 / changelog / 文档站,每页标题不同 | 勉强能用统一品牌图 | ✅ 标题进图,辨识度高 |
| 用户生成内容(活动页、个人主页) | 不行 | ✅ 或模板 SaaS |
| 多语言同一路径不同文案 | 易混 | ✅ 按 locale 出图 |
| 活动期每天换主视觉 | 可手工 | 看更新频率 |
动态图解决的是卡片辨识度,不解决「标签写错了」「爬虫抓不到 HTML」「缓存还是旧图」。那些仍走标签、SSR、rescrape 链路——见系列里的空白卡片与缓存篇。
代码路径 vs 模板 SaaS:选型表
代码路径(Next opengraph-image / Satori / @vercel/og) |
模板 SaaS(Bannerbear、Placid 等) | |
|---|---|---|
| 谁改文案 | 开发改代码或读 CMS / frontmatter | 运营在模板里拖文案层 |
| 出图时机 | 构建期静态生成,或请求时边缘 / Node 渲染 | 调 API 生成后存 CDN URL |
| 字体与中文 | 自己 subset、自己背锅 | 厂商字库与排版,受套餐限制 |
| 成本形态 | 算力 / 冷启动 / 依赖体积 | 按张数或套餐订阅 |
| 版本与回滚 | 跟仓库走,可 PR review | 模板在 SaaS 后台,和代码分叉 |
| 适合 | 标题 + 品牌底图这种可编程布局 | 设计迭代频繁、非技术同学要自助出图 |
OGKit 明确不做模板设计器。 产品边界是诊断与门禁(规则、扫描、CI、扩展 rescrape),不是和 Bannerbear 抢拖拽画板。需要可视化模板时:走 SaaS,或自建设计稿导出再挂绝对 URL。本文只写代码路径里最容易踩的坑,以及如何在生成之后用规则验最终 URL。
最小 Next 结构(概念级)
Next App Router 约定:在路由段放 opengraph-image.tsx(或 .jsx / 静态 .png),框架会把生成图挂到该段页面的 Open Graph 图片上。底层常用 ImageResponse(next/og),渲染引擎是 Satori——把受限的 JSX 布局画成 PNG,不是完整浏览器。
下面是示意结构,字段名与 import 路径会随 Next 小版本微调,以你当前文档为准:
// app/blog/[slug]/opengraph-image.tsx (示意)
import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export const alt = "Article share card";
export default async function Image({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await loadPost(slug); // 你的数据层
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 64,
background: "#0b1120",
color: "#f8fafc",
fontSize: 56,
fontFamily: "YourSubsetFont",
}}
>
<div style={{ display: "flex", fontSize: 28, color: "#94a3b8" }}>
Blog
</div>
<div style={{ display: "flex", fontWeight: 600, lineHeight: 1.15 }}>
{post.title}
</div>
<div style={{ display: "flex", fontSize: 26, color: "#94a3b8" }}>
example.com
</div>
</div>
),
{
...size,
fonts: [
{
name: "YourSubsetFont",
data: await loadFontBytes(),
weight: 600,
style: "normal",
},
],
},
);
}
要点(比 API 细节更重要):
- 布局子集 — Satori 支持 flex 为主的样式,不是完整 CSS。
position: absolute、复杂 grid、任意 Web 字体加载方式都会踩坑。 - 字体必须显式传入 — 没有系统字体兜底。英文可 subset Inter;中文必须自己准备字重与字符集(见下一节)。
size与真实像素一致 — 行业默认分享尺寸约 1200×630(约 1.91:1)。过小会糊,见 og-image-dimensions-too-small。- 导出稳定 — 静态路由尽量
generateStaticParams把图打进构建产物;动态路由要想清楚缓存头与冷启动延迟。 - meta 仍要正确 — 动态图只是
og:image的来源之一;og:title/og:url/ 绝对 HTTPS 等规则不变。
同站博客的真实实现也是这条路径:opengraph-image + ImageResponse + 预 subset 字体,标题来自 frontmatter,而不是运营后台。
体积、字体、中文、emoji
动态图最容易在「能本地预览」之后挂在生产或 IM 里。按频率排序:
1. 平台字节预算(WhatsApp 最苛)
大 PNG 在浏览器里看没事,WhatsApp 等 IM 可能静默丢图。OGKit 平台档案里 WhatsApp 硬上限约 300KB,软上限约 200KB(以 平台档案 与规则为准;数字会随档案更新)。更稳妥的聊天场景目标常是 约 100–300KB。
规则:og-image-bytes-exceeds-platform-budget。
完整压缩、格式、重定向与 Content-Type 见系列《OG 图片工程》。
Satori / ImageResponse 默认出 PNG。文字卡片通常还能压在预算内;一旦加摄影底图、大面积渐变、未压缩位图,很容易破线。需要时:生成后二次压成 JPEG/WebP 再托管,或简化构图。
2. 字体加载与中文 subset
- 整包 Noto Sans SC 动辄十余 MB,不能塞进边缘函数或每次请求读全量。
- 做法:按实际会出现的字符 + 字重做 subset(例如只要 600 字重、只含站点文案与标点)。本站 OG 用的 CJK 子集大约几十 KB 量级——这是有意的工程取舍,不是「随便丢个 woff 就行」。
- 英文字体与中文字体混排时,字重与 x-height 要对齐,否则标题一中一英「一边粗一边细」。
- 缺字时 Satori 常表现为空白方框或直接省略,本地英文标题测过关、中文标题上线才炸。
3. Emoji
Emoji 不是普通字形:需要彩色 emoji 字体或事先栅格化。Satori 默认栈对 emoji 支持有限。标题里「🚀 发布」在卡片上变成方框,是预期内的坑。选择:去掉 emoji、换成 SVG 图标节点,或预渲染含 emoji 的静态图。
4. 依赖与冷启动
@vercel/og / next/og 链路带 resvg、字体解码等原生向依赖。边缘运行时有包体与 CPU 预算;Node runtime 更宽,但冷启动仍会拖「第一次分享」的抓取超时。能静态生成的路由,优先构建期出图。
5. 颜色与对比度
深色底 + 浅色字在缩略图里更稳。低对比在 LinkedIn / Slack 的小图预览里会糊成一团——这是设计问题,不是协议问题,但用户体感是「图坏了」。
动态图 URL 稳定性与缓存
生成逻辑对了,分享仍可能显示旧图,因为:
- 平台缓存的是第一次成功抓到的图(URL 不变时尤其明显)。
- 你的
opengraph-image若带不稳定 query、或 CDN 对同一 path 回了不同字节,调试会非常痛苦。 - 改标题后图内容变了,但
og:imageURL 路径未变 → 需要 官方 rescrape,而不是再刷新自己的浏览器。
建议:
- 同一文章的 OG 图 URL 路径稳定(框架默认的
/…/opengraph-image通常即可)。 - 内容变更后,对真正在意的平台走 rescrape;系列专文见《缓存与 rescrape》。
- 不要用每次随机的签名 URL 当
og:image(除非你能接受缓存永远对不齐,或主动做版本化 path)。
上线 checklist
- 每条需要差异化卡片的路由都有
opengraph-image(或等价绝对 HTTPS 图 URL) - 输出约 1200×630;
contentType与真实字节一致 - 中文 / 英文标题各抽几条真实数据渲染,确认无缺字、无方框
- 成品体积落在最紧平台预算内(聊天场景优先压到约 300KB 以下)
- 标题过长时有字号或换行策略,避免裁切到关键信息
- emoji / 特殊符号有策略(去掉、替换图标,或接受方框)
- 生产环境用社交爬虫 UA 能 200 拉到图;无登录墙、无无限跳转(og-image-redirects、og-image-forbidden)
- 改文案或改模板后,对 Meta / LinkedIn 等做了 rescrape
- (可选)CI 里对关键 URL 跑图片字节与维度规则,防止某次加背景图把 WhatsApp 打爆
生成之后:用 OGKit 验最终 URL
出图只是链路中段。平台看到的是最终可抓取的图片 URL 与页面上的 meta,不是你本地的 JSX 预览。
把线上文章 URL 贴进首页扫描:规则会检查相对路径、HTTPS、重定向、维度、字节预算等。机器可读清单见规则参考。第三方预览站可以看布局近似,但权威缓存失效仍以各平台官方 debugger 为准——和产品文案一致。
OGKit 不替你拖拽模板,但可以在你用 Next/Satori 或 SaaS 生成之后,告诉你这张卡会不会被 IM 静默丢掉。
下一篇读什么
- OG 图片工程:尺寸、体积、格式、重定向 —— 动态图也逃不开的字节与格式细节
- 缓存与 rescrape —— 图对了为什么分享还是旧的
- Open Graph 到底在解决什么 —— 全流程与工具地图
- 为什么你的 Open Graph 卡片一片空白 —— 五分钟分诊
需要模板设计器时请走 SaaS 或自建;需要确认「这张最终 URL 过不过平台预算」时,用规则与扫描,而不是再截一张自己浏览器的图。