ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

异步加载原理与性能优化:从浏览器解析机制到前端实践

异步加载原理与性能优化:从浏览器解析机制到前端实践 前端圈子里有个现象几乎每个人都知道异步加载能优化性能但真要问一句“异步加载到底改变的是浏览器哪个阶段的什么行为”很多人就开始含糊了。这也是为什么每当页面出现白屏、卡顿、脚本执行顺序错乱时大家第一反应是背八股而不是从原理层面去定位问题。这篇博文我打算结合自己这些年调优线上项目的经验把异步加载背后的浏览器解析机制、主流的异步方案、以及与性能优化组合拳之间的关系一次性讲清楚。适合前端工程师、移动端开发者以及所有被首屏性能折磨过的人阅读。1. 异步加载的本质先搞懂浏览器怎么“读”HTML不理解浏览器解析HTML的过程就永远没法真正理解异步加载。很多优化手段看似不同底层其实都在和同一套机制打交道。1.1 浏览器不是“看完再画”而是边看边画很多人设想过浏览器拿到HTML后会先整体读完、解析完再开始渲染但实际上完全不是这样。浏览器的解析器是边接收数据边构建DOM树的CSS和JavaScript的处理方式又各不相同。HTML解析器遇到一张图片时只会发出加载请求并不会停下来等图片数据返回遇到一个script标签时却会立刻停止DOM解析先把这个脚本下载并执行完再说。这个过程通俗讲就是浏览器像个流水线上的工人看到普通零件就继续手里的活看到某个特定零件却必须先停下整条流水线专门把它处理完才能继续。正是这种“遇脚本即停”的特性决定了脚本在页面中的位置会直接影响首屏渲染速度。很多人只知道脚本放head里会阻塞却不知道为什么。阻塞的本质在于解析器被暂停DOM树无法继续构建后续的所有节点都没法出现在页面上。1.2 同步脚本阻塞的代价到底有多大想象一个场景HTML文档其实已经下载完了一大半DOM树构建得好好的结果突然遇到一个体积高达2MB的第三方统计脚本。这个脚本如果是经典的同步引入方式那么浏览器会立刻陷入等待状态。下载脚本要时间脚本下载完还要解析、编译、执行整个过程DOM解析器完全停工。用户看到的画面就是白屏或半白屏哪怕页面后面明明还有大量视觉内容已经准备好了。这里还有一重容易被忽略的连带影响脚本执行期间页面连基本的交互都做不了。因为JavaScript是单线程的脚本一旦开始执行主线程就被占用用户点击、滚动这些事件都得排队等着。这个阶段专业上叫“主线程阻塞期”是影响首屏可交互时间Time to Interactive简称TTI的最关键因素。1.3 关键渲染路径里的“黄金五步”异步加载优化再花哨最终服务的都是一条核心链路DOM树构建、CSSOM树构建、渲染树合并、布局计算、绘制。这五步合起来叫关键渲染路径。同步脚本会卡在第一步因为DOM构建被中断同步样式表则会卡在CSSOM构建阶段。有一个非常典型的问题值得单独说放在页面底部的同步脚本是否就不阻塞了答案是否定的。放在底部的脚本确实不会阻塞首屏内容显示但它依然会阻塞浏览器解析到它之后的所有DOM节点。如果你的页面很长、脚本又在中间位置那脚本后面的区块都会延迟出现。这也是为什么“放到底部就行”只是一个经验法则并不能解决所有阻塞问题。注意真正要做优化不要只盯着“脚本不被执行”这一个点还要看脚本是否延迟了DOMContentLoaded事件、是否占用了主线程导致首次交互变慢。这几件事经常被绑在一起缺一个环节效果都会差很多。2. 主流异步加载方案拆解异步加载并不是一个单一的技术动作而是几种不同方案的统称。选择哪种方案取决于你对脚本执行时机的要求有多高。2.1 async与defer看起来差不多行为差很多很多时候面试题里都考过async和defer但实际项目里很多人选型时靠的还是“经验”而不是原理。这两个属性都能让浏览器在后台下载脚本区别在三个地方下载同时是否暂停DOM解析、执行时机、以及多个脚本之间的执行顺序。我把差异整理成一张表方便对照参考加载方式下载时机DOM解析是否暂停执行时机执行顺序普通script遇到即下载是下载完立即执行按文档顺序async脚本后台下载否下载完立即执行可能在DOM解析过程中执行谁先下载完谁先执行不保证顺序defer脚本后台下载否DOM解析完成后、DOMContentLoaded触发前执行按文档顺序执行实际项目中defer最稳妥。比如页面里引用的两个业务脚本一个负责初始化数据一个负责渲染组件它们有先后依赖关系时才必须用defer。async则适合那种“谁先到谁先跑”的场景比如埋点统计、独立广告脚本顺序无关紧要越快执行越好。2.2 动态创建script标签异步加载的“手动挡”动态创建script标签本质上就是你手写一条异步下载路径。做法是这样的function loadScript(url, callback) { var script document.createElement(script); script.src url; script.onload function() { callback callback(); }; document.body.appendChild(script); }这里有个浏览器默认行为值得注意通过appendChild插入的script默认就是异步的。也就是说不需要加async属性它也不会阻塞当前正在进行的DOM解析。使用场景非常明确用户点击某个按钮后才需要用到某个大型功能模块比如地图组件、富文本编辑器此时再动态加载对应脚本能有效避免首屏把不需要的代码也下载下来。但动态加载有一个非常容易踩的坑脚本加载完成前用户可能已经又触发了同一个操作。如果没有做一个“加载中”的防重判断页面里就会被插入多个相同的脚本造成重复下载和重复执行。我习惯加一个全局计数器或者把加载Promise缓存起来第二次调用时直接复用上一次的Promise。2.3 路由懒加载与组件异步化现代前端框架里异步加载最常见的落点其实是路由。以Vue为例路由懒加载把“访问到某个路由时才加载对应组件代码”这个动作变成了标准操作const routes [ { path: /dashboard, name: Dashboard, component: () import(../views/Dashboard.vue) } ];这段代码在构建时会自动把Dashboard.vue拆成一个独立的chunk只有当用户真正访问/dashboard这个地址时浏览器才会发起这个chunk的请求。这背后是webpack或Vite里的动态导入语法和代码分割机制。组件级的异步化也是同理。如果某个弹窗组件不是首屏必需就可以用defineAsyncComponent包裹起来甚至给一个加载中的占位状态。实操中要注意的是懒加载不是越碎越好。我把一个首屏页面里的二十个小组件全部改成异步加载后反而因为多出了很多小请求导致页面整体加载时间变长了。分组平衡很重要一个路由或一个功能模块拆成一到两个异步块就足够。2.4 IntersectionObserver实现图片懒加载图片懒加载是异步加载里最常见的“看得见”的部分。以前大家习惯监听scroll事件然后判断图片距离视口顶部的距离但这种方案有两个明显问题滚动事件高频触发很容易造成主线程负担各种边界条件处理起来又容易出Bug。现代的推荐方案是IntersectionObserver它直接由浏览器底层提供能力你只需要告诉它“哪个元素进入视口时触发回调”。基础用法是const observer new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; observer.unobserve(img); } }); }); document.querySelectorAll(img[data-src]).forEach((img) { observer.observe(img); });这套方案的优势是性能开销极小不用频繁计算滚动位置。但有两个使用细节其一rootMargin参数可以用来微调“提前多少像素触发加载”我通常设置为200px左右让图片即将进入视口时就开始加载避免用户快速滚动时看到空白区域其二当图片本身就处于视口内且有高度时最好直接用原生loadinglazy属性浏览器内置优化比自己写脚本更省心。3. 异步加载之外的性能优化组合拳异步加载只是性能优化拼图里的一块。如果只做异步加载而忽略资源预加载、缓存策略和构建产物尺寸优化效果会大打折扣。这三样东西组合起来才算是完整的思路。3.1 preload、prefetch与prerender提前告诉浏览器你想干什么异步加载的核心思路是“用到时再加载”而preload和prefetch则反其道而行之在浏览器空闲时提前把未来可能用到的资源下载好。它们用法相似指向的场景却完全不同。preload用于当前页面马上就要用到的资源比如首屏需要一个很大的背景图但它是由CSS引用的、又很深浏览器原生解析顺序可能发现得太晚。通过下面这行代码可以让浏览器尽早发起下载link relpreload href/img/bg.webp asimage /prefetch则用于“用户接下来很可能访问”的页面资源典型场景是用户停留在首页时提前把详情页的脚本下载下来缓存好。当用户真正点击跳转时资源已经存在页面切换几乎瞬间完成。这个技巧在移动端H5里效果尤其明显但也要注意不要把所有页面都prefetch一遍否则会浪费大量用户的流量。prerender会在后台把整个页面渲染好代价最大适合那种确定用户一定会访问的落地页。用的时候要克制我一般只在高转化的单页流程里用。3.2 缓存策略让第二次访问真正变快异步加载解决的是首次访问的资源“什么时候下”的问题缓存策略则决定资源第二次访问时“还需不需要下”。两者配合起来才能让页面在不同访问阶段都保持良好体验。一个常用的组合是给静态资源文件名加上哈希指纹然后设置长期缓存。我习惯把缓存策略拆成三档资源类型缓存策略说明HTML文档no-cache每次都回源协商保证用户拿到最新入口JS/CSS产物一年强缓存 哈希指纹文件名变化后浏览器自然请求新版本图片字体等静态资源一年强缓存便于快速二次展示这里有一个容易踩坑的地方如果服务端错误地给HTML也设置了长时间强缓存那么即使你发布了新版本用户依然会拿着旧的HTML去引用旧的脚本地址结果就是页面永远更新不了。出现“我发布用户没反应”的情况先检查HTML的响应头是不是被误加了Cache-Control: max-age3600这类配置。3.3 构建产物代码分割从源头减少首屏体积异步加载的极致形态是构建阶段的代码分割。webpack的SplitChunksPlugin默认会做一些自动拆分但很多人从来不看它的默认配置结果所有第三方依赖都打进了同一个大包里。我见过一个首屏页面仅仅一个Ant Design就占了700KB的压缩后体积影响了整个首屏加载时间。合理的做法是手动配置分组。比如把体积最大、更新频率最低的框架库单独拆出来例如React、ReactDOM、Ant Design拆成一个vendor包再配合哈希指纹做长期缓存。这样即使业务代码频繁更新用户也不需要重新下载体积巨大的框架代码。代码分割的粒度要在“请求数增多”和“单包体积变小”之间做权衡。因为HTTP/2时代多请求的额外开销已经大幅降低但请求数量太多依然会造成连接拥塞和服务端压力。一个经验值是首屏相关的请求尽量控制在20个以内单个JS包大小不超过300KB压缩体积。3.4 HTTP/2与并行加载的底层逻辑谈到性能优化不能忽略HTTP协议本身的作用。HTTP/1.1时代浏览器对同一个域名下的并发连接数有限制大约6个连接。这意味着即使你把脚本全部异步化文件多了之后依然要排队等待。这也是为什么很多老项目采用“域名分片”技巧把资源分散到多个域名来突破并发限制。HTTP/2则通过多路复用彻底解决了这个问题。它允许在同一个连接上并行传输多个资源不再受6个连接的约束。因此在HTTP/2环境下优先考虑的是减少总体积、合理拆分资源块而不是拼命合并文件。团队里如果还有人沿用老思想把所有代码塞进一个文件那反而会浪费HTTP/2的优势。需要注意的是部署环境是否真的启用了HTTP/2。检查方式很直接打开浏览器开发者工具的Network面板看协议列显示的是h2还是http/1.1。如果还在用HTTP/1.1那异步加载带来的多请求优势会被排队抵消不少。4. 实操中的坑与排查实录这一部分我把这些年遇到过的、最容易让优化工作翻车的典型问题整理出来。所有内容都来自真实线上的踩坑经历不是概念推演。4.1 踩过的坑异步脚本执行顺序乱了有一年我做一个营销活动页页面需要依次加载数据初始化脚本、渲染脚本、交互绑定脚本。当时图省事给三个脚本都加了async结果上线后偶发性出现页面渲染不出来。排查到最后发现交互绑定脚本先执行了但数据初始化脚本还没执行组件拿到的数据全是空的。这就是async脚本顺序不保证带来的问题。解决思路有两个如果脚本之间有依赖关系必须用defer而不是async因为defer会保证按文档顺序执行如果依赖关系比较复杂更推荐的做法是把多个脚本合并成一个模块用代码内部的模块化机制来管理顺序而不是依赖标签顺序。这里还有一个容易忽略的细节async脚本的执行时机不固定它可能在DOMContentLoaded事件之前也可能在之后。所以如果代码里依赖DOM结构又不加DOMContentLoaded或defer保护偶尔就会报“找不到某个节点”的错误。4.2 踩过的坑懒加载导致布局抖动和样式闪烁图片懒加载最经典的问题之一就是布局抖动。用户快速滚动页面时如果图片还没有加载完成高度为0页面的高度就会忽高忽低滚动位置也会跟着跳动。解决办法是在图片的容器上预留固定宽高比区域或者给img标签直接设置width和height属性让浏览器提前知道图片的占位尺寸。样式闪烁则是另一类问题。异步加载按钮或弹窗组件时主包里的默认样式先渲染了异步组件的样式后到用户会看到一小段时间的“裸样式”页面。解决方法是把异步组件的关键样式提取到主样式表中或者用骨架屏占住位置。我更推荐骨架屏方案因为它同时解决了布局抖动和视觉闪烁两个问题。4.3 踩过的坑看似异步其实更慢的情况异步加载并不是一定能带来性能提升。我见过一个团队把首屏所有组件都用懒加载处理结果用户的每一次交互都要等待数秒来下载对应代码整体体验反而不如直接同步加载。判断一个资源是否应该异步化核心标准是这个资源在首屏或高概率用户路径中是否用得到。如果用得概率超过80%那就别懒加载直接放进主包。还有一种情况是异步加载时机的误判。有人给一个“用户大概率要点击”的折叠区内容加了懒加载结果大多数人都会打开折叠区反而增加了等待时间。对于这种高频交互区域倒不如用prefetch提前在空闲时加载好让用户点击的瞬间就能看到内容。4.4 用Performance面板验证优化效果优化做没做到位不能靠感觉要看数据。浏览器开发者工具的Performance面板是检验异步加载效果的最好工具。我通常的做法是先记录优化前的加载过程生成一个Performance报告然后应用异步加载、懒加载、预加载等策略后再记录一次。重点对比三个指标首屏内容绘制时间First Contentful PaintFCP、最长内容绘制时间Largest Contentful PaintLCP和主线程被阻塞的总时长。Network面板同样重要。在Waterfall视图里能看到每个资源的加载阶段。如果发现某个同步脚本让后续好多资源都在等待那它就是要优先优化的对象。优化完再看一眼请求瀑布图正常情况下异步脚本会并行的加载不再是一条直线排队。4.5 移动端的特殊性弱网环境必须单独考虑同样的异步加载策略在Wi-Fi下可能效果很好到了4G弱网甚至3G环境下情况就完全不一样了。弱网环境下每一个额外的资源请求都要付出更大的延迟代价。所以移动端优化和PC端优化不能共用一套思路。移动端方面我一般会更激进地使用合并策略把必须用的关键脚本合并成一个文件减少请求次数非关键的埋点、统计脚本则延迟到页面空闲时再加载。可以利用requestIdleCallback来安排非关键任务的执行时间它会在浏览器空闲时执行回调避免抢占主线程。还有一个针对弱网的细节大尺寸图片在弱网下加载特别慢轻则白屏重则卡死整体体验。除了异步加载和懒加载外还应该使用不同尺寸的响应式图片方案或者WebP格式减少实际传输的字节数。5. 最后分享几条实操心得这些经验不一定能写进教科书但都是我一次次调优踩坑后总结出来的真实感受。第一异步加载要配合“度量”才能落地。我们团队每次性能优化都会先把关键的指标数据记录下来对比优化前后的FCP、LCP、TTI变化。如果异步化改动后TTI不升反降那就说明异步化的对象选错了。优化的第一步永远是找瓶颈而不是急着改代码。第二不要过度使用懒加载。懒加载的本质是用“延迟”换“首屏速度”但用户的每一次等待都是体验损失。如果一个资源用户大概率会访问懒加载反而变成负优化。我见过不少项目为了追求优化KPI把所有的图片、路由、组件全部做成懒加载结果重要页面反而变得很迟钝。第三异步加载方案要与团队维护成本挂钩。如果方案很优雅但团队成员经常看不懂、乱改上线出问题的概率会显著增加。优先选最朴素、最稳定的方案比如先统一用defer加上合理的资源优先级控制再逐步尝试更复杂的动态加载和代码分割。简洁的方案往往才是线上最可靠的方案。最后再分享一个小技巧在做异步加载优化时我会先关掉浏览器的缓存再测性能。很多人在优化后看数据好看其实是因为本地缓存帮了大忙但新用户第一次访问时根本没有缓存可用。关掉缓存后优化效果才真实反映首访体验。这个细节看起来小却能避免很多“自我感觉良好”的优化结论。
返回列表