编写一个简单的 webpack 模块打包器

早期JavaScript只需要实现简单的页面交互,几行代码即可搞定。随着浏览器性能的提升以及前端技术的不断发展,JavaScript代码日益膨胀,此时就需要一个完善的模块化机制来解决这个问题。因此诞生了CommonJS(NodeJS), AMD(sea.js), ES6 Module(ES6, Webpack), CMD(require.js)等模块化规范。

什么是模块化?

模块化是一种处理复杂系统分解为更好的可管理模块的方式,用来分割,组织和打包软件。每一个模块完成一个特定的子功能,所有的模块按照某种方式组装起来,成为一个整体,完成整个系统的所有要求功能。

模块化的好处是什么?

模块间解耦,提高模块的复用性。
避免命名冲突。
分离以及按需加载。
提高系统的维护性。

JS模块化的演进
JavaScript模块化的发展进程了。早期JavaScript模块化模式比较简单粗暴,将一个模块定义为一个全局函数

function module1() {
  // code
}
function module2() {
    // code  
}
// 这种方案非常简单,但问题也很明显:污染全局命名空间,引起命名冲突或数据不安全,而且模块间的依赖关系并不明显。

在此基础上,又有了namespace模式,利用一个对象来对模块进行包装

var module1 = {
  data: {  }, // 数据区域
  func1: function() {}
  func2: function() {}
}
// 这种方案的问题依然是数据不安全,外面能直接修改module1的data

因此又有了IIFE模式,利用自执行函数(闭包)

!function(window) {
  var data = {};
  function func1() {
    data.hello = "hello";
  }
  function func2() {
    data.world = "world";
  }
  window.module1 = { func1, func2 };
} (window)
//数据定义为私有,外部只能通过模块暴露的方法来对data进行操作,但这依然没有解决模块依赖的问题

基于IIFE,又提出了一种新的模块化方案,即在IIFE的基础上引入了依赖(现代模块化的基石,Webpack、NodeJS等模块化都是基于此实现的)

!function (window, module2) {
  var data = {};
  function func1() {
    data.world = "world";
    module2.hello();
  }
  window.module1 = { func1 };
} (window, { hello: function() {}, });
// 这样使IIFE模块化的依赖关系变得更明显,又保证了IIFE模块化独有的特性。

什么是 webpack

webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。

我们知道了模块打包器会将多个文件打包成一个文件,那么打包后的文件到底是什么样的了,我们必须知道这个才能够进行具体实现,因此我们查看以下 webpack 打包后的效果。示例:假设我们在同一个文件夹下有以下几个文件:文件:index.js

let action = require("./action.js").action;   // 引入aciton.js
let name = require("./name.js").name;         // 引入name.js
let message = `${name} is ${action}`;
// index.js文件中引入了action.js和name.js。

文件:action.js

let action = "making webpack";
exports.action = action;

文件:name.js

let familyName = require("./family-name.js").name;
exports.name = `${familyName} 阿尔伯特`;
// 文件name.js又引入了family-name.js文件。

文件:family-name.js

exports.name = "haiyingsitan";

接下来我们使用 webpack 进行打包,并去除打包后的注释,得到如下代码:

 (() => {
 
   var __webpack_modules__ = ({
     "./action.js": ((__unused_webpack_module, exports) => {
       let action = "making webpack";
       exports.action = action;
     }),
     "./family-name.js": ((__unused_webpack_module, exports) => {
       exports.name = "haiyingsitan";
     }),
     "./name.js": ((__unused_webpack_module, exports, __webpack_require__) => {
       let familyName = __webpack_require__( /*! ./family-name.js */ "./family-name.js").name;
       exports.name = `${familyName} 阿尔伯特`;
     })
   });
 
   var __webpack_module_cache__ = {};
   function __webpack_require__(moduleId) {
     if (__webpack_module_cache__[moduleId]) {
       return __webpack_module_cache__[moduleId].exports;
     }
     var module = __webpack_module_cache__[moduleId] = {
       exports: {}
     };
     __webpack_modules__[moduleId](module, module.exports, __webpack_require__ "moduleId");
     return module.exports;
   }
 
   (() => {
     let action = __webpack_require__(  "./action.js").action;
     let name = __webpack_require__(  "./name.js").name;
     let message = `${name} is ${action}`;
     console.log(message);
   })();
 
 })();

我们进一步简化它


(() => {
    // 获取所有的依赖
  var modules = {
    "./action.js": (module, exports) => {
      let action = "making webpack";
      exports.action = action;
    },
     // ... 其他代码
  };
 
  // require对应的模块函数执行
  function __webpack_require__(moduleId) {
    // 其他实现
    return module.exports;
  }
 
  // 入口函数立即执行
  let entryFn = () => {
    let action = __webpack_require__("./action.js").action;
    let name = __webpack_require__("./name.js").name;
    let message = `${name} is ${action}`;
    console.log(message);
  };
  entryFn();
})();

我们可以发现,文件最终打包后就是一个立即执行函数。这个函数由三部分组成:
1、模块集合 这个模块集合是所有模块的集合,以路径作为key值,模块内容作为value值。当我们需要使用某个模块时,直接从这个模块集合中进行获取即可。为什么需要这个模块集合了?试想一下,如果我们遇到require("./action.js"),那么这个action.js到底对应的是哪个模块了?因此,我们必须能够获取到所有的模块,并对他们进行区分(使用模块id或者模块名称),到时候直接从这个模块集合中通过模块id或者模块名进行获取即可。
2、模块函数执行 每一个模块对应于一个函数,当遇到require(xxx)的时候实际上就是去执行引入的这个模块函数。
3、入口文件立即执行(执行模块的函数) 我们都知道一个模块的打包,必须有一个入口文件,而且这个文件必须立即执行,才能获取到所有的依赖。其实入口文件,也是一个模块,立即执行这个模块对应的函数即可。

我们可以发现每个模块实际上就是在外层套上了一个函数的外壳。为什么要把文件内容放入到一个函数中了,这是因为我们都知道模块化最重要的一个特点就是环境隔离,各个模块之间互不影响。

如果我们想要实现同样的功能,只需要同时实现:模块集合,模块执行和入口函数立即执行即可。


image.png

也就是说,我们最终要实现的就是这样的一个集合。到目前为止,我们要实现的功能是:

1、给每个文件内容加壳
2、每个模块以路径作为模块 id
3、将所有的模块合在一起形成一个集合

我们看下具体的实现如下:

const fs = require("fs");
let modules = {};
const fileToModule = function (path) {
  const fileContent = fs.readFileSync(path).toString();
  return {
    id: path,                             // 这里以路径作为模块id
    code: `function(require,exports){     // 这里加壳了
            ${fileContent.toString()};
        }`,
  };
};
let result = fileToModule("./index.js");
modules[result["id"]] = result.code;
console.log("modules=",modules);

输出的结果为:


modules= {
    './index.js':'function(require,exports){\n    let action = require("./action.js").action;\r\nlet name = require("./name.js").name;\r\nlet message = `${name} is ${action}`;\r\nconsole.log(message);\r\n;\n  }'

从上面我们可以看出,我们成功地将入口文件加壳转化成一个模块,并且给其命名,然后添加到模块对象中去了。但是我们发现我们的文件中其实还依赖了./action.js和./name.js,然而我们无法获取到他们的模块内容。因此,我们需要处理require引入的模块。也就是说要找到当前模块中的所有依赖,然后解析这些依赖将其放入模块集合中。

获取当前模块的所有依赖


// const action = require("./action.js")
function getDependencies(fileContent) {
  let reg = /require\(['"](.+? "'"")['"]\)/g;
  let result = null;
  let dependencies = [];
  while ((result = reg.exec(fileContent))) {
    dependencies.push(result[1]);
  }
  return dependencies;
}

这里我们使用了正则判断,只要是require("")或者require('')这种格式的都当作模块引入进行处理(这种处理有点问题,我们暂时先不管,等到下面进行优化)。然后把所有的引入都放到一个数组中,从而获取到当前模块所有的依赖。我们使用这个函数查看下入口文件的依赖:

const fileContent = fs.readFileSync(path).toString();
let result = getDependencies(fileContent);
console.log(result)  // ["./action.js","./name.js"]

将所有模块组成一个集合

function createGraph(filename) {
  let module = fileToModule(filename);
  let queue = [module];
 
  for (let module of queue) {
    const dirname = path.dirname(module.id);
    module.dependencies.forEach((relativePath) => {
      const absolutePath = path.join(dirname, relativePath);
      const child = fileToModule(absolutePath);
      queue.push(child);
    });
  }
    // 上面得到的是一个数组。转化成对象
  let modules = {}
  queue.forEach((item) => {
    modules[item.id] = item.code;
  })
  return modules;
}
console.log(createGraph("./index.js"));

执行模块的函数
我们在上面的模块对象中获得了所有模块信息,接下来我们执行入口文件对应的函数exec。


image.png

从上图中我们可以看出:当我们执行入口文件对应的函数时exec(index.js),它发现:

存在依赖./action.js,于是调用exec("./action.js")。这时候不存在其他依赖了,那么直接返回值。这条线结束。

存在依赖./name.js,于是调用exec("./name.js")。又发现依赖./family-name.js,于是调用exec("./family-name.js")。这时候不存在其他依赖了,返回值。这条线结束。
我们可以发现其实这就是一个递归的过程,不断查找依赖,然后执行对应的函数。因此,我们可以大致写出以下这个函数:

const exec = function(moduleId){
  const fn = modules[moduleId];  // 获取到每个id对应的函数
  let exports = {};
  const require = function(filename){
     const dirname = path.dirname(module.id);
     const absolutePath = path.join(dirname, filename);
      return exec(absolutePath);
  }
  fn(require, exports);
  return exports
}

最终完整的代码如下:

const fs = require("fs");
const path = require("path");
const {parse} = require("@babel/parser");
const traverse = require("@babel/traverse").default;
 
// 1.加壳
const fileToModule = function (path) {
  const fileContent = fs.readFileSync(path).toString();
  return {
    id: path,
    dependencies: getDependencies(path),
    code: `function (require, exports) {
      ${fileContent};
    }`,
  };
};
// 2.获取依赖
function getDependencies(filePath) {
  let result = null;
  let dependencies = [];
  const fileContent = fs.readFileSync(filePath).toString();
  // parse
  const ast = parse(fileContent, { sourceType: "CommonJs" });
  // transform
  traverse(ast, {
    enter: (item) => {
      if (
        item.node.type === "CallExpression" &&
        item.node.callee.name === "require"
      ) {
        const dirname = path.dirname(filePath);
        dependencies.push(path.join(dirname, item.node.arguments[0].value));
        console.log("dependencies", dependencies);
      }
    },
  });
  return dependencies;
}
// 3. 将所有依赖形成一个集合
function createGraph(filename) {
  let module = fileToModule(filename);
  let queue = [module];
 
  for (let module of queue) {
    const dirname = path.dirname(module.id);
    module.dependencies.forEach((relativePath) => {
      const absolutePath = path.join(dirname, relativePath);
      console.log("queue:",queue);
      console.log("absolutePath:",absolutePath);
      const result = queue.every((item) => {
        return item.id !== absolutePath;
      });
      if (result) {
        const child = fileToModule(absolutePath);
        queue.push(child);
      } else {
        return false;
      }
    });
  }
  let modules = {};
  queue.forEach((item) => {
    modules[item.id] = item.code;
  });
  return modules;
}
 
let modules = createGraph("./index.js");
// 4. 执行模块
const exec = function (moduleId) {
  const fn = modules[moduleId];
  let exports = {};
  const require = function (filename) {
    const dirname = path.dirname(module.id);
    const absolutePath = path.join(dirname, filename);
    return exec(absolutePath);
  };
  fn(require, exports);
  return exports;
};
// exec("./index.js");
// 5. 写入文件
function createBundle(modules){
  let __modules = "";
  for (let attr in modules) {
    __modules += `"${attr}":${modules[attr]},`;
  }
  const result = `(function(){
    const modules = {${__modules}};
    const exec = function (moduleId) {
      const fn = modules[moduleId];
      let exports = {};
      const require = function (filename) {
        const dirname = path.dirname(module.id);
        const absolutePath = path.join(dirname, filename);
        return exec(absolutePath);
      };
      fn(require, exports);
      return exports;
    };
    exec("./index.js");
  })()`;
  fs.writeFileSync("./dist/bundle3.js", result);
}
 
createBundle(modules)
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 161,326评论 4 369
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,228评论 1 304
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,979评论 0 252
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,489评论 0 217
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,894评论 3 294
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,900评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,075评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,803评论 0 205
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,565评论 1 249
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,778评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,255评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,582评论 3 261
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,254评论 3 241
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,151评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,952评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,035评论 2 285
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,839评论 2 277

推荐阅读更多精彩内容