gulp、webpack、rollup、vite實(shí)現(xiàn)原理
大廠技術(shù) 高級(jí)前端 Node進(jìn)階
點(diǎn)擊上方 程序員成長(zhǎng)指北,關(guān)注公眾號(hào)
回復(fù)1,加入高級(jí)Node交流群
序言
現(xiàn)在前端項(xiàng)目的開發(fā)過程離不開構(gòu)建工具幫助,面對(duì)琳瑯滿目的構(gòu)建工具我們?cè)撊绾芜x擇最合適自己場(chǎng)景的構(gòu)建工具是一個(gè)問題。在研究各種配置之余,我們?nèi)パ芯恳幌聵?gòu)建工具發(fā)展過程、底層原理,面對(duì)一些問題的時(shí)候往往事半功倍。
通過本文你可以了解到:
前端構(gòu)建工具的進(jìn)化歷程 前端構(gòu)建工具技術(shù)方案對(duì)比 常用構(gòu)建工具核心實(shí)現(xiàn)原理
什么是構(gòu)建?

構(gòu)建簡(jiǎn)單的說就是將我們開發(fā)環(huán)境的代碼,轉(zhuǎn)化成生產(chǎn)環(huán)境可用來部署的代碼。
市面上存在很多構(gòu)建工具,但是最終的目標(biāo)都是轉(zhuǎn)化開發(fā)環(huán)境的代碼為生產(chǎn)環(huán)境中可用的代碼。在不同的前端項(xiàng)目中使用的技術(shù)棧是不一樣的,比如:不同的框架、不同的樣式處理方案等,為了生產(chǎn)出生產(chǎn)環(huán)境可用的 JS、CSS,構(gòu)建工具實(shí)現(xiàn)了諸如:代碼轉(zhuǎn)換、代碼壓縮、tree shaking、code spliting 等。
前端構(gòu)建工具可以做什么?

前端構(gòu)建工具進(jìn)化史

無模塊化時(shí)代
YUI Tool + Ant
YUI Tool 是 07 年左右出現(xiàn)的一個(gè)構(gòu)建工具,可以實(shí)現(xiàn)壓縮混淆前端代碼,依賴于 java 的 ant 使用。
在早期 web 應(yīng)用開發(fā)主要采用 JSP,處于混合開發(fā)的狀態(tài),不像是現(xiàn)在的前后端分離開發(fā)。通常由 java 開發(fā)人員來編寫 js、css 代碼。此時(shí)出現(xiàn)的構(gòu)建工具依賴于別的語(yǔ)言實(shí)現(xiàn)。
JS 內(nèi)聯(lián)外聯(lián)
前端代碼是否必須通過構(gòu)建才可以在瀏覽器中運(yùn)行呢?當(dāng)然不是。如下:
<html>
<head>
<title>Hello World</title>
</head>
<body>
<div id="root"/>
<script type="text/javascript">
document.getElementById('root').innerText = 'Hello World'
</script>
</body>
</html>
上述代碼,我們只需要按格式寫幾個(gè) HTML 標(biāo)簽,插入簡(jiǎn)單的 JS 腳本,打開瀏覽器,一個(gè) Hello World 的前端頁(yè)面就呈現(xiàn)在我們面前了。但是當(dāng)項(xiàng)目進(jìn)入真正的實(shí)戰(zhàn)開發(fā),代碼規(guī)模開始急速擴(kuò)張后,大量邏輯混雜在一個(gè)文件之中就變得難以維護(hù)起來。早期的前端項(xiàng)目一般如下組織:
<html>
<head>
<title>JQuery</title>
</head>
<body>
<div id="root"/>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script type="text/javascript">
$(document).ready(function(){
$('#root')[0].innerText = 'Hello World'
})
</script>
</body>
</html>
通過 JS 的內(nèi)聯(lián)外聯(lián)組織代碼,將不同的代碼放在不同的文件中,但是這也僅僅解決了代碼組織混亂的問題,還存在很多問題:
大量的全局變量,代碼之間的依賴是不透明的,任何代碼都可能悄悄的改變了全局變量。 腳本的引入需要依賴特定的順序。
后續(xù)出現(xiàn)過一些 IIFE、命名空間等解決方案,但是從本質(zhì)上都沒有解決依賴全局變量通信的問題。在這樣的背景下,前端工程化成為解決此類問題的正軌。
社區(qū)模塊化時(shí)代
AMD/CMD - 異步模塊加載
為了解決瀏覽器端 JS 模塊化的問題,出現(xiàn)了通過引入相關(guān)工具庫(kù)的方式來解決這一問題。出現(xiàn)了兩種應(yīng)用比較廣的規(guī)范及其相關(guān)庫(kù):AMD(RequireJs) 和 CMD(Sea.js)。AMD 推崇依賴前置、提前執(zhí)行,CMD 推崇依賴就近、延遲執(zhí)行。下面領(lǐng)略下相關(guān)寫法:
Require.js
// 加載完jquery后,將執(zhí)行結(jié)果 $ 作為參數(shù)傳入了回調(diào)函數(shù)
define(["jquery"], function ($) {
$(document).ready(function(){
$('#root')[0].innerText = 'Hello World';
})
return $
})
Sea.js
// 預(yù)加載jquery
define(function(require, exports, module) {
// 執(zhí)行jquery模塊,并得到結(jié)果賦值給 $
var $ = require('jquery');
// 調(diào)用jquery.js模塊提供的方法
$('#header').hide();
});
兩種模塊化規(guī)范實(shí)現(xiàn)的原理基本上是一致的,只不過各自堅(jiān)持的理念不同。兩者都是以異步的方式獲取當(dāng)前模塊所需的模塊,不同的地方在于 AMD 在獲取到相關(guān)模塊后立即執(zhí)行,CMD 則是在用到相關(guān)模塊的位置再執(zhí)行的。
AMD/CMD 解決問題:
手動(dòng)維護(hù)代碼引用順序。從此不再需要手動(dòng)調(diào)整 HTML 文件中的腳本順序,依賴數(shù)組會(huì)自動(dòng)偵測(cè)模塊間的依賴關(guān)系,并自動(dòng)化的插入頁(yè)面。 全局變量污染問題。將模塊內(nèi)容在函數(shù)內(nèi)實(shí)現(xiàn),利用閉包導(dǎo)出的變量通信,不會(huì)存在全局變量污染的問題。
Grunt/Gulp
在 Google Chrome 推出 V8 引擎后,基于其高性能和平臺(tái)獨(dú)立的特性,Nodejs 這個(gè) JS 運(yùn)行時(shí)也現(xiàn)世了。至此,JS 打破了瀏覽器的限制,擁有了文件讀寫的能力。Nodejs 不僅在服務(wù)器領(lǐng)域占據(jù)一席之地,也將前端工程化帶進(jìn)了正軌。
在這個(gè)背景下,第一批基于 Node.js 的構(gòu)建工具出現(xiàn)了。
Grunt
Grunt[1] 主要能夠幫助我們自動(dòng)化的處理一些反復(fù)重復(fù)的任務(wù),例如壓縮、編譯、單元測(cè)試、linting 等。
// Gruntfile.js
module.exports = function(grunt) {
// 功能配置
grunt.initConfig({
// 定義任務(wù)
jshint: {
files: ['Gruntfile.js', 'src/**/*.js', 'test/**/*.js'],
options: {
globals: {
jQuery: true
}
}
},
// 時(shí)時(shí)偵聽文件變化所執(zhí)行的任務(wù)
watch: {
files: ['<%= jshint.files %>'],
tasks: ['jshint']
}
});
// 加載任務(wù)所需要的插件
grunt.loadNpmTasks('grunt-contrib-jshint');
grunt.loadNpmTasks('grunt-contrib-watch');
// 默認(rèn)執(zhí)行的任務(wù)
grunt.registerTask('default', ['jshint']);
};
Gulp
Grunt 的 I/O 操作比較“呆板”,每個(gè)任務(wù)執(zhí)行結(jié)束后都會(huì)將文件寫入磁盤,下個(gè)任務(wù)執(zhí)行時(shí)再將文件從磁盤中讀出,這樣的操作會(huì)產(chǎn)生一些問題:
運(yùn)行速度較慢 硬件壓力大
Gulp[2] 最大特點(diǎn)是引入了流的概念,同時(shí)提供了一系列常用的插件去處理流,流可以在插件之間傳遞。同時(shí) Gulp 設(shè)計(jì)簡(jiǎn)單,既可以單獨(dú)使用,也可以結(jié)合別的工具一起使用。
// gulpfile.js
const { src, dest } = require('gulp');
// gulp提供的一系列api
// src 讀取文件
// dest 寫入文件
const babel = require('gulp-babel');
exports.default = function() {
// 將src文件夾下的所有js文件取出,經(jīng)過Babel轉(zhuǎn)換后放入output文件夾之中
return src('src/*.js')
.pipe(babel())
.pipe(dest('output/'));
}
Browserify
隨著 Node.js 的興起,CommonJS 模塊化規(guī)范成為了當(dāng)時(shí)的主流規(guī)范。但是我們知道 CommonJS 所使用的 require 語(yǔ)法是同步的,當(dāng)代碼執(zhí)行到 require 方法的時(shí)候,必須要等這個(gè)模塊加載完后,才會(huì)執(zhí)行后面的代碼。這種方式在服務(wù)端是可行的,這是因?yàn)榉?wù)器只需要從本地磁盤中讀取文件,速度還是很快的,但是在瀏覽器端,我們通過網(wǎng)絡(luò)請(qǐng)求獲取文件,網(wǎng)絡(luò)環(huán)境以及文件大小都可能使頁(yè)面無響應(yīng)。
browserify[3] 致力于打包產(chǎn)出在瀏覽器端可以運(yùn)行的 CommonJS 規(guī)范的 JS 代碼。
var browserify = require('browserify')
var b = browserify()
var fs = require('fs')
// 添加入口文件
b.add('./src/browserifyIndex.js')
// 打包所有模塊至一個(gè)文件之中并輸出bundle
b.bundle().pipe(fs.createWriteStream('./output/bundle.js'))
browserify 怎么實(shí)現(xiàn)的呢?
browserify 在運(yùn)行時(shí)會(huì)通過進(jìn)行 AST 語(yǔ)法樹分析,確定各個(gè)模塊之間的依賴關(guān)系,生成一個(gè)依賴字典。之后包裝每個(gè)模塊,傳入依賴字典以及自己實(shí)現(xiàn)的 export 和 require 函數(shù),最終生成一個(gè)可以在瀏覽器環(huán)境中執(zhí)行的 JS 文件。
browserify 專注于 JS 打包,功能單一,一般配合 Gulp 一起使用。
ESM 規(guī)范出現(xiàn)
在 2015 年 JavaScript 官方的模塊化終于出現(xiàn)了,但是官方只闡述如何實(shí)現(xiàn)該規(guī)范,瀏覽器少有支持。
Webpack
其實(shí)在 ESM 標(biāo)準(zhǔn)出現(xiàn)之前, webpack[4]已經(jīng)誕生了,只是沒有火起來。webpack 的理念更偏向于工程化,伴隨著 MVC 框架以及 ESM 的出現(xiàn)與興起,webpack2 順勢(shì)發(fā)布,宣布支持 AMD\CommonJS\ESM、css/less/sass/stylus、babel、TypeScript、JSX、Angular 2 組件和 vue 組件。從來沒有一個(gè)如此大而全的工具支持如此多的功能,幾乎能夠解決目前所有構(gòu)建相關(guān)的問題。至此 webpack 真正成為了前端工程化的核心。
webpack 是基于配置。
module.exports = {
// SPA入口文件
entry: 'src/js/index.js',
// 出口
output: {
filename: 'bundle.js'
}
// 模塊匹配和處理 大部分都是做編譯處理
module: {
rules: [
// babel轉(zhuǎn)換語(yǔ)法
{ test: /.js$/, use: 'babel-loader' },
//...
]
},
// 插件
plugins: [
// 根據(jù)模版創(chuàng)建html文件
new HtmlWebpackPlugin({ template: './src/index.html' }),
],
}
webpack 要兼顧各種方案的支持,也暴露出其缺點(diǎn):
配置往往非常繁瑣,開發(fā)人員心智負(fù)擔(dān)大。 webpack 為了支持 cjs 和 esm,自己做了 polyfill,導(dǎo)致產(chǎn)物代碼很“丑”。
在 webpack 出現(xiàn)兩年后,rollup 誕生了~
Rollup
rollup[5] 是一款面向未來的構(gòu)建工具,完全基于 ESM 模塊規(guī)范進(jìn)行打包,率先提出了 Tree-Shaking 的概念。并且配置簡(jiǎn)單,易于上手,成為了目前最流行的 JS 庫(kù)打包工具。
import resolve from 'rollup-plugin-node-resolve';
import babel from 'rollup-plugin-babel';
export default {
// 入口文件
input: 'src/main.js',
output: {
file: 'bundle.js',
// 輸出模塊規(guī)范
format: 'esm'
},
plugins: [
// 轉(zhuǎn)換commonjs模塊為ESM
resolve(),
// babel轉(zhuǎn)換語(yǔ)法
babel({
exclude: 'node_modules/**'
})
]
}
rollup 基于 esm,實(shí)現(xiàn)了強(qiáng)大的 Tree-Shaking 功能,使得構(gòu)建產(chǎn)物足夠的簡(jiǎn)潔、體積足夠的小。但是要考慮瀏覽器的兼容性問題的話,往往需要配合額外的 polyfill 庫(kù),或者結(jié)合 webpack 使用。
ESM 規(guī)范原生支持
Esbuild
在實(shí)際開發(fā)過程中,隨著項(xiàng)目規(guī)模逐漸龐大,前端工程的啟動(dòng)和打包的時(shí)間也不斷上升,一些工程動(dòng)輒幾分鐘甚至十幾分鐘,漫長(zhǎng)的等待,真的讓人絕望。這使得打包工具的性能被越來越多的人關(guān)注。
esbuild[6]是一個(gè)非常新的模塊打包工具,它提供了類似 webpack 資源打包的能力,但是擁有著超高的性能。
esbuild 支持 ES6/CommonJS 規(guī)范、Tree Shaking、TypeScript、JSX 等功能特性。提供了 JS API/Go API/CLI 多種調(diào)用方式。
// JS API調(diào)用
require('esbuild').build({
entryPoints: ['app.jsx'],
bundle: true,
outfile: 'out.js',
}).catch(() => process.exit(1))

根據(jù)官方提供的性能對(duì)比,我們可以看到性能足有百倍的提升,為什么會(huì)這么快?
語(yǔ)言優(yōu)勢(shì)
esBuild 是選擇 Go 語(yǔ)言編寫的,而在 esBuild 之前,前端構(gòu)建工具都是基于 Node,使用 JS 進(jìn)行編寫。JavaScript 是一門解釋性腳本語(yǔ)言,即使 V8 引擎做了大量?jī)?yōu)化(JWT 及時(shí)編譯),本質(zhì)上還是無法打破性能的瓶頸。而 Go 是一種編譯型語(yǔ)言,在編譯階段就已經(jīng)將源碼轉(zhuǎn)譯為機(jī)器碼,啟動(dòng)時(shí)只需要直接執(zhí)行這些機(jī)器碼即可。 Go 天生具有多線程運(yùn)行能力,而 JavaScript 本質(zhì)上是一門單線程語(yǔ)言。esBuild 經(jīng)過精心的設(shè)計(jì),將代碼 parse、代碼生成等過程實(shí)現(xiàn)完全并行處理。
性能至上原則
esBuild 只提供現(xiàn)代 Web 應(yīng)用最小的功能集合,所以其架構(gòu)復(fù)雜度相對(duì)較小,更容易將性能做到極致 在 webpack、rollup 這類工具中, 我們習(xí)慣于使用多種第三方工作來增強(qiáng)工程能力。比如:babel、eslint、less 等。在代碼經(jīng)過多個(gè)工具流轉(zhuǎn)的過程中,存在著很多性能上的浪費(fèi),比如:多次進(jìn)行代碼 -> AST、AST -> 代碼的轉(zhuǎn)換。esBuild 對(duì)此類工具完全進(jìn)行了定制化重寫,舍棄部分可維護(hù)性,追求極致的編譯性能。
雖然 esBuild 性能非常高,但是其提供的功能很基礎(chǔ),不適合直接用到生產(chǎn)環(huán)境,更適合作為底層的模塊構(gòu)建工具,在它基礎(chǔ)上進(jìn)行二次封裝。
Vite
vite[7] 是下一代前端開發(fā)與構(gòu)建工具,提供 noBundle 的開發(fā)服務(wù),并內(nèi)置豐富的功能,無需復(fù)雜配置。
vite 在開發(fā)環(huán)境和生產(chǎn)環(huán)境分別做了不同的處理,在開發(fā)環(huán)境中底層基于 esBuild 進(jìn)行提速,在生產(chǎn)環(huán)境中使用 rollup 進(jìn)行打包。
為什么 vite 開發(fā)服務(wù)這么快?

傳統(tǒng) bundle based 服務(wù):
無論是 webpack 還是 rollup 提供給開發(fā)者使用的服務(wù),都是基于構(gòu)建結(jié)果的。 基于構(gòu)建結(jié)果提供服務(wù),意味著提供服務(wù)前一定要構(gòu)建結(jié)束,隨著項(xiàng)目膨脹,等待時(shí)間也會(huì)逐漸變長(zhǎng)。
noBundle 服務(wù):
對(duì)于 vite、snowpack 這類工具,提供的都是 noBundle 服務(wù),無需等待構(gòu)建,直接提供服務(wù)。 對(duì)于項(xiàng)目中的第三方依賴,僅在初次啟動(dòng)和依賴變化時(shí)重構(gòu)建,會(huì)執(zhí)行一個(gè) 依賴預(yù)構(gòu)建的過程。由于是基于 esBuild 做的構(gòu)建,所以非???。對(duì)于項(xiàng)目代碼,則會(huì)依賴于瀏覽器的 ESM 的支持,直接按需訪問,不必全量構(gòu)建。
為什么在生產(chǎn)環(huán)境中構(gòu)建使用 rollup?
由于瀏覽器的兼容性問題以及實(shí)際網(wǎng)絡(luò)中使用 ESM 可能會(huì)造成 RTT 時(shí)間過長(zhǎng),所以仍然需要打包構(gòu)建。 esbuild 雖然快,但是它還沒有發(fā)布 1.0 穩(wěn)定版本,另外 esbuild 對(duì)代碼分割和 css 處理等支持較弱,所以生產(chǎn)環(huán)境仍然使用 rollup。
目前 vite 發(fā)布了 3.0 版本,相對(duì)于 2.0,修復(fù)了 400+issue,已經(jīng)比較穩(wěn)定,可以用于生產(chǎn)了。Vite 決定每年發(fā)布一個(gè)新的版本。
vite.config.js:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import {resolve} from 'path';
// defineConfig 這個(gè)方法沒有什么實(shí)際的含義, 主要是可以提供語(yǔ)法提示
export default defineConfig({
resolve:{
alias:{
'@':resolve('src')
}
},
plugins: [vue()]
})
技術(shù)方案對(duì)比
前端構(gòu)建工具實(shí)在是琳瑯滿目,以工程化的視角對(duì)社區(qū)仍然比較流行的構(gòu)建工具進(jìn)行簡(jiǎn)單對(duì)比,一些功能專一、特定場(chǎng)景下的工具不在考慮范圍內(nèi)~。
2021 前端構(gòu)建工具排行[8]

一些值得思考的問題
為什么 webpack 構(gòu)建產(chǎn)物看著很丑?
我們?cè)谑褂?webpack 構(gòu)建項(xiàng)目后,會(huì)發(fā)現(xiàn)打包出來的代碼非常的“丑”,這是為什么?原因就是:webpack 支持多種模塊規(guī)范,但是最后都會(huì)變成commonJS規(guī)范(webpack5 對(duì)純 esm 做了一定的優(yōu)化),但是瀏覽器不支持commonJS規(guī)范,于是 webpack 自己實(shí)現(xiàn)了require和module.exports,所以會(huì)有很多 polyfill 代碼的注入。
針對(duì)·common.js 加載 common.js 這種情況分析一下構(gòu)建產(chǎn)物。
源代碼:
// src/index.js
let title = require('./title.js')
console.log(title);
// src/title.js
module.exports = 'bu';
產(chǎn)物代碼:
(() => {
//把所有模塊定義全部存放到modules對(duì)象里
//屬性名是模塊的ID,也就是相對(duì)于根目錄的相對(duì)路徑,包括文件擴(kuò)展名
//值是此模塊的定義函數(shù),函數(shù)體就是原來的模塊內(nèi)的代碼
var modules = ({
"./src/title.js": ((module) => {
module.exports = 'bu';
})
});
// 緩存對(duì)象
var cache = {};
// webpack 打包后的代碼能夠運(yùn)行, 是因?yàn)閣ebpack根據(jù)commonJS規(guī)范實(shí)現(xiàn)了一個(gè)require方法
function require(moduleId) {
var cachedModule = cache[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// 緩存和創(chuàng)建模塊對(duì)象
var module = cache[moduleId] = {
exports: {}
};
// 運(yùn)行模塊代碼
modules[moduleId](module, module.exports, require "moduleId");
return module.exports;
}
var exports = {};
(() => {
// 入口相關(guān)的代碼
let title = require("./src/title.js")
console.log(title);
})();
})();
webpack 按需加載的模塊怎么在瀏覽器中運(yùn)行?
在實(shí)際項(xiàng)目開發(fā)中,隨著代碼越寫越多,構(gòu)建后的 bundle 文件也會(huì)越來越大,我們往往按照種種策略對(duì)代碼進(jìn)行按需加載,將某部分代碼在用戶事件觸發(fā)后再進(jìn)行加載,那么 webpack 在運(yùn)行時(shí)是怎么實(shí)現(xiàn)的呢?
其實(shí)原理很簡(jiǎn)單,就是以 JSONP 的方式加載按需的腳本,但是如何將這些異步模塊使用起來就比較有意思了~
對(duì)一個(gè)簡(jiǎn)單的 case 進(jìn)行分析。
源代碼:
// index.js
import("./hello").then((result) => {
console.log(result.default);
});
// hello.js
export default 'hello';
產(chǎn)物代碼:
main.js
// PS: 對(duì)代碼做了部分簡(jiǎn)化及優(yōu)化, 否則太難讀了~~~
// 定一個(gè)模塊對(duì)象
var modules = ({});
// webpack在瀏覽器里實(shí)現(xiàn)require方法
function require(moduleId) {xxx}
/**
* chunkIds 代碼塊的ID數(shù)組
* moreModules 代碼塊的模塊定義
*/
function webpackJsonpCallback([chunkIds, moreModules]) {
const result = [];
for(let i = 0 ; i < chunkIds.length ; i++){
const chunkId = chunkIds[i];
result.push(installedChunks[chunkId][0]);
installedChunks[chunkId] = 0; // 表示此代碼塊已經(jīng)下載完畢
}
// 將代碼塊合并到 modules 對(duì)象中去
for(const moduleId in moreModules){
modules[moduleId] = moreModules[moduleId];
}
//依次將require.e方法中的promise變?yōu)槌晒B(tài)
while(result.length){
result.shift()();
}
}
// 用來存放代碼塊的加載狀態(tài), key是代碼塊的名字
// 每次打包至少產(chǎn)生main的代碼塊
// 0 表示已經(jīng)加載就緒
var installedChunks = {
"main": 0
}
require.d = (exports, definition) => {
for (var key in definition) {
Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
}
};
require.r = (exports) => {
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
Object.defineProperty(exports, '__esModule', { value: true });
};
// 給require方法定義一個(gè)m屬性, 指向模塊定義對(duì)象
require.m = modules;
require.f = {};
// 利用JSONP加載一個(gè)按需引入的模塊
require.l = function (url) {
let script = document.createElement("script");
script.src = url;
document.head.appendChild(script);
}
// 用于通過JSONP異步加載一個(gè)chunkId對(duì)應(yīng)的代碼塊文件, 其實(shí)就是hello.main.js
require.f.j = function(chunkId, promises){
let installedChunkData;
// 當(dāng)前代碼塊的數(shù)據(jù)
const promise = new Promise((resolve, reject) => {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push(installedChunkData[2] = promise);
// 獲取模塊的訪問路徑
const url = chunkId + '.main.js';
require.l(url);
}
require.e = function(chunkId) {
let promises = [];
require.f.j(chunkId, promises);
console.log(promises);
return Promise.all(promises);
}
var chunkLoadingGlobal = window['webpack'] = [];
// 由于按需加載的模塊, 會(huì)在加載成功后調(diào)用此模塊,所以這是JSONP的成功后的回掉
chunkLoadingGlobal.push = webpackJsonpCallback;
/**
* require.e異步加載hello代碼塊文件 hello.main.js
* promise成功后會(huì)把 hello.main.js里面的代碼定義合并到require.m對(duì)象上,也就是modules上
* 調(diào)用require方法加載./src/hello.js模塊,獲取 模塊的導(dǎo)出對(duì)象,進(jìn)行打印
*/
require.e('hello').then(require.bind(require, './src/hello.js')).then(result => console.log(result));
hello.main.js
"use strict";
(self["webpack"] = self["webpack"] || []).push([
["hello"], {
"./src/hello.js": ((module, exports, require) => {
require.r(exports);
require.d(exports, {
"default": () => (_DEFAULT_EXPORT__)
});
const _DEFAULT_EXPORT__ = ("hello");
})
}
]);
webpack 在產(chǎn)物代碼中聲明了一個(gè)全局變量 webpack 并賦值為一個(gè)數(shù)組,然后改寫了這個(gè)數(shù)組的 push 方法。在異步代碼加載完成后執(zhí)行時(shí),會(huì)調(diào)用這個(gè) push 方法,在重寫的方法內(nèi)會(huì)將異步模塊放到全局模塊中然后等待使用。
白話版 webpack 構(gòu)建流程
時(shí)至今日,webpack 的功能集已經(jīng)非常龐大了,代碼量更是非常驚人,源碼的學(xué)習(xí)成本非常高,但是了解 webpack 構(gòu)建流程又十分有必要,可以幫我們了解構(gòu)建產(chǎn)物是怎么產(chǎn)生的,以及遇到實(shí)際問題時(shí)如何下手解決問題。

思路實(shí)現(xiàn)
簡(jiǎn)單模擬下 webpack 實(shí)現(xiàn)思路:
class Compilation {
constructor(options) {
this.options = options;
// 本次編譯所有生成出來的模塊
this.modules = [];
// 本次編譯產(chǎn)出的所有代碼塊, 入口模塊和依賴模塊打包在一起成為代碼塊
this.chunks = [];
// 本次編譯產(chǎn)出的資源文件
this.assets = {};
}
build(callback) {
//5.根據(jù)配置文件中的`entry`配置項(xiàng)找到所有的入口
let entry = {xxx: 'xxx'};
//6.從入口文件出發(fā),調(diào)用所有配置的loader規(guī)則,比如說loader對(duì)模塊進(jìn)行編譯
for(let entryName in entry){
// 6. 從入口文件出發(fā),調(diào)用所有配置的Loader對(duì)模塊進(jìn)行編譯
const entryModule = this.buildModule(entryName, entryFilePath);
this.modules.push(entryModule);
//8.等把所有的模塊編譯完成后,根據(jù)模塊之間的依賴關(guān)系,組裝成一個(gè)個(gè)包含多個(gè)模塊的chunk
let chunk = {
name: entryName, // 代碼塊的名稱就是入口的名稱
entryModule, // 此代碼塊對(duì)應(yīng)的入口模塊
modules: this.modules.filter((module) => module.names.includes(entryName)) // 此代碼塊包含的依賴模塊
};
this.chunks.push(chunk);
}
//9.再把各個(gè)代碼塊chunk轉(zhuǎn)換成一個(gè)一個(gè)的文件(asset)加入到輸出列表
this.chunks.forEach((chunk) => {
const filename = this.options.output.filename.replace('[name]', chunk.name); // 獲取輸出文件名稱
this.assets[filename] = getSource(chunk);
});
// 調(diào)用編譯結(jié)束的回掉
callback(null, {
modules: this.modules,
chunks: this.chunks,
assets: this.assets
}, this.fileDependencies);
}
//當(dāng)你編譯 模塊的時(shí)候,需要傳遞你這個(gè)模塊是屬于哪個(gè)代碼塊的,傳入代碼塊的名稱
buildModule(name, modulePath) {
// 6. 從入口文件出發(fā),調(diào)用所有配置的Loader對(duì)模塊進(jìn)行編譯, loader 只會(huì)在編譯過程中使用, plugin則會(huì)貫穿整個(gè)流程
// 讀取模塊內(nèi)容
let sourceCode = fs.readFileSync(modulePath, 'utf8');
//創(chuàng)建一個(gè)模塊對(duì)象
let module = {
id: moduleId, // 模塊ID =》 相對(duì)于工作目錄的相對(duì)路徑
names: [name], // 表示當(dāng)前的模塊屬于哪個(gè)代碼塊(chunk)
dependencies: [], // 表示當(dāng)前模塊依賴的模塊
}
// 查找所有匹配的loader,自右向左讀取loader, 進(jìn)行轉(zhuǎn)譯, 通過loader翻譯后的內(nèi)容一定是JS內(nèi)容
sourceCode = loaders.reduceRight((sourceCode, loader) => {
return require(loader)(sourceCode);
}, sourceCode);
// 7. 再找出該模塊依賴的模塊,再遞歸本步驟直到所有入口依賴的文件都經(jīng)過了本步驟的處理
// 創(chuàng)建語(yǔ)法樹, 遍歷語(yǔ)法樹,在此過程進(jìn)行依賴收集, 繪制依賴圖
let ast = parser.parse(sourceCode, { sourceType: 'module' });
traverse(ast, {});
let { code } = generator(ast);
// 把轉(zhuǎn)譯后的源代碼放到module._source上
module._source = code;
// 再遞歸本步驟直到所有入口依賴的文件都經(jīng)過了本步驟的處理
module.dependencies.forEach(({ depModuleId, depModulePath }) => {
const depModule = this.buildModule(name, depModulePath);
this.modules.push(depModule)
});
return module;
}
}
function getSource(chunk) {
return `
(() => {
var modules = {
${chunk.modules.map(
(module) => `
"${module.id}": (module) => {
${module._source}
}
`
)}
};
var cache = {};
function require(moduleId) {
var cachedModule = cache[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
var module = (cache[moduleId] = {
exports: {},
});
modules[moduleId](module, module.exports, require "moduleId");
return module.exports;
}
var exports ={};
${chunk.entryModule._source}
})();
`;
}
class Compiler {
constructor(options) {
this.options = options;
this.hooks = {
run: new SyncHook(), //會(huì)在編譯剛開始的時(shí)候觸發(fā)此run鉤子
done: new SyncHook(), //會(huì)在編譯 結(jié)束的時(shí)候觸發(fā)此done鉤子
}
}
//4.執(zhí)行`Compiler`對(duì)象的`run`方法開始執(zhí)行編譯
run() {
// 在編譯前觸發(fā)run鉤子執(zhí)行, 表示開始啟動(dòng)編譯了
this.hooks.run.call();
// 編譯成功之后的回掉
const onCompiled = (err, stats, fileDependencies) => {
// 10. 在確定好輸出內(nèi)容后,根據(jù)配置確定輸出的路徑和文件名,把文件內(nèi)容寫入到文件系統(tǒng)
for(let filename in stats.assets) {
fs.writeFileSync(filePath,stats.assets[filename], 'utf8' );
}
//當(dāng)編譯成功后會(huì)觸發(fā)done這個(gè)鉤子執(zhí)行
this.hooks.done.call();
}
//開始編譯,編譯 成功之后調(diào)用onCompiled方法
this.compile(onCompiled);
}
compile(callback) {
// webpack雖然只有一個(gè)Compiler, 但是每次編譯都會(huì)產(chǎn)出一個(gè)新的Compilation, 用來存放本次編譯產(chǎn)出的 文件、chunk、和模塊
// 比如:監(jiān)聽模式會(huì)觸發(fā)多次編譯
let compilation = new Compilation(this.options);
//執(zhí)行compilation的build方法進(jìn)行編譯 ,編譯 成功之后執(zhí)行回調(diào)
compilation.build(callback);
}
}
function webpack(options) {
//1.初始化參數(shù),從配置文件和shell語(yǔ)句中讀取并合并參數(shù),并得到最終的配置對(duì)象
let finalOptions = {...options, ...shellOptions};
// 2.用上一步的配置對(duì)象初始化Compiler對(duì)象, 整個(gè)編譯流程只有一個(gè)complier對(duì)象
const compiler = new Compiler(finalOptions);
// 3.加載所有在配置文件中配置的插件
const { plugins } = finalOptions;
for(let plugin of plugins){
plugin.apply(compiler);
}
return compiler;
}
// webpackOptions webpack的配置項(xiàng)
const compiler = webpack(webpackOptions);
//4.執(zhí)行對(duì)象的run方法開始執(zhí)行編譯
compiler.run();
為什么 Rollup 構(gòu)建產(chǎn)物很干凈?
rollup 只對(duì) ESM 模塊進(jìn)行打包,對(duì)于 cjs 模塊也會(huì)通過插件將其轉(zhuǎn)化為 ESM 模塊進(jìn)行打包。所以不會(huì)像 webpack 有很多的代碼注入。 rollup 對(duì)打包結(jié)果也支持多種 format 的輸出,比如:esm、cjs、am 等等,但是 rollup 并不保證代碼可靠運(yùn)行,需要運(yùn)行環(huán)境可靠支持。比如我們輸出 esm 規(guī)范代碼,代碼運(yùn)行時(shí)完全依賴高版本瀏覽器原生去支持 esm,rollup 不會(huì)像 webpack 一樣注入一系列兼容代碼。 rollup 實(shí)現(xiàn)了強(qiáng)大的 tree-shaking 能力。
為什么 Vite 可以讓代碼直接運(yùn)行在瀏覽器上?
前文我們提到,在開發(fā)環(huán)境時(shí),我們使用 vite 開發(fā),是無需打包的,直接利用瀏覽器對(duì) ESM 的支持,就可以訪問我們寫的組件代碼,但是一些組件代碼文件往往不是 JS 文件,而是 .ts、.tsx、.vue 等類型的文件。這些文件瀏覽器肯定直接是識(shí)別不了的,vite 在這個(gè)過程中做了些什么?
我們以一個(gè)簡(jiǎn)單的 vue 組件訪問分析一下:
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
// /src/main.js
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app');
// src/App.vue
<template>
<h1>Hello</h1>
</template>
在瀏覽器中打開頁(yè)面后,會(huì)發(fā)現(xiàn)瀏覽器對(duì)入口文件發(fā)起了請(qǐng)求:

我們可以觀察到 vue 這個(gè)第三方包的訪問路徑改變了,變成了node_modules/.vite下的一個(gè) vue 文件,這里真正訪問的文件就是前面我們提到的,vite 會(huì)對(duì)第三方依賴進(jìn)行依賴預(yù)構(gòu)建所生成的緩存文件。
另外瀏覽器也對(duì) App.vue 發(fā)起了訪問,相應(yīng)內(nèi)容是 JS:

返回內(nèi)容(做了部分簡(jiǎn)化,移除了一些熱更新的代碼):
const _sfc_main = {
name: 'App'
}
// vue 提供的一些API,用于生成block、虛擬DOM
import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/vue.js?v=b618a526"
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("h1", null, "App"))
}
// 組件的render方法
_sfc_main.render = _sfc_render;
export default _sfc_main;
總結(jié):當(dāng)用戶訪問 vite 提供的開發(fā)服務(wù)器時(shí),對(duì)于瀏覽器不能直接識(shí)別的文件,服務(wù)器的一些中間件會(huì)將此類文件轉(zhuǎn)換成瀏覽器認(rèn)識(shí)的文件,從而保證正常訪問。
Node 社群 我組建了一個(gè)氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對(duì)Node.js學(xué)習(xí)感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行Node.js相關(guān)的交流、學(xué)習(xí)、共建。下方加 考拉 好友回復(fù)「Node」即可。
“分享、點(diǎn)贊、在看” 支持一波??
參考資料
Grunt: https://www.gruntjs.net/
[2]Gulp: https://www.gulpjs.com.cn/
[3]browserify: https://browserify.org/
[4]webpack: https://webpack.docschina.org/
[5]rollup: https://rollupjs.org/guide/zh/
[6]esbuild: https://esbuild.github.io/
[7]vite: https://cn.vitejs.dev/guide/
[8]2021 前端構(gòu)建工具排行: https://risingstars.js.org/2021/zh#section-build
[9]前端構(gòu)建工具簡(jiǎn)史: https://juejin.cn/post/7085613927249215525
[10]ESM 實(shí)現(xiàn)原理: https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/
[11]Webpack 核心原理:https://mp.weixin.qq.com/s/SbJNbSVzSPSKBe2YStn2Zw
