手把手教你寫一個迷你 Webpack

「來源: |前端技術江湖 ID:bigerfe」

一、前言

最近正好在學習 Webpack,覺得 Webpack 這種透過構建模組依賴圖來打包專案檔案的思想很有意思,於是參考了網上的一些文章實現了一個簡陋版本的 mini-webpack,透過入口檔案將依賴的模組打包在一起,生成一份最終執行的程式碼。想了解 Webpack 的構建原理還需要補充一些相關的背景知識,下面一起來看看。

二、背景知識

1。 抽象語法樹(AST)

什麼是抽象語法樹?

平時我們編寫程式的時候,會經常在程式碼中根據需要 import 一些模組,那 Webpack 在構建專案、分析依賴的時候是如何得知我們程式碼中是否有 import 檔案,import 的是什麼檔案的呢?Webpack 並不是人,無法像我們一樣一看到程式碼語句就明白其含義,所以我們需要將編寫的程式碼轉換成 Webpack 認識的格式讓他它進行處理,這份轉換後生成的東西就是抽象語法樹。下面這張圖能很好地說明什麼是抽象語法樹:

手把手教你寫一個迷你 Webpack

可以看到,抽象語法樹是原始碼的抽象語法結構樹狀表現形式,我們每條編寫的程式碼語句都可以被解析成一個個的節點,將一整個程式碼檔案解析後就會生成一顆節點樹,作為程式程式碼的抽象表示。透過抽象語法樹,我們可以做以下事情:

IDE 的錯誤提示、程式碼格式化、程式碼高亮、程式碼自動補全等

JSLint、JSHint、ESLint 對程式碼錯誤或風格的檢查等

Webpack、rollup 進行程式碼打包等

Babel 轉換 ES6 到 ES5 語法

注入程式碼統計單元測試覆蓋率

想看看你的程式碼會生成怎樣的抽象語法樹嗎?這裡有一個工具

AST Explorer

能夠線上預覽你的程式碼生成的抽象語法樹,感興趣的不妨上去試一試。

手把手教你寫一個迷你 Webpack

2。 Babel

Babel 是一個工具鏈,主要用於將採用 ECMAScript 2015+ 語法編寫的程式碼轉換為向後相容的 JavaScript 語法,以便能夠執行在當前和舊版本的瀏覽器或其他環境中。透過 Babel 我們可以做以下事情:

語法轉換

透過 Polyfill 方式在目標環境中新增缺失的特性(透過第三方 Polyfill 模組,例如

原始碼轉換 (codemods)

一般來說專案使用 Webpack 來打包檔案都會配置 babel-loader 將 ES6 的程式碼轉換成 ES5 的格式以相容瀏覽器,這個過程就需要將我們的程式碼轉換成抽象語法樹後再進行轉換處理,轉換完成後再將抽象語法樹還原成程式碼。

// Babel 輸入:ES2015 箭頭函式

[1, 2, 3]。map((n) => n + 1);

// Babel 輸出:ES5 語法實現的同等功能

[1, 2, 3]。map(function(n) {

return n + 1;

});

3。 Webpack 打包原理

Webpack 的構建過程一般會分為以下幾步:

讀取 Webpack 基礎配置

// 讀取 webpack。config。js 配置檔案:

const path = require(“path”)

module。exports = {

entry:“。/src/index。js”

mode:“development”

output:{

path:path。resolve(__dirname,“。/dist”),

filename:“bundle。js”

}

}

入口檔案分析

分析依賴模組

分析內容

編譯內容

依賴模組分析

分析依賴模組是否有其他模組

分析內容

編譯內容

生成打包檔案

// 基礎結構為一個IIFE自執行函式

// 接收一個物件引數,key 為入口檔案的目錄,value為一個執行入口檔案裡面程式碼的函式

(function (modules) {

// installedModules 用來存放快取

const installedModules = {};

// __webpack_require__用來轉化入口檔案裡面的程式碼

function__webpack_require__(moduleIid) { 。。。 }

// IIFE將 modules 中的 key 傳遞給 __webpack_require__ 函式並返回。

return __webpack_require__(__webpack_require__。s = ‘。/src/index。js’);

}({

‘。/src/index。js’: (function (module, exports) {

eval(‘console。log(\’test webpack entry\‘)’);

}),

}));

三、具體實現

1。 安裝相關依賴

我們需要用到以下幾個包:

@babel/parser:用於將輸入程式碼解析成抽象語法樹(AST)

@babel/traverse:用於對輸入的抽象語法樹(AST)進行遍歷

@babel/core:babel 的核心模組,進行程式碼的轉換

@babel/preset-env:可根據配置的目標瀏覽器或者執行環境來自動將 ES2015 + 的程式碼轉換為 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”

}

}

然後我們新建一個類,用於實現分析編譯等函式,並在建構函式中初始化配置資訊:

const options = require(‘。/mini-webpack。config’);

classMiniWebpack{

constructor(options){

this。options = options;

}

// 。。。

}

3。 程式碼轉換,獲取模組資訊

我們使用 fs 讀取檔案內容,使用 parser 將模組程式碼轉換成抽象語法樹,再使用 traverse 遍歷抽象語法樹,針對其中的 ImportDeclaration 節點儲存模組的依賴資訊,最終使用 babel。transformFromAst 方法將抽象語法樹還原成 ES5 風格的程式碼。

parse = filename => {

// 讀取檔案

const fileBuffer = fs。readFileSync(filename, ‘utf-8’);

// 轉換成抽象語法樹

const ast = parser。parse(fileBuffer, { sourceType: ‘module’ });

const dependencies = {};

// 遍歷抽象語法樹

traverse(ast, {

// 處理ImportDeclaration節點

ImportDeclaration({node}){

const dirname = path。dirname(filename);

const newDirname = ‘。/’ + path。join(dirname, node。source。value)。replace(‘\\’, ‘/’);

dependencies[node。source。value] = newDirname;

}

})

// 將抽象語法樹轉換成程式碼

const { code } = babel。transformFromAst(ast, null, {

presets:[‘@babel/preset-env’]

});

return {

filename,

dependencies,

code

}

}

4。 分析依賴關係

從入口檔案開始,迴圈解析每個檔案與其依賴檔案的資訊,最終生成以檔名為 key,以包含依賴關係與編譯後模組程式碼的物件為 value 的依賴圖譜物件並返回。

analyse = entry => {

// 解析入口檔案

const entryModule = this。parse(entry);

const graphArray = [entryModule];

// 迴圈解析模組,儲存資訊

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。 生成打包程式碼

生成依賴圖譜物件,作為引數傳入一個自執行函式當中。可以看到,自執行函式中有個 require 函式,它的作用是透過呼叫 eval 執行模組程式碼來獲取模組內部 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 資訊,將打包程式碼輸出到對應檔案中。

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 函式

我們將上面的流程整合到一個 run 函式中,透過呼叫該函式來將整個構建打包流程跑通。

run = () => {

const { entry, output } = this。options;

const graph = this。analyse(entry);

// stringify依賴圖譜物件,防止在模板字串中呼叫toString()返回[object Object]

const graphStr = JSON。stringify(graph);

const code = this。generate(graphStr, entry);

this。fileOutput(output, code);

}

8。mini-webpack 大功告成

透過上面的流程,我們的 mini-webpack 已經完成了。我們將檔案儲存為 main。js,新建一個 MiniWebpack 物件並執行它的 run 函式:

// main。js

const options = require(‘。/mini-webpack。config’);

classMiniWebpack{

constructor(options){

// 。。。

}

parse = filename => {

// 。。。

}

analyse = entry => {

// 。。。

}

generate = (graph, entry) => {

// 。。。

}

fileOutput = (output, code) => {

// 。。。

}

run = () => {

// 。。。

}

}

const miniWebpack = new MiniWebpack(options);

miniWebpack。run();

四、實際演示

我們來實際試驗一下,看看這個 mini-webpack 能不能正常執行。

1。 新建測試檔案

首先在根目錄下建立 src 資料夾,新建 a。js、b。js、index。js 三個檔案

手把手教你寫一個迷你 Webpack

三個檔案內容如下:

a。js

exportdefault1;

b。js

exportdefaultfunction(){

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 命令,內容為執行 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。 效果演示

我們執行 npm run build 命令,可以看到在根目錄下生成了 dist 資料夾,裡面有個 bundle。js 檔案,內容正是我們輸出的打包程式碼:

手把手教你寫一個迷你 Webpack

執行下 bundle。js 檔案,看看會有什麼輸出:

手把手教你寫一個迷你 Webpack

可以看到,bundle。js 的輸出正是 index。js 檔案中兩個 console。log 輸出的值,說明我們的程式碼轉換沒有問題,到這裡試驗算是成功了。

五、專案 Git 地址

專案程式碼在此:

mini-webpack

六、參考文章

Babel 中文文件

webpack 構建原理和實現簡單 webpack