动态OG图片生成实战:用Node.js和satori打造非AI名片
2026/8/26 23:26:42 网站建设 项目流程

在这个 AI 能写文章、能画海报、能写代码的时代,一个很微妙的问题开始出现:当你发布一条内容时,读者第一反应可能不是“这条内容是否有价值”,而是“这到底是不是 AI 生成的”。与其抱怨这个现象,不如把它当成一个技术练习题。这个系列叫《Going out of my way to prove I'm not an AI》,第一期我选择了一个很小、但特别适合展示“人味”的切面:Open Graph Images(简称 OG Images,也就是社交媒体分享卡片)。

这篇文章会从 OG Images 是什么讲起,然后带你搭建一个可用的动态 OG 图片生成服务,用的技术栈是 Node.js + Express + satori + resvg-js。整个过程不是简单贴代码,而是把每一步背后的原理、坑点和工程化建议都一起讲清楚。无论你是博客作者、前端开发者,还是对“AI 时代如何保留手工痕迹”这个话题感兴趣的人,这篇都能给你一些可落地的思路。

1. 背景:当 AI 能写一切,你如何保留“人味”

先聊一个比较现实的问题。

现在的 AI 可以几秒钟写出一篇结构完整的文章、生成一张风格统一的海报、甚至帮你把代码注释补得整整齐齐。但也正因为这种“太整齐”,大量 AI 生成内容开始变得同质化:标题结构相似、排版布局雷同、措辞习惯统一。读者刷到一条内容时,甚至会下意识去判断——这是人写的,还是模型生成的?

我个人的态度是:不去纠结“证明自己不是 AI”,而是主动在内容里留下一些只有人在乎的细节。这些细节通常不是宏大的创意,而是像手写签名、个人偏好、某个不完美的角落一样的东西。OG Images 就是很典型的一个场景。

为什么?因为绝大多数博客、工具站、开源项目,社交分享图要么不用,要么用一张万年不变的静态图。稍微讲究一点的,会用 Canva、Figma 或 Photoshop 手动做几张模板图,再根据每篇文章手动改文字。但这样做有两个问题:

  1. 无法做到“每篇文章一张专属卡片”,因为手动改图是重复劳动。
  2. 手动改图虽然花时间,但模板一旦固定下来,内容依然会很“模板化”,缺少那种“这篇是特别的”感觉。

动态 OG Image 能解决第一个问题,而第二个问题其实取决于你如何设计模板。如果你在模板里加入自己的标识、签名风格、甚至一些微小的随机变量,生成出来的卡片就会带着一种“程序化的手工感”——这本身就是一种有趣的反差。

2. OG Images 是什么,它和“人味”有什么关系

Open Graph 协议最早由 Facebook 提出,用来控制一个网页在社交平台上被分享时,展示成什么样子。后来 Twitter、微信、Slack、Discord 等平台也陆续支持了类似机制。

一张链接分享卡片通常由三部分组成:

  • 标题(og:title
  • 描述(og:description
  • 图片(og:image

对应的 HTML meta 标签大致长这样:

<head> <meta property="og:title" content="这是一篇关于 Rust 的文章" /> <meta property="og:description" content="从零开始构建一个命令行工具,并分享一些编译期优化经验。" /> <meta property="og:image" content="https://your-domain.com/covers/2025-rust-cli.png" /> <meta property="og:type" content="article" /> </head>

当有人把链接粘贴到社交平台时,平台服务器会去抓取这些标签。特别是og:image,如果图片地址可以访问,并且尺寸符合平台要求(最常见的推荐尺寸是 1200 × 630 像素),就会自动生成一张包含图片、标题、描述的分享卡片。

那这跟“证明不是 AI”有什么关系?

因为og:image可以是一个“动态接口”,不是一张静态图片。只要这个接口能够根据 URL 参数实时生成图片,你就可以做到:每一篇文章、每一个页面,都有一张独立的、带有你个人风格标识的分享图。这张图不是从某个素材库复制粘贴的,而是你的技术栈、你的设计偏好、你的内容标题共同作用的产物。

这种“每页一张定制卡片”的能力,很多高级博客系统也有,但多数是依赖现成服务,很少自己从头搭建。自己动手搭建一遍,你会对这个链路有完全不一样的理解。

3. 技术选型:为什么选择 satori + resvg-js

生成动态图片的技术方案其实不少,我梳理一下主流几条路线,方便你理解为什么最终选了 satori + resvg-js。

3.1 手动设计模板 + 工具改图

用 Canva、Figma、PS 做一张底图,然后每篇文章手动改标题。这是最原始的方式,优点是设计上限高,缺点是无法规模化,不适合作为自动化的“接口”存在。

3.2 Puppeteer / Playwright 截图

用无头浏览器打开一个 HTML 页面,再截图输出 PNG。这条路的好处是“可以写 HTML/CSS,自由度极高”,坏处是头一次启动浏览器实例非常慢,在服务器端跑起来内存占用也高,如果访问量稍大,很容易把 Node 进程内存打满。

3.3 Canvas 服务端绘制

用 node-canvas 在服务端画图,依赖原生库编译,环境问题比较多,而且绘制复杂布局时,代码可读性很差。

3.4 satori + resvg-js(本文选择)

satori 是 Vercel 开源的一个库,它能用类似 HTML/CSS 的语法描述 UI,然后在服务端把它转成 SVG 矢量图。resvg-js 则是一个 Rust 编写的 SVG 渲染库的 Node 绑定,可以把 SVG 快速转成 PNG。

这两个库组合起来的优势非常明显:

  • 不需要启动浏览器,轻量、速度快。
  • 布局语法接近 Web 开发,前端开发者上手成本极低。
  • 支持 flexbox 布局、渐变、边框、圆角等常用样式能力。
  • 中文字体只要以二进制形式传入即可,不依赖服务器系统安装字体。
  • 整个过程不涉及浏览器渲染,资源占用小。

要注意,satori 不是全量 CSS 实现,它只支持布局相关的子集,比如 flexbox、margin、padding、position、font-size 等。像box-shadowfilter这类能力支持不全,这个我在后面“常见问题”部分还会再提。

4. 环境准备:初始化 Node.js 项目与字体

正式开始写代码前,先把环境准备好。

4.1 基础环境

本文示例环境如下:

  • Node.js 18 及以上版本
  • npm 或 pnpm 作为包管理器
  • 任意 Linux / macOS / Windows 系统均可,但字体路径会略有不同

如果你还不确定自己的 Node 版本,可以执行:

node -v

建议确保在 18+,因为示例代码中会用到 ESM 语法以及比较新的fetch能力(本文暂时没用到 fetch,但 18+ 更稳)。

4.2 初始化项目

创建一个目录,并初始化 package.json:

mkdir heartbeat-og-server cd heartbeat-og-server npm init -y

然后安装依赖:

npm install express satori resvg-js

安装完成后,package.json的 dependencies 部分大约像这样:

{ "dependencies": { "express": "^4.19.2", "resvg-js": "^2.6.2", "satori": "^0.10.13" } }

这里版本号是示例,实际以你安装时的版本为准。satori 的 API 在 0.x 阶段陆续有调整,如果后续大版本更新,请留意官方文档。

因为 satori 是 ESM only 的包,所以我在项目里使用 ESM 模块规范。修改package.json,加上:

{ "type": "module" }

4.3 准备中文字体

这是一个很容易被忽略,但一旦踩坑就要花很久解决的环节。

satori 本身不读取系统字体,它需要你把字体文件以 Buffer 形式传进去。如果字体不包含中文字形,生成出来的中文内容就会变成空白方块。

所以我们要准备一份开源可商用的中文字体。推荐使用 Google 的 Noto Sans SC(思源黑体的 Google 版),它是 SIL Open Font License 授权,个人和商业项目都能用。

你可以从 Google Fonts 或 GitHub 下载一份NotoSansSC-Regular.otf,然后放到项目的fonts目录下:

heartbeat-og-server/ ├── fonts/ │ └── NotoSansSC-Regular.otf ├── src/ ├── package.json └── index.js

如果手头暂时没有 Noto Sans SC,也可以先用系统自带的字体,但要注意版权。比如 Windows 系统自带的微软雅黑、macOS 自带的苹方,在个人测试、学习环境中作为本地示例问题不大,但生产环境商用请谨慎,最好换用开源字体。

5. 完整实战:搭建动态 OG Image 生成服务

下面进入核心部分。我们一起来搭一个能在浏览器里直接访问的 OG 图片生成服务。

最终效果是:访问类似下面的地址,会返回一张 1200 × 630 的 PNG 图片:

http://localhost:3000/og?title=你好,世界&author=zhangshan&tag=TECH&date=2025-01-20

5.1 项目结构

先规划一下项目结构,保持清晰:

heartbeat-og-server/ ├── fonts/ │ └── NotoSansSC-Regular.otf ├── index.js # Express 服务入口 ├── template.js # 卡片布局模板 └── package.json
  • index.js:负责加载字体、接收 HTTP 请求、调用 satori 和 resvg 生成图片。
  • template.js:定义卡片长什么样,相当于前端的“模板组件”。

5.2 编写页面模板

模板是整个卡片视觉的关键。我设计了一个比较简洁的“博客分享卡片”布局,包含四个区域:

  • 顶部左侧:显示一个栏目标签,比如HUMAN PROOF // TECH
  • 顶部右侧:一个绿色描边的NOT AI徽章,呼应主题。
  • 中间:大号标题文字。
  • 底部:左侧显示作者,右侧显示日期和期数。

satori 的模板结构不是 JSX,而是对象树。每个节点包含typepropsprops里用style描述样式,用children描述子节点。

来写template.js

// 文件路径:template.js export function buildPoster({ title, author, tag, date }) { return { type: 'div', props: { style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: '64px 72px', background: 'linear-gradient(135deg, #0f172a 0%, #334155 100%)', color: '#f8fafc' }, children: [ // 顶部信息区:左侧标签 + 右侧 NOT AI 徽章 { type: 'div', props: { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' }, children: [ { type: 'div', props: { style: { fontSize: '28px', fontWeight: 700, letterSpacing: '2px', color: '#94a3b8' }, children: `HUMAN PROOF // ${tag}` } }, { type: 'div', props: { style: { display: 'flex', alignItems: 'center', borderRadius: '999px', border: '2px solid #34d399', color: '#34d399', padding: '8px 20px', fontSize: '24px', fontWeight: 700, letterSpacing: '1px' }, children: 'NOT AI' } } ] } }, // 中间标题区 { type: 'div', props: { style: { display: 'flex', flexDirection: 'column', marginTop: '32px', marginBottom: '32px' }, children: [ { type: 'div', props: { style: { fontSize: '88px', fontWeight: 800, lineHeight: '1.25', maxWidth: '900px' }, children: title } } ] } }, // 底部信息区:作者 + 日期 { type: 'div', props: { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderTop: '2px solid rgba(148,163,184,0.3)', paddingTop: '24px' }, children: [ { type: 'div', props: { style: { fontSize: '28px', fontWeight: 600, color: '#cbd5e1' }, children: author } }, { type: 'div', props: { style: { fontSize: '28px', color: '#94a3b8' }, children: `Ep. 1 // ${date}` } } ] } } ] } }; }

这段代码看起来有点长,但逻辑很直接。你可能已经发现了,这不是字符串拼接模板,而是嵌套的 JavaScript 对象。satori 会把这棵对象树渲染成 SVG 的内容。

这里有几个注意事项:

  • maxWidth用来限制标题区域宽度,避免标题过长时把布局撑乱。
  • 中文字体不要选择太细的字重,否则在大字号、深色背景上辨识度会下降。
  • borderRadius: '999px'用于把右侧徽章做成圆角胶囊形状。

5.3 实现主服务与图片生成

接下来写index.js

这部分要做的事情是:启动 Express 服务,定义/og路由,从 URL query 中获取标题、作者、标签、日期等参数,调用模板函数得到对象树,然后依次交给 satori 和 resvg 处理。

// 文件路径:index.js import express from 'express'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import satori from 'satori'; import { Resvg } from 'resvg-js'; import { buildPoster } from './template.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); const PORT = process.env.PORT || 3000; // 输出图片尺寸 const WIDTH = 1200; const HEIGHT = 630; // 优先加载项目内置字体,找不到就尝试常见系统字体路径 const FONT_CANDIDATES = [ path.join(__dirname, 'fonts', 'NotoSansSC-Regular.otf'), path.join(__dirname, 'fonts', 'NotoSansSC-Regular.ttf'), '/System/Library/Fonts/PingFang.ttc', 'C:/Windows/Fonts/msyh.ttc', 'C:/Windows/Fonts/simhei.ttf' ]; let fontData; for (const fontPath of FONT_CANDIDATES) { try { fontData = fs.readFileSync(fontPath); console.log('使用字体:', fontPath); break; } catch (_) { // 尝试下一个路径 } } if (!fontData) { console.error('未找到可用字体,请下载一个中文字体放入 fonts/ 目录。'); process.exit(1); } app.get('/og', async (req, res) => { try { // 从 query 取参数,并做长度限制,防止恶意超长内容 const title = String(req.query.title || '我的独立博客').slice(0, 30); const author = String(req.query.author || 'Handmade Human').slice(0, 20); const tag = String(req.query.tag || 'TECH').slice(0, 12); const date = String(req.query.date || new Date().toISOString().slice(0, 10)).slice(0, 10); // 1. 构建模板对象树 const element = buildPoster({ title, author, tag, date }); // 2. satori 将对象树渲染为 SVG 字符串 const svg = await satori(element, { width: WIDTH, height: HEIGHT, fonts: [ { name: 'Noto Sans SC', data: fontData, weight: 400, style: 'normal' } ] }); // 3. resvg 将 SVG 渲染为 PNG const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: WIDTH } }); const pngData = resvg.render(); const pngBuffer = pngData.asPng(); // 4. 输出图片,并设置缓存头 res.setHeader('Content-Type', 'image/png'); res.setHeader('Cache-Control', 'public, max-age=600'); res.send(pngBuffer); } catch (error) { console.error('生成 OG Image 失败:', error); res.status(500).send('生成失败: ' + error.message); } }); app.listen(PORT, () => { console.log(`OG Image 服务已启动: http://localhost:${PORT}/og`); });

这段代码里有几个容易被新手忽略的点,我单独解释一下:

第一,字体加载逻辑。我写了一个FONT_CANDIDATES数组,优先读项目内fonts目录下的字体文件,读取失败时再去系统常见路径里找。这样既方便你下载开源字体放入项目,也能在缺少字体文件时自动用系统字体兜底。

第二,参数要做长度限制。req.query.title来自用户输入,数据源不可控。如果不截断,攻击者可以传一个超长字符串,导致图片渲染时布局错乱,甚至撑爆内存。这里统一用.slice(0, 30)限长,是一种简单的前置防护。

第三,satori()的返回值是字符串,不能直接作为图片返回。必须先交给new Resvg(svg).render().asPng(),得到 Buffer 后才能真正通过res.send()发送给浏览器。

第四,Cache-Control头不要设置太长。动态图片如果内容经常变化,缓存太久会导致新图不生效;如果完全不缓存,每次访问都会现场渲染,性能又差。600 秒(10 分钟)是一个比较均衡的初始值,后面可以再按场景调整。

5.4 启动服务并验证

在项目根目录执行:

node index.js

看到类似输出:

使用字体: /path/to/heartbeat-og-server/fonts/NotoSansSC-Regular.otf OG Image 服务已启动: http://localhost:3000/og

然后打开浏览器,访问:

http://localhost:3000/og?title=亲手搭建%20OG%20Image%20服务&author=zhangshan&tag=TECH&date=2025-01-20

记得在 URL 中,中文内容需要 URL 编码。比如上面的亲手搭建%20OG%20Image%20服务,其中%20代表空格。

如果一切正常,你会在浏览器里看到一张深色背景、白色标题、右下角有NOT AI绿色徽章的 1200 × 630 卡片。

5.5 扩展:在模板中加入随机扰动

既然这期的主题是“证明我不是 AI”,我们可以给模板加一点有趣的小细节:每次生成的卡片,右上角的徽章位置可以在一个很小的范围内随机偏移。

为什么这么做?因为 AI 生成的图片往往过于稳定、过于对齐、过于“完美”。而人类手工制作的东西,哪怕是程序生成的,也常常带有一些刻意保留的随机性。

template.js中,可以通过额外传入一个noise参数来控制偏移量。这个偏移可以作用在徽章的marginRightmarginTop上。注意 satori 对样式的支持是有限的,所以这里不要做太复杂的位移,简单加几像素偏移就够了。

示例调整如下:

export function buildPoster({ title, author, tag, date, noise = 0 }) { const offsetX = (noise % 5) - 2; // 生成 -2 到 2 之间的偏移 // ... // 在 NOT AI 徽章的 style 中加入: // marginRight: `${offsetX}px` }

然后在index.js中获取noise参数:

const noise = Number(req.query.noise || 0);

这个功能本身很轻量,但它让每次生成的图片都有轻微的、肉眼几乎察觉不到的差异。每次分享出去的卡片,都是“这一秒”生成出来的版本。这种概念层面的趣味性,其实比单纯把图片做得漂亮更贴合主题。

6. 接入博客与社交平台

服务搭建好了,接下来就是把动态 OG 图片接入你的真实页面。

假设你的博客页面是 HTML,你只需要在<head>中把og:image指向这个动态服务,并带上当前文章的参数:

<meta property="og:title" content="亲手搭建 OG Image 服务" /> <meta property="og:description" content="本文从零开始实现一个动态 Open Graph 图片生成服务。" /> <meta property="og:image" content="https://your-domain.com/og?title=亲手搭建%20OG%20Image%20服务&author=zhangshan&tag=TECH&date=2025-01-20&v=20250120" /> <meta property="og:type" content="article" />

如果你用的是 Hexo、VitePress、Next.js 这类框架,可以在主题模板里把og:image拼成动态 URL。以 VitePress 为例,你可以在head配置或者在布局组件里动态生成。

有一点要特别注意:og:image的 URL 必须是可以被社交平台服务器公开访问的地址。localhost无效,必须是公网可访问的域名。如果你在本地做实验,可以用ngrok之类的工具把本地服务临时暴露到公网,但公网暴露时要注意加防滥用措施,这个我在后面“工程化建议”部分会讲。

另外,URL 中的v参数很有用。社交平台一般都会缓存第一次抓取到的 og 信息,如果你更新了图片或标题,平台可能还在用旧缓存。这时只要你修改 URL 中的v参数,就是一个全新的 URL,平台会把它当作新图片重新抓取。

7. 常见问题与排查思路

自己实现动态 OG 图片服务,最常见的坑集中在字体、布局、缓存和性能四个方面。我整理成一张表,方便你直接对照排查。

问题现象常见原因解决思路
图片生成成功,但中文显示为空白方块satori 没有拿到包含中文字形的字体数据确认fonts目录下有中文字体文件,并且启动日志中显示“使用字体”
生成过程报错,且提示 CSS 属性不支持satori 只实现 CSS 布局子集,部分复杂样式不支持改用 flexbox、简单背景、border、borderRadius 等基础属性,避免box-shadow等特殊效果
图片内容不是预期的 1200 × 630satori 宽高参数与 resvg 输出尺寸不一致统一用WIDTH = 1200HEIGHT = 630,resvg 用fitTo.width保持输出尺寸
每次访问都很慢,响应经常超过 2 秒没有加缓存,每次请求都完整走一遍渲染流程加内存缓存或 CDN 缓存,用 query 参数作为缓存 key
社交平台分享时仍显示旧图平台缓存了旧的 og:image 地址修改 URL 中的v参数,强制生成新 URL;同时在平台的分享调试工具中重新抓取
部署到服务器后访问 500 错误服务器系统缺少可用字体,或字体文件路径不对检查启动日志中“使用字体”是否打到了项目内字体路径,确保字体文件随项目一起部署
有人恶意刷接口,服务器资源被耗尽接口没有做访问控制和限流增加签名参数、IP 限流,或部署前限制可访问的域名/请求来源

这里单独说一下“分享调试工具”。

  • Facebook 有 Sharing Debugger,可以输入 URL 后强制重新抓取。
  • Twitter/X 有 Card Validator,可以预览卡片效果。
  • 微信没有公开的调试工具,很多开发者反映微信对 og 标签的解析和缓存策略跟海外平台不同,而且更新很慢。如果你的目标主要是微信生态,建议把图片地址的版本参数做明显,比如v=微信更新日期,便于手动刷新。

8. 工程化建议与最佳实践

服务能跑通之后,如果你想把它真正用到生产环境,还有几个工程层面的问题需要提前考虑。

8.1 加一层缓存

动态生成图片的本质是把“文本数据”渲染成“图片文件”,这个过程并不廉价。如果访问量稍大,每次都现场渲染会白白消耗 CPU。

最简单有效的方案是在 Node 进程内维护一个 LRU 缓存,把 query 字符串作为 key,把生成的 PNG Buffer 作为 value。命中缓存时直接返回,不命中时才走渲染链路。

示例思路如下:

const cache = new Map(); const MAX_CACHE = 200; function getCacheKey(query) { return JSON.stringify(query); } function setCache(key, buffer) { if (cache.size >= MAX_CACHE) { const oldestKey = cache.keys().next().value; cache.delete(oldestKey); } cache.set(key, buffer); }

更精细的做法是把生成的图片推到 CDN,让边缘节点承担缓存。CDN 的缓存命中率远高于单机内存缓存,而且能降低源站压力。

8.2 参数签名与防滥用

一个公开的动态生成接口,如果没有任何防护,很容易被刷。因为生成图片比返回普通 HTML 更消耗资源。

常见做法是给 URL 加一个签名参数:

https://your-domain.com/og?title=xxx&author=xxx&tag=xxx&sign=md5(title+author+secret)

服务端用相同的secret算出签名,再和请求参数中的sign对比,不匹配就拒绝渲染。这样只有你知道秘钥,别人无法随意发起海量请求。

注意,签名参数只能防“非授权调用”,不能防“拿到合法 URL 后反复请求”。所以最好再配合 IP 限流、每分钟请求次数限制等策略。

8.3 模板版本管理

模板视觉总会有迭代。如果你改版了设计,但线上还有旧文章,怎么办?

建议在 URL 中显式带一个v版本参数,例如v=20250120。模板变化时,版本号跟着变。这样旧文章仍然可以用旧版本号渲染旧风格,新文章用新版本号渲染新风格,两边互不干扰。

如果你真的希望整个站点统一换新,只需所有页面生成og:image时统一升级v参数即可。这里的核心思想是:把“图片内容”和“模板版本”解耦。

8.4 字体版权与性能

生产环境尽量使用 OFL 或 Apache 2.0 授权的开源字体,比如 Noto Sans SC、思源黑体、阿里巴巴普惠体。不要直接拷贝微软雅黑、苹方这些商用授权不明确的字体,尤其是部署在企业项目中时。

字体文件的体积也会影响内存。一个完整的中文字体文件可能 10MB 甚至更大,如果每次请求都从磁盘读取一次,会浪费 IO。建议在服务启动时读取一次字体 Buffer 并复用,这也是本文示例代码中的做法。

8.5 长文本与安全边界

标题超长是动态图片最容易出现的视觉问题。除了代码里做.slice(0, 30),还应该支持按字数调整字号,或者在模板中用 CSS 的文本省略能力。但要注意,satori 对text-overflow: ellipsis支持并不稳定,所以最稳妥的方式还是在上游控制标题长度,或者按长度分档设置字号。

另外,用户传入的标题、作者、标签等内容最终会出现在图片里。虽然渲染成 SVG 后不会执行脚本,但如果生产环境允许外部传参,仍然建议对内容做 HTML 转义,避免出现异常的 SVG 解析结果。

9. 小结与下一步

这一期我们从“怎么在 AI 时代保留一点手工痕迹”这个略显抽象的问题出发,落地成一个非常具体的技术方案:一个基于 Node.js、satori、resvg-js 的动态 OG Image 生成服务。

你现在应该已经掌握:

  • OG Images 在网页分享链路中的作用;
  • satori + resvg-js 的技术原理与组装方式;
  • 如何设计一个可复用的卡片模板;
  • 中文字体处理、参数限制、缓存与防滥用等工程细节;
  • 接入博客和社交平台时需要留意的版本与缓存问题。

下一步,你可以把这个/og接口接入自己的博客主题,让每一篇文章的分享卡片都自动生成。如果还想继续深入,可以研究一下@vercel/og这个更上层的封装,它内部其实就是 satori + resvg 的组合,只是包装成了更易用的 Next.js 接口。理解了底层实现之后,再看那些封装也就不再觉得“黑盒”了。

如果你也在自己的项目里做过类似“保留人类痕迹”的小设计,欢迎在评论区聊聊,下一期我打算继续这个系列,折腾一点更适合证明“我是真人”的小内容。

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

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

立即咨询