[{"data":1,"prerenderedAt":878},["ShallowReactive",2],{"post-\u002Fposts\u002Fstatic-search":3,"post-nav-\u002Fposts\u002Fstatic-search":841,"post-related-\u002Fposts\u002Fstatic-search":860},{"id":4,"title":5,"body":6,"category":826,"cover":827,"date":828,"description":829,"draft":830,"extension":831,"featured":830,"meta":832,"navigation":560,"path":833,"pinned":830,"seo":834,"stem":835,"tags":836,"updated":827,"__hash__":840},"posts\u002Fposts\u002Fstatic-search.md","静态博客的搜索为什么不用后端",{"type":7,"value":8,"toc":817},"minimark",[9,13,34,42,46,200,203,206,209,230,237,482,503,506,511,518,715,721,724,740,743,749,752,755,761,767,777,780,783,789,795,798,801,804,810,813],[10,11,12],"p",{},"每次有人问我\"静态博客怎么做搜索\"，标准答案通常是这四种：",[14,15,16,20,23,26],"ol",{},[17,18,19],"li",{},"Algolia（要付费、要联网、要等索引同步）",[17,21,22],{},"MeiliSearch（要跑一个服务）",[17,24,25],{},"Pagefind（好，但更偏向几千页以上的站）",[17,27,28,29,33],{},"搜索引擎的 ",[30,31,32],"code",{},"site:"," 语法（等于没有）",[10,35,36,37,41],{},"我用的是第五种：",[38,39,40],"strong",{},"构建时生成一个 JSON 索引，前端按需加载","。",[43,44,45],"h2",{"id":45},"索引长什么样",[47,48,53],"pre",{"className":49,"code":50,"language":51,"meta":52,"style":52},"language-json shiki shiki-themes github-light github-dark","[\n  {\n    \"id\": \"typography-over-color\",\n    \"title\": \"排版比配色更决定气质\",\n    \"description\": \"把同样一套颜色交给两个人排，出来的东西可以完全不同。\",\n    \"tags\": [\"排版\", \"设计\", \"CSS\"],\n    \"category\": \"设计\",\n    \"text\": \"做前端这些年我逐渐发现一个规律换一套配色页面会变好看改一遍排版页面会变成另一个东西…\",\n    \"path\": \"\u002Fposts\u002Ftypography-over-color\",\n    \"date\": \"2025-10-28T00:00:00.000Z\"\n  }\n]\n","json","",[30,54,55,64,70,87,100,113,139,151,164,177,188,194],{"__ignoreMap":52},[56,57,60],"span",{"class":58,"line":59},"line",1,[56,61,63],{"class":62},"sVt8B","[\n",[56,65,67],{"class":58,"line":66},2,[56,68,69],{"class":62},"  {\n",[56,71,73,77,80,84],{"class":58,"line":72},3,[56,74,76],{"class":75},"sj4cs","    \"id\"",[56,78,79],{"class":62},": ",[56,81,83],{"class":82},"sZZnC","\"typography-over-color\"",[56,85,86],{"class":62},",\n",[56,88,90,93,95,98],{"class":58,"line":89},4,[56,91,92],{"class":75},"    \"title\"",[56,94,79],{"class":62},[56,96,97],{"class":82},"\"排版比配色更决定气质\"",[56,99,86],{"class":62},[56,101,103,106,108,111],{"class":58,"line":102},5,[56,104,105],{"class":75},"    \"description\"",[56,107,79],{"class":62},[56,109,110],{"class":82},"\"把同样一套颜色交给两个人排，出来的东西可以完全不同。\"",[56,112,86],{"class":62},[56,114,116,119,122,125,128,131,133,136],{"class":58,"line":115},6,[56,117,118],{"class":75},"    \"tags\"",[56,120,121],{"class":62},": [",[56,123,124],{"class":82},"\"排版\"",[56,126,127],{"class":62},", ",[56,129,130],{"class":82},"\"设计\"",[56,132,127],{"class":62},[56,134,135],{"class":82},"\"CSS\"",[56,137,138],{"class":62},"],\n",[56,140,142,145,147,149],{"class":58,"line":141},7,[56,143,144],{"class":75},"    \"category\"",[56,146,79],{"class":62},[56,148,130],{"class":82},[56,150,86],{"class":62},[56,152,154,157,159,162],{"class":58,"line":153},8,[56,155,156],{"class":75},"    \"text\"",[56,158,79],{"class":62},[56,160,161],{"class":82},"\"做前端这些年我逐渐发现一个规律换一套配色页面会变好看改一遍排版页面会变成另一个东西…\"",[56,163,86],{"class":62},[56,165,167,170,172,175],{"class":58,"line":166},9,[56,168,169],{"class":75},"    \"path\"",[56,171,79],{"class":62},[56,173,174],{"class":82},"\"\u002Fposts\u002Ftypography-over-color\"",[56,176,86],{"class":62},[56,178,180,183,185],{"class":58,"line":179},10,[56,181,182],{"class":75},"    \"date\"",[56,184,79],{"class":62},[56,186,187],{"class":82},"\"2025-10-28T00:00:00.000Z\"\n",[56,189,191],{"class":58,"line":190},11,[56,192,193],{"class":62},"  }\n",[56,195,197],{"class":58,"line":196},12,[56,198,199],{"class":62},"]\n",[10,201,202],{},"就这些字段，一个都不多。",[43,204,205],{"id":205},"为什么这么小",[10,207,208],{},"假设你有 100 篇文章，每篇 3000 字。",[210,211,212,215,218,224],"ul",{},[17,213,214],{},"原始 markdown：100 × 3000 × 3 bytes ≈ 900 KB",[17,216,217],{},"HTML 渲染后：大约 2.5 MB（标签很占地方）",[17,219,220,223],{},[38,221,222],{},"剥离 HTML 后的纯文本","：约 850 KB",[17,225,226,227],{},"gzip 之后：",[38,228,229],{},"约 180 KB",[10,231,232,233,236],{},"关键动作是",[38,234,235],{},"剥 HTML","。这一步能砍掉 60% 以上。",[47,238,242],{"className":239,"code":240,"language":241,"meta":52,"style":52},"language-ts shiki shiki-themes github-light github-dark","function stripHtml(html: string): string {\n  return html\n    .replace(\u002F\u003Cscript[\\s\\S]*?\u003C\\\u002Fscript>\u002Fgi, ' ')\n    .replace(\u002F\u003Cstyle[\\s\\S]*?\u003C\\\u002Fstyle>\u002Fgi, ' ')\n    .replace(\u002F\u003Cpre[\\s\\S]*?\u003C\\\u002Fpre>\u002Fgi, ' ')   \u002F\u002F 代码块也剥掉\n    .replace(\u002F\u003C[^>]+>\u002Fg, ' ')\n    .replace(\u002F\\s+\u002Fg, ' ')\n    .trim()\n}\n","ts",[30,243,244,277,285,331,365,404,442,467,477],{"__ignoreMap":52},[56,245,246,250,254,257,261,264,267,270,272,274],{"class":58,"line":59},[56,247,249],{"class":248},"szBVR","function",[56,251,253],{"class":252},"sScJk"," stripHtml",[56,255,256],{"class":62},"(",[56,258,260],{"class":259},"s4XuR","html",[56,262,263],{"class":248},":",[56,265,266],{"class":75}," string",[56,268,269],{"class":62},")",[56,271,263],{"class":248},[56,273,266],{"class":75},[56,275,276],{"class":62}," {\n",[56,278,279,282],{"class":58,"line":66},[56,280,281],{"class":248},"  return",[56,283,284],{"class":62}," html\n",[56,286,287,290,293,295,298,302,305,308,311,315,318,320,323,325,328],{"class":58,"line":72},[56,288,289],{"class":62},"    .",[56,291,292],{"class":252},"replace",[56,294,256],{"class":62},[56,296,297],{"class":82},"\u002F",[56,299,301],{"class":300},"sA_wV","\u003Cscript",[56,303,304],{"class":75},"[\\s\\S]",[56,306,307],{"class":248},"*?",[56,309,310],{"class":300},"\u003C",[56,312,314],{"class":313},"snhLl","\\\u002F",[56,316,317],{"class":300},"script>",[56,319,297],{"class":82},[56,321,322],{"class":248},"gi",[56,324,127],{"class":62},[56,326,327],{"class":82},"' '",[56,329,330],{"class":62},")\n",[56,332,333,335,337,339,341,344,346,348,350,352,355,357,359,361,363],{"class":58,"line":89},[56,334,289],{"class":62},[56,336,292],{"class":252},[56,338,256],{"class":62},[56,340,297],{"class":82},[56,342,343],{"class":300},"\u003Cstyle",[56,345,304],{"class":75},[56,347,307],{"class":248},[56,349,310],{"class":300},[56,351,314],{"class":313},[56,353,354],{"class":300},"style>",[56,356,297],{"class":82},[56,358,322],{"class":248},[56,360,127],{"class":62},[56,362,327],{"class":82},[56,364,330],{"class":62},[56,366,367,369,371,373,375,378,380,382,384,386,389,391,393,395,397,400],{"class":58,"line":102},[56,368,289],{"class":62},[56,370,292],{"class":252},[56,372,256],{"class":62},[56,374,297],{"class":82},[56,376,377],{"class":300},"\u003Cpre",[56,379,304],{"class":75},[56,381,307],{"class":248},[56,383,310],{"class":300},[56,385,314],{"class":313},[56,387,388],{"class":300},"pre>",[56,390,297],{"class":82},[56,392,322],{"class":248},[56,394,127],{"class":62},[56,396,327],{"class":82},[56,398,399],{"class":62},")   ",[56,401,403],{"class":402},"sJ8bj","\u002F\u002F 代码块也剥掉\n",[56,405,406,408,410,412,414,416,419,422,425,428,431,433,436,438,440],{"class":58,"line":115},[56,407,289],{"class":62},[56,409,292],{"class":252},[56,411,256],{"class":62},[56,413,297],{"class":82},[56,415,310],{"class":300},[56,417,418],{"class":75},"[",[56,420,421],{"class":248},"^",[56,423,424],{"class":75},">]",[56,426,427],{"class":248},"+",[56,429,430],{"class":300},">",[56,432,297],{"class":82},[56,434,435],{"class":248},"g",[56,437,127],{"class":62},[56,439,327],{"class":82},[56,441,330],{"class":62},[56,443,444,446,448,450,452,455,457,459,461,463,465],{"class":58,"line":141},[56,445,289],{"class":62},[56,447,292],{"class":252},[56,449,256],{"class":62},[56,451,297],{"class":82},[56,453,454],{"class":75},"\\s",[56,456,427],{"class":248},[56,458,297],{"class":82},[56,460,435],{"class":248},[56,462,127],{"class":62},[56,464,327],{"class":82},[56,466,330],{"class":62},[56,468,469,471,474],{"class":58,"line":153},[56,470,289],{"class":62},[56,472,473],{"class":252},"trim",[56,475,476],{"class":62},"()\n",[56,478,479],{"class":58,"line":166},[56,480,481],{"class":62},"}\n",[10,483,484,485,488,489,492,493,492,496,499,500,502],{},"那行 ",[30,486,487],{},"\u003Cpre>"," 的处理是有争议的。代码块里全是 ",[30,490,491],{},"useState","、",[30,494,495],{},"div",[30,497,498],{},"const"," 这种标识符，进索引只会制造噪音——用户搜「前端」不该命中一个满是 ",[30,501,495],{}," 的代码块。",[43,504,505],{"id":505},"什么时候加载",[10,507,508],{},[38,509,510],{},"用户第一次打开搜索框的时候。",[10,512,513,514,517],{},"不是页面加载时，不是空闲时，是真正按下 ",[30,515,516],{},"Ctrl+K"," 的那一刻。",[47,519,521],{"className":239,"code":520,"language":241,"meta":52,"style":52},"let indexPromise: Promise\u003CMiniSearch> | null = null\n\nfunction ensureIndex() {\n  if (!indexPromise) {\n    indexPromise = fetch('\u002Fapi\u002Fsearch-index.json')\n      .then((r) => r.json())\n      .then((docs) => {\n        const ms = new MiniSearch({ \u002F* ... *\u002F })\n        ms.addAll(docs)\n        return ms\n      })\n  }\n  return indexPromise\n}\n",[30,522,523,556,562,572,586,604,632,649,674,685,693,698,702,710],{"__ignoreMap":52},[56,524,525,528,531,533,536,538,541,544,547,550,553],{"class":58,"line":59},[56,526,527],{"class":248},"let",[56,529,530],{"class":62}," indexPromise",[56,532,263],{"class":248},[56,534,535],{"class":252}," Promise",[56,537,310],{"class":62},[56,539,540],{"class":252},"MiniSearch",[56,542,543],{"class":62},"> ",[56,545,546],{"class":248},"|",[56,548,549],{"class":75}," null",[56,551,552],{"class":248}," =",[56,554,555],{"class":75}," null\n",[56,557,558],{"class":58,"line":66},[56,559,561],{"emptyLinePlaceholder":560},true,"\n",[56,563,564,566,569],{"class":58,"line":72},[56,565,249],{"class":248},[56,567,568],{"class":252}," ensureIndex",[56,570,571],{"class":62},"() {\n",[56,573,574,577,580,583],{"class":58,"line":89},[56,575,576],{"class":248},"  if",[56,578,579],{"class":62}," (",[56,581,582],{"class":248},"!",[56,584,585],{"class":62},"indexPromise) {\n",[56,587,588,591,594,597,599,602],{"class":58,"line":102},[56,589,590],{"class":62},"    indexPromise ",[56,592,593],{"class":248},"=",[56,595,596],{"class":252}," fetch",[56,598,256],{"class":62},[56,600,601],{"class":82},"'\u002Fapi\u002Fsearch-index.json'",[56,603,330],{"class":62},[56,605,606,609,612,615,618,621,624,627,629],{"class":58,"line":115},[56,607,608],{"class":62},"      .",[56,610,611],{"class":252},"then",[56,613,614],{"class":62},"((",[56,616,617],{"class":259},"r",[56,619,620],{"class":62},") ",[56,622,623],{"class":248},"=>",[56,625,626],{"class":62}," r.",[56,628,51],{"class":252},[56,630,631],{"class":62},"())\n",[56,633,634,636,638,640,643,645,647],{"class":58,"line":141},[56,635,608],{"class":62},[56,637,611],{"class":252},[56,639,614],{"class":62},[56,641,642],{"class":259},"docs",[56,644,620],{"class":62},[56,646,623],{"class":248},[56,648,276],{"class":62},[56,650,651,654,657,659,662,665,668,671],{"class":58,"line":153},[56,652,653],{"class":248},"        const",[56,655,656],{"class":75}," ms",[56,658,552],{"class":248},[56,660,661],{"class":248}," new",[56,663,664],{"class":252}," MiniSearch",[56,666,667],{"class":62},"({ ",[56,669,670],{"class":402},"\u002F* ... *\u002F",[56,672,673],{"class":62}," })\n",[56,675,676,679,682],{"class":58,"line":166},[56,677,678],{"class":62},"        ms.",[56,680,681],{"class":252},"addAll",[56,683,684],{"class":62},"(docs)\n",[56,686,687,690],{"class":58,"line":179},[56,688,689],{"class":248},"        return",[56,691,692],{"class":62}," ms\n",[56,694,695],{"class":58,"line":190},[56,696,697],{"class":62},"      })\n",[56,699,700],{"class":58,"line":196},[56,701,193],{"class":62},[56,703,705,707],{"class":58,"line":704},13,[56,706,281],{"class":248},[56,708,709],{"class":62}," indexPromise\n",[56,711,713],{"class":58,"line":712},14,[56,714,481],{"class":62},[10,716,717,718,720],{},"用一个 Promise 做缓存的好处是：连续按三次 ",[30,719,516],{}," 也只会发一次请求。",[43,722,723],{"id":723},"加载要多久",[10,725,726,727,730,731,730,734,736,737,41],{},"本地测：180KB 的 JSON，",[30,728,729],{},"fetch"," + ",[30,732,733],{},"JSON.parse",[30,735,681],{}," 建索引，",[38,738,739],{},"大约 60~90ms",[10,741,742],{},"用户感知不到。但如果索引超过 500KB，这个数字会变成 300ms+，就该考虑分片或者换 Pagefind 了。",[10,744,745,746],{},"所以我的判断线是：",[38,747,748],{},"索引 gzip 后 300KB 以内用方案五，超过就用 Pagefind。",[10,750,751],{},"对一个个人博客（几十到几百篇）来说，永远到不了这条线。",[43,753,754],{"id":754},"不用后端的三个好处",[10,756,757,760],{},[38,758,759],{},"一、离线可用。"," Service Worker 缓存了索引之后，断网也能搜。",[10,762,763,766],{},[38,764,765],{},"二、零延迟。"," 没有网络往返，搜索是同步的（建完索引之后）。",[10,768,769,772,773,776],{},[38,770,771],{},"三、部署即生效。"," 写文章 → ",[30,774,775],{},"git push"," → 构建 → 索引和文章一起上线。不需要\"等 Algolia 爬\"。",[43,778,779],{"id":779},"唯一的问题",[10,781,782],{},"索引是构建时生成的，所以：",[10,784,785,788],{},[38,786,787],{},"草稿不会被搜到。"," 这是对的。",[10,790,791,794],{},[38,792,793],{},"但新文章要重新构建才能搜到。"," 对静态博客来说这本来就是事实——文章本身也要重新构建才存在。",[10,796,797],{},"所以这个\"缺点\"根本不存在。",[43,799,800],{"id":800},"结语",[10,802,803],{},"技术选型的本质是匹配规模。",[10,805,806,807,41],{},"Algolia 对一万篇文章的电商站是对的；MeiliSearch 对需要实时索引的社区是对的；",[38,808,809],{},"一个 JSON 文件对个人博客是对的",[10,811,812],{},"用大炮打蚊子不叫稳健，叫没算清楚。",[814,815,816],"style",{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sA_wV, html code.shiki .sA_wV{--shiki-default:#032F62;--shiki-dark:#DBEDFF}html pre.shiki code .snhLl, html code.shiki .snhLl{--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#85E89D;--shiki-dark-font-weight:bold}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#57606A;--shiki-dark:#9AA4B0}",{"title":52,"searchDepth":66,"depth":66,"links":818},[819,820,821,822,823,824,825],{"id":45,"depth":66,"text":45},{"id":205,"depth":66,"text":205},{"id":505,"depth":66,"text":505},{"id":723,"depth":66,"text":723},{"id":754,"depth":66,"text":754},{"id":779,"depth":66,"text":779},{"id":800,"depth":66,"text":800},"工程",null,"2025-09-05","300KB 的索引、一次懒加载、纯前端查询。对一个几百篇文章的博客来说，这个方案比任何搜索引擎都合适。",false,"md",{},"\u002Fposts\u002Fstatic-search",{"title":5,"description":829},"posts\u002Fstatic-search",[837,838,839],"搜索","架构","SSG","ZNhVMda266TuDsYgo8_uf5PESHH3z1DqIQ1ocR0HGdM",{"prev":842,"next":852},{"path":843,"title":844,"description":845,"date":846,"updated":827,"tags":847,"category":826,"cover":851,"draft":830,"pinned":560,"featured":830},"\u002Fposts\u002Fxml-escaping-stress-test","特殊字符与代码块压测：& \u003C > \" ' 全都要","一篇专门用来喂 RSS\u002FAtom 校验器的文章。正文里塞满了 & \u003C > 引号、CDATA 边界、嵌套代码块和原始 HTML 片段。","2025-10-20",[848,849,850],"RSS","XML","测试","\u002Fimages\u002Fcovers\u002Fcharset.png",{"path":853,"title":854,"description":855,"date":856,"updated":827,"tags":857,"category":826,"cover":827,"draft":830,"pinned":830,"featured":830},"\u002Fposts\u002Fzero-hardcode","一个配置文件能解决多少问题","零硬编码听起来是教条，实践起来是架构约束。这篇讲我怎么用四个配置文件把全站的可变内容抽干净，以及它带来的真实收益。","2025-07-10",[838,858,859],"配置","可维护性",[861,869,876],{"path":862,"title":863,"description":864,"date":865,"updated":827,"tags":866,"category":826,"cover":827,"draft":830,"pinned":830,"featured":830},"\u002Fposts\u002Fdeleted-90-percent","我删掉了站点的 90% 功能","一个博客需要什么？列表、文章、归档、搜索、订阅。其他东西我全删了，然后发现加载时间从 2.3 秒掉到了 380 毫秒。","2025-10-12",[867,838,868],"性能","反思",{"path":870,"title":871,"description":872,"date":873,"updated":827,"tags":874,"category":826,"cover":827,"draft":830,"pinned":830,"featured":830},"\u002Fposts\u002Fcjk-tokenizer","中文分词为什么把搜索做坏了","MiniSearch 默认按空格切词，对中文来说等于完全失效。这篇讲 CJK 该怎么切，以及 bigram 加单字兜底为什么比单纯整词匹配更靠谱。","2025-09-30",[837,540,875],"中文",{"path":853,"title":854,"description":855,"date":856,"updated":827,"tags":877,"category":826,"cover":827,"draft":830,"pinned":830,"featured":830},[838,858,859],1794113901485]