从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

联系我

发表评论

电子邮件地址不会被公开。 必填项已用*标注