寫了這麼多webpack配置,不想作一個本身的打包工具嗎

總所周知,現代前端,基本都離不開前端工程化,多多少少都要用上打包工具進行處理,例如webpack、rollup。不過寫得再多,也只是針對webpack/rollup的配置工程師,具體打包過程,對咱們來講倒是個黑盒。前端

不如,咱們來搞點事情?node

梳理流程

在構建以前咱們要梳理一下打包的流程。webpack

  1. 咱們的打包工具要有一個打包入口 [entry]git

  2. 咱們的 [entry] 文件會引入依賴,所以須要一個 {graph} 來存儲模塊圖譜,這個模塊圖譜有3個核心內容:github

    • filepath :模塊路徑,例如 ./src/message.js'
    • dependencies :模塊裏用了哪些依賴
    • code :這個模塊中具體的代碼
  3. {graph} 會存儲全部的模塊,所以須要遞歸遍歷每個被依賴了的模塊web

  4. 根據 {graph} 的依賴路徑,用 {graph.code} 構建可運行的代碼shell

  5. 將代碼輸出到出口文件,打包完成npm

搭建架構

首先寫好咱們須要打包的代碼windows

// ./src/index.js
import message from './message.js';
import {word} from './word.js';

console.log(message);
console.log(word);
複製代碼
// ./src/message.js
import {word} from './word.js';
const message = `hello ${word}`;

export default message;
複製代碼
// ./src/word.js
export const word = 'paraslee';
複製代碼

而後在根目錄建立bundler.js,設計好具體要用上的功能,前端工程化

// ./bundler.js
// 模塊分析能力:分析單個模塊,返回分析結果
function moduleAnalyser(filepath) {
    return {};
}

// 圖譜構建能力:遞歸模塊,調用moduleAnalyser獲取每個模塊的信息,綜合存儲成一個模塊圖譜
function moduleGraph(entry) {
    const moduleMuster = moduleAnalyser(entry);
    
    const graph = {};
    return graph;
}

// 生成代碼能力:經過獲得的模塊圖譜生成可執行代碼
function generateCode(entry) {
    const graph = moduleGraph(entry);
    const code = '';
    
    return code;
}

// 調用bundleCode執行打包操做,獲取到可執行代碼後,將代碼輸出到文件裏
function bundleCode() {
    const code = generateCode('./src/index.js');
}

bundleCode();
複製代碼

自底向上,編碼開始!

模塊分析

首先是最底層的功能:moduleAnalyser(模塊分析)

由於第一個分析的模塊必定是入口文件,因此咱們在寫 moduleAnalyser 的具體內容時能夠把其餘代碼先註釋掉,單獨編寫這一塊。

function moduleAnalyser(filepath) {}
moduleAnalyser('./src/index.js');
複製代碼

首先,咱們須要讀取這個文件的信息,經過node提供的 fs API,咱們能夠讀取到文件的具體內容

const fs = require('fs');
function moduleAnalyser(filepath) {
    const content = fs.readFileSync(filePath, 'utf-8');
}
複製代碼

打印content能獲得下圖的結果

第二步,咱們須要獲取這個模塊的全部依賴,即 ./message.js./word.js 。有兩種方法能夠拿到依賴:1. 手寫規則進行字符串匹配;2. 使用babel工具操做

第一種方法實在吃力不討好,並且容錯性低,效率也不高,所以我採用第二種方法

babel有工具能幫咱們把JS代碼轉換成AST,經過對AST進行遍歷,能夠直接獲取到使用了 import 語句的內容

npm i @babel/parser @babel/traverse
複製代碼
...
const parser = require('@babel/parser');
const traverse = require("@babel/traverse").default;

const moduleAnalyser = (filePath) => {
    const content = fs.readFileSync(filePath, 'utf-8');
    // 經過 [@babel/parser的parse方法] 能將js代碼轉換成ast
    const AST = parser.parse(content, {
        sourceType: 'module' // 若是代碼使用了esModule,須要配置這一項
    });

    // [@babel/traverse] 能遍歷AST樹
    traverse(AST, {
        // 匹配 ImportDeclaration 類型節點 (import語法)
        ImportDeclaration: function(nodePath) {
            // 獲取模塊路徑
            const relativePath = nodePath.node.source.value;
        }
    });
}
複製代碼

若是咱們在控制檯中打印 relativePath 就能輸出 ./message.js./word.js

AST實在是太長♂了,感興趣的小夥伴能夠本身輸出AST看看長啥樣,我就不放出來了


第三步,獲取到依賴信息後,連同代碼內容一塊兒存儲下來

下面是 moduleAnalyser的完整代碼,看完後可能會有幾個疑惑,我會針對標註處挨個進行解釋

npm i @babel/core
複製代碼
...
const babel = require('@babel/core');

const moduleAnalyser = (filePath) => {
    const content = fs.readFileSync(filePath, 'utf-8');
    const AST = parser.parse(content, {
        sourceType: 'module'
    });

    // 存放文件路徑 #1
    const dirname = path.dirname(filePath);
    // 存放依賴信息
    const dependencies = {};

    traverse(AST, {
        ImportDeclaration: function(nodePath) {
            const relativePath = nodePath.node.source.value;
            // 將相對模塊的路徑 改成 相對根目錄的路徑 #2
            const absolutePath = path.join(dirname, relativePath);
            // replace是爲了解決windows系統下的路徑問題 #3
            dependencies[relativePath] = './' + absolutePath.replace(/\\/g, '/');
        }
    });

    // 用babel將AST編譯成可運行的代碼 #4
    const {code} = babel.transformFromAst(AST, null, {
        presets: ["@babel/preset-env"]
    })

    return {
        filePath,
        dependencies,
        code
    }
}
複製代碼

#1 爲何要獲取dirname?

首先入口文件爲 ./src/index.js,默認 ./src 爲代碼的根目錄,全部依賴,全部模塊文件都在 ./src 下面 (暫時先不考慮node_modules),所以咱們要獲取這個根目錄信息, dirname === 'src'

#2 爲何要把相對模塊的路徑 改成 相對根目錄的路徑

在 ./src/index.js 中是這樣引入模塊的 import message from './message.js' ,relativePath 變量存儲的值爲 ./message.js ,這對於分析 message.js 文件很是不便,轉換成 ./src/message.js 後就能直接經過fs讀取這個文件,方便了不少

#3 爲何要這樣存儲依賴信息

經過鍵值對的存儲,既能夠保留 將相對模塊的路徑 ,又能夠存放 相對根目錄的路徑

#4 爲何要作代碼編譯

代碼編譯能夠將esModule轉換成commonjs,以後構建代碼時,咱們能夠編寫本身的 require() 方法進行模塊化處理.


OK,如今已經理解了 moduleAnalyser 方法,讓咱們看看輸出結果長啥樣

圖譜構建

如今已經實現了 模塊分析能力,接下來咱們須要遞歸全部被導入了的模塊,將每一個模塊的分析結果存儲下來做爲 grapth

...
const moduleAnalyser = (filePath) => {...}

const moduleGraph = (entry) => {
    // moduleMuster 存放已經分析過的模塊集合, 默認直接加入入口文件的分析結果
    const moduleMuster = [moduleAnalyser(entry)];
    // cache記錄已經被分析過的模塊,減小模塊的重複分析
    const cache = {
        [moduleMuster[0].filePath]: 1
    };
    // 存放真正的graph信息
    const graph = {};

    // 遞歸遍歷全部的模塊
    for (let i = 0; i < moduleMuster.length; i++) {
        const {filePath} = moduleMuster[i];

        if (!graph[filePath]) {
            const {dependencies, code} = moduleMuster[i];
            graph[filePath] = {
                dependencies,
                code
            };

            for (let key in dependencies) {
                if (!cache[dependencies[key]]) {
                    moduleMuster.push(moduleAnalyser(dependencies[key]));
                    cache[dependencies[key]] = 1;
                }
            }
        }
    }

    return graph;
}

// 先直接傳入enrty信息,獲取圖譜信息
moduleGraph('./src/index.js');
複製代碼

moduleGraph 方法並不難理解,主要內容在遞歸層面。

輸出看看最終生成的圖譜 graph

構建代碼

接下來就是重點中的重點,核心中的核心:根據graph生成可執行代碼

...
const moduleAnalyser = (filePath) => {...}
const moduleGraph = (entry) => {...}

const generateCode = (entry) => {
    // 代碼在文件裏實際上是一串字符串,瀏覽器是把字符串轉換成AST後再操做執行的,所以這裏須要把圖譜轉換成字符串來使用
    const graph = JSON.stringify(moduleGraph(entry));
    return ` (function(graph) { // 瀏覽器沒有require方法,須要自行建立 function require(module) { // 代碼中引入模塊時是使用的相對模塊的路徑 ex. var _word = require('./word.js') // 但咱們在引入依賴時須要轉換成相對根路徑的路徑 ex. require('./src/word.js') // 將requireInEval傳遞到閉包中供轉換使用 function requireInEval(relativePath) { return require(graph[module].dependencies[relativePath]); } // 子模塊的內容存放在exports中,須要建立空對象以便使用。 var exports = {}; // 使用閉包避免模塊之間的變量污染 (function(code, require, exports) { // 經過eval執行代碼 eval(code); })(graph[module].code, requireInEval, exports) // 將模塊所依賴的內容返回給模塊 return exports; } // 入口模塊需主動引入 require('${entry}'); })(${graph})`;
}

generateCode('./src/index.js');
複製代碼

此刻一萬匹草泥馬在心中狂奔:這破函數到底寫的是個啥玩意兒? 給爺看暈了

GGMM不着急,我來一步步說明

首先把字符串化的graph傳入閉包函數中以供使用。

而後須要手動導入入口文件模塊,即 require('${entry}') ,注意這裏要使用引號包裹,確保爲字符串

所以咱們的 require 函數此時爲

function require(module = './src/index.js') {}
複製代碼

根據 graph['./src/index.js'] 能獲取到 入口文件的分析結果,

function require(module = './src/index.js') {
    (function(code) {
        // 經過eval執行代碼
        eval(code);
    })(graph[module].code)
}
複製代碼

而後咱們看一下此時eval會執行的代碼,即入口文件編譯後的代碼

"use strict";
Object.defineProperty(exports, "__esModule", {
  value: true
});
exports["default"] = void 0;
var _word = require("./word.js");
var message = "hello ".concat(_word.word);
var _default = message;
exports["default"] = _default;
複製代碼

在第六行裏有一句 var _word = require("./word.js"); ,由於做用域鏈的存在,這裏的require會調用最外層的require方法, 可是咱們本身寫的require方法接受的是相對根目錄的路徑,所以須要有一個方法進行轉換。

// 此時的require
function require(module = './src/index.js') {
    function requireInEval(relativePath = './word.js') {
        return require(graph[module].dependencies[relativePath]);
    }
    var exports = {};
    (function(code, require, exports) {
        eval(code);
    })(graph[module].code, requireInEval, exports)
    return exports;
}
複製代碼

經過 requireInEval 進行路徑轉換,並傳入到閉包當作,根據做用域的特性,eval中執行的 require 爲傳入的requireInEval 方法。

eval執行時,會把依賴裏的數據存放到exports對象中,所以在外面咱們也須要建立一個exports對象接受數據。

最後將exports返回出去


以後就是重複以上步驟的循環調用

生成文件

如今,打包流程基本已經完成了,generateCode 方法返回的code代碼,能夠直接放到瀏覽器中運行。

不過好歹是個打包工具,確定要把打包結果輸出出來。

const os = require('os'); // 用來讀取系統信息
...
const moduleAnalyser = (filePath) => {...}
const moduleGraph = (entry) => {...}
const generateCode = (entry) => {...}

function bundleCode(entry, output) {
    // 獲取輸出文件的絕對路徑
    const outPath = path.resolve(__dirname, output);
    const iswin = os.platform() === 'win32'; // 是不是windows
    const isMac = os.platform() === 'darwin'; // 是不是mac
    const code = generateCode(entry);

    // 讀取輸出文件夾
    fs.readdir(outPath, function(err, files) {
        // 若是沒有文件夾就建立文件夾
        let hasDir = true;
        if (err) {
            if (
                (iswin && err.errno === -4058)
                || (isMac && err.errno === -2)
            ) {
                fs.mkdir(outPath, {recursive: true}, err => {
                    if (err) {
                        throw err;
                    }
                });
                hasDir = false;
            } else {
                throw err;
            }
        }

        // 清空文件夾裏的內容
        if (hasDir) {
            files = fs.readdirSync(outPath);
            files.forEach((file) => {
                let curPath = outPath + (iswin ? '\\' :"/") + file;
                fs.unlinkSync(curPath);
            });
        }

        // 將代碼寫入文件,並輸出
        fs.writeFile(`${outPath}/main.js`, code, 'utf8', function(error){
            if (error) {
                throw error;
            }

            console.log('打包完成!');
        })
    })
}

bundleCode('./scr/index.js', 'dist');
複製代碼

執行 node bundler.js 看看最終結果吧!

尾聲

到這裏,一個基礎的打包工具就完成了!

你能夠本身添加 bundler.config.js ,把配置信息添加進去,傳入bundler.js,這樣看上去更像個完整的打包工具了。

這個打包工具很是簡單,很是基礎,webpack/rollup由於涉及了海量的功能和優化,其內部實現遠比這個複雜N倍,但打包的核心思路大致相同。

這裏放上完整的代碼: github

若是文中有錯誤/不足/須要改進/能夠優化的地方,但願能在評論裏提出,做者看到後會在第一時間裏處理

既然你都看到這裏了,爲什麼不點個贊👍再走,github的星星⭐是對我持續創做最大的支持❤️️

拜託啦,這對我真的很重要

相關文章
相關標籤/搜索