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