自己实现一个小型的打包工具

803 阅读4分钟

007.jpg

一、用打包模板实现最简单的模块的打包

先试想一下,对于下面这样一个遵循CommonJS规范的模块:

  • tiny-bundler\index.js
console.log('hello, bundler');
module.exports = 'hello, world';

将它打包后,需要变成什么样子呢?

曾在前文 几种常用的模块化规范总结 中提到过,Node.js对于CommonJS模块的处理做法是在外面套一层壳,把原生JS中没有的require、module、exports等方法注进去。这里,我们也可以学习这种做法。此外,为了获得一个模块的作用域,我们把它封在(function(){})()中。

1. (function() {
2.   var moduleList = [
3.     function (require, module, exports) {
4.       console.log('hello, bundler');
5.       module.exports = 'hello, world';
6.     }
7.   ]
8. 
9.   var module = {
10.     exports: {}
11.   };
12. 
13.   moduleList[0](null, module, null);
14. })();

通过观察,我们发现,模块的内容只体现在第4-5行,于是,我们想到可以把其余的部分抽成模板。于是,我们建立文件:

  • tiny-bundler\src\index.bundle.boilerplate
(function() {
  var moduleList = [
    function (require, module, exports) {
      /* template */
    }
  ]

  var module = {
    exports: {}
  };

  moduleList[0](null, module, null);
})();
  • tiny-bundler\src\bundle.js
const fs = require('fs');
const path = require('path');

const boilerplate = fs.readFileSync(path.resolve(__dirname, 'index.bundle.boilerplate'), 'utf-8');
const target = fs.readFileSync(path.resolve(__dirname, '../index.js'), 'utf-8');
const content = boilerplate.replace('/* template */', target);

fs.writeFileSync(path.resolve(__dirname, '../dist/index.bundle.js'), content, 'utf-8');

我们在tiny-bundler目录下执行node src/bundle.js,就可以得到打包后的结果:

  • tiny-bundler\dist\index.bundle.js
  var moduleList = [
    function (require, module, exports) {
      console.log('hello, world');
      module.exports = 'hello, world';
    }
  ]

  var module = {
    exports: {}
  };

  moduleList[0](null, module, null);
})();

二、自己实现require,完成带有require的模块的打包

下面,我们新建一个文件:

  • tiny-bundler\moduleA.js
module.exports = new Date().getTime();

并将tiny-bundler\index.js的内容修改为:

const moduleA = require('./moduleA');
console.log(moduleA);

这个时候,打包应该怎么实现呢?我们仍然可以采用倒推的方法,先自己来手写一下index.bundle.js:

1. (function() {
2.   var moduleList = [
3.     // index.js
4.     function (require, module, exports) {
5.       const moduleA = require('./moduleA');
6.       console.log(moduleA);
7.     },
8.     // moduleA.js
9.     function (require, module, exports) {
10.       module.exports = new Date().getTime();
11.     }
12.   ]
13. 
14.   var moduleDepIdList = [
15.     {
16.       // 表示moduleList中的第0个模块中,如果遇到了require('./moduleA'),就需要去moduleList[1]中找这个模块
17.       './moduleA': 1,
18.     },
19.     {
20.     }
21.   ]
22. 
23.   // 自己实现一个require
24.   function require(id, parentId) {
25.     var currentModuleId = parentId !== undefined ? moduleDepIdList[parentId][id] : id;
26.     var module = { exports: {} };
27.     var moduleFunc = moduleList[currentModuleId];
28.     moduleFunc((id) => require(id, currentModuleId), module, module.exports);
29.     return module.exports;
30.   }
31. 
32.   require(0);
33. })();

经过观察,我们发现上面代码中3-11、15-20行是面对不同模块的打包时要变化的部分,其余部分是不变的。因此,我们可以把tiny-bundler\src\index.bundle.boilerplate改成:

(function() {
  var moduleList = [
    /* template-module-list */
  ]

  var moduleDepIdList = [
    /* template-module-dep-id-list */
  ]

  function require(id, parentId) {
    var currentModuleId = parentId !== undefined ? moduleDepIdList[parentId][id] : id;
    var module = { exports: {} };
    var moduleFunc = moduleList[currentModuleId];
    moduleFunc((id) => require(id, currentModuleId), module, module.exports);
    return module.exports;
  }

  require(0);
})();

三、实现require.ensure,完成chunk异步加载的打包

还是采用倒推的方式,我们先手写出来一个实现的效果:

  • tiny-bundler\index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <script src="./index.bundle.js"></script>
</body>
</html>
  • tiny-bundler\index.bundle.js
(function() {
  var moduleList = [
    function (require, module, exports) {
      require
        .ensure('1')
        .then(res => {
          console.log(res);
        })
    }
  ]

  var moduleDepIdList = [];

  var cache = {};

  function require(id, parentId) {
    var currentModuleId = parentId !== undefined ? moduleDepIdList[parentId][id] : id;
    var module = { exports: {} };
    var moduleFunc = moduleList[currentModuleId];
    moduleFunc((id) => require(id, currentModuleId), module, module.exports);
    return module.exports;
  }

  window.__JSONP = function (chunkId, moduleFunc) {
    var resolve = cache[chunkId][0];
    var module = {
      exports: {}
    };
    // 执行这个chunk的代码
    moduleFunc(require, module, module.exports);
    // 然后resolve掉执行模块代码后得到的module.exports
    resolve(module.exports);
  }

  require.ensure = function (chunkId, parentId) {
    var currentModuleId = parentId !== undefined ? moduleDepIdList[parentId][chunkId] : chunkId;

    // 如果cache[currentModuleId] === undefined,表明是首次加载
    if (cache[currentModuleId] === undefined) {
      // 通过JSONP异步加载这个JS
      var $script = document.createElement('script');
      $script.src = '/chunk_' + chunkId + '.js';
      document.body.appendChild($script);

      // 把几个状态都挂到全局的cache里面
      var promise = new Promise(function (resolve) {
        cache[currentModuleId] = [resolve];
        cache[currentModuleId].status = true; // 这个状态为true则表示'/chunk_' + chunkId + '.js'这个JS还在加载过程中
      });
      cache[currentModuleId].push(promise);

      return promise;
    } else if (cache[currentModuleId].status) {
      // 正在加载中
      return cache[currentModuleId][1];
    }

    return cache[currentModuleId];
  }

  moduleList[0](require, null, null);
})();
  • tiny-bundler\chunk_1.js
window.__JSONP('1', function(require, module, exports) {
  module.exports = 'hello, world';
});

在tiny-bundler目录下执行anywhere -p 80启动静态服务器,访问 http://localhost 在Chrome Devtools中可看到如下结果:

image.png

image.png

image.png

具体的实现上,可以如前面一样,抽取出来模板,进行replace替换。

顺便提一下热更新。因为moduleList中存储着所有编译后的模块的代码,所以,当我们对某个模块进行了更新的时候,websocket server端通过fsevents.watch会监听到,这个时候,就会触发重新打包,打包后就通过websocket通知客户端,客户端获得通知后只需要将moduleList中对应模块的代码进行替换,这个时候就能在内存中直接将moduleList中对应模块的内容改掉,就能实现热更新的效果。