寫在前面
前端模塊化/構建工具從最開始的基於瀏覽器運行時加載的 RequireJs/Sea.js 到將所有資源組裝依賴打包 webpack/rollup/parcel的bundle類模塊化構建工具,再到現在的bundleless基於瀏覽器原生 ES 模塊的 snowpack/vite,前端的模塊化/構建工具發展到現在已經快 10 年了。
本文主要回顧 10 年間,前端模塊化/構建工具的發展歷程及其實現原理。
看完本文你可以學到以下知識:
- 模塊化規範方案
- 前端構建工具演變,對前端構建有一個系統性認識
- 各個工具誕生歷程及所解決的問題
- webpack/parcel/vite 的構建流程及原理分析
(因涉及一些歷史、趨勢,本文觀點僅代表個人主觀看法)
基於瀏覽器的模塊化
CommonJS
一切的開始要從CommonJS規範説起。
CommonJS 本來叫ServerJs,其目標本來是為瀏覽器之外的javascript代碼制定規範,在那時NodeJs還沒有出生,有一些零散的應用於服務端的JavaScript代碼,但是沒有完整的生態。
之後就是 NodeJs 從 CommonJS 社區的規範中吸取經驗創建了本身的模塊系統。
RequireJs 和 AMD
CommonJs 是一套同步模塊導入規範,但是在瀏覽器上還沒法實現同步加載,這一套規範在瀏覽器上明顯行不通,所以基於瀏覽器的異步模塊 AMD(Asynchronous Module Definition)規範誕生。
define(id?, dependencies?, factory);
define("alpha", ["require", "exports", "beta"], function (
require,
exports,
beta
) {
exports.verb = function () {
return beta.verb();
//Or:
return require("beta").verb();
};
});
AMD規範採用依賴前置,先把需要用到的依賴提前寫在 dependencies 數組裏,在所有依賴下載完成後再調用factory回調,通過傳參來獲取模塊,同時也支持require("beta")的方式來獲取模塊,但實際上這個require只是語法糖,模塊並非在require的時候導入,而是跟前面説的一樣在調用factory回調之前就被執行,關於依賴前置和執行時機這點在當時有很大的爭議,被 CommonJs社區所不容。
在當時瀏覽器上應用CommonJs還有另外一個流派 module/2.0, 其中有BravoJS的 Modules/2.0-draft 規範和 FlyScript的 Modules/Wrappings規範。
代碼實現大致如下:
module.declare(function (require, exports, module) {
var a = require("a");
exports.foo = a.name;
});
奈何RequireJs如日中天,根本爭不過。
關於這段的內容可以看玉伯的 前端模塊化開發那點歷史。
Sea.js 和 CMD
在不斷給 RequireJs 提建議,但不斷不被採納後,玉伯結合RequireJs和module/2.0規範寫出了基於 CMD(Common Module Definition)規範的Sea.js。
define(factory);
define(function (require, exports, module) {
var add = require("math").add;
exports.increment = function (val) {
return add(val, 1);
};
});
在 CMD 規範中,一個模塊就是一個文件。模塊只有在被require才會被執行。
相比於 AMD 規範,CMD 更加簡潔,而且也更加易於兼容 CommonJS 和 Node.js 的 Modules 規範。
總結
RequireJs和Sea.js都是利用動態創建script來異步加載 js 模塊的。
在作者還是前端小白使用這兩個庫的時候就很好奇它是怎麼在函數調用之前就獲取到其中的依賴的,後來看了源碼後恍然大悟,沒想到就是簡單的函數 toString 方法
通過對factory回調toString拿到函數的代碼字符串,然後通過正則匹配獲取require函數裏面的字符串依賴
這也是為什麼二者都不允許require更換名稱或者變量賦值,也不允許依賴字符串使用變量,只能使用字符串字面量的原因
規範之爭在當時還是相當混亂的,先有CommonJs社區,然後有了 AMD/CMD 規範和 NodeJs 的 module 規範,但是當那些CommonJs的實現庫逐漸沒落,並隨着NodeJs越來越火,我們口中所説的CommonJs 好像就只有 NodeJs所代表的modules了。
bundle 類的構建工具
Grunt
隨着NodeJs的逐漸流行,基於NodeJs的自動化構建工具Grunt誕生
Grunt可以幫我們自動化處理需要反覆重複的任務,例如壓縮(minification)、編譯、單元測試、linting 等,還有強大的插件生態。
Grunt採用配置化的思想:
module.exports = function (grunt) {
// Project configuration.
grunt.initConfig({
pkg: grunt.file.readJSON("package.json"),
uglify: {
options: {
banner:
'/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n',
},
build: {
src: "src/<%= pkg.name %>.js",
dest: "build/<%= pkg.name %>.min.js",
},
},
});
// 加載包含 "uglify" 任務的插件。
grunt.loadNpmTasks("grunt-contrib-uglify");
// 默認被執行的任務列表。
grunt.registerTask("default", ["uglify"]);
};
基於 nodejs 的一系列自動化工具的出現,也標誌着前端進入了新的時代。
browserify
browserify致力於在瀏覽器端使用CommonJs,他使用跟 NodeJs 一樣的模塊化語法,然後將所有依賴文件編譯到一個bundle文件,在瀏覽器通過<script>標籤使用的,並且支持 npm 庫。
var foo = require("./foo.js");
var gamma = require("gamma");
var elem = document.getElementById("result");
var x = foo(100);
elem.textContent = gamma(x);
$ browserify main.js > bundle.js
當時RequireJs(r.js)雖然也有了 node 端的 api 可以編譯AMD語法輸出到單個文件,但主流的還是使用瀏覽器端的RequireJs。
AMD / RequireJS:
require(["./thing1", "./thing2", "./thing3"], function (
thing1,
thing2,
thing3
) {
// 告訴模塊返回/導出什麼
return function () {
console.log(thing1, thing2, thing3);
};
});
CommonJS:
var thing1 = require("./thing1");
var thing2 = require("./thing2");
var thing3 = require("./thing3");
// 告訴模塊返回/導出什麼
module.exports = function () {
console.log(thing1, thing2, thing3);
};
相比於 AMD 規範為瀏覽器做出的妥協,在服務端的預編譯方面CommonJs的語法更加友好。
常用的搭配就是 browserify + Grunt,使用Grunt的browserify插件來構建模塊化代碼,並對代碼進行壓縮轉換等處理。
UMD
現在有了RequireJs,也有了browserify但是這兩個用的是不同的模塊化規範,所以有了 UMD - 通用模塊規範,UMD 規範就是為了兼容AMD和CommonJS規範。就是以下這坨東西:
(function (global, factory) {
typeof exports === "object" && typeof module !== "undefined"
? (module.exports = factory())
: typeof define === "function" && define.amd
? define(factory)
: (global.libName = factory());
})(this, function () {
"use strict";
});
Gulp
上面説到Grunt是基於配置的,配置化的上手難度較高,需要了解每個配置的參數,當配置複雜度上升的時候,代碼看起來比較混亂。
gulp 基於代碼配置和對 Node.js 流的應用使得構建更簡單、更直觀。可以配置更加複雜的任務。
var browserify = require("browserify");
var source = require("vinyl-source-stream");
var buffer = require("vinyl-buffer");
var uglify = require("gulp-uglify");
var size = require("gulp-size");
var gulp = require("gulp");
gulp.task("build", function () {
var bundler = browserify("./index.js");
return bundler
.bundle()
.pipe(source("index.js"))
.pipe(buffer())
.pipe(uglify())
.pipe(size())
.pipe(gulp.dest("dist/"));
});
以上是一個配置browserify的例子,可以看出來非常簡潔直觀。
webpack
在説webpack之前,先放一下阮一峯老師的吐槽
webpack1支持CommonJs和AMD模塊化系統,優化依賴關係,支持分包,支持多種類型 script、image、file、css/less/sass/stylus、mocha/eslint/jshint 的打包,豐富的插件體系。
以上的 3 個庫 Grunt/Gulp/browserify 都是偏向於工具,而 webpack將以上功能都集成到一起,相比於工具它的功能大而全。
webpack的概念更偏向於工程化,但是在當時並沒有馬上火起來,因為當時的前端開發並沒有太複雜,有一些 mvc 框架但都是曇花一現,前端的技術棧在 requireJs/sea.js、grunt/gulp、browserify、webpack 這幾個工具之間抉擇。
webpack真正的火起來是在2015/2016,隨着ES2015(ES6)發佈,不止帶來了新語法,也帶來了屬於前端的模塊規範ES module,vue/react/Angular三大框架打得火熱,webpack2 發佈:支持ES module、babel、typescript,jsx,Angular 2 組件和 vue 組件,webpack搭配react/vue/Angular成為最佳選擇,至此前端開發離不開webpack,webpack真正成為前端工程化的核心。
webpack的其他功能就不在這裏贅述。
原理
webpack主要的三個模塊就是,後兩個也是我們經常配置的:
- 核心流程
- loader
- plugins
webpack依賴於Tapable做事件分發,內部有大量的hooks鈎子,在Compiler和compilation 核心流程中通過鈎子分發事件,在plugins中註冊鈎子,實際代碼全都由不同的內置 plugins 來執行,而 loader 在中間負責轉換代碼接受一個源碼處理後返回處理結果content string -> result string。
因為鈎子太多了,webpack 源碼看起來十分的繞,簡單説一下大致流程:
- 通過命令行和
webpack.config.js來獲取參數 - 創建
compiler對象,初始化plugins - 開始編譯階段,
addEntry添加入口資源 addModule創建模塊runLoaders執行loader- 依賴收集,js 通過
acorn解析為AST,然後查找依賴,並重復 4 步 - 構建完依賴樹後,進入生成階段,調用
compilation.seal - 經過一系列的
optimize優化依賴,生成chunks,寫入文件
webpack的優點就不用説了,現在説一下 2 個缺點:
- 配置複雜
- 大型項目構建慢
配置複雜這一塊一直是webpack被吐槽的一點,主要還是過重的插件系統,複雜的插件配置,插件文檔也不清晰,更新過快插件沒跟上或者文檔沒跟上等問題。
比如現在 webpack 已經到 5 了網上一搜全都是 webpack3 的文章,往往是新增一個功能,按照文檔配置完後,誒有報錯,網上一頓查,這裏拷貝一段,那裏拷貝一段,又來幾個報錯,又經過一頓搞後終於可以運行。
後來針對這個問題,衍生出了前端腳手架,react出了create-react-app,vue出了vue-cli,腳手架內置了webpack開發中的常用配置,達到了 0 配置,開發者無需關心 webpack 的複雜配置。
rollup
2015 年,前端的ES module發佈後,rollup應聲而出。
rollup編譯ES6模塊,提出了Tree-shaking,根據ES module靜態語法特性,刪除未被實際使用的代碼,支持導出多種規範語法,並且導出的代碼非常簡潔,如果看過 vue 的dist 目錄代碼就知道導出的 vue 代碼完全不影響閲讀。
rollup的插件系統支持:babel、CommonJs、terser、typescript等功能。
相比於browserify的CommonJs,rollup專注於ES module。
相比於webpack大而全的前端工程化,rollup專注於純javascript,大多被用作打包tool工具或library庫。
react、vue 等庫都使用rollup打包項目,並且下面説到的vite也依賴rollup用作生產環境打包 js。
Tree-shaking
export const a = 1;
export const b = 2;
import { a } from "./num";
console.log(a);
以上代碼最終打包後 b 的聲明就會被刪除掉。
這依賴ES module的靜態語法,在編譯階段就可以確定模塊的導入導出有哪些變量。
CommonJs 因為是基於運行時的模塊導入,其導出的是一個整體,並且require(variable)內容可以為變量,所以在ast編譯階段沒辦法識別為被使用的依賴。
webpack4中也開始支持tree-shaking,但是因為歷史原因,有太多的基於CommonJS代碼,需要額外的配置。
parcel
上面提到過webpack的兩個缺點,而parcel的誕生就是為了解決這兩個缺點,parcel 主打極速零配置。
| 打包工具 | 時間 |
|---|---|
| browserify | 22.98s |
| webpack | 20.71s |
| parcel | 9.98s |
| parcel - with cache | 2.64s |
以上是 parcel 官方的一個數據,基於一個合理大小的應用,包含 1726 個模塊,6.5M 未壓縮大小。在一台有 4 個物理核心 CPU 的 2016 MacBook Pro 上構建。
parcel 使用 worker 進程去啓用多核編譯,並且使用文件緩存。
parcel 支持 0 配置,內置了 html、babel、typescript、less、sass、vue等功能,無需配置,並且不同於webpack只能將 js 文件作為入口,在 parcel 中萬物皆資源,所以 html 文件 css 文件都可以作為入口來打包。
所以不需要webpack的複雜配置,只需要一個parcel index.html命令就可以直接起一個自帶熱更新的server來開發vue/react項目。
parcel 也有它的缺點:
- 0 配置的代價,0 配置是好,但是如果想要配置一些複雜的配置就很麻煩。
- 生態,相比於
webpack比較小眾,如果遇到錯誤查找解決方案比較麻煩。
原理
commander獲取命令- 啓動
server服務,啓動watch監聽文件,啓動WebSocket服務用於 hmr,啓動多線程 - 如果是第一次啓動,針對入口文件開始編譯
- 根據擴展名生成對應
asset資源,例如jsAsset、cssAsset、vueAsset,如果parcel識別less文件後項目內如果沒有less庫會自動安裝 - 讀取緩存,如果有緩存跳到第 7 步
- 多線程編譯文件,調用
asset內方法parse -> ast -> 收集依賴 -> transform(轉換代碼) -> generate(生成代碼),在這個過程中收集到依賴,編譯完結果寫入緩存 - 編譯依賴文件,重複第 4 步開始
createBundleTree創建依賴樹,替換 hash 等,package打包生成最終代碼- 當
watch文件發生變化,重複第 4 步,並將結果 7 通過WebSocket發送到瀏覽器,進行熱更新。
一個完整的模塊化打包工具就以上功能和流程。
基於瀏覽器 ES 模塊的構建工具
browserify、webpack、rollup、parcel這些工具的思想都是遞歸循環依賴,然後組裝成依賴樹,優化完依賴樹後生成代碼。
但是這樣做的缺點就是慢,需要遍歷完所有依賴,即使 parcel 利用了多核,webpack 也支持多線程,在打包大型項目的時候依然慢可能會用上幾分鐘,存在性能瓶頸。
所以基於瀏覽器原生 ESM 的運行時打包工具出現:
僅打包屏幕中用到的資源,而不用打包整個項目,開發時的體驗相比於 bundle類的工具只能用極速來形容。
(實際生產環境打包依然會構建依賴方式打包)
snowpack 和 vite
因為 snowpack 和 vite 比較類似,都是bundleless所以一起拿來説,區別可以看一下 vite 和 snowpack 區別,這裏就不贅述了。
bundleless類運行時打包工具的啓動速度是毫秒級的,因為不需要打包任何內容,只需要起兩個server,一個用於頁面加載,另一個用於HMR的WebSocket,當瀏覽器發出原生的ES module請求,server收到請求只需編譯當前文件後返回給瀏覽器不需要管依賴。
bundleless工具在生產環境打包的時候依然bundle構建所以依賴視圖的方式,vite 是利用 rollup 打包生產環境的 js 的。
原理拿 vite 舉例:
vite在啓動服務器後,會預先以所有 html 為入口,使用 esbuild 編譯一遍,把所有的 node_modules 下的依賴編譯並緩存起來,例如vue緩存為單個文件。
當打開在瀏覽器中輸入鏈接,渲染index.html文件的時候,利用瀏覽器自帶的ES module來請求文件。
<script type="module" src="/src/main.js"></script>
vite 收到一個src/main.js的 http 文件請求,使用esbuild開始編譯main.js,這裏不進行main.js裏面的依賴編譯。
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
瀏覽器獲取到並編譯main.js後,再次發出 2 個請求,一個是 vue 的請求,因為前面已經説了 vue 被預先緩存下來,直接返回緩存給瀏覽器,另一個是App.vue文件,這個需要@vitejs/plugin-vue來編譯,編譯完成後返回結果給瀏覽器(@vitejs/plugin-vue會在腳手架創建模板的時候自動配置)。
因為是基於瀏覽器的ES module,所以編譯過程中需要把一些 CommonJs、UMD 的模塊都轉成 ESM。
Vite 同時利用 HTTP 頭來加速整個頁面的重新加載(再次讓瀏覽器為我們做更多事情):源碼模塊的請求會根據 304 Not Modified 進行協商緩存,而依賴模塊請求則會通過 Cache-Control: max-age=31536000,immutable 進行強緩存,因此一旦被緩存它們將不需要再次請求,即使緩存失效只要服務沒有被殺死,編譯結果依然保存在程序內存中也會很快返回。
上面多次提到了esbuild,esbuild使用 go 語言編寫,所以在 i/o 和運算運行速度上比解釋性語言 NodeJs 快得多,esbuild 號稱速度是 node 寫的其他工具的 10~100 倍。
ES module 依賴運行時編譯的概念 + esbuild + 緩存 讓 vite 的速度遠遠甩開其他構建工具。
總結
簡單的彙總:
-
前端運行時模塊化
RequireJsAMD 規範sea.jsCMD 規範
-
自動化工具
Grunt基於配置Gulp基於代碼和文件流
-
模塊化
browserify基於CommonJs規範只負責模塊化rollup基於ES module,tree shaking優化代碼,支持多種規範導出,可通過插件集成壓縮、編譯、commonjs 語法 等功能
-
工程化
webpack大而全的模塊化構建工具parcel極速 0 配置的模塊化構建工具snowpack/viteESM運行時模塊化構建工具
這 10 年,前端的構建工具隨着 nodejs 的逐漸成熟衍生出一系列的工具,除了文中列舉的還有一些其他的工具,或者基於這些工具二次封裝,在nodejs出現之前前端也不是沒有構建工具雖然很少,只能説nodejs的出現讓更多人可以參與進來,尤其是前端可以使用本身熟悉的語言參與到開發工具使用工具中,npm 上至今已經有 17 萬個包,周下載量 300 億。
在這個過程中也有些模塊化歷史遺留問題,我們現在還在使用着 UMD 規範庫來兼容這 AMD 規範,npm 的包大都是基於CommonJs,不得不兼容ESM和CommonJs。
webpack統治前端已經 5 年,人們提到開發項目只會想到 webpack,而下一個 5 年會由誰來替代?snowpack/vite嗎,當打包速度達到 0 秒後,未來有沒有可能出現新一代的構建工具?下一個 10 年前端又會有什麼變化?