
1. 原理拆解先搞清楚浏览器是怎么加载页面的做性能优化这么多年我最大的感受是很多人一上来就抄工具、上插件、加各种配置却连“一个script标签放在哪里最合适”背后的原理都说不清楚。异步加载这个词在面试题里几乎被说烂了但真正理解它为什么能提速、什么时候会失效、有哪些坑的人其实不多。先说个最基本的背景浏览器加载一个页面不是把HTML拿回来就完事了而是一条流水线。网络进程先下载HTML文档解析器边下载边解析HTML遇到CSS就构建CSSOM遇到JavaScript就暂停解析、去下载并执行脚本。执行完了解析器才能继续往下走继续构建DOM树。DOM和CSSOM合成渲染树最后经过Layout、Paint、Composite用户才能看到画面。这里面最关键的一个概念就是渲染阻塞Render-blocking。CSS会阻塞渲染因为在CSSOM构建完之前浏览器没法计算节点布局画出来的页面是残缺的。而普通的外链JavaScript更严重它不光阻塞解析还阻塞渲染因为浏览器不知道脚本里会不会用document.write去修改DOM。阻塞期间渲染进程的主线程是被占着的页面一片空白用户面前只有白屏。这就解释了为什么传统做法把CSS放head、把JS放body底部——是为了让HTML先解析完最后再执行脚本。但“放底部”只是最原始的方案它依然是一条同步链脚本必须一个个按顺序下载、按顺序执行中间任何一个脚本慢都会拖慢整个页面从加载到可交互的时间。异步加载要解决的就是这整条同步链的问题。它不是简单地把标签挪个位置而是从加载时机、执行时机、资源优先级三个维度去重构资源的加载策略。核心目标很清楚首屏关键路径上的东西优先且尽快完成非关键路径上的东西往后靠、并发拉、不阻塞。在聊具体方案前先估一下页面的性能基线。通常我会先用Lighthouse或者Performance面板跑一遍原始数据记录这么几个值指标含义推荐阈值FCP首次内容绘制页面渲染出第一个文本/图片1.8s以内LCP最大内容绘制首屏最大元素可见的时间2.5s以内TBT主线程被长任务阻塞的总时长200ms以内CLS累计布局偏移衡量页面元素跳动0.1以内TTI页面可交互时间3.8s以内如果TBT偏高主线程上往往挂着一堆同步脚本如果LCP偏高首屏关键资源可能没被提前加载如果FCP和LCP差值过大说明CSS或者首屏图片成了瓶颈。后面所有优化动作都是围绕这些指标展开的。下面就把异步加载的各种技术逐个拆开讲。2. 三种异步加载脚本的正统姿势async、defer与动态注入2.1 defer与async看似兄弟行为天差地远HTML里给script标签加defer或者async属性都能让浏览器在后台异步下载脚本文件不阻塞HTML解析。但两个属性在“何时执行”上有着本质区别。defer的意思是“延迟执行”脚本下载不阻塞解析但执行必须等到整个文档解析完之后。更重要的是所有defer脚本会按它们在文档里出现的顺序依次执行。这一点很关键假如A脚本依赖B脚本写成script defer srcB.js放在前面、script defer srcA.js放在后面执行顺序就是B先、A后依赖关系不会乱。async的意思是“异步执行”脚本一旦下载完就立刻执行执行时照样阻塞解析。多个async脚本的执行顺序完全由下载完成时间决定谁先到谁先跑跟标签书写顺序无关。所以async脚本之间不能有依赖关系。从性能角度说defer更适合那些需要在DOM就绪之后运行的业务逻辑比如操作DOM的初始化代码async则适合独立脚本比如数据统计、广告SDK、埋点这类谁都不依赖、也不依赖谁的外部脚本。用错场景会出问题把有依赖关系的脚本标成async很容易出现“XX is not defined”的报错排查起来非常恼火。2.2 动态创建script标签最灵活的异步注入方式还有一种非常经典的方式就是用JavaScript动态创建script标签再插入页面function loadScript(url, callback) { const script document.createElement(script); script.src url; script.onload () callback callback(url, loaded); script.onerror () callback callback(url, error); document.head.appendChild(script); }动态创建的script标签默认就是异步加载的不会阻塞HTML解析。这种方式最大的价值在于**“按需注入”**用户触发某个交互时才加载对应逻辑的脚本比如点击“查看全部订单”时才去拉取订单列表组件代码。这比在HTML里写死一堆script标签要可控得多因为它把“加载什么”和“什么时候加载”的决定权完全交给了运行时的代码逻辑。不过动态注入有个隐藏问题如果同一页面多个模块都在动态加载同一份脚本可能出现重复请求。所以实操中一般要维护一个加载状态表已经存在或者正在加载的脚本不要重复注入const scriptCache new Map(); function loadScript(url) { return new Promise((resolve, reject) { if (scriptCache.has(url)) { scriptCache.get(url).then(resolve, reject); return; } const promise new Promise((res, rej) { const script document.createElement(script); script.src url; script.onload () res(url); script.onerror err rej(err); document.head.appendChild(script); }); scriptCache.set(url, promise); return promise; }); }2.3 模块化时代的主角动态import()ES Module的出现让异步加载有了更高级的形态。import()函数可以在运行时动态加载一个模块返回值是一个Promise天然支持按需加载和代码分割。async function openEditor() { const { default: Editor } await import(./components/Editor.js); const app new Editor(document.getElementById(editor-root)); app.mount(); }import()和前面几种方式的根本区别在于它是ES语法层面的标准能力而不仅仅是DOM操作。它配合打包器Webpack、Vite、Rollup使用时打包器会自动把动态import的模块单独拆成一个chunk文件浏览器在运行时才去请求这个chunk。这样首屏入口文件体积可以大幅缩小加载完主框架代码即可渲染页面主体编辑器、图表库、大屏组件这些重模块全部按需拉取。从性能角度看动态import()是单页应用路由懒加载的核心实现方案。Vue Router和React Router的懒加载路由底层都是借助动态import把路由组件拆成独立chunk。3. 渲染层面绕不开的异步优化手段3.1 图片懒加载的三种姿势图片是页面里最普遍也最容易拖垮性能的资源。一张全屏banner如果是2MB的PNG首屏加载时间直接被拉高几秒。图片懒加载的核心逻辑是视口内的图片立即加载视口外的图片等到即将滚入视口时再加载。最原始的方案是监听scroll事件用getBoundingClientRect()判断图片是否进入视口再替换src。这种方案性能不稳定scroll事件触发频率极高即使做了节流也容易产生主线程压力。后来出现了Interp Observer可以在浏览器空闲期间异步观察元素可见性性能好很多const observer new IntersectionObserver((entries) { for (const entry of entries) { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; observer.unobserve(img); } } }, { rootMargin: 100px 0px, // 提前100px开始加载 }); document.querySelectorAll(img[data-src]).forEach(img observer.observe(img));不过现在浏览器层面已经提供了原生方案loadinglazy属性。直接加在img标签上浏览器原生处理懒加载逻辑零JavaScript开销img srcreal-image.jpg loadinglazy alt描述 /需要说明的是原生懒加载有自己的判定策略不同浏览器阈值略有差异在复杂场景下不一定精准。比如轮播图里隐藏的非当前帧图片用原生懒加载有时会出现预加载不足的问题。我自己常用的做法是静态图片优先用原生loadinglazy动态插入的、行为复杂的图片用IntersectionObserver方案两者结合。3.2 资源预加载preload、prefetch与preconnect异步加载不只是“把加载延后”也包括“把加载提前”。很多人忽略了这四个link标签的差异指令作用适用场景link relpreload预先下载当前页面马上要用的关键资源首屏字体、LCP图片、关键CSS/JSlink relprefetch空闲时间下载下一个页面可能用的资源用户即将访问的路由chunklink relpreconnect提前建立与目标域名的连接DNSTCPTLS第三方API、CDN资源link reldns-prefetch提前完成DNS解析跨域请求很多时preload是当前页面关键资源的优先通道。举个例子如果你用自定义字体而字体文件加载太慢文字可能一直显示为系统默认字体或者干脆隐形导致CLS抖动。在head里加上link relpreload href/fonts/inter-var.woff2 asfont typefont/woff2 crossorigin这样字体文件的下载就能提前启动。但preload有个要注意的点一旦指定了preload浏览器会强制下载对应资源即使页面没用到。下载完的资源如果没在关键路径上被消费反而浪费带宽。所以preload只适合当前页面确定会用的资源。prefetch则是为了“前瞻”加载。用户有可能点击的下一个页面的JS chunk、图片组都可以在浏览器空闲时提前下载。真正的风险在于如果prefetch的资源在服务器上更新频繁被预取的可能是过期缓存。另外移动端网络环境差时大量prefetch会白烧用户流量必须收敛使用场景。3.3 异步执行长任务把主线程让出来还有一个经常被忽略的思路就算某个脚本要执行能否不一次性占用主线程太久浏览器有一个API叫requestIdleCallback可以在浏览器空闲期执行低优先级的回调。场景很典型埋点数据的批量上报、日志聚合、页面无关紧要的DOM增强逻辑。这些任务如果挤在主线程忙时执行会阻塞用户交互。if (requestIdleCallback in window) { requestIdleCallback(() { // 批量上报埋点数据 flushAnalytics(); }, { timeout: 2000 }); }另一种更精细的做法是把同步长任务拆成多个小任务用scheduler.postTask目前Chrome已支持或scheduler.yield让出主线程。页面里有重型图表库渲染、大列表渲染时一帧时间约16.7ms如果被一个超过50ms的任务塞满用户滑动页面就会感到卡顿。拆任务的标准做法是优先用requestIdleCallback拿空闲时间再退化回requestAnimationFrame的分帧处理。4. 性能优化落地实战一次完整的首屏优化记录4.1 搭建性能基线从Lighthouse到Performance前面讲了这么多原理现在以一个新的H5活动页为例完整跑一遍优化流程。这个页面的结构顶部banner图、一个首屏产品渲染列表、底部有地图组件和用户评论模块依赖主包里的三个业务chunkHTML引用了全量的业务JS和公共库JS图片全部直接给真实地址。拿到手先跑Lighthouse移动端模拟场景。最终分数是39分其中Performance 39LCP 6.7sTBT 520msCLS 0.23。从Network面板能直观看到问题首页一次性加载了主包2.4MB三个业务chunk一共1.56MB一张2.3MB的全屏banner图还有字体文件没设preload导致字体在3.2秒后才到达。很明显这是一个典型的“全量加载、无分级、无异步”页面。下面按优先级逐项拆解。4.2 打包层优化从2.4MB压到首屏只加载800KB第一步操作不是改HTML标签而是改打包配置。项目用的是Webpack 5把单个入口文件拆开。首先是路由级代码分割。页面有“首页商品、地图详情、用户评论”三个模块但用户进入页面时只需要看见首页商品。改成Vue Router的懒加载写法const routes [ { path: /, component: () import(./views/Home.vue) }, { path: /map, component: () import(./views/Map.vue) }, { path: /comments, component: () import(./views/Comments.vue) }, ];这一步让Map和Comments两个视图从首屏bundle里剥离出去打包产物出现三个独立chunkHome约800KBMap约560KBComments约430KB。接着处理公共依赖。项目里用了ECharts绘制地图体积很大不是首屏必要资源。用SplitChunksPlugin把ECharts单独拆出来并且在Map组件内部用动态import加载async function loadMapModule() { const echarts await import(echarts); const { createMap } await import(./map.js); createMap(echarts); }公共依赖被拆出去之后首屏bundle进一步压缩到520KB。再加上gzip之后首屏加载体积从原来的2.4MB降到285KB左右。4.3 HTML层优化script放置、async标记与preload打包拆完之后接着改页面HTML的资源加载策略。这一步是针对运行时加载链的微操。先看原来HTML的结构所有script标签都放在body底部但存在两个问题一是公共库脚本比如Vue运行时、axios没有加async或defer属于同步加载阻塞解析二是业务脚本之间有耦合关系不能随便标async。仔细分析后确认Vue运行时、ElementPlus这类UI库和基础工具库之间没有依赖关系可以标async。业务代码内部的主逻辑放到一个入口脚本里保留同步特性在DOM解析完后再执行用defer保证顺序。优化后的头部结构是这样link relpreload href/js/vendor.dll.js asscript link relpreload href/fonts/iconfont.woff2 asfont typefont/woff2 crossorigin link relpreconnect hrefhttps://api.example.combody底部的脚本则拆成了script src/js/vendor.dll.js async/script script src/js/main-entry.js defer/script script // 内联的三段式关键脚本预加载数据请求提前到脚本执行前 /script这里有个很关键的细节如果把内联脚本的请求时机提前到HTML解析阶段用fetch直接请求接口数据再在defer脚本执行时复用这份数据首屏数据的TTFB到渲染时间能省下大几百毫秒。我在页面里写了一个内联的关键请求模块script window.__PRELOAD_DATA__ null; fetch(/api/main/products) .then(res res.json()) .then(data { window.__PRELOAD_DATA__ data; }) .catch(() {}); /script主入口脚本执行时检查window.__PRELOAD_DATA__有数据就直接渲染没有数据再走正常的fetch流程。这种方式把数据请求和脚本执行并行起来整体首屏时间减少非常明显。4.4 图片与字体专项懒加载能省一半请求H5页面的banner图是2.3MB的原始位图。处理方式分两步第一步在CDN侧生成多尺寸裁剪版本按设备宽度输出对应的WebP格式第二步在图片标签上增加懒加载策略。面向当前用户的机制效果落地到HTML时不是简单全部加loadinglazy因为首屏banner属于LCP候选元素加懒加载反而会拖延首屏绘制。正确的做法是首屏banner用fetchpriorityhigh配合preload提前拉取首屏以下的图片加loadinglazy并统一加上CSS占位尺寸防抖。这块的优化下来首屏图片总体请求数从14个降到6个全部视口外图片都延迟加载过了首屏区域才按需请求。字体方面页面用的是iconfont字体文件因为不是关键路径原本3.2秒才到达导致图标闪烁。加入preload之后字体文件被提到CSS加载阶段并行拉取图标闪烁问题直接消失了。4.5 第二轮验证核心指标对照所有改造完成后重新跑Lighthouse同时用Performance面板录了一段真实滚动过程。结果指标优化前优化后Performance评分3987FCP3.4s1.6sLCP6.7s2.2sTBT520ms180msCLS0.230.05首屏请求数2817首屏传输体积gzip后3.8MB920KB从Network时间线看关键路径上的资源基本都在1.5秒以内到达并解析完主线程空闲时间明显增长页面滚动流畅很多。可能有人问为什么没有直接到100分因为页面里还保留了第三方统计脚本的同步加载——这是一个刻意保留的取舍统计脚本可靠性高于性能收益标注async容易导致上报丢失。这种有选择的折中在真实项目里比追求满分更重要。5. 性能排查实战七类高频问题与解决实录5.1 async脚本依赖关系的连锁报错最常见也最恶心的就是“Uncaught ReferenceError: xxx is not defined”。这类问题的根源十有八九是给有依赖关系的脚本加上了async。排查方法很简单在控制台Sources面板找到抛错脚本的加载时间点看它是否依赖另一个脚本的全局变量。如果是把多个脚本整体合并成一个入口或者改用defer保证顺序。5.2 preload字体但没有crossorigin导致字体加载两次preload第三方字体时如果请求是跨域的必须在link标签上加crossorigin属性否则preload发起的请求和CSS中请求字体的请求模式不一致会被看作两个不同请求浏览器会下载两次字体文件。这也是字体要命的CLS恶化的原因。5.3 动态import的chunk优先级太低被晚加载了打包器默认给动态import的chunk不高优先级页面切到地图选项卡时地图chunk可能要300ms才能下载完感官上很卡。解决办法是在页面空闲时预加载这个chunkconst preloadMap requestIdleCallback(() { import(./views/Map.vue); }, { timeout: 3000 });这样用户真正点击地图时chunk往往已经缓存在本地了切换几乎是瞬时的。这个技巧在单页应用里非常实用。5.4 图片懒加载导致滚动时大量图片同时请求如果rootMargin设得太大或者图片离视口很近才触发瞬间会发出大量请求造成网络拥塞。软纤优化方案是配合loadingeager的少量图片与其余用lazy或者在IntersectionObserver回调里按优先级分组处理不要一次性把所有接近视口的图片都换成真实地址。5.5 requestIdleCallback的回调长期不执行在用户一直在交互、主线程一直忙碌的运行场景里requestIdleCallback给出的空闲时间非常有限甚至长时间不触发回调。解决方法是设置timeout参数强制超时执行或者用setTimeout模拟兜底。如果是页面加载初期的非关键任务可以直接交给requestAnimationFrame先做避免无限等待。5.6 预加载资源下载了没用浪费用户流量preload误配置会让移动端用户多下载不必要的资源。排查方式打开Network面板过滤请求类型为preload逐一确认这些资源是否被实际选中。有些时候是因为as字段写错了preload解析器把它当成另一类资源下载页面却按另一类来请求导致双份流量。5.7 数据请求慢拖累首屏接口慢造成的性能问题不是靠前端异步标签能解决的但可以通过“提前请求”和“数据缓存”两条路绕过去。上面提过的内联脚本提前请求就是其一。另一个是给数据接口加HTTP缓存比如活动页的静态商品列表可以短缓存10分钟这样二次进入页面可以直接用本地缓存渲染。6. 一套可以直接上手的异步加载策略模板6.1 通用决策流程做任何页面的异步加载优化之前先按这套流程过一遍列出页面首屏真正需要渲染出的核心块对应的资源叫做关键资源。把关键资源的script和style放在head中使用preload优先加载。非关键的业务脚本一律延迟加载用defer或放到body底部。独立无依赖的第三方SDK能标async就标async。首屏以下图片全部懒加载首屏LCP图片用fetchpriorityhigh。路由级组件全部动态import再配合requestIdleCallback预取下一个路由的chunk。字体文件必须preload且加crossorigin防止双重下载和FOUT。接口请求提前到HTML解析阶段并行发起。6.2 不同场景下的参数调节异步加载不是一套配置走天下。我整理了几个主要场景的推荐参数供参考场景推荐方案关键参数企业官网首页defer加载全量业务脚本首图用preloadpreload asimage图片格式WebP大型治理后台路由懒加载组件动态importKeepAlive缓存组件预取常用二级路由H5营销页内联关键请求数据图片懒加载预连接接口域名preconnect到API域名请求提前并行电商首页首屏SSR或静态化首屏以下A/B区图片分批懒加载分批加载间距150px-200px数据可视化大屏图表库动态import按需注册图表确保ECharts tree shaking生效6.3 快速验证清单优化提交之前过一遍这份清单Performance面板没有红色长任务主线程长任务少于5个Network面板首屏关键资源请求时间线在LCP前完成所有script标签要么有defer要么有async要么在body最底部图片全部有宽高或aspect-ratio占位CLS为0动态import的chunk在gzip后小于200KB字体preload且带crossorigin无重复下载的资源这套模板我在多种业务线验证过通常能把首屏时间削减40%到60%。不过要记住任何指标都只是参考最终级的判断标准是用户在产品里的真实体验——减少卡顿、等待和白屏才是性能优化始终要回归的本源。