给静态站塞进留言和视频分类
在不破坏"静态优先"原则的前提下,给 Next.js 静态站加一个无登录的留言区,把视频页改成按 YouTube 播放列表分 tab,顺便挑了 21 只灰色小动物当匿名头像。
上一篇"这个站怎么搭起来的"写完一周,又落下两件东西:每篇博客下面有了留言区(朋友给的反馈),视频页改成了策展过的版本,分类直接走 YouTube playlist(反正 YouTube 本来就是我管理这些视频的地方)。
两件事都要遵守和整套技术栈一样的约束:浏览器只看到 HTML / JS / CSS / PNG,没有我需要养着的服务器。下面是最后跑通的方案。
Part 1 —— 没有自家数据库的留言系统
最直接的答案是托管的评论服务。绕了一圈才发现,主流方案要么靠广告(Disqus),要么付费(Hyvor),要么和美国大厂强绑定、国内访问体验很差。最后落在了 Waline:开源、免登录、轻量级,本质就是"一个 serverless 函数 + 一段小客户端"。
Waline 的函数随便扔哪都行——Vercel、Cloudflare Workers、Deno Deploy、Netlify。存储号称什么都支持:MongoDB、MySQL、Postgres、SQLite、GitHub 当数据库、LeanCloud、Cloudflare D1……号称无关。
后端存储的"坑爹巡礼"
但其实没那么无关。我用一个下午亲身验证了这件事:
| 试过的后端 | 翻车点 |
|---|---|
| Cloudflare D1 | 没有官方支持。别试。 |
| Vercel + Upstash Redis | Redis 根本不在 Waline 支持列表里,我看文档看岔了。 |
| Vercel + GitHub 当存储 | 理论上能跑,但当种子 JSON 文件不存在时 Waline 会直接崩,环境变量约定也没文档。 |
| Azure Cosmos DB(Mongo API) | ResourceRequest timeout。Cosmos 的负载均衡器在 ismaster 握手时把内部副本集主机名甩给驱动,驱动去连那些根本访问不到的内网地址,hang 死。directConnection=true 也救不了。 |
| MongoDB Atlas M0 | 同样超时。Waline 的 think-mongo 适配器只认 MONGO_HOST/PORT/USER/PASSWORD/DB,不解析 mongodb+srv:// URL,MONGO_OPTIONS 也被它默默吞掉。 |
| Neon Postgres(免费档) | 一次成功。前提是把 Waline 官方的 assets/waline.pgsql 建表脚本在 Neon 的 SQL Editor 里跑一遍。 |
教训很直白:如果你手头没现成数据库,挑一个最简单的托管 Postgres,少走 Mongo 系的弯路。
把它装进 Next.js 静态页
组件本身就是个客户端薄壳,但有三点要注意:
- 后端没起来前,生产环境一个像素都不渲染。 整个 widget 由
process.env.NEXT_PUBLIC_WALINE_SERVER控制;没设这个变量时,留言区在生产构建里直接消失,在 dev 环境会显示一句"留言后端未配置"。 - 动态 import SDK。 Waline 的 client + CSS 加起来大概 80 KB。如果用静态 import,每个页面初始 JS 都要带上。所以:
首屏干净,只有真正挂了 widget 的页面才付这份代价。await import('@waline/client/style'); const { init } = await import('@waline/client'); - 路由切换时清理掉。
useEffect在挂载时init({...}),卸载时instance.destroy(),依赖里挂了pathname,客户端在文章间跳转时会自动重建。
双语共享的小坑
Waline 用页面的 path 作为评论线程的主键。我的站有两个 locale:/en/blog/foo 和 /zh/blog/foo,在 Waline 眼里是两个不同的页面。结果就是中文页留的言英文页看不到,反之亦然。
修复就一行:
const walinePath = pathname.replace(/^\/(en|zh)(?=\/|$)/, '') || '/';把 locale 前缀剥掉再传给 Waline,两个路由就指向同一条线程了。
头像,以及"大多数访客其实不会填邮箱"
默认情况下 Waline 找 Gravatar 拿头像。没填邮箱时,Gravatar 会返回一个根据……空字符串生成的固定几何图案——所有匿名留言看起来完全一样。而且默认 fallback 在我这种偏纸张色调的站里也挺扎眼。
我想要的效果是:灰色小动物剪影,每个人一个、可重现、看到熊猫就知道是同一个 Jackie 留的。三件小事:
- 挑素材。 OpenMoji 的黑色 SVG 套装是 CC-BY-SA 协议、画风干净统一、动物全得离谱——从
🐨到🦘到🦦都有。我下了 21 个放到public/avatars/openmoji/,配一份NOTICE.txt做归属。 - 改掉 Waline 渲染好的头像。 Waline 没暴露头像插槽,所以组件在自己的容器上挂了个
MutationObserver,Waline 每追加一张评论卡,就把卡里img.wl-user[-avatar]?的src替换成本地动物 SVG。 - 种子选得好一点。 哈希现有的 Gravatar URL 会让所有不填邮箱的人撞同一只动物(fallback URL 是同一个字符串)。所以最后选的种子是 "昵称 + 评论后端 ID"(Waline 把 objectId 挂在
.wl-card[id]上):- 两条不同的匿名留言能拿到不同动物。
- 同一个有昵称的人在同一帖子里保持一致。
const card = img.closest<HTMLElement>('.wl-card');
const nick = card?.querySelector('.wl-nick')?.textContent?.trim() ?? '';
const cardId = card?.id ?? '';
const seed = `${nick}|${cardId}`;
img.src = ANIMAL_AVATARS[hash(seed) % ANIMAL_AVATARS.length];总代价:~60 KB 的静态 SVG,直接走 CDN。
Part 2 —— 让 YouTube 当视频的真相源
YouTube 好用的地方在于,playlist UI 本身就是个很顺手的策展面板:可以拖拽排序、可以随意改名、一个视频可以加进多个 playlist。与其在自家 repo 里再造一个后台,不如让构建脚本直接读那边现成的就行了。
用播放列表当 tab
针对频道上的每个 playlist,sync 脚本调用:
GET /youtube/v3/playlists?channelId=<id>&part=snippet,contentDetails
GET /youtube/v3/playlistItems?playlistId=<each>&part=contentDetails
每个 playlist 记录 (id, title, description, videoIds[]),并且关键是 videoIds 保留策展者的顺序(YouTube 返回的就是你在 UI 上排好的顺序)。
- 每个至少包含一个你上传视频的 playlist 一个 tab,外加一个默认的"全部" tab。
- 点"全部" → 按发布时间倒序。点某个 playlist → 按那个 playlist 的
videoIds[]顺序。
const filtered = active === ALL
? videos // 最新在前
: playlist.videoIds // YouTube 上的策展顺序
.map((id) => byId.get(id))
.filter(Boolean);"在我自己站上重排视频"这个功能现在的实现就是:去 YouTube 上拖一拖,等下一次定时重建。零管理后台要写,分类有变也不用维护任何代码。
首页的"精选视频"
首页之前那块"精选视频"区域是个空占位符。现在它直接拉两条最新上传,用缩略图链接到 YouTube——也是同一份 .cache/videos/videos.json 喂出来的。
两个功能现在都上线了。想看效果的话,这篇文章下面就是留言区——你会随机分到一只灰色小动物。
分享这篇文章