前端構(gòu)建這十年

◆ 寫在前面
前端模塊化/構(gòu)建工具從最開始的基于瀏覽器運(yùn)行時(shí)加載的 RequireJs/Sea.js 到將所有資源組裝依賴打包 webpack/rollup/parcel的bundle類模塊化構(gòu)建工具,再到現(xiàn)在的bundleless基于瀏覽器原生 ES 模塊的 snowpack/vite,前端的模塊化/構(gòu)建工具發(fā)展到現(xiàn)在已經(jīng)快 10 年了。
本文主要回顧 10 年間,前端模塊化/構(gòu)建工具的發(fā)展歷程及其實(shí)現(xiàn)原理。
看完本文你可以學(xué)到以下知識(shí):
模塊化規(guī)范方案
前端構(gòu)建工具演變,對(duì)前端構(gòu)建有一個(gè)系統(tǒng)性認(rèn)識(shí)
各個(gè)工具誕生歷程及所解決的問題
webpack/parcel/vite 的構(gòu)建流程及原理分析
(因涉及一些歷史、趨勢(shì),本文觀點(diǎn)僅代表個(gè)人主觀看法)
◆ 基于瀏覽器的模塊化
· CommonJS
一切的開始要從CommonJS規(guī)范說起。
CommonJS 本來叫ServerJs,其目標(biāo)本來是為瀏覽器之外的javascript代碼制定規(guī)范,在那時(shí)NodeJs還沒有出生,有一些零散的應(yīng)用于服務(wù)端的JavaScript代碼,但是沒有完整的生態(tài)。
之后就是 NodeJs 從 CommonJS 社區(qū)的規(guī)范中吸取經(jīng)驗(yàn)創(chuàng)建了本身的模塊系統(tǒng)。
· RequireJs 和 AMD
CommonJs 是一套同步模塊導(dǎo)入規(guī)范,但是在瀏覽器上還沒法實(shí)現(xiàn)同步加載,這一套規(guī)范在瀏覽器上明顯行不通,所以基于瀏覽器的異步模塊 AMD(Asynchronous Module Definition)規(guī)范誕生。
define(id?, dependencies?, factory);

AMD規(guī)范采用依賴前置,先把需要用到的依賴提前寫在 dependencies 數(shù)組里,在所有依賴下載完成后再調(diào)用factory回調(diào),通過傳參來獲取模塊,同時(shí)也支持require("beta")的方式來獲取模塊,但實(shí)際上這個(gè)require只是語(yǔ)法糖,模塊并非在require的時(shí)候?qū)耄歉懊嬲f的一樣在調(diào)用factory回調(diào)之前就被執(zhí)行,關(guān)于依賴前置和執(zhí)行時(shí)機(jī)這點(diǎn)在當(dāng)時(shí)有很大的爭(zhēng)議,被 CommonJs社區(qū)所不容。
在當(dāng)時(shí)瀏覽器上應(yīng)用CommonJs還有另外一個(gè)流派 module/2.0, 其中有BravoJS的 Modules/2.0-draft 規(guī)范和 FlyScript的 Modules/Wrappings規(guī)范。
代碼實(shí)現(xiàn)大致如下:

奈何RequireJs如日中天,根本爭(zhēng)不過。
關(guān)于這段的內(nèi)容可以看玉伯的 前端模塊化開發(fā)那點(diǎn)歷史。
· Sea.js 和 CMD
在不斷給 RequireJs 提建議,但不斷不被采納后,玉伯結(jié)合RequireJs和module/2.0規(guī)范寫出了基于 CMD(Common Module Definition)規(guī)范的Sea.js。
define(factory);

在 CMD 規(guī)范中,一個(gè)模塊就是一個(gè)文件。模塊只有在被require才會(huì)被執(zhí)行。
相比于 AMD 規(guī)范,CMD 更加簡(jiǎn)潔,而且也更加易于兼容 CommonJS 和 Node.js 的 Modules 規(guī)范。
· 總結(jié)
RequireJs和Sea.js都是利用動(dòng)態(tài)創(chuàng)建script來異步加載 js 模塊的。
在作者還是前端小白使用這兩個(gè)庫(kù)的時(shí)候就很好奇它是怎么在函數(shù)調(diào)用之前就獲取到其中的依賴的,后來看了源碼后恍然大悟,沒想到就是簡(jiǎn)單的函數(shù) toString 方法

通過對(duì)factory回調(diào)toString拿到函數(shù)的代碼字符串,然后通過正則匹配獲取require函數(shù)里面的字符串依賴
這也是為什么二者都不允許require更換名稱或者變量賦值,也不允許依賴字符串使用變量,只能使用字符串字面量的原因
規(guī)范之爭(zhēng)在當(dāng)時(shí)還是相當(dāng)混亂的,先有CommonJs社區(qū),然后有了 AMD/CMD 規(guī)范和 NodeJs 的 module 規(guī)范,但是當(dāng)那些CommonJs的實(shí)現(xiàn)庫(kù)逐漸沒落,并隨著NodeJs越來越火,我們口中所說的CommonJs 好像就只有 NodeJs所代表的modules了。
◆ bundle 類的構(gòu)建工具
· Grunt
隨著NodeJs的逐漸流行,基于NodeJs的自動(dòng)化構(gòu)建工具Grunt誕生
Grunt可以幫我們自動(dòng)化處理需要反復(fù)重復(fù)的任務(wù),例如壓縮(minification)、編譯、單元測(cè)試、linting 等,還有強(qiáng)大的插件生態(tài)。
Grunt采用配置化的思想:

基于 nodejs 的一系列自動(dòng)化工具的出現(xiàn),也標(biāo)志著前端進(jìn)入了新的時(shí)代。
· browserify
browserify致力于在瀏覽器端使用CommonJs,他使用跟 NodeJs 一樣的模塊化語(yǔ)法,然后將所有依賴文件編譯到一個(gè)bundle文件,在瀏覽器通過<script>標(biāo)簽使用的,并且支持 npm 庫(kù)。

當(dāng)時(shí)RequireJs(r.js)雖然也有了 node 端的 api 可以編譯AMD語(yǔ)法輸出到單個(gè)文件,但主流的還是使用瀏覽器端的RequireJs。
AMD / RequireJS:

CommonJS:

相比于 AMD 規(guī)范為瀏覽器做出的妥協(xié),在服務(wù)端的預(yù)編譯方面CommonJs的語(yǔ)法更加友好。
常用的搭配就是 browserify + Grunt,使用Grunt的browserify插件來構(gòu)建模塊化代碼,并對(duì)代碼進(jìn)行壓縮轉(zhuǎn)換等處理。
· UMD
現(xiàn)在有了RequireJs,也有了browserify但是這兩個(gè)用的是不同的模塊化規(guī)范,所以有了 UMD - 通用模塊規(guī)范,UMD 規(guī)范就是為了兼容AMD和CommonJS規(guī)范。就是以下這坨東西:

· Gulp
上面說到Grunt是基于配置的,配置化的上手難度較高,需要了解每個(gè)配置的參數(shù),當(dāng)配置復(fù)雜度上升的時(shí)候,代碼看起來比較混亂。gulp 基于代碼配置和對(duì) Node.js 流的應(yīng)用使得構(gòu)建更簡(jiǎn)單、更直觀。可以配置更加復(fù)雜的任務(wù)。

以上是一個(gè)配置browserify的例子,可以看出來非常簡(jiǎn)潔直觀。
· webpack
在說webpack之前,先放一下阮一峰老師的吐槽

webpack1支持CommonJs和AMD模塊化系統(tǒng),優(yōu)化依賴關(guān)系,支持分包,支持多種類型 script、image、file、css/less/sass/stylus、mocha/eslint/jshint 的打包,豐富的插件體系。
以上的 3 個(gè)庫(kù) Grunt/Gulp/browserify 都是偏向于工具,而 webpack將以上功能都集成到一起,相比于工具它的功能大而全。
webpack的概念更偏向于工程化,但是在當(dāng)時(shí)并沒有馬上火起來,因?yàn)楫?dāng)時(shí)的前端開發(fā)并沒有太復(fù)雜,有一些 mvc 框架但都是曇花一現(xiàn),前端的技術(shù)棧在 requireJs/sea.js、grunt/gulp、browserify、webpack 這幾個(gè)工具之間抉擇。
webpack真正的火起來是在2015/2016,隨著ES2015(ES6)發(fā)布,不止帶來了新語(yǔ)法,也帶來了屬于前端的模塊規(guī)范ES module,vue/react/Angular三大框架打得火熱,webpack2 發(fā)布:支持ES module、babel、typescript,jsx,Angular 2 組件和 vue 組件,webpack搭配react/vue/Angular成為最佳選擇,至此前端開發(fā)離不開webpack,webpack真正成為前端工程化的核心。
webpack的其他功能就不在這里贅述。
· 原理
webpack主要的三個(gè)模塊就是,后兩個(gè)也是我們經(jīng)常配置的:
核心流程
loader
plugins
webpack依賴于Tapable做事件分發(fā),內(nèi)部有大量的hooks鉤子,在Compiler和compilation 核心流程中通過鉤子分發(fā)事件,在plugins中注冊(cè)鉤子,實(shí)際代碼全都由不同的內(nèi)置 plugins 來執(zhí)行,而 loader 在中間負(fù)責(zé)轉(zhuǎn)換代碼接受一個(gè)源碼處理后返回處理結(jié)果content string -> result string。
因?yàn)殂^子太多了,webpack 源碼看起來十分的繞,簡(jiǎn)單說一下大致流程:
通過命令行和
webpack.config.js來獲取參數(shù)創(chuàng)建
compiler對(duì)象,初始化plugins開始編譯階段,
addEntry添加入口資源addModule創(chuàng)建模塊runLoaders執(zhí)行loader依賴收集,js 通過
acorn解析為AST,然后查找依賴,并重復(fù) 4 步構(gòu)建完依賴樹后,進(jìn)入生成階段,調(diào)用
compilation.seal經(jīng)過一系列的
optimize優(yōu)化依賴,生成chunks,寫入文件
webpack的優(yōu)點(diǎn)就不用說了,現(xiàn)在說一下 2 個(gè)缺點(diǎn):
配置復(fù)雜
大型項(xiàng)目構(gòu)建慢
配置復(fù)雜這一塊一直是webpack被吐槽的一點(diǎn),主要還是過重的插件系統(tǒng),復(fù)雜的插件配置,插件文檔也不清晰,更新過快插件沒跟上或者文檔沒跟上等問題。
比如現(xiàn)在 webpack 已經(jīng)到 5 了網(wǎng)上一搜全都是 webpack3 的文章,往往是新增一個(gè)功能,按照文檔配置完后,誒有報(bào)錯(cuò),網(wǎng)上一頓查,這里拷貝一段,那里拷貝一段,又來幾個(gè)報(bào)錯(cuò),又經(jīng)過一頓搞后終于可以運(yùn)行。
后來針對(duì)這個(gè)問題,衍生出了前端腳手架,react出了create-react-app,vue出了vue-cli,腳手架內(nèi)置了webpack開發(fā)中的常用配置,達(dá)到了 0 配置,開發(fā)者無需關(guān)心 webpack 的復(fù)雜配置。
· rollup
2015 年,前端的ES module發(fā)布后,rollup應(yīng)聲而出。
rollup編譯ES6模塊,提出了Tree-shaking,根據(jù)ES module靜態(tài)語(yǔ)法特性,刪除未被實(shí)際使用的代碼,支持導(dǎo)出多種規(guī)范語(yǔ)法,并且導(dǎo)出的代碼非常簡(jiǎn)潔,如果看過 vue 的dist 目錄代碼就知道導(dǎo)出的 vue 代碼完全不影響閱讀。
rollup的插件系統(tǒng)支持:babel、CommonJs、terser、typescript等功能。
相比于browserify的CommonJs,rollup專注于ES module。
相比于webpack大而全的前端工程化,rollup專注于純javascript,大多被用作打包tool工具或library庫(kù)。
react、vue 等庫(kù)都使用rollup打包項(xiàng)目,并且下面說到的vite也依賴rollup用作生產(chǎn)環(huán)境打包 js。
· Tree-shaking

以上代碼最終打包后 b 的聲明就會(huì)被刪除掉。
這依賴ES module的靜態(tài)語(yǔ)法,在編譯階段就可以確定模塊的導(dǎo)入導(dǎo)出有哪些變量。
CommonJs 因?yàn)槭腔谶\(yùn)行時(shí)的模塊導(dǎo)入,其導(dǎo)出的是一個(gè)整體,并且require(variable)內(nèi)容可以為變量,所以在ast編譯階段沒辦法識(shí)別為被使用的依賴。
webpack4中也開始支持tree-shaking,但是因?yàn)闅v史原因,有太多的基于CommonJS代碼,需要額外的配置。
· parcel
上面提到過webpack的兩個(gè)缺點(diǎn),而parcel的誕生就是為了解決這兩個(gè)缺點(diǎn),parcel 主打極速零配置。
| 打包工具 | 時(shí)間 |
|---|---|
| browserify | 22.98s |
| webpack | 20.71s |
| parcel | 9.98s |
| parcel - with cache | 2.64s |
以上是 parcel 官方的一個(gè)數(shù)據(jù),基于一個(gè)合理大小的應(yīng)用,包含 1726 個(gè)模塊,6.5M 未壓縮大小。在一臺(tái)有 4 個(gè)物理核心 CPU 的 2016 MacBook Pro 上構(gòu)建。
parcel 使用 worker 進(jìn)程去啟用多核編譯,并且使用文件緩存。
parcel 支持 0 配置,內(nèi)置了 html、babel、typescript、less、sass、vue等功能,無需配置,并且不同于webpack只能將 js 文件作為入口,在 parcel 中萬物皆資源,所以 html 文件 css 文件都可以作為入口來打包。
所以不需要webpack的復(fù)雜配置,只需要一個(gè)parcel index.html命令就可以直接起一個(gè)自帶熱更新的server來開發(fā)vue/react項(xiàng)目。
parcel 也有它的缺點(diǎn):
0 配置的代價(jià),0 配置是好,但是如果想要配置一些復(fù)雜的配置就很麻煩。
生態(tài),相比于
webpack比較小眾,如果遇到錯(cuò)誤查找解決方案比較麻煩。
· 原理
commander獲取命令啟動(dòng)
server服務(wù),啟動(dòng)watch監(jiān)聽文件,啟動(dòng)WebSocket服務(wù)用于 hmr,啟動(dòng)多線程如果是第一次啟動(dòng),針對(duì)入口文件開始編譯
根據(jù)擴(kuò)展名生成對(duì)應(yīng)
asset資源,例如jsAsset、cssAsset、vueAsset,如果parcel識(shí)別less文件后項(xiàng)目?jī)?nèi)如果沒有less庫(kù)會(huì)自動(dòng)安裝讀取緩存,如果有緩存跳到第 7 步
多線程編譯文件,調(diào)用
asset內(nèi)方法parse -> ast -> 收集依賴 -> transform(轉(zhuǎn)換代碼) -> generate(生成代碼),在這個(gè)過程中收集到依賴,編譯完結(jié)果寫入緩存編譯依賴文件,重復(fù)第 4 步開始
createBundleTree創(chuàng)建依賴樹,替換 hash 等,package打包生成最終代碼當(dāng)
watch文件發(fā)生變化,重復(fù)第 4 步,并將結(jié)果 7 通過WebSocket發(fā)送到瀏覽器,進(jìn)行熱更新。
一個(gè)完整的模塊化打包工具就以上功能和流程。
◆ 基于瀏覽器 ES 模塊的構(gòu)建工具
browserify、webpack、rollup、parcel這些工具的思想都是遞歸循環(huán)依賴,然后組裝成依賴樹,優(yōu)化完依賴樹后生成代碼。
但是這樣做的缺點(diǎn)就是慢,需要遍歷完所有依賴,即使 parcel 利用了多核,webpack 也支持多線程,在打包大型項(xiàng)目的時(shí)候依然慢可能會(huì)用上幾分鐘,存在性能瓶頸。
所以基于瀏覽器原生 ESM 的運(yùn)行時(shí)打包工具出現(xiàn):


僅打包屏幕中用到的資源,而不用打包整個(gè)項(xiàng)目,開發(fā)時(shí)的體驗(yàn)相比于 bundle類的工具只能用極速來形容。
(實(shí)際生產(chǎn)環(huán)境打包依然會(huì)構(gòu)建依賴方式打包)
· snowpack 和 vite
因?yàn)?nbsp;snowpack 和 vite 比較類似,都是bundleless所以一起拿來說,區(qū)別可以看一下 vite 和 snowpack 區(qū)別,這里就不贅述了。
bundleless類運(yùn)行時(shí)打包工具的啟動(dòng)速度是毫秒級(jí)的,因?yàn)椴恍枰虬魏蝺?nèi)容,只需要起兩個(gè)server,一個(gè)用于頁(yè)面加載,另一個(gè)用于HMR的WebSocket,當(dāng)瀏覽器發(fā)出原生的ES module請(qǐng)求,server收到請(qǐng)求只需編譯當(dāng)前文件后返回給瀏覽器不需要管依賴。
bundleless工具在生產(chǎn)環(huán)境打包的時(shí)候依然bundle構(gòu)建所以依賴視圖的方式,vite 是利用 rollup 打包生產(chǎn)環(huán)境的 js 的。
原理拿 vite 舉例:
vite在啟動(dòng)服務(wù)器后,會(huì)預(yù)先以所有 html 為入口,使用 esbuild 編譯一遍,把所有的 node_modules 下的依賴編譯并緩存起來,例如vue緩存為單個(gè)文件。
當(dāng)打開在瀏覽器中輸入鏈接,渲染index.html文件的時(shí)候,利用瀏覽器自帶的ES module來請(qǐng)求文件。

vite 收到一個(gè)src/main.js的 http 文件請(qǐng)求,使用esbuild開始編譯main.js,這里不進(jìn)行main.js里面的依賴編譯。

瀏覽器獲取到并編譯main.js后,再次發(fā)出 2 個(gè)請(qǐng)求,一個(gè)是 vue 的請(qǐng)求,因?yàn)榍懊嬉呀?jīng)說了 vue 被預(yù)先緩存下來,直接返回緩存給瀏覽器,另一個(gè)是App.vue文件,這個(gè)需要@vitejs/plugin-vue來編譯,編譯完成后返回結(jié)果給瀏覽器(@vitejs/plugin-vue會(huì)在腳手架創(chuàng)建模板的時(shí)候自動(dòng)配置)。
因?yàn)槭腔跒g覽器的ES module,所以編譯過程中需要把一些 CommonJs、UMD 的模塊都轉(zhuǎn)成 ESM。
Vite 同時(shí)利用 HTTP 頭來加速整個(gè)頁(yè)面的重新加載(再次讓瀏覽器為我們做更多事情):源碼模塊的請(qǐng)求會(huì)根據(jù) 304 Not Modified 進(jìn)行協(xié)商緩存,而依賴模塊請(qǐng)求則會(huì)通過 Cache-Control: max-age=31536000,immutable 進(jìn)行強(qiáng)緩存,因此一旦被緩存它們將不需要再次請(qǐng)求,即使緩存失效只要服務(wù)沒有被殺死,編譯結(jié)果依然保存在程序內(nèi)存中也會(huì)很快返回。
上面多次提到了esbuild,esbuild使用 go 語(yǔ)言編寫,所以在 i/o 和運(yùn)算運(yùn)行速度上比解釋性語(yǔ)言 NodeJs 快得多,esbuild 號(hào)稱速度是 node 寫的其他工具的 10~100 倍。

ES module 依賴運(yùn)行時(shí)編譯的概念 + esbuild + 緩存 讓 vite 的速度遠(yuǎn)遠(yuǎn)甩開其他構(gòu)建工具。
· 總結(jié)
簡(jiǎn)單的匯總:
前端運(yùn)行時(shí)模塊化
RequireJsAMD 規(guī)范sea.jsCMD 規(guī)范自動(dòng)化工具
Grunt基于配置Gulp基于代碼和文件流模塊化
browserify基于CommonJs規(guī)范只負(fù)責(zé)模塊化rollup基于ES module,tree shaking優(yōu)化代碼,支持多種規(guī)范導(dǎo)出,可通過插件集成壓縮、編譯、commonjs 語(yǔ)法 等功能工程化
webpack大而全的模塊化構(gòu)建工具parcel極速 0 配置的模塊化構(gòu)建工具snowpack/viteESM運(yùn)行時(shí)模塊化構(gòu)建工具
這 10 年,前端的構(gòu)建工具隨著 nodejs 的逐漸成熟衍生出一系列的工具,除了文中列舉的還有一些其他的工具,或者基于這些工具二次封裝,在nodejs出現(xiàn)之前前端也不是沒有構(gòu)建工具雖然很少,只能說nodejs的出現(xiàn)讓更多人可以參與進(jìn)來,尤其是前端可以使用本身熟悉的語(yǔ)言參與到開發(fā)工具使用工具中,npm 上至今已經(jīng)有 17 萬個(gè)包,周下載量 300 億。
在這個(gè)過程中也有些模塊化歷史遺留問題,我們現(xiàn)在還在使用著 UMD 規(guī)范庫(kù)來兼容這 AMD 規(guī)范,npm 的包大都是基于CommonJs,不得不兼容ESM和CommonJs。
webpack統(tǒng)治前端已經(jīng) 5 年,人們提到開發(fā)項(xiàng)目只會(huì)想到 webpack,而下一個(gè) 5 年會(huì)由誰來替代?snowpack/vite嗎,當(dāng)打包速度達(dá)到 0 秒后,未來有沒有可能出現(xiàn)新一代的構(gòu)建工具?下一個(gè) 10 年前端又會(huì)有什么變化?
