跳转到内容
关系图谱

og-image:无浏览器管线的逐页 OG 分享图

2,031 字 7 分钟

属性 1
series
VaultKit 包指南

@vaultkit/vitepress-plugin-og-image 在 VitePress buildEnd 钩子里为每个页面生成一张 Open Graph 分享图(1200 × 630 PNG)。管线是 satori(元素树 → SVG,纯计算)+ resvg(SVG → PNG),全程无浏览器——CI 里不需要 Puppeteer / Playwright,客户端零成本。

针对中文站点它做了一件关键的事:完整 CJK 字体动辄数 MB,不能整体喂给 satori;本包先收集全部待渲染文本的码点,再按 Fontsource 的 unicode-range 元数据只加载覆盖到的字体子集——典型博客站最终只载入个位数的 30-45KB 小文件。这是纯构建期包,本文的产物说明与配置示例全部用代码围栏呈现。

为什么需要它

链接被分享到社交平台 / 聊天软件时,抓取器读的是页面的 og:image meta。没有专图的页面要么显示空白卡片,要么全站共用一张兜底图——每篇文章的标题、标签这些现成信息全浪费了。

逐页生成 OG 图的传统方案是起一个 headless 浏览器截图,代价是 CI 里装 Chromium(数百 MB)、冷启动秒级、并发内存爆炸。satori 换了条路:不渲染 HTML,而是接收一棵受限的元素树(Flexbox 子集样式),纯计算排版直接吐 SVG;resvg(Rust 实现的 SVG 渲染器,走 napi 绑定)再把 SVG 光栅化成 PNG。整条管线是普通的 Node 函数调用,每页约 50-150ms。

安装

sh
pnpm add -D @vaultkit/vitepress-plugin-og-image

依赖三件:satori@resvg/resvg-js@fontsource/noto-sans-sc(字体子集来源)。

使用 @vaultkit/vitepress-theme-vault 主题则开箱即带:features.ogImage 缺省为 true,主题按 og/<slug>.png 契约为每篇已发布笔记生成专图,并在 transformPageData 里逐页注入 og:image meta。关闭方式:

ts
export default defineConfigWithTheme({
  extends: createVaultTheme({
    site: { /* ... */ },
    features: { ogImage: false },
  }),
})

快速上手

.vitepress/config.ts
ts
import { generateOgImages } from '@vaultkit/vitepress-plugin-og-image'
import { defineConfig } from 'vitepress'

export default defineConfig({
  async buildEnd(config) {
    await generateOgImages(config, {
      siteTitle: '我的数字花园',
      pages: posts.map(post => ({
        file: `og/${post.slug}.png`, // 相对 outDir 的产物路径
        title: post.title,
        subtitle: post.date,
        tags: post.tags,
      })),
    })
  },
})

注意本包只负责生成图片文件;逐页的 og:image meta 标签由宿主注入(如 transformPageData),两侧路径遵守同一 file 契约。构建结束打印 [og-image] 已生成 N 张分享图(M 个字体子集,Xms)

公开导出一览

导出形态说明
generateOgImages异步函数生成入口,(config, options) => Promise<void>pages 为空数组时直接返回
defaultTemplate函数缺省模板 (page, ctx) => OgElement,自定义模板可参考或复用
OgImageOptions类型选项接口
OgImagePage类型单页数据
OgFont类型satori 字体项
OgElement类型satori 无 JSX 形态的元素树节点
OgTemplateContext类型模板上下文 { siteTitle, width, height }
OgSiteConfig类型SiteConfig 的结构化子集(只消费 outDir

OgImageOptions 逐项详解

siteTitle(必填)

  • 类型:string
  • 语义:站点名,缺省模板渲染在底部品牌行;同时参与码点收集(它也是待渲染文本的一部分)。

pages(必填)

  • 类型:OgImagePage[]
  • 语义:待生成页面集合,每项一张图。空数组直接返回、不加载字体不做任何工作——「无文章的初始站点」构建不付出成本。

width / height

  • 类型:number,默认 1200 / 630
  • 语义:画布尺寸(px)。1200 × 630 是 Open Graph 的事实标准(约 1.91:1),Twitter/X 的 summary_large_image、Telegram、微信都按此裁切最友好,没有特殊需求不要改。

fonts

  • 类型:OgFont[],默认从 @fontsource/noto-sans-sc 按码点需求加载 400/700 两字重的最小子集组合
  • 语义:satori 字体表整体覆写。OgFont 逐字段:
字段类型说明
namestring字体族名,与模板样式里的 fontFamily 对应
dataArrayBuffer | Buffer字体二进制(TTF / OTF / WOFF,不支持 WOFF2
weightnumber字重(可选)
style'normal' | 'italic'字形(可选)

自定义品牌字体示例:

ts
import { readFile } from 'node:fs/promises'

await generateOgImages(config, {
  siteTitle: '数字花园',
  pages,
  fonts: [
    { name: 'LXGW WenKai', data: await readFile('fonts/LXGWWenKai-Regular.ttf'), weight: 400 },
    { name: 'LXGW WenKai', data: await readFile('fonts/LXGWWenKai-Bold.ttf'), weight: 700 },
  ],
})

template

  • 类型:(page: OgImagePage, ctx: OgTemplateContext) => OgElement,默认 defaultTemplate
  • 语义:模板覆写,返回 satori 元素树。OgElement 是 React 元素结构的纯对象子集({ type, props: { style, children } }),无需 JSX 与 React 依赖:
ts
import type { OgElement, OgImagePage, OgTemplateContext } from '@vaultkit/vitepress-plugin-og-image'

function myTemplate(page: OgImagePage, ctx: OgTemplateContext): OgElement {
  return {
    type: 'div',
    props: {
      style: { width: ctx.width, height: ctx.height, display: 'flex', backgroundColor: '#fff', fontFamily: 'Noto Sans SC' },
      children: page.title,
    },
  }
}

satori 只支持 Flexbox 布局子集:每个多子节点的 div 都要显式 display: 'flex',不支持 grid / float

OgImagePage 逐字段详解

字段类型必填说明
filestring相对 outDir 的产物路径(如 'og/minimal-theme.png'),中间目录自动创建
titlestring页面标题,缺省模板的视觉主体
subtitlestring副行(日期 / 摘要等),缺省模板渲染在标题下方、最多 2 行
tagsstring[]标签,缺省模板取前 4 个渲染为胶囊行

产物解析:缺省模板构图

缺省模板生成的 PNG 构图(自上而下):

  • 顶线:贴住画布顶边一条 10px 高的品牌色渐变横线(#3451b2 → #8a63d2,即 VitePress 品牌蓝到紫)
  • 底色:深色斜向渐变(135deg#1b1b1f → #26262c → #2d3348),颜色对齐 VitePress 暗色变量的静态值,分享卡片与站点暗色态视觉同源
  • 标题区(中上部):58px 加粗白字大标题,最多 3 行截断(lineClamp: 3);其下可选 26px 灰色副行(最多 2 行);再往下是标签胶囊行——圆角 999 全圆胶囊、半透明靛蓝底、浅紫蓝文字,最多 4 枚
  • 品牌行(底部):14px 品牌蓝圆点 + 28px 灰色站点名,整版内容以 space-between 上下撑开,四周留 64/72px 内边距

一句话概括:深色渐变卡片上的「大标题 + 元信息 + 站点署名」,不放插图不放头像,信息密度与可读性优先。

设计机制

satori + resvg:无浏览器管线

ts
const svg = await satori(template(page, ctx), { width, height, fonts })
const png = new Resvg(svg, { fitTo: { mode: 'width', value: width } }).render().asPng()

两步都是纯函数式调用:satori 做文字排版(用字体度量精确计算断行、对齐、截断),resvg 做光栅化。没有浏览器进程、没有 GPU、没有沙箱开销,CI 镜像不需要任何额外系统依赖——@resvg/resvg-js 通过 napi 预编译二进制随 npm 安装即可用。

CJK 按码点子集加载

Fontsource 把 Noto Sans SC 按 unicode-range 切成约 100 个子集文件(每个 30-45KB WOFF),并在包内附带 unicode.json 元数据(子集名 → 码点区间)。插件的装载流程:

  1. siteTitle 与所有 pagestitle / subtitle / tags 拼成一个大字符串,逐字符收集码点集合
  2. 解析 unicode.json 里每个子集的区间表达(支持 U+4e00-9fff 区间与 U+4??? 通配两种形态)
  3. 只要子集覆盖到任何一个待渲染码点,就把它计入需要装载的名单
  4. 对每个命中的子集读取 400 / 700 两个字重的 WOFF 文件,组成 satori 字体表

典型博客站的标题文本只命中个位数子集,字体装载从「数 MB 整字体」降到「几百 KB 按需小文件」,构建内存与耗时都可控。文本一个子集都没命中时直接抛错(提示检查 pages 文本),子集文件缺失(Fontsource 布局变化)时报错指向依赖版本排查。

无缓存的取舍

产物不做跨构建缓存:每页 50-150ms、12 页量级全量小于 4 秒,缓存失效判定(模板变了吗?字体变了吗?)的复杂度远高于重算成本。

常见问题

Q:自定义字体不生效 / satori 报字体解析错误? satori 支持 TTF / OTF / WOFF,不支持 WOFF2。Fontsource 包里通常同时带 woff 与 woff2,注意取 .woff;从 Google Fonts 下载则取 TTF。

Q:标题里的 emoji / 生僻字变成空白? 缺省字体只覆盖 Noto Sans SC 收录的简中/拉丁字形。需要 emoji 时传自定义 fonts 补充(如 Noto Emoji),或在标题里避免使用。

Q:报 [og-image] 待渲染文本未命中任何字体子集pages 里的文本全是缺省字体覆盖外的字符(如纯 emoji 标题)。检查文本内容,或换用覆盖所需字符的自定义 fonts

Q:图片生成了但分享卡片不显示? 本包只写 PNG 文件,og:image meta 要宿主自己注入且必须是绝对 URLhttps://example.com/og/xxx.png)。用主题则两侧已按同一契约对齐。

相关链接