尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

浏览器异步加载与性能优化:从关键渲染路径到代码分割的实践指南

浏览器异步加载与性能优化:从关键渲染路径到代码分割的实践指南 1. 从一次白屏事故说起脚本加载顺序如何决定页面生死大概几个月前团队上线了一个营销活动页。上线当天我就觉得不对劲负责的同事在旁边说页面有点慢他用手机4G网络打开白屏整整转了三秒多才出内容。我打开DevTools的Network面板扫了一眼差点没背过气——head里塞了三个渲染阻塞的script标签一个360KB的jQuery插件、一个统计SDK、一个轮播图组件前面还有一个不带defer的第三方脚本在等着。两个CSS样式表还被放在了后面。整个加载瀑布图像一串倒霉的多米诺骨牌一个压一个谁都没法提前走。这就是典型的能跑就行但不问为什么慢的代码。页面功能全都正常用户也不会去看瀑布图但体感就是卡。而体感卡的本质就是浏览器在解析HTML的过程中被一个个同步脚本堵住了嘴。只要你用过JavaScript就一定见过那种放在head里的script src...但可能很少有人认真想过它到底做了什么让整个页面非得等它这篇文章想聊的就是异步加载与性能优化背后那条真正的原理线。我会从一次真实事故出发拆开浏览器从拿到HTML到画出一帧画面之间到底发生了哪些步骤、什么东西在阻塞、什么东西能异步、异步之后又会带来哪些新的问题。不管你是刚接触前端性能优化还是已经在项目里写过懒加载和代码分割这篇文章都值得一看——尤其是那些代码能跑但说不清为什么这样写更快的部分。先记住一句话性能优化不是为了把页面变快而是为了让关键路径上只保留必须做的事情把不紧急的事情挪走或延后。异步加载就是这套思路里最核心的挪动手法。2. 关键渲染路径浏览器到底在等什么2.1 从URL到像素的五个大步骤在谈异步之前得先把浏览器的工作流程捋一遍。浏览器拿到HTML之后不是一次性渲染完所有东西而是走一条固定的流水线。粗略分是这样五步解析HTML构建DOM树。解析CSS构建CSSOM树。将DOM和CSSOM合并成渲染树Render Tree。根据渲染树计算每个节点的几何位置也就是布局Layout。最后是绘制Paint和合成Composite把像素真正画到屏幕上。这条链路在性能优化领域有个专门的名字叫关键渲染路径Critical Rendering PathCRP。从用户输入网址到首屏出现内容时间都消耗在这条路径上。你做的所有优化本质上都是在缩短这条链路的耗时或者把某些环节从首屏路径上摘出去。很多人有个误解觉得图片加载慢所以页面慢。这句话只对了一半。首屏渲染的速度首先取决于HTML和CSS能不能尽快变成渲染树。图片是在布局和绘制阶段才介入的资源它不阻塞DOM和CSSOM的构建只会影响页面元素的显示完整性。真正会卡住首屏的是脚本、样式表这些参与页面结构计算的资源。2.2 三种阻塞的差别CSS、JS与图片到底谁在挡路我把常见的资源分成了三类它们在关键渲染路径上的地位完全不一样资源类型是否阻塞DOM解析是否阻塞渲染是否影响首屏CSS样式表否是CSSOM未构建完不渲染是普通同步script是解析器等待是可能等待CSSOM是图片/字体否否部分影响LCP先看CSS。CSS会阻塞渲染因为渲染树必须同时依赖DOM和CSSOM两棵树缺一不可。浏览器遇到link relstylesheet时会继续解析后面的HTML但暂停渲染这个动作直到CSSOM构建完成。也正因为如此CSS必须放在head里尽早加载——你把样式表放到body尾部就会出现先看到无样式HTML、再突然闪一下变成正式样式的白屏闪烁FOUC用户体感很差。再看脚本。普通script标签也就是没有async也没有defer的那种在HTML解析器眼里就是一颗定时炸弹。遇到它解析器会直接停下来先去网络下载这个脚本然后执行执行完了才继续往后解析HTML。浏览器之所以这么保守是因为脚本里可能写了document.write、可能修改了即将解析的DOM节点如果边解析边执行状态就乱了。这里还有一个容易被忽略的细节不光是DOM解析会被脚本阻塞脚本执行本身还要等CSSOM。因为脚本在运行过程中可能读取样式比如getComputedStyle所以浏览器会保证在脚本执行前CSS已经加载并构建完成了。也就是说一个同步脚本哪怕已经下载完了如果前面的CSS还没下载完它也得等着。这条规则在HTML标准里有个专门的说法叫stylesheet blocking scripts意思就是样式表阻塞脚本。至于图片、字体、iframe这些它们不参与关键渲染路径的第一步不会被算作渲染阻塞资源。图片会影响LCP最大内容绘制字体影响文字渲染时机但它们和页面结构能不能出来是两码事。理解了这一层就能明白为什么性能优化的优先级里脚本和样式表永远排在最前面。2.3 网络与CPU两个瓶颈分开看异步加载的底层逻辑其实是在跟两个不同的瓶颈打交道网络瓶颈脚本要下载就有网络耗时。这个耗时受带宽、RTT往返延迟影响尤其是在弱网环境下一个300KB的脚本可能要1秒多。CPU瓶颈脚本下载完还要执行。执行是纯CPU计算这个过程不能被加速只能被拆分、延后、或者削峰。网络瓶颈可以靠异步加载来解决——让脚本下载不阻塞解析下载完再安排执行。但CPU瓶颈不一样脚本执行的工作量是客观存在的你可以把它挪到空闲时间执行可以让它只执行一部分但不能让它凭空消失。所以我在做任何性能分析时都会先问一个问题当前的瓶颈到底是下载还是执行打开DevTools的Performance面板看那条蓝色的网络线和黄色的Scripting块前者长说明网络瓶颈后者长说明CPU瓶颈。针对网络瓶颈方案是压缩、缓存、CDN、异步加载针对CPU瓶颈方案是代码分割、减少不必要的逻辑、把长任务切片。两件事不要混着做否则经常是白忙一场。3. async、defer、动态注入与Module异步加载的四种姿势3.1 defer和async两个看似一样、实际相反的方案很多人在面试里被问过defer和async的区别但实际写代码时未必分得清。这俩确实都实现了加载不阻塞解析但语义有本质区别。defer的意思是这个脚本延迟执行等整个HTML解析完之后按文档顺序依次执行执行时机在DOMContentLoaded事件之前。script defer srcmain.js/script script defer srclib.js/script上面的代码里lib.js虽然写在main.js后面但按defer的规则它会排在前面先执行。defer保证了脚本执行顺序与标签顺序一致所以适合有依赖关系的脚本。async的意思是加载过程不阻塞解析下载完就立即执行执行时机完全由网络决定不保证顺序。script async srcanalytics.js/script script async srcwidget.js/script这两个谁先下载完谁就先执行。所以async脚本之间尽量不要有依赖关系否则会出现另一个脚本还没执行这个脚本调用了它的方法导致报错的竞态问题。用一张表格对比会更清楚特性deferasync下载是否阻塞解析否否执行时机HTML解析完后、DOMContentLoaded前下载完立即执行是否保证执行顺序按标签顺序不保证DOMContentLoaded等待等待defer脚本执行完不等待async脚本适用场景有依赖关系的业务脚本独立无依赖的统计、埋点脚本选择原则也很简单如果是业务代码、模块之间有依赖关系用defer如果是第三方统计、广告、小工具这类无依赖的脚本用async。我自己遇到过最典型的问题就是把一个核心业务脚本加了async却不知道它内部依赖另一个前置脚本提供的全局变量导致线上偶发白屏。排查半天最后发现就是执行顺序被打乱了。3.2 动态脚本注入默认就是async的一种玩法除了标签上的async和defer还有一种常见的异步加载方式是动态创建script标签function loadScript(url) { const script document.createElement(script); script.src url; script.onload () console.log(loaded, url); document.body.appendChild(script); }这种方式创建的脚本浏览器默认给它设置了async语义也就是加载不阻塞解析下载完立即执行。这也是很多SDK加载器的底层原理。但动态注入有个坑因为它是append到文档里才触发加载如果你在页面解析早期就执行这段代码脚本的加载时机其实和同步脚本差不多只是不阻塞而已。更隐蔽的问题是动态脚本的错误处理需要你手动接onerror否则脚本加载失败时你连个提示都没有用户那边就是静默的空白区域。在async加载的脚本里如果脚本内部代码出错全局错误事件还能捕获到但如果是加载阶段失败404、网络断开window.onerror是收不到的必须靠script.onerror。这一点我在做第三方支付SDK接入时吃过亏同步加载时一切正常改成异步加载后某天部分用户支付组件加载失败了前端完全没感知用户一直点按钮没反应。后来才补上了超时检测和降级提示。3.3 ES Module现代前端默认拥有defer特性在原生ES Modulescript typemodule里浏览器对模块脚本的处理默认等同于defer。也就是说支持ES Module的现代浏览器会替你自动处理加载不阻塞解析、按依赖关系执行这种事。script typemodule srcapp.js/script模块脚本之间可以通过import建立依赖关系浏览器会自己解析依赖图、按拓扑顺序执行。这比一堆defer脚本靠手写标签顺序维护依赖要可靠得多。不过要注意模块脚本默认是跨域的本地用file://协议直接打开会报CORS错误必须走HTTP服务。这是很多人第一次玩原生ES Module时最常遇到的小坑。另外typemodule脚本默认也会延迟执行所以你在head里写它也不用担心阻塞。但如果要兼容老浏览器就需要额外的构建工具把所有模块打包成普通脚本。工程化到后面你写代码用的都是模块语法最终产物则取决于构建工具怎么处理这就引出了下一部分说的代码分割。4. 现代工程化异步加载代码分割、路由懒加载与资源提示4.1 代码分割的三个层次路由级、组件级、库级原生标签层面的异步加载只是性能优化的第一层。到了现代前端工程化环境里Webpack、Vite、Rollup异步加载的主要表现形式是代码分割Code Splitting。原理其实一样——把大Bundle拆成多个小块首屏只加载必要的那块其他块在需要时才去加载。按照拆分粒度我习惯把它分成三个层次。路由级拆分每个路由对应一个独立的chunk。用户访问首页时只加载首页代码进入详情页时才加载详情页代码。这是收益最明显、成本最低的拆分方式。在Vue里通常是这样const routes [ { path: /, component: () import(./views/Home.vue) }, { path: /detail/:id, component: () import(./views/Detail.vue) } ];组件级拆分某个体积很大、但只在特定条件下才渲染的组件比如富文本编辑器、图表库、代码高亮器。通过defineAsyncComponentVue 3或者React的React.lazy按需加载。// Vue 3 const RichEditor defineAsyncComponent(() import(/components/RichEditor.vue));库级拆分有些第三方库只在极少数场景用到或者内部可以按功能模块拆开。比如lodash改成按需引入lodash-esdayjs替换moment图表库只引入用到的图表类型。这类拆分在Webpack里靠splitChunks控制// webpack.config.js module.exports { optimization: { splitChunks: { chunks: all, cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: vendors, priority: -10 }, charts: { test: /[\\/]node_modules[\\/](echarts|antv)[\\/]/, name: charts, priority: 0 } } } } };这个配置的意义在于第三方依赖一旦变化频率低、缓存价值高就单独打成vendorschunk。不这样做的话业务代码更新会连带第三方库一起让用户重新下载。把第三方库独立出来用户第二次访问页面时命中缓存的概率会高很多。4.2 骨架屏不是炫技是感知性能的一部分路由懒加载之后一个很现实的问题出现了切换路由时用户会看到短暂的白屏因为新的chunk正在下载。虽然实际加载体积变小了但加载发生了延后用户在等待期间看到空白。骨架屏Skeleton Screen就是为这个问题生的。它不是减少加载时间而是用占位元素让用户感觉页面在快速进展。谷歌做过一个经典结论感知性能有时候比真实性能更重要。同样200ms的等待空白占位和骨架占位给用户的主观体验完全不同。实现骨架屏的方式有很多最朴素的是在路由组件里放一个默认的加载状态script setup import { defineAsyncComponent } from vue; const AsyncPanel defineAsyncComponent({ loader: () import(./Panel.vue), loadingComponent: SkeletonPanel, // 骨架屏组件 timeout: 3000, errorComponent: ErrorPanel }); /script这个方案不需要刷新策略纯前端就行。需要注意的是骨架屏不要做得比真实内容还花哨否则等真实内容加载出来后用户反而觉得比刚才还卡。4.3 资源提示preload、prefetch、preconnect的正确用法现代浏览器提供了几种资源提示Resource Hints它们的作用是提前告诉浏览器接下来会发生什么。异步加载不只是把脚本延后也包含主动把未来要用的资源提前准备好。这几个API很容易被滥用我用下来的体会是这样的preload当前页面马上要用、但发现得太晚的资源。典型场景是字体文件。字体通常在CSS里被引用浏览器要等到CSSOM构建完、渲染树算完才会发现字体请求这就白白多了一次往返。如果明确知道首屏要用某个字体就在head里link relpreload hreffont.woff2 asfont让浏览器提前下载。preload对其他资源也可以使用但切忌过度把所有资源都preload等于把所有资源都变成优先加载项反而破坏了优先级管理。prefetch未来某个时刻可能要用到的资源。典型场景是路由懒加载后的next chunk。用户在当前页面停留时浏览器有空闲带宽可以提前下载用户即将跳转的那一页的chunk让跳转瞬间变得顺滑。preconnect提前建立到目标服务器的连接。如果你的页面要请求第三方CDN、字体服务器在head里加link relpreconnect hrefhttps://cdn.example.com可以提前完成DNS查询、TCP握手和TLS协商省下不少时间。这里要留意preconnect只建立连接不下载内容所以它适合连接成本高、但数据量小的请求。反过来如果第三方资源本身也没多大那也许直接用preload更划算。在工程化项目里资源提示通常是构建工具自动生成的。比如Vite的vite:preload插件会根据路由配置自动生成preload链接把它插入到HTML里。手动维护这些标签很快就会失控一定要交给构建工具。5. 异步加载之后的新战场渲染主线程、性能度量与跨端对比5.1 从FCP到TTI四个指标让你知道自己到底快没快有时候感觉页面变快了是不够的需要量化。我常用的四个首屏相关指标它们的意义和作用范围不同FCPFirst Contentful Paint用户看到第一个内容的时间。这个内容不一定是有效内容可能只是一个标题、一个按钮。FCP衡量的是渲染有没有开始。LCPLargest Contentful Paint页面上最大元素绘制出来的时间。LCP基本代表了用户感知的首屏主体内容出现。Google推荐的LCP目标是在2.5秒以内。TTITime to Interactive页面从加载完成到可以流畅响应用户交互的时间。TTI的长短往往取决于主线程上有多少长任务。TBTTotal Blocking Time主线程被长任务阻塞的总时长。长任务指执行时间超过50ms的任务好几十毫秒的任务阻塞会让用户点击没反应、动画掉帧。这几个指标之间的关系我这样理解FCP讲的是看到了LCP讲的是看全了TTI讲的是能点了TBT讲的是为什么不能点。异步加载优化得再好如果主线程在加载完之后还有一堆长任务在执行TTI依然会很差。直接用PerformanceObserver把LCP的数值打出来比反复刷新DevTools更直观new PerformanceObserver((entryList) { const entries entryList.getEntries(); const lastEntry entries[entries.length - 1]; console.log(LCP:, lastEntry.startTime); }).observe({ type: largest-contentful-paint, buffered: true });同理观察长任务new PerformanceObserver((entryList) { const entries entryList.getEntries(); entries.forEach((entry) { console.log(Long Task:, entry.duration, entry.startTime); }); }).observe({ type: longtask, buffered: true });5.2 长任务不是不能有而是不能堵在最前面异步加载只能解决资源什么时候下载的问题解决不了下载完之后执行导致主线程卡顿的问题。很多页面加载指标已经优化得很好了LCP跑进2秒但用户点按钮还是延迟响应原因就是脚本加载完后在DOMContentLoaded前后连续执行了好几段重逻辑。解决长任务的手段主要有两个方向拆分和延后。拆分是把一个100ms的任务拆成两个50ms的任务中间让出主线程给浏览器处理交互。setTimeout的经典用法可以做到这一点function processLargeList(items) { const chunkSize 100; let index 0; function nextChunk() { const limit Math.min(index chunkSize, items.length); for (; index limit; index) { // 处理单个item } if (index items.length) { setTimeout(nextChunk, 0); } } nextChunk(); }这样每处理100条数据就回到任务队列尾部队列刷新用户点击事件有机会插队响应。但要注意这个方法不能无脑用如果循环体内有依赖前一步结果的状态拆分之后要注意共享变量的读写顺序。延后则是用requestIdleCallback把优先级低的初始化任务放到浏览器空闲时段执行requestIdleCallback(() { // 初始化图表、绑定事件、预加载数据等 }, { timeout: 2000 });但requestIdleCallback在兼容性上有一定限制React内部在Scheduler层自己实现了类似的调度。在原生环境里使用前最好做一层能力检测或者垫片。5.3 跨端视角Android启动优化、手游与Julia生态的对照性能优化其实不止存在于浏览器里。看过一些非前端的性能优化案例后你会发现它们的底层思想和异步加载完全一致。安卓启动优化里有个很经典的思路把Application.onCreate里的初始化任务尽可能异步化用AndroidX Startup或者自己写的初始化任务调度器把不紧急的SDK初始化放到子线程或者延迟到首帧渲染之后再执行。这和前端把不阻塞首屏的脚本改成defer本质上是同一件事减少关键路径上的同步工作量。手游性能优化里异步加载体现为资源流式加载和分帧加载。进入新场景时如果一次性加载所有贴图、模型、音频游戏就会卡顿甚至闪退。成熟的方案是建立资源预加载池优先加载当前场景需要的资源其他资源按需流式加载并在加载过程中用Loading画面隐藏卡顿。移动端相比PC端更慢的存储和网络让这种按需加载变得不可或缺。连Julia这种偏科学计算的语言生态里性能优化也离不开类似的思想。Julia是JIT编译的语言第一次执行某个函数时会触发编译产生明显的延迟。为了解决这个问题生态里发展了预编译缓存Precompile Statements、PackageCompiler等方案思路就是把以后肯定要用、但不必现在算的工作提前算好存起来或者延迟到实际需要时再触发。这跟前端做代码分割、按需加载其实是同构的都是围绕关键时刻只做关键工作非关键工作在别的时间做来展开的。这些跨场景的类比说明异步加载不是前端的某个API技巧而是一种系统性的性能设计思想。理解了这一点你去看任何一个平台的性能优化方案都会觉得顺畅很多。6. 我踩过的坑时序竞态、错误兜底与SEO的权衡6.1 时序竞态async之后的依赖地狱把同步脚本改成async之后最常见的问题是时序竞态。我做过一个活动页里面先加载了基础库再加载一个依赖基础库的业务脚本。这两个脚本都加了async看起来下载快了很多。结果线上大概有5%的用户会偶发报错错误信息是xxx is not defined。排查过程不复杂复现方式是在Network面板里手动降低网络速度然后刷新。能看到大部分情况下基础库先执行完但偶尔业务脚本下载更快抢在基础库之前执行了自然就报错了。解决方案也不是把async改回同步而是给业务脚本做一个初始化等待机制——基础库加载完成后把一个全局Promise置为resolved业务脚本启动时先await这个Promise再继续// base.js window.baseReady new Promise((resolve) { // 初始化逻辑... resolve(window.baseLib); }); // business.js (async () { const base await window.baseReady; // 使用 base base.init(); })();这个小改动既保住了异步加载的收益又避免了执行顺序的随机性。后来我做SDK接入时基本都会要求SDK方提供一个初始化完成回调目的就是规避这种竞态。6.2 加载失败要兜底异步化的最后一公里异步加载还有一个天然缺陷加载失败的概率比同步加载更大。同步脚本失败时页面的其他部分可能还能继续跑异步脚本失败时如果不做兜底用户看到的就是一个功能缺失而且没有任何提示。我的兜底实践有三层第一层给动态加载的脚本绑定onerror失败时主动加载降级资源或者触发全局提示。第二层给关键功能设置超时。比如支付组件设定5秒内加载不完就直接显示当前网络异常请重试避免用户反复点击却没有反应。第三层对业务逻辑做好解耦。异步加载的模块之间不要强依赖模块A加载失败时模块B仍然可以正常工作。这块靠的是设计阶段的模块边界划分而不是事故后的修复。6.3 异步加载和SEO懒加载不是百搭最后是一个容易被忽略的层面懒加载会影响爬虫抓取。搜索引擎的爬虫在执行JavaScript上各有各的额度如果你把整页内容都做成异步加载关键信息完全靠JS渲染出来就可能出现抓不到内容的情况。这也是SPA为什么普遍要做SSR或SSG的原因——把首屏内容在服务端就渲染成HTML返回然后前端再通过hydrate让页面获得交互能力。我的建议是SEO敏感页面的关键内容不要用纯客户端懒加载改服务端渲染或混合渲染。但非SEO敏感、用户登录后才能看到的页面比如后台管理系统、仪表盘懒加载就是完全正确的选择。SSG hydration的方案现在有很多成熟的框架支持。它跟异步加载的关系是首屏HTML是同步的保证内容可达交互逻辑的JS是异步的保证体验不阻塞。这正好走到了异步加载的另一个极端——从所有东西都靠客户端加载变成把最小必要内容同步输出其余全部异步。两者并不矛盾而是同一个优化思路在不同约束条件下的不同解法。7. 写在最后一次完整优化的复盘心得上面说了一堆原理和工具最后分享一个我最近做的实际案例算是给这篇文章收个尾。一个后台管理系统的首屏优化前的指标是JS总体积大概1.8MBgzip后约480KBFCP 2.1秒LCP 3.6秒TTI 5.2秒。优化动作分四步走第一步把所有第三方SDK改成异步加载第二步路由级代码分割首屏只加载当前页面所需的约350KB代码第三步抽离第三方vendor独立chunk并开启长期缓存第四步给主要的路由chunk加了prefetch用户停留在首页时预先下载二级页面所需代码。优化后的结果首屏JS请求体积降到gzip后约180KBFCP 1.2秒LCP 2.3秒TTI 3.4秒。原先的1.8MB总量并没有消失它只是被打散并延后了用户真正需要时才去加载。整个优化没有删减任何功能改的只是什么时候加载、怎么加载、优先级如何。这个案例让我更确信了一点性能优化从来不是一次性的调参而是一套需要持续观察、反复验证的工程习惯。我在实际工作中已经养成了固定的检查序列——先看Network瀑布图里有没有阻塞渲染的脚本再看Performance面板里有没有超过50ms的长任务然后才是考虑拆包、加preload、做骨架屏。每一步都先看见问题再动手解决而不是凭感觉一通优化。如果你手头正好有页面觉得有点慢我的建议是从最原始的一步开始打开DevTools的Network面板关掉缓存设成Slow 4G刷新一遍看看瀑布图里哪些请求堵住了首屏。找到那个堵塞点再回来对照这篇文章里的思路。性能优化的道理不复杂难的是养成动手前先看证据的习惯。希望这篇原理篇能帮你把这第一块基石踩稳。
返回列表