文章目标: 结合代码或经典源码进行讲解,大致地介绍一下前端模块化的发展历程。
0x01 - 纯天然无加工的全局函数模式
直接将函数名暴露在全局作用域之中,完全不做模块化处理,我们可以很清除地看到这种奔放的写法在大规模团队协作开发的时候必然会出现的一些问题:
- 模块依赖难以快速确定;
- 容易造成命名冲突;
但咱真不是啥东西都能往全局作用域上面写的。
你写代码的样子真的很潇洒,但修bug的样子真的很狼狈。
<!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>蛮荒时代-全局函数模式</title>
</head>
<body></body>
<script src="./A.js"></script>
<script src="./B.js"></script>
<script src="./C.js"></script>
<script>
function globalFunctionA() {
// ...
}
function globalFunctionB() {
// ...
}
function globalFucntionA() {
// ...
console.log("喜欢我阿草吗?");
}
</script>
</html>
0x02 - 使用命名空间的全局函数模式
用对象来设定一个命名空间,然后通过对象名来调用命名空间中的属性和方法。
- namespace.property
- namespace.function()
命名空间真的尽力了, Brendan在干什么?#查询Brendan状态。
这里可以看一下jQuery在这个时期的源码 code.jquery.com/jquery-1.2.… 就是使用的这种方式。
// namespaceA.js
let namespaceA = {
namespaceName: "Ava",
functionA: function () {
// ...
},
functionB: function () {
// ...
}
}
// namespaceB.js
let namespaceB = {
namespaceName: "Diana",
functionA: function () {
// ...
},
functionB: function () {
// ...
namespaceA.functionA();
console.log(`${namespaceA.namespaceName}`);
}
}
// 你可以这么使用它
namespaceB.functionB();
// Ava
通过上面的代码,我们可以看到,引入命名空间在一定程度上解决了全局作用域中命名冲突的问题,但是它也存在着它的局限性:
- 因为命名空间本质上还是一个对象,对象中的属性和方法直接暴露在外界,所以它还存在着被外部修改操作影响到内部状态,进而产生的系统性风险。
- 模块间的依赖关系依旧难以确定。
// namespaceA.js
let namespaceA = {
namespaceName: "Ava",
functionA: function () {
// ...
console.log(`才没有很喜欢${this.namespaceName}呢!哼~`)
},
functionB: function () {
// ...
}
}
// namespaceB.js
let namespaceB = {
namespaceName: "Diana",
functionA: function () {
// ...
},
functionB: function () {
// ...
namespaceA.namespaceName = "Diana";
namespaceA.functionA();
console.log(`但其实早已经是${namespaceA.namespaceName}的形状了,嘉晚饭赢麻了。`);
}
}
// 你可以这么使用它
namespaceB.functionB();
/*
才没有很喜欢Diana呢!哼~
但其实早已经是Diana的形状了,嘉晚饭赢麻了。
*/
0x03 - IIFE —— 全局函数模式中的阿喀琉斯
IIFE使用闭包实现了内部属性的私有化,很不错的设计,直到现在webpack打包的时候都是用的这个思路,但是这种模式实现的模块化依然存在它致命的弱点。
毕竟不能总指望着大规模生产环境能做到会把严格控制JS文件加载顺序或者代码的书写顺序吧,那难度未免也太大了,这很不现实。
还是拿jQuery做例子 code.jquery.com/jquery-1.4.… 这里已经在使用IIFE来代替命名空间了。
// whiteBlowfish.js 让我们来看看IIFE的强大之处,然后再探讨一下它的不足。
const otherModuleA =(function() {
function fn () {
console.log(`fn from otherModuleA`);
}
return {
fn
}
})();
const otherModuleB =(function() {
function fn () {
console.log(`fn from otherModuleB`);
}
return {
fn
}
})();
const Bella = (function () {
let times = 55;
let name = "Bella";
let darling = "Eileen";
otherModuleA.fn();
otherModuleB.fn();
function getName() {
return name;
}
function getTimes() {
return times;
}
function setDarling(moreDarling) {
darling = moreDarling;
}
function getDarling() {
return darling;
}
function onemoreTime() {
times = times + 1;
}
function findSpider() {
console.log(`${name}在舞蹈练习室中发现了蜘蛛, ${name}差点晕了过去。`);
}
return {
getTimes,
getName,
onemoreTime,
setDarling,
getDarling,
findSpider
}
})(otherModuleA, otherModuleB);
console.log(`Eileen 已经摸了${Bella.getName()}大腿${Bella.getTimes()}次!严查入团动机!`);
Bella.onemoreTime();
console.log(`光明正大地再摸一次后,Eileen 已经摸了 ${Bella.getName()} 大腿${Bella.getTimes()}次!严查入团动机!`);
Bella.times = 0; // Eileen试图篡改数据
console.log(`Eileen试图篡改数据, 然而这是不可能的,一共是${ Bella.getTimes()}次。`);
// 那么这是为什么呢?
console.log(Bella);
// 当然是神奇的作用域啦, 闭包实现的私有变量可是没有办法直接修改的哦。
/*
{
getTimes: [Function: getTimes],
getName: [Function: getName],
onemoreTime: [Function: onemoreTime],
setDarling: [Function: setDarling],
getDarling: [Function: getDarling],
findSpider: [Function: findSpider],
times: 0
}
*/
// 当然, 大三角乱炖一下,那也不是不行。
Bella.setDarling(`${Bella.getDarling() + ", Carol"}`);
console.log(`什么是大三角:${Bella.getName()}, ${Bella.getDarling()}`);
// 强大的小队长Bella也会害怕蜘蛛。
Bella.findSpider();
// Polaris 试图使用修正小队长Bella的这一弱点。
Bella.findSpider = function() {
console.log(`小队长${Bella.getName()}发现了蜘蛛,但她完全没在怕的说。`)
};
// 再试一次,很好,Polaris成功了。
Bella.findSpider();
Polaris在上面的代码中成功地修正了小队长Bella的阿喀琉斯之踵, 但是IIFE却没有修复它的阿喀琉斯之踵。尽管IIFE使用闭包实现了其内部属性的私有化,但从上面的代码中我们可以看到,这种方式所暴露出来的方法依旧可以被外部篡改。
// 用来验证第一、第二点的代码
var moduleA = (function () {
let moduleName = "moduleA";
function getModuleName() {
console.log(moduleName);
};
function fn() {
getModuleName()
console.log(`fn from moduleA`);
};
return {
fn,
getModuleName
};
})();
var moduleC = (function (A, B) {
let moduleName = "moduleC";
function getModuleName() {
console.log(moduleName);
};
function fn() {
A.fn();
A.fn = function () {
console.log('A.fn() 已经被篡改')
};
B.fn();
};
return {
fn,
getModuleName
};
})(moduleA, moduleB);
var moduleB = (function (A) {
let moduleName = "moduleB";
function getModuleName() {
console.log(moduleName);
};
function fn() {
A.fn();
getModuleName()
console.log(`fn from moduleB`);
};
return {
fn,
getModuleName
};
})(moduleA);
moduleC.fn();
/*
moduleA
fn from moduleA
A.fn() 已经被篡改
moduleB
fn from moduleB
*/
// 用来验证第三点的代码
var moduleA = (function () {
let moduleName = "moduleA";
function getModuleName() {
console.log(moduleName);
};
function fn() {
getModuleName()
console.log(`fn from moduleA`);
};
return {
fn,
getModuleName
};
})();
var moduleC = (function (A, B) {
let moduleName = "moduleC";
function getModuleName() {
console.log(moduleName);
};
function fn() {
A.fn();
A.fn = function () {
console.log('A.fn() 已经被篡改')
};
B.fn();
};
return {
fn,
getModuleName
};
})(moduleA, moduleB);
var moduleB = (function (A) {
let moduleName = "moduleB";
function getModuleName() {
console.log(moduleName);
};
function fn() {
A.fn();
getModuleName()
console.log(`fn from moduleB`);
};
return {
fn,
getModuleName
};
})(moduleA);
moduleC.fn();
/*
B.fn();
^
TypeError: Cannot read property 'fn' of undefined
// moduleB在moduleA被初始化后才初始化,尽管var关键字会把变量提升到作用域顶部,但还是无法避免这种错误。少数的几个小体量的js代码固然可以做到严格控制模块的书写顺序,但是团队协作开发中想要达成这一点,毫无疑问是充满挑战性的。
*/
通过上面这段代码我们可以看到IIFE虽然使用闭包实现的自身内部属性的私有化,但是其实现的模块化仍然存在改进空间,(为了验证第三点,使用了var关键字来定义模块。):
- 需要手动收集依赖;
- 无法保证在编写当前模块的操作中不会影响其他模块的运行;
- 需要严格控制JS文件加载顺序或者代码的书写顺序;
0x04 - CommonJS —— 前端模块化发展历程上重要的一步
require + module.exports NodeJS将每个文件都当做一个模块,其底层实现会把这些文件用Module构造函数给它们包裹上一层模块信息。 废话不多说,我们直接上源码。
// index.js
console.log(module); // 没错,直接梭哈,看看在控制台里nodejs会给我们打印出什么。
/*
Module {
id: '.',
path: 'D:\\project\\src',
exports: { },
parent: null,
filename: 'D:\\project\\src\\index.js',
loaded: false,
children: [],
paths: [
'D:\\project\\src\\node_modules',
'D:\\project\\node_modules',
'D:\\node_modules'
]
}
*/
// 从上面控制台的输出结果我们还可以看到 module.exports 指向了一个空对象,对象是啥,是复杂类型数据,是存放在堆内存上的,这也就意味着 module.exports记录的只是一个堆内存上的内存地址。
console.log(module.exports === exports); // true , 我们不在文件内修改 module.exports 和 exports 时,它们指向了同一个地址。
// 这里提一下一个初学者容易搞错的地方,就是很容易直接把值赋给exports,或者在同一个js文件中混用了 module.exports 和 exports 导致设想中想要暴露给其他模块的内部成员和实际被暴露出去的内部成员不一致。
exports = function() {
console.log('错啦')
};
console.log(module); // 打印出来看看。
/*
Module {
id: '.',
path: 'D:\\project\\src',
exports: {}, // 没有被修改,说明这里其实是 module.exports ,而不是exports, 我们再修改 module.exports 试试看。
parent: null,
filename: 'D:\\project\\src\\index.js',
loaded: false,
children: [],
paths: [
'D:\\project\\src\\node_modules',
'D:\\project\\node_modules',
'D:\\node_modules'
]
}
*/
module.exports = {
A: function() {
console.log('对啦')
},
B: "B"
};
console.log(module); // 再打印出来看看。
/*
Module {
id: '.',
path: 'D:\\project\\src',
exports: { A: [Function: A], B: 'B' }, // 确实修改了。
parent: null,
filename: 'D:\\project\\src\\index.js',
loaded: false,
children: [],
paths: [
'D:\\project\\src\\node_modules',
'D:\\project\\node_modules',
'D:\\node_modules'
]
}
*/
Tips: 不要在同一个文件上混用
module.exports和exports
- 只使用
module.exports.xxx = xxx或者module.exports = { xxx, yyy, zzz }这样的模块暴露方式;- 或者只使用
exports.xxx = xxx, 千万不要 使用exports = { xxx, yyy, zzz }, 毕竟exports只是module.exports 的一个拷贝,最终影响暴露出去的接口的还是module.exports;
回到模块化的主题上来,那么这里的Module是在哪里被创建的呢?
查看NodeJS源码Github仓库_node/lib/internal/modules/cjs/loader.js_L808
// node/lib/internal/modules/cjs/loader.js L808
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) { // 这里查找缓存,如果不为空,说明该组件已经加载过了,直接从内存里取出来就好。
updateChildren(parent, cachedModule, true);
if (!cachedModule.loaded) {
const parseCachedModule = cjsParseCache.get(cachedModule);
if (!parseCachedModule || parseCachedModule.loaded)
return getExportsForCircularRequire(cachedModule);
parseCachedModule.loaded = true;
} else {
return cachedModule.exports;
}
}
const mod = loadNativeModule(filename, request);
if (mod?.canBeRequiredByUsers) return mod.exports;
// Don't call updateChildren(), Module constructor already does.
const module = cachedModule || new Module(filename, parent); // 让我们再来看看Module的构造函数里都有啥。
if (isMain) {
process.mainModule = module;
module.id = '.'; // 嘿,你说巧不巧,上面的代码里的id就是'.'。
}
Module._cache[filename] = module; // 这里也很重要,nodejs就是在这里将第一次加载的模块缓存起来的。
// 上面这段源码也有一个比较有用的知识点,接下来我们展开来讲讲。
// carol.js
let bobo = 2;
let willBoBoWho = "Bella";
let addBoBo = function () {
bobo = bobo + 1;
}
module.exports = {
bobo, willBoBoWho, addBoBo
};
// index.js
let carol = require('./carol.js');
console.log(`Carol will bobo ${carol.willBoBoWho} ${carol.bobo} times.`);
// Carol will bobo Bella 2 times. 初次输出,作为对照。
carol.addBoBo(); // 调用方法试图修改原模块中的值
console.log(`Carol will bobo ${carol.willBoBoWho} ${carol.bobo} times.`);
// Carol will bobo Bella 2 times. 显而易见地失败了。
// 因为 require 的是被导出模块中值的拷贝, 当值被模块A导出之后,调用模块A中的方法修改模块A数值,都不会改变到当前模块B下的这个模块A的拷贝上。
carol.willBoBoWho = carol.willBoBoWho + ", 皇珈骑士"; // 那么直接修改呢?
carol = require('./carol.js');
// Carol will bobo Bella, 皇珈骑士 2 times.
// 这是为啥呢?因为carol.js已经加载过了,内存中有它的缓存,所以再次引入的时候,是直接从内存中取的缓存。所以这里的修改没有被原模块中的数据覆盖掉。
console.log(`Carol will bobo ${carol.willBoBoWho} ${carol.bobo} times.`);
Tips:
- 当值被模块A导出之后,调用模块A中的方法修改模块A数值,都不会改变到当前模块B下的这个模块A的拷贝上。
- 模块第一次加载时会被缓存到内存中,第二次require是直接从内存中读取到的缓存。
NodeJS源码Github仓库_node/lib/internal/modules/cjs/loader.js_L172
// node/lib/internal/modules/cjs/loader.js L172
function Module(id = '', parent) {
this.id = id;
this.path = path.dirname(id);
this.exports = {};
moduleParentCache.set(this, parent);
updateChildren(parent, this, false);
this.filename = null;
this.loaded = false;
this.children = [];
}
// 这就对上了。
NodeJS源码Github仓库_node/lib/internal/modules/cjs/loader.js_L1025
// node/lib/internal/modules/cjs/loader.js L1025
try { // 平时既不用我们声明、也不用我们引入,就可以在nodejs上使用的变量和方法原来是nodejs给每个文件都加了这么些东西。
return vm.compileFunction(content, [
'exports',
'require',
'module',
'__filename',
'__dirname',
], {
filename,
importModuleDynamically(specifier, _, importAssertions) {
const loader = asyncESM.esmLoader;
return loader.import(specifier, normalizeReferrerURL(filename),
importAssertions);
},
});
} catch (err) {
if (process.mainModule === cjsModuleInstance)
enrichCJSError(err, content);
throw err;
}
那么commonJS是如何实现模块化的呢?
自己写了一版,感觉还是太粗糙了,现阶段还是不拿出来献丑了,后面等我阅读了nodejs内建模块的实现方式后再补上。
对这个实现比较感兴趣的可以看看这两位老哥写的文章,我觉得已经很详实了。
0x05 - AMD
AMD模块的特点:
- 模块通过异步加载:浏览器端模块加载需要依赖网络环境,AMD规范采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。
- 依赖前置收集: AMD模块会在define()函数的第二个参数中明确的指出该模块需要依赖哪些模块。在定义模块的时候就要声明其依赖的模块
define(id = 模块加载器请求的指定的脚本的名字, dependencies = ["require", "exports", "module"], factory);
define (
id?:
String, // 可选dependencies?:
String[], // 可选 factory:
Function|Object// 必选);
第一个参数,id,是个字符串。它指的是定义中模块的名字,这个参数是可选的。如果没有提供该参数,模块的名字应该默认为模块加载器请求的指定脚本的名字。
第二个参数dependencies是一个字符串数组,作为该AMD模块的依赖数组。
- 如果 "require"、"exports" 或 "module" 的值出现在依赖列表中,则参数应解析为 CommonJS 模块规范定义的相应自由变量。
- 依赖项参数是可选的。如果在使用时省略此参数,则默认它为 ["require", "exports", "module"]。但是,如果工厂方法的 arity(长度属性)小于 3,则加载器可以选择仅调用具有与函数的 arity 或长度相对应的参数数量的工厂方法。
第三个参数factory是必须传入的工厂方法。
define('module1', function() {
let fn = function () {
console.log('module1 ...');
};
return fn;
});
define('module2', ['module1'], function(module1) {
let fn = function() {
module1.fn();
};
return fn;
});
define('module3', ['module1, module2'], function(module1, module2) {
let fn1 = function() {
module1.fn();
};
let fn2 = function() {
module2.fn();
};
return {
fn1,
fn2
};
});
// weather.js 当factory是对象时。
define({
weather: [
{
city: 'hangzhou',
weather: 'rainy',
}, {
city: 'huzhou',
weather: 'rainy',
},
//...
]
});
define(['weather'], function(weather) {
// 在这里使用weather
});
AMD规范也使用require关键字来加载模块,但不同于CommonJS,AMD规范中的require需要两个参数。
require(
modules:
String [], callback:
Function);
构成modules的模块加载成功后,就会执行回调函数callback
require([module1, module2, ...], function callback (module1, module2, ...) {
module1.fn();
module2.fn();
// ...
// module1.fn()和module2.fn() 这些方法的执行和 module1和module2 这些模块加载不是同步的, 所以不会出现浏览器假死的情况,很适合浏览器环境。
});
这里还是拿jQuery来做例子 code.jquery.com/jquery-1.8.… ,这里jQuery已经在使用AMD规范了,具体时间可能是在bugs.jquery.com/ticket/1411… 前后。
// jQuery-1.8.3.js 直接来到文件的最末尾看这一段。
// Expose jQuery as an AMD module, but only for AMD loaders that
// understand the issues with loading multiple versions of jQuery
// in a page that all might call define(). The loader will indicate
// they have special allowances for multiple jQuery versions by
// specifying define.amd.jQuery = true. Register as a named module,
// since jQuery can be concatenated with other files that may use define,
// but not use a proper concatenation script that understands anonymous
// AMD modules. A named AMD is safest and most robust way to register.
// Lowercase jquery is used because AMD module names are derived from
// file names, and jQuery is normally delivered in a lowercase file name.
// Do this after creating the global so that if an AMD module wants to call
// noConflict to hide this version of jQuery, it will work.
if (typeof define === "function" && define.amd && define.amd.jQuery) {
define("jquery", [], function () { return jQuery; });
}
也可以阅读一下require.js关于模块化的一些介绍。
如何改造已有的CommonJS代码,使其兼容AMD规范呢?
define(function (require, exports, module) {
const a = require('a'),
b = require('b'),
c = require('./c.js');
const fn = function() {
a.fn();
b.fn();
};
return {
fn,
... // 其他可能的函数
};
};
0x06 - CMD
CMD是SeaJS在推广过程中对模块定义的规范化产出。 CMD模块的特点:
- 依赖就近: 在代码块的哪里使用用就在哪里引入,不需要提前写入到define函数的参数中。
- 按需加载: 若当前模块依赖其他的多个外部模块,在一个外部模块还不被需要时,它不会被引入。
define (
callback: Function | String | Array | Object| OtherType
);
// carol.js
define(function (require, exports, module) => {
exports.fn = function () {
// ...
};
});
// eileen.js
define(function (require, exports, module) => {
exports.fn = function() {
// ...
};
});
// bella.js
define(function (require, exports, module) => {
let carol = require('carol'); // 依赖就近: 哪里用就在哪里引入
carol.fn(); // 按需加载:这里运行的时候, 下面的 eileen 不会被引入,因为还未执行到那里。
// ... 一些逻辑
let eileen = require('eileen');
moduleB.fn();
// ... 一些逻辑
});
当 factory 的类型不是Function的时候:
// 这段代码直接用的CMD规范上的例子
// object-data.js
define({
foo: "bar"
});
// array-data.js
define([
'foo',
'bar'
]);
// string-data.js
define('foo bar');
0x06 - UMD
哪里都能跑的UMD模块做了各种规范的转换,让模块化后的代码能在各种环境上运行。
哪个库更适合拿出来做例子呢,欸,大家常用的axios就是一个浏览器端和服务器端都能使用的库。
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory(); // NodeJS中的CommonJS实现的exporst和module都是对象
else if(typeof define === 'function' && define.amd)
define([], factory); // 有define函数的同时还有define.amd属性,说明是AMD模块
else if(typeof exports === 'object')
exports["axios"] = factory(); // CommonJS的其他实现
else
root["axios"] = factory(); // 没有模块环境就直接挂到全局对象上。
})(this, function() {
// axios代码
});
// 不过axios的这个umd模块处理是不包括CMD规范的。
那让我们来写一个兼容CommonJS、AMD、CMD等规范的UMD模块吧。
// 这个代码是在webpack5 output.library.type = 'umd'的输出上修改的来的
(function (root, factory) {
if (typeof module === 'object' && typeof exports === 'object') {
module.exports = factory(require('depModule')); // NodeJS中的CommonJS实现的exporst和module都是对象
} else if (typeof define === 'function' && define.amd) {
define(['depModule'], factory); // 有define函数的同时还有define.amd属性,说明是AMD模块
} else if (typeof define === 'function' && define.cmd) {
// 有define函数的同时还有define.amd属性,说明是CMD模块
// webpack打包中没有CMD规范的实现,我们需要自己手写一个转换方法
define(function(require, exports, module) {
const depModule = require('depModule');
module.exports = factory(depModule);
});
} else if(typeof exports === 'object') { // CommonJS的其他实现
exports["axios"] = factory();
} else { // 没有模块环境就直接挂到全局对象上。
root.moduleName = factory(root.depModule)
}
}(this, function(depModule) { // ... 这里的factory随便写一个就行
let fn = function () {
console.log('fn...');
}
return {
fn
}
}));
0x07 - ES Module — 模块化成为ECMAScript标准
import 、export 这些大家应该都很熟悉了,就不多废话了。
// test.mjs
export default function() {
console.log('会者不难捏');
}
// index.js
import test from './test.mjs';
test();
// 会者不难捏
//console.log(module); // ReferenceError: module is not defined in ES module scope
//console.log(exports); // ReferenceError: exports is not defined in ES module scope
后续又在ES11(2020年6月发行)推出了动态引入的新特性。
import('test.mjs').then(dynamicEsModule => {
dynamicEsModule.fn();
})
接下来就是前端工程化的内容了,这个部分我会尽快发布的。
这里是前端小菜鸟_司南,如果我的文章能够给你带来收获,那是最好不过的事情了。
文笔不佳,才疏学浅,还望各位大佬不吝指教。