从构建产物洞悉模块化原理
一、前言
该文是 从零到亿系统性的建立前端构建知识体系✨ 中的第一篇,整体难度 ⭐️⭐️。
本文将从前端模块化的发展历史出发,逐步探讨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()
方法能识别 Map
、GeneratorFunction
、Promise
这些类型是因为浏览器引擎为它们设置好了 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)
打印却发现 name
竟然变成了一个函数。刚开始小 A 不知所措,后来发现在小 B 开发的 home.js
文件中这么写道:
function name(){
//...
}
......
上述例子就是没有使用模块化开发,造成的全局污染的问题,每个加载的js文件都共享变量
。
当然,在实际的项目开发中,可以使用匿名函数自执行的方式,形成独立的块级作用域
解决这个问题。
只需要在 home.js
中这么写道:
(function (){
function name(){
//...
}
})()
这样小 A 就能正常在 list.js
中获取 name 属性
。
但是,这又带来了新的问题:
- 我必须记得每一个
模块中返回对象的命名
,才能在其他模块使用过程中正确的使用 - 代码写起来
杂乱无章
,每个文件中的代码都需要包裹在一个匿名函数中来编写 - 在
没有合适的规范
情况下,每个人、每个公司都可能会任意命名、甚至出现模块名称相同的情况
所以现在急需一个统一的规范,来解决这些缺陷问题,就此CommonJS规范
问世了。
四、CommonJS规范
CommonJS
是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为ServerJS,后来为了体现它的广泛性,修改为CommonJS规范
。Node 是 CommonJS
在服务器端一个具有代表性的实现。
正是因为Node中对CommonJS
进行了支持和实现,所以它具备以下几个特点:
- 在Node中
每一个js文件都是一个单独的模块
- 该模块中,包含CommonJS规范的核心变量:
exports
、module.exports
、require
- 使用核心变量,进行
模块化
开发
使用:
//在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
搭建的项目中,它是允许我们使用各种各样的模块化的。最常用的方式就是 CommonJS
和 ES 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");
})();
整体代码
如果不喜欢看代码的可以看图,重在理解思想即可。
//模块定义
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的打包后的代码(经优化):
//模块定义
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
模块化原理不同的在于:
- 通过
require.setModuleTag
函数来标识这是一个ES Module
(在现在这个例子中其实没什么作用) - 给传入的
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
文件,控制台会输出什么呢?评论区告诉我哦😁😁😁。
十二、推荐阅读
- 从零到亿系统性的建立前端构建知识体系✨
- 我是如何带领团队从零到一建立前端规范的?🎉🎉🎉
- 【Webpack Plugin】写了个插件跟喜欢的女生表白,结果......😭😭😭
- 前端工程化基石 -- AST(抽象语法树)以及AST的广泛应用🔥
- 学会这些自定义hooks,让你摸鱼时间再翻一倍🐟🐟
- 浅析前端异常及降级处理
- 前端重新部署后,领导跟我说页面崩溃了...
- 前端场景下的搜索框,你真的理解了吗?
- 手把手教你实现React数据持久化机制
- 面试官:你确定多窗口之间sessionStorage不能共享状态吗???🤔
参考文献:
转载自:https://juejin.cn/post/7147365025047379981