mini-pack 實現原理講解

文章首發於個人博客 https://github.com/mcuking/bl...

實現源碼請查閱 https://github.com/mcuking/bl...javascript

本文主要是闡述如何一步步實現一個相似 webpack 的前端應用打包器。html

webpack 的本質

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

webpack 就像一條生產線,要通過一系列處理流程後才能將源文件轉換成輸出結果。 這條生產線上的每一個處理流程的職責都是單一的,多個流程之間有存在依賴關係,只有完成當前處理後才能交給下一個流程去處理。插件就像是一個插入到生產線中的一個功能,在特定的時機對生產線上的資源作處理。webpack 經過 Tapable 來組織這條複雜的生產線。 webpack 在運行過程當中會廣播事件,插件只須要監聽它所關心的事件,就能加入到這條生產線中,去改變生產線的運做。 webpack 的事件流機制保證了插件的有序性,使得整個系統擴展性很好。前端

-- 深刻淺出 webpack 吳浩麟java

Webpack 運行機制

整個運行機制是串行的,從啓動到結束會依次執行如下流程 :node

  1. 初始化參數:從配置文件和 Shell 語句中讀取與合併參數,得出最終的參數;
  2. 開始編譯:用上一步獲得的參數初始化 Compiler 對象,加載全部配置的插件,執行對象的 run 方法開始執行編譯;
  3. 肯定入口:根據配置中的 entry 找出全部的入口文件;
  4. 編譯模塊:從入口文件出發,調用全部配置的 Loader 對模塊進行翻譯,再找出該模塊依賴的模塊,再遞歸本步驟直到全部入口依賴的文件都通過了本步驟的處理;
  5. 完成模塊編譯:在通過第 4 步使用 Loader 翻譯完全部模塊後,獲得了每一個模塊被翻譯後的最終內容以及它們之間的依賴關係;
  6. 輸出資源:根據入口和模塊之間的依賴關係,組裝成一個個包含多個模塊的 Chunk,再把每一個 Chunk 轉換成一個單獨的文件加入到輸出列表,這步是能夠修改輸出內容的最後機會;
  7. 輸出完成:在肯定好輸出內容後,根據配置肯定輸出的路徑和文件名,把文件內容寫入到文件系統。

在以上過程當中,Webpack 會在特定的時間點廣播出特定的事件,插件在監聽到感興趣的事件後會執行特定的邏輯,而且插件能夠調用 Webpack 提供的 API 改變 Webpack 的運行結果webpack

mini-pack 實現過程

首先須要明確 mini-pack 要實現的目標:git

將 src 中 js 代碼編譯成 es5 版本,並打包成一個 bundle js(注意:只關注 js)。github

下面咱們根據剛纔對 webpack 運行機制的闡述,逐步實現 mini-pack:web

1. 首先支持定義相似 webpack.config.js 文件,可命名爲 minipack.config.js,文件內定義 output、entry 等參數。以下面所示:babel

const path = require('path');

module.exports = {
  entry: path.join(__dirname, './src/index.js'),
  output: {
    path: path.join(__dirname, './dist'),
    filename: 'main.js'
  }
};

2. 而後進入編譯階段:根據 minipack.config.js 定義的參數,初始化一個 Compiler 參數,並執行 run 方法

index.js 代碼

const Compiler = require('./compiler');
const options = require('../minipack.config');

// 根據 minipack.config.js 配置的參數,初始化 Compiler 對象,並啓動編譯
new Compiler(options).run();

compiler.js 代碼

const {getAST, getDependencies, transform} = require('./utils');
const path = require('path');

module.exports = class Compiler {
  constructor(options) {
    const {entry, output} = options;
    // 打包入口
    this.entry = entry;
    // 出口
    this.output = output;
    // 模塊集
    this.modules = [];
  }

  // 啓動構建
  run() {
    const entryModule = this.buildModule(this.entry, true);

    this.modules.push(entryModule);
  }

  // 編譯單個模塊
  buildModule(filename, isEntry) {
    let ast;

    ast = getAST(filename);

    return {
      filename,
      source: transform(ast),
      dependencies: getDependencies(ast)
    };
  }

  // 將編譯的 js 模塊輸出到指定目錄中
  emitFiles() {}
};

此步驟就是將入口 js 文件編譯成 module 對象,格式以下:

{
  filename  // 文件名
  source  // 代碼
  dependencies  // 依賴文件,即該模塊引入的其餘模塊
}

其中編譯方法 getAST、轉換 ast 到 code 的方法 transform、以及獲取模塊依賴方法 getDependencies 均單獨封裝在一個 utils 文件中。

const fs = require('fs');
const path = require('path');
const {parse} = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const {transformFromAst} = require('@babel/core');

module.exports = {
  // 將路徑對應的文件 js 代碼編譯成 ast
  getAST(path) {
    const content = fs.readFileSync(path, 'utf-8');
    return parse(content, {
      sourceType: 'module'
    });
  },

  // 經過 babel-traverse 遍歷全部節點
  // 並根據 ImportDeclaration 節點來收集一個模塊的依賴
  getDependencies(ast) {
    const dependencies = [];
    traverse(ast, {
      ImportDeclaration({node}) {
        dependencies.push(node.source.value);
      }
    });
    return dependencies;
  },

  // 將轉化後 ast 的代碼從新轉化成代碼
  // 並經過配置 @babel/preset-env 預置插件編譯成 es5
  transform(ast) {
    const {code} = transformFromAst(ast, null, {
      presets: ['@babel/preset-env']
    });
    return code;
  }
};

3. 肯定入口,根據配置中的 entry 找出全部的入口文件,上面已經實現了對 entry 文件的編譯

4. 從入口文件出發,對模塊進行編譯(這裏並不打算支持運行 loader),再找出該模塊依賴的模塊,再遞歸本步驟直到全部入口依賴的文件都通過了本步驟的處理。也就是說經過 babel-traverse 工具遍歷這個模塊 ast 上的 ImportDeclaration 節點(對應代碼中 import),查找這個模塊全部的 import 的其餘模塊,而後以遞歸的方式編譯其餘模塊,重複剛纔的操做。新增代碼以下:

compiler.js

module.exports = class Compiler {
  constructor(options) {
    const {entry, output} = options;
    // 打包入口
    this.entry = entry;
    // 出口
    this.output = output;
    // 模塊集
    this.modules = [];
  }

  // 啓動構建
  run() {
    this.buildModule(this.entry, true);

    this.emitFiles();
  }

  // 遞歸調用直至編譯全部被引用模塊
  buildModule(filename, isEntry) {
    const _module = this.build(filename, isEntry);

    this.modules.push(_module);

    _module.dependencies.forEach(dependency => {
      this.buildModule(dependency, false);
    });
  }

  // 編譯單個模塊
  build(filename, isEntry) {
    let ast;

    if (isEntry) {
      ast = getAST(filename);
    } else {
      const absolutePath = path.join(process.cwd(), './src', filename);
      ast = getAST(absolutePath);
    }

    return {
      filename,
      source: transform(ast),
      dependencies: getDependencies(ast)
    };
  }

  // 將編譯的 js 模塊輸出到指定目錄中
  emitFiles() {}
};

5. 完成模塊編譯,上面的代碼已經實現了遞歸編譯全部被引用的模塊

6. 輸出資源,這裏 mini-pack 準備將全部模塊打包放入一個文件裏,並不是像 webpack 那樣組裝成一個個包含多個模塊的 Chunk,再把每一個 Chunk 轉換成一個單獨的文件加入到輸出列表

既然要將全部模塊的代碼打包進一個文件中,那麼勢必會致使命名衝突問題,爲了保證各個模塊互不影響,能夠將不一樣模塊分別用一個函數來包裹下(利用 js 函數做用域)。那麼又會存在另外一個問題 -- 模塊之間的引用問題。對此咱們能夠自定義 require 函數,用來引用其餘模塊的變量或方法,而後將自定義的 require 方法以參數的形式傳入剛剛的包裹函數中,以供模塊中代碼調用。具體模式以下:

(function(modules) {
  function require(filename) {
    var fn = modules[filename];
    var module = {exports: {} };

    fn(require, module, module.exports);
    return module.exports;
  }

  return require('./entry');
})({
  './entry': function(require, module, exports) {
      var addModule = require("./add");
      console.log(addModule.add(1, 1));
  },
  './add': function(require, module, exports) {
      module.exports = {
        add: function(x, y) {
            return x + y;
        }
      }
  }
});

所以 Compiler 實現代碼可繼續完善以下:

module.exports = class Compiler {
  constructor(options) {
    const { entry, output } = options;
    // 打包入口
    this.entry = entry;
    // 出口
    this.output = output;
    // 模塊集
    this.modules = [];
  }

  // 啓動構建
  run() {
    this.buildModule(this.entry, true);

    this.emitFiles();
  }

  // 遞歸調用直至編譯全部被引用模塊
  buildModule(filename, isEntry) {
    // 同上
  }

  // 編譯單個模塊
  build(filename, isEntry) {
    // 同上
  }

  // 將編譯的 js 模塊輸出到指定目錄中
  emitFiles() {
    // 將全部模塊代碼分別放入一個函數中(利用函數做用域實現做用域隔離,避免變量衝突)
    // 同時實現一個 require 方法已實現從其餘模塊中引入須要的變量或方法
    let modules = '';

    this.modules.forEach(_module => {
      modules += `'${_module.filename}': function(require, module, exports) {${_module.source}},`;
    });

    const bundle = `(function(modules) {
      function require(filename) {
        var fn = modules[filename];
        var module = {exports: {}};

        fn(require, module, module.exports);
        return module.exports;
      }

      return require('${this.entry}')
    })({${modules}})`;
  }
};

7. 輸出完成:在肯定好輸出內容後,根據配置肯定輸出的路徑和文件名,把文件內容寫入到文件系統。即經過 fs 模塊將編譯後大代碼輸出到指定目錄中。代碼以下:

module.exports = class Compiler {
  constructor(options) {
    const {entry, output} = options;
    // 打包入口
    this.entry = entry;
    // 出口
    this.output = output;
    // 模塊集
    this.modules = [];
  }

  // 啓動構建
  run() {
    this.buildModule(this.entry, true);

    this.emitFiles();
  }

  // 遞歸調用直至編譯全部被引用模塊
  buildModule(filename, isEntry) {
    // 同上
  }

  // 編譯單個模塊
  build(filename, isEntry) {
    // 同上
  }

  // 將編譯的 js 模塊輸出到指定目錄中
  emitFiles() {
    // 將全部模塊代碼分別放入一個函數中(利用函數做用域實現做用域隔離,避免變量衝突)
    // 同時實現一個 require 方法已實現從其餘模塊中引入須要的變量或方法
    let modules = '';

    this.modules.forEach(_module => {
      modules += `'${_module.filename}': function(require, module, exports) {${_module.source}},`;
    });

    const bundle = `(function(modules) {
      function require(filename) {
        var fn = modules[filename];
        var module = {exports: {}};

        fn(require, module, module.exports);
        return module.exports;
      }

      return require('${this.entry}')
    })({${modules}})`;

    // 將編譯後的代碼寫入到 output 指定的目錄
    const distPath = path.join(process.cwd(), './dist');
    if (fs.existsSync(distPath)) {
      removeDir(distPath);
    }

    fs.mkdirSync(distPath);

    const outputPath = path.join(this.output.path, this.output.filename);
    fs.writeFileSync(outputPath, bundle, 'utf-8');

    // 將編譯後的 js 插入 html 中,並寫入到 output 指定的目錄
    this.emitHtml();
  }

  // 將 html 插入 script 標籤(引入打包後的 bundle js),並輸出到指定目錄中
  emitHtml() {
    const publicHtmlPath = path.join(process.cwd(), './public/index.html');
    let html = fs.readFileSync(publicHtmlPath, 'utf-8');
    html = html.replace(
      /<\/body>/,
      `  <script type="text/javascript" src="./main.js"></script>
  </body>`
    );

    const distHtmlPath = path.join(process.cwd(), './dist/index.html');
    fs.writeFileSync(distHtmlPath, html, 'utf-8');
  }
};

在此過程當中,Webpack 會在特定的時間點廣播出特定的事件,以便通知相應插件執行指定任務改變打包結果。對此,並不在 mini-pack 最初的設定功能方位,所以到此爲止,封裝已經完成。下面是 Compiler 的完整代碼:

const path = require('path');
const fs = require('fs');
const {getAST, getDependencies, transform, removeDir} = require('./utils');

module.exports = class Compiler {
  constructor(options) {
    const {entry, output} = options;
    // 打包入口
    this.entry = entry;
    // 出口
    this.output = output;
    // 模塊集
    this.modules = [];
  }

  // 啓動構建
  run() {
    this.buildModule(this.entry, true);

    this.emitFiles();
  }

  // 遞歸調用直至編譯全部被引用模塊
  buildModule(filename, isEntry) {
    const _module = this.build(filename, isEntry);

    this.modules.push(_module);

    _module.dependencies.forEach(dependency => {
      this.buildModule(dependency, false);
    });
  }

  // 編譯單個模塊
  build(filename, isEntry) {
    let ast;

    if (isEntry) {
      ast = getAST(filename);
    } else {
      const absolutePath = path.join(process.cwd(), './src', filename);
      ast = getAST(absolutePath);
    }

    return {
      filename,
      source: transform(ast),
      dependencies: getDependencies(ast)
    };
  }

  // 將編譯的 js 模塊輸出到指定目錄中
  emitFiles() {
    // 將全部模塊代碼分別放入一個函數中(利用函數做用域實現做用域隔離,避免變量衝突)
    // 同時實現一個 require 方法已實現從其餘模塊中引入須要的變量或方法
    let modules = '';

    this.modules.forEach(_module => {
      modules += `'${_module.filename}': function(require, module, exports) {${_module.source}},`;
    });

    const bundle = `(function(modules) {
      function require(filename) {
        var fn = modules[filename];
        var module = {exports: {}};

        fn(require, module, module.exports);
        return module.exports;
      }

      return require('${this.entry}')
    })({${modules}})`;

    // 將編譯後的代碼寫入到 output 指定的目錄
    const distPath = path.join(process.cwd(), './dist');
    if (fs.existsSync(distPath)) {
      removeDir(distPath);
    }

    fs.mkdirSync(distPath);

    const outputPath = path.join(this.output.path, this.output.filename);
    fs.writeFileSync(outputPath, bundle, 'utf-8');

    // 將編譯後的 js 插入 html 中,並寫入到 output 指定的目錄
    this.emitHtml();
  }

  // 將 html 插入 script 標籤(引入打包後的 bundle js),並輸出到指定目錄中
  emitHtml() {
    const publicHtmlPath = path.join(process.cwd(), './public/index.html');
    let html = fs.readFileSync(publicHtmlPath, 'utf-8');
    html = html.replace(
      /<\/body>/,
      `  <script type="text/javascript" src="./main.js"></script>
  </body>`
    );

    const distHtmlPath = path.join(process.cwd(), './dist/index.html');
    fs.writeFileSync(distHtmlPath, html, 'utf-8');
  }
};

結束語

到這裏一個簡單的前端項目打包器已經實現了,完整實現代碼請查閱 mini-pack。經歷了整個過程,相信讀者對前端項目打包過程的理解會更加深刻了。

相關文章
相關標籤/搜索