从构建产物洞悉模块化原理

12,067 阅读13分钟

一、前言

该文是 从零到亿系统性的建立前端构建知识体系✨ 中的第一篇,整体难度 ⭐️⭐️。

本文将从前端模块化的发展历史出发,逐步探讨CommonJS规范 和 ES Module规范之间的异同,最后将深入模块化内部原理进行多维度剖析,彻底弄懂 Web环境下 Webpack 是如何支持这些模块化规范的,整体深度阅读时间约15分钟。

在正式开始之前我们先看看几个常见的相关面试题:

  • 模块化的产生是为了解决什么问题?在什么场景下诞生的?
  • Web环境中是如何支持模块化的?加载过程是怎么样的?
  • CommonJS 可以加载 ES Module 导出的内容吗?
  • ES Module 可以加载 CommonJS 导出的内容吗?
  • Webpack 内部是如何区分一个模块是采用的哪种模块化规范?
  • 一个模块内可以既使用CommonJS,又使用 ES Module 吗?
  • ......

相信读完本文,你对上面的一系列问题都能够轻松的解答。

另外,文章总结栏中附有一道代码执行题,用来检测自己是否完全吸收,别忘记做了哦。

二、前置知识

在正式内容开始之前,先来学一个预备小知识点,以免影响后面的学习。

我们有时会使用 Object.prototype.toString 这个方法来判断数据的类型,比如:

Object.prototype.toString.call('hello');     // "[object String]"
Object.prototype.toString.call([1, 2]);    // "[object Array]"
Object.prototype.toString.call(3);         // "[object Number]"
Object.prototype.toString.call(true);      // "[object Boolean]"
Object.prototype.toString.call(undefined); // "[object Undefined]"
Object.prototype.toString.call(null);      // "[object Null]"
// ... and more

那我们如果想自定义数据的类型标签怎么办?就像这样:

Object.prototype.toString.call(new Map());       // "[object Map]"
Object.prototype.toString.call(function* () {}); // "[object GeneratorFunction]"
Object.prototype.toString.call(Promise.resolve()); // "[object Promise]"
// ... and more

这里 toString() 方法能识别 MapGeneratorFunctionPromise这些类型是因为浏览器引擎为它们设置好了 toStringTag 标签,那我们该如何设置自己想要的类型标签呢?

引自官方介绍:Symbol.toStringTag 是一个内置 symbol,它通常作为对象的属性键使用,对应的属性值应该为字符串类型,这个字符串用来表示该对象的自定义类型标签,通常只有内置的 Object.prototype.toString() 方法会去读取这个标签并把它包含在自己的返回值里。

我们来试一试:通过 Object.defineProperty 在对象上定义 toStringTag 属性:

const obj = {};

//定义属性
Object.defineProperty(obj, Symbol.toStringTag, { value: "Module" });

//查看自定义类型
console.log(Object.prototype.toString.call(obj)) //'[object Module]'改变了类型为Module

得到结果为[object Module],拿到了我们想要的类型。

三、模块化发展历史

早期 JavaScript 开发很容易存在全局污染依赖管理混乱问题,这些问题在多人开发前端应用的情况下变得更加棘手。我这里例举一个很常见的场景:

<body>
  <script src="./index.js"></script>
  <script src="./home.js"></script>
  <script src="./list.js"></script>
</body>

没有模块化,那么 script 内部的变量是可以相互污染的。比如有一种场景,如上 ./index.js 文件和 ./list.js 文件为小 A 开发的,./home.js 为小 B 开发的。

小 A 在 index.js中声明 name 属性是一个字符串。

var name = '不要秃头啊'

然后小 A 在 list.js 中,引用 name 属性,

console.log(name)

1.jpg

打印却发现 name 竟然变成了一个函数。刚开始小 A 不知所措,后来发现在小 B 开发的 home.js 文件中这么写道:

function name(){
    //...
}

......

上述例子就是没有使用模块化开发,造成的全局污染的问题,每个加载的js文件都共享变量

当然,在实际的项目开发中,可以使用匿名函数自执行的方式,形成独立的块级作用域解决这个问题。

只需要在 home.js 中这么写道:

(function (){
    function name(){
        //...
    }
})()

这样小 A 就能正常在 list.js 中获取 name 属性

但是,这又带来了新的问题:

  • 我必须记得每一个模块中返回对象的命名,才能在其他模块使用过程中正确的使用
  • 代码写起来杂乱无章,每个文件中的代码都需要包裹在一个匿名函数中来编写
  • 没有合适的规范情况下,每个人、每个公司都可能会任意命名、甚至出现模块名称相同的情况

所以现在急需一个统一的规范,来解决这些缺陷问题,就此CommonJS规范问世了。

四、CommonJS规范

CommonJS 是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为ServerJS,后来为了体现它的广泛性,修改为CommonJS规范NodeCommonJS在服务器端一个具有代表性的实现。

正是因为Node中对CommonJS进行了支持和实现,所以它具备以下几个特点:

  • 在Node中每一个js文件都是一个单独的模块
  • 该模块中,包含CommonJS规范的核心变量: exportsmodule.exportsrequire
  • 使用核心变量,进行模块化开发

使用:

//在a.js中导出变量
const name = "不要秃头啊";
const age = "18";
module.exports = { name, age };
//或者:
exports.name = "不要秃头啊";
exports.age = "18";

//在b.js中引用导出的变量
const { name, age } = require("./a.js");
console.log( name , age )

五、ES Module规范

Nodejs 借鉴了 Commonjs 实现了模块化 。从 ES6 开始, JavaScript 才真正意义上有自己的模块化规范。

Es Module 的产生有很多优势,比如:

  • 借助 Es Module 的静态导入导出的优势,实现了 tree shaking(后续文章会重点讲到)
  • Es Module 还可以 import() 懒加载方式实现代码分割(下篇文章会进行详情讲解)

在 Es Module 中用 export 用来导出模块,import 用来导入模块。

使用:

/**
 * 导出
 */
export * from 'module'; //重定向导出 不包括 module内的default
export { name1, name2, ..., nameN } from 'module'; // 重定向命名导出
export { import1 as name1, import2 as name2, ..., nameN } from 'module'; // 重定向重命名导出
export { name1, name2, …, nameN }; // 与之前声明的变量名绑定 命名导出
export { variable1 as name1, variable2 as name2, …, nameN }; // 重命名导出
export let name1 = 'name1'; // 声明命名导出 或者 var, const,function, function*, class
export default expression; // 默认导出
export default function () { ... } // 或者 function*, class
export default function name1() { ... } // 或者 function*, class

/**
 * 导入
 */
import defaultExport from "module"; // 默认导入
import { a, b, c } from "module"; //解构导入
import defaultExport, { a, b, c as newC } from "module"; //混合导入
import * as name from "module"; //混合导入
var promise = import("module"); //动态导入(异步导入)

六、在Webpack中的基本配置

在使用 Webpack 搭建的项目中,它是允许我们使用各种各样的模块化的。最常用的方式就是 CommonJSES Module。那么它内部是如何帮助我们实现了代码中支持模块化呢?

接下来将从这四个角度来研究一下它的原理:

  • CommonJS模块化实现原理
  • ES Module实现原理
  • CommonJS加载ES Module的原理
  • ES Module加载CommonJS的原理

为了防止我行你不行的场景发生,在这里统一配置:

 "webpack": "^5.73.0",
 "webpack-cli": "^4.10.0",

Webpack基本配置:webpack.config.js

module.exports = {
  mode: "development", //防止代码压缩
  entry: "./src/main.js",
  devtool: "source-map",//查看打包后的代码更方便
}

七、CommonJs模块化实现原理

name.js:

module.exports = "不要秃头啊";

main.js:

let author = require("./name.js");
console.log(author, "author");

在看具体打包代码之前,我们先来分析一下 👀。

name.js中有一个 module 对象,module 对象上有一个 exports 属性,我们给 exports 属性进行了赋值:"不要秃头啊"

main.js中,我们调用了 require 函数,入参为模块路径(./name.js),最后返回值为 module.exports 的内容。

如果让我们来设计一下这个运行过程,是不是这样就可以了:将name.js中的内容转换到一个modules对象中,该对象中key值为该模块路径,value值为该模块代码。在require函数执行时获取导出对象。

var modules = {
  "./name.js": () => {
    var module = {};
    module.exports = "不要秃头啊";
    return module.exports;
  },
};
const require = (modulePath) => {
  return modules[modulePath]();
};

let author = require("./name.js");
console.log(author, "author");

其实源码中的大致思路也是类似的,以上就是CommonJs能在浏览器中运行的核心思想。

接下来我们看看具体源码中的实现(对打包后的内容进行了调整优化,不影响阅读)。

主要分为以下几个部分:

  • 初始化:定义 modules 对象
  • 定义缓存对象cache
  • 定义加载模块函数require
  • 执行入口函数

初始化:定义 modules 对象

// 初始化:定义了一个对象modules, key为模块的路径 value是一个函数,函数里面是我们编写的源代码
var modules = {
  "./src/name.js": (module) => {
    module.exports = "不要秃头啊";
  },
};

定义缓存对象cache

var cache = {};

定义加载模块函数require

require:接受模块的路径为参数,返回具体的模块的内容。

//接受模块的路径为参数,返回具体的模块的内容
function require(modulePath) {
  var cachedModule = cache[modulePath]; //获取模块缓存
  if (cachedModule !== undefined) {
    //如果有缓存则不允许模块内容,直接retuen导出的值
    return cachedModule.exports;
  }
  //如果没有缓存,则定义module对象,定义exports属性
  //这里注意!!!module = cache[modulePath] 代表引用的是同一个内存地址
  var module = (cache[modulePath] = {
    exports: {},
  });
  //运行模块内的代码,在模块代码中会给module.exports对象赋值
  modules[modulePath](module, module.exports, require);

  //导入module.exports对象
  return module.exports;
}

执行入口函数

防止命名冲突,包装成一个立即执行函数。

(() => {
  let author = require("./src/name.js");
  console.log(author, "author");
})();

整体代码

如果不喜欢看代码的可以看图,重在理解思想即可。

image.png

//模块定义
var modules = {
  "./src/name.js": (module) => {
    module.exports = "不要秃头啊";
  },
};
var cache = {};

//接受模块的路径为参数,返回具体的模块的内容
function require(modulePath) {
  var cachedModule = cache[modulePath]; //获取模块缓存
  if (cachedModule !== undefined) {
    //如果有缓存则不允许模块内容,直接retuen导出的值
    return cachedModule.exports;
  }
  //如果没有缓存,则定义module对象,定义exports属性
  //这里注意!!!module = cache[modulePath] 代表引用的是同一个内存地址
  var module = (cache[modulePath] = {
    exports: {},
  });
  //运行模块内的代码,在模块代码中会给module.exports对象赋值
  modules[modulePath](module, module.exports, require);

  //导入module.exports对象
  return module.exports;
}

(() => {
  let author = require("./src/name.js");
  console.log(author, "author");
})();

八、ES Module模块化原理

name.js:

const author = "不要秃头啊";

export const age = "18";
export default author;

main.js:

import author, { age } from "./name";

console.log(author, "author");
console.log(age, "age");

我们还是先来理一理思路。

这下可没有exports对象给我们赋值了,这可怎么办?

换一种思路:我们可不可以将 name.js 中导出的内容还是挂载在 exports 对象上,如果是通过export default 方式导出的,那就在 exports 对象加一个 default 属性,将 name.js 中导出的内容变成这样:

const exports = {
  age: "18",
  default: "不要秃头啊",
}

然后在模块引用时(在 Webpack 编译时会将 import author from "./name" 代码块转换成 const exports = require(./name) 代码块),这样在 main.js 中拿到的是还是这个 exports 对象,就能够正常取值啦。

大致原理就是这么简单,只不过这里给exports赋值的方式是通过代理做到的。

接下来我们看看Webpack的打包后的代码(经优化):

image.png

//模块定义
var modules = {
  "./src/name.js": (module, exports, require) => {
    //给该模块设置tag:标识这是一个ES Module
    require.setModuleTag(exports);
    //通过代理给exports设置属性值
    require.defineProperty(exports, {
      age: () => age,
      default: () => DEFAULT_EXPORT,
    });
    const author = "不要秃头啊";
    const age = "18";
    const DEFAULT_EXPORT = author;
  },
};

var cache = {};
function require(modulePath) {
  var cachedModule = cache[modulePath];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  var module = (cache[modulePath] = {
    exports: {},
  });
  modules[modulePath](module, module.exports, require);
  return module.exports;
}

//对exports对象做代理
require.defineProperty = (exports, definition) => {
  for (var key in definition) {
    Object.defineProperty(exports, key, {
      enumerable: true,
      get: definition[key],
    });
  }
};

//标识模块的类型为ES Module
require.setModuleTag = (exports) => {
  Object.defineProperty(exports, Symbol.toStringTag, {
    value: "Module",
  });

  Object.defineProperty(exports, "__esModule", {
    value: true,
  });
};

//以下是main.js编译后的代码
//拿到模块导出对象exports
var _name__WEBPACK_IMPORTED_MODULE_0__ = require("./src/name.js");

console.log(_name__WEBPACK_IMPORTED_MODULE_0__["default"], "author");
console.log(_name__WEBPACK_IMPORTED_MODULE_0__.age, "age");

这里与 CommonJS 模块化原理不同的在于:

  1. 通过 require.setModuleTag 函数来标识这是一个ES Module(在现在这个例子中其实没什么作用)
  2. 给传入的 exports 对象通过 Object.defineProperty 做了一层代理(这样当访问default属性时,其实访问的是DEFAULT_EXPORT变量,访问age属性时,访问的是age变量)。

九、CommonJS 加载 ES Module的原理

后面的代码就不带着大家一起读了,其实都和前面大同小异。相信大家如果能够看到这里,是一定可以轻松读懂的。

name.js:

export const age = 18;
export default "不要秃头啊";

main.js:

let obj = require("./name");
console.log(obj, "obj");

对打包后的代码进行分析(经过优化):

var modules = {
  "./src/name.js": (module, exports, require) => {
    require.setModuleTag(exports);
    require.defineProperty(exports, {
      age: () => age,
      default: () => DEFAULT_EXPORT,
    });
    const age = 18;
    const DEFAULT_EXPORT = "不要秃头啊";
  },
};
var cache = {};
function require(moduleId) {
  var cachedModule = cache[moduleId];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  var module = (cache[moduleId] = {
    exports: {},
  });
  modules[moduleId](module, module.exports, require);
  return module.exports;
}

require.defineProperty = (exports, definition) => {
  for (var key in definition) {
    Object.defineProperty(exports, key, {
      enumerable: true,
      get: definition[key],
    });
  }
};

require.setModuleTag = (exports) => {
  Object.defineProperty(exports, Symbol.toStringTag, {
    value: "Module",
  });

  Object.defineProperty(exports, "__esModule", {
    value: true,
  });
};

(() => {
  let obj = require("./src/name.js");
  console.log(obj, "obj");
})();

运行结果:

{ age: [Getter], default: [Getter] } obj

十、ES Module加载CommonJS的原理

name.js:

module.exports = "不要秃头啊";

main.js:

import author from "./name";

console.log(author, "author");

这一步的思路其实跟前面基本上相同,唯一的区别在于多了个require.n函数,它用来返回模块的默认导出内容,核心思想依旧是将最终模块的内容导出为一个 exports 对象。

对打包后的代码进行分析(经过优化):

var modules = {
  "./src/name.js": (module) => {
    module.exports = "不要秃头啊";
  },
};
var cache = {};
function require(modulePath) {
  var cachedModule = cache[modulePath];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  var module = (cache[modulePath] = {
    exports: {},
  });
  modules[modulePath](module, module.exports, require);
  return module.exports;
}

require.n = (module) => {
  var getter =
    module && module.__esModule ? () => module["default"] : () => module;
  require.defineProperty(getter, {
    a: getter,
  });
  return getter;
};

require.defineProperty = (exports, definition) => {
  for (var key in definition) {
    Object.defineProperty(exports, key, {
      enumerable: true,
      get: definition[key],
    });
  }
};

require.setModuleTag = (exports) => {
  Object.defineProperty(exports, Symbol.toStringTag, {
    value: "Module",
  });

  Object.defineProperty(exports, "__esModule", {
    value: true,
  });
};

var __webpack_exports__ = {};
(() => {
  "use strict";
  require.setModuleTag(__webpack_exports__);
  var _name__WEBPACK_IMPORTED_MODULE_0__ = require("./src/name.js");
  var _name__WEBPACK_IMPORTED_MODULE_0___default = require.n(
    _name__WEBPACK_IMPORTED_MODULE_0__
  );
  console.log(_name__WEBPACK_IMPORTED_MODULE_0___default(), "author");
})();

十一、总结

该篇文章从构建产物的角度出发,带领大家从各个角度分析 Webpack 中模块化的原理。整体代码量不多,却能够支持各种模块间的相互引用,设计比较巧妙。重在理解其中设计思想,建议大家反复阅读。

最后,通过一道代码执行题来看看大家到底掌握没有哦!考点是这些模块化规范是如何解决循环依赖的问题的。

  • a.js文件
const getMes = require('./b')
console.log('我是 a 文件')
exports.say = function(){
    const message = getMes()
    console.log(message)
}
  • b.js文件
const say = require('./a')
const  object = {
   name:'从构建产物洞悉模块化原理',
   author:'不要秃头啊'
}
console.log('我是 b 文件')
module.exports = function(){
    return object
}
  • 主文件main.js
const a = require('./a')
const b = require('./b')

console.log('node 入口文件')

接下来执行 main.js 文件,控制台会输出什么呢?评论区告诉我哦😁😁😁。

十二、推荐阅读

  1. 从零到亿系统性的建立前端构建知识体系✨
  2. 我是如何带领团队从零到一建立前端规范的?🎉🎉🎉
  3. 二十张图片彻底讲明白Webpack设计理念,以看懂为目的
  4. 【中级/高级前端】为什么我建议你一定要读一读 Tapable 源码?
  5. 前端工程化基石 -- AST(抽象语法树)以及AST的广泛应用
  6. 线上崩了?一招教你快速定位问题!
  7. 【Webpack Plugin】写了个插件跟喜欢的女生表白,结果.....
  8. Webpack深度进阶:两张图彻底讲明白热更新原理!
  9. 【万字长文|趣味图解】彻底弄懂Webpack中的Loader机制
  10. Esbuild深度调研:吹了三年,能上生产了吗?

参考文献: