乐趣区

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 的内容时




就这样 一个简单的基于 minipack 的 HMR 就完成了。

不过显然易见,存在的问题很多。纯当抛砖引玉。

(例如 module 的副作用,资源只有 js 资源等等,仔细剖析还有很多有趣的点)

扩展阅读

  • github minipack
  • what-aspect-of-hot-module-replacement-is-this-article-for
  • node 创建 websocket
  • browserify-hmr
  • webpack 热更新流程

本文示例代码

minipack hmr

联系我

  • kangkangblog/zwkang
  • zwkang github
退出移动版