<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>洛墨-天染-依然</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://blog.luomo.moe/</id>
  <link href="https://blog.luomo.moe/" rel="alternate"/>
  <link href="https://blog.luomo.moe/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, 洛墨-天染-依然</rights>
  <subtitle>循此苦旅 直抵群星</subtitle>
  <title>Luomoの云日常</title>
  <updated>2026-08-08T11:45:00.000Z</updated>
  <entry>
    <author>
      <name>洛墨-天染-依然</name>
    </author>
    <category term="技术实践" scheme="https://blog.luomo.moe/categories/%E6%8A%80%E6%9C%AF%E5%AE%9E%E8%B7%B5/"/>
    <category term="Hexo" scheme="https://blog.luomo.moe/tags/Hexo/"/>
    <category term="Butterfly" scheme="https://blog.luomo.moe/tags/Butterfly/"/>
    <category term="Vercel" scheme="https://blog.luomo.moe/tags/Vercel/"/>
    <category term="GitHub" scheme="https://blog.luomo.moe/tags/GitHub/"/>
    <category term="pnpm" scheme="https://blog.luomo.moe/tags/pnpm/"/>
    <category term="CI/CD" scheme="https://blog.luomo.moe/tags/CI-CD/"/>
    <content>
      <![CDATA[<link rel="stylesheet" type="text&#x2F;css" href="https://cdn.jsdelivr.net/npm/hexo-tag-hint@0.3.1/dist/hexo-tag-hint.min.css"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="/assets/css/APlayer.min.css"><script src="/assets/js/APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="/assets/js/Meting.min.js"></script><p>这篇文章把 <ruby>Luomoの云日常<rt>本站博客</rt></ruby> 自己放到解剖台上：源码托管在 GitHub，Hexo 把 Markdown 编译成静态文件，Butterfly 负责页面结构与样式，Vercel 连接 <code>main</code> 分支并将构建结果发布到 <code>blog.luomo.moe</code>。这不是一份只在空目录里成功过的“理论教程”，而是对当前生产仓库、部署记录和几次真实事故的复盘。</p><mark class="hl-label blue">Hexo静态生成</mark>  <mark class="hl-label purple">Butterfly主题</mark>  <mark class="hl-label black">GitHub源代码</mark>  <mark class="hl-label green">Vercel生产部署</mark> <a class="btn-beautify blue larger" href="https://blog.luomo.moe"   title="打开博客"><i class="fas fa-blog"></i><span>打开博客</span></a><a class="btn-beautify purple larger" href="https://github.com/luomo66ccff/hexo"   title="查看 GitHub 仓库"><i class="fab fa-github"></i><span>查看 GitHub 仓库</span></a><a class="btn-beautify green larger" href="https://hexo.io/docs/"   title="Hexo 官方文档"><i class="fas fa-book"></i><span>Hexo 官方文档</span></a><a class="btn-beautify orange larger" href="https://vercel.com/docs/git"   title="Vercel Git 部署文档"><i class="fas fa-cloud"></i><span>Vercel Git 部署文档</span></a><div class="note success flat"><p>本文所有版本号、目录、构建命令和故障原因都来自当前仓库或真实部署记录。账号令牌、百度推送 Token、评论系统密钥等不会写进文章，更不会塞进 Git 历史供全世界考古。</p></div><span id="more"></span><h2 id="先看最终架构">先看最终架构</h2><div class="mermaid-wrap"><pre class="mermaid-src" hidden>  flowchart LR  A[&quot;source&#x2F;_posts&#x2F;*.md\n文章与 Front-matter&quot;] --&gt; B[&quot;Markdown-it 与内容插件\nKaTeX &#x2F; Ruby &#x2F; Hint &#x2F; Spoiler&quot;]  B --&gt; C[&quot;Hexo 7.3.0\n生成路由与静态资源&quot;]  T[&quot;Butterfly 4.13.0\nPug + Stylus + Tag Plugins&quot;] --&gt; C  X[&quot;自定义 CSS &#x2F; JS\n白色主题与 PJAX 兼容&quot;] --&gt; C  C --&gt; P[&quot;public&#x2F;\nHTML &#x2F; CSS &#x2F; JS &#x2F; XML&quot;]  P --&gt; V[&quot;Vercel Production\n静态托管与边缘缓存&quot;]  G[&quot;GitHub main 分支&quot;] --&gt; V  V --&gt; D[&quot;blog.luomo.moe\nHTTPS 自定义域名&quot;]  </pre></div><p>整条链路里没有运行时数据库，也没有常驻 Node.js 服务。Node.js 只在构建阶段执行 Hexo；部署完成后，访客拿到的是预先生成的 HTML、CSS、JavaScript、图片、搜索索引、Feed 和站点地图。<span class="hint--info hint--rounded hint--top" data-hint="静态文件托管生产域名分发" ontouchstart>构建时生成</span> 是理解这套架构的三个关键词。</p><h2 id="当前生产基线">当前生产基线</h2><p>截至本文发布前，我重新执行了版本检查、干净构建与 Vercel 控制面审计：</p><table><thead><tr><th>项目</th><th>当前值</th><th>说明</th></tr></thead><tbody><tr><td>GitHub 仓库</td><td><code>luomo66ccff/hexo</code></td><td>Vercel 项目已真实连接该仓库</td></tr><tr><td>生产分支</td><td><code>main</code></td><td>推送后触发 Production 部署</td></tr><tr><td>Hexo</td><td><code>7.3.0</code></td><td>实际安装版本，不是只看范围声明</td></tr><tr><td>Butterfly</td><td><code>4.13.0</code></td><td>主题源码随仓库版本化</td></tr><tr><td>本地验证环境</td><td>Node.js <code>24.14.0</code>、pnpm <code>11.16.0</code></td><td>本文构建基线</td></tr><tr><td>Vercel 项目运行时</td><td>Node.js <code>20.x</code></td><td>当前仍可构建，但已经收到弃用警告</td></tr><tr><td>Vercel 项目根目录</td><td><code>.</code></td><td>博客位于仓库根目录</td></tr><tr><td>构建输出</td><td><code>public/</code></td><td>由 <code>hexo generate</code> 生成</td></tr><tr><td>最近生产状态</td><td><code>READY</code></td><td>控制面显示最新 Production 已就绪</td></tr><tr><td>正式域名</td><td><code>https://blog.luomo.moe</code></td><td>指向最新生产部署</td></tr></tbody></table><div class="note warning flat"><p>Vercel 已提示 Node.js 20.x 将在 2026 年 10 月 1 日后阻止新构建。新建项目应直接选 24.x；本站也需要在截止日前完成 24.x 迁移和一次完整回归，不能等构建日历翻脸时再临场表演滑跪。</p></div><h2 id="仓库结构：哪些文件真正参与部署">仓库结构：哪些文件真正参与部署</h2><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">hexo-blog/</span><br><span class="line">├─ _config.yml                    # Hexo 站点、路由与插件配置</span><br><span class="line">├─ package.json                   # 依赖与 clean/build/server 脚本</span><br><span class="line">├─ pnpm-lock.yaml                 # 可复现依赖图</span><br><span class="line">├─ pnpm-workspace.yaml            # 将仓库根声明为 pnpm workspace</span><br><span class="line">├─ vercel.json                    # Vercel 构建、输出与缓存响应头</span><br><span class="line">├─ source/</span><br><span class="line">│  ├─ _posts/                    # 已发布 Markdown 文章</span><br><span class="line">│  ├─ _drafts/                   # 草稿，不进入正式构建</span><br><span class="line">│  ├─ css/luomo-theme.css        # 白色主题覆盖层</span><br><span class="line">│  ├─ js/luomo-theme.js          # 项目筛选与 PJAX 重初始化</span><br><span class="line">│  └─ service-worker.js          # 旧离线缓存的一次性退役脚本</span><br><span class="line">├─ themes/butterfly/              # Butterfly 4.13.0 主题源码</span><br><span class="line">└─ public/                        # 构建产物，忽略提交，由 Vercel 生成</span><br></pre></td></tr></table></figure><p>这里最重要的边界是：<code>source/</code>、主题和配置属于源码，<code>public/</code> 属于可重复生成的产物。仓库的 <code>.gitignore</code> 会排除 <code>public/</code>、<code>node_modules/</code>、<code>db.json</code>、<code>.vercel/</code> 与本地环境文件，避免把几万份可再生文件和项目链接元数据一起扔进 Git。</p><h2 id="从零准备运行环境">从零准备运行环境</h2><p>为了避开即将退役的 Node.js 20，新部署建议直接使用 Node.js 24 LTS，并通过 Corepack 或独立安装获得 pnpm：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">node --version</span><br><span class="line">corepack <span class="built_in">enable</span></span><br><span class="line">corepack prepare pnpm@11.9.0 --activate</span><br><span class="line">pnpm --version</span><br></pre></td></tr></table></figure><p>然后克隆仓库并严格按照锁文件安装：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">git <span class="built_in">clone</span> https://github.com/luomo66ccff/hexo.git</span><br><span class="line"><span class="built_in">cd</span> hexo</span><br><span class="line">pnpm install --frozen-lockfile</span><br></pre></td></tr></table></figure><p><code>--frozen-lockfile</code> 会在 <code>package.json</code> 与锁文件不一致时直接失败。失败虽然很不给面子，却比生产构建偷偷换一批依赖、上线后集体失忆更安全。</p><h3 id="pnpm-版本还有一个现实差异">pnpm 版本还有一个现实差异</h3><p>仓库的 <code>packageManager</code> 声明为 <code>pnpm@11.9.0</code>，当前本地验证实际使用 <code>11.16.0</code>；现有 Vercel 项目则根据项目创建时间和锁文件格式选择了 pnpm 9.x。也就是说，“写了 packageManager”不自动等于每个环境都精确执行该版本。</p><p>若团队需要完全一致，可以启用 Vercel 支持的 Corepack 机制，并把本地、CI 与 Vercel 的 pnpm 版本统一；在完成迁移前，至少要保留锁文件安装和 Preview 验证，不能一边跨大版本一边直冲生产。</p><h2 id="package-json：把流程收口成四个命令"><code>package.json</code>：把流程收口成四个命令</h2><p>仓库使用的脚本非常克制：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;scripts&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;build&quot;</span><span class="punctuation">:</span> <span class="string">&quot;hexo generate&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;clean&quot;</span><span class="punctuation">:</span> <span class="string">&quot;hexo clean&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;deploy&quot;</span><span class="punctuation">:</span> <span class="string">&quot;hexo deploy&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;server&quot;</span><span class="punctuation">:</span> <span class="string">&quot;hexo server&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>日常真正使用的是：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">pnpm run clean     <span class="comment"># 删除 db.json 与 public，排除旧产物污染</span></span><br><span class="line">pnpm run build     <span class="comment"># 生成 public</span></span><br><span class="line">pnpm run server    <span class="comment"># 在 http://localhost:4000 预览</span></span><br></pre></td></tr></table></figure><p>虽然还保留了 <code>hexo deploy</code> 脚本，但本站生产链路并不依赖它。仓库也没有安装 <code>hexo-deployer-git</code>，所以正确发布方式是提交源码并推送 GitHub，再由 Vercel 构建；不要把 <code>hexo deploy</code> 和 Vercel Git 集成同时当作两位司机抢方向盘。</p><h2 id="为什么需要-pnpm-workspace-yaml">为什么需要 <code>pnpm-workspace.yaml</code></h2><p>当前文件内容如下：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">packages:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">.</span></span><br><span class="line"></span><br><span class="line"><span class="attr">allowBuilds:</span></span><br><span class="line">  <span class="attr">core-js:</span> <span class="literal">true</span></span><br><span class="line">  <span class="attr">ejs:</span> <span class="literal">true</span></span><br><span class="line">  <span class="attr">hexo-util:</span> <span class="literal">true</span></span><br><span class="line">  <span class="attr">highlight.js:</span> <span class="literal">true</span></span><br></pre></td></tr></table></figure><p><code>packages: - .</code> 明确告诉 pnpm：仓库根就是工作区包。它看起来像在认真声明“我家住我家”，但这行曾经解决过 Vercel 对工作区根识别不完整的问题。<code>allowBuilds</code> 则只允许列出的依赖执行构建脚本，降低安装阶段随意运行脚本的范围。</p><p><a href="https://pnpm.io/workspaces">pnpm 官方工作区文档</a>要求工作区根包含 <code>pnpm-workspace.yaml</code>。即使这里只有一个包，把根目录显式列出也能让本地与远端对项目边界形成同一套认知。</p><h2 id="Hexo-主配置：域名、路由与生成目录">Hexo 主配置：域名、路由与生成目录</h2><p><code>_config.yml</code> 中与部署最相关的部分可以压缩为：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">title:</span> <span class="string">Luomoの云日常</span></span><br><span class="line"><span class="attr">language:</span> <span class="string">zh-CN</span></span><br><span class="line"><span class="attr">timezone:</span> <span class="string">Asia/Shanghai</span></span><br><span class="line"></span><br><span class="line"><span class="attr">url:</span> <span class="string">https://blog.luomo.moe</span></span><br><span class="line"><span class="attr">permalink:</span> <span class="string">posts/:abbrlink.html</span></span><br><span class="line"><span class="attr">source_dir:</span> <span class="string">source</span></span><br><span class="line"><span class="attr">public_dir:</span> <span class="string">public</span></span><br><span class="line"><span class="attr">render_drafts:</span> <span class="literal">false</span></span><br><span class="line"><span class="attr">theme:</span> <span class="string">butterfly</span></span><br><span class="line"></span><br><span class="line"><span class="attr">markdown:</span></span><br><span class="line">  <span class="attr">plugins:</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">&#x27;@renbaoshuo/markdown-it-katex&#x27;</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">markdown-it-ruby</span></span><br></pre></td></tr></table></figure><p><code>url</code> 必须写正式域名，否则 Feed、站点地图、规范链接和百度提交列表可能继续散发旧地址。<code>permalink</code> 使用 <code>hexo-abbrlink</code> 生成稳定短链接，标题改名时不会顺手给所有外链来一记背刺。</p><p>草稿放在 <code>source/_drafts/</code>，因为 <code>render_drafts: false</code>，正式构建不会发布；需要预览时使用 <code>hexo server --draft</code>，不要为了看草稿把它先塞进 <code>_posts</code> 再祈祷自己记得删。</p><h2 id="我扫描到的-28-个直接依赖">我扫描到的 28 个直接依赖</h2><p>依赖多不代表每篇文章都要全员上台。下面按职责列出当前安装结果，版本来自实际依赖树：</p><details class="toggle" style="border: 1px solid #425b89"><summary class="toggle-button" style="background-color: #425b89;color: #ffffff">展开完整插件审计</summary><div class="toggle-content"><table><thead><tr><th>类别</th><th>包与实际版本</th><th>当前职责</th></tr></thead><tbody><tr><td>核心</td><td><code>hexo 7.3.0</code>、<code>hexo-util 3.3.0</code>、<code>hexo-server 3.0.0</code></td><td>生成器、工具与本地预览</td></tr><tr><td>渲染器</td><td><code>hexo-renderer-markdown-it 7.1.1</code>、<code>ejs 2.0.0</code>、<code>pug 3.0.0</code>、<code>stylus 3.0.1</code></td><td>Markdown、模板与样式编译</td></tr><tr><td>基础生成器</td><td><code>archive 2.0.0</code>、<code>category 2.0.0</code>、<code>index 3.0.0</code>、<code>tag 2.0.0</code></td><td>首页、归档、分类与标签</td></tr><tr><td>发现与订阅</td><td><code>searchdb 1.5.0</code>、<code>feed 4.0.0</code>、<code>sitemap 3.0.1</code>、<code>baidu-sitemap 0.1.9</code></td><td>搜索索引、Atom、两类站点地图</td></tr><tr><td>链接与 SEO</td><td><code>abbrlink 2.2.1</code>、<code>filter-nofollow 2.0.2</code>、<code>baidu-url-submit 0.0.6</code></td><td>固定链接、外链属性与提交列表</td></tr><tr><td>数学与排版</td><td><code>markdown-it-katex 2.0.2</code>、<code>markdown-it-ruby 0.1.1</code>、<code>wordcount 6.0.1</code></td><td>公式、Ruby 注音、字数与阅读时间</td></tr><tr><td>交互内容</td><td><code>hexo-spoiler 1.7.4</code>、<code>hexo-tag-hint 0.3.1</code></td><td>模糊隐藏与行内提示</td></tr><tr><td>媒体标签</td><td><code>hexo-tag-aplayer 3.0.4</code>、<code>hexo-tag-bilibili 0.3.1</code></td><td>音乐和视频嵌入，本文不强行使用</td></tr><tr><td>说说生态</td><td><code>hexo-butterfly-artitalk 1.0.4</code>、<code>hexo-butterfly-hpptalk 1.0.4</code></td><td>说说页面能力，需要独立配置凭据</td></tr><tr><td>备用主题</td><td><code>hexo-theme-landscape 1.0.0</code></td><td>Hexo 默认主题依赖，当前未启用</td></tr></tbody></table></div></details><p>全站构建时，搜索、Feed、站点地图、nofollow 与字数统计会自然参与；本文另外使用了 KaTeX、Ruby、Hint、Spoiler，以及 Butterfly 自带的 Mermaid、Note、Tabs、Timeline、Button、Label 和 HideToggle。APlayer 与 Bilibili 不服务于部署主题，硬塞一段音乐视频只会让教程像项目经理突然唱起片尾曲。</p><h2 id="Butterfly-与白色主题如何叠加">Butterfly 与白色主题如何叠加</h2><p>本站把 Butterfly 4.13.0 的源码放在 <code>themes/butterfly/</code> 中，并启用了这些关键项：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">display_mode:</span> <span class="string">light</span></span><br><span class="line"></span><br><span class="line"><span class="attr">darkmode:</span></span><br><span class="line">  <span class="attr">enable:</span> <span class="literal">false</span></span><br><span class="line">  <span class="attr">button:</span> <span class="literal">false</span></span><br><span class="line"></span><br><span class="line"><span class="attr">preloader:</span></span><br><span class="line">  <span class="attr">enable:</span> <span class="literal">false</span></span><br><span class="line"></span><br><span class="line"><span class="attr">wordcount:</span></span><br><span class="line">  <span class="attr">enable:</span> <span class="literal">true</span></span><br><span class="line"></span><br><span class="line"><span class="attr">search:</span></span><br><span class="line">  <span class="attr">use:</span> <span class="string">local_search</span></span><br><span class="line"></span><br><span class="line"><span class="attr">pjax:</span></span><br><span class="line">  <span class="attr">enable:</span> <span class="literal">true</span></span><br><span class="line"></span><br><span class="line"><span class="attr">lazyload:</span></span><br><span class="line">  <span class="attr">enable:</span> <span class="literal">true</span></span><br><span class="line"></span><br><span class="line"><span class="attr">mermaid:</span></span><br><span class="line">  <span class="attr">enable:</span> <span class="literal">true</span></span><br></pre></td></tr></table></figure><p>自定义样式不直接堆进文章，而是由主题注入：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">inject:</span></span><br><span class="line">  <span class="attr">head:</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">&lt;link</span> <span class="string">rel=&quot;stylesheet&quot;</span> <span class="string">href=&quot;/css/luomo-theme.css?v=20260804-white&quot;&gt;</span></span><br><span class="line">  <span class="attr">bottom:</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">&lt;script</span> <span class="string">src=&quot;/js/luomo-theme.js?v=20260626-butterfly&quot;&gt;&lt;/script&gt;</span></span><br></pre></td></tr></table></figure><p><code>source/css/luomo-theme.css</code> 将正文卡片、表格、代码块、导航和页脚统一为白色；移动端表格单独允许横向滚动。<code>source/js/luomo-theme.js</code> 在 <code>DOMContentLoaded</code> 和 <code>pjax:complete</code> 后重新初始化交互，避免 PJAX 换页后按钮看得见却像灵魂已离职。</p><p>长期维护时，优先把个性化放在 <code>source/css</code>、<code>source/js</code> 和独立主题配置中。本站历史上修改过少量 Butterfly 内部模板与过滤器，所以升级主题前必须对比这些补丁，不能直接覆盖整个主题目录。</p><h2 id="vercel-json：生产构建的唯一明确契约"><code>vercel.json</code>：生产构建的唯一明确契约</h2><p>当前仓库使用：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;$schema&quot;</span><span class="punctuation">:</span> <span class="string">&quot;https://openapi.vercel.sh/vercel.json&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;buildCommand&quot;</span><span class="punctuation">:</span> <span class="string">&quot;pnpm run build&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;installCommand&quot;</span><span class="punctuation">:</span> <span class="string">&quot;pnpm install --frozen-lockfile&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;outputDirectory&quot;</span><span class="punctuation">:</span> <span class="string">&quot;public&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;framework&quot;</span><span class="punctuation">:</span> <span class="string">&quot;hexo&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;headers&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;source&quot;</span><span class="punctuation">:</span> <span class="string">&quot;/(.*)&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;headers&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">        <span class="punctuation">&#123;</span></span><br><span class="line">          <span class="attr">&quot;key&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Cache-Control&quot;</span><span class="punctuation">,</span></span><br><span class="line">          <span class="attr">&quot;value&quot;</span><span class="punctuation">:</span> <span class="string">&quot;public, max-age=0, s-maxage=300, must-revalidate&quot;</span></span><br><span class="line">        <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">]</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>四个核心字段各管一层：</p><table><thead><tr><th>字段</th><th>作用</th></tr></thead><tbody><tr><td><code>framework</code></td><td>让 Vercel 按 Hexo 项目理解仓库</td></tr><tr><td><code>installCommand</code></td><td>严格按锁文件安装依赖</td></tr><tr><td><code>buildCommand</code></td><td>运行 <code>hexo generate</code></td></tr><tr><td><code>outputDirectory</code></td><td>只发布 <code>public/</code>，不把源码端上桌</td></tr></tbody></table><p>控制台的 Project Inspect 可能显示框架默认命令，但仓库中的显式配置会进入实际构建。判断最终执行了什么要看部署日志；本站生产日志确实运行了 <code>pnpm install --frozen-lockfile</code> 与 <code>pnpm run build</code>。</p><h3 id="缓存头为什么这样写">缓存头为什么这样写</h3><p><code>max-age=0</code> 要求浏览器重新验证，<code>s-maxage=300</code> 允许共享边缘缓存保留 300 秒，<code>must-revalidate</code> 避免过期内容被随意继续使用。它适合更新频率不高的静态博客，同时降低长时间卡住旧 HTML 的概率。</p><p>一次发布的体感时间可以近似拆成：</p><p><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><msub><mi>T</mi><mrow><mi>v</mi><mi>i</mi><mi>s</mi><mi>i</mi><mi>b</mi><mi>l</mi><mi>e</mi></mrow></msub><mo>≈</mo><msub><mi>T</mi><mrow><mi>i</mi><mi>n</mi><mi>s</mi><mi>t</mi><mi>a</mi><mi>l</mi><mi>l</mi></mrow></msub><mo>+</mo><msub><mi>T</mi><mrow><mi>g</mi><mi>e</mi><mi>n</mi><mi>e</mi><mi>r</mi><mi>a</mi><mi>t</mi><mi>e</mi></mrow></msub><mo>+</mo><msub><mi>T</mi><mrow><mi>u</mi><mi>p</mi><mi>l</mi><mi>o</mi><mi>a</mi><mi>d</mi></mrow></msub><mo>+</mo><msub><mi>T</mi><mrow><mi>a</mi><mi>l</mi><mi>i</mi><mi>a</mi><mi>s</mi></mrow></msub></mrow><annotation encoding="application/x-tex">T_{visible} \approx T_{install} + T_{generate} + T_{upload} + T_{alias}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em;">T</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em;"><span style="top:-2.55em;margin-left:-0.1389em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0359em;">v</span><span class="mord mathnormal mtight">i</span><span class="mord mathnormal mtight">s</span><span class="mord mathnormal mtight">ib</span><span class="mord mathnormal mtight" style="margin-right:0.0197em;">l</span><span class="mord mathnormal mtight">e</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">≈</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em;">T</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em;"><span style="top:-2.55em;margin-left:-0.1389em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">in</span><span class="mord mathnormal mtight">s</span><span class="mord mathnormal mtight">t</span><span class="mord mathnormal mtight">a</span><span class="mord mathnormal mtight" style="margin-right:0.0197em;">l</span><span class="mord mathnormal mtight" style="margin-right:0.0197em;">l</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.9694em;vertical-align:-0.2861em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em;">T</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em;"><span style="top:-2.55em;margin-left:-0.1389em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0359em;">g</span><span class="mord mathnormal mtight">e</span><span class="mord mathnormal mtight">n</span><span class="mord mathnormal mtight" style="margin-right:0.0278em;">er</span><span class="mord mathnormal mtight">a</span><span class="mord mathnormal mtight">t</span><span class="mord mathnormal mtight">e</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2861em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.9694em;vertical-align:-0.2861em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em;">T</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em;"><span style="top:-2.55em;margin-left:-0.1389em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">u</span><span class="mord mathnormal mtight" style="margin-right:0.0197em;">pl</span><span class="mord mathnormal mtight">o</span><span class="mord mathnormal mtight">a</span><span class="mord mathnormal mtight">d</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2861em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em;">T</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em;"><span style="top:-2.55em;margin-left:-0.1389em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">a</span><span class="mord mathnormal mtight" style="margin-right:0.0197em;">l</span><span class="mord mathnormal mtight">ia</span><span class="mord mathnormal mtight">s</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span></span></p><p>边缘缓存与 DNS 状态会影响个别访问，但 Vercel 的部署本身是独立产物，Production 就绪后再把域名别名切过去。不要在构建还红着时把“CDN 有缓存”当作电子护身符。</p><h2 id="在-Vercel-导入-GitHub-仓库">在 Vercel 导入 GitHub 仓库</h2><h3 id="1-导入仓库">1. 导入仓库</h3><p>进入 Vercel Dashboard，选择 <strong>Add New → Project</strong>，授权 GitHub 后导入 <code>luomo66ccff/hexo</code>。本站控制面当前确认的绑定关系是：</p><table><thead><tr><th>设置</th><th>值</th></tr></thead><tbody><tr><td>Git Provider</td><td>GitHub</td></tr><tr><td>Repository</td><td><code>luomo66ccff/hexo</code></td></tr><tr><td>Production Branch</td><td><code>main</code></td></tr><tr><td>Root Directory</td><td><code>.</code></td></tr><tr><td>Framework Preset</td><td>Hexo</td></tr><tr><td>Node.js Version</td><td>建议 <code>24.x</code></td></tr></tbody></table><p>因为仓库已有 <code>vercel.json</code>，不必在控制台重复维护三套命令。若控制台有旧的手工覆盖值，要么清空让仓库配置接管，要么确保两边完全一致；最怕一边改成 pnpm，另一边还在召唤 npm，最后构建日志现场举行包管理器武林大会。</p><h3 id="2-理解-Preview-与-Production">2. 理解 Preview 与 Production</h3><p>Vercel 的 Git 集成会为非生产分支和 Pull Request 创建 Preview，<code>main</code> 则跟踪 Production。推荐流程是：</p><div class="timeline blue"><div class='timeline-item headline'><div class='timeline-item-title'><div class='item-circle'><p>一次正常发布</p></div></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol><li>本地写作</li></ol></div></div><div class='timeline-item-content'><p>在 <code>source/_posts/</code> 新建文章，填写 Front-matter，并只提交真正需要的素材。</p></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol start="2"><li>干净构建</li></ol></div></div><div class='timeline-item-content'><p>执行 <code>pnpm run clean &amp;&amp; pnpm run build</code>，确认新页面、搜索、Feed 和 Sitemap 都生成。</p></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol start="3"><li>本地视觉验收</li></ol></div></div><div class='timeline-item-content'><p>运行 <code>pnpm run server</code>，分别检查桌面端和手机端，关注白色主题、代码块、表格、Mermaid 与横向溢出。</p></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol start="4"><li>Git 提交</li></ol></div></div><div class='timeline-item-content'><p>明确审查 <code>git status</code> 和差异，只提交本篇文章及关联资源。</p></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol start="5"><li>Preview 或 Production</li></ol></div></div><div class='timeline-item-content'><p>功能分支推送后先看 Preview；确认无误再合并 <code>main</code>。个人站也可在完整本地 QA 后直接推送 <code>main</code>。</p></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol start="6"><li>控制面确认</li></ol></div></div><div class='timeline-item-content'><p>等待 Vercel 状态变为 Ready，核对提交哈希、生成文件、生产域名和错误日志。</p></div></div></div><h2 id="本地发布命令">本地发布命令</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">pnpm install --frozen-lockfile</span><br><span class="line">pnpm run clean</span><br><span class="line">pnpm run build</span><br><span class="line">pnpm run server</span><br></pre></td></tr></table></figure><p>浏览器检查完成后：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">git status --short</span><br><span class="line">git diff --check</span><br><span class="line">git add <span class="built_in">source</span>/_posts/your-post.md</span><br><span class="line">git commit -m <span class="string">&quot;docs: 发布 Hexo 部署教程&quot;</span></span><br><span class="line">git push origin main</span><br></pre></td></tr></table></figure><p>推送 <code>main</code> 后，Git 集成会自动创建 Production 部署。<a href="https://vercel.com/docs/git">Vercel Git 部署文档</a>也说明非生产分支和 Pull Request 会获得独立 Preview，生产分支则生成 Production。</p><h2 id="CLI-发布与排障备用路线">CLI 发布与排障备用路线</h2><p>Git 自动部署是日常主路，CLI 是手动发布、检查和回滚的备用通道：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">npx vercel login</span><br><span class="line">npx vercel <span class="built_in">link</span></span><br><span class="line">npx vercel project inspect hexo</span><br><span class="line">npx vercel list hexo --status READY</span><br><span class="line"></span><br><span class="line"><span class="comment"># Preview</span></span><br><span class="line">npx vercel deploy</span><br><span class="line"></span><br><span class="line"><span class="comment"># 明确发布 Production</span></span><br><span class="line">npx vercel deploy --prod</span><br></pre></td></tr></table></figure><p><code>vercel link</code> 会创建 <code>.vercel/project.json</code>，其中是项目与组织标识，不是部署源码；本站将整个 <code>.vercel/</code> 加入 <code>.gitignore</code>。自动化环境中的令牌必须放在 Secret 或环境变量中，禁止写进命令历史和仓库。</p><div class="tabs" id="vercel-ops"><ul class="nav-tabs"><button type="button" class="tab  active" data-href="vercel-ops-1"><i class="fas fa-magnifying-glass"></i>检查部署</button><button type="button" class="tab " data-href="vercel-ops-2"><i class="fas fa-rotate-left"></i>回滚</button><button type="button" class="tab " data-href="vercel-ops-3"><i class="fas fa-box"></i>预构建发布</button></ul><div class="tab-contents"><div class="tab-item-content active" id="vercel-ops-1"><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">npx vercel inspect &lt;deployment-url&gt;</span><br><span class="line">npx vercel logs &lt;deployment-url&gt; --level error --since 1h</span><br></pre></td></tr></table></figure><p>确认目标为 Production、状态为 Ready、提交 SHA 正确，并查看构建或运行错误。</p></div><div class="tab-item-content" id="vercel-ops-2"><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">npx vercel rollback</span><br><span class="line"><span class="comment"># 或指定一份已知正常的部署</span></span><br><span class="line">npx vercel rollback &lt;deployment-url-or-id&gt;</span><br></pre></td></tr></table></figure><p>静态博客没有数据库迁移，回滚通常就是把生产域名重新指向上一份完整静态产物。</p></div><div class="tab-item-content" id="vercel-ops-3"><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">npx vercel pull --<span class="built_in">yes</span> --environment=production</span><br><span class="line">npx vercel build --prod</span><br><span class="line">npx vercel deploy --prebuilt --prod</span><br></pre></td></tr></table></figure><p>适合需要在构建与发布之间插入额外测试门禁的 CI；普通个人博客无需为了显得专业把流水线堆成跨海大桥。</p></div></div><div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="fas fa-arrow-up"></i></button></div></div><h2 id="绑定-blog-luomo-moe">绑定 <code>blog.luomo.moe</code></h2><p>在 Vercel 项目的 <strong>Settings → Domains</strong> 添加 <code>blog.luomo.moe</code>。这是子域名，通常需要在 DNS 服务商处添加 Vercel 当时显示的 CNAME 目标。</p><p>不要从旧教程里复制某个固定 CNAME 值。<a href="https://vercel.com/docs/domains/working-with-domains/add-a-domain">Vercel 自定义域名文档</a>要求以项目面板显示的 DNS 目标为准；域名被其他账号占用时还可能要求 TXT 验证。DNS 验证成功后，Vercel 会自动申请 HTTPS 证书。</p><p>站点配置也必须同步：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># _config.yml</span></span><br><span class="line"><span class="attr">url:</span> <span class="string">https://blog.luomo.moe</span></span><br></pre></td></tr></table></figure><p>还要检查 <code>source/robots.txt</code>、Feed、Sitemap、Open Graph、评论系统白名单和第三方回调地址。只改浏览器地址栏能访问，不代表整站的机器读者已经跟着搬家。</p><h2 id="四次真实踩坑复盘">四次真实踩坑复盘</h2><h3 id="坑一：Vercel-识别-pnpm-工作区不稳定">坑一：Vercel 识别 pnpm 工作区不稳定</h3><p>早期仓库只有 <code>allowBuilds</code>，没有 <code>packages</code>。加入下面两行后，仓库根被明确识别为工作区包：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">packages:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">.</span></span><br></pre></td></tr></table></figure><p>同时将 <code>.vercel/</code> 和 <code>.env.local</code> 排除提交。修复重点不是“多写一份 YAML”，而是让本地 pnpm、Vercel 安装阶段和项目根目录对同一边界达成一致。</p><h3 id="坑二：页面黑屏和文章排版错位">坑二：页面黑屏和文章排版错位</h3><p>这次故障由多件小事叠加：</p><ol><li>自定义 CSS 使用了 <code>.article-container</code>，而实际正文节点是 <code>#article-container</code>；</li><li>全屏预加载器让异常加载表现成整页遮罩；</li><li>文章头图与全站图片懒加载规则发生重复处理；</li><li>表格在手机端没有自己的横向滚动容器；</li><li>Note 标签参数与当前 Butterfly 版本语法不一致。</li></ol><p>修复后关闭全屏预加载器、改正选择器、让带 <code>nolazyload</code> 的图片绕过过滤器，并为表格添加移动端滚动。<!-- spoiler-1ffd9:black --><span class="spoiler" onclick="this.classList.toggle('spoiler')"><span class="spoiler-blur spoiler-1ffd9">“把背景改白”只能遮住部分症状，真正的黑屏还可能来自覆盖层、缓存和资源加载。</span></span></p><h3 id="坑三：主题配置是-light，视觉却仍然很黑">坑三：主题配置是 light，视觉却仍然很黑</h3><p><code>display_mode: light</code> 只决定 Butterfly 默认模式，旧自定义 CSS 仍可用深色变量把整站重新涂黑。因此白色主题迁移同时修改了：</p><ul><li>页面背景与玻璃卡片变量；</li><li>导航、正文、标题和页脚文字色；</li><li>代码块、表格、引用与 Note；</li><li>前后文章导航；</li><li>移动端正文内边距与表格滚动；</li><li>禁用自动暗色模式与暗色切换按钮。</li></ul><p>验证主题不能只检查 <code>data-theme=&quot;light&quot;</code>，还要读取最终计算样式，并在真实尺寸截图中确认正文背景和文字对比度。</p><h3 id="坑四：插件删了，旧-Service-Worker-还活着">坑四：插件删了，旧 Service Worker 还活着</h3><p>移除 <code>hexo-offline</code> 只会阻止新构建继续生成缓存逻辑，已经注册在访客浏览器里的 Service Worker 不会自动原地圆寂。它仍可能把旧 HTML、CSS 和脚本塞回来，让生产站出现“代码已经改了，浏览器坚持活在前朝”的现象。</p><p>本站使用一次性迁移 Worker：安装后接管页面、删除旧 Cache Storage、注销自身，再让页面重新导航。<code>vercel.json</code> 对 <code>/service-worker.js</code> 单独发送 <code>no-store, no-cache, must-revalidate</code>，确保浏览器能够拿到退役脚本，而不是继续缓存旧 Worker。</p><div class="note danger flat"><p>不要随意把 Service Worker 文件直接删掉后就宣布胜利。要清理已经安装的旧 Worker，需要先在同一作用域发布能接管并注销自己的版本，等待足够的迁移窗口后再移除。</p></div><h2 id="构建产物应该验收什么">构建产物应该验收什么</h2><p>一次干净构建不是只看退出码为 0。本文写作前的基线构建生成 69 个文件，其中 30 个为 HTML；新增文章后还要检查以下内容：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">public/posts/&lt;abbrlink&gt;.html     新文章页面</span><br><span class="line">public/index.html                首页收录</span><br><span class="line">public/search.json               本地搜索索引</span><br><span class="line">public/atom.xml                  Atom Feed</span><br><span class="line">public/sitemap.xml               通用站点地图</span><br><span class="line">public/baidusitemap.xml          百度站点地图</span><br><span class="line">public/baidu_urls.txt            最新链接列表</span><br></pre></td></tr></table></figure><p><code>hexo-baidu-url-submit</code> 在公开仓库中只保留生成配置，真实 Token 必须通过私有配置或环境变量提供。看到 <code>baidu_urls.txt</code> 生成，不等于已经向百度成功提交；二者不能用一个绿色日志互相冒充。</p><h2 id="桌面端与手机端视觉-QA">桌面端与手机端视觉 QA</h2><details class="toggle" style="border: 1px solid #425b89"><summary class="toggle-button" style="background-color: #425b89;color: #ffffff">展开发布前检查表</summary><div class="toggle-content"><ol><li>页面主题为 light，正文卡片计算背景是白色或接近白色；</li><li>页面 <code>scrollWidth</code> 不大于视口宽度，手机底部没有横向滚动条；</li><li>Mermaid 已从源码块转换为 SVG，而不是原样露出文本；</li><li>KaTeX 公式、Ruby、Hint、Spoiler、Tabs、Timeline 与折叠块均成功渲染；</li><li>代码块可读、长命令可换行或局部滚动；</li><li>表格只在自身容器滚动，不把整页撑宽；</li><li>图片无 404，头图没有被懒加载重复改写；</li><li>页面中不存在未解析的 Hexo 标签、模板空值或渲染错误；</li><li>搜索、Feed 和 Sitemap 包含新文章；</li><li>浏览器控制台没有新的 JavaScript 错误。</li></ol></div></details><h2 id="生产发布后的判断顺序">生产发布后的判断顺序</h2><ol><li>Vercel 部署是否从 Building 进入 Ready；</li><li>部署关联的 Git 提交 SHA 是否与远端 <code>main</code> 一致；</li><li>构建日志是否生成目标文章；</li><li>Production 域名是否已经指向该部署；</li><li>若页面异常，先区分构建失败、域名未切换、边缘缓存和浏览器 Service Worker；</li><li>问题影响全站时先回滚，再慢慢写事故文学。</li></ol><p>Vercel 每次部署都有独立 URL。Preview 没问题但正式域名异常时，不要立刻重写 Hexo；先检查 Production alias、DNS 与缓存层，避免对着正确代码进行错误手术。</p><h2 id="还需要继续收紧的地方">还需要继续收紧的地方</h2><table><thead><tr><th>项目</th><th>当前状态</th><th>后续动作</th></tr></thead><tbody><tr><td>Node.js</td><td>Vercel 仍为 20.x</td><td>在 2026-10-01 前升级 24.x并回归</td></tr><tr><td>pnpm</td><td>本地声明、实际本地和 Vercel 版本不完全一致</td><td>统一 Corepack 与 packageManager 策略</td></tr><tr><td>Butterfly</td><td>主题源码入库且有少量内部补丁</td><td>升级前逐文件合并，不整目录覆盖</td></tr><tr><td>Service Worker</td><td>正在承担一次性旧缓存退役</td><td>经过足够迁移窗口后评估移除</td></tr><tr><td>外部凭据</td><td>公开配置中不含真实 Token</td><td>继续放在 Vercel 环境变量或私有配置</td></tr><tr><td>SEO 域名</td><td>多份生成物依赖 <code>url</code> 与 robots</td><td>域名变更时做全仓扫描和产物验收</td></tr></tbody></table><h2 id="最后的复盘">最后的复盘</h2><p>这套博客的部署价值不在于“Vercel 点一下就上线”，而在于把内容、渲染、主题、生成、版本控制和托管边界拆得足够清楚。Markdown 是源，<code>public/</code> 是产物，GitHub 是版本事实，Vercel Deployment 是不可变发布单元，自定义域名只负责把访客带到已经通过验证的生产版本。</p><p>真正让站点稳定的也不是某个神奇插件，而是锁文件、干净构建、Preview、移动端 QA、缓存认知和可回滚发布。把这些步骤做实，写完文章推一次 <code>main</code> 就能优雅上线；把它们跳过，三分钟部署也可能附赠三小时“为什么我这还是黑的”。</p><a class="btn-beautify blue center larger" href="https://github.com/luomo66ccff/hexo"   title="从真实仓库开始复现"><i class="fab fa-github"></i><span>从真实仓库开始复现</span></a>]]>
    </content>
    <id>https://blog.luomo.moe/posts/3d61fa8b.html</id>
    <link href="https://blog.luomo.moe/posts/3d61fa8b.html"/>
    <published>2026-08-08T11:45:00.000Z</published>
    <summary>以 Luomoの云日常的真实仓库和生产配置为样本，完整拆解 Hexo、Butterfly、插件、GitHub、Vercel、自定义域名、缓存、回滚，以及黑屏和旧 Service Worker 等实际故障的处理路径。</summary>
    <title>从 Markdown 到全球 CDN：我的 Hexo 博客 GitHub + Vercel 部署全流程</title>
    <updated>2026-08-08T11:45:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>洛墨-天染-依然</name>
    </author>
    <category term="技术实践" scheme="https://blog.luomo.moe/categories/%E6%8A%80%E6%9C%AF%E5%AE%9E%E8%B7%B5/"/>
    <category term="Vercel" scheme="https://blog.luomo.moe/tags/Vercel/"/>
    <category term="GitHub" scheme="https://blog.luomo.moe/tags/GitHub/"/>
    <category term="Serverless" scheme="https://blog.luomo.moe/tags/Serverless/"/>
    <category term="Node.js" scheme="https://blog.luomo.moe/tags/Node-js/"/>
    <category term="Hono" scheme="https://blog.luomo.moe/tags/Hono/"/>
    <category term="API" scheme="https://blog.luomo.moe/tags/API/"/>
    <content>
      <![CDATA[<link rel="stylesheet" type="text&#x2F;css" href="https://cdn.jsdelivr.net/npm/hexo-tag-hint@0.3.1/dist/hexo-tag-hint.min.css"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="/assets/css/APlayer.min.css"><script src="/assets/js/APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="/assets/js/Meting.min.js"></script><p>这次我选择拆解 <ruby>HotAPI<rt>热榜接口部署仓</rt></ruby>。它不是我原创的一整套爬虫内核，而是我在 GitHub 上维护、交给 Vercel 托管的部署适配项目：上游核心来自 <a href="https://github.com/imsyy/DailyHotApi">imsyy/DailyHotApi</a>，遵循 MIT 协议；我的工作重点是把 npm 包固定成可复现依赖，再用极薄的 Node.js 入口与 <code>vercel.json</code> 把它变成公开 Serverless API。</p><mark class="hl-label blue">GitHub部署仓</mark>  <mark class="hl-label black">Vercel函数</mark>  <mark class="hl-label green">JSON与RSS</mark>  <mark class="hl-label purple">上游MIT</mark> <a class="btn-beautify orange larger" href="https://hotapi-ten.vercel.app"   title="打开 HotAPI"><i class="fas fa-fire"></i><span>打开 HotAPI</span></a><a class="btn-beautify purple larger" href="https://github.com/luomo66ccff/hotapi"   title="查看 GitHub 部署仓"><i class="fab fa-github"></i><span>查看 GitHub 部署仓</span></a><a class="btn-beautify blue larger" href="https://github.com/imsyy/DailyHotApi"   title="查看上游核心"><i class="fab fa-github"></i><span>查看上游核心</span></a><div class="note warning flat"><p>先把署名边界钉牢：本文讲的是“如何部署、约束和维护上游 API”，不是把上游作者 imsyy 的采集器改姓。薄适配层同样有工程价值，但它的价值来自可复现部署、运行时边界和运维策略，而不是抢走核心实现的作者席位。</p></div><span id="more"></span><h2 id="为什么一个三行入口仍然值得写">为什么一个三行入口仍然值得写</h2><p>项目入口只有三行：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> serveHotApi <span class="keyword">from</span> <span class="string">&quot;dailyhot-api&quot;</span>;</span><br><span class="line"></span><br><span class="line"><span class="title function_">serveHotApi</span>();</span><br></pre></td></tr></table></figure><p>三行不等于没有架构。它把职责切得很清楚：上游包负责 Hono 路由、数据抓取、格式归一化、JSON/RSS 输出和缓存；部署仓负责版本锁定、ESM 入口、Vercel Function 构建、全路径转发与 GitHub 更新入口。<span class="hint--info hint--rounded hint--top" data-hint="业务代码很少版本和运行时要固定路由与发布边界要明确" ontouchstart>薄部署仓</span> 的目标正是少写重复代码，而不是少做验证。</p><p>截至本文审计时，Vercel 项目 <code>hotapi</code> 的生产部署状态为 <code>Ready</code>，构建结果是一枚位于 <code>iad1</code> 的 Node.js Function，解包前显示约 <code>939.08 KB</code>。GitHub 仓库的锁文件固定了 <code>dailyhot-api 2.0.6</code>；上游已经发布 <code>2.0.8</code>，所以“生产正在跑什么”和“上游最新是什么”必须分开记录。</p><h2 id="仓库虽小，边界要完整">仓库虽小，边界要完整</h2><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">hotapi/</span><br><span class="line">├─ index.js                 # Serverless 入口</span><br><span class="line">├─ package.json             # ESM、依赖与脚本</span><br><span class="line">├─ package-lock.json        # 可复现安装契约</span><br><span class="line">├─ vercel.json              # Function 构建与全路径路由</span><br><span class="line">├─ public/                  # 图标等静态资源</span><br><span class="line">└─ .github/</span><br><span class="line">   └─ dependabot.yml        # 每日检查 npm 版本更新</span><br></pre></td></tr></table></figure><p><code>package.json</code> 中有三个不能随手删的关键点：</p><table><thead><tr><th>配置</th><th>当前值</th><th>作用</th></tr></thead><tbody><tr><td><code>type</code></td><td><code>module</code></td><td>让 <code>index.js</code> 使用原生 ESM <code>import</code></td></tr><tr><td><code>dependencies</code></td><td><code>dailyhot-api: ^2.0.6</code></td><td>引入上游运行时；实际部署版本由锁文件决定</td></tr><tr><td><code>devDependencies</code></td><td><code>@vercel/node</code></td><td>把入口构建成 Vercel Node.js Function</td></tr></tbody></table><p>Vercel 官方文档也明确：无框架 JavaScript Function 若使用 ESM，需要在 <code>package.json</code> 设置 <code>&quot;type&quot;: &quot;module&quot;</code>，或者改用 <code>.mjs</code>。这里选择前者，入口文件保持普通 <code>.js</code> 即可。</p><h2 id="请求是怎样穿过这三层的">请求是怎样穿过这三层的</h2><div class="mermaid-wrap"><pre class="mermaid-src" hidden>  flowchart LR  U[&quot;浏览器 &#x2F; RSS 阅读器 &#x2F; 脚本&quot;] --&gt; V[&quot;Vercel 路由层&quot;]  V --&gt; F[&quot;Node.js Function\nindex.js&quot;]  F --&gt; H[&quot;dailyhot-api\nHono 应用&quot;]  H --&gt; R[&quot;动态路由注册表&quot;]  R --&gt; S[&quot;公开数据源 &#x2F; RSS &#x2F; 页面&quot;]  R --&gt; C[&quot;NodeCache\n实例内缓存&quot;]  H --&gt; J[&quot;JSON 响应&quot;]  H --&gt; X[&quot;RSS XML 响应&quot;]  </pre></div><div class="timeline orange"><div class='timeline-item headline'><div class='timeline-item-title'><div class='item-circle'><p>一次热榜请求的生命史</p></div></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol><li>Vercel 接收路径</li></ol></div></div><div class='timeline-item-content'><p><code>/bilibili?limit=10</code> 先命中部署仓的 catch-all 路由，再交给 <code>index.js</code> 对应的 Node.js Function。</p></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol start="2"><li>Hono 匹配动态路由</li></ol></div></div><div class='timeline-item-content'><p>上游在启动时扫描 <code>routes/</code>。2.0.6 标签中共有 42 个路由文件，其中 40 个可用，<code>52pojie</code> 与 <code>hostloc</code> 被显式排除。</p></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol start="3"><li>尝试读取缓存</li></ol></div></div><div class='timeline-item-content'><p>默认 TTL 为 3600 秒。命中当前 Function 实例的 NodeCache 就直接返回；<code>cache=false</code> 会主动绕过缓存。</p></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol start="4"><li>获取并标准化数据</li></ol></div></div><div class='timeline-item-content'><p>未命中缓存时，具体路由向公开数据源请求内容，再统一成标题、链接、热度、更新时间和列表项等字段。</p></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol start="5"><li>决定输出格式</li></ol></div></div><div class='timeline-item-content'><p>默认返回 JSON；带 <code>rss=true</code> 时生成 RSS XML。<code>limit</code> 参数在响应前裁剪条目数。</p></div></div></div><h2 id="动态路由为什么比手写-40-次更稳">动态路由为什么比手写 40 次更稳</h2><p>上游的注册器扫描路由目录，为每个文件创建 <code>/&lt;route&gt;</code> 入口，并在请求时动态导入对应实现。逻辑可以压缩成下面这段伪代码：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> (<span class="keyword">const</span> route <span class="keyword">of</span> routeFiles) &#123;</span><br><span class="line">  app.<span class="title function_">get</span>(<span class="string">`/<span class="subst">$&#123;route&#125;</span>`</span>, <span class="title function_">async</span> (context) =&gt; &#123;</span><br><span class="line">    <span class="keyword">const</span> noCache = context.<span class="property">req</span>.<span class="title function_">query</span>(<span class="string">&quot;cache&quot;</span>) === <span class="string">&quot;false&quot;</span>;</span><br><span class="line">    <span class="keyword">const</span> limit = <span class="title class_">Number</span>(context.<span class="property">req</span>.<span class="title function_">query</span>(<span class="string">&quot;limit&quot;</span>));</span><br><span class="line">    <span class="keyword">const</span> rss = context.<span class="property">req</span>.<span class="title function_">query</span>(<span class="string">&quot;rss&quot;</span>) === <span class="string">&quot;true&quot;</span>;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">const</span> &#123; handleRoute &#125; = <span class="keyword">await</span> <span class="keyword">import</span>(<span class="string">`./routes/<span class="subst">$&#123;route&#125;</span>.js`</span>);</span><br><span class="line">    <span class="keyword">const</span> result = <span class="keyword">await</span> <span class="title function_">handleRoute</span>(context, noCache);</span><br><span class="line">    <span class="keyword">return</span> rss ? <span class="title function_">toRss</span>(result) : context.<span class="title function_">json</span>(<span class="title function_">trim</span>(result, limit));</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这样新增一个榜单通常只需新增一个路由文件，不必再修改总入口。<code>/all</code> 还能返回路由目录，前端或监控脚本不需要把列表硬编码到自己的版本里。</p><p>常用请求如下：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 查看可用接口</span></span><br><span class="line">curl --fail https://hotapi-ten.vercel.app/all</span><br><span class="line"></span><br><span class="line"><span class="comment"># 只取前 10 条</span></span><br><span class="line">curl --fail <span class="string">&quot;https://hotapi-ten.vercel.app/bilibili?limit=10&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 输出 RSS</span></span><br><span class="line">curl --fail <span class="string">&quot;https://hotapi-ten.vercel.app/zhihu?rss=true&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 绕过缓存，仅用于排障或人工刷新</span></span><br><span class="line">curl --fail <span class="string">&quot;https://hotapi-ten.vercel.app/36kr?cache=false&quot;</span></span><br></pre></td></tr></table></figure><div class="note danger flat"><p>不要把 <code>cache=false</code> 塞进公开前端的默认请求。那等于让每位访客都拿着小锤子敲上游站点，缓存当场表演“我装了，但没完全装”。</p></div><h2 id="缓存：Serverless-最容易被误解的一层">缓存：Serverless 最容易被误解的一层</h2><p>上游 2.0.6 默认使用 <code>NodeCache</code>，TTL 是 3600 秒。单进程常驻服务器上，理想情况下同一路由每小时只回源一次；但 Vercel 会冷启动、并发扩容，也可能同时存在多个 Function 实例。各实例内存不共享，因此更准确的近似是：</p><p><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><msub><mi>R</mi><mrow><mi>o</mi><mi>r</mi><mi>i</mi><mi>g</mi><mi>i</mi><mi>n</mi></mrow></msub><mo>≈</mo><mfrac><mrow><msub><mi>I</mi><mrow><mi>w</mi><mi>a</mi><mi>r</mi><mi>m</mi></mrow></msub><mo>+</mo><msub><mi>I</mi><mrow><mi>c</mi><mi>o</mi><mi>l</mi><mi>d</mi></mrow></msub></mrow><msub><mi>T</mi><mrow><mi>T</mi><mi>T</mi><mi>L</mi></mrow></msub></mfrac></mrow><annotation encoding="application/x-tex">R_{origin} \approx \frac{I_{warm} + I_{cold}}{T_{TTL}}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.9694em;vertical-align:-0.2861em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0077em;">R</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em;"><span style="top:-2.55em;margin-left:-0.0077em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em;">or</span><span class="mord mathnormal mtight">i</span><span class="mord mathnormal mtight" style="margin-right:0.0359em;">g</span><span class="mord mathnormal mtight">in</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2861em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">≈</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:2.1963em;vertical-align:-0.836em;"></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.3603em;"><span style="top:-2.314em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em;">T</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3283em;"><span style="top:-2.55em;margin-left:-0.1389em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.1389em;">T</span><span class="mord mathnormal mtight" style="margin-right:0.1389em;">T</span><span class="mord mathnormal mtight">L</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span><span style="top:-3.23em;"><span class="pstrut" style="height:3em;"></span><span class="frac-line" style="border-bottom-width:0.04em;"></span></span><span style="top:-3.677em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord"><span class="mord mathnormal" style="margin-right:0.0785em;">I</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em;"><span style="top:-2.55em;margin-left:-0.0785em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0269em;">w</span><span class="mord mathnormal mtight">a</span><span class="mord mathnormal mtight" style="margin-right:0.0278em;">r</span><span class="mord mathnormal mtight">m</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0785em;">I</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em;"><span style="top:-2.55em;margin-left:-0.0785em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">co</span><span class="mord mathnormal mtight" style="margin-right:0.0197em;">l</span><span class="mord mathnormal mtight">d</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.836em;"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span></span></span></span></span></p><p>其中 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msub><mi>I</mi><mrow><mi>w</mi><mi>a</mi><mi>r</mi><mi>m</mi></mrow></msub></mrow><annotation encoding="application/x-tex">I_{warm}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0785em;">I</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em;"><span style="top:-2.55em;margin-left:-0.0785em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0269em;">w</span><span class="mord mathnormal mtight">a</span><span class="mord mathnormal mtight" style="margin-right:0.0278em;">r</span><span class="mord mathnormal mtight">m</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span> 是窗口内实际承接请求的暖实例数，<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msub><mi>I</mi><mrow><mi>c</mi><mi>o</mi><mi>l</mi><mi>d</mi></mrow></msub></mrow><annotation encoding="application/x-tex">I_{cold}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0785em;">I</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em;"><span style="top:-2.55em;margin-left:-0.0785em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">co</span><span class="mord mathnormal mtight" style="margin-right:0.0197em;">l</span><span class="mord mathnormal mtight">d</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span> 是发生冷启动的实例数，<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msub><mi>T</mi><mrow><mi>T</mi><mi>T</mi><mi>L</mi></mrow></msub></mrow><annotation encoding="application/x-tex">T_{TTL}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em;">T</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3283em;"><span style="top:-2.55em;margin-left:-0.1389em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.1389em;">T</span><span class="mord mathnormal mtight" style="margin-right:0.1389em;">T</span><span class="mord mathnormal mtight">L</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span> 是缓存秒数。这个公式不是计费器，而是在提醒我们：进程内缓存只能减少单实例回源，不能承诺全局每小时一次。</p><p>如果流量上升，需要跨实例稳定缓存，应升级到支持 Redis 的上游版本并接入兼容服务，或者把适合缓存的响应交给 CDN；同时必须遵守数据源更新频率和使用条款，不能用“我有缓存”给高频抓取穿隐身衣。</p><h2 id="vercel-json-到底做了什么"><code>vercel.json</code> 到底做了什么</h2><p>仓库当前配置使用显式构建器：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;version&quot;</span><span class="punctuation">:</span> <span class="number">2</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;builds&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;src&quot;</span><span class="punctuation">:</span> <span class="string">&quot;index.js&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;use&quot;</span><span class="punctuation">:</span> <span class="string">&quot;@vercel/node&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;routes&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;src&quot;</span><span class="punctuation">:</span> <span class="string">&quot;/(.*)&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;dest&quot;</span><span class="punctuation">:</span> <span class="string">&quot;/&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>第一段把 <code>index.js</code> 编译为 Node.js Function；第二段把所有访问路径送进同一个 Hono 应用，让 <code>/all</code>、<code>/bilibili</code>、<code>/zhihu</code> 不必各自创建 Function 文件。Vercel 目前仍支持 <code>routes</code>，但官方建议普通重写和响应头优先使用更高层的 <code>rewrites</code>、<code>headers</code> 配置。这个仓库的显式 Function 适配属于需要先做回归验证再迁移的情况，不要只因为新语法看起来更潮就闭眼改配置。</p><h3 id="当前-CORS-配置需要收紧">当前 CORS 配置需要收紧</h3><p>原仓库在 Vercel 路由层同时声明了：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">Access-Control-Allow-Credentials: true</span><br><span class="line">Access-Control-Allow-Origin: *</span><br></pre></td></tr></table></figure><p>浏览器的凭据型 CORS 不接受通配来源；而热榜 API 本身通常只需要公开的 <code>GET</code> 与预检 <code>OPTIONS</code>，也没必要默认放行 <code>PATCH</code>、<code>DELETE</code>、<code>POST</code>、<code>PUT</code>。更稳妥的原则是二选一：</p><ol><li>公开只读 API：允许 <code>Origin: *</code>，移除 credentials，只开放 <code>GET, OPTIONS</code>；</li><li>私有前端 API：把 Origin 写成明确域名，保留 credentials，并补上 <code>Vary: Origin</code>。</li></ol><p>上游 Hono 应用本身已经有 CORS 中间件，部署层不应再发一套互相打架的头。正式上线前应统一到一个地方管理，并用浏览器预检请求验证，而不是让两层 CORS 像两位保安互相检查工作证。</p><!-- spoiler-1ffd9:black --><span class="spoiler" onclick="this.classList.toggle('spoiler')"><span class="spoiler-blur spoiler-1ffd9">看到“全放行最省事”时，请默念：省下来的配置时间，通常会变成未来的事故复盘时间。</span></span><h2 id="从-GitHub-部署到-Vercel">从 GitHub 部署到 Vercel</h2><p>下面给出一条可复现、可回滚的部署路线。仓库当前生产项目使用 Node.js 22；上游 2.0.6 要求 Node.js 20 或更高版本。</p><h3 id="1-拉取代码并锁定安装">1. 拉取代码并锁定安装</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">git <span class="built_in">clone</span> https://github.com/luomo66ccff/hotapi.git</span><br><span class="line"><span class="built_in">cd</span> hotapi</span><br><span class="line">node --version</span><br><span class="line">npm ci</span><br></pre></td></tr></table></figure><p>这里用 <code>npm ci</code>，不使用 <code>npm install</code>。前者严格读取 <code>package-lock.json</code>，确保部署复现 2.0.6；后者可能因为 <code>^2.0.6</code> 自动解析到新的兼容版本，让“重新部署”偷偷变成“顺便升级”。</p><h3 id="2-本地用-Vercel-运行时验证">2. 本地用 Vercel 运行时验证</h3><p>这个部署仓没有普通 <code>dev</code> 脚本，最接近生产路径的方式是：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npx vercel dev</span><br></pre></td></tr></table></figure><p>另开终端做四个冒烟测试：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">curl --fail http://localhost:3000/all</span><br><span class="line">curl --fail <span class="string">&quot;http://localhost:3000/bilibili?limit=3&quot;</span></span><br><span class="line">curl --fail <span class="string">&quot;http://localhost:3000/zhihu?rss=true&quot;</span></span><br><span class="line">curl -i -X POST http://localhost:3000/bilibili</span><br></pre></td></tr></table></figure><p>预期分别是：路由目录、三条 JSON 数据、XML 响应，以及不支持方法的 <code>405</code>。某个数据源失败不必立刻判定整站坏掉；先用 <code>/all</code> 区分运行时故障与单路由上游变化。</p><h3 id="3-导入-GitHub-仓库">3. 导入 GitHub 仓库</h3><p>在 Vercel 控制台选择 <strong>Add New → Project</strong>，导入 <code>luomo66ccff/hotapi</code>。设置如下：</p><table><thead><tr><th>项目</th><th>值</th></tr></thead><tbody><tr><td>Framework Preset</td><td>Other</td></tr><tr><td>Root Directory</td><td><code>.</code></td></tr><tr><td>Install Command</td><td><code>npm ci</code></td></tr><tr><td>Node.js Version</td><td>22.x</td></tr><tr><td>Build / Function config</td><td>读取仓库根目录 <code>vercel.json</code></td></tr><tr><td>Production Branch</td><td><code>main</code></td></tr></tbody></table><p>也可以用 CLI：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">npx vercel <span class="built_in">link</span></span><br><span class="line">npx vercel deploy</span><br><span class="line">npx vercel deploy --prod</span><br></pre></td></tr></table></figure><p>第一次先发布 Preview，完成接口检查后再发 Production。GitHub 连接建立后，分支或 Pull Request 生成预览部署，<code>main</code> 进入生产；这样依赖升级不会直接在正式接口上玩俄罗斯轮盘。</p><h3 id="4-环境变量建议">4. 环境变量建议</h3><p>默认配置可以启动，但公开服务建议显式记录这些值：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">CACHE_TTL=3600</span><br><span class="line">DISALLOW_ROBOT=true</span><br><span class="line">RSS_MODE=false</span><br><span class="line">USE_LOG_FILE=false</span><br><span class="line">ALLOWED_HOST=your-frontend.example</span><br><span class="line">ALLOWED_DOMAIN=https://your-frontend.example</span><br></pre></td></tr></table></figure><p><code>USE_LOG_FILE=false</code> 很重要：Vercel Function 的本地文件系统不是持久日志盘，运行日志应写到标准输出，再由 Vercel Logs 收集。</p><div class="note warning flat"><p>锁定的上游 2.0.6 有一个已修复的配置错误：<code>REQUEST_TIMEOUT</code> 实际误读了 <code>CACHE_TTL</code>。因此不要在 2.0.6 上假设 <code>REQUEST_TIMEOUT=6000</code> 已生效；先升级并验证到 2.0.8，再单独配置请求超时。</p></div><h3 id="5-发布后的验收">5. 发布后的验收</h3><div class="tabs" id="hotapi-check"><ul class="nav-tabs"><button type="button" class="tab  active" data-href="hotapi-check-1"><i class="fas fa-vial"></i>接口正确性</button><button type="button" class="tab " data-href="hotapi-check-2"><i class="fas fa-cloud"></i>平台状态</button><button type="button" class="tab " data-href="hotapi-check-3"><i class="fas fa-shield-halved"></i>浏览器 CORS</button></ul><div class="tab-contents"><div class="tab-item-content active" id="hotapi-check-1"><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">curl --fail https://your-project.vercel.app/all</span><br><span class="line">curl --fail <span class="string">&quot;https://your-project.vercel.app/bilibili?limit=3&quot;</span></span><br><span class="line">curl -I <span class="string">&quot;https://your-project.vercel.app/zhihu?rss=true&quot;</span></span><br></pre></td></tr></table></figure><p>确认 <code>/all</code> 的 <code>code</code> 为 200、<code>routes</code> 非空；榜单 <code>data</code> 是数组；RSS 的 <code>Content-Type</code> 是 XML。</p></div><div class="tab-item-content" id="hotapi-check-2"><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">npx vercel inspect https://your-project.vercel.app</span><br><span class="line">npx vercel logs https://your-project.vercel.app --since 1h</span><br></pre></td></tr></table></figure><p>确认目标是 production、状态是 Ready、Function 构建成功，并检查是否存在连续超时、上游 403 或模块缺失。</p></div><div class="tab-item-content" id="hotapi-check-3"><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">curl -i -X OPTIONS https://your-project.vercel.app/bilibili \</span><br><span class="line">  -H <span class="string">&quot;Origin: https://your-frontend.example&quot;</span> \</span><br><span class="line">  -H <span class="string">&quot;Access-Control-Request-Method: GET&quot;</span></span><br></pre></td></tr></table></figure><p>确认只返回预期 Origin 和方法；若使用 <code>*</code>，就不应同时允许 credentials。</p></div></div><div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="fas fa-arrow-up"></i></button></div></div><h3 id="6-绑定域名与回滚">6. 绑定域名与回滚</h3><p>在 Vercel 的 Domains 中添加 API 子域名，再按控制台给出的 DNS 目标配置。不要凭记忆硬写 CNAME；同一个域名若已经被其他项目占用，应先确认归属，避免为了一个热榜接口把主站 DNS 拔成盆栽。</p><p>每次更新前保留上一个 Ready 部署。新版本出现系统性 500 时，优先在 Vercel 控制台把别名切回上一份通过验收的生产部署，再分析依赖或数据源变化；不要在事故现场边改 <code>package-lock.json</code> 边许愿。</p><h2 id="依赖升级：把“每天检查”变成真正的门禁">依赖升级：把“每天检查”变成真正的门禁</h2><p>仓库已启用 Dependabot，每天检查 npm 依赖。但自动开 PR 不等于自动安全升级，尤其 <code>dailyhot-api</code> 同时包含路由、爬取器、缓存和页面解析逻辑。建议每个升级 PR 至少执行：</p><ol><li>锁文件差异审查，确认没有意外主版本跳跃；</li><li><code>npx vercel dev</code> 本地启动；</li><li><code>/all</code>、一个 JSON 路由、一个 RSS 路由和一个错误方法测试；</li><li>Preview 部署检查 Function 体积和冷启动；</li><li>CORS、缓存参数与 robots 行为回归；</li><li>只在 Preview 全绿后合并到 <code>main</code>。</li></ol><p>从 2.0.6 升到 2.0.8 时，建议把依赖改为精确版本，避免以后无审查漂移：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">npm install --save-exact dailyhot-api@2.0.8</span><br><span class="line">npx vercel dev</span><br></pre></td></tr></table></figure><p>验证完再提交 <code>package.json</code> 与 <code>package-lock.json</code>。若希望继续自动接收补丁版本，也可以保留 caret，但生产部署仍必须使用锁文件安装。</p><h2 id="公开-API-还缺哪些生产护栏">公开 API 还缺哪些生产护栏</h2><p>当前部署适合个人项目、开发测试和轻量聚合，但不能因为 Vercel 自动扩容就假装它无限抗打。</p><table><thead><tr><th>风险</th><th>表现</th><th>建议</th></tr></thead><tbody><tr><td>数据源变更</td><td>单路由 403、结构解析失败</td><td>独立监控重点路由，不把单源故障升级成全站宕机</td></tr><tr><td>无全局限流</td><td>热门接口被脚本集中调用</td><td>在边缘层增加限流、Bot 防护或调用配额</td></tr><tr><td>实例缓存不共享</td><td>并发实例重复回源</td><td>需要时接 Redis/CDN，并设置合理 TTL</td></tr><tr><td>宽松 CORS</td><td>任意站点借用接口、配置互相冲突</td><td>只读与凭据场景分开配置</td></tr><tr><td>抓取合规</td><td>数据源条款或结构变化</td><td>尊重站点规则、降低频率、保留停用单路由能力</td></tr><tr><td>依赖漂移</td><td>重部署行为突然变化</td><td><code>npm ci</code>、Preview、锁文件审查与可回滚部署</td></tr></tbody></table><details class="toggle" style="border: 1px solid #425b89"><summary class="toggle-button" style="background-color: #425b89;color: #ffffff">点开：接口异常时按这个顺序排查</summary><div class="toggle-content"><ol><li><code>/all</code> 也失败：检查 Vercel Function 构建、Node 版本、ESM 配置和入口是否被正确打包。</li><li><code>/all</code> 正常、某个榜单失败：大概率是该数据源接口、反爬或页面结构变化；查看对应上游路由的运行日志。</li><li>JSON 正常、RSS 失败：检查 <code>rss=true</code> 是否正确传递、响应是否包含无法生成 Feed 的异常字段。</li><li>首次请求慢、随后恢复：区分冷启动与上游超时；不要先把 TTL 调成一年假装世界和平。</li><li>浏览器报 CORS、命令行正常：检查响应里是否同时出现多套 <code>Access-Control-*</code>，并修复 wildcard + credentials 冲突。</li><li>更新依赖后全部 500：回滚上一份 Ready 部署，再比较 Node 要求、导出方式、锁文件与 <code>vercel.json</code>。</li></ol></div></details><h2 id="最后的复盘">最后的复盘</h2><p>HotAPI 最值得复用的不是那句 <code>serveHotApi()</code>，而是“核心能力做成包，平台差异留在薄适配层”的思路。业务路由不必知道自己跑在 Vercel，部署仓也不复制 40 份抓取实现；两边通过版本、入口和 HTTP 契约连接。</p><p>但薄并不等于随便。真正决定它能否长期运行的是锁文件、Serverless 缓存认知、CORS 边界、Preview 门禁、日志和回滚。把这些补齐，三行代码可以是一座稳稳的桥；忽略它们，三行代码也能是一块写着“此路不通”的电子牌。</p><a class="btn-beautify blue center larger" href="https://github.com/luomo66ccff/hotapi"   title="从部署仓开始复现"><i class="fab fa-github"></i><span>从部署仓开始复现</span></a>]]>
    </content>
    <id>https://blog.luomo.moe/posts/9f7e31ac.html</id>
    <link href="https://blog.luomo.moe/posts/9f7e31ac.html"/>
    <published>2026-08-04T14:10:00.000Z</published>
    <summary>从三行入口、ESM 依赖、动态路由与进程内缓存，到 Vercel Node.js Function、GitHub 持续部署、CORS 加固和版本升级，完整拆解 HotAPI 的部署适配路径。</summary>
    <title>把一个 npm 包变成热榜 API：HotAPI 的 Vercel Serverless 技术路径与部署实战</title>
    <updated>2026-08-04T14:10:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>洛墨-天染-依然</name>
    </author>
    <category term="技术实践" scheme="https://blog.luomo.moe/categories/%E6%8A%80%E6%9C%AF%E5%AE%9E%E8%B7%B5/"/>
    <category term="Next.js" scheme="https://blog.luomo.moe/tags/Next-js/"/>
    <category term="React" scheme="https://blog.luomo.moe/tags/React/"/>
    <category term="TypeScript" scheme="https://blog.luomo.moe/tags/TypeScript/"/>
    <category term="Zustand" scheme="https://blog.luomo.moe/tags/Zustand/"/>
    <category term="Playwright" scheme="https://blog.luomo.moe/tags/Playwright/"/>
    <category term="Docker" scheme="https://blog.luomo.moe/tags/Docker/"/>
    <category term="Cloudflare" scheme="https://blog.luomo.moe/tags/Cloudflare/"/>
    <content>
      <![CDATA[<link rel="stylesheet" type="text&#x2F;css" href="https://cdn.jsdelivr.net/npm/hexo-tag-hint@0.3.1/dist/hexo-tag-hint.min.css"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="/assets/css/APlayer.min.css"><script src="/assets/js/APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="/assets/js/Meting.min.js"></script><p>我把自己做过的公开项目重新过了一遍，最后选择详细拆解 <ruby>雾港档案<rt>Fog Harbor Archive</rt></ruby>。理由并不是它的技术名词最多，而是它把一个原创题材从“能点的网页”做成了完整产品：有 11 个调查模块、23 份证物、4 组谜题、3 个结局、二周目变化、跨设备布局、自动化测试和可重复部署链路。它最能说明一件事：沉浸感不是靠全屏特效糊出来的，而是叙事、状态、交互和工程约束一起咬合的结果。</p><mark class="hl-label purple">原创叙事</mark>  <mark class="hl-label blue">本地存档</mark>  <mark class="hl-label green">完整E2E</mark>  <mark class="hl-label orange">已上线</mark> <a class="btn-beautify blue larger" href="https://fog-harbor-archive.luomo.moe"   title="进入雾港调查室"><i class="fas fa-play"></i><span>进入雾港调查室</span></a><a class="btn-beautify purple larger" href="https://github.com/luomo66ccff/fog-harbor-archive"   title="查看 GitHub 源码"><i class="fab fa-github"></i><span>查看 GitHub 源码</span></a><div class="note info flat"><p>本文只谈工程，不剧透案件答案。核心真相会继续躺在雾里，绝不被教程一铲子挖出来。</p></div><span id="more"></span><h2 id="先看成品：同一案件，两套交互密度">先看成品：同一案件，两套交互密度</h2><div class="gallery-container" data-type="data" data-button="true">      <div class="gallery-data">[{"url":"/images/fog-harbor/desktop-investigation.webp","alt":"桌面端调查工作区：多窗口、任务栏与案件材料并存","title":"桌面端调查工作区"},{"url":"/images/fog-harbor/mobile-investigation.webp","alt":"移动端调查界面：内容重排为单列阅读与操作","title":"移动端调查界面"},{"url":"/images/fog-harbor/investigator-index.webp","alt":"调查员索引：二周目和隐藏线索不会硬塞进第一次流程","title":"调查员索引"}]</div>      <div class="gallery-items">      </div>    </div><p>桌面端不是普通网页套一层“窗口皮肤”：窗口有打开、最小化、聚焦、层级和位置；移动端也不是把桌面缩成邮票，而是换成更适合触控的导航与阅读顺序。两端共享案件状态，但各自拥有符合设备的交互密度。</p><h2 id="为什么这类项目真正难做">为什么这类项目真正难做</h2><p>一个线性故事只需要回答“下一段是什么”。调查游戏却同时要回答：玩家看过什么、证物是否被关联、谜题是否解开、当前任务能否推进、二周目是否开启、窗口该出现在什么位置，以及异常存档是否会把进度机炸成烟花。</p><p>因此我没有先堆页面，而是把项目拆成四种不同寿命的状态：</p><table><thead><tr><th>状态</th><th>例子</th><th>保存位置</th><th>设计目的</th></tr></thead><tbody><tr><td>案件事实</td><td>证物、人物、文档、消息、时间线</td><td>TypeScript 静态数据</td><td>可审查、可测试、不会被 UI 改写</td></tr><tr><td>长期进度</td><td>已解锁证物、谜题、结局、二周目</td><td><code>localStorage</code></td><td>刷新或下次访问后继续调查</td></tr><tr><td>会话状态</td><td>彩蛋触发、临时提示</td><td><code>sessionStorage</code></td><td>关闭标签页后自然重置</td></tr><tr><td>界面状态</td><td>窗口位置、层级、最小化、待处理意图</td><td>Zustand 内存状态</td><td>快速响应，不污染案件数据</td></tr></tbody></table><div class="note warning flat"><p>最重要的边界是：窗口开没开，不等于证据解没解锁；动画播没播，也不等于剧情已经推进。把 UI 状态和领域状态混在一起，后面一定会出现“关个窗口把结局关没了”的赛博灵异事件。</p></div><h2 id="技术路线：先建立规则，再让界面长出来">技术路线：先建立规则，再让界面长出来</h2><div class="timeline blue"><div class='timeline-item headline'><div class='timeline-item-title'><div class='item-circle'><p>从想法到可部署产品</p></div></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol><li>把叙事写成可查询的数据</li></ol></div></div><div class='timeline-item-content'><p>人物、证物、文档、消息、音频转写、谜题、任务、时间线和结局先获得稳定 ID。组件只消费数据，不在 JSX 里偷偷决定案件真相。</p></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol start="2"><li>把推进规则抽成纯逻辑</li></ol></div></div><div class='timeline-item-content'><p>证据解锁、谜题判定、任务推进、结局条件、二周目变化和调查日志分别进入 <code>lib/</code>。它们可以脱离浏览器做单元测试。</p></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol start="3"><li>用两个 Store 管理两种世界</li></ol></div></div><div class='timeline-item-content'><p><code>case-store</code> 负责案件进度、迁移与持久化；<code>window-store</code> 负责窗口系统、层级和会话彩蛋。两者通过明确动作协作，不互相偷改内部字段。</p></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol start="4"><li>再实现桌面与移动交互</li></ol></div></div><div class='timeline-item-content'><p>桌面端组合窗口管理器、任务栏和叙事层；移动端重排内容和入口。动效遵守 reduced-motion，音频不可用时仍提供文本转写。</p></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol start="5"><li>用自动化测试钉住关键旅程</li></ol></div></div><div class='timeline-item-content'><p>CI 依次执行 lint、TypeScript 检查、构建、Node 单元测试、依赖审计和 Playwright；桌面、移动、二周目与彩蛋流程都进入浏览器测试。</p></div></div><div class='timeline-item'><div class='timeline-item-title'><div class='item-circle'><ol start="6"><li>用不可公开暴露的容器端口上线</li></ol></div></div><div class='timeline-item-content'><p>生产构建输出 Next.js standalone，由非 root 容器运行；Cloudflare Tunnel 在同一 Compose 网络中接入，宿主机只监听 <code>127.0.0.1:8797</code>。</p></div></div></div><h2 id="架构全景">架构全景</h2><div class="mermaid-wrap"><pre class="mermaid-src" hidden>  flowchart TB  U[&quot;调查员 &#x2F; 浏览器&quot;] --&gt; C[&quot;GameClient&quot;]  C --&gt; P[&quot;Providers&quot;]  P --&gt; CS[&quot;case-store&quot;]  P --&gt; WS[&quot;window-store&quot;]  CS --&gt; E[&quot;推进与谜题引擎&quot;]  WS --&gt; W[&quot;桌面窗口系统&quot;]  E --&gt; D[&quot;案件静态数据&quot;]  CS --&gt; LS[&quot;localStorage\nfog-harbor-save-v1&quot;]  WS --&gt; SS[&quot;sessionStorage\nfog-harbor-easter-session-v1&quot;]  C --&gt; N[&quot;叙事层 &#x2F; 过场 &#x2F; 环境事件&quot;]  C --&gt; M[&quot;桌面组件 &#x2F; 移动组件 &#x2F; 谜题组件&quot;]  </pre></div><p>代码按 Next.js App Router 组织，React 与 TypeScript 承担视图和类型边界，Tailwind CSS 负责视觉系统，Framer Motion 处理过场与微动效，Zustand 管状态。默认开发/构建命令由 Vinext 驱动；服务器部署则在 Docker 构建阶段显式执行 <code>next build</code>，并通过环境变量启用 Next.js 的 <code>standalone</code> 输出。这样，日常开发目标和自托管运行时互不冒充。</p><h2 id="路径一：叙事数据要“可计算”">路径一：叙事数据要“可计算”</h2><p>调查游戏里的文案并不只是字符串。每份证物至少需要稳定 ID、来源、显示条件和关联关系；任务需要前置条件；结局需要一组可以解释的判定。稳定 ID 是这套系统的地基，因为存档、测试和 UI 都只应该引用 ID，而不是复制整份对象。</p><p>时间谜题也应先成为规则。例如比较实体钟与系统记录时，可以把核心偏差写成：</p><p><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi mathvariant="normal">Δ</mi><mi>t</mi><mo>=</mo><msub><mi>t</mi><mtext>physical</mtext></msub><mo>−</mo><msub><mi>t</mi><mtext>system</mtext></msub></mrow><annotation encoding="application/x-tex">\Delta t = t_{\text{physical}} - t_{\text{system}}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6833em;"></span><span class="mord">Δ</span><span class="mord mathnormal">t</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.9012em;vertical-align:-0.2861em;"></span><span class="mord"><span class="mord mathnormal">t</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord text mtight"><span class="mord mtight">physical</span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2861em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.9012em;vertical-align:-0.2861em;"></span><span class="mord"><span class="mord mathnormal">t</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord text mtight"><span class="mord mtight">system</span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2861em;"><span></span></span></span></span></span></span></span></span></span></span></p><p>界面负责收集输入和展示反馈，谜题引擎负责标准化输入、计算 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi mathvariant="normal">Δ</mi><mi>t</mi></mrow><annotation encoding="application/x-tex">\Delta t</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6833em;"></span><span class="mord">Δ</span><span class="mord mathnormal">t</span></span></span></span>、判断容差并返回结果。这样同一条规则可以被桌面窗口、移动页面和单元测试复用。</p><!-- spoiler-1ffd9:black --><span class="spoiler" onclick="this.classList.toggle('spoiler')"><span class="spoiler-blur spoiler-1ffd9">案件答案当然不会写在公式下面。想套答案的侦探请收起你那伸向 F12 的小手。</span></span><h2 id="路径二：存档不是-JSON-parse-完就下班">路径二：存档不是 <code>JSON.parse</code> 完就下班</h2><p>长期状态保存在 <code>fog-harbor-save-v1</code>。读取时不直接相信浏览器里的 JSON，而是执行迁移和清洗：旧字段补默认值，集合按合法 ID 白名单过滤，枚举落到允许范围，计数和布尔值做类型归一化。损坏或被手工篡改的存档最多丢失异常字段，不能让整个应用在启动阶段白屏。</p><p>推荐把持久化层看成一个版本化协议：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> <span class="title class_">PersistedCaseState</span> = &#123;</span><br><span class="line">  <span class="attr">version</span>: <span class="built_in">number</span>;</span><br><span class="line">  <span class="attr">unlockedEvidenceIds</span>: <span class="built_in">string</span>[];</span><br><span class="line">  <span class="attr">solvedPuzzleIds</span>: <span class="built_in">string</span>[];</span><br><span class="line">  <span class="attr">unlockedEndingIds</span>: <span class="built_in">string</span>[];</span><br><span class="line">  <span class="attr">secondRun</span>: <span class="built_in">boolean</span>;</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure><p>每次升级只从“磁盘里的未知数据”迁移到“当前内存模型”，不要让 UI 组件各自兼容历史版本。迁移完成后再由 Store 暴露稳定动作，组件只调用 <code>unlockEvidence()</code>、<code>solvePuzzle()</code> 一类的领域操作。</p><p>调查日志导出也遵循最小化原则：只包含玩家主动形成的调查记录，不夹带身份、网络或设备信息。一个离线单机叙事项目，没必要突然 cosplay 数据经纪人。</p><h2 id="路径三：桌面窗口系统的关键不是拖拽">路径三：桌面窗口系统的关键不是拖拽</h2><p>窗口管理真正麻烦的是一致性：打开已存在窗口时要聚焦而非复制；最小化后任务栏状态要同步；点击窗口要提升 z-index；视口变化后位置不能漂到屏幕外；剧情发出的“打开某文件”意图要等目标窗口准备好后消费。</p><p><code>window-store</code> 因此维护窗口实体、焦点、层级、位置和 pending intent。组件发动作，Store 完成原子更新，最终由视图渲染。移动端则绕开自由窗口布局，直接把相同内容投影到单列导航中。共享的是领域信息，不是桌面坐标。</p><p>动效还要有降级路径。系统开启 <code>prefers-reduced-motion</code> 时，九段短过场切换到静态表达；音频失败时保留转写；环境彩蛋可以增强氛围，但绝不能成为推进主线的唯一入口。</p><h2 id="路径四：测试一条“玩家真的会走”的路">路径四：测试一条“玩家真的会走”的路</h2><p>这个项目的 CI 不是只跑一次构建。GitHub Actions 使用 Node.js 22，并按下面的顺序验证：</p><ol><li><code>npm ci</code> 保证锁文件安装可复现；</li><li>ESLint 与 <code>tsc --noEmit</code> 守住静态边界；</li><li>Vinext 构建确认默认目标可产出；</li><li>Node 原生测试覆盖推进、谜题、结局和状态清洗等纯逻辑；</li><li><code>npm audit</code> 对生产依赖的 high 和全依赖的 critical 风险设门禁；</li><li>Playwright 安装 Chromium，跑桌面、移动端、二周目和彩蛋旅程；</li><li>失败时上传截图、视频、trace 和报告，避免只留一句“元素不存在”让人对着空气破案。</li></ol><div class="note success flat"><p>测试分层的收益是定位速度：纯规则错了看单元测试，交互旅程断了看 Playwright，部署运行时错了看容器健康检查。三类问题不会在同一个红灯里抱团取暖。</p></div><h2 id="部署教程：Docker-Cloudflare-Tunnel">部署教程：Docker + Cloudflare Tunnel</h2><p>下面是仓库当前真实使用的生产路径。它不要求在公网开放应用端口，适合已有 Linux 服务器和 Cloudflare 托管域名的场景。</p><h3 id="1-准备环境">1. 准备环境</h3><ul><li>一台安装 Docker Engine 与 Docker Compose v2 的 Linux 主机；</li><li>一个 Cloudflare Zero Trust 账户与已创建的 remotely-managed Tunnel；</li><li>域名已接入 Cloudflare；</li><li>Git，用于拉取和更新代码。</li></ul><div class="tabs" id="fog-harbor-setup"><ul class="nav-tabs"><button type="button" class="tab  active" data-href="fog-harbor-setup-1"><i class="fas fa-laptop-code"></i>本地开发</button><button type="button" class="tab " data-href="fog-harbor-setup-2"><i class="fas fa-server"></i>生产服务器</button></ul><div class="tab-contents"><div class="tab-item-content active" id="fog-harbor-setup-1"><p>本地开发需要 Node.js 22.13 或更高版本：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">git <span class="built_in">clone</span> https://github.com/luomo66ccff/fog-harbor-archive.git</span><br><span class="line"><span class="built_in">cd</span> fog-harbor-archive</span><br><span class="line">npm ci</span><br><span class="line">npm run dev</span><br></pre></td></tr></table></figure><p>提交前建议执行完整验证：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm run <span class="built_in">test</span>:all</span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="fog-harbor-setup-2"><p>服务器只需要 Git、Docker 与 Compose：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">git <span class="built_in">clone</span> https://github.com/luomo66ccff/fog-harbor-archive.git</span><br><span class="line"><span class="built_in">cd</span> fog-harbor-archive</span><br></pre></td></tr></table></figure><p>不要在服务器全局安装 Node。依赖安装、构建和运行都在版本固定的 <code>node:22-alpine</code> 镜像里完成。</p></div></div><div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="fas fa-arrow-up"></i></button></div></div><h3 id="2-配置-Tunnel-Token">2. 配置 Tunnel Token</h3><p>在 Cloudflare Zero Trust 后台创建 Tunnel，把公开主机名指向 <code>http://app:3000</code>。<code>app</code> 是 Compose 服务名，cloudflared 与它处于同一私有网络，因此无需绕到宿主机公网端口。</p><p>在项目根目录创建 <code>.env.server</code>：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">TUNNEL_TOKEN=粘贴你的_Remotely_Managed_Tunnel_Token</span><br></pre></td></tr></table></figure><p>这个文件已经被 Git 忽略。不要把 Token 写进 <code>compose.server.yaml</code>，更不要截图发群里表演“一键共享基础设施”。</p><p>如果你使用自己的域名，还要把 <code>compose.server.yaml</code> 中的构建参数改为实际地址：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">build:</span></span><br><span class="line">  <span class="attr">args:</span></span><br><span class="line">    <span class="attr">NEXT_PUBLIC_SITE_URL:</span> <span class="string">https://your-domain.example</span></span><br></pre></td></tr></table></figure><h3 id="3-构建并启动">3. 构建并启动</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">docker compose -f compose.server.yaml --env-file .env.server up -d --build</span><br></pre></td></tr></table></figure><p>构建采用三阶段镜像：<code>dependencies</code> 通过 <code>npm ci</code> 安装锁定依赖；<code>builder</code> 设置 <code>FOG_HARBOR_SERVER_BUILD=1</code> 并执行 <code>npx next build</code>；<code>runner</code> 只复制 <code>.next/standalone</code>、静态文件和 <code>public</code>，最后以非 root 的 <code>nextjs</code> 用户运行 <code>server.js</code>。</p><h3 id="4-验证健康状态">4. 验证健康状态</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">docker compose -f compose.server.yaml --env-file .env.server ps</span><br><span class="line">curl --fail http://127.0.0.1:8797/</span><br><span class="line">docker compose -f compose.server.yaml --env-file .env.server logs --<span class="built_in">tail</span>=100 app tunnel</span><br></pre></td></tr></table></figure><p><code>app</code> 的健康检查每 15 秒请求容器内的 <code>127.0.0.1:3000</code>。只有它进入 healthy，<code>tunnel</code> 才会启动。宿主机映射为 <code>127.0.0.1:8797:3000</code>，所以 8797 只允许本机访问；公网流量必须经过 Tunnel。</p><h3 id="5-安全与运维检查">5. 安全与运维检查</h3><p>两个服务都启用了 <code>no-new-privileges</code> 并丢弃全部 Linux capabilities。应用容器不以 root 运行，Tunnel Token 位于未提交的环境文件，服务端也没有数据库和玩家账号。上线前仍应确认服务器防火墙没有额外放行 3000 或 8797。</p><p>更新代码时使用：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">git pull --ff-only</span><br><span class="line">docker compose -f compose.server.yaml --env-file .env.server up -d --build</span><br><span class="line">docker image prune -f</span><br></pre></td></tr></table></figure><p>生产回滚最好依赖已验证 Git 标签。切到上一个稳定标签后重新构建，而不是手改正在运行的容器：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">git switch --detach v2.2.0</span><br><span class="line">docker compose -f compose.server.yaml --env-file .env.server up -d --build</span><br></pre></td></tr></table></figure><details class="toggle" style="border: 1px solid #425b89"><summary class="toggle-button" style="background-color: #425b89;color: #ffffff">点开：部署失败时按这个顺序排查</summary><div class="toggle-content"><ol><li><code>app</code> 构建失败：先看 Docker 构建输出，确认锁文件未漂移、Node 版本仍为 22、<code>next build</code> 没有类型或静态生成错误。</li><li><code>app</code> 一直 unhealthy：进入日志检查 <code>server.js</code> 是否启动，确认容器端口仍是 3000，健康检查 URL 没被重定向到不可达地址。</li><li><code>tunnel</code> 退出：检查 <code>.env.server</code> 是否存在、Token 是否来自当前 Tunnel、容器时间与服务器 DNS 是否正常。</li><li>域名显示 502：确认 Zero Trust 的 Service URL 是 <code>http://app:3000</code>，不是宿主机的 <code>localhost:8797</code>；容器里的 localhost 只指向它自己。</li><li>页面能开但旧资源未更新：检查浏览器缓存和 Cloudflare 缓存，再确认构建参数里的站点 URL 是否还是旧域名。</li></ol></div></details><h2 id="这条路线为什么适合它">这条路线为什么适合它</h2><p>这个项目的核心状态全部在浏览器本地，服务器只负责交付应用。Docker standalone 控制运行时体积和版本，Cloudflare Tunnel 解决入口、TLS 与隐藏源站端口，Zustand 持久化让故事不依赖数据库。每层只解决自己的问题，部署路径就不会为了“看起来像大厂”凭空养出一套用户系统、消息队列和三台吃灰的数据库。</p><p>它也保留了继续扩展的空间：若未来需要云存档，可以在现有持久化协议外增加同步适配层；若需要更多案件，可以复用引擎与窗口系统，仅替换数据包和主题组件；如果要把客户端交付到其他运行时，领域规则和测试仍然可以保留。</p><h2 id="最后的复盘">最后的复盘</h2><p>《雾港档案》最值得复用的并不是某个组件，而是这条顺序：先把事实做成数据，把推进做成纯规则，把长期状态和界面状态拆开，再让桌面、移动端、动效和部署围绕同一组边界生长。顺序对了，特效是放大器；顺序错了，特效就是案发现场的烟雾弹。</p><p>如果你准备做自己的互动叙事项目，可以先只实现一个人物、三份证物、一个谜题和一个结局，但从第一天就给它们稳定 ID、迁移策略和一条自动化玩家旅程。小规模的正确结构，比大规模的临时奇迹更容易走到上线。</p><a class="btn-beautify blue center larger" href="https://fog-harbor-archive.luomo.moe"   title="现在开始调查"><i class="fas fa-magnifying-glass"></i><span>现在开始调查</span></a>]]>
    </content>
    <id>https://blog.luomo.moe/posts/7c2a4f61.html</id>
    <link href="https://blog.luomo.moe/posts/7c2a4f61.html"/>
    <published>2026-08-04T10:30:00.000Z</published>
    <summary>从叙事数据建模、桌面式交互、持久化迁移和自动化测试，到 Docker 与 Cloudflare Tunnel 上线，完整拆解《雾港档案：失踪的第七码头》的工程路径。</summary>
    <title>把网页做成一间会记住你的调查室：雾港档案 2.2 技术路径与部署实战</title>
    <updated>2026-08-04T10:30:00.000Z</updated>
  </entry>
</feed>
