给纯静态博客加上短链系统
问题
我的博客文章 URL 一直很长:
https://yuxu.ge/blog/post.html?p=posts/gol/2026-02-25-cross-experiment-en
分享到社交媒体时,这些链接占掉半条消息的篇幅,看着也不清爽。我想要一个短链系统,比如:
https://yuxu.ge/s/fewt3
但我的博客是纯静态站点(GitHub Pages),没有后端服务器。所以传统的短链服务方案——请求打到服务端,查数据库,302 重定向——统统用不了。
设计思路
既然没有服务器,所有的逻辑都只能在两个时间点完成:构建时和浏览器端。
核心思路很简单:构建时为每篇文章生成一个短码,然后创建一个对应的 HTML 文件做跳转。GitHub Pages 会自动把 /s/fewt3/ 路由到 /s/fewt3/index.html,所以只要在那个位置放一个跳转页面就行了。
构建时 (Node.js)
══════════════════════════════════════════════════
[posts.json] ──► [build-shortlinks.js]
│
┌────────────┼────────────┐
▼ ▼ ▼
shortlinks.json /s/abcde/ /s/fewt3/
(slug → code) index.html index.html
(跳转页面) (跳转页面)
浏览器端
══════════════════════════════════════════════════
文章页面加载
│
▼
fetch shortlinks.json(一次性)
│
▼
查找当前 slug → 获得短码
│
▼
注入"复制短链"按钮
短码生成算法
短码的生成需要满足两个要求:确定性(同一个 slug 永远生成同一个码)和低碰撞率。
我选择了一个基于 SHA-256 的方案:
"posts/2026/2026-02-20-git-crypt-private-notes-zh" (slug)
│
▼ SHA-256
"a7f3e1b9c4d8..." (64 位 hex 字符串)
│
▼ 截取前 8 个 hex 字符
"a7f3e1b9"
│
▼ parseInt(..., 16)
2818769337
│
▼ .toString(36)
"4g9kfp"
│
▼ padStart(5, '0').slice(0, 5)
"4g9kf" (最终短码)
核心实现只有三行:
function generateShortCode(slug, offset = 0) {
const hash = crypto.createHash('sha256').update(slug).digest('hex');
const num = parseInt(hash.slice(offset, offset + 8), 16);
return num.toString(36).padStart(5, '0').slice(0, 5);
}
碰撞处理
虽然 5 位 base36 有约 6000 万种组合(36^5 ≈ 60,466,176),碰撞概率极低,但一个健壮的系统必须处理它。
策略是滑动窗口:如果从 hex 的 [0:8] 生成的码和已有的冲突了,就尝试 [8:16],然后 [16:24],以此类推。一个 64 字符的 SHA-256 hex 提供了 7 次重试机会,足够了。
for (const slug of slugs) {
let offset = 0;
let code = generateShortCode(slug, offset);
while (codeToSlug[code] && codeToSlug[code] !== slug) {
offset += 8;
if (offset >= 56) {
console.error(`FATAL: Cannot resolve collision for ${slug}`);
process.exit(1);
}
code = generateShortCode(slug, offset);
}
codeToSlug[code] = slug;
slugToCode[slug] = code;
}
实际运行下来,224 篇文章生成了 224 个短码,零碰撞。做个简单的概率分析:根据生日问题,碰撞概率约为 n² / (2 × 60.5M),224 篇时只有 0.04%,即使到 5000 篇也只有约 17%——而且滑动窗口能自动化解碰撞。
跳转页面
每个短码对应一个 index.html,负责即时跳转到原始文章:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=/blog/2026/2026-02-20-git-crypt-private-notes-zh.html">
<link rel="canonical" href="https://yuxu.ge/blog/2026/...">
<title>Redirecting...</title>
</head>
<body>
<script>window.location.replace('/blog/2026/...');</script>
<noscript><p>Redirecting...</p></noscript>
</body>
</html>
同时使用了 meta http-equiv="refresh" 和 window.location.replace()。前者兼容禁用 JavaScript 的情况,后者更快且不会在浏览器历史中留下记录——用户按返回键不会卡在跳转页面上。canonical 标签告诉搜索引擎原始 URL 才是权威版本。
前端集成
文章页面需要在标题旁显示一个"复制短链"按钮。
async function injectShortLinkButton() {
if (!postName) return;
if (document.getElementById('short-link-btn')) return;
// 懒加载映射表(一次性)
if (!window._shortlinks) {
try {
const resp = await fetch('/blog/shortlinks.json');
window._shortlinks = await resp.json();
} catch { window._shortlinks = {}; }
}
const code = window._shortlinks[postName];
if (!code) return;
const shortUrl = location.origin + '/s/' + code;
// 在 h1 后注入按钮
const h1 = document.querySelector('article h1');
if (!h1) return;
const btn = document.createElement('button');
btn.id = 'short-link-btn';
btn.className = 'short-link-btn';
btn.textContent = '🔗';
btn.title = shortUrl;
btn.onclick = async () => {
await navigator.clipboard.writeText(shortUrl);
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = '🔗', 1500);
};
h1.after(btn);
}
对于构建时生成的静态 HTML 页面,按钮则在 build-static.js 中直接注入到 HTML 中。
链接持久化:永不失效的短链
系统跑起来之后,一个问题浮现了:如果未来我修改了算法,会发生什么?
比如,我把短码从 5 位扩展到 6 位。由于算法是确定性的——slice(0, 5) 和 slice(0, 6) 产生的结果完全不同——所有已有文章的短码都会变。之前分享出去的链接会全部失效。
为了解决这个问题,我引入了一个持久化锁定文件 _tools/shortlinks-locked.json。
构建流程
══════════════════════════════════════════════════
shortlinks-locked.json posts.json
(已锁定的 slug→code) (全部文章)
│ │
└───────┬───────────────┘
▼
build-shortlinks.js
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
已锁定的码 新文章走算法 shortlinks-locked.json
直接使用 生成新码 (自动追加新码)
核心逻辑:
- 构建时先加载锁定文件中的所有映射
- 已锁定的 slug 直接使用其短码,不重新计算
- 只对新出现的 slug 运行生成算法
- 有新码产生时,自动将完整映射写回锁定文件
// 加载锁定映射
const locked = loadLockedCodes();
// 已锁定的直接使用
for (const slug of slugs) {
if (locked[slug]) {
codeToSlug[locked[slug]] = slug;
slugToCode[slug] = locked[slug];
}
}
// 只对新 slug 生成码
for (const slug of slugs) {
if (slugToCode[slug]) continue; // 已锁定,跳过
// ... 运行生成算法 ...
}
// 自动锁定新码
if (!skipLock && newCount > 0) {
fs.writeFileSync(LOCKED_JSON, JSON.stringify(slugToCode, null, 2) + '\n');
}
这样,未来无论怎么修改算法,旧链接永远有效。--no-lock 参数可以在调试时跳过锁定。
构建集成
在 build.sh 中添加一步:
echo "=== Building Short Links ==="
node $TOOLS_DIR/build-shortlinks.js
在 clear.sh 中添加清理:
rm -rf s/
rm -f blog/shortlinks.json
运行效果:
$ node _tools/build-shortlinks.js
Generated: 224 short links (224 locked, 0 new)
Generated: blog/shortlinks.json
小结
这个短链系统的核心设计原则是构建时完成一切,运行时零成本。没有数据库,没有服务器,没有 API 调用——只有一堆预生成的 HTML 文件和一个 JSON 映射表。
持久化锁定文件是整个系统最关键的设计决策。它把"算法可以随时改"和"链接永远不失效"这两个看似矛盾的需求优雅地统一了起来。