手把手教你寫一個迷你 Webpack
一、前言
最近正好在學(xué)習(xí) Webpack,覺得 Webpack 這種通過構(gòu)建模塊依賴圖來打包項目文件的思想很有意思,于是參考了網(wǎng)上的一些文章實現(xiàn)了一個簡陋版本的 mini-webpack,通過入口文件將依賴的模塊打包在一起,生成一份最終運行的代碼。想了解 Webpack 的構(gòu)建原理還需要補充一些相關(guān)的背景知識,下面一起來看看。
二、背景知識
1. 抽象語法樹(AST)
什么是抽象語法樹?
平時我們編寫程序的時候,會經(jīng)常在代碼中根據(jù)需要 import 一些模塊,那 Webpack 在構(gòu)建項目、分析依賴的時候是如何得知我們代碼中是否有 import 文件,import 的是什么文件的呢?Webpack 并不是人,無法像我們一樣一看到代碼語句就明白其含義,所以我們需要將編寫的代碼轉(zhuǎn)換成 Webpack 認(rèn)識的格式讓他它進(jìn)行處理,這份轉(zhuǎn)換后生成的東西就是抽象語法樹。下面這張圖能很好地說明什么是抽象語法樹:

可以看到,抽象語法樹是源代碼的抽象語法結(jié)構(gòu)樹狀表現(xiàn)形式,我們每條編寫的代碼語句都可以被解析成一個個的節(jié)點,將一整個代碼文件解析后就會生成一顆節(jié)點樹,作為程序代碼的抽象表示。通過抽象語法樹,我們可以做以下事情:
IDE 的錯誤提示、代碼格式化、代碼高亮、代碼自動補全等
JSLint、JSHint、ESLint 對代碼錯誤或風(fēng)格的檢查等
Webpack、rollup 進(jìn)行代碼打包等
Babel 轉(zhuǎn)換 ES6 到 ES5 語法
注入代碼統(tǒng)計單元測試覆蓋率
想看看你的代碼會生成怎樣的抽象語法樹嗎?這里有一個工具?AST Explorer?能夠在線預(yù)覽你的代碼生成的抽象語法樹,感興趣的不妨上去試一試。

2. Babel
Babel 是一個工具鏈,主要用于將采用 ECMAScript 2015+ 語法編寫的代碼轉(zhuǎn)換為向后兼容的 JavaScript 語法,以便能夠運行在當(dāng)前和舊版本的瀏覽器或其他環(huán)境中。通過 Babel 我們可以做以下事情:
語法轉(zhuǎn)換
通過 Polyfill 方式在目標(biāo)環(huán)境中添加缺失的特性(通過第三方 Polyfill 模塊,例如?core-js,實現(xiàn))
源碼轉(zhuǎn)換 (codemods)
一般來說項目使用 Webpack 來打包文件都會配置 babel-loader 將 ES6 的代碼轉(zhuǎn)換成 ES5 的格式以兼容瀏覽器,這個過程就需要將我們的代碼轉(zhuǎn)換成抽象語法樹后再進(jìn)行轉(zhuǎn)換處理,轉(zhuǎn)換完成后再將抽象語法樹還原成代碼。
// Babel 輸入:ES2015 箭頭函數(shù)
[1,?2,?3].map((n)?=>?n?+?1);
// Babel 輸出:ES5 語法實現(xiàn)的同等功能
[1,?2,?3].map(function(n)?{
??return?n?+?1;
});
3. Webpack 打包原理
Webpack 的構(gòu)建過程一般會分為以下幾步:
讀取 Webpack 基礎(chǔ)配置
????//?讀取 webpack.config.js 配置文件:
????const?path?=?require("path")
????module.exports?=?{
????????entry:"./src/index.js"
????????mode:"development"
????????output:{
??????????path:path.resolve(__dirname,"./dist"),
??????????filename:"bundle.js"
????????}
????}
入口文件分析
分析依賴模塊
分析內(nèi)容
編譯內(nèi)容
依賴模塊分析
分析依賴模塊是否有其他模塊
分析內(nèi)容
編譯內(nèi)容
生成打包文件
????//?基礎(chǔ)結(jié)構(gòu)為一個IIFE自執(zhí)行函數(shù)
????//?接收一個對象參數(shù),key?為入口文件的目錄,value為一個執(zhí)行入口文件里面代碼的函數(shù)
????(function?(modules)?{
??????//?installedModules?用來存放緩存
??????const?installedModules?=?{};
??????//?__webpack_require__用來轉(zhuǎn)化入口文件里面的代碼
??????function?__webpack_require__(moduleIid)?{?...?}
??????// IIFE將 modules 中的 key 傳遞給?__webpack_require__?函數(shù)并返回。
??????return?__webpack_require__(__webpack_require__.s?=?'./src/index.js');
????}({
??????'./src/index.js':?(function?(module,?exports)?{
????????eval('console.log(\'test?webpack?entry\')');
??????}),
????}));
三、具體實現(xiàn)
1. 安裝相關(guān)依賴
我們需要用到以下幾個包:
@babel/parser:用于將輸入代碼解析成抽象語法樹(AST)
@babel/traverse:用于對輸入的抽象語法樹(AST)進(jìn)行遍歷
@babel/core:babel 的核心模塊,進(jìn)行代碼的轉(zhuǎn)換
@babel/preset-env:可根據(jù)配置的目標(biāo)瀏覽器或者運行環(huán)境來自動將 ES2015 + 的代碼轉(zhuǎn)換為 es5
使用 npm 命令安裝一下:
npm?install?@babel/parser?@babel/traverse?@babel/core?@babel/preset-env?-D
2. 讀取基本配置
要讀取 Webpack 的基本配置,首先我們得有一個全局的配置文件:
//?mini-webpack.config.js
const?path?=?require('path');
module.exports?={
????entry:?"./src/index.js",
????mode:?"development",
????output:?{
??????path:?path.resolve(__dirname,"./dist"),
??????filename:?"bundle.js"
????}
}
然后我們新建一個類,用于實現(xiàn)分析編譯等函數(shù),并在構(gòu)造函數(shù)中初始化配置信息:
const?options?=?require('./mini-webpack.config');
class?MiniWebpack{
????constructor(options){
????????this.options?=?options;
????}
????//?...
}
3. 代碼轉(zhuǎn)換,獲取模塊信息
我們使用?fs?讀取文件內(nèi)容,使用?parser?將模塊代碼轉(zhuǎn)換成抽象語法樹,再使用?traverse?遍歷抽象語法樹,針對其中的?ImportDeclaration?節(jié)點保存模塊的依賴信息,最終使用?babel.transformFromAst?方法將抽象語法樹還原成 ES5 風(fēng)格的代碼。
parse?=?filename?=>?{
????//?讀取文件
????const?fileBuffer?=?fs.readFileSync(filename,?'utf-8');
????//?轉(zhuǎn)換成抽象語法樹
????const?ast?=?parser.parse(fileBuffer,?{?sourceType:?'module'?});
????const?dependencies?=?{};
????//?遍歷抽象語法樹
????traverse(ast,?{
????????//?處理ImportDeclaration節(jié)點
????????ImportDeclaration({node}){
????????????const?dirname?=?path.dirname(filename);
????????????const?newDirname?=?'./'?+?path.join(dirname,?node.source.value).replace('\\',?'/');
????????????dependencies[node.source.value]?=?newDirname;
????????}
????})
????//?將抽象語法樹轉(zhuǎn)換成代碼
????const?{?code?}?=?babel.transformFromAst(ast,?null,?{
????????presets:['@babel/preset-env']
????});
????
????return?{
????????filename,
????????dependencies,
????????code
????}
}
4. 分析依賴關(guān)系
從入口文件開始,循環(huán)解析每個文件與其依賴文件的信息,最終生成以文件名為?key,以包含依賴關(guān)系與編譯后模塊代碼的對象為?value?的依賴圖譜對象并返回。
analyse?=?entry?=>?{
????//?解析入口文件
????const?entryModule?=?this.parse(entry);
????const?graphArray?=?[entryModule];
????//?循環(huán)解析模塊,保存信息
????for(let?i=0;i????????const?{?dependencies?}?=?graphArray[i];
????????Object.keys(dependencies).forEach(filename?=>?{
????????????graphArray.push(this.parse(dependencies[filename]));
????????})
????}
????const?graph?=?{};
????//?生成依賴圖譜對象
????graphArray.forEach(({filename,?dependencies,?code})=>{
????????graph[filename]?=?{
????????????dependencies,
????????????code
????????};
????})
????return?graph;
}
5. 生成打包代碼
生成依賴圖譜對象,作為參數(shù)傳入一個自執(zhí)行函數(shù)當(dāng)中??梢钥吹?,自執(zhí)行函數(shù)中有個 require 函數(shù),它的作用是通過調(diào)用 eval 執(zhí)行模塊代碼來獲取模塊內(nèi)部 export 出來的值。最終我們返回打包的代碼。
generate?=?(graph,?entry)?=>?{
????return?`
????(function(graph){
????????function?require(filename){
????????????function?localRequire(relativePath){
????????????????return?require(graph[filename].dependencies[relativePath]);
????????????}
????????????const?exports?=?{};
????????????(function(require,?exports,?code){
????????????????eval(code);
????????????})(localRequire,?exports,?graph[filename].code)
????????????return?exports;
????????}
????????
????????require('${entry}');
????})(${graph})
????`
}
6. 輸出最終文件
通過獲取 this.options 中的 output 信息,將打包代碼輸出到對應(yīng)文件中。
fileOutput?=?(output,?code)?=>?{
????const?{?path:?dirPath,?filename?}?=?output;
????const?outputPath?=?path.join(dirPath,?filename);
????//?如果沒有文件夾的話,生成文件夾
????if(!fs.existsSync(dirPath)){
????????fs.mkdirSync(dirPath)
????}
????//?寫入文件中
????fs.writeFileSync(outputPath,?code,?'utf-8');
}
7. 模擬 run 函數(shù)
我們將上面的流程集成到一個 run 函數(shù)中,通過調(diào)用該函數(shù)來將整個構(gòu)建打包流程跑通。
run?=?()?=>?{
????const?{?entry,?output?}?=?this.options;
????const?graph?=?this.analyse(entry);
????// stringify依賴圖譜對象,防止在模板字符串中調(diào)用toString()返回[object Object]
????const?graphStr?=?JSON.stringify(graph);
????const?code?=?this.generate(graphStr,?entry);
????this.fileOutput(output,?code);
}
8.mini-webpack 大功告成
通過上面的流程,我們的 mini-webpack 已經(jīng)完成了。我們將文件保存為 main.js,新建一個 MiniWebpack 對象并執(zhí)行它的 run 函數(shù):
//?main.js
const?options?=?require('./mini-webpack.config');
class?MiniWebpack{
????constructor(options){
????????//?...
????}
????parse?=?filename?=>?{
????????//?...
????}
????analyse?=?entry?=>?{
????????//?...
????}
????generate?=?(graph,?entry)?=>?{
????????//?...
????}
????fileOutput?=?(output,?code)?=>?{
????????//?...
????}
????run?=?()?=>?{
????????//?...
????}
}
const?miniWebpack?=?new?MiniWebpack(options);
miniWebpack.run();
四、實際演示
我們來實際試驗一下,看看這個 mini-webpack 能不能正常運行。
1. 新建測試文件
首先在根目錄下創(chuàng)建?src?文件夾,新建?a.js、b.js、index.js?三個文件

三個文件內(nèi)容如下:
a.js
export?default?1;
b.js
export?default?function(){
????console.log('I?am?b');
}
index.js
import?a?from?'./a.js';
import?b?from?'./b.js';
console.log(a);
console.log(b);
2. 填入配置文件
配置好入口文件、輸出文件等信息:
const?path?=?require('path');
module.exports?={
????entry:?"./src/index.js",
????mode:?"development",
????output:?{
??????path:?path.resolve(__dirname,"./dist"),
??????filename:?"bundle.js"
????}
}
3. 完善 package.json
我們在 package.json 的?scripts?中新增一個?build?命令,內(nèi)容為執(zhí)行 main.js:
{
??"name":?"mini-webpack",
??"version":?"1.0.0",
??"description":?"",
??"main":?"index.js",
??"scripts":?{
????"test":?"echo?\"Error:?no?test?specified\"?&&?exit?1",
????"build":?"node?main.js"
??},
??"author":?"",
??"license":?"ISC",
??"devDependencies":?{
????"@babel/core":?"^7.15.4",
????"@babel/parser":?"^7.15.4",
????"@babel/preset-env":?"^7.15.4",
????"@babel/traverse":?"^7.15.4"
??}
}
4. 效果演示
我們執(zhí)行?npm run build?命令,可以看到在根目錄下生成了 dist 文件夾,里面有個 bundle.js 文件,內(nèi)容正是我們輸出的打包代碼:

執(zhí)行下?bundle.js?文件,看看會有什么輸出:

可以看到,bundle.js 的輸出正是 index.js 文件中兩個 console.log 輸出的值,說明我們的代碼轉(zhuǎn)換沒有問題,到這里試驗算是成功了。
五、項目 Git 地址
項目代碼在此:mini-webpack
