返回列表
9 分钟阅读#waline #youtube #nextjs #comments

给静态站塞进留言和视频分类

在不破坏"静态优先"原则的前提下,给 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 RedisRedis 根本不在 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 静态页

组件本身就是个客户端薄壳,但有三点要注意:

  1. 后端没起来前,生产环境一个像素都不渲染。 整个 widget 由 process.env.NEXT_PUBLIC_WALINE_SERVER 控制;没设这个变量时,留言区在生产构建里直接消失,在 dev 环境会显示一句"留言后端未配置"。
  2. 动态 import SDK。 Waline 的 client + CSS 加起来大概 80 KB。如果用静态 import,每个页面初始 JS 都要带上。所以:
    await import('@waline/client/style');
    const { init } = await import('@waline/client');
    首屏干净,只有真正挂了 widget 的页面才付这份代价。
  3. 路由切换时清理掉。 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 留的。三件小事:

  1. 挑素材。 OpenMoji 的黑色 SVG 套装是 CC-BY-SA 协议、画风干净统一、动物全得离谱——从 🐨🦘🦦 都有。我下了 21 个放到 public/avatars/openmoji/,配一份 NOTICE.txt 做归属。
  2. 改掉 Waline 渲染好的头像。 Waline 没暴露头像插槽,所以组件在自己的容器上挂了个 MutationObserver,Waline 每追加一张评论卡,就把卡里 img.wl-user[-avatar]?src 替换成本地动物 SVG。
  3. 种子选得好一点。 哈希现有的 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 喂出来的。

两个功能现在都上线了。想看效果的话,这篇文章下面就是留言区——你会随机分到一只灰色小动物。

分享这篇文章

留言