JS 模块化
主讲:云隐
课程目标
- 了解熟悉
JS模块化的发展与变迁; - 掌握不同模块化方案的原理与实现;
- 通过对相关开放型面试题目分析的理解,能够顺畅应对相关面试题;
- 初步了解整体前端模块化、工程化的脉络;
不得不说的历史
背景
JS 本身就是为了满足简单的页面设计:页面动画 + 表单提交,并无模块化 or 命名空间的概念;
JS 的模块化需求日益增长;
幼年期:无模块化
- 开始需要在页面中加载不同的
js:例如动画、表单、格式化等等; - 多种
js文件被分在不同的文件中; - 不同的文件又被同一个模板引用;
<script src="jquery.js"></script>
<script src="main.js"></script>
<script src="dep1.js"></script>
- 认可:文件分离拆分是最基础的模块化(第一步);
- 问题出现:污染全局作用域 => 不利于大型项目的开发以及多人团队的共建;
追问:script 标签的参数 - async 和 defer
<script src="jquery.js" async></script>
<script src="jquery.js" defer></script>
- 总结:
- 普通 - 解析到立即阻塞,立刻下载执行当前
script; async- 解析到标签开始异步下载,下载完成后开始执行并且阻塞渲染,执行完成之后继续渲染;defer- 解析到标签开始异步下载,解析完之后开始执行;
- 普通 - 解析到立即阻塞,立刻下载执行当前
- 兼容性 >
IE9 - 问题可以被引导到:
- 浏览器渲染原理;
- 同步异步原理;
- 模块化加载原理;
成长期:模块化的雏形 - IIFE(语法侧的优化)
IIFE- 立即执行函数;- 本质:作用域的把控;
例子:
// 定义一个全局变量
let count = 0;
// 代码块1
const increase = () => ++count;
// 代码块2
const reset = () => {
count = 0;
};
increase();
reset();
利用函数的块级作用域 - 隔离区:
() => {
let count = 0;
// ...
};
仅定义了一个函数,如果立即执行:
(() => {
let count = 0;
// ...
})();
初步实现了一个最最最最简单的模块;
尝试去定义一个最简单的模块:
const iifeModule = (() => {
let count = 0;
return {
increase: () => ++count,
reset: () => {
count = 0;
}
};
})();
iifeModule.increase();
iifeModule.reset();
追问:独立模块本身的额外依赖,如何优化 IIFE 相关代码?
优化 1:依赖其他模块的 IIFE;
const iifeModule = ((dependencyModule1, dependencyModule2) => {
let count = 0;
// dependencyModule1.xxx
// dependencyModule2.xxx
return {
increase: () => ++count,
reset: () => {
count = 0;
}
};
})(dependencyModule1, dependencyModule2);
iifeModule.increase();
iifeModule.reset();
面试题 1:【早期经典问题】了解早期 jquery 的依赖处理以及模块加载方案吗?(了解传统 IIFE 是如何解决多方依赖的问题?)
答:IIFE 加传参调配
实际上,jquery 等框架其实应用了 revealing 的写法:在写法的思想上更强调的是,API 是定义在函数中,而仅仅是暴露接口的一个思想;
const iifeModule = ((dependencyModule1, dependencyModule2) => {
let count = 0;
const increase = () => ++count;
const reset = () => {
count = 0;
};
return {
increase,
reset
};
})(dependencyModule1, dependencyModule2);
iifeModule.increase();
iifeModule.reset();
重点是想问:揭示模式 revealing => 上层无需了解底层实现,仅关注抽象(也就是 return 暴露出的接口);
追问:
- 继续模块化横向展开;
- 转向框架:
jquery|vue|react模块化细节; - 转向设计模式;
成熟期:
CJS - CommonJS
-
node.js制定; -
特征:
-
通过
module + exports去对外暴露接口; -
通过
require来调用其他模块;
-
-
模块组织方式
例子:
main.js文件:
// 引入部分
const dependencyModule1 = require('./dependencyModule1');
const dependencyModule2 = require('./dependencyModule2');
// 处理部分
let count = 0;
const increase = () => ++count;
const reset = () => {
count = 0;
};
// 做一些跟引入依赖相关事宜...
// 暴露接口部分
exports.increase = increase;
exports.reset = reset;
// 或者
module.exports = {
increase,
reset
};
- 模块使用方式:
const { increase, reset } = require('./main.js');
increase();
reset();
可能被问到的问题:在模块化里面对其他模块进行模块化加载
实际执行处理:把 IIFE 和 CommonJS 整合到一起
// 复合使用 - IIFE 和 CommonJS
(function (thisValue, exports, require, module) {
const dependencyModule1 = require('./dependencyModule1');
const dependencyModule2 = require('./dependencyModule2');
// 业务逻辑...
}.call(thisValue, exports, require, module));
// 为什么一些开源项目为何要把全局、指针以及框架本身引用作为参数?
// 例如:
(function (window, $, undefined) {
const _show = function () {
$('#app').val('hi zhaowa');
};
window.webShow = _show;
})(window, jQuery);
// 考察的是阻断思路
// 可以分开回答:
// window - 1. 全局作用域转化成局部作用域,提升执行效率 2. 编译时优化
(function (c) {})(window); // window 会被优化成 c,里面所有的执行或者变化,随着执行完毕会被跟 c 一起被销毁,提高了压缩成本和回收成本
// jquery - 1. 从全局转化为局部后,可以独立定制复写和挂载 2. 防止全局串扰
// undefined - 防止重写,保证里面的 undefined 是正统的,不会被改写
- 优点:
CommonJS率先在服务端实现了,从框架层面解决依赖、全局变量污染的问题; - 缺点:主要针对了服务端的解决方案,对于异步拉取依赖的处理整合不是很完美;
- 新的问题 —— 异步依赖;
AMD 规范
- 通过异步加载 + 允许制定回调函数;
- 经典实现框架是:
require.js;
新增定义方式:define + require
// 通过 define 来定义一个模块,然后 require 进行加载
/*
define
params: 模块名,依赖模块,工厂方法
*/
define(id, [depends], callback);
require([module], callback);
模块定义方式:
define('amdModule', ['dependencyModule1', 'dependencyModule2'], (dependencyModule1, dependencyModule2) => {
// 业务逻辑
// 处理部分
let count = 0;
const increase = () => ++count;
const reset = () => {
count = 0;
};
return {
increase,
reset
};
});
引入模块方式:
require(['amdModule'], amdModule => {
amdModule.increase();
});
面试题 2:如果在 AMDmodule 中想兼容已有代码,怎么办?
define('amdModule', [], require => {
// 引入部分
const dependencyModule1 = require('./dependencyModule1');
const dependencyModule2 = require('./dependencyModule2');
// 处理部分
let count = 0;
const increase = () => ++count;
const reset = () => {
count = 0;
};
// 做一些跟引入依赖相关事宜...
return {
increase,
reset
};
});
面试题 3:AMD 中使用 revealing
define('amdModule', [], (require, exports, module) => {
// 引入部分
const dependencyModule1 = require('./dependencyModule1');
const dependencyModule2 = require('./dependencyModule2');
// 处理部分
let count = 0;
const increase = () => ++count;
const reset = () => {
count = 0;
};
// 做一些跟引入依赖相关事宜...
exports.increase = increase();
exports.reset = reset();
});
define('amdModule', [], require => {
const otherModule = require('amdModule');
otherModule.increase();
otherModule.reset();
});
面试题 4:手写兼容 CJS & AMD(如何判断 CJS 和 AMD),也就是后续 UMD 的出现;
/*
判断关键
step1:object 还是 function
step2:有没有 exports?
step3:是不是 define?
*/
define('amdModule', [], (require, exports, module) => {
// 引入部分
const dependencyModule1 = require('./dependencyModule1');
const dependencyModule2 = require('./dependencyModule2');
// 处理部分
let count = 0;
const increase = () => ++count;
const reset = () => {
count = 0;
};
// 做一些跟引入依赖相关事宜...
exports.increase = increase();
exports.reset = reset();
})(
// 目标:一次性区分 CJS or AMD
typeof module === 'object' && module.exports && typeof define !== 'function'
? // 是 CJS
factory => (module.exports = factory(require, exports, module))
: // 是 AMD
define
);
AMD 总结:
-
优点:适合在浏览器中加载异步模块,可以并行加载多个模块;
-
缺点:会有引入成本,不能按需加载;
CMD 规范
- 按需加载,就近依赖;
- 主要应用的框架
sea.js;
define('module', (require, exports, module) => {
let $ = require('jquery');
// jquery 相关逻辑
let dependencyModule1 = require('./dependencyModule1');
// dependencyModule1 相关逻辑
});
-
优点:按需加载,依赖就近;
-
缺点:依赖于打包,加载逻辑存在于每个模块中,扩大了模块体积,同时功能上依赖编译;
面试题 5:AMD & CMD 区别?
答:依赖就近,按需加载;
ES6 模块化 - 走向新时代
- 新增定义:
- 引入关键字 ——
import - 导出关键字 ——
export
- 引入关键字 ——
模块引入、导出和定义的地方:
// 引入区域
import dependencyModule1 from './dependencyModule1.js';
import dependencyModule2 from './dependencyModule2.js';
// 实现代码逻辑...
let count = 0;
// 单独导出
export const increase = () => ++count;
export const reset = () => {
count = 0;
};
// 导出区域
export default {
increase,
reset
};
模板引入的地方:
<script type="module" src="esModule.js"></script>
nodeJs 中:
import { increase, reset } from './esModule.mjs';
increase();
reset();
import esModule from './esModule.mjs';
esModule.increase();
esModule.reset();
面试题 6:1、性能方法 - 按需加载;2、动态模块
考察:export promise
ES11 原生解决方案:
import('./esModule.js').then(dynamicEsModule => {
dynamicEsModule.increase();
});
-
优点(重要性):通过一种最终统一各端的形态,整合了
js模块化的通用方案; -
缺点(局限性):本质上还是浏览器运行时的依赖分析;
解决模块化的新思路 - 前端工程化
遗留
根本问题:运行时进行依赖分析(前端的模块化处理方案依赖于运行时分析);
解决方案:线下执行;
追问:可否简述,实现一个编译时依赖处理的思路?
主模板文件:
<!doctype html>
<script src="main.js"></script>
<script>
// 给构建工具一个标识位
require.config(__FRAME_CONFIG__);
</script>
<script>
require(['a', 'e'], () => {
// 业务处理
})
</script>
</html>
依赖的文件:
define('a', () => {
let b = require('b');
let c = require('c');
});
工程化实现
步骤 1:扫描依赖关系表
{
"a": ["b", "c"],
"b": ["d"],
"e": []
}
步骤 2:根据依赖关系重新生成模板
<!doctype html>
<script src="main.js"></script>
<script>
// 构建工具生成数据
require.config({
"deps": {
a: ['b', 'c'],
b: ['d'],
e: []
}
})
</script>
<script>
require(['a', 'e'], () => {
// 业务处理
})
</script>
</html>
步骤 3:执行工具,采用模块化方案解决模块化处理依赖
define('a', ['b', 'c'], () => {
// 执行代码
export.run = () => {}
})
优点:
- 构建时生成配置,运行时执行;
- 最终转化成可执行的依赖处理;
- 可以拓展;
前端模块化的完全体
完全体:webpack 为核心的前端工程化 + mvvm 框架的组件化 + 设计模式
CommonJS模块输出的是一个值的拷贝,ES6模块输出的是值的引用。CommonJS模块是运行时加载,ES6模块是编译时输出接口。CommonJS是单个值导出,ES6 Module可以导出多个CommonJS是动态语法可以写在判断里,ES6 Module静态语法只能写在顶层,CommonJS的this是当前模块,ES6 Module的this是undefined。
原始图:www.processon.com/diagraming/…
补充知识点
在 WEB 开发的早期,为了团队协作和代码维护的方便,许多开发者会选择将 JavaScript 代码分开写在不同的文件里面,然后通过多个 script 标签来加载它们。
<script src="./a.js"></script>
<script src="./b.js"></script>
<script src="./c.js"></script>
虽然每个代码块处在不同的⽂件中,但最终所有 JS 变量还是会处在同⼀个 全局作用域 下,这时候就需要额外注意由于作用域 变量提升 所带来的问题。
<script>
// a.js
var num = 1;
setTimeout(() => console.log(num), 1000);
</script>
<script>
// b.js
var num = 2;
</script>
在这个例子中,我们分别加载了两个 script 标签,两段 JS 都声明了 num 变量。第⼀段脚本的本意本来是希望在 1s 后打印自己声明的 num 变量 1。但最终运行结果却打印了第二段脚本中的 num 变量的结果 2 。虽然两段代码写在不同的文件中,但是因为运行时声明变量都在全局下,最终产生了冲突。
同时,如果代码之间有依赖关系的话,需要额外关注脚本加载的顺序。如果文件依赖顺序有改动,就需要在 html 手动变更加载标签的顺序,非常麻烦。
要解决这样的问题,我们就需要将这些脚本文件【模块化】:
- 每个模块都要有自己的 【变量作用域】,两个模块之间的内部变量不会产生冲突。
- 不同模块之间保留相互 导入和导出 的方式方法,模块间能够相互通信。模块的执行与加载遵循⼀定的规范,能保证彼此之间的依赖关系。
主流的编程语言都有处理模块的关键词,在这些语言中,模块与模块之间的内部变量相互不受影响。同时,也可以通过关键字进行模块定义,引入和导出等等,例如 Java 里的 module 关键词,python 中的 import 。
但是 JavaScript 这门语言在 Ecmascript6 规范之前并没有语言层面的模块导入导出关键词及相关规范。为了解决这样的问题,不同的 JS 运行环境分别有着自己的解决方案。
CommonJS 规范初探(同步)
Node.js 就是⼀个基于 V8 引擎,事件驱动 I/O 的服务端 JS 运行环境,在 2009 年刚推出时,它就实现了⼀套名为 CommonJS 的模块化规范。
在 CommonJS 规范⾥,每个 JS 文件就是一个 模块(module) ,每个模块内部可以使用 require 函数和 module.exports 对象来对模块进行导入和导出。
// ⼀个比较简单的 CommonJS 模块
const moduleA = require('./moduleA'); // 获取相邻的相对路径 ./moduleA 文件导出的结果
module.exports = moduleA; // 导出当前模块内部 moduleA 的值
下面这三个模块稍微复杂⼀些,它们都是合法的 CommonJS 模块:
./moduleB.js
var m = new Date().getTime();
module.exports = m;
./moduleA.js
console.log('moduleA start');
let m = require('./moduleB'); // 同步的
console.log('moduleA-moduleB >>> ', m);
console.log('typeof m >>> ', typeof m); // number
setTimeout(() => {
console.log('moduleA-moduleB setTimeout >>> ', m);
console.log('moduleA end');
}, 2000);
./index.js
require('./moduleA');
let m = require('./moduleB');
console.log('index moduleB >>> ', m);
/*
输出结果:(输出的时间都是相同的)
moduleA start
moduleA-moduleB >>> 1620613530897
index moduleB >>> 1620613530897
此处间隔 2s 后
moduleA-moduleB setTimeout >>> 1620613530897
moduleA end
*/
- index.js 代表的模块通过执行
require函数,分别加载了相对路径为./moduleA和./moduleB的两个模块,同时输出moduleB模块的结果。 - moduleA.js 文件内也通过
require函数加载了moduleB.js模块,在2s后也输出了加载进来的结果。 - moduleB.js 文件内部相对来说就简单的多,仅仅定义了一个时间戳,然后直接通过
module.exports导出。
如果想不缓存 moduleB 中的数据,可以返回一个函数,每次导入的时候执行此函数:
./moduleB.js
module.exports = function () {
return new Date().getTime();
};
./moduleA.js
console.log('moduleA start');
let m = require('./moduleB'); // 同步的
console.log('moduleA-moduleB >>> ', m());
console.log('moduleA middle');
setTimeout(() => {
console.log('moduleA-moduleB setTimeout >>> ', m());
console.log('moduleA end');
}, 2000);
// ========== ./moduleB.js ==========
module.exports = function () {
return new Date().getTime();
};
./index.js
require('./moduleA');
let m = require('./moduleB');
console.log('index moduleB >>> ', m());
/*
输出结果:(输出的时间不同)
moduleA start
moduleA-moduleB >>> 1625424765901
moduleA middle
index moduleB >>> 1625424765902
此处间隔 1s 后
moduleA-moduleB setTimeout >>> 1625424767903
moduleA end
*/
它们之间的 物理关系 和 逻辑关系 如下图:
在装有 Node.js 的机器上,我们可以直接执行 node index.js 查看输出的结果。我们可以发现,无论执行多少次,最终输出的两行结果均相同。
虽然这个例子非常简单,但是我们却可以发现 CommonJS 完美的解决了最开始我们提出的痛点:
- 模块之间内部即使有相同的变量名,它们运行时没有冲突。这说明它有处理模块变量作用域的能力。上面这个例子中三个模块中均有
m变量,但是并没有冲突。 moduleB通过module.exports导出了⼀个内部变量,而它在moduleA和index模块中能被加载。这说明它有导入和导出模块的方式,同时能够处理基本的依赖关系。- 我们在不同的模块加载了
moduleB两次,我们得到了相同的结果。这说明它保证了模块单例。
但是,这样的 CommonJS 模块只能在 Node.js 环境中才能运行,直接在其他环境中运行这样的代码模块就会报错。这是因为只有 node 才会在解析 JS 的过程中提供⼀个 require 方法,这样当解析器执行代码时,发现有模块调用了 require 函数,就会通过参数找到对应模块的物理路径,通过系统调用从硬盘读取文件内容,解析这段内容最终拿到导出结果并返回。而其他运行环境并不⼀定会在解析时提供这么⼀个 require 方法,也就不能直接运行这样的模块了。
从它的执行过程也能看出来 CommonJS 是⼀个 同步加载模块 的模块化规范,每当⼀个模块 require ⼀个子模块时,都会停止当前模块的解析直到子模块读取解析并加载。
基于 CommonJS 中的导入和导出
1、CommonJS 中的导出
console.log('cal.js');
module.exports = {
name: 'cal',
add: function (a, b) {
return a + b;
}
};
// 为了书写方便,可以像下面简写
exports.name = 'cal';
exports.add = function (a, b) {
return a + b;
};
上面两段代码,在实现效果上没有任何的不同。
**其内在机制是将 exports 指向了 module.exports,而 module.exports 在初始化时是一个空对象。**我们可以简单的理解为,CommonJS 在每个模块的首部默认添加可以下代码:
var module = {
exports: {}
};
var exports = module.exports;
因此,为 exports.add 赋值,相当于在 module.exports 对象上添加了一个属性。
**【注意】:**在使用 exports 时要注意一个问题,即不要直接给 exports 赋值,否则导致其失效,如:
exports = {
add: function (a, b) {
return a + b;
}
};
上面代码中,由于对 exports 进行了赋值操作,使其指向了一个新的对象,module.exports 却仍然是原来的空对象,因此 add 属性并不会被导出,例如下面这个代码:
var module = {
exports: {}
};
var exports = module.exports;
console.log(exports === module.exports); // true
exports = {
add: function (a, b) {
return a + b;
}
};
console.log(exports === module.exports); // false,注意这个地方是 false
**【注意】:**另一个需要注意的问题,module.exports 和 exports 不要混用。
exports.add = function (a, b) {
return a + b;
};
module.exports = {
name: 'cal'
};
上面代码中的 module.exports 指向了另一个对象,导致之前的 add 属性被丢失,所以最后导出只有 name 属性。
在 module.exports 和 exports 后面的代码依旧会执行,但不建议这样写。
2、CommonJS 中的导入
使用 require 导入模块时,有下面两种情况:
require的模块是第一次被加载,这时会首先执行该模块,然后导出内容;require的模块曾被加载过,这时该模块的代码不会再次执行,而是直接导出上次执行后的结果;
例如:
./cal.js
console.log('cal.js');
module.exports = {
name: 'cal',
add: function (a, b) {
return a + b;
}
};
// 为了书写方便,可以像下面简写
// exports.name = "cal";
// exports.add = function (a, b) {
// return a + b;
// };
./moduleA.js
// 第一次 require
const add = require('./cal').add;
console.log('add function moduleA >>> ', add(1, 2));
./moduleB.js
// 第二次 require
const name = require('./cal').name;
console.log('name >>> ', name);
const add = require('./cal').add;
console.log('add function moduleB >>> ', add(1, 2));
./index.js
require('./moduleA');
require('./moduleB');
此时的结果如下:cal.js 只会输出一次;
从结果上可以看出,尽管我们 require 两次,但其内部代码只会执行一遍。
./cal.js
console.log('cal.js');
let obj = {
name: 'cal',
num: 0,
add: function (a, b) {
console.log(obj.num);
obj.num += a + b;
return obj.num;
}
};
module.exports = obj;
./moduleA.js
// 第一次 require
const add = require('./cal').add;
console.log('add function moduleA >>> ', add(1, 2));
./moduleB.js
// 第二次 require
const name = require('./cal').name;
console.log('name >>> ', name);
const add = require('./cal').add;
console.log('add function moduleB >>> ', add(1, 2));
./index.js
require('./moduleA');
require('./moduleB');
此时的结果如下:cal.js 只会输出一次;
其原理大致如下:
在模块中,有一个 module 对象用来存储信息,这个对象中有一个属性 loaded 用于记录该模块是否被加载过,它的默认值是 false,当模块第一次被加载和执行过后会变成 true。
如果后面再次加载时,检测到 loaded 为 true,则不会次再执行模块代码。
注意点:
- 有时我们加载一个模块,不需要获取其导出的内容,只是想要通过执行它而产生某种作用,比如把它的接口挂载到全局对象上,此时直接使用
require即可;
require('./task.js');
- 另外,
require可以接收表达式,可以动态加载指定的模块;
const moduleNames = ['foo.js', 'bar.js'];
moduleNames.forEach(name => {
require('./' + name);
});
适合 WEB 开发的 AMD 模块化规范(异步)
另⼀个为 WEB 开发者所熟知的 JS 运行环境就是浏览器了。浏览器并没有提供像 Node.js 里⼀样的 require 方法。不过,受到 CommonJS 模块化规范的启发,WEB 端还是逐渐发展起来了 AMD,SystemJS 规范等适合浏览器端运行的 JS 模块化开发规范。
AMD 全称 Asynchronous module definition,意为 异步的模块定义 ,不同于 CommonJS 规范的同步加载,AMD 正如其名所有模块默认都是异步加载,这也是早期为了满足 web 开发的需要,因为如果在 web 端也使用同步加载,那么页面在解析脚本文件的过程中可能使页面暂停响应。
而 AMD 模块的定义与 CommonJS 稍有不同,上面这个例子的三个模块分别改成 AMD 规范就类似这样:
./moduleB.js
define(function (require, factory) {
return Date.now();
});
./moduleA.js
define(['moduleB'], function (moduleB) {
setTimeout(() => {
console.log('moduleA in moduleB >>>> ', moduleB);
}, 1000);
for (let i = 0; i < 1000000000; i++) {}
return 'moduleA ==> ' + new Date().getTime();
});
./index.js
require(['moduleA', 'moduleB'], function (moduleA, moduleB) {
console.log('moduleA ===== ', moduleA);
console.log('moduleB ===== ', moduleB);
});
/*
输出结果:
moduleA ===== moduleA ==> 1644684881863
moduleB ===== 1644684896320
moduleA in moduleB >>>> 1644684896320
*/
我们可以对比看到,AMD 规范也支持文件级别的模块,模块 ID 默认为文件名,在这个模块文件中,我们需要使用 define 函数来定义⼀个模块,在回调函数中接受定义组件内容。这个回调函数接受⼀个 require 方法,能够在组件内部加载其他模块,这里我们分别传入模块 ID,就能加载对应文件内的 AMD 模块。不同于 CommonJS 的是,这个回调函数的返回值即是模块导出结果。
差异比较大的地方在于我们的入口模块,我们定义好了 moduleA 和 moduleB 之后,入口处需要加载进来它们,于是乎就需要使用 AMD 提供的 require 函数,第⼀个参数写明入口模块的依赖列表,第二个参数作为回调参数依次会传入前面依赖的导出值,所以这里我们在 index.js 中只需要在回调函数中打印 moduleB 传入的值即可。
Node.js 里我们直接通过 node ./index.js 来查看模块输出结果,在 WEB 端我们就需要使用⼀个 html 文件,同时在里面加载这个入口模块。这里我们再加入⼀个 index.html 作为浏览器中的启动入口。
如果想要使用 AMD 规范,我们还需要添加⼀个符合 AMD 规范的加载器脚本在页面中,符合 AMD 规范实现的库很多,比较有名的就是 require.js。
<!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>
<!-- 此处必须加载 require.js 之类的 AMD 模块化库之后才可以继续加载模块 -->
<script src="./require.js"></script>
<!-- 只需要加载入口模块即可 -->
<script src="./index.js"></script>
</body>
</html>
使用 AMD 规范改造项目之后的关系如下图,在物理关系里多了两个文件,但是模块间的逻辑关系仍与之前相同。
启动静态服务之后我们打开浏览器中的控制台,无论我们刷新多少次页面,同 Node.js 的例子⼀样,输出的结果均相同。同时我们还能看到,虽然我们只加载了 index.js 也就是入口模块,但当使用到 moduleA 和 moduleB 的时候,浏览器就会发请求去获取对应模块的内容。
从结果上来看,AMD 与 CommonJS ⼀样,都完美的解决了上面说的 变量作用域 和 依赖关系 之类的问题。但是 AMD 这种默认异步,在回调函数中定义模块内容,相对来说使用起来就会麻烦⼀些。
同样的,AMD 的模块也不能直接运行在 Node 端,因为内部的 define 函数,require 函数都必须配合在浏览器中加载 require.js 这类 AMD 库才能使用。
能同时被 CommonJS 规范和 AMD 规范加载的 UMD 模块
有时候我们写的模块需要同时运行在浏览器端和 Node.js 里面,这也就需要我们分别写⼀份 AMD 模块和 CommonJS 模块来运行在各自环境,这样如果每次模块内容有改动还得去两个地方分别进行更改,就比较麻烦。
// ⼀个返回随机数的模块,浏览器使用的 AMD 模块
// ========== math.js ==========
define(function () {
return function () {
return Math.random();
};
});
// ⼀个返回随机数的模块,Node.js 使用的 CommonJS 模块
module.exports = function () {
return Math.random();
};
基于这样的问题,UMD(Universal Module Definition) 作为⼀种 同构(isomorphic) 的模块化解决方案出现,它能够让我们只需要在⼀个地方定义模块内容,并同时兼容 AMD 和 CommonJS 语法。
写⼀个 UMD 模块也非常简单,我们只需要判断⼀下这些模块化规范的特征值,判断出当前究竟在哪种模块化规范的环境下,然后把模块内容用检测出的模块化规范的语法导出即可。
(function (self, factory) {
if (typeof module === 'object' && typeof module.exports === 'object') {
// 当前环境是 CommonJS 规范环境
module.exports = factory();
} else if (typeof define === 'function' && define.amd) {
// 当前环境是 AMD 规范环境
define(factory);
} else {
// 什么环境都不是,直接挂在全局对象上
self.umdModule = factory();
}
})(this, function () {
return function () {
return Math.random();
};
});
上面就是⼀种定义 UMD 模块的方式,我们可以看到首先他会检测当前加载模块的规范究竟是什么。如果 module.exports 在当前环境中为对象,那么肯定为 CommonJS,我们就能用 module.exports 导出模块内容。如果当前环境中有 define 函数并且 define.amd 为 true ,那我们就可以使用 AMD 的 define 函数来定义⼀个模块。最后,即使没检测出来当前环境的模块化规范,我们也可以直接把模块内容挂载在全局对象上,这样也能加载到模块导出的结果。
ESModule 规范
前面我们说到的 CommonJS 规范和 AMD 规范有这么几个特点:
- 语言上层的运行环境中实现的模块化规范,模块化规范由环境自己定义。
- 相互之间不能共用模块。例如不能在
Node.js运行AMD模块,不能直接在浏览器运行CommonJS模块。
使用 babel 编译:
- 安装依赖:
yarn add webpack webpack-cli
yarn add babel-loader @babel/core @babel/preset-env
webpack.config.js
const path = require('path');
module.exports = {
mode: 'none',
entry: './index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'index_bundle.js',
publicPath: 'dist/'
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
}
]
}
};
- 配置
package.json
{
"name": "index",
"version": "1.0.0",
"description": "test",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack"
},
"author": "",
"license": "ISC",
"dependencies": {
"@babel/core": "^7.14.0",
"@babel/preset-env": "^7.14.1",
"babel-loader": "^8.2.2",
"webpack": "^5.37.0",
"webpack-cli": "^4.7.0"
}
}
- 执行编译:
yarn build
在 EcmaScript 2015 也就是我们常说的 ES6 之后,JS 有了语言层面的模块化导入导出关键词与语法以及与之匹配的 ESModule 规范。使用 ESModule 规范,我们可以通过 import 和 export 两个关键词来对模块进行导入与导出。
还是之前的例子,使用 ESModule 规范和新的关键词就需要这样定义:
./moduleB.js
var num = 0;
var m = new Date().getTime();
const update = () => {
num += 5;
};
export { num, m, update };
./moduleA.js
import { num, m, update } from './moduleB';
import * as moduleB from './moduleB';
console.log('moduleB ======== ', moduleB);
/*
moduleB 导出的是一个对象
moduleB ======== Object [Module] { num: [Getter], m: [Getter], update: [Getter] }
*/
console.log('typeof moduleB ========= ', typeof moduleB); // object
setTimeout(() => {
console.log('moduleA >>> ', m);
}, 1000);
console.log('moduleA in num ==== ', num);
update();
./index.js
import './moduleA';
import { m, num } from './moduleB';
console.log('index >>> ', m);
console.log('index in num ==== >>> ', num);
/*
输出结果:
moduleA in num ==== 0
index >>> 1645292589260
index in num ==== >>> 5
moduleA >>> 1645292589260
*/
ESModule 与 CommonJS 和 AMD 最大的区别在于,ESModule 是由 JS 解释器实现,而后两者是在宿主环境中运行时实现。ESModule 导入实际上是在语法层面新增了⼀个语句,而 AMD 和 CommonJS 加载模块实际上是调用了 require 函数。
// 这是⼀个新的语法,我们没办法兼容,如果浏览器无法解析就会报语法错误
import moduleA from './moduleA';
// 我们只需要新增加一个 require 函数,就可以首先保证 AMD 或 CommonJS 模块不报语法错误
function require() {}
const moduleA = require('./moduleA');
ESModule 规范支持通过这些方式导入导出代码,具体使用哪种情况得根据如何导出来决定:
import { var1, var2 } from "./moduleA";
import * as vars from "./moduleB";
import m from "./moduleC";
export default {
var1: 1,
var2: 2
};
export const var1 = 1;
const obj = {
var1,
var2
};
export default obj;
这里又⼀个地方需要额外指出,import { var1 } from "./moduleA" 这里的括号并不代表获取结果是个对象,虽然与 ES6 之后的对象解构语法非常相似。
// 【错误方式】这些用法都是错误的,这里不能使用对象默认值,对象 key 为变量这些语法
import { var1 = 1 } from "./moduleA"
import { [test]: a } from "./moduleA";
// 【正确方式】这个才是 ESModule 导入语句种正确的重命名方式
import { var1 as customVar1 } from "./moduleA";
// 【正确方式】这些用法都是合理的,因为 CommonJS 导出的就是个对象,我们可以用操作对象的方式来操作导出结果
const { var1 = 1 } = require("./moduleA");
const { [test]: var1 = a } = require("./moduleA");
// 【错误方式】这种用法是错误的,因为对象不能这么使用
const { var1 as customVar1 } = require("./moduleA");
用⼀张图来表示各种模块规范语法和它们所处环境之间的关系:
每个 JS 的运行环境都有⼀个解析器,否则这个环境也不会认识 JS 语法。它的作用就是用 ECMAScript 的规范去解释 JS 语法,也就是处理和执行语言本身的内容,例如按照逻辑正确执行 var a = "123" ,function func() { console.log("hahaha") } 之类的内容。
在解析器的上层,每个运行环境都会在解释器的基础上封装⼀些环境相关的 API。例如 Node.js 中的 global 对象、process 对象,浏览器中的 window 对象,document 对象等等。这些运行环境的 API 受到各自规范的影响,例如浏览器端的 W3C 规范,它们规定了 window 对象和 document 对象上的 API 内容,以使得我们能让 document.getElementById 这样的 API 在所有浏览器上运行正常。
事实上,类似于 setTimeout 和 console 这样的 API,大部分也不是 JS Core 层面的,只不过是所有运行环境实现了相似的结果。
setTimeout 在 ES7 规范之后才进入 JS Core 层面,在这之前都是浏览器和 Node.js 等环境进行实现。
console 类似 promise,有自己的规范,但实际上也是环境自己进行实现的,这也就是为什么 Node.js 的 console.log 是异步的而浏览器是同步的⼀个原因。同时,早期的 Node.js 版本是可以使用 sys.puts 来代替 console.log 来输出至 stdout 的。
**ESModule 就属于 JS Core 层面的规范,而 AMD,CommonJS 是运行环境的规范。**所以,想要使运行环境支持 ESModule 其实是比较简单的,只需要升级自己环境中的 JS Core 解释引擎到足够的版本,引擎层面就能认识这种语法,从而不认为这是个 语法错误(syntax error) ,运行环境中只需要做⼀些兼容工作即可。
Node.js 在 V12 版本之后才可以使用 ESModule 规范的模块,在 V12 没进入 LTS 之前,我们需要加上 --experimental-modules 的 flag 才能使用这样的特性,也就是通过 node --experimental-modules index.js 来执行。浏览器端 Chrome 61 之后的版本可以开启支持 ESModule 的选项,只需要通过 import export 这样的标签加载即可。
这也就是说,如果想在 Node.js 环境中使用 ESModule,就需要升级 Node.js 到高版本,这相对来说比较容易,毕竟服务端 Node.js 版本控制在开发人员自己手中。但浏览器端具有分布式的特点,是否能使用这种高版本特性取决于用户访问时的版本,而且这种解释器语法层面的内容无法像 AMD 那样在运行时进行兼容,所以想要直接使用就会比较麻烦。
后模块化时代
通过前面的分析我们可以看出来,使用 ESModule 的模块明显更符合 JS 开发的历史进程,因为任何⼀个支持 JS 的环境,随着对应解释器的升级,最终⼀定会支持 ESModule 的标准。但是,WEB 端受制于用户使用的浏览器版本,我们并不能随心所欲的随时使用 JS 的最新特性。为了能让我们的新代码也运行在用户的老浏览器中,社区涌现出了越来越多的工具,它们能静态将高版本规范的代码编译为低版本规范的代码,最为大家所熟知的就是 babel。
它把 JS Core 中高版本规范的语法,也能按照相同语义在静态阶段转化为低版本规范的语法,这样即使是早期的浏览器,它们内置的 JS 解释器也能看懂。
然后,不幸的是,对于模块化相关的 import 和 export 关键字,babel 最终会将它编译为包含 require 和 exports 的 CommonJS 规范。点击链接在线查看编译结果
import './anotherModule';
import a from './moduleA';
export default a;
babel 编译之后的代码:
'use strict';
Object.defineProperty(exports, '__esModule', {
value: true
});
exports.default = void 0;
require('./anotherModule');
var _moduleA = _interopRequireDefault(require('./moduleA'));
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
var _default = _moduleA.default;
exports.default = _default;
**这就造成了另外一个问题,这样带有模块化关键词的模块,编译之后还是没办法直接运行在浏览器中,因为浏览器端并不能运行 CommonJS 的模块。**为了能在 WEB 端直接使用 CommonJS 规范的模块,除了编译之外,我们还需要一个步骤叫做 打包(bundle)。
所以打包工具比如 webpack/rollup,编译工具 babel 它们之间的区别和作用就很清楚了:
- 打包工具(
webpack)主要处理的是JS不同版本间模块化的区别; - 编译工具(
babel)主要处理的是JS版本间语义的问题;
如果使用了 ESModule:必须使用 webpack 和 babel;
如果是 AMD 或 CommonJS:只用 webpack;