[{"data":1,"prerenderedAt":600},["ShallowReactive",2],{"post-\u002Fposts\u002F746cfa4c":3,"post-nav-\u002Fposts\u002F746cfa4c":568,"post-related-\u002Fposts\u002F746cfa4c":587},{"id":4,"title":5,"abbrlink":6,"aisummary":7,"body":8,"category":553,"cover":554,"date":555,"description":556,"draft":557,"extension":558,"featured":557,"meta":559,"navigation":305,"path":560,"pinned":557,"seo":561,"stem":562,"tags":563,"updated":554,"__hash__":567},"posts\u002Fposts\u002Fno-loading-feel.md","无加载不是速度，是连续感","746cfa4c","无加载并非单纯追求加载速度，而是通过提前准备内容、避免空白帧和去除加载提示，让用户感觉页面切换是连续的。实现需满足三个条件：页面提前生成、切换过程无空白帧、无加载指示器。SSG与预取技术可满足前两个条件，而删除加载条能提升体验。此方案适用于静态内容、可控页面数量和个人博客等场景，首屏则需通过减少体积进行优化。",{"type":9,"value":10,"toc":543},"minimark",[11,15,18,21,24,31,34,39,45,48,61,65,68,71,78,127,130,136,164,168,171,174,177,184,242,385,388,394,398,401,404,427,430,437,443,446,449,457,460,466,469,472,478,484,490,493,496,503,506,521,524,527,536,539],[12,13,14],"p",{},"先做一个思想实验。",[12,16,17],{},"方案 A：点击链接后，页面白屏 300ms，然后新页面完整出现。",[12,19,20],{},"方案 B：点击链接后，立刻显示骨架屏，800ms 后内容填进来。",[12,22,23],{},"哪个更快？A。",[12,25,26,27],{},"哪个感觉更快？",[28,29,30],"strong",{},"A。",[12,32,33],{},"骨架屏不是为了让页面\"看起来在加载\"，它是在承认\"我在加载\"。而这个承认本身，就是一种延迟信号。",[35,36,38],"h2",{"id":37},"真正的无加载是什么","真正的「无加载」是什么",[12,40,41,42],{},"无加载的意思是：",[28,43,44],{},"用户从未看到\"等待\"这个状态。",[12,46,47],{},"实现它需要三个条件同时成立：",[49,50,51,55,58],"ol",{},[52,53,54],"li",{},"页面在点击之前就已经准备好了",[52,56,57],{},"切换过程不出现空白帧",[52,59,60],{},"没有任何 loading 指示器",[35,62,64],{"id":63},"条件一提前准备","条件一：提前准备",[12,66,67],{},"SSG（静态生成）把这件事做到了极致：所有页面在构建时就变成了 HTML 文件。",[12,69,70],{},"点击链接时，浏览器请求的是一个已经存在的静态文件——不是\"等服务器查询数据库再渲染\"。",[12,72,73,74,77],{},"但即使如此，仍然有一次网络往返。所以还需要",[28,75,76],{},"预取","：",[79,80,85],"pre",{"className":81,"code":82,"language":83,"meta":84,"style":84},"language-html shiki shiki-themes github-light github-dark","\u003C!-- Nuxt 的 NuxtLink 默认开启 prefetch -->\n\u003CNuxtLink to=\"\u002Farchive\">归档\u003C\u002FNuxtLink>\n","html","",[86,87,88,97],"code",{"__ignoreMap":84},[89,90,93],"span",{"class":91,"line":92},"line",1,[89,94,96],{"class":95},"sJ8bj","\u003C!-- Nuxt 的 NuxtLink 默认开启 prefetch -->\n",[89,98,100,104,108,112,115,119,122,124],{"class":91,"line":99},2,[89,101,103],{"class":102},"sVt8B","\u003C",[89,105,107],{"class":106},"s7hpK","NuxtLink",[89,109,111],{"class":110},"sScJk"," to",[89,113,114],{"class":102},"=",[89,116,118],{"class":117},"sZZnC","\"\u002Farchive\"",[89,120,121],{"class":102},">归档\u003C\u002F",[89,123,107],{"class":106},[89,125,126],{"class":102},">\n",[12,128,129],{},"Nuxt 会在链接进入视口时预取目标页面的 payload。用户真正点击时，数据已经在内存里了。",[12,131,132,135],{},[28,133,134],{},"关键细节","：外链和友链要显式关闭预取，否则会预取一堆无关域名。",[79,137,139],{"className":81,"code":138,"language":83,"meta":84,"style":84},"\u003CNuxtLink to=\"\u002Flinks\" no-prefetch>友链\u003C\u002FNuxtLink>\n",[86,140,141],{"__ignoreMap":84},[89,142,143,145,147,149,151,154,157,160,162],{"class":91,"line":92},[89,144,103],{"class":102},[89,146,107],{"class":106},[89,148,111],{"class":110},[89,150,114],{"class":102},[89,152,153],{"class":117},"\"\u002Flinks\"",[89,155,156],{"class":110}," no-prefetch",[89,158,159],{"class":102},">友链\u003C\u002F",[89,161,107],{"class":106},[89,163,126],{"class":102},[35,165,167],{"id":166},"条件二不出现空白帧","条件二：不出现空白帧",[12,169,170],{},"即使数据准备好了，页面切换仍然会有一次 DOM 重建。",[12,172,173],{},"如果没有过渡，你会看到：旧页面消失 → 空白 → 新页面出现。",[12,175,176],{},"那\"空白\"可能只有 16ms（一帧），但眼睛能看见。",[12,178,179,180,183],{},"解法是 ",[86,181,182],{},"out-in"," 模式的页面过渡：",[79,185,189],{"className":186,"code":187,"language":188,"meta":84,"style":84},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F nuxt.config.ts\napp: {\n  pageTransition: { name: 'page', mode: 'out-in' },\n}\n","ts",[86,190,191,196,204,236],{"__ignoreMap":84},[89,192,193],{"class":91,"line":92},[89,194,195],{"class":95},"\u002F\u002F nuxt.config.ts\n",[89,197,198,201],{"class":91,"line":99},[89,199,200],{"class":110},"app",[89,202,203],{"class":102},": {\n",[89,205,207,210,213,216,219,222,225,228,230,233],{"class":91,"line":206},3,[89,208,209],{"class":110},"  pageTransition",[89,211,212],{"class":102},": { ",[89,214,215],{"class":110},"name",[89,217,218],{"class":102},": ",[89,220,221],{"class":117},"'page'",[89,223,224],{"class":102},", ",[89,226,227],{"class":110},"mode",[89,229,218],{"class":102},[89,231,232],{"class":117},"'out-in'",[89,234,235],{"class":102}," },\n",[89,237,239],{"class":91,"line":238},4,[89,240,241],{"class":102},"}\n",[79,243,247],{"className":244,"code":245,"language":246,"meta":84,"style":84},"language-css shiki shiki-themes github-light github-dark",".page-enter-active,\n.page-leave-active {\n  transition: opacity 200ms ease-out, transform 200ms ease-out;\n}\n\n.page-enter-from {\n  opacity: 0;\n  transform: translateY(4px);\n}\n\n.page-leave-to {\n  opacity: 0;\n}\n","css",[86,248,249,257,265,296,300,307,315,328,351,356,361,369,380],{"__ignoreMap":84},[89,250,251,254],{"class":91,"line":92},[89,252,253],{"class":110},".page-enter-active",[89,255,256],{"class":102},",\n",[89,258,259,262],{"class":91,"line":99},[89,260,261],{"class":110},".page-leave-active",[89,263,264],{"class":102}," {\n",[89,266,267,271,274,277,281,284,287,289,291,293],{"class":91,"line":206},[89,268,270],{"class":269},"sj4cs","  transition",[89,272,273],{"class":102},": opacity ",[89,275,276],{"class":269},"200",[89,278,280],{"class":279},"szBVR","ms",[89,282,283],{"class":269}," ease-out",[89,285,286],{"class":102},", transform ",[89,288,276],{"class":269},[89,290,280],{"class":279},[89,292,283],{"class":269},[89,294,295],{"class":102},";\n",[89,297,298],{"class":91,"line":238},[89,299,241],{"class":102},[89,301,303],{"class":91,"line":302},5,[89,304,306],{"emptyLinePlaceholder":305},true,"\n",[89,308,310,313],{"class":91,"line":309},6,[89,311,312],{"class":110},".page-enter-from",[89,314,264],{"class":102},[89,316,318,321,323,326],{"class":91,"line":317},7,[89,319,320],{"class":269},"  opacity",[89,322,218],{"class":102},[89,324,325],{"class":269},"0",[89,327,295],{"class":102},[89,329,331,334,336,339,342,345,348],{"class":91,"line":330},8,[89,332,333],{"class":269},"  transform",[89,335,218],{"class":102},[89,337,338],{"class":269},"translateY",[89,340,341],{"class":102},"(",[89,343,344],{"class":269},"4",[89,346,347],{"class":279},"px",[89,349,350],{"class":102},");\n",[89,352,354],{"class":91,"line":353},9,[89,355,241],{"class":102},[89,357,359],{"class":91,"line":358},10,[89,360,306],{"emptyLinePlaceholder":305},[89,362,364,367],{"class":91,"line":363},11,[89,365,366],{"class":110},".page-leave-to",[89,368,264],{"class":102},[89,370,372,374,376,378],{"class":91,"line":371},12,[89,373,320],{"class":269},[89,375,218],{"class":102},[89,377,325],{"class":269},[89,379,295],{"class":102},[89,381,383],{"class":91,"line":382},13,[89,384,241],{"class":102},[12,386,387],{},"200ms 的淡入淡出。旧页面淡出的同时新页面淡入，中间没有空白帧。",[12,389,390,393],{},[28,391,392],{},"为什么是 200ms？"," 低于 150ms 感知不到，高于 300ms 开始觉得慢。200ms 是\"有过渡但不等待\"的窗口。",[35,395,397],{"id":396},"条件三删掉-loading-指示器","条件三：删掉 loading 指示器",[12,399,400],{},"这一条最反直觉。",[12,402,403],{},"大部分项目会加一个顶部进度条（NuxtLoadingIndicator）：",[79,405,409],{"className":406,"code":407,"language":408,"meta":84,"style":84},"language-vue shiki shiki-themes github-light github-dark","\u003C!-- 删掉这个 -->\n\u003CNuxtLoadingIndicator \u002F>\n","vue",[86,410,411,416],{"__ignoreMap":84},[89,412,413],{"class":91,"line":92},[89,414,415],{"class":95},"\u003C!-- 删掉这个 -->\n",[89,417,418,420,424],{"class":91,"line":99},[89,419,103],{"class":102},[89,421,423],{"class":422},"s9eBZ","NuxtLoadingIndicator",[89,425,426],{"class":102}," \u002F>\n",[12,428,429],{},"进度条的存在本身就在告诉用户：\"可能要等一下\"。",[12,431,432,433,436],{},"当你的页面跳转真的在 200ms 内完成时，进度条会",[28,434,435],{},"闪一下","——比没有更糟。",[12,438,439,440,442],{},"所以我把 ",[86,441,423],{}," 完全删掉了，靠 view transition 自然过渡。",[35,444,445],{"id":445},"一个真实的对比",[12,447,448],{},"改之前（有骨架屏 + 进度条）：",[79,450,455],{"className":451,"code":453,"language":454},[452],"language-text","点击 → 进度条出现 → 骨架屏 → 内容填入 → 进度条消失\n总时长：约 600ms，用户全程在\"等待\"\n","text",[86,456,453],{"__ignoreMap":84},[12,458,459],{},"改之后（SSG + 预取 + 过渡）：",[79,461,464],{"className":462,"code":463,"language":454},[452],"点击 → 淡出 → 淡入\n总时长：约 200ms，用户感知为\"瞬间\"\n",[86,465,463],{"__ignoreMap":84},[35,467,468],{"id":468},"什么情况下做不到",[12,470,471],{},"诚实地说，这套方案有三个前提：",[12,473,474,477],{},[28,475,476],{},"一、内容必须是静态的。"," 如果页面需要实时数据（股票、聊天），SSG 帮不上忙。",[12,479,480,483],{},[28,481,482],{},"二、页面数量要可控。"," 全量预渲染 10 万个页面，构建要几小时。",[12,485,486,489],{},[28,487,488],{},"三、首屏没法这样优化。"," 用户第一次访问仍然要下载 HTML + CSS + JS。这是物理限制。",[12,491,492],{},"对一个个人博客来说，这三个前提全都成立——所以它能做到真正的无加载。",[35,494,495],{"id":495},"首屏怎么办",[12,497,498,499,502],{},"首屏的唯一优化方向是",[28,500,501],{},"减小体积","。",[12,504,505],{},"我的做法：",[507,508,509,512,515,518],"ul",{},[52,510,511],{},"没有第三方脚本（统计默认关闭）",[52,513,514],{},"字体分片，实际加载约 180KB",[52,516,517],{},"搜索索引懒加载（首次搜索才请求）",[52,519,520],{},"零运行时状态库（没有 Pinia）",[12,522,523],{},"结果：首屏 JS 约 41KB（gzip 后）。",[35,525,526],{"id":526},"一句话",[528,529,530],"blockquote",{},[12,531,532,533,502],{},"无加载不是把加载藏起来，是",[28,534,535],{},"让加载根本不需要发生",[12,537,538],{},"这两者的区别在于：前者是障眼法，后者是架构。",[540,541,542],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#57606A;--shiki-dark:#9AA4B0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s7hpK, html code.shiki .s7hpK{--shiki-default:#B31D28;--shiki-default-font-style:italic;--shiki-dark:#FDAEB7;--shiki-dark-font-style:italic}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":84,"searchDepth":99,"depth":99,"links":544},[545,546,547,548,549,550,551,552],{"id":37,"depth":99,"text":38},{"id":63,"depth":99,"text":64},{"id":166,"depth":99,"text":167},{"id":396,"depth":99,"text":397},{"id":445,"depth":99,"text":445},{"id":468,"depth":99,"text":468},{"id":495,"depth":99,"text":495},{"id":526,"depth":99,"text":526},"前端",null,"2025-05-15","一个 300ms 的白屏比一个 800ms 的骨架屏更让人焦虑。这篇讲怎么用 SSG、预取和 view transition 把页面跳转做成「一瞬间的事」。",false,"md",{},"\u002Fposts\u002F746cfa4c",{"title":5,"description":556},"posts\u002Fno-loading-feel",[564,565,566],"性能","体验","Nuxt","bbqirJSHYM87M3lsnuF1NkdQCiGHI7-Fg7h3Rd8kVhM",{"prev":569,"next":579},{"path":570,"title":571,"description":572,"date":573,"updated":554,"tags":574,"category":553,"cover":578,"draft":557,"pinned":557,"featured":305},"\u002Fposts\u002F7e4167d8","View Transitions 不是动画库","用它做页面切换、做主题扩散、做列表到详情的共享元素过渡。它真正的价值不是\"让页面动起来\"，而是让你不必手动管理进入和离开两套状态。","2025-09-18",[575,576,577],"动画","浏览器","CSS","\u002Fimages\u002Fcovers\u002Fview-transition.png",{"path":580,"title":581,"description":582,"date":583,"updated":554,"tags":584,"category":553,"cover":554,"draft":557,"pinned":557,"featured":557},"\u002Fposts\u002F7d72abe8","我把 CSS 变量当成了 API","设计 token 写进配置文件，构建期编译成 CSS 变量，组件只读变量。这套做法让「改一处，全站生效」从口号变成了构建期保证。","2025-04-20",[577,585,586],"设计系统","构建",[588,596,598],{"path":589,"title":590,"description":591,"date":592,"updated":554,"tags":593,"category":553,"cover":554,"draft":557,"pinned":557,"featured":557},"\u002Fposts\u002Fb240ed92","自托管字体的一年","从 Google Fonts 换成自托管，从自托管换成子集化分片。聊聊为什么一个中文字体能从 25MB 变成实际加载 180KB。","2025-08-08",[594,564,595],"字体","中文",{"path":570,"title":571,"description":572,"date":573,"updated":554,"tags":597,"category":553,"cover":578,"draft":557,"pinned":557,"featured":305},[575,576,577],{"path":580,"title":581,"description":582,"date":583,"updated":554,"tags":599,"category":553,"cover":554,"draft":557,"pinned":557,"featured":557},[577,585,586],1794495071018]