前端構建工具進化歷程
序言
現在前端項目的開發過程離不開構建工具幫助,面對琳琅滿目的構建工具我們該如何選擇最合適自己場景的構建工具是一個問題。在研究各種配置之餘,我們去研究一下構建工具發展過程、底層原理,面對一些問題的時候往往事半功倍。
通過本文你可以瞭解到:
-
前端構建工具的進化歷程
-
前端構建工具技術方案對比
-
常用構建工具核心實現原理
什麼是構建?
構建簡單的說就是將我們開發環境的代碼,轉化成生產環境可用來部署的代碼。
市面上存在很多構建工具,但是最終的目標都是轉化開發環境的代碼爲生產環境中可用的代碼。在不同的前端項目中使用的技術棧是不一樣的,比如:不同的框架、不同的樣式處理方案等,爲了生產出生產環境可用的 JS、CSS,構建工具實現了諸如:代碼轉換、代碼壓縮、tree shaking、code spliting 等。
前端構建工具可以做什麼?
前端構建工具進化史
無模塊化時代
YUI Tool + Ant
YUI Tool 是 07 年左右出現的一個構建工具,可以實現壓縮混淆前端代碼,依賴於 java 的 ant 使用。
在早期 web 應用開發主要採用 JSP,處於混合開發的狀態,不像是現在的前後端分離開發。通常由 java 開發人員來編寫 js、css 代碼。此時出現的構建工具依賴於別的語言實現。
JS 內聯外聯
前端代碼是否必須通過構建纔可以在瀏覽器中運行呢?當然不是。如下:
<html>
<head>
<title>Hello World</title>
</head>
<body>
<div id="root"/>
<script type="text/javascript">
document.getElementById('root').innerText = 'Hello World'
</script>
</body>
</html>
上述代碼,我們只需要按格式寫幾個 HTML 標籤,插入簡單的 JS 腳本,打開瀏覽器,一個 Hello World 的前端頁面就呈現在我們面前了。但是當項目進入真正的實戰開發,代碼規模開始急速擴張後,大量邏輯混雜在一個文件之中就變得難以維護起來。早期的前端項目一般如下組織:
<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 的內聯外聯組織代碼,將不同的代碼放在不同的文件中,但是這也僅僅解決了代碼組織混亂的問題,還存在很多問題:
-
大量的全局變量,代碼之間的依賴是不透明的,任何代碼都可能悄悄的改變了全局變量。
-
腳本的引入需要依賴特定的順序。
後續出現過一些 IIFE、命名空間等解決方案,但是從本質上都沒有解決依賴全局變量通信的問題。在這樣的背景下,前端工程化成爲解決此類問題的正軌。
社區模塊化時代
AMD/CMD - 異步模塊加載
爲了解決瀏覽器端 JS 模塊化的問題,出現了通過引入相關工具庫的方式來解決這一問題。出現了兩種應用比較廣的規範及其相關庫:AMD(RequireJs) 和 CMD(Sea.js)。AMD 推崇依賴前置、提前執行,CMD 推崇依賴就近、延遲執行。下面領略下相關寫法:
Require.js
// 加載完jquery後,將執行結果 $ 作爲參數傳入了回調函數
define(["jquery"], function ($) {
$(document).ready(function(){
$('#root')[0].innerText = 'Hello World';
})
return $
})
Sea.js
// 預加載jquery
define(function(require, exports, module) {
// 執行jquery模塊,並得到結果賦值給 $
var $ = require('jquery');
// 調用jquery.js模塊提供的方法
$('#header').hide();
});
兩種模塊化規範實現的原理基本上是一致的,只不過各自堅持的理念不同。兩者都是以異步的方式獲取當前模塊所需的模塊,不同的地方在於 AMD 在獲取到相關模塊後立即執行,CMD 則是在用到相關模塊的位置再執行的。
AMD/CMD 解決問題:
-
手動維護代碼引用順序。從此不再需要手動調整 HTML 文件中的腳本順序,依賴數組會自動偵測模塊間的依賴關係,並自動化的插入頁面。
-
全局變量污染問題。將模塊內容在函數內實現,利用閉包導出的變量通信,不會存在全局變量污染的問題。
Grunt/Gulp
在 Google Chrome 推出 V8 引擎後,基於其高性能和平臺獨立的特性,Nodejs 這個 JS 運行時也現世了。至此,JS 打破了瀏覽器的限制,擁有了文件讀寫的能力。Nodejs 不僅在服務器領域佔據一席之地,也將前端工程化帶進了正軌。
在這個背景下,第一批基於 Node.js 的構建工具出現了。
Grunt
Grunt[1] 主要能夠幫助我們自動化的處理一些反覆重複的任務,例如壓縮、編譯、單元測試、linting 等。
// Gruntfile.js
module.exports = function(grunt) {
// 功能配置
grunt.initConfig({
// 定義任務
jshint: {
files: ['Gruntfile.js', 'src/**/*.js', 'test/**/*.js'],
options: {
globals: {
jQuery: true
}
}
},
// 時時偵聽文件變化所執行的任務
watch: {
files: ['<%= jshint.files %>'],
tasks: ['jshint']
}
});
// 加載任務所需要的插件
grunt.loadNpmTasks('grunt-contrib-jshint');
grunt.loadNpmTasks('grunt-contrib-watch');
// 默認執行的任務
grunt.registerTask('default', ['jshint']);
};
Gulp
Grunt 的 I/O 操作比較 “呆板”,每個任務執行結束後都會將文件寫入磁盤,下個任務執行時再將文件從磁盤中讀出,這樣的操作會產生一些問題:
-
運行速度較慢
-
硬件壓力大
Gulp[2] 最大特點是引入了流的概念,同時提供了一系列常用的插件去處理流,流可以在插件之間傳遞。同時 Gulp 設計簡單,既可以單獨使用,也可以結合別的工具一起使用。
// gulpfile.js
const { src, dest } = require('gulp');
// gulp提供的一系列api
// src 讀取文件
// dest 寫入文件
const babel = require('gulp-babel');
exports.default = function() {
// 將src文件夾下的所有js文件取出,經過Babel轉換後放入output文件夾之中
return src('src/*.js')
.pipe(babel())
.pipe(dest('output/'));
}
Browserify
隨着 Node.js 的興起,CommonJS 模塊化規範成爲了當時的主流規範。但是我們知道 CommonJS 所使用的 require 語法是同步的,當代碼執行到 require 方法的時候,必須要等這個模塊加載完後,纔會執行後面的代碼。這種方式在服務端是可行的,這是因爲服務器只需要從本地磁盤中讀取文件,速度還是很快的,但是在瀏覽器端,我們通過網絡請求獲取文件,網絡環境以及文件大小都可能使頁面無響應。
browserify[3] 致力於打包產出在瀏覽器端可以運行的 CommonJS 規範的 JS 代碼。
var browserify = require('browserify')
var b = browserify()
var fs = require('fs')
// 添加入口文件
b.add('./src/browserifyIndex.js')
// 打包所有模塊至一個文件之中並輸出bundle
b.bundle().pipe(fs.createWriteStream('./output/bundle.js'))
browserify 怎麼實現的呢?
browserify 在運行時會通過進行 AST 語法樹分析,確定各個模塊之間的依賴關係,生成一個依賴字典。之後包裝每個模塊,傳入依賴字典以及自己實現的 export 和 require 函數,最終生成一個可以在瀏覽器環境中執行的 JS 文件。
browserify 專注於 JS 打包,功能單一,一般配合 Gulp 一起使用。
ESM 規範出現
在 2015 年 JavaScript 官方的模塊化終於出現了,但是官方只闡述如何實現該規範,瀏覽器少有支持。
Webpack
其實在 ESM 標準出現之前, webpack[4] 已經誕生了,只是沒有火起來。webpack 的理念更偏向於工程化,伴隨着 MVC 框架以及 ESM 的出現與興起,webpack2 順勢發佈,宣佈支持 AMD\CommonJS\ESM、css/less/sass/stylus、babel、TypeScript、JSX、Angular 2 組件和 vue 組件。從來沒有一個如此大而全的工具支持如此多的功能,幾乎能夠解決目前所有構建相關的問題。至此 webpack 真正成爲了前端工程化的核心。
webpack 是基於配置。
module.exports = {
// SPA入口文件
entry: 'src/js/index.js',
// 出口
output: {
filename: 'bundle.js'
}
// 模塊匹配和處理 大部分都是做編譯處理
module: {
rules: [
// babel轉換語法
{ test: /.js$/, use: 'babel-loader' },
//...
]
},
// 插件
plugins: [
// 根據模版創建html文件
new HtmlWebpackPlugin({ template: './src/index.html' }),
],
}
webpack 要兼顧各種方案的支持,也暴露出其缺點:
-
配置往往非常繁瑣,開發人員心智負擔大。
-
webpack 爲了支持 cjs 和 esm,自己做了 polyfill,導致產物代碼很 “醜”。
在 webpack 出現兩年後,rollup 誕生了~
Rollup
rollup[5] 是一款面向未來的構建工具,完全基於 ESM 模塊規範進行打包,率先提出了 Tree-Shaking 的概念。並且配置簡單,易於上手,成爲了目前最流行的 JS 庫打包工具。
import resolve from 'rollup-plugin-node-resolve';
import babel from 'rollup-plugin-babel';
export default {
// 入口文件
input: 'src/main.js',
output: {
file: 'bundle.js',
// 輸出模塊規範
format: 'esm'
},
plugins: [
// 轉換commonjs模塊爲ESM
resolve(),
// babel轉換語法
babel({
exclude: 'node_modules/**'
})
]
}
rollup 基於 esm,實現了強大的 Tree-Shaking 功能,使得構建產物足夠的簡潔、體積足夠的小。但是要考慮瀏覽器的兼容性問題的話,往往需要配合額外的 polyfill 庫,或者結合 webpack 使用。
ESM 規範原生支持
Esbuild
在實際開發過程中,隨着項目規模逐漸龐大,前端工程的啓動和打包的時間也不斷上升,一些工程動輒幾分鐘甚至十幾分鍾,漫長的等待,真的讓人絕望。這使得打包工具的性能被越來越多的人關注。
esbuild[6] 是一個非常新的模塊打包工具,它提供了類似 webpack 資源打包的能力,但是擁有着超高的性能。
esbuild 支持 ES6/CommonJS 規範、Tree Shaking、TypeScript、JSX 等功能特性。提供了 JS API/Go API/CLI 多種調用方式。
// JS API調用
require('esbuild').build({
entryPoints: ['app.jsx'],
bundle: true,
outfile: 'out.js',
}).catch(() => process.exit(1))
根據官方提供的性能對比,我們可以看到性能足有百倍的提升,爲什麼會這麼快?
語言優勢
-
esBuild 是選擇 Go 語言編寫的,而在 esBuild 之前,前端構建工具都是基於 Node,使用 JS 進行編寫。JavaScript 是一門解釋性腳本語言,即使 V8 引擎做了大量優化(JWT 及時編譯),本質上還是無法打破性能的瓶頸。而 Go 是一種編譯型語言,在編譯階段就已經將源碼轉譯爲機器碼,啓動時只需要直接執行這些機器碼即可。
-
Go 天生具有多線程運行能力,而 JavaScript 本質上是一門單線程語言。esBuild 經過精心的設計,將代碼 parse、代碼生成等過程實現完全並行處理。
性能至上原則
-
esBuild 只提供現代 Web 應用最小的功能集合,所以其架構複雜度相對較小,更容易將性能做到極致
-
在 webpack、rollup 這類工具中, 我們習慣於使用多種第三方工作來增強工程能力。比如:babel、eslint、less 等。在代碼經過多個工具流轉的過程中,存在着很多性能上的浪費,比如:多次進行代碼 -> AST、AST -> 代碼的轉換。esBuild 對此類工具完全進行了定製化重寫,捨棄部分可維護性,追求極致的編譯性能。
雖然 esBuild 性能非常高,但是其提供的功能很基礎,不適合直接用到生產環境,更適合作爲底層的模塊構建工具,在它基礎上進行二次封裝。
Vite
vite[7] 是下一代前端開發與構建工具,提供 noBundle 的開發服務,並內置豐富的功能,無需複雜配置。
vite 在開發環境和生產環境分別做了不同的處理,在開發環境中底層基於 esBuild 進行提速,在生產環境中使用 rollup 進行打包。
爲什麼 vite 開發服務這麼快?
傳統 bundle based 服務:
-
無論是 webpack 還是 rollup 提供給開發者使用的服務,都是基於構建結果的。
-
基於構建結果提供服務,意味着提供服務前一定要構建結束,隨着項目膨脹,等待時間也會逐漸變長。
noBundle 服務:
-
對於 vite、snowpack 這類工具,提供的都是 noBundle 服務,無需等待構建,直接提供服務。
-
對於項目中的第三方依賴,僅在初次啓動和依賴變化時重構建,會執行一個
依賴預構建
的過程。由於是基於 esBuild 做的構建,所以非常快。 -
對於項目代碼,則會依賴於瀏覽器的 ESM 的支持,直接按需訪問,不必全量構建。
爲什麼在生產環境中構建使用 rollup?
-
由於瀏覽器的兼容性問題以及實際網絡中使用 ESM 可能會造成 RTT 時間過長,所以仍然需要打包構建。
-
esbuild 雖然快,但是它還沒有發佈 1.0 穩定版本,另外 esbuild 對代碼分割和 css 處理等支持較弱,所以生產環境仍然使用 rollup。
目前 vite 發佈了 3.0 版本,相對於 2.0,修復了 400+issue,已經比較穩定,可以用於生產了。Vite 決定每年發佈一個新的版本。
vite.config.js:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import {resolve} from 'path';
// defineConfig 這個方法沒有什麼實際的含義, 主要是可以提供語法提示
export default defineConfig({
resolve:{
alias:{
'@':resolve('src')
}
},
plugins: [vue()]
})
技術方案對比
前端構建工具實在是琳琅滿目,以工程化的視角對社區仍然比較流行的構建工具進行簡單對比,一些功能專一、特定場景下的工具不在考慮範圍內~。
2021 前端構建工具排行 [8]
一些值得思考的問題
爲什麼 webpack 構建產物看着很醜?
我們在使用 webpack 構建項目後,會發現打包出來的代碼非常的 “醜”,這是爲什麼?原因就是:webpack 支持多種模塊規範,但是最後都會變成commonJS規範
(webpack5 對純 esm 做了一定的優化),但是瀏覽器不支持commonJS規範
,於是 webpack 自己實現了require
和module.exports
,所以會有很多 polyfill 代碼的注入。
針對 ·common.js 加載 common.js 這種情況分析一下構建產物。
源代碼:
// src/index.js
let title = require('./title.js')
console.log(title);
// src/title.js
module.exports = 'bu';
產物代碼:
(() => {
//把所有模塊定義全部存放到modules對象裏
//屬性名是模塊的ID,也就是相對於根目錄的相對路徑,包括文件擴展名
//值是此模塊的定義函數,函數體就是原來的模塊內的代碼
var modules = ({
"./src/title.js": ((module) => {
module.exports = 'bu';
})
});
// 緩存對象
var cache = {};
// webpack 打包後的代碼能夠運行, 是因爲webpack根據commonJS規範實現了一個require方法
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 = {};
(() => {
// 入口相關的代碼
let title = require("./src/title.js")
console.log(title);
})();
})();
webpack 按需加載的模塊怎麼在瀏覽器中運行?
在實際項目開發中,隨着代碼越寫越多,構建後的 bundle 文件也會越來越大,我們往往按照種種策略對代碼進行按需加載,將某部分代碼在用戶事件觸發後再進行加載,那麼 webpack 在運行時是怎麼實現的呢?
其實原理很簡單,就是以 JSONP 的方式加載按需的腳本,但是如何將這些異步模塊使用起來就比較有意思了~
對一個簡單的 case 進行分析。
源代碼:
// index.js
import("./hello").then((result) => {
console.log(result.default);
});
// hello.js
export default 'hello';
產物代碼:
main.js
// PS: 對代碼做了部分簡化及優化, 否則太難讀了~~~
// 定一個模塊對象
var modules = ({});
// webpack在瀏覽器裏實現require方法
function require(moduleId) {xxx}
/**
* chunkIds 代碼塊的ID數組
* 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; // 表示此代碼塊已經下載完畢
}
// 將代碼塊合併到 modules 對象中去
for(const moduleId in moreModules){
modules[moduleId] = moreModules[moduleId];
}
//依次將require.e方法中的promise變爲成功態
while(result.length){
result.shift()();
}
}
// 用來存放代碼塊的加載狀態, key是代碼塊的名字
// 每次打包至少產生main的代碼塊
// 0 表示已經加載就緒
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方法定義一個m屬性, 指向模塊定義對象
require.m = modules;
require.f = {};
// 利用JSONP加載一個按需引入的模塊
require.l = function (url) {
let script = document.createElement("script");
script.src = url;
document.head.appendChild(script);
}
// 用於通過JSONP異步加載一個chunkId對應的代碼塊文件, 其實就是hello.main.js
require.f.j = function(chunkId, promises){
let installedChunkData;
// 當前代碼塊的數據
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'] = [];
// 由於按需加載的模塊, 會在加載成功後調用此模塊,所以這是JSONP的成功後的回掉
chunkLoadingGlobal.push = webpackJsonpCallback;
/**
* require.e異步加載hello代碼塊文件 hello.main.js
* promise成功後會把 hello.main.js裏面的代碼定義合併到require.m對象上,也就是modules上
* 調用require方法加載./src/hello.js模塊,獲取 模塊的導出對象,進行打印
*/
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 在產物代碼中聲明瞭一個全局變量 webpack
並賦值爲一個數組,然後改寫了這個數組的 push 方法。在異步代碼加載完成後執行時,會調用這個 push 方法,在重寫的方法內會將異步模塊放到全局模塊中然後等待使用。
白話版 webpack 構建流程
時至今日,webpack 的功能集已經非常龐大了,代碼量更是非常驚人,源碼的學習成本非常高,但是瞭解 webpack 構建流程又十分有必要,可以幫我們瞭解構建產物是怎麼產生的,以及遇到實際問題時如何下手解決問題。
思路實現
簡單模擬下 webpack 實現思路:
class Compilation {
constructor(options) {
this.options = options;
// 本次編譯所有生成出來的模塊
this.modules = [];
// 本次編譯產出的所有代碼塊, 入口模塊和依賴模塊打包在一起成爲代碼塊
this.chunks = [];
// 本次編譯產出的資源文件
this.assets = {};
}
build(callback) {
//5.根據配置文件中的`entry`配置項找到所有的入口
let entry = {xxx: 'xxx'};
//6.從入口文件出發,調用所有配置的loader規則,比如說loader對模塊進行編譯
for(let entryName in entry){
// 6. 從入口文件出發,調用所有配置的Loader對模塊進行編譯
const entryModule = this.buildModule(entryName, entryFilePath);
this.modules.push(entryModule);
//8.等把所有的模塊編譯完成後,根據模塊之間的依賴關係,組裝成一個個包含多個模塊的chunk
let chunk = {
name: entryName, // 代碼塊的名稱就是入口的名稱
entryModule, // 此代碼塊對應的入口模塊
modules: this.modules.filter((module) => module.names.includes(entryName)) // 此代碼塊包含的依賴模塊
};
this.chunks.push(chunk);
}
//9.再把各個代碼塊chunk轉換成一個一個的文件(asset)加入到輸出列表
this.chunks.forEach((chunk) => {
const filename = this.options.output.filename.replace('[name]', chunk.name); // 獲取輸出文件名稱
this.assets[filename] = getSource(chunk);
});
// 調用編譯結束的回掉
callback(null, {
modules: this.modules,
chunks: this.chunks,
assets: this.assets
}, this.fileDependencies);
}
//當你編譯 模塊的時候,需要傳遞你這個模塊是屬於哪個代碼塊的,傳入代碼塊的名稱
buildModule(name, modulePath) {
// 6. 從入口文件出發,調用所有配置的Loader對模塊進行編譯, loader 只會在編譯過程中使用, plugin則會貫穿整個流程
// 讀取模塊內容
let sourceCode = fs.readFileSync(modulePath, 'utf8');
//創建一個模塊對象
let module = {
id: moduleId, // 模塊ID =》 相對於工作目錄的相對路徑
names: [name], // 表示當前的模塊屬於哪個代碼塊(chunk)
dependencies: [], // 表示當前模塊依賴的模塊
}
// 查找所有匹配的loader,自右向左讀取loader, 進行轉譯, 通過loader翻譯後的內容一定是JS內容
sourceCode = loaders.reduceRight((sourceCode, loader) => {
return require(loader)(sourceCode);
}, sourceCode);
// 7. 再找出該模塊依賴的模塊,再遞歸本步驟直到所有入口依賴的文件都經過了本步驟的處理
// 創建語法樹, 遍歷語法樹,在此過程進行依賴收集, 繪製依賴圖
let ast = parser.parse(sourceCode, { sourceType: 'module' });
traverse(ast, {});
let { code } = generator(ast);
// 把轉譯後的源代碼放到module._source上
module._source = code;
// 再遞歸本步驟直到所有入口依賴的文件都經過了本步驟的處理
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(), //會在編譯剛開始的時候觸發此run鉤子
done: new SyncHook(), //會在編譯 結束的時候觸發此done鉤子
}
}
//4.執行`Compiler`對象的`run`方法開始執行編譯
run() {
// 在編譯前觸發run鉤子執行, 表示開始啓動編譯了
this.hooks.run.call();
// 編譯成功之後的回掉
const onCompiled = (err, stats, fileDependencies) => {
// 10. 在確定好輸出內容後,根據配置確定輸出的路徑和文件名,把文件內容寫入到文件系統
for(let filename in stats.assets) {
fs.writeFileSync(filePath,stats.assets[filename], 'utf8' );
}
//當編譯成功後會觸發done這個鉤子執行
this.hooks.done.call();
}
//開始編譯,編譯 成功之後調用onCompiled方法
this.compile(onCompiled);
}
compile(callback) {
// webpack雖然只有一個Compiler, 但是每次編譯都會產出一個新的Compilation, 用來存放本次編譯產出的 文件、chunk、和模塊
// 比如:監聽模式會觸發多次編譯
let compilation = new Compilation(this.options);
//執行compilation的build方法進行編譯 ,編譯 成功之後執行回調
compilation.build(callback);
}
}
function webpack(options) {
//1.初始化參數,從配置文件和shell語句中讀取併合並參數,並得到最終的配置對象
let finalOptions = {...options, ...shellOptions};
// 2.用上一步的配置對象初始化Compiler對象, 整個編譯流程只有一個complier對象
const compiler = new Compiler(finalOptions);
// 3.加載所有在配置文件中配置的插件
const { plugins } = finalOptions;
for(let plugin of plugins){
plugin.apply(compiler);
}
return compiler;
}
// webpackOptions webpack的配置項
const compiler = webpack(webpackOptions);
//4.執行對象的run方法開始執行編譯
compiler.run();
爲什麼 Rollup 構建產物很乾淨?
-
rollup 只對 ESM 模塊進行打包,對於 cjs 模塊也會通過插件將其轉化爲 ESM 模塊進行打包。所以不會像 webpack 有很多的代碼注入。
-
rollup 對打包結果也支持多種 format 的輸出,比如:esm、cjs、am 等等,但是 rollup 並不保證代碼可靠運行,需要運行環境可靠支持。比如我們輸出 esm 規範代碼,代碼運行時完全依賴高版本瀏覽器原生去支持 esm,rollup 不會像 webpack 一樣注入一系列兼容代碼。
-
rollup 實現了強大的 tree-shaking 能力。
爲什麼 Vite 可以讓代碼直接運行在瀏覽器上?
前文我們提到,在開發環境時,我們使用 vite 開發,是無需打包的,直接利用瀏覽器對 ESM 的支持,就可以訪問我們寫的組件代碼,但是一些組件代碼文件往往不是 JS 文件,而是 .ts
、.tsx
、.vue
等類型的文件。這些文件瀏覽器肯定直接是識別不了的,vite 在這個過程中做了些什麼?
我們以一個簡單的 vue 組件訪問分析一下:
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta />
<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>
在瀏覽器中打開頁面後,會發現瀏覽器對入口文件發起了請求:
我們可以觀察到 vue 這個第三方包的訪問路徑改變了,變成了node_modules/.vite
下的一個 vue 文件,這裏真正訪問的文件就是前面我們提到的,vite 會對第三方依賴進行依賴預構建
所生成的緩存文件。
另外瀏覽器也對 App.vue 發起了訪問,相應內容是 JS:
返回內容(做了部分簡化,移除了一些熱更新的代碼):
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;
總結:當用戶訪問 vite 提供的開發服務器時,對於瀏覽器不能直接識別的文件,服務器的一些中間件會將此類文件轉換成瀏覽器認識的文件,從而保證正常訪問。
參考資料
[1]
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 前端構建工具排行: https://risingstars.js.org/2021/zh#section-build
[9]
前端構建工具簡史: https://juejin.cn/post/7085613927249215525
[10]
ESM 實現原理: https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/
[11]
Webpack 核心原理:https://mp.weixin.qq.com/s/SbJNbSVzSPSKBe2YStn2Zw
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/o8B8HAczZtIZM8V_HHwNqg