戰(zhàn):前端兼容性配置與按需加載策略解析)
前端兼容性這事常常不是靠“小心避開新特性”來解決的而是要靠Polyfill。核心原因很簡單你代碼里調(diào)用的那個(gè)方法可能在一部分用戶的瀏覽器內(nèi)核里壓根不存在。這篇就用core-js實(shí)際拆一遍Polyfill的執(zhí)行方式、包結(jié)構(gòu)以及各種配置策略聊清楚什么場景該全量引入、什么場景按需裁剪最后附上我平時(shí)在項(xiàng)目中反復(fù)使用和校驗(yàn)的配置模板以及幾次從崩潰到穩(wěn)定的排錯(cuò)記錄。適合來看這篇文章的人不只是被“老瀏覽器兼容”折騰過的前端工程師。準(zhǔn)備系統(tǒng)整理工程化配置的團(tuán)隊(duì)、寫組件庫時(shí)需要隔離全局變量的維護(hù)者還有一直不太理解“為什么Babel編譯完還要再來一層polyfill”的初學(xué)者都能從這里找到實(shí)際能落地的思路。老項(xiàng)目改造、多端訪問、低版本運(yùn)行環(huán)境這幾類場景尤其值得花幾分鐘讀完。1. 為什么需要Polyfill兼容性問題的本質(zhì)1.1 不是語法問題是“標(biāo)準(zhǔn)庫缺零件”很多人一開始理解兼容性第一反應(yīng)是語法太新。比如代碼里用了可選鏈?.編譯工具把它轉(zhuǎn)成舊語法就行。真正麻煩的是標(biāo)準(zhǔn)庫方法缺失老瀏覽器不認(rèn)識Promise.prototype.finally沒有Array.prototype.includes沒有Object.fromEntries甚至沒有Map和Set。語法編譯器改不了這些東西因?yàn)樗鼈兪沁\(yùn)行時(shí)的“零件”不是語法糖。這就好比一臺(tái)老車駕駛位有方向盤和油門但缺了一個(gè)空氣濾芯。你換一套駕駛儀表盤沒有用得把那個(gè)零件補(bǔ)上。Polyfill做的事情就是“補(bǔ)零件”在瀏覽器不提供某個(gè)能力時(shí)用代碼模擬出一個(gè)足夠接近的版本塞進(jìn)全局對象讓后續(xù)代碼正常使用。一個(gè)很常見的排查現(xiàn)場是這樣的新功能上線后數(shù)據(jù)表格在部分用戶的瀏覽器里始終是空白控制臺(tái)也沒有報(bào)錯(cuò)。仔細(xì)查下去往往就是某個(gè)數(shù)組方法返回了非法值或者在舊的執(zhí)行環(huán)境里URL對象壓根沒定義。這種問題很容易被誤判成后端數(shù)據(jù)問題浪費(fèi)一整輪聯(lián)調(diào)。這也是為什么我建議項(xiàng)目一上來就定好Polyfill策略而不是等功能失效了再回頭補(bǔ)。1.2 Polyfill的底層邏輯先檢測再補(bǔ)丁Polyfill的原理并不復(fù)雜核心就兩步檢測目標(biāo)對象上是否存在對應(yīng)能力如果不存在則用符合規(guī)范的實(shí)現(xiàn)覆蓋。比如要給舊版瀏覽器補(bǔ)Object.assign就先檢查typeof Object.assign function為否時(shí)才掛載自己的實(shí)現(xiàn)。實(shí)際實(shí)現(xiàn)要比這復(fù)雜不少尤其是涉及迭代器和Symbol的時(shí)候。Symbol.iterator本身需要先被定義后續(xù)Array.from、Map、Set、for...of才能工作。core-js內(nèi)部有一套非常講究的墊片依賴順序會(huì)先處理底層機(jī)制再處理上層API。這也是我不建議直接拿網(wǎng)上幾行代碼拼湊Polyfill的原因順序錯(cuò)了補(bǔ)丁之間會(huì)互相打架表面上每個(gè)方法都有了一跑起來就是各種類型錯(cuò)誤。還有一點(diǎn)容易被忽略Polyfill不是Transpile。Transpile解決的是語法層面的轉(zhuǎn)換比如把class轉(zhuǎn)換成構(gòu)造函數(shù)把箭頭函數(shù)轉(zhuǎn)換成普通函數(shù)。Polyfill解決的是運(yùn)行時(shí)能力的補(bǔ)充。兩者經(jīng)常配合使用前者由Babel的插件完成后者由core-js這類庫完成。如果你只做了語法轉(zhuǎn)換而沒有做Polyfill代碼在舊瀏覽器上依然會(huì)炸。1.3 為什么一定繞不開core-js市面上的Polyfill方案不止core-js一個(gè)但實(shí)際工程里它基本是繞不開的基礎(chǔ)設(shè)施。理由是它的覆蓋面太全從ES5時(shí)代殘留的坑到ES2015、ES2020、ES2023再到還在提案階段的新特性幾乎都有對應(yīng)實(shí)現(xiàn)。它還覆蓋了一些Web標(biāo)準(zhǔn)接口比如URL、TextEncoder雖然不是純ECMAScript范圍但對業(yè)務(wù)代碼同樣重要。更關(guān)鍵的是它和編譯工具鏈的整合度最好。只要在Babel配置里同時(shí)聲明useBuiltIns和corejs版本編譯器會(huì)自動(dòng)根據(jù)目標(biāo)瀏覽器列表決定哪些特性需要被補(bǔ)丁、哪些可以跳過。這種自動(dòng)按需裁剪能力是很多手寫的簡單Polyfill不具備的。換句話說core-js不只是“一個(gè)墊片庫”它更像一個(gè)兼容性編譯中樞的地基這也是它出現(xiàn)在幾乎每個(gè)正經(jīng)前端項(xiàng)目package.json里的原因。2. core-js包結(jié)構(gòu)深度拆解2.1 三個(gè)入口包c(diǎn)ore-js、core-js-pure、core-js-bundle聊包結(jié)構(gòu)之前得先搞清楚core-js官方實(shí)際上發(fā)布了幾種形態(tài)。最常用的是core-js本體它通過修改全局對象來提供Polyfill適合業(yè)務(wù)項(xiàng)目直接使用。如果你在開發(fā)的是組件庫或第三方SDK不希望自己的補(bǔ)丁污染使用者的全局環(huán)境那就應(yīng)該用core-js-pure。它不掛全局對象而是通過導(dǎo)出函數(shù)的方式讓你顯式調(diào)用代價(jià)是代碼體積和調(diào)用成本會(huì)高一些。還有一種形態(tài)叫core-js-bundle它是一個(gè)打包好的、可以直接用script標(biāo)簽引入的獨(dú)立文件。這種通常用于沒有模塊化構(gòu)建條件的頁面比如一些老舊的CMS頁面或活動(dòng)頁。三類之間的取舍我后面在配置策略部分會(huì)展開說。要記的要點(diǎn)是不是所有場景都該默認(rèn)用core-js本體組件庫和業(yè)務(wù)項(xiàng)目的最佳選擇并不相同。包名是否污染全局體積特征典型場景core-js是聚合入口較大可配合按需裁剪業(yè)務(wù)項(xiàng)目core-js-pure否模塊粒度更細(xì)調(diào)用方式繁瑣組件庫、SDKcore-js-bundle是單文件一次性引入無構(gòu)建環(huán)境頁面2.2 進(jìn)入包體內(nèi)部模塊目錄和入口邏輯當(dāng)你在項(xiàng)目里import core-js/stable時(shí)實(shí)際進(jìn)入的是庫根目錄的入口文件。以常見的3.x版本為例目錄結(jié)構(gòu)大致包括es、stable、proposals、web、features、modules、internals這些目錄。普通用戶真正需要直接引用的通常是stable和proposals目錄es目錄里的模塊是更細(xì)粒度的按版本拆分可以理解為“ES2015”“ES2020”的年代戳。如果你打開modules目錄會(huì)看到幾乎所有方法的獨(dú)立實(shí)現(xiàn)文件比如es.array.includes.js、es.object.from-entries.js。這種拆法是為了配合Babel做按需加載當(dāng)編譯器確定某個(gè)特性需要補(bǔ)丁時(shí)只會(huì)從對應(yīng)文件里引入那部分代碼而不是整套庫。internals目錄則藏著各種內(nèi)部公共邏輯比如類型判斷、迭代器輔助函數(shù)、全局對象檢測等。它雖然不允許外部直接引用但在體積優(yōu)化和模塊復(fù)用上起著決定性作用。很多打包體積異常的問題追根溯源都是因?yàn)殄e(cuò)誤地引入了一個(gè)聚合入口把整個(gè)核心邏輯都帶進(jìn)了主包。所以如果你只是想補(bǔ)某幾個(gè)API盡量走具體模塊的引入路徑不要一上來就import core-js。2.3 版本演進(jìn)的幾個(gè)關(guān)鍵變化如果用詞更準(zhǔn)確大部分人的兼容性記憶應(yīng)該從core-js 2開始。那個(gè)時(shí)代最流行的做法是配合babel/polyfill整體引入省事但體積驚人而且它內(nèi)部對core-js的封裝是全局的很難精細(xì)裁剪。后來官方明確不再推薦babel/polyfillcore-js 3成為主流改用babel/preset-env里的corejs配置項(xiàng)來指定版本實(shí)現(xiàn)了真正按需。核心變化主要有三點(diǎn)。第一模塊拆分得更細(xì)不再需要一次性引入全部第二支持通過core-js-compat先查看目標(biāo)瀏覽器的缺失清單從而提前知道該補(bǔ)什么第三對proposal特性采用單獨(dú)的入口和標(biāo)記避免穩(wěn)定版API混入仍在變動(dòng)的提案實(shí)現(xiàn)。這些變化對絕大多數(shù)項(xiàng)目來說都是好事但也帶來一個(gè)遷移成本如果你從2跳到3要檢查配置里的版本聲明還要重新審視那些直接引用babel/polyfill的老代碼。注意遷移到core-js 3之后不要繼續(xù)使用babel/polyfill。正確做法是通過babel/preset-env的corejs: 3.x配置來指定版本然后把入口文件里的babel/polyfill引用全部刪掉。3. 各種配置策略從手動(dòng)到全自動(dòng)3.1 方案一直接在入口全量引入最簡單粗暴的配置是import core-js/stable放在入口文件頭部。優(yōu)點(diǎn)是省心幾乎不會(huì)漏缺點(diǎn)是整個(gè)stable集合都會(huì)被打進(jìn)主包體積明顯增加。在gzip環(huán)境下一個(gè)完整的core-js stable大概有幾十KB對于首屏性能敏感的項(xiàng)目并不友好。適合對兼容性要求極高、流量不大的內(nèi)部系統(tǒng)或者臨時(shí)救火的老項(xiàng)目改造初期。如果只想覆蓋當(dāng)前業(yè)務(wù)用到的特性可以手動(dòng)引入具體模塊比如用得多的是Array.prototype.includes就只寫import core-js/es/array/includes。這種方式思路清晰但維護(hù)成本偏高。項(xiàng)目越大越容易漏補(bǔ)后面每次用到新API都得想起來檢查一遍排查問題更費(fèi)勁。所以它只適合很小的項(xiàng)目或者補(bǔ)丁數(shù)量非常明確的場景。3.2 方案二Babel自動(dòng)按需核心推薦。配置方式是在Babel配置里加上babel/preset-env并打開useBuiltIns。關(guān)鍵參數(shù)有三個(gè)。一個(gè)是useBuiltIns: usageBabel會(huì)根據(jù)代碼里的實(shí)際使用自動(dòng)注入polyfill連Array.prototype.includes出現(xiàn)在哪個(gè)模塊都能精準(zhǔn)定位從而做到精細(xì)裁剪。另一個(gè)是corejs: 3.x顯式聲明使用core-js 3避免Babel按舊版邏輯處理。還有一個(gè)是targets指定要兼容的瀏覽器范圍配合browserslist使用。usage模式聽起來很完美但它有一個(gè)前提編譯器需要能看到代碼中API的使用位置。如果你的代碼沒有經(jīng)過這個(gè)編譯鏈比如某個(gè)第三方模塊未被Babel處理它就不會(huì)被檢測到。這時(shí)候usage模式可能會(huì)漏。另一個(gè)極端是useBuiltIns: entry它需要你在入口文件顯式導(dǎo)入一個(gè)polyfill入口然后Babel會(huì)根據(jù)目標(biāo)瀏覽器去重和替換只保留缺失的補(bǔ)丁。module.exports { presets: [ [babel/preset-env, { targets: 0.25%, not dead, useBuiltIns: usage, corejs: { version: 3.39, proposals: false } }] ] };3.3 方案三精確目標(biāo)環(huán)境與漸進(jìn)增強(qiáng)browserslist在這里扮演了“過濾器”的角色。你可以在package.json里加browserslist字段也可以建單獨(dú)配置文件。常見的寫法是 0.25%, not dead。這段定義決定了Babel在usage模式下判斷“是否需要補(bǔ)丁”的依據(jù)。舉個(gè)例子如果目標(biāo)環(huán)境只在最新兩個(gè)大版本的主流瀏覽器上跑很多ES2020特性本來就有原生支持polyfill幾乎不會(huì)被注入但如果目標(biāo)環(huán)境列表里包含了一些老牌瀏覽器那么Array.from、Promise等基礎(chǔ)方法會(huì)立刻被列入清單。這就是為什么同樣一份業(yè)務(wù)代碼換了browserslist之后構(gòu)建產(chǎn)物體積可能差出一倍。這里容易出現(xiàn)的一個(gè)誤區(qū)是把browserslist和Babel的targets混在一起寫結(jié)果產(chǎn)生了兩套沖突規(guī)則。我的習(xí)慣是只在一處維護(hù)瀏覽器目標(biāo)Babel配置里不再重復(fù)聲明targets直接用browserslist的默認(rèn)讀取邏輯這樣團(tuán)隊(duì)里其他構(gòu)建工具也能共享同一套目標(biāo)。3.4 方案四組件庫與第三方包的隔離策略如果你是組件庫作者最怕的不是自己跑不起來而是把自己用的polyfill擴(kuò)散到使用方的全局命名空間導(dǎo)致別人的全局對象被修改。core-js-pure就是為了解決這個(gè)問題存在的。它導(dǎo)出的不是“全局補(bǔ)丁”而是獨(dú)立的函數(shù)。調(diào)用方式完全變了比如原來Array.prototype.includes可以直接用在core-js-pure模式下得先import includes from core-js-pure/features/array/includes再傳入數(shù)組調(diào)用。這種隔離換來了安全但也會(huì)讓你的庫代碼變得繁瑣并且每個(gè)polyfill都會(huì)存在多次引用的問題。實(shí)踐中組件庫更推薦的做法是盡量不用polyfill用特性檢測并在文檔里寫明支持的瀏覽器范圍。如果有硬性需求再考慮隔離方案。對于內(nèi)部業(yè)務(wù)團(tuán)隊(duì)使用的公共組件也要提前約定好組件庫不要把全局補(bǔ)丁帶出去業(yè)務(wù)項(xiàng)目自己統(tǒng)一管理polyfill策略兩邊各司其職。3.5 方案五運(yùn)行時(shí)動(dòng)態(tài)加載與降級靜態(tài)注入有一個(gè)先天不足不管用戶瀏覽器是否需要只要在目標(biāo)清單里代碼就會(huì)被打包進(jìn)去并執(zhí)行。對于想要追求極致首屏的項(xiàng)目可以考慮運(yùn)行時(shí)動(dòng)態(tài)加載。做法是在入口先跑一段特性檢測如果發(fā)現(xiàn)某個(gè)關(guān)鍵對象缺失再用動(dòng)態(tài)import()加載對應(yīng)的polyfill模塊之后才啟動(dòng)業(yè)務(wù)代碼。這種模式適合SPA等有明顯啟動(dòng)門檻的應(yīng)用對活動(dòng)頁或老頁面不一定劃算。需要提醒的是動(dòng)態(tài)加載邏輯會(huì)推高首屏的復(fù)雜度還會(huì)引入加載時(shí)序問題。一旦網(wǎng)絡(luò)鏈路不穩(wěn)定polyfill加載失敗業(yè)務(wù)邏輯就無法啟動(dòng)。正是因?yàn)檫@種風(fēng)險(xiǎn)業(yè)界一般只對“缺失概率較高且影響致命的特性”做動(dòng)態(tài)降級而不是把所有polyfill都放進(jìn)動(dòng)態(tài)分支。做個(gè)類比靜態(tài)去重在多數(shù)情況下已經(jīng)夠用動(dòng)態(tài)方案屬于特定場景的手術(shù)刀。4. 實(shí)操過程與構(gòu)建對比4.1 我常用的三套配置模板第一套模板適合小型活動(dòng)頁或老項(xiàng)目改造初期。只改Babel配置不額外拆構(gòu)建產(chǎn)物目標(biāo)是先保證功能在舊瀏覽器里能跑。// babel.config.js module.exports { presets: [ [babel/preset-env, { targets: 0.25%, not dead, useBuiltIns: usage, corejs: { version: 3.39, proposals: false } }] ] };第二套模板適合中大型多頁面項(xiàng)目。在usage模式基礎(chǔ)上把browserslist獨(dú)立維護(hù)同時(shí)把polyfill相關(guān)代碼拆成獨(dú)立chunk避免業(yè)務(wù)代碼和補(bǔ)丁代碼混在一起導(dǎo)致緩存失效。// package.json { browserslist: [ 0.25%, not dead ] }第三套模板適合組件庫開發(fā)。使用core-js-pure且不對全局對象做任何修改。每個(gè)用到新API的地方都通過顯式導(dǎo)入來獲取能力代碼會(huì)啰嗦一點(diǎn)但至少不會(huì)把使用方坑了。4.2 構(gòu)建產(chǎn)物體積到底差多少我用自己的一個(gè)模擬項(xiàng)目X做過對比。頁面大概10個(gè)入口代碼里用到了Promise、Array.from、Object.assign、URLSearchParams、TextEncoder這些常見API。同樣一份源碼三組配置的產(chǎn)物差異非常明顯。配置方式產(chǎn)物增量gzip說明入口全量引入 core-js/stable約 50KB最省心代價(jià)最大useBuiltIns usage 較寬瀏覽器目標(biāo)約 25KB推薦日常項(xiàng)目使用useBuiltIns usage 較新瀏覽器目標(biāo)約 8KB只補(bǔ)少量關(guān)鍵API這組數(shù)字只是一個(gè)參考具體取決于項(xiàng)目用到的API數(shù)量和目標(biāo)瀏覽器列表。但趨勢很明確browserslist越新、目標(biāo)越明確產(chǎn)物越小。如果你發(fā)現(xiàn)死活壓不下去先懷疑是不是某個(gè)聚合入口把不必要的模塊帶進(jìn)來了。4.3 如何確認(rèn)補(bǔ)丁真的打上了配置完成不是終點(diǎn)。我習(xí)慣先用構(gòu)建產(chǎn)物體積做一次粗判斷再去產(chǎn)物里搜索特定的core-js模塊路徑。比如代碼里使用了Array.prototype.flat那么構(gòu)建后應(yīng)該在core-js/modules相關(guān)文件里看到對應(yīng)實(shí)現(xiàn)。如果搜不到說明Babel在usage模式下并沒有把它判斷為需要補(bǔ)丁。更穩(wěn)的驗(yàn)證方法是在真實(shí)舊內(nèi)核環(huán)境里跑一遍關(guān)鍵流程。開發(fā)者工具的UA模擬只能改請求頭和部分平臺(tái)標(biāo)識并不能真正模擬出核心API的缺失。最好準(zhǔn)備一臺(tái)低版本系統(tǒng)的測試設(shè)備或者用虛擬鏡像環(huán)境把產(chǎn)品的主要路徑完整點(diǎn)一遍。還有一個(gè)小技巧在業(yè)務(wù)代碼啟動(dòng)前加一行臨時(shí)探針比如console.log(typeof Promise.prototype.finally)。先不引polyfill跑一遍舊環(huán)境確認(rèn)輸出是undefined再引polyfill跑一遍確認(rèn)變成function。這一步能快速定位“補(bǔ)丁到底起沒起作用”。5. 常見問題與排查技巧實(shí)錄5.1 Polyfill明明引入了功能還是報(bào)錯(cuò)一個(gè)常見原因useBuiltIns: usage只處理Babel編譯過的源碼。如果第三方依賴沒有被Babel處理它的特性缺失就不會(huì)被檢測。如果把所有node_modules的第三方代碼都交給Babel構(gòu)建時(shí)間會(huì)明顯上升所以行業(yè)通用做法是node_modules做轉(zhuǎn)譯排除只轉(zhuǎn)譯業(yè)務(wù)代碼。這時(shí)第三方庫用到的舊API可能只能靠入口的全局polyfill覆蓋。另一個(gè)常見原因是順序問題。如果業(yè)務(wù)代碼在import core-js/stable之前執(zhí)行或者polyfill被異步加載而業(yè)務(wù)代碼已經(jīng)啟動(dòng)同樣會(huì)出現(xiàn)“明明有配置卻還是報(bào)錯(cuò)”。排查時(shí)先看構(gòu)建產(chǎn)物里polyfill代碼的位置再確認(rèn)入口順序確認(rèn)第三方庫的引入是否被提前執(zhí)行。5.2 補(bǔ)丁引入后全局環(huán)境下出現(xiàn)偶發(fā)沖突core-js 3在全局環(huán)境下會(huì)盡量保持原有屬性的描述符但多版本共存仍然可能沖突。最常見的是A庫依賴core-js 2B庫依賴core-js 3最終打包時(shí)出現(xiàn)兩份甚至三份不同的polyfill實(shí)現(xiàn)。它們互相覆蓋可能導(dǎo)致某一方的能力判斷失效癥狀就是一會(huì)兒能用一會(huì)兒不能。解決辦法是統(tǒng)一版本。在package.json里通過overrides或resolutions強(qiáng)制鎖定core-js版本。如果三方過多考慮用core-js-pure隔離。另一個(gè)思路是盡量減少對全局補(bǔ)丁的依賴把業(yè)務(wù)代碼限定在較現(xiàn)代的運(yùn)行環(huán)境里只在必要的時(shí)候引入補(bǔ)丁。5.3 完整問題速查表現(xiàn)象可能原因處理建議控制臺(tái)報(bào)“includes is not a function”對應(yīng)polyfill未引入或usage模式漏檢檢查該模塊是否被Babel處理確認(rèn)入口順序打包體積突然暴漲全量引入stable或usage配置失效檢查useBuiltIns和corejs版本改用配置模板屬性存在但行為不一致存在多版本polyfill沖突鎖定core-js版本統(tǒng)一入口異步加載后業(yè)務(wù)先啟動(dòng)polyfill chunk未先于業(yè)務(wù)執(zhí)行用靜態(tài)依賴保證加載順序還有一個(gè)容易踩的坑babel/runtime和core-js各管一攤不要混為一談。babel/runtime處理的是語法轉(zhuǎn)換后的輔助函數(shù)比如類繼承的_inherits、對象展開的輔助代碼它不補(bǔ)標(biāo)準(zhǔn)庫API。core-js才負(fù)責(zé)補(bǔ)齊Promise、Array.prototype.includes這類運(yùn)行時(shí)能力。兩者配合使用才能既解決語法又解決API缺失。最后說一點(diǎn)個(gè)人體會(huì)。我在實(shí)際項(xiàng)目中踩過幾次坑之后已經(jīng)不再追求“用同一套配置打天下”。接到一個(gè)項(xiàng)目我會(huì)先看它的用戶群體、設(shè)備分布和構(gòu)建路線再?zèng)Q定是靜態(tài)全量、usage按需、還是引入動(dòng)態(tài)降級。Polyfill不是越全越好關(guān)鍵是在你能接受的兼容范圍內(nèi)盡量少破壞運(yùn)行時(shí)的性能。如果你面對的產(chǎn)品本身就是以較新瀏覽器為絕對主流那甚至可以考慮只補(bǔ)少數(shù)幾個(gè)關(guān)鍵API把省下來的體積留給業(yè)務(wù)代碼。畢竟兼容性的本質(zhì)是權(quán)衡而core-js只是幫你把這個(gè)權(quán)衡做得更精細(xì)的工具。