前端構(gòu)建系統(tǒng)淺析
開(kāi)發(fā)者編寫JavaScript代碼,而瀏覽器運(yùn)行JavaScript代碼。從根本上說(shuō),前端開(kāi)發(fā)不需要構(gòu)建步驟。那么,為什么現(xiàn)代前端需要構(gòu)建步驟呢?
隨著前端代碼庫(kù)越來(lái)越龐大,以及開(kāi)發(fā)者體驗(yàn)越來(lái)越重要,直接將JavaScript源碼傳輸給客戶端會(huì)帶來(lái)兩個(gè)主要問(wèn)題:
- 不支持的語(yǔ)言特性:由于JavaScript在瀏覽器中運(yùn)行,而瀏覽器種類繁多、版本各異,每增加一種語(yǔ)言特性,能運(yùn)行你JavaScript的客戶端數(shù)量就會(huì)減少。此外,像JSX這樣的語(yǔ)言擴(kuò)展不是有效的JavaScript,任何瀏覽器都無(wú)法運(yùn)行。
- 性能問(wèn)題:瀏覽器必須單獨(dú)請(qǐng)求每個(gè)JavaScript文件。在一個(gè)大型代碼庫(kù)中,這可能導(dǎo)致成千上萬(wàn)次的HTTP請(qǐng)求來(lái)渲染一個(gè)頁(yè)面。在HTTP/2之前,這還會(huì)導(dǎo)致成千上萬(wàn)次的TLS握手。另外,可能需要幾次連續(xù)的網(wǎng)絡(luò)往返才能加載所有JavaScript。例如,如果index.js導(dǎo)入page.js,而page.js又導(dǎo)入button.js,那么需要三次連續(xù)的網(wǎng)絡(luò)往返才能完全加載JavaScript。這被稱為瀑布問(wèn)題。源文件由于長(zhǎng)變量名和空白縮進(jìn)字符等原因,也可能不必要地變大,增加帶寬使用和網(wǎng)絡(luò)加載時(shí)間。
前端構(gòu)建系統(tǒng)處理源代碼并生成一個(gè)或多個(gè)優(yōu)化后的JavaScript文件,便于傳輸給瀏覽器。最終的可分發(fā)文件通常是人類難以閱讀的。
構(gòu)建步驟
前端構(gòu)建系統(tǒng)通常包括三個(gè)步驟:轉(zhuǎn)譯、打包和壓縮。
某些應(yīng)用程序可能不需要所有三個(gè)步驟。例如,較小的代碼庫(kù)可能不需要打包或壓縮,而開(kāi)發(fā)服務(wù)器可能為了性能跳過(guò)打包和/或壓縮。此外,還可以添加自定義步驟。
有些工具實(shí)現(xiàn)了多個(gè)構(gòu)建步驟。尤其是打包工具通常實(shí)現(xiàn)所有三個(gè)步驟,僅使用打包工具就足以構(gòu)建簡(jiǎn)單的應(yīng)用程序。復(fù)雜的應(yīng)用程序可能需要專門的工具來(lái)分別執(zhí)行每個(gè)構(gòu)建步驟,以提供更大的功能集。
轉(zhuǎn)譯
轉(zhuǎn)譯通過(guò)將用現(xiàn)代JavaScript標(biāo)準(zhǔn)編寫的代碼轉(zhuǎn)換為舊版本的JavaScript標(biāo)準(zhǔn)來(lái)解決不支持的語(yǔ)言特性問(wèn)題。如今,ES6/ES2015是一個(gè)常見(jiàn)的目標(biāo)版本。
框架和工具也可能引入轉(zhuǎn)譯步驟。例如,JSX語(yǔ)法必須轉(zhuǎn)譯為JavaScript。如果一個(gè)庫(kù)提供了Babel插件,這通常意味著它需要一個(gè)轉(zhuǎn)譯步驟。此外,像TypeScript、CoffeeScript和Elm這樣的語(yǔ)言必須轉(zhuǎn)譯為JavaScript。
CommonJS模塊(CJS)也必須轉(zhuǎn)譯為瀏覽器兼容的模塊系統(tǒng)。自從2018年瀏覽器廣泛支持ES6模塊(ESM)后,通常建議轉(zhuǎn)譯為ESM。由于ESM的導(dǎo)入和導(dǎo)出是靜態(tài)定義的,因此更容易優(yōu)化和進(jìn)行樹(shù)搖。
目前常用的轉(zhuǎn)譯器有Babel、SWC和TypeScript Compiler。
- Babel(2014)是標(biāo)準(zhǔn)的轉(zhuǎn)譯器:一個(gè)用JavaScript編寫的單線程轉(zhuǎn)譯器,速度較慢。許多需要轉(zhuǎn)譯的框架和庫(kù)通過(guò)Babel插件實(shí)現(xiàn),因此Babel必須成為構(gòu)建過(guò)程的一部分。然而,Babel難以調(diào)試且常常令人困惑。
- SWC(2020)是一個(gè)用Rust編寫的多線程快速轉(zhuǎn)譯器。它聲稱速度比Babel快20倍,因此被較新的框架和構(gòu)建工具使用。它支持轉(zhuǎn)譯TypeScript和JSX。如果你的應(yīng)用程序不需要Babel,SWC是一個(gè)更好的選擇。
- TypeScript Compiler(tsc)也支持轉(zhuǎn)譯TypeScript和JSX。它是TypeScript的參考實(shí)現(xiàn),也是唯一功能全面的TypeScript類型檢查器。然而,它非常慢。雖然TypeScript應(yīng)用程序必須使用TypeScript Compiler進(jìn)行類型檢查,但在構(gòu)建步驟中,使用其他轉(zhuǎn)譯器會(huì)更高效。
如果你的代碼是純JavaScript并且使用ES6模塊,可以跳過(guò)轉(zhuǎn)譯步驟。
對(duì)于某些不支持的語(yǔ)言特性,另一個(gè)解決方案是polyfill。polyfill在運(yùn)行時(shí)執(zhí)行,實(shí)現(xiàn)在執(zhí)行主應(yīng)用程序邏輯之前任何缺失的語(yǔ)言特性。然而,這增加了運(yùn)行時(shí)開(kāi)銷,有些語(yǔ)言特性無(wú)法用polyfill實(shí)現(xiàn)。參見(jiàn)core-js。
所有打包工具本質(zhì)上都是轉(zhuǎn)譯器,因?yàn)樗鼈兘馕龆鄠€(gè)JavaScript源文件并生成一個(gè)新的打包JavaScript文件。在此過(guò)程中,它們可以選擇在生成的JavaScript文件中使用哪些語(yǔ)言特性。有些打包工具還可以解析TypeScript和JSX源文件。如果你的應(yīng)用程序有簡(jiǎn)單的轉(zhuǎn)譯需求,可能不需要單獨(dú)的轉(zhuǎn)譯器。
打包
打包解決了需要進(jìn)行多次網(wǎng)絡(luò)請(qǐng)求和瀑布問(wèn)題。打包工具將多個(gè)JavaScript源文件連接成一個(gè)JavaScript輸出文件,稱為bundle,而不改變應(yīng)用程序行為。該bundle可以通過(guò)瀏覽器在一次網(wǎng)絡(luò)往返請(qǐng)求中高效加載。
目前常用的打包工具有Webpack、Parcel、Rollup、esbuild和Turbopack。
- Webpack(2014)在2016年左右獲得了巨大的人氣,后來(lái)成為標(biāo)準(zhǔn)的打包工具。與當(dāng)時(shí)流行的Browserify不同,Webpack開(kāi)創(chuàng)了“加載器”這一概念,通過(guò)導(dǎo)入轉(zhuǎn)換源文件,使Webpack能夠協(xié)調(diào)整個(gè)構(gòu)建流程。加載器允許開(kāi)發(fā)者在JavaScript文件中透明地導(dǎo)入靜態(tài)資源,將所有源文件和靜態(tài)資源組合成一個(gè)依賴關(guān)系圖。使用Gulp時(shí),每種類型的靜態(tài)資源必須作為單獨(dú)的任務(wù)進(jìn)行構(gòu)建。Webpack還支持開(kāi)箱即用的代碼分割,簡(jiǎn)化了其設(shè)置和配置。Webpack速度較慢且是單線程的,用JavaScript編寫。它高度可配置,但其眾多配置選項(xiàng)可能令人困惑。
- Rollup(2016)利用了ES6模塊在瀏覽器中的廣泛支持以及它帶來(lái)的優(yōu)化,尤其是樹(shù)搖。它生成的bundle大小遠(yuǎn)小于Webpack,導(dǎo)致Webpack后來(lái)也采用了類似的優(yōu)化。Rollup是一個(gè)單線程的打包工具,用JavaScript編寫,性能僅略優(yōu)于Webpack。
- Parcel(2018)是一個(gè)低配置的打包工具,旨在開(kāi)箱即用,為構(gòu)建過(guò)程的所有步驟和開(kāi)發(fā)者工具需求提供合理的默認(rèn)配置。它是多線程的,速度比Webpack和Rollup快得多。Parcel 2在底層使用SWC。
- Esbuild(2020)是一個(gè)為并行性和性能優(yōu)化而架構(gòu)的打包工具,用Go編寫。它的性能比Webpack、Rollup和Parcel高出數(shù)十倍。Esbuild實(shí)現(xiàn)了一個(gè)基本的轉(zhuǎn)譯器和一個(gè)壓縮工具。然而,它的功能不如其他打包工具,提供的插件API有限,不能直接修改AST??梢栽趥鬟f給esbuild之前對(duì)源文件進(jìn)行轉(zhuǎn)換,而不是使用esbuild插件修改源文件。
- Turbopack(2022)是一個(gè)支持增量重建的快速Rust打包工具。該項(xiàng)目由Vercel構(gòu)建,并由Webpack的創(chuàng)建者領(lǐng)導(dǎo)。目前處于測(cè)試階段,可以在Next.js中選擇使用。
如果你的模塊很少或網(wǎng)絡(luò)延遲很低(例如在本地環(huán)境中),可以跳過(guò)打包步驟。一些開(kāi)發(fā)服務(wù)器在開(kāi)發(fā)服務(wù)器中也選擇不打包模塊。
代碼拆分
默認(rèn)情況下,客戶端React應(yīng)用會(huì)被轉(zhuǎn)換為一個(gè)bundle。對(duì)于有很多頁(yè)面和功能的大型應(yīng)用,bundle可能非常大,抵消了打包的原始性能優(yōu)勢(shì)。
通過(guò)將bundle拆分成多個(gè)較小的bundle,或稱為代碼拆分,解決了這個(gè)問(wèn)題。一種常見(jiàn)的方法是將每個(gè)頁(yè)面拆分為一個(gè)單獨(dú)的bundle。在HTTP/2下,共享依賴項(xiàng)也可以被分解到它們自己的bundle中,以避免重復(fù),幾乎沒(méi)有成本。此外,大型模塊可以拆分為單獨(dú)的bundle,并按需延遲加載。
代碼拆分后,每個(gè)bundle的文件大小大大減小,但現(xiàn)在需要額外的網(wǎng)絡(luò)往返,從而可能重新引入瀑布式加載問(wèn)題。代碼拆分是一個(gè)權(quán)衡。
文件系統(tǒng)路由器,由Next.js流行起來(lái),優(yōu)化了代碼拆分的權(quán)衡。Next.js為每個(gè)頁(yè)面創(chuàng)建單獨(dú)的bundle,只包括該頁(yè)面導(dǎo)入的代碼。在加載一個(gè)頁(yè)面時(shí),會(huì)并行預(yù)加載該頁(yè)面使用的所有bundle。這優(yōu)化了bundle大小而不會(huì)重新引入瀑布式加載問(wèn)題。文件系統(tǒng)路由器通過(guò)為每個(gè)頁(yè)面創(chuàng)建一個(gè)入口點(diǎn)(pages/**/*.jsx),而不是傳統(tǒng)客戶端React應(yīng)用的單個(gè)入口點(diǎn)(index.jsx)來(lái)實(shí)現(xiàn)這一點(diǎn)。
搖樹(shù)
一個(gè)bundle由多個(gè)模塊組成,每個(gè)模塊包含一個(gè)或多個(gè)導(dǎo)出。通常,一個(gè)給定的bundle只使用其導(dǎo)入模塊的一個(gè)子集。打包工具可以在搖樹(shù)過(guò)程中移除未使用的模塊和導(dǎo)出。這樣優(yōu)化了bundle大小,提升了加載和解析時(shí)間。
搖樹(shù)依賴于對(duì)源文件的靜態(tài)分析,因此當(dāng)靜態(tài)分析變得更加困難時(shí),搖樹(shù)的效率會(huì)受到影響。兩個(gè)主要因素影響搖樹(shù)的效率:
- 模塊系統(tǒng): ES6模塊具有靜態(tài)導(dǎo)入和導(dǎo)出,而CommonJS模塊具有動(dòng)態(tài)導(dǎo)入和導(dǎo)出。因此,打包工具在搖樹(shù)ES6模塊時(shí)可以更加積極和高效。
- 副作用: package.json的sideEffects屬性聲明了一個(gè)模塊在導(dǎo)入時(shí)是否具有副作用。當(dāng)存在副作用時(shí),由于靜態(tài)分析的限制,未使用的模塊和導(dǎo)出可能無(wú)法被搖樹(shù)。
靜態(tài)資源
靜態(tài)資源,如CSS、圖片和字體,通常在打包步驟中被添加到可分發(fā)文件中。它們也可能在壓縮步驟中被優(yōu)化文件大小。
在Webpack之前,靜態(tài)資源在構(gòu)建管道中與源代碼分開(kāi)構(gòu)建,作為一個(gè)獨(dú)立的構(gòu)建任務(wù)。為了加載靜態(tài)資源,應(yīng)用必須通過(guò)它們?cè)诳煞职l(fā)文件中的最終路徑引用它們。因此,常常需要根據(jù)URL約定仔細(xì)組織資源(例如 /assets/css/banner.jpg 和 /assets/fonts/Inter.woff2)。
Webpack的 loader 允許從JavaScript中導(dǎo)入靜態(tài)資源,將代碼和靜態(tài)資源統(tǒng)一到一個(gè)依賴圖中,簡(jiǎn)化了它們的組織和加載。盡管如此,將靜態(tài)資源捆綁在JavaScript文件中會(huì)增加bundle大小,最好將靜態(tài)資源分離。
代碼壓縮
代碼壓縮主要是解決文件過(guò)大的問(wèn)題。壓縮工具可以在不改變代碼功能的情況下,減少文件的大小。對(duì)于JavaScript和CSS等代碼,壓縮工具可以縮短變量名、去除空白和注釋、刪除無(wú)用代碼,并優(yōu)化語(yǔ)言特性使用。對(duì)于其他靜態(tài)資源,壓縮工具也能優(yōu)化文件大小。通常,壓縮工具會(huì)在構(gòu)建過(guò)程的最后一步運(yùn)行。
目前常用的JavaScript壓縮工具包括Terser、esbuild和SWC。Terser是從不再維護(hù)的uglify-es分支出來(lái)的,用JavaScript編寫,因此速度較慢。而esbuild和SWC除了壓縮功能外,還有其他功能,并且速度比Terser更快。
常用的CSS壓縮工具有cssnano、csso和Lightning CSS。cssnano和csso是純CSS壓縮工具,用JavaScript編寫,因此速度較慢。Lightning CSS則是用Rust編寫的,聲稱速度比cssnano快100倍。此外,Lightning CSS還支持CSS轉(zhuǎn)換和打包功能。
開(kāi)發(fā)工具
基本的前端構(gòu)建管道可以生成優(yōu)化的生產(chǎn)發(fā)布版。然而,有許多工具可以增強(qiáng)基本構(gòu)建管道,提升開(kāi)發(fā)體驗(yàn)。
元框架
前端領(lǐng)域在選擇合適的工具包時(shí)常常令人困惑。例如,上述五種打包工具中,你應(yīng)該選擇哪一種?
元框架提供了一組經(jīng)過(guò)精選的工具包,包括構(gòu)建工具,它們可以協(xié)同工作,實(shí)現(xiàn)特定的應(yīng)用模式。例如,Next.js專注于服務(wù)器端渲染(SSR),而Remix則專注于漸進(jìn)增強(qiáng)。
元框架通常提供預(yù)配置的構(gòu)建系統(tǒng),省去了自己拼湊的麻煩。它們的構(gòu)建系統(tǒng)既有生產(chǎn)環(huán)境的配置,也有開(kāi)發(fā)服務(wù)器的配置。
與元框架類似,Vite等構(gòu)建工具也提供預(yù)配置的構(gòu)建系統(tǒng),適用于生產(chǎn)和開(kāi)發(fā)環(huán)境。不同的是,它們不強(qiáng)制特定的應(yīng)用模式,適用于一般的前端應(yīng)用。
源映射(Sourcemaps)
構(gòu)建管道生成的發(fā)布版對(duì)大多數(shù)人來(lái)說(shuō)是難以閱讀的。這使得調(diào)試錯(cuò)誤變得困難,因?yàn)殄e(cuò)誤的追蹤指向的是不可讀的代碼。
源映射解決了這個(gè)問(wèn)題,將發(fā)布版中的代碼映射回其原始源碼位置。瀏覽器和調(diào)試工具(如Sentry)使用源映射來(lái)恢復(fù)并顯示原始源碼。在生產(chǎn)環(huán)境中,源映射通常對(duì)瀏覽器隱藏,只上傳到調(diào)試工具,以避免公開(kāi)源碼。
構(gòu)建管道的每一步都可以生成源映射。如果使用多個(gè)構(gòu)建工具,源映射將形成一個(gè)鏈條(例如:source.js -> transpiler.map -> bundler.map -> minifier.map)。要找到壓縮代碼對(duì)應(yīng)的源碼,必須遍歷源映射鏈條。
然而,大多數(shù)工具無(wú)法解釋源映射鏈條;它們最多只期望每個(gè)文件有一個(gè)源映射。因此,源映射鏈條必須被壓平成一個(gè)源映射。預(yù)配置的構(gòu)建系統(tǒng)會(huì)解決這個(gè)問(wèn)題(如Vite的combineSourcemaps函數(shù))。
熱重載(Hot Reload)
開(kāi)發(fā)服務(wù)器通常提供熱重載功能,當(dāng)源代碼改變時(shí),自動(dòng)重新構(gòu)建新包并重新加載瀏覽器。雖然這比手動(dòng)重建和重新加載要好得多,但仍然有點(diǎn)慢,并且所有客戶端狀態(tài)在重新加載時(shí)都會(huì)丟失。
模塊熱替換(Hot Module Replacement)改進(jìn)了熱重載,通過(guò)在運(yùn)行的應(yīng)用程序中替換更改的包進(jìn)行原位更新。這保留了未更改模塊的客戶端狀態(tài),并減少了代碼更改到應(yīng)用更新之間的延遲。
然而,每次代碼更改都會(huì)觸發(fā)導(dǎo)入它的所有包的重建。這使得重建時(shí)間相對(duì)于包大小呈線性增長(zhǎng)。因此,在大型應(yīng)用中,模塊熱替換可能會(huì)因?yàn)橹亟ǔ杀镜脑黾佣兟?/p>
Vite倡導(dǎo)的無(wú)打包開(kāi)發(fā)服務(wù)器模式則不打包開(kāi)發(fā)服務(wù)器,而是直接向?yàn)g覽器提供每個(gè)源碼文件對(duì)應(yīng)的ESM模塊。在這種模式下,每次代碼更改只觸發(fā)一個(gè)模塊在前端的替換。這樣,刷新時(shí)間復(fù)雜度相對(duì)于應(yīng)用大小幾乎是恒定的。然而,如果模塊很多,初始頁(yè)面加載時(shí)間可能會(huì)變長(zhǎng)。
單一倉(cāng)庫(kù)(Monorepos)
在擁有多個(gè)團(tuán)隊(duì)或多個(gè)應(yīng)用的組織中,前端可能會(huì)被拆分成多個(gè)JavaScript包,但保留在一個(gè)倉(cāng)庫(kù)中。在這種架構(gòu)下,每個(gè)包都有自己的構(gòu)建步驟,共同形成包的依賴圖。應(yīng)用程序位于依賴圖的根部。
單一倉(cāng)庫(kù)工具負(fù)責(zé)協(xié)調(diào)依賴圖的構(gòu)建。它們通常提供增量重建、并行處理和遠(yuǎn)程緩存等功能。通過(guò)這些功能,大型代碼庫(kù)也能享受小型代碼庫(kù)的構(gòu)建時(shí)間。
標(biāo)準(zhǔn)的單一倉(cāng)庫(kù)工具如Bazel,支持多種語(yǔ)言、復(fù)雜的構(gòu)建圖和隔離執(zhí)行。然而,前端JavaScript生態(tài)系統(tǒng)是最難完全整合到這些工具中的,目前幾乎沒(méi)有先例。
幸運(yùn)的是,針對(duì)前端的單一倉(cāng)庫(kù)工具存在,但它們?nèi)狈azel等工具的靈活性和穩(wěn)健性,特別是隔離執(zhí)行。
目前常用的前端單一倉(cāng)庫(kù)工具是Nx和Turborepo。Nx更成熟,功能更豐富,而Turborepo是Vercel生態(tài)系統(tǒng)的一部分。過(guò)去,Lerna是將多個(gè)JavaScript包鏈接在一起并發(fā)布到NPM的標(biāo)準(zhǔn)工具。2022年,Nx團(tuán)隊(duì)接管了Lerna,現(xiàn)在Lerna在后臺(tái)使用Nx進(jìn)行構(gòu)建。
趨勢(shì)
最后,來(lái)說(shuō)一說(shuō)前端構(gòu)建的趨勢(shì)。
較新的構(gòu)建工具使用編譯語(yǔ)言編寫,注重性能。2019年前端構(gòu)建非常慢,但現(xiàn)代工具大大加快了速度。然而,現(xiàn)代工具的功能較少,有時(shí)與庫(kù)不兼容,因此舊代碼庫(kù)往往難以輕松切換到它們。
服務(wù)器端渲染(SSR)在Next.js興起后變得更受歡迎。SSR對(duì)前端構(gòu)建系統(tǒng)沒(méi)有引入任何根本性的不同。SSR應(yīng)用也必須向?yàn)g覽器提供JavaScript,因此它們執(zhí)行相同的構(gòu)建步驟。
本文譯自:https://sunsetglow.net/posts/frontend-build-systems.html