手把手教你擼一個簡易的 webpack

muwoo發表於2019-03-03

背景

隨著前端複雜度的不斷提升,誕生出很多打包工具,比如最先的gruntgulp。到後來的webpackParcel。但是目前很多腳手架工具,比如vue-cli已經幫我們整合了一些構建工具的使用。有的時候我們可能並不知道其內部的實現原理。其實瞭解這些工具的工作方式可以幫助我們更好理解和使用這些工具,也方便我們在專案開發中應用。

一些知識點

在我們開始造輪子前,我們需要對一些知識點做一些儲備工作。

模組化知識

首先是模組的相關知識,主要的是 es6 modulescommonJS模組化的規範。更詳細的介紹可以參考這裡 CommonJS、AMD/CMD、ES6 Modules 以及 webpack 原理淺析。現在我們只需要瞭解:

  1. es6 modules 是一個編譯時就會確定模組依賴關係的方式。
  2. CommonJS的模組規範中,Node 在對 JS 檔案進行編譯的過程中,會對檔案中的內容進行頭尾包裝 ,在頭部新增(function (export, require, modules, __filename, __dirname){\n 在尾部新增了\n};。這樣我們在單個JS檔案內部可以使用這些引數。

AST 基礎知識

什麼是抽象語法樹?

在電腦科學中,抽象語法樹(abstract syntax tree 或者縮寫為 AST),或者語法樹(syntax tree),是原始碼的抽象語法結構的樹狀表現形式,這裡特指程式語言的原始碼。樹上的每個節點都表示原始碼中的一種結構。之所以說語法是「抽象」的,是因為這裡的語法並不會表示出真實語法中出現的每個細節。

image

大家可以通過Esprima 這個網站來將程式碼轉化成 ast。首先一段程式碼轉化成的抽象語法樹是一個物件,該物件會有一個頂級的type屬性Program,第二個屬性是body是一個陣列。body陣列中存放的每一項都是一個物件,裡面包含了所有的對於該語句的描述資訊:

type:描述該語句的型別 --變數宣告語句
kind:變數宣告的關鍵字 -- var
declaration: 宣告的內容陣列,裡面的每一項也是一個物件
	type: 描述該語句的型別 
	id: 描述變數名稱的物件
		type:定義
		name: 是變數的名字
    init: 初始化變數值得物件
		type: 型別
		value: 值 "is tree" 不帶引號
		row: "\"is tree"\" 帶引號
複製程式碼

進入正題

webpack 簡易打包

有了上面這些基礎的知識,我們先來看一下一個簡單的webpack打包的過程,首先我們定義3個檔案:

// index.js
import a from './test'

console.log(a)

// test.js
import b from './message'

const a = 'hello' + b

export default a

// message.js
const b = 'world'

export default b
複製程式碼

方式很簡單,定義了一個index.js引用test.jstest.js內部引用message.js。看一下打包後的程式碼:

(function (modules) {
  var installedModules = {};

  function __webpack_require__(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };

    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // Flag the module as loaded
    module.l = true;
    // Return the exports of the module
    return module.exports;
  }

  // expose the modules object (__webpack_modules__)
  __webpack_require__.m = modules;
  // expose the module cache
  __webpack_require__.c = installedModules;
  // define getter function for harmony exports
  __webpack_require__.d = function (exports, name, getter) {
    if (!__webpack_require__.o(exports, name)) {
      Object.defineProperty(exports, name, {enumerable: true, get: getter});
    }
  };
  // define __esModule on exports
  __webpack_require__.r = function (exports) {
    if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
      Object.defineProperty(exports, Symbol.toStringTag, {value: 'Module'});
    }
    Object.defineProperty(exports, '__esModule', {value: true});
  };
  // create a fake namespace object
  // mode & 1: value is a module id, require it
  // mode & 2: merge all properties of value into the ns
  // mode & 4: return value when already ns object
  // mode & 8|1: behave like require
  __webpack_require__.t = function (value, mode) {
    /******/
    if (mode & 1) value = __webpack_require__(value);
    if (mode & 8) return value;
    if ((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
    var ns = Object.create(null);
    __webpack_require__.r(ns);
    Object.defineProperty(ns, 'default', {enumerable: true, value: value});
    if (mode & 2 && typeof value != 'string') for (var key in value) __webpack_require__.d(ns, key, function (key) {
      return value[key];
    }.bind(null, key));
    return ns;
  };
  // getDefaultExport function for compatibility with non-harmony modules
  __webpack_require__.n = function (module) {
    var getter = module && module.__esModule ?
      function getDefault() {
        return module['default'];
      } :
      function getModuleExports() {
        return module;
      };
    __webpack_require__.d(getter, 'a', getter);
    return getter;
  };
  // Object.prototype.hasOwnProperty.call
  __webpack_require__.o = function (object, property) {
    return Object.prototype.hasOwnProperty.call(object, property);
  };
  // __webpack_public_path__
  __webpack_require__.p = "";
  // Load entry module and return exports
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})({
  "./src/index.js": (function (module, __webpack_exports__, __webpack_require__) {

    "use strict";
    eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _test__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./test */ \"./src/test.js\");\n\n\nconsole.log(_test__WEBPACK_IMPORTED_MODULE_0__[\"default\"])\n\n\n//# sourceURL=webpack:///./src/index.js?");

  }),
  "./src/message.js": (function (module, __webpack_exports__, __webpack_require__) {
    // ...
  }),
  "./src/test.js": (function (module, __webpack_exports__, __webpack_require__) {
    // ...
  })
});
複製程式碼

看起來很亂?沒關係,我們來屢一下。一眼看過去我們看到的是這樣的形式:

(function(modules) {
  // ...
})({
 // ...
})
複製程式碼

這樣好理解了吧,就是一個自執行函式,傳入了一個modules物件,modules 物件是什麼樣的格式呢?上面的程式碼已經給了我們答案:

{
  "./src/index.js": (function (module, __webpack_exports__, __webpack_require__) {
    // ...
  }),
  "./src/message.js": (function (module, __webpack_exports__, __webpack_require__) {
    // ...
  }),
  "./src/test.js": (function (module, __webpack_exports__, __webpack_require__) {
    // ...
  })
}
複製程式碼

是這樣的一個 路徑 --> 函式 這樣的 key,value 鍵值對。而函式內部是我們定義的檔案轉移成 ES5 之後的程式碼:

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _test__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./test */ \"./src/test.js\");\n\n\nconsole.log(_test__WEBPACK_IMPORTED_MODULE_0__[\"default\"])\n\n\n//# sourceURL=webpack:///./src/index.js?");
複製程式碼

到這裡基本上結構是分析完了,接著我們看看他的執行,自執行函式一開始執行的程式碼是:

__webpack_require__(__webpack_require__.s = "./src/index.js");
複製程式碼

呼叫了__webpack_require_函式,並傳入了一個moduleId引數是"./src/index.js"。再看看函式內部的主要實現:

// 定義 module 格式   
var module = installedModules[moduleId] = {
      i: moduleId, // moduleId
      l: false, // 是否已經快取
      exports: {} // 匯出物件,提供掛載

};

modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
複製程式碼

這裡呼叫了我們modules中的函式,並傳入了__webpack_require__函式作為函式內部的呼叫。module.exports引數作為函式內部的匯出。因為index.js裡面引用了test.js,所以又會通過__webpack_require__來執行對test.js的載入:

var _test__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/test.js");
複製程式碼

test.js內又使用了message.js所以,test.js內部又會執行對message.js的載入。message.js執行完成之後,因為沒有依賴項,所以直接返回了結果:

var b = 'world'
__webpack_exports__["default"] = (b)
複製程式碼

執行完成之後,再一級一級返回到根檔案index.js。最終完成整個檔案依賴的處理。 整個過程中,我們像是通過一個依賴關係樹的形式,不斷地向數的內部進入,等返回結果,又開始回溯到根。

開發一個簡單的 tinypack

通過上面的這些調研,我們先考慮一下一個基礎的打包編譯工具可以做什麼?

  1. 轉換ES6語法成ES5
  2. 處理模組載入依賴
  3. 生成一個可以在瀏覽器載入執行的 js 檔案

第一個問題,轉換語法,其實我們可以通過babel來做。核心步驟也就是:

  • 通過babylon生成AST
  • 通過babel-core將AST重新生成原始碼
/**
 * 獲取檔案,解析成ast語法
 * @param filename // 入口檔案
 * @returns {*}
 */
function getAst (filename) {
  const content = fs.readFileSync(filename, 'utf-8')

  return babylon.parse(content, {
    sourceType: 'module',
  });
}

/**
 * 編譯
 * @param ast
 * @returns {*}
 */
function getTranslateCode(ast) {
  const {code} = transformFromAst(ast, null, {
    presets: ['env']
  });
  return code
}
複製程式碼

接著我們需要處理模組依賴的關係,那就需要得到一個依賴關係檢視。好在babel-traverse提供了一個可以遍歷AST檢視並做處理的功能,通過 ImportDeclaration 可以得到依賴屬性:

function getDependence (ast) {
  let dependencies = []
  traverse(ast, {
    ImportDeclaration: ({node}) => {
      dependencies.push(node.source.value);
    },
  })
  return dependencies
}

/**
 * 生成完整的檔案依賴關係對映
 * @param fileName
 * @param entry
 * @returns {{fileName: *, dependence, code: *}}
 */
function parse(fileName, entry) {
  let filePath = fileName.indexOf('.js') === -1 ? fileName + '.js' : fileName
  let dirName = entry ? '' : path.dirname(config.entry)
  let absolutePath = path.join(dirName, filePath)
  const ast = getAst(absolutePath)
  return {
    fileName,
    dependence: getDependence(ast),
    code: getTranslateCode(ast),
  };
}
複製程式碼

到目前為止,我們也只是得到根檔案的依賴關係和編譯後的程式碼,比如我們的index.js依賴了test.js但是我們並不知道test.js還需要依賴message.js,他們的原始碼也是沒有編譯過。所以此時我們還需要做深度遍歷,得到完成的深度依賴關係:

/**
 * 獲取深度佇列依賴關係
 * @param main
 * @returns {*[]}
 */
function getQueue(main) {
  let queue = [main]
  for (let asset of queue) {
    asset.dependence.forEach(function (dep) {
      let child = parse(dep)
      queue.push(child)
    })
  }
  return queue
}
複製程式碼

那麼進行到這一步我們已經完成了所有檔案的編譯解析。最後一步,就是需要我們按照webpack的思想對原始碼進行一些包裝。第一步,先是要生成一個modules物件:

function bundle(queue) {
  let modules = ''
  queue.forEach(function (mod) {
    modules += `'${mod.fileName}': function (require, module, exports) { ${mod.code} },`
  })
  // ...
}
複製程式碼

得到 modules 物件後,接下來便是對整體檔案的外部包裝,註冊requiremodule.exports

(function(modules) {
      function require(fileName) {
          // ...
      }
     require('${config.entry}');
 })({${modules}})
複製程式碼

而函式內部,也只是迴圈執行每個依賴檔案的 JS 程式碼而已,完成程式碼:

function bundle(queue) {
  let modules = ''
  queue.forEach(function (mod) {
    modules += `'${mod.fileName}': function (require, module, exports) { ${mod.code} },`
  })

  const result = `
    (function(modules) {
      function require(fileName) {
        const fn = modules[fileName];

        const module = { exports : {} };

        fn(require, module, module.exports);

        return module.exports;
      }

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

  return result;
}
複製程式碼

到這裡基本上也就介紹完了,我們來打包試一下:

(function (modules) {
  function require(fileName) {
    const fn = modules[fileName];

    const module = {exports: {}};

    fn(require, module, module.exports);

    return module.exports;
  }

  require('./src/index.js');
})({
  './src/index.js': function (require, module, exports) {
    "use strict";

    var _test = require("./test");

    var _test2 = _interopRequireDefault(_test);

    function _interopRequireDefault(obj) {
      return obj && obj.__esModule ? obj : {default: obj};
    }

    console.log(_test2.default);
  }, './test': function (require, module, exports) {
    "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true
    });

    var _message = require("./message");

    var _message2 = _interopRequireDefault(_message);

    function _interopRequireDefault(obj) {
      return obj && obj.__esModule ? obj : {default: obj};
    }

    var a = 'hello' + _message2.default;
    exports.default = a;
  }, './message': function (require, module, exports) {
    "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true
    });
    var b = 'world';

    exports.default = b;
  },
})
複製程式碼

再測試一下:

image

恩,基本上已經完成一個簡易的 tinypack

參考文章

抽象語法樹 Abstract syntax tree

一看就懂的JS抽象語法樹

原始碼

tinypack 所有的原始碼已經上傳 github

相關文章