戰(zhàn):前端性能優(yōu)化如何將首屏從3.2秒降到1.1秒)
“首屏加載 3.2 秒”這幾個(gè)字?jǐn)[在我面前的時(shí)候產(chǎn)品經(jīng)理表情是平靜的我心里是拔涼的。當(dāng)時(shí)的頁面不算重但打包出來一個(gè) 1.2MB 的整包 JS里面混著編輯器、圖表庫、日期組件首屏用到的其實(shí)只占五分之一。整輪優(yōu)化做下來沒上什么奇技淫巧核心思路就一個(gè)詞異步加載。把不是首屏必須的東西全部往后挪能晚加載就晚加載能分片加載就分片加載最終首屏壓到 1.1 秒。這篇文章把這些內(nèi)容整理成一套可以直接抄走的方案覆蓋基礎(chǔ)原理、常見手段、真實(shí)改造過程和排錯(cuò)技巧適合手里有頁面加載慢、白屏?xí)r間長、想做性能優(yōu)化又不知道從哪下手的同學(xué)。1. 異步加載到底在解決什么問題先不急著上工具把原理層的東西聊透。很多人用了很多年 async、defer、動(dòng)態(tài) import但你說不清它們到底改變了什么。性能優(yōu)化最怕的就是“照著別人的配置抄了一遍出了問題不知道怎么調(diào)”所以這一部分把異步加載的底層邏輯拆開講。1.1 瀏覽器解析 HTML 時(shí)的“卡殼”現(xiàn)象瀏覽器從服務(wù)器拿到 HTML 文檔之后渲染主線程會(huì)從頭到尾掃描并解析它。解析過程中遇到一個(gè)普通的script標(biāo)簽沒有加 async 或 defer瀏覽器會(huì)立刻停止解析 HTML先發(fā)送請求把這段腳本下載下來然后執(zhí)行完再繼續(xù)解析后面的內(nèi)容。這個(gè)行為叫“解析阻塞”Parser Blocking。為什么它是性能的大敵因?yàn)橄螺d是網(wǎng)絡(luò)操作執(zhí)行是 CPU 操作兩個(gè)都是耗時(shí)大戶。假如你有一個(gè) 400KB 的腳本放在head里在 3G 網(wǎng)絡(luò)下下載可能需要 2 秒這 2 秒里用戶看到的就是一個(gè)白屏頁面連第一行文字都渲染不出來。而頁面需要用戶盡快看到東西每一百毫秒的延遲都會(huì)讓用戶覺得“這個(gè)網(wǎng)站很慢”。可以打個(gè)比方你在廚房里做飯每切一個(gè)菜都要停下等幫廚把下一個(gè)食材遞到手上而且這個(gè)幫廚動(dòng)作很慢。明明你有五個(gè)菜要做大部分時(shí)間卻耗在等待上。異步加載想做的事情很簡單——把“等待”從主流程上摘出去讓做菜的流水線不要停。1.2 主線程單線程與渲染阻塞的必然性這里還有一個(gè)很多人忽略的問題為什么瀏覽器非要用同步的方式執(zhí)行腳本讓頁面卡住而不是“腳本慢慢下載我先渲染頁面”呢因?yàn)?JavaScript 可以修改 DOM 結(jié)構(gòu)比如document.write()可以直接往文檔里寫內(nèi)容也可以刪除節(jié)點(diǎn)、改樣式。如果瀏覽器一邊解析 HTML 一邊執(zhí)行腳本兩邊同時(shí)對 DOM 做操作狀態(tài)就會(huì)亂套。所以瀏覽器做了一個(gè)硬性規(guī)定解析和腳本執(zhí)行必須互斥主線程一次只能干一件事。這是保證頁面行為一致性的前提犧牲的就是“速度”。理解了這一點(diǎn)你就能明白異步加載的本質(zhì)它不是把腳本執(zhí)行的耗時(shí)抹掉了而是把腳本執(zhí)行的時(shí)機(jī)調(diào)度到“不需要阻塞渲染”的時(shí)間點(diǎn)上。比如 defer 會(huì)把腳本推遲到整個(gè)文檔解析完之后再執(zhí)行這樣首屏文本、圖片可以先出來用戶先看到東西腳本在后臺(tái)執(zhí)行再對頁面做增強(qiáng)。這是“感知性能”的巨大勝利。1.3 異步代碼范式的演進(jìn)從回調(diào)到 async/await異步加載不只是“腳本標(biāo)簽加屬性”做工程化的時(shí)候我們經(jīng)常需要?jiǎng)討B(tài)控制腳本的加載時(shí)機(jī)和依賴關(guān)系。這個(gè)過程繞不開 JavaScript 異步編程范式的演進(jìn)簡單捋一遍。最初是純回調(diào)。比如動(dòng)態(tài)加載一個(gè) SDKfunction loadScript(url, callback) { var script document.createElement(script); script.src url; script.onload callback; document.head.appendChild(script); } loadScript(sdk-a.js, function () { loadScript(sdk-b.js, function () { initApp(); }); });代碼不復(fù)雜但一旦 SDK 數(shù)量變多、依賴層級變深就進(jìn)入“回調(diào)地獄”很難維護(hù)。而且并行加載兩個(gè)腳本的時(shí)候必須手動(dòng)計(jì)數(shù)寫起來非常啰嗦。后來有了 Promise配合Promise.all可以實(shí)現(xiàn)并行加載并且統(tǒng)一處理結(jié)果function loadScript(url) { return new Promise(function (resolve, reject) { var script document.createElement(script); script.src url; script.onload resolve; script.onerror reject; document.head.appendChild(script); }); } Promise.all([loadScript(a.js), loadScript(b.js)]) .then(function () { return loadScript(c.js); }) .then(initApp) .catch(function () { console.error(腳本加載失敗); });再后來就是async/await代碼讀起來跟同步一樣直觀async function init() { await loadScript(a.js); await loadScript(b.js); render(); }這套演進(jìn)對性能優(yōu)化的意義在于你可以用非常清晰的方式控制“什么時(shí)候加載什么資源”而不是依賴文檔里 script 標(biāo)簽的排列順序。異步加載不是“內(nèi)容變少了”而是“命令的組織方式更可控了”。2. 常見異步加載方案與選型要點(diǎn)現(xiàn)在到了動(dòng)手環(huán)節(jié)。異步加載的手段非常多樣每一套都有它的適用場景和坑。這一節(jié)把主流的方案逐一說清楚并用選型視角告訴你在什么情況下選哪一種。2.1 script 標(biāo)簽的 async 與 defer這是最基礎(chǔ)、也是使用頻率最高的一對屬性。兩者都讓瀏覽器“邊解析 HTML 邊下載腳本”不阻塞解析但執(zhí)行時(shí)機(jī)差別很大。屬性下載時(shí)機(jī)執(zhí)行時(shí)機(jī)順序保證典型場景async邊解析邊下載下載完成后立即執(zhí)行不保證誰先下載完誰先執(zhí)行獨(dú)立統(tǒng)計(jì)腳本、廣告腳本不依賴其他資源defer邊解析邊下載文檔解析完成后、觸發(fā) DOMContentLoaded 之前按文檔中的順序執(zhí)行需要操作 DOM、依賴執(zhí)行順序的腳本注意幾個(gè)容易翻車的點(diǎn)。如果你有兩個(gè)腳本b.js 依賴 a.js 里聲明的函數(shù)用 async 就可能報(bào)錯(cuò)——因?yàn)?a.js 體積大、下載慢b.js 先下載完先執(zhí)行調(diào)用一個(gè)不存在的函數(shù)直接 ReferenceError。defer 就沒這個(gè)問題它保證按順序執(zhí)行。另外一個(gè)細(xì)節(jié)是多腳本場景下 defer 的執(zhí)行順序是文檔順序但它們會(huì)在DOMContentLoaded事件之前統(tǒng)一執(zhí)行。意味著你可以在“解析完但還沒觸發(fā)事件”的時(shí)候做初始化工作。這類腳本適合放業(yè)務(wù)代碼async 腳本適合埋點(diǎn)、監(jiān)控、AB 實(shí)驗(yàn)這類完全獨(dú)立、加載完跑一下就不管的東西。2.2 動(dòng)態(tài)腳本注入與按需加載有些場景下腳本不在 HTML 里而是用戶觸發(fā)某個(gè)動(dòng)作之后才需要。比如用戶點(diǎn)擊“幫助中心”按鈕之后才需要打開客服 SDK用戶把頁面滾動(dòng)到某個(gè)區(qū)塊才需要加載圖表渲染庫。這時(shí)候動(dòng)態(tài)注入腳本是更精準(zhǔn)的手段。基礎(chǔ)寫法一句話就能概括function loadScript(url) { return new Promise(function (resolve, reject) { var script document.createElement(script); script.src url; script.onload resolve; script.onerror reject; document.head.appendChild(script); }); }實(shí)現(xiàn)層面有兩個(gè)實(shí)際問題值得注意。第一是防重復(fù)加載如果用戶反復(fù)點(diǎn)擊按鈕腳本會(huì)被重復(fù)注入浪費(fèi)流量不說還會(huì)帶來重復(fù)執(zhí)行副作用。一般用一個(gè) Map 記錄已經(jīng)加載或正在加載的 URLvar loadingMap {}; function loadScript(url) { if (loadingMap[url]) { return loadingMap[url]; } loadingMap[url] new Promise(function (resolve, reject) { var script document.createElement(script); script.src url; script.onload resolve; script.onerror function () { delete loadingMap[url]; reject(new Error(加載失敗: url)); }; document.head.appendChild(script); }); return loadingMap[url]; }第二是錯(cuò)誤處理網(wǎng)絡(luò)抖動(dòng)、CDN 掛了都會(huì)導(dǎo)致加載失敗如果 Promise 沒有 catch 住控制臺(tái)會(huì)報(bào) Unhandled Promise Rejection。生產(chǎn)環(huán)境里最好在 catch 里給用戶一個(gè)輕提示同時(shí)允許重試。2.3 打包器視角代碼分割與動(dòng)態(tài) import現(xiàn)代前端項(xiàng)目里手動(dòng)創(chuàng)建 script 標(biāo)簽已經(jīng)不多了更主流的是“代碼切割 動(dòng)態(tài) import”的組合。以 Webpack 或 Vite 為例只要代碼里寫了動(dòng)態(tài) import打包器就會(huì)自動(dòng)把這個(gè)模塊拆成一個(gè)獨(dú)立的 chunk瀏覽器在運(yùn)行到 import 語句時(shí)才發(fā)起請求。最簡單的例子是路由懶加載。以 Vue 為例const routes [ { path: /detail, component: () import(./views/Detail.vue) } ];React 同類場景用React.lazy配合Suspenseconst Detail React.lazy(() import(./views/Detail));這種做法對首屏最友好的點(diǎn)在于用戶訪問首頁時(shí)只加載首頁代碼只有真正跳轉(zhuǎn)到某個(gè)路由時(shí)才加載對應(yīng)的 JS。需要特別注意的是動(dòng)態(tài) import 返回的是 Promise如果你的頁面里對懶加載組件做了“即時(shí)渲染”在 chunk 還沒下載完成時(shí)會(huì)觸發(fā) Suspense 的 fallback如果 fallback UI 沒寫會(huì)直接白屏。JavaScript 里動(dòng)態(tài) import 的錯(cuò)誤也需要捕獲特別是懶加載失敗之后不能一點(diǎn)反饋都沒有。2.4 圖片懶加載與預(yù)加載的配合圖片是頁面體積的大頭有時(shí)候一張高清圖比整個(gè) JS 還大。圖片生性能優(yōu)化最簡單的一條路就是不要一口氣全加載。原生loadinglazy屬性已經(jīng)不需要任何庫瀏覽器自動(dòng)判斷圖片進(jìn)入視口附近才加載兼容性足夠用。不過原生屬性的觸發(fā)時(shí)機(jī)沒有做精細(xì)控制想要“提前一點(diǎn)加載”或者“做骨架占位”用IntersectionObserver更穩(wěn)var observer new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting) { var img entry.target; img.src img.dataset.src; observer.unobserve(img); } }); }, { rootMargin: 200px 0px }); document.querySelectorAll(img[data-src]).forEach(function (img) { observer.observe(img); });rootMargin 設(shè)置成200px 0px表示圖片進(jìn)入視口下方 200px 范圍時(shí)就開始加載這樣用戶滾動(dòng)到圖片附近時(shí)它已經(jīng)基本加載完不會(huì)有明顯的刷新感。預(yù)加載preload/prefetch則是異步加載體系里的“先手”link relpreload hrefcritical.css asstyle link relprefetch hrefdetail-page-chunk.jspreload 適合急用資源告訴瀏覽器“你現(xiàn)在就下優(yōu)先級調(diào)高”但注意別濫用首屏同時(shí) preload 十幾個(gè)資源會(huì)讓網(wǎng)絡(luò)通道擁擠得不償失。prefetch 則是“如果你有空幫我下載未來會(huì)用的資源”行為更溫和適合路由級 chunk。3. 實(shí)戰(zhàn)記錄一個(gè) 3.2 秒頁面的完整改造原理和方案講了這么多最終還是要落到真實(shí)項(xiàng)目里。記錄一個(gè)我經(jīng)手過的典型優(yōu)化把過程和數(shù)據(jù)都擺出來你可以照著思路復(fù)現(xiàn)。3.1 改造前的病灶診斷項(xiàng)目是一個(gè)后臺(tái)管理系統(tǒng)技術(shù)棧是 Vue 2 Webpack入口文件在 main.js 把幾乎所有業(yè)務(wù)頁面需要的公共依賴都 import 了一遍再加上全量引入了幾個(gè)第三方庫。打包產(chǎn)物是 vendor.js app.js總共 1.2MB未壓縮。在 Chrome DevTools 的 Network 面板里看瀑布圖問題非常直觀HTML 文檔下載只用了 80ms但 vendor.js 下載耗時(shí) 904ms執(zhí)行耗時(shí) 612msapp.js 下載 486ms執(zhí)行 523ms這兩段 JS 從下載到執(zhí)行拖了 2.5 秒多期間首屏一直白屏加上 CSS 和幾張首屏大圖的加載首屏完全可交互的時(shí)間穩(wěn)定在 3.2 秒以上。還有更糟的登錄頁用得極少的地圖組件也在主包里面用戶根本沒打開過相關(guān)頁面代價(jià)卻是每次進(jìn)系統(tǒng)都要白下載幾百 KB 代碼。3.2 四步改造路徑第一步是拆包。把第三方依賴拆成 core 和 extras 兩撥。core 只保留 Vue、Vue Router、公共狀態(tài)管理這類任何頁面都必須的東西extras 包括圖表庫、富文本編輯器、地圖 SDK全部改成動(dòng)態(tài) import。第二步是路由懶加載。系統(tǒng)有登錄、列表、詳情、設(shè)置四個(gè)主路由每個(gè)路由組件改成() import()寫法打包器自動(dòng)拆成獨(dú)立 chunk。改完之后首頁分包從 1.2MB 縮到 260KB這是首屏?xí)r間大幅下降最直接的原因。第三步是組件級按需引用。以前為了省事在全局 components 里注冊了一堆 UI 組件現(xiàn)在改成頁面內(nèi)單獨(dú) import。像日期范圍選擇器這種只有列表頁篩選欄用到的東西再也不用出現(xiàn)在登錄頁的加載鏈路里。第四步是圖片懶加載。首屏之外的輪播圖、長列表封面圖全部加loadinglazy對需要精確控制位置的圖片使用 IntersectionObserver 方案設(shè)置 200px 的提前量保證用戶滾動(dòng)時(shí)圖片已經(jīng)悄悄加載了不會(huì)出現(xiàn)“加載一半卡住”的視覺斷層。3.3 優(yōu)化效果與關(guān)鍵參數(shù)對照改造完成后用同樣的網(wǎng)絡(luò)環(huán)境Fast 3G 模擬跑了一遍數(shù)據(jù)指標(biāo)改造前改造后首屏完全可交互時(shí)間3.2s1.1s首屏 JS 體積未壓縮1.2MB260KBTCP 連接數(shù)2312LCP最大內(nèi)容繪制3.0s1.2sLighthouse 性能分4286其中 LCP 這個(gè)指標(biāo)特別值得關(guān)注因?yàn)樗从车氖恰坝脩艨吹街饕獌?nèi)容”的時(shí)間。首屏圖片和標(biāo)題渲染明顯提前了因?yàn)椴辉俦灰淮薮蟮哪_本卡在最后。這個(gè)改造過程有兩點(diǎn)經(jīng)驗(yàn)值得說。第一拆包之后的體積收益要配合服務(wù)端 gzip 才有最終效果我們同時(shí)開了 gzip260KB 的包實(shí)際傳輸只有 90KB 左右網(wǎng)絡(luò)損耗進(jìn)一步降低。第二chunk 文件名要加 contenthash否則用戶瀏覽器緩存還是拿舊文件等于白優(yōu)化。4. 常見問題與排錯(cuò)技巧實(shí)錄異步加載用上之后新問題也會(huì)跟著來。這里整理幾個(gè)出現(xiàn)頻率最高的坑和排查思路基本覆蓋日常開發(fā)的常見雷區(qū)。4.1 異步腳本執(zhí)行順序錯(cuò)亂用 async 加載兩個(gè)互相依賴的腳本運(yùn)行時(shí)報(bào)xxx is not defined這種問題在上線大廳調(diào)試時(shí)非常尷尬。排查思路很直接先看腳本的依賴方向b.js 依賴 a.js 的函數(shù)那不能給這兩個(gè)腳本都用 async可以改成 defer或者用動(dòng)態(tài)加載的方式通過 Promise 控制順序。另外注意一個(gè)細(xì)節(jié)defer 嚴(yán)格按文檔順序執(zhí)行但它執(zhí)行的時(shí)候文檔已經(jīng)解析完畢。如果你有一個(gè)腳本想“越早執(zhí)行越好”又需要操作 DOM那么腳本位置放body底部配合 defer比放head里更合理。4.2 懶加載導(dǎo)致圖片容器塌陷圖片懶加載的時(shí)候如果圖片沒有顯式高度容器高度就是 0等圖片加載完成后容器突然被撐高頁面視覺上會(huì)“跳一下”。尤其是列表頁每跳一下用戶都得重新定位閱讀位置體驗(yàn)非常差。解決辦法是兩個(gè)給圖片容器固定寬高或者使用 CSS 的aspect-ratio屬性預(yù)設(shè)比例。再配合一點(diǎn) background-color 占位加載過程基本無感。4.3 預(yù)加載過度導(dǎo)致首屏變慢新手容易把 preload 當(dāng)成“萬能加速器”一口氣把首屏所有圖片、字體、 chunk 都塞進(jìn) preload。結(jié)果是瀏覽器網(wǎng)絡(luò)通道被塞滿真正關(guān)鍵的腳本和樣式反而排在后面首屏?xí)r間不減反增。preload 的合理使用范圍是首屏關(guān)鍵資源比如最大那張首屏圖、關(guān)鍵字體、首屏必需樣式其余未來資源一律用 prefetch 或者不預(yù)加載。4.4 動(dòng)態(tài) import 的 chunk 請求地址 404項(xiàng)目部署上線后點(diǎn)擊某個(gè)按鈕觸發(fā)懶加載Network 面板里看到請求 chunk 文件 404。常見原因是 Webpack 的 publicPath 配置與 CDN 實(shí)際目錄不一致。排查路徑先看 Network 面板里請求的完整 URL再對照 CDN 上的實(shí)際文件路徑最后檢查構(gòu)建配置里的 publicPath。另一個(gè)隱蔽問題某些 CDN 回源慢導(dǎo)致 chunk 首次訪問超時(shí)重試一下能好那就要在加載失敗的重試邏輯上做文章。4.5 性能優(yōu)化過程中容易忽略的隱藏項(xiàng)很多人優(yōu)化完 JS 和圖片發(fā)現(xiàn)首屏還是慢轉(zhuǎn)頭一看才發(fā)現(xiàn)是字體文件拖后腿。字體加載默認(rèn)是“阻塞換行渲染”的也就是字體文件沒加載完瀏覽器不會(huì)渲染使用了該字體的文本。最通用的解法是font-display: swap讓文本先用替代字體展示字體加載完成后切換。雖然會(huì)有極短時(shí)間的字體跳變但比起白屏等字體這個(gè)代價(jià)完全可以接受。還有緩存策略。異步加載的資源文件帶 contenthash 之后可以設(shè)置很長的 Cache-Control 過期時(shí)間用戶二次訪問時(shí)直接走本地緩存不再請求服務(wù)器。這一步優(yōu)化對“往返訪問”場景的提速效果比任何加載方案都明顯。5. 寫給正在做性能優(yōu)化的你異步加載這套組合拳打下來我最深的感受是性能優(yōu)化不是“把代碼變少”而是“把代碼出現(xiàn)的時(shí)間線重新排列”。用戶感知到的速度取決于“關(guān)鍵內(nèi)容出現(xiàn)在屏幕上的時(shí)間”而不是“所有資源都加載完的時(shí)間”。認(rèn)清這一點(diǎn)很多優(yōu)化決策會(huì)變得非常清晰——凡是首屏用不到的統(tǒng)統(tǒng)往后排凡是用戶馬上要用的優(yōu)先級拉滿凡是有依賴關(guān)系的順序必須可控。最后再分享一個(gè)小技巧優(yōu)化完成之后不要只盯著本地的快速網(wǎng)絡(luò)數(shù)據(jù)看。打開 DevTools 的 Network 面板把網(wǎng)絡(luò)節(jié)流調(diào)到 Slow 3G再跑一遍然后到真機(jī)低端機(jī)上試一圈。很多優(yōu)化在強(qiáng)網(wǎng)環(huán)境下看起來區(qū)別不大一到弱網(wǎng)環(huán)境就原形畢露。場景越惡劣異步加載做得好的頁面優(yōu)勢越明顯。把弱網(wǎng)下的表現(xiàn)作為驗(yàn)收標(biāo)準(zhǔn)你優(yōu)化出來的頁面才是真的能打。