化核心:異步加載原理、落地方式與實(shí)戰(zhàn)指南)
1. 異步加載到底解決了什么問(wèn)題做過(guò)前端或者客戶端性能優(yōu)化的朋友應(yīng)該都有過(guò)這種體驗(yàn)頁(yè)面代碼越堆越多首屏打開(kāi)越來(lái)越慢白屏?xí)r間從原來(lái)的500毫秒變成一秒兩秒用戶早跑了。剛開(kāi)始我以為是網(wǎng)絡(luò)問(wèn)題后來(lái)把Network面板打開(kāi)一看一個(gè)首屏加載了2MB的JS、1MB的CSS還有一堆沒(méi)在首屏出現(xiàn)的圖片全擠在那條加載鏈里。問(wèn)題很明確我們讓用戶為根本看不到的內(nèi)容付了費(fèi)——這個(gè)費(fèi)就是等待時(shí)間。異步加載的核心思想其實(shí)特別樸素現(xiàn)在不用的東西就別現(xiàn)在加載。但就是這句樸素的話落地到工程里卻牽扯出一整套設(shè)計(jì)和權(quán)衡。從圖片懶加載到路由級(jí)代碼分割從Web Worker到Android端的異步初始化背后共享的是同一個(gè)底層邏輯——把關(guān)鍵路徑上的任務(wù)砍短把非關(guān)鍵任務(wù)踢出主流程。這也是為什么性能優(yōu)化里異步加載幾乎是所有優(yōu)化手段的地基。你去做LCP優(yōu)化、做啟動(dòng)耗時(shí)優(yōu)化、做TTI優(yōu)化最后大都會(huì)落到某些資源是不是可以晚點(diǎn)再加載這個(gè)決策上。這篇文章我打算把異步加載從原理到實(shí)操完整拆一遍包括它到底在優(yōu)化什么、有哪幾種落地方式、每種方式適合什么場(chǎng)景、踩過(guò)哪些坑以及怎么用數(shù)據(jù)驗(yàn)證優(yōu)化到底有沒(méi)有效果。不管你是做Web前端、Android客戶端還是做跨端應(yīng)用這套思路都是通用的。我會(huì)把我在實(shí)際項(xiàng)目里驗(yàn)證過(guò)的東西直接寫(xiě)出來(lái)能幫你少走不少?gòu)澛?。先說(shuō)說(shuō)異步加載之所以能提升性能的根本原因。瀏覽器和操作系統(tǒng)的渲染機(jī)制里有一條絕對(duì)的主干道——主線程。DOM的解析、樣式計(jì)算、布局、繪制、JavaScript的執(zhí)行全部擠在這條主干道上。同步加載的意思是一個(gè)任務(wù)沒(méi)走完后面所有任務(wù)全部排隊(duì)等著。你加載了一個(gè)同步腳本HTML解析器就得停下去拉腳本、編譯腳本、執(zhí)行腳本全部搞完才繼續(xù)解析后續(xù)標(biāo)簽。頁(yè)面內(nèi)容越靠后用戶看到首屏的時(shí)間就越晚。異步加載的本質(zhì)就是把不阻塞首屏渲染的任務(wù)挪到主干道之外或者挪到不那么緊急的時(shí)間點(diǎn)。這里有個(gè)非常重要的認(rèn)知異步加載不是減少工作量而是調(diào)整工作的時(shí)間分布。比如路由懶加載該寫(xiě)的代碼一行沒(méi)少但寫(xiě)代碼的那2MB文件從首屏必須下載并執(zhí)行變成了用戶進(jìn)入某個(gè)路由時(shí)才下載執(zhí)行。對(duì)于首屏來(lái)說(shuō)它要處理的工作量就變少了所以首屏變快了。但對(duì)于整個(gè)應(yīng)用生命周期來(lái)說(shuō)總工作量并沒(méi)有減少甚至可能因?yàn)榉职鸬貌缓侠磉€增加了請(qǐng)求次數(shù)。理解了這一層你就不會(huì)盲目地凡事皆異步而是會(huì)想清楚哪條路徑是用戶最關(guān)心的哪條路徑是可以往后放的。在做具體方案之前還需要構(gòu)建一個(gè)最基本的理論框架事件循環(huán)機(jī)制。JS是單線程的但通過(guò)事件循環(huán)把任務(wù)分成了同步任務(wù)、宏任務(wù)、微任務(wù)、渲染步驟等不同隊(duì)列。setTimeout和setInterval把任務(wù)延遲到宏任務(wù)隊(duì)列Promise.then、MutationObserver把回調(diào)排到微任務(wù)隊(duì)列requestAnimationFrame把任務(wù)排在渲染之前requestIdleCallback把任務(wù)排在瀏覽器空閑時(shí)段。這些API是異步加載的調(diào)度器你選擇哪個(gè)API實(shí)際上是在選擇任務(wù)在事件循環(huán)的哪個(gè)階段執(zhí)行這會(huì)直接影響用戶的感知流暢度。后面講具體場(chǎng)景時(shí)我會(huì)再回來(lái)說(shuō)它們之間的取舍。2. 異步加載的五種主流落地方式異步加載不是某一種單一技術(shù)它是一族方案的統(tǒng)稱。我按實(shí)際項(xiàng)目的使用頻率把它們的實(shí)現(xiàn)原理、適用場(chǎng)景和踩坑點(diǎn)都列一下。理解這些方案的差異是做好選型的前提。2.1 資源級(jí)的異步加載defer與asyncHTML里加載外部腳本默認(rèn)是同步阻塞的。你寫(xiě)了一個(gè)script srcapp.js放在head里瀏覽器的HTML解析器就會(huì)卡住下載并執(zhí)行完這個(gè)腳本才能繼續(xù)渲染。這其實(shí)是最古老的性能殺手。后來(lái)有了defer和async兩個(gè)屬性但它們的工作原理并不一樣。defer的意思是腳本的下載和HTML解析并行但執(zhí)行被推遲到HTML解析完畢之后。多個(gè)defer腳本會(huì)按照文檔順序依次執(zhí)行。async的意思是下載是異步的但一旦下載完成立刻中斷HTML解析去執(zhí)行腳本。多個(gè)async腳本誰(shuí)先下載完誰(shuí)先執(zhí)行跟文檔順序無(wú)關(guān)。從行為上看defer更適合那些依賴DOM結(jié)構(gòu)已經(jīng)準(zhǔn)備好的腳本async更適合那些獨(dú)立性強(qiáng)的腳本比如統(tǒng)計(jì)腳本、廣告腳本。這里有一個(gè)常見(jiàn)誤區(qū)很多人覺(jué)得在標(biāo)簽上加了async或者defer就萬(wàn)事大吉其實(shí)不是。如果你有一個(gè)2MB的腳本加defer只是讓它執(zhí)行得晚一點(diǎn)下載仍然占了帶寬、也會(huì)在解析完畢后占用主線程執(zhí)行。所以資源級(jí)的異步加載只解決了不被阻塞解析的問(wèn)題沒(méi)有解決加載了不該加載的東西的問(wèn)題。代碼分割要配合路由懶加載去做資源級(jí)異步只是其中一塊拼圖。2.2 按需加載圖片懶加載與組件懶加載圖片懶加載是最常見(jiàn)也最容易上手的異步加載實(shí)踐。核心邏輯是圖片的真實(shí)地址不直接寫(xiě)在src里而先放在>/** * 圖片懶加載工具類 * 用法new LazyLoad({ selector: .lazy-img }) */ class LazyLoad { constructor(options {}) { const defaultOptions { selector: .lazy-img, root: null, // 默認(rèn)使用瀏覽器視口作為觀察根元素 rootMargin: 0px 0px 200px 0px, // 提前200px開(kāi)始加載提高加載感知流暢度 threshold: 0.01, // 元素進(jìn)入視口1%時(shí)觸發(fā)避免元素還在邊緣就加載 loadedClass: is-loaded }; this.options { ...defaultOptions, ...options }; // 保存所有未加載的圖片引用 this.images Array.from(document.querySelectorAll(this.options.selector)); this.init(); } init() { if (!(IntersectionObserver in window)) { // 降級(jí)方案直接加載所有圖片 this.images.forEach(img this.loadImage(img)); return; } this.observer new IntersectionObserver((entries) { entries.forEach(entry { // 只有isIntersecting為true時(shí)才真正觸發(fā)加載 if (entry.isIntersecting) { const img entry.target; this.loadImage(img); // 圖片一旦開(kāi)始加載就不需要再被觀察了及時(shí)解除觀察 this.observer.unobserve(img); } }); }, { root: this.options.root, rootMargin: this.options.rootMargin, threshold: this.options.threshold }); this.images.forEach(img this.observer.observe(img)); } loadImage(img) { // 把data-src的真實(shí)地址還原到src同時(shí)兼容data-srcset響應(yīng)式圖片 const src img.getAttribute(data-src); if (src) { img.src src; img.removeAttribute(data-src); } const srcset img.getAttribute(data-srcset); if (srcset) { img.srcset srcset; img.removeAttribute(data-srcset); } // 加載完成后標(biāo)記狀態(tài)用于后續(xù)樣式控制 img.addEventListener(load, () { img.classList.add(this.options.loadedClass); }); // 加載失敗時(shí)嘗試用默認(rèn)占位圖兜底 img.addEventListener(error, () { img.src data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw; img.classList.add(load-error); }); } } export default LazyLoad;幾個(gè)關(guān)鍵參數(shù)值得細(xì)說(shuō)rootMargin我這里設(shè)置了0px 0px 200px 0px意思是在視口底部往下擴(kuò)展200px作為觸發(fā)區(qū)域。這樣用戶滾動(dòng)到某張圖片前的200px時(shí)瀏覽器就已經(jīng)開(kāi)始加載它了。等到圖片真正進(jìn)入視口大概率已經(jīng)加載完成用戶感知不到圖片突然出現(xiàn)的卡頓。這個(gè)200px不是一個(gè)固定值在網(wǎng)速較慢且圖片較多的場(chǎng)景可以調(diào)整為300px但也不能設(shè)太大——設(shè)置太大等于提前加載了大量還沒(méi)看到的圖片性能優(yōu)化就失真了。threshold我設(shè)為0.01元素哪怕只有1%進(jìn)入視口就觸發(fā)。如果設(shè)為0在某些瀏覽器里會(huì)有邊界情況——元素剛好在視口邊緣但沒(méi)有實(shí)際露出時(shí)可能不觸發(fā)所以用0.01來(lái)規(guī)避。unobserve一定要調(diào)用。圖片一旦開(kāi)始加載就不需要繼續(xù)監(jiān)聽(tīng)它的位置變化了不加這行的話觀察器會(huì)一直持有這些元素引用滾動(dòng)過(guò)程中還會(huì)反復(fù)計(jì)算交叉狀態(tài)白消耗性能。4.3 避免布局抖動(dòng)的占位方案與圖片渲染細(xì)節(jié)圖片懶加載最大的隱藏坑是布局抖動(dòng)導(dǎo)致的CLS指標(biāo)惡化。普通圖片渲染有幾個(gè)階段HTML解析到img標(biāo)簽時(shí)只知道寬高屬性圖片資源加載完成后瀏覽器需要用實(shí)際尺寸重新布局。如果你沒(méi)有預(yù)先為圖片占位置加載完成后頁(yè)面高度突然增加下面的內(nèi)容全部往下跳用戶正在閱讀的位置就會(huì)被頂走——這個(gè)體驗(yàn)非常糟糕。解決方案有兩個(gè)CSS預(yù)設(shè)寬高比和占位背景色。CSS預(yù)設(shè)寬高比現(xiàn)在最優(yōu)雅的實(shí)現(xiàn)是aspect-ratio屬性它讓元素在圖片加載前就占據(jù)和最終渲染尺寸接近的空間.lazy-img { width: 100%; aspect-ratio: 16 / 9; /* 圖片寬高比從設(shè)計(jì)稿或接口數(shù)據(jù)中獲取 */ object-fit: cover; /* 裁剪而不是拉伸保證視覺(jué)不扭曲 */ background-color: #f0f0f0; /* 加載前的占位底色弱化空白感 */ }用aspect-ratio的好處是高度在CSS計(jì)算階段就已經(jīng)確定瀏覽器不需要等圖片加載完成再做二次布局。不過(guò)前提是你得知道圖片的寬高比。如果是CMS后臺(tái)隨便上傳的圖片建議在服務(wù)端做一次圖片信息解析把寬高比下發(fā)給前端如果是固定尺寸的封面圖直接用固定比例就行。實(shí)在拿不到比例的情況就給一個(gè)min-height的保底值配合背景色兜底。還有一個(gè)細(xì)節(jié)是不要給懶加載圖片加過(guò)渡動(dòng)畫(huà)。很多人喜歡給圖片加一個(gè)淡入效果opacity從0到1但opacity動(dòng)畫(huà)本身會(huì)觸發(fā)合成層創(chuàng)建如果頁(yè)面上同時(shí)有幾十張圖片在滾動(dòng)中淡入性能開(kāi)銷反而大。content-visibility屬性其實(shí)是一個(gè)更好用的方案——它可以讓瀏覽器跳過(guò)屏幕外元素的渲染工作但兼容性目前還有限在團(tuán)隊(duì)技術(shù)棧允許的情況下可以作為補(bǔ)充手段。4.4 加載失敗兜底與網(wǎng)絡(luò)切換場(chǎng)景處理真實(shí)網(wǎng)絡(luò)環(huán)境比開(kāi)發(fā)環(huán)境復(fù)雜得多。3G弱網(wǎng)、4G信號(hào)切換、Wi-Fi斷連這些都會(huì)導(dǎo)致圖片加載失敗。如果不做兜底頁(yè)面上會(huì)出現(xiàn)一片破圖用戶感知尤其差。我的兜底方案分了三層第一層是loading屬性給所有圖片加上loadinglazy作為瀏覽器的原生兜底?,F(xiàn)代瀏覽器已經(jīng)原生支持懶加載即便沒(méi)有引入任何JavaScript代碼loadinglazy也能讓瀏覽器自動(dòng)推遲屏幕外圖片的加載。但注意原生loadinglazy的觸發(fā)時(shí)機(jī)由瀏覽器決定開(kāi)發(fā)者無(wú)法精細(xì)控制提前量也沒(méi)有失敗回調(diào)所以它適合做兜底不適合做主方案。第二層是上面代碼里的error回調(diào)圖片加載失敗時(shí)替換為一張極小體積的1x1透明GIF占位圖避免破圖圖標(biāo)顯示。同時(shí)給圖片加一個(gè)load-error類方便后續(xù)通過(guò)樣式表現(xiàn)加載失敗狀態(tài)。第三層是網(wǎng)絡(luò)狀態(tài)變化后的手動(dòng)重試機(jī)制。監(jiān)聽(tīng)navigator.onLine和online事件網(wǎng)絡(luò)恢復(fù)后重新掃描頁(yè)面上還有哪些>const LoginPage React.lazy(() import(./pages/Login)); const DashboardPage React.lazy(() import(./pages/Dashboard)); const UserManagePage React.lazy(() import(./pages/UserManage)); function RouterConfig() { return ( Suspense fallback{PageSkeleton /} Routes Route path/login element{LoginPage /} / Route path/dashboard element{DashboardPage /} / Route path/users element{UserManagePage /} / /Routes /Suspense ); }Vue Router則更簡(jiǎn)單直接在路由配置里用動(dòng)態(tài)import返回組件即可const routes [ { path: /dashboard, component: () import(./views/Dashboard.vue), meta: { title: 工作臺(tái) } } ];代碼分割的核心收益不只是首屏體積變小還有一個(gè)隱藏收益是緩存命中率變高。業(yè)務(wù)代碼和公共庫(kù)代碼分開(kāi)打包后公共庫(kù)的hash幾乎不變用戶第二次訪問(wèn)其他頁(yè)面時(shí)公共庫(kù)直接命中緩存只需要下載業(yè)務(wù)chunk即可體驗(yàn)提升非常明顯。5.2 移動(dòng)端啟動(dòng)性能優(yōu)化中的異步加載應(yīng)用異步加載在移動(dòng)端的重要性比Web更突出因?yàn)橐苿?dòng)端設(shè)備的主線程資源更緊張而且用戶對(duì)啟動(dòng)速度的要求更高——從點(diǎn)擊圖標(biāo)到看到首幀超過(guò)兩秒用戶就會(huì)開(kāi)始不耐煩。Android端啟動(dòng)優(yōu)化的核心矛盾是Application的onCreate里有大量初始化任務(wù)SDK初始化、數(shù)據(jù)庫(kù)創(chuàng)建、緩存預(yù)加載、埋點(diǎn)模塊啟動(dòng)這些任務(wù)如果全部同步執(zhí)行在onCreate里啟動(dòng)耗時(shí)會(huì)被拉得很長(zhǎng)。主流方案包括兩類第一類是啟動(dòng)器框架如AndroidX StartUp。它的設(shè)計(jì)思想是把初始化任務(wù)抽象成一個(gè)一個(gè)有依賴關(guān)系的Task框架根據(jù)依賴關(guān)系構(gòu)建一個(gè)有向無(wú)環(huán)圖在沒(méi)有依賴關(guān)系的Task之間并行執(zhí)行有依賴關(guān)系的Task保持順序執(zhí)行。這本質(zhì)上就是一種異步加載——把原本串行、阻塞、全部擠在主線程的初始化流程改成了并行、按需、可延后的調(diào)度流程。第二類是更細(xì)力度的任務(wù)延后。那些非首屏必需的初始化比如推送SDK初始化、地理位置獲取、日志上傳可以放到首幀渲染完成之后再啟動(dòng)。啟動(dòng)耗時(shí)被首幀之前這個(gè)窗口嚴(yán)格約束首幀之前的任務(wù)越少啟動(dòng)速度越快。任務(wù)可以細(xì)分為必須在主線程、可以在子線程、必須首幀前、可以首幀后四類逐一打標(biāo)簽后重新編排。這里有一個(gè)常見(jiàn)的坑子線程初始化看起來(lái)是異步了但如果多個(gè)子線程都去訪問(wèn)同一個(gè)共享資源比如同一個(gè)SQLite數(shù)據(jù)庫(kù)或者都在做大量CPU計(jì)算線程之間的鎖競(jìng)爭(zhēng)和CPU爭(zhēng)搶反而會(huì)讓主線程更卡。所以異步不意味著無(wú)腦開(kāi)線程合理的做法是控制線程數(shù)盡量合并同類任務(wù)避免線程顛簸。5.3 前端框架中的異步組件與副作用處理在React和Vue之外跨端框架和小程序框架里也能做異步加載只是方式要跟著框架走。Taro/uni-app這類跨端框架頁(yè)面本身是按路由拆分的但頁(yè)面內(nèi)部仍可以通過(guò)動(dòng)態(tài)import把模塊級(jí)的大依賴拆出去。比如頁(yè)面里有一個(gè)圖表組件用了ECharts這個(gè)圖表組件體積很大但只出現(xiàn)在頁(yè)面底部那就可以把它單獨(dú)拆出來(lái)等用戶滾動(dòng)到底部時(shí)再加載。異步組件的加載過(guò)程里還有一個(gè)容易被忽視的副作用——狀態(tài)丟失。如果你在組件加載完成前用戶就已經(jīng)開(kāi)始了某個(gè)交互操作等組件加載完成后這個(gè)交互狀態(tài)可能和組件的初始化狀態(tài)沖突。比如用戶在Modal打開(kāi)前就點(diǎn)擊了確認(rèn)按鈕而承載確認(rèn)邏輯的異步組件還沒(méi)加載完點(diǎn)擊事件就丟了。所以異步組件場(chǎng)景下要做的事不只是加載組件還要處理加載期間的交互事件緩沖和重放。如果這個(gè)組件承載了關(guān)鍵業(yè)務(wù)流程我會(huì)傾向于把它的加載時(shí)機(jī)提前而不是極限延后——性能優(yōu)化不能以犧牲體驗(yàn)一致性為代價(jià)。5.4 長(zhǎng)列表的虛擬滾動(dòng)與異步渲染結(jié)合長(zhǎng)列表場(chǎng)景比如信息流、聊天記錄、商城商品列表一次性渲染幾百上千個(gè)DOM節(jié)點(diǎn)即使所有資源都本地已有渲染本身也會(huì)卡頓。虛擬滾動(dòng)是長(zhǎng)列表性能問(wèn)題的標(biāo)配解法只渲染可視區(qū)域內(nèi)的元素其他區(qū)域留空占位。這和懶加載的邏輯一脈相承——都是不渲染、不加載當(dāng)前看不到的東西。虛擬滾動(dòng)的實(shí)現(xiàn)邏輯比圖片懶加載復(fù)雜一些需要計(jì)算可視區(qū)域內(nèi)顯示哪些行根據(jù)滾動(dòng)位置實(shí)時(shí)更新。成熟的庫(kù)有react-window、vue-virtual-scroller等也可以自己實(shí)現(xiàn)。自己實(shí)現(xiàn)的關(guān)鍵參數(shù)是預(yù)估行高——如果每條內(nèi)容的行高是固定的計(jì)算很簡(jiǎn)單如果高度不固定比如文本長(zhǎng)短不一就需要預(yù)估行高加上渲染后的實(shí)際高度校準(zhǔn)。校準(zhǔn)過(guò)程中上滑或下滑時(shí)的滾動(dòng)條跳動(dòng)是高頻問(wèn)題一般通過(guò)給未渲染區(qū)域預(yù)設(shè)一個(gè)估算高度來(lái)規(guī)避。和異步渲染結(jié)合的方式是每條列表項(xiàng)內(nèi)部如果有圖片或按鈕等資源仍然可以配合懶加載或按需加載策略。列表項(xiàng)滾動(dòng)進(jìn)入視口時(shí)才加載其內(nèi)部資源列表項(xiàng)滾出視口就銷毀或回收其DOM結(jié)構(gòu)。這樣疊加之后長(zhǎng)列表才能做到萬(wàn)條數(shù)據(jù)也不卡的流暢度。6. 異步加載的常見(jiàn)問(wèn)題與性能排查實(shí)錄這部分是我在實(shí)際項(xiàng)目里踩過(guò)、也在團(tuán)隊(duì)里手把手帶人排查過(guò)的典型問(wèn)題。按問(wèn)題現(xiàn)象、排查思路、最終解決三列整理成速查表。6.1 高頻問(wèn)題速查表問(wèn)題現(xiàn)象可能原因解決方案懶加載圖片加載前出現(xiàn)布局跳動(dòng)圖片容器未設(shè)置寬高比使用aspect-ratio或固定寬高預(yù)留占位懶加載后LCP指標(biāo)反而變差首屏LCP元素也被加了懶加載對(duì)LCP元素禁用懶加載改為立即加載異步組件加載后頁(yè)面白屏/報(bào)錯(cuò)動(dòng)態(tài)import的模塊加載失敗或路徑錯(cuò)誤檢查打包輸出的chunk路徑配置webpackChunkName加ErrorBoundary滾動(dòng)頁(yè)面卡頓、掉幀scroll事件監(jiān)聽(tīng)回調(diào)里做了getBoundingClientRect或大量DOM操作改為IntersectionObserver如必須監(jiān)聽(tīng)scroll用requestAnimationFrame節(jié)流大量異步請(qǐng)求同時(shí)發(fā)起導(dǎo)致接口排隊(duì)?wèi)屑虞d組件一次性全部觸發(fā)控制并發(fā)數(shù)重要資源優(yōu)先加載其他進(jìn)入延遲隊(duì)列使用原生loadinglazy后圖片始終不加載圖片在首屏或根元素設(shè)置了0高度檢查圖片父容器高度是否為0或改用IntersectionObserver方案Android啟動(dòng)時(shí)子線程初始化導(dǎo)致主線程卡頓子線程任務(wù)過(guò)多且爭(zhēng)用共享資源統(tǒng)一用StartUp框架管理任務(wù)依賴與調(diào)度控制線程池?cái)?shù)量異步加載了公共庫(kù)導(dǎo)致重復(fù)加載多個(gè)chunk都打包了同一份公共代碼配置依賴自動(dòng)拆分splitChunks或manualChunks抽取公共依賴為單獨(dú)chunk6.2 疑難問(wèn)題排查方法論與案例復(fù)盤(pán)這里挑一個(gè)最典型的案例復(fù)盤(pán)。我負(fù)責(zé)過(guò)的一個(gè)H5活動(dòng)頁(yè)優(yōu)化前加載很流暢做完異步加載改造之后反倒變卡了。排查時(shí)先用Performance面板做了錄制發(fā)現(xiàn)主線程被一處占用了很久的任務(wù)給堵住了。追下去才發(fā)現(xiàn)異步加載的組件包里不小心把ECharts也打了進(jìn)去而ECharts初始化時(shí)會(huì)去解析大量主題配置和series配置這一執(zhí)行就是300ms的長(zhǎng)任務(wù)。組件是異步了但組件內(nèi)部的初始化邏輯卻扔在了主線程上執(zhí)行——異步加載只解決了下載時(shí)機(jī)沒(méi)解決執(zhí)行開(kāi)銷。這個(gè)案例給了兩個(gè)非常重要的結(jié)論第一異步加載之后一定要復(fù)查異步組件內(nèi)部的初始化邏輯。組件的JavaScript下載和執(zhí)行本身就是兩件事下載是網(wǎng)絡(luò)層面的事執(zhí)行是主線程層面的事。下載異步了但執(zhí)行時(shí)如果做了大量同步計(jì)算照樣會(huì)卡住主線程。遇到這類情況應(yīng)該把ECharts這類重組件的初始化也改為異步渲染或者Worker內(nèi)計(jì)算至少也要把初始化邏輯放在空閑時(shí)間執(zhí)行。第二異步加載的效果要通過(guò)性能數(shù)據(jù)和用戶反饋雙重驗(yàn)證。我后來(lái)在優(yōu)化前后分別采集了FCP、LCP、TBT三個(gè)指標(biāo)用數(shù)據(jù)確認(rèn)了優(yōu)化方向是對(duì)的。只憑感覺(jué)變快了來(lái)評(píng)估性能優(yōu)化很容易被錯(cuò)覺(jué)誤導(dǎo)。還有一個(gè)輔助手段是錄制用戶操作軌跡回放時(shí)注意觀察滾動(dòng)過(guò)程有沒(méi)有卡頓掉幀這種主觀感受配合客觀數(shù)據(jù)才能準(zhǔn)確評(píng)估優(yōu)化是否成功。6.3 異步加載的邊界不要為了異步而異步聊了這么多還是要潑一盆冷水。異步加載不是越徹底越好它有一個(gè)合理邊界。判斷依據(jù)很簡(jiǎn)單用戶在這個(gè)時(shí)間點(diǎn)會(huì)不會(huì)用到這個(gè)東西如果用戶登錄后第一屏就是數(shù)據(jù)看板而看板要依賴的核心數(shù)據(jù)請(qǐng)求被你異步到了空閑期才發(fā)那首屏就是一片空白等待——這就是異步用過(guò)頭了。同理如果某個(gè)異步組件承載的是用戶最核心的操作入口把它延后加載就需要權(quán)衡是縮短首屏加載時(shí)間重要還是保證用戶立刻能用核心功能重要另外不是所有東西都適合拆分。有些模塊雖然體積大但被多個(gè)路由共享比如公共的UI組件庫(kù)、請(qǐng)求庫(kù)、工具函數(shù)這樣的模塊強(qiáng)制拆分成異步chunk反而會(huì)導(dǎo)致每個(gè)頁(yè)面都要重新請(qǐng)求一份拷貝。這種情況下應(yīng)該把它打進(jìn)公共依賴chunk利用瀏覽器緩存長(zhǎng)期復(fù)用。代碼分割的正確粒度應(yīng)該是模塊訪問(wèn)頻率低且獨(dú)立而不是純粹按文件大小切。我做性能優(yōu)化這么長(zhǎng)時(shí)間體會(huì)最深的一點(diǎn)是性能優(yōu)化的本質(zhì)是理解用戶行為把資源花在用戶最需要的地方。異步加載只是一個(gè)手段不是目的。真正合理的架構(gòu)不會(huì)為了懶加載把所有圖片全部加loadinglazy也不會(huì)為了讓首屏更快就把所有代碼全部拆成異步。它是在理解用戶的訪問(wèn)路徑、理解資源的依賴關(guān)系之后做出的一個(gè)全局最優(yōu)解。7. 我沉淀的一套異步加載自檢清單按我以前帶團(tuán)隊(duì)的做法每次做完全站性能優(yōu)化Review之后會(huì)逐個(gè)頁(yè)面跑一遍自檢清單。現(xiàn)在把它也分享出來(lái)可以作為你項(xiàng)目里異步加載優(yōu)化的驗(yàn)收標(biāo)準(zhǔn)。[ ] 首屏LCP元素是否被誤加了懶加載LCP圖片必須顯式設(shè)置fetchpriorityhigh且立即加載。[ ] 所有懶加載圖片是否預(yù)留了寬高比能否在沒(méi)有網(wǎng)絡(luò)的情況下復(fù)現(xiàn)布局跳動(dòng)[ ] 路由級(jí)代碼分割是否執(zhí)行了首屏JS體積相對(duì)優(yōu)化前下降了多少[ ] 異步組件是否有合理的加載失敗兜底是否有ErrorBoundary包裹[ ] 公共依賴是否被正確抽取而不是被打進(jìn)多個(gè)chunk[ ] 長(zhǎng)列表場(chǎng)景是否使用了虛擬滾動(dòng)而不是一次性渲染全部DOM[ ] 非關(guān)鍵腳本是否使用了defer/async統(tǒng)計(jì)腳本是否放到了頁(yè)面底部[ ] 有沒(méi)有做過(guò)優(yōu)化前后的Lighthouse對(duì)比FCP/LCP/TBT是否都有改善[ ] 移動(dòng)端啟動(dòng)階段Application.onCreate里是否還有可延后或可切換子線程的初始化任務(wù)[ ] 異步任務(wù)之間是否存在共享資源競(jìng)爭(zhēng)線程或Worker數(shù)量是否合理這套清單我每次性能復(fù)盤(pán)都會(huì)過(guò)一遍能過(guò)濾掉大部分低級(jí)失誤。它不復(fù)雜但每一條都來(lái)自實(shí)際生產(chǎn)環(huán)境里的教訓(xùn)。異步加載這個(gè)主題看起來(lái)是單個(gè)技術(shù)點(diǎn)但做深了會(huì)發(fā)現(xiàn)它其實(shí)橫跨網(wǎng)絡(luò)加載、渲染機(jī)制、線程調(diào)度、框架設(shè)計(jì)多個(gè)層面是一個(gè)值得長(zhǎng)期深耕的方向。我建議你把今天聊到的幾個(gè)方案資源異步、代碼分割、任務(wù)調(diào)度、移動(dòng)端啟動(dòng)優(yōu)化在自己的項(xiàng)目里各找一個(gè)場(chǎng)景試著落地然后拿數(shù)據(jù)說(shuō)話再對(duì)比優(yōu)化前后的指標(biāo)變化。這個(gè)過(guò)程走完一遍你對(duì)異步加載和性能優(yōu)化的理解會(huì)比你看十篇文章都深。