minipack源碼解析以及擴展

前置知識
  1. 首先可能你需要知道打包工具是什麼存在
  2. 基本的模塊化演變進程
  3. 對模塊化bundle有一定了解
  4. 瞭解babel的一些常識
  5. 對node有一定常識

常見的一些打包工具

如今最常見的模塊化構建工具 應該是webpack,rollup,fis,parcel等等各種各樣。

但是現在可謂是webpack社區較爲龐大。

其實呢,模塊化開發很大的一點是爲了程序可維護性

那麼其實我們是不是可以理解爲打包工具是將我們一塊塊模塊化的代碼進行智能拼湊。使得我們程序正常運行。

基本的模塊化演變

// 1. 全局函數

function module1 () {
    // do somethings
}
function module2 () {
    // do somethings
}

// 2. 以對象做單個命名空間

var module = {}

module.addpath = function() {}

// 3. IIFE保護私有成員

var module1 = (function () {
    var test = function (){}
    var dosomething = function () {
        test();
    }
    return {
        dosomething: dosomething
    }
})();

// 4. 複用模塊

var module1 = (function (module) {
    module.moduledosomething = function() {}
    return module
})(modules2);

// 再到後來的COMMONJS、AMD、CMD

// node module是COMMONJS的典型

(function(exports, require, module, __filename, __dirname) {
    // 模塊的代碼實際上在這裏
    function test() {
        // dosomethings
    }
    modules.exports = {
        test: test
    }
});

// AMD 異步加載 依賴前置

// requireJS示例

define('mymodule', ['module depes'], function () {
    function dosomethings() {}
    return {
        dosomethings: dosomethings
    }
})
require('mymodule', function (mymodule) {
    mymodule.dosomethings()
})

// CMD 依賴後置 
// seajs 示例
// mymodule.js
define(function(require, exports, module) {
    var module1 = require('module1')
    module.exports = {
        dosomethings: module1.dosomethings
    }
})

seajs.use(['mymodule.js'], function (mymodule) {
    mymodule.dosomethings();
})


// 還有現在流行的esModule

// mymodule 

export default {
    dosomething: function() {}
}

import mymodule from './mymodule.js'
mymodule.dosomething()

minipack的打包流程

可以分成兩大部分
  1. 生成模塊依賴(循環引用等問題沒有解決的~)
  2. 根據處理依賴進行打包

模塊依賴生成

具體步驟

  1. 給定入口文件
  2. 根據入口文件分析依賴(藉助bable獲取)
  3. 廣度遍歷依賴圖獲取依賴
  4. 根據依賴圖生成(模塊id)key:(數組)value的對象表示
  5. 建立require機制實現模塊加載運行

源碼的分析

const fs = require('fs');
const path = require('path');
const babylon = require('babylon');//AST 解析器
const traverse = require('babel-traverse').default; //遍歷工具
const {transformFromAst} = require('babel-core'); // babel-core

let ID = 0;

function createAsset(filename) {
  const content = fs.readFileSync(filename, 'utf-8');
  // 獲得文件內容, 從而在下面做語法樹分析
  const ast = babylon.parse(content, {
    sourceType: 'module',
  });
  
  // 解析內容至AST
  // This array will hold the relative paths of modules this module depends on.
  const dependencies = [];
  // 初始化依賴集
  // 使用babel-traverse基礎知識,需要找到一個statement然後定義進去的方法。
  // 這裏進ImportDeclaration 這個statement內。然後對節點import的依賴值進行push進依賴集
  traverse(ast, {
    ImportDeclaration: ({node}) => {
      // We push the value that we import into the dependencies array.
      dependencies.push(node.source.value);
    },
  });
  // id自增
  const id = ID++;

  const {code} = transformFromAst(ast, null, {
    presets: ['env'],
  });

  // 返回這麼模塊的所有信息
  // 我們設置的id filename 依賴集 代碼
  return {
    id,
    filename,
    dependencies,
    code,
  };
}

function createGraph(entry) {
  // 從一個入口進行解析依賴圖譜
  // Start by parsing the entry file.
  const mainAsset = createAsset(entry);

  // 最初的依賴集
  const queue = [mainAsset];

  // 一張圖常見的遍歷算法有廣度遍歷與深度遍歷
  // 這裏採用的是廣度遍歷
  for (const asset of queue) {
    // 給當前依賴做mapping記錄
    asset.mapping = {};
    // 獲得依賴模塊地址
    const dirname = path.dirname(asset.filename);
    // 剛開始只有一個asset 但是dependencies可能多個
    asset.dependencies.forEach(relativePath => {
      // 這邊獲得絕對路徑
      const absolutePath = path.join(dirname, relativePath);
      // 這裏做解析
      // 相當於這層做的解析擴散到下一層,從而遍歷整個圖
      const child = createAsset(absolutePath);

      // 相當於當前模塊與子模塊做關聯
      asset.mapping[relativePath] = child.id;
      // 廣度遍歷藉助隊列
      queue.push(child);
    });
  }

  // 返回遍歷完依賴的隊列
  return queue;
}
function bundle(graph) {
  let modules = '';
  graph.forEach(mod => {
    modules += `${mod.id}: [
      function (require, module, exports) { ${mod.code} },
      ${JSON.stringify(mod.mapping)},
    ],`;
  });
  // CommonJS風格
  const result = `
    (function(modules) {
      function require(id) {
        const [fn, mapping] = modules[id];
        function localRequire(name) {
          return require(mapping[name]);
        }
        const module = { exports : {} };
        fn(localRequire, module, module.exports);
        return module.exports;
      }

      require(0);
    })({${modules}})
  `;
  return result;
}

一個簡單的實例

// doing.js 
import t from './hahaha.js'

document.body.onclick = function (){
    console.log(t.name)
}

// hahaha.js

export default {
    name: 'ZWkang'
}

const graph = createGraph('../example/doing.js');
const result = bundle(graph);

實例result 如下

// 打包出的代碼類似
    (function(modules) {
      function require(id) {
        const [fn, mapping] = modules[id];
        function localRequire(name) {
          return require(mapping[name]);
        }
        const module = { exports : {} };
        fn(localRequire, module, module.exports);
        return module.exports;
      }

      require(0);
    })({0: [
      function (require, module, exports) { "use strict";
        
        var _hahaha = require("./hahaha.js");
        
        var _hahaha2 = _interopRequireDefault(_hahaha);
        
        function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
        
        document.body.onclick = function () {
          console.log(_hahaha2.default.name);
        }; },
      {"./hahaha.js":1},
    ],1: [
      function (require, module, exports) { "use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.default = {
  name: 'ZWkang'
}; },
      {},
    ],})
依賴的圖生成的文件可以簡化爲
modules = {
    0: [function code , {deps} ],
    1: [function code , {deps} ]
}

而require則是模擬了一個很簡單的COMMONJS模塊module的操作

function require(id) {
    const [fn, mapping] = modules[id];
    function localRequire(name) {
      return require(mapping[name]);
    }
    const module = { exports : {} };
    fn(localRequire, module, module.exports);
    return module.exports;
}

require(0);

分析得

我們模塊代碼會被執行。並且執行的結果會存儲在module.exports中

並接受三個參數 require module module.exports

類似COMMONJS module會在模塊閉包內注入exports, require, module, __filename, __dirname

會在入口處對其代碼進行require執行一遍。

minipack源碼總結

通過上述分析,我們可以瞭解

  • minipack的基本構造
  • 打包工具的基本形態
  • 模塊的一些問題

擴展

既然bundle都已經實現了,我們可不可以基於minipack實現一個簡單的HMR用於熱替換模塊內容

可以簡單的實現一下

一個簡單HMR實現

可以分爲以下幾步
  1. watch file change
  2. emit update to front-end
  3. front-end replace modules

當然還有更多仔細的處理。

例如,模塊細分的hotload 處理,HMR的顆粒度等等

主要還是在設置module bundle時需要考慮。

基於minipack實現

我們可以設想一下需要做什麼。

watch module asset的變化
利用ws進行前後端update通知。
改變前端的modules[變化id]

// 建立一個文件夾目錄格式爲

- test.js
- base.js
- bundle.js
- wsserver.js
- index.js
- temp.html
// temp.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    <button class="click"> click me </button>
    <% script %> 
    <!-- 替換用佔位符 -->
</body>
</html>
// base.js與test.js則是測試用的模塊
// base.js

var result = {
    name: 'ZWKas'
}

export default result

// test.js

import t from './base.js'

console.log(t, '1');
document.body.innerHTML = t.name

watch module asset的變化

// 首先是實現第一步
// watch asset file

function createGraph(entry) {
  // Start by parsing the entry file.
  const mainAsset = createAsset(entry);

  const queue = [mainAsset];

  for (const asset of queue) {
    asset.mapping = {};

    const dirname = path.dirname(asset.filename);

    fs.watch(path.join(__dirname,asset.filename), (event, filename) => {
        console.log('watch ',event, filename)
        const assetSource = createAsset(path.join(__dirname,asset.filename))
        wss.emitmessage(assetSource)
    })
    asset.dependencies.forEach(relativePath => {

      const absolutePath = path.join(dirname, relativePath);

      const child = createAsset(absolutePath);

      asset.mapping[relativePath] = child.id;
      queue.push(child);
    });
  }

  return queue;
}

簡單改造了createGraphl 添加了fs.watch方法作爲觸發點。

(根據操作系統觸發底層實現的不同,watch的事件可能觸發幾次)

創建資源圖的同時對資源進行了watch操作。

這邊還有一點要補充的。當我們使用creareAsset的時候,如果沒有對id與path做關聯的話,那再次觸發獲得的id也會發生改動。

可以直接將絕對地址module id關聯。從而複用了module的id

// createasset一些代碼的改動 關鍵代碼
let mapWithPath = new Map()
if(!mapWithPath.has(path.resolve(__dirname, filename))) {
    mapWithPath.set(path.resolve(__dirname, filename), id)
}
const afterid = mapWithPath.get(path.resolve(__dirname, filename))
return {
    id: afterid,
    filename,
    dependencies,
    code,
};

利用websockt進行交互提示update

 
// wsserver.js file 則是實現第二步。利用websocket與前端進行交互,提示update


const EventEmitter = require('events').EventEmitter
const WebSocket = require('ws')

class wsServer extends EventEmitter {
    constructor(port) {
        super()
        this.wss = new WebSocket.Server({ port });
        this.wss.on('connection', function connection(ws) {
            ws.on('message', function incoming(message) {
              console.log('received: %s', message);
            });
        });
    }
    emitmessage(assetSource) {
        this.wss.clients.forEach(ws => {
            ws.send(JSON.stringify({
                type: 'update',
                ...assetSource
            }))
        })
    }
}


const wsserver = new wsServer(8080)
module.exports = wsserver
// 簡單地export一個帶對客戶端傳輸update信息的websocket實例

在fs.watch觸發點觸發


const assetSource = createAsset(path.join(__dirname,asset.filename))
wss.emitmessage(assetSource)

這裏就是做這個操作。將資源圖進行重新的創建。包括id,code等

bundle.js則是做我們的打包操作

const minipack = require('./index')
const fs = require('fs')

const makeEntry = (entryHtml, outputhtml ) => {
    const temp = fs.readFileSync(entryHtml).toString()
    // console.log(temp)caches.c
    const graph = minipack.createGraph('./add.js')

    const result = minipack.bundle(graph)

    const data = temp.replace('<% script %>', `<script>${result}</script><script>
    const ws = new WebSocket('ws://127.0.0.1:8080')

    ws.onmessage = function(data) {
        console.log(data)
        let parseData
        try {
            parseData = JSON.parse(data.data)
        }catch(e) {
            throw e;
        }
        if(parseData.type === 'update') {
            const [fn,mapping] = modules[parseData.id]
            modules[parseData.id] = [
                new Function('require', 'module', 'exports', parseData.code),
                mapping
            ]
            require(0)
        }
    }
    
    </script>`)
    fs.writeFileSync(outputhtml, data)
}

makeEntry('./temp.html', './index.html')

操作則是獲取temp.html 將依賴圖打包注入script到temp.html中

並且建立了ws鏈接。以獲取數據

在前端進行模塊替換

const [fn,mapping] = modules[parseData.id]
modules[parseData.id] = [
    new Function('require', 'module', 'exports', parseData.code),
    mapping
] // 這裏是刷新對應module的內容
require(0) // 從入口從新運行一次

當然一些細緻操作可能replace只會對引用的模塊parent進行replace,但是這裏簡化版可以先不做吧

這時候我們去run bundle.js的file我們會發現watch模式開啓了。此時
訪問生成的index.html文件

網頁實際訪問效果

當我們改動base.js的內容時

第一次修改base.js editor
第一次修改效果
第二次修改base.js editor
第二次修改效果

就這樣 一個簡單的基於minipack的HMR就完成了。

不過顯然易見,存在的問題很多。純當拋磚引玉。

(例如module的副作用,資源只有js資源等等,仔細剖析還有很多有趣的點)

擴展閱讀

本文示例代碼

minipack hmr

聯繫我

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章