前端模塊化/構建工具從最開始的基於瀏覽器運行時加載的 RequireJs/Sea.js
到將全部資源組裝依賴打包 webpack
/rollup
/parcel
的bundle
類模塊化構建工具,再到如今的bundleless
基於瀏覽器原生 ES 模塊的 snowpack
/vite
,前端的模塊化/構建工具發展到如今已經快 10 年了。javascript
本文主要回顧 10 年間,前端模塊化/構建工具的發展歷程及其實現原理。css
(因涉及一些歷史、趨勢,本文觀點僅表明我的主觀見解)html
一切的開始要從CommonJS規範提及。前端
CommonJS
原本叫ServerJs,其目標原本是爲瀏覽器以外的javascript
代碼制定規範,在那時NodeJs
尚未出生,有一些零散的應用於服務端的JavaScript
代碼,可是沒有完整的生態。vue
以後就是 NodeJs
從 CommonJS
社區的規範中吸收經驗建立了自己的模塊系統。java
CommonJs
是一套同步模塊導入規範,可是在瀏覽器上還無法實現同步加載,這一套規範在瀏覽器上明顯行不通,因此基於瀏覽器的異步模塊 AMD
(Asynchronous Module Definition)規範誕生。node
define(id?, dependencies?, factory);
react
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
社區所不容。webpack
在當時瀏覽器上應用CommonJs
還有另一個流派 module/2.0
, 其中有BravoJS
的 Modules/2.0-draft 規範和 FlyScript
的 Modules/Wrappings規範。git
代碼實現大體以下:
module.declare(function (require, exports, module) { var a = require("a"); exports.foo = a.name; });
奈何RequireJs
如日中天,根本爭不過。
關於這段的內容能夠看玉伯的 前端模塊化開發那點歷史。
在不斷給 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
了。
隨着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
致力於在瀏覽器端使用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
插件來構建模塊化代碼,並對代碼進行壓縮轉換等處理。
如今有了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"; });
上面說到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
以前,先放一下阮一峯老師的吐槽
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
主要的三個模塊就是,後兩個也是咱們常常配置的:
webpack
依賴於Tapable
作事件分發,內部有大量的hooks
鉤子,在Compiler
和compilation
核心流程中經過鉤子分發事件,在plugins
中註冊鉤子,實際代碼全都由不一樣的內置 plugins
來執行,而 loader
在中間負責轉換代碼接受一個源碼處理後返回處理結果content string -> result string
。
由於鉤子太多了,webpack
源碼看起來十分的繞,簡單說一下大體流程:
webpack.config.js
來獲取參數compiler
對象,初始化plugins
addEntry
添加入口資源addModule
建立模塊runLoaders
執行 loader
acorn
解析爲 AST
,而後查找依賴,並重復 4 步compilation.seal
optimize
優化依賴,生成 chunks
,寫入文件webpack
的優勢就不用說了,如今說一下 2 個缺點:
配置複雜這一塊一直是webpack
被吐槽的一點,主要仍是太重的插件系統,複雜的插件配置,插件文檔也不清晰,更新過快插件沒跟上或者文檔沒跟上等問題。
好比如今 webpack
已經到 5 了網上一搜全都是 webpack3
的文章,每每是新增一個功能,按照文檔配置完後,誒有報錯,網上一頓查,這裏拷貝一段,那裏拷貝一段,又來幾個報錯,又通過一頓搞後終於能夠運行。
後來針對這個問題,衍生出了前端腳手架,react
出了create-react-app
,vue
出了vue-cli
,腳手架內置了webpack
開發中的經常使用配置,達到了 0 配置,開發者無需關心 webpack
的複雜配置。
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。
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
代碼,須要額外的配置。
上面提到過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 也有它的缺點:
webpack
比較小衆,若是遇到錯誤查找解決方案比較麻煩。commander
獲取命令server
服務,啓動 watch
監聽文件,啓動 WebSocket
服務用於 hmr,啓動多線程asset
資源,例如jsAsset
、cssAsset
、vueAsset
,若是parcel
識別 less
文件後項目內若是沒有 less
庫會自動安裝asset
內方法parse -> ast -> 收集依賴 -> transform(轉換代碼) -> generate(生成代碼)
,在這個過程當中收集到依賴,編譯完結果寫入緩存createBundleTree
建立依賴樹,替換 hash 等,package
打包生成最終代碼watch
文件發生變化,重複第 4 步,並將結果 7 經過WebSocket
發送到瀏覽器,進行熱更新。一個完整的模塊化打包工具就以上功能和流程。
browserify
、webpack
、rollup
、parcel
這些工具的思想都是遞歸循環依賴,而後組裝成依賴樹,優化完依賴樹後生成代碼。
可是這樣作的缺點就是慢,須要遍歷完全部依賴,即便 parcel
利用了多核,webpack
也支持多線程,在打包大型項目的時候依然慢可能會用上幾分鐘,存在性能瓶頸。
因此基於瀏覽器原生 ESM
的運行時打包工具出現:
僅打包屏幕中用到的資源,而不用打包整個項目,開發時的體驗相比於 bundle
類的工具只能用極速來形容。
(實際生產環境打包依然會構建依賴方式打包)
由於 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
的速度遠遠甩開其餘構建工具。
簡單的彙總:
前端運行時模塊化
RequireJs
AMD 規範sea.js
CMD 規範自動化工具
Grunt
基於配置Gulp
基於代碼和文件流模塊化
browserify
基於CommonJs
規範只負責模塊化rollup
基於ES module
,tree shaking
優化代碼,支持多種規範導出,可經過插件集成壓縮、編譯、commonjs 語法 等功能工程化
webpack
大而全的模塊化構建工具parcel
極速 0 配置的模塊化構建工具snowpack/vite
ESM
運行時模塊化構建工具這 10 年,前端的構建工具隨着 nodejs
的逐漸成熟衍生出一系列的工具,除了文中列舉的還有一些其餘的工具,或者基於這些工具二次封裝,在nodejs
出現以前前端也不是沒有構建工具雖然不多,只能說nodejs
的出現讓更多人能夠參與進來,尤爲是前端可使用自己熟悉的語言參與到開發工具使用工具中,npm 上至今已經有 17 萬個包,周下載量 300 億。
在這個過程當中也有些模塊化歷史遺留問題,咱們如今還在使用着 UMD 規範庫來兼容這 AMD 規範,npm 的包大都是基於CommonJs
,不得不兼容ESM
和CommonJs
。
webpack
統治前端已經 5 年,人們提到開發項目只會想到 webpack
,而下一個 5 年會由誰來替代?snowpack/vite
嗎,當打包速度達到 0 秒後,將來有沒有可能出現新一代的構建工具?下一個 10 年前端又會有什麼變化?