← Back to Blog
EN中文

给纯静态博客加上短链系统

问题

我的博客文章 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
  直接使用      生成新码       (自动追加新码)

核心逻辑:

  1. 构建时先加载锁定文件中的所有映射
  2. 已锁定的 slug 直接使用其短码,不重新计算
  3. 只对新出现的 slug 运行生成算法
  4. 有新码产生时,自动将完整映射写回锁定文件
// 加载锁定映射
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 映射表。

持久化锁定文件是整个系统最关键的设计决策。它把"算法可以随时改"和"链接永远不失效"这两个看似矛盾的需求优雅地统一了起来。