拓冰建站拓冰建站
首页 / 资讯中心 / 正文

异步加载与性能优化:从原理到实践的完整指南

1. 异步加载到底在解决什么问题前端性能优化这个话题说起来人人都懂一点但真到了项目里很多人第一反应还是“压缩图片、上CDN、开gzip”这三板斧。这些当然有用但它们解决的是“传输体积”的问题而异步加载解决的是另一个更隐蔽的问题主线程被阻塞导致页面响应迟钝。我做过一个统计一个中等复杂度的后台管理系统首屏同步加载的JS体积轻松突破2MB。这2MB的脚本在弱网环境下要下载十几秒下载完之后浏览器还要花2到4秒去解析和执行。这期间主线程完全被占用用户点什么都没反应滚动也卡顿体验极差。异步加载的核心思路就是把这2MB拆开只加载当前屏幕真正需要的那部分剩下的等用户操作到了再加载。这里有个关键概念需要先厘清异步加载不等于懒加载。懒加载是异步加载的一种具体策略指的是“等到需要的时候再加载”。而异步加载的范围更广它还包括“并行加载但不阻塞渲染”“预加载但延迟执行”等模式。很多人把这两个概念混着用在技术方案选型的时候就会犯迷糊。从浏览器的工作原理来看一个script标签默认是同步阻塞的。浏览器解析HTML时遇到它会暂停DOM构建去下载并执行这个脚本执行完了才继续往下走。这就是为什么我们把script放在body末尾——至少让DOM先渲染出来。但即便放在末尾多个脚本之间仍然是串行执行的前一个没执行完后一个就得等着。异步加载要打破的就是这个串行链条。手段有很多async属性、defer属性、动态创建script标签、import()动态导入、requestIdleCallback空闲调度等等。每种手段的适用场景不一样选错了反而会引入新的问题。比如async脚本的执行时机完全不可控如果它依赖DOM或者其他脚本就很容易报错。我在实际项目里踩过最典型的一个坑用async加载了一个统计脚本结果它比业务脚本先执行导致统计代码里访问的全局变量还没定义直接抛异常。后来改成defer就稳了因为defer保证在DOM解析完成后、按顺序执行。这个细节很多教程不会强调但它在真实项目里非常致命。所以这一章我想先把异步加载的底层逻辑讲透后面再展开具体的实操方案。理解了“为什么要异步”你才能判断“什么时候该异步、什么时候不该异步”。1.1 浏览器渲染管线与阻塞点定位要搞懂异步加载得先知道浏览器是怎么把一堆字节变成你看到的页面的。整个过程大致分五步解析HTML构建DOM树、解析CSS构建CSSOM树、合并成渲染树、布局计算、绘制。这五步里DOM构建和CSSOM构建是并行的但渲染树的生成必须等两者都完成。JavaScript的位置很特殊。它既能读改DOM又能读改CSSOM所以浏览器遇到同步脚本时必须等CSSOM构建完才执行脚本执行完脚本才继续构建DOM。这就形成了一个阻塞链条CSS阻塞JSJS阻塞DOM。异步加载要做的就是把这个链条上的某些环节解开。具体来说阻塞点有三个脚本下载阻塞DOM解析同步脚本在下载期间HTML解析器会停下来等。脚本执行阻塞DOM解析下载完了还要执行执行期间DOM构建暂停。CSS阻塞脚本执行如果脚本前面有未加载完的样式表脚本会等样式表加载完才执行。async和defer解决的是第一个和第二个问题。它们让脚本的下载变成并行不阻塞HTML解析。区别在于执行时机async是下载完立刻执行执行时仍然阻塞DOMdefer是等DOM解析完再执行执行时DOM已经就绪。我画过一个时间线对比图这里用文字描述假设有两个脚本A和B各需1秒下载、0.5秒执行。同步模式下总耗时是10.510.53秒。async模式下A和B并行下载总下载时间1秒然后各自执行总耗时约10.50.52秒。defer模式下下载也是并行的但执行要等DOM解析完总耗时取决于DOM解析时间通常也在2秒左右。这个账算下来异步加载的收益是实打实的。但要注意async的执行顺序不确定A可能比B后执行。如果你的脚本之间有依赖关系async就是灾难。defer则保证按声明顺序执行适合有依赖的脚本。还有一个容易被忽略的点动态创建的script标签默认就是async的。很多人用document.createElement(script)来加载脚本以为它是同步的其实不是。要让它按顺序执行得手动设置script.async false。这个坑我在三个项目里都遇到过每次都要重新查一遍文档。1.2 异步加载的四种典型模式与选型依据根据加载时机和执行时机的不同组合异步加载可以分成四种典型模式。我把它们整理成一张表方便你对照选型。模式加载时机执行时机适用场景风险点async脚本并行下载下载完立即执行独立第三方脚本如统计、广告执行顺序不可控可能阻塞DOMdefer脚本并行下载DOM解析完后按序执行有依赖关系的业务脚本不支持动态插入的脚本动态import按需下载下载完立即执行路由级代码分割、按需功能需要打包工具支持有兼容性要求空闲调度按需下载浏览器空闲时执行非关键任务如预加载、埋点执行时机不确定可能被延迟很久选型的核心判断依据是三个问题这个脚本是否关键它依赖DOM吗它和其他脚本有依赖关系吗如果脚本是关键路径上的比如框架运行时那就不该异步应该内联或者用defer保证尽早执行。如果是非关键的比如页面底部的评论区、推荐模块那就用动态import按需加载。如果是完全独立的第三方脚本用async最省事。我个人的经验是业务代码优先用defer第三方代码优先用async路由级代码用动态import。这个组合在大多数项目里都能work。但有一个例外如果你的项目用了HTTP/2多个小文件的并行加载成本很低这时候可以更激进地拆分把异步加载的粒度做细。还有一个进阶玩法是预加载加延迟执行。用link relpreload提前把资源下载到缓存里但不执行等真正需要的时候再从缓存里取。这样既避免了下载阻塞又避免了执行时机的不可控。这个方案在加载大型图表库、编辑器这类“重但非首屏必需”的模块时特别好用。不过预加载也有坑。如果你preload了但一直不用浏览器会警告“资源被预加载但未使用”而且会浪费带宽。所以preload的资源必须是高概率会用到的不能滥用。1.3 从首屏指标反推异步策略性能优化不能凭感觉得有指标。首屏相关的核心指标有三个FCP首次内容绘制、LCP最大内容绘制、TTI可交互时间。异步加载主要影响的是TTI因为它减少了主线程的阻塞时间。我通常的做法是先用Lighthouse跑一遍看TTI和TBT总阻塞时间是多少。如果TBT超过300毫秒说明主线程被长任务阻塞了这时候异步加载的收益会很明显。如果TBT本来就很低那优化重点就不在异步加载上而可能在图片体积或者网络延迟上。具体到策略制定我会把首屏需要的脚本分成三类必须同步的框架运行时、首屏组件的样式和逻辑。这部分用内联或者defer保证尽早执行。可以延迟的首屏以下的组件、弹窗、下拉菜单的逻辑。这部分用动态import等用户交互时再加载。可以完全异步的统计、监控、客服 widget。这部分用async不关心执行时机。这个分类做完通常能把首屏脚本体积砍掉40%到60%。我最近做的一个项目首屏JS从1.8MB降到了720KBTTI从4.2秒降到了1.8秒。效果非常明显。但要注意拆分不是越细越好。拆得太细会导致请求数暴增在HTTP/1.1下反而更慢。而且每个chunk都有固定的解析开销chunk太多会累积成新的瓶颈。我的经验值是首屏关键chunk控制在3到5个每个不超过200KB。非关键chunk可以更细但也要控制在10个以内。2. 动态import与代码分割的落地细节动态import是异步加载里最实用的手段没有之一。它让“按需加载”从理论变成了日常操作。但很多人对它的理解停留在“import()返回Promise”这个层面真到项目里用的时候各种问题就冒出来了打包后chunk名字看不懂、重复打包、加载失败没兜底、预加载时机不对等等。这一章我把动态import的实操细节拆开讲包括打包工具的配置、chunk命名策略、加载失败的处理、以及和路由的配合方式。这些都是文档里不会细说但项目里一定会遇到的东西。2.1 动态import的编译产物与chunk命名先看一段最基础的动态import代码button.addEventListener(click, async () { const module await import(./heavyModule.js); module.doSomething(); });打包工具以webpack为例看到这段代码会做三件事把heavyModule.js及其依赖单独打成一个chunk把import()替换成一个__webpack_require__.e调用在运行时动态插入一个script标签去加载这个chunk。默认情况下chunk的名字是一串数字比如1.js、2.js。这在开发环境里还能忍到了生产环境排查问题就是噩梦。你看到控制台报错说3.js加载失败根本不知道是哪个模块。所以必须配置chunk命名。webpack里用magic commentconst module await import( /* webpackChunkName: heavy-module */ ./heavyModule.js );这样打包出来就是heavy-module.js一目了然。如果多个模块要打到一个chunk里可以用相同的chunkNameconst a await import(/* webpackChunkName: group-editor */ ./editor/a.js); const b await import(/* webpackChunkName: group-editor */ ./editor/b.js);这两个模块会被合并到group-editor.js里。这个技巧在加载同一功能域的多个模块时特别有用能减少请求数。还有一个配置是webpackPrefetch和webpackPreloadconst module await import( /* webpackChunkName: chart */ /* webpackPrefetch: true */ ./chart.js );webpackPrefetch会在浏览器空闲时提前下载这个chunk但不执行。等真正import的时候直接从缓存里取速度极快。webpackPreload则是和父chunk并行下载优先级更高适合当前页面高概率会用到的模块。我一般会给“用户下一步很可能点击”的模块加prefetch。比如列表页的“详情”按钮对应的模块用户大概率会点提前prefetch能显著提升体验。但prefetch不能滥用prefetch太多会抢占带宽反而拖慢关键资源。2.2 加载失败与超时兜底动态import返回的是Promise这意味着它可能reject。reject的原因有很多网络断了、chunk被CDN缓存了旧版本、服务器返回404等等。如果不处理reject用户就会看到一个白屏或者无响应的按钮。最基础的兜底是try-catchasync function loadModule() { try { const module await import(./heavyModule.js); return module; } catch (error) { console.error(模块加载失败, error); showToast(加载失败请检查网络后重试); return null; } }但这还不够。有些场景下用户希望自动重试比如网络抖动导致的失败。这时候可以加一个重试逻辑async function loadWithRetry(path, retries 3) { for (let i 0; i retries; i) { try { return await import(path); } catch (error) { if (i retries - 1) throw error; await new Promise(resolve setTimeout(resolve, 1000 * (i 1))); } } }这个重试逻辑里每次重试的间隔递增1秒、2秒、3秒避免在服务器压力大时雪上加霜。还有一个更隐蔽的问题版本不一致导致的加载失败。假设用户打开了页面A此时服务器发布了新版本chunk的hash变了。用户点击按钮触发动态import请求的是旧hash的chunk但服务器上已经没有了返回404。这就是所谓的“版本漂移”问题。解决办法是在构建时生成一个manifest文件记录所有chunk的映射关系。运行时如果加载失败先拉取最新的manifest再根据新的映射重新加载。这个方案实现起来有点复杂但它是彻底解决版本漂移的唯一办法。如果项目不大也可以简单粗暴地提示用户“页面已更新请刷新”。2.3 路由级代码分割的实操配置路由级代码分割是动态import最经典的应用场景。每个路由对应一个chunk用户访问哪个路由就加载哪个chunk。这样首屏只需要加载当前路由的代码其他路由的代码等跳转时再加载。以React Router为例配合React.lazy和Suspenseimport { lazy, Suspense } from react; import { BrowserRouter, Routes, Route } from react-router-dom; const Home lazy(() import(/* webpackChunkName: home */ ./pages/Home)); const Detail lazy(() import(/* webpackChunkName: detail */ ./pages/Detail)); const Settings lazy(() import(/* webpackChunkName: settings */ ./pages/Settings)); function App() { return ( BrowserRouter Suspense fallback{Loading /} Routes Route path/ element{Home /} / Route path/detail/:id element{Detail /} / Route path/settings element{Settings /} / /Routes /Suspense /BrowserRouter ); }这里有几个细节要注意。第一Suspense的fallback要设计好不能是一个空白页最好是一个骨架屏让用户感知到“正在加载”而不是“卡住了”。第二lazy的import路径必须是静态字符串不能是变量否则打包工具无法分析。第三如果路由有嵌套每个嵌套层级都要有自己的Suspense避免一个子路由加载时整个页面都进入loading状态。Vue的配置类似用defineAsyncComponentimport { defineAsyncComponent } from vue; const Detail defineAsyncComponent({ loader: () import(/* webpackChunkName: detail */ ./pages/Detail.vue), loadingComponent: Loading, delay: 200, timeout: 5000 });delay: 200的意思是200毫秒内加载完就不显示loading避免闪烁。timeout: 5000是5秒还没加载完就报错。这两个参数很实用建议都配上。我踩过的一个坑是路由chunk里包含了公共依赖导致重复打包。比如Home和Detail都引用了lodash如果没配置splitChunkslodash会被打进两个chunk里体积翻倍。解决办法是在webpack里配置optimization.splitChunks把node_modules里的公共依赖抽成vendor chunk。optimization: { splitChunks: { chunks: all, cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: vendors, priority: 10 } } } }这样配置之后lodash只会被打进vendors chunk里路由chunk只包含业务代码。首屏加载vendors跳转时只加载业务chunk效率最高。3. 主线程调度与空闲任务处理异步加载解决了“下载和执行”的阻塞问题但还有一个更细粒度的问题即使脚本都加载完了执行的时候仍然可能阻塞主线程。一个复杂的计算、一次大量的DOM操作、一个大的JSON解析都会让页面卡住。这时候就需要更精细的调度手段把长任务拆成小任务在浏览器空闲的时候执行。这一章讲三个东西requestIdleCallback的用法和坑、长任务拆分的基本模式、以及Web Worker的适用边界。这三个手段配合使用能把主线程的阻塞时间压到最低。3.1 requestIdleCallback的适用场景与兼容处理requestIdleCallback的API很简单requestIdleCallback((deadline) { while (deadline.timeRemaining() 0 tasks.length 0) { const task tasks.shift(); task(); } if (tasks.length 0) { requestIdleCallback(processTasks); } });它的逻辑是浏览器每帧渲染完之后如果还有剩余时间就执行回调。回调收到一个deadline对象timeRemaining()返回当前帧还剩多少毫秒。你可以在剩余时间里执行任务时间用完了就停下来等下一帧继续。这个API适合执行非关键、可延迟、可拆分的任务。比如预加载下一页的数据、计算非首屏的布局、上报埋点日志、清理缓存等等。这些任务晚一点执行没关系但不能阻塞用户交互。但requestIdleCallback有几个坑。第一兼容性不好。Safari直到15.4才支持之前完全不支持。所以必须做polyfillconst requestIdle window.requestIdleCallback || function(cb) { const start Date.now(); return setTimeout(() { cb({ didTimeout: false, timeRemaining: () Math.max(0, 50 - (Date.now() - start)) }); }, 1); };这个polyfill用setTimeout模拟给每个任务50毫秒的预算。虽然不如原生精确但能用。第二空闲时间不可控。如果页面一直很忙空闲回调可能永远不执行。所以关键任务不能依赖它必须设置超时requestIdleCallback(callback, { timeout: 2000 });timeout的意思是如果2秒内还没执行就强制执行不管有没有空闲时间。这个参数在需要保证任务最终会执行的场景下很重要。第三回调里不能做DOM操作。因为空闲回调可能在渲染之后执行这时候改DOM会导致下一帧重新布局反而更慢。所以空闲回调里只做纯计算、数据预处理、缓存写入这类操作。我个人的经验是requestIdleCallback最适合做“预加载”和“数据预处理”。比如用户在看列表页的时候空闲时预加载详情页的数据等用户点击详情时直接渲染体验非常流畅。但不要用它做“必须执行”的任务因为执行时机太不确定。3.2 长任务拆分与时间切片长任务是指执行时间超过50毫秒的任务。浏览器的主线程是单线程的一个长任务执行期间用户输入、动画、渲染全部被阻塞。所以性能优化的一个核心原则是把长任务拆成多个小于50毫秒的小任务。拆分的模式有两种基于时间的拆分和基于任务的拆分。基于时间的拆分是用performance.now()监控执行时间超过阈值就暂停用setTimeout或requestIdleCallback继续function processLargeArray(items, callback) { let index 0; const chunkSize 100; function processChunk() { const start performance.now(); while (index items.length performance.now() - start 40) { processItem(items[index]); index; } if (index items.length) { setTimeout(processChunk, 0); } else { callback(); } } processChunk(); }这里的时间阈值设40毫秒留10毫秒给浏览器做渲染和其他工作。setTimeout(processChunk, 0)会把下一个chunk放到宏任务队列里让浏览器有机会处理用户输入。基于任务的拆分是把大任务按逻辑拆成多个小任务每个任务独立调度。比如渲染一个1000行的表格可以拆成“渲染表头”“渲染前100行”“渲染后100行”等多个任务每个任务之间让出主线程。async function renderTable(data) { renderHeader(); await yieldToMain(); for (let i 0; i data.length; i 100) { renderRows(data.slice(i, i 100)); await yieldToMain(); } } function yieldToMain() { return new Promise(resolve setTimeout(resolve, 0)); }yieldToMain是关键它让出主线程让浏览器有机会响应用户输入。如果没有这个让出即使拆成了多个任务它们仍然会在同一个宏任务里连续执行用户还是感觉卡。这里有个细节setTimeout(resolve, 0)的实际延迟不是0通常是4毫秒左右浏览器的最小时间粒度。如果拆得太细累积的延迟会很可观。所以拆分的粒度要平衡不能太粗也不能太细。我的经验是每个chunk执行40到50毫秒让出一次这样既保证了响应性又不会引入太多调度开销。3.3 Web Worker的边界与通信成本Web Worker是真正意义上的多线程。它在一个独立的线程里执行JavaScript完全不阻塞主线程。听起来很美好但用起来有很多限制。首先Worker里不能访问DOM。这意味着所有涉及DOM的操作都不能放在Worker里。Worker适合做纯计算数据排序、图像处理、加密解密、大JSON解析、复杂算法等等。其次通信有成本。主线程和Worker之间通过postMessage通信数据是结构化克隆的不是共享的。这意味着每次通信都要复制数据。如果数据量大复制的开销可能比计算本身还大。// 主线程 const worker new Worker(worker.js); worker.postMessage({ type: sort, data: largeArray }); worker.onmessage (e) { console.log(排序完成, e.data); }; // worker.js self.onmessage (e) { if (e.data.type sort) { const sorted e.data.data.sort((a, b) a - b); self.postMessage(sorted); } };如果largeArray有10万条数据postMessage的复制开销可能达到几十毫秒。这时候用Worker反而更慢。解决办法是用Transferable Objects转移所有权而不是复制const buffer new ArrayBuffer(1024 * 1024); worker.postMessage(buffer, [buffer]);转移之后主线程就不能再访问这个buffer了但省去了复制开销。这个技巧在处理二进制数据如图片像素时特别有用。还有一个坑是Worker的启动成本。创建一个Worker大约需要几毫秒到几十毫秒如果只是执行一个很小的任务启动成本可能比任务本身还大。所以Worker适合“重计算、低频次”的场景不适合“轻计算、高频次”的场景。我个人的判断标准是如果计算时间超过50毫秒且不涉及DOM且数据量可控就用Worker。否则用时间切片在主线程里做。两者没有绝对优劣要看具体场景。4. 性能监控与持续优化闭环异步加载和性能优化不是一次性的工作而是一个持续的过程。你今天优化到TTI 1.8秒下个月加了个新功能可能又回到3秒了。所以必须建立监控体系持续跟踪关键指标及时发现退化。这一章讲三个东西核心指标的采集方法、性能退化的预警机制、以及优化效果的验证流程。这些都是我在实际项目里跑通的方案可以直接抄。4.1 核心指标采集与上报首屏相关的核心指标浏览器提供了PerformanceObserver来采集const observer new PerformanceObserver((list) { for (const entry of list.getEntries()) { if (entry.entryType largest-contentful-paint) { reportMetric(LCP, entry.startTime); } if (entry.entryType first-input) { reportMetric(FID, entry.processingStart - entry.startTime); } if (entry.entryType layout-shift) { if (!entry.hadRecentInput) { cumulativeLayoutShift entry.value; } } } }); observer.observe({ entryTypes: [largest-contentful-paint, first-input, layout-shift] });LCP采集最大内容元素的绘制时间FID采集首次输入的延迟CLS采集布局偏移的累积值。这三个指标加上FCP和TTI基本能覆盖首屏体验的全貌。采集到数据之后要上报。上报本身不能影响性能所以要用navigator.sendBeacon它在页面卸载时也能可靠发送function reportMetric(name, value) { const data { name, value, url: location.href, timestamp: Date.now() }; navigator.sendBeacon(/api/metrics, JSON.stringify(data)); }sendBeacon是异步的不阻塞页面卸载比fetch更适合做埋点上报。但要注意上报不能太频繁。如果每个指标都单独上报请求数会很多。我的做法是攒一批再上报比如每5秒或者攒够10条就发一次。页面隐藏时visibilitychange事件强制上报一次保证数据不丢。还有一个细节是区分冷启动和热启动。用户第一次访问和第二次访问有缓存的性能差异很大。上报时要带上performance.navigation.type区分是navigate冷启动还是reload/back_forward热启动。否则数据混在一起看不出真实情况。4.2 性能退化的预警机制光有数据不够还得有预警。我的做法是设置阈值超过阈值就告警。阈值怎么定用历史数据的P75作为基线超过基线20%就预警。比如LCP的P75是2.0秒那阈值就设2.4秒。如果某天的P75超过2.4秒就发告警。告警要带上上下文哪个页面、哪个版本、什么设备、什么网络环境。这样才能快速定位问题。const thresholds { LCP: 2400, FID: 100, CLS: 0.1, TTI: 3000 }; function checkThreshold(name, value) { if (value thresholds[name]) { sendAlert({ metric: name, value, threshold: thresholds[name], page: location.pathname, version: window.__APP_VERSION__, device: navigator.userAgent }); } }除了阈值告警还要做版本对比。每次发版后对比新版本和旧版本的指标。如果新版本明显退化就要回滚或者修复。这个对比可以自动化在CI流程里加一步性能测试退化超过10%就阻断发布。我踩过的一个坑是告警太多导致麻木。一开始什么指标都告警结果每天收到几十条慢慢就没人看了。后来改成只告警P75超过阈值的而且同一个问题24小时内只告警一次这样告警才有意义。4.3 优化效果的A/B验证性能优化做完怎么证明有效不能凭感觉说“快了”得有数据。最严谨的方法是A/B测试一部分用户用旧版本一部分用新版本对比两组的指标。实现方式是在入口处做分流const bucket Math.random() 0.5 ? control : treatment; if (bucket treatment) { loadOptimizedVersion(); } else { loadOriginalVersion(); } reportMetric(bucket, bucket);然后对比两组的LCP、TTI、跳出率、转化率等指标。如果treatment组明显更好就全量发布。A/B测试要注意样本量。样本太小差异可能是随机波动。一般来说每组至少需要几千个样本才能得出可靠结论。如果流量小可以延长测试时间或者用更敏感的指标比如TBT而不是TTI。还有一个替代方案是灰度发布。先给1%的用户用新版本观察指标没有退化再逐步扩大到10%、50%、100%。灰度发布比A/B测试简单但只能发现“退化”不能证明“提升”。如果目标是证明提升还是得用A/B测试。我个人的经验是大改动用A/B测试小改动用灰度发布。大改动风险高需要严谨验证小改动风险低快速灰度就行。两者结合既能保证效果又能控制成本。最后再分享一个小技巧性能优化要留出预算。给每个页面设定一个性能预算比如首屏JS不超过500KBLCP不超过2.5秒。每次发版前检查预算超了就优化或者砍功能。这样能防止性能在迭代中慢慢退化。预算不是死的可以根据业务重要性调整但一定要有。没有预算性能优化就是无底洞永远做不完。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门