
同步加载、异步加载、延迟加载、预加载这四个词我几乎每次带人做前端性能排查都会被拎出来重新对一遍。原因很实在它们经常被混着用甚至被当成同义词互相替换——有人把defer说成异步加载有人把preload当成懒加载的优化版还有人一看到首屏慢就把所有脚本改成async结果页面交互顺序全乱。这四个词描述的其实是四件不同的事同步加载管的是“解析到就立刻拿、拿完立刻执行”异步加载管的是“别挡住解析下载和执行时机另说”延迟加载管的是“现在根本不该要等快用上了再去要”预加载管的是“现在提前要把未来的等待挪到现在花掉”。它们不是四个可选项而是一套分层的资源调度策略。这篇文章我打算把这四条路从浏览器内核的行为层面拆开配上可以直接抄的分层方案、真实场景里的参数估算还有我这些年踩过的坑。适合已经能写页面、但对性能优化还停留在“加个 defer 试试”阶段的前端也适合做移动端、做后台系统、做内容站的同学按需取用——不管你手上是几十个路由的大项目还是只有一个落地页的小站点这四张牌的取舍逻辑是共通的。1. 四个名词先摆到一张桌子上1.1 用后厨上菜的方式理解四种加载我习惯用一个后厨的比喻跟新人讲这四件事讲完之后他们基本就不会再混淆。把浏览器想成一家餐厅的前厅HTML 解析就是服务员摆台JS 和图片就是后厨要出的菜。同步加载是服务员摆到某一张桌子时发现这道菜还没上于是他停下手里的活儿站在那儿等后厨做完、端上来、摆好再继续摆下一张桌子——整个前厅停摆。异步加载是服务员照常摆台同时告诉后厨“这道菜你做完直接送不用管我摆到哪了”于是菜什么时候到取决于后厨的手速可能早于、也可能晚于你摆完甚至几道异步的菜到达顺序都说不准。延迟加载是这张桌子上的客人还没来服务员压根不去下单等客人入座了滚动到视口、点击展开、路由切换才通知后厨开始做。预加载是服务员提前知道晚上有包场趁现在厨房空着先把食材焯水、把汤底吊上等客人真到了直接出锅——等待时间被前移但前提是你得猜准猜错了就是白白浪费食材和灶台。这个比喻之所以好用是因为它同时点出了四个维度是否阻塞主流程、执行时机的决定方是谁、触发条件是什么、猜错的代价有多大。实际做技术选型的时候我基本就是拿这四个维度去套的——不要问“哪个性能最好”而要问“这段资源阻塞了谁、我能不能控制它的时机、我猜错的成本高不高”。很多性能问题不是选错了技术而是把“猜”当成了“确定”比如给一个用户行为强依赖的模块写了prefetch猜用户下一步会去结果大部分用户根本不去白下载几十 KB还跟首屏关键资源抢了带宽。1.2 为什么浏览器最初只有同步这一条路想搞明白异步为什么重要得先回到同步为什么是默认值。早期网页是纯文档脚本的作用是给文档打补丁——比如校验一个表单、写一段document.write。脚本可能修改 DOM 结构而浏览器没法预测脚本会改哪一部分所以最安全的设计就是遇到script就暂停 HTML 解析把脚本下载下来、执行完再继续解析后面的 HTML。这套设计在脚本只有几 KB 的年代毫无问题成本可以忽略。但当页面变成一个几百 KB 的应用时这条规则就成了性能杀手解析器被卡住意味着后面的 HTML 迟迟不能变成 DOMDOM 不完整CSSOM 和渲染树就出不来首屏就是一片白。所以这四个概念本质上是一部“浏览器把控制权逐步还给开发者”的历史。第一步给的是defer和async让下载不再阻塞解析第二步给的是IntersectionObserver和loadinglazy让“要不要现在下载”变成可编程的第三步给的是preload、preconnect、fetchpriority让“提前多少”变成可调的。到了今天fetchpriority、Speculation Rules这类 API 已经能精细到“这张图是首屏 LCP给我提到最高优先级”这种颗粒度。理解这条演进线你在看各种“最佳实践”时就不会盲从——很多文章还在教你用 JS 监听 scroll 事件做懒加载那套写法在IntersectionObserver普及后已经属于负优化了滚动事件在主线程上疯狂触发自己就把自己拖慢了。1.3 四张底牌的核心差异对照我把这四个东西的关键属性整理成一张表这张表我建议你存下来做评审的时候直接对着念比临场解释快得多。维度同步加载异步加载async / defer / 动态注入延迟加载lazy预加载preload / prefetch / preconnect是否阻塞 HTML 解析阻塞不阻塞async 会在下载完成后插入执行短暂打断解析不涉及元素原本就在 DOM 里不阻塞下载时机解析到标签时立刻解析到标签时立刻开始下载满足触发条件时进入视口、路由命中、用户交互声明时立刻preload或空闲时prefetch执行 / 使用时机下载完成后立刻defer 在解析完成后按顺序async 在下载完成后立刻动态注入默认 async触发后才开始天然晚于首屏只负责“备好”用不用由后续逻辑决定顺序保证严格按文档顺序defer 保证顺序async 不保证不适用不适用典型失败后果首屏白屏、长任务堆积依赖关系断裂、报错未定义布局抖动CLS、滚动时卡顿重复下载、抢关键资源带宽、控制台警告典型适用对象必须最先执行的埋点、主题变量、polyfill 探测统计脚本、独立组件、第三方挂件长列表图片、折叠区域、非首屏路由首屏字体、LCP 大图、下一个页面必然要用的接口表格里有一行值得单独拎出来说“典型失败后果”这一列才是这四个概念真正的区别所在。很多人背得出定义但一到线上出问题就懵——图片懒加载导致页面一跳一跳那是 CLS脚本加了async之后某个插件报xxx is not defined那是顺序断裂控制台报“预加载的资源在几秒内未被使用”那是猜错了。定义只能帮你记住名词后果才能帮你判断该翻哪张牌。2. 同步加载最老实也最容易把首屏拖下水2.1 script 标签被解析到的那一刻浏览器在干什么我把这个过程拆成几步你在开发者工具里是能一一对应上的。HTML 解析器从上往下扫扫到script srca.js时它会做三件事第一暂停 DOM 树的构建第二如果这个脚本还没有缓存发起网络请求第三拿到响应后交给 JS 引擎编译执行执行完毕后解析器才继续往下走。注意这里的暂停是解析暂停不是渲染暂停但结果是等价的——DOM 不完整渲染就无从谈起。你在 Performance 面板里会看到主线程上出现一段很长的黄色块标着Parse HTML被Evaluate Script打断这就是同步脚本正在“收过路费”。还有一个容易被忽略的耦合如果页面里有link relstylesheet排在script前面那么这个脚本的执行还必须等 CSSOM 构建完成。因为脚本里可能有getComputedStyle之类的操作浏览器不敢在样式没算出来的时候执行它。这就是所谓的“CSS 阻塞 JS”。我曾经遇到过一个页面样式表里有一个慢接口返回的字体文件结果样式表迟迟不 ready后面三个同步脚本全部干等白屏时间被拉到了四秒多。这个问题的解法不是把脚本改成async就完事而是要先把那个阻塞样式表的关键路径切短——比如把首屏关键 CSS 内联非关键 CSS 用media或者后续加载。注意同步脚本的阻塞是“串行叠加”的。三个各需要 100ms 的同步脚本代价不是 100ms而是 300ms而且这 300ms 里解析器一步都不能动。2.2 算一笔账三个同步脚本到底吃掉了多少时间不估算就没法做决策我给一个我常用的粗算模型。假设首屏有三个同步脚本压缩传输后分别是 80KB、60KB、100KB合计 240KB服务器在 40ms 往返延迟、有效下行 10Mbps 的网络条件下每个脚本的解析执行时间约 40ms。传输时间240KB 换算成比特是 240 × 1024 × 8 ≈ 1966Kb除以 10240Kbps ≈ 192ms。因为三个脚本是串行的第一个执行完才继续解析才轮到第二个每一次都要重新计算往返保守加两次握手级别的等待按 40ms × 3 120ms 算。执行时间三个合计 120ms。总阻塞时长 ≈ 192 120 120 ≈ 432ms。这 432ms 意味着什么意味着用户在这段时间里看到的是白屏意味着 LCP 的候选时刻被整体往后推了将近半秒。如果你在移动端弱网下有效带宽降到 1.6Mbps往返 150ms这个数字会膨胀到两秒以上——这就是为什么同一个页面在办公室里“感觉很快”在用户手里“慢得想关掉”。反过来如果把这 240KB 中真正必须首屏执行的只有 80KB 抽出来同步剩下 160KB 改成defer阻塞时长大概能压到 150ms 上下剩下的在解析完成后补齐。再进一步如果那 80KB 里还有一部分是“用户点了才用”的那它连defer都不该占直接丢进懒加载。提示这个模型是粗算目的是让你形成“量级感”不是让你拿去写 SLA。真正的数据永远以 Performance 面板和真实用户监控为准。2.3 同步加载还不能一刀切掉这些场景必须留着有些文章把同步加载说得一无是处这是矫枉过正。我梳理了几类我至今仍然坚持用同步或至少在head里内联同步执行的场景一是必须在渲染前生效的全局配置。比如主题色的初始化——用户选了暗色模式你需要在首屏渲染前把>// 用户点击“导出报表”时才加载体积较大的导出模块 const exportBtn document.querySelector(#export); exportBtn.addEventListener(click, async () { exportBtn.disabled true; try { const { generateReport } await import( /* webpackChunkName: report-exporter */ ./modules/report-exporter.js ); await generateReport(); } catch (err) { // 网络失败或模块加载失败都要有兜底 console.error(导出模块加载失败, err); } finally { exportBtn.disabled false; } });这段代码里有几个我踩过坑才加上的细节webpackChunkName注释是为了让拆出来的 chunk 有个可读名字线上排查时你能在 Network 面板里一眼认出来try/catch是必须的动态导入会因为网络问题失败失败后按钮必须恢复可用否则用户会以为页面卡死了disabled的切换是为了防连点导致的重复加载。这些都是文档里不会重点写的东西但线上真的会发生。3.3 async 的顺序陷阱与补救写法我再单独说说async的顺序问题因为它是最容易翻车的地方。有一类脚本天生不能无序比如“先注册全局 SDK再调用它的方法”。这种场景下你有三个选择第一两个都改成defer简单直接代价是执行被推迟到解析完成。如果这两个脚本对首屏渲染没有影响这是最省心的方案。第二只给后执行的脚本加一个“等待前置条件”的逻辑比如轮询检查全局对象是否就绪或者让前置脚本执行完派发一个自定义事件后续脚本监听这个事件再启动。写法上比defer复杂但能保住“尽早开始”的优势。第三用动态import()显式表达依赖关系让模块系统帮你排序。这条路最干净前提是项目本身已经是模块化的。我个人的偏好是能用模块依赖表达的顺序绝不用加载属性去赌。属性是声明式的、隐式的出了问题只能靠猜import是显式的、有类型检查和打包校验的出问题构建阶段就报错了。第三方脚本没法模块化的时候才退回到defer或事件协调。还有一个细节async脚本如果加载失败浏览器不会重试也不会阻塞后续内容等于静默失败。对于统计类脚本这是可以接受的对于功能类脚本就必须自己做失败兜底——监听script.onerror决定是降级、重试还是给用户提示。我见过一个页面因为第三方组件 CDN 抖动async脚本没加载上导致整个评论区空白用户完全不知道发生了什么这种体验比慢一点糟糕得多。4. 延迟加载不是下载慢是根本还没到该要的时候4.1 原生 loadinglazy 的边界与坑img loadinglazy和iframe loadinglazy是浏览器原生的懒加载写一个属性就生效实现成本几乎为零。它的触发逻辑大体是“元素接近视口时开始加载”具体阈值由浏览器决定而且会考虑网络状况——弱网下预取距离会更保守。这个方案我现在的默认选择就是它因为它是浏览器层面实现的不占主线程比自己写滚动监听要稳得多。但它的边界也很明确。第一只对img和iframe生效背景图CSSbackground-image管不了video的poster也管不了部分场景可以用preloadnone配合。第二它是“接近视口才加载”不是“完全不加载”所以首屏之外的图片依然会在滚动到附近时被请求如果你的列表页有几百张图用户一路滚到底该下的还是会下只是错开了高峰。第三阈值不可控你没法精确指定“提前 500px 开始加载”这在做某些交互动画比如图片必须在滑入视口前就位否则会看到空框时不够用这种场景就得自己写。还有一类元素特别容易被误伤首屏的 LCP 大图。给首屏主视觉图加loadinglazy是典型的负优化因为它本来应该最高优先级尽快下载加了 lazy 之后浏览器会把它降级、延后LCP 直接变差。我的判断标准很简单这个图在首屏可视区域内就绝对不加 lazy在首屏之外加。至于“首屏”怎么界定我用视口高度来判断——首屏按 100vh 算留一点余量的话 1.2 倍视口以内的图都不加。4.2 用 IntersectionObserver 写一个可控的懒加载器当原生属性不够用时IntersectionObserver是我唯一推荐的方案。它的核心价值是元素是否进入视口的判断由浏览器在合成线程完成不占用主线程这跟scroll事件监听 getBoundingClientRect()的老写法有本质区别——后者会在滚动时疯狂触发主线程计算滚动越流畅的页面越容易被它拖卡。下面是我项目里常用的一版支持提前量、支持失败兜底、支持只加载一次// rootMargin 用正值表示“提前多少像素开始加载” const observer new IntersectionObserver( (entries) { entries.forEach((entry) { if (!entry.isIntersecting) return; const el entry.target; const src el.dataset.src; if (!src) return; // 用真实 src 替换占位地址 el.src src; el.removeAttribute(data-src); // 加载失败时回退到占位图避免出现裂图 el.addEventListener( error, () { el.src /assets/placeholder.svg; }, { once: true } ); // 加载完就取消观察避免重复触发 observer.unobserve(el); }); }, { root: null, // 提前 600px 开始下载弱网下可以调到 1200px rootMargin: 600px 0px, threshold: 0.01, } ); document.querySelectorAll(img[data-src]).forEach((img) observer.observe(img));几个参数的选择我解释一下。rootMargin的 600px 不是随便写的按常见手机屏幕宽度 390px 估算一屏高度大概 700 到 800px提前 600px 意味着用户还有接近一屏的滚动距离时就开始下载这个距离差不多能覆盖 3G 网络下 200KB 图片的下载时间。弱网环境我会把/api之类的动态配置下发下来把提前量调大。threshold设 0.01 而不是 0是因为某些老版本浏览器在 0 阈值下对“刚好贴边”的元素判断不稳定。unobserve必须加否则元素每次进出视口都会触发回调白白消耗性能。这套写法的代价是需要处理占位。因为src一开始是空的或者占位图图片区域必须预先占好位置否则图片一加载就会把下面的内容顶下去这就是 CLS。解决办法有两个给img写上明确的width和height属性浏览器会用它们计算宽高比并预留空间或者用 CSS 的aspect-ratio。这两个我都用谁方便用谁。4.3 路由级懒加载与代码分割的落地方式页面级的懒加载价值比图片大得多因为它省的是 JS 执行时间而 JS 执行是直接挤占主线程的。核心手段是路由级代码分割把每个路由的组件单独打成一个 chunk用户进到哪个路由才加载哪个。以常见的组件化框架为例写法是用框架提供的懒加载 API 包一层import { lazy, Suspense } from react; import { BrowserRouter, Routes, Route } from react-router-dom; // 每个路由单独分包首屏只加载首页和必要的公共依赖 const Home lazy(() import(./pages/Home)); const Dashboard lazy(() import(./pages/Dashboard)); const Settings lazy(() import(./pages/Settings)); export default function App() { return ( BrowserRouter Suspense fallback{div classNamepage-skeleton /} Routes Route path/ element{Home /} / Route path/dashboard element{Dashboard /} / Route path/settings element{Settings /} / /Routes /Suspense /BrowserRouter ); }这里面有几个经验点。分包不要太碎我曾经见过一个项目把每个页面拆成十几个小 chunk结果打开一个路由要发十几条请求HTTP/2 下虽然多路复用但每条请求的头部开销和调度成本加起来反而更慢。我的经验是单页 chunk 控制在 50KB 到 200KB压缩后比较合适小文件合并大文件再拆。公共依赖要提出来框架、UI 库、工具函数这些几乎每个路由都用的东西必须配置到公共 chunk 里否则会被重复打进每一个路由包总下载量翻几倍。要有加载态Suspense的 fallback 不是装饰它是用户在弱网下唯一能看到的反馈用一个和页面结构相近的骨架屏比转圈好得多因为骨架屏能减少用户的等待焦虑也能减少布局跳变。还有一种更细粒度的懒加载是“组件级”的比如一个只在点击后才展开的富文本编辑器、一个图表库、一个地图组件。这类东西的共同特征是体积大、使用频率低、不影响首屏理解。判断标准也很简单——这个组件的第一屏可见内容里需不需要它。不需要就懒加载。4.4 懒加载最容易踩的三个坑CLS、首屏误伤、SEO第一个坑是布局抖动。懒加载的图片如果没有占位加载完成的瞬间会把下方内容整体推下去用户正在读的文字突然跑位这是最伤体验的问题之一而且在性能指标里会被记为 CLS。解法前面说了固定宽高比或者写死宽高。还有一个更隐蔽的情况图片的宽高比和数据里记录的不一致占位按 16:9 预留实际图片是 4:3加载完还是会跳。这种要在数据层就校验或者用object-fit: cover强制裁切。第二个坑是首屏误伤。除了前面说的 LCP 大图还有一种情况是“看起来不在首屏、实际在首屏”。比如页面顶部有一个可折叠的公告栏展开状态下会把下面的内容推下去原本在首屏的图就变成了第二屏但用户实际看到的位置和设计稿不一致。这类问题只能靠真机走查我在交付前会拿三台不同高度的手机各滚一遍专门看有没有“该出来的没出来”或者“不该出来的先出来了”。第三个坑是搜索引擎抓取。现代搜索引擎的爬虫大多能执行 JS也会模拟滚动理论上懒加载不影响索引。但“理论上”和“实际上”之间隔着爬虫的预算限制——它可能只滚动一段就放弃了。我的保守策略是正文里的关键图片、文章封面不加懒加载只有列表页的次要缩略图、评论区头像这类才加。另外如果站点严重依赖图片搜索流量宁可牺牲一点性能也不要冒这个风险。5. 预加载把未来的等待挪到现在来花5.1 preload、prefetch、preconnect、dns-prefetch、prerender 的分工这几个词经常被混着说其实它们的激进程度是递进的我按“动作深度”排一下指令提前做到哪一步优先级典型用途猜错的代价dns-prefetch只做域名解析极低第三方域名、CDN 域名几乎为零preconnectDNS TCP TLS 握手低一定会请求的跨域接口很小一个空闲连接prefetch完整下载资源并放入缓存最低空闲时下一个页面必然要用的 JS / 图片浪费带宽可能挤占首屏preload以高优先级提前下载当前页面要用的资源高首屏字体、LCP 图片、关键 CSS重复下载、警告、抢带宽prerender/ 预渲染连页面渲染都提前做完最低极高概率的下一步导航浪费大量资源可能执行副作用我的使用顺序基本是dns-prefetch无脑加preconnect挑两三个最重要的加preload只给首屏关键资源加prefetch看用户路径的确定性决定加不加预渲染只在极少数场景用。之所以这么保守是因为越往后猜错的成本越高。preload猜错会导致同一个资源被下载两次——一次是 preload一次是真正用到时的正常请求因为浏览器发现 preload 的东西和实际请求的凭据模式、as 类型对不上就不认缓存等于凭空多了一次请求。5.2 preload 的 as 属性和优先级写错等于白写preload最容易写错的地方是as。这个属性不是可选的装饰它告诉浏览器“我要的是哪一类资源”浏览器据此决定优先级、决定放在哪个缓存里、决定要不要做内容协商。写错或者不写浏览器会警告而且大概率不生效。!-- 首屏字体必须带 crossorigin否则会重复下载 -- link relpreload href/fonts/main-subset.woff2 asfont typefont/woff2 crossorigin / !-- 首屏 LCP 大图 -- link relpreload href/images/hero-1200.webp asimage imagesrcset/images/hero-800.webp 800w, /images/hero-1200.webp 1200w imagesizes100vw / !-- 关键 CSS -- link relpreload href/css/critical.css asstyle onloadthis.relstylesheet / noscriptlink relstylesheet href/css/critical.css //noscript字体这一条我单独强调跨域字体必须写crossorigin因为字体请求在规范里是按 CORS 模式发起的preload 如果不带这个属性浏览器会认为它跟实际请求不是同一个东西于是重新下载一遍。这个坑我见过太多次控制台会明确提示“资源被 preload 但未被使用”很多人以为是没生效其实是匹配不上。asimage配合imagesrcset和imagesizes是响应式图片的正确写法如果你只写href不写srcset浏览器可能预加载了一个尺寸不对的图等真正渲染时又按srcset选了另一张白下。这个在实际项目里很常见尤其是用了图片 CDN 自动裁切的场景。还有一个容易被忽略的新属性fetchpriority它可以直接调优先级不需要走 preload!-- 首屏主图明确提到高优先级 -- img src/images/hero.webp fetchpriorityhigh width1200 height600 alt / !-- 底部推荐位的小图明确降级别跟首屏抢 -- img src/images/thumb.webp loadinglazy fetchprioritylow width200 height120 alt /这两个属性搭配起来用效果比单独用loadinglazy好因为它把“优先级”和“加载时机”这两件事分开控制了。5.3 预加载翻车的几种典型姿势第一种是预加载了又不用。控制台会警告“该资源在 load 事件后几秒内未被使用”。我遇到过一次是在一个活动页里 preload 了三张轮播图结果用户网络慢首屏 JS 还没执行完就到了判定时间警告刷屏而且这三张图确实抢了主图的带宽。后来改成只 preload 第一张其余靠正常的懒加载。第二种是preload 太多等于没有优先级。所有资源都是高优先级那就没有高优先级。我给自己定的规则是一个页面里preload不超过三个且必须能说出每一个对应的是哪个首屏关键元素。说不出来的就删掉。第三种是prefetch 猜错路径。prefetch是在浏览器空闲时下载理论上不抢首屏但如果用户首屏交互很重、空闲时间很少prefetch就永远排不上队白白占了一个缓存条目。更麻烦的是它可能下载了用户根本不会访问的页面浪费了用户的流量。我的做法是只在“上一步到下一步的转化率极高”的路径上加prefetch比如列表页到详情页用户在列表页停留超过两秒、且滚动过一定距离才动态插入prefetch标签。这样命中率高得多。第四种是**preconnect开了太多**。每个preconnect都会占用一个连接资源开十几个反而拖慢。我的上限是四个只给最重要的跨域域名。6. 落到一个真实页面上分层加载方案的完整实操6.1 第一步永远是量不是改我自己踩过最大的坑就是“先改后测”。看到首屏慢凭直觉把脚本全改成defer把图片全加上lazy结果某些场景下反而更慢而且已经改乱了很难判断是哪一步导致的。所以现在我固定按这个顺序来。第一步用开发者工具的 Network 面板打开勾上“禁用缓存”把网络限速调到“快速 3G”然后刷新页面。重点看三列大小、时间、优先级。按大小排序看谁最占带宽按时间排序看谁最晚返回看优先级列确认首屏关键资源是不是真的拿到了 High 或 Highest。如果一张首屏大图的优先级显示为 Low那基本可以确定是被 lazy 或者某个属性拖下去了。第二步看 Performance 面板的火焰图。我要找的是主线程上的长任务超过 50ms 的黄色块以及Parse HTML被打断的位置。如果发现解析过程被切成好几段每一段中间都夹着Evaluate Script那就是同步脚本在作祟具体是哪几个点开调用栈就能看到文件地址。第三步跑一遍 Lighthouse看“消除阻塞渲染的资源”和“预加载关键请求”这两项。Lighthouse 的建议不一定全对但它指出的阻塞资源清单是准的可以当线索用。第四步也是最重要的一步看真实用户数据。实验室环境再像也不如真实分布。我会看两个分位数LCP 的 75 分位和 INP 的 75 分位。如果 75 分位的 LCP 已经到了 3 秒以上说明有相当一部分用户在网络条件差的环境里这时候优化优先级要往前调。这一步的数据来源每个团队不一样但思路是一样的先知道“慢在谁身上”再决定改什么。6.2 分层策略与代码落地量完之后我会把页面上的资源按“何时必须就位”分成四层每一层对应一种加载方式层级判定标准加载方式我常用的具体手段第 0 层渲染前必须影响首屏样式或渲染正确性体积很小同步内联主题变量、视口相关的类名挂载第 1 层首屏必须首屏可见内容依赖包括 LCP 元素同步 / 高优先级关键 CSS 内联、LCP 图fetchpriorityhigh、关键字体preload第 2 层首屏之后很快要用首屏外但在第一次交互范围内defer/ 并行下载主逻辑脚本、首屏外第一屏图片、下一个路由的公共 chunk第 3 层用户触发才需要折叠区、弹窗、非首屏路由、低频组件懒加载loadinglazy、IntersectionObserver、动态import()落地的时候HTML 大概长这样head !-- 第 0 层极小内联渲染前生效 -- script (function () { try { var theme localStorage.getItem(theme); if (theme) document.documentElement.dataset.theme theme; } catch (e) {} })(); /script !-- 第 1 层关键 CSS 内联非关键 CSS 异步 -- style/* 首屏关键样式控制在 14KB 以内 *//style link relpreload href/css/app.css asstyle onloadthis.relstylesheet / noscriptlink relstylesheet href/css/app.css //noscript !-- 第 1 层字体与 LCP 图 -- link relpreload href/fonts/main.woff2 asfont typefont/woff2 crossorigin / link relpreload href/images/hero.webp asimage / !-- 跨域域名提前握手 -- link relpreconnect hrefhttps://api.example-cdn.invalid crossorigin / !-- 第 2 层主逻辑defer 保证顺序且不阻塞解析 -- script src/js/vendor.js defer/script script src/js/app.js defer/script /head注意这里我用了onload把rel改成stylesheet的写法这是“异步加载 CSS 但不阻塞渲染”的经典手法。它的副作用是在 CSS 加载完成之前页面会处于无样式状态如果用户在极短时间内看到会闪一下。解决办法是把首屏关键样式内联这样即使外部 CSS 还没到首屏也是对的。这个内联的体量要控制我一般卡在 14KB 左右因为这是首个 TCP 往返窗口的典型容量超过就得再来一轮反而拖慢。提示preconnect的域名我写成了示例域名实际项目里替换成你自己的接口域名。这个属性只对跨域资源有意义同域资源不需要。6.3 改完之后怎么验证没有回退改完必须验证而且不能只看“感觉快了”。我固定做三件事。第一对比同一网络条件下的关键指标。用同一台设备、同一个限速档位改前改后各跑五次看 LCP、CLS、INP 的中位数变化。只看一次容易被波动骗。第二检查控制台。重点看两类警告preload 未被使用、资源加载失败。只要有一条就说明有一处配置没对齐。第三走一遍功能回归。这是最容易被跳过但最要命的一步。改成defer的脚本会不会有执行顺序问题改成懒加载的图片在快速点击跳转时会不会来不及加载动态import()在某些路由下会不会失败我一般会专门测三种情况弱网限速最慢档、快速滚动到底、以及在页面加载中途点按钮。这三种最容易暴露问题。还有一个小技巧在代码里埋一个简单的性能标记把关键里程碑打出来上线后能对比不同版本的差异。// 记录关键节点方便线上排查 performance.mark(app-bootstrap-start); // ...应用初始化逻辑 performance.mark(app-bootstrap-end); performance.measure(bootstrap, app-bootstrap-start, app-bootstrap-end); // 动态导入的耗时也值得单独看 const t0 performance.now(); import(./modules/chart.js).then(() { console.log(图表模块加载耗时, Math.round(performance.now() - t0), ms); });这些标记在本地看不出价值但线上出问题时能帮你快速排除“是不是某个 chunk 加载慢了”这种可能。我吃过一次亏用户反馈某个面板打不开排查了半天以为是接口问题最后发现是那个懒加载的 chunk 因为构建配置改了路径404 了而代码里没做失败提示静默失败。7. 常见问题与排查技巧实录7.1 问题速查表这张表是我这些年攒下来的遇到现象先对一遍能省不少时间。现象最可能的原因排查动作处理方式首屏白屏时间长火焰图里解析被切成多段存在同步脚本看Parse HTML打断位置的调用栈挪到defer或懒加载只留极小内联报xxx is not defined本地正常线上偶发async导致顺序错乱检查脚本是否都加了async改用defer或动态import()表达依赖控制台提示 preload 的资源未被使用as类型不符或字体缺crossorigin对比 preload 与实际请求的类型、凭据模式补齐as和crossorigin或删掉 preload同一资源在 Network 里出现两次preload 与实际请求不匹配看两次请求的 Initiator 和请求头对齐as、crossorigin、type滚动时图片位置跳动CLS 偏高懒加载图片没有预留尺寸检查img是否有宽高或aspect-ratio补宽高属性或用占位容器固定比例首屏大图迟迟不出现被加了loadinglazy看该图请求的优先级去掉 lazy加fetchpriorityhigh弱网下页面很慢办公室很快资源总量大、串行请求多限速到 3G 复测拆包、合并小文件、关键资源预加载懒加载的组件点击后长时间空白动态导入体积过大或失败静默看该 chunk 的大小与请求状态拆分 chunk、加加载态与失败兜底滚动越滚越卡用了 scroll 事件手动计算位置看主线程是否有高频 layout换成IntersectionObserver页面上线后某个面板打不开无报错懒加载 chunk 路径错误 404Network 里筛 JS 请求看状态码加onerror兜底与错误上报7.2 几个只有踩过才知道的细节第一个细节defer的脚本也不是完全没有代价。它虽然不阻塞解析但会在DOMContentLoaded之前集中执行如果几个大脚本都在这个时间点跑会形成一个很长的主线程任务把首次交互的时间往后推。我的做法是控制defer脚本的总执行时间在 100ms 以内超过的部分继续往后挪改成“空闲时加载”或者“交互后加载”。判断标准是看这个脚本对首屏可交互有没有贡献——如果只是挂一些不影响首屏操作的监听器那它完全没必要挤在DOMContentLoaded前面。第二个细节懒加载的“提前量”是跟网络状况相关的不能写死一个值。我在 4G 下用 600px 提前量很舒服到了弱网就来不及了用户滑到图片位置时还是白框。后来改成根据navigator.connection.effectiveType动态调4G 用 400px3G 用 1000px2G 直接不做懒加载全部按正常加载走因为弱网下“边滑边下”的体验还不如一次多下一点。这个 API 不是所有浏览器都支持所以要有默认值兜底。第三个细节预加载和懒加载会在同一个页面上打架。比如你给某个图片既写了loadinglazy又在head里preload了它浏览器会困惑——preload 说“我很重要快下”lazy 说“先别下”。实际表现通常是 preload 生效lazy 白写但如果配置有细微不一致比如 preload 的是 A 尺寸srcset选的是 B 尺寸就会出现下两次。所以我在做优化清单的时候会强制要求“预加载清单”和“懒加载清单”互斥用脚本检查一遍有没有交集。第四个细节首屏关键 CSS 内联之后构建产物会变得难维护。内联意味着样式散落在模板里改一次要同步改两处。我的做法是用构建插件自动提取首屏用到的 CSS 规则内联进去其余走异步加载这样源文件还是正常的样式文件只是产物做了处理。如果项目规模不大、样式不多手动维护一小段关键 CSS 也完全可以接受重点是别让它随着时间慢慢膨胀成几百行。第五个细节别忽略iframe的懒加载。嵌入的视频、地图、第三方评论区往往是最拖慢首屏的东西一个内嵌的第三方组件可能带来几十个请求。iframe loadinglazy一个属性就能把它们压到视口附近才加载收益比优化自己的图片大得多。更好的做法是“点击后再加载”——先渲染一张缩略图加播放按钮用户点了才把iframe插进去这样首屏一个第三方请求都不会发。最后分享一个我自己形成的判断习惯面对任何一个资源我先问三个问题——它影响首屏的第一眼吗它的体积值得为它单独做一次网络往返吗它失败的后果用户能感知吗第一个问题答“是”就归到第 0 层或第 1 层同步或高优先级预加载答“否”且第二个问题答“是”就归到第 3 层做懒加载第二个问题答“否”那就干脆合并进别的资源里别为几 KB 的东西单独发请求。第三个问题答“是”的无论放在哪一层都得配上失败兜底和用户可感知的提示。这套问法不依赖任何具体框架换到移动端、小程序、甚至桌面端应用里逻辑照样成立因为资源调度的本质从来没变过——你只是在决定什么时候把用户的等待花掉以及花在哪。