化:異步加載的底層原理與工程實踐)
1. 為什么現(xiàn)在的頁面性能問題幾乎都出在加載這一環(huán)做了這么多年前端我越來越覺得一個現(xiàn)象很有意思絕大多數(shù)用戶眼中的卡其實根本不是頁面跑起來之后的計算卡頓而是內(nèi)容半天出不來、點了按鈕沒反應(yīng)、滾動的時候圖片一張張蹦出來這種加載型卡頓。尤其是移動端網(wǎng)絡(luò)環(huán)境波動大、設(shè)備性能參差不齊加載環(huán)節(jié)稍微處理不好用戶兩秒內(nèi)就關(guān)頁面走人了。我自己的實測數(shù)據(jù)是一個頁面從3秒優(yōu)化到1.5秒跳出率能降差不多20個百分點這個收益任何業(yè)務(wù)場景都值得認真對待。異步加載這個概念核心要解決的其實就是三件事減少首屏必須等待的資源、把非關(guān)鍵資源的加載時間挪到空閑時段、避免單個資源的失敗拖垮整條鏈路。聽起來不復雜但真正落地的時候很多人會踩坑比如代碼分割之后導致重復請求、懶加載觸發(fā)時機不對導致首屏反而變慢、預加載用過頭導致帶寬被占滿。這些問題的根源往往不是某一個工具用得不對而是對整個加載過程缺少清晰的心理模型。這篇內(nèi)容我打算從一個真實的優(yōu)化框架來講先理清瀏覽器的加載機制和異步原理再講具體落地手段defer、async、代碼分割、懶加載、預加載然后配合一套可量化的測量方法最后把移動端和App啟動優(yōu)化的思路一并帶上。適合的人群是有一定前端基礎(chǔ)、想把性能優(yōu)化從玄學變成工程方法的開發(fā)者。不管你是寫業(yè)務(wù)頁面還是做基建框架這套思路都通用。在展開細節(jié)之前先提供一個核心認知異步加載不是把代碼寫異步這么簡單而是要從資源調(diào)度層面重新思考整個頁面加載的時間線。瀏覽器從拿到HTML到頁面可交互中間每一毫秒都有優(yōu)化的空間只是大多數(shù)人只盯著接口耗時忽略了自己能掌控的一部分。2. 異步加載的底層運行邏輯2.1 瀏覽器加載頁面的完整時間線先把時間線拉出來看一遍。用戶在地址欄輸入網(wǎng)址按下回車之后瀏覽器大致經(jīng)歷這幾個階段DNS解析、TCP連接、TLS握手、發(fā)送HTTP請求、收到HTML、解析HTML、構(gòu)建DOM樹、解析CSS構(gòu)建CSSOM、執(zhí)行JavaScript、合成布局、繪制。這里大多數(shù)開發(fā)者對解析HTML的理解過于簡化了。實際上瀏覽器解析HTML是一個邊下載邊解析的流式過程。遇到script標簽時默認行為是立即下載并執(zhí)行這會阻塞解析器——也就是說后面的HTML即便已經(jīng)下載到本地也不能繼續(xù)生成DOM。這就是最原始的同步加載模型在十年前頁面普遍簡單的時候問題不大但今天的頁面動輒幾十個腳本同步加載直接導致首屏被無限拉長?;谶@個機制性能優(yōu)化的核心思路就變成了減少阻塞解析器的時間。而實現(xiàn)手段就是異步加載。具體來說有兩種常見方式一是把腳本標記為defer或async二是把資源放到頁面底部或通過動態(tài)注入的方式加載。兩者背后的邏輯都是不讓我等你你下載好了再說。2.2 事件循環(huán)、宏任務(wù)與微任務(wù)異步的底層心法很多初學者以為異步就是代碼同時執(zhí)行這個理解需要修正。JavaScript是單線程語言異步的本質(zhì)是先把任務(wù)掛起等滿足條件后回到主線程執(zhí)行。瀏覽器有一套事件循環(huán)機制在調(diào)度這一切主線程執(zhí)行完當前宏任務(wù)之后會清空微任務(wù)隊列Promise的回調(diào)就在這里執(zhí)行然后才從宏任務(wù)隊列里取下一個任務(wù)setTimeout、事件回調(diào)、IO回調(diào)等都屬于宏任務(wù)。這套機制和性能優(yōu)化有什么關(guān)系關(guān)系太大了。如果一段異步回調(diào)里做了大量同步計算它依然會阻塞主線程因為異步不等于輕量。我見過不少團隊把重計算塞進Promise里就以為是異步優(yōu)化了結(jié)果頁面依然卡原因就是異步只改變了執(zhí)行時機沒有改變執(zhí)行代價。在資源加載層面瀏覽器的網(wǎng)絡(luò)進程是多線程的同一域名下一般允許6個左右的并發(fā)連接。這意味著資源請求是真正并行的但這個并行度有限。如果你首屏同時發(fā)起30個資源請求它們會在隊列里排隊關(guān)鍵資源反而不一定最先到達。異步加載策略必須考慮這種并發(fā)限制合理規(guī)劃哪些資源優(yōu)先、哪些延后。2.3 渲染進程與網(wǎng)絡(luò)進程的分工現(xiàn)代瀏覽器普遍采用多進程架構(gòu)網(wǎng)絡(luò)進程負責下載資源渲染進程負責解析、布局、繪制和JavaScript執(zhí)行。這兩個進程之間通過IPC通信。理解這個分工對異步加載很有幫助——資源下載可以完全獨立于渲染進行所以你完全可以在渲染空閑時提前把后面可能用到的資源下載好這就是預加載和預獲取的理論基礎(chǔ)。不過有一個很容易被忽視的細節(jié)雖然下載不阻塞渲染但資源到達渲染進程后解析和執(zhí)行依然會阻塞主線程。所以異步加載策略要分兩層考慮第一層是網(wǎng)絡(luò)層面讓資源早點到或晚點到第二層是執(zhí)行層面讓腳本晚點跑或分片跑。兩者配合才是完整的優(yōu)化方案。3. 四類常用的異步加載落地手段3.1 defer與async腳本加載的首選方案在HTML中給script加上defer或async屬性是最基礎(chǔ)的異步加載手段。兩者區(qū)別經(jīng)常有人混淆我用一句話總結(jié)async是下載完就執(zhí)行defer是下載完等DOM解析完再執(zhí)行。!-- 立即下載不阻塞解析下載完立即執(zhí)行會中斷解析 -- script async srcanalytics.js/script !-- 立即下載不阻塞解析下載完等待DOM解析完成后執(zhí)行 -- script defer srcapp.js/script具體選哪個要看場景。第三方統(tǒng)計腳本、廣告腳本互相沒有依賴關(guān)系適合用async它不保證執(zhí)行順序誰先下載完誰先執(zhí)行但也無所謂。業(yè)務(wù)代碼之間有依賴關(guān)系必須保持執(zhí)行順序或者要等DOM就緒之后操作就用defer。defer還有一個好處執(zhí)行時機在DOMContentLoaded事件之前這意味著你可以在腳本里安全地操作完整DOM。我自己的實踐習慣是凡是業(yè)務(wù)相關(guān)腳本一律defer凡是第三方獨立腳本一律async能放底部的同時加上async也完全沒問題。不過defer有一個兼容性細節(jié)defer對src外鏈腳本有效對內(nèi)聯(lián)腳本無效——內(nèi)聯(lián)腳本沒有下載過程它的執(zhí)行還是同步的。如果遇到必須內(nèi)聯(lián)又不想阻塞的情況就需要用動態(tài)創(chuàng)建script標簽的方式了。3.2 動態(tài)import與代碼分割按需加載的工程化實踐現(xiàn)代前端工程化的主流做法是使用import()語法實現(xiàn)動態(tài)加載。注意這里的動態(tài)import和ES模塊的靜態(tài)import是完全不同的概念。靜態(tài)import在編譯期就把模塊關(guān)系確定了打包工具會把代碼打進同一個chunk動態(tài)import()是運行時執(zhí)行的返回一個PromiseWebpack、Vite等工具會將對應(yīng)的代碼單獨打成chunk觸發(fā)加載時才去請求。// 靜態(tài)導入打包時合并到當前chunk import { initReport } from ./modules/report; // 動態(tài)導入打包時單獨分包點擊/路由切換時才加載 button.addEventListener(click, async () { const { initReport } await import(./modules/report); initReport(); });這個機制的價值在于首屏只加載當前路由或首屏真正需要執(zhí)行的代碼其余代碼推遲到實際需要時再拉取。比如一個管理后臺用戶可能永遠不打開報表選項卡那報表相關(guān)的代碼干嘛要在首屏加載呢動態(tài)導入就是解決這個問題的標準答案。使用動態(tài)導入有一個工程上的坑——模塊體積過小會導致分包碎片化。如果你把每個工具函數(shù)都動態(tài)導入會產(chǎn)生大量幾十KB甚至幾KB的chunkHTTP請求數(shù)量暴漲反而拖累性能。Webpack有optimization.splitChunks配置可以合并小模塊Vite底層也有類似機制。我一般建議按路由或按業(yè)務(wù)模塊動態(tài)導入粒度控制在100KB以上才值得單獨分包太小的合并到公共chunk更劃算。3.3 懶加載與占位策略圖片和組件的延遲渲染懶加載是異步加載的另一大主戰(zhàn)場最常見的場景就是圖片。一個電商列表頁可能有上百張商品圖如果全部在首屏加載帶寬瞬間被占滿LCPLargest Contentful Paint最大內(nèi)容繪制會差得離譜。圖片懶加載的標準做法是先用占位符占據(jù)空間等圖片即將進入視口時再加載真實地址。現(xiàn)代瀏覽器原生支持loadinglazy屬性這是最輕量的方案img srcplaceholder.jpg>const observer new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; observer.unobserve(img); } }); }, { rootMargin: 200px 0px }); // 提前200px開始加載 document.querySelectorAll(img[data-src]).forEach(img observer.observe(img));rootMargin: 200px 0px的作用是預加載提前量用戶還沒看到圖片但圖片距視口只有200px時就開始加載體感上無縫銜接。這個值不要設(shè)得太大否則就失去了懶加載的意義也不要設(shè)太小否則用戶滾動到圖片位置時還是能看到白框閃一下的過程。200px左右是經(jīng)驗值在主流瀏覽器上表現(xiàn)都不錯。組件懶加載的原理也類似在React中常用React.lazy配合Suspense在Vue中可以使用異步組件defineAsyncComponent。核心都是基于動態(tài)import()只是封裝成了框架層API。3.4 預加載與預連接空閑時段的主動出擊異步加載不只是晚點加載還包含提前加載。瀏覽器提供了一組資源提示其中最常用的是preload、preconnect和prefetch。preload告訴瀏覽器這個資源當前頁面立刻需要請優(yōu)先下載。適合首屏關(guān)鍵但發(fā)現(xiàn)得晚的資源比如字體文件link relpreload hreffont.woff2 asfont typefont/woff2 crossoriginpreconnect提前建立與第三方域的連接DNS解析TCPTLS適合已知要請求外部API或第三方CDN的場景l(fā)ink relpreconnect hrefhttps://cdn.example.comprefetch則是空閑時下載下一個頁面/功能可能用到的資源比如用戶停留在登錄頁時提前下載主頁的核心JSlink relprefetch href/home/index.js這里有一個很重要的忠告預加載不是越多越好。preload用多了會變成把未來的問題提前到現(xiàn)在在低端設(shè)備或弱網(wǎng)環(huán)境下反而拉低首屏速度。我一般制定這樣的策略首屏關(guān)鍵字體和CSS用preload已知第三方請求域名用preconnect下一個交互場景的資源用prefetch未來頁面用prefetchVanilla式其余一律不額外預取。資源提示和普通加載的區(qū)別在于它不阻塞當前渲染只是提前占用網(wǎng)絡(luò)所以需要你站在用戶角度想清楚什么值得提前。4. 性能優(yōu)化的測量閉環(huán)與優(yōu)化前后對比4.1 指標先行從FCP到LCP的性能標尺聊優(yōu)化之前得先說清楚怎么衡量優(yōu)化效果。性能優(yōu)化領(lǐng)域有幾個全球通行的核心指標我畫了張快速對照表指標全稱含義可感知體驗FCPFirst Contentful Paint首次內(nèi)容繪制白屏結(jié)束、第一次出現(xiàn)文字或圖片LCPLargest Contentful Paint最大內(nèi)容繪制首屏最大元素通常是圖片或標題出現(xiàn)TTITime to Interactive可交互時間頁面能可靠響應(yīng)用戶操作TBTTotal Blocking Time總阻塞時間主線程被長任務(wù)阻塞的總時長CLSCumulative Layout Shift累計布局偏移頁面元素跳動程度異步加載主要影響的就是FCP和LCP而腳本執(zhí)行優(yōu)化則決定TTI和TBT。如果你的頁面LCP在2.5秒以上移動端參考值優(yōu)先看是不是首屏關(guān)鍵資源被過多的非關(guān)鍵腳本拖累了這就是異步加載發(fā)揮價值的地方。4.2 用Performance API做現(xiàn)場測量瀏覽器Performance API可以直接在頁面里拿到各類時間點這對做自動化性能監(jiān)控特別有用// 拿到關(guān)鍵時間點 const nav performance.getEntriesByType(navigation)[0]; console.log(TTFB:, nav.responseStart - nav.requestStart); console.log(DOMContentLoaded:, nav.domContentLoadedEventEnd - nav.requestStart); console.log(Load:, nav.loadEventEnd - nav.requestStart); // 監(jiān)聽性能時間線 const paint performance.getEntriesByType(paint); paint.forEach(p console.log(p.name, p.startTime));LCP在PerformanceObserver里可以動態(tài)監(jiān)聽new PerformanceObserver((list) { const entries list.getEntries(); const lastEntry entries[entries.length - 1]; console.log(LCP:, lastEntry.startTime); }).observe({ type: largest-contentful-paint, buffered: true });實測中我習慣在開發(fā)環(huán)境用Chrome DevTools的Performance面板錄一段加載過程重點看主線程的長任務(wù)Long Task。任何超過50ms的任務(wù)都會導致用戶可感知的延遲而這些長任務(wù)絕大部分來自于同步腳本執(zhí)行。這就把優(yōu)化目標變得很明確把超過50ms的任務(wù)拆碎或者推遲到空閑時間執(zhí)行。4.3 一個實際案例的優(yōu)化前后對比拿我之前遇到的一個項目舉例是一個內(nèi)容型站點首屏包含大約3MB的JavaScript打包文件。優(yōu)化前的LCP在移動端中檔Android機、4G網(wǎng)絡(luò)測試是4.8秒TTI是7.2秒用戶基本要等六七秒才能正常操作。做了三件事第一用路由級代碼分割首屏只加載核心bundle從3MB減到1.1MB第二所有圖片開啟懶加載首屏只加載視口內(nèi)的5張第三把第三方統(tǒng)計腳本加上async并移除它在首屏的render-blocking標記。優(yōu)化后同一設(shè)備同一網(wǎng)絡(luò)環(huán)境下LCP降到2.3秒TTI降到3.9秒FCP從2.1秒降到1.2秒。沒有改任何業(yè)務(wù)邏輯純粹是資源調(diào)度策略的調(diào)整。這個案例最大的啟發(fā)是首屏JS體積減少不意味著總代碼量減少而是該等的資源不用等了??偞a量可能沒變但用戶感知發(fā)生了質(zhì)變。5. 場景延伸移動端與App啟動優(yōu)化5.1 移動端性能優(yōu)化的特殊約束移動端和桌面端的性能優(yōu)化有本質(zhì)差異。桌面端有穩(wěn)定的網(wǎng)絡(luò)和充足的內(nèi)存移動端則面臨兩個核心瓶頸網(wǎng)絡(luò)延遲更高、CPU/內(nèi)存更有限。根據(jù)實際統(tǒng)計中低端Android設(shè)備的CPU性能可能只有旗艦機的三分之一而JavaScript引擎的即時編譯能力差別更大。同一個異步加載方案在旗艦機上看不出差別在低端機上可能仍然明顯卡頓。移動端還要考慮耗電和流量。一個后臺無限重試的網(wǎng)絡(luò)請求可能讓用戶在不知情的情況下消耗大量流量。異步加載方案在移動端要額外關(guān)注減少不必要的網(wǎng)絡(luò)請求數(shù)、合并小請求、合理利用緩存。Service Worker配合Cache API可以做離線緩存這是移動端性能優(yōu)化的重要武器——第一次訪問后靜態(tài)資源都能從本地緩存加載二次訪問速度會驚人地快。5.2 Android啟動優(yōu)化的思路遷移Android應(yīng)用啟動過程和Web頁面加載本質(zhì)上遵循同一個邏輯啟動時間 必須要做的同步工作 可以推遲的異步工作。Android開發(fā)中常說的啟動優(yōu)化手段跟Web的異步加載思路驚人地相似。第一類是懶初始化Application的onCreate里只初始化真正必要的SDK其他SDK放到后臺線程或者等用到時再初始化。這對應(yīng)Web里把非關(guān)鍵腳本從首屏移除。第二類是啟動時分線程用ExecutorService把IO操作、數(shù)據(jù)庫預加載、圖片預解碼放到后臺線程主線程只做UI準備。第三類是啟動后延遲加載通過postOnIdle()或IdleHandler把任務(wù)推遲到主線程空閑時執(zhí)行對應(yīng)Web的requestIdleCallback。// Android主線程空閑時再執(zhí)行非關(guān)鍵初始化 Looper.myLooper()?.queue?.addIdleHandler { initUnimportantSDKs() false // false表示執(zhí)行完移除不重復執(zhí)行 }這種主線程優(yōu)先做關(guān)鍵事非關(guān)鍵任務(wù)塞進空閑時間的哲學橫跨Web和App開發(fā)完全通用。我做App性能優(yōu)化時經(jīng)常先把Web前端那套資源調(diào)度模型在腦子里過一遍在移動端場景直接套用只是API不同、網(wǎng)絡(luò)情況更復雜一點。5.3 如何應(yīng)對移動端弱網(wǎng)環(huán)境弱網(wǎng)是移動端異步加載最大的變數(shù)。一個經(jīng)驗不要假設(shè)用戶的網(wǎng)絡(luò)質(zhì)量和你辦公網(wǎng)絡(luò)一樣。我見過用WiFi測試一切正常、用戶用2G網(wǎng)絡(luò)打開白屏十秒的項目。弱網(wǎng)環(huán)境下的異步加載策略要注意幾個細節(jié)超時時間要合理設(shè)置不能無限等待加載失敗要有重試機制重試要有退避策略關(guān)鍵資源要有兜底方案比如圖片加載失敗顯示占位圖而不是爛圖考慮數(shù)據(jù)壓縮和接口合并減少網(wǎng)絡(luò)往返。還有一個容易被忽視的點——連接復用。HTTP/2和HTTP/3的多路復用能力能大幅減少握手開銷在移動端尤其明顯。如果你的服務(wù)端還停留在HTTP/1.1很多異步加載的優(yōu)化效果會大打折扣。我建議在條件允許時優(yōu)先升級到HTTP/2或HTTP/3這本身就是一種看不見的性能優(yōu)化。6. 常見問題與排查經(jīng)驗實錄6.1 異步加載后首屏反而變慢的怪現(xiàn)象這是最常見也是最詭異的問題明明做了懶加載和代碼分割首屏怎么還變慢了我排查過幾次之后發(fā)現(xiàn)多半是懶加載時機和資源提示打架。比如某張首屏大圖使用懶加載但另一處代碼里又preload了同一張圖結(jié)果瀏覽器按高優(yōu)先級把它提前拉取了懶加載白做了?;蛘邉討B(tài)import觸發(fā)的chunk體積過大首屏核心邏輯本身就包含了亟待加載的模塊一旦延遲反而讓關(guān)鍵路徑變長。解決方法是分清主次先建立資源優(yōu)先級清單。首屏必須渲染的內(nèi)容果斷用preload或高優(yōu)先級加載不要懶加載首屏不需要的內(nèi)容統(tǒng)一用懶加載或異步加載不要畫蛇添足。盡可能避免對同一資源既懶加載又預取。6.2 預加載失效字體文件與crossorigin屬性我在預加載字體時踩過一個隱蔽的坑preload字體文件必須加crossorigin屬性否則字體無法生效。具體原因是瀏覽器對字體請求默認發(fā)起跨域請求而preload請求本身默認是同源的兩者請求方式不一致導致緩存命中失敗。正確寫法link relpreload hreffont.woff2 asfont typefont/woff2 crossorigin這個細節(jié)如果不注意會出現(xiàn)明明preload了但字體還是文字閃一下變字體的FOUT現(xiàn)象。我在團隊內(nèi)部checklist里專門加了一條preload字體必須帶crossorigin。6.3 動態(tài)import的循環(huán)依賴與執(zhí)行順序動態(tài)import在復雜業(yè)務(wù)中容易引發(fā)循環(huán)依賴問題——A模塊動態(tài)導入BB又動態(tài)導入A。由于動態(tài)導入是運行時才執(zhí)行循環(huán)依賴不會被像靜態(tài)導入那樣在構(gòu)建期直接報錯而是運行時會拿到undefined然后報錯。排查思路是看網(wǎng)絡(luò)面板中這兩個chunk是否出現(xiàn)互相請求、循環(huán)等待的現(xiàn)象。解決辦法通常是提取公共部分為一個獨立模塊切斷循環(huán)鏈。另一個相關(guān)問題是執(zhí)行順序。defer保證執(zhí)行順序但動態(tài)import不保證。如果兩個chunk之間有依賴關(guān)系正確的做法是在同一個import()的Promise內(nèi)部順序引入而不是分別動態(tài)導入。// 錯誤兩個動態(tài)導入的執(zhí)行順序無保證 const modB await import(./b.js); const modA await import(./a.js); // 正確在同一個模塊內(nèi)通過靜態(tài)import再導出 const { b } await import(./b.js); const { a } await import(./a.js);6.4 常見問題速查表現(xiàn)象可能原因解決方案首屏白屏時間變長關(guān)鍵CSS/字體未preload對首屏關(guān)鍵資源加preload懶加載圖片閃白框rootMargin太小或占位尺寸不符增大提前量設(shè)置明確寬高第三方腳本拖慢加載大量同步腳本阻塞解析加async或defer或改用動態(tài)注入JS執(zhí)行卡頓單次任務(wù)超過50ms拆分任務(wù)、延遲非關(guān)鍵計算、使用Web Worker弱網(wǎng)下接口超時超時設(shè)置過長設(shè)置合理超時和重試退避策略預加載不生效請求屬性不匹配檢查是否缺少crossorigin等屬性分包過碎請求暴漲模塊拆分粒度太小用splitChunks合并小chunk或提高分包閾值6.5 我踩過的最有價值的一次坑最后分享一個印象最深的排查經(jīng)歷。有一次優(yōu)化一個后臺管理系統(tǒng)首屏腳本從3.2MB降到了800KB但用戶反饋打開速度沒感覺變快。我?guī)е蓡柸タ戳吮O(jiān)控數(shù)據(jù)發(fā)現(xiàn)FCP確實降低了不少但TTI幾乎沒變。原因是雖然代碼分割讓首屏腳本變小了但登錄頁里有一個遞歸組件被錯誤地放入了核心chunk這個組件第一次渲染時要執(zhí)行大量遞歸計算把主線程長時間占住導致頁面雖然畫出來了但其實不可交互。這次經(jīng)歷給我留下的教訓很深性能優(yōu)化不要只盯著資源加載還要盯著主線程任務(wù)。資源加載決定了內(nèi)容出現(xiàn)的速度但主線程是否空閑決定了用戶是否能操作。兩者缺一不可。后面我做性能方案都養(yǎng)成了一個習慣用Performance面板錄一段完整加載過程專門看有沒有超過50ms的長任務(wù)逐個處理收益非常直接。