如何自己實現一個簡單的webpack構建工具 【精讀】

Peter譚金傑發表於2019-09-03

clipboard.png

如果對React技術棧感興趣的你,可以去閱讀我的前面兩篇文章:

從零自己實現一個mini-React框架

從零搭建一個React優化版腳手架

GitHub上面都有對應的原始碼哦~ 歡迎Star

webpack可以說是目前最火的打包工具,如果用不好他,真的不敢說自己是個合格的前端工程師

本文會先介紹webpack的打包流程,執行原理,然後去實現一個簡單的webpack

clipboard.png

本質上,webpack 是一個現代 JavaScript 應用程式的靜態模組打包器(module bundler)。當 webpack 處理應用程式時,它會遞迴地構建一個依賴關係圖(dependency graph),其中包含應用程式需要的每個模組,然後將所有這些模組打包成一個或多個 bundle

webpack打包過程

1.識別入口檔案

2.通過逐層識別模組依賴。(Commonjs、amd或者es6的import,webpack都會對其進行分析。來獲取程式碼的依賴)

3.webpack做的就是分析程式碼。轉換程式碼,編譯程式碼,輸出程式碼

4.最終形成打包後的程式碼

webpack打包原理

1.先逐級遞迴識別依賴,構建依賴圖譜

2.將程式碼轉化成AST抽象語法樹

下圖是一個抽象語法樹:

clipboard.png
]

3.在AST階段中去處理程式碼

4.把AST抽象語法樹變成瀏覽器可以識別的程式碼, 然後輸出

準備工作

在編寫自己的構建工具前,需要下載四個包。

1.@babel/parser: 分析我們通過 fs.readFileSync 讀取的檔案內容,返回 AST (抽象語法樹)
2.@babel/traverse: 可以遍歷 AST, 拿到必要的資料
3.@babel/core: babel 核心模組,其有個transformFromAst方法,可以將 AST 轉化為瀏覽器可以執行的程式碼
4.@babel/preset-env: 將程式碼轉化成 ES5 程式碼

使用yarn下載:

$ yarn init -y 

$ yarn add @babel/parser @babel/traverse @babel/core @babel/preset-env 

首先檢視如何將最簡單的一個檔案轉換成AST

目錄結構:

clipboard.png

程式碼實現:

const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default; 
// traverse 採用的 ES Module 匯出,我們通過 requier 引入的話就加個 .default
const babel = require('@babel/core');

const read = fileName => {
  const buffer = fs.readFileSync(fileName, 'utf-8');
  const AST = parser.parse(buffer, { sourceType: 'module' });
  console.log(AST);
};
read('./test1.js');


上面程式碼:

1.先用同步的Node API讀取檔案流

2.再將對應的buffer轉換成下面的AST

Node {
  type: 'File',
  start: 0,
  end: 32,
  loc:
   SourceLocation {
     start: Position { line: 1, column: 0 },
     end: Position { line: 1, column: 32 } },
  program:
   Node {
     type: 'Program',
     start: 0,
     end: 32,
     loc: SourceLocation { start: [Position], end: [Position] },
     sourceType: 'module',
     interpreter: null,
     body: [ [Node] ],
     directives: [] },
  comments: [] }

我們已經將程式碼轉換成了AST語法樹,那麼還需要遍歷AST,然後轉換成瀏覽器可以認識的程式碼

read函式中加入如下程式碼:


// 依賴收集
    const dependencies = {};

    // 使用 traverse 來遍歷 AST
    traverse(AST, {
        ImportDeclaration({ node }) { // 函式名是 AST 中包含的內容,引數是一些節點,node 表示這些節點下的子內容
            const dirname = path.dirname(filename); // 我們從抽象語法樹裡面拿到的路徑是相對路徑,然後我們要處理它,在 bundler.js 中才能正確使用
            const newDirname = './' + path.join(dirname, node.source.value).replace('\\', '/'); // 將dirname 和 獲取到的依賴聯合生成絕對路徑
            dependencies[node.source.value] = newDirname; // 將源路徑和新路徑以 key-value 的形式儲存起來
        }
    })

    // 將抽象語法樹轉換成瀏覽器可以執行的程式碼
    const { code } = babel.transformFromAst(AST, null, {
        presets: ['@babel/preset-env']
    })

    return {
        filename,
        dependencies,
        code
    }

當我們呼叫read函式,讀取test1.js的內容時:

const result = read('./test1.js');
console.log(result);

得到列印的輸出結果:

{ fileName: './test1.js',
  dependencies: {},
  code: '"use strict";\n\nconsole.log(\'this is test1.js \');' }

原本test1.js的內容是:

clipboard.png

正式開始,下面加入ES6模組化,重新定義檔案目錄

clipboard.png

啟動檔案 index.js

...//一些的邏輯都在這個檔案中,我們只需要傳入一個entry入口

app.js

import test1 from './test1.js'
console.log(test1)

test1.js

import test2 from './test2.js';
console.log('this is test1.js ', test2);

test2.js

function test2() {
  console.log('this is test2 ');
}

export default test2;

依賴關係非常清楚:
入口是index.js - > 依賴test1.js依賴 - > test2.js

上面僅僅做了一些的處理,如果遇到依賴的檔案還有依賴就不行了。

於是我們需要建立一個可以處理依賴關係的函式:

獲取依賴圖譜

// 建立依賴圖譜函式, 遞迴遍歷所有依賴模組
const makeDependenciesGraph = (entry) => {
    const entryModule = read(entry)
    const graghArray = [ entryModule ]; // 首先將我們分析的入口檔案結果放入圖譜陣列中
    for (let i = 0; i < graghArray.length; i ++) {
    const item = graghArray[i];
        const { dependencies } = item; // 拿到當前模組所依賴的模組
        if (dependencies) {
            for ( let j in dependencies ) { // 通過 for-in 遍歷物件
                graghArray.push(read(dependencies[j])); // 如果子模組又依賴其它模組,就分析子模組的內容
            }
        }
    }

    const gragh = {}; // 將圖譜的陣列形式轉換成物件形式
    graghArray.forEach( item => {
        gragh[item.filename] = {
            dependencies: item.dependencies,
            code: item.code
        }
  })
  console.log(gragh)
    return gragh;
}

列印gragh得到的物件:

{ './app.js':
   { dependencies: { './test1.js': './test1.js' },
     code:
      '"use strict";\n\nvar _test = _interopRequireDefault(require("./test1.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nconsole.log(test
1);' },
  './test1.js':
   { dependencies: { './test2.js': './test2.js' },
     code:
      '"use strict";\n\nvar _test = _interopRequireDefault(require("./test2.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nconsole.log(\'th
is is test1.js \', _test["default"]);' },
  './test2.js':
   { dependencies: {},
     code:
      '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nexports["default"] = void 0;\n\nfunction test2() {\n  console.log(\'this is test2 \');\n}\n\nvar _default = tes
t2;\nexports["default"] = _default;' } } 

此時我們已經獲取了所有的依賴,以及依賴的程式碼內容,只需要處理輸出即可

最終處理程式碼輸出


const generateCode = (entry) => {
    // 注意:我們的 gragh 是一個物件,key是我們所有模組的絕對路徑,需要通過 JSON.stringify 來轉換
    const gragh = JSON.stringify(makeDependenciesGraph(entry));
    // 我們知道,webpack 是將我們的所有模組放在閉包裡面執行的,所以我們寫一個自執行的函式
    // 注意: 我們生成的程式碼裡面,都是使用的 require 和 exports 來引入匯出模組的,而我們的瀏覽器是不認識的,所以需要構建這樣的函式
    return `
        (function( gragh ) {
            function require( module ) {
                // 相對路徑轉換成絕對路徑的方法
                function localRequire(relativePath) {
                    return require(gragh[module].dependencies[relativePath])
                }
                const exports = {};
                (function( require, exports, code ) {
                    eval(code)
                })( localRequire, exports, gragh[module].code )

                return exports;
            }
            require('${ entry }')
        })(${ gragh })
    `;
}

const code = generateCode('./app.js');

console.log(code)

得到編譯輸出的程式碼code如下:

(function( gragh ) {
                        function require( module ) {
                                // 相對路徑轉換成絕對路徑的方法
                                function localRequire(relativePath) {
                                        return require(gragh[module].dependencies[relativePath])
                                }
                                const exports = {};
                                (function( require, exports, code ) {
                                        eval(code)
                                })( localRequire, exports, gragh[module].code )

                                return exports;
                        }
                        require('./app.js')
                })({"./app.js":{"dependencies":{"./test1.js":"./test1.js"},"code":"\"use strict\";\n\nvar _test = _interopRequireDefault(require(\"./test1.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nconsole.log(_test[\"default\"]);"},"./test1.js":{"dependencies":{"./test2.js":"./test2.js"},"code":"\"use strict\";\n\nvar _test = _interopRequireDefault(require(\"./test2.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nconsole.log('this is test1.js ', _test[\"default\"]);"},"./test2.js":{"dependencies":{},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports[\"default\"] = void 0;\n\nfunction test2() {\n  console.log('this is test2 ');\n}\n\nvar _default = test2;\nexports[\"default\"] = _default;"}})

複製這段程式碼到瀏覽器中執行:

clipboard.png

程式碼是可以執行的,ES6模組化已經可以被瀏覽器識別

模仿webpack實現loaderplugin:

在開頭那篇文章有介紹到,webpackloaderplugin本質:

loader本質是對字串的正則匹配操作

plugin的本質,是依靠webpack執行時廣播出來的生命週期事件,再呼叫Node.jsAPI利用webpack的全域性例項物件進行操作,不論是硬碟檔案的操作,還是記憶體中的資料操作。

webpack的核心依賴庫: Tapable

tapablewebpack的核心依賴庫 想要讀懂webpack原始碼 就必須首先熟悉tapable


const {
    SyncHook,
    SyncBailHook,
    SyncWaterfallHook,
    SyncLoopHook,
    AsyncParallelHook,
    AsyncParallelBailHook,
    AsyncSeriesHook,
    AsyncSeriesBailHook,
    AsyncSeriesWaterfallHook
 } = require("tapable");

這些鉤子可分為同步的鉤子和非同步的鉤子,Sync開頭的都是同步的鉤子,Async開頭的都是非同步的鉤子。而非同步的鉤子又可分為並行和序列,其實同步的鉤子也可以理解為序列的鉤子。

我的理解:

這是一個釋出-訂閱模式

webpack執行時廣播出事件,讓之前訂閱這些事件的訂閱者們(其實就是外掛)都觸發對應的事件,並且拿到全域性的webpack例項物件,再做一系列的處理,就可以完成很複雜的功能

同步的鉤子是序列

非同步的鉤子分為並行和序列的鉤子,並行是指 等待所有併發的非同步事件執行之後再執行最終的非同步回撥。
而序列是值 第一步執行完畢再去執行第二步,以此類推,直到執行完所有回撥再去執行最終的非同步回撥。

拿最簡單的同步鉤子,SyncHook來說

const { SyncHook } = require('tapable');

class Hook{
    constructor(){
        /** 1 生成SyncHook例項 */
        this.hooks = new SyncHook(['name']);
    }
    tap(){
        /** 2 註冊監聽函式 */
        this.hooks.tap('node',function(name){
            console.log('node',name);
        });
        this.hooks.tap('react',function(name){
            console.log('react',name);
        });
    }
    start(){
        /** 3出發監聽函式 */
        this.hooks.call('call end.');
    }
}

let h = new Hook();

h.tap();/** 類似訂閱 */ 
h.start();/** 類似釋出 */

/* 列印順序:
    node call end.
    react call end.
*/

再看一個非同步鉤子AsyncParallelHook

const { AsyncParallelHook } = require('tapable');

class Hook{
    constructor(){
        this.hooks = new AsyncParallelHook(['name']);
    }
    tap(){
        /** 非同步的註冊方法是tapAsync() 
         * 並且有回撥函式cb.
        */
        this.hooks.tapAsync('node',function(name,cb){
            setTimeout(()=>{
                console.log('node',name);
                cb();
            },1000);
        });
        this.hooks.tapAsync('react',function(name,cb){
            setTimeout(()=>{
                console.log('react',name);
                cb();
            },1000);
        });
    }
    start(){
        /** 非同步的觸發方法是callAsync() 
         * 多了一個最終的回撥函式 fn.
        */
        this.hooks.callAsync('call end.',function(){
            console.log('最終的回撥');
        });
    }
}

let h = new Hook();

h.tap();/** 類似訂閱 */
h.start();/** 類似釋出 */

/* 列印順序:
    node call end.
    react call end.
    最終的回撥
*/


當然,作者的能力還沒有到完全解析webpack的水平,如果有興趣可以深入研究下Tapable這個庫的原始碼

有興趣深入研究的可以看看這兩篇文章

深入理解Webpack核心模組Tapable鉤子---同步版

深入理解Webpack核心模組Tapable鉤子---非同步版

今天先寫到這裡了,如果覺得寫得不錯,別忘了點個贊

歡迎加入segmentFault前端交流群

我的個人微信:CALASFxiaotan 拉你進群

小姐姐們等你哦~

相關文章