[{"data":1,"prerenderedAt":13601},["ShallowReactive",2],{"\u002Ftutorials\u002Fnuxt":3,"tutorials-search\u002Ftutorials\u002Fnuxt":13196},{"id":4,"title":5,"body":6,"category":13185,"cover":13186,"date":13187,"description":13188,"extension":13189,"meta":13190,"navigation":457,"path":13191,"seo":13192,"sitemap":13193,"stem":13194,"__hash__":13195},"tutorials\u002Ftutorials\u002Fnuxt.md","Nuxt 4 教程：从第一个页面到静态部署",{"type":7,"value":8,"toc":13042},"minimark",[9,22,44,49,55,58,167,172,179,182,211,217,224,228,258,262,279,289,295,322,326,410,414,417,515,521,615,632,639,647,677,682,685,691,694,912,928,932,942,1120,1147,1152,1155,1182,1206,1228,1246,1270,1285,1296,1306,1310,1455,1459,1475,1478,1484,1496,1503,1513,1616,1619,1629,1902,1931,1937,1946,2116,2126,2131,2142,2145,2159,2200,2218,2246,2267,2290,2306,2315,2319,2479,2483,2493,2496,2503,2555,2564,2661,2679,2682,2733,2751,2758,2921,2924,2962,2969,2994,2997,3049,3069,3076,3135,3150,3154,3172,3175,3196,3218,3231,3246,3259,3271,3287,3291,3427,3435,3442,3449,3458,3465,3704,3732,3740,3836,3845,3849,3913,3926,3937,3950,4117,4125,4132,4186,4202,4208,4211,4251,4257,4261,4269,4272,4296,4319,4335,4357,4379,4400,4413,4435,4439,4676,4683,4694,4802,4815,4980,5002,5081,5085,5088,5099,5246,5249,5276,5280,5334,5344,5352,5426,5429,5437,5446,5457,5469,5494,5518,5529,5547,5554,5639,5646,5657,5660,5783,5922,5957,5961,6236,6249,6252,6312,6318,6321,6341,6345,6348,6370,6386,6399,6414,6430,6448,6465,6507,6522,6535,6539,6713,6720,6728,6732,6829,6869,6886,6889,6980,6994,6997,7000,7058,7070,7073,7085,7102,7111,7147,7163,7177,7189,7209,7229,7233,7350,7357,7366,7369,7549,7666,7670,7689,7737,7744,7748,7751,7762,7768,7772,7775,7783,7801,7834,7851,7865,7874,7978,7996,8007,8019,8025,8199,8203,8210,8217,8407,8441,8448,8567,8587,8591,8606,8679,8704,8708,8834,8848,8852,8869,8942,8954,8957,8970,8984,9000,9019,9027,9042,9053,9069,9073,9154,9161,9168,9175,9233,9242,9260,9264,9281,9306,9469,9521,9537,9541,9590,9651,9656,9659,9662,9688,9696,9716,9728,9736,9748,9758,9762,9845,9849,9852,9930,9933,10049,10052,10142,10154,10158,10256,10362,10388,10399,10409,10412,10493,10496,10514,10532,10550,10556,10580,10588,10615,10626,10630,10719,10723,10734,10737,10825,10828,10845,10848,10854,10866,10912,10937,10961,10965,10969,11071,11074,11121,11127,11167,11209,11224,11227,11311,11314,11322,11340,11356,11370,11391,11399,11411,11417,11421,11516,11520,11523,11526,11608,11623,11627,11806,11809,11821,11826,11922,11931,11935,12054,12057,12060,12068,12076,12093,12109,12118,12129,12144,12154,12171,12175,12271,12275,12279,12486,12489,12740,12743,12957,12960,13031,13038],[10,11,12,13],"p",{},"这门课的写法参照本站（一个真实的 Nuxt 4 静态站点）：每一章先给「最短能跑的一段代码」，再给对照表，最后列出这一章会踩的坑。学完的判定标准很朴素：",[14,15,16,17,21],"strong",{},"你能从零起一个 Nuxt 项目，写出 5 个页面、2 个服务端接口，",[18,19,20],"code",{},"nuxt generate"," 出一份能丢进 Nginx 的静态产物。",[10,23,24,25,28,29,28,32,35,36,39,40,43],{},"版本基线：Nuxt 4.5 \u002F Nitro 2.x \u002F Vue 3.5 \u002F Vite 7。文中关键配置（",[18,26,27],{},"app.baseURL","、",[18,30,31],{},"nitro.prerender",[18,33,34],{},"components"," 前缀、",[18,37,38],{},"routeRules"," 等）都对照本项目的 ",[18,41,42],{},"nuxt.config.ts"," 与产物核过；需要区分旧行为时会注明「Nuxt 3 时代」。前置知识是本教程的姊妹篇《Vue 3 教程》——Nuxt 不发明新语法，它只是把 Vue 应用装进了一个有服务端的外壳里。",[45,46,48],"h2",{"id":47},"一nuxt-是什么vue-应用外面那层服务端","一、Nuxt 是什么：Vue 应用外面那层服务端",[10,50,51,52],{},"一句话：",[14,53,54],{},"Nuxt = Vue + 路由 + 服务端渲染 + 构建配置 + 一整套目录约定。",[10,56,57],{},"如果你已经按《Vue 3 教程》用 Vite 建过项目，你会遇到这三件事，Nuxt 就是把它们全包了：",[59,60,61,74],"table",{},[62,63,64],"thead",{},[65,66,67,71],"tr",{},[68,69,70],"th",{},"你自己搭 Vite + Vue 时的活",[68,72,73],{},"Nuxt 里的对应",[75,76,77,97,116,131,148,156],"tbody",{},[65,78,79,90],{},[80,81,82,83,86,87],"td",{},"装 ",[18,84,85],{},"vue-router","、写路由表、注册 ",[18,88,89],{},"\u003CRouterView>",[80,91,92,93,96],{},"在 ",[18,94,95],{},"app\u002Fpages\u002F"," 放文件，路由自动生成",[65,98,99,109],{},[80,100,101,102,105,106],{},"建 ",[18,103,104],{},"components\u002F","、每个组件手写 ",[18,107,108],{},"import",[80,110,111,112,115],{},"放进 ",[18,113,114],{},"app\u002Fcomponents\u002F","，模板里直接用",[65,117,118,125],{},[80,119,120,121,124],{},"写 ",[18,122,123],{},"useCounter.js"," 组合式函数并手动 import",[80,126,111,127,130],{},[18,128,129],{},"app\u002Fcomposables\u002F","，自动可用",[65,132,133,143],{},[80,134,135,136,28,139,142],{},"配 ",[18,137,138],{},"vite.config.js",[18,140,141],{},"build.base","、dev 代理",[80,144,145,147],{},[18,146,42],{}," 一个文件",[65,149,150,153],{},[80,151,152],{},"需要 SEO \u002F 首屏快 → 自己搭 Node SSR 服务",[80,154,155],{},"内置 SSR，且能一个开关切到纯静态",[65,157,158,161],{},[80,159,160],{},"需要几个后端接口给前端用",[80,162,163,166],{},[18,164,165],{},"server\u002Fapi\u002F*.ts"," 文件即接口（Nitro）",[168,169,171],"h3",{"id":170},"为什么首屏这件事值得多一层服务端","为什么「首屏」这件事值得多一层服务端",[10,173,174,175,178],{},"浏览器拿到的是 HTML。SPA（纯客户端渲染）的 HTML 里只有一个空 ",[18,176,177],{},"\u003Cdiv id=\"app\">\u003C\u002Fdiv>","，正文要等 JS 下载、执行、请求数据之后才出现；SSR 的 HTML 里正文已经在里面了。",[10,180,181],{},"差别体现在三件具体的事上：",[183,184,185,192,198],"ol",{},[186,187,188,191],"li",{},[14,189,190],{},"用户","：首屏不白屏，弱网\u002F低端机上观感差距明显。",[186,193,194,197],{},[14,195,196],{},"爬虫","：搜索引擎抓取的是 HTML 源码。正文在源码里 = 能被索引；空壳 = 你得赌它的渲染引擎会执行 JS。社交平台的分享卡片（Open Graph）同理。",[186,199,200,203,204,206,207,210],{},[14,201,202],{},"成本","：SSR 需要服务器跑 Node 进程；但 Nuxt 还能「构建期就把 HTML 全生成好」（",[18,205,20],{},"），产物是纯静态文件，一台 Nginx 甚至对象存储就够——",[14,208,209],{},"这正好是很多内容站真正需要的形态","。",[212,213],"demo-html",{"height":214,"src":215,"title":216},680,"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F01-ssr-vs-csr.html","示例 01：CSR \u002F SSR \u002F SSG 的加载时序与水合现场",[10,218,219,220,223],{},"示例 01 把两种模式的瀑布一步步放给你看，最值得注意的是 SSR 那条：",[14,221,222],{},"看得见 ≠ 点得动","。服务端渲染出的 HTML 只是「长得一样」，事件要等客户端 JS 执行完「水合」才绑上——这是理解后面所有坑的前提。",[168,225,227],{"id":226},"nuxt-与-vue-的关系不是什么","Nuxt 与 Vue 的关系：不是什么",[229,230,231,248,253],"ul",{},[186,232,233,234,237,238,28,241,28,244,247],{},"Nuxt ",[14,235,236],{},"不是","另一个框架，语法还是 Vue 那一套（",[18,239,240],{},"\u003Cscript setup>",[18,242,243],{},"ref",[18,245,246],{},"computed","、组件、Pinia、Vue Router 全都在）。",[186,249,233,250,252],{},[14,251,236],{},"「必须有一个后端」。它可以输出纯静态文件，服务器上完全不跑 Node。",[186,254,233,255,257],{},[14,256,236],{}," Next\u002FNuxt 之争的主角。它对标的是「Next.js 之于 React」，但作为 Vue 官方推荐的应用框架，接口设计更贴近 Vue 的直觉。",[168,259,261],{"id":260},"️-本章的坑","⚠️ 本章的坑",[10,263,264,267,268,270,271,274,275,278],{},[14,265,266],{},"1. 以为 Nuxt 一定要 Node 服务器。"," 内容站用 ",[18,269,20],{}," 出静态产物 + Nginx 就够了（第十三章实测链路）。反过来说，需要「按请求动态渲染」时才要 Node（",[18,272,273],{},"nuxt build"," + ",[18,276,277],{},"node .output\u002Fserver\u002Findex.mjs","）。",[10,280,281,284,285,288],{},[14,282,283],{},"2. 把 SSR 当成「一定比 CSR 快」。"," 服务端要为每个请求跑一遍组件渲染，高并发下 CPU 是成本；对纯内部系统（登录才能进、无 SEO 需求）",[18,286,287],{},"ssr: false"," 反而更省事。",[10,290,291,294],{},[14,292,293],{},"3. 忽略了「水合」这一层的代价。"," SSR 首屏有内容，但要等 JS 加载完才可交互，这段间隔叫 TTI。页面里塞了大 JS 时，SSR 也救不了交互延迟——所以第九章的渲染模式和第十四章的包体积优化要一起看。",[10,296,297,300,301,304,305,308,309,311,312,28,315,28,318,321],{},[14,298,299],{},"4. 拿 Nuxt 2 \u002F Nuxt 3 的老教程直接抄。"," Nuxt 4 把源码目录收进了 ",[18,302,303],{},"app\u002F","（",[18,306,307],{},"pages\u002F"," → ",[18,310,95],{},"），",[18,313,314],{},"server\u002F",[18,316,317],{},"public\u002F",[18,319,320],{},"content\u002F"," 仍在根目录。还有一批默认值变化（见第二章对照表）。搜资料时先确认版本。",[168,323,325],{"id":324},"附名词对照","附：名词对照",[59,327,328,338],{},[62,329,330],{},[65,331,332,335],{},[68,333,334],{},"名词",[68,336,337],{},"是什么",[75,339,340,348,359,371,382,390,398],{},[65,341,342,345],{},[80,343,344],{},"Nuxt",[80,346,347],{},"应用框架（你写代码的地方）",[65,349,350,353],{},[80,351,352],{},"Nitro",[80,354,355,356,358],{},"Nuxt 的服务端引擎（",[18,357,314],{}," 目录、构建产物、部署适配都是它管的）",[65,360,361,364],{},[80,362,363],{},"Vite",[80,365,366,367,370],{},"开发服务器与打包器，Nuxt 用它，配置通过 ",[18,368,369],{},"vite: {}"," 传",[65,372,373,375],{},[80,374,168],{},[80,376,377,378,381],{},"Nitro 里的 HTTP 框架，",[18,379,380],{},"defineEventHandler"," 来自它",[65,383,384,387],{},[80,385,386],{},"SSR \u002F SSG \u002F CSR",[80,388,389],{},"服务端渲染 \u002F 构建期静态生成 \u002F 纯客户端渲染",[65,391,392,395],{},[80,393,394],{},"水合（hydration）",[80,396,397],{},"客户端把已有 DOM 接管为响应式组件，并绑上事件",[65,399,400,403],{},[80,401,402],{},"payload",[80,404,405,406,409],{},"Nuxt 把服务端取到的数据序列化进 ",[18,407,408],{},"_payload.json","，供客户端复用",[45,411,413],{"id":412},"二跑起来第一个项目三条命令与目录约定","二、跑起来第一个项目：三条命令与目录约定",[168,415,416],{"id":416},"创建与运行",[418,419,424],"pre",{"className":420,"code":421,"language":422,"meta":423,"style":423},"language-bash shiki shiki-themes github-dark","# ① 官方脚手架（会问你是否装 modules \u002F 用 TS \u002F 起 dev 服务）\nnpx nuxi@latest init my-site\n\n# ② 手动（已有仓库时）\nnpm create nuxt@latest my-site\n\ncd my-site\nnpm install\nnpm run dev          # http:\u002F\u002Flocalhost:3000，改文件即时热更新\n","bash","",[18,425,426,435,452,459,465,479,484,493,501],{"__ignoreMap":423},[427,428,431],"span",{"class":429,"line":430},"line",1,[427,432,434],{"class":433},"sAwPA","# ① 官方脚手架（会问你是否装 modules \u002F 用 TS \u002F 起 dev 服务）\n",[427,436,438,442,446,449],{"class":429,"line":437},2,[427,439,441],{"class":440},"svObZ","npx",[427,443,445],{"class":444},"sU2Wk"," nuxi@latest",[427,447,448],{"class":444}," init",[427,450,451],{"class":444}," my-site\n",[427,453,455],{"class":429,"line":454},3,[427,456,458],{"emptyLinePlaceholder":457},true,"\n",[427,460,462],{"class":429,"line":461},4,[427,463,464],{"class":433},"# ② 手动（已有仓库时）\n",[427,466,468,471,474,477],{"class":429,"line":467},5,[427,469,470],{"class":440},"npm",[427,472,473],{"class":444}," create",[427,475,476],{"class":444}," nuxt@latest",[427,478,451],{"class":444},[427,480,482],{"class":429,"line":481},6,[427,483,458],{"emptyLinePlaceholder":457},[427,485,487,491],{"class":429,"line":486},7,[427,488,490],{"class":489},"sDLfK","cd",[427,492,451],{"class":444},[427,494,496,498],{"class":429,"line":495},8,[427,497,470],{"class":440},[427,499,500],{"class":444}," install\n",[427,502,504,506,509,512],{"class":429,"line":503},9,[427,505,470],{"class":440},[427,507,508],{"class":444}," run",[427,510,511],{"class":444}," dev",[427,513,514],{"class":433},"          # http:\u002F\u002Flocalhost:3000，改文件即时热更新\n",[10,516,517,520],{},[18,518,519],{},"package.json"," 里 Nuxt 给了四条命令，语义区别要一次记清：",[59,522,523,539],{},[62,524,525],{},[65,526,527,530,533,536],{},[68,528,529],{},"命令",[68,531,532],{},"做什么",[68,534,535],{},"产物",[68,537,538],{},"用于",[75,540,541,557,578,599],{},[65,542,543,548,551,554],{},[80,544,545],{},[18,546,547],{},"nuxt dev",[80,549,550],{},"带 SSR 的开发服务器",[80,552,553],{},"无（内存里跑）",[80,555,556],{},"日常开发",[65,558,559,563,569,575],{},[80,560,561],{},[18,562,273],{},[80,564,565,566],{},"生产构建，",[14,567,568],{},"保留服务端",[80,570,571,574],{},[18,572,573],{},".output\u002F","（含 Node server）",[80,576,577],{},"需要按请求动态渲染\u002F接口",[65,579,580,584,590,596],{},[80,581,582],{},[18,583,20],{},[80,585,586,587],{},"构建 + ",[14,588,589],{},"把所有页面预渲染成 HTML",[80,591,592,595],{},[18,593,594],{},".output\u002Fpublic\u002F","（纯静态）",[80,597,598],{},"内容站、部署到 Nginx\u002FOSS",[65,600,601,606,609,612],{},[80,602,603],{},[18,604,605],{},"nuxt preview",[80,607,608],{},"本地按产物方式预览",[80,610,611],{},"—",[80,613,614],{},"上线前必跑一遍",[10,616,617,620,621,624,625,628,629,210],{},[18,618,619],{},"nuxt prepare","（脚手架会挂到 ",[18,622,623],{},"postinstall","）用来生成 ",[18,626,627],{},".nuxt\u002F"," 里的类型与自动导入声明——",[14,630,631],{},"IDE 报「找不到 useFetch」多半是它没跑过",[168,633,635,636,638],{"id":634},"目录约定nuxt-4源码进-app","目录约定（Nuxt 4：源码进 ",[18,637,303],{},"）",[418,640,645],{"className":641,"code":643,"language":644},[642],"language-text","my-site\u002F\n├── nuxt.config.ts        唯一的总配置\n├── app\u002F                  ← Nuxt 4 的 srcDir\n│   ├── app.vue           根组件（挂 \u003CNuxtLayout>\u003CNuxtPage\u002F>\u003C\u002FNuxtLayout>）\n│   ├── error.vue         错误页（放在 app\u002F 根，专门显示 404\u002F500）\n│   ├── pages\u002F            页面 → 自动生成路由（第三章）\n│   ├── layouts\u002F          布局骨架（default.vue 默认全站套用）\n│   ├── components\u002F       组件，自动导入（本章最后一节）\n│   ├── composables\u002F      useXxx 组合式函数，自动导入\n│   ├── utils\u002F            纯函数，自动导入（无需 use 前缀）\n│   ├── plugins\u002F          插件（第十二节）\n│   ├── middleware\u002F       路由中间件（第十二章）\n│   └── assets\u002F           需要被打包的 css\u002F图片\u002F字体\n├── server\u002F               Nitro 服务端\n│   ├── api\u002F              文件即接口 → \u002Fapi\u002Fxxx\n│   ├── routes\u002F           不走 \u002Fapi 前缀的接口\n│   ├── middleware\u002F       服务端中间件\n│   └── utils\u002F            服务端内部工具，自动导入\n├── public\u002F               原样拷贝到产物根（favicon、静态 demo）\n├── content\u002F              内容模块的 Markdown（本站教程就放这里）\n└── .nuxt\u002F                构建缓存与生成物，别提交 git\n","text",[18,646,643],{"__ignoreMap":423},[10,648,649,651,652,655,656,304,661,664,665,668,669,672,673,676],{},[18,650,317],{}," 与 ",[18,653,654],{},"app\u002Fassets\u002F"," 的分界是最常问的：",[14,657,658,660],{},[18,659,317],{}," 里的文件路径不变、原样搬过去",[18,662,663],{},"public\u002Fimg\u002Flogo.png"," 直接用 ",[18,666,667],{},"\u002Fimg\u002Flogo.png","）；",[18,670,671],{},"assets\u002F"," 里的会被打包、加哈希、能参与 CSS ",[18,674,675],{},"url()"," 解析和体积优化。",[212,678],{"height":679,"src":680,"title":681},660,"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F02-directory-tree.html","示例 02：目录树点击器——每个目录会自动发生什么",[10,683,684],{},"示例 02 把整棵树做成了可点开的形式，顶部还能按关键词（layouts \u002F api \u002F public）过滤，忘了某个目录放什么就点一下。",[168,686,688,690],{"id":687},"nuxtconfigts-的最小可用版",[18,689,42],{}," 的最小可用版",[10,692,693],{},"本站的实际配置形状（有删减，注释是我加的）：",[418,695,699],{"className":696,"code":697,"language":698,"meta":423,"style":423},"language-ts shiki shiki-themes github-dark","\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  compatibilityDate: '2025-09-01',      \u002F\u002F 锁定行为版本，避免升级悄悄改默认值\n  devtools: { enabled: true },\n\n  ssr: true,                            \u002F\u002F 服务端渲染（默认就是 true）\n\n  modules: ['@nuxt\u002Fcontent', '@nuxtjs\u002Fsitemap'],\n\n  components: [{ path: '~\u002Fcomponents', pathPrefix: false }],  \u002F\u002F 关掉「目录名前缀」\n\n  nitro: {\n    prerender: {\n      crawlLinks: true,                 \u002F\u002F 从入口出发顺着链接爬，自动发现要生成的页面\n      routes: ['\u002F', '\u002Ftools', '\u002Farticles', '\u002Ftutorials']\n    }\n  },\n\n  site: { url: 'https:\u002F\u002Fyour-domain.com', name: '站名' }   \u002F\u002F sitemap \u002F OG 绝对地址用\n})\n","ts",[18,700,701,706,722,736,747,751,764,768,785,789,810,815,821,827,841,868,874,880,885,906],{"__ignoreMap":423},[427,702,703],{"class":429,"line":430},[427,704,705],{"class":433},"\u002F\u002F nuxt.config.ts\n",[427,707,708,712,715,718],{"class":429,"line":437},[427,709,711],{"class":710},"snl16","export",[427,713,714],{"class":710}," default",[427,716,717],{"class":440}," defineNuxtConfig",[427,719,721],{"class":720},"s95oV","({\n",[427,723,724,727,730,733],{"class":429,"line":454},[427,725,726],{"class":720},"  compatibilityDate: ",[427,728,729],{"class":444},"'2025-09-01'",[427,731,732],{"class":720},",      ",[427,734,735],{"class":433},"\u002F\u002F 锁定行为版本，避免升级悄悄改默认值\n",[427,737,738,741,744],{"class":429,"line":461},[427,739,740],{"class":720},"  devtools: { enabled: ",[427,742,743],{"class":489},"true",[427,745,746],{"class":720}," },\n",[427,748,749],{"class":429,"line":467},[427,750,458],{"emptyLinePlaceholder":457},[427,752,753,756,758,761],{"class":429,"line":481},[427,754,755],{"class":720},"  ssr: ",[427,757,743],{"class":489},[427,759,760],{"class":720},",                            ",[427,762,763],{"class":433},"\u002F\u002F 服务端渲染（默认就是 true）\n",[427,765,766],{"class":429,"line":486},[427,767,458],{"emptyLinePlaceholder":457},[427,769,770,773,776,779,782],{"class":429,"line":495},[427,771,772],{"class":720},"  modules: [",[427,774,775],{"class":444},"'@nuxt\u002Fcontent'",[427,777,778],{"class":720},", ",[427,780,781],{"class":444},"'@nuxtjs\u002Fsitemap'",[427,783,784],{"class":720},"],\n",[427,786,787],{"class":429,"line":503},[427,788,458],{"emptyLinePlaceholder":457},[427,790,792,795,798,801,804,807],{"class":429,"line":791},10,[427,793,794],{"class":720},"  components: [{ path: ",[427,796,797],{"class":444},"'~\u002Fcomponents'",[427,799,800],{"class":720},", pathPrefix: ",[427,802,803],{"class":489},"false",[427,805,806],{"class":720}," }],  ",[427,808,809],{"class":433},"\u002F\u002F 关掉「目录名前缀」\n",[427,811,813],{"class":429,"line":812},11,[427,814,458],{"emptyLinePlaceholder":457},[427,816,818],{"class":429,"line":817},12,[427,819,820],{"class":720},"  nitro: {\n",[427,822,824],{"class":429,"line":823},13,[427,825,826],{"class":720},"    prerender: {\n",[427,828,830,833,835,838],{"class":429,"line":829},14,[427,831,832],{"class":720},"      crawlLinks: ",[427,834,743],{"class":489},[427,836,837],{"class":720},",                 ",[427,839,840],{"class":433},"\u002F\u002F 从入口出发顺着链接爬，自动发现要生成的页面\n",[427,842,844,847,850,852,855,857,860,862,865],{"class":429,"line":843},15,[427,845,846],{"class":720},"      routes: [",[427,848,849],{"class":444},"'\u002F'",[427,851,778],{"class":720},[427,853,854],{"class":444},"'\u002Ftools'",[427,856,778],{"class":720},[427,858,859],{"class":444},"'\u002Farticles'",[427,861,778],{"class":720},[427,863,864],{"class":444},"'\u002Ftutorials'",[427,866,867],{"class":720},"]\n",[427,869,871],{"class":429,"line":870},16,[427,872,873],{"class":720},"    }\n",[427,875,877],{"class":429,"line":876},17,[427,878,879],{"class":720},"  },\n",[427,881,883],{"class":429,"line":882},18,[427,884,458],{"emptyLinePlaceholder":457},[427,886,888,891,894,897,900,903],{"class":429,"line":887},19,[427,889,890],{"class":720},"  site: { url: ",[427,892,893],{"class":444},"'https:\u002F\u002Fyour-domain.com'",[427,895,896],{"class":720},", name: ",[427,898,899],{"class":444},"'站名'",[427,901,902],{"class":720}," }   ",[427,904,905],{"class":433},"\u002F\u002F sitemap \u002F OG 绝对地址用\n",[427,907,909],{"class":429,"line":908},20,[427,910,911],{"class":720},"})\n",[10,913,914,917,918,921,922,311,924,927],{},[18,915,916],{},"~"," 别名指向 ",[18,919,920],{},"srcDir","（Nuxt 4 里就是 ",[18,923,303],{},[18,925,926],{},"@@"," 指向项目根目录。",[168,929,931],{"id":930},"自动导入nuxt-最省力也最容易懵的一层","自动导入：Nuxt 最省力也最容易懵的一层",[10,933,934,935,938,939,941],{},"下面这些",[14,936,937],{},"不需要 import","，在 ",[18,940,303],{}," 里直接写就能用：",[59,943,944,957],{},[62,945,946],{},[65,947,948,951,954],{},[68,949,950],{},"类别",[68,952,953],{},"例子",[68,955,956],{},"规则",[75,958,959,985,1013,1035,1068,1089,1104],{},[65,960,961,964,979],{},[80,962,963],{},"Vue \u002F Vue Router API",[80,965,966,28,968,28,970,28,973,28,976],{},[18,967,243],{},[18,969,246],{},[18,971,972],{},"watch",[18,974,975],{},"useRoute",[18,977,978],{},"useRouter",[80,980,981,982,984],{},"自动按需引入（",[18,983,108],{}," 手写也行，不会重复）",[65,986,987,990,1010],{},[80,988,989],{},"Nuxt 组合式函数",[80,991,992,28,995,28,998,28,1001,28,1004,28,1007],{},[18,993,994],{},"useFetch",[18,996,997],{},"useAsyncData",[18,999,1000],{},"useHead",[18,1002,1003],{},"navigateTo",[18,1005,1006],{},"callOnce",[18,1008,1009],{},"useState",[80,1011,1012],{},"内置",[65,1014,1015,1018,1032],{},[80,1016,1017],{},"编译器宏",[80,1019,1020,28,1023,28,1026,28,1029],{},[18,1021,1022],{},"definePageMeta",[18,1024,1025],{},"defineNuxtRouteMiddleware",[18,1027,1028],{},"defineNuxtPlugin",[18,1030,1031],{},"defineAppConfig",[80,1033,1034],{},"别去 import，它们由编译器在构建时替换",[65,1036,1037,1042,1050],{},[80,1038,1039,1041],{},[18,1040,114],{}," 的组件",[80,1043,1044,308,1047],{},[18,1045,1046],{},"AppHeader.vue",[18,1048,1049],{},"\u003CAppHeader>",[80,1051,1052,1053,1056,1057,308,1060,1063,1064,1067],{},"默认",[14,1054,1055],{},"带目录前缀","：",[18,1058,1059],{},"components\u002Fui\u002FBaseButton.vue",[18,1061,1062],{},"\u003CUiBaseButton>","；用上面 ",[18,1065,1066],{},"pathPrefix: false"," 关掉",[65,1069,1070,1074,1079],{},[80,1071,1072],{},[18,1073,129],{},[80,1075,1076],{},[18,1077,1078],{},"useDocToc()",[80,1080,1081,1082,1085,1086],{},"文件名里必须是 ",[18,1083,1084],{},"use-xxx"," \u002F ",[18,1087,1088],{},"useXxx",[65,1090,1091,1096,1101],{},[80,1092,1093],{},[18,1094,1095],{},"app\u002Futils\u002F",[80,1097,1098],{},[18,1099,1100],{},"formatDate()",[80,1102,1103],{},"无命名限制",[65,1105,1106,1111,1117],{},[80,1107,1108],{},[18,1109,1110],{},"server\u002Futils\u002F",[80,1112,1113,1116],{},[18,1114,1115],{},"useDb()","（仅服务端可用）",[80,1118,1119],{},"服务端自动导入",[10,1121,1122,1125,1126,28,1129,28,1132,1135,1136,1139,1140,1143,1144,1146],{},[14,1123,1124],{},"不会自动导入的","：第三方库（",[18,1127,1128],{},"axios",[18,1130,1131],{},"lodash-es",[18,1133,1134],{},"dayjs","）、你自己的非约定目录（",[18,1137,1138],{},"app\u002Fhelpers\u002F","）、Node 内置模块（",[18,1141,1142],{},"node:fs","，而且只能在 ",[18,1145,314],{}," 里用）。",[212,1148],{"height":1149,"src":1150,"title":1151},700,"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F04-auto-import.html","示例 04：哪些不用 import —— 自动导入边界自测",[168,1153,261],{"id":1154},"️-本章的坑-1",[10,1156,1157,1163,1164,1166,1167,1170,1171,1174,1175,1178,1179,210],{},[14,1158,1159,1160,210],{},"1. IDE 报 ",[18,1161,1162],{},"Cannot find name 'useFetch'"," 没跑 ",[18,1165,619],{},"（或 ",[18,1168,1169],{},"npm install"," 后没触发 postinstall）。跑一次即可，",[18,1172,1173],{},".nuxt\u002Ftypes\u002F"," 里就有声明。TS 项目还要确认 ",[18,1176,1177],{},"tsconfig.json"," 是 ",[18,1180,1181],{},"extends \".\u002F.nuxt\u002Ftsconfig.json\"",[10,1183,1184,1190,1191,1194,1195,1197,1198,1201,1202,1205],{},[14,1185,1186,1187,1189],{},"2. ",[18,1188,1177],{}," 指向了错误的 workspace。"," Nuxt 会生成 ",[18,1192,1193],{},".nuxt\u002Ftsconfig.server.json"," 给 ",[18,1196,314],{}," 用（两套上下文不同：一个有 ",[18,1199,1200],{},"window","，一个有 ",[18,1203,1204],{},"process","）。把整个项目塞进同一份 tsconfig 里会看到一堆假报错。",[10,1207,1208,1215,1216,308,1218,1220,1221,210,1224,1227],{},[14,1209,1210,1211,1214],{},"3. 组件目录前缀让 ",[18,1212,1213],{},"\u003CBaseButton>"," 找不到。"," 默认规则是「子目录名会拼进组件名」（",[18,1217,1059],{},[18,1219,1062],{},"）。两种解法：按规则写名字，或配 ",[18,1222,1223],{},"components: [{ path: '~\u002Fcomponents', pathPrefix: false }]",[14,1225,1226],{},"同一项目里选一种，别混","——混用会出现「同名组件被两个来源解析」。",[10,1229,1230,1233,1234,1237,1238,1241,1242,1245],{},[14,1231,1232],{},"4. 组件名冲突时静默覆盖。"," 两个不同目录里的同名 ",[18,1235,1236],{},"Card.vue","（关掉前缀后尤其容易），后者赢，且只有一个能用。给基础组件加前缀（",[18,1239,1240],{},"Base","\u002F",[18,1243,1244],{},"App","\u002F领域名）是根治办法。",[10,1247,1248,1257,1258,1260,1261,1263,1264,1266,1267],{},[14,1249,1250,1251,1253,1254,1256],{},"5. 把 ",[18,1252,317],{}," 当 ",[18,1255,671],{}," 用。"," ",[18,1259,317],{}," 里的图片不参与哈希与优化，改文件后浏览器可能拿旧缓存；",[18,1262,671],{}," 里的被打包且带哈希。反过来把大文件塞进 ",[18,1265,671],{}," 会让构建变慢。经验：",[14,1268,1269],{},"要缓存指纹的进 assets，直接按路径引用的（favicon、robots.txt、demo）进 public。",[10,1271,1272,1281,1282,1284],{},[14,1273,1274,1275,1277,1278,210],{},"6. 在 ",[18,1276,303],{}," 里 ",[18,1279,1280],{},"import 'node:fs'"," Nuxt 不会拦你，但运行到服务端上下文之外就炸。需要在服务端读文件的逻辑请放 ",[18,1283,314],{},"（第七章）。",[10,1286,1287,1293,1294,210],{},[14,1288,1289,1290,1292],{},"7. ",[18,1291,627],{}," 提交进 git 或手工编辑。"," 那是生成物，随时会被覆盖。要改配置就改 ",[18,1295,42],{},[10,1297,1298,1305],{},[14,1299,1300,1301,1304],{},"8. ",[18,1302,1303],{},"compatibilityDate"," 忘了设。"," 不设时 Nuxt 会用一个默认日期，未来升级后部分行为默认值改变（例如某些序列化、路由匹配细节）。显式写死它，是「升级不炸」的最低成本保险。",[168,1307,1309],{"id":1308},"附nuxt-3-nuxt-4-迁移对照","附：Nuxt 3 → Nuxt 4 迁移对照",[59,1311,1312,1325],{},[62,1313,1314],{},[65,1315,1316,1319,1322],{},[68,1317,1318],{},"项目",[68,1320,1321],{},"Nuxt 3",[68,1323,1324],{},"Nuxt 4",[75,1326,1327,1348,1359,1390,1402,1420,1432,1442],{},[65,1328,1329,1332,1340],{},[80,1330,1331],{},"源码目录",[80,1333,1334,1335,28,1337,1339],{},"根目录 ",[18,1336,307],{},[18,1338,104],{},"…",[80,1341,1342,1343,304,1345,638],{},"全部进 ",[18,1344,303],{},[18,1346,1347],{},"srcDir: 'app\u002F'",[65,1349,1350,1353,1356],{},[80,1351,1352],{},"服务端 \u002F public \u002F content",[80,1354,1355],{},"根目录",[80,1357,1358],{},"不变（仍在根目录）",[65,1360,1361,1364,1376],{},[80,1362,1363],{},"异步数据默认值",[80,1365,1366,1368,1369,1372,1373],{},[18,1367,994],{}," 无 ",[18,1370,1371],{},"default"," 时给 ",[18,1374,1375],{},"null",[80,1377,1378,1379,1382,1383,1386,1387,638],{},"需要通过 ",[18,1380,1381],{},"default: () => …"," 显式给（",[18,1384,1385],{},"data"," 初始为 ",[18,1388,1389],{},"undefined",[65,1391,1392,1396,1399],{},[80,1393,1394],{},[18,1395,1009],{},[80,1397,1398],{},"可在组件外调用",[80,1400,1401],{},"必须在 setup 上下文内",[65,1403,1404,1411,1417],{},[80,1405,1406,1407,1410],{},"路由 ",[18,1408,1409],{},"meta"," 里的 key 前缀、hydration 属性",[80,1412,1413,1416],{},[18,1414,1415],{},"data-v-app"," 等",[80,1418,1419],{},"若干 DOM 细节变化（依赖第三方 CSS 选择器时要注意）",[65,1421,1422,1425,1427],{},[80,1423,1424],{},"升级命令",[80,1426,611],{},[80,1428,1429],{},[18,1430,1431],{},"npx nuxi@latest upgrade --force",[65,1433,1434,1436,1438],{},[80,1435,1424],{},[80,1437,611],{},[80,1439,1440],{},[18,1441,1431],{},[65,1443,1444,1447,1449],{},[80,1445,1446],{},"官方 codemod（批量搬目录）",[80,1448,611],{},[80,1450,1451,1454],{},[18,1452,1453],{},"npx @nuxt\u002Fupgrade-nuxt-4","（或 upgrade 向导里的 move files）",[45,1456,1458],{"id":1457},"三页面与路由文件名就是-url","三、页面与路由：文件名就是 URL",[10,1460,1461,1463,1464,1467,1468,1471,1472,1474],{},[18,1462,95],{}," 下的 ",[18,1465,1466],{},".vue"," 文件会被编译成路由表——",[14,1469,1470],{},"没有单独的路由配置文件需要维护","（背后仍是 Vue Router，",[18,1473,85],{}," 是 Nuxt 的依赖）。这也是 Nuxt 相比手搭 Vite 项目省掉的最大一块活。",[168,1476,1477],{"id":1477},"路径映射规则",[418,1479,1482],{"className":1480,"code":1481,"language":644},[642],"app\u002Fpages\u002Findex.vue                 →  \u002F\napp\u002Fpages\u002Fabout.vue                 →  \u002Fabout\napp\u002Fpages\u002Fblog\u002Findex.vue            →  \u002Fblog\napp\u002Fpages\u002Fblog\u002F[slug].vue           →  \u002Fblog\u002F:slug            动态段\napp\u002Fpages\u002Fusers\u002F[id]\u002Forders.vue     →  \u002Fusers\u002F:id\u002Forders\napp\u002Fpages\u002Fdocs\u002F[...path].vue        →  \u002Fdocs\u002F*                通配（catch-all）\napp\u002Fpages\u002F(marketing)\u002Fpricing.vue   →  \u002Fpricing               括号目录（分组）不进 URL\napp\u002Fpages\u002Fp\u002F[[id]].vue              →  \u002Fp\u002F:id?                可选参数（双中括号）\n",[18,1483,1481],{"__ignoreMap":423},[10,1485,1486,304,1489,28,1492,1495],{},[14,1487,1488],{},"分组目录",[18,1490,1491],{},"(admin)\u002F",[18,1493,1494],{},"(marketing)\u002F","）纯粹用来在文件管理器里归类文件，对 URL 无影响；同名的分组可以跨目录合并到同一个父路由。",[168,1497,1499,1500],{"id":1498},"嵌套页面父组件里要写-nuxtpage","嵌套页面：父组件里要写 ",[18,1501,1502],{},"\u003CNuxtPage \u002F>",[10,1504,1505,1506,1512],{},"这是新手最容易卡住的一条：",[14,1507,1508,1509,1511],{},"父级页面模板里没有 ",[18,1510,1502],{},"，子路由就渲染不出来","（不报错，只是空白）。",[418,1514,1518],{"className":1515,"code":1516,"language":1517,"meta":423,"style":423},"language-vue shiki shiki-themes github-dark","\u003C!-- app\u002Fpages\u002Fdashboard.vue：\u002Fdashboard 与 \u002Fdashboard\u002Fanalytics 共用的外壳 -->\n\u003Ctemplate>\n  \u003Cdiv class=\"dash\">\n    \u003Caside>侧边栏（子路由切换时它不会重建）\u003C\u002Faside>\n    \u003CNuxtPage \u002F>          \u003C!-- 子页面渲染在这里 -->\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n\n\u003C!-- app\u002Fpages\u002Fdashboard\u002Findex.vue → \u002Fdashboard -->\n\u003C!-- app\u002Fpages\u002Fdashboard\u002Fanalytics.vue → \u002Fdashboard\u002Fanalytics -->\n","vue",[18,1519,1520,1525,1537,1556,1571,1584,1593,1602,1606,1611],{"__ignoreMap":423},[427,1521,1522],{"class":429,"line":430},[427,1523,1524],{"class":433},"\u003C!-- app\u002Fpages\u002Fdashboard.vue：\u002Fdashboard 与 \u002Fdashboard\u002Fanalytics 共用的外壳 -->\n",[427,1526,1527,1530,1534],{"class":429,"line":437},[427,1528,1529],{"class":720},"\u003C",[427,1531,1533],{"class":1532},"s4JwU","template",[427,1535,1536],{"class":720},">\n",[427,1538,1539,1542,1545,1548,1551,1554],{"class":429,"line":454},[427,1540,1541],{"class":720},"  \u003C",[427,1543,1544],{"class":1532},"div",[427,1546,1547],{"class":440}," class",[427,1549,1550],{"class":720},"=",[427,1552,1553],{"class":444},"\"dash\"",[427,1555,1536],{"class":720},[427,1557,1558,1561,1564,1567,1569],{"class":429,"line":461},[427,1559,1560],{"class":720},"    \u003C",[427,1562,1563],{"class":1532},"aside",[427,1565,1566],{"class":720},">侧边栏（子路由切换时它不会重建）\u003C\u002F",[427,1568,1563],{"class":1532},[427,1570,1536],{"class":720},[427,1572,1573,1575,1578,1581],{"class":429,"line":467},[427,1574,1560],{"class":720},[427,1576,1577],{"class":1532},"NuxtPage",[427,1579,1580],{"class":720}," \u002F>          ",[427,1582,1583],{"class":433},"\u003C!-- 子页面渲染在这里 -->\n",[427,1585,1586,1589,1591],{"class":429,"line":481},[427,1587,1588],{"class":720},"  \u003C\u002F",[427,1590,1544],{"class":1532},[427,1592,1536],{"class":720},[427,1594,1595,1598,1600],{"class":429,"line":486},[427,1596,1597],{"class":720},"\u003C\u002F",[427,1599,1533],{"class":1532},[427,1601,1536],{"class":720},[427,1603,1604],{"class":429,"line":495},[427,1605,458],{"emptyLinePlaceholder":457},[427,1607,1608],{"class":429,"line":503},[427,1609,1610],{"class":433},"\u003C!-- app\u002Fpages\u002Fdashboard\u002Findex.vue → \u002Fdashboard -->\n",[427,1612,1613],{"class":429,"line":791},[427,1614,1615],{"class":433},"\u003C!-- app\u002Fpages\u002Fdashboard\u002Fanalytics.vue → \u002Fdashboard\u002Fanalytics -->\n",[10,1617,1618],{},"只换子页面时父组件实例被复用（和 Vue Router 的行为一致），这正是「切换 tab 但侧边栏不闪」的实现方式。",[168,1620,1622,1623,651,1626],{"id":1621},"导航nuxtlink-与-navigateto","导航：",[18,1624,1625],{},"\u003CNuxtLink>",[18,1627,1628],{},"navigateTo()",[418,1630,1632],{"className":1515,"code":1631,"language":1517,"meta":423,"style":423},"\u003Ctemplate>\n  \u003CNuxtLink to=\"\u002Fabout\">关于\u003C\u002FNuxtLink>\n  \u003CNuxtLink :to=\"{ path: '\u002Fblog\u002Fvue3', query: { utm: 'nav' } }\">读一读\u003C\u002FNuxtLink>\n  \u003CNuxtLink to=\"\u002Fblog\" active-class=\"on\" exact-active-class=\"on-exact\">博客\u003C\u002FNuxtLink>\n  \u003CNuxtLink to=\"https:\u002F\u002Fexample.com\" external target=\"_blank\" rel=\"noopener\">站外链接\u003C\u002FNuxtLink>\n  \u003CNuxtLink :custom=\"false\">…\u003C\u002FNuxtLink>\n\u003C\u002Ftemplate>\n\n\u003Cscript setup>\n\u002F\u002F 命令式跳转\nawait navigateTo('\u002Flogin?redirect=' + route.fullPath)      \u002F\u002F 推荐：SSR 期间也能正确发起跳转\n\u002F\u002F router.push('\u002Fx')  仍然可用，但 SSR 阶段请优先 navigateTo\nconst route = useRoute()\nconst router = useRouter()\n\u003C\u002Fscript>\n",[18,1633,1634,1642,1664,1705,1741,1780,1806,1814,1818,1830,1835,1858,1863,1880,1894],{"__ignoreMap":423},[427,1635,1636,1638,1640],{"class":429,"line":430},[427,1637,1529],{"class":720},[427,1639,1533],{"class":1532},[427,1641,1536],{"class":720},[427,1643,1644,1646,1649,1652,1654,1657,1660,1662],{"class":429,"line":437},[427,1645,1541],{"class":720},[427,1647,1648],{"class":1532},"NuxtLink",[427,1650,1651],{"class":440}," to",[427,1653,1550],{"class":720},[427,1655,1656],{"class":444},"\"\u002Fabout\"",[427,1658,1659],{"class":720},">关于\u003C\u002F",[427,1661,1648],{"class":1532},[427,1663,1536],{"class":720},[427,1665,1666,1668,1670,1673,1676,1678,1681,1684,1687,1690,1693,1696,1698,1701,1703],{"class":429,"line":454},[427,1667,1541],{"class":720},[427,1669,1648],{"class":1532},[427,1671,1672],{"class":720}," :",[427,1674,1675],{"class":440},"to",[427,1677,1550],{"class":720},[427,1679,1680],{"class":444},"\"",[427,1682,1683],{"class":720},"{ path: ",[427,1685,1686],{"class":444},"'\u002Fblog\u002Fvue3'",[427,1688,1689],{"class":720},", query: { utm: ",[427,1691,1692],{"class":444},"'nav'",[427,1694,1695],{"class":720}," } }",[427,1697,1680],{"class":444},[427,1699,1700],{"class":720},">读一读\u003C\u002F",[427,1702,1648],{"class":1532},[427,1704,1536],{"class":720},[427,1706,1707,1709,1711,1713,1715,1718,1721,1723,1726,1729,1731,1734,1737,1739],{"class":429,"line":461},[427,1708,1541],{"class":720},[427,1710,1648],{"class":1532},[427,1712,1651],{"class":440},[427,1714,1550],{"class":720},[427,1716,1717],{"class":444},"\"\u002Fblog\"",[427,1719,1720],{"class":440}," active-class",[427,1722,1550],{"class":720},[427,1724,1725],{"class":444},"\"on\"",[427,1727,1728],{"class":440}," exact-active-class",[427,1730,1550],{"class":720},[427,1732,1733],{"class":444},"\"on-exact\"",[427,1735,1736],{"class":720},">博客\u003C\u002F",[427,1738,1648],{"class":1532},[427,1740,1536],{"class":720},[427,1742,1743,1745,1747,1749,1751,1754,1757,1760,1762,1765,1768,1770,1773,1776,1778],{"class":429,"line":467},[427,1744,1541],{"class":720},[427,1746,1648],{"class":1532},[427,1748,1651],{"class":440},[427,1750,1550],{"class":720},[427,1752,1753],{"class":444},"\"https:\u002F\u002Fexample.com\"",[427,1755,1756],{"class":440}," external",[427,1758,1759],{"class":440}," target",[427,1761,1550],{"class":720},[427,1763,1764],{"class":444},"\"_blank\"",[427,1766,1767],{"class":440}," rel",[427,1769,1550],{"class":720},[427,1771,1772],{"class":444},"\"noopener\"",[427,1774,1775],{"class":720},">站外链接\u003C\u002F",[427,1777,1648],{"class":1532},[427,1779,1536],{"class":720},[427,1781,1782,1784,1786,1788,1791,1793,1795,1797,1799,1802,1804],{"class":429,"line":481},[427,1783,1541],{"class":720},[427,1785,1648],{"class":1532},[427,1787,1672],{"class":720},[427,1789,1790],{"class":440},"custom",[427,1792,1550],{"class":720},[427,1794,1680],{"class":444},[427,1796,803],{"class":489},[427,1798,1680],{"class":444},[427,1800,1801],{"class":720},">…\u003C\u002F",[427,1803,1648],{"class":1532},[427,1805,1536],{"class":720},[427,1807,1808,1810,1812],{"class":429,"line":486},[427,1809,1597],{"class":720},[427,1811,1533],{"class":1532},[427,1813,1536],{"class":720},[427,1815,1816],{"class":429,"line":495},[427,1817,458],{"emptyLinePlaceholder":457},[427,1819,1820,1822,1825,1828],{"class":429,"line":503},[427,1821,1529],{"class":720},[427,1823,1824],{"class":1532},"script",[427,1826,1827],{"class":440}," setup",[427,1829,1536],{"class":720},[427,1831,1832],{"class":429,"line":791},[427,1833,1834],{"class":433},"\u002F\u002F 命令式跳转\n",[427,1836,1837,1840,1843,1846,1849,1852,1855],{"class":429,"line":812},[427,1838,1839],{"class":710},"await",[427,1841,1842],{"class":440}," navigateTo",[427,1844,1845],{"class":720},"(",[427,1847,1848],{"class":444},"'\u002Flogin?redirect='",[427,1850,1851],{"class":710}," +",[427,1853,1854],{"class":720}," route.fullPath)      ",[427,1856,1857],{"class":433},"\u002F\u002F 推荐：SSR 期间也能正确发起跳转\n",[427,1859,1860],{"class":429,"line":817},[427,1861,1862],{"class":433},"\u002F\u002F router.push('\u002Fx')  仍然可用，但 SSR 阶段请优先 navigateTo\n",[427,1864,1865,1868,1871,1874,1877],{"class":429,"line":823},[427,1866,1867],{"class":710},"const",[427,1869,1870],{"class":489}," route",[427,1872,1873],{"class":710}," =",[427,1875,1876],{"class":440}," useRoute",[427,1878,1879],{"class":720},"()\n",[427,1881,1882,1884,1887,1889,1892],{"class":429,"line":829},[427,1883,1867],{"class":710},[427,1885,1886],{"class":489}," router",[427,1888,1873],{"class":710},[427,1890,1891],{"class":440}," useRouter",[427,1893,1879],{"class":720},[427,1895,1896,1898,1900],{"class":429,"line":843},[427,1897,1597],{"class":720},[427,1899,1824],{"class":1532},[427,1901,1536],{"class":720},[10,1903,1904,1906,1907,1910,1911,1914,1915,1918,1919,1922,1923,1926,1927,1930],{},[18,1905,1625],{}," 相比 ",[18,1908,1909],{},"\u003CRouterLink>"," 多做了几件事：自动给",[14,1912,1913],{},"外链","加 ",[18,1916,1917],{},"target","（配合 ",[18,1920,1921],{},"external","）、默认对站内链接开启",[14,1924,1925],{},"组件与数据预取","（鼠标悬停即预取，点击时数据已在本地）、可写 ",[18,1928,1929],{},"no-prefetch"," 关掉。",[168,1932,1934,1935],{"id":1933},"页面级配置definepagemeta","页面级配置：",[18,1936,1022],{},[10,1938,1939,1940,1942,1943,1056],{},"写在页面组件的 ",[18,1941,240],{}," 顶层，编译器宏，",[14,1944,1945],{},"不要 import",[418,1947,1949],{"className":1515,"code":1948,"language":1517,"meta":423,"style":423},"\u003Cscript setup>\ndefinePageMeta({\n  name: 'blog-detail',           \u002F\u002F 命名路由，便于 { name, params } 跳转\n  layout: 'doc',                 \u002F\u002F 套用 app\u002Flayouts\u002Fdoc.vue；false 表示不用布局\n  middleware: ['auth'],          \u002F\u002F 命名中间件（第十二章）\n  meta: { public: true },         \u002F\u002F 自定义字段，中间件里 to.meta.public 读得到\n  keepAlive: true,               \u002F\u002F 页面级缓存（切走不销毁）\n  keepalive: { max: 5 },         \u002F\u002F 更细的配置写法\n  scrollToTop: true,             \u002F\u002F 进入本页滚到顶部\n  validate: (route) => \u002F^\\d{4}$\u002F.test(route.params.year)   \u002F\u002F 参数不合法直接 404\n})\n\u003C\u002Fscript>\n",[18,1950,1951,1961,1967,1981,1994,2008,2021,2034,2047,2060,2104,2108],{"__ignoreMap":423},[427,1952,1953,1955,1957,1959],{"class":429,"line":430},[427,1954,1529],{"class":720},[427,1956,1824],{"class":1532},[427,1958,1827],{"class":440},[427,1960,1536],{"class":720},[427,1962,1963,1965],{"class":429,"line":437},[427,1964,1022],{"class":440},[427,1966,721],{"class":720},[427,1968,1969,1972,1975,1978],{"class":429,"line":454},[427,1970,1971],{"class":720},"  name: ",[427,1973,1974],{"class":444},"'blog-detail'",[427,1976,1977],{"class":720},",           ",[427,1979,1980],{"class":433},"\u002F\u002F 命名路由，便于 { name, params } 跳转\n",[427,1982,1983,1986,1989,1991],{"class":429,"line":461},[427,1984,1985],{"class":720},"  layout: ",[427,1987,1988],{"class":444},"'doc'",[427,1990,837],{"class":720},[427,1992,1993],{"class":433},"\u002F\u002F 套用 app\u002Flayouts\u002Fdoc.vue；false 表示不用布局\n",[427,1995,1996,1999,2002,2005],{"class":429,"line":467},[427,1997,1998],{"class":720},"  middleware: [",[427,2000,2001],{"class":444},"'auth'",[427,2003,2004],{"class":720},"],          ",[427,2006,2007],{"class":433},"\u002F\u002F 命名中间件（第十二章）\n",[427,2009,2010,2013,2015,2018],{"class":429,"line":481},[427,2011,2012],{"class":720},"  meta: { public: ",[427,2014,743],{"class":489},[427,2016,2017],{"class":720}," },         ",[427,2019,2020],{"class":433},"\u002F\u002F 自定义字段，中间件里 to.meta.public 读得到\n",[427,2022,2023,2026,2028,2031],{"class":429,"line":486},[427,2024,2025],{"class":720},"  keepAlive: ",[427,2027,743],{"class":489},[427,2029,2030],{"class":720},",               ",[427,2032,2033],{"class":433},"\u002F\u002F 页面级缓存（切走不销毁）\n",[427,2035,2036,2039,2042,2044],{"class":429,"line":495},[427,2037,2038],{"class":720},"  keepalive: { max: ",[427,2040,2041],{"class":489},"5",[427,2043,2017],{"class":720},[427,2045,2046],{"class":433},"\u002F\u002F 更细的配置写法\n",[427,2048,2049,2052,2054,2057],{"class":429,"line":503},[427,2050,2051],{"class":720},"  scrollToTop: ",[427,2053,743],{"class":489},[427,2055,2056],{"class":720},",             ",[427,2058,2059],{"class":433},"\u002F\u002F 进入本页滚到顶部\n",[427,2061,2062,2065,2068,2072,2075,2078,2081,2084,2087,2090,2092,2095,2098,2101],{"class":429,"line":791},[427,2063,2064],{"class":440},"  validate",[427,2066,2067],{"class":720},": (",[427,2069,2071],{"class":2070},"s9osk","route",[427,2073,2074],{"class":720},") ",[427,2076,2077],{"class":710},"=>",[427,2079,2080],{"class":444}," \u002F",[427,2082,2083],{"class":710},"^",[427,2085,2086],{"class":489},"\\d",[427,2088,2089],{"class":710},"{4}$",[427,2091,1241],{"class":444},[427,2093,2094],{"class":720},".",[427,2096,2097],{"class":440},"test",[427,2099,2100],{"class":720},"(route.params.year)   ",[427,2102,2103],{"class":433},"\u002F\u002F 参数不合法直接 404\n",[427,2105,2106],{"class":429,"line":812},[427,2107,911],{"class":720},[427,2109,2110,2112,2114],{"class":429,"line":817},[427,2111,1597],{"class":720},[427,2113,1824],{"class":1532},[427,2115,1536],{"class":720},[10,2117,2118,2121,2122,2125],{},[18,2119,2120],{},"validate"," 是很划算的一个字段：一个函数就能把 ",[18,2123,2124],{},"\u002Fblog\u002F2026-abcd"," 这种脏 URL 变成干净的 404，而不是让页面组件去处理空数据。",[212,2127],{"height":2128,"src":2129,"title":2130},760,"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F03-file-routing.html","示例 03：文件路径 ⇄ URL 双向查询与练习",[10,2132,2133,2134,2137,2138,2141],{},"示例 03 上半部分是真的规则引擎（输入 ",[18,2135,2136],{},"blog\u002F[slug].vue"," 就出 ",[18,2139,2140],{},"\u002Fblog\u002F:slug","），下半部分是五道反向练习题。",[168,2143,261],{"id":2144},"️-本章的坑-2",[10,2146,2147,2152,2153,2155,2156,278],{},[14,2148,2149,2150,210],{},"1. 父页面忘写 ",[18,2151,1502],{}," 子路由一片空白，控制台干净。排查顺序：父组件模板 → 子文件是否在 ",[18,2154,307],{}," 下（不是 ",[18,2157,2158],{},"views\u002F",[10,2160,2161,2169,2170,2173,2174,2177,2178,2181,2182,2184,2185,2188,2189,2192,2193,2195,2196,2199],{},[14,2162,1186,2163,2165,2166,2168],{},[18,2164,95],{}," 下所有 ",[18,2167,1466],{}," 都会变成路由。"," 你随手放的 ",[18,2171,2172],{},"README.vue","、误建在 ",[18,2175,2176],{},"app\u002Fpages\u002Fcomponents\u002FCard.vue"," 的组件，都会生成一个可被访问的页面（",[18,2179,2180],{},"\u002Fcomponents\u002Fcard","）。非页面组件请一律放 ",[18,2183,114],{},"；只想给某个页面用的私有组件，放 ",[18,2186,2187],{},"app\u002Fcomponents\u002F\u003C页面名>\u002F"," 下（例如 ",[18,2190,2191],{},"app\u002Fcomponents\u002Farticle\u002FCommentBox.vue","），别留在 ",[18,2194,307],{}," 里。若要依赖「以 ",[18,2197,2198],{},"-"," 开头的文件被忽略」这类规则，请先在你的 Nuxt 版本上实测确认再生效。",[10,2201,2202,2211,2212,2214,2215,2217],{},[14,2203,2204,2205,651,2207,2210],{},"3. ",[18,2206,1003],{},[18,2208,2209],{},"router.push"," 混用。"," 服务端渲染阶段（比如中间件里）要用 ",[18,2213,1628],{},"——它会把跳转变成一次真正的 HTTP 重定向；",[18,2216,2209],{}," 在 SSR 阶段没有意义。",[10,2219,2220,1257,2223,2226,2227,2230,2231,2234,2235,2238,2239,304,2242,2245],{},[14,2221,2222],{},"4. 动态段参数是字符串。",[18,2224,2225],{},"route.params.id"," 拿到 ",[18,2228,2229],{},"'42'","，直接参与 ",[18,2232,2233],{},"==="," 比较或算术就有类型问题。要数字就在跳转处传数字、在页面里 ",[18,2236,2237],{},"Number(route.params.id)","，或用 ",[18,2240,2241],{},"props: true",[18,2243,2244],{},"definePageMeta({ props: true })","）让 Nuxt 转成组件 props 后声明类型。",[10,2247,2248,2255,2256,2259,2260,1178,2263,2266],{},[14,2249,2250,2251,2254],{},"5. 通配路由 ",[18,2252,2253],{},"[...path].vue"," 会吃掉所有子路径。"," 它必须能处理「参数为空」的情况（",[18,2257,2258],{},"\u002Fdocs"," 本身命中时 ",[18,2261,2262],{},"path",[18,2264,2265],{},"[]","），否则进入父路径就是 500。",[10,2268,2269,1257,2275,308,2278,2281,2282,2285,2286,2289],{},[14,2270,2271,2272,210],{},"6. 只有 query 变化的跳转不会重跑 ",[18,2273,2274],{},"onMounted",[18,2276,2277],{},"\u002Flist?page=1",[18,2279,2280],{},"\u002Flist?page=2"," 复用同一组件实例。数据要跟着 query 走，请用 ",[18,2283,2284],{},"useFetch(url, { watch: [query] })"," 或 ",[18,2287,2288],{},"watch(() => route.query, ...)","（第五章展开）。",[10,2291,2292,2298,2299,2301,2302,2305],{},[14,2293,1289,2294,2297],{},[18,2295,2296],{},"keepAlive: true"," 后页面数据不再刷新。"," 与 Vue 教程第十四章同一个坑：",[18,2300,2274],{}," 只跑一次，需要「每次进入都刷新」请写 ",[18,2303,2304],{},"onActivated","，或对该页面关掉 keepAlive。",[10,2307,2308,1257,2311,2314],{},[14,2309,2310],{},"8. 分组目录名里带斜杠或特殊字符。",[18,2312,2313],{},"(a\u002Fb)"," 是非法的，会被当成两层目录。分组名请只用字母数字和连字符。",[168,2316,2318],{"id":2317},"附文件名-url-速查","附：文件名 → URL 速查",[59,2320,2321,2334],{},[62,2322,2323],{},[65,2324,2325,2328,2331],{},[68,2326,2327],{},"文件",[68,2329,2330],{},"URL",[68,2332,2333],{},"读参数",[75,2335,2336,2349,2363,2377,2393,2408,2426,2443,2457],{},[65,2337,2338,2343,2347],{},[80,2339,2340],{},[18,2341,2342],{},"pages\u002Findex.vue",[80,2344,2345],{},[18,2346,1241],{},[80,2348,611],{},[65,2350,2351,2356,2361],{},[80,2352,2353],{},[18,2354,2355],{},"pages\u002Fabout.vue",[80,2357,2358],{},[18,2359,2360],{},"\u002Fabout",[80,2362,611],{},[65,2364,2365,2370,2375],{},[80,2366,2367],{},[18,2368,2369],{},"pages\u002Fblog\u002Findex.vue",[80,2371,2372],{},[18,2373,2374],{},"\u002Fblog",[80,2376,611],{},[65,2378,2379,2384,2388],{},[80,2380,2381],{},[18,2382,2383],{},"pages\u002Fblog\u002F[slug].vue",[80,2385,2386],{},[18,2387,2140],{},[80,2389,2390],{},[18,2391,2392],{},"route.params.slug",[65,2394,2395,2400,2405],{},[80,2396,2397],{},[18,2398,2399],{},"pages\u002Fblog\u002F[year]\u002F[month].vue",[80,2401,2402],{},[18,2403,2404],{},"\u002Fblog\u002F:year\u002F:month",[80,2406,2407],{},"两个参数",[65,2409,2410,2415,2420],{},[80,2411,2412],{},[18,2413,2414],{},"pages\u002Fdocs\u002F[...path].vue",[80,2416,2417],{},[18,2418,2419],{},"\u002Fdocs\u002F*",[80,2421,2422,2425],{},[18,2423,2424],{},"route.params.path","（数组）",[65,2427,2428,2433,2438],{},[80,2429,2430],{},[18,2431,2432],{},"pages\u002Fp\u002F[[id]].vue",[80,2434,2435],{},[18,2436,2437],{},"\u002Fp\u002F:id?",[80,2439,2440,2441],{},"可能为 ",[18,2442,1389],{},[65,2444,2445,2450,2455],{},[80,2446,2447],{},[18,2448,2449],{},"pages\u002F(group)\u002Fx.vue",[80,2451,2452],{},[18,2453,2454],{},"\u002Fx",[80,2456,611],{},[65,2458,2459,2464,2469],{},[80,2460,2461],{},[18,2462,2463],{},"pages\u002Fusers\u002F[id]\u002Findex.vue",[80,2465,2466],{},[18,2467,2468],{},"\u002Fusers\u002F:id",[80,2470,2471,2472,2475,2476],{},"父 ",[18,2473,2474],{},"users\u002F[id].vue"," 需 ",[18,2477,2478],{},"\u003CNuxtPage\u002F>",[45,2480,2482],{"id":2481},"四布局错误页与页面骨架","四、布局、错误页与页面骨架",[10,2484,2485,2486,274,2489,2492],{},"一个站点的所有页面共享的「页头 + 容器 + 页脚」叫布局。Nuxt 用 ",[18,2487,2488],{},"app\u002Flayouts\u002F",[18,2490,2491],{},"\u003CNuxtLayout>"," 承担这件事。",[168,2494,2495],{"id":2495},"根组件与默认布局",[10,2497,2498,2499,2502],{},"Nuxt 4 里 ",[18,2500,2501],{},"app\u002Fapp.vue"," 是根组件，标准形状就三行：",[418,2504,2506],{"className":1515,"code":2505,"language":1517,"meta":423,"style":423},"\u003C!-- app\u002Fapp.vue -->\n\u003Ctemplate>\n  \u003CNuxtLayout>\n    \u003CNuxtPage \u002F>\n  \u003C\u002FNuxtLayout>\n\u003C\u002Ftemplate>\n",[18,2507,2508,2513,2521,2530,2539,2547],{"__ignoreMap":423},[427,2509,2510],{"class":429,"line":430},[427,2511,2512],{"class":433},"\u003C!-- app\u002Fapp.vue -->\n",[427,2514,2515,2517,2519],{"class":429,"line":437},[427,2516,1529],{"class":720},[427,2518,1533],{"class":1532},[427,2520,1536],{"class":720},[427,2522,2523,2525,2528],{"class":429,"line":454},[427,2524,1541],{"class":720},[427,2526,2527],{"class":1532},"NuxtLayout",[427,2529,1536],{"class":720},[427,2531,2532,2534,2536],{"class":429,"line":461},[427,2533,1560],{"class":720},[427,2535,1577],{"class":1532},[427,2537,2538],{"class":720}," \u002F>\n",[427,2540,2541,2543,2545],{"class":429,"line":467},[427,2542,1588],{"class":720},[427,2544,2527],{"class":1532},[427,2546,1536],{"class":720},[427,2548,2549,2551,2553],{"class":429,"line":481},[427,2550,1597],{"class":720},[427,2552,1533],{"class":1532},[427,2554,1536],{"class":720},[10,2556,2557,2560,2561,2563],{},[18,2558,2559],{},"app\u002Flayouts\u002Fdefault.vue"," 会被自动套上（因为文件名是 ",[18,2562,1371],{},"）：",[418,2565,2567],{"className":1515,"code":2566,"language":1517,"meta":423,"style":423},"\u003C!-- app\u002Flayouts\u002Fdefault.vue -->\n\u003Ctemplate>\n  \u003Cdiv class=\"site\">\n    \u003CSiteHeader \u002F>\n    \u003Cmain class=\"container\">\u003Cslot \u002F>\u003C\u002Fmain>\n    \u003CSiteFooter \u002F>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n",[18,2568,2569,2574,2582,2597,2606,2636,2645,2653],{"__ignoreMap":423},[427,2570,2571],{"class":429,"line":430},[427,2572,2573],{"class":433},"\u003C!-- app\u002Flayouts\u002Fdefault.vue -->\n",[427,2575,2576,2578,2580],{"class":429,"line":437},[427,2577,1529],{"class":720},[427,2579,1533],{"class":1532},[427,2581,1536],{"class":720},[427,2583,2584,2586,2588,2590,2592,2595],{"class":429,"line":454},[427,2585,1541],{"class":720},[427,2587,1544],{"class":1532},[427,2589,1547],{"class":440},[427,2591,1550],{"class":720},[427,2593,2594],{"class":444},"\"site\"",[427,2596,1536],{"class":720},[427,2598,2599,2601,2604],{"class":429,"line":461},[427,2600,1560],{"class":720},[427,2602,2603],{"class":1532},"SiteHeader",[427,2605,2538],{"class":720},[427,2607,2608,2610,2613,2615,2617,2620,2623,2626,2629,2632,2634],{"class":429,"line":467},[427,2609,1560],{"class":720},[427,2611,2612],{"class":1532},"main",[427,2614,1547],{"class":440},[427,2616,1550],{"class":720},[427,2618,2619],{"class":444},"\"container\"",[427,2621,2622],{"class":720},">\u003C",[427,2624,2625],{"class":1532},"slot",[427,2627,2080],{"class":2628},"s6RL2",[427,2630,2631],{"class":720},">\u003C\u002F",[427,2633,2612],{"class":1532},[427,2635,1536],{"class":720},[427,2637,2638,2640,2643],{"class":429,"line":481},[427,2639,1560],{"class":720},[427,2641,2642],{"class":1532},"SiteFooter",[427,2644,2538],{"class":720},[427,2646,2647,2649,2651],{"class":429,"line":486},[427,2648,1588],{"class":720},[427,2650,1544],{"class":1532},[427,2652,1536],{"class":720},[427,2654,2655,2657,2659],{"class":429,"line":495},[427,2656,1597],{"class":720},[427,2658,1533],{"class":1532},[427,2660,1536],{"class":720},[10,2662,2663,2666,2667,2672,2673,2675,2676,2678],{},[18,2664,2665],{},"\u003Cslot \u002F>"," 就是页面组件插进来的位置。",[14,2668,2669,2670],{},"布局里不要直接写 ",[18,2671,1502],{},"，用 ",[18,2674,2665],{},"——这是官方明确的规定，写 ",[18,2677,1502],{}," 会导致嵌套层级错乱（布局切换时页面被重新挂载）。",[10,2680,2681],{},"换布局或不用布局：",[418,2683,2685],{"className":1515,"code":2684,"language":1517,"meta":423,"style":423},"\u003Cscript setup>\ndefinePageMeta({ layout: 'doc' })     \u002F\u002F 用 app\u002Flayouts\u002Fdoc.vue\ndefinePageMeta({ layout: false })     \u002F\u002F 不要布局（登录页、独立工具页常用）\n\u003C\u002Fscript>\n",[18,2686,2687,2697,2712,2725],{"__ignoreMap":423},[427,2688,2689,2691,2693,2695],{"class":429,"line":430},[427,2690,1529],{"class":720},[427,2692,1824],{"class":1532},[427,2694,1827],{"class":440},[427,2696,1536],{"class":720},[427,2698,2699,2701,2704,2706,2709],{"class":429,"line":437},[427,2700,1022],{"class":440},[427,2702,2703],{"class":720},"({ layout: ",[427,2705,1988],{"class":444},[427,2707,2708],{"class":720}," })     ",[427,2710,2711],{"class":433},"\u002F\u002F 用 app\u002Flayouts\u002Fdoc.vue\n",[427,2713,2714,2716,2718,2720,2722],{"class":429,"line":454},[427,2715,1022],{"class":440},[427,2717,2703],{"class":720},[427,2719,803],{"class":489},[427,2721,2708],{"class":720},[427,2723,2724],{"class":433},"\u002F\u002F 不要布局（登录页、独立工具页常用）\n",[427,2726,2727,2729,2731],{"class":429,"line":461},[427,2728,1597],{"class":720},[427,2730,1824],{"class":1532},[427,2732,1536],{"class":720},[10,2734,2735,2736,304,2739,2742,2743,2746,2747,2750],{},"运行时切换：",[18,2737,2738],{},"nuxtApp.yamlContext.layout.value = 'doc'",[18,2740,2741],{},"useNuxtApp()"," 拿 ",[18,2744,2745],{},"nuxtApp","），或 ",[18,2748,2749],{},"\u003CNuxtLayout name=\"doc\">"," 手动包住一段内容。",[168,2752,2754,2755],{"id":2753},"错误页apperrorvue","错误页：",[18,2756,2757],{},"app\u002Ferror.vue",[418,2759,2761],{"className":1515,"code":2760,"language":1517,"meta":423,"style":423},"\u003C!-- app\u002Ferror.vue -->\n\u003Cscript setup>\nconst props = defineProps({ error: { type: Object, required: true } })\nconst home = () => navigateTo('\u002F')\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv class=\"error\">\n    \u003Ch1>{{ error.statusCode }}\u003C\u002Fh1>\n    \u003Cp>{{ error.statusMessage || (error.statusCode === 404 ? '页面不存在' : '出错了') }}\u003C\u002Fp>\n    \u003Cbutton @click=\"home\">回首页\u003C\u002Fbutton>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n",[18,2762,2763,2768,2778,2798,2821,2829,2833,2841,2856,2870,2883,2905,2913],{"__ignoreMap":423},[427,2764,2765],{"class":429,"line":430},[427,2766,2767],{"class":433},"\u003C!-- app\u002Ferror.vue -->\n",[427,2769,2770,2772,2774,2776],{"class":429,"line":437},[427,2771,1529],{"class":720},[427,2773,1824],{"class":1532},[427,2775,1827],{"class":440},[427,2777,1536],{"class":720},[427,2779,2780,2782,2785,2787,2790,2793,2795],{"class":429,"line":454},[427,2781,1867],{"class":710},[427,2783,2784],{"class":489}," props",[427,2786,1873],{"class":710},[427,2788,2789],{"class":440}," defineProps",[427,2791,2792],{"class":720},"({ error: { type: Object, required: ",[427,2794,743],{"class":489},[427,2796,2797],{"class":720}," } })\n",[427,2799,2800,2802,2805,2807,2810,2812,2814,2816,2818],{"class":429,"line":461},[427,2801,1867],{"class":710},[427,2803,2804],{"class":440}," home",[427,2806,1873],{"class":710},[427,2808,2809],{"class":720}," () ",[427,2811,2077],{"class":710},[427,2813,1842],{"class":440},[427,2815,1845],{"class":720},[427,2817,849],{"class":444},[427,2819,2820],{"class":720},")\n",[427,2822,2823,2825,2827],{"class":429,"line":467},[427,2824,1597],{"class":720},[427,2826,1824],{"class":1532},[427,2828,1536],{"class":720},[427,2830,2831],{"class":429,"line":481},[427,2832,458],{"emptyLinePlaceholder":457},[427,2834,2835,2837,2839],{"class":429,"line":486},[427,2836,1529],{"class":720},[427,2838,1533],{"class":1532},[427,2840,1536],{"class":720},[427,2842,2843,2845,2847,2849,2851,2854],{"class":429,"line":495},[427,2844,1541],{"class":720},[427,2846,1544],{"class":1532},[427,2848,1547],{"class":440},[427,2850,1550],{"class":720},[427,2852,2853],{"class":444},"\"error\"",[427,2855,1536],{"class":720},[427,2857,2858,2860,2863,2866,2868],{"class":429,"line":503},[427,2859,1560],{"class":720},[427,2861,2862],{"class":1532},"h1",[427,2864,2865],{"class":720},">{{ error.statusCode }}\u003C\u002F",[427,2867,2862],{"class":1532},[427,2869,1536],{"class":720},[427,2871,2872,2874,2876,2879,2881],{"class":429,"line":791},[427,2873,1560],{"class":720},[427,2875,10],{"class":1532},[427,2877,2878],{"class":720},">{{ error.statusMessage || (error.statusCode === 404 ? '页面不存在' : '出错了') }}\u003C\u002F",[427,2880,10],{"class":1532},[427,2882,1536],{"class":720},[427,2884,2885,2887,2890,2893,2895,2898,2901,2903],{"class":429,"line":812},[427,2886,1560],{"class":720},[427,2888,2889],{"class":1532},"button",[427,2891,2892],{"class":440}," @click",[427,2894,1550],{"class":720},[427,2896,2897],{"class":444},"\"home\"",[427,2899,2900],{"class":720},">回首页\u003C\u002F",[427,2902,2889],{"class":1532},[427,2904,1536],{"class":720},[427,2906,2907,2909,2911],{"class":429,"line":817},[427,2908,1588],{"class":720},[427,2910,1544],{"class":1532},[427,2912,1536],{"class":720},[427,2914,2915,2917,2919],{"class":429,"line":823},[427,2916,1597],{"class":720},[427,2918,1533],{"class":1532},[427,2920,1536],{"class":720},[10,2922,2923],{},"触发一个错误页（404\u002F500 的标准做法）：",[418,2925,2927],{"className":696,"code":2926,"language":698,"meta":423,"style":423},"\u002F\u002F 页面、中间件、服务端接口里都能用\nthrow createError({ statusCode: 404, statusMessage: '教程不存在', fatal: true })\n",[18,2928,2929,2934],{"__ignoreMap":423},[427,2930,2931],{"class":429,"line":430},[427,2932,2933],{"class":433},"\u002F\u002F 页面、中间件、服务端接口里都能用\n",[427,2935,2936,2939,2942,2945,2948,2951,2954,2957,2959],{"class":429,"line":437},[427,2937,2938],{"class":710},"throw",[427,2940,2941],{"class":440}," createError",[427,2943,2944],{"class":720},"({ statusCode: ",[427,2946,2947],{"class":489},"404",[427,2949,2950],{"class":720},", statusMessage: ",[427,2952,2953],{"class":444},"'教程不存在'",[427,2955,2956],{"class":720},", fatal: ",[427,2958,743],{"class":489},[427,2960,2961],{"class":720}," })\n",[10,2963,2964,2965,2968],{},"本站教程详情页就是这么处理的：查不到文档就直接 ",[18,2966,2967],{},"throw createError({ statusCode: 404, fatal: true })","，而不是渲染一个「数据为空」的分支——前者能让爬虫和监控正确识别 404。",[10,2970,2971,2974,2975,2978,2979,2982,2983,2986,2987,2989,2990,2993],{},[18,2972,2973],{},"error.vue"," 有几点硬约束：它在",[14,2976,2977],{},"布局之外","（拿不到 ",[18,2980,2981],{},"useAppStore"," 之类），所以要用到的样式请在 ",[18,2984,2985],{},"app.vue"," 层或全局 CSS 里给；它不参与 ",[18,2988,307],{}," 路由，别指望用 ",[18,2991,2992],{},"navigateTo('\u002Ferror')"," 跳过去。",[168,2995,2996],{"id":2996},"路由动画与公告",[418,2998,3000],{"className":1515,"code":2999,"language":1517,"meta":423,"style":423},"\u003CNuxtPage :transition=\"{ name: 'page', mode: 'out-in' }\" \u002F>\n\u003CNuxtRouteAnnouncer \u002F>      \u003C!-- 给读屏软件播报「已进入 关于 页」 -->\n",[18,3001,3002,3036],{"__ignoreMap":423},[427,3003,3004,3006,3008,3010,3013,3015,3017,3020,3023,3026,3029,3032,3034],{"class":429,"line":430},[427,3005,1529],{"class":720},[427,3007,1577],{"class":1532},[427,3009,1672],{"class":720},[427,3011,3012],{"class":440},"transition",[427,3014,1550],{"class":720},[427,3016,1680],{"class":444},[427,3018,3019],{"class":720},"{ name: ",[427,3021,3022],{"class":444},"'page'",[427,3024,3025],{"class":720},", mode: ",[427,3027,3028],{"class":444},"'out-in'",[427,3030,3031],{"class":720}," }",[427,3033,1680],{"class":444},[427,3035,2538],{"class":720},[427,3037,3038,3040,3043,3046],{"class":429,"line":437},[427,3039,1529],{"class":720},[427,3041,3042],{"class":1532},"NuxtRouteAnnouncer",[427,3044,3045],{"class":720}," \u002F>      ",[427,3047,3048],{"class":433},"\u003C!-- 给读屏软件播报「已进入 关于 页」 -->\n",[10,3050,3051,3054,3055,28,3058,3061,3062,3065,3066,278],{},[18,3052,3053],{},"\u003CNuxtRouteAnnouncer>"," 是无障碍的正路（SPA 换页不会触发屏幕阅读器的标题播报）。动画需要配 CSS 类：",[18,3056,3057],{},".page-enter-from",[18,3059,3060],{},".page-leave-to","（和 Vue ",[18,3063,3064],{},"\u003CTransition>"," 一模一样，别漏 ",[18,3067,3068],{},"-from",[168,3070,3072,3073],{"id":3071},"加载态nuxtloadingindicator","加载态：",[18,3074,3075],{},"\u003CNuxtLoadingIndicator>",[418,3077,3079],{"className":1515,"code":3078,"language":1517,"meta":423,"style":423},"\u003C!-- app\u002Fapp.vue -->\n\u003Ctemplate>\n  \u003CNuxtLoadingIndicator color=\"#0969da\" \u002F>\n  \u003CNuxtLayout>\u003CNuxtPage \u002F>\u003C\u002FNuxtLayout>\n\u003C\u002Ftemplate>\n",[18,3080,3081,3085,3093,3110,3127],{"__ignoreMap":423},[427,3082,3083],{"class":429,"line":430},[427,3084,2512],{"class":433},[427,3086,3087,3089,3091],{"class":429,"line":437},[427,3088,1529],{"class":720},[427,3090,1533],{"class":1532},[427,3092,1536],{"class":720},[427,3094,3095,3097,3100,3103,3105,3108],{"class":429,"line":454},[427,3096,1541],{"class":720},[427,3098,3099],{"class":1532},"NuxtLoadingIndicator",[427,3101,3102],{"class":440}," color",[427,3104,1550],{"class":720},[427,3106,3107],{"class":444},"\"#0969da\"",[427,3109,2538],{"class":720},[427,3111,3112,3114,3116,3118,3120,3123,3125],{"class":429,"line":461},[427,3113,1541],{"class":720},[427,3115,2527],{"class":1532},[427,3117,2622],{"class":720},[427,3119,1577],{"class":1532},[427,3121,3122],{"class":720}," \u002F>\u003C\u002F",[427,3124,2527],{"class":1532},[427,3126,1536],{"class":720},[427,3128,3129,3131,3133],{"class":429,"line":467},[427,3130,1597],{"class":720},[427,3132,1533],{"class":1532},[427,3134,1536],{"class":720},[10,3136,3137,3138,3140,3141,3143,3144,2285,3147,210],{},"放在 ",[18,3139,2985],{}," 里就能给「所有导航和 ",[18,3142,994],{},"」共用的顶部进度条。不想让某个请求触发进度条：",[18,3145,3146],{},"useFetch(url, { dedupe: 'defer' })",[18,3148,3149],{},"\u003CNuxtLoadingIndicator :throttle=\"500\">",[168,3151,3153],{"id":3152},"单页面渲染不需要-layouts-的小项目","单页面渲染（不需要 layouts 的小项目）",[10,3155,3156,3157,3159,3160,210,3163,3171],{},"删掉 ",[18,3158,2985],{}," 时 Nuxt 会生成一个默认的 ",[18,3161,3162],{},"\u003Cdiv id=\"app\">\u003CNuxtLayout>\u003CNuxtPage\u002F>\u003C\u002FNuxtLayout>\u003C\u002Fdiv>",[14,3164,3165,3166,3168,3169],{},"留着 ",[18,3167,2985],{}," 但里面只有 ",[18,3170,2478],{}," 也是合法配置（没有布局需求时）。",[168,3173,261],{"id":3174},"️-本章的坑-3",[10,3176,3177,3182,3183,3186,3187,210],{},[14,3178,3179,3180,210],{},"1. 布局里写 ",[18,3181,1502],{}," 布局切换时页面会被重复挂载，",[18,3184,3185],{},"definePageMeta({ key })"," 行为诡异。规则：",[14,3188,3189,3190,3192,3193],{},"页面用 ",[18,3191,2478],{},"，布局用 ",[18,3194,3195],{},"\u003Cslot\u002F>",[10,3197,3198,3206,3207,3209,3210,3213,3214,3217],{},[14,3199,1186,3200,3202,3203,210],{},[18,3201,2973],{}," 里访问 ",[18,3204,3205],{},"useRoute()"," 错误页不在正常路由上下文里，",[18,3208,3205],{}," 拿到的不是你以为的那个对象，",[18,3211,3212],{},"route.meta"," 是空的。需要的信息从 ",[18,3215,3216],{},"props.error"," 里取。",[10,3219,3220,1257,3223,3226,3227,3230],{},[14,3221,3222],{},"3. 布局名字打错，静默回到 default。",[18,3224,3225],{},"definePageMeta({ layout: 'docs' })"," 但只有 ",[18,3228,3229],{},"doc.vue","，Nuxt 会警告后落到默认布局（或空白，取决于版本）。新建布局时先访问一次页面确认。",[10,3232,3233,1257,3240,3243,3244,210],{},[14,3234,3235,3236,3239],{},"4. ",[18,3237,3238],{},"layouts\u002F"," 下的子目录文件不会自动生效。",[18,3241,3242],{},"layouts\u002Fadmin\u002Fsidebar.vue"," 不是布局。要复用片段请放 ",[18,3245,104],{},[10,3247,3248,3251,3252,3255,3256,3258],{},[14,3249,3250],{},"5. 布局里放的数据请求会在每次换页时重跑？"," 不会——布局实例复用。但如果你用了 ",[18,3253,3254],{},"definePageMeta({ layout: false })"," 又 ",[18,3257,2491],{}," 手动包，就可能出现「同一份数据取两遍」。二选一。",[10,3260,3261,3267,3268,210],{},[14,3262,3263,3264,3266],{},"6. ",[18,3265,2491],{}," 的默认插槽必须只有一个元素。"," 包多个根元素会收到「Layout should have a single root element」警告（Vue 3 多根组件在这个位置不被允许）。外面套一个 ",[18,3269,3270],{},"\u003Cdiv>",[10,3272,3273,1257,3276,3278,3279,3282,3283,3286],{},[14,3274,3275],{},"7. 静态生成时 404 页需要额外一份。",[18,3277,20],{}," 会产出 ",[18,3280,3281],{},"404.html","，但服务器要真的把未知路径指到它（Nginx ",[18,3284,3285],{},"error_page 404 \u002Fnuxt\u002F404.html;","），否则用户看到 Nginx 的默认 404。第十三章有配置。",[168,3288,3290],{"id":3289},"附内置组件清单","附：内置组件清单",[59,3292,3293,3306],{},[62,3294,3295],{},[65,3296,3297,3300,3303],{},[68,3298,3299],{},"组件",[68,3301,3302],{},"放哪",[68,3304,3305],{},"作用",[75,3307,3308,3324,3338,3352,3364,3377,3390,3402,3414],{},[65,3309,3310,3318,3321],{},[80,3311,3312,3315,3316,638],{},[18,3313,3314],{},"\u003CNuxtApp>"," 相关 API（",[18,3317,2741],{},[80,3319,3320],{},"任意",[80,3322,3323],{},"拿运行时上下文、注册钩子",[65,3325,3326,3331,3335],{},[80,3327,3328],{},[18,3329,3330],{},"\u003CNuxtPage>",[80,3332,3333],{},[18,3334,2985],{},[80,3336,3337],{},"渲染当前路由组件，可配 transition\u002Fkeepalive",[65,3339,3340,3344,3349],{},[80,3341,3342],{},[18,3343,2491],{},[80,3345,3346,3348],{},[18,3347,2985],{}," 或页面",[80,3350,3351],{},"套用布局",[65,3353,3354,3358,3361],{},[80,3355,3356],{},[18,3357,2665],{},[80,3359,3360],{},"布局内部",[80,3362,3363],{},"页面插入点",[65,3365,3366,3370,3374],{},[80,3367,3368],{},[18,3369,3075],{},[80,3371,3372],{},[18,3373,2985],{},[80,3375,3376],{},"顶部进度条",[65,3378,3379,3383,3387],{},[80,3380,3381],{},[18,3382,3053],{},[80,3384,3385],{},[18,3386,2985],{},[80,3388,3389],{},"路由变化的无障碍播报",[65,3391,3392,3397,3399],{},[80,3393,3394],{},[18,3395,3396],{},"\u003CClientOnly>",[80,3398,3320],{},[80,3400,3401],{},"内部只在客户端渲染（SSR 安全逃生舱）",[65,3403,3404,3409,3411],{},[80,3405,3406],{},[18,3407,3408],{},"\u003CDevOnly>",[80,3410,3320],{},[80,3412,3413],{},"内部只在开发环境渲染",[65,3415,3416,3421,3424],{},[80,3417,3418],{},[18,3419,3420],{},"\u003CNuxtWelcome>",[80,3422,3423],{},"临时页面",[80,3425,3426],{},"官方欢迎页（初始化时用一次）",[45,3428,3430,3431,651,3433],{"id":3429},"五数据获取usefetch-与-useasyncdata","五、数据获取：",[18,3432,994],{},[18,3434,997],{},[10,3436,3437,3438,3441],{},"这是 Nuxt 的核心章节。",[14,3439,3440],{},"数据什么时候在服务端取、什么时候在客户端取、取几次","，决定了首屏速度、SEO 和一半的性能坑。",[168,3443,3445,3446],{"id":3444},"为什么不能用-onmounted-fetch","为什么不能用 ",[18,3447,3448],{},"onMounted + fetch",[10,3450,3451,3452,3457],{},"先想清楚这条链：SSR 阶段服务端跑组件时",[14,3453,3454,3455],{},"不会执行 ",[18,3456,2274],{},"（那是浏览器里的钩子），于是服务端渲染出的 HTML 里没有正文；到客户端才发请求，用户看着白屏或 loading 等一跳。爬虫抓到的也是空壳。",[10,3459,3460,1085,3462,3464],{},[18,3461,994],{},[18,3463,997],{}," 解决的就是这个：它们是「服务端会等待、结果会被序列化」的异步数据。",[418,3466,3468],{"className":1515,"code":3467,"language":1517,"meta":423,"style":423},"\u003Cscript setup>\n\u002F\u002F 完整写法：顶层 await → 服务端取完才渲染 HTML\nconst { data: posts, pending, status, error, refresh } =\n  await useFetch('\u002Fapi\u002Fposts', { query: { page: 1 }, pick: ['id', 'title'] })\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp v-if=\"pending\">加载中…\u003C\u002Fp>\n  \u003Cul v-else-if=\"!error\">\n    \u003Cli v-for=\"p in posts\" :key=\"p.id\">{{ p.title }}\u003C\u002Fli>\n  \u003C\u002Ful>\n  \u003Cp v-else class=\"err\">{{ error.statusMessage || '加载失败' }}\u003C\u002Fp>\n  \u003Cbutton @click=\"refresh()\">重新获取\u003C\u002Fbutton>\n\u003C\u002Ftemplate>\n",[18,3469,3470,3480,3485,3526,3559,3567,3571,3579,3600,3616,3645,3653,3676,3696],{"__ignoreMap":423},[427,3471,3472,3474,3476,3478],{"class":429,"line":430},[427,3473,1529],{"class":720},[427,3475,1824],{"class":1532},[427,3477,1827],{"class":440},[427,3479,1536],{"class":720},[427,3481,3482],{"class":429,"line":437},[427,3483,3484],{"class":433},"\u002F\u002F 完整写法：顶层 await → 服务端取完才渲染 HTML\n",[427,3486,3487,3489,3492,3494,3497,3500,3502,3505,3507,3510,3512,3515,3517,3520,3523],{"class":429,"line":454},[427,3488,1867],{"class":710},[427,3490,3491],{"class":720}," { ",[427,3493,1385],{"class":2070},[427,3495,3496],{"class":720},": ",[427,3498,3499],{"class":489},"posts",[427,3501,778],{"class":720},[427,3503,3504],{"class":489},"pending",[427,3506,778],{"class":720},[427,3508,3509],{"class":489},"status",[427,3511,778],{"class":720},[427,3513,3514],{"class":489},"error",[427,3516,778],{"class":720},[427,3518,3519],{"class":489},"refresh",[427,3521,3522],{"class":720}," } ",[427,3524,3525],{"class":710},"=\n",[427,3527,3528,3531,3534,3536,3539,3542,3545,3548,3551,3553,3556],{"class":429,"line":461},[427,3529,3530],{"class":710},"  await",[427,3532,3533],{"class":440}," useFetch",[427,3535,1845],{"class":720},[427,3537,3538],{"class":444},"'\u002Fapi\u002Fposts'",[427,3540,3541],{"class":720},", { query: { page: ",[427,3543,3544],{"class":489},"1",[427,3546,3547],{"class":720}," }, pick: [",[427,3549,3550],{"class":444},"'id'",[427,3552,778],{"class":720},[427,3554,3555],{"class":444},"'title'",[427,3557,3558],{"class":720},"] })\n",[427,3560,3561,3563,3565],{"class":429,"line":467},[427,3562,1597],{"class":720},[427,3564,1824],{"class":1532},[427,3566,1536],{"class":720},[427,3568,3569],{"class":429,"line":481},[427,3570,458],{"emptyLinePlaceholder":457},[427,3572,3573,3575,3577],{"class":429,"line":486},[427,3574,1529],{"class":720},[427,3576,1533],{"class":1532},[427,3578,1536],{"class":720},[427,3580,3581,3583,3585,3588,3590,3593,3596,3598],{"class":429,"line":495},[427,3582,1541],{"class":720},[427,3584,10],{"class":1532},[427,3586,3587],{"class":440}," v-if",[427,3589,1550],{"class":720},[427,3591,3592],{"class":444},"\"pending\"",[427,3594,3595],{"class":720},">加载中…\u003C\u002F",[427,3597,10],{"class":1532},[427,3599,1536],{"class":720},[427,3601,3602,3604,3606,3609,3611,3614],{"class":429,"line":503},[427,3603,1541],{"class":720},[427,3605,229],{"class":1532},[427,3607,3608],{"class":440}," v-else-if",[427,3610,1550],{"class":720},[427,3612,3613],{"class":444},"\"!error\"",[427,3615,1536],{"class":720},[427,3617,3618,3620,3622,3625,3627,3630,3633,3635,3638,3641,3643],{"class":429,"line":791},[427,3619,1560],{"class":720},[427,3621,186],{"class":1532},[427,3623,3624],{"class":440}," v-for",[427,3626,1550],{"class":720},[427,3628,3629],{"class":444},"\"p in posts\"",[427,3631,3632],{"class":440}," :key",[427,3634,1550],{"class":720},[427,3636,3637],{"class":444},"\"p.id\"",[427,3639,3640],{"class":720},">{{ p.title }}\u003C\u002F",[427,3642,186],{"class":1532},[427,3644,1536],{"class":720},[427,3646,3647,3649,3651],{"class":429,"line":812},[427,3648,1588],{"class":720},[427,3650,229],{"class":1532},[427,3652,1536],{"class":720},[427,3654,3655,3657,3659,3662,3664,3666,3669,3672,3674],{"class":429,"line":817},[427,3656,1541],{"class":720},[427,3658,10],{"class":1532},[427,3660,3661],{"class":440}," v-else",[427,3663,1547],{"class":440},[427,3665,1550],{"class":720},[427,3667,3668],{"class":444},"\"err\"",[427,3670,3671],{"class":720},">{{ error.statusMessage || '加载失败' }}\u003C\u002F",[427,3673,10],{"class":1532},[427,3675,1536],{"class":720},[427,3677,3678,3680,3682,3684,3686,3689,3692,3694],{"class":429,"line":823},[427,3679,1541],{"class":720},[427,3681,2889],{"class":1532},[427,3683,2892],{"class":440},[427,3685,1550],{"class":720},[427,3687,3688],{"class":444},"\"refresh()\"",[427,3690,3691],{"class":720},">重新获取\u003C\u002F",[427,3693,2889],{"class":1532},[427,3695,1536],{"class":720},[427,3697,3698,3700,3702],{"class":429,"line":829},[427,3699,1597],{"class":720},[427,3701,1533],{"class":1532},[427,3703,1536],{"class":720},[10,3705,3706,3707,3709,3710,3712,3713,3715,3716,304,3718,3721,3722,3724,3725,3728,3729,278],{},"返回值里五个字段各有分工：",[18,3708,1385],{}," 数据本体、",[18,3711,3514],{}," 失败时的错误对象、",[18,3714,3504],{},"（当前这一次请求）与 ",[18,3717,3509],{},[18,3719,3720],{},"idle | pending | success | error","）、",[18,3723,3519],{},"（重跑 handler）、",[18,3726,3727],{},"execute","（手动触发，配 ",[18,3730,3731],{},"immediate: false",[168,3733,3735,651,3737,3739],{"id":3734},"usefetch-与-useasyncdata-的分工",[18,3736,994],{},[18,3738,997],{}," 的分工",[59,3741,3742,3758],{},[62,3743,3744],{},[65,3745,3746,3748,3753],{},[68,3747],{},[68,3749,3750],{},[18,3751,3752],{},"useFetch(url, opts)",[68,3754,3755],{},[18,3756,3757],{},"useAsyncData(key, handler, opts)",[75,3759,3760,3777,3791,3808,3825],{},[65,3761,3762,3765,3774],{},[80,3763,3764],{},"内部做什么",[80,3766,3767,3768,3770,3771],{},"就是 ",[18,3769,997],{}," 的包装，handler 是 ",[18,3772,3773],{},"$fetch(url)",[80,3775,3776],{},"你给任意异步函数（可以是三次串行请求、读文件、算东西）",[65,3778,3779,3782,3785],{},[80,3780,3781],{},"key",[80,3783,3784],{},"默认用 URL 生成",[80,3786,3787,3790],{},[14,3788,3789],{},"必须自己给","（同 key 共享同一份结果）",[65,3792,3793,3796,3802],{},[80,3794,3795],{},"自动取消",[80,3797,3798,3799,638],{},"有（",[18,3800,3801],{},"abort",[80,3803,3804,3805,638],{},"无（自己用 ",[18,3806,3807],{},"AbortController",[65,3809,3810,3813,3822],{},[80,3811,3812],{},"请求拦截\u002F自动前缀",[80,3814,3798,3815,3817,3818,3821],{},[18,3816,994],{}," 内部 ",[18,3819,3820],{},"$fetch"," 的 baseURL 逻辑）",[80,3823,3824],{},"无",[65,3826,3827,3830,3833],{},[80,3828,3829],{},"什么时候用",[80,3831,3832],{},"取一个 HTTP 接口",[80,3834,3835],{},"组合多个来源、非 HTTP 的数据（DB、文件、计算）",[10,3837,3838,3839,1056,3841,3844],{},"本站详情页用的就是 ",[18,3840,997],{},[18,3842,3843],{},"queryCollection('tutorials').path(route.path).first()"," 是本地数据库查询，不是 HTTP 请求。",[168,3846,3848],{"id":3847},"keynuxt-数据缓存的开关","key：Nuxt 数据缓存的开关",[418,3850,3852],{"className":696,"code":3851,"language":698,"meta":423,"style":423},"\u002F\u002F 两个组件同 key → 只请求一次，共享同一份 data\nawait useAsyncData('user-stats', () => $fetch('\u002Fapi\u002Fstats'))\nawait useAsyncData('user-stats', () => $fetch('\u002Fapi\u002Fstats'))   \u002F\u002F 不会二次请求\n",[18,3853,3854,3859,3887],{"__ignoreMap":423},[427,3855,3856],{"class":429,"line":430},[427,3857,3858],{"class":433},"\u002F\u002F 两个组件同 key → 只请求一次，共享同一份 data\n",[427,3860,3861,3863,3866,3868,3871,3874,3876,3879,3881,3884],{"class":429,"line":437},[427,3862,1839],{"class":710},[427,3864,3865],{"class":440}," useAsyncData",[427,3867,1845],{"class":720},[427,3869,3870],{"class":444},"'user-stats'",[427,3872,3873],{"class":720},", () ",[427,3875,2077],{"class":710},[427,3877,3878],{"class":440}," $fetch",[427,3880,1845],{"class":720},[427,3882,3883],{"class":444},"'\u002Fapi\u002Fstats'",[427,3885,3886],{"class":720},"))\n",[427,3888,3889,3891,3893,3895,3897,3899,3901,3903,3905,3907,3910],{"class":429,"line":454},[427,3890,1839],{"class":710},[427,3892,3865],{"class":440},[427,3894,1845],{"class":720},[427,3896,3870],{"class":444},[427,3898,3873],{"class":720},[427,3900,2077],{"class":710},[427,3902,3878],{"class":440},[427,3904,1845],{"class":720},[427,3906,3883],{"class":444},[427,3908,3909],{"class":720},"))   ",[427,3911,3912],{"class":433},"\u002F\u002F 不会二次请求\n",[10,3914,3915,3918,3919,3922,3923,210],{},[18,3916,3917],{},"useFetch('\u002Fapi\u002Flist')"," 的默认 key 由 URL + 选项生成。两个组件用同一 URL 就自动共享；",[14,3920,3921],{},"URL 参数不同 key 就不同","，会各请求一次。要强制共享就显式写 ",[18,3924,3925],{},"{ key: 'list' }",[10,3927,3928,3929,3932,3933,3936],{},"这也解释了「为什么我在页脚拿不到最新数据」：同 key 的数据在 payload 里已存在，客户端不会自动重新请求。要刷新请调 ",[18,3930,3931],{},"refresh()","，或加 ",[18,3934,3935],{},"{ dedupe: 'always' }"," 之类的策略。",[168,3938,3940,3941,28,3944,28,3947],{"id":3939},"客户端才想要的数据lazyserver-falseimmediate","客户端才想要的数据：",[18,3942,3943],{},"lazy",[18,3945,3946],{},"server: false",[18,3948,3949],{},"immediate",[418,3951,3953],{"className":696,"code":3952,"language":698,"meta":423,"style":423},"\u002F\u002F ① 不阻塞首屏：先渲染，数据到达后再更新（HTML 里服务端已经取过，只是不 await）\nconst { data } = await useFetch('\u002Fapi\u002Fheavy', { lazy: true })\n\n\u002F\u002F ② 服务端完全不取（数据只在浏览器拿）—— 注意：正文不会进 SSR HTML，SEO 会空\nconst { data } = await useFetch('\u002Fapi\u002Fme', { server: false })\n\n\u002F\u002F ③ 先不请求，点按钮时才请求\nconst { data, execute } = await useAsyncData('detail', () => $fetch(`\u002Fapi\u002Fx\u002F${id}`), { immediate: false })\n\n\u002F\u002F ④ 参数变化时自动重新请求（默认只 watch 路由）\nconst { data } = await useFetch('\u002Fapi\u002Fsearch', { query: { q }, watch: [q] })\n",[18,3954,3955,3960,3989,3993,3998,4026,4030,4035,4084,4088,4093],{"__ignoreMap":423},[427,3956,3957],{"class":429,"line":430},[427,3958,3959],{"class":433},"\u002F\u002F ① 不阻塞首屏：先渲染，数据到达后再更新（HTML 里服务端已经取过，只是不 await）\n",[427,3961,3962,3964,3966,3968,3970,3972,3975,3977,3979,3982,3985,3987],{"class":429,"line":437},[427,3963,1867],{"class":710},[427,3965,3491],{"class":720},[427,3967,1385],{"class":489},[427,3969,3522],{"class":720},[427,3971,1550],{"class":710},[427,3973,3974],{"class":710}," await",[427,3976,3533],{"class":440},[427,3978,1845],{"class":720},[427,3980,3981],{"class":444},"'\u002Fapi\u002Fheavy'",[427,3983,3984],{"class":720},", { lazy: ",[427,3986,743],{"class":489},[427,3988,2961],{"class":720},[427,3990,3991],{"class":429,"line":454},[427,3992,458],{"emptyLinePlaceholder":457},[427,3994,3995],{"class":429,"line":461},[427,3996,3997],{"class":433},"\u002F\u002F ② 服务端完全不取（数据只在浏览器拿）—— 注意：正文不会进 SSR HTML，SEO 会空\n",[427,3999,4000,4002,4004,4006,4008,4010,4012,4014,4016,4019,4022,4024],{"class":429,"line":467},[427,4001,1867],{"class":710},[427,4003,3491],{"class":720},[427,4005,1385],{"class":489},[427,4007,3522],{"class":720},[427,4009,1550],{"class":710},[427,4011,3974],{"class":710},[427,4013,3533],{"class":440},[427,4015,1845],{"class":720},[427,4017,4018],{"class":444},"'\u002Fapi\u002Fme'",[427,4020,4021],{"class":720},", { server: ",[427,4023,803],{"class":489},[427,4025,2961],{"class":720},[427,4027,4028],{"class":429,"line":481},[427,4029,458],{"emptyLinePlaceholder":457},[427,4031,4032],{"class":429,"line":486},[427,4033,4034],{"class":433},"\u002F\u002F ③ 先不请求，点按钮时才请求\n",[427,4036,4037,4039,4041,4043,4045,4047,4049,4051,4053,4055,4057,4060,4062,4064,4066,4068,4071,4074,4077,4080,4082],{"class":429,"line":495},[427,4038,1867],{"class":710},[427,4040,3491],{"class":720},[427,4042,1385],{"class":489},[427,4044,778],{"class":720},[427,4046,3727],{"class":489},[427,4048,3522],{"class":720},[427,4050,1550],{"class":710},[427,4052,3974],{"class":710},[427,4054,3865],{"class":440},[427,4056,1845],{"class":720},[427,4058,4059],{"class":444},"'detail'",[427,4061,3873],{"class":720},[427,4063,2077],{"class":710},[427,4065,3878],{"class":440},[427,4067,1845],{"class":720},[427,4069,4070],{"class":444},"`\u002Fapi\u002Fx\u002F${",[427,4072,4073],{"class":720},"id",[427,4075,4076],{"class":444},"}`",[427,4078,4079],{"class":720},"), { immediate: ",[427,4081,803],{"class":489},[427,4083,2961],{"class":720},[427,4085,4086],{"class":429,"line":503},[427,4087,458],{"emptyLinePlaceholder":457},[427,4089,4090],{"class":429,"line":791},[427,4091,4092],{"class":433},"\u002F\u002F ④ 参数变化时自动重新请求（默认只 watch 路由）\n",[427,4094,4095,4097,4099,4101,4103,4105,4107,4109,4111,4114],{"class":429,"line":812},[427,4096,1867],{"class":710},[427,4098,3491],{"class":720},[427,4100,1385],{"class":489},[427,4102,3522],{"class":720},[427,4104,1550],{"class":710},[427,4106,3974],{"class":710},[427,4108,3533],{"class":440},[427,4110,1845],{"class":720},[427,4112,4113],{"class":444},"'\u002Fapi\u002Fsearch'",[427,4115,4116],{"class":720},", { query: { q }, watch: [q] })\n",[10,4118,4119,4121,4122,4124],{},[18,4120,3946],{}," 的代价要说清楚：SSR 出的 HTML 里那块是空的（或是 ",[18,4123,1371],{}," 给的骨架），爬虫看到的是骨架，用户看到的是「内容闪一下」。只在「确实需要浏览器环境」（依赖 localStorage、需要当前登录 cookie 且接口不可服务端代理）时用。",[168,4126,4128,4129,4131],{"id":4127},"给数据一个-default少写一层判空","给数据一个 ",[18,4130,1371],{},"，少写一层判空",[418,4133,4135],{"className":696,"code":4134,"language":698,"meta":423,"style":423},"const { data: list } = await useFetch('\u002Fapi\u002Flist', {\n  default: () => []          \u002F\u002F Nuxt 4 起 data 初始为 undefined，不写 default 就得判 undefined\n})\n",[18,4136,4137,4166,4182],{"__ignoreMap":423},[427,4138,4139,4141,4143,4145,4147,4150,4152,4154,4156,4158,4160,4163],{"class":429,"line":430},[427,4140,1867],{"class":710},[427,4142,3491],{"class":720},[427,4144,1385],{"class":2070},[427,4146,3496],{"class":720},[427,4148,4149],{"class":489},"list",[427,4151,3522],{"class":720},[427,4153,1550],{"class":710},[427,4155,3974],{"class":710},[427,4157,3533],{"class":440},[427,4159,1845],{"class":720},[427,4161,4162],{"class":444},"'\u002Fapi\u002Flist'",[427,4164,4165],{"class":720},", {\n",[427,4167,4168,4171,4174,4176,4179],{"class":429,"line":437},[427,4169,4170],{"class":440},"  default",[427,4172,4173],{"class":720},": () ",[427,4175,2077],{"class":710},[427,4177,4178],{"class":720}," []          ",[427,4180,4181],{"class":433},"\u002F\u002F Nuxt 4 起 data 初始为 undefined，不写 default 就得判 undefined\n",[427,4183,4184],{"class":429,"line":454},[427,4185,911],{"class":720},[10,4187,4188,4189,4191,4192,4194,4195,4197,4198,4201],{},"Nuxt 3 时代 ",[18,4190,1385],{}," 默认是 ",[18,4193,1375],{},"，Nuxt 4 变成 ",[18,4196,1389],{},"——从旧教程抄代码时这里最容易出「",[18,4199,4200],{},"list.length"," 报错」。",[168,4203,4205,4206],{"id":4204},"一次性副作用callonce","一次性副作用：",[18,4207,1006],{},[10,4209,4210],{},"有些东西只该在服务端跑一次（初始化数据库、预热缓存、写 cookie）：",[418,4212,4214],{"className":696,"code":4213,"language":698,"meta":423,"style":423},"await callOnce('seed', async () => { await $fetch('\u002Fapi\u002Fseed') })\n",[18,4215,4216],{"__ignoreMap":423},[427,4217,4218,4220,4223,4225,4228,4230,4233,4235,4237,4239,4241,4243,4245,4248],{"class":429,"line":430},[427,4219,1839],{"class":710},[427,4221,4222],{"class":440}," callOnce",[427,4224,1845],{"class":720},[427,4226,4227],{"class":444},"'seed'",[427,4229,778],{"class":720},[427,4231,4232],{"class":710},"async",[427,4234,2809],{"class":720},[427,4236,2077],{"class":710},[427,4238,3491],{"class":720},[427,4240,1839],{"class":710},[427,4242,3878],{"class":440},[427,4244,1845],{"class":720},[427,4246,4247],{"class":444},"'\u002Fapi\u002Fseed'",[427,4249,4250],{"class":720},") })\n",[10,4252,4253,4254,4256],{},"同一个 key 在整次请求生命周期内只执行一次；",[18,4255,1000],{}," 之类的全站初始化也能用它兜住。",[212,4258],{"height":1149,"src":4259,"title":4260},"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F05-fetch-timeline.html","示例 05：两种写法的请求时序对比与 SSR payload",[10,4262,4263,4264,1085,4266,4268],{},"示例 05 把「onMounted + fetch」和「await useFetch」各放一遍：计数器显示 SSR 阶段 1 次 + 客户端 0 次 vs 1 次 + 1 次；再切 ",[18,4265,3946],{},[18,4267,3943],{}," 两个开关，看首屏文字和时间轴如何变化。",[168,4270,261],{"id":4271},"️-本章的坑-4",[10,4273,4274,1257,4280,4282,4283,4285,4286,4289,4290,4292,4293,210],{},[14,4275,4276,4277,4279],{},"1. ",[18,4278,1839],{}," 写在异步函数里没生效。",[18,4281,240],{}," 支持顶层 await；但如果你把 ",[18,4284,994],{}," 塞进一个普通函数（",[18,4287,4288],{},"function load() { await useFetch(...) }","）再在 ",[18,4291,2274],{}," 里调用，SSR 阶段就白跑了。要么在 setup 顶层直接 await，要么改用 ",[18,4294,4295],{},"onServerPrefetch",[10,4297,4298,4309,4310,4313,4314,2285,4317,210],{},[14,4299,4300,4301,4303,4304,1241,4306,210],{},"2. 在 ",[18,4302,994],{}," 里读 ",[18,4305,1200],{},[18,4307,4308],{},"document"," 服务端也会执行 handler 所在的上下文，SSR 时报 ",[18,4311,4312],{},"window is not defined","。需要浏览器环境的数据请用 ",[18,4315,4316],{},"{ server: false }",[18,4318,3396],{},[10,4320,4321,4324,4325,4328,4329,28,4332,278],{},[14,4322,4323],{},"3. key 撞车导致数据互相覆盖。"," 两个不同页面用了同一个显式 key（",[18,4326,4327],{},"'list'","），从一个页面切回另一个页面时拿到的是对方的数据。给 key 带上业务语义（",[18,4330,4331],{},"tutorials-all",[18,4333,4334],{},"article-${id}",[10,4336,4337,4346,4347,4349,4350,2285,4353,4356],{},[14,4338,3235,4339,1085,4342,4345],{},[18,4340,4341],{},"pick",[18,4343,4344],{},"transform"," 忘了用，payload 巨大。"," SSR 把取到的数据序列化进 ",[18,4348,408],{},"，列表接口返回 200 字段而页面只用 3 个字段时，用户多下载了 60 倍数据。",[18,4351,4352],{},"pick: ['id','title']",[18,4354,4355],{},"transform: (r) => r.map(slim)","，本章末尾有对照表。",[10,4358,4359,1257,4365,4367,4368,4370,4371,4374,4375,4378],{},[14,4360,4361,4362,4364],{},"5. 页面里 ",[18,4363,3931],{}," 之后 loading 状态闪了两次。",[18,4366,3931],{}," 会重跑请求；要「静默刷新」用 ",[18,4369,3931],{}," 配合 ",[18,4372,4373],{},"{ dedupe: 'defer' }","，或直接 ",[18,4376,4377],{},"refresh({ dedupe: 'defer' })","（版本相关，实测为准）。",[10,4380,4381,1257,4384,4387,4388,4391,4392,4395,4396,4399],{},[14,4382,4383],{},"6. 相对路径在服务端解析的是 Nuxt 服务器，不是你的后端。",[18,4385,4386],{},"useFetch('\u002Fapi\u002Fposts')"," 在 SSR 阶段由 Nitro 内部处理（命中你自己的 ",[18,4389,4390],{},"server\u002Fapi"," 时最省事，不发起真实网络请求）；但请求",[14,4393,4394],{},"外部域名","时必须写绝对 URL 或配 ",[18,4397,4398],{},"baseURL","——这是「本地能跑、服务器上 fetch 超时」的头号原因（第六章解决）。",[10,4401,4402,4405,4406,4408,4409,4412],{},[14,4403,4404],{},"7. 认证 cookie 在服务端取不到。"," SSR 阶段 ",[18,4407,3820],{}," 不会自动带上浏览器的 cookie。要么用 ",[18,4410,4411],{},"useRequestHeaders(['cookie'])"," 把请求头透传给内部接口，要么把这类「只有浏览器才知道结果」的数据放客户端取。",[10,4414,4415,1257,4420,4423,4424,4427,4428,4430,4431,4434],{},[14,4416,1300,4417,4419],{},[18,4418,1385],{}," 是可响应式的，别在回调里整体替换它然后期望触发更新。",[18,4421,4422],{},"data.value = newData"," 是对的；",[18,4425,4426],{},"data.list.push(x)"," 若 ",[18,4429,1385],{}," 是 ref 就漏了 ",[18,4432,4433],{},".value","。（Vue 教程第四章的老规矩在这里一样适用。）",[168,4436,4438],{"id":4437},"附数据获取选项速查","附：数据获取选项速查",[59,4440,4441,4453],{},[62,4442,4443],{},[65,4444,4445,4448,4451],{},[68,4446,4447],{},"选项",[68,4449,4450],{},"类型",[68,4452,3305],{},[75,4454,4455,4467,4483,4494,4511,4524,4536,4549,4566,4581,4597,4617,4629,4644,4663],{},[65,4456,4457,4461,4464],{},[80,4458,4459],{},[18,4460,3781],{},[80,4462,4463],{},"string",[80,4465,4466],{},"缓存键；同 key 只跑一次",[65,4468,4469,4474,4477],{},[80,4470,4471],{},[18,4472,4473],{},"server",[80,4475,4476],{},"boolean",[80,4478,4479,4480,4482],{},"是否在服务端执行（",[18,4481,803],{}," = 只客户端）",[65,4484,4485,4489,4491],{},[80,4486,4487],{},[18,4488,3943],{},[80,4490,4476],{},[80,4492,4493],{},"不阻塞导航\u002F渲染，数据到达后再更新",[65,4495,4496,4500,4502],{},[80,4497,4498],{},[18,4499,3949],{},[80,4501,4476],{},[80,4503,4504,4505,4507,4508,638],{},"创建时是否立刻执行（",[18,4506,803],{}," 配 ",[18,4509,4510],{},"execute()",[65,4512,4513,4517,4521],{},[80,4514,4515],{},[18,4516,4341],{},[80,4518,4463,4519],{},[427,4520],{},[80,4522,4523],{},"只保留返回值的指定字段进 payload",[65,4525,4526,4530,4533],{},[80,4527,4528],{},[18,4529,4344],{},[80,4531,4532],{},"function",[80,4534,4535],{},"转换\u002F裁剪返回值",[65,4537,4538,4542,4544],{},[80,4539,4540],{},[18,4541,1371],{},[80,4543,4532],{},[80,4545,4546,4548],{},[18,4547,1385],{}," 的初始值（Nuxt 4 建议显式给）",[65,4550,4551,4555,4560],{},[80,4552,4553],{},[18,4554,972],{},[80,4556,4557,4558],{},"Ref",[427,4559],{},[80,4561,4562,4563,4565],{},"依赖变化时自动重新请求；传 ",[18,4564,2265],{}," 关掉自动触发",[65,4567,4568,4573,4578],{},[80,4569,4570],{},[18,4571,4572],{},"dedupe",[80,4574,4575],{},[18,4576,4577],{},"'cancel' | 'defer' | 'allow'",[80,4579,4580],{},"同一请求并发时的策略（默认 cancel）",[65,4582,4583,4591,4594],{},[80,4584,4585,1085,4588],{},[18,4586,4587],{},"query",[18,4589,4590],{},"params",[80,4592,4593],{},"object",[80,4595,4596],{},"拼 URL 查询串（对象值会被序列化）",[65,4598,4599,4610,4612],{},[80,4600,4601,1085,4604,1085,4607],{},[18,4602,4603],{},"headers",[18,4605,4606],{},"method",[18,4608,4609],{},"body",[80,4611,611],{},[80,4613,4614,4615],{},"透传给 ",[18,4616,3820],{},[65,4618,4619,4624,4626],{},[80,4620,4621],{},[18,4622,4623],{},"getCachedData",[80,4625,4532],{},[80,4627,4628],{},"自定义「是否复用已有数据」（做数据级缓存）",[65,4630,4631,4639,4641],{},[80,4632,4633,1085,4636],{},[18,4634,4635],{},"onResponse",[18,4637,4638],{},"onError",[80,4640,4532],{},[80,4642,4643],{},"请求回调（埋点、错误提示）",[65,4645,4646,4651,4653],{},[80,4647,4648],{},[18,4649,4650],{},"response",[80,4652,611],{},[80,4654,4655,4658,4659,4662],{},[18,4656,4657],{},"await useFetch(...)"," 返回的 promise 上有 ",[18,4660,4661],{},".response","（含状态码\u002F头）",[65,4664,4665,4670,4673],{},[80,4666,4667],{},[18,4668,4669],{},"signal",[80,4671,4672],{},"AbortSignal",[80,4674,4675],{},"自己接管取消",[45,4677,4679,4680,4682],{"id":4678},"六fetch底层请求函数与它的-baseurl","六、",[18,4681,3820],{},"：底层请求函数与它的 baseURL",[10,4684,4685,4687,4688,4690,4691,4693],{},[18,4686,994],{}," 内部调的就是 ",[18,4689,3820],{},"。当你不需要「进 payload、SSR 等待、缓存」这套语义时（比如提交表单、点按钮加载下一页），直接用 ",[18,4692,3820],{}," 更轻。",[418,4695,4697],{"className":696,"code":4696,"language":698,"meta":423,"style":423},"\u002F\u002F 组件里\nconst form = reactive({ title: '', body: '' })\nasync function submit() {\n  const res = await $fetch('\u002Fapi\u002Fposts', { method: 'POST', body: { ...form } })\n  await navigateTo(`\u002Fposts\u002F${res.id}`)\n}\n",[18,4698,4699,4704,4729,4742,4775,4797],{"__ignoreMap":423},[427,4700,4701],{"class":429,"line":430},[427,4702,4703],{"class":433},"\u002F\u002F 组件里\n",[427,4705,4706,4708,4711,4713,4716,4719,4722,4725,4727],{"class":429,"line":437},[427,4707,1867],{"class":710},[427,4709,4710],{"class":489}," form",[427,4712,1873],{"class":710},[427,4714,4715],{"class":440}," reactive",[427,4717,4718],{"class":720},"({ title: ",[427,4720,4721],{"class":444},"''",[427,4723,4724],{"class":720},", body: ",[427,4726,4721],{"class":444},[427,4728,2961],{"class":720},[427,4730,4731,4733,4736,4739],{"class":429,"line":454},[427,4732,4232],{"class":710},[427,4734,4735],{"class":710}," function",[427,4737,4738],{"class":440}," submit",[427,4740,4741],{"class":720},"() {\n",[427,4743,4744,4747,4750,4752,4754,4756,4758,4760,4763,4766,4769,4772],{"class":429,"line":461},[427,4745,4746],{"class":710},"  const",[427,4748,4749],{"class":489}," res",[427,4751,1873],{"class":710},[427,4753,3974],{"class":710},[427,4755,3878],{"class":440},[427,4757,1845],{"class":720},[427,4759,3538],{"class":444},[427,4761,4762],{"class":720},", { method: ",[427,4764,4765],{"class":444},"'POST'",[427,4767,4768],{"class":720},", body: { ",[427,4770,4771],{"class":710},"...",[427,4773,4774],{"class":720},"form } })\n",[427,4776,4777,4779,4781,4783,4786,4789,4791,4793,4795],{"class":429,"line":467},[427,4778,3530],{"class":710},[427,4780,1842],{"class":440},[427,4782,1845],{"class":720},[427,4784,4785],{"class":444},"`\u002Fposts\u002F${",[427,4787,4788],{"class":720},"res",[427,4790,2094],{"class":444},[427,4792,4073],{"class":720},[427,4794,4076],{"class":444},[427,4796,2820],{"class":720},[427,4798,4799],{"class":429,"line":481},[427,4800,4801],{"class":720},"}\n",[10,4803,4804,4806,4807,4810,4811,4814],{},[18,4805,3820],{}," 来自 ",[18,4808,4809],{},"ofetch","，Nuxt 给它注入了一层拦截（",[18,4812,4813],{},"useNuxtApp().$fetch","）。常用的四个选项：",[418,4816,4818],{"className":696,"code":4817,"language":698,"meta":423,"style":423},"await $fetch('\u002Fapi\u002Flist', {\n  baseURL: '\u002Fapi',              \u002F\u002F 前缀（注意：URL 已经是绝对地址时 baseURL 被忽略）\n  query: { page: 2, tag: ['a', 'b'] },\n  headers: { 'x-trace': id },\n  timeout: 5000,\n  retry: 1,                     \u002F\u002F 失败重试次数（默认：GET 重试、POST 不重试）\n  onResponse({ response, options }) { \u002F* 统一处理 *\u002F },\n  onRequestError({ request, options, error }) { \u002F* 请求没发出去\u002F被取消 *\u002F },\n  onResponseError({ request, response, options }) { \u002F* 4xx\u002F5xx：会 throw *\u002F }\n})\n",[18,4819,4820,4832,4846,4868,4879,4890,4903,4926,4951,4976],{"__ignoreMap":423},[427,4821,4822,4824,4826,4828,4830],{"class":429,"line":430},[427,4823,1839],{"class":710},[427,4825,3878],{"class":440},[427,4827,1845],{"class":720},[427,4829,4162],{"class":444},[427,4831,4165],{"class":720},[427,4833,4834,4837,4840,4843],{"class":429,"line":437},[427,4835,4836],{"class":720},"  baseURL: ",[427,4838,4839],{"class":444},"'\u002Fapi'",[427,4841,4842],{"class":720},",              ",[427,4844,4845],{"class":433},"\u002F\u002F 前缀（注意：URL 已经是绝对地址时 baseURL 被忽略）\n",[427,4847,4848,4851,4854,4857,4860,4862,4865],{"class":429,"line":454},[427,4849,4850],{"class":720},"  query: { page: ",[427,4852,4853],{"class":489},"2",[427,4855,4856],{"class":720},", tag: [",[427,4858,4859],{"class":444},"'a'",[427,4861,778],{"class":720},[427,4863,4864],{"class":444},"'b'",[427,4866,4867],{"class":720},"] },\n",[427,4869,4870,4873,4876],{"class":429,"line":461},[427,4871,4872],{"class":720},"  headers: { ",[427,4874,4875],{"class":444},"'x-trace'",[427,4877,4878],{"class":720},": id },\n",[427,4880,4881,4884,4887],{"class":429,"line":467},[427,4882,4883],{"class":720},"  timeout: ",[427,4885,4886],{"class":489},"5000",[427,4888,4889],{"class":720},",\n",[427,4891,4892,4895,4897,4900],{"class":429,"line":481},[427,4893,4894],{"class":720},"  retry: ",[427,4896,3544],{"class":489},[427,4898,4899],{"class":720},",                     ",[427,4901,4902],{"class":433},"\u002F\u002F 失败重试次数（默认：GET 重试、POST 不重试）\n",[427,4904,4905,4908,4911,4913,4915,4918,4921,4924],{"class":429,"line":486},[427,4906,4907],{"class":440},"  onResponse",[427,4909,4910],{"class":720},"({ ",[427,4912,4650],{"class":2070},[427,4914,778],{"class":720},[427,4916,4917],{"class":2070},"options",[427,4919,4920],{"class":720}," }) { ",[427,4922,4923],{"class":433},"\u002F* 统一处理 *\u002F",[427,4925,746],{"class":720},[427,4927,4928,4931,4933,4936,4938,4940,4942,4944,4946,4949],{"class":429,"line":495},[427,4929,4930],{"class":440},"  onRequestError",[427,4932,4910],{"class":720},[427,4934,4935],{"class":2070},"request",[427,4937,778],{"class":720},[427,4939,4917],{"class":2070},[427,4941,778],{"class":720},[427,4943,3514],{"class":2070},[427,4945,4920],{"class":720},[427,4947,4948],{"class":433},"\u002F* 请求没发出去\u002F被取消 *\u002F",[427,4950,746],{"class":720},[427,4952,4953,4956,4958,4960,4962,4964,4966,4968,4970,4973],{"class":429,"line":503},[427,4954,4955],{"class":440},"  onResponseError",[427,4957,4910],{"class":720},[427,4959,4935],{"class":2070},[427,4961,778],{"class":720},[427,4963,4650],{"class":2070},[427,4965,778],{"class":720},[427,4967,4917],{"class":2070},[427,4969,4920],{"class":720},[427,4971,4972],{"class":433},"\u002F* 4xx\u002F5xx：会 throw *\u002F",[427,4974,4975],{"class":720}," }\n",[427,4977,4978],{"class":429,"line":791},[427,4979,911],{"class":720},[10,4981,4982,4983,4986,4987,4990,4991,4993,4994,4997,4998,5001],{},"一条必须知道的默认行为：",[14,4984,4985],{},"非 2xx 会抛错","（不是返回一个 ",[18,4988,4989],{},"{ ok: false }","）。所以所有 ",[18,4992,3820],{}," 都要包 ",[18,4995,4996],{},"try\u002Fcatch","，或者在 ",[18,4999,5000],{},"onResponseError"," 里统一转成提示：",[418,5003,5005],{"className":696,"code":5004,"language":698,"meta":423,"style":423},"try {\n  await $fetch('\u002Fapi\u002Forder', { method: 'POST', body })\n} catch (e: any) {\n  \u002F\u002F e.data 是被解析后的响应体；e.response.status 是状态码\n  toast(e?.data?.message || '提交失败，请稍后再试')\n}\n",[18,5006,5007,5015,5033,5056,5061,5077],{"__ignoreMap":423},[427,5008,5009,5012],{"class":429,"line":430},[427,5010,5011],{"class":710},"try",[427,5013,5014],{"class":720}," {\n",[427,5016,5017,5019,5021,5023,5026,5028,5030],{"class":429,"line":437},[427,5018,3530],{"class":710},[427,5020,3878],{"class":440},[427,5022,1845],{"class":720},[427,5024,5025],{"class":444},"'\u002Fapi\u002Forder'",[427,5027,4762],{"class":720},[427,5029,4765],{"class":444},[427,5031,5032],{"class":720},", body })\n",[427,5034,5035,5038,5041,5044,5047,5050,5053],{"class":429,"line":454},[427,5036,5037],{"class":720},"} ",[427,5039,5040],{"class":710},"catch",[427,5042,5043],{"class":720}," (",[427,5045,5046],{"class":2070},"e",[427,5048,5049],{"class":710},":",[427,5051,5052],{"class":489}," any",[427,5054,5055],{"class":720},") {\n",[427,5057,5058],{"class":429,"line":461},[427,5059,5060],{"class":433},"  \u002F\u002F e.data 是被解析后的响应体；e.response.status 是状态码\n",[427,5062,5063,5066,5069,5072,5075],{"class":429,"line":467},[427,5064,5065],{"class":440},"  toast",[427,5067,5068],{"class":720},"(e?.data?.message ",[427,5070,5071],{"class":710},"||",[427,5073,5074],{"class":444}," '提交失败，请稍后再试'",[427,5076,2820],{"class":720},[427,5078,5079],{"class":429,"line":481},[427,5080,4801],{"class":720},[168,5082,5084],{"id":5083},"服务端阶段访问外部-apibaseurl-的真相","服务端阶段访问外部 API：baseURL 的真相",[10,5086,5087],{},"这是 Nuxt 新手最大的一个坑，必须单独讲。",[10,5089,5090,5091,5094,5095,5098],{},"SSR 阶段执行 ",[18,5092,5093],{},"useFetch('\u002Fapi\u002Fx')"," 时，请求",[14,5096,5097],{},"不发往浏览器地址","，而是在 Nitro 内部直接调用对应的 handler（省一次网络往返，很好）。但如果你的意图是「让浏览器去请求第三方域名」，写成相对路径就错了：",[418,5100,5102],{"className":696,"code":5101,"language":698,"meta":423,"style":423},"\u002F\u002F ✗ SSR 阶段：Nitro 会把它当成自己的 \u002Fapi\u002Fx 处理 → 404 或打到不存在的 handler\nawait useFetch('\u002Fapi\u002Fx')\n\n\u002F\u002F ✗ SSR 阶段：从服务器发起，指向的是「本机」而不是外部服务\nawait useFetch('\u002Fweather', { baseURL: 'https:\u002F\u002Fapi.open-meteo.com' })  \u002F\u002F 这种写法是对的（绝对域名）\n\n\u002F\u002F ✓ 正确姿势一：让服务端接口做代理，前端只访问自己\n\u002F\u002F   app\u002Fpages\u002F…\nawait useFetch('\u002Fapi\u002Fweather')          \u002F\u002F Nuxt 自己的接口\n\u002F\u002F   server\u002Fapi\u002Fweather.get.ts\nexport default defineEventHandler(() => $fetch('https:\u002F\u002Fapi.open-meteo.com\u002Fv1\u002Fforecast?latitude=…'))\n\n\u002F\u002F ✓ 正确姿势二：外部地址写成绝对 URL，并接受「这是服务端发起的请求」\nawait useFetch('https:\u002F\u002Fapi.example.com\u002Fv1\u002Flist', { headers: { authorization: `Bearer ${token}` } })\n",[18,5103,5104,5109,5122,5126,5131,5154,5158,5163,5168,5185,5190,5213,5217,5222],{"__ignoreMap":423},[427,5105,5106],{"class":429,"line":430},[427,5107,5108],{"class":433},"\u002F\u002F ✗ SSR 阶段：Nitro 会把它当成自己的 \u002Fapi\u002Fx 处理 → 404 或打到不存在的 handler\n",[427,5110,5111,5113,5115,5117,5120],{"class":429,"line":437},[427,5112,1839],{"class":710},[427,5114,3533],{"class":440},[427,5116,1845],{"class":720},[427,5118,5119],{"class":444},"'\u002Fapi\u002Fx'",[427,5121,2820],{"class":720},[427,5123,5124],{"class":429,"line":454},[427,5125,458],{"emptyLinePlaceholder":457},[427,5127,5128],{"class":429,"line":461},[427,5129,5130],{"class":433},"\u002F\u002F ✗ SSR 阶段：从服务器发起，指向的是「本机」而不是外部服务\n",[427,5132,5133,5135,5137,5139,5142,5145,5148,5151],{"class":429,"line":467},[427,5134,1839],{"class":710},[427,5136,3533],{"class":440},[427,5138,1845],{"class":720},[427,5140,5141],{"class":444},"'\u002Fweather'",[427,5143,5144],{"class":720},", { baseURL: ",[427,5146,5147],{"class":444},"'https:\u002F\u002Fapi.open-meteo.com'",[427,5149,5150],{"class":720}," })  ",[427,5152,5153],{"class":433},"\u002F\u002F 这种写法是对的（绝对域名）\n",[427,5155,5156],{"class":429,"line":481},[427,5157,458],{"emptyLinePlaceholder":457},[427,5159,5160],{"class":429,"line":486},[427,5161,5162],{"class":433},"\u002F\u002F ✓ 正确姿势一：让服务端接口做代理，前端只访问自己\n",[427,5164,5165],{"class":429,"line":495},[427,5166,5167],{"class":433},"\u002F\u002F   app\u002Fpages\u002F…\n",[427,5169,5170,5172,5174,5176,5179,5182],{"class":429,"line":503},[427,5171,1839],{"class":710},[427,5173,3533],{"class":440},[427,5175,1845],{"class":720},[427,5177,5178],{"class":444},"'\u002Fapi\u002Fweather'",[427,5180,5181],{"class":720},")          ",[427,5183,5184],{"class":433},"\u002F\u002F Nuxt 自己的接口\n",[427,5186,5187],{"class":429,"line":791},[427,5188,5189],{"class":433},"\u002F\u002F   server\u002Fapi\u002Fweather.get.ts\n",[427,5191,5192,5194,5196,5199,5202,5204,5206,5208,5211],{"class":429,"line":812},[427,5193,711],{"class":710},[427,5195,714],{"class":710},[427,5197,5198],{"class":440}," defineEventHandler",[427,5200,5201],{"class":720},"(() ",[427,5203,2077],{"class":710},[427,5205,3878],{"class":440},[427,5207,1845],{"class":720},[427,5209,5210],{"class":444},"'https:\u002F\u002Fapi.open-meteo.com\u002Fv1\u002Fforecast?latitude=…'",[427,5212,3886],{"class":720},[427,5214,5215],{"class":429,"line":817},[427,5216,458],{"emptyLinePlaceholder":457},[427,5218,5219],{"class":429,"line":823},[427,5220,5221],{"class":433},"\u002F\u002F ✓ 正确姿势二：外部地址写成绝对 URL，并接受「这是服务端发起的请求」\n",[427,5223,5224,5226,5228,5230,5233,5236,5239,5242,5244],{"class":429,"line":829},[427,5225,1839],{"class":710},[427,5227,3533],{"class":440},[427,5229,1845],{"class":720},[427,5231,5232],{"class":444},"'https:\u002F\u002Fapi.example.com\u002Fv1\u002Flist'",[427,5234,5235],{"class":720},", { headers: { authorization: ",[427,5237,5238],{"class":444},"`Bearer ${",[427,5240,5241],{"class":720},"token",[427,5243,4076],{"class":444},[427,5245,2797],{"class":720},[10,5247,5248],{},"用服务端代理的三条理由（也是所有「前端不该持有密钥」场景的通用答案）：",[183,5250,5251,5260,5266],{},[186,5252,5253,5256,5257,5259],{},[14,5254,5255],{},"密钥不下发","：第三方 token 留在 ",[18,5258,314],{},"，浏览器看不到（第八章会说到 runtimeConfig）。",[186,5261,5262,5265],{},[14,5263,5264],{},"绕开 CORS","：服务端之间没有同源限制。",[186,5267,5268,5271,5272,5275],{},[14,5269,5270],{},"可控","：能加缓存（",[18,5273,5274],{},"swr","）、限流、降级、日志。",[168,5277,5279],{"id":5278},"请求头与-cookie-的透传","请求头与 cookie 的透传",[418,5281,5283],{"className":696,"code":5282,"language":698,"meta":423,"style":423},"\u002F\u002F 只在服务端执行：把浏览器带来的 cookie 转交给内部接口\nconst headers = useRequestHeaders(['cookie'])\nconst { data } = await useFetch('\u002Fapi\u002Fme', { headers })\n",[18,5284,5285,5290,5311],{"__ignoreMap":423},[427,5286,5287],{"class":429,"line":430},[427,5288,5289],{"class":433},"\u002F\u002F 只在服务端执行：把浏览器带来的 cookie 转交给内部接口\n",[427,5291,5292,5294,5297,5299,5302,5305,5308],{"class":429,"line":437},[427,5293,1867],{"class":710},[427,5295,5296],{"class":489}," headers",[427,5298,1873],{"class":710},[427,5300,5301],{"class":440}," useRequestHeaders",[427,5303,5304],{"class":720},"([",[427,5306,5307],{"class":444},"'cookie'",[427,5309,5310],{"class":720},"])\n",[427,5312,5313,5315,5317,5319,5321,5323,5325,5327,5329,5331],{"class":429,"line":454},[427,5314,1867],{"class":710},[427,5316,3491],{"class":720},[427,5318,1385],{"class":489},[427,5320,3522],{"class":720},[427,5322,1550],{"class":710},[427,5324,3974],{"class":710},[427,5326,3533],{"class":440},[427,5328,1845],{"class":720},[427,5330,4018],{"class":444},[427,5332,5333],{"class":720},", { headers })\n",[10,5335,5336,5339,5340,5343],{},[18,5337,5338],{},"useRequestHeaders()"," 在客户端返回空对象——这不是 bug，而是「浏览器本来就会自动带 cookie」。",[14,5341,5342],{},"判断标准","：SSR 阶段的请求需要你手工带上下文（cookie \u002F authorization \u002F user-agent），客户端阶段的请求由浏览器自动带。",[168,5345,5347,651,5349,5351],{"id":5346},"usefetch-与-fetch-的取舍",[18,5348,994],{},[18,5350,3820],{}," 的取舍",[59,5353,5354,5364],{},[62,5355,5356],{},[65,5357,5358,5361],{},[68,5359,5360],{},"需求",[68,5362,5363],{},"用什么",[75,5365,5366,5378,5390,5400,5412],{},[65,5367,5368,5371],{},[80,5369,5370],{},"页面渲染需要的数据，要进 SSR HTML 和 payload",[80,5372,5373,1085,5376],{},[18,5374,5375],{},"await useFetch",[18,5377,997],{},[65,5379,5380,5383],{},[80,5381,5382],{},"用户点按钮才触发的加载（下一页、加载更多）",[80,5384,5385,5387,5388],{},[18,5386,3820],{}," + 本地 ",[18,5389,243],{},[65,5391,5392,5395],{},[80,5393,5394],{},"提交表单、删除、点赞",[80,5396,5397,5399],{},[18,5398,3820],{},"（写操作不该被缓存）",[65,5401,5402,5405],{},[80,5403,5404],{},"服务端接口内部再调外部 API",[80,5406,5407,304,5409,5411],{},[18,5408,3820],{},[18,5410,314],{}," 里，没有 payload 概念）",[65,5413,5414,5417],{},[80,5415,5416],{},"图片\u002F文件下载",[80,5418,5419,5421,5422,5425],{},[18,5420,994],{}," 的 ",[18,5423,5424],{},"responseType: 'arrayBuffer'"," 或直接浏览器行为",[168,5427,261],{"id":5428},"️-本章的坑-5",[10,5430,5431,5436],{},[14,5432,5433,5434,210],{},"1. 忘记 ",[18,5435,4996],{}," 4xx\u002F5xx 会 throw，未被捕获时 SSR 阶段直接把页面炸成错误页（客户端则表现为「点了没反应」+ 一条 unhandled rejection）。",[10,5438,5439,5442,5443,5445],{},[14,5440,5441],{},"2. 相对路径当成「浏览器地址」。"," 上面整节讲的就是它。症状很迷惑：",[18,5444,547],{}," 里正常，部署到别的域名\u002F子路径后接口全 404。",[10,5447,5448,5453,5454,5456],{},[14,5449,2204,5450,5452],{},[18,5451,4398],{}," 与绝对 URL 同时给。"," 一旦请求字符串里有协议+域名，",[18,5455,4398],{}," 被忽略，请求发往你以为没在用的地址。二选一。",[10,5458,5459,1257,5462,5465,5466,210],{},[14,5460,5461],{},"4. POST 被自动重试造成重复下单。",[18,5463,5464],{},"retry"," 默认对幂等请求生效；把写操作设计成非幂等时显式写 ",[18,5467,5468],{},"retry: 0",[10,5470,5471,1257,5479,5481,5482,5484,5485,5487,5488,1277,5491,278],{},[14,5472,5473,5474,5476,5477,210],{},"5. ",[18,5475,4587],{}," 里塞 ",[18,5478,1389],{},[18,5480,4809],{}," 会跳过 ",[18,5483,1389],{},"，但序列化 ",[18,5486,1375],{},"\u002F空串——「接口收到的 query 和预期不一致」时先打印实际 URL（",[18,5489,5490],{},"onRequest",[18,5492,5493],{},"console.log(options.url + options.query)",[10,5495,5496,1257,5499,5502,5503,5506,5507,2285,5510,5513,5514,5517],{},[14,5497,5498],{},"6. 数组查询参数的编码格式。",[18,5500,5501],{},"query: { ids: [1, 2] }"," 编成 ",[18,5504,5505],{},"ids=1&ids=2","；不少后端要的是 ",[18,5508,5509],{},"ids[]=1",[18,5511,5512],{},"ids=1,2","。用 ",[18,5515,5516],{},"query: { ids: ids.join(',') }"," 明确格式，别赌框架默认。",[10,5519,5520,5528],{},[14,5521,5522,5523,1277,5526,210],{},"7. 在 ",[18,5524,5525],{},"onRequestError",[18,5527,2938],{}," 拦截器里的异常会覆盖原始错误，排查时会看到「错误来源不明」。回调里只做记录，改写结果请返回新值。",[10,5530,5531,5538,5539,5542,5543,2285,5545,278],{},[14,5532,5533,5534,5537],{},"8. 服务端拿不到 ",[18,5535,5536],{},"useRequestHeaders"," 之外的浏览器状态。"," localStorage、",[18,5540,5541],{},"navigator"," 一律为空。要这类信息就放到客户端再取（",[18,5544,4316],{},[18,5546,3396],{},[168,5548,5550,5551,5553],{"id":5549},"附fetch-错误对象形状","附：",[18,5552,3820],{}," 错误对象形状",[59,5555,5556,5566],{},[62,5557,5558],{},[65,5559,5560,5563],{},[68,5561,5562],{},"字段",[68,5564,5565],{},"内容",[75,5567,5568,5582,5602,5616,5626],{},[65,5569,5570,5575],{},[80,5571,5572],{},[18,5573,5574],{},"error.message",[80,5576,5577,5578,5581],{},"文本描述（",[18,5579,5580],{},"404 Not Found: \u002Fapi\u002Fx"," 这类）",[65,5583,5584,5589],{},[80,5585,5586],{},[18,5587,5588],{},"error.response",[80,5590,5591,5592,5595,5596,28,5599,638],{},"原生 ",[18,5593,5594],{},"Response"," 对象（",[18,5597,5598],{},".status",[18,5600,5601],{},".headers",[65,5603,5604,5609],{},[80,5605,5606],{},[18,5607,5608],{},"error.data",[80,5610,5611,5612,5615],{},"解析后的响应体（后端返回的 ",[18,5613,5614],{},"{ code, message }"," 在这里）",[65,5617,5618,5623],{},[80,5619,5620],{},[18,5621,5622],{},"error.options",[80,5624,5625],{},"本次请求的配置（URL、method、headers）",[65,5627,5628,5631],{},[80,5629,5630],{},"判定位置",[80,5632,5633,5634,5636,5637],{},"4xx\u002F5xx → ",[18,5635,5000],{},"；网络\u002FDNS\u002F取消 → ",[18,5638,5525],{},[45,5640,5642,5643,5645],{"id":5641},"七服务端引擎-nitroserver-里的文件就是接口","七、服务端引擎 Nitro：",[18,5644,314],{}," 里的文件就是接口",[10,5647,5648,5650,5651,5653,5654,210],{},[18,5649,20],{}," 出静态站时不需要它；但只要你需要「有秘密参与」或「按请求计算」的功能（登录、上传、支付回调、爬虫代理、读文件），",[18,5652,314],{}," 就是最短路径——",[14,5655,5656],{},"不用另起一个后端项目",[168,5658,5659],{"id":5659},"一个接口三步",[418,5661,5663],{"className":696,"code":5662,"language":698,"meta":423,"style":423},"\u002F\u002F server\u002Fapi\u002Fnotes\u002Findex.get.ts\nexport default defineEventHandler(async (event) => {\n  const { q = '', page = '1' } = getQuery(event)\n  const list = await useDb(event).note.search({ q, page: Number(page) })\n  return { total: list.length, items: list.map(slim) }     \u002F\u002F 返回值 = JSON 响应体\n})\n",[18,5664,5665,5670,5693,5727,5756,5779],{"__ignoreMap":423},[427,5666,5667],{"class":429,"line":430},[427,5668,5669],{"class":433},"\u002F\u002F server\u002Fapi\u002Fnotes\u002Findex.get.ts\n",[427,5671,5672,5674,5676,5678,5680,5682,5684,5687,5689,5691],{"class":429,"line":437},[427,5673,711],{"class":710},[427,5675,714],{"class":710},[427,5677,5198],{"class":440},[427,5679,1845],{"class":720},[427,5681,4232],{"class":710},[427,5683,5043],{"class":720},[427,5685,5686],{"class":2070},"event",[427,5688,2074],{"class":720},[427,5690,2077],{"class":710},[427,5692,5014],{"class":720},[427,5694,5695,5697,5699,5702,5704,5707,5709,5712,5714,5717,5719,5721,5724],{"class":429,"line":454},[427,5696,4746],{"class":710},[427,5698,3491],{"class":720},[427,5700,5701],{"class":489},"q",[427,5703,1873],{"class":710},[427,5705,5706],{"class":444}," ''",[427,5708,778],{"class":720},[427,5710,5711],{"class":489},"page",[427,5713,1873],{"class":710},[427,5715,5716],{"class":444}," '1'",[427,5718,3522],{"class":720},[427,5720,1550],{"class":710},[427,5722,5723],{"class":440}," getQuery",[427,5725,5726],{"class":720},"(event)\n",[427,5728,5729,5731,5734,5736,5738,5741,5744,5747,5750,5753],{"class":429,"line":461},[427,5730,4746],{"class":710},[427,5732,5733],{"class":489}," list",[427,5735,1873],{"class":710},[427,5737,3974],{"class":710},[427,5739,5740],{"class":440}," useDb",[427,5742,5743],{"class":720},"(event).note.",[427,5745,5746],{"class":440},"search",[427,5748,5749],{"class":720},"({ q, page: ",[427,5751,5752],{"class":440},"Number",[427,5754,5755],{"class":720},"(page) })\n",[427,5757,5758,5761,5764,5767,5770,5773,5776],{"class":429,"line":467},[427,5759,5760],{"class":710},"  return",[427,5762,5763],{"class":720}," { total: list.",[427,5765,5766],{"class":489},"length",[427,5768,5769],{"class":720},", items: list.",[427,5771,5772],{"class":440},"map",[427,5774,5775],{"class":720},"(slim) }     ",[427,5777,5778],{"class":433},"\u002F\u002F 返回值 = JSON 响应体\n",[427,5780,5781],{"class":429,"line":481},[427,5782,911],{"class":720},[59,5784,5785,5798],{},[62,5786,5787],{},[65,5788,5789,5792,5795],{},[68,5790,5791],{},"文件写法",[68,5793,5794],{},"生成的 URL",[68,5796,5797],{},"方法",[75,5799,5800,5815,5829,5843,5858,5873,5896,5908],{},[65,5801,5802,5807,5812],{},[80,5803,5804],{},[18,5805,5806],{},"server\u002Fapi\u002Fnotes.get.ts",[80,5808,5809],{},[18,5810,5811],{},"\u002Fapi\u002Fnotes",[80,5813,5814],{},"GET",[65,5816,5817,5822,5826],{},[80,5818,5819],{},[18,5820,5821],{},"server\u002Fapi\u002Fnotes.post.ts",[80,5823,5824],{},[18,5825,5811],{},[80,5827,5828],{},"POST",[65,5830,5831,5836,5840],{},[80,5832,5833],{},[18,5834,5835],{},"server\u002Fapi\u002Fnotes\u002Findex.get.ts",[80,5837,5838],{},[18,5839,5811],{},[80,5841,5842],{},"GET（与上面等价，用于同目录多方法）",[65,5844,5845,5850,5855],{},[80,5846,5847],{},[18,5848,5849],{},"server\u002Fapi\u002Fnotes\u002F[id].delete.ts",[80,5851,5852],{},[18,5853,5854],{},"\u002Fapi\u002Fnotes\u002F:id",[80,5856,5857],{},"DELETE",[65,5859,5860,5865,5870],{},[80,5861,5862],{},[18,5863,5864],{},"server\u002Fapi\u002Fsearch.ts",[80,5866,5867],{},[18,5868,5869],{},"\u002Fapi\u002Fsearch",[80,5871,5872],{},"任意（不限制方法）",[65,5874,5875,5880,5885],{},[80,5876,5877],{},[18,5878,5879],{},"server\u002Froutes\u002Fhealth.get.ts",[80,5881,5882],{},[18,5883,5884],{},"\u002Fhealth",[80,5886,5887,5888,5891,5892,5895],{},"GET（",[18,5889,5890],{},"routes\u002F"," 不带 ",[18,5893,5894],{},"\u002Fapi"," 前缀）",[65,5897,5898,5903,5905],{},[80,5899,5900],{},[18,5901,5902],{},"server\u002Fmiddleware\u002Flog.ts",[80,5904,611],{},[80,5906,5907],{},"每个请求前跑",[65,5909,5910,5915,5917],{},[80,5911,5912],{},[18,5913,5914],{},"server\u002Futils\u002Fdb.ts",[80,5916,611],{},[80,5918,5919,5920],{},"不是接口，被自动导入成 ",[18,5921,1115],{},[10,5923,5924,5925,1085,5928,1085,5931,1085,5934,1085,5937,1085,5940,1085,5943,278,5946,5949,5950,651,5953,5956],{},"后缀决定方法（",[18,5926,5927],{},".get",[18,5929,5930],{},".post",[18,5932,5933],{},".put",[18,5935,5936],{},".patch",[18,5938,5939],{},".delete",[18,5941,5942],{},".head",[18,5944,5945],{},".options",[14,5947,5948],{},"一个文件一个方法","是约定，同一资源的不同方法就分文件写（示例 06 演示了 ",[18,5951,5952],{},"[id].get.ts",[18,5954,5955],{},"[id].patch.ts"," 各自命中）。",[168,5958,5960],{"id":5959},"handler-里常用的工具","handler 里常用的工具",[418,5962,5964],{"className":696,"code":5963,"language":698,"meta":423,"style":423},"export default defineEventHandler(async (event) => {\n  const id = getRouterParam(event, 'id')                 \u002F\u002F 路径参数\n  const query = getQuery(event)                          \u002F\u002F ?a=1\n  const body = await readBody(event)                     \u002F\u002F JSON 请求体\n  const raw = await readRawBody(event)                   \u002F\u002F 原始文本\n  const file = await readMultipartFormData(event)        \u002F\u002F 上传（数组，每项有 filename\u002Fdata）\n  const cookie = getCookie(event, 'sid')\n\n  setHeader(event, 'cache-control', 's-maxage=60, stale-while-revalidate')\n  setResponseStatus(event, 201)\n  setCookie(event, 'sid', 'abc', { httpOnly: true, sameSite: 'lax', maxAge: 60 * 60 * 24 })\n\n  if (!id) throw createError({ statusCode: 400, statusMessage: '缺少 id' })\n  return { ok: true, id }                                \u002F\u002F ← 返回即响应；后面别再手写 res\n})\n",[18,5965,5966,5988,6011,6028,6048,6068,6088,6107,6111,6128,6140,6184,6188,6217,6232],{"__ignoreMap":423},[427,5967,5968,5970,5972,5974,5976,5978,5980,5982,5984,5986],{"class":429,"line":430},[427,5969,711],{"class":710},[427,5971,714],{"class":710},[427,5973,5198],{"class":440},[427,5975,1845],{"class":720},[427,5977,4232],{"class":710},[427,5979,5043],{"class":720},[427,5981,5686],{"class":2070},[427,5983,2074],{"class":720},[427,5985,2077],{"class":710},[427,5987,5014],{"class":720},[427,5989,5990,5992,5995,5997,6000,6003,6005,6008],{"class":429,"line":437},[427,5991,4746],{"class":710},[427,5993,5994],{"class":489}," id",[427,5996,1873],{"class":710},[427,5998,5999],{"class":440}," getRouterParam",[427,6001,6002],{"class":720},"(event, ",[427,6004,3550],{"class":444},[427,6006,6007],{"class":720},")                 ",[427,6009,6010],{"class":433},"\u002F\u002F 路径参数\n",[427,6012,6013,6015,6018,6020,6022,6025],{"class":429,"line":454},[427,6014,4746],{"class":710},[427,6016,6017],{"class":489}," query",[427,6019,1873],{"class":710},[427,6021,5723],{"class":440},[427,6023,6024],{"class":720},"(event)                          ",[427,6026,6027],{"class":433},"\u002F\u002F ?a=1\n",[427,6029,6030,6032,6035,6037,6039,6042,6045],{"class":429,"line":461},[427,6031,4746],{"class":710},[427,6033,6034],{"class":489}," body",[427,6036,1873],{"class":710},[427,6038,3974],{"class":710},[427,6040,6041],{"class":440}," readBody",[427,6043,6044],{"class":720},"(event)                     ",[427,6046,6047],{"class":433},"\u002F\u002F JSON 请求体\n",[427,6049,6050,6052,6055,6057,6059,6062,6065],{"class":429,"line":467},[427,6051,4746],{"class":710},[427,6053,6054],{"class":489}," raw",[427,6056,1873],{"class":710},[427,6058,3974],{"class":710},[427,6060,6061],{"class":440}," readRawBody",[427,6063,6064],{"class":720},"(event)                   ",[427,6066,6067],{"class":433},"\u002F\u002F 原始文本\n",[427,6069,6070,6072,6075,6077,6079,6082,6085],{"class":429,"line":481},[427,6071,4746],{"class":710},[427,6073,6074],{"class":489}," file",[427,6076,1873],{"class":710},[427,6078,3974],{"class":710},[427,6080,6081],{"class":440}," readMultipartFormData",[427,6083,6084],{"class":720},"(event)        ",[427,6086,6087],{"class":433},"\u002F\u002F 上传（数组，每项有 filename\u002Fdata）\n",[427,6089,6090,6092,6095,6097,6100,6102,6105],{"class":429,"line":486},[427,6091,4746],{"class":710},[427,6093,6094],{"class":489}," cookie",[427,6096,1873],{"class":710},[427,6098,6099],{"class":440}," getCookie",[427,6101,6002],{"class":720},[427,6103,6104],{"class":444},"'sid'",[427,6106,2820],{"class":720},[427,6108,6109],{"class":429,"line":495},[427,6110,458],{"emptyLinePlaceholder":457},[427,6112,6113,6116,6118,6121,6123,6126],{"class":429,"line":503},[427,6114,6115],{"class":440},"  setHeader",[427,6117,6002],{"class":720},[427,6119,6120],{"class":444},"'cache-control'",[427,6122,778],{"class":720},[427,6124,6125],{"class":444},"'s-maxage=60, stale-while-revalidate'",[427,6127,2820],{"class":720},[427,6129,6130,6133,6135,6138],{"class":429,"line":791},[427,6131,6132],{"class":440},"  setResponseStatus",[427,6134,6002],{"class":720},[427,6136,6137],{"class":489},"201",[427,6139,2820],{"class":720},[427,6141,6142,6145,6147,6149,6151,6154,6157,6159,6162,6165,6168,6171,6174,6177,6179,6182],{"class":429,"line":812},[427,6143,6144],{"class":440},"  setCookie",[427,6146,6002],{"class":720},[427,6148,6104],{"class":444},[427,6150,778],{"class":720},[427,6152,6153],{"class":444},"'abc'",[427,6155,6156],{"class":720},", { httpOnly: ",[427,6158,743],{"class":489},[427,6160,6161],{"class":720},", sameSite: ",[427,6163,6164],{"class":444},"'lax'",[427,6166,6167],{"class":720},", maxAge: ",[427,6169,6170],{"class":489},"60",[427,6172,6173],{"class":710}," *",[427,6175,6176],{"class":489}," 60",[427,6178,6173],{"class":710},[427,6180,6181],{"class":489}," 24",[427,6183,2961],{"class":720},[427,6185,6186],{"class":429,"line":817},[427,6187,458],{"emptyLinePlaceholder":457},[427,6189,6190,6193,6195,6198,6201,6203,6205,6207,6210,6212,6215],{"class":429,"line":823},[427,6191,6192],{"class":710},"  if",[427,6194,5043],{"class":720},[427,6196,6197],{"class":710},"!",[427,6199,6200],{"class":720},"id) ",[427,6202,2938],{"class":710},[427,6204,2941],{"class":440},[427,6206,2944],{"class":720},[427,6208,6209],{"class":489},"400",[427,6211,2950],{"class":720},[427,6213,6214],{"class":444},"'缺少 id'",[427,6216,2961],{"class":720},[427,6218,6219,6221,6224,6226,6229],{"class":429,"line":829},[427,6220,5760],{"class":710},[427,6222,6223],{"class":720}," { ok: ",[427,6225,743],{"class":489},[427,6227,6228],{"class":720},", id }                                ",[427,6230,6231],{"class":433},"\u002F\u002F ← 返回即响应；后面别再手写 res\n",[427,6233,6234],{"class":429,"line":843},[427,6235,911],{"class":720},[10,6237,6238,6239,6244,6245,6248],{},"规则一句话：",[14,6240,6241,6243],{},[18,6242,4232],{}," handler 里，返回值就是响应体。"," 不要用 ",[18,6246,6247],{},"event.node.res.end()","（除非你确定要接管原始响应，比如流式\u002F重定向），否则会出现「已经返回了但连接没关」或「双重写入」的诡异行为。",[168,6250,6251],{"id":6251},"前端怎么调它",[418,6253,6255],{"className":1515,"code":6254,"language":1517,"meta":423,"style":423},"\u003Cscript setup>\n\u002F\u002F SSR 阶段：这个请求不经过网络，Nitro 内部直接调用 handler（快、无 CORS、无 baseURL 问题）\nconst { data: notes } = await useFetch('\u002Fapi\u002Fnotes', { query: { page: 1 } })\n\u003C\u002Fscript>\n",[18,6256,6257,6267,6272,6304],{"__ignoreMap":423},[427,6258,6259,6261,6263,6265],{"class":429,"line":430},[427,6260,1529],{"class":720},[427,6262,1824],{"class":1532},[427,6264,1827],{"class":440},[427,6266,1536],{"class":720},[427,6268,6269],{"class":429,"line":437},[427,6270,6271],{"class":433},"\u002F\u002F SSR 阶段：这个请求不经过网络，Nitro 内部直接调用 handler（快、无 CORS、无 baseURL 问题）\n",[427,6273,6274,6276,6278,6280,6282,6285,6287,6289,6291,6293,6295,6298,6300,6302],{"class":429,"line":454},[427,6275,1867],{"class":710},[427,6277,3491],{"class":720},[427,6279,1385],{"class":2070},[427,6281,3496],{"class":720},[427,6283,6284],{"class":489},"notes",[427,6286,3522],{"class":720},[427,6288,1550],{"class":710},[427,6290,3974],{"class":710},[427,6292,3533],{"class":440},[427,6294,1845],{"class":720},[427,6296,6297],{"class":444},"'\u002Fapi\u002Fnotes'",[427,6299,3541],{"class":720},[427,6301,3544],{"class":489},[427,6303,2797],{"class":720},[427,6305,6306,6308,6310],{"class":429,"line":461},[427,6307,1597],{"class":720},[427,6309,1824],{"class":1532},[427,6311,1536],{"class":720},[10,6313,6314,6317],{},[18,6315,6316],{},"\u003CNuxtLink to=\"\u002Fapi\u002Fnotes\">"," 之类直接访问也能工作——部署时它就是普通的 HTTP 接口。",[168,6319,6320],{"id":6320},"环境变量与密钥的位置",[10,6322,6323,6325,6326,6329,6330,6333,6334,6340],{},[18,6324,314],{}," 里读环境变量用 ",[18,6327,6328],{},"useRuntimeConfig()","（第八章），或 Nitro 的 ",[18,6331,6332],{},"process.env","（只在服务端可见）。",[14,6335,6336,6337,6339],{},"密钥永远不要出现在 ",[18,6338,303],{}," 下的代码里","——打包产物是给人下载的文件。",[212,6342],{"height":679,"src":6343,"title":6344},"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F06-server-api.html","示例 06：server 目录文件 → URL\u002F方法 三栏联动",[168,6346,261],{"id":6347},"️-本章的坑-6",[10,6349,6350,6355,6356,6359,6360,6363,6364,6366,6367,6369],{},[14,6351,4276,6352,6354],{},[18,6353,4390],{}," 与前端路由撞名。"," 有人把接口放 ",[18,6357,6358],{},"app\u002Fpages\u002Fapi\u002F…","（当页面渲染了）或把页面放 ",[18,6361,6362],{},"server\u002Fapi\u002F…","（当成接口了）。分界很清楚：",[18,6365,314],{}," 是接口，",[18,6368,95],{}," 是页面。",[10,6371,6372,1257,6375,6378,6379,6382,6383,210],{},[14,6373,6374],{},"2. 忘写方法后缀。",[18,6376,6377],{},"server\u002Fapi\u002Fnotes.ts"," 会接受",[14,6380,6381],{},"所有方法","——一个本该只读的接口就这样成了 POST 入口。显式 ",[18,6384,6385],{},".get.ts",[10,6387,6388,6395,6396,6398],{},[14,6389,6390,6391,6394],{},"3. 在 handler 里 ",[18,6392,6393],{},"console.log"," 当调试手段，客户端看不到。"," 服务端日志打印在跑 ",[18,6397,547],{}," 的那个终端里，不是浏览器控制台。「接口没反应」先去终端找。",[10,6400,6401,6407,6408,1085,6411,210],{},[14,6402,3235,6403,6406],{},[18,6404,6405],{},"readBody"," 对 GET 请求没意义。"," 有些客户端发 GET 带 body，Nitro 默认不解析；参数请用 ",[18,6409,6410],{},"getQuery",[18,6412,6413],{},"getRouterParam",[10,6415,6416,6422,6423,6426,6427,210],{},[14,6417,6418,6419,210],{},"5. 上传文件没配 ",[18,6420,6421],{},"bodySizeLimit"," 大文件被截断或 413。事件里用 ",[18,6424,6425],{},"readMultipartFormData(event, { limit })","，并同步调 Nginx 的 ",[18,6428,6429],{},"client_max_body_size",[10,6431,6432,6439,6440,6443,6444,6447],{},[14,6433,6434,6435,6438],{},"6. 直接 ",[18,6436,6437],{},"return"," 了 Buffer\u002Fstream 却忘了设头。"," 返回二进制请 ",[18,6441,6442],{},"setHeader(event, 'content-type', 'application\u002Fpdf')"," 再送（或用 ",[18,6445,6446],{},"event.node.res"," 明确接管）。",[10,6449,6450,6453,6454,5421,6456,1241,6458,6461,6462,1056],{},[14,6451,6452],{},"7. 在接口里做重计算，没加缓存。"," 每次请求都全量算 → CPU 打满。用 ",[18,6455,38],{},[18,6457,5274],{},[18,6459,6460],{},"isr","（第九章）或 ",[18,6463,6464],{},"defineCachedEventHandler",[418,6466,6468],{"className":696,"code":6467,"language":698,"meta":423,"style":423},"export default defineCachedEventHandler(async () => heavy(), { maxAge: 60, swr: true, name: 'report' })\n",[18,6469,6470],{"__ignoreMap":423},[427,6471,6472,6474,6476,6479,6481,6483,6485,6487,6490,6493,6495,6498,6500,6502,6505],{"class":429,"line":430},[427,6473,711],{"class":710},[427,6475,714],{"class":710},[427,6477,6478],{"class":440}," defineCachedEventHandler",[427,6480,1845],{"class":720},[427,6482,4232],{"class":710},[427,6484,2809],{"class":720},[427,6486,2077],{"class":710},[427,6488,6489],{"class":440}," heavy",[427,6491,6492],{"class":720},"(), { maxAge: ",[427,6494,6170],{"class":489},[427,6496,6497],{"class":720},", swr: ",[427,6499,743],{"class":489},[427,6501,896],{"class":720},[427,6503,6504],{"class":444},"'report'",[427,6506,2961],{"class":720},[10,6508,6509,1257,6515,6518,6519,210],{},[14,6510,6511,6512,6514],{},"8. 中间件里 ",[18,6513,6437],{}," 了东西。",[18,6516,6517],{},"server\u002Fmiddleware\u002F*.ts"," 的返回值会被忽略（它是「路过看一眼」的角色）；要拦截请 ",[18,6520,6521],{},"throw createError(...)",[10,6523,6524,6531,6532,6534],{},[14,6525,6526,6527,6530],{},"9. dev 环境改 ",[18,6528,6529],{},"server\u002Futils"," 后类型没更新。"," 跑 ",[18,6533,619],{}," 或重启 dev server，TS 服务才会重新读自动导入声明。",[168,6536,6538],{"id":6537},"附h3-工具函数速查","附：h3 工具函数速查",[59,6540,6541,6550],{},[62,6542,6543],{},[65,6544,6545,6548],{},[68,6546,6547],{},"工具",[68,6549,3305],{},[75,6551,6552,6562,6572,6585,6598,6611,6627,6643,6653,6663,6676,6685,6703],{},[65,6553,6554,6559],{},[80,6555,6556],{},[18,6557,6558],{},"defineEventHandler(fn)",[80,6560,6561],{},"声明一个 handler（唯一的入口包装）",[65,6563,6564,6569],{},[80,6565,6566],{},[18,6567,6568],{},"getQuery(event)",[80,6570,6571],{},"解析查询串成对象",[65,6573,6574,6582],{},[80,6575,6576,1085,6579],{},[18,6577,6578],{},"getRouterParam(event, 'id')",[18,6580,6581],{},"event.context.params",[80,6583,6584],{},"路径参数",[65,6586,6587,6595],{},[80,6588,6589,1085,6592],{},[18,6590,6591],{},"readBody(event)",[18,6593,6594],{},"readValidatedBody(event, schema)",[80,6596,6597],{},"请求体（后者可配 zod 校验）",[65,6599,6600,6608],{},[80,6601,6602,1085,6605],{},[18,6603,6604],{},"readRawBody",[18,6606,6607],{},"readMultipartFormData",[80,6609,6610],{},"原始体 \u002F 表单上传",[65,6612,6613,6624],{},[80,6614,6615,1085,6618,1085,6621],{},[18,6616,6617],{},"getCookie",[18,6619,6620],{},"setCookie",[18,6622,6623],{},"deleteCookie",[80,6625,6626],{},"cookie",[65,6628,6629,6640],{},[80,6630,6631,1085,6634,1085,6637],{},[18,6632,6633],{},"getHeader",[18,6635,6636],{},"setHeader",[18,6638,6639],{},"setHeaders",[80,6641,6642],{},"请求\u002F响应头",[65,6644,6645,6650],{},[80,6646,6647],{},[18,6648,6649],{},"setResponseStatus(event, code, msg)",[80,6651,6652],{},"状态码",[65,6654,6655,6660],{},[80,6656,6657],{},[18,6658,6659],{},"sendRedirect(event, url, code)",[80,6661,6662],{},"3xx",[65,6664,6665,6670],{},[80,6666,6667],{},[18,6668,6669],{},"createError({ statusCode, statusMessage })",[80,6671,6672,6673,6675],{},"抛出结构化错误（前端 ",[18,6674,5608],{}," 能拿到）",[65,6677,6678,6682],{},[80,6679,6680],{},[18,6681,6328],{},[80,6683,6684],{},"运行时配置（第八章）",[65,6686,6687,6692],{},[80,6688,6689],{},[18,6690,6691],{},"defineCachedEventHandler(fn, opts)",[80,6693,6694,6695,1241,6698,1241,6700,638],{},"接口级缓存（",[18,6696,6697],{},"maxAge",[18,6699,5274],{},[18,6701,6702],{},"variables",[65,6704,6705,6710],{},[80,6706,6707],{},[18,6708,6709],{},"event.context",[80,6711,6712],{},"本次请求的上下文（可挂东西，跨中间件传值）",[45,6714,6716,6717],{"id":6715},"八运行时配置与环境变量runtimeconfig","八、运行时配置与环境变量：",[18,6718,6719],{},"runtimeConfig",[10,6721,6722,6723,274,6725,210],{},"「同一个包，跑在开发\u002F测试\u002F生产三套环境」——这件事必须由配置层解决，而不是改代码。Nuxt 的答案是 ",[18,6724,6719],{},[18,6726,6727],{},".env",[168,6729,6731],{"id":6730},"一份配置两种可见性","一份配置，两种可见性",[418,6733,6735],{"className":696,"code":6734,"language":698,"meta":423,"style":423},"\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  runtimeConfig: {\n    \u002F\u002F 只有服务端能读到（Nitro handler、SSR 阶段的代码）\n    dbUrl: '',\n    apiSecret: '',\n    \u002F\u002F 以 public 开头的会进客户端产物（构建时被替换成实际值）\n    public: {\n      apiBase: '\u002Fapi',\n      siteName: '码上智能',\n      mapKey: ''\n    }\n  }\n})\n",[18,6736,6737,6741,6751,6756,6761,6770,6779,6784,6789,6798,6808,6816,6820,6825],{"__ignoreMap":423},[427,6738,6739],{"class":429,"line":430},[427,6740,705],{"class":433},[427,6742,6743,6745,6747,6749],{"class":429,"line":437},[427,6744,711],{"class":710},[427,6746,714],{"class":710},[427,6748,717],{"class":440},[427,6750,721],{"class":720},[427,6752,6753],{"class":429,"line":454},[427,6754,6755],{"class":720},"  runtimeConfig: {\n",[427,6757,6758],{"class":429,"line":461},[427,6759,6760],{"class":433},"    \u002F\u002F 只有服务端能读到（Nitro handler、SSR 阶段的代码）\n",[427,6762,6763,6766,6768],{"class":429,"line":467},[427,6764,6765],{"class":720},"    dbUrl: ",[427,6767,4721],{"class":444},[427,6769,4889],{"class":720},[427,6771,6772,6775,6777],{"class":429,"line":481},[427,6773,6774],{"class":720},"    apiSecret: ",[427,6776,4721],{"class":444},[427,6778,4889],{"class":720},[427,6780,6781],{"class":429,"line":486},[427,6782,6783],{"class":433},"    \u002F\u002F 以 public 开头的会进客户端产物（构建时被替换成实际值）\n",[427,6785,6786],{"class":429,"line":495},[427,6787,6788],{"class":720},"    public: {\n",[427,6790,6791,6794,6796],{"class":429,"line":503},[427,6792,6793],{"class":720},"      apiBase: ",[427,6795,4839],{"class":444},[427,6797,4889],{"class":720},[427,6799,6800,6803,6806],{"class":429,"line":791},[427,6801,6802],{"class":720},"      siteName: ",[427,6804,6805],{"class":444},"'码上智能'",[427,6807,4889],{"class":720},[427,6809,6810,6813],{"class":429,"line":812},[427,6811,6812],{"class":720},"      mapKey: ",[427,6814,6815],{"class":444},"''\n",[427,6817,6818],{"class":429,"line":817},[427,6819,873],{"class":720},[427,6821,6822],{"class":429,"line":823},[427,6823,6824],{"class":720},"  }\n",[427,6826,6827],{"class":429,"line":829},[427,6828,911],{"class":720},[418,6830,6832],{"className":420,"code":6831,"language":422,"meta":423,"style":423},"# .env（本地开发用；生产由部署环境注入，别把 .env 提交进仓库）\nNUXT_DB_URL=postgres:\u002F\u002Fuser:pass@127.0.0.1:5432\u002Fshop\nNUXT_PUBLIC_API_BASE=https:\u002F\u002Fapi.yoursite.com\nNUXT_PUBLIC_MAP_KEY=abc123\n",[18,6833,6834,6839,6849,6859],{"__ignoreMap":423},[427,6835,6836],{"class":429,"line":430},[427,6837,6838],{"class":433},"# .env（本地开发用；生产由部署环境注入，别把 .env 提交进仓库）\n",[427,6840,6841,6844,6846],{"class":429,"line":437},[427,6842,6843],{"class":720},"NUXT_DB_URL",[427,6845,1550],{"class":710},[427,6847,6848],{"class":444},"postgres:\u002F\u002Fuser:pass@127.0.0.1:5432\u002Fshop\n",[427,6850,6851,6854,6856],{"class":429,"line":454},[427,6852,6853],{"class":720},"NUXT_PUBLIC_API_BASE",[427,6855,1550],{"class":710},[427,6857,6858],{"class":444},"https:\u002F\u002Fapi.yoursite.com\n",[427,6860,6861,6864,6866],{"class":429,"line":461},[427,6862,6863],{"class":720},"NUXT_PUBLIC_MAP_KEY",[427,6865,1550],{"class":710},[427,6867,6868],{"class":444},"abc123\n",[10,6870,6871,6872,6881,6882,6885],{},"命名规则固定：",[14,6873,6874,6876,6877,6880],{},[18,6875,6719],{}," 里的字段名 → 环境变量名 = ",[18,6878,6879],{},"NUXT_"," + 大写下划线","；嵌套就是 ",[18,6883,6884],{},"NUXT_PUBLIC_XXX","。这个映射是自动的，不需要手写对应关系。",[168,6887,6888],{"id":6888},"读取",[418,6890,6892],{"className":696,"code":6891,"language":698,"meta":423,"style":423},"\u002F\u002F app 侧（组件\u002F组合式函数）\nconst cfg = useRuntimeConfig()\ncfg.public.apiBase          \u002F\u002F ✓ 有\ncfg.dbUrl                   \u002F\u002F ✗ 客户端拿到 undefined（SSR 阶段能读到）\n\n\u002F\u002F server 侧\nexport default defineEventHandler(() => {\n  const cfg = useRuntimeConfig()\n  return query(cfg.dbUrl)   \u002F\u002F ✓ 密钥只在服务端出现\n})\n",[18,6893,6894,6899,6913,6921,6929,6933,6938,6952,6964,6976],{"__ignoreMap":423},[427,6895,6896],{"class":429,"line":430},[427,6897,6898],{"class":433},"\u002F\u002F app 侧（组件\u002F组合式函数）\n",[427,6900,6901,6903,6906,6908,6911],{"class":429,"line":437},[427,6902,1867],{"class":710},[427,6904,6905],{"class":489}," cfg",[427,6907,1873],{"class":710},[427,6909,6910],{"class":440}," useRuntimeConfig",[427,6912,1879],{"class":720},[427,6914,6915,6918],{"class":429,"line":454},[427,6916,6917],{"class":720},"cfg.public.apiBase          ",[427,6919,6920],{"class":433},"\u002F\u002F ✓ 有\n",[427,6922,6923,6926],{"class":429,"line":461},[427,6924,6925],{"class":720},"cfg.dbUrl                   ",[427,6927,6928],{"class":433},"\u002F\u002F ✗ 客户端拿到 undefined（SSR 阶段能读到）\n",[427,6930,6931],{"class":429,"line":467},[427,6932,458],{"emptyLinePlaceholder":457},[427,6934,6935],{"class":429,"line":481},[427,6936,6937],{"class":433},"\u002F\u002F server 侧\n",[427,6939,6940,6942,6944,6946,6948,6950],{"class":429,"line":486},[427,6941,711],{"class":710},[427,6943,714],{"class":710},[427,6945,5198],{"class":440},[427,6947,5201],{"class":720},[427,6949,2077],{"class":710},[427,6951,5014],{"class":720},[427,6953,6954,6956,6958,6960,6962],{"class":429,"line":495},[427,6955,4746],{"class":710},[427,6957,6905],{"class":489},[427,6959,1873],{"class":710},[427,6961,6910],{"class":440},[427,6963,1879],{"class":720},[427,6965,6966,6968,6970,6973],{"class":429,"line":503},[427,6967,5760],{"class":710},[427,6969,6017],{"class":440},[427,6971,6972],{"class":720},"(cfg.dbUrl)   ",[427,6974,6975],{"class":433},"\u002F\u002F ✓ 密钥只在服务端出现\n",[427,6977,6978],{"class":429,"line":791},[427,6979,911],{"class":720},[10,6981,6982,6983,6989,6990,6993],{},"关键认知：",[14,6984,6985,6988],{},[18,6986,6987],{},"public.*"," 的值在构建期就被写死进产物","。用户 ",[18,6991,6992],{},"Ctrl+U"," 查看 JS 文件就能读到它。所以「公开的配置」不等于「不敏感的配置」，而是「即使被看到也没关系的」——站点名、公开接口地址可以；任何能花钱或被刷的东西不行。",[168,6995,6996],{"id":6996},"开发期跨域代理",[10,6998,6999],{},"前端调本机另一个后端（比如 Java 服务在 8080）时，用 dev 代理避免 CORS：",[418,7001,7003],{"className":696,"code":7002,"language":698,"meta":423,"style":423},"export default defineNuxtConfig({\n  vite: {\n    server: {\n      proxy: { '\u002Fapi': { target: 'http:\u002F\u002Flocalhost:8080', changeOrigin: true } }\n    }\n  }\n})\n",[18,7004,7005,7015,7020,7025,7046,7050,7054],{"__ignoreMap":423},[427,7006,7007,7009,7011,7013],{"class":429,"line":430},[427,7008,711],{"class":710},[427,7010,714],{"class":710},[427,7012,717],{"class":440},[427,7014,721],{"class":720},[427,7016,7017],{"class":429,"line":437},[427,7018,7019],{"class":720},"  vite: {\n",[427,7021,7022],{"class":429,"line":454},[427,7023,7024],{"class":720},"    server: {\n",[427,7026,7027,7030,7032,7035,7038,7041,7043],{"class":429,"line":461},[427,7028,7029],{"class":720},"      proxy: { ",[427,7031,4839],{"class":444},[427,7033,7034],{"class":720},": { target: ",[427,7036,7037],{"class":444},"'http:\u002F\u002Flocalhost:8080'",[427,7039,7040],{"class":720},", changeOrigin: ",[427,7042,743],{"class":489},[427,7044,7045],{"class":720}," } }\n",[427,7047,7048],{"class":429,"line":467},[427,7049,873],{"class":720},[427,7051,7052],{"class":429,"line":481},[427,7053,6824],{"class":720},[427,7055,7056],{"class":429,"line":486},[427,7057,911],{"class":720},[10,7059,7060,7066,7067,7069],{},[14,7061,7062,7063,7065],{},"这条只在 ",[18,7064,547],{}," 有效。"," 生产环境必须由 Nginx\u002F网关把 ",[18,7068,5894],{}," 反代到后端（第十三章），否则线上 404——这是「本地一切正常，上线接口全挂」的经典成因。",[168,7071,261],{"id":7072},"️-本章的坑-7",[10,7074,7075,7081,7082,210],{},[14,7076,7077,7078,210],{},"1. 把密钥写进 ",[18,7079,7080],{},"public"," 它会被打进前端包。写完检查一遍产物：",[18,7083,7084],{},"grep -r \"sk-\" .output\u002Fpublic\u002F_nuxt\u002F",[10,7086,7087,1257,7090,1178,7093,7095,7096,7098,7099,7101],{},[14,7088,7089],{},"2. 在客户端读非 public 字段。",[18,7091,7092],{},"cfg.dbUrl",[18,7094,1389],{},"，报错信息常是「undefined is not an object」，很难联想到环境变量。规则：非 ",[18,7097,7080],{}," 字段只在 ",[18,7100,314],{}," 和 SSR 阶段用。",[10,7103,7104,1257,7107,7110],{},[14,7105,7106],{},"3. 环境变量名打错没报错。",[18,7108,7109],{},"NUXT_DB_URl"," 这类拼写错误不会提示，读到空串。给配置加显式校验：",[418,7112,7114],{"className":696,"code":7113,"language":698,"meta":423,"style":423},"if (!process.env.NUXT_DB_URL) throw new Error('缺少 NUXT_DB_URL')\n",[18,7115,7116],{"__ignoreMap":423},[427,7117,7118,7121,7123,7125,7128,7130,7132,7134,7137,7140,7142,7145],{"class":429,"line":430},[427,7119,7120],{"class":710},"if",[427,7122,5043],{"class":720},[427,7124,6197],{"class":710},[427,7126,7127],{"class":720},"process.env.",[427,7129,6843],{"class":489},[427,7131,2074],{"class":720},[427,7133,2938],{"class":710},[427,7135,7136],{"class":710}," new",[427,7138,7139],{"class":440}," Error",[427,7141,1845],{"class":720},[427,7143,7144],{"class":444},"'缺少 NUXT_DB_URL'",[427,7146,2820],{"class":720},[10,7148,7149,7154,7155,7158,7159,7162],{},[14,7150,3235,7151,7153],{},[18,7152,6727],{}," 提交进仓库。"," 至少要在 ",[18,7156,7157],{},".gitignore"," 里排除，并给一份 ",[18,7160,7161],{},".env.example"," 列出需要哪些键（不含值）。",[10,7164,7165,7171,7172,5421,7174,7176],{},[14,7166,7167,7168,7170],{},"5. 以为改了 ",[18,7169,6727],{}," 就生效。"," 需要重启 dev server；生产要重启进程。",[18,7173,20],{},[18,7175,7080],{}," 值已经烧进产物，改环境变量必须重新 generate。",[10,7178,7179,7184,7185,7188],{},[14,7180,7181,7182,210],{},"6. 静态产物里改不了 ",[18,7183,6987],{}," 这是「一次构建多处部署」的障碍。解法：把可变配置改成运行时接口（",[18,7186,7187],{},"\u002Fapi\u002Fconfig","），或在部署脚本里做字符串替换。",[10,7190,7191,7198,7199,7202,7203,7208],{},[14,7192,1289,7193,651,7196,2210],{},[18,7194,7195],{},"import.meta.env",[18,7197,6328],{}," 前者是 Vite 层的构建期常量（",[18,7200,7201],{},"VITE_*","，前端可读），后者是 Nuxt 层的运行时配置（服务端可读、可被环境变量覆盖）。",[14,7204,7205,7206],{},"优先 ",[18,7207,6719],{},"：一套写法同时覆盖服务端和客户端。",[10,7210,7211,7222,7223,7225,7226,278],{},[14,7212,7213,7214,7216,7217,7219,7220,210],{},"8. 在 ",[18,7215,42],{}," 里直接读 ",[18,7218,6332],{}," 拼进 ",[18,7221,6719],{}," 可以，但别忘了构建机上没有生产环境变量，产物里就固化成空串——正确姿势是让 ",[18,7224,6719],{}," 字段留空，由运行期环境变量填充（",[18,7227,7228],{},"NUXT_XXX",[168,7230,7232],{"id":7231},"附配置来源优先级","附：配置来源优先级",[59,7234,7235,7251],{},[62,7236,7237],{},[65,7238,7239,7242,7245,7248],{},[68,7240,7241],{},"来源",[68,7243,7244],{},"时机",[68,7246,7247],{},"客户端可见",[68,7249,7250],{},"典型用途",[75,7252,7253,7269,7286,7303,7318,7333],{},[65,7254,7255,7260,7263,7266],{},[80,7256,7257],{},[18,7258,7259],{},"runtimeConfig.public.*",[80,7261,7262],{},"构建期固化",[80,7264,7265],{},"✓",[80,7267,7268],{},"公开 API 地址、站点名、开关",[65,7270,7271,7277,7280,7283],{},[80,7272,7273,7276],{},[18,7274,7275],{},"runtimeConfig.*","（非 public）",[80,7278,7279],{},"运行期读环境变量",[80,7281,7282],{},"✗",[80,7284,7285],{},"数据库、第三方密钥",[65,7287,7288,7294,7297,7300],{},[80,7289,7290,7293],{},[18,7291,7292],{},"NUXT_*"," 环境变量",[80,7295,7296],{},"部署平台注入",[80,7298,7299],{},"视是否 public",[80,7301,7302],{},"分环境差异",[65,7304,7305,7310,7313,7315],{},[80,7306,7307],{},[18,7308,7309],{},"import.meta.env.VITE_*",[80,7311,7312],{},"构建期常量",[80,7314,7265],{},[80,7316,7317],{},"构建模式、非敏感常量",[65,7319,7320,7325,7328,7330],{},[80,7321,7322,7323],{},"服务端 ",[18,7324,7187],{},[80,7326,7327],{},"运行期请求",[80,7329,7265],{},[80,7331,7332],{},"需要「同一份产物改配置」的场景",[65,7334,7335,7342,7345,7347],{},[80,7336,7337,304,7340,638],{},[18,7338,7339],{},"app.config",[18,7341,1031],{},[80,7343,7344],{},"构建期",[80,7346,7265],{},[80,7348,7349],{},"UI 常量（主题色、菜单结构），不可被环境变量覆盖",[45,7351,7353,7354,7356],{"id":7352},"九渲染模式与-routerules同一站点里不同页面用不同策略","九、渲染模式与 ",[18,7355,38],{},"：同一站点里不同页面用不同策略",[10,7358,7359,7360,7363,7364,210],{},"前面反复出现「SSR」和「generate」。这一节把它们讲成可以",[14,7361,7362],{},"按路径","配置的东西：",[18,7365,38],{},[168,7367,7368],{"id":7368},"四个开关",[418,7370,7372],{"className":696,"code":7371,"language":698,"meta":423,"style":423},"export default defineNuxtConfig({\n  ssr: true,                                    \u002F\u002F 全站默认：服务端渲染\n  nitro: { prerender: { crawlLinks: true, routes: ['\u002F', '\u002Fabout'] } },   \u002F\u002F 构建期出 HTML\n  routeRules: {\n    '\u002Fadmin\u002F**':    { ssr: false },                     \u002F\u002F 这个子树走纯客户端（SPA）\n    '\u002Fblog\u002F**':     { prerender: true },                \u002F\u002F 构建期生成静态 HTML\n    '\u002Fproducts\u002F**': { swr: 60 },                        \u002F\u002F 缓存 60s，过期后台刷新\n    '\u002Fapi\u002Fsearch':  { cors: true, headers: { 'cache-control': 'no-store' } },\n    '\u002Fold-path':    { redirect: '\u002Fnew-path' },          \u002F\u002F 301（写 { to, code } 改状态码）\n    '\u002Flegacy\u002F**':   { proxy: 'https:\u002F\u002Fold.example.com\u002F**' },\n    '\u002Fprivacy':     { isr: true }                       \u002F\u002F 平台按需 ISR（Vercel\u002FNetlify 等）\n  }\n})\n",[18,7373,7374,7384,7396,7419,7424,7440,7456,7472,7495,7512,7525,7541,7545],{"__ignoreMap":423},[427,7375,7376,7378,7380,7382],{"class":429,"line":430},[427,7377,711],{"class":710},[427,7379,714],{"class":710},[427,7381,717],{"class":440},[427,7383,721],{"class":720},[427,7385,7386,7388,7390,7393],{"class":429,"line":437},[427,7387,755],{"class":720},[427,7389,743],{"class":489},[427,7391,7392],{"class":720},",                                    ",[427,7394,7395],{"class":433},"\u002F\u002F 全站默认：服务端渲染\n",[427,7397,7398,7401,7403,7406,7408,7410,7413,7416],{"class":429,"line":454},[427,7399,7400],{"class":720},"  nitro: { prerender: { crawlLinks: ",[427,7402,743],{"class":489},[427,7404,7405],{"class":720},", routes: [",[427,7407,849],{"class":444},[427,7409,778],{"class":720},[427,7411,7412],{"class":444},"'\u002Fabout'",[427,7414,7415],{"class":720},"] } },   ",[427,7417,7418],{"class":433},"\u002F\u002F 构建期出 HTML\n",[427,7420,7421],{"class":429,"line":461},[427,7422,7423],{"class":720},"  routeRules: {\n",[427,7425,7426,7429,7432,7434,7437],{"class":429,"line":467},[427,7427,7428],{"class":444},"    '\u002Fadmin\u002F**'",[427,7430,7431],{"class":720},":    { ssr: ",[427,7433,803],{"class":489},[427,7435,7436],{"class":720}," },                     ",[427,7438,7439],{"class":433},"\u002F\u002F 这个子树走纯客户端（SPA）\n",[427,7441,7442,7445,7448,7450,7453],{"class":429,"line":481},[427,7443,7444],{"class":444},"    '\u002Fblog\u002F**'",[427,7446,7447],{"class":720},":     { prerender: ",[427,7449,743],{"class":489},[427,7451,7452],{"class":720}," },                ",[427,7454,7455],{"class":433},"\u002F\u002F 构建期生成静态 HTML\n",[427,7457,7458,7461,7464,7466,7469],{"class":429,"line":486},[427,7459,7460],{"class":444},"    '\u002Fproducts\u002F**'",[427,7462,7463],{"class":720},": { swr: ",[427,7465,6170],{"class":489},[427,7467,7468],{"class":720}," },                        ",[427,7470,7471],{"class":433},"\u002F\u002F 缓存 60s，过期后台刷新\n",[427,7473,7474,7477,7480,7482,7485,7487,7489,7492],{"class":429,"line":495},[427,7475,7476],{"class":444},"    '\u002Fapi\u002Fsearch'",[427,7478,7479],{"class":720},":  { cors: ",[427,7481,743],{"class":489},[427,7483,7484],{"class":720},", headers: { ",[427,7486,6120],{"class":444},[427,7488,3496],{"class":720},[427,7490,7491],{"class":444},"'no-store'",[427,7493,7494],{"class":720}," } },\n",[427,7496,7497,7500,7503,7506,7509],{"class":429,"line":503},[427,7498,7499],{"class":444},"    '\u002Fold-path'",[427,7501,7502],{"class":720},":    { redirect: ",[427,7504,7505],{"class":444},"'\u002Fnew-path'",[427,7507,7508],{"class":720}," },          ",[427,7510,7511],{"class":433},"\u002F\u002F 301（写 { to, code } 改状态码）\n",[427,7513,7514,7517,7520,7523],{"class":429,"line":791},[427,7515,7516],{"class":444},"    '\u002Flegacy\u002F**'",[427,7518,7519],{"class":720},":   { proxy: ",[427,7521,7522],{"class":444},"'https:\u002F\u002Fold.example.com\u002F**'",[427,7524,746],{"class":720},[427,7526,7527,7530,7533,7535,7538],{"class":429,"line":812},[427,7528,7529],{"class":444},"    '\u002Fprivacy'",[427,7531,7532],{"class":720},":     { isr: ",[427,7534,743],{"class":489},[427,7536,7537],{"class":720}," }                       ",[427,7539,7540],{"class":433},"\u002F\u002F 平台按需 ISR（Vercel\u002FNetlify 等）\n",[427,7542,7543],{"class":429,"line":817},[427,7544,6824],{"class":720},[427,7546,7547],{"class":429,"line":823},[427,7548,911],{"class":720},[59,7550,7551,7570],{},[62,7552,7553],{},[65,7554,7555,7558,7561,7564,7567],{},[68,7556,7557],{},"模式",[68,7559,7560],{},"配置",[68,7562,7563],{},"首屏 HTML 有正文",[68,7565,7566],{},"服务器要跑 Node",[68,7568,7569],{},"适合",[75,7571,7572,7591,7609,7631,7648],{},[65,7573,7574,7577,7583,7585,7588],{},[80,7575,7576],{},"SSR",[80,7578,7579,7580,638],{},"默认（",[18,7581,7582],{},"ssr: true",[80,7584,7265],{},[80,7586,7587],{},"要",[80,7589,7590],{},"内容随请求变化、需要 SEO",[65,7592,7593,7596,7601,7603,7606],{},[80,7594,7595],{},"SPA",[80,7597,7598,7600],{},[18,7599,287],{},"（全站或路由级）",[80,7602,7282],{},[80,7604,7605],{},"可不要（纯发静态文件时）",[80,7607,7608],{},"后台系统、登录后的界面",[65,7610,7611,7614,7621,7623,7628],{},[80,7612,7613],{},"SSG",[80,7615,7616,1085,7618],{},[18,7617,31],{},[18,7619,7620],{},"{ prerender: true }",[80,7622,7265],{},[80,7624,7625,595],{},[14,7626,7627],{},"不要",[80,7629,7630],{},"博客、文档、营销页（本站就是这个模式）",[65,7632,7633,7636,7641,7643,7645],{},[80,7634,7635],{},"SWR",[80,7637,7638],{},[18,7639,7640],{},"{ swr: 60 }",[80,7642,7265],{},[80,7644,7587],{},[80,7646,7647],{},"内容变化不敏感、想挡住突发流量",[65,7649,7650,7653,7658,7660,7663],{},[80,7651,7652],{},"ISR",[80,7654,7655],{},[18,7656,7657],{},"{ isr: true }",[80,7659,7265],{},[80,7661,7662],{},"平台决定",[80,7664,7665],{},"部署在支持按需再生成的托管平台",[168,7667,7669],{"id":7668},"匹配优先级越具体越优先","匹配优先级：越具体越优先",[10,7671,7672,7673,7676,7677,7680,7681,7684,7685,7688],{},"多条规则命中同一个 URL 时，按路径",[14,7674,7675],{},"精确度","排序：",[18,7678,7679],{},"\u002Fblog\u002Fhello"," 精确规则 > ",[18,7682,7683],{},"\u002Fblog\u002F**"," > ",[18,7686,7687],{},"\u002F**","。所以「给全站设默认，再覆盖某几个路径」是能成立的——但反着写会翻车：",[418,7690,7692],{"className":696,"code":7691,"language":698,"meta":423,"style":423},"routeRules: {\n  '\u002F**': { ssr: false },              \u002F\u002F ← 全站关掉 SSR\n  '\u002Fblog\u002F**': { prerender: true }     \u002F\u002F 仍然会预渲染，但 ssr:false 让产物是空壳！\n}\n",[18,7693,7694,7701,7717,7733],{"__ignoreMap":423},[427,7695,7696,7698],{"class":429,"line":430},[427,7697,38],{"class":440},[427,7699,7700],{"class":720},": {\n",[427,7702,7703,7706,7709,7711,7714],{"class":429,"line":437},[427,7704,7705],{"class":444},"  '\u002F**'",[427,7707,7708],{"class":720},": { ssr: ",[427,7710,803],{"class":489},[427,7712,7713],{"class":720}," },              ",[427,7715,7716],{"class":433},"\u002F\u002F ← 全站关掉 SSR\n",[427,7718,7719,7722,7725,7727,7730],{"class":429,"line":454},[427,7720,7721],{"class":444},"  '\u002Fblog\u002F**'",[427,7723,7724],{"class":720},": { prerender: ",[427,7726,743],{"class":489},[427,7728,7729],{"class":720}," }     ",[427,7731,7732],{"class":433},"\u002F\u002F 仍然会预渲染，但 ssr:false 让产物是空壳！\n",[427,7734,7735],{"class":429,"line":461},[427,7736,4801],{"class":720},[10,7738,7739,7740,7743],{},"这类组合的后果很隐蔽：",[14,7741,7742],{},"预渲染出来的是不含正文的 HTML","。示例 07 的「URL 判定器」就是让你手动改一下顺序、亲眼看结论怎么翻转。",[168,7745,7747],{"id":7746},"什么时候真的需要-ssr","什么时候真的需要 SSR",[10,7749,7750],{},"三条判断，任何一条成立才值得留服务端：",[183,7752,7753,7756,7759],{},[186,7754,7755],{},"未登录用户能看到的内容需要被索引（首屏正文必须在 HTML 里）。",[186,7757,7758],{},"数据必须按请求计算（价格、库存、权限）。",[186,7760,7761],{},"首屏时间对业务指标有直接影响，且接口足够快。",[10,7763,7764,7765,7767],{},"都不成立时 ",[18,7766,287],{}," 更省：部署是纯静态文件、服务器不跑 Node，也不会遇到「SSR 阶段 window is not defined」（第五章第 2 条）。",[212,7769],{"height":214,"src":7770,"title":7771},"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F07-render-modes.html","示例 07：五种渲染模式对照与 routeRules 判定器",[168,7773,261],{"id":7774},"️-本章的坑-8",[10,7776,7777,7782],{},[14,7778,4276,7779,7781],{},[18,7780,287],{}," 之后 SEO 归零。"," 产物 HTML 是空壳。别以为「反正有水合」——爬虫与分享卡片读的是源码（示例 01 有对照）。",[10,7784,7785,7793,7794,7797,7798,210],{},[14,7786,1186,7787,274,7790,7792],{},[18,7788,7789],{},"prerender: true",[18,7791,287],{}," 同时命中。"," 生成空壳静态页（上面那段代码）。要么去掉 ",[18,7795,7796],{},"\u002F**: { ssr: false }","，要么给需要预渲染的路径显式合并写 ",[18,7799,7800],{},"{ prerender: true, ssr: true }",[10,7802,7803,1257,7806,7809,7810,7815,7816,7819,7820,7823,7824,7827,7828,7830,7831,278],{},[14,7804,7805],{},"3. 预渲染没覆盖所有路由。",[18,7807,7808],{},"crawlLinks"," 只沿",[14,7811,7812,7813],{},"页面里的 ",[18,7814,1625],{}," 爬。没有链接指向的页面（只在 sitemap 里、或列表第二页）不会生成，",[18,7817,7818],{},"generate"," 后访问就是 404。解法：",[18,7821,7822],{},"nitro.prerender.routes"," 显式列，或让列表页给出全部链接。本站的工具页是手工维护一份清单进 ",[18,7825,7826],{},"routes"," 的（",[18,7829,42],{}," 里的 ",[18,7832,7833],{},"TOOL_PRERENDER_ROUTES",[10,7835,7836,1257,7839,7841,7842,7844,7845,2285,7848,210],{},[14,7837,7838],{},"4. 预渲染阶段接口必须可用。",[18,7840,7818],{}," 时页面里每个 ",[18,7843,994],{}," 都会真的执行。若接口依赖登录态或外部服务，构建要么失败、要么把「未登录」的空数据固化进 HTML。这类页面改成 ",[18,7846,7847],{},"{ ssr: false }",[18,7849,7850],{},"{ prerender: false }",[10,7852,7853,7860,7861,7864],{},[14,7854,5473,7855,1241,7857,7859],{},[18,7856,5274],{},[18,7858,6460],{}," 依赖缓存存储。"," 默认是内存缓存，多实例部署各节点一份、命中率与一致性都差。要一致请配 ",[18,7862,7863],{},"nitro: { storage: { cache: … } }","（Redis 等）。",[10,7866,7867,7870,7871],{},[14,7868,7869],{},"6. 动态路由的预渲染要自己提供数据源。"," 原则：",[14,7872,7873],{},"能爬到的就不用列，爬不到的才列。",[418,7875,7877],{"className":696,"code":7876,"language":698,"meta":423,"style":423},"nitro: {\n  prerender: {\n    crawlLinks: true,\n    routes: async () => {\n      const { data } = await $fetch('https:\u002F\u002Fapi.example.com\u002Fsitemap-urls')\n      return data.map((u) => u.path)\n    }\n  }\n}\n",[18,7878,7879,7886,7893,7904,7919,7943,7966,7970,7974],{"__ignoreMap":423},[427,7880,7881,7884],{"class":429,"line":430},[427,7882,7883],{"class":440},"nitro",[427,7885,7700],{"class":720},[427,7887,7888,7891],{"class":429,"line":437},[427,7889,7890],{"class":440},"  prerender",[427,7892,7700],{"class":720},[427,7894,7895,7898,7900,7902],{"class":429,"line":454},[427,7896,7897],{"class":440},"    crawlLinks",[427,7899,3496],{"class":720},[427,7901,743],{"class":489},[427,7903,4889],{"class":720},[427,7905,7906,7909,7911,7913,7915,7917],{"class":429,"line":461},[427,7907,7908],{"class":440},"    routes",[427,7910,3496],{"class":720},[427,7912,4232],{"class":710},[427,7914,2809],{"class":720},[427,7916,2077],{"class":710},[427,7918,5014],{"class":720},[427,7920,7921,7924,7926,7928,7930,7932,7934,7936,7938,7941],{"class":429,"line":467},[427,7922,7923],{"class":710},"      const",[427,7925,3491],{"class":720},[427,7927,1385],{"class":489},[427,7929,3522],{"class":720},[427,7931,1550],{"class":710},[427,7933,3974],{"class":710},[427,7935,3878],{"class":440},[427,7937,1845],{"class":720},[427,7939,7940],{"class":444},"'https:\u002F\u002Fapi.example.com\u002Fsitemap-urls'",[427,7942,2820],{"class":720},[427,7944,7945,7948,7951,7953,7956,7959,7961,7963],{"class":429,"line":481},[427,7946,7947],{"class":710},"      return",[427,7949,7950],{"class":720}," data.",[427,7952,5772],{"class":440},[427,7954,7955],{"class":720},"((",[427,7957,7958],{"class":2070},"u",[427,7960,2074],{"class":720},[427,7962,2077],{"class":710},[427,7964,7965],{"class":720}," u.path)\n",[427,7967,7968],{"class":429,"line":486},[427,7969,873],{"class":720},[427,7971,7972],{"class":429,"line":495},[427,7973,6824],{"class":720},[427,7975,7976],{"class":429,"line":503},[427,7977,4801],{"class":720},[10,7979,7980,7988,7989,7992,7993,210],{},[14,7981,1289,7982,7984,7985,7987],{},[18,7983,38],{}," 里写的 ",[18,7986,4603],{}," 与 Nginx 设的头冲突。"," 最终结果取决于先后。排查顺序：先 ",[18,7990,7991],{},"curl -I"," 看框架输出，再看 Nginx ",[18,7994,7995],{},"add_header",[10,7997,7998,1257,8001,8003,8004,8006],{},[14,7999,8000],{},"8. 渲染模式与服务端中间件的期望不一致。",[18,8002,7847],{}," 的路由上，服务端不会为页面渲染执行（但 ",[18,8005,5894],{}," 仍会）。别指望用服务端中间件给 SPA 页面「注入用户信息」。",[10,8008,8009,8015,8016,8018],{},[14,8010,8011,8012,8014],{},"9. 以为 ",[18,8013,38],{}," 能改客户端行为。"," 它管的是服务端\u002F构建阶段的策略；组件里的 ",[18,8017,2274],{}," 照旧只在浏览器跑。",[168,8020,5550,8022,8024],{"id":8021},"附routerules-常用字段",[18,8023,38],{}," 常用字段",[59,8026,8027,8037],{},[62,8028,8029],{},[65,8030,8031,8033,8035],{},[68,8032,5562],{},[68,8034,4450],{},[68,8036,3305],{},[75,8038,8039,8051,8063,8075,8087,8102,8118,8130,8145,8157,8169,8184],{},[65,8040,8041,8046,8048],{},[80,8042,8043],{},[18,8044,8045],{},"ssr",[80,8047,4476],{},[80,8049,8050],{},"该路径是否服务端渲染",[65,8052,8053,8058,8060],{},[80,8054,8055],{},[18,8056,8057],{},"prerender",[80,8059,4476],{},[80,8061,8062],{},"构建期生成静态 HTML",[65,8064,8065,8069,8072],{},[80,8066,8067],{},[18,8068,5274],{},[80,8070,8071],{},"number\u002Ftrue",[80,8073,8074],{},"stale-while-revalidate 缓存秒数",[65,8076,8077,8081,8084],{},[80,8078,8079],{},[18,8080,6460],{},[80,8082,8083],{},"boolean\u002Fnumber",[80,8085,8086],{},"增量静态再生成（托管平台）",[65,8088,8089,8094,8099],{},[80,8090,8091],{},[18,8092,8093],{},"cache",[80,8095,8096],{},[18,8097,8098],{},"{ maxAge, swr, group, variables }",[80,8100,8101],{},"更细粒度的缓存控制",[65,8103,8104,8109,8115],{},[80,8105,8106],{},[18,8107,8108],{},"redirect",[80,8110,8111,8112],{},"string 或 ",[18,8113,8114],{},"{ to, code }",[80,8116,8117],{},"重定向（默认 301）",[65,8119,8120,8125,8127],{},[80,8121,8122],{},[18,8123,8124],{},"proxy",[80,8126,4463],{},[80,8128,8129],{},"反向代理（老站合并常用）",[65,8131,8132,8136,8138],{},[80,8133,8134],{},[18,8135,4603],{},[80,8137,4593],{},[80,8139,8140,8141,8144],{},"加响应头（",[18,8142,8143],{},"cache-control","、CSP）",[65,8146,8147,8152,8154],{},[80,8148,8149],{},[18,8150,8151],{},"cors",[80,8153,4476],{},[80,8155,8156],{},"允许跨域",[65,8158,8159,8164,8166],{},[80,8160,8161],{},[18,8162,8163],{},"static",[80,8165,4476],{},[80,8167,8168],{},"当静态文件处理",[65,8170,8171,8176,8178],{},[80,8172,8173],{},[18,8174,8175],{},"noIndex",[80,8177,4476],{},[80,8179,8180,8181],{},"响应加 ",[18,8182,8183],{},"x-robots-tag: noindex",[65,8185,8186,8191,8193],{},[80,8187,8188],{},[18,8189,8190],{},"payloadExtraction",[80,8192,4476],{},[80,8194,8195,8196,8198],{},"payload 拆成 ",[18,8197,408],{},"（关了就内联进 HTML）",[45,8200,8202],{"id":8201},"十seo-与-head标题metasitemap结构化数据","十、SEO 与 head：标题、meta、sitemap、结构化数据",[10,8204,8205,8206,8209],{},"内容站的成败往往不在交互，而在「搜索引擎和分享卡片怎么看你」。Nuxt 把 ",[18,8207,8208],{},"\u003Chead>"," 管理做成了组合式函数，SSR 阶段就会写进 HTML——这正是静态站点也能拿 SEO 的原因。",[168,8211,8213,8214],{"id":8212},"标题与描述useseometa","标题与描述：",[18,8215,8216],{},"useSeoMeta",[418,8218,8220],{"className":1515,"code":8219,"language":1517,"meta":423,"style":423},"\u003Cscript setup>\nconst page = \u002F* … 取到的文档 … *\u002F\n\nuseSeoMeta({\n  title: page.title,                        \u002F\u002F 浏览器标签与搜索结果标题\n  description: page.description,            \u002F\u002F 搜索结果里的摘要\n  ogTitle: page.title,                      \u002F\u002F 社交平台卡片\n  ogDescription: page.description,\n  ogImage: '\u002Fimg\u002F' + page.category + '-cover.svg',\n  ogType: 'article',\n  twitterCard: 'summary_large_image',\n  robots: 'index, follow'\n})\n\n\u002F\u002F 需要更底层控制（link、script、html 属性）时用 useHead\nuseHead({\n  link: [{ rel: 'canonical', href: 'https:\u002F\u002Fyour-site.com\u002Ftutorials\u002Fvue3' }],\n  htmlAttrs: { lang: 'zh-CN' },\n  script: [{ src: 'https:\u002F\u002F…', defer: true }]\n})\n\u003C\u002Fscript>\n",[18,8221,8222,8232,8244,8248,8254,8262,8270,8278,8283,8304,8314,8324,8332,8336,8340,8345,8351,8368,8378,8394,8398],{"__ignoreMap":423},[427,8223,8224,8226,8228,8230],{"class":429,"line":430},[427,8225,1529],{"class":720},[427,8227,1824],{"class":1532},[427,8229,1827],{"class":440},[427,8231,1536],{"class":720},[427,8233,8234,8236,8239,8241],{"class":429,"line":437},[427,8235,1867],{"class":710},[427,8237,8238],{"class":489}," page",[427,8240,1873],{"class":710},[427,8242,8243],{"class":433}," \u002F* … 取到的文档 … *\u002F\n",[427,8245,8246],{"class":429,"line":454},[427,8247,458],{"emptyLinePlaceholder":457},[427,8249,8250,8252],{"class":429,"line":461},[427,8251,8216],{"class":440},[427,8253,721],{"class":720},[427,8255,8256,8259],{"class":429,"line":467},[427,8257,8258],{"class":720},"  title: page.title,                        ",[427,8260,8261],{"class":433},"\u002F\u002F 浏览器标签与搜索结果标题\n",[427,8263,8264,8267],{"class":429,"line":481},[427,8265,8266],{"class":720},"  description: page.description,            ",[427,8268,8269],{"class":433},"\u002F\u002F 搜索结果里的摘要\n",[427,8271,8272,8275],{"class":429,"line":486},[427,8273,8274],{"class":720},"  ogTitle: page.title,                      ",[427,8276,8277],{"class":433},"\u002F\u002F 社交平台卡片\n",[427,8279,8280],{"class":429,"line":495},[427,8281,8282],{"class":720},"  ogDescription: page.description,\n",[427,8284,8285,8288,8291,8293,8296,8299,8302],{"class":429,"line":503},[427,8286,8287],{"class":720},"  ogImage: ",[427,8289,8290],{"class":444},"'\u002Fimg\u002F'",[427,8292,1851],{"class":710},[427,8294,8295],{"class":720}," page.category ",[427,8297,8298],{"class":710},"+",[427,8300,8301],{"class":444}," '-cover.svg'",[427,8303,4889],{"class":720},[427,8305,8306,8309,8312],{"class":429,"line":791},[427,8307,8308],{"class":720},"  ogType: ",[427,8310,8311],{"class":444},"'article'",[427,8313,4889],{"class":720},[427,8315,8316,8319,8322],{"class":429,"line":812},[427,8317,8318],{"class":720},"  twitterCard: ",[427,8320,8321],{"class":444},"'summary_large_image'",[427,8323,4889],{"class":720},[427,8325,8326,8329],{"class":429,"line":817},[427,8327,8328],{"class":720},"  robots: ",[427,8330,8331],{"class":444},"'index, follow'\n",[427,8333,8334],{"class":429,"line":823},[427,8335,911],{"class":720},[427,8337,8338],{"class":429,"line":829},[427,8339,458],{"emptyLinePlaceholder":457},[427,8341,8342],{"class":429,"line":843},[427,8343,8344],{"class":433},"\u002F\u002F 需要更底层控制（link、script、html 属性）时用 useHead\n",[427,8346,8347,8349],{"class":429,"line":870},[427,8348,1000],{"class":440},[427,8350,721],{"class":720},[427,8352,8353,8356,8359,8362,8365],{"class":429,"line":876},[427,8354,8355],{"class":720},"  link: [{ rel: ",[427,8357,8358],{"class":444},"'canonical'",[427,8360,8361],{"class":720},", href: ",[427,8363,8364],{"class":444},"'https:\u002F\u002Fyour-site.com\u002Ftutorials\u002Fvue3'",[427,8366,8367],{"class":720}," }],\n",[427,8369,8370,8373,8376],{"class":429,"line":882},[427,8371,8372],{"class":720},"  htmlAttrs: { lang: ",[427,8374,8375],{"class":444},"'zh-CN'",[427,8377,746],{"class":720},[427,8379,8380,8383,8386,8389,8391],{"class":429,"line":887},[427,8381,8382],{"class":720},"  script: [{ src: ",[427,8384,8385],{"class":444},"'https:\u002F\u002F…'",[427,8387,8388],{"class":720},", defer: ",[427,8390,743],{"class":489},[427,8392,8393],{"class":720}," }]\n",[427,8395,8396],{"class":429,"line":908},[427,8397,911],{"class":720},[427,8399,8401,8403,8405],{"class":429,"line":8400},21,[427,8402,1597],{"class":720},[427,8404,1824],{"class":1532},[427,8406,1536],{"class":720},[10,8408,8409,651,8411,8413,8414,1241,8417,8420,8421,28,8424,28,8427,651,8430,278,8433,8438,8439,210],{},[18,8410,8216],{},[18,8412,1000],{}," 的关系：前者是后者的语义化包装（自动把 ",[18,8415,8416],{},"title",[18,8418,8419],{},"description"," 写成对应的 ",[18,8422,8423],{},"\u003Ctitle>",[18,8425,8426],{},"\u003Cmeta name=\"description\">",[18,8428,8429],{},"og:",[18,8431,8432],{},"twitter:",[14,8434,8435,8436],{},"优先用 ",[18,8437,8216],{},"，写不出来的字段再回 ",[18,8440,1000],{},[168,8442,8444,8445,8447],{"id":8443},"全站默认值放在-appvue-或配置里","全站默认值放在 ",[18,8446,2985],{}," 或配置里",[418,8449,8451],{"className":696,"code":8450,"language":698,"meta":423,"style":423},"export default defineNuxtConfig({\n  app: {\n    head: {\n      charset: 'utf-8',\n      viewport: 'width=device-width, initial-scale=1',\n      link: [{ rel: 'icon', href: '\u002Ffavicon.ico' }],\n      meta: [{ name: 'theme-color', content: '#0969da' }]\n    }\n  },\n  site: {\n    url: 'https:\u002F\u002Fyour-site.com',    \u002F\u002F 绝对地址的基准：sitemap \u002F og:url \u002F JSON-LD 都读它\n    name: '站名'\n  }\n})\n",[18,8452,8453,8463,8468,8473,8483,8493,8508,8524,8528,8532,8537,8551,8559,8563],{"__ignoreMap":423},[427,8454,8455,8457,8459,8461],{"class":429,"line":430},[427,8456,711],{"class":710},[427,8458,714],{"class":710},[427,8460,717],{"class":440},[427,8462,721],{"class":720},[427,8464,8465],{"class":429,"line":437},[427,8466,8467],{"class":720},"  app: {\n",[427,8469,8470],{"class":429,"line":454},[427,8471,8472],{"class":720},"    head: {\n",[427,8474,8475,8478,8481],{"class":429,"line":461},[427,8476,8477],{"class":720},"      charset: ",[427,8479,8480],{"class":444},"'utf-8'",[427,8482,4889],{"class":720},[427,8484,8485,8488,8491],{"class":429,"line":467},[427,8486,8487],{"class":720},"      viewport: ",[427,8489,8490],{"class":444},"'width=device-width, initial-scale=1'",[427,8492,4889],{"class":720},[427,8494,8495,8498,8501,8503,8506],{"class":429,"line":481},[427,8496,8497],{"class":720},"      link: [{ rel: ",[427,8499,8500],{"class":444},"'icon'",[427,8502,8361],{"class":720},[427,8504,8505],{"class":444},"'\u002Ffavicon.ico'",[427,8507,8367],{"class":720},[427,8509,8510,8513,8516,8519,8522],{"class":429,"line":486},[427,8511,8512],{"class":720},"      meta: [{ name: ",[427,8514,8515],{"class":444},"'theme-color'",[427,8517,8518],{"class":720},", content: ",[427,8520,8521],{"class":444},"'#0969da'",[427,8523,8393],{"class":720},[427,8525,8526],{"class":429,"line":495},[427,8527,873],{"class":720},[427,8529,8530],{"class":429,"line":503},[427,8531,879],{"class":720},[427,8533,8534],{"class":429,"line":791},[427,8535,8536],{"class":720},"  site: {\n",[427,8538,8539,8542,8545,8548],{"class":429,"line":812},[427,8540,8541],{"class":720},"    url: ",[427,8543,8544],{"class":444},"'https:\u002F\u002Fyour-site.com'",[427,8546,8547],{"class":720},",    ",[427,8549,8550],{"class":433},"\u002F\u002F 绝对地址的基准：sitemap \u002F og:url \u002F JSON-LD 都读它\n",[427,8552,8553,8556],{"class":429,"line":817},[427,8554,8555],{"class":720},"    name: ",[427,8557,8558],{"class":444},"'站名'\n",[427,8560,8561],{"class":429,"line":823},[427,8562,6824],{"class":720},[427,8564,8565],{"class":429,"line":829},[427,8566,911],{"class":720},[10,8568,8569,8572,8573,8579,8580,8583,8584,8586],{},[18,8570,8571],{},"site.url"," 是最容易被遗忘的一项：",[14,8574,8575,8576],{},"默认值是 ",[18,8577,8578],{},"http:\u002F\u002Flocalhost:3000","，忘记改就会生成一份全是本地地址的 sitemap，且 ",[18,8581,8582],{},"og:url"," 也错（本站在 ",[18,8585,42],{}," 里给它写了显眼注释）。",[168,8588,8590],{"id":8589},"sitemap-与-robots","sitemap 与 robots",[418,8592,8594],{"className":420,"code":8593,"language":422,"meta":423,"style":423},"npm install @nuxtjs\u002Fsitemap\n",[18,8595,8596],{"__ignoreMap":423},[427,8597,8598,8600,8603],{"class":429,"line":430},[427,8599,470],{"class":440},[427,8601,8602],{"class":444}," install",[427,8604,8605],{"class":444}," @nuxtjs\u002Fsitemap\n",[418,8607,8609],{"className":696,"code":8608,"language":698,"meta":423,"style":423},"export default defineNuxtConfig({\n  modules: ['@nuxtjs\u002Fsitemap'],\n  site: { url: 'https:\u002F\u002Fyour-site.com' },\n  sitemap: {\n    autoLastmod: true,\n    exclude: ['\u002Fadmin\u002F**', '\u002Fprivate\u002F**', '\u002Ftools\u002F**?*_=*']\n  }\n})\n",[18,8610,8611,8621,8629,8637,8642,8651,8671,8675],{"__ignoreMap":423},[427,8612,8613,8615,8617,8619],{"class":429,"line":430},[427,8614,711],{"class":710},[427,8616,714],{"class":710},[427,8618,717],{"class":440},[427,8620,721],{"class":720},[427,8622,8623,8625,8627],{"class":429,"line":437},[427,8624,772],{"class":720},[427,8626,781],{"class":444},[427,8628,784],{"class":720},[427,8630,8631,8633,8635],{"class":429,"line":454},[427,8632,890],{"class":720},[427,8634,8544],{"class":444},[427,8636,746],{"class":720},[427,8638,8639],{"class":429,"line":461},[427,8640,8641],{"class":720},"  sitemap: {\n",[427,8643,8644,8647,8649],{"class":429,"line":467},[427,8645,8646],{"class":720},"    autoLastmod: ",[427,8648,743],{"class":489},[427,8650,4889],{"class":720},[427,8652,8653,8656,8659,8661,8664,8666,8669],{"class":429,"line":481},[427,8654,8655],{"class":720},"    exclude: [",[427,8657,8658],{"class":444},"'\u002Fadmin\u002F**'",[427,8660,778],{"class":720},[427,8662,8663],{"class":444},"'\u002Fprivate\u002F**'",[427,8665,778],{"class":720},[427,8667,8668],{"class":444},"'\u002Ftools\u002F**?*_=*'",[427,8670,867],{"class":720},[427,8672,8673],{"class":429,"line":486},[427,8674,6824],{"class":720},[427,8676,8677],{"class":429,"line":495},[427,8678,911],{"class":720},[10,8680,8681,8684,8685,8688,8689,8691,8692,8695,8696,8703],{},[18,8682,8683],{},"robots.txt"," 建议直接放 ",[18,8686,8687],{},"public\u002Frobots.txt","（静态、零成本）。要按环境屏蔽预发站，就在 ",[18,8690,8687],{}," 里写 ",[18,8693,8694],{},"Disallow: \u002F","，或用中间件在预发域名返回不同内容——",[14,8697,8698,8699,8702],{},"别依赖 ",[18,8700,8701],{},"\u003Cmeta name=\"robots\">"," 拦已泄露的 URL","，它要求页面能被访问到才生效。",[168,8705,8707],{"id":8706},"结构化数据json-ld","结构化数据（JSON-LD）",[418,8709,8711],{"className":1515,"code":8710,"language":1517,"meta":423,"style":423},"\u003Cscript setup>\nuseHead({\n  script: [{\n    type: 'application\u002Fld+json',\n    innerHTML: JSON.stringify({\n      '@context': 'https:\u002F\u002Fschema.org',\n      '@type': 'TechArticle',\n      headline: page.title,\n      datePublished: page.date,\n      author: { '@type': 'Organization', name: '码上智能' }\n    })\n  }]\n})\n\u003C\u002Fscript>\n",[18,8712,8713,8723,8729,8734,8744,8759,8771,8783,8788,8793,8812,8817,8822,8826],{"__ignoreMap":423},[427,8714,8715,8717,8719,8721],{"class":429,"line":430},[427,8716,1529],{"class":720},[427,8718,1824],{"class":1532},[427,8720,1827],{"class":440},[427,8722,1536],{"class":720},[427,8724,8725,8727],{"class":429,"line":437},[427,8726,1000],{"class":440},[427,8728,721],{"class":720},[427,8730,8731],{"class":429,"line":454},[427,8732,8733],{"class":720},"  script: [{\n",[427,8735,8736,8739,8742],{"class":429,"line":461},[427,8737,8738],{"class":720},"    type: ",[427,8740,8741],{"class":444},"'application\u002Fld+json'",[427,8743,4889],{"class":720},[427,8745,8746,8749,8752,8754,8757],{"class":429,"line":467},[427,8747,8748],{"class":720},"    innerHTML: ",[427,8750,8751],{"class":489},"JSON",[427,8753,2094],{"class":720},[427,8755,8756],{"class":440},"stringify",[427,8758,721],{"class":720},[427,8760,8761,8764,8766,8769],{"class":429,"line":481},[427,8762,8763],{"class":444},"      '@context'",[427,8765,3496],{"class":720},[427,8767,8768],{"class":444},"'https:\u002F\u002Fschema.org'",[427,8770,4889],{"class":720},[427,8772,8773,8776,8778,8781],{"class":429,"line":486},[427,8774,8775],{"class":444},"      '@type'",[427,8777,3496],{"class":720},[427,8779,8780],{"class":444},"'TechArticle'",[427,8782,4889],{"class":720},[427,8784,8785],{"class":429,"line":495},[427,8786,8787],{"class":720},"      headline: page.title,\n",[427,8789,8790],{"class":429,"line":503},[427,8791,8792],{"class":720},"      datePublished: page.date,\n",[427,8794,8795,8798,8801,8803,8806,8808,8810],{"class":429,"line":791},[427,8796,8797],{"class":720},"      author: { ",[427,8799,8800],{"class":444},"'@type'",[427,8802,3496],{"class":720},[427,8804,8805],{"class":444},"'Organization'",[427,8807,896],{"class":720},[427,8809,6805],{"class":444},[427,8811,4975],{"class":720},[427,8813,8814],{"class":429,"line":812},[427,8815,8816],{"class":720},"    })\n",[427,8818,8819],{"class":429,"line":817},[427,8820,8821],{"class":720},"  }]\n",[427,8823,8824],{"class":429,"line":823},[427,8825,911],{"class":720},[427,8827,8828,8830,8832],{"class":429,"line":829},[427,8829,1597],{"class":720},[427,8831,1824],{"class":1532},[427,8833,1536],{"class":720},[10,8835,8836,8839,8840,8843,8844,8847],{},[18,8837,8838],{},"innerHTML"," 一定要 ",[18,8841,8842],{},"JSON.stringify","（对象直接传会被转成 ",[18,8845,8846],{},"[object Object]","）。上线前拿 Google 的「富媒体测试」和「网址检查」各跑一次，比自查有效。",[168,8849,8851],{"id":8850},"防主题闪烁一段内联脚本","防主题闪烁：一段内联脚本",[10,8853,8854,8855,8858,8859,8861,8862,8865,8866,1056],{},"有暗色模式的站点会遇到 FOUC：预渲染的 HTML 里没有 ",[18,8856,8857],{},"data-theme","，暗色用户先看到一帧亮色。标准解法是在 ",[18,8860,8208],{}," 里放一段",[14,8863,8864],{},"内联同步脚本","，早于首次绘制把主题写进 ",[18,8867,8868],{},"\u003Chtml>",[418,8870,8872],{"className":1515,"code":8871,"language":1517,"meta":423,"style":423},"\u003Cscript setup>\nuseHead({\n  script: [{\n    tagPosition: 'head',\n    innerHTML: `(function(){try{var t=localStorage.getItem('site-theme');` +\n      `if(t!=='dark'&&t!=='light'){t=matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}` +\n      `document.documentElement.setAttribute('data-theme',t);}catch(e){}})();`\n  }]\n})\n\u003C\u002Fscript>\n",[18,8873,8874,8884,8890,8894,8904,8914,8921,8926,8930,8934],{"__ignoreMap":423},[427,8875,8876,8878,8880,8882],{"class":429,"line":430},[427,8877,1529],{"class":720},[427,8879,1824],{"class":1532},[427,8881,1827],{"class":440},[427,8883,1536],{"class":720},[427,8885,8886,8888],{"class":429,"line":437},[427,8887,1000],{"class":440},[427,8889,721],{"class":720},[427,8891,8892],{"class":429,"line":454},[427,8893,8733],{"class":720},[427,8895,8896,8899,8902],{"class":429,"line":461},[427,8897,8898],{"class":720},"    tagPosition: ",[427,8900,8901],{"class":444},"'head'",[427,8903,4889],{"class":720},[427,8905,8906,8908,8911],{"class":429,"line":467},[427,8907,8748],{"class":720},[427,8909,8910],{"class":444},"`(function(){try{var t=localStorage.getItem('site-theme');`",[427,8912,8913],{"class":710}," +\n",[427,8915,8916,8919],{"class":429,"line":481},[427,8917,8918],{"class":444},"      `if(t!=='dark'&&t!=='light'){t=matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}`",[427,8920,8913],{"class":710},[427,8922,8923],{"class":429,"line":486},[427,8924,8925],{"class":444},"      `document.documentElement.setAttribute('data-theme',t);}catch(e){}})();`\n",[427,8927,8928],{"class":429,"line":495},[427,8929,8821],{"class":720},[427,8931,8932],{"class":429,"line":503},[427,8933,911],{"class":720},[427,8935,8936,8938,8940],{"class":429,"line":791},[427,8937,1597],{"class":720},[427,8939,1824],{"class":1532},[427,8941,1536],{"class":720},[10,8943,8944,8945,8947,8948,8953],{},"这段是本站 ",[18,8946,2501],{}," 里真实在用的代码。要点：",[14,8949,8950,8951],{},"必须在 head、必须内联、必须 ",[18,8952,4996],{},"（隐私模式读 localStorage 会抛）。",[168,8955,261],{"id":8956},"️-本章的坑-9",[10,8958,8959,8964,8965,1241,8967,8969],{},[14,8960,4276,8961,8963],{},[18,8962,8216],{}," 写在异步分支里。"," 它必须在 setup 同步执行期调用。放进 ",[18,8966,7120],{},[18,8968,2274],{}," 里，SSR 出的 HTML 就没有这个 meta（客户端才补上，爬虫看不到）。",[10,8971,8972,8975,8976,8979,8980,8983],{},[14,8973,8974],{},"2. 动态标题依赖客户端数据。"," 正确顺序是：",[18,8977,8978],{},"await useFetch()"," 拿到数据 → 立刻 ",[18,8981,8982],{},"useSeoMeta({ title: data.title })","。数据没在服务端取到，标题就只能是默认值。",[10,8985,8986,8991,8992,8995,8996,8999],{},[14,8987,2204,8988,8990],{},[18,8989,8571],{}," 忘了改。"," 生成的是 ",[18,8993,8994],{},"localhost"," 的绝对地址（本章「全站默认值」那节的 ",[18,8997,8998],{},"site"," 配置就是它）。",[10,9001,9002,9008,9009,1056,9012,9015,9016,9018],{},[14,9003,3235,9004,9007],{},[18,9005,9006],{},"og:image"," 用了相对路径。"," 平台抓卡片时不会拼你的域名，必须给",[14,9010,9011],{},"绝对 URL",[18,9013,9014],{},"useSeoMeta({ ogImage: config.siteUrl + cover })"," 或让 ",[18,9017,8571],{}," 帮你补全（新版本会补，但要实测确认，别赌）。",[10,9020,9021,1257,9024,9026],{},[14,9022,9023],{},"5. 预渲染页面的 meta 被固化。",[18,9025,7818],{}," 时每个页面各算各的 head，运行时改不了。若 meta 依赖「当前用户」，这类页面就不该预渲染。",[10,9028,9029,9032,9033,9035,9036,9038,9039,9041],{},[14,9030,9031],{},"6. 同一 key 的 head 重复注入。"," 在循环\u002F子组件里调用 ",[18,9034,1000],{}," 会产生多个 ",[18,9037,8423],{},"。全站级的写在 ",[18,9040,2985],{},"，页面级的写在页面里。",[10,9043,9044,9050,9051,210],{},[14,9045,9046,9047,9049],{},"7. 用 ",[18,9048,3396],{}," 包住正文。"," 屏蔽闪烁的目的若是「不让爬虫看到」，那 SEO 目标就同时落空了。正文要 SSR，只把「依赖浏览器环境的按钮\u002F图表」放进 ",[18,9052,3396],{},[10,9054,9055,9058,9059,9062,9063,9066,9067,210],{},[14,9056,9057],{},"8. robots 与 noindex 打架。"," 网关加了 ",[18,9060,9061],{},"X-Robots-Tag: noindex","，页面 meta 却写 ",[18,9064,9065],{},"index","——头信息优先。排查索引问题先看 ",[18,9068,7991],{},[168,9070,9072],{"id":9071},"附head-相关-api","附：head 相关 API",[59,9074,9075,9085],{},[62,9076,9077],{},[65,9078,9079,9082],{},[68,9080,9081],{},"API",[68,9083,9084],{},"用途",[75,9086,9087,9097,9107,9117,9133,9144],{},[65,9088,9089,9094],{},[80,9090,9091],{},[18,9092,9093],{},"useHead(input)",[80,9095,9096],{},"任意 head 标签（link\u002Fscript\u002Fstyle\u002FhtmlAttrs）",[65,9098,9099,9104],{},[80,9100,9101],{},[18,9102,9103],{},"useSeoMeta(input)",[80,9105,9106],{},"title\u002Fdescription\u002Fog\u002Ftwitter 的语义化写法",[65,9108,9109,9114],{},[80,9110,9111],{},[18,9112,9113],{},"useServerSeoMeta",[80,9115,9116],{},"同上，但明确只在服务端输出（减小客户端体积）",[65,9118,9119,9130],{},[80,9120,9121,1085,9124,304,9127,9129],{},[18,9122,9123],{},"setHead",[18,9125,9126],{},"head",[18,9128,2985],{}," 里）",[80,9131,9132],{},"全站默认",[65,9134,9135,9141],{},[80,9136,9137,274,9139],{},[18,9138,6464],{},[18,9140,8093],{},[80,9142,9143],{},"让带 meta 的 HTML 也进缓存",[65,9145,9146,9151],{},[80,9147,9148],{},[18,9149,9150],{},"useRequestURL()",[80,9152,9153],{},"拿到当前请求的绝对 URL（canonical \u002F og:url 用）",[45,9155,9157,9158,9160],{"id":9156},"十一状态共享usestate-与-pinia","十一、状态共享：",[18,9159,1009],{}," 与 Pinia",[10,9162,9163,9164,9167],{},"《Vue 3 教程》第十五章讲了 Pinia 本身；这里只讲",[14,9165,9166],{},"在 Nuxt 里用它的三件额外事","，以及一个 Nuxt 特有的轻量替代品。",[168,9169,9171,9172,9174],{"id":9170},"先试-usestatessr-安全的极简共享状态","先试 ",[18,9173,1009],{},"：SSR 安全的极简共享状态",[418,9176,9178],{"className":696,"code":9177,"language":698,"meta":423,"style":423},"\u002F\u002F 任意组件\u002F组合式函数里\nconst panel = useState('side-panel', () => ({ open: false, tab: 'toc' }))\npanel.value.open = true      \u002F\u002F 全站共享，且服务端每次请求各自一份（不会串号）\n",[18,9179,9180,9185,9220],{"__ignoreMap":423},[427,9181,9182],{"class":429,"line":430},[427,9183,9184],{"class":433},"\u002F\u002F 任意组件\u002F组合式函数里\n",[427,9186,9187,9189,9192,9194,9197,9199,9202,9204,9206,9209,9211,9214,9217],{"class":429,"line":437},[427,9188,1867],{"class":710},[427,9190,9191],{"class":489}," panel",[427,9193,1873],{"class":710},[427,9195,9196],{"class":440}," useState",[427,9198,1845],{"class":720},[427,9200,9201],{"class":444},"'side-panel'",[427,9203,3873],{"class":720},[427,9205,2077],{"class":710},[427,9207,9208],{"class":720}," ({ open: ",[427,9210,803],{"class":489},[427,9212,9213],{"class":720},", tab: ",[427,9215,9216],{"class":444},"'toc'",[427,9218,9219],{"class":720}," }))\n",[427,9221,9222,9225,9227,9230],{"class":429,"line":454},[427,9223,9224],{"class":720},"panel.value.open ",[427,9226,1550],{"class":710},[427,9228,9229],{"class":489}," true",[427,9231,9232],{"class":433},"      \u002F\u002F 全站共享，且服务端每次请求各自一份（不会串号）\n",[10,9234,9235,9237,9238,9241],{},[18,9236,1009],{}," 的数据会进 payload：服务端写的值，客户端水合时直接复用，不重复请求。",[14,9239,9240],{},"它适合「一次会话内的界面状态」","：侧栏开合、当前筛选、向导步骤。不适合需要 getter\u002Faction\u002Fdevtools 的复杂逻辑。",[10,9243,9244,9245,9248,9249,9252,9253,9255,9256,9259],{},"三条限制要清楚：值必须",[14,9246,9247],{},"可序列化","（不能存函数、DOM 引用、类实例）；key 是全局的（",[14,9250,9251],{},"撞 key 就撞数据","）；它不是持久化，刷新就没了（要落盘自己配 ",[18,9254,972],{}," + localStorage，Vue 教程第十三章的 ",[18,9257,9258],{},"useLocalStorage"," 正合适）。",[168,9261,9263],{"id":9262},"pinia装进-nuxt-的三步","Pinia：装进 Nuxt 的三步",[418,9265,9267],{"className":420,"code":9266,"language":422,"meta":423,"style":423},"npm install pinia @pinia\u002Fnuxt\n",[18,9268,9269],{"__ignoreMap":423},[427,9270,9271,9273,9275,9278],{"class":429,"line":430},[427,9272,470],{"class":440},[427,9274,8602],{"class":444},[427,9276,9277],{"class":444}," pinia",[427,9279,9280],{"class":444}," @pinia\u002Fnuxt\n",[418,9282,9284],{"className":696,"code":9283,"language":698,"meta":423,"style":423},"\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({ modules: ['@pinia\u002Fnuxt'] })\n",[18,9285,9286,9290],{"__ignoreMap":423},[427,9287,9288],{"class":429,"line":430},[427,9289,705],{"class":433},[427,9291,9292,9294,9296,9298,9301,9304],{"class":429,"line":437},[427,9293,711],{"class":710},[427,9295,714],{"class":710},[427,9297,717],{"class":440},[427,9299,9300],{"class":720},"({ modules: [",[427,9302,9303],{"class":444},"'@pinia\u002Fnuxt'",[427,9305,3558],{"class":720},[418,9307,9309],{"className":696,"code":9308,"language":698,"meta":423,"style":423},"\u002F\u002F app\u002Fstores\u002Fuser.ts   —— 与《Vue 3 教程》第十五章完全同构\nimport { defineStore } from 'pinia'\n\nexport const useUserStore = defineStore('user', () => {\n  const profile = ref(null)\n  const isLoggedIn = computed(() => !!profile.value)\n\n  async function load() {\n    profile.value = await $fetch('\u002Fapi\u002Fme')        \u002F\u002F SSR 阶段也会执行\n  }\n  function logout() { profile.value = null }\n  return { profile, isLoggedIn, load, logout }\n})\n",[18,9310,9311,9316,9329,9333,9359,9377,9399,9403,9415,9436,9440,9458,9465],{"__ignoreMap":423},[427,9312,9313],{"class":429,"line":430},[427,9314,9315],{"class":433},"\u002F\u002F app\u002Fstores\u002Fuser.ts   —— 与《Vue 3 教程》第十五章完全同构\n",[427,9317,9318,9320,9323,9326],{"class":429,"line":437},[427,9319,108],{"class":710},[427,9321,9322],{"class":720}," { defineStore } ",[427,9324,9325],{"class":710},"from",[427,9327,9328],{"class":444}," 'pinia'\n",[427,9330,9331],{"class":429,"line":454},[427,9332,458],{"emptyLinePlaceholder":457},[427,9334,9335,9337,9340,9343,9345,9348,9350,9353,9355,9357],{"class":429,"line":461},[427,9336,711],{"class":710},[427,9338,9339],{"class":710}," const",[427,9341,9342],{"class":489}," useUserStore",[427,9344,1873],{"class":710},[427,9346,9347],{"class":440}," defineStore",[427,9349,1845],{"class":720},[427,9351,9352],{"class":444},"'user'",[427,9354,3873],{"class":720},[427,9356,2077],{"class":710},[427,9358,5014],{"class":720},[427,9360,9361,9363,9366,9368,9371,9373,9375],{"class":429,"line":467},[427,9362,4746],{"class":710},[427,9364,9365],{"class":489}," profile",[427,9367,1873],{"class":710},[427,9369,9370],{"class":440}," ref",[427,9372,1845],{"class":720},[427,9374,1375],{"class":489},[427,9376,2820],{"class":720},[427,9378,9379,9381,9384,9386,9389,9391,9393,9396],{"class":429,"line":481},[427,9380,4746],{"class":710},[427,9382,9383],{"class":489}," isLoggedIn",[427,9385,1873],{"class":710},[427,9387,9388],{"class":440}," computed",[427,9390,5201],{"class":720},[427,9392,2077],{"class":710},[427,9394,9395],{"class":710}," !!",[427,9397,9398],{"class":720},"profile.value)\n",[427,9400,9401],{"class":429,"line":486},[427,9402,458],{"emptyLinePlaceholder":457},[427,9404,9405,9408,9410,9413],{"class":429,"line":495},[427,9406,9407],{"class":710},"  async",[427,9409,4735],{"class":710},[427,9411,9412],{"class":440}," load",[427,9414,4741],{"class":720},[427,9416,9417,9420,9422,9424,9426,9428,9430,9433],{"class":429,"line":503},[427,9418,9419],{"class":720},"    profile.value ",[427,9421,1550],{"class":710},[427,9423,3974],{"class":710},[427,9425,3878],{"class":440},[427,9427,1845],{"class":720},[427,9429,4018],{"class":444},[427,9431,9432],{"class":720},")        ",[427,9434,9435],{"class":433},"\u002F\u002F SSR 阶段也会执行\n",[427,9437,9438],{"class":429,"line":791},[427,9439,6824],{"class":720},[427,9441,9442,9445,9448,9451,9453,9456],{"class":429,"line":812},[427,9443,9444],{"class":710},"  function",[427,9446,9447],{"class":440}," logout",[427,9449,9450],{"class":720},"() { profile.value ",[427,9452,1550],{"class":710},[427,9454,9455],{"class":489}," null",[427,9457,4975],{"class":720},[427,9459,9460,9462],{"class":429,"line":817},[427,9461,5760],{"class":710},[427,9463,9464],{"class":720}," { profile, isLoggedIn, load, logout }\n",[427,9466,9467],{"class":429,"line":823},[427,9468,911],{"class":720},[418,9470,9472],{"className":1515,"code":9471,"language":1517,"meta":423,"style":423},"\u003Cscript setup>\nconst user = useUserStore()\nawait user.load()          \u002F\u002F 想让它进 SSR HTML 就在 setup 顶层 await\n\u003C\u002Fscript>\n",[18,9473,9474,9484,9497,9513],{"__ignoreMap":423},[427,9475,9476,9478,9480,9482],{"class":429,"line":430},[427,9477,1529],{"class":720},[427,9479,1824],{"class":1532},[427,9481,1827],{"class":440},[427,9483,1536],{"class":720},[427,9485,9486,9488,9491,9493,9495],{"class":429,"line":437},[427,9487,1867],{"class":710},[427,9489,9490],{"class":489}," user",[427,9492,1873],{"class":710},[427,9494,9342],{"class":440},[427,9496,1879],{"class":720},[427,9498,9499,9501,9504,9507,9510],{"class":429,"line":454},[427,9500,1839],{"class":710},[427,9502,9503],{"class":720}," user.",[427,9505,9506],{"class":440},"load",[427,9508,9509],{"class":720},"()          ",[427,9511,9512],{"class":433},"\u002F\u002F 想让它进 SSR HTML 就在 setup 顶层 await\n",[427,9514,9515,9517,9519],{"class":429,"line":461},[427,9516,1597],{"class":720},[427,9518,1824],{"class":1532},[427,9520,1536],{"class":720},[10,9522,9523,9526,9527,9530,9531,9533,9534,9536],{},[18,9524,9525],{},"defineStore"," 可以留在 ",[18,9528,9529],{},"app\u002Fstores\u002F","（Nuxt 不自动导入 store，需要 ",[18,9532,108],{},"）；要免 import 就放进 ",[18,9535,129],{}," 里包一层。",[168,9538,9540],{"id":9539},"ssr-场景的三条铁律","SSR 场景的三条铁律",[183,9542,9543,9564,9577],{},[186,9544,9545,1257,9548,9551,9552,9555,9556,9559,9560,9563],{},[14,9546,9547],{},"每个请求一个 Pinia 实例。",[18,9549,9550],{},"@pinia\u002Fnuxt"," 已经做了这件事（插件里 ",[18,9553,9554],{},"createPinia()"," 每次请求新建）。你",[14,9557,9558],{},"自己","在模块顶层创建的普通单例（",[18,9561,9562],{},"const cache = new Map()","）会跨请求共享——这是 SSR 最危险的一类 bug：A 用户看到了 B 用户的数据。",[186,9565,9566,9573,9574,9576],{},[14,9567,9568,9569,1241,9571],{},"store 初始化数据要走 ",[18,9570,997],{},[18,9572,1009],{},"，不要 ",[18,9575,2274],{},"。否则服务端渲染时 store 是空的，首屏正文也就是空的。",[186,9578,9579,210,9582,9585,9586,9589],{},[14,9580,9581],{},"持久化只在客户端做",[18,9583,9584],{},"localStorage"," 在服务端不存在，SSR 阶段访问它会报错。用 ",[18,9587,9588],{},"import.meta.client"," 守卫：",[418,9591,9593],{"className":696,"code":9592,"language":698,"meta":423,"style":423},"if (import.meta.client) {\n  watch(() => store.theme, (v) => localStorage.setItem('theme', v), { immediate: true })\n}\n",[18,9594,9595,9610,9647],{"__ignoreMap":423},[427,9596,9597,9599,9601,9603,9605,9607],{"class":429,"line":430},[427,9598,7120],{"class":710},[427,9600,5043],{"class":720},[427,9602,108],{"class":710},[427,9604,2094],{"class":720},[427,9606,1409],{"class":489},[427,9608,9609],{"class":720},".client) {\n",[427,9611,9612,9615,9617,9619,9622,9625,9627,9629,9632,9635,9637,9640,9643,9645],{"class":429,"line":437},[427,9613,9614],{"class":440},"  watch",[427,9616,5201],{"class":720},[427,9618,2077],{"class":710},[427,9620,9621],{"class":720}," store.theme, (",[427,9623,9624],{"class":2070},"v",[427,9626,2074],{"class":720},[427,9628,2077],{"class":710},[427,9630,9631],{"class":720}," localStorage.",[427,9633,9634],{"class":440},"setItem",[427,9636,1845],{"class":720},[427,9638,9639],{"class":444},"'theme'",[427,9641,9642],{"class":720},", v), { immediate: ",[427,9644,743],{"class":489},[427,9646,2961],{"class":720},[427,9648,9649],{"class":429,"line":454},[427,9650,4801],{"class":720},[212,9652],{"height":9653,"src":9654,"title":9655},640,"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F15-pinia-store.html","跨课示例（Vue 3 教程 示例 15）：两个组件共享同一个 store",[10,9657,9658],{},"跨课程提示：上面这个示例属于《Vue 3 教程》。在 Nuxt 里唯一多出来的就是「实例按请求创建」这一层，其余用法不变。",[168,9660,261],{"id":9661},"️-本章的坑-10",[10,9663,9664,1257,9667,9670,9671,9674,9675,9678,9679,1085,9682,9684,9685,9687],{},[14,9665,9666],{},"1. 模块顶层的可变单例。",[18,9668,9669],{},"const state = reactive({ list: [] })"," 写在 ",[18,9672,9673],{},"\u003Cscript>"," 顶层或某个 ",[18,9676,9677],{},"utils"," 文件里，SSR 下所有请求共用 → 数据串号。放进 ",[18,9680,9681],{},"useState()",[18,9683,9525],{}," \u002F 或 ",[18,9686,1028],{}," 内部创建。",[10,9689,9690,9695],{},[14,9691,1186,9692,9694],{},[18,9693,1009],{}," 存了不可序列化的东西。"," 存 class 实例、函数、DOM 元素 → payload 里丢失或报错，表现为「服务端有值、客户端 undefined」。只存纯数据。",[10,9697,9698,1257,9703,1085,9705,9708,9709,9711,9712,28,9714,210],{},[14,9699,2204,9700,9702],{},[18,9701,9584],{}," 在 SSR 阶段裸奔。",[18,9704,4312],{},[18,9706,9707],{},"localStorage is not defined","。三种正解：",[18,9710,9588],{}," 判断、",[18,9713,4316],{},[18,9715,3396],{},[10,9717,9718,9721,9722,9724,9725,9727],{},[14,9719,9720],{},"4. 忘记水合导致「闪一下」。"," 服务端把 store 初始值写进 payload，若客户端又在 ",[18,9723,2274],{}," 里重新请求一遍，界面会出现「旧值 → 空 → 新值」。改成 setup 顶层 ",[18,9726,1839],{}," 一次。",[10,9729,9730,9735],{},[14,9731,9732,9733,210],{},"5. store 里直接读 ",[18,9734,3205],{}," 可以，但要在 setup 上下文中；且注意服务端阶段的 route 与客户端阶段可能不同（重定向后）。需要「进入页面时的路由」请存进局部变量。",[10,9737,9738,9744,9745,9747],{},[14,9739,3263,9740,9743],{},[18,9741,9742],{},"$reset()"," 在 setup store 里不存在。"," 与《Vue 3 教程》第十五章第 3 条一样：自己写一个 ",[18,9746,9742],{}," 返回。",[10,9749,9750,9753,9754,9757],{},[14,9751,9752],{},"7. 把 SSR 阶段取的用户数据留在 Pinia 里返回给别的用户。"," 严格来说这是第 1 条的后果，但值得单列：",[14,9755,9756],{},"任何「按请求的状态」都必须由 Pinia\u002FuseState 托管，不能由模块变量托管","。上线前用两个不同账号并发压一次，看有没有串。",[168,9759,9761],{"id":9760},"附共享状态选型","附：共享状态选型",[59,9763,9764,9772],{},[62,9765,9766],{},[65,9767,9768,9770],{},[68,9769,5360],{},[68,9771,5363],{},[75,9773,9774,9783,9791,9805,9817,9825,9837],{},[65,9775,9776,9779],{},[80,9777,9778],{},"一次访问内的界面状态（侧栏、筛选、步骤）",[80,9780,9781],{},[18,9782,1009],{},[65,9784,9785,9788],{},[80,9786,9787],{},"需要 getter\u002Faction\u002F调试面板的业务状态",[80,9789,9790],{},"Pinia",[65,9792,9793,9796],{},[80,9794,9795],{},"服务端数据缓存与刷新",[80,9797,9798,1085,9800,9802,9803],{},[18,9799,994],{},[18,9801,997],{},"（第五章）+ ",[18,9804,4623],{},[65,9806,9807,9810],{},[80,9808,9809],{},"只有当前组件用",[80,9811,9812,1085,9814],{},[18,9813,243],{},[18,9815,9816],{},"reactive",[65,9818,9819,9822],{},[80,9820,9821],{},"父到子 \u002F 子到父",[80,9823,9824],{},"props \u002F emit",[65,9826,9827,9830],{},[80,9828,9829],{},"主题、语言这类环境值",[80,9831,9832,2285,9834,9836],{},[18,9833,1009],{},[18,9835,7339],{},"（构建期常量）",[65,9838,9839,9842],{},[80,9840,9841],{},"URL 能表达的状态（分页、tab、关键词）",[80,9843,9844],{},"路由 query",[45,9846,9848],{"id":9847},"十二插件中间件与组合式函数挂到生命周期上","十二、插件、中间件与组合式函数：挂到生命周期上",[10,9850,9851],{},"这三样是 Nuxt 里「组织代码」的主要手段，分工很清楚：",[59,9853,9854,9866],{},[62,9855,9856],{},[65,9857,9858,9861,9864],{},[68,9859,9860],{},"手段",[68,9862,9863],{},"触发时机",[68,9865,7250],{},[75,9867,9868,9882,9903,9916],{},[65,9869,9870,9876,9879],{},[80,9871,9872,9873],{},"路由中间件 ",[18,9874,9875],{},"app\u002Fmiddleware\u002F",[80,9877,9878],{},"每次导航（客户端）\u002F 服务端渲染前",[80,9880,9881],{},"鉴权、语言切换、埋点重定向",[65,9883,9884,9890,9896],{},[80,9885,9886,9887],{},"Nuxt 插件 ",[18,9888,9889],{},"app\u002Fplugins\u002F",[80,9891,9892,9893,638],{},"应用初始化（",[14,9894,9895],{},"每个请求各跑一次",[80,9897,9898,9899,9902],{},"注册 ",[18,9900,9901],{},"$http","、注入全局样式逻辑、接第三方 SDK",[65,9904,9905,9910,9913],{},[80,9906,9907,9908],{},"组合式函数 ",[18,9909,129],{},[80,9911,9912],{},"你在组件里调用时",[80,9914,9915],{},"复用「状态 + 逻辑」",[65,9917,9918,9924,9927],{},[80,9919,9920,9921],{},"服务端中间件 ",[18,9922,9923],{},"server\u002Fmiddleware\u002F",[80,9925,9926],{},"每个进入服务端的请求",[80,9928,9929],{},"日志、CORS、请求计时",[168,9931,9932],{"id":9932},"路由中间件",[418,9934,9936],{"className":696,"code":9935,"language":698,"meta":423,"style":423},"\u002F\u002F app\u002Fmiddleware\u002Fauth.ts   （文件名即中间件名：definePageMeta({ middleware: 'auth' })）\nexport default defineNuxtRouteMiddleware((to) => {\n  const user = useState('user', () => null)\n  if (!user.value && to.meta.requiresAuth !== false) {\n    return navigateTo(`\u002Flogin?redirect=${to.fullPath}`)     \u002F\u002F 返回跳转 = 拦截\n  }\n  \u002F\u002F 返回 undefined 放行；return abortNavigation('消息') 直接进错误页\n})\n",[18,9937,9938,9943,9962,9984,10009,10036,10040,10045],{"__ignoreMap":423},[427,9939,9940],{"class":429,"line":430},[427,9941,9942],{"class":433},"\u002F\u002F app\u002Fmiddleware\u002Fauth.ts   （文件名即中间件名：definePageMeta({ middleware: 'auth' })）\n",[427,9944,9945,9947,9949,9952,9954,9956,9958,9960],{"class":429,"line":437},[427,9946,711],{"class":710},[427,9948,714],{"class":710},[427,9950,9951],{"class":440}," defineNuxtRouteMiddleware",[427,9953,7955],{"class":720},[427,9955,1675],{"class":2070},[427,9957,2074],{"class":720},[427,9959,2077],{"class":710},[427,9961,5014],{"class":720},[427,9963,9964,9966,9968,9970,9972,9974,9976,9978,9980,9982],{"class":429,"line":454},[427,9965,4746],{"class":710},[427,9967,9490],{"class":489},[427,9969,1873],{"class":710},[427,9971,9196],{"class":440},[427,9973,1845],{"class":720},[427,9975,9352],{"class":444},[427,9977,3873],{"class":720},[427,9979,2077],{"class":710},[427,9981,9455],{"class":489},[427,9983,2820],{"class":720},[427,9985,9986,9988,9990,9992,9995,9998,10001,10004,10007],{"class":429,"line":461},[427,9987,6192],{"class":710},[427,9989,5043],{"class":720},[427,9991,6197],{"class":710},[427,9993,9994],{"class":720},"user.value ",[427,9996,9997],{"class":710},"&&",[427,9999,10000],{"class":720}," to.meta.requiresAuth ",[427,10002,10003],{"class":710},"!==",[427,10005,10006],{"class":489}," false",[427,10008,5055],{"class":720},[427,10010,10011,10014,10016,10018,10021,10023,10025,10028,10030,10033],{"class":429,"line":467},[427,10012,10013],{"class":710},"    return",[427,10015,1842],{"class":440},[427,10017,1845],{"class":720},[427,10019,10020],{"class":444},"`\u002Flogin?redirect=${",[427,10022,1675],{"class":720},[427,10024,2094],{"class":444},[427,10026,10027],{"class":720},"fullPath",[427,10029,4076],{"class":444},[427,10031,10032],{"class":720},")     ",[427,10034,10035],{"class":433},"\u002F\u002F 返回跳转 = 拦截\n",[427,10037,10038],{"class":429,"line":481},[427,10039,6824],{"class":720},[427,10041,10042],{"class":429,"line":486},[427,10043,10044],{"class":433},"  \u002F\u002F 返回 undefined 放行；return abortNavigation('消息') 直接进错误页\n",[427,10046,10047],{"class":429,"line":495},[427,10048,911],{"class":720},[10,10050,10051],{},"三种注册方式：",[418,10053,10055],{"className":696,"code":10054,"language":698,"meta":423,"style":423},"definePageMeta({ middleware: ['auth'] })            \u002F\u002F 命名中间件（app\u002Fmiddleware\u002F 里的文件）\ndefinePageMeta({ middleware: [(to) => { \u002F* … *\u002F }] }) \u002F\u002F 行内\ndefinePageMeta({ middleware: (to) => { if (to.meta.globalSkip) return } })  \u002F\u002F 单个页面加全局逻辑\n\n\u002F\u002F 全局中间件（每个路由都跑）：文件名加 .global.ts\n\u002F\u002F app\u002Fmiddleware\u002Flogger.global.ts\n",[18,10056,10057,10072,10096,10128,10132,10137],{"__ignoreMap":423},[427,10058,10059,10061,10064,10066,10069],{"class":429,"line":430},[427,10060,1022],{"class":440},[427,10062,10063],{"class":720},"({ middleware: [",[427,10065,2001],{"class":444},[427,10067,10068],{"class":720},"] })            ",[427,10070,10071],{"class":433},"\u002F\u002F 命名中间件（app\u002Fmiddleware\u002F 里的文件）\n",[427,10073,10074,10076,10079,10081,10083,10085,10087,10090,10093],{"class":429,"line":437},[427,10075,1022],{"class":440},[427,10077,10078],{"class":720},"({ middleware: [(",[427,10080,1675],{"class":2070},[427,10082,2074],{"class":720},[427,10084,2077],{"class":710},[427,10086,3491],{"class":720},[427,10088,10089],{"class":433},"\u002F* … *\u002F",[427,10091,10092],{"class":720}," }] }) ",[427,10094,10095],{"class":433},"\u002F\u002F 行内\n",[427,10097,10098,10100,10102,10105,10107,10109,10111,10113,10115,10117,10120,10122,10125],{"class":429,"line":454},[427,10099,1022],{"class":440},[427,10101,4910],{"class":720},[427,10103,10104],{"class":440},"middleware",[427,10106,2067],{"class":720},[427,10108,1675],{"class":2070},[427,10110,2074],{"class":720},[427,10112,2077],{"class":710},[427,10114,3491],{"class":720},[427,10116,7120],{"class":710},[427,10118,10119],{"class":720}," (to.meta.globalSkip) ",[427,10121,6437],{"class":710},[427,10123,10124],{"class":720}," } })  ",[427,10126,10127],{"class":433},"\u002F\u002F 单个页面加全局逻辑\n",[427,10129,10130],{"class":429,"line":461},[427,10131,458],{"emptyLinePlaceholder":457},[427,10133,10134],{"class":429,"line":467},[427,10135,10136],{"class":433},"\u002F\u002F 全局中间件（每个路由都跑）：文件名加 .global.ts\n",[427,10138,10139],{"class":429,"line":481},[427,10140,10141],{"class":433},"\u002F\u002F app\u002Fmiddleware\u002Flogger.global.ts\n",[10,10143,10144,10145,274,10147,10149,10150,10153],{},"异步中间件直接 ",[18,10146,4232],{},[18,10148,1839],{}," 即可。",[14,10151,10152],{},"顺序","：全局在前、命名在后、行内最后；多个中间件里只要有一个返回了跳转，后面的就不再执行。",[168,10155,10157],{"id":10156},"nuxt-插件提供全局能力","Nuxt 插件：提供全局能力",[418,10159,10161],{"className":696,"code":10160,"language":698,"meta":423,"style":423},"\u002F\u002F app\u002Fplugins\u002Fhttp.ts\nexport default defineNuxtPlugin(() => {\n  const config = useRuntimeConfig()\n  const http = $fetch.create({ baseURL: config.public.apiBase, retry: 1 })\n  http.interceptors.request.use((ctx) => { \u002F* 统一加 token *\u002F })\n  return { provide: { http } }          \u002F\u002F 组件里：const { $http } = useNuxtApp()\n})\n",[18,10162,10163,10168,10183,10196,10218,10242,10252],{"__ignoreMap":423},[427,10164,10165],{"class":429,"line":430},[427,10166,10167],{"class":433},"\u002F\u002F app\u002Fplugins\u002Fhttp.ts\n",[427,10169,10170,10172,10174,10177,10179,10181],{"class":429,"line":437},[427,10171,711],{"class":710},[427,10173,714],{"class":710},[427,10175,10176],{"class":440}," defineNuxtPlugin",[427,10178,5201],{"class":720},[427,10180,2077],{"class":710},[427,10182,5014],{"class":720},[427,10184,10185,10187,10190,10192,10194],{"class":429,"line":454},[427,10186,4746],{"class":710},[427,10188,10189],{"class":489}," config",[427,10191,1873],{"class":710},[427,10193,6910],{"class":440},[427,10195,1879],{"class":720},[427,10197,10198,10200,10203,10205,10208,10211,10214,10216],{"class":429,"line":461},[427,10199,4746],{"class":710},[427,10201,10202],{"class":489}," http",[427,10204,1873],{"class":710},[427,10206,10207],{"class":720}," $fetch.",[427,10209,10210],{"class":440},"create",[427,10212,10213],{"class":720},"({ baseURL: config.public.apiBase, retry: ",[427,10215,3544],{"class":489},[427,10217,2961],{"class":720},[427,10219,10220,10223,10226,10228,10231,10233,10235,10237,10240],{"class":429,"line":467},[427,10221,10222],{"class":720},"  http.interceptors.request.",[427,10224,10225],{"class":440},"use",[427,10227,7955],{"class":720},[427,10229,10230],{"class":2070},"ctx",[427,10232,2074],{"class":720},[427,10234,2077],{"class":710},[427,10236,3491],{"class":720},[427,10238,10239],{"class":433},"\u002F* 统一加 token *\u002F",[427,10241,2961],{"class":720},[427,10243,10244,10246,10249],{"class":429,"line":481},[427,10245,5760],{"class":710},[427,10247,10248],{"class":720}," { provide: { http } }          ",[427,10250,10251],{"class":433},"\u002F\u002F 组件里：const { $http } = useNuxtApp()\n",[427,10253,10254],{"class":429,"line":486},[427,10255,911],{"class":720},[418,10257,10259],{"className":696,"code":10258,"language":698,"meta":423,"style":423},"\u002F\u002F app\u002Fplugins\u002Ftrack.ts —— 需要在导航上挂钩子的插件\nexport default defineNuxtPlugin((nuxtApp) => {\n  nuxtApp.hook('page:start', (comp) => { \u002F* 页面开始渲染 *\u002F })\n  nuxtApp.hook('page:finish', () => { \u002F* 页面渲染完，做埋点 *\u002F })\n  nuxtApp.hook('app:mounted', () => { \u002F* 仅客户端 *\u002F })\n})\n",[18,10260,10261,10266,10284,10314,10336,10358],{"__ignoreMap":423},[427,10262,10263],{"class":429,"line":430},[427,10264,10265],{"class":433},"\u002F\u002F app\u002Fplugins\u002Ftrack.ts —— 需要在导航上挂钩子的插件\n",[427,10267,10268,10270,10272,10274,10276,10278,10280,10282],{"class":429,"line":437},[427,10269,711],{"class":710},[427,10271,714],{"class":710},[427,10273,10176],{"class":440},[427,10275,7955],{"class":720},[427,10277,2745],{"class":2070},[427,10279,2074],{"class":720},[427,10281,2077],{"class":710},[427,10283,5014],{"class":720},[427,10285,10286,10289,10292,10294,10297,10300,10303,10305,10307,10309,10312],{"class":429,"line":454},[427,10287,10288],{"class":720},"  nuxtApp.",[427,10290,10291],{"class":440},"hook",[427,10293,1845],{"class":720},[427,10295,10296],{"class":444},"'page:start'",[427,10298,10299],{"class":720},", (",[427,10301,10302],{"class":2070},"comp",[427,10304,2074],{"class":720},[427,10306,2077],{"class":710},[427,10308,3491],{"class":720},[427,10310,10311],{"class":433},"\u002F* 页面开始渲染 *\u002F",[427,10313,2961],{"class":720},[427,10315,10316,10318,10320,10322,10325,10327,10329,10331,10334],{"class":429,"line":461},[427,10317,10288],{"class":720},[427,10319,10291],{"class":440},[427,10321,1845],{"class":720},[427,10323,10324],{"class":444},"'page:finish'",[427,10326,3873],{"class":720},[427,10328,2077],{"class":710},[427,10330,3491],{"class":720},[427,10332,10333],{"class":433},"\u002F* 页面渲染完，做埋点 *\u002F",[427,10335,2961],{"class":720},[427,10337,10338,10340,10342,10344,10347,10349,10351,10353,10356],{"class":429,"line":467},[427,10339,10288],{"class":720},[427,10341,10291],{"class":440},[427,10343,1845],{"class":720},[427,10345,10346],{"class":444},"'app:mounted'",[427,10348,3873],{"class":720},[427,10350,2077],{"class":710},[427,10352,3491],{"class":720},[427,10354,10355],{"class":433},"\u002F* 仅客户端 *\u002F",[427,10357,2961],{"class":720},[427,10359,10360],{"class":429,"line":481},[427,10361,911],{"class":720},[10,10363,10364,10365,28,10368,28,10371,28,10374,28,10377,304,10380,3721,10385,210],{},"常用钩子：",[18,10366,10367],{},"app:created",[18,10369,10370],{},"app:mounted",[18,10372,10373],{},"page:start",[18,10375,10376],{},"page:finish",[18,10378,10379],{},"vue:setup",[14,10381,10382,10383],{},"这个钩子里才能用 ",[18,10384,4295],{},[18,10386,10387],{},"meta:watch",[10,10389,10390,10393,10394,1241,10396,10398],{},[14,10391,10392],{},"插件按请求执行","（SSR 下每个请求都跑一遍）。所以：不要在插件外面创建可变单例；注册全局样式\u002F第三方 SDK 时注意别重复挂载；重逻辑放 ",[18,10395,1009],{},[18,10397,997],{}," 里让 Nuxt 管缓存。",[10,10400,10401,10402,10405,10406,210],{},"组合式函数与自动导入（",[18,10403,10404],{},"app\u002Fcomposables\u002FuseXxx.ts","）的写法和《Vue 3 教程》第十三章完全一致，Nuxt 只多给你一条便利：",[14,10407,10408],{},"放进目录就不用 import",[168,10410,10411],{"id":10411},"服务端中间件",[418,10413,10415],{"className":696,"code":10414,"language":698,"meta":423,"style":423},"\u002F\u002F server\u002Fmiddleware\u002Ftiming.ts\nexport default defineEventHandler((event) => {\n  const start = Date.now()\n  event.context.requestId = crypto.randomUUID()\n  setHeader(event, 'x-request-id', event.context.requestId)\n  \u002F\u002F 返回值在这里被忽略；要做「响应后」的事用 event.waitUntil 或钩子\n})\n",[18,10416,10417,10422,10440,10457,10472,10484,10489],{"__ignoreMap":423},[427,10418,10419],{"class":429,"line":430},[427,10420,10421],{"class":433},"\u002F\u002F server\u002Fmiddleware\u002Ftiming.ts\n",[427,10423,10424,10426,10428,10430,10432,10434,10436,10438],{"class":429,"line":437},[427,10425,711],{"class":710},[427,10427,714],{"class":710},[427,10429,5198],{"class":440},[427,10431,7955],{"class":720},[427,10433,5686],{"class":2070},[427,10435,2074],{"class":720},[427,10437,2077],{"class":710},[427,10439,5014],{"class":720},[427,10441,10442,10444,10447,10449,10452,10455],{"class":429,"line":454},[427,10443,4746],{"class":710},[427,10445,10446],{"class":489}," start",[427,10448,1873],{"class":710},[427,10450,10451],{"class":720}," Date.",[427,10453,10454],{"class":440},"now",[427,10456,1879],{"class":720},[427,10458,10459,10462,10464,10467,10470],{"class":429,"line":461},[427,10460,10461],{"class":720},"  event.context.requestId ",[427,10463,1550],{"class":710},[427,10465,10466],{"class":720}," crypto.",[427,10468,10469],{"class":440},"randomUUID",[427,10471,1879],{"class":720},[427,10473,10474,10476,10478,10481],{"class":429,"line":467},[427,10475,6115],{"class":440},[427,10477,6002],{"class":720},[427,10479,10480],{"class":444},"'x-request-id'",[427,10482,10483],{"class":720},", event.context.requestId)\n",[427,10485,10486],{"class":429,"line":481},[427,10487,10488],{"class":433},"  \u002F\u002F 返回值在这里被忽略；要做「响应后」的事用 event.waitUntil 或钩子\n",[427,10490,10491],{"class":429,"line":486},[427,10492,911],{"class":720},[168,10494,261],{"id":10495},"️-本章的坑-11",[10,10497,10498,1257,10504,10507,10508,10510,10511,210],{},[14,10499,10500,10501,210],{},"1. 全局中间件命名忘加 ",[18,10502,10503],{},".global",[18,10505,10506],{},"app\u002Fmiddleware\u002Fauth.ts"," 只在被 ",[18,10509,1022],{}," 引用时生效。想全站生效请 ",[18,10512,10513],{},"auth.global.ts",[10,10515,10516,1257,10525,10528,10529,10531],{},[14,10517,10518,10519,10521,10522,10524],{},"2. 中间件里 ",[18,10520,1839],{}," 了但没 ",[18,10523,6437],{}," 跳转。",[18,10526,10527],{},"navigateTo('\u002Flogin')"," 不加 ",[18,10530,6437],{},"，导航会继续（表现为「跳了又跳回来」）。中间件的返回值就是控制流。",[10,10533,10534,10540,10541,10543,10544,10547,10548,210],{},[14,10535,10536,10537,10539],{},"3. 中间件里调 ",[18,10538,994],{}," 报错。"," 中间件不在组件 setup 上下文里，某些组合式（",[18,10542,1009],{}," 部分用法、",[18,10545,10546],{},"useNuxtApp"," 的组件级 API）不可用。要取数据请放页面里，或在中间件用 ",[18,10549,3820],{},[10,10551,10552,10555],{},[14,10553,10554],{},"4. 插件里创建可变单例。"," 「第一个用户设置了主题，第二个用户的主题跟着变」就是它（第十一章第 1 条）。",[10,10557,10558,10561,10562,10564,10565,10568,10569,2746,10572,10575,10576,10579],{},[14,10559,10560],{},"5. 第三方 SDK 在 SSR 阶段被 import 就炸。"," 它一加载就读 ",[18,10563,1200],{},"。解法：动态 ",[18,10566,10567],{},"await import()"," 并只在客户端执行（",[18,10570,10571],{},"if (import.meta.client)",[18,10573,10574],{},"plugins"," 里用 ",[18,10577,10578],{},"apply: 'client'","（写法随版本略有差异，实测为准）。",[10,10581,10582,10585,10586,210],{},[14,10583,10584],{},"6. 客户端与服务端各跑一遍插件，导致重复初始化。"," 日志里看到同一个 SDK 初始化两次——一次是 SSR、一次是水合，属正常；真需要只跑一次就用 ",[18,10587,1006],{},[10,10589,10590,1257,10593,10596,10597,10599,10600,10603,10604,304,10607,10610,10611,10614],{},[14,10591,10592],{},"7. 插件文件名带连字符，注入的 key 变了。",[18,10594,10595],{},"plugins\u002Fmy-http.ts"," 提供 ",[18,10598,9901],{}," 时，key 由你 ",[18,10601,10602],{},"provide"," 的对象决定，跟文件名无关；但",[14,10605,10606],{},"用默认导出名",[18,10608,10609],{},"export default defineNuxtPlugin(...)"," 里返回 ",[18,10612,10613],{},"{ provide: { mySdk } }","）时请显式命名，别指望自动推断。",[10,10616,10617,10623,10624,210],{},[14,10618,10619,10620,10622],{},"8. 服务端中间件里 ",[18,10621,6437],{}," 了响应。"," 中间件的返回被忽略（与 h3 那套一致），想短路请 ",[18,10625,6521],{},[168,10627,10629],{"id":10628},"附生命周期顺序一次页面访问","附：生命周期顺序（一次页面访问）",[59,10631,10632,10645],{},[62,10633,10634],{},[65,10635,10636,10639,10642],{},[68,10637,10638],{},"阶段",[68,10640,10641],{},"服务端",[68,10643,10644],{},"客户端",[75,10646,10647,10656,10668,10687,10706],{},[65,10648,10649,10651,10654],{},[80,10650,3544],{},[80,10652,10653],{},"全局中间件 → 页面中间件",[80,10655,10653],{},[65,10657,10658,10660,10665],{},[80,10659,4853],{},[80,10661,10662,10664],{},[18,10663,10367],{},"（插件按请求执行）",[80,10666,10667],{},"插件（水合阶段不重复创建应用，但 setup 钩子会跑）",[65,10669,10670,10673,10683],{},[80,10671,10672],{},"3",[80,10674,10675,10676,10679,10680,10682],{},"组件 ",[18,10677,10678],{},"setup"," 顶层 await（",[18,10681,994],{},"\u002Fstore.load）",[80,10684,10675,10685],{},[18,10686,10678],{},[65,10688,10689,10692,10699],{},[80,10690,10691],{},"4",[80,10693,10694,10696,10697],{},[18,10695,10373],{}," → 渲染 HTML → ",[18,10698,10376],{},[80,10700,10701,10702,308,10704],{},"水合 → ",[18,10703,10370],{},[18,10705,2274],{},[65,10707,10708,10710,10713],{},[80,10709,2041],{},[80,10711,10712],{},"响应带 payload 发出",[80,10714,10715,10716,10718],{},"后续导航：组件复用，",[18,10717,2274],{}," 不再跑（除非 key 变）",[45,10720,10722],{"id":10721},"十三部署静态产物子路径与-nginx","十三、部署：静态产物、子路径与 Nginx",[10,10724,10725,10726,10733],{},"本站就是这个模式的真实案例：",[14,10727,10728,10729,10732],{},"Nuxt 生成的静态产物交给已有的 Docker Nginx 托管，挂在 ",[18,10730,10731],{},"\u002Fnuxt\u002F"," 子路径下","。下面把整条链路和踩过的坑一次讲完。",[168,10735,10736],{"id":10736},"路线选择",[59,10738,10739,10753],{},[62,10740,10741],{},[65,10742,10743,10746,10748,10750],{},[68,10744,10745],{},"目标",[68,10747,529],{},[68,10749,535],{},[68,10751,10752],{},"服务器要什么",[75,10754,10755,10771,10794,10809],{},[65,10756,10757,10760,10764,10768],{},[80,10758,10759],{},"纯静态站点（内容站、文档、营销页）",[80,10761,10762],{},[18,10763,20],{},[80,10765,10766],{},[18,10767,594],{},[80,10769,10770],{},"Nginx \u002F OSS \u002F 任意静态托管",[65,10772,10773,10779,10783,10791],{},[80,10774,10775,10776,10778],{},"需要按请求渲染或 ",[18,10777,5894],{}," 接口",[80,10780,10781],{},[18,10782,273],{},[80,10784,10785,10787,10788,638],{},[18,10786,573],{},"（含 ",[18,10789,10790],{},"server\u002Findex.mjs",[80,10792,10793],{},"Node 22+ 进程，或 Docker",[65,10795,10796,10799,10803,10806],{},[80,10797,10798],{},"托管平台（Vercel \u002F Netlify \u002F Cloudflare）",[80,10800,10801],{},[18,10802,273],{},[80,10804,10805],{},"平台自适应 preset",[80,10807,10808],{},"平台自带",[65,10810,10811,10814,10819,10822],{},[80,10812,10813],{},"容器化部署",[80,10815,10816,10818],{},[18,10817,273],{}," + 官方镜像",[80,10820,10821],{},"一个镜像",[80,10823,10824],{},"见本章最后",[168,10826,10827],{"id":10827},"静态部署完整链路",[418,10829,10831],{"className":420,"code":10830,"language":422,"meta":423,"style":423},"npm run generate          # = nuxt generate，产物在 .output\u002Fpublic\n",[18,10832,10833],{"__ignoreMap":423},[427,10834,10835,10837,10839,10842],{"class":429,"line":430},[427,10836,470],{"class":440},[427,10838,508],{"class":444},[427,10840,10841],{"class":444}," generate",[427,10843,10844],{"class":433},"          # = nuxt generate，产物在 .output\u002Fpublic\n",[10,10846,10847],{},"产物形状（本站实测）：",[418,10849,10852],{"className":10850,"code":10851,"language":644},[642],".output\u002Fpublic\u002F\n├── index.html\n├── tutorials\u002Fvue3.html           每个路由一个 HTML 文件\n├── tutorials\u002Fvue3\u002F_payload.json  ← 该页的 SSR 数据，水合时复用，不再请求\n├── articles\u002Findex.html\n├── _nuxt\u002F                        打包后的 JS\u002FCSS（带哈希）\n├── img\u002F…  tutorials\u002F…\u002Fdemos\u002F…    来自 public\u002F 的原样文件\n└── 404.html                      预渲染的错误页\n",[18,10853,10851],{"__ignoreMap":423},[10,10855,10856,10861,10862,10865],{},[14,10857,10858,10859,210],{},"子路径部署必须配 ",[18,10860,27],{}," 站点访问地址是 ",[18,10863,10864],{},"https:\u002F\u002Fsite.com\u002Fnuxt\u002F"," 时：",[418,10867,10869],{"className":696,"code":10868,"language":698,"meta":423,"style":423},"export default defineNuxtConfig({\n  app: { baseURL: '\u002Fnuxt\u002F' },      \u002F\u002F 注意：首尾都要有斜杠\n  nitro: { prerender: { crawlLinks: true, routes: ['\u002F'] } }\n})\n",[18,10870,10871,10881,10895,10908],{"__ignoreMap":423},[427,10872,10873,10875,10877,10879],{"class":429,"line":430},[427,10874,711],{"class":710},[427,10876,714],{"class":710},[427,10878,717],{"class":440},[427,10880,721],{"class":720},[427,10882,10883,10886,10889,10892],{"class":429,"line":437},[427,10884,10885],{"class":720},"  app: { baseURL: ",[427,10887,10888],{"class":444},"'\u002Fnuxt\u002F'",[427,10890,10891],{"class":720}," },      ",[427,10893,10894],{"class":433},"\u002F\u002F 注意：首尾都要有斜杠\n",[427,10896,10897,10899,10901,10903,10905],{"class":429,"line":454},[427,10898,7400],{"class":720},[427,10900,743],{"class":489},[427,10902,7405],{"class":720},[427,10904,849],{"class":444},[427,10906,10907],{"class":720},"] } }\n",[427,10909,10910],{"class":429,"line":461},[427,10911,911],{"class":720},[10,10913,10914,10915,28,10918,28,10921,10923,10924,10926,10927,10929,10930,10933,10934,10936],{},"配了这一行，产物里的 ",[18,10916,10917],{},"\u003Cscript src>",[18,10919,10920],{},"\u003Clink href>",[18,10922,408],{}," 请求路径、",[18,10925,1625],{}," 生成的 URL 全部会带上 ",[18,10928,10731],{}," 前缀（Vite 的 ",[18,10931,10932],{},"base"," 由 ",[18,10935,27],{}," 自动推导，不需要重复配）。",[10,10938,10939,10942,10943,10946,10947,10949,10950,10952,10953,10956,10957,10960],{},[14,10940,10941],{},"这就是本站最容易复发的一次事故","：把产物拷到 ",[18,10944,10945],{},"nginx\u002Fhtml\u002Fnuxt\u002F"," 目录，但 ",[18,10948,4398],{}," 仍是默认的 ",[18,10951,1241],{},"，结果 ",[18,10954,10955],{},"index.html"," 能返回、资源却去根路径请求 → 白屏 + 一堆 404，看起来像「Nuxt 有问题」。",[14,10958,10959],{},"判定方法很简单","：查看源码，资源前缀必须和访问前缀一致。示例 08 就是这个检查器。",[212,10962],{"height":2128,"src":10963,"title":10964},"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F08-deploy-check.html","示例 08：baseURL × 部署目录 → 资源路径与 404 判定",[168,10966,10968],{"id":10967},"nginx-配置history-路由-静态产物","Nginx 配置（history 路由 + 静态产物）",[418,10970,10974],{"className":10971,"code":10972,"language":10973,"meta":423,"style":423},"language-nginx shiki shiki-themes github-dark","# 场景：产物在 \u002Fusr\u002Fshare\u002Fnginx\u002Fhtml\u002Fnuxt，站点访问 \u002Fnuxt\u002F\nlocation \u002Fnuxt\u002F {\n  alias \u002Fusr\u002Fshare\u002Fnginx\u002Fhtml\u002Fnuxt\u002F;\n  # 1) 静态文件优先；2) 未知路径回落到本站 index.html；3) 都没有则 404\n  try_files $uri $uri\u002F $uri.html \u002Fnuxt\u002Findex.html;\n}\n\n# 若还需要 Nuxt 的服务端接口（build 模式）\nlocation \u002Fnuxt\u002Fapi\u002F {\n  proxy_pass http:\u002F\u002F127.0.0.1:3000\u002Fapi\u002F;\n  proxy_set_header Host $host;\n  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n  proxy_set_header X-Forwarded-Proto $scheme;\n}\n\n# 缓存策略：带哈希的 JS\u002FCSS 可以长期缓存，HTML 与 payload 不能\nlocation \u002Fnuxt\u002F_nuxt\u002F {\n  expires 1y;\n  add_header Cache-Control \"public, immutable\";\n}\n","nginx",[18,10975,10976,10981,10986,10991,10996,11001,11005,11009,11014,11019,11024,11029,11034,11039,11043,11047,11052,11057,11062,11067],{"__ignoreMap":423},[427,10977,10978],{"class":429,"line":430},[427,10979,10980],{},"# 场景：产物在 \u002Fusr\u002Fshare\u002Fnginx\u002Fhtml\u002Fnuxt，站点访问 \u002Fnuxt\u002F\n",[427,10982,10983],{"class":429,"line":437},[427,10984,10985],{},"location \u002Fnuxt\u002F {\n",[427,10987,10988],{"class":429,"line":454},[427,10989,10990],{},"  alias \u002Fusr\u002Fshare\u002Fnginx\u002Fhtml\u002Fnuxt\u002F;\n",[427,10992,10993],{"class":429,"line":461},[427,10994,10995],{},"  # 1) 静态文件优先；2) 未知路径回落到本站 index.html；3) 都没有则 404\n",[427,10997,10998],{"class":429,"line":467},[427,10999,11000],{},"  try_files $uri $uri\u002F $uri.html \u002Fnuxt\u002Findex.html;\n",[427,11002,11003],{"class":429,"line":481},[427,11004,4801],{},[427,11006,11007],{"class":429,"line":486},[427,11008,458],{"emptyLinePlaceholder":457},[427,11010,11011],{"class":429,"line":495},[427,11012,11013],{},"# 若还需要 Nuxt 的服务端接口（build 模式）\n",[427,11015,11016],{"class":429,"line":503},[427,11017,11018],{},"location \u002Fnuxt\u002Fapi\u002F {\n",[427,11020,11021],{"class":429,"line":791},[427,11022,11023],{},"  proxy_pass http:\u002F\u002F127.0.0.1:3000\u002Fapi\u002F;\n",[427,11025,11026],{"class":429,"line":812},[427,11027,11028],{},"  proxy_set_header Host $host;\n",[427,11030,11031],{"class":429,"line":817},[427,11032,11033],{},"  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n",[427,11035,11036],{"class":429,"line":823},[427,11037,11038],{},"  proxy_set_header X-Forwarded-Proto $scheme;\n",[427,11040,11041],{"class":429,"line":829},[427,11042,4801],{},[427,11044,11045],{"class":429,"line":843},[427,11046,458],{"emptyLinePlaceholder":457},[427,11048,11049],{"class":429,"line":870},[427,11050,11051],{},"# 缓存策略：带哈希的 JS\u002FCSS 可以长期缓存，HTML 与 payload 不能\n",[427,11053,11054],{"class":429,"line":876},[427,11055,11056],{},"location \u002Fnuxt\u002F_nuxt\u002F {\n",[427,11058,11059],{"class":429,"line":882},[427,11060,11061],{},"  expires 1y;\n",[427,11063,11064],{"class":429,"line":887},[427,11065,11066],{},"  add_header Cache-Control \"public, immutable\";\n",[427,11068,11069],{"class":429,"line":908},[427,11070,4801],{},[10,11072,11073],{},"三条要点：",[229,11075,11076,11088,11096],{},[186,11077,11078,304,11084,11087],{},[14,11079,11080,11083],{},[18,11081,11082],{},"try_files"," 的回落地址必须带子路径",[18,11085,11086],{},"\u002Fnuxt\u002Findex.html","），否则刷新子页面时被回落到站点根的那个应用。",[186,11089,11090,11095],{},[14,11091,11092,11094],{},[18,11093,408],{}," 与 HTML 不能长期缓存","，否则改了内容用户还是旧数据（payload 与页面绑定，版本不一致会导致数据错位）。",[186,11097,11098,11106,11107,651,11109,11112,11113,11116,11117,11120],{},[14,11099,11100,274,11103,11105],{},[18,11101,11102],{},"alias",[18,11104,11082],{}," 的组合有历史坑","：某些 Nginx 版本下 ",[18,11108,11102],{},[18,11110,11111],{},"try_files $uri\u002F"," 会 404。若怎么配都不对，改用 ",[18,11114,11115],{},"root"," 方案（把产物放到 ",[18,11118,11119],{},"\u003Croot>\u002Fnuxt\u002F","）最稳。",[168,11122,11124,11125,638],{"id":11123},"服务端模式部署nuxt-build","服务端模式部署（",[18,11126,273],{},[418,11128,11130],{"className":420,"code":11129,"language":422,"meta":423,"style":423},"node .output\u002Fserver\u002Findex.mjs        # 默认 3000\nPORT=8080 NITRO_HOST=0.0.0.0 node .output\u002Fserver\u002Findex.mjs\n",[18,11131,11132,11143],{"__ignoreMap":423},[427,11133,11134,11137,11140],{"class":429,"line":430},[427,11135,11136],{"class":440},"node",[427,11138,11139],{"class":444}," .output\u002Fserver\u002Findex.mjs",[427,11141,11142],{"class":433},"        # 默认 3000\n",[427,11144,11145,11148,11150,11153,11156,11158,11161,11164],{"class":429,"line":437},[427,11146,11147],{"class":720},"PORT",[427,11149,1550],{"class":710},[427,11151,11152],{"class":444},"8080",[427,11154,11155],{"class":720}," NITRO_HOST",[427,11157,1550],{"class":710},[427,11159,11160],{"class":444},"0.0.0.0",[427,11162,11163],{"class":440}," node",[427,11165,11166],{"class":444}," .output\u002Fserver\u002Findex.mjs\n",[418,11168,11172],{"className":11169,"code":11170,"language":11171,"meta":423,"style":423},"language-dockerfile shiki shiki-themes github-dark","# 单阶段版（简单）；生产建议用多阶段 + nitro 的 docker preset\nFROM node:22-alpine\nWORKDIR \u002Fapp\nCOPY .output .\u002F\nENV NITRO_HOST=0.0.0.0 PORT=3000\nEXPOSE 3000\nCMD [\"node\", \".\u002Fserver\u002Findex.mjs\"]\n","dockerfile",[18,11173,11174,11179,11184,11189,11194,11199,11204],{"__ignoreMap":423},[427,11175,11176],{"class":429,"line":430},[427,11177,11178],{},"# 单阶段版（简单）；生产建议用多阶段 + nitro 的 docker preset\n",[427,11180,11181],{"class":429,"line":437},[427,11182,11183],{},"FROM node:22-alpine\n",[427,11185,11186],{"class":429,"line":454},[427,11187,11188],{},"WORKDIR \u002Fapp\n",[427,11190,11191],{"class":429,"line":461},[427,11192,11193],{},"COPY .output .\u002F\n",[427,11195,11196],{"class":429,"line":467},[427,11197,11198],{},"ENV NITRO_HOST=0.0.0.0 PORT=3000\n",[427,11200,11201],{"class":429,"line":481},[427,11202,11203],{},"EXPOSE 3000\n",[427,11205,11206],{"class":429,"line":486},[427,11207,11208],{},"CMD [\"node\", \".\u002Fserver\u002Findex.mjs\"]\n",[10,11210,11211,11213,11214,28,11217,11220,11221,11223],{},[18,11212,273],{}," 模式下别忘了：",[18,11215,11216],{},"NITRO_...",[18,11218,11219],{},"NUXT_..."," 环境变量在容器里注入（第八章），别把 ",[18,11222,6727],{}," 拷进镜像。",[168,11225,11226],{"id":11226},"部署前逐项核对",[59,11228,11229,11239],{},[62,11230,11231],{},[65,11232,11233,11236],{},[68,11234,11235],{},"检查",[68,11237,11238],{},"命令\u002F方法",[75,11240,11241,11255,11263,11271,11281,11291,11303],{},[65,11242,11243,11246],{},[80,11244,11245],{},"资源前缀正确",[80,11247,11248,11249,11251,11252,11254],{},"查看产物 ",[18,11250,10955],{}," 源码，",[18,11253,10917],{}," 前缀 = 访问前缀",[65,11256,11257,11260],{},[80,11258,11259],{},"全部路由都生成了",[80,11261,11262],{},"`find .output\u002Fpublic -name '*.html'",[65,11264,11265,11268],{},[80,11266,11267],{},"无未预渲染的 404",[80,11269,11270],{},"逐个访问列表页里的链接；sitemap 条数一致",[65,11272,11273,11276],{},[80,11274,11275],{},"密钥没进产物",[80,11277,11278],{},[18,11279,11280],{},"grep -r \"sk-|password\" .output\u002Fpublic",[65,11282,11283,11286],{},[80,11284,11285],{},"sitemap \u002F robots 域名正确",[80,11287,11288],{},[18,11289,11290],{},"curl https:\u002F\u002Fsite.com\u002Fnuxt\u002Fsitemap.xml",[65,11292,11293,11296],{},[80,11294,11295],{},"history 刷新正常",[80,11297,11298,11299,11302],{},"直接访问 ",[18,11300,11301],{},"\u002Fnuxt\u002Ftutorials\u002Fvue3"," 回车",[65,11304,11305,11308],{},[80,11306,11307],{},"meta 是真实内容",[80,11309,11310],{},"`curl -s URL",[168,11312,261],{"id":11313},"️-本章的坑-12",[10,11315,11316,11321],{},[14,11317,4276,11318,11320],{},[18,11319,4398],{}," 与访问路径不一致","（本站真实事故，上面整段就是为它写的）。",[10,11323,11324,1257,11327,11330,11331,11334,11335,1056,11338,210],{},[14,11325,11326],{},"2. 子路径少写一个斜杠。",[18,11328,11329],{},"'\u002Fnuxt'"," 会拼成 ",[18,11332,11333],{},"\u002Fnuxtindex.js"," 这种东西。",[14,11336,11337],{},"首尾都要斜杠",[18,11339,10888],{},[10,11341,11342,11347,11348,11351,11352,11355],{},[14,11343,2204,11344,11346],{},[18,11345,7818],{}," 后仍然有服务端接口需求。"," 静态产物里的 ",[18,11349,11350],{},"\u002Fapi\u002F*"," 不存在。需要接口就换 ",[18,11353,11354],{},"build"," 模式或把接口挪到别的服务 + Nginx 反代。",[10,11357,11358,11361,11362,11365,11366,11369],{},[14,11359,11360],{},"4. HTML 被 CDN 缓存。"," 改了内容线上不变，还「有时对有时错」。给 HTML 明确 ",[18,11363,11364],{},"Cache-Control: no-cache","，带哈希的 ",[18,11367,11368],{},"_nuxt\u002F*"," 才用长期缓存。",[10,11371,11372,11377,11378,1918,11380,11383,11384,1056,11387,11390],{},[14,11373,5473,11374,11376],{},[18,11375,3281],{}," 没接上。"," Nginx 需要 ",[18,11379,3285],{},[18,11381,11382],{},"internal","），否则未知路径回落 index 时返回 200（软 404，对 SEO 有害）。",[14,11385,11386],{},"软 404 检查方法",[18,11388,11389],{},"curl -I https:\u002F\u002Fsite\u002Fnuxt\u002F不存在","，状态码必须是 404。",[10,11392,11393,1257,11396,11398],{},[14,11394,11395],{},"6. 环境变量在构建期固化。",[18,11397,6987],{}," 值改不了「一份产物跑多环境」，只能重新 generate（第八章第 6 条）。",[10,11400,11401,11407,11408],{},[14,11402,11403,11404,11406],{},"7. 本地 ",[18,11405,605],{}," 正常，线上不同步。"," preview 的 base 处理与真 Nginx 不完全一样。",[14,11409,11410],{},"最终以「产物直接放进目标目录」为准做一次真机验证。",[10,11412,11413,11416],{},[14,11414,11415],{},"8. Windows 上构建的大小写问题。"," 本地能 build 过、Linux 容器里失败，八成是 import 路径大小写（《Vue 3 教程》第十八章第 1 条同款）。",[168,11418,11420],{"id":11419},"附常用部署配置速查","附：常用部署配置速查",[59,11422,11423,11431],{},[62,11424,11425],{},[65,11426,11427,11429],{},[68,11428,7560],{},[68,11430,3305],{},[75,11432,11433,11443,11456,11466,11476,11486,11496,11506],{},[65,11434,11435,11440],{},[80,11436,11437],{},[18,11438,11439],{},"app: { baseURL: '\u002Fnuxt\u002F' }",[80,11441,11442],{},"站点访问前缀（资源 + 路由 + payload 全靠它）",[65,11444,11445,11450],{},[80,11446,11447],{},[18,11448,11449],{},"app: { cdnURL: 'https:\u002F\u002Fcdn.x.com' }",[80,11451,11452,11453,11455],{},"把 ",[18,11454,11368],{}," 指到 CDN",[65,11457,11458,11463],{},[80,11459,11460],{},[18,11461,11462],{},"nitro: { prerender: { crawlLinks, routes, concurrency } }",[80,11464,11465],{},"静态生成的范围与并发",[65,11467,11468,11473],{},[80,11469,11470],{},[18,11471,11472],{},"nitro: { static: true }",[80,11474,11475],{},"明确纯静态（不生成 server 逻辑）",[65,11477,11478,11483],{},[80,11479,11480],{},[18,11481,11482],{},"nitro: { output: { dir } }",[80,11484,11485],{},"改产物目录（方便 CI 直接拷）",[65,11487,11488,11493],{},[80,11489,11490],{},[18,11491,11492],{},"nitro: { preset: 'static' | 'node_server' | 'docker' }",[80,11494,11495],{},"目标平台",[65,11497,11498,11503],{},[80,11499,11500],{},[18,11501,11502],{},"nitro: { storage: { cache: { driver: 'redis', … } } }",[80,11504,11505],{},"多实例共享缓存（swr 需要）",[65,11507,11508,11513],{},[80,11509,11510],{},[18,11511,11512],{},"routeRules: { '\u002Fx': { prerender: true } }",[80,11514,11515],{},"按路径覆盖生成策略",[45,11517,11519],{"id":11518},"十四性能与产物体积先测再优化","十四、性能与产物体积：先测，再优化",[10,11521,11522],{},"这一章的写法是「先给测量方法，再给手段」——不测量就优化，等于把时间花在猜测上。",[168,11524,11525],{"id":11525},"先学会看三个数",[418,11527,11529],{"className":420,"code":11528,"language":422,"meta":423,"style":423},"# 1) 产物体积构成\nnpx nuxtanalyze            # 生成一份可视化报告（旧名 nuxi analyze）\nnpx vite-bundle-visualizer # 或者直接看 Vite 产物\n\n# 2) 首屏 HTML 里有没有正文（判断 SSR\u002F预渲染是否真的生效）\ncurl -s https:\u002F\u002Fsite\u002Fnuxt\u002Ftutorials\u002Fvue3 | head -c 2000\n\n# 3) payload 有多大（SSR 数据序列化的代价）\nls -la .output\u002Fpublic\u002Ftutorials\u002Fvue3\u002F_payload.json\n",[18,11530,11531,11536,11546,11556,11560,11565,11588,11592,11597],{"__ignoreMap":423},[427,11532,11533],{"class":429,"line":430},[427,11534,11535],{"class":433},"# 1) 产物体积构成\n",[427,11537,11538,11540,11543],{"class":429,"line":437},[427,11539,441],{"class":440},[427,11541,11542],{"class":444}," nuxtanalyze",[427,11544,11545],{"class":433},"            # 生成一份可视化报告（旧名 nuxi analyze）\n",[427,11547,11548,11550,11553],{"class":429,"line":454},[427,11549,441],{"class":440},[427,11551,11552],{"class":444}," vite-bundle-visualizer",[427,11554,11555],{"class":433}," # 或者直接看 Vite 产物\n",[427,11557,11558],{"class":429,"line":461},[427,11559,458],{"emptyLinePlaceholder":457},[427,11561,11562],{"class":429,"line":467},[427,11563,11564],{"class":433},"# 2) 首屏 HTML 里有没有正文（判断 SSR\u002F预渲染是否真的生效）\n",[427,11566,11567,11570,11573,11576,11579,11582,11585],{"class":429,"line":481},[427,11568,11569],{"class":440},"curl",[427,11571,11572],{"class":489}," -s",[427,11574,11575],{"class":444}," https:\u002F\u002Fsite\u002Fnuxt\u002Ftutorials\u002Fvue3",[427,11577,11578],{"class":710}," |",[427,11580,11581],{"class":440}," head",[427,11583,11584],{"class":489}," -c",[427,11586,11587],{"class":489}," 2000\n",[427,11589,11590],{"class":429,"line":486},[427,11591,458],{"emptyLinePlaceholder":457},[427,11593,11594],{"class":429,"line":495},[427,11595,11596],{"class":433},"# 3) payload 有多大（SSR 数据序列化的代价）\n",[427,11598,11599,11602,11605],{"class":429,"line":503},[427,11600,11601],{"class":440},"ls",[427,11603,11604],{"class":489}," -la",[427,11606,11607],{"class":444}," .output\u002Fpublic\u002Ftutorials\u002Fvue3\u002F_payload.json\n",[10,11609,11610,11611,11614,11615,11618,11619,11622],{},"Chrome DevTools 里看三个指标就够：",[14,11612,11613],{},"FCP","（首屏有内容）、",[14,11616,11617],{},"TTI","（能交互，水合完成的时刻）、",[14,11620,11621],{},"Total JS","（下载量）。SSR 优化的是 FCP，代码分割优化的是 TTI 和下载量。",[168,11624,11626],{"id":11625},"有效的手段按性价比排","有效的手段（按性价比排）",[59,11628,11629,11641],{},[62,11630,11631],{},[65,11632,11633,11635,11638],{},[68,11634,9860],{},[68,11636,11637],{},"做法",[68,11639,11640],{},"收益",[75,11642,11643,11661,11678,11693,11714,11728,11739,11758,11772,11789],{},[65,11644,11645,11648,11658],{},[80,11646,11647],{},"路由级懒加载",[80,11649,11650,11651,11653,11654,11657],{},"Nuxt 默认对 ",[18,11652,307],{}," 组件做了；确认 ",[18,11655,11656],{},"import()"," 没被手写静态引入破坏",[80,11659,11660],{},"首屏 JS 大幅下降",[65,11662,11663,11666,11675],{},[80,11664,11665],{},"别用 barrel 文件",[80,11667,11668,1277,11671,11674],{},[18,11669,11670],{},"app\u002Fcomponents\u002Findex.ts",[18,11672,11673],{},"export * from '.\u002FA'"," 会把整个目录拖进依赖图",[80,11676,11677],{},"保住 tree-shaking",[65,11679,11680,11683,11690],{},[80,11681,11682],{},"只取需要的字段",[80,11684,11685,1085,11688],{},[18,11686,11687],{},"useFetch(url, { pick: ['id','title'] })",[18,11689,4344],{},[80,11691,11692],{},"payload 与网络双降",[65,11694,11695,11698,11711],{},[80,11696,11697],{},"图片交给构建",[80,11699,11700,11701,304,11704,11707,11708,11710],{},"用 ",[18,11702,11703],{},"@nuxt\u002Fimage",[18,11705,11706],{},"\u003CNuxtImg>","）或 ",[18,11709,671],{}," + 导入",[80,11712,11713],{},"自动格式\u002F尺寸\u002F懒加载",[65,11715,11716,11719,11725],{},[80,11717,11718],{},"重组件异步化",[80,11720,11721,11724],{},[18,11722,11723],{},"defineAsyncComponent(() => import('…'))","（图表、编辑器、地图）",[80,11726,11727],{},"首屏不含重组件",[65,11729,11730,11733,11736],{},[80,11731,11732],{},"列表裁剪",[80,11734,11735],{},"长列表分页\u002F虚拟滚动（《Vue 3 教程》第十一章）",[80,11737,11738],{},"渲染耗时",[65,11740,11741,11744,11755],{},[80,11742,11743],{},"静态内容别 SSR 两遍",[80,11745,11746,11747,1085,11750,11752,11753],{},"纯静态子树用 ",[18,11748,11749],{},"v-memo",[18,11751,3396],{}," 慎用；优先 ",[18,11754,8057],{},[80,11756,11757],{},"CPU + 水合成本",[65,11759,11760,11763,11769],{},[80,11761,11762],{},"依赖预打包检查",[80,11764,11765,11768],{},[18,11766,11767],{},"vite: { optimizeDeps: { include: [...] } }"," 只对 dev 生效",[80,11770,11771],{},"开发体验",[65,11773,11774,11777,11786],{},[80,11775,11776],{},"第三方脚本延后",[80,11778,11779,11781,11782,11785],{},[18,11780,1000],{}," 里给 ",[18,11783,11784],{},"defer","，或改成用户触发后再注入",[80,11787,11788],{},"TBT（阻塞时长）",[65,11790,11791,11794,11803],{},[80,11792,11793],{},"关 devtools\u002F分析包",[80,11795,11796,11799,11800],{},[18,11797,11798],{},"devtools: { enabled: false }"," 生产无影响但别忘 ",[18,11801,11802],{},"sourcemap: false",[80,11804,11805],{},"体积与泄露",[168,11807,11808],{"id":11808},"水合相关的两个具体做法",[10,11810,11811,11814,11815,11817,11818,11820],{},[14,11812,11813],{},"首屏之外的东西别水合。"," 大列表的下半部分、页脚的动态内容可以用 ",[18,11816,1006],{}," \u002F 纯静态渲染减少水合成本；",[18,11819,3396],{}," 是逃生舱不是常规手段（它会让 SSR HTML 变空）。",[10,11822,11823,1056],{},[14,11824,11825],{},"hydration mismatch 的三类源头",[418,11827,11829],{"className":1515,"code":11828,"language":1517,"meta":423,"style":423},"\u003C!-- 1) 服务端与客户端算出不同结果 -->\n\u003Cp>{{ Date.now() }}\u003C\u002Fp>                      \u003C!-- ✗ 每次都不同 -->\n\u003Cp>{{ Math.random() }}\u003C\u002Fp>                    \u003C!-- ✗ -->\n\u003Cp>{{ localStorage.getItem('theme') }}\u003C\u002Fp>    \u003C!-- ✗ 服务端没有 -->\n\n\u003C!-- 2) 服务端 HTML 结构被浏览器改了 -->\n\u003Ctbody>\u003Ctr>…\u003C\u002Ftr>\u003C\u002Ftbody>   \u003C!-- 浏览器会把非法嵌套修一遍，水合就对不上 -->\n\n\u003C!-- 3) 内联脚本提前改了 DOM（本站的主题脚本是刻意这么做的，它只写 \u003Chtml> 属性，不动正文） -->\n",[18,11830,11831,11836,11853,11870,11887,11891,11896,11913,11917],{"__ignoreMap":423},[427,11832,11833],{"class":429,"line":430},[427,11834,11835],{"class":433},"\u003C!-- 1) 服务端与客户端算出不同结果 -->\n",[427,11837,11838,11840,11842,11845,11847,11850],{"class":429,"line":437},[427,11839,1529],{"class":720},[427,11841,10],{"class":1532},[427,11843,11844],{"class":720},">{{ Date.now() }}\u003C\u002F",[427,11846,10],{"class":1532},[427,11848,11849],{"class":720},">                      ",[427,11851,11852],{"class":433},"\u003C!-- ✗ 每次都不同 -->\n",[427,11854,11855,11857,11859,11862,11864,11867],{"class":429,"line":454},[427,11856,1529],{"class":720},[427,11858,10],{"class":1532},[427,11860,11861],{"class":720},">{{ Math.random() }}\u003C\u002F",[427,11863,10],{"class":1532},[427,11865,11866],{"class":720},">                    ",[427,11868,11869],{"class":433},"\u003C!-- ✗ -->\n",[427,11871,11872,11874,11876,11879,11881,11884],{"class":429,"line":461},[427,11873,1529],{"class":720},[427,11875,10],{"class":1532},[427,11877,11878],{"class":720},">{{ localStorage.getItem('theme') }}\u003C\u002F",[427,11880,10],{"class":1532},[427,11882,11883],{"class":720},">    ",[427,11885,11886],{"class":433},"\u003C!-- ✗ 服务端没有 -->\n",[427,11888,11889],{"class":429,"line":467},[427,11890,458],{"emptyLinePlaceholder":457},[427,11892,11893],{"class":429,"line":481},[427,11894,11895],{"class":433},"\u003C!-- 2) 服务端 HTML 结构被浏览器改了 -->\n",[427,11897,11898,11900,11902,11905,11907,11910],{"class":429,"line":486},[427,11899,1529],{"class":720},[427,11901,75],{"class":1532},[427,11903,11904],{"class":720},">\u003Ctr>…\u003C\u002Ftr>\u003C\u002F",[427,11906,75],{"class":1532},[427,11908,11909],{"class":720},">   ",[427,11911,11912],{"class":433},"\u003C!-- 浏览器会把非法嵌套修一遍，水合就对不上 -->\n",[427,11914,11915],{"class":429,"line":495},[427,11916,458],{"emptyLinePlaceholder":457},[427,11918,11919],{"class":429,"line":503},[427,11920,11921],{"class":433},"\u003C!-- 3) 内联脚本提前改了 DOM（本站的主题脚本是刻意这么做的，它只写 \u003Chtml> 属性，不动正文） -->\n",[10,11923,11924,11925,11927,11928,11930],{},"修不了就用 ",[18,11926,3396],{}," 包住那一小块，或用 ",[18,11929,2274],{}," 之后再赋值（并接受那块没有 SSR 正文）。",[168,11932,11934],{"id":11933},"测量清单本站用过的","测量清单（本站用过的）",[418,11936,11938],{"className":420,"code":11937,"language":422,"meta":423,"style":423},"# 首屏 HTML 是否含正文\ncurl -s URL | grep -c 'article__body'\n\n# 有多少个 JS 文件、总大小\nfind .output\u002Fpublic\u002F_nuxt -name '*.js' | xargs du -ch | tail -1\n\n# 预渲染页数量是否等于路由数\nfind .output\u002Fpublic -name '*.html' | wc -l\n\n# 单个页面 payload 体积\ndu -h .output\u002Fpublic\u002Ftutorials\u002Fvue3\u002F_payload.json\n",[18,11939,11940,11945,11964,11968,11973,12006,12010,12015,12035,12039,12044],{"__ignoreMap":423},[427,11941,11942],{"class":429,"line":430},[427,11943,11944],{"class":433},"# 首屏 HTML 是否含正文\n",[427,11946,11947,11949,11951,11954,11956,11959,11961],{"class":429,"line":437},[427,11948,11569],{"class":440},[427,11950,11572],{"class":489},[427,11952,11953],{"class":444}," URL",[427,11955,11578],{"class":710},[427,11957,11958],{"class":440}," grep",[427,11960,11584],{"class":489},[427,11962,11963],{"class":444}," 'article__body'\n",[427,11965,11966],{"class":429,"line":454},[427,11967,458],{"emptyLinePlaceholder":457},[427,11969,11970],{"class":429,"line":461},[427,11971,11972],{"class":433},"# 有多少个 JS 文件、总大小\n",[427,11974,11975,11978,11981,11984,11987,11989,11992,11995,11998,12000,12003],{"class":429,"line":467},[427,11976,11977],{"class":440},"find",[427,11979,11980],{"class":444}," .output\u002Fpublic\u002F_nuxt",[427,11982,11983],{"class":489}," -name",[427,11985,11986],{"class":444}," '*.js'",[427,11988,11578],{"class":710},[427,11990,11991],{"class":440}," xargs",[427,11993,11994],{"class":444}," du",[427,11996,11997],{"class":489}," -ch",[427,11999,11578],{"class":710},[427,12001,12002],{"class":440}," tail",[427,12004,12005],{"class":489}," -1\n",[427,12007,12008],{"class":429,"line":481},[427,12009,458],{"emptyLinePlaceholder":457},[427,12011,12012],{"class":429,"line":486},[427,12013,12014],{"class":433},"# 预渲染页数量是否等于路由数\n",[427,12016,12017,12019,12022,12024,12027,12029,12032],{"class":429,"line":495},[427,12018,11977],{"class":440},[427,12020,12021],{"class":444}," .output\u002Fpublic",[427,12023,11983],{"class":489},[427,12025,12026],{"class":444}," '*.html'",[427,12028,11578],{"class":710},[427,12030,12031],{"class":440}," wc",[427,12033,12034],{"class":489}," -l\n",[427,12036,12037],{"class":429,"line":503},[427,12038,458],{"emptyLinePlaceholder":457},[427,12040,12041],{"class":429,"line":791},[427,12042,12043],{"class":433},"# 单个页面 payload 体积\n",[427,12045,12046,12049,12052],{"class":429,"line":812},[427,12047,12048],{"class":440},"du",[427,12050,12051],{"class":489}," -h",[427,12053,11607],{"class":444},[212,12055],{"height":1149,"src":4259,"title":12056},"示例 05（回看）：数据取几次、payload 里装了什么",[168,12058,261],{"id":12059},"️-本章的坑-13",[10,12061,12062,12067],{},[14,12063,12064,12065,210],{},"1. 为了「优化」把正文塞进 ",[18,12066,3396],{}," 首屏 HTML 空了，SEO 与 FCP 双输。SSR 是内容站的基本盘，客户端只留「真的依赖浏览器」的部分。",[10,12069,12070,12075],{},[14,12071,1186,12072,12074],{},[18,12073,4341],{}," 忘用，payload 几 MB。"," 移动端下载 payload 的时间可能比接口本身还长（第五章第 4 条）。",[10,12077,12078,12085,12086,12089,12090],{},[14,12079,12080,12081,12084],{},"3. barrel ",[18,12082,12083],{},"index.ts"," 让 tree-shaking 失效。"," 一个 ",[18,12087,12088],{},"export * from '.\u002Fall-charts'"," 能把 400KB 拖进首屏。",[14,12091,12092],{},"目录里请直接 import 具体文件。",[10,12094,12095,12102,12103,12106,12107,210],{},[14,12096,12097,12098,12101],{},"4. 手写 ",[18,12099,12100],{},"import Chart from 'echarts'"," 破坏了懒加载。"," 重组件请 ",[18,12104,12105],{},"defineAsyncComponent","，或在事件回调里 ",[18,12108,10567],{},[10,12110,12111,12114,12115,12117],{},[14,12112,12113],{},"5. 生产开了 sourcemap。"," 体积翻倍且把源码结构泄露出去。",[18,12116,11802],{},"（或用平台托管的隐藏 sourcemap）。",[10,12119,12120,12126,12127,210],{},[14,12121,12122,12123,12125],{},"6. 图片放在 ",[18,12124,671],{}," 里指望自动压缩。"," Nuxt 默认只做哈希与内联阈值，不改尺寸不转格式。响应式图片要 ",[18,12128,11703],{},[10,12130,12131,12134,12135,12138,12139,274,12141,210],{},[14,12132,12133],{},"7. 字体导致布局抖动。"," 没写 ",[18,12136,12137],{},"font-display: swap"," \u002F 没有 fallback 尺寸，文字会「跳一下」（CLS 变差）。字体建议 ",[18,12140,317],{},[18,12142,12143],{},"\u003Clink rel=\"preload\">",[10,12145,12146,12149,12150,12153],{},[14,12147,12148],{},"8. 只优化了自己的代码，没管第三方。"," 一个统计脚本就能吃掉 200KB。上线前用 ",[18,12151,12152],{},"nuxtanalyze"," 看第一屏柱子——通常最大那根不是你的代码。",[10,12155,12156,12162,12163,1241,12165,274,12167,12170],{},[14,12157,12158,12159,12161],{},"9. 拿 ",[18,12160,547],{}," 的速度判断生产。"," dev 有未压缩与中间件开销，冷热差异极大。任何性能结论以 ",[18,12164,273],{},[18,12166,7818],{},[18,12168,12169],{},"preview","（或真环境）为准。",[168,12172,12174],{"id":12173},"附性能问题定位顺序","附：性能问题定位顺序",[59,12176,12177,12190],{},[62,12178,12179],{},[65,12180,12181,12184,12187],{},[68,12182,12183],{},"症状",[68,12185,12186],{},"先看什么",[68,12188,12189],{},"常见根因",[75,12191,12192,12207,12218,12229,12243,12257],{},[65,12193,12194,12197,12200],{},[80,12195,12196],{},"白屏时间长",[80,12198,12199],{},"FCP \u002F HTML 是否含正文",[80,12201,12202,12203,12206],{},"没 SSR（",[18,12204,12205],{},"ssr:false","）、SSR 阶段接口慢",[65,12208,12209,12212,12215],{},[80,12210,12211],{},"有内容但点不动",[80,12213,12214],{},"TTI \u002F 水合耗时",[80,12216,12217],{},"JS 体积大、组件树深、水合数据大",[65,12219,12220,12223,12226],{},[80,12221,12222],{},"图片\u002F字体闪烁",[80,12224,12225],{},"资源是否内联、preload",[80,12227,12228],{},"关键 CSS\u002F字体未预加载",[65,12230,12231,12234,12237],{},[80,12232,12233],{},"每次导航都慢",[80,12235,12236],{},"payload 请求耗时",[80,12238,12239,12240,12242],{},"页面数据太大、",[18,12241,3943],{}," 没配",[65,12244,12245,12248,12251],{},[80,12246,12247],{},"列表长页面卡顿",[80,12249,12250],{},"渲染节点数",[80,12252,12253,12254,12256],{},"没裁剪、",[18,12255,3781],{}," 不稳定、无虚拟滚动",[65,12258,12259,12262,12268],{},[80,12260,12261],{},"构建变慢",[80,12263,12264,12267],{},[18,12265,12266],{},".nuxt"," 缓存、依赖数",[80,12269,12270],{},"barrel 文件、过多模块",[45,12272,12274],{"id":12273},"十五收尾坑汇总对照表与下一步","十五、收尾：坑汇总、对照表与下一步",[168,12276,12278],{"id":12277},"从-vue-到-nuxt只多了这几层","从 Vue 到 Nuxt：只多了这几层",[59,12280,12281,12294],{},[62,12282,12283],{},[65,12284,12285,12288,12291],{},[68,12286,12287],{},"Vue 3 里你写的",[68,12289,12290],{},"Nuxt 里的等价物",[68,12292,12293],{},"多出来的注意点",[75,12295,12296,12315,12337,12352,12369,12384,12403,12416,12435,12451,12468],{},[65,12297,12298,12303,12309],{},[80,12299,12300],{},[18,12301,12302],{},"createApp(App).use(router).mount('#app')",[80,12304,12305,12306,12308],{},"不存在了，",[18,12307,2501],{}," 就是根",[80,12310,12311,12312],{},"不要再手写 ",[18,12313,12314],{},"main.js",[65,12316,12317,12324,12331],{},[80,12318,12319,12321,12322],{},[18,12320,7826],{}," 数组 + ",[18,12323,89],{},[80,12325,12326,12328,12329],{},[18,12327,95],{}," 文件即路由 + ",[18,12330,2478],{},[80,12332,12333,12334,12336],{},"父组件要放 ",[18,12335,2478],{},"（第三章）",[65,12338,12339,12344,12349],{},[80,12340,12341],{},[18,12342,12343],{},"import X from '.\u002FX.vue'",[80,12345,12346,12348],{},[18,12347,114],{}," 自动导入",[80,12350,12351],{},"目录前缀规则（第二章）",[65,12353,12354,12359,12363],{},[80,12355,12356,12358],{},[18,12357,1088],{}," 手动 import",[80,12360,12361,12348],{},[18,12362,129],{},[80,12364,12365,12366],{},"命名必须 ",[18,12367,12368],{},"use*",[65,12370,12371,12376,12379],{},[80,12372,12373],{},[18,12374,12375],{},"useRoute\u002FuseRouter",[80,12377,12378],{},"一样可用",[80,12380,12381,12382],{},"跳转优先 ",[18,12383,1628],{},[65,12385,12386,12389,12397],{},[80,12387,12388],{},"手写 axios 实例",[80,12390,12391,1085,12393,1085,12395],{},[18,12392,994],{},[18,12394,997],{},[18,12396,3820],{},[80,12398,12399,12402],{},[14,12400,12401],{},"取数时机","（第五、六章）",[65,12404,12405,12407,12413],{},[80,12406,9790],{},[80,12408,12409,274,12411],{},[18,12410,9550],{},[18,12412,1009],{},[80,12414,12415],{},"实例按请求创建（第十一章）",[65,12417,12418,12429,12432],{},[80,12419,12420,1241,12423,1241,12426],{},[18,12421,12422],{},"defineProps",[18,12424,12425],{},"defineEmits",[18,12427,12428],{},"defineModel",[80,12430,12431],{},"完全一样",[80,12433,12434],{},"无变化",[65,12436,12437,12443,12448],{},[80,12438,12439,12442],{},[18,12440,12441],{},"vue-tsc"," 类型检查",[80,12444,12445],{},[18,12446,12447],{},"nuxt typecheck",[80,12449,12450],{},"两份 tsconfig（app 与 server）",[65,12452,12453,12458,12462],{},[80,12454,12455,12456],{},"Vite ",[18,12457,10932],{},[80,12459,12460],{},[18,12461,27],{},[80,12463,12464,12467],{},[14,12465,12466],{},"子路径部署的头号坑","（第十三章）",[65,12469,12470,12477,12483],{},[80,12471,12472,7830,12474,12476],{},[18,12473,10955],{},[18,12475,8423],{},"\u002Fmeta",[80,12478,12479,1085,12481],{},[18,12480,8216],{},[18,12482,1000],{},[80,12484,12485],{},"SSR 阶段必须同步调用（第十章）",[168,12487,12488],{"id":12488},"按症状找原因",[59,12490,12491,12503],{},[62,12492,12493],{},[65,12494,12495,12497,12500],{},[68,12496,12183],{},[68,12498,12499],{},"最可能的原因",[68,12501,12502],{},"详见",[75,12504,12505,12522,12535,12549,12560,12574,12587,12602,12615,12631,12643,12657,12668,12685,12698,12714,12726],{},[65,12506,12507,12514,12519],{},[80,12508,12509,12510,12513],{},"首屏白，源码里有 ",[18,12511,12512],{},"\u003Cdiv id=\"app\">"," 但没正文",[80,12515,12516,12518],{},[18,12517,287],{}," 生效 \u002F 数据只在客户端取",[80,12520,12521],{},"九、五",[65,12523,12524,12527,12532],{},[80,12525,12526],{},"本地能跑，部署后资源 404、页面白屏",[80,12528,12529,12531],{},[18,12530,27],{}," 与访问子路径不一致",[80,12533,12534],{},"十三（本站真实事故）",[65,12536,12537,12540,12546],{},[80,12538,12539],{},"刷新子页面 404",[80,12541,12542,12543,12545],{},"Nginx 缺 ",[18,12544,11082],{}," 回落；或该页没被预渲染",[80,12547,12548],{},"十三、九",[65,12550,12551,12554,12557],{},[80,12552,12553],{},"改了内容线上还是旧的",[80,12555,12556],{},"HTML\u002Fpayload 被缓存；或产物没重新拷贝",[80,12558,12559],{},"十三",[65,12561,12562,12566,12571],{},[80,12563,12564],{},[18,12565,1162],{},[80,12567,12568,12569],{},"没跑 ",[18,12570,619],{},[80,12572,12573],{},"二",[65,12575,12576,12579,12584],{},[80,12577,12578],{},"页面空白、控制台没报错",[80,12580,12581,12582],{},"父页面漏 ",[18,12583,2478],{},[80,12585,12586],{},"三",[65,12588,12589,12594,12600],{},[80,12590,10675,12591,12593],{},[18,12592,1213],{}," 找不到",[80,12595,12596,12597,12599],{},"目录前缀（应为 ",[18,12598,1062],{},"）或路径大小写",[80,12601,12573],{},[65,12603,12604,12609,12612],{},[80,12605,12606,12607],{},"服务端报 ",[18,12608,4312],{},[80,12610,12611],{},"SSR 阶段访问了浏览器 API",[80,12613,12614],{},"五、十一",[65,12616,12617,12622,12628],{},[80,12618,12619,12620],{},"数据在服务端取到了但界面 ",[18,12621,1389],{},[80,12623,12624,12625,12627],{},"Nuxt 4 的 ",[18,12626,1371],{}," 缺失 \u002F payload 序列化失败",[80,12629,12630],{},"五",[65,12632,12633,12636,12641],{},[80,12634,12635],{},"同一份数据请求两次",[80,12637,12638,12639],{},"两处 key 不同（URL 参数变了）或用了 ",[18,12640,3946],{},[80,12642,12630],{},[65,12644,12645,12648,12654],{},[80,12646,12647],{},"切了筛选，列表数据不变",[80,12649,12650,12651,12653],{},"同路由只换 query 未触发重新请求（",[18,12652,972],{}," 选项）",[80,12655,12656],{},"三、五",[65,12658,12659,12662,12665],{},[80,12660,12661],{},"两个用户数据串了",[80,12663,12664],{},"模块级可变单例 \u002F 插件里创建共享状态",[80,12666,12667],{},"十一、十二",[65,12669,12670,12673,12682],{},[80,12671,12672],{},"页面「闪一下」或 hydration 警告",[80,12674,12675,1241,12678,12681],{},[18,12676,12677],{},"Date.now()",[18,12679,12680],{},"random","\u002FlocalStorage 进了模板",[80,12683,12684],{},"十四",[65,12686,12687,12692,12695],{},[80,12688,12689,12691],{},[18,12690,2973],{}," 样式全丢",[80,12693,12694],{},"它在布局之外",[80,12696,12697],{},"四",[65,12699,12700,12706,12711],{},[80,12701,12702,12705],{},[18,12703,12704],{},"sitemap.xml"," 里全是 localhost",[80,12707,12708,12710],{},[18,12709,8571],{}," 没改",[80,12712,12713],{},"十",[65,12715,12716,12719,12724],{},[80,12717,12718],{},"卡片分享没图",[80,12720,12721,12723],{},[18,12722,9006],{}," 不是绝对 URL",[80,12725,12713],{},[65,12727,12728,12731,12737],{},[80,12729,12730],{},"构建在 CI 失败、本地成功",[80,12732,12733,12734,12736],{},"大小写敏感、Node 版本、",[18,12735,1303],{}," 差异",[80,12738,12739],{},"二、十三",[168,12741,12742],{"id":12742},"上线核对清单",[59,12744,12745,12757],{},[62,12746,12747],{},[65,12748,12749,12751,12754],{},[68,12750,950],{},[68,12752,12753],{},"检查项",[68,12755,12756],{},"不做的后果",[75,12758,12759,12771,12783,12795,12809,12827,12842,12863,12877,12890,12911,12924,12941],{},[65,12760,12761,12763,12768],{},[80,12762,7560],{},[80,12764,12765,12767],{},[18,12766,27],{}," = 真实访问前缀（含首尾斜杠）",[80,12769,12770],{},"白屏 + 资源 404",[65,12772,12773,12775,12780],{},[80,12774,7560],{},[80,12776,12777,12779],{},[18,12778,8571],{}," 是真实域名",[80,12781,12782],{},"sitemap\u002Fog 指向 localhost",[65,12784,12785,12787,12792],{},[80,12786,7560],{},[80,12788,12789,12791],{},[18,12790,1303],{}," 显式设定",[80,12793,12794],{},"升级后行为漂移",[65,12796,12797,12800,12806],{},[80,12798,12799],{},"渲染",[80,12801,12802,12803,12805],{},"每个路径的模式与预期一致（",[18,12804,38],{}," 顺序检查过）",[80,12807,12808],{},"空壳被预渲染 \u002F SEO 归零",[65,12810,12811,12814,12824],{},[80,12812,12813],{},"数据",[80,12815,12816,12817,12819,12820,651,12822],{},"首屏数据用 ",[18,12818,5375],{},"，且配了 ",[18,12821,1371],{},[18,12823,4341],{},[80,12825,12826],{},"正文缺失、payload 巨大",[65,12828,12829,12831,12839],{},[80,12830,12813],{},[80,12832,12833,12834,12836,12837],{},"需要密钥的请求走 ",[18,12835,4390],{}," 代理，密钥不在 ",[18,12838,7080],{},[80,12840,12841],{},"密钥泄露",[65,12843,12844,12847,12860],{},[80,12845,12846],{},"路由",[80,12848,12849,12850,12852,12853,12855,12856,12859],{},"无用的 ",[18,12851,1466],{}," 不在 ",[18,12854,307],{}," 下；404 由 ",[18,12857,12858],{},"createError"," 抛出",[80,12861,12862],{},"脏路由、软 404",[65,12864,12865,12868,12874],{},[80,12866,12867],{},"SEO",[80,12869,12870,12871,12873],{},"每页 title\u002Fdescription 由 SSR 输出；",[18,12872,11569],{}," 验证过",[80,12875,12876],{},"索引不到、卡片空白",[65,12878,12879,12882,12887],{},[80,12880,12881],{},"生成",[80,12883,12884,12886],{},[18,12885,7822],{}," 覆盖所有不可爬到的页面",[80,12888,12889],{},"generate 后 404",[65,12891,12892,12895,12908],{},[80,12893,12894],{},"服务器",[80,12896,12897,12899,12900,12903,12904,12907],{},[18,12898,11082],{}," 回落地址带子路径；HTML ",[18,12901,12902],{},"no-cache","；",[18,12905,12906],{},"_nuxt"," 长缓存",[80,12909,12910],{},"刷新 404、内容不更新",[65,12912,12913,12915,12921],{},[80,12914,12894],{},[80,12916,12917,12920],{},[18,12918,12919],{},"error_page 404"," 指向预渲染的 404，状态码真的是 404",[80,12922,12923],{},"软 404 有害 SEO",[65,12925,12926,12929,12938],{},[80,12927,12928],{},"安全",[80,12930,12931,12933,12934,12937],{},[18,12932,6727],{}," 未入库；产物 ",[18,12935,12936],{},"grep"," 过密钥；sourcemap 关闭",[80,12939,12940],{},"泄露源码与密钥",[65,12942,12943,12946,12954],{},[80,12944,12945],{},"验证",[80,12947,12948,12950,12951,12953],{},[18,12949,20],{}," + 真目录部署跑过一遍；",[18,12952,12152],{}," 看过体积",[80,12955,12956],{},"把「能构建」当「能上线」",[168,12958,12959],{"id":12959},"下一步",[183,12961,12962,12982,12993,13007,13025],{},[186,12963,12964,1056,12967,12969,12970,12973,12974,12977,12978,12981],{},[14,12965,12966],{},"补服务端能力",[18,12968,4390],{}," 之外还有定时任务（",[18,12971,12972],{},"nitro: { scheduledTasks }","）、WebSocket（",[18,12975,12976],{},"defineWebSocketHandler","）、存储抽象（",[18,12979,12980],{},"useStorage","，本站用它管内容）。关键词：Nitro 文档的 Deployment 与 Storage 两节。",[186,12983,12984,1056,12987,12990,12991,210],{},[14,12985,12986],{},"内容型站点",[18,12988,12989],{},"@nuxt\u002Fcontent"," v3（集合 + SQLite 索引，本站教程栏目就是它驱动的）；配图与响应式图片用 ",[18,12992,11703],{},[186,12994,12995,12998,12999,28,13001,28,13003,13006],{},[14,12996,12997],{},"交互复杂度上来后","：把本教程第五章的 ",[18,13000,4623],{},[18,13002,4572],{},[18,13004,13005],{},"refreshNuxtData"," 玩熟，再评估要不要 TanStack Query。",[186,13008,13009,1056,13012,13015,13016,13018,13019,13021,13022,13024],{},[14,13010,13011],{},"测试与质量",[18,13013,13014],{},"@nuxt\u002Ftest-utils","（e2e + 渲染测试）、",[18,13017,12447],{},"、CI 里跑一次 ",[18,13020,7818],{}," 并 ",[18,13023,7991],{}," 抽查 404 状态码。",[186,13026,13027,13030],{},[14,13028,13029],{},"原理补两页","：Nuxt 文档的「Rendering Modes」与「Best Practices」；水合与 payload 的机制看懂后，第九章和第十四章的所有坑都能自己推出来。",[10,13032,13033,13034,13037],{},"Nuxt 带来的全部复杂度，其实只有一句话：",[14,13035,13036],{},"同一份组件代码，可能在服务器上跑，也可能在浏览器里跑，还可能两边各跑一次。"," 写每一行代码时问自己「这段在服务端跑会怎样」，这门课的所有坑就都成了可预测的事。",[13039,13040,13041],"style",{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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 pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .s6RL2, html code.shiki .s6RL2{--shiki-default:#FDAEB7;--shiki-default-font-style:italic}",{"title":423,"searchDepth":437,"depth":437,"links":13043},[13044,13050,13060,13071,13082,13098,13108,13117,13125,13134,13145,13154,13161,13171,13179],{"id":47,"depth":437,"text":48,"children":13045},[13046,13047,13048,13049],{"id":170,"depth":454,"text":171},{"id":226,"depth":454,"text":227},{"id":260,"depth":454,"text":261},{"id":324,"depth":454,"text":325},{"id":412,"depth":437,"text":413,"children":13051},[13052,13053,13055,13057,13058,13059],{"id":416,"depth":454,"text":416},{"id":634,"depth":454,"text":13054},"目录约定（Nuxt 4：源码进 app\u002F）",{"id":687,"depth":454,"text":13056},"nuxt.config.ts 的最小可用版",{"id":930,"depth":454,"text":931},{"id":1154,"depth":454,"text":261},{"id":1308,"depth":454,"text":1309},{"id":1457,"depth":437,"text":1458,"children":13061},[13062,13063,13065,13067,13069,13070],{"id":1477,"depth":454,"text":1477},{"id":1498,"depth":454,"text":13064},"嵌套页面：父组件里要写 \u003CNuxtPage \u002F>",{"id":1621,"depth":454,"text":13066},"导航：\u003CNuxtLink> 与 navigateTo()",{"id":1933,"depth":454,"text":13068},"页面级配置：definePageMeta",{"id":2144,"depth":454,"text":261},{"id":2317,"depth":454,"text":2318},{"id":2481,"depth":437,"text":2482,"children":13072},[13073,13074,13076,13077,13079,13080,13081],{"id":2495,"depth":454,"text":2495},{"id":2753,"depth":454,"text":13075},"错误页：app\u002Ferror.vue",{"id":2996,"depth":454,"text":2996},{"id":3071,"depth":454,"text":13078},"加载态：\u003CNuxtLoadingIndicator>",{"id":3152,"depth":454,"text":3153},{"id":3174,"depth":454,"text":261},{"id":3289,"depth":454,"text":3290},{"id":3429,"depth":437,"text":13083,"children":13084},"五、数据获取：useFetch 与 useAsyncData",[13085,13087,13089,13090,13092,13094,13096,13097],{"id":3444,"depth":454,"text":13086},"为什么不能用 onMounted + fetch",{"id":3734,"depth":454,"text":13088},"useFetch 与 useAsyncData 的分工",{"id":3847,"depth":454,"text":3848},{"id":3939,"depth":454,"text":13091},"客户端才想要的数据：lazy、server: false、immediate",{"id":4127,"depth":454,"text":13093},"给数据一个 default，少写一层判空",{"id":4204,"depth":454,"text":13095},"一次性副作用：callOnce",{"id":4271,"depth":454,"text":261},{"id":4437,"depth":454,"text":4438},{"id":4678,"depth":437,"text":13099,"children":13100},"六、$fetch：底层请求函数与它的 baseURL",[13101,13102,13103,13105,13106],{"id":5083,"depth":454,"text":5084},{"id":5278,"depth":454,"text":5279},{"id":5346,"depth":454,"text":13104},"useFetch 与 $fetch 的取舍",{"id":5428,"depth":454,"text":261},{"id":5549,"depth":454,"text":13107},"附：$fetch 错误对象形状",{"id":5641,"depth":437,"text":13109,"children":13110},"七、服务端引擎 Nitro：server\u002F 里的文件就是接口",[13111,13112,13113,13114,13115,13116],{"id":5659,"depth":454,"text":5659},{"id":5959,"depth":454,"text":5960},{"id":6251,"depth":454,"text":6251},{"id":6320,"depth":454,"text":6320},{"id":6347,"depth":454,"text":261},{"id":6537,"depth":454,"text":6538},{"id":6715,"depth":437,"text":13118,"children":13119},"八、运行时配置与环境变量：runtimeConfig",[13120,13121,13122,13123,13124],{"id":6730,"depth":454,"text":6731},{"id":6888,"depth":454,"text":6888},{"id":6996,"depth":454,"text":6996},{"id":7072,"depth":454,"text":261},{"id":7231,"depth":454,"text":7232},{"id":7352,"depth":437,"text":13126,"children":13127},"九、渲染模式与 routeRules：同一站点里不同页面用不同策略",[13128,13129,13130,13131,13132],{"id":7368,"depth":454,"text":7368},{"id":7668,"depth":454,"text":7669},{"id":7746,"depth":454,"text":7747},{"id":7774,"depth":454,"text":261},{"id":8021,"depth":454,"text":13133},"附：routeRules 常用字段",{"id":8201,"depth":437,"text":8202,"children":13135},[13136,13138,13140,13141,13142,13143,13144],{"id":8212,"depth":454,"text":13137},"标题与描述：useSeoMeta",{"id":8443,"depth":454,"text":13139},"全站默认值放在 app.vue 或配置里",{"id":8589,"depth":454,"text":8590},{"id":8706,"depth":454,"text":8707},{"id":8850,"depth":454,"text":8851},{"id":8956,"depth":454,"text":261},{"id":9071,"depth":454,"text":9072},{"id":9156,"depth":437,"text":13146,"children":13147},"十一、状态共享：useState 与 Pinia",[13148,13150,13151,13152,13153],{"id":9170,"depth":454,"text":13149},"先试 useState：SSR 安全的极简共享状态",{"id":9262,"depth":454,"text":9263},{"id":9539,"depth":454,"text":9540},{"id":9661,"depth":454,"text":261},{"id":9760,"depth":454,"text":9761},{"id":9847,"depth":437,"text":9848,"children":13155},[13156,13157,13158,13159,13160],{"id":9932,"depth":454,"text":9932},{"id":10156,"depth":454,"text":10157},{"id":10411,"depth":454,"text":10411},{"id":10495,"depth":454,"text":261},{"id":10628,"depth":454,"text":10629},{"id":10721,"depth":437,"text":10722,"children":13162},[13163,13164,13165,13166,13168,13169,13170],{"id":10736,"depth":454,"text":10736},{"id":10827,"depth":454,"text":10827},{"id":10967,"depth":454,"text":10968},{"id":11123,"depth":454,"text":13167},"服务端模式部署（nuxt build）",{"id":11226,"depth":454,"text":11226},{"id":11313,"depth":454,"text":261},{"id":11419,"depth":454,"text":11420},{"id":11518,"depth":437,"text":11519,"children":13172},[13173,13174,13175,13176,13177,13178],{"id":11525,"depth":454,"text":11525},{"id":11625,"depth":454,"text":11626},{"id":11808,"depth":454,"text":11808},{"id":11933,"depth":454,"text":11934},{"id":12059,"depth":454,"text":261},{"id":12173,"depth":454,"text":12174},{"id":12273,"depth":437,"text":12274,"children":13180},[13181,13182,13183,13184],{"id":12277,"depth":454,"text":12278},{"id":12488,"depth":454,"text":12488},{"id":12742,"depth":454,"text":12742},{"id":12959,"depth":454,"text":12959},"nuxt","\u002Fimg\u002Fnuxt-cover.svg","2026-10-16","零基础入门 Nuxt：目录约定、文件路由、useFetch 与水合、Nitro 服务端接口、渲染模式与 routeRules、静态部署与子路径 baseURL，15 章循序渐进，每章配可运行示例，末章汇总常见坑。","md",{},"\u002Ftutorials\u002Fnuxt",{"title":5,"description":13188},{"loc":13191},"tutorials\u002Fnuxt","IEa399YVyvsTqb4iIFTF7_XF8X8OSwBO0Cf0VKhuvJQ",[13197,13200,13204,13208,13212,13216,13220,13223,13227,13231,13235,13239,13243,13247,13251,13255,13259,13263,13267,13271,13275,13279,13283,13287,13291,13295,13299,13303,13307,13311,13315,13319,13323,13327,13331,13335,13339,13343,13347,13351,13355,13359,13363,13367,13371,13375,13379,13383,13387,13391,13395,13399,13403,13407,13411,13415,13419,13423,13427,13431,13435,13439,13443,13447,13451,13455,13459,13463,13467,13471,13475,13479,13483,13487,13491,13495,13499,13503,13507,13511,13515,13519,13523,13527,13531,13535,13539,13543,13547,13551,13555,13558,13562,13566,13570,13574,13578,13582,13585,13589,13593,13597],{"id":13191,"title":5,"titles":13198,"content":13199,"level":430},[],"零基础入门 Nuxt：目录约定、文件路由、useFetch 与水合、Nitro 服务端接口、渲染模式与 routeRules、静态部署与子路径 baseURL，15 章循序渐进，每章配可运行示例，末章汇总常见坑。 这门课的写法参照本站（一个真实的 Nuxt 4 静态站点）：每一章先给「最短能跑的一段代码」，再给对照表，最后列出这一章会踩的坑。学完的判定标准很朴素：你能从零起一个 Nuxt 项目，写出 5 个页面、2 个服务端接口，nuxt generate 出一份能丢进 Nginx 的静态产物。 版本基线：Nuxt 4.5 \u002F Nitro 2.x \u002F Vue 3.5 \u002F Vite 7。文中关键配置（app.baseURL、nitro.prerender、components 前缀、routeRules 等）都对照本项目的 nuxt.config.ts 与产物核过；需要区分旧行为时会注明「Nuxt 3 时代」。前置知识是本教程的姊妹篇《Vue 3 教程》——Nuxt 不发明新语法，它只是把 Vue 应用装进了一个有服务端的外壳里。",{"id":13201,"title":48,"titles":13202,"content":13203,"level":437},"\u002Ftutorials\u002Fnuxt#一nuxt-是什么vue-应用外面那层服务端",[5],"一句话：Nuxt = Vue + 路由 + 服务端渲染 + 构建配置 + 一整套目录约定。 如果你已经按《Vue 3 教程》用 Vite 建过项目，你会遇到这三件事，Nuxt 就是把它们全包了： 你自己搭 Vite + Vue 时的活 Nuxt 里的对应 装 vue-router、写路由表、注册 \u003CRouterView> 在 app\u002Fpages\u002F 放文件，路由自动生成 建 components\u002F、每个组件手写 import 放进 app\u002Fcomponents\u002F，模板里直接用 写 useCounter.js 组合式函数并手动 import 放进 app\u002Fcomposables\u002F，自动可用 配 vite.config.js、build.base、dev 代理 nuxt.config.ts 一个文件 需要 SEO \u002F 首屏快 → 自己搭 Node SSR 服务 内置 SSR，且能一个开关切到纯静态 需要几个后端接口给前端用 server\u002Fapi\u002F*.ts 文件即接口（Nitro）",{"id":13205,"title":171,"titles":13206,"content":13207,"level":454},"\u002Ftutorials\u002Fnuxt#为什么首屏这件事值得多一层服务端",[5,48],"浏览器拿到的是 HTML。SPA（纯客户端渲染）的 HTML 里只有一个空 \u003Cdiv id=\"app\">\u003C\u002Fdiv>，正文要等 JS 下载、执行、请求数据之后才出现；SSR 的 HTML 里正文已经在里面了。 差别体现在三件具体的事上： 用户：首屏不白屏，弱网\u002F低端机上观感差距明显。 爬虫：搜索引擎抓取的是 HTML 源码。正文在源码里 = 能被索引；空壳 = 你得赌它的渲染引擎会执行 JS。社交平台的分享卡片（Open Graph）同理。 成本：SSR 需要服务器跑 Node 进程；但 Nuxt 还能「构建期就把 HTML 全生成好」（nuxt generate），产物是纯静态文件，一台 Nginx 甚至对象存储就够——这正好是很多内容站真正需要的形态。 示例 01 把两种模式的瀑布一步步放给你看，最值得注意的是 SSR 那条：看得见 ≠ 点得动。服务端渲染出的 HTML 只是「长得一样」，事件要等客户端 JS 执行完「水合」才绑上——这是理解后面所有坑的前提。",{"id":13209,"title":227,"titles":13210,"content":13211,"level":454},"\u002Ftutorials\u002Fnuxt#nuxt-与-vue-的关系不是什么",[5,48],"Nuxt 不是另一个框架，语法还是 Vue 那一套（\u003Cscript setup>、ref、computed、组件、Pinia、Vue Router 全都在）。 Nuxt 不是「必须有一个后端」。它可以输出纯静态文件，服务器上完全不跑 Node。 Nuxt 不是 Next\u002FNuxt 之争的主角。它对标的是「Next.js 之于 React」，但作为 Vue 官方推荐的应用框架，接口设计更贴近 Vue 的直觉。",{"id":13213,"title":261,"titles":13214,"content":13215,"level":454},"\u002Ftutorials\u002Fnuxt#️-本章的坑",[5,48],"1. 以为 Nuxt 一定要 Node 服务器。 内容站用 nuxt generate 出静态产物 + Nginx 就够了（第十三章实测链路）。反过来说，需要「按请求动态渲染」时才要 Node（nuxt build + node .output\u002Fserver\u002Findex.mjs）。 2. 把 SSR 当成「一定比 CSR 快」。 服务端要为每个请求跑一遍组件渲染，高并发下 CPU 是成本；对纯内部系统（登录才能进、无 SEO 需求）ssr: false 反而更省事。 3. 忽略了「水合」这一层的代价。 SSR 首屏有内容，但要等 JS 加载完才可交互，这段间隔叫 TTI。页面里塞了大 JS 时，SSR 也救不了交互延迟——所以第九章的渲染模式和第十四章的包体积优化要一起看。 4. 拿 Nuxt 2 \u002F Nuxt 3 的老教程直接抄。 Nuxt 4 把源码目录收进了 app\u002F（pages\u002F → app\u002Fpages\u002F），server\u002F、public\u002F、content\u002F 仍在根目录。还有一批默认值变化（见第二章对照表）。搜资料时先确认版本。",{"id":13217,"title":325,"titles":13218,"content":13219,"level":454},"\u002Ftutorials\u002Fnuxt#附名词对照",[5,48],"名词 是什么 Nuxt 应用框架（你写代码的地方） Nitro Nuxt 的服务端引擎（server\u002F 目录、构建产物、部署适配都是它管的） Vite 开发服务器与打包器，Nuxt 用它，配置通过 vite: {} 传 h3 Nitro 里的 HTTP 框架，defineEventHandler 来自它 SSR \u002F SSG \u002F CSR 服务端渲染 \u002F 构建期静态生成 \u002F 纯客户端渲染 水合（hydration） 客户端把已有 DOM 接管为响应式组件，并绑上事件 payload Nuxt 把服务端取到的数据序列化进 _payload.json，供客户端复用",{"id":13221,"title":413,"titles":13222,"content":423,"level":437},"\u002Ftutorials\u002Fnuxt#二跑起来第一个项目三条命令与目录约定",[5],{"id":13224,"title":416,"titles":13225,"content":13226,"level":454},"\u002Ftutorials\u002Fnuxt#创建与运行",[5,413],"# ① 官方脚手架（会问你是否装 modules \u002F 用 TS \u002F 起 dev 服务）\nnpx nuxi@latest init my-site\n\n# ② 手动（已有仓库时）\nnpm create nuxt@latest my-site\n\ncd my-site\nnpm install\nnpm run dev          # http:\u002F\u002Flocalhost:3000，改文件即时热更新 package.json 里 Nuxt 给了四条命令，语义区别要一次记清： 命令 做什么 产物 用于 nuxt dev 带 SSR 的开发服务器 无（内存里跑） 日常开发 nuxt build 生产构建，保留服务端 .output\u002F（含 Node server） 需要按请求动态渲染\u002F接口 nuxt generate 构建 + 把所有页面预渲染成 HTML .output\u002Fpublic\u002F（纯静态） 内容站、部署到 Nginx\u002FOSS nuxt preview 本地按产物方式预览 — 上线前必跑一遍 nuxt prepare（脚手架会挂到 postinstall）用来生成 .nuxt\u002F 里的类型与自动导入声明——IDE 报「找不到 useFetch」多半是它没跑过。",{"id":13228,"title":13054,"titles":13229,"content":13230,"level":454},"\u002Ftutorials\u002Fnuxt#目录约定nuxt-4源码进-app",[5,413],"my-site\u002F\n├── nuxt.config.ts        唯一的总配置\n├── app\u002F                  ← Nuxt 4 的 srcDir\n│   ├── app.vue           根组件（挂 \u003CNuxtLayout>\u003CNuxtPage\u002F>\u003C\u002FNuxtLayout>）\n│   ├── error.vue         错误页（放在 app\u002F 根，专门显示 404\u002F500）\n│   ├── pages\u002F            页面 → 自动生成路由（第三章）\n│   ├── layouts\u002F          布局骨架（default.vue 默认全站套用）\n│   ├── components\u002F       组件，自动导入（本章最后一节）\n│   ├── composables\u002F      useXxx 组合式函数，自动导入\n│   ├── utils\u002F            纯函数，自动导入（无需 use 前缀）\n│   ├── plugins\u002F          插件（第十二节）\n│   ├── middleware\u002F       路由中间件（第十二章）\n│   └── assets\u002F           需要被打包的 css\u002F图片\u002F字体\n├── server\u002F               Nitro 服务端\n│   ├── api\u002F              文件即接口 → \u002Fapi\u002Fxxx\n│   ├── routes\u002F           不走 \u002Fapi 前缀的接口\n│   ├── middleware\u002F       服务端中间件\n│   └── utils\u002F            服务端内部工具，自动导入\n├── public\u002F               原样拷贝到产物根（favicon、静态 demo）\n├── content\u002F              内容模块的 Markdown（本站教程就放这里）\n└── .nuxt\u002F                构建缓存与生成物，别提交 git public\u002F 与 app\u002Fassets\u002F 的分界是最常问的：public\u002F 里的文件路径不变、原样搬过去（public\u002Fimg\u002Flogo.png 直接用 \u002Fimg\u002Flogo.png）；assets\u002F 里的会被打包、加哈希、能参与 CSS url() 解析和体积优化。 示例 02 把整棵树做成了可点开的形式，顶部还能按关键词（layouts \u002F api \u002F public）过滤，忘了某个目录放什么就点一下。",{"id":13232,"title":13056,"titles":13233,"content":13234,"level":454},"\u002Ftutorials\u002Fnuxt#nuxtconfigts-的最小可用版",[5,413],"本站的实际配置形状（有删减，注释是我加的）： \u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  compatibilityDate: '2025-09-01',      \u002F\u002F 锁定行为版本，避免升级悄悄改默认值\n  devtools: { enabled: true },\n\n  ssr: true,                            \u002F\u002F 服务端渲染（默认就是 true）\n\n  modules: ['@nuxt\u002Fcontent', '@nuxtjs\u002Fsitemap'],\n\n  components: [{ path: '~\u002Fcomponents', pathPrefix: false }],  \u002F\u002F 关掉「目录名前缀」\n\n  nitro: {\n    prerender: {\n      crawlLinks: true,                 \u002F\u002F 从入口出发顺着链接爬，自动发现要生成的页面\n      routes: ['\u002F', '\u002Ftools', '\u002Farticles', '\u002Ftutorials']\n    }\n  },\n\n  site: { url: 'https:\u002F\u002Fyour-domain.com', name: '站名' }   \u002F\u002F sitemap \u002F OG 绝对地址用\n}) ~ 别名指向 srcDir（Nuxt 4 里就是 app\u002F），@@ 指向项目根目录。",{"id":13236,"title":931,"titles":13237,"content":13238,"level":454},"\u002Ftutorials\u002Fnuxt#自动导入nuxt-最省力也最容易懵的一层",[5,413],"下面这些不需要 import，在 app\u002F 里直接写就能用： 类别 例子 规则 Vue \u002F Vue Router API ref、computed、watch、useRoute、useRouter 自动按需引入（import 手写也行，不会重复） Nuxt 组合式函数 useFetch、useAsyncData、useHead、navigateTo、callOnce、useState 内置 编译器宏 definePageMeta、defineNuxtRouteMiddleware、defineNuxtPlugin、defineAppConfig 别去 import，它们由编译器在构建时替换 app\u002Fcomponents\u002F 的组件 AppHeader.vue → \u003CAppHeader> 默认带目录前缀：components\u002Fui\u002FBaseButton.vue → \u003CUiBaseButton>；用上面 pathPrefix: false 关掉 app\u002Fcomposables\u002F useDocToc() 文件名里必须是 use-xxx \u002F useXxx app\u002Futils\u002F formatDate() 无命名限制 server\u002Futils\u002F useDb()（仅服务端可用） 服务端自动导入 不会自动导入的：第三方库（axios、lodash-es、dayjs）、你自己的非约定目录（app\u002Fhelpers\u002F）、Node 内置模块（node:fs，而且只能在 server\u002F 里用）。",{"id":13240,"title":261,"titles":13241,"content":13242,"level":454},"\u002Ftutorials\u002Fnuxt#️-本章的坑-1",[5,413],"1. IDE 报 Cannot find name 'useFetch'。 没跑 nuxt prepare（或 npm install 后没触发 postinstall）。跑一次即可，.nuxt\u002Ftypes\u002F 里就有声明。TS 项目还要确认 tsconfig.json 是 extends \".\u002F.nuxt\u002Ftsconfig.json\"。 2. tsconfig.json 指向了错误的 workspace。 Nuxt 会生成 .nuxt\u002Ftsconfig.server.json 给 server\u002F 用（两套上下文不同：一个有 window，一个有 process）。把整个项目塞进同一份 tsconfig 里会看到一堆假报错。 3. 组件目录前缀让 \u003CBaseButton> 找不到。 默认规则是「子目录名会拼进组件名」（components\u002Fui\u002FBaseButton.vue → \u003CUiBaseButton>）。两种解法：按规则写名字，或配 components: [{ path: '~\u002Fcomponents', pathPrefix: false }]。同一项目里选一种，别混——混用会出现「同名组件被两个来源解析」。 4. 组件名冲突时静默覆盖。 两个不同目录里的同名 Card.vue（关掉前缀后尤其容易），后者赢，且只有一个能用。给基础组件加前缀（Base\u002FApp\u002F领域名）是根治办法。 5. 把 public\u002F 当 assets\u002F 用。 public\u002F 里的图片不参与哈希与优化，改文件后浏览器可能拿旧缓存；assets\u002F 里的被打包且带哈希。反过来把大文件塞进 assets\u002F 会让构建变慢。经验：要缓存指纹的进 assets，直接按路径引用的（favicon、robots.txt、demo）进 public。 6. 在 app\u002F 里 import 'node:fs'。 Nuxt 不会拦你，但运行到服务端上下文之外就炸。需要在服务端读文件的逻辑请放 server\u002F（第七章）。 7. .nuxt\u002F 提交进 git 或手工编辑。 那是生成物，随时会被覆盖。要改配置就改 nuxt.config.ts。 8. compatibilityDate 忘了设。 不设时 Nuxt 会用一个默认日期，未来升级后部分行为默认值改变（例如某些序列化、路由匹配细节）。显式写死它，是「升级不炸」的最低成本保险。",{"id":13244,"title":1309,"titles":13245,"content":13246,"level":454},"\u002Ftutorials\u002Fnuxt#附nuxt-3-nuxt-4-迁移对照",[5,413],"项目 Nuxt 3 Nuxt 4 源码目录 根目录 pages\u002F、components\u002F… 全部进 app\u002F（srcDir: 'app\u002F'） 服务端 \u002F public \u002F content 根目录 不变（仍在根目录） 异步数据默认值 useFetch 无 default 时给 null 需要通过 default: () => … 显式给（data 初始为 undefined） useState 可在组件外调用 必须在 setup 上下文内 路由 meta 里的 key 前缀、hydration 属性 data-v-app 等 若干 DOM 细节变化（依赖第三方 CSS 选择器时要注意） 升级命令 — npx nuxi@latest upgrade --force 升级命令 — npx nuxi@latest upgrade --force 官方 codemod（批量搬目录） — npx @nuxt\u002Fupgrade-nuxt-4（或 upgrade 向导里的 move files）",{"id":13248,"title":1458,"titles":13249,"content":13250,"level":437},"\u002Ftutorials\u002Fnuxt#三页面与路由文件名就是-url",[5],"app\u002Fpages\u002F 下的 .vue 文件会被编译成路由表——没有单独的路由配置文件需要维护（背后仍是 Vue Router，vue-router 是 Nuxt 的依赖）。这也是 Nuxt 相比手搭 Vite 项目省掉的最大一块活。",{"id":13252,"title":1477,"titles":13253,"content":13254,"level":454},"\u002Ftutorials\u002Fnuxt#路径映射规则",[5,1458],"app\u002Fpages\u002Findex.vue                 →  \u002F\napp\u002Fpages\u002Fabout.vue                 →  \u002Fabout\napp\u002Fpages\u002Fblog\u002Findex.vue            →  \u002Fblog\napp\u002Fpages\u002Fblog\u002F[slug].vue           →  \u002Fblog\u002F:slug            动态段\napp\u002Fpages\u002Fusers\u002F[id]\u002Forders.vue     →  \u002Fusers\u002F:id\u002Forders\napp\u002Fpages\u002Fdocs\u002F[...path].vue        →  \u002Fdocs\u002F*                通配（catch-all）\napp\u002Fpages\u002F(marketing)\u002Fpricing.vue   →  \u002Fpricing               括号目录（分组）不进 URL\napp\u002Fpages\u002Fp\u002F[[id]].vue              →  \u002Fp\u002F:id?                可选参数（双中括号） 分组目录（(admin)\u002F、(marketing)\u002F）纯粹用来在文件管理器里归类文件，对 URL 无影响；同名的分组可以跨目录合并到同一个父路由。",{"id":13256,"title":13064,"titles":13257,"content":13258,"level":454},"\u002Ftutorials\u002Fnuxt#嵌套页面父组件里要写-nuxtpage",[5,1458],"这是新手最容易卡住的一条：父级页面模板里没有 \u003CNuxtPage \u002F>，子路由就渲染不出来（不报错，只是空白）。 \u003C!-- app\u002Fpages\u002Fdashboard.vue：\u002Fdashboard 与 \u002Fdashboard\u002Fanalytics 共用的外壳 -->\n\u003Ctemplate>\n  \u003Cdiv class=\"dash\">\n    \u003Caside>侧边栏（子路由切换时它不会重建）\u003C\u002Faside>\n    \u003CNuxtPage \u002F>          \u003C!-- 子页面渲染在这里 -->\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n\n\u003C!-- app\u002Fpages\u002Fdashboard\u002Findex.vue → \u002Fdashboard -->\n\u003C!-- app\u002Fpages\u002Fdashboard\u002Fanalytics.vue → \u002Fdashboard\u002Fanalytics --> 只换子页面时父组件实例被复用（和 Vue Router 的行为一致），这正是「切换 tab 但侧边栏不闪」的实现方式。",{"id":13260,"title":13066,"titles":13261,"content":13262,"level":454},"\u002Ftutorials\u002Fnuxt#导航nuxtlink-与-navigateto",[5,1458],"\u003Ctemplate>\n  \u003CNuxtLink to=\"\u002Fabout\">关于\u003C\u002FNuxtLink>\n  \u003CNuxtLink :to=\"{ path: '\u002Fblog\u002Fvue3', query: { utm: 'nav' } }\">读一读\u003C\u002FNuxtLink>\n  \u003CNuxtLink to=\"\u002Fblog\" active-class=\"on\" exact-active-class=\"on-exact\">博客\u003C\u002FNuxtLink>\n  \u003CNuxtLink to=\"https:\u002F\u002Fexample.com\" external target=\"_blank\" rel=\"noopener\">站外链接\u003C\u002FNuxtLink>\n  \u003CNuxtLink :custom=\"false\">…\u003C\u002FNuxtLink>\n\u003C\u002Ftemplate>\n\n\u003Cscript setup>\n\u002F\u002F 命令式跳转\nawait navigateTo('\u002Flogin?redirect=' + route.fullPath)      \u002F\u002F 推荐：SSR 期间也能正确发起跳转\n\u002F\u002F router.push('\u002Fx')  仍然可用，但 SSR 阶段请优先 navigateTo\nconst route = useRoute()\nconst router = useRouter()\n\u003C\u002Fscript> \u003CNuxtLink> 相比 \u003CRouterLink> 多做了几件事：自动给外链加 target（配合 external）、默认对站内链接开启组件与数据预取（鼠标悬停即预取，点击时数据已在本地）、可写 no-prefetch 关掉。",{"id":13264,"title":13068,"titles":13265,"content":13266,"level":454},"\u002Ftutorials\u002Fnuxt#页面级配置definepagemeta",[5,1458],"写在页面组件的 \u003Cscript setup> 顶层，编译器宏，不要 import： \u003Cscript setup>\ndefinePageMeta({\n  name: 'blog-detail',           \u002F\u002F 命名路由，便于 { name, params } 跳转\n  layout: 'doc',                 \u002F\u002F 套用 app\u002Flayouts\u002Fdoc.vue；false 表示不用布局\n  middleware: ['auth'],          \u002F\u002F 命名中间件（第十二章）\n  meta: { public: true },         \u002F\u002F 自定义字段，中间件里 to.meta.public 读得到\n  keepAlive: true,               \u002F\u002F 页面级缓存（切走不销毁）\n  keepalive: { max: 5 },         \u002F\u002F 更细的配置写法\n  scrollToTop: true,             \u002F\u002F 进入本页滚到顶部\n  validate: (route) => \u002F^\\d{4}$\u002F.test(route.params.year)   \u002F\u002F 参数不合法直接 404\n})\n\u003C\u002Fscript> validate 是很划算的一个字段：一个函数就能把 \u002Fblog\u002F2026-abcd 这种脏 URL 变成干净的 404，而不是让页面组件去处理空数据。 示例 03 上半部分是真的规则引擎（输入 blog\u002F[slug].vue 就出 \u002Fblog\u002F:slug），下半部分是五道反向练习题。",{"id":13268,"title":261,"titles":13269,"content":13270,"level":454},"\u002Ftutorials\u002Fnuxt#️-本章的坑-2",[5,1458],"1. 父页面忘写 \u003CNuxtPage \u002F>。 子路由一片空白，控制台干净。排查顺序：父组件模板 → 子文件是否在 pages\u002F 下（不是 views\u002F）。 2. app\u002Fpages\u002F 下所有 .vue 都会变成路由。 你随手放的 README.vue、误建在 app\u002Fpages\u002Fcomponents\u002FCard.vue 的组件，都会生成一个可被访问的页面（\u002Fcomponents\u002Fcard）。非页面组件请一律放 app\u002Fcomponents\u002F；只想给某个页面用的私有组件，放 app\u002Fcomponents\u002F\u003C页面名>\u002F 下（例如 app\u002Fcomponents\u002Farticle\u002FCommentBox.vue），别留在 pages\u002F 里。若要依赖「以 - 开头的文件被忽略」这类规则，请先在你的 Nuxt 版本上实测确认再生效。 3. navigateTo 与 router.push 混用。 服务端渲染阶段（比如中间件里）要用 navigateTo()——它会把跳转变成一次真正的 HTTP 重定向；router.push 在 SSR 阶段没有意义。 4. 动态段参数是字符串。 route.params.id 拿到 '42'，直接参与 === 比较或算术就有类型问题。要数字就在跳转处传数字、在页面里 Number(route.params.id)，或用 props: true（definePageMeta({ props: true })）让 Nuxt 转成组件 props 后声明类型。 5. 通配路由 [...path].vue 会吃掉所有子路径。 它必须能处理「参数为空」的情况（\u002Fdocs 本身命中时 path 是 []），否则进入父路径就是 500。 6. 只有 query 变化的跳转不会重跑 onMounted。 \u002Flist?page=1 → \u002Flist?page=2 复用同一组件实例。数据要跟着 query 走，请用 useFetch(url, { watch: [query] }) 或 watch(() => route.query, ...)（第五章展开）。 7. keepAlive: true 后页面数据不再刷新。 与 Vue 教程第十四章同一个坑：onMounted 只跑一次，需要「每次进入都刷新」请写 onActivated，或对该页面关掉 keepAlive。 8. 分组目录名里带斜杠或特殊字符。 (a\u002Fb) 是非法的，会被当成两层目录。分组名请只用字母数字和连字符。",{"id":13272,"title":2318,"titles":13273,"content":13274,"level":454},"\u002Ftutorials\u002Fnuxt#附文件名-url-速查",[5,1458],"文件 URL 读参数 pages\u002Findex.vue \u002F — pages\u002Fabout.vue \u002Fabout — pages\u002Fblog\u002Findex.vue \u002Fblog — pages\u002Fblog\u002F[slug].vue \u002Fblog\u002F:slug route.params.slug pages\u002Fblog\u002F[year]\u002F[month].vue \u002Fblog\u002F:year\u002F:month 两个参数 pages\u002Fdocs\u002F[...path].vue \u002Fdocs\u002F* route.params.path（数组） pages\u002Fp\u002F[[id]].vue \u002Fp\u002F:id? 可能为 undefined pages\u002F(group)\u002Fx.vue \u002Fx — pages\u002Fusers\u002F[id]\u002Findex.vue \u002Fusers\u002F:id 父 users\u002F[id].vue 需 \u003CNuxtPage\u002F>",{"id":13276,"title":2482,"titles":13277,"content":13278,"level":437},"\u002Ftutorials\u002Fnuxt#四布局错误页与页面骨架",[5],"一个站点的所有页面共享的「页头 + 容器 + 页脚」叫布局。Nuxt 用 app\u002Flayouts\u002F + \u003CNuxtLayout> 承担这件事。",{"id":13280,"title":2495,"titles":13281,"content":13282,"level":454},"\u002Ftutorials\u002Fnuxt#根组件与默认布局",[5,2482],"Nuxt 4 里 app\u002Fapp.vue 是根组件，标准形状就三行： \u003C!-- app\u002Fapp.vue -->\n\u003Ctemplate>\n  \u003CNuxtLayout>\n    \u003CNuxtPage \u002F>\n  \u003C\u002FNuxtLayout>\n\u003C\u002Ftemplate> app\u002Flayouts\u002Fdefault.vue 会被自动套上（因为文件名是 default）： \u003C!-- app\u002Flayouts\u002Fdefault.vue -->\n\u003Ctemplate>\n  \u003Cdiv class=\"site\">\n    \u003CSiteHeader \u002F>\n    \u003Cmain class=\"container\">\u003Cslot \u002F>\u003C\u002Fmain>\n    \u003CSiteFooter \u002F>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate> \u003Cslot \u002F> 就是页面组件插进来的位置。布局里不要直接写 \u003CNuxtPage \u002F>，用 \u003Cslot \u002F>——这是官方明确的规定，写 \u003CNuxtPage \u002F> 会导致嵌套层级错乱（布局切换时页面被重新挂载）。 换布局或不用布局： \u003Cscript setup>\ndefinePageMeta({ layout: 'doc' })     \u002F\u002F 用 app\u002Flayouts\u002Fdoc.vue\ndefinePageMeta({ layout: false })     \u002F\u002F 不要布局（登录页、独立工具页常用）\n\u003C\u002Fscript> 运行时切换：nuxtApp.yamlContext.layout.value = 'doc'（useNuxtApp() 拿 nuxtApp），或 \u003CNuxtLayout name=\"doc\"> 手动包住一段内容。",{"id":13284,"title":13075,"titles":13285,"content":13286,"level":454},"\u002Ftutorials\u002Fnuxt#错误页apperrorvue",[5,2482],"\u003C!-- app\u002Ferror.vue -->\n\u003Cscript setup>\nconst props = defineProps({ error: { type: Object, required: true } })\nconst home = () => navigateTo('\u002F')\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv class=\"error\">\n    \u003Ch1>{{ error.statusCode }}\u003C\u002Fh1>\n    \u003Cp>{{ error.statusMessage || (error.statusCode === 404 ? '页面不存在' : '出错了') }}\u003C\u002Fp>\n    \u003Cbutton @click=\"home\">回首页\u003C\u002Fbutton>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate> 触发一个错误页（404\u002F500 的标准做法）： \u002F\u002F 页面、中间件、服务端接口里都能用\nthrow createError({ statusCode: 404, statusMessage: '教程不存在', fatal: true }) 本站教程详情页就是这么处理的：查不到文档就直接 throw createError({ statusCode: 404, fatal: true })，而不是渲染一个「数据为空」的分支——前者能让爬虫和监控正确识别 404。 error.vue 有几点硬约束：它在布局之外（拿不到 useAppStore 之类），所以要用到的样式请在 app.vue 层或全局 CSS 里给；它不参与 pages\u002F 路由，别指望用 navigateTo('\u002Ferror') 跳过去。",{"id":13288,"title":2996,"titles":13289,"content":13290,"level":454},"\u002Ftutorials\u002Fnuxt#路由动画与公告",[5,2482],"\u003CNuxtPage :transition=\"{ name: 'page', mode: 'out-in' }\" \u002F>\n\u003CNuxtRouteAnnouncer \u002F>      \u003C!-- 给读屏软件播报「已进入 关于 页」 --> \u003CNuxtRouteAnnouncer> 是无障碍的正路（SPA 换页不会触发屏幕阅读器的标题播报）。动画需要配 CSS 类：.page-enter-from、.page-leave-to（和 Vue \u003CTransition> 一模一样，别漏 -from）。",{"id":13292,"title":13078,"titles":13293,"content":13294,"level":454},"\u002Ftutorials\u002Fnuxt#加载态nuxtloadingindicator",[5,2482],"\u003C!-- app\u002Fapp.vue -->\n\u003Ctemplate>\n  \u003CNuxtLoadingIndicator color=\"#0969da\" \u002F>\n  \u003CNuxtLayout>\u003CNuxtPage \u002F>\u003C\u002FNuxtLayout>\n\u003C\u002Ftemplate> 放在 app.vue 里就能给「所有导航和 useFetch」共用的顶部进度条。不想让某个请求触发进度条：useFetch(url, { dedupe: 'defer' }) 或 \u003CNuxtLoadingIndicator :throttle=\"500\">。",{"id":13296,"title":3153,"titles":13297,"content":13298,"level":454},"\u002Ftutorials\u002Fnuxt#单页面渲染不需要-layouts-的小项目",[5,2482],"删掉 app.vue 时 Nuxt 会生成一个默认的 \u003Cdiv id=\"app\">\u003CNuxtLayout>\u003CNuxtPage\u002F>\u003C\u002FNuxtLayout>\u003C\u002Fdiv>。留着 app.vue 但里面只有 \u003CNuxtPage\u002F> 也是合法配置（没有布局需求时）。",{"id":13300,"title":261,"titles":13301,"content":13302,"level":454},"\u002Ftutorials\u002Fnuxt#️-本章的坑-3",[5,2482],"1. 布局里写 \u003CNuxtPage \u002F>。 布局切换时页面会被重复挂载，definePageMeta({ key }) 行为诡异。规则：页面用 \u003CNuxtPage\u002F>，布局用 \u003Cslot\u002F>。 2. error.vue 里访问 useRoute()。 错误页不在正常路由上下文里，useRoute() 拿到的不是你以为的那个对象，route.meta 是空的。需要的信息从 props.error 里取。 3. 布局名字打错，静默回到 default。 definePageMeta({ layout: 'docs' }) 但只有 doc.vue，Nuxt 会警告后落到默认布局（或空白，取决于版本）。新建布局时先访问一次页面确认。 4. layouts\u002F 下的子目录文件不会自动生效。 layouts\u002Fadmin\u002Fsidebar.vue 不是布局。要复用片段请放 components\u002F。 5. 布局里放的数据请求会在每次换页时重跑？ 不会——布局实例复用。但如果你用了 definePageMeta({ layout: false }) 又 \u003CNuxtLayout> 手动包，就可能出现「同一份数据取两遍」。二选一。 6. \u003CNuxtLayout> 的默认插槽必须只有一个元素。 包多个根元素会收到「Layout should have a single root element」警告（Vue 3 多根组件在这个位置不被允许）。外面套一个 \u003Cdiv>。 7. 静态生成时 404 页需要额外一份。 nuxt generate 会产出 404.html，但服务器要真的把未知路径指到它（Nginx error_page 404 \u002Fnuxt\u002F404.html;），否则用户看到 Nginx 的默认 404。第十三章有配置。",{"id":13304,"title":3290,"titles":13305,"content":13306,"level":454},"\u002Ftutorials\u002Fnuxt#附内置组件清单",[5,2482],"组件 放哪 作用 \u003CNuxtApp> 相关 API（useNuxtApp()） 任意 拿运行时上下文、注册钩子 \u003CNuxtPage> app.vue 渲染当前路由组件，可配 transition\u002Fkeepalive \u003CNuxtLayout> app.vue 或页面 套用布局 \u003Cslot \u002F> 布局内部 页面插入点 \u003CNuxtLoadingIndicator> app.vue 顶部进度条 \u003CNuxtRouteAnnouncer> app.vue 路由变化的无障碍播报 \u003CClientOnly> 任意 内部只在客户端渲染（SSR 安全逃生舱） \u003CDevOnly> 任意 内部只在开发环境渲染 \u003CNuxtWelcome> 临时页面 官方欢迎页（初始化时用一次）",{"id":13308,"title":13083,"titles":13309,"content":13310,"level":437},"\u002Ftutorials\u002Fnuxt#五数据获取usefetch-与-useasyncdata",[5],"这是 Nuxt 的核心章节。数据什么时候在服务端取、什么时候在客户端取、取几次，决定了首屏速度、SEO 和一半的性能坑。",{"id":13312,"title":13086,"titles":13313,"content":13314,"level":454},"\u002Ftutorials\u002Fnuxt#为什么不能用-onmounted-fetch",[5,13083],"先想清楚这条链：SSR 阶段服务端跑组件时不会执行 onMounted（那是浏览器里的钩子），于是服务端渲染出的 HTML 里没有正文；到客户端才发请求，用户看着白屏或 loading 等一跳。爬虫抓到的也是空壳。 useFetch \u002F useAsyncData 解决的就是这个：它们是「服务端会等待、结果会被序列化」的异步数据。 \u003Cscript setup>\n\u002F\u002F 完整写法：顶层 await → 服务端取完才渲染 HTML\nconst { data: posts, pending, status, error, refresh } =\n  await useFetch('\u002Fapi\u002Fposts', { query: { page: 1 }, pick: ['id', 'title'] })\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp v-if=\"pending\">加载中…\u003C\u002Fp>\n  \u003Cul v-else-if=\"!error\">\n    \u003Cli v-for=\"p in posts\" :key=\"p.id\">{{ p.title }}\u003C\u002Fli>\n  \u003C\u002Ful>\n  \u003Cp v-else class=\"err\">{{ error.statusMessage || '加载失败' }}\u003C\u002Fp>\n  \u003Cbutton @click=\"refresh()\">重新获取\u003C\u002Fbutton>\n\u003C\u002Ftemplate> 返回值里五个字段各有分工：data 数据本体、error 失败时的错误对象、pending（当前这一次请求）与 status（idle | pending | success | error）、refresh（重跑 handler）、execute（手动触发，配 immediate: false）。",{"id":13316,"title":13088,"titles":13317,"content":13318,"level":454},"\u002Ftutorials\u002Fnuxt#usefetch-与-useasyncdata-的分工",[5,13083],"useFetch(url, opts) useAsyncData(key, handler, opts) 内部做什么 就是 useAsyncData 的包装，handler 是 $fetch(url) 你给任意异步函数（可以是三次串行请求、读文件、算东西） key 默认用 URL 生成 必须自己给（同 key 共享同一份结果） 自动取消 有（abort） 无（自己用 AbortController） 请求拦截\u002F自动前缀 有（useFetch 内部 $fetch 的 baseURL 逻辑） 无 什么时候用 取一个 HTTP 接口 组合多个来源、非 HTTP 的数据（DB、文件、计算） 本站详情页用的就是 useAsyncData：queryCollection('tutorials').path(route.path).first() 是本地数据库查询，不是 HTTP 请求。",{"id":13320,"title":3848,"titles":13321,"content":13322,"level":454},"\u002Ftutorials\u002Fnuxt#keynuxt-数据缓存的开关",[5,13083],"\u002F\u002F 两个组件同 key → 只请求一次，共享同一份 data\nawait useAsyncData('user-stats', () => $fetch('\u002Fapi\u002Fstats'))\nawait useAsyncData('user-stats', () => $fetch('\u002Fapi\u002Fstats'))   \u002F\u002F 不会二次请求 useFetch('\u002Fapi\u002Flist') 的默认 key 由 URL + 选项生成。两个组件用同一 URL 就自动共享；URL 参数不同 key 就不同，会各请求一次。要强制共享就显式写 { key: 'list' }。 这也解释了「为什么我在页脚拿不到最新数据」：同 key 的数据在 payload 里已存在，客户端不会自动重新请求。要刷新请调 refresh()，或加 { dedupe: 'always' } 之类的策略。",{"id":13324,"title":13091,"titles":13325,"content":13326,"level":454},"\u002Ftutorials\u002Fnuxt#客户端才想要的数据lazyserver-falseimmediate",[5,13083],"\u002F\u002F ① 不阻塞首屏：先渲染，数据到达后再更新（HTML 里服务端已经取过，只是不 await）\nconst { data } = await useFetch('\u002Fapi\u002Fheavy', { lazy: true })\n\n\u002F\u002F ② 服务端完全不取（数据只在浏览器拿）—— 注意：正文不会进 SSR HTML，SEO 会空\nconst { data } = await useFetch('\u002Fapi\u002Fme', { server: false })\n\n\u002F\u002F ③ 先不请求，点按钮时才请求\nconst { data, execute } = await useAsyncData('detail', () => $fetch(`\u002Fapi\u002Fx\u002F${id}`), { immediate: false })\n\n\u002F\u002F ④ 参数变化时自动重新请求（默认只 watch 路由）\nconst { data } = await useFetch('\u002Fapi\u002Fsearch', { query: { q }, watch: [q] }) server: false 的代价要说清楚：SSR 出的 HTML 里那块是空的（或是 default 给的骨架），爬虫看到的是骨架，用户看到的是「内容闪一下」。只在「确实需要浏览器环境」（依赖 localStorage、需要当前登录 cookie 且接口不可服务端代理）时用。",{"id":13328,"title":13093,"titles":13329,"content":13330,"level":454},"\u002Ftutorials\u002Fnuxt#给数据一个-default少写一层判空",[5,13083],"const { data: list } = await useFetch('\u002Fapi\u002Flist', {\n  default: () => []          \u002F\u002F Nuxt 4 起 data 初始为 undefined，不写 default 就得判 undefined\n}) Nuxt 3 时代 data 默认是 null，Nuxt 4 变成 undefined——从旧教程抄代码时这里最容易出「list.length 报错」。",{"id":13332,"title":13095,"titles":13333,"content":13334,"level":454},"\u002Ftutorials\u002Fnuxt#一次性副作用callonce",[5,13083],"有些东西只该在服务端跑一次（初始化数据库、预热缓存、写 cookie）： await callOnce('seed', async () => { await $fetch('\u002Fapi\u002Fseed') }) 同一个 key 在整次请求生命周期内只执行一次；useHead 之类的全站初始化也能用它兜住。 示例 05 把「onMounted + fetch」和「await useFetch」各放一遍：计数器显示 SSR 阶段 1 次 + 客户端 0 次 vs 1 次 + 1 次；再切 server: false \u002F lazy 两个开关，看首屏文字和时间轴如何变化。",{"id":13336,"title":261,"titles":13337,"content":13338,"level":454},"\u002Ftutorials\u002Fnuxt#️-本章的坑-4",[5,13083],"1. await 写在异步函数里没生效。 \u003Cscript setup> 支持顶层 await；但如果你把 useFetch 塞进一个普通函数（function load() { await useFetch(...) }）再在 onMounted 里调用，SSR 阶段就白跑了。要么在 setup 顶层直接 await，要么改用 onServerPrefetch。 2. 在 useFetch 里读 window\u002Fdocument。 服务端也会执行 handler 所在的上下文，SSR 时报 window is not defined。需要浏览器环境的数据请用 { server: false } 或 \u003CClientOnly>。 3. key 撞车导致数据互相覆盖。 两个不同页面用了同一个显式 key（'list'），从一个页面切回另一个页面时拿到的是对方的数据。给 key 带上业务语义（tutorials-all、article-${id}）。 4. pick \u002F transform 忘了用，payload 巨大。 SSR 把取到的数据序列化进 _payload.json，列表接口返回 200 字段而页面只用 3 个字段时，用户多下载了 60 倍数据。pick: ['id','title'] 或 transform: (r) => r.map(slim)，本章末尾有对照表。 5. 页面里 refresh() 之后 loading 状态闪了两次。 refresh() 会重跑请求；要「静默刷新」用 refresh() 配合 { dedupe: 'defer' }，或直接 refresh({ dedupe: 'defer' })（版本相关，实测为准）。 6. 相对路径在服务端解析的是 Nuxt 服务器，不是你的后端。 useFetch('\u002Fapi\u002Fposts') 在 SSR 阶段由 Nitro 内部处理（命中你自己的 server\u002Fapi 时最省事，不发起真实网络请求）；但请求外部域名时必须写绝对 URL 或配 baseURL——这是「本地能跑、服务器上 fetch 超时」的头号原因（第六章解决）。 7. 认证 cookie 在服务端取不到。 SSR 阶段 $fetch 不会自动带上浏览器的 cookie。要么用 useRequestHeaders(['cookie']) 把请求头透传给内部接口，要么把这类「只有浏览器才知道结果」的数据放客户端取。 8. data 是可响应式的，别在回调里整体替换它然后期望触发更新。 data.value = newData 是对的；data.list.push(x) 若 data 是 ref 就漏了 .value。（Vue 教程第四章的老规矩在这里一样适用。）",{"id":13340,"title":4438,"titles":13341,"content":13342,"level":454},"\u002Ftutorials\u002Fnuxt#附数据获取选项速查",[5,13083],"选项 类型 作用 key string 缓存键；同 key 只跑一次 server boolean 是否在服务端执行（false = 只客户端） lazy boolean 不阻塞导航\u002F渲染，数据到达后再更新 immediate boolean 创建时是否立刻执行（false 配 execute()） pick string 只保留返回值的指定字段进 payload transform function 转换\u002F裁剪返回值 default function data 的初始值（Nuxt 4 建议显式给） watch Ref 依赖变化时自动重新请求；传 [] 关掉自动触发 dedupe 'cancel' | 'defer' | 'allow' 同一请求并发时的策略（默认 cancel） query \u002F params object 拼 URL 查询串（对象值会被序列化） headers \u002F method \u002F body — 透传给 $fetch getCachedData function 自定义「是否复用已有数据」（做数据级缓存） onResponse \u002F onError function 请求回调（埋点、错误提示） response — await useFetch(...) 返回的 promise 上有 .response（含状态码\u002F头） signal AbortSignal 自己接管取消",{"id":13344,"title":13099,"titles":13345,"content":13346,"level":437},"\u002Ftutorials\u002Fnuxt#六fetch底层请求函数与它的-baseurl",[5],"useFetch 内部调的就是 $fetch。当你不需要「进 payload、SSR 等待、缓存」这套语义时（比如提交表单、点按钮加载下一页），直接用 $fetch 更轻。 \u002F\u002F 组件里\nconst form = reactive({ title: '', body: '' })\nasync function submit() {\n  const res = await $fetch('\u002Fapi\u002Fposts', { method: 'POST', body: { ...form } })\n  await navigateTo(`\u002Fposts\u002F${res.id}`)\n} $fetch 来自 ofetch，Nuxt 给它注入了一层拦截（useNuxtApp().$fetch）。常用的四个选项： await $fetch('\u002Fapi\u002Flist', {\n  baseURL: '\u002Fapi',              \u002F\u002F 前缀（注意：URL 已经是绝对地址时 baseURL 被忽略）\n  query: { page: 2, tag: ['a', 'b'] },\n  headers: { 'x-trace': id },\n  timeout: 5000,\n  retry: 1,                     \u002F\u002F 失败重试次数（默认：GET 重试、POST 不重试）\n  onResponse({ response, options }) { \u002F* 统一处理 *\u002F },\n  onRequestError({ request, options, error }) { \u002F* 请求没发出去\u002F被取消 *\u002F },\n  onResponseError({ request, response, options }) { \u002F* 4xx\u002F5xx：会 throw *\u002F }\n}) 一条必须知道的默认行为：非 2xx 会抛错（不是返回一个 { ok: false }）。所以所有 $fetch 都要包 try\u002Fcatch，或者在 onResponseError 里统一转成提示： try {\n  await $fetch('\u002Fapi\u002Forder', { method: 'POST', body })\n} catch (e: any) {\n  \u002F\u002F e.data 是被解析后的响应体；e.response.status 是状态码\n  toast(e?.data?.message || '提交失败，请稍后再试')\n}",{"id":13348,"title":5084,"titles":13349,"content":13350,"level":454},"\u002Ftutorials\u002Fnuxt#服务端阶段访问外部-apibaseurl-的真相",[5,13099],"这是 Nuxt 新手最大的一个坑，必须单独讲。 SSR 阶段执行 useFetch('\u002Fapi\u002Fx') 时，请求不发往浏览器地址，而是在 Nitro 内部直接调用对应的 handler（省一次网络往返，很好）。但如果你的意图是「让浏览器去请求第三方域名」，写成相对路径就错了： \u002F\u002F ✗ SSR 阶段：Nitro 会把它当成自己的 \u002Fapi\u002Fx 处理 → 404 或打到不存在的 handler\nawait useFetch('\u002Fapi\u002Fx')\n\n\u002F\u002F ✗ SSR 阶段：从服务器发起，指向的是「本机」而不是外部服务\nawait useFetch('\u002Fweather', { baseURL: 'https:\u002F\u002Fapi.open-meteo.com' })  \u002F\u002F 这种写法是对的（绝对域名）\n\n\u002F\u002F ✓ 正确姿势一：让服务端接口做代理，前端只访问自己\n\u002F\u002F   app\u002Fpages\u002F…\nawait useFetch('\u002Fapi\u002Fweather')          \u002F\u002F Nuxt 自己的接口\n\u002F\u002F   server\u002Fapi\u002Fweather.get.ts\nexport default defineEventHandler(() => $fetch('https:\u002F\u002Fapi.open-meteo.com\u002Fv1\u002Fforecast?latitude=…'))\n\n\u002F\u002F ✓ 正确姿势二：外部地址写成绝对 URL，并接受「这是服务端发起的请求」\nawait useFetch('https:\u002F\u002Fapi.example.com\u002Fv1\u002Flist', { headers: { authorization: `Bearer ${token}` } }) 用服务端代理的三条理由（也是所有「前端不该持有密钥」场景的通用答案）： 密钥不下发：第三方 token 留在 server\u002F，浏览器看不到（第八章会说到 runtimeConfig）。 绕开 CORS：服务端之间没有同源限制。 可控：能加缓存（swr）、限流、降级、日志。",{"id":13352,"title":5279,"titles":13353,"content":13354,"level":454},"\u002Ftutorials\u002Fnuxt#请求头与-cookie-的透传",[5,13099],"\u002F\u002F 只在服务端执行：把浏览器带来的 cookie 转交给内部接口\nconst headers = useRequestHeaders(['cookie'])\nconst { data } = await useFetch('\u002Fapi\u002Fme', { headers }) useRequestHeaders() 在客户端返回空对象——这不是 bug，而是「浏览器本来就会自动带 cookie」。判断标准：SSR 阶段的请求需要你手工带上下文（cookie \u002F authorization \u002F user-agent），客户端阶段的请求由浏览器自动带。",{"id":13356,"title":13104,"titles":13357,"content":13358,"level":454},"\u002Ftutorials\u002Fnuxt#usefetch-与-fetch-的取舍",[5,13099],"需求 用什么 页面渲染需要的数据，要进 SSR HTML 和 payload await useFetch \u002F useAsyncData 用户点按钮才触发的加载（下一页、加载更多） $fetch + 本地 ref 提交表单、删除、点赞 $fetch（写操作不该被缓存） 服务端接口内部再调外部 API $fetch（server\u002F 里，没有 payload 概念） 图片\u002F文件下载 useFetch 的 responseType: 'arrayBuffer' 或直接浏览器行为",{"id":13360,"title":261,"titles":13361,"content":13362,"level":454},"\u002Ftutorials\u002Fnuxt#️-本章的坑-5",[5,13099],"1. 忘记 try\u002Fcatch。 4xx\u002F5xx 会 throw，未被捕获时 SSR 阶段直接把页面炸成错误页（客户端则表现为「点了没反应」+ 一条 unhandled rejection）。 2. 相对路径当成「浏览器地址」。 上面整节讲的就是它。症状很迷惑：nuxt dev 里正常，部署到别的域名\u002F子路径后接口全 404。 3. baseURL 与绝对 URL 同时给。 一旦请求字符串里有协议+域名，baseURL 被忽略，请求发往你以为没在用的地址。二选一。 4. POST 被自动重试造成重复下单。 retry 默认对幂等请求生效；把写操作设计成非幂等时显式写 retry: 0。 5. query 里塞 undefined。 ofetch 会跳过 undefined，但序列化 null\u002F空串——「接口收到的 query 和预期不一致」时先打印实际 URL（onRequest 里 console.log(options.url + options.query)）。 6. 数组查询参数的编码格式。 query: { ids: [1, 2] } 编成 ids=1&ids=2；不少后端要的是 ids[]=1 或 ids=1,2。用 query: { ids: ids.join(',') } 明确格式，别赌框架默认。 7. 在 onRequestError 里 throw。 拦截器里的异常会覆盖原始错误，排查时会看到「错误来源不明」。回调里只做记录，改写结果请返回新值。 8. 服务端拿不到 useRequestHeaders 之外的浏览器状态。 localStorage、navigator 一律为空。要这类信息就放到客户端再取（{ server: false } 或 \u003CClientOnly>）。",{"id":13364,"title":13107,"titles":13365,"content":13366,"level":454},"\u002Ftutorials\u002Fnuxt#附fetch-错误对象形状",[5,13099],"字段 内容 error.message 文本描述（404 Not Found: \u002Fapi\u002Fx 这类） error.response 原生 Response 对象（.status、.headers） error.data 解析后的响应体（后端返回的 { code, message } 在这里） error.options 本次请求的配置（URL、method、headers） 判定位置 4xx\u002F5xx → onResponseError；网络\u002FDNS\u002F取消 → onRequestError",{"id":13368,"title":13109,"titles":13369,"content":13370,"level":437},"\u002Ftutorials\u002Fnuxt#七服务端引擎-nitroserver-里的文件就是接口",[5],"nuxt generate 出静态站时不需要它；但只要你需要「有秘密参与」或「按请求计算」的功能（登录、上传、支付回调、爬虫代理、读文件），server\u002F 就是最短路径——不用另起一个后端项目。",{"id":13372,"title":5659,"titles":13373,"content":13374,"level":454},"\u002Ftutorials\u002Fnuxt#一个接口三步",[5,13109],"\u002F\u002F server\u002Fapi\u002Fnotes\u002Findex.get.ts\nexport default defineEventHandler(async (event) => {\n  const { q = '', page = '1' } = getQuery(event)\n  const list = await useDb(event).note.search({ q, page: Number(page) })\n  return { total: list.length, items: list.map(slim) }     \u002F\u002F 返回值 = JSON 响应体\n}) 文件写法 生成的 URL 方法 server\u002Fapi\u002Fnotes.get.ts \u002Fapi\u002Fnotes GET server\u002Fapi\u002Fnotes.post.ts \u002Fapi\u002Fnotes POST server\u002Fapi\u002Fnotes\u002Findex.get.ts \u002Fapi\u002Fnotes GET（与上面等价，用于同目录多方法） server\u002Fapi\u002Fnotes\u002F[id].delete.ts \u002Fapi\u002Fnotes\u002F:id DELETE server\u002Fapi\u002Fsearch.ts \u002Fapi\u002Fsearch 任意（不限制方法） server\u002Froutes\u002Fhealth.get.ts \u002Fhealth GET（routes\u002F 不带 \u002Fapi 前缀） server\u002Fmiddleware\u002Flog.ts — 每个请求前跑 server\u002Futils\u002Fdb.ts — 不是接口，被自动导入成 useDb() 后缀决定方法（.get \u002F .post \u002F .put \u002F .patch \u002F .delete \u002F .head \u002F .options）。一个文件一个方法是约定，同一资源的不同方法就分文件写（示例 06 演示了 [id].get.ts 与 [id].patch.ts 各自命中）。",{"id":13376,"title":5960,"titles":13377,"content":13378,"level":454},"\u002Ftutorials\u002Fnuxt#handler-里常用的工具",[5,13109],"export default defineEventHandler(async (event) => {\n  const id = getRouterParam(event, 'id')                 \u002F\u002F 路径参数\n  const query = getQuery(event)                          \u002F\u002F ?a=1\n  const body = await readBody(event)                     \u002F\u002F JSON 请求体\n  const raw = await readRawBody(event)                   \u002F\u002F 原始文本\n  const file = await readMultipartFormData(event)        \u002F\u002F 上传（数组，每项有 filename\u002Fdata）\n  const cookie = getCookie(event, 'sid')\n\n  setHeader(event, 'cache-control', 's-maxage=60, stale-while-revalidate')\n  setResponseStatus(event, 201)\n  setCookie(event, 'sid', 'abc', { httpOnly: true, sameSite: 'lax', maxAge: 60 * 60 * 24 })\n\n  if (!id) throw createError({ statusCode: 400, statusMessage: '缺少 id' })\n  return { ok: true, id }                                \u002F\u002F ← 返回即响应；后面别再手写 res\n}) 规则一句话：async handler 里，返回值就是响应体。 不要用 event.node.res.end()（除非你确定要接管原始响应，比如流式\u002F重定向），否则会出现「已经返回了但连接没关」或「双重写入」的诡异行为。",{"id":13380,"title":6251,"titles":13381,"content":13382,"level":454},"\u002Ftutorials\u002Fnuxt#前端怎么调它",[5,13109],"\u003Cscript setup>\n\u002F\u002F SSR 阶段：这个请求不经过网络，Nitro 内部直接调用 handler（快、无 CORS、无 baseURL 问题）\nconst { data: notes } = await useFetch('\u002Fapi\u002Fnotes', { query: { page: 1 } })\n\u003C\u002Fscript> \u003CNuxtLink to=\"\u002Fapi\u002Fnotes\"> 之类直接访问也能工作——部署时它就是普通的 HTTP 接口。",{"id":13384,"title":6320,"titles":13385,"content":13386,"level":454},"\u002Ftutorials\u002Fnuxt#环境变量与密钥的位置",[5,13109],"server\u002F 里读环境变量用 useRuntimeConfig()（第八章），或 Nitro 的 process.env（只在服务端可见）。密钥永远不要出现在 app\u002F 下的代码里——打包产物是给人下载的文件。",{"id":13388,"title":261,"titles":13389,"content":13390,"level":454},"\u002Ftutorials\u002Fnuxt#️-本章的坑-6",[5,13109],"1. server\u002Fapi 与前端路由撞名。 有人把接口放 app\u002Fpages\u002Fapi\u002F…（当页面渲染了）或把页面放 server\u002Fapi\u002F…（当成接口了）。分界很清楚：server\u002F 是接口，app\u002Fpages\u002F 是页面。 2. 忘写方法后缀。 server\u002Fapi\u002Fnotes.ts 会接受所有方法——一个本该只读的接口就这样成了 POST 入口。显式 .get.ts。 3. 在 handler 里 console.log 当调试手段，客户端看不到。 服务端日志打印在跑 nuxt dev 的那个终端里，不是浏览器控制台。「接口没反应」先去终端找。 4. readBody 对 GET 请求没意义。 有些客户端发 GET 带 body，Nitro 默认不解析；参数请用 getQuery \u002F getRouterParam。 5. 上传文件没配 bodySizeLimit。 大文件被截断或 413。事件里用 readMultipartFormData(event, { limit })，并同步调 Nginx 的 client_max_body_size。 6. 直接 return 了 Buffer\u002Fstream 却忘了设头。 返回二进制请 setHeader(event, 'content-type', 'application\u002Fpdf') 再送（或用 event.node.res 明确接管）。 7. 在接口里做重计算，没加缓存。 每次请求都全量算 → CPU 打满。用 routeRules 的 swr\u002Fisr（第九章）或 defineCachedEventHandler： export default defineCachedEventHandler(async () => heavy(), { maxAge: 60, swr: true, name: 'report' }) 8. 中间件里 return 了东西。 server\u002Fmiddleware\u002F*.ts 的返回值会被忽略（它是「路过看一眼」的角色）；要拦截请 throw createError(...)。 9. dev 环境改 server\u002Futils 后类型没更新。 跑 nuxt prepare 或重启 dev server，TS 服务才会重新读自动导入声明。",{"id":13392,"title":6538,"titles":13393,"content":13394,"level":454},"\u002Ftutorials\u002Fnuxt#附h3-工具函数速查",[5,13109],"工具 作用 defineEventHandler(fn) 声明一个 handler（唯一的入口包装） getQuery(event) 解析查询串成对象 getRouterParam(event, 'id') \u002F event.context.params 路径参数 readBody(event) \u002F readValidatedBody(event, schema) 请求体（后者可配 zod 校验） readRawBody \u002F readMultipartFormData 原始体 \u002F 表单上传 getCookie \u002F setCookie \u002F deleteCookie cookie getHeader \u002F setHeader \u002F setHeaders 请求\u002F响应头 setResponseStatus(event, code, msg) 状态码 sendRedirect(event, url, code) 3xx createError({ statusCode, statusMessage }) 抛出结构化错误（前端 error.data 能拿到） useRuntimeConfig() 运行时配置（第八章） defineCachedEventHandler(fn, opts) 接口级缓存（maxAge\u002Fswr\u002Fvariables） event.context 本次请求的上下文（可挂东西，跨中间件传值）",{"id":13396,"title":13118,"titles":13397,"content":13398,"level":437},"\u002Ftutorials\u002Fnuxt#八运行时配置与环境变量runtimeconfig",[5],"「同一个包，跑在开发\u002F测试\u002F生产三套环境」——这件事必须由配置层解决，而不是改代码。Nuxt 的答案是 runtimeConfig + .env。",{"id":13400,"title":6731,"titles":13401,"content":13402,"level":454},"\u002Ftutorials\u002Fnuxt#一份配置两种可见性",[5,13118],"\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  runtimeConfig: {\n    \u002F\u002F 只有服务端能读到（Nitro handler、SSR 阶段的代码）\n    dbUrl: '',\n    apiSecret: '',\n    \u002F\u002F 以 public 开头的会进客户端产物（构建时被替换成实际值）\n    public: {\n      apiBase: '\u002Fapi',\n      siteName: '码上智能',\n      mapKey: ''\n    }\n  }\n}) # .env（本地开发用；生产由部署环境注入，别把 .env 提交进仓库）\nNUXT_DB_URL=postgres:\u002F\u002Fuser:pass@127.0.0.1:5432\u002Fshop\nNUXT_PUBLIC_API_BASE=https:\u002F\u002Fapi.yoursite.com\nNUXT_PUBLIC_MAP_KEY=abc123 命名规则固定：runtimeConfig 里的字段名 → 环境变量名 = NUXT_ + 大写下划线；嵌套就是 NUXT_PUBLIC_XXX。这个映射是自动的，不需要手写对应关系。",{"id":13404,"title":6888,"titles":13405,"content":13406,"level":454},"\u002Ftutorials\u002Fnuxt#读取",[5,13118],"\u002F\u002F app 侧（组件\u002F组合式函数）\nconst cfg = useRuntimeConfig()\ncfg.public.apiBase          \u002F\u002F ✓ 有\ncfg.dbUrl                   \u002F\u002F ✗ 客户端拿到 undefined（SSR 阶段能读到）\n\n\u002F\u002F server 侧\nexport default defineEventHandler(() => {\n  const cfg = useRuntimeConfig()\n  return query(cfg.dbUrl)   \u002F\u002F ✓ 密钥只在服务端出现\n}) 关键认知：public.* 的值在构建期就被写死进产物。用户 Ctrl+U 查看 JS 文件就能读到它。所以「公开的配置」不等于「不敏感的配置」，而是「即使被看到也没关系的」——站点名、公开接口地址可以；任何能花钱或被刷的东西不行。",{"id":13408,"title":6996,"titles":13409,"content":13410,"level":454},"\u002Ftutorials\u002Fnuxt#开发期跨域代理",[5,13118],"前端调本机另一个后端（比如 Java 服务在 8080）时，用 dev 代理避免 CORS： export default defineNuxtConfig({\n  vite: {\n    server: {\n      proxy: { '\u002Fapi': { target: 'http:\u002F\u002Flocalhost:8080', changeOrigin: true } }\n    }\n  }\n}) 这条只在 nuxt dev 有效。 生产环境必须由 Nginx\u002F网关把 \u002Fapi 反代到后端（第十三章），否则线上 404——这是「本地一切正常，上线接口全挂」的经典成因。",{"id":13412,"title":261,"titles":13413,"content":13414,"level":454},"\u002Ftutorials\u002Fnuxt#️-本章的坑-7",[5,13118],"1. 把密钥写进 public。 它会被打进前端包。写完检查一遍产物：grep -r \"sk-\" .output\u002Fpublic\u002F_nuxt\u002F。 2. 在客户端读非 public 字段。 cfg.dbUrl 是 undefined，报错信息常是「undefined is not an object」，很难联想到环境变量。规则：非 public 字段只在 server\u002F 和 SSR 阶段用。 3. 环境变量名打错没报错。 NUXT_DB_URl 这类拼写错误不会提示，读到空串。给配置加显式校验： if (!process.env.NUXT_DB_URL) throw new Error('缺少 NUXT_DB_URL') 4. .env 提交进仓库。 至少要在 .gitignore 里排除，并给一份 .env.example 列出需要哪些键（不含值）。 5. 以为改了 .env 就生效。 需要重启 dev server；生产要重启进程。nuxt generate 的 public 值已经烧进产物，改环境变量必须重新 generate。 6. 静态产物里改不了 public.*。 这是「一次构建多处部署」的障碍。解法：把可变配置改成运行时接口（\u002Fapi\u002Fconfig），或在部署脚本里做字符串替换。 7. import.meta.env 与 useRuntimeConfig() 混用。 前者是 Vite 层的构建期常量（VITE_*，前端可读），后者是 Nuxt 层的运行时配置（服务端可读、可被环境变量覆盖）。优先 runtimeConfig：一套写法同时覆盖服务端和客户端。 8. 在 nuxt.config.ts 里直接读 process.env 拼进 runtimeConfig。 可以，但别忘了构建机上没有生产环境变量，产物里就固化成空串——正确姿势是让 runtimeConfig 字段留空，由运行期环境变量填充（NUXT_XXX）。",{"id":13416,"title":7232,"titles":13417,"content":13418,"level":454},"\u002Ftutorials\u002Fnuxt#附配置来源优先级",[5,13118],"来源 时机 客户端可见 典型用途 runtimeConfig.public.* 构建期固化 ✓ 公开 API 地址、站点名、开关 runtimeConfig.*（非 public） 运行期读环境变量 ✗ 数据库、第三方密钥 NUXT_* 环境变量 部署平台注入 视是否 public 分环境差异 import.meta.env.VITE_* 构建期常量 ✓ 构建模式、非敏感常量 服务端 \u002Fapi\u002Fconfig 运行期请求 ✓ 需要「同一份产物改配置」的场景 app.config（defineAppConfig） 构建期 ✓ UI 常量（主题色、菜单结构），不可被环境变量覆盖",{"id":13420,"title":13126,"titles":13421,"content":13422,"level":437},"\u002Ftutorials\u002Fnuxt#九渲染模式与-routerules同一站点里不同页面用不同策略",[5],"前面反复出现「SSR」和「generate」。这一节把它们讲成可以按路径配置的东西：routeRules。",{"id":13424,"title":7368,"titles":13425,"content":13426,"level":454},"\u002Ftutorials\u002Fnuxt#四个开关",[5,13126],"export default defineNuxtConfig({\n  ssr: true,                                    \u002F\u002F 全站默认：服务端渲染\n  nitro: { prerender: { crawlLinks: true, routes: ['\u002F', '\u002Fabout'] } },   \u002F\u002F 构建期出 HTML\n  routeRules: {\n    '\u002Fadmin\u002F**':    { ssr: false },                     \u002F\u002F 这个子树走纯客户端（SPA）\n    '\u002Fblog\u002F**':     { prerender: true },                \u002F\u002F 构建期生成静态 HTML\n    '\u002Fproducts\u002F**': { swr: 60 },                        \u002F\u002F 缓存 60s，过期后台刷新\n    '\u002Fapi\u002Fsearch':  { cors: true, headers: { 'cache-control': 'no-store' } },\n    '\u002Fold-path':    { redirect: '\u002Fnew-path' },          \u002F\u002F 301（写 { to, code } 改状态码）\n    '\u002Flegacy\u002F**':   { proxy: 'https:\u002F\u002Fold.example.com\u002F**' },\n    '\u002Fprivacy':     { isr: true }                       \u002F\u002F 平台按需 ISR（Vercel\u002FNetlify 等）\n  }\n}) 模式 配置 首屏 HTML 有正文 服务器要跑 Node 适合 SSR 默认（ssr: true） ✓ 要 内容随请求变化、需要 SEO SPA ssr: false（全站或路由级） ✗ 可不要（纯发静态文件时） 后台系统、登录后的界面 SSG nitro.prerender \u002F { prerender: true } ✓ 不要（纯静态） 博客、文档、营销页（本站就是这个模式） SWR { swr: 60 } ✓ 要 内容变化不敏感、想挡住突发流量 ISR { isr: true } ✓ 平台决定 部署在支持按需再生成的托管平台",{"id":13428,"title":7669,"titles":13429,"content":13430,"level":454},"\u002Ftutorials\u002Fnuxt#匹配优先级越具体越优先",[5,13126],"多条规则命中同一个 URL 时，按路径精确度排序：\u002Fblog\u002Fhello 精确规则 > \u002Fblog\u002F** > \u002F**。所以「给全站设默认，再覆盖某几个路径」是能成立的——但反着写会翻车： routeRules: {\n  '\u002F**': { ssr: false },              \u002F\u002F ← 全站关掉 SSR\n  '\u002Fblog\u002F**': { prerender: true }     \u002F\u002F 仍然会预渲染，但 ssr:false 让产物是空壳！\n} 这类组合的后果很隐蔽：预渲染出来的是不含正文的 HTML。示例 07 的「URL 判定器」就是让你手动改一下顺序、亲眼看结论怎么翻转。",{"id":13432,"title":7747,"titles":13433,"content":13434,"level":454},"\u002Ftutorials\u002Fnuxt#什么时候真的需要-ssr",[5,13126],"三条判断，任何一条成立才值得留服务端： 未登录用户能看到的内容需要被索引（首屏正文必须在 HTML 里）。 数据必须按请求计算（价格、库存、权限）。 首屏时间对业务指标有直接影响，且接口足够快。 都不成立时 ssr: false 更省：部署是纯静态文件、服务器不跑 Node，也不会遇到「SSR 阶段 window is not defined」（第五章第 2 条）。",{"id":13436,"title":261,"titles":13437,"content":13438,"level":454},"\u002Ftutorials\u002Fnuxt#️-本章的坑-8",[5,13126],"1. ssr: false 之后 SEO 归零。 产物 HTML 是空壳。别以为「反正有水合」——爬虫与分享卡片读的是源码（示例 01 有对照）。 2. prerender: true + ssr: false 同时命中。 生成空壳静态页（上面那段代码）。要么去掉 \u002F**: { ssr: false }，要么给需要预渲染的路径显式合并写 { prerender: true, ssr: true }。 3. 预渲染没覆盖所有路由。 crawlLinks 只沿页面里的 \u003CNuxtLink> 爬。没有链接指向的页面（只在 sitemap 里、或列表第二页）不会生成，generate 后访问就是 404。解法：nitro.prerender.routes 显式列，或让列表页给出全部链接。本站的工具页是手工维护一份清单进 routes 的（nuxt.config.ts 里的 TOOL_PRERENDER_ROUTES）。 4. 预渲染阶段接口必须可用。 generate 时页面里每个 useFetch 都会真的执行。若接口依赖登录态或外部服务，构建要么失败、要么把「未登录」的空数据固化进 HTML。这类页面改成 { ssr: false } 或 { prerender: false }。 5. swr\u002Fisr 依赖缓存存储。 默认是内存缓存，多实例部署各节点一份、命中率与一致性都差。要一致请配 nitro: { storage: { cache: … } }（Redis 等）。 6. 动态路由的预渲染要自己提供数据源。 原则：能爬到的就不用列，爬不到的才列。 nitro: {\n  prerender: {\n    crawlLinks: true,\n    routes: async () => {\n      const { data } = await $fetch('https:\u002F\u002Fapi.example.com\u002Fsitemap-urls')\n      return data.map((u) => u.path)\n    }\n  }\n} 7. routeRules 里写的 headers 与 Nginx 设的头冲突。 最终结果取决于先后。排查顺序：先 curl -I 看框架输出，再看 Nginx add_header。 8. 渲染模式与服务端中间件的期望不一致。 { ssr: false } 的路由上，服务端不会为页面渲染执行（但 \u002Fapi 仍会）。别指望用服务端中间件给 SPA 页面「注入用户信息」。 9. 以为 routeRules 能改客户端行为。 它管的是服务端\u002F构建阶段的策略；组件里的 onMounted 照旧只在浏览器跑。",{"id":13440,"title":13133,"titles":13441,"content":13442,"level":454},"\u002Ftutorials\u002Fnuxt#附routerules-常用字段",[5,13126],"字段 类型 作用 ssr boolean 该路径是否服务端渲染 prerender boolean 构建期生成静态 HTML swr number\u002Ftrue stale-while-revalidate 缓存秒数 isr boolean\u002Fnumber 增量静态再生成（托管平台） cache { maxAge, swr, group, variables } 更细粒度的缓存控制 redirect string 或 { to, code } 重定向（默认 301） proxy string 反向代理（老站合并常用） headers object 加响应头（cache-control、CSP） cors boolean 允许跨域 static boolean 当静态文件处理 noIndex boolean 响应加 x-robots-tag: noindex payloadExtraction boolean payload 拆成 _payload.json（关了就内联进 HTML）",{"id":13444,"title":8202,"titles":13445,"content":13446,"level":437},"\u002Ftutorials\u002Fnuxt#十seo-与-head标题metasitemap结构化数据",[5],"内容站的成败往往不在交互，而在「搜索引擎和分享卡片怎么看你」。Nuxt 把 \u003Chead> 管理做成了组合式函数，SSR 阶段就会写进 HTML——这正是静态站点也能拿 SEO 的原因。",{"id":13448,"title":13137,"titles":13449,"content":13450,"level":454},"\u002Ftutorials\u002Fnuxt#标题与描述useseometa",[5,8202],"\u003Cscript setup>\nconst page = \u002F* … 取到的文档 … *\u002F\n\nuseSeoMeta({\n  title: page.title,                        \u002F\u002F 浏览器标签与搜索结果标题\n  description: page.description,            \u002F\u002F 搜索结果里的摘要\n  ogTitle: page.title,                      \u002F\u002F 社交平台卡片\n  ogDescription: page.description,\n  ogImage: '\u002Fimg\u002F' + page.category + '-cover.svg',\n  ogType: 'article',\n  twitterCard: 'summary_large_image',\n  robots: 'index, follow'\n})\n\n\u002F\u002F 需要更底层控制（link、script、html 属性）时用 useHead\nuseHead({\n  link: [{ rel: 'canonical', href: 'https:\u002F\u002Fyour-site.com\u002Ftutorials\u002Fvue3' }],\n  htmlAttrs: { lang: 'zh-CN' },\n  script: [{ src: 'https:\u002F\u002F…', defer: true }]\n})\n\u003C\u002Fscript> useSeoMeta 与 useHead 的关系：前者是后者的语义化包装（自动把 title\u002Fdescription 写成对应的 \u003Ctitle>、\u003Cmeta name=\"description\">、og: 与 twitter:）。优先用 useSeoMeta，写不出来的字段再回 useHead。",{"id":13452,"title":13139,"titles":13453,"content":13454,"level":454},"\u002Ftutorials\u002Fnuxt#全站默认值放在-appvue-或配置里",[5,8202],"export default defineNuxtConfig({\n  app: {\n    head: {\n      charset: 'utf-8',\n      viewport: 'width=device-width, initial-scale=1',\n      link: [{ rel: 'icon', href: '\u002Ffavicon.ico' }],\n      meta: [{ name: 'theme-color', content: '#0969da' }]\n    }\n  },\n  site: {\n    url: 'https:\u002F\u002Fyour-site.com',    \u002F\u002F 绝对地址的基准：sitemap \u002F og:url \u002F JSON-LD 都读它\n    name: '站名'\n  }\n}) site.url 是最容易被遗忘的一项：默认值是 http:\u002F\u002Flocalhost:3000，忘记改就会生成一份全是本地地址的 sitemap，且 og:url 也错（本站在 nuxt.config.ts 里给它写了显眼注释）。",{"id":13456,"title":8590,"titles":13457,"content":13458,"level":454},"\u002Ftutorials\u002Fnuxt#sitemap-与-robots",[5,8202],"npm install @nuxtjs\u002Fsitemap export default defineNuxtConfig({\n  modules: ['@nuxtjs\u002Fsitemap'],\n  site: { url: 'https:\u002F\u002Fyour-site.com' },\n  sitemap: {\n    autoLastmod: true,\n    exclude: ['\u002Fadmin\u002F**', '\u002Fprivate\u002F**', '\u002Ftools\u002F**?*_=*']\n  }\n}) robots.txt 建议直接放 public\u002Frobots.txt（静态、零成本）。要按环境屏蔽预发站，就在 public\u002Frobots.txt 里写 Disallow: \u002F，或用中间件在预发域名返回不同内容——别依赖 \u003Cmeta name=\"robots\"> 拦已泄露的 URL，它要求页面能被访问到才生效。",{"id":13460,"title":8707,"titles":13461,"content":13462,"level":454},"\u002Ftutorials\u002Fnuxt#结构化数据json-ld",[5,8202],"\u003Cscript setup>\nuseHead({\n  script: [{\n    type: 'application\u002Fld+json',\n    innerHTML: JSON.stringify({\n      '@context': 'https:\u002F\u002Fschema.org',\n      '@type': 'TechArticle',\n      headline: page.title,\n      datePublished: page.date,\n      author: { '@type': 'Organization', name: '码上智能' }\n    })\n  }]\n})\n\u003C\u002Fscript> innerHTML 一定要 JSON.stringify（对象直接传会被转成 [object Object]）。上线前拿 Google 的「富媒体测试」和「网址检查」各跑一次，比自查有效。",{"id":13464,"title":8851,"titles":13465,"content":13466,"level":454},"\u002Ftutorials\u002Fnuxt#防主题闪烁一段内联脚本",[5,8202],"有暗色模式的站点会遇到 FOUC：预渲染的 HTML 里没有 data-theme，暗色用户先看到一帧亮色。标准解法是在 \u003Chead> 里放一段内联同步脚本，早于首次绘制把主题写进 \u003Chtml>： \u003Cscript setup>\nuseHead({\n  script: [{\n    tagPosition: 'head',\n    innerHTML: `(function(){try{var t=localStorage.getItem('site-theme');` +\n      `if(t!=='dark'&&t!=='light'){t=matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}` +\n      `document.documentElement.setAttribute('data-theme',t);}catch(e){}})();`\n  }]\n})\n\u003C\u002Fscript> 这段是本站 app\u002Fapp.vue 里真实在用的代码。要点：必须在 head、必须内联、必须 try\u002Fcatch（隐私模式读 localStorage 会抛）。",{"id":13468,"title":261,"titles":13469,"content":13470,"level":454},"\u002Ftutorials\u002Fnuxt#️-本章的坑-9",[5,8202],"1. useSeoMeta 写在异步分支里。 它必须在 setup 同步执行期调用。放进 if\u002FonMounted 里，SSR 出的 HTML 就没有这个 meta（客户端才补上，爬虫看不到）。 2. 动态标题依赖客户端数据。 正确顺序是：await useFetch() 拿到数据 → 立刻 useSeoMeta({ title: data.title })。数据没在服务端取到，标题就只能是默认值。 3. site.url 忘了改。 生成的是 localhost 的绝对地址（本章「全站默认值」那节的 site 配置就是它）。 4. og:image 用了相对路径。 平台抓卡片时不会拼你的域名，必须给绝对 URL：useSeoMeta({ ogImage: config.siteUrl + cover }) 或让 site.url 帮你补全（新版本会补，但要实测确认，别赌）。 5. 预渲染页面的 meta 被固化。 generate 时每个页面各算各的 head，运行时改不了。若 meta 依赖「当前用户」，这类页面就不该预渲染。 6. 同一 key 的 head 重复注入。 在循环\u002F子组件里调用 useHead 会产生多个 \u003Ctitle>。全站级的写在 app.vue，页面级的写在页面里。 7. 用 \u003CClientOnly> 包住正文。 屏蔽闪烁的目的若是「不让爬虫看到」，那 SEO 目标就同时落空了。正文要 SSR，只把「依赖浏览器环境的按钮\u002F图表」放进 \u003CClientOnly>。 8. robots 与 noindex 打架。 网关加了 X-Robots-Tag: noindex，页面 meta 却写 index——头信息优先。排查索引问题先看 curl -I。",{"id":13472,"title":9072,"titles":13473,"content":13474,"level":454},"\u002Ftutorials\u002Fnuxt#附head-相关-api",[5,8202],"API 用途 useHead(input) 任意 head 标签（link\u002Fscript\u002Fstyle\u002FhtmlAttrs） useSeoMeta(input) title\u002Fdescription\u002Fog\u002Ftwitter 的语义化写法 useServerSeoMeta 同上，但明确只在服务端输出（减小客户端体积） setHead \u002F head（app.vue 里） 全站默认 defineCachedEventHandler + cache 让带 meta 的 HTML 也进缓存 useRequestURL() 拿到当前请求的绝对 URL（canonical \u002F og:url 用）",{"id":13476,"title":13146,"titles":13477,"content":13478,"level":437},"\u002Ftutorials\u002Fnuxt#十一状态共享usestate-与-pinia",[5],"《Vue 3 教程》第十五章讲了 Pinia 本身；这里只讲在 Nuxt 里用它的三件额外事，以及一个 Nuxt 特有的轻量替代品。",{"id":13480,"title":13149,"titles":13481,"content":13482,"level":454},"\u002Ftutorials\u002Fnuxt#先试-usestatessr-安全的极简共享状态",[5,13146],"\u002F\u002F 任意组件\u002F组合式函数里\nconst panel = useState('side-panel', () => ({ open: false, tab: 'toc' }))\npanel.value.open = true      \u002F\u002F 全站共享，且服务端每次请求各自一份（不会串号） useState 的数据会进 payload：服务端写的值，客户端水合时直接复用，不重复请求。它适合「一次会话内的界面状态」：侧栏开合、当前筛选、向导步骤。不适合需要 getter\u002Faction\u002Fdevtools 的复杂逻辑。 三条限制要清楚：值必须可序列化（不能存函数、DOM 引用、类实例）；key 是全局的（撞 key 就撞数据）；它不是持久化，刷新就没了（要落盘自己配 watch + localStorage，Vue 教程第十三章的 useLocalStorage 正合适）。",{"id":13484,"title":9263,"titles":13485,"content":13486,"level":454},"\u002Ftutorials\u002Fnuxt#pinia装进-nuxt-的三步",[5,13146],"npm install pinia @pinia\u002Fnuxt \u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({ modules: ['@pinia\u002Fnuxt'] }) \u002F\u002F app\u002Fstores\u002Fuser.ts   —— 与《Vue 3 教程》第十五章完全同构\nimport { defineStore } from 'pinia'\n\nexport const useUserStore = defineStore('user', () => {\n  const profile = ref(null)\n  const isLoggedIn = computed(() => !!profile.value)\n\n  async function load() {\n    profile.value = await $fetch('\u002Fapi\u002Fme')        \u002F\u002F SSR 阶段也会执行\n  }\n  function logout() { profile.value = null }\n  return { profile, isLoggedIn, load, logout }\n}) \u003Cscript setup>\nconst user = useUserStore()\nawait user.load()          \u002F\u002F 想让它进 SSR HTML 就在 setup 顶层 await\n\u003C\u002Fscript> defineStore 可以留在 app\u002Fstores\u002F（Nuxt 不自动导入 store，需要 import）；要免 import 就放进 app\u002Fcomposables\u002F 里包一层。",{"id":13488,"title":9540,"titles":13489,"content":13490,"level":454},"\u002Ftutorials\u002Fnuxt#ssr-场景的三条铁律",[5,13146],"每个请求一个 Pinia 实例。 @pinia\u002Fnuxt 已经做了这件事（插件里 createPinia() 每次请求新建）。你自己在模块顶层创建的普通单例（const cache = new Map()）会跨请求共享——这是 SSR 最危险的一类 bug：A 用户看到了 B 用户的数据。 store 初始化数据要走 useAsyncData\u002FuseState，不要 onMounted。否则服务端渲染时 store 是空的，首屏正文也就是空的。 持久化只在客户端做。localStorage 在服务端不存在，SSR 阶段访问它会报错。用 import.meta.client 守卫： if (import.meta.client) {\n  watch(() => store.theme, (v) => localStorage.setItem('theme', v), { immediate: true })\n} 跨课程提示：上面这个示例属于《Vue 3 教程》。在 Nuxt 里唯一多出来的就是「实例按请求创建」这一层，其余用法不变。",{"id":13492,"title":261,"titles":13493,"content":13494,"level":454},"\u002Ftutorials\u002Fnuxt#️-本章的坑-10",[5,13146],"1. 模块顶层的可变单例。 const state = reactive({ list: [] }) 写在 \u003Cscript> 顶层或某个 utils 文件里，SSR 下所有请求共用 → 数据串号。放进 useState() \u002F defineStore \u002F 或 defineNuxtPlugin 内部创建。 2. useState 存了不可序列化的东西。 存 class 实例、函数、DOM 元素 → payload 里丢失或报错，表现为「服务端有值、客户端 undefined」。只存纯数据。 3. localStorage 在 SSR 阶段裸奔。 window is not defined \u002F localStorage is not defined。三种正解：import.meta.client 判断、{ server: false }、\u003CClientOnly>。 4. 忘记水合导致「闪一下」。 服务端把 store 初始值写进 payload，若客户端又在 onMounted 里重新请求一遍，界面会出现「旧值 → 空 → 新值」。改成 setup 顶层 await 一次。 5. store 里直接读 useRoute()。 可以，但要在 setup 上下文中；且注意服务端阶段的 route 与客户端阶段可能不同（重定向后）。需要「进入页面时的路由」请存进局部变量。 6. $reset() 在 setup store 里不存在。 与《Vue 3 教程》第十五章第 3 条一样：自己写一个 $reset() 返回。 7. 把 SSR 阶段取的用户数据留在 Pinia 里返回给别的用户。 严格来说这是第 1 条的后果，但值得单列：任何「按请求的状态」都必须由 Pinia\u002FuseState 托管，不能由模块变量托管。上线前用两个不同账号并发压一次，看有没有串。",{"id":13496,"title":9761,"titles":13497,"content":13498,"level":454},"\u002Ftutorials\u002Fnuxt#附共享状态选型",[5,13146],"需求 用什么 一次访问内的界面状态（侧栏、筛选、步骤） useState 需要 getter\u002Faction\u002F调试面板的业务状态 Pinia 服务端数据缓存与刷新 useFetch \u002F useAsyncData（第五章）+ getCachedData 只有当前组件用 ref \u002F reactive 父到子 \u002F 子到父 props \u002F emit 主题、语言这类环境值 useState 或 app.config（构建期常量） URL 能表达的状态（分页、tab、关键词） 路由 query",{"id":13500,"title":9848,"titles":13501,"content":13502,"level":437},"\u002Ftutorials\u002Fnuxt#十二插件中间件与组合式函数挂到生命周期上",[5],"这三样是 Nuxt 里「组织代码」的主要手段，分工很清楚： 手段 触发时机 典型用途 路由中间件 app\u002Fmiddleware\u002F 每次导航（客户端）\u002F 服务端渲染前 鉴权、语言切换、埋点重定向 Nuxt 插件 app\u002Fplugins\u002F 应用初始化（每个请求各跑一次） 注册 $http、注入全局样式逻辑、接第三方 SDK 组合式函数 app\u002Fcomposables\u002F 你在组件里调用时 复用「状态 + 逻辑」 服务端中间件 server\u002Fmiddleware\u002F 每个进入服务端的请求 日志、CORS、请求计时",{"id":13504,"title":9932,"titles":13505,"content":13506,"level":454},"\u002Ftutorials\u002Fnuxt#路由中间件",[5,9848],"\u002F\u002F app\u002Fmiddleware\u002Fauth.ts   （文件名即中间件名：definePageMeta({ middleware: 'auth' })）\nexport default defineNuxtRouteMiddleware((to) => {\n  const user = useState('user', () => null)\n  if (!user.value && to.meta.requiresAuth !== false) {\n    return navigateTo(`\u002Flogin?redirect=${to.fullPath}`)     \u002F\u002F 返回跳转 = 拦截\n  }\n  \u002F\u002F 返回 undefined 放行；return abortNavigation('消息') 直接进错误页\n}) 三种注册方式： definePageMeta({ middleware: ['auth'] })            \u002F\u002F 命名中间件（app\u002Fmiddleware\u002F 里的文件）\ndefinePageMeta({ middleware: [(to) => { \u002F* … *\u002F }] }) \u002F\u002F 行内\ndefinePageMeta({ middleware: (to) => { if (to.meta.globalSkip) return } })  \u002F\u002F 单个页面加全局逻辑\n\n\u002F\u002F 全局中间件（每个路由都跑）：文件名加 .global.ts\n\u002F\u002F app\u002Fmiddleware\u002Flogger.global.ts 异步中间件直接 async + await 即可。顺序：全局在前、命名在后、行内最后；多个中间件里只要有一个返回了跳转，后面的就不再执行。",{"id":13508,"title":10157,"titles":13509,"content":13510,"level":454},"\u002Ftutorials\u002Fnuxt#nuxt-插件提供全局能力",[5,9848],"\u002F\u002F app\u002Fplugins\u002Fhttp.ts\nexport default defineNuxtPlugin(() => {\n  const config = useRuntimeConfig()\n  const http = $fetch.create({ baseURL: config.public.apiBase, retry: 1 })\n  http.interceptors.request.use((ctx) => { \u002F* 统一加 token *\u002F })\n  return { provide: { http } }          \u002F\u002F 组件里：const { $http } = useNuxtApp()\n}) \u002F\u002F app\u002Fplugins\u002Ftrack.ts —— 需要在导航上挂钩子的插件\nexport default defineNuxtPlugin((nuxtApp) => {\n  nuxtApp.hook('page:start', (comp) => { \u002F* 页面开始渲染 *\u002F })\n  nuxtApp.hook('page:finish', () => { \u002F* 页面渲染完，做埋点 *\u002F })\n  nuxtApp.hook('app:mounted', () => { \u002F* 仅客户端 *\u002F })\n}) 常用钩子：app:created、app:mounted、page:start、page:finish、vue:setup（这个钩子里才能用 onServerPrefetch）、meta:watch。 插件按请求执行（SSR 下每个请求都跑一遍）。所以：不要在插件外面创建可变单例；注册全局样式\u002F第三方 SDK 时注意别重复挂载；重逻辑放 useState\u002FuseAsyncData 里让 Nuxt 管缓存。 组合式函数与自动导入（app\u002Fcomposables\u002FuseXxx.ts）的写法和《Vue 3 教程》第十三章完全一致，Nuxt 只多给你一条便利：放进目录就不用 import。",{"id":13512,"title":10411,"titles":13513,"content":13514,"level":454},"\u002Ftutorials\u002Fnuxt#服务端中间件",[5,9848],"\u002F\u002F server\u002Fmiddleware\u002Ftiming.ts\nexport default defineEventHandler((event) => {\n  const start = Date.now()\n  event.context.requestId = crypto.randomUUID()\n  setHeader(event, 'x-request-id', event.context.requestId)\n  \u002F\u002F 返回值在这里被忽略；要做「响应后」的事用 event.waitUntil 或钩子\n})",{"id":13516,"title":261,"titles":13517,"content":13518,"level":454},"\u002Ftutorials\u002Fnuxt#️-本章的坑-11",[5,9848],"1. 全局中间件命名忘加 .global。 app\u002Fmiddleware\u002Fauth.ts 只在被 definePageMeta 引用时生效。想全站生效请 auth.global.ts。 2. 中间件里 await 了但没 return 跳转。 navigateTo('\u002Flogin') 不加 return，导航会继续（表现为「跳了又跳回来」）。中间件的返回值就是控制流。 3. 中间件里调 useFetch 报错。 中间件不在组件 setup 上下文里，某些组合式（useState 部分用法、useNuxtApp 的组件级 API）不可用。要取数据请放页面里，或在中间件用 $fetch。 4. 插件里创建可变单例。 「第一个用户设置了主题，第二个用户的主题跟着变」就是它（第十一章第 1 条）。 5. 第三方 SDK 在 SSR 阶段被 import 就炸。 它一加载就读 window。解法：动态 await import() 并只在客户端执行（if (import.meta.client)），或 plugins 里用 apply: 'client'（写法随版本略有差异，实测为准）。 6. 客户端与服务端各跑一遍插件，导致重复初始化。 日志里看到同一个 SDK 初始化两次——一次是 SSR、一次是水合，属正常；真需要只跑一次就用 callOnce。 7. 插件文件名带连字符，注入的 key 变了。 plugins\u002Fmy-http.ts 提供 $http 时，key 由你 provide 的对象决定，跟文件名无关；但用默认导出名（export default defineNuxtPlugin(...) 里返回 { provide: { mySdk } }）时请显式命名，别指望自动推断。 8. 服务端中间件里 return 了响应。 中间件的返回被忽略（与 h3 那套一致），想短路请 throw createError(...)。",{"id":13520,"title":10629,"titles":13521,"content":13522,"level":454},"\u002Ftutorials\u002Fnuxt#附生命周期顺序一次页面访问",[5,9848],"阶段 服务端 客户端 1 全局中间件 → 页面中间件 全局中间件 → 页面中间件 2 app:created（插件按请求执行） 插件（水合阶段不重复创建应用，但 setup 钩子会跑） 3 组件 setup 顶层 await（useFetch\u002Fstore.load） 组件 setup 4 page:start → 渲染 HTML → page:finish 水合 → app:mounted → onMounted 5 响应带 payload 发出 后续导航：组件复用，onMounted 不再跑（除非 key 变）",{"id":13524,"title":10722,"titles":13525,"content":13526,"level":437},"\u002Ftutorials\u002Fnuxt#十三部署静态产物子路径与-nginx",[5],"本站就是这个模式的真实案例：Nuxt 生成的静态产物交给已有的 Docker Nginx 托管，挂在 \u002Fnuxt\u002F 子路径下。下面把整条链路和踩过的坑一次讲完。",{"id":13528,"title":10736,"titles":13529,"content":13530,"level":454},"\u002Ftutorials\u002Fnuxt#路线选择",[5,10722],"目标 命令 产物 服务器要什么 纯静态站点（内容站、文档、营销页） nuxt generate .output\u002Fpublic\u002F Nginx \u002F OSS \u002F 任意静态托管 需要按请求渲染或 \u002Fapi 接口 nuxt build .output\u002F（含 server\u002Findex.mjs） Node 22+ 进程，或 Docker 托管平台（Vercel \u002F Netlify \u002F Cloudflare） nuxt build 平台自适应 preset 平台自带 容器化部署 nuxt build + 官方镜像 一个镜像 见本章最后",{"id":13532,"title":10827,"titles":13533,"content":13534,"level":454},"\u002Ftutorials\u002Fnuxt#静态部署完整链路",[5,10722],"npm run generate          # = nuxt generate，产物在 .output\u002Fpublic 产物形状（本站实测）： .output\u002Fpublic\u002F\n├── index.html\n├── tutorials\u002Fvue3.html           每个路由一个 HTML 文件\n├── tutorials\u002Fvue3\u002F_payload.json  ← 该页的 SSR 数据，水合时复用，不再请求\n├── articles\u002Findex.html\n├── _nuxt\u002F                        打包后的 JS\u002FCSS（带哈希）\n├── img\u002F…  tutorials\u002F…\u002Fdemos\u002F…    来自 public\u002F 的原样文件\n└── 404.html                      预渲染的错误页 子路径部署必须配 app.baseURL。 站点访问地址是 https:\u002F\u002Fsite.com\u002Fnuxt\u002F 时： export default defineNuxtConfig({\n  app: { baseURL: '\u002Fnuxt\u002F' },      \u002F\u002F 注意：首尾都要有斜杠\n  nitro: { prerender: { crawlLinks: true, routes: ['\u002F'] } }\n}) 配了这一行，产物里的 \u003Cscript src>、\u003Clink href>、_payload.json 请求路径、\u003CNuxtLink> 生成的 URL 全部会带上 \u002Fnuxt\u002F 前缀（Vite 的 base 由 app.baseURL 自动推导，不需要重复配）。 这就是本站最容易复发的一次事故：把产物拷到 nginx\u002Fhtml\u002Fnuxt\u002F 目录，但 baseURL 仍是默认的 \u002F，结果 index.html 能返回、资源却去根路径请求 → 白屏 + 一堆 404，看起来像「Nuxt 有问题」。判定方法很简单：查看源码，资源前缀必须和访问前缀一致。示例 08 就是这个检查器。",{"id":13536,"title":10968,"titles":13537,"content":13538,"level":454},"\u002Ftutorials\u002Fnuxt#nginx-配置history-路由-静态产物",[5,10722],"# 场景：产物在 \u002Fusr\u002Fshare\u002Fnginx\u002Fhtml\u002Fnuxt，站点访问 \u002Fnuxt\u002F\nlocation \u002Fnuxt\u002F {\n  alias \u002Fusr\u002Fshare\u002Fnginx\u002Fhtml\u002Fnuxt\u002F;\n  # 1) 静态文件优先；2) 未知路径回落到本站 index.html；3) 都没有则 404\n  try_files $uri $uri\u002F $uri.html \u002Fnuxt\u002Findex.html;\n}\n\n# 若还需要 Nuxt 的服务端接口（build 模式）\nlocation \u002Fnuxt\u002Fapi\u002F {\n  proxy_pass http:\u002F\u002F127.0.0.1:3000\u002Fapi\u002F;\n  proxy_set_header Host $host;\n  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n  proxy_set_header X-Forwarded-Proto $scheme;\n}\n\n# 缓存策略：带哈希的 JS\u002FCSS 可以长期缓存，HTML 与 payload 不能\nlocation \u002Fnuxt\u002F_nuxt\u002F {\n  expires 1y;\n  add_header Cache-Control \"public, immutable\";\n} 三条要点： try_files 的回落地址必须带子路径（\u002Fnuxt\u002Findex.html），否则刷新子页面时被回落到站点根的那个应用。 _payload.json 与 HTML 不能长期缓存，否则改了内容用户还是旧数据（payload 与页面绑定，版本不一致会导致数据错位）。 alias + try_files 的组合有历史坑：某些 Nginx 版本下 alias 与 try_files $uri\u002F 会 404。若怎么配都不对，改用 root 方案（把产物放到 \u003Croot>\u002Fnuxt\u002F）最稳。",{"id":13540,"title":13167,"titles":13541,"content":13542,"level":454},"\u002Ftutorials\u002Fnuxt#服务端模式部署nuxt-build",[5,10722],"node .output\u002Fserver\u002Findex.mjs        # 默认 3000\nPORT=8080 NITRO_HOST=0.0.0.0 node .output\u002Fserver\u002Findex.mjs # 单阶段版（简单）；生产建议用多阶段 + nitro 的 docker preset\nFROM node:22-alpine\nWORKDIR \u002Fapp\nCOPY .output .\u002F\nENV NITRO_HOST=0.0.0.0 PORT=3000\nEXPOSE 3000\nCMD [\"node\", \".\u002Fserver\u002Findex.mjs\"] nuxt build 模式下别忘了：NITRO_...、NUXT_... 环境变量在容器里注入（第八章），别把 .env 拷进镜像。",{"id":13544,"title":11226,"titles":13545,"content":13546,"level":454},"\u002Ftutorials\u002Fnuxt#部署前逐项核对",[5,10722],"检查 命令\u002F方法 资源前缀正确 查看产物 index.html 源码，\u003Cscript src> 前缀 = 访问前缀 全部路由都生成了 `find .output\u002Fpublic -name '*.html' 无未预渲染的 404 逐个访问列表页里的链接；sitemap 条数一致 密钥没进产物 grep -r \"sk-|password\" .output\u002Fpublic sitemap \u002F robots 域名正确 curl https:\u002F\u002Fsite.com\u002Fnuxt\u002Fsitemap.xml history 刷新正常 直接访问 \u002Fnuxt\u002Ftutorials\u002Fvue3 回车 meta 是真实内容 `curl -s URL",{"id":13548,"title":261,"titles":13549,"content":13550,"level":454},"\u002Ftutorials\u002Fnuxt#️-本章的坑-12",[5,10722],"1. baseURL 与访问路径不一致（本站真实事故，上面整段就是为它写的）。 2. 子路径少写一个斜杠。 '\u002Fnuxt' 会拼成 \u002Fnuxtindex.js 这种东西。首尾都要斜杠：'\u002Fnuxt\u002F'。 3. generate 后仍然有服务端接口需求。 静态产物里的 \u002Fapi\u002F* 不存在。需要接口就换 build 模式或把接口挪到别的服务 + Nginx 反代。 4. HTML 被 CDN 缓存。 改了内容线上不变，还「有时对有时错」。给 HTML 明确 Cache-Control: no-cache，带哈希的 _nuxt\u002F* 才用长期缓存。 5. 404.html 没接上。 Nginx 需要 error_page 404 \u002Fnuxt\u002F404.html;（配合 internal），否则未知路径回落 index 时返回 200（软 404，对 SEO 有害）。软 404 检查方法：curl -I https:\u002F\u002Fsite\u002Fnuxt\u002F不存在，状态码必须是 404。 6. 环境变量在构建期固化。 public.* 值改不了「一份产物跑多环境」，只能重新 generate（第八章第 6 条）。 7. 本地 nuxt preview 正常，线上不同步。 preview 的 base 处理与真 Nginx 不完全一样。最终以「产物直接放进目标目录」为准做一次真机验证。 8. Windows 上构建的大小写问题。 本地能 build 过、Linux 容器里失败，八成是 import 路径大小写（《Vue 3 教程》第十八章第 1 条同款）。",{"id":13552,"title":11420,"titles":13553,"content":13554,"level":454},"\u002Ftutorials\u002Fnuxt#附常用部署配置速查",[5,10722],"配置 作用 app: { baseURL: '\u002Fnuxt\u002F' } 站点访问前缀（资源 + 路由 + payload 全靠它） app: { cdnURL: 'https:\u002F\u002Fcdn.x.com' } 把 _nuxt\u002F* 指到 CDN nitro: { prerender: { crawlLinks, routes, concurrency } } 静态生成的范围与并发 nitro: { static: true } 明确纯静态（不生成 server 逻辑） nitro: { output: { dir } } 改产物目录（方便 CI 直接拷） nitro: { preset: 'static' | 'node_server' | 'docker' } 目标平台 nitro: { storage: { cache: { driver: 'redis', … } } } 多实例共享缓存（swr 需要） routeRules: { '\u002Fx': { prerender: true } } 按路径覆盖生成策略",{"id":13556,"title":11519,"titles":13557,"content":11522,"level":437},"\u002Ftutorials\u002Fnuxt#十四性能与产物体积先测再优化",[5],{"id":13559,"title":11525,"titles":13560,"content":13561,"level":454},"\u002Ftutorials\u002Fnuxt#先学会看三个数",[5,11519],"# 1) 产物体积构成\nnpx nuxtanalyze            # 生成一份可视化报告（旧名 nuxi analyze）\nnpx vite-bundle-visualizer # 或者直接看 Vite 产物\n\n# 2) 首屏 HTML 里有没有正文（判断 SSR\u002F预渲染是否真的生效）\ncurl -s https:\u002F\u002Fsite\u002Fnuxt\u002Ftutorials\u002Fvue3 | head -c 2000\n\n# 3) payload 有多大（SSR 数据序列化的代价）\nls -la .output\u002Fpublic\u002Ftutorials\u002Fvue3\u002F_payload.json Chrome DevTools 里看三个指标就够：FCP（首屏有内容）、TTI（能交互，水合完成的时刻）、Total JS（下载量）。SSR 优化的是 FCP，代码分割优化的是 TTI 和下载量。",{"id":13563,"title":11626,"titles":13564,"content":13565,"level":454},"\u002Ftutorials\u002Fnuxt#有效的手段按性价比排",[5,11519],"手段 做法 收益 路由级懒加载 Nuxt 默认对 pages\u002F 组件做了；确认 import() 没被手写静态引入破坏 首屏 JS 大幅下降 别用 barrel 文件 app\u002Fcomponents\u002Findex.ts 里 export * from '.\u002FA' 会把整个目录拖进依赖图 保住 tree-shaking 只取需要的字段 useFetch(url, { pick: ['id','title'] }) \u002F transform payload 与网络双降 图片交给构建 用 @nuxt\u002Fimage（\u003CNuxtImg>）或 assets\u002F + 导入 自动格式\u002F尺寸\u002F懒加载 重组件异步化 defineAsyncComponent(() => import('…'))（图表、编辑器、地图） 首屏不含重组件 列表裁剪 长列表分页\u002F虚拟滚动（《Vue 3 教程》第十一章） 渲染耗时 静态内容别 SSR 两遍 纯静态子树用 v-memo \u002F \u003CClientOnly> 慎用；优先 prerender CPU + 水合成本 依赖预打包检查 vite: { optimizeDeps: { include: [...] } } 只对 dev 生效 开发体验 第三方脚本延后 useHead 里给 defer，或改成用户触发后再注入 TBT（阻塞时长） 关 devtools\u002F分析包 devtools: { enabled: false } 生产无影响但别忘 sourcemap: false 体积与泄露",{"id":13567,"title":11808,"titles":13568,"content":13569,"level":454},"\u002Ftutorials\u002Fnuxt#水合相关的两个具体做法",[5,11519],"首屏之外的东西别水合。 大列表的下半部分、页脚的动态内容可以用 callOnce \u002F 纯静态渲染减少水合成本；\u003CClientOnly> 是逃生舱不是常规手段（它会让 SSR HTML 变空）。 hydration mismatch 的三类源头： \u003C!-- 1) 服务端与客户端算出不同结果 -->\n\u003Cp>{{ Date.now() }}\u003C\u002Fp>                      \u003C!-- ✗ 每次都不同 -->\n\u003Cp>{{ Math.random() }}\u003C\u002Fp>                    \u003C!-- ✗ -->\n\u003Cp>{{ localStorage.getItem('theme') }}\u003C\u002Fp>    \u003C!-- ✗ 服务端没有 -->\n\n\u003C!-- 2) 服务端 HTML 结构被浏览器改了 -->\n\u003Ctbody>\u003Ctr>…\u003C\u002Ftr>\u003C\u002Ftbody>   \u003C!-- 浏览器会把非法嵌套修一遍，水合就对不上 -->\n\n\u003C!-- 3) 内联脚本提前改了 DOM（本站的主题脚本是刻意这么做的，它只写 \u003Chtml> 属性，不动正文） --> 修不了就用 \u003CClientOnly> 包住那一小块，或用 onMounted 之后再赋值（并接受那块没有 SSR 正文）。",{"id":13571,"title":11934,"titles":13572,"content":13573,"level":454},"\u002Ftutorials\u002Fnuxt#测量清单本站用过的",[5,11519],"# 首屏 HTML 是否含正文\ncurl -s URL | grep -c 'article__body'\n\n# 有多少个 JS 文件、总大小\nfind .output\u002Fpublic\u002F_nuxt -name '*.js' | xargs du -ch | tail -1\n\n# 预渲染页数量是否等于路由数\nfind .output\u002Fpublic -name '*.html' | wc -l\n\n# 单个页面 payload 体积\ndu -h .output\u002Fpublic\u002Ftutorials\u002Fvue3\u002F_payload.json",{"id":13575,"title":261,"titles":13576,"content":13577,"level":454},"\u002Ftutorials\u002Fnuxt#️-本章的坑-13",[5,11519],"1. 为了「优化」把正文塞进 \u003CClientOnly>。 首屏 HTML 空了，SEO 与 FCP 双输。SSR 是内容站的基本盘，客户端只留「真的依赖浏览器」的部分。 2. pick 忘用，payload 几 MB。 移动端下载 payload 的时间可能比接口本身还长（第五章第 4 条）。 3. barrel index.ts 让 tree-shaking 失效。 一个 export * from '.\u002Fall-charts' 能把 400KB 拖进首屏。目录里请直接 import 具体文件。 4. 手写 import Chart from 'echarts' 破坏了懒加载。 重组件请 defineAsyncComponent，或在事件回调里 await import()。 5. 生产开了 sourcemap。 体积翻倍且把源码结构泄露出去。sourcemap: false（或用平台托管的隐藏 sourcemap）。 6. 图片放在 assets\u002F 里指望自动压缩。 Nuxt 默认只做哈希与内联阈值，不改尺寸不转格式。响应式图片要 @nuxt\u002Fimage。 7. 字体导致布局抖动。 没写 font-display: swap \u002F 没有 fallback 尺寸，文字会「跳一下」（CLS 变差）。字体建议 public\u002F + \u003Clink rel=\"preload\">。 8. 只优化了自己的代码，没管第三方。 一个统计脚本就能吃掉 200KB。上线前用 nuxtanalyze 看第一屏柱子——通常最大那根不是你的代码。 9. 拿 nuxt dev 的速度判断生产。 dev 有未压缩与中间件开销，冷热差异极大。任何性能结论以 nuxt build\u002Fgenerate + preview（或真环境）为准。",{"id":13579,"title":12174,"titles":13580,"content":13581,"level":454},"\u002Ftutorials\u002Fnuxt#附性能问题定位顺序",[5,11519],"症状 先看什么 常见根因 白屏时间长 FCP \u002F HTML 是否含正文 没 SSR（ssr:false）、SSR 阶段接口慢 有内容但点不动 TTI \u002F 水合耗时 JS 体积大、组件树深、水合数据大 图片\u002F字体闪烁 资源是否内联、preload 关键 CSS\u002F字体未预加载 每次导航都慢 payload 请求耗时 页面数据太大、lazy 没配 列表长页面卡顿 渲染节点数 没裁剪、key 不稳定、无虚拟滚动 构建变慢 .nuxt 缓存、依赖数 barrel 文件、过多模块",{"id":13583,"title":12274,"titles":13584,"content":423,"level":437},"\u002Ftutorials\u002Fnuxt#十五收尾坑汇总对照表与下一步",[5],{"id":13586,"title":12278,"titles":13587,"content":13588,"level":454},"\u002Ftutorials\u002Fnuxt#从-vue-到-nuxt只多了这几层",[5,12274],"Vue 3 里你写的 Nuxt 里的等价物 多出来的注意点 createApp(App).use(router).mount('#app') 不存在了，app\u002Fapp.vue 就是根 不要再手写 main.js routes 数组 + \u003CRouterView> app\u002Fpages\u002F 文件即路由 + \u003CNuxtPage\u002F> 父组件要放 \u003CNuxtPage\u002F>（第三章） import X from '.\u002FX.vue' app\u002Fcomponents\u002F 自动导入 目录前缀规则（第二章） useXxx 手动 import app\u002Fcomposables\u002F 自动导入 命名必须 use* useRoute\u002FuseRouter 一样可用 跳转优先 navigateTo() 手写 axios 实例 useFetch \u002F useAsyncData \u002F $fetch 取数时机（第五、六章） Pinia @pinia\u002Fnuxt + useState 实例按请求创建（第十一章） defineProps\u002FdefineEmits\u002FdefineModel 完全一样 无变化 vue-tsc 类型检查 nuxt typecheck 两份 tsconfig（app 与 server） Vite base app.baseURL 子路径部署的头号坑（第十三章） index.html 里的 \u003Ctitle>\u002Fmeta useSeoMeta \u002F useHead SSR 阶段必须同步调用（第十章）",{"id":13590,"title":12488,"titles":13591,"content":13592,"level":454},"\u002Ftutorials\u002Fnuxt#按症状找原因",[5,12274],"症状 最可能的原因 详见 首屏白，源码里有 \u003Cdiv id=\"app\"> 但没正文 ssr: false 生效 \u002F 数据只在客户端取 九、五 本地能跑，部署后资源 404、页面白屏 app.baseURL 与访问子路径不一致 十三（本站真实事故） 刷新子页面 404 Nginx 缺 try_files 回落；或该页没被预渲染 十三、九 改了内容线上还是旧的 HTML\u002Fpayload 被缓存；或产物没重新拷贝 十三 Cannot find name 'useFetch' 没跑 nuxt prepare 二 页面空白、控制台没报错 父页面漏 \u003CNuxtPage\u002F> 三 组件 \u003CBaseButton> 找不到 目录前缀（应为 \u003CUiBaseButton>）或路径大小写 二 服务端报 window is not defined SSR 阶段访问了浏览器 API 五、十一 数据在服务端取到了但界面 undefined Nuxt 4 的 default 缺失 \u002F payload 序列化失败 五 同一份数据请求两次 两处 key 不同（URL 参数变了）或用了 server: false 五 切了筛选，列表数据不变 同路由只换 query 未触发重新请求（watch 选项） 三、五 两个用户数据串了 模块级可变单例 \u002F 插件里创建共享状态 十一、十二 页面「闪一下」或 hydration 警告 Date.now()\u002Frandom\u002FlocalStorage 进了模板 十四 error.vue 样式全丢 它在布局之外 四 sitemap.xml 里全是 localhost site.url 没改 十 卡片分享没图 og:image 不是绝对 URL 十 构建在 CI 失败、本地成功 大小写敏感、Node 版本、compatibilityDate 差异 二、十三",{"id":13594,"title":12742,"titles":13595,"content":13596,"level":454},"\u002Ftutorials\u002Fnuxt#上线核对清单",[5,12274],"类别 检查项 不做的后果 配置 app.baseURL = 真实访问前缀（含首尾斜杠） 白屏 + 资源 404 配置 site.url 是真实域名 sitemap\u002Fog 指向 localhost 配置 compatibilityDate 显式设定 升级后行为漂移 渲染 每个路径的模式与预期一致（routeRules 顺序检查过） 空壳被预渲染 \u002F SEO 归零 数据 首屏数据用 await useFetch，且配了 default 与 pick 正文缺失、payload 巨大 数据 需要密钥的请求走 server\u002Fapi 代理，密钥不在 public 密钥泄露 路由 无用的 .vue 不在 pages\u002F 下；404 由 createError 抛出 脏路由、软 404 SEO 每页 title\u002Fdescription 由 SSR 输出；curl 验证过 索引不到、卡片空白 生成 nitro.prerender.routes 覆盖所有不可爬到的页面 generate 后 404 服务器 try_files 回落地址带子路径；HTML no-cache；_nuxt 长缓存 刷新 404、内容不更新 服务器 error_page 404 指向预渲染的 404，状态码真的是 404 软 404 有害 SEO 安全 .env 未入库；产物 grep 过密钥；sourcemap 关闭 泄露源码与密钥 验证 nuxt generate + 真目录部署跑过一遍；nuxtanalyze 看过体积 把「能构建」当「能上线」",{"id":13598,"title":12959,"titles":13599,"content":13600,"level":454},"\u002Ftutorials\u002Fnuxt#下一步",[5,12274],"补服务端能力：server\u002Fapi 之外还有定时任务（nitro: { scheduledTasks }）、WebSocket（defineWebSocketHandler）、存储抽象（useStorage，本站用它管内容）。关键词：Nitro 文档的 Deployment 与 Storage 两节。 内容型站点：@nuxt\u002Fcontent v3（集合 + SQLite 索引，本站教程栏目就是它驱动的）；配图与响应式图片用 @nuxt\u002Fimage。 交互复杂度上来后：把本教程第五章的 getCachedData、dedupe、refreshNuxtData 玩熟，再评估要不要 TanStack Query。 测试与质量：@nuxt\u002Ftest-utils（e2e + 渲染测试）、nuxt typecheck、CI 里跑一次 generate 并 curl -I 抽查 404 状态码。 原理补两页：Nuxt 文档的「Rendering Modes」与「Best Practices」；水合与 payload 的机制看懂后，第九章和第十四章的所有坑都能自己推出来。 Nuxt 带来的全部复杂度，其实只有一句话：同一份组件代码，可能在服务器上跑，也可能在浏览器里跑，还可能两边各跑一次。 写每一行代码时问自己「这段在服务端跑会怎样」，这门课的所有坑就都成了可预测的事。 html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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 pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .s6RL2, html code.shiki .s6RL2{--shiki-default:#FDAEB7;--shiki-default-font-style:italic}",1793263560549]