學習原理-手動實現小型webpack

前言

因爲如今社區有太多的零配置腳手架,致使平常業務開發中基本不會關注webpack的原理,甚至一些具體配置都不會去看。javascript

因爲疫情嚴重被困在家,無聊中透露着寂寞,我就按照着官方40分鐘教你寫webpack,學着實現一個小型的webpack, 經過這次實踐簡單瞭解webpack的打包原理。java

準備

由於涉及到 ES6 轉 ES5,因此咱們首先須要安裝一些 Babel 相關的工具node

yarn add babylon babel-core babel-traverse babel-preset-env
複製代碼

爲了方便調試查看, 另外安裝一些輔助包(高亮代碼/格式代碼)webpack

yarn add cli-highlight js-beautify
複製代碼

package.json 文件中添加script命令web

"bundle": "node bundler.js | js-beautify | highlight"
複製代碼

待打包文件

咱們按照官方操做,建立三個待打包文件(entry.js -> message.js -> name.js)json

// entry.js 入口文件
import message from './message.js';
console.log(message);

// message.js
import {name} from './name.js';
export default `hello ${name}!`;

// name.js
export const name = 'world';
複製代碼

源碼文件

在根目錄建立bundler.js, 編寫打包代碼, 這裏面主要包括三個函數數組

// 據入口文件獲取文件信息, 獲取當前js文件的依賴信息
function createAsset(filename) {//代碼略}
// 從入口開始分析全部依賴項,造成依賴圖,採用廣度遍歷
function createGraph(entry) {//代碼略}
// 根據生成的依賴關係圖,生成瀏覽器可執行文件
function bundle(graph) {//代碼略}
複製代碼

目錄結構瀏覽器

- example
    - entry.js
    - message.js
    - name.js
- bundler.js
複製代碼

實現

獲取依賴關係(createAsset)

如何獲取依賴呢,其實思路很簡單:bash

  1. 根據webpack的入口配置,指向一個文件, 經過這個文件的路徑讀取文件的信息
  2. 把讀取的文件代碼(字符串),轉化成AST(抽象語法樹)
  3. AST中找到它所依賴的模塊, 獲取其相對路徑,組成json格式數據
const fs = require('fs')
const path = require('path')
const babylon = require('babylon')
const traverse = require('babel-traverse').default
const babel = require('babel-core')

let ID = 0

// 根據入口文件獲取文件信息, 獲取當前js文件的依賴信息
function createAsset(filename) {
  //獲取文件,返回值是字符串
  const content = fs.readFileSync(filename, 'utf-8')
  // babylon 轉換成 AST
  const ast = babylon.parse(content, {
    sourceType: 'module'
  })

  // 用來存儲當前文件所依賴的文件路徑
  const dependencies = []
  
  // 遍歷 ast
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      // 把當前依賴的模塊加入到數組中,其實這存的是字符串,
      dependencies.push(node.source.value)
    }
  })
  // 建立id, 方便以後找到依賴關係,下面會講
  const id = ID++

  // 這邊主要把ES6 的代碼轉成 ES5
  const { code } = babel.transformFromAst(ast, null, {
    presets: ['env']
  });

  return {
    id,
    filename,
    dependencies,
    code
  }
}
複製代碼

接下來在末行添加代碼babel

console.log(createAsset('./example/entry.js'))
複製代碼

運行yarn bundle命令,查看生成的數據:

{
    id: 0,
    filename: './example/entry.js',
    dependencies: ['./message.js'],
    code: '"use strict";\n\nvar _message = require("./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);'
}
複製代碼

生成依賴關係圖(createGraph)

從上面的代碼能夠看出,以入口文件爲引查詢到它的依賴關係(entry.js 依賴 message.js), 那以後就能夠經過遍歷的方式,查詢message.js以後的依賴關係,造成數組數據

// 從入口開始分析全部依賴項,造成依賴圖,採用廣度遍歷
function createGraph(entry) {
  // 如上所示,表示入口文件的依賴
  const mainAsset = createAsset(entry)
  
  // 既然要廣度遍歷確定要有一個隊列,第一個元素確定是 從 "./example/entry.js" 返回的信息
  const queue = [mainAsset]

  for (const asset of queue) {
    // 獲取相對路徑
    const dirname = path.dirname(asset.filename)

    // 新增一個屬性來保存子依賴項的數據
    // 保存相似 這樣的數據結構 ---> {"./message.js" : 1}
    // 對應上面的 id, 方便找到依賴關係
    asset.mapping = {}

    // 根據依賴添加數組元素
    asset.dependencies.forEach(relativePath => {
      const absolutePath = path.join(dirname, relativePath)
      // 得到子依賴(子模塊)的依賴項、代碼、模塊id,文件名
      const child = createAsset(absolutePath) 
      
      asset.mapping[relativePath] = child.id
      queue.push(child)
    })
  }
  return queue
}
複製代碼

接下來跟一樣進行測試

const graph = createGraph('./example/entry.js')
console.log(graph)
複製代碼

獲得以下數據:

[
    {
        id: 0,
        filename: './example/entry.js',
        dependencies: ['./message.js'],
        code: '"use strict";\n\nvar _message = require("./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);',
        mapping: {
            './message.js': 1
        }
    },
    {
        id: 1,
        filename: 'example/message.js',
        dependencies: ['./name.js'],
        code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\n\nvar _name = require("./name.js");\n\nexports.default = "hello " + _name.name + "!";',
        mapping: {
            './name.js': 2
        }
    },
    {
        id: 2,
        filename: 'example/name.js',
        dependencies: [],
        code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nvar name = exports.name = \'world\';',
        mapping: {}
    }
]
複製代碼

打包代碼(bundle)

接下來就是最重要的一步,以上已經生成的依賴關係數據,而且都有對應的code, 那如今要作的就是把這些code整合起來,讓它能夠在瀏覽器端運行。

首先看一下代碼的大體結構:

function bundle(graph) {
  let modules = "";

  //循環依賴關係,並把每一個模塊中的代碼存在function做用域裏
  graph.forEach(mod => {
    modules += `${mod.id}:[ function (require, module, exports){ ${mod.code} }, ${JSON.stringify(mod.mapping)}, ],`;
  });

  const result = ` (function(modules) { // 代碼略 })({${modules}}) `;

  return result;
}
複製代碼

這塊稍微有些複雜,一步一步來。

首先咱們把全部的代碼都轉換成了ES5,而且生成了依賴關係圖,如今要作的第一步就是把代碼整合在一塊兒,而作到這一步的總體思路就是:建立匿名函數,遍歷整個依賴關係數組,生成一個函數對象看成參數傳進去

生成的結構如圖所示

(function(modules) {
    
})({
    0: [
        function(require, module, exports) {
 "use strict";
            var _message = require("./message.js");
            var _message2 = _interopRequireDefault(_message);
            function _interopRequireDefault(obj) {
                return obj && obj.__esModule ? obj : {
                    default: obj
                };
            }
            console.log(_message2.default);
        },
        // 導入模塊對應的 id
        {
            "./message.js": 1
        },
    ],
    1: [
        function(require, module, exports) {
 "use strict";
            Object.defineProperty(exports, "__esModule", {
                value: true
            });
            var _name = require("./name.js");
            exports.default = "hello " + _name.name + "!";
        },
        {
            "./name.js": 2
        },
    ],
    2: [
        function(require, module, exports) {
 "use strict";
            Object.defineProperty(exports, "__esModule", {
                value: true
            });
            var name = exports.name = 'world';
        },
        {},
    ],
})
複製代碼

由上圖能夠看到entry.js代碼通過Babel轉碼後是什麼樣子

// 原代碼
import message from './message.js';
console.log(message)

// 轉換成 ES5
"use strict";
var _message = require("./message.js");
var _message2 = _interopRequireDefault(_message);
function _interopRequireDefault(obj) { 
    return obj && obj.__esModule ? obj : { default: obj }; 
}
console.log(_message2.default);
複製代碼

這代碼放在瀏覽器中確定是沒法運行的

VM689:1 Uncaught ReferenceError: require is not defined
    at <anonymous>:1:16
複製代碼

Babel將咱們 ES6 的模塊化代碼轉換爲了 CommonJS, 而如今須要在瀏覽器端運行CommonJS代碼就須要一些操做,這也就是匿名函數內部所要作的事情, 具體實現就是模擬建立require方法,返回值是導入模塊的export

例如entry.jsvar _message = require("./message.js");, 如何獲取message.js的內容獲取它的返回值?

(function(modules) {
    function require(id) {
        // 根據id獲取 function 和 mapping
        const [fn, mapping] = modules[id];
        
        function localRequire(relativePath){
          //根據模塊的路徑在mapping中找到對應的模塊id
          return require(mapping[relativePath]);
        }
        const module = {exports:{}};
        //執行每一個模塊的代碼。
        //對應上方的 function(require, module, exports)
        fn(localRequire,module,module.exports);
        return module.exports;
    }
    // 導入entry.js 代碼
    require(0)
})(modules)
複製代碼

乍一看可能有點蒙圈,請對照上方每塊轉換成ES5的代碼,我簡單解釋一下具體的流程:

  1. require(0)執行entry.js代碼,也就是執行fn(localRequire,module,module.exports);
  2. 走到 require("./message.js")這裏,這裏的require就是上方傳入的localRequire,傳入了"./message.js",根據mapping獲取ID也就是1, 至關於執行了一次require(1),但此時尚未獲取到message的值
  3. 走到了name.js中,又執行了require("./name.js");,一模一樣,也就是require(2)
  4. 此時執行了var name = exports.name = 'world';, 此時傳入的exports終於有了值,require方法返回了module.exports的值,也就是說var _name = require("./name.js");, name的值是'world'
  5. 以後執行exports.default = "hello " + _name.name + "!";也獲取了require("./message.js")的返回值

就我淺薄的理解而言,就是模擬建立並遞歸調用require方法,外部暴露module.export並做爲返回值

完整的bundle函數以下所示:

function bundle(graph) {
  let modules = "";

  //循環依賴關係,並把每一個模塊中的代碼存在function做用域裏
  graph.forEach(mod => {
    modules += `${mod.id}:[ function (require, module, exports){ ${mod.code} }, ${JSON.stringify(mod.mapping)}, ],`;
  });

  //require, module, exports 是 cjs的標準不能再瀏覽器中直接使用,因此這裏模擬cjs模塊加載,執行,導出操做。
  const result = ` (function(modules){ //建立require函數, 它接受一個模塊ID(這個模塊id是數字0,1,2) ,它會在咱們上面定義 modules 中找到對應是模塊. function require(id){ const [fn, mapping] = modules[id]; function localRequire(relativePath){ //根據模塊的路徑在mapping中找到對應的模塊id return require(mapping[relativePath]); } const module = {exports:{}}; //執行每一個模塊的代碼。 fn(localRequire,module,module.exports); return module.exports; } //執行入口文件, require(0); })({${modules}}) `;

  return result;
}
複製代碼

打包

const graph = createGraph("./example/entry.js");
const result = bundle(graph);

// 打包生成文件
fs.writeFileSync("./bundle.js", result);
複製代碼

瀏覽器端完美運行

參考文獻

官方40分鐘教你寫webpack

理解webpack原理, 手寫一個100行的webpack

相關文章
相關標籤/搜索