[{"data":1,"prerenderedAt":1722},["ShallowReactive",2],{"post-2026-05-28-nuxt-ssg-trailing-slash-hydration-trap-zh":3,"surround-2026-05-28-nuxt-ssg-trailing-slash-hydration-trap-zh":1713,"randomIndex-year-month-day-slug___zh-{\"year\":\"2026\",\"month\":\"05\",\"day\":\"28\",\"slug\":\"nuxt-ssg-trailing-slash-hydration-trap\"}":121,"language-switch-year-month-day-slug___zh-{\"year\":\"2026\",\"month\":\"05\",\"day\":\"28\",\"slug\":\"nuxt-ssg-trailing-slash-hydration-trap\"}-en":1721},{"title":4,"date":5,"path":6,"tags":7,"body":12,"description":1712},"Nuxt SSG 博客的尾斜杠到底怎么加？","2026-05-28","/2026/05/28/nuxt-ssg-trailing-slash-hydration-trap",[8,9,10,11],"Nuxt","Vue.js","Front-End","SSG",{"type":13,"value":14,"toc":1700},"minimark",[15,29,48,52,55,91,97,101,108,159,170,174,185,239,257,260,389,393,399,433,579,586,590,608,616,632,745,749,762,773,780,1055,1058,1094,1097,1105,1113,1116,1183,1209,1228,1414,1438,1441,1444,1470,1473,1583,1587,1593,1602,1609,1616,1624,1647,1672,1678,1690,1693,1696],[16,17,18,19,28],"p",{},"本站是用 Nuxt v4 + Nuxt Content v3 + i18n 搭出来的纯 SSG 博客。开站时随手定了一个看似无关紧要的策略——",[20,21,22,23,27],"strong",{},"所有页面 URL 以 ",[24,25,26],"code",{},"/"," 结尾","。",[16,30,31,32,35,36,43,44,47],{},"听起来一行配置就该完事的事，做下来才发现 Nuxt 在尾斜杠这件事上",[20,33,34],{},"至今没有一个统一的官方开关","（",[37,38,42],"a",{"href":39,"rel":40},"https://github.com/nuxt/nuxt/issues/15462",[41],"nofollow","nuxt/nuxt#15462"," 这个 issue 从 2022 年挂到现在），整套策略最后是靠",[20,45,46],{},"六个不同层面","拼出来的。这篇就把站点里所有跟 trailing slash 相关的配置完整盘一遍，留作给自己和后人的备忘。",[49,50,51],"h2",{"id":51},"为什么要尾斜杠",[16,53,54],{},"简单提一句动机：",[56,57,58,74,85,88],"ul",{},[59,60,61,62,65,66,69,70,73],"li",{},"同一篇文章 ",[24,63,64],{},"/2026/05/28/foo"," 和 ",[24,67,68],{},"/2026/05/28/foo/"," 在搜索引擎眼里",[20,71,72],{},"理论上是两个 URL","，要么你给一个 canonical，要么干脆只允许一种形态；",[59,75,76,77,80,81,84],{},"SSG 产物里\"目录形态\"更自然——",[24,78,79],{},"about/index.html"," 比 ",[24,82,83],{},"about.html"," 更便于嵌套子页面、也更符合直觉；",[59,86,87],{},"风格上，我个人更喜欢看 URL 末尾那个斜杠。",[59,89,90],{},"在我的博客重构前使用的 hexo 框架就是这样的，我希望保留原有 URL 不变。",[16,92,93,94,28],{},"确定了\"全部带斜杠\"这个目标，下面要做的事就是",[20,95,96],{},"让站点的每一个发出 URL 的地方、每一个接收 URL 的地方、每一个用 URL 做 key 的地方都遵守这条约定",[49,98,100],{"id":99},"layer-1seo-层","Layer 1：SEO 层",[16,102,103,104,107],{},"最先想到的是 SEO，所以 ",[24,105,106],{},"@nuxtjs/seo"," 的配置里：",[109,110,115],"pre",{"className":111,"code":112,"language":113,"meta":114,"style":114},"language-ts shiki shiki-themes one-light one-dark-pro","// nuxt.config.ts\nsite: {\n  trailingSlash: true,\n}\n","ts","",[24,116,117,126,137,153],{"__ignoreMap":114},[118,119,122],"span",{"class":120,"line":121},"line",1,[118,123,125],{"class":124},"sW2Sy","// nuxt.config.ts\n",[118,127,129,133],{"class":120,"line":128},2,[118,130,132],{"class":131},"sz0mV","site",[118,134,136],{"class":135},"s5ixo",": {\n",[118,138,140,143,146,150],{"class":120,"line":139},3,[118,141,142],{"class":131},"  trailingSlash",[118,144,145],{"class":135},": ",[118,147,149],{"class":148},"sAGMh","true",[118,151,152],{"class":135},",\n",[118,154,156],{"class":120,"line":155},4,[118,157,158],{"class":135},"}\n",[16,160,161,162,165,166,169],{},"这个开关",[20,163,164],{},"只影响"," canonical link、sitemap.xml、robots.txt、OpenGraph URL 等 SEO 模块生成的 URL。它不会改你页面里实际渲染出来的 ",[24,167,168],{},"\u003Ca href>","，也不会拦截入站请求。但既然它是\"对外发布我自己的 URL 形态\"，配上就对了。",[49,171,173],{"id":172},"layer-2出站链接","Layer 2：出站链接",[16,175,176,177,180,181,184],{},"第二层是页面里 ",[24,178,179],{},"\u003CNuxtLink>"," 渲染出来的 ",[24,182,183],{},"href","。Nuxt 4 提供了 experimental 配置：",[109,186,188],{"className":111,"code":187,"language":113,"meta":114,"style":114},"// nuxt.config.ts\nexperimental: {\n  defaults: {\n    nuxtLink: { trailingSlash: 'append' }\n  }\n}\n",[24,189,190,194,201,208,228,234],{"__ignoreMap":114},[118,191,192],{"class":120,"line":121},[118,193,125],{"class":124},[118,195,196,199],{"class":120,"line":128},[118,197,198],{"class":131},"experimental",[118,200,136],{"class":135},[118,202,203,206],{"class":120,"line":139},[118,204,205],{"class":131},"  defaults",[118,207,136],{"class":135},[118,209,210,213,216,219,221,225],{"class":120,"line":155},[118,211,212],{"class":131},"    nuxtLink",[118,214,215],{"class":135},": { ",[118,217,218],{"class":131},"trailingSlash",[118,220,145],{"class":135},[118,222,224],{"class":223},"sDhpE","'append'",[118,226,227],{"class":135}," }\n",[118,229,231],{"class":120,"line":230},5,[118,232,233],{"class":135},"  }\n",[118,235,237],{"class":120,"line":236},6,[118,238,158],{"class":135},[16,240,241,242,245,246,249,250,28],{},"打开以后，全站任何 ",[24,243,244],{},"\u003CNuxtLink to=\"/about\">"," 渲染出来都是 ",[24,247,248],{},"href=\"/about/\"","，",[20,251,252,253,256],{},"不管你 ",[24,254,255],{},"to"," 写没写斜杠",[16,258,259],{},"需要注意的边界：",[56,261,262,281],{},[59,263,264,265,268,269,272,273,276,277,280],{},"这个配置",[20,266,267],{},"不影响"," ",[24,270,271],{},"router.push('/about')"," 这种代码侧的导航，所以代码里手动 push 时还得自己拼。本站基本走 ",[24,274,275],{},"localePath()"," + ",[24,278,279],{},"NuxtLinkLocale","，绕过了这个雷区。",[59,282,283,286,287,290,291,294,295,249,297,300,301,303,304,382,385,386,28],{},[24,284,285],{},"localePath('/tags')"," 拼出来的 ",[24,288,289],{},"/tags","，外面再手动 ",[24,292,293],{},"+ '/' + tagName"," 的话，最后一段不带 ",[24,296,26],{},[20,298,299],{},"但"," NuxtLink 的 ",[24,302,224],{}," 会兜底再补一次。比如：",[109,305,309],{"className":306,"code":307,"language":308,"meta":114,"style":114},"language-vue shiki shiki-themes one-light one-dark-pro","\u003CNuxtLink :to=\"`${localePath('/tags')}/${encodeURIComponent(tag)}`\">\n","vue",[24,310,311],{"__ignoreMap":114},[118,312,313,316,320,323,325,328,331,334,338,342,345,349,351,353,356,359,361,363,366,368,371,373,375,377,379],{"class":120,"line":121},[118,314,315],{"class":135},"\u003C",[118,317,319],{"class":318},"sJa8x","NuxtLink",[118,321,322],{"class":135}," :",[118,324,255],{"class":148},[118,326,327],{"class":135},"=",[118,329,330],{"class":135},"\"",[118,332,333],{"class":223},"`",[118,335,337],{"class":336},"sAOjX","${",[118,339,341],{"class":340},"sAdtL","localePath",[118,343,344],{"class":135},"(",[118,346,348],{"class":347},"sWwuK","'",[118,350,289],{"class":223},[118,352,348],{"class":347},[118,354,355],{"class":135},")",[118,357,358],{"class":336},"}",[118,360,26],{"class":223},[118,362,337],{"class":336},[118,364,365],{"class":340},"encodeURIComponent",[118,367,344],{"class":135},[118,369,370],{"class":131},"tag",[118,372,355],{"class":135},[118,374,358],{"class":336},[118,376,333],{"class":223},[118,378,330],{"class":135},[118,380,381],{"class":135},">\n",[383,384],"br",{},"实际渲染出来的 href 是 ",[24,387,388],{},"/tags/Foo/",[49,390,392],{"id":391},"layer-3硬编码的链接","Layer 3：硬编码的链接",[16,394,395,396,398],{},"虽然有了 ",[24,397,224],{}," 兜底，但项目里还是把所有硬编码的链接都直接写成带斜杠的形式，作为第二道防线：",[109,400,402],{"className":306,"code":401,"language":308,"meta":114,"style":114},"\u003C!-- FooterContent.vue -->\n\u003CNuxtLinkLocale to=\"/donate/\" aria-label=\"Donate\">\n",[24,403,404,409],{"__ignoreMap":114},[118,405,406],{"class":120,"line":121},[118,407,408],{"class":124},"\u003C!-- FooterContent.vue -->\n",[118,410,411,413,415,418,420,423,426,428,431],{"class":120,"line":128},[118,412,315],{"class":135},[118,414,279],{"class":318},[118,416,417],{"class":148}," to",[118,419,327],{"class":135},[118,421,422],{"class":223},"\"/donate/\"",[118,424,425],{"class":148}," aria-label",[118,427,327],{"class":135},[118,429,430],{"class":223},"\"Donate\"",[118,432,381],{"class":135},[109,434,436],{"className":111,"code":435,"language":113,"meta":114,"style":114},"// Pagination.vue\nfunction getPageUrl(page: number) {\n  if (page \u003C 1 || page > props.totalPages) return '#'\n  return page === 1 ? `${props.urlPrefix}/` : `${props.urlPrefix}/page/${page}/`\n}\n",[24,437,438,443,469,514,575],{"__ignoreMap":114},[118,439,440],{"class":120,"line":121},[118,441,442],{"class":124},"// Pagination.vue\n",[118,444,445,449,452,454,458,462,466],{"class":120,"line":128},[118,446,448],{"class":447},"sLKXg","function",[118,450,451],{"class":340}," getPageUrl",[118,453,344],{"class":135},[118,455,457],{"class":456},"s8iYz","page",[118,459,461],{"class":460},"st7oF",":",[118,463,465],{"class":464},"sN9Y4"," number",[118,467,468],{"class":135},") {\n",[118,470,471,474,477,479,483,486,489,492,495,499,502,505,508,511],{"class":120,"line":139},[118,472,473],{"class":447},"  if",[118,475,476],{"class":135}," (",[118,478,457],{"class":131},[118,480,482],{"class":481},"s_Sar"," \u003C",[118,484,485],{"class":148}," 1",[118,487,488],{"class":481}," ||",[118,490,491],{"class":131}," page",[118,493,494],{"class":481}," >",[118,496,498],{"class":497},"s7GmK"," props",[118,500,501],{"class":135},".",[118,503,504],{"class":318},"totalPages",[118,506,507],{"class":135},") ",[118,509,510],{"class":447},"return",[118,512,513],{"class":223}," '#'\n",[118,515,516,519,521,524,526,530,533,535,538,541,544,546,549,551,553,555,557,559,561,563,566,568,570,572],{"class":120,"line":155},[118,517,518],{"class":447},"  return",[118,520,491],{"class":131},[118,522,523],{"class":481}," ===",[118,525,485],{"class":148},[118,527,529],{"class":528},"s7DPa"," ?",[118,531,532],{"class":223}," `",[118,534,337],{"class":336},[118,536,537],{"class":497},"props",[118,539,501],{"class":540},"sMj0N",[118,542,543],{"class":318},"urlPrefix",[118,545,358],{"class":336},[118,547,548],{"class":223},"/`",[118,550,322],{"class":528},[118,552,532],{"class":223},[118,554,337],{"class":336},[118,556,537],{"class":497},[118,558,501],{"class":540},[118,560,543],{"class":318},[118,562,358],{"class":336},[118,564,565],{"class":223},"/page/",[118,567,337],{"class":336},[118,569,457],{"class":131},[118,571,358],{"class":336},[118,573,574],{"class":223},"/`\n",[118,576,577],{"class":120,"line":230},[118,578,158],{"class":135},[16,580,581,582,585],{},"养成这个习惯有个好处：将来如果 Nuxt 把 ",[24,583,584],{},"experimental.defaults.nuxtLink.trailingSlash"," 又改名了或者拿掉了（experimental API 嘛，懂的都懂），站点也不会因为这个一夜暴毙。",[49,587,589],{"id":588},"layer-4prerender-产物落地形态","Layer 4：Prerender 产物落地形态",[16,591,592,593,596,597,600,601,604,605,28],{},"SSG 阶段是 Nitro 在干活。它有个",[20,594,595],{},"默认开启","的配置叫 ",[24,598,599],{},"prerender.autoSubfolderIndex","——会把 prerender 出来的每个页面落到 ",[24,602,603],{},"\u003Cpath>/index.html","，而不是 ",[24,606,607],{},"\u003Cpath>.html",[109,609,614],{"className":610,"code":612,"language":613},[611],"language-text",".output/public/\n├── about/\n│   ├── index.html\n│   └── _payload.json\n├── 2026/\n│   └── 05/\n│       └── 28/\n│           └── nuxt-ssg-trailing-slash-hydration-trap/\n│               ├── index.html\n│               └── _payload.json\n└── ...\n","text",[24,615,612],{"__ignoreMap":114},[16,617,618,619,65,622,625,626,631],{},"这一步意味着，无论是 Vercel 这种 serverless 平台，还是 Nginx / Caddy，请求 ",[24,620,621],{},"/about",[24,623,624],{},"/about/"," 两种形态，",[20,627,628,629],{},"静态文件服务器都能 fallback 到同一份 ",[24,630,79],{},"——所以\"用户输错斜杠也能开页\"这件事根本不需要应用层兜底。",[633,634,635,638,742],"blockquote",{},[16,636,637],{},"顺便：本站还显式列了几条 prerender route：",[109,639,641],{"className":111,"code":640,"language":113,"meta":114,"style":114},"nitro: {\n  prerender: {\n    routes: [\n      '/rss.xml',\n      '/en/rss.xml',\n      '/search/sections.json',\n      '/tags/Vue.js',     // 带 . 的标签页，crawler 不会自动跟进\n      ...Object.keys(blogConfig.redirects)\n    ]\n  }\n}\n",[24,642,643,650,657,665,672,679,686,698,726,732,737],{"__ignoreMap":114},[118,644,645,648],{"class":120,"line":121},[118,646,647],{"class":131},"nitro",[118,649,136],{"class":135},[118,651,652,655],{"class":120,"line":128},[118,653,654],{"class":131},"  prerender",[118,656,136],{"class":135},[118,658,659,662],{"class":120,"line":139},[118,660,661],{"class":131},"    routes",[118,663,664],{"class":135},": [\n",[118,666,667,670],{"class":120,"line":155},[118,668,669],{"class":223},"      '/rss.xml'",[118,671,152],{"class":135},[118,673,674,677],{"class":120,"line":230},[118,675,676],{"class":223},"      '/en/rss.xml'",[118,678,152],{"class":135},[118,680,681,684],{"class":120,"line":236},[118,682,683],{"class":223},"      '/search/sections.json'",[118,685,152],{"class":135},[118,687,689,692,695],{"class":120,"line":688},7,[118,690,691],{"class":223},"      '/tags/Vue.js'",[118,693,694],{"class":135},",     ",[118,696,697],{"class":124},"// 带 . 的标签页，crawler 不会自动跟进\n",[118,699,701,704,707,709,712,714,717,719,723],{"class":120,"line":700},8,[118,702,703],{"class":460},"      ...",[118,705,706],{"class":497},"Object",[118,708,501],{"class":135},[118,710,711],{"class":340},"keys",[118,713,344],{"class":135},[118,715,716],{"class":497},"blogConfig",[118,718,501],{"class":135},[118,720,722],{"class":721},"sj4iG","redirects",[118,724,725],{"class":135},")\n",[118,727,729],{"class":120,"line":728},9,[118,730,731],{"class":135},"    ]\n",[118,733,735],{"class":120,"line":734},10,[118,736,233],{"class":135},[118,738,740],{"class":120,"line":739},11,[118,741,158],{"class":135},[16,743,744],{},"这些是 crawler 抓不到、必须显式喂的，跟尾斜杠没直接关系，但放在这里作为完整的 nitro 配置一并列出。",[49,746,748],{"id":747},"layer-5入站-url-规范化纯前端","Layer 5：入站 URL 规范化（纯前端）",[16,750,751,752,755,756,758,759,761],{},"到这里 SEO、出站链接、产物落地都齐了，但",[20,753,754],{},"有一类场景还没覆盖","——用户手敲一个没斜杠的 URL（或者外部跳转过来），地址栏里挂着 ",[24,757,621],{},"，需要不需要把它改写成 ",[24,760,624],{},"？",[16,763,764,765,768,769,772],{},"经典做法是 HTTP 301。但本站是",[20,766,767],{},"双平台部署（Vercel + Caddy）","，301 就得两份规则，能避免就避免。而且 Vercel 是把 SSG 产物放在 CDN 上的，301 写在 ",[24,770,771],{},"vercel.json"," 里也算半个绑定方案，不够纯粹。",[16,774,775,776,779],{},"所以这一层走",[20,777,778],{},"纯前端","：一个全局 client middleware。",[109,781,783],{"className":111,"code":782,"language":113,"meta":114,"style":114},"// app/middleware/trailing-slash.global.ts\nexport default defineNuxtRouteMiddleware((to) => {\n  if (import.meta.server) return\n  if (to.path === '/' || to.path.endsWith('/')) return\n\n  // 跳过 favicon.ico、rss.xml 这类带后缀的资源路径\n  const lastSegment = to.path.slice(to.path.lastIndexOf('/') + 1)\n  if (lastSegment.includes('.')) return\n\n  return navigateTo(\n    { path: to.path + '/', query: to.query, hash: to.hash },\n    { replace: true }\n  )\n})\n",[24,784,785,790,815,839,881,887,892,941,964,968,978,1028,1043,1049],{"__ignoreMap":114},[118,786,787],{"class":120,"line":121},[118,788,789],{"class":124},"// app/middleware/trailing-slash.global.ts\n",[118,791,792,795,799,802,805,807,809,812],{"class":120,"line":128},[118,793,794],{"class":447},"export",[118,796,798],{"class":797},"sq3v1"," default",[118,800,801],{"class":340}," defineNuxtRouteMiddleware",[118,803,804],{"class":135},"((",[118,806,255],{"class":456},[118,808,507],{"class":135},[118,810,811],{"class":447},"=>",[118,813,814],{"class":135}," {\n",[118,816,817,819,821,824,826,829,831,834,836],{"class":120,"line":139},[118,818,473],{"class":447},[118,820,476],{"class":135},[118,822,823],{"class":447},"import",[118,825,501],{"class":135},[118,827,828],{"class":318},"meta",[118,830,501],{"class":135},[118,832,833],{"class":318},"server",[118,835,507],{"class":135},[118,837,838],{"class":447},"return\n",[118,840,841,843,845,847,849,852,854,857,859,861,863,866,868,871,873,876,879],{"class":120,"line":155},[118,842,473],{"class":447},[118,844,476],{"class":135},[118,846,255],{"class":497},[118,848,501],{"class":135},[118,850,851],{"class":318},"path",[118,853,523],{"class":481},[118,855,856],{"class":223}," '/'",[118,858,488],{"class":481},[118,860,417],{"class":497},[118,862,501],{"class":135},[118,864,851],{"class":865},"s2QsP",[118,867,501],{"class":135},[118,869,870],{"class":340},"endsWith",[118,872,344],{"class":135},[118,874,875],{"class":223},"'/'",[118,877,878],{"class":135},")) ",[118,880,838],{"class":447},[118,882,883],{"class":120,"line":230},[118,884,886],{"emptyLinePlaceholder":885},true,"\n",[118,888,889],{"class":120,"line":236},[118,890,891],{"class":124},"  // 跳过 favicon.ico、rss.xml 这类带后缀的资源路径\n",[118,893,894,897,901,904,906,908,910,912,915,917,919,921,923,925,928,930,932,934,937,939],{"class":120,"line":688},[118,895,896],{"class":447},"  const",[118,898,900],{"class":899},"sNmU0"," lastSegment",[118,902,903],{"class":481}," =",[118,905,417],{"class":497},[118,907,501],{"class":135},[118,909,851],{"class":865},[118,911,501],{"class":135},[118,913,914],{"class":340},"slice",[118,916,344],{"class":135},[118,918,255],{"class":497},[118,920,501],{"class":135},[118,922,851],{"class":865},[118,924,501],{"class":135},[118,926,927],{"class":340},"lastIndexOf",[118,929,344],{"class":135},[118,931,875],{"class":223},[118,933,507],{"class":135},[118,935,936],{"class":481},"+",[118,938,485],{"class":148},[118,940,725],{"class":135},[118,942,943,945,947,950,952,955,957,960,962],{"class":120,"line":700},[118,944,473],{"class":447},[118,946,476],{"class":135},[118,948,949],{"class":497},"lastSegment",[118,951,501],{"class":135},[118,953,954],{"class":340},"includes",[118,956,344],{"class":135},[118,958,959],{"class":223},"'.'",[118,961,878],{"class":135},[118,963,838],{"class":447},[118,965,966],{"class":120,"line":728},[118,967,886],{"emptyLinePlaceholder":885},[118,969,970,972,975],{"class":120,"line":734},[118,971,518],{"class":447},[118,973,974],{"class":340}," navigateTo",[118,976,977],{"class":135},"(\n",[118,979,980,983,985,987,989,991,993,996,998,1001,1004,1006,1008,1010,1012,1014,1017,1019,1021,1023,1025],{"class":120,"line":739},[118,981,982],{"class":135},"    { ",[118,984,851],{"class":318},[118,986,461],{"class":460},[118,988,417],{"class":497},[118,990,501],{"class":135},[118,992,851],{"class":318},[118,994,995],{"class":481}," +",[118,997,856],{"class":223},[118,999,1000],{"class":135},", ",[118,1002,1003],{"class":318},"query",[118,1005,461],{"class":460},[118,1007,417],{"class":497},[118,1009,501],{"class":135},[118,1011,1003],{"class":318},[118,1013,1000],{"class":135},[118,1015,1016],{"class":318},"hash",[118,1018,461],{"class":460},[118,1020,417],{"class":497},[118,1022,501],{"class":135},[118,1024,1016],{"class":318},[118,1026,1027],{"class":135}," },\n",[118,1029,1031,1033,1036,1038,1041],{"class":120,"line":1030},12,[118,1032,982],{"class":135},[118,1034,1035],{"class":318},"replace",[118,1037,461],{"class":460},[118,1039,1040],{"class":148}," true",[118,1042,227],{"class":135},[118,1044,1046],{"class":120,"line":1045},13,[118,1047,1048],{"class":135},"  )\n",[118,1050,1052],{"class":120,"line":1051},14,[118,1053,1054],{"class":135},"})\n",[16,1056,1057],{},"几个细节：",[56,1059,1060,1073,1085],{},[59,1061,1062,1068,1069,1072],{},[20,1063,1064,1067],{},[24,1065,1066],{},"import.meta.server"," 直接 return。"," SSG prerender 阶段 Nitro 自己已经归一化了；如果在 server 端再 ",[24,1070,1071],{},"navigateTo","，可能在产物里写出非预期的 30x 跳转。",[59,1074,1075,1080,1081,1084],{},[20,1076,1077],{},[24,1078,1079],{},"replace: true"," 让浏览器",[20,1082,1083],{},"替换","当前 history 条目，不会留一条\"刚刚那个没斜杠的版本\"的返回栈。",[59,1086,1087,1093],{},[20,1088,1089,1090,1092],{},"排除带 ",[24,1091,501],{}," 的路径","，避免误把静态资源也加上斜杠。",[16,1095,1096],{},"这层做完后，全链路 0 个 HTTP 301，配置上也不绑任何一家部署平台。",[49,1098,1100,1101,1104],{"id":1099},"layer-6useasyncdata-的-key隐藏的雷区","Layer 6：",[24,1102,1103],{},"useAsyncData"," 的 key（隐藏的雷区）",[16,1106,1107,1108,28],{},"前五层做完，URL 的形态已经全部规范化，但还有一层非常隐蔽的地方需要照顾——",[20,1109,1110,1112],{},[24,1111,1103],{}," 的 key",[16,1114,1115],{},"很容易写出这种代码：",[109,1117,1119],{"className":111,"code":1118,"language":113,"meta":114,"style":114},"const { data } = useAsyncData(\n  `randomIndex${route.path}`,  // ← 雷\n  async () => ...\n)\n",[24,1120,1121,1142,1166,1179],{"__ignoreMap":114},[118,1122,1123,1126,1129,1132,1135,1137,1140],{"class":120,"line":121},[118,1124,1125],{"class":447},"const",[118,1127,1128],{"class":135}," { ",[118,1130,1131],{"class":899},"data",[118,1133,1134],{"class":135}," } ",[118,1136,327],{"class":481},[118,1138,1139],{"class":340}," useAsyncData",[118,1141,977],{"class":135},[118,1143,1144,1147,1149,1152,1154,1156,1158,1160,1163],{"class":120,"line":128},[118,1145,1146],{"class":223},"  `randomIndex",[118,1148,337],{"class":336},[118,1150,1151],{"class":497},"route",[118,1153,501],{"class":540},[118,1155,851],{"class":318},[118,1157,358],{"class":336},[118,1159,333],{"class":223},[118,1161,1162],{"class":135},",  ",[118,1164,1165],{"class":124},"// ← 雷\n",[118,1167,1168,1171,1174,1176],{"class":120,"line":139},[118,1169,1170],{"class":447},"  async",[118,1172,1173],{"class":135}," () ",[118,1175,811],{"class":447},[118,1177,1178],{"class":460}," ...\n",[118,1180,1181],{"class":120,"line":155},[118,1182,725],{"class":135},[16,1184,1185,1186,1189,1190,1193,1194,1197,1198,1201,1202,1204,1205,1208],{},"问题是 ",[24,1187,1188],{},"route.path"," 在 SSR / client / prerender / SPA 导航这四种上下文里",[20,1191,1192],{},"不一定一致","。一旦 key 在 SSR 时算出 ",[24,1195,1196],{},"randomIndex/about","、客户端水合时算出 ",[24,1199,1200],{},"randomIndex/about/","，payload 命中失败，整个 ",[24,1203,1103],{}," 在客户端会",[20,1206,1207],{},"重跑一遍","，对应组件直接退化成 CSR。",[16,1210,1211,1212,1220,1221,276,1224,1227],{},"本站的处理是：",[20,1213,1214,1215,1217,1218],{},"所有 ",[24,1216,1103],{}," 的 key 都不沾 ",[24,1219,1188],{},"，要带路由信息就用 ",[24,1222,1223],{},"route.name",[24,1225,1226],{},"route.params","：",[109,1229,1231],{"className":111,"code":1230,"language":113,"meta":114,"style":114},"const route = useRoute()\nconst routeKey = `${String(route.name ?? 'unknown')}-${JSON.stringify(route.params)}`\n\nconst { data: randomIndex } = useAsyncData(\n  `randomIndex-${routeKey}`,\n  async () => Math.floor(Math.random() * appConfig.appearance.backgrounds.length)\n)\n",[24,1232,1233,1248,1317,1321,1342,1358,1410],{"__ignoreMap":114},[118,1234,1235,1237,1240,1242,1245],{"class":120,"line":121},[118,1236,1125],{"class":447},[118,1238,1239],{"class":899}," route",[118,1241,903],{"class":481},[118,1243,1244],{"class":340}," useRoute",[118,1246,1247],{"class":135},"()\n",[118,1249,1250,1252,1255,1257,1259,1261,1264,1266,1268,1270,1273,1276,1279,1282,1284,1286,1288,1291,1293,1296,1298,1301,1303,1305,1307,1310,1312,1314],{"class":120,"line":128},[118,1251,1125],{"class":447},[118,1253,1254],{"class":899}," routeKey",[118,1256,903],{"class":481},[118,1258,532],{"class":223},[118,1260,337],{"class":336},[118,1262,1263],{"class":340},"String",[118,1265,344],{"class":135},[118,1267,1151],{"class":497},[118,1269,501],{"class":540},[118,1271,1272],{"class":318},"name",[118,1274,1275],{"class":481}," ??",[118,1277,1278],{"class":347}," '",[118,1280,1281],{"class":223},"unknown",[118,1283,348],{"class":347},[118,1285,355],{"class":135},[118,1287,358],{"class":336},[118,1289,1290],{"class":223},"-",[118,1292,337],{"class":336},[118,1294,1295],{"class":899},"JSON",[118,1297,501],{"class":540},[118,1299,1300],{"class":340},"stringify",[118,1302,344],{"class":135},[118,1304,1151],{"class":497},[118,1306,501],{"class":540},[118,1308,1309],{"class":318},"params",[118,1311,355],{"class":135},[118,1313,358],{"class":336},[118,1315,1316],{"class":223},"`\n",[118,1318,1319],{"class":120,"line":139},[118,1320,886],{"emptyLinePlaceholder":885},[118,1322,1323,1325,1327,1329,1331,1334,1336,1338,1340],{"class":120,"line":155},[118,1324,1125],{"class":447},[118,1326,1128],{"class":135},[118,1328,1131],{"class":318},[118,1330,145],{"class":135},[118,1332,1333],{"class":899},"randomIndex",[118,1335,1134],{"class":135},[118,1337,327],{"class":481},[118,1339,1139],{"class":340},[118,1341,977],{"class":135},[118,1343,1344,1347,1349,1352,1354,1356],{"class":120,"line":230},[118,1345,1346],{"class":223},"  `randomIndex-",[118,1348,337],{"class":336},[118,1350,1351],{"class":131},"routeKey",[118,1353,358],{"class":336},[118,1355,333],{"class":223},[118,1357,152],{"class":135},[118,1359,1360,1362,1364,1366,1369,1371,1374,1376,1379,1381,1384,1387,1390,1393,1395,1398,1400,1403,1405,1408],{"class":120,"line":236},[118,1361,1170],{"class":447},[118,1363,1173],{"class":135},[118,1365,811],{"class":447},[118,1367,1368],{"class":497}," Math",[118,1370,501],{"class":135},[118,1372,1373],{"class":340},"floor",[118,1375,344],{"class":135},[118,1377,1378],{"class":497},"Math",[118,1380,501],{"class":135},[118,1382,1383],{"class":340},"random",[118,1385,1386],{"class":135},"() ",[118,1388,1389],{"class":481},"*",[118,1391,1392],{"class":497}," appConfig",[118,1394,501],{"class":135},[118,1396,1397],{"class":865},"appearance",[118,1399,501],{"class":135},[118,1401,1402],{"class":865},"backgrounds",[118,1404,501],{"class":135},[118,1406,1407],{"class":318},"length",[118,1409,725],{"class":135},[118,1411,1412],{"class":120,"line":688},[118,1413,725],{"class":135},[16,1415,1416,1418,1419,1422,1423,1425,1426,1429,1430,1433,1434,1437],{},[24,1417,1223],{}," 是 vue-router 内部的路由名（i18n 自动生成的形如 ",[24,1420,1421],{},"about___zh","），",[24,1424,1226],{}," 是动态段，",[20,1427,1428],{},"两者在任何上下文都一致","。最终构建出来的 ",[24,1431,1432],{},"_payload.json"," key 形如 ",[24,1435,1436],{},"randomIndex-about___zh-{}","，跟尾斜杠完全脱钩。",[49,1439,1440],{"id":1440},"整体回顾",[16,1442,1443],{},"整个站点的尾斜杠策略可以一句话总结：",[633,1445,1446],{},[16,1447,1448,1449,1452,1453,1456,1457,1460,1461,1463,1464,1466,1467,28],{},"Prerender 时让 Nitro 落到 ",[24,1450,1451],{},"xxx/index.html","，SEO 由 ",[24,1454,1455],{},"site.trailingSlash"," 负责对外发布形态，出站链接由 ",[24,1458,1459],{},"nuxtLink.trailingSlash: 'append'"," 自动补斜杠（+ 硬编码做第二道防线），入站直链由全局 client middleware 兜底，",[24,1462,1103],{}," 的 key 一律不依赖 ",[24,1465,1188],{}," —— ",[20,1468,1469],{},"全链路 0 个 HTTP 301",[16,1471,1472],{},"对照表：",[1474,1475,1476,1492],"table",{},[1477,1478,1479],"thead",{},[1480,1481,1482,1486,1489],"tr",{},[1483,1484,1485],"th",{},"层",[1483,1487,1488],{},"配置/代码",[1483,1490,1491],{},"解决什么",[1493,1494,1495,1509,1523,1537,1556,1567],"tbody",{},[1480,1496,1497,1501,1506],{},[1498,1499,1500],"td",{},"SEO",[1498,1502,1503],{},[24,1504,1505],{},"site.trailingSlash: true",[1498,1507,1508],{},"canonical / sitemap / OG URL",[1480,1510,1511,1514,1518],{},[1498,1512,1513],{},"出站链接",[1498,1515,1516],{},[24,1517,1459],{},[1498,1519,1520,1522],{},[24,1521,179],{}," 渲染出来的 href",[1480,1524,1525,1528,1534],{},[1498,1526,1527],{},"硬编码",[1498,1529,1530,1533],{},[24,1531,1532],{},"to=\"/donate/\""," 这种",[1498,1535,1536],{},"兜底 + 风格统一",[1480,1538,1539,1542,1548],{},[1498,1540,1541],{},"产物落地",[1498,1543,1544,1547],{},[24,1545,1546],{},"nitro.prerender.autoSubfolderIndex","（默认）",[1498,1549,1550,1551,65,1553,1555],{},"让 ",[24,1552,621],{},[24,1554,624],{}," 命中同一文件",[1480,1557,1558,1561,1564],{},[1498,1559,1560],{},"入站 URL",[1498,1562,1563],{},"global client middleware",[1498,1565,1566],{},"用户输错 / 外链跳转的地址栏规范化",[1480,1568,1569,1572,1580],{},[1498,1570,1571],{},"数据层",[1498,1573,1574,1576,1577],{},[24,1575,1103],{}," 的 key 用 ",[24,1578,1579],{},"route.name + params",[1498,1581,1582],{},"避免两端 key 错位导致水合崩盘",[49,1584,1586],{"id":1585},"小插曲这套方案是怎么来的","小插曲：这套方案是怎么来的",[16,1588,1589,1590,1592],{},"说起来这套配置并不是我开站时一次性想清楚的，最后两层（client middleware 和 ",[24,1591,1103],{}," 的 key）其实是前几天 debug 一个怪现象时被迫补上去的。",[16,1594,1595,1596,1598,1599,28],{},"那天我打开自己博客的 ",[37,1597,624],{"href":624}," 页，注意到一个怪事——背景图每次进来都\"啪\"地换一张。F12 一看，控制台挂着 Vue 的 ",[24,1600,1601],{},"Hydration completed but contains mismatches.",[16,1603,1604],{},[1605,1606],"img",{"alt":1607,"src":1608},"控制台报错","https://static.031130.xyz/uploads/2026/05/28/e1dab1bdd0721.webp",[16,1610,1611,1612,1615],{},"明明 SSG 出来的纯静态产物，HTML 里 ",[24,1613,1614],{},"\u003Cdiv id=\"__nuxt\">"," 都齐齐整整，凭什么客户端不认账？",[16,1617,1618,1620,1621,1623],{},[24,1619,1103],{}," 是怎么命中 payload 的——key 一致就读 payload，不一致就重跑 fetch。那只能是 key 不一致。把构建产物的 ",[24,1622,1432],{}," 抠出来看：",[109,1625,1629],{"className":1626,"code":1627,"language":1628,"meta":114,"style":114},"language-json shiki shiki-themes one-light one-dark-pro","{\"randomIndex/about\": ...}\n","json",[24,1630,1631],{"__ignoreMap":114},[118,1632,1633,1636,1639,1641,1645],{"class":120,"line":121},[118,1634,1635],{"class":135},"{",[118,1637,1638],{"class":318},"\"randomIndex/about\"",[118,1640,145],{"class":135},[118,1642,1644],{"class":1643},"sUNH4","...",[118,1646,158],{"class":135},[16,1648,1649,1650,249,1652,1655,1656,1659,1660,1662,1663,1665,1666,1668,1669,28],{},"key 是 ",[24,1651,1196],{},[20,1653,1654],{},"没有尾斜杠","。可这个文件本身在 ",[24,1657,1658],{},".output/public/about/_payload.json","，浏览器访问的 URL 是 ",[24,1661,624],{},"，客户端 ",[24,1664,1188],{}," 拼出来的 key 是 ",[24,1667,1200],{},"——",[20,1670,1671],{},"多了一个斜杠",[16,1673,1674,1677],{},[24,1675,1676],{},"Math.random()"," 在客户端重跑，DOM 与服务端渲染对不上，水合崩盘，整页 re-render。",[16,1679,1680,1681,1683,1684,1686,1687,1689],{},"罪魁祸首就是 ",[24,1682,1188],{}," 在 SSR / client 两端因为 Nitro prerender 的归一化时机而不一致。修起来不难——",[24,1685,1103],{}," 的 key 改用 ",[24,1688,1579],{},"，跟路径解耦就完了。",[16,1691,1692],{},"修完才想起来，地址栏里那个没斜杠的 URL 还在挂着，于是又顺手把 client middleware 也加上，把\"用户输错斜杠\"这条路径也一并接住。",[16,1694,1695],{},"完了发现这是个值得正经写一篇下来留底的事——所以才有了这篇文章。",[1697,1698,1699],"style",{},"html pre.shiki code .sW2Sy, html code.shiki .sW2Sy{--shiki-default:#A0A1A7;--shiki-default-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic}html pre.shiki code .sz0mV, html code.shiki .sz0mV{--shiki-default:#383A42;--shiki-dark:#E06C75}html pre.shiki code .s5ixo, html code.shiki .s5ixo{--shiki-default:#383A42;--shiki-dark:#ABB2BF}html pre.shiki code .sAGMh, html code.shiki .sAGMh{--shiki-default:#986801;--shiki-dark:#D19A66}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sDhpE, html code.shiki .sDhpE{--shiki-default:#50A14F;--shiki-dark:#98C379}html pre.shiki code .sJa8x, html code.shiki .sJa8x{--shiki-default:#E45649;--shiki-dark:#E06C75}html pre.shiki code .sAOjX, html code.shiki .sAOjX{--shiki-default:#CA1243;--shiki-dark:#C678DD}html pre.shiki code .sAdtL, html code.shiki .sAdtL{--shiki-default:#4078F2;--shiki-dark:#61AFEF}html pre.shiki code .sWwuK, html code.shiki .sWwuK{--shiki-default:#CA1243;--shiki-dark:#98C379}html pre.shiki code .sLKXg, html code.shiki .sLKXg{--shiki-default:#A626A4;--shiki-dark:#C678DD}html pre.shiki code .s8iYz, html code.shiki .s8iYz{--shiki-default:#383A42;--shiki-default-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic}html pre.shiki code .st7oF, html code.shiki .st7oF{--shiki-default:#0184BC;--shiki-dark:#ABB2BF}html pre.shiki code .sN9Y4, html code.shiki .sN9Y4{--shiki-default:#0184BC;--shiki-dark:#E5C07B}html pre.shiki code .s_Sar, html code.shiki .s_Sar{--shiki-default:#0184BC;--shiki-dark:#56B6C2}html pre.shiki code .s7GmK, html code.shiki .s7GmK{--shiki-default:#383A42;--shiki-dark:#E5C07B}html pre.shiki code .s7DPa, html code.shiki .s7DPa{--shiki-default:#0184BC;--shiki-dark:#C678DD}html pre.shiki code .sMj0N, html code.shiki .sMj0N{--shiki-default:#50A14F;--shiki-dark:#ABB2BF}html pre.shiki code .sj4iG, html code.shiki .sj4iG{--shiki-default:#C18401;--shiki-dark:#E06C75}html pre.shiki code .sq3v1, html code.shiki .sq3v1{--shiki-default:#E45649;--shiki-dark:#C678DD}html pre.shiki code .s2QsP, html code.shiki .s2QsP{--shiki-default:#E45649;--shiki-dark:#E5C07B}html pre.shiki code .sNmU0, html code.shiki .sNmU0{--shiki-default:#986801;--shiki-dark:#E5C07B}html pre.shiki code .sUNH4, html code.shiki .sUNH4{--shiki-default:white;--shiki-dark:#FFFFFF}",{"title":114,"searchDepth":128,"depth":128,"links":1701},[1702,1703,1704,1705,1706,1707,1708,1710,1711],{"id":51,"depth":128,"text":51},{"id":99,"depth":128,"text":100},{"id":172,"depth":128,"text":173},{"id":391,"depth":128,"text":392},{"id":588,"depth":128,"text":589},{"id":747,"depth":128,"text":748},{"id":1099,"depth":128,"text":1709},"Layer 6：useAsyncData 的 key（隐藏的雷区）",{"id":1440,"depth":128,"text":1440},{"id":1585,"depth":128,"text":1586},"本站是用 Nuxt v4 + Nuxt Content v3 + i18n 搭出来的纯 SSG 博客。开站时随手定了一个看似无关紧要的策略——所有页面 URL 以 / 结尾。",[1714,1715],null,{"title":1716,"path":1717,"stem":1718,"date":1719,"lang":1720,"children":-1},"小米 Xiaomi Book Pro 14 （Ultra X7） Linux 兼容性实测","/2026/04/30/xiaomi-book-pro-14-2026-on-linux","posts/zh/xiaomi-book-pro-14-2026-on-linux","2026-04-30","zh-CN","/en/2026/05/28/nuxt-ssg-trailing-slash-hydration-trap/",1779982438616]