
预加载这个词我在不同场合听到过太多版本前端同学说的是link relpreload做游戏的朋友说的是资源包预热后端同事聊的是缓存预热做多媒体的同行讲的是视频缓冲。名字一样骨子里的逻辑也差不多——在用户真正需要某个东西之前先把它准备好。听着简单真落地的时候坑不少预加载了却没命中缓存、预加载抢了关键资源的带宽、预加载了一堆用户根本用不到的东西。这篇就把预加载这件事从头到尾捋一遍从它到底在解决什么问题到 Web 端各类资源提示的选型再到图片、媒体、接口、服务端缓存的实操细节最后是度量和排查。前端、全栈、客户端方向的同学都能对号入座刚接触性能优化的新手看完也能直接上手抄配置。1. 预加载到底在解决什么问题1.1 从一次首屏卡顿说起前阵子帮一个做内容站点的朋友看首屏场景很典型页面 HTML 返回很快TTFB 不到 200ms但用户在手机上看到完整首屏要三秒多。抓了一遍瀑布图问题一目了然——首屏那张主图是在 CSS 解析完、DOM 树建好之后才被发现的浏览器在这之前完全不知道有这么个资源要下。也就是说从 HTML 到达浏览器到主图开始下载中间白白空了一秒多。这就是预加载要解决的第一类问题资源发现的时机太晚。浏览器解析 HTML 是流式的遇到img才知道要下载图片遇到script才知道要拉脚本。如果关键资源藏在 CSS 的background-image里、藏在 JS 动态插入的标签里、藏在懒加载逻辑后面那它的下载起点就被推得很靠后。预加载做的事情本质上是把「发现资源」这个动作提前让下载和解析并行起来。第二类问题是连接的建立成本。跨域请求要经历 DNS 解析、TCP 握手、TLS 协商在移动网络下这一套下来几百毫秒很正常。哪怕资源本身只有 10KB等连接建好用户已经感觉到卡了。所以除了预加载资源本身还有一类技术专门预加载「连接」。第三类问题更隐蔽用户下一步要干什么系统其实猜得到但没提前准备。用户把鼠标移到导航链接上、手指开始横向滑动轮播、搜索结果页还在滚动这些信号都预示下一个请求即将到来。预加载的高级玩法就是利用这些信号做预测性加载。这三类问题对应三种不同层次的手段后面的章节会逐一展开。1.2 预加载的本质把等待挪到用户不感知的地方我习惯用一个类比来解释预加载去餐厅吃饭厨师不可能等你点完单才开始洗菜切菜备菜这件事一定是在营业前就做好的。预加载就是「备菜」核心不是让总耗时变短而是让用户能感知到的等待变短。这里有个关键认知需要摆正预加载几乎从来不减少总的网络传输量很多时候还会增加——预加载了但用户没用上这部分流量就是纯浪费。它优化的是时间分布把耗时的动作挪到用户注意力不在的那个时间段里。所以判断一个预加载方案值不值不能只看「加载快了没有」得看「浪费了多少」和「抢占了多少」这两笔账。浪费很好理解预加载命中率低就是浪费。抢占则更容易被忽略浏览器和服务端的带宽、连接数、CPU 都是有限的你给一个非关键资源开了高优先级预加载它就会和首屏最关键的那张图抢同一条 TCP 连接的带宽。我见过有项目把首屏所有图片全加上preload结果 LCP 反而从 2.1s 涨到 2.8s原因就是这个——所有资源都成了关键资源等于没有关键资源。所以预加载的设计原则我总结成三句话只预加载高确定性的资源只给真正卡住渲染的资源提优先级一定要有度量手段验证收益。后面所有具体技术都是在这三条原则下做取舍。1.3 三类典型误用先避坑再谈优化在讲具体技术之前先把最常见的三类误用摆出来因为它们出现的频率高得离谱。第一类是无差别预加载。某后台管理系统为了「提升体验」在路由切换时把剩下所有页面的 JS chunk 全部prefetch了一遍二十多个页面加起来十几兆。结果是首页刚加载完网络就被这些低优先级请求占满了用户点进下一个页面时反而更慢。正确的做法是根据用户行为路径做加权只预热高概率的下一站。第二类是预加载和实际请求对不上。这个坑极其隐蔽link relpreload href/api/list预加载了接口但实际发请求时带了个?page1ts1699999的时间戳参数URL 不一致浏览器缓存里那份预加载结果完全用不上等于白下。跨域资源漏写crossorigin也是同类问题预加载用的是无凭证模式实际请求带凭证缓存键不同两次下载。第三类是预加载了却没人用。控制台那句The resource was preloaded using link preload but not used within a few seconds就是在提醒你。常见于条件渲染的组件代码里预加载了弹窗里的图表库但用户压根没点开弹窗。这类警告不要当噪音忽略每一条背后都是一次真实的带宽浪费。2. Web 端资源提示的技术图谱与选型2.1 四种资源提示的定位差异Web 平台提供的「预」类能力按介入深度从浅到深大致是这么一条线dns-prefetch→preconnect→prefetch→preload。很多人把它们混着用其实四者的成本、收益、适用场景差别很大。dns-prefetch最轻只提前做域名解析把域名到 IP 的映射结果缓存起来。成本几乎可以忽略收益在跨域场景下通常是几十毫秒。写法就是link reldns-prefetch href//cdn.example-cdn.com。它适合那种「可能会用到但不确定」的第三方域名多写几个也不心疼。preconnect更进一步除了 DNS 还要完成 TCP 握手和 TLS 协商。收益明显更大但成本也上来了——每个连接都要占用服务端和客户端的资源浏览器对同时保持的预连接数量有限制。我的经验是控制在三到四个以内优先给字体域名、核心 CDN 域名、核心接口域名。写法上记得加crossorigin因为大部分跨域资源都需要 CORS 模式连接link relpreconnect hrefhttps://cdn.example-cdn.com crossorigin link reldns-prefetch hrefhttps://cdn.example-cdn.com两个一起写是常见做法dns-prefetch作为preconnect不被支持时的降级。这个组合我基本在所有跨域资源多的项目里都会加。prefetch面向的是「下一个页面可能用到」的资源优先级最低浏览器只在空闲时下载结果放进 HTTP 缓存。它不会阻塞当前页面但会消耗流量。用户在弱网或者流量敏感的场景下prefetch要慎用最好配合navigator.connection.saveData做判断。preload是这四者里最强硬的它告诉浏览器「当前页面一定会用到这个资源请立刻按指定优先级下载」。注意是下载不是执行。脚本预加载完了不会运行样式预加载完了不会应用都只是躺在内存或缓存里等着被真正引用。正因为强硬用错了代价也最大。2.2 preload 的正确姿势与几个必踩的坑preload的语法不复杂但细节很多。最基本的形式是link relpreload href/static/main.js asscript。as属性不是可选的装饰它决定了浏览器用什么样的优先级、什么样的请求头、放进哪个缓存池。写错as的直接后果是资源被下载两次。常见as取值和对应场景as 取值适用资源注意事项scriptJS 文件模块脚本建议改用 modulepreloadstyleCSS 文件预加载后仍需 link 引用才会应用image图片响应式图片需配合 imagesrcsetfont字体必须加 crossorigin否则重复下载fetchfetch/XHR 请求的数据需要 CORS 模式时加 crossorigindocumentiframe 或页面用得较少video / audio媒体文件大文件慎用字体这一项我要单独说因为它是踩坑率最高的。字体请求默认是 CORS 模式的link relpreload asfont如果不加crossorigin浏览器会用 no-cors 模式预加载一份实际用字体时又用 cors 模式请求一份两份缓存键不同白下一遍。这个问题在控制台会有明确提示但很多人不看控制台。link relpreload href/fonts/brand.woff2 asfont typefont/woff2 crossorigin还有个细节type属性建议写上。浏览器可以据此判断自己是否支持该格式不支持就直接跳过预加载避免下载一个用不上的文件。woff2 现在支持度已经很好了但写上总没坏处。2.3 响应式图片的预加载imagesrcset 怎么用普通的link relpreload asimage只认一个固定 URL遇到srcset就抓瞎了——浏览器在渲染阶段才会根据视口宽度和设备像素比挑选合适的候选图预加载阶段它不知道最终会选哪张。这个问题在很长一段时间里让响应式图片没法享受预加载的收益。后来标准补上了imagesrcset和imagesizes两个属性让预加载也能参与候选图选择link relpreload asimage imagesrcset/img/hero-480.webp 480w, /img/hero-960.webp 960w, /img/hero-1440.webp 1440w imagesizes(max-width: 600px) 100vw, 50vw fetchpriorityhigh这里的imagesizes必须和img标签上的sizes完全一致否则浏览器预加载的那张图和最终渲染选的那张图对不上等于没预加载。这种「两处配置必须同步」的地方最容易在迭代中失配我一般会把这两个值抽成同一份配置模板里同时渲染避免手改漏改。fetchpriorityhigh这个属性值得单独提一句。它作用在img和link relpreload上直接告诉浏览器这个资源的优先级。首屏主图、LCP 元素相关的资源加这个属性效果立竿见影比单纯preload更直接。反过来非关键资源可以标fetchprioritylow主动给它让路。2.4 modulepreload 与脚本预加载的取舍现代项目基本都是 ES module 了link relmodulepreload是对应的专用提示。它和preload asscript的区别在于modulepreload不仅下载还会解析模块、建立依赖图把静态 import 的子模块一并预加载。对于有明显依赖链的入口文件这个收益比普通preload大得多。不过要提醒一句modulepreload会把整条依赖链都拉下来如果入口文件的依赖图很庞大预加载的动作本身可能就很重。我的做法是只对首屏必需的入口做modulepreload路由级的 chunk 交给路由库自己的 prefetch 机制按用户行为触发不要一上来全拉。preload asscript在模块化项目里还有一个副作用如果同一个模块既被preload又被import部分浏览器可能因为请求模式差异比如preload不带typemodule语义导致重复请求。所以模块脚本我一律用modulepreload不再混用。2.5 Speculation Rules声明式的预测性加载link relprefetch的老问题是它只知道「预取资源」不知道「预渲染页面」。用户点链接进去HTML 有了、JS 有了但还是要走一遍完整的解析、布局、执行流程。Speculation Rules API 把这件事推进了一步可以用一段 JSON 声明什么样的链接在什么条件下预取甚至预渲染script typespeculationrules { prerender: [{ where: { href_matches: /article/* }, eagerness: moderate }], prefetch: [{ where: { href_matches: /* }, eagerness: conservative }] } /scripteagerness控制触发时机从immediate到conservative分几档本质上是在「预测准确度」和「资源浪费」之间调旋钮。moderate大致对应用户鼠标悬停在链接上的时刻这个时机点很好用——用户已经把注意力放到链接上了点击概率大幅上升但还没真的点。预渲染的收益非常大因为它是把整个页面在后台完整跑一遍用户点进去几乎是瞬开。但代价也大后台页面会执行 JS、可能发起接口请求、可能消耗 CPU。所以适用场景要挑内容型站点的文章详情页、商品详情页这类「结构稳定、副作用小」的页面适合带复杂交互或者有副作用的页面就要谨慎。这块能力目前的浏览器支持还在演进用之前务必做特性检测和能力降级。3. 图片与媒体资源的预加载实操3.1 首屏关键图片的三层保障首屏主图的加载我一般会做三层配置从发现时机、优先级、候选图三个维度一起下手。第一层把图片的img标签直接写进 HTML不要用 JS 动态插入也不要用 CSS 背景图。CSS 背景图要等 CSSOM 建好才被发现JS 插入的更要等脚本执行这两条路径都会大幅推迟下载起点。这是最基础也最有效的一招很多项目性能差就差在这一步。第二层给这张图加fetchpriorityhigh同时在head里补一条preload作为双保险head link relpreload asimage href/img/hero-960.webp imagesrcset/img/hero-480.webp 480w, /img/hero-960.webp 960w, /img/hero-1440.webp 1440w imagesizes(max-width: 600px) 100vw, 50vw fetchpriorityhigh /head body img src/img/hero-960.webp srcset/img/hero-480.webp 480w, /img/hero-960.webp 960w, /img/hero-1440.webp 1440w sizes(max-width: 600px) 100vw, 50vw fetchpriorityhigh alt首屏主视觉 /body第三层格式上优先用 WebP 或 AVIF配合type属性让浏览器跳过不支持的格式。实测下来同样视觉质量下 WebP 比 JPEG 小 25% 到 35%AVIF 还能再小一截但编码耗时和兼容性要权衡。我一般的策略是 AVIF 打头、WebP 兜底、JPEG 做最终降级用picture组织。三层叠下来首屏主图的下载起点能从「CSS 解析完」提前到「HTML 头部解析到」这一步在移动网络下能省几百毫秒。3.2 轮播与长列表图片的预热策略轮播图是个经典场景第一张图显示中第二张图马上要出现。如果等用户滑到第二张才开始下载切换瞬间必然是空白。这时候可以在第一张图加载完成后主动预热下一张。纯前端做法是在第一张图的load事件里创建new Image()赋值src或者用link relprefetch。不过我更推荐用IntersectionObserver做判断——轮播容器进入视口之后再触发后续图片的预加载避免用户在页面顶部根本没滚到轮播区就开始下载。长列表的图片预热则需要节流。用户快速滚动时如果每进入视口一张图就预加载后面三张滚动到列表中部时可能已经预加载了几十张。我的做法是设置一个预热窗口只预加载当前视口下方一到两屏范围内的图片超出范围的取消或延后。配合loadinglazy让浏览器自己管懒加载前端只负责在关键节点做「提前量」补充职责划分清爽很多。这里有个反直觉的点loadinglazy和预加载是可以共存的。lazy管的是「什么时候开始下载」预加载管的是「提前多久开始」。我的经验是lazy保留但在距离视口还有一段距离时就触发预热让图片在真正进入视口前就已经在下载中滚到位置时正好显示。纯靠lazy的默认阈值经常来不及。3.3 视频与音频的 preload 属性video和audio标签自带preload属性取值有none、metadata、auto三档。这个和前面讲的link relpreload是两套机制很多人搞混。preloadnone表示不预加载任何内容等用户点了播放再说最省流量。preloadmetadata只拉文件头部的元信息拿到时长、尺寸、封面播放时再缓冲这是大多数场景的推荐值。preloadauto让浏览器自己决定预加载多少实践中它往往会拉不少数据流量敏感场景要慎用。video preloadmetadata poster/img/poster.webp playsinline source src/media/intro.webm typevideo/webm source src/media/intro.mp4 typevideo/mp4 /video需要提醒的是移动端浏览器对preloadauto的执行并不一致很多情况下会忽略它、强制走省流量策略。所以别指望靠这个属性做「视频秒开」真要秒开得用分段加载加首帧预载的方案或者用 MSE 做分片缓冲。这是另一个话题展开就偏了。3.4 预加载抢带宽一个必须验证的副作用前面反复提到抢占这里给个具体的排查方法。假设你给首屏主图加了preload但 LCP 没降反升大概率是预加载列表里混进了会抢带宽的东西。第一步打开开发者工具的网络面板按优先级排序看请求列表。如果Highest或High优先级的请求数量明显多于首屏真正必需的资源数量那就是优先级通胀了。第二步看这些高优先级请求的发起时间和主图的下载时间有没有重叠。带宽是共享的几个大文件同时在跑主图的速度必然被拖慢。第三步逐个摘掉可疑的预加载项重新测 LCP。这个二分排查法虽然土但非常有效。我一般会先摘掉体积最大的那几个一次摘一个测三次取中位数避免噪声干扰。实测下来一个很常见的结论是预加载字体文件的收益往往被高估。字体文件动辄几十上百 KB预加载它会明显挤压主图带宽而字体加载慢一点只影响文字渲染对 LCP 的影响远小于主图。所以如果预算有限宁可把带宽留给主图字体用font-display: swap先降级显示。4. 数据预加载接口预热、缓存预热与骨架屏4.1 路由跳转前的数据预取资源预加载解决的是「静态文件来得慢」数据预加载解决的是「接口回得慢」。后者的用户感知往往更明显因为静态资源至少还有浏览器缓存接口每次都要跑一遍服务端逻辑。现代路由库基本都提供了预取能力。以使用比较广的 React 生态为例路由 loader 模式可以在用户悬停链接时就开始拉数据等真正跳转时数据已经在缓存里了。核心思路是把「路由跳转」和「数据获取」这两个动作解耦让后者提前发生。具体实现上我一般会做两层悬停触发一次轻量预取只拉最关键的列表数据跳转后如果发现还有缺失的详情数据再补一次。这样既不会在悬停时就打一堆请求又能保证主流程的数据已经就位。手动实现也不复杂核心是一个带缓存的请求函数const cache new Map(); function prefetch(key, fetcher) { if (cache.has(key)) return cache.get(key); const p fetcher().catch(err { cache.delete(key); throw err; }); cache.set(key, p); return p; } // 悬停时触发 link.addEventListener(mouseenter, () { prefetch(/api/detail/${id}, () fetch(/api/detail/${id}).then(r r.json()) ); }, { once: true });几个细节要注意。once: true防止用户反复悬停重复触发缓存里存的是 Promise 而不是结果这样并发请求会自动去重失败时要从缓存里删掉否则一次失败会被缓存住后续重试永远拿到 rejected 的 Promise。第三点我踩过排查了很久才发现是个已缓存的失败 Promise 在作祟。4.2 移动端的触摸信号怎么用桌面端有mouseenter移动端没有悬停得换别的信号。我的经验是看touchstart和pointerdown。用户手指按下链接的瞬间到手指抬起触发点击中间有个几十到一百多毫秒的间隔这段时间足够发起一个请求的握手了。不过移动端更有效的信号其实是滚动速度骤降。用户在列表里快速滑动时说明还在浏览滑动速度突然降下来甚至停住说明注意力落到了某几项上这时候预热这几项对应的详情数据命中率会高不少。还有一种更保守的做法只在 Wi-Fi 环境下做数据预取蜂窝网络下关闭。实现上用navigator.connection.effectiveType判断4g以上才启用const conn navigator.connection; const canPrefetch !conn?.saveData (!conn?.effectiveType || /4g/.test(conn.effectiveType));saveData这个字段是用户主动开启的省流量开关优先级最高看到它就一律不预取。这个细节很多项目会漏但它直接影响用户对产品的观感。4.3 服务端缓存预热启动即热前端预加载再努力接口本身如果冷启动要跑两秒用户照样等。所以预加载这件事是要贯穿全链路的服务端这一层不能缺。服务端缓存预热的典型做法是应用启动时加载热点数据。比如一个内容站点可以在启动钩子里把访问量最高的若干篇文章查出来放进缓存让第一批请求直接命中。预热逻辑要放在健康检查通过之前避免预热没完成就被流量打进来。async function warmup() { const hotIds await db.query( SELECT id FROM articles ORDER BY views DESC LIMIT 200 ); await Promise.all( hotIds.map(({ id }) cache.set(article:${id}, loadArticle(id), { ttl: 3600 }) ) ); }这里有两个经验点。第一预热的条目数量不要贪多按实际 QPS 和缓存容量算热数据通常遵循二八分布抓头部就够了尾部交给懒加载。第二预热要有超时和失败容忍不能因为某条数据查不到就卡住整个启动流程。我见过有项目把预热写成await Promise.all且不带任何容错数据库抖一下整个服务起不来。如果是多实例部署还要考虑缓存击穿的问题——所有实例同时启动、同时预热同一批 key瞬间给数据库造成一波压力。解决办法是加个随机延迟或者只让一个实例负责预热、其余实例等通知。4.4 骨架屏和预加载的分工骨架屏经常被和预加载混为一谈其实两者解决的是不同问题。预加载是「让数据早点到」骨架屏是「数据没到时先给个结构占位」一个管速度一个管感知。两者配合得好效果是 11 大于 2预加载保证了数据和图片在跳转后很快到位骨架屏覆盖了那几百毫秒的空窗期用户不会看到白屏或者布局跳动。但骨架屏本身也有成本如果结构做得太复杂、动画太多反而会拖慢首屏渲染得不偿失。我的做法是骨架屏只保留结构轮廓不加渐变闪烁动画或者让动画用一个纯 CSS 的animation跑在合成层上不触发重排。另外骨架屏的尺寸要和真实内容严格对齐否则数据到位那一刻会有一跳观感很差。5. 度量、监控与问题排查5.1 用什么指标判断预加载是否有效预加载做完了不看数据等于没做。我的判断标准分三层。第一层看命中率。预加载的资源里有多少在短时间内被真正使用了控制台警告能反映一部分但生产环境得靠监控。可以监听PerformanceObserver的resource条目比对预加载列表和实际请求列表算出命中比例。低于 70% 就要回头检查预加载范围是不是铺太大了。第二层看关键指标。首屏图片的预加载对应 LCP交互相关的预加载对应 INP路由跳转的数据预取对应页面切换的主观耗时。指标要按设备分档看低端机和中高端机的结论经常相反——在高配机器上没问题的预加载在低端机上可能因为 CPU 抢占反而更慢。第三层看真实用户监控的分位数。平均值最会骗人P75 和 P95 才能反映真实体验。一个优化如果只改善了 P50 没改善 P75说明它只对原本就快的用户有效价值有限。5.2 常见问题速查表现象可能原因排查方法处理方式控制台提示资源被预加载但未使用预加载范围过宽看警告涉及哪些资源核对是否条件渲染缩小范围或改成 prefetch同一资源出现两次请求as写错或缺 crossorigin网络面板看两次请求的 initiator补全 as 与 crossoriginLCP 不降反升预加载抢了关键资源带宽看高优先级请求数量摘掉大体积非关键预加载字体仍未生效更快缺少 crossorigin比对两次字体请求的缓存键补 crossorigin预加载接口未命中URL 参数不一致对比预加载 URL 与实际请求 URL统一 URL 生成逻辑移动端流量消耗异常prefetch 未做网络判断检查 saveData 判断增加网络环境判断路由切换仍卡只预取了资源没预取数据看切换时是否有接口请求补路由级数据预取5.3 几条从实践中换来的心得第一条预加载列表要像依赖清单一样管理。不要散落在各个组件里随手写link集中到一份配置里随构建产物一起生成。我见过有项目预加载列表是半年前写的里面好几个资源路径早就变了一直在预加载 404。第二条新功能上线时同步检查预加载收益。页面结构变了LCP 元素可能从图片变成了标题文字原来给图片做的预加载就不再有价值但代码还在跑白白消耗带宽。每次大改版都花十分钟重测一遍成本很低。第三条给预加载留一个开关。线上如果发现某个预加载策略导致异常能一键关掉比紧急发版快得多。实现上可以用配置中心或者 URL 参数控制粗粒度即可。第四条别迷信工具给的「优化建议」。有些性能检测工具看到资源没预加载就建议加preload但它不知道这个资源在你的业务里是不是关键资源。工具是参考最终判断得靠你自己对页面渲染路径的理解。第五条也是我最想强调的预加载的收益是有上限的。如果一个页面的瓶颈在服务端处理、在数据库慢查询、在巨大的 JS 包体积那预加载再怎么优化也是杯水车薪。我遇到过团队花两周调预加载策略最后发现真正的瓶颈是一个没加索引的查询加个索引所有指标全好了。优化的顺序应该是先量找到真正的瓶颈再决定用什么手段。预加载是很有效的工具但它只解决「发现太晚」和「等待太长」这一类问题不该承担它承担不了的责任。我个人的习惯是每次动预加载之前先在纸上画一遍页面的资源加载时间轴标出每个资源的发现时刻和下载时刻看看空档到底在哪。这个动作花不了几分钟但能避免掉大部分盲目优化的功夫。