Nuxt SSG 博客的尾斜杠到底怎么加?
<p>本站是用 Nuxt v4 + Nuxt Content v3 + i18n 搭出来的纯 SSG 博客。开站时随手定了一个看似无关紧要的策略——<strong>所有页面 URL 以 <code>/</code> 结尾</strong>。</p> <p>听起来一行配置就该完事的事,做下来才发现 Nuxt 在尾斜杠这件事上<strong>至今没有一个统一的官方开关</strong>(<a href="https://github.com/nuxt/nuxt/issues/15462">nuxt/nuxt#15462</a> 这个 issue 从 2022 年挂到现在),整套策略最后是靠<strong>六个不同层面</strong>拼出来的。这篇就把站点里所有跟 trailing slash 相关的配置完整盘一遍,留作给自己和后人的备忘。</p> <h2>为什么要尾斜杠</h2> <p>简单提一句动机:</p> <ul> <li>同一篇文章 <code>/2026/05/28/foo</code> 和 <code>/2026/05/28/foo/</code> 在搜索引擎眼里<strong>理论上是两个 URL</strong>,要么你给一个 canonical,要么干脆只允许一种形态;</li> <li>SSG 产物里"目录形态"更自然——<code>about/index.html</code> 比 <code>about.html</code> 更便于嵌套子页面、也更符合直觉;</li> <li>风格上,我个人更喜欢看 URL 末尾那个斜杠。</li> <li>在我的博客重构前使用的 hexo 框架就是这样的,我希望保留原有 URL 不变。</li> </ul> <p>确定了"全部带斜杠"这个目标,下面要做的事就是<strong>让站点的每一个发出 URL 的地方、每一个接收 URL 的地方、每一个用 URL 做 key 的地方都遵守这条约定</strong>。</p> <h2>Layer 1:SEO 层</h2> <p>最先想到的是 SEO,所以 <code>@nuxtjs/seo</code> 的配置里:</p> <pre><code class="language-ts">// nuxt.config.ts site: { trailingSlash: true, } </code></pre> <p>这个开关<strong>只影响</strong> canonical link、sitemap.xml、robots.txt、OpenGraph URL 等 SEO 模块生成的 URL。它不会改你页面里实际渲染出来的 <code><a href></code>,也不会拦截入站请求。但既然它是"对外发布我自己的 URL 形态",配上就对了。</p> <h2>Layer 2:出站链接</h2> <p>第二层是页面里 <code><NuxtLink></code> 渲染出来的 <code>href</code>。Nuxt 4 提供了 experimental 配置:</p> <pre><code class="language-ts">// nuxt.config.ts experimental: { defaults: { nuxtLink: { trailingSlash: 'append' } } } </code></pre> <p>打开以后,全站任何 <code><NuxtLink to="/about"></code> 渲染出来都是 <code>href="/about/"</code>,<strong>不管你 <code>to</code> 写没写斜杠</strong>。</p> <p>需要注意的边界:</p> <ul> <li> <p>这个配置<strong>不影响</strong> <code>router.push('/about')</code> 这种代码侧的导航,所以代码里手动 push 时还得自己拼。本站基本走 <code>localePath()</code> + <code>NuxtLinkLocale</code>,绕过了这个雷区。</p> </li> <li> <p><code>localePath('/tags')</code> 拼出来的 <code>/tags</code>,外面再手动 <code>+ '/' + tagName</code> 的话,最后一段不带 <code>/</code>,<strong>但</strong> NuxtLink 的 <code>'append'</code> 会兜底再补一次。比如:</p> <pre><code class="language-vue"><NuxtLink :to="`${localePath('/tags')}/${encodeURIComponent(tag)}`"> </code></pre> <p>实际渲染出来的 href 是 <code>/tags/Foo/</code>。</p> </li> </ul> <h2>Layer 3:硬编码的链接</h2> <p>虽然有了 <code>'append'</code> 兜底,但项目里还是把所有硬编码的链接都直接写成带斜杠的形式,作为第二道防线:</p> <pre><code class="language-vue"><!-- FooterContent.vue --> <NuxtLinkLocale to="/donate/" aria-label="Donate"> </code></pre> <pre><code class="language-ts">// Pagination.vue function getPageUrl(page: number) { if (page < 1 || page > props.totalPages) return '#' return page === 1 ? `${props.urlPrefix}/` : `${props.urlPrefix}/page/${page}/` } </code></pre> <p>养成这个习惯有个好处:将来如果 Nuxt 把 <code>experimental.defaults.nuxtLink.trailingSlash</code> 又改名了或者拿掉了(experimental API 嘛,懂的都懂),站点也不会因为这个一夜暴毙。</p> <h2>Layer 4:Prerender 产物落地形态</h2> <p>SSG 阶段是 Nitro 在干活。它有个<strong>默认开启</strong>的配置叫 <code>prerender.autoSubfolderIndex</code>——会把 prerender 出来的每个页面落到 <code><path>/index.html</code>,而不是 <code><path>.html</code>。</p> <pre><code>.output/public/ ├── about/ │ ├── index.html │ └── _payload.json ├── 2026/ │ └── 05/ │ └── 28/ │ └── nuxt-ssg-trailing-slash-hydration-trap/ │ ├── index.html │ └── _payload.json └── ... </code></pre> <p>这一步意味着,无论是 Vercel 这种 serverless 平台,还是 Nginx / Caddy,请求 <code>/about</code> 和 <code>/about/</code> 两种形态,<strong>静态文件服务器都能 fallback 到同一份 <code>about/index.html</code></strong>——所以"用户输错斜杠也能开页"这件事根本不需要应用层兜底。</p> <blockquote> <p>顺便:本站还显式列了几条 prerender route:</p> <pre><code class="language-ts">nitro: { prerender: { routes: [ '/rss.xml', '/en/rss.xml', '/search/sections.json', '/tags/Vue.js', // 带 . 的标签页,crawler 不会自动跟进 ...Object.keys(blogConfig.redirects) ] } } </code></pre> <p>这些是 crawler 抓不到、必须显式喂的,跟尾斜杠没直接关系,但放在这里作为完整的 nitro 配置一并列出。</p> </blockquote> <h2>Layer 5:入站 URL 规范化(纯前端)</h2> <p>到这里 SEO、出站链接、产物落地都齐了,但<strong>有一类场景还没覆盖</strong>——用户手敲一个没斜杠的 URL(或者外部跳转过来),地址栏里挂着 <code>/about</code>,需要不需要把它改写成 <code>/about/</code>?</p> <p>经典做法是 HTTP 301。但本站是<strong>双平台部署(Vercel + Caddy)</strong>,301 就得两份规则,能避免就避免。而且 Vercel 是把 SSG 产物放在 CDN 上的,301 写在 <code>vercel.json</code> 里也算半个绑定方案,不够纯粹。</p> <p>所以这一层走<strong>纯前端</strong>:一个全局 client middleware。</p> <pre><code class="language-ts">// app/middleware/trailing-slash.global.ts export default defineNuxtRouteMiddleware((to) => { if (import.meta.server) return if (to.path === '/' || to.path.endsWith('/')) return // 跳过 favicon.ico、rss.xml 这类带后缀的资源路径 const lastSegment = to.path.slice(to.path.lastIndexOf('/') + 1) if (lastSegment.includes('.')) return return navigateTo( { path: to.path + '/', query: to.query, hash: to.hash }, { replace: true } ) }) </code></pre> <p>几个细节:</p> <ul> <li><strong><code>import.meta.server</code> 直接 return。</strong> SSG prerender 阶段 Nitro 自己已经归一化了;如果在 server 端再 <code>navigateTo</code>,可能在产物里写出非预期的 30x 跳转。</li> <li><strong><code>replace: true</code></strong> 让浏览器<strong>替换</strong>当前 history 条目,不会留一条"刚刚那个没斜杠的版本"的返回栈。</li> <li><strong>排除带 <code>.</code> 的路径</strong>,避免误把静态资源也加上斜杠。</li> </ul> <p>这层做完后,全链路 0 个 HTTP 301,配置上也不绑任何一家部署平台。</p> <h2>Layer 6:<code>useAsyncData</code> 的 key(隐藏的雷区)</h2> <p>前五层做完,URL 的形态已经全部规范化,但还有一层非常隐蔽的地方需要照顾——<strong><code>useAsyncData</code> 的 key</strong>。</p> <p>很容易写出这种代码:</p> <pre><code class="language-ts">const { data } = useAsyncData( `randomIndex${route.path}`, // ← 雷 async () => ... ) </code></pre> <p>问题是 <code>route.path</code> 在 SSR / client / prerender / SPA 导航这四种上下文里<strong>不一定一致</strong>。一旦 key 在 SSR 时算出 <code>randomIndex/about</code>、客户端水合时算出 <code>randomIndex/about/</code>,payload 命中失败,整个 <code>useAsyncData</code> 在客户端会<strong>重跑一遍</strong>,对应组件直接退化成 CSR。</p> <p>本站的处理是:<strong>所有 <code>useAsyncData</code> 的 key 都不沾 <code>route.path</code></strong>,要带路由信息就用 <code>route.name</code> + <code>route.params</code>:</p> <pre><code class="language-ts">const route = useRoute() const routeKey = `${String(route.name ?? 'unknown')}-${JSON.stringify(route.params)}` const { data: randomIndex } = useAsyncData( `randomIndex-${routeKey}`, async () => Math.floor(Math.random() * appConfig.appearance.backgrounds.length) ) </code></pre> <p><code>route.name</code> 是 vue-router 内部的路由名(i18n 自动生成的形如 <code>about___zh</code>),<code>route.params</code> 是动态段,<strong>两者在任何上下文都一致</strong>。最终构建出来的 <code>_payload.json</code> key 形如 <code>randomIndex-about___zh-{}</code>,跟尾斜杠完全脱钩。</p> <h2>整体回顾</h2> <p>整个站点的尾斜杠策略可以一句话总结:</p> <blockquote> <p>Prerender 时让 Nitro 落到 <code>xxx/index.html</code>,SEO 由 <code>site.trailingSlash</code> 负责对外发布形态,出站链接由 <code>nuxtLink.trailingSlash: 'append'</code> 自动补斜杠(+ 硬编码做第二道防线),入站直链由全局 client middleware 兜底,<code>useAsyncData</code> 的 key 一律不依赖 <code>route.path</code> —— <strong>全链路 0 个 HTTP 301</strong>。</p> </blockquote> <p>对照表:</p> <table> <thead> <tr> <th>层</th> <th>配置/代码</th> <th>解决什么</th> </tr> </thead> <tbody> <tr> <td>SEO</td> <td><code>site.trailingSlash: true</code></td> <td>canonical / sitemap / OG URL</td> </tr> <tr> <td>出站链接</td> <td><code>nuxtLink.trailingSlash: 'append'</code></td> <td><code><NuxtLink></code> 渲染出来的 href</td> </tr> <tr> <td>硬编码</td> <td><code>to="/donate/"</code> 这种</td> <td>兜底 + 风格统一</td> </tr> <tr> <td>产物落地</td> <td><code>nitro.prerender.autoSubfolderIndex</code>(默认)</td> <td>让 <code>/about</code> 和 <code>/about/</code> 命中同一文件</td> </tr> <tr> <td>入站 URL</td> <td>global client middleware</td> <td>用户输错 / 外链跳转的地址栏规范化</td> </tr> <tr> <td>数据层</td> <td><code>useAsyncData</code> 的 key 用 <code>route.name + params</code></td> <td>避免两端 key 错位导致水合崩盘</td> </tr> </tbody> </table> <h2>小插曲:这套方案是怎么来的</h2> <p>说起来这套配置并不是我开站时一次性想清楚的,最后两层(client middleware 和 <code>useAsyncData</code> 的 key)其实是前几天 debug 一个怪现象时被迫补上去的。</p> <p>那天我打开自己博客的 <a href="/about/">/about/</a> 页,注意到一个怪事——背景图每次进来都"啪"地换一张。F12 一看,控制台挂着 Vue 的 <code>Hydration completed but contains mismatches.</code>。</p> <p><img src="https://static.031130.xyz/uploads/2026/05/28/e1dab1bdd0721.webp" alt="控制台报错"></p> <p>明明 SSG 出来的纯静态产物,HTML 里 <code><div id="__nuxt"></code> 都齐齐整整,凭什么客户端不认账?</p> <p><code>useAsyncData</code> 是怎么命中 payload 的——key 一致就读 payload,不一致就重跑 fetch。那只能是 key 不一致。把构建产物的 <code>_payload.json</code> 抠出来看:</p> <pre><code class="language-json">{"randomIndex/about": ...} </code></pre> <p>key 是 <code>randomIndex/about</code>,<strong>没有尾斜杠</strong>。可这个文件本身在 <code>.output/public/about/_payload.json</code>,浏览器访问的 URL 是 <code>/about/</code>,客户端 <code>route.path</code> 拼出来的 key 是 <code>randomIndex/about/</code>——<strong>多了一个斜杠</strong>。</p> <p><code>Math.random()</code> 在客户端重跑,DOM 与服务端渲染对不上,水合崩盘,整页 re-render。</p> <p>罪魁祸首就是 <code>route.path</code> 在 SSR / client 两端因为 Nitro prerender 的归一化时机而不一致。修起来不难——<code>useAsyncData</code> 的 key 改用 <code>route.name + params</code>,跟路径解耦就完了。</p> <p>修完才想起来,地址栏里那个没斜杠的 URL 还在挂着,于是又顺手把 client middleware 也加上,把"用户输错斜杠"这条路径也一并接住。</p> <p>完了发现这是个值得正经写一篇下来留底的事——所以才有了这篇文章。</p>