模块化
模块化是指自上而下把一个复杂问题(功能)划分成若干模块的过程,在编程中就是指通过某种规则对程序(代码)进行分割、组织、打包,每个模块完成一个特定的子功能,再把所有的模块按照某种规则进行组装,合并成一个整体,最终完成整个系统的所有功能。
从基于 Node.js 的服务端 CommonJS 模块化,到前端基于浏览器的 AMD、CMD 模块化,再到 ECMAScript 6 开始原生内置的模块化, JavaScript 的模块化方案和系统日趋成熟。
模块化历程
-
AMD —— 最古老的模块系统之一,最初由 require.js 库实现。
-
CommonJS —— 为 Node.js 服务器创建的模块系统。
-
UMD —— 另外一个模块系统,建议作为通用的模块系统,它与 AMD 和 CommonJS 都兼容。
-
ESM —— 语言级的模块系统在 2015 年的时候出现在了标准(ES6)中,此后逐渐发展,现在已经得到了所有主流浏览器和 Node.js 的支持。
无论是那种模块化规范,都着重于保证模块独立性 的同时又能很好的 与其它模块进行交互:
- 如何定义一个模块与模块内部私有作用域
- 通过何种方式导出模块内部数据
- 通过何种方式导入其它外部模块数据
基于服务端、桌面端的模块化
CommonJS
在早期,对于运行在浏览器端的 JavaScript 代码,模块化的需求并不那么的强烈,反而是偏向 服务端、桌面端 的应用对模块化有迫切的需求(相对来说,服务端、桌面端程序的代码和需求要复杂一些)。CommonJS 规范就是一套偏向服务端的模块化规范,它为非浏览器端的模块化实现制定了一些的方案和标准,NodeJS 就采用了这个规范。
因为
NodeJS就是CommonJS的实现,所以使用node就行,不用引入其他包。
- 独立模块作用域
一个文件就是模块,拥有独立的作用域。
- 导出模块内部数据
通过 module.exports 或 exports 对象导出模块内部数据:
// ./a.js
let a = 1;
let b = 2;
module.exports = {
x: a,
y: b
}
// or
exports.x = a;
exports.y = b;
exports 和 module.export 的区别
exports:对于本身来讲是一个变量(对象),它不是module的引用,它是{}的引用,它指向module.exports的{}模块。 只能使用.语法 向外暴露变量。
module.exports:module是一个变量,指向一块内存。exports是module中的一个属性,存储在内存中,exports属性指向{}模块。 既可以使用.语法,也可以使用=直接赋值。
require引用模块后,返回给调用者的是module.exports而不是exports。
- 导入外部模块数据
require
通过 require 函数导入外部模块数据,必须加 ./ 路径,不加的话会去 node_modules 文件找。
导入自定义的模块时,参数包含路径,可省略后缀:
// ./b.js
let a = require('./a'); // 导入自定义模块,可省略后缀
const Koa = require('koa'); // 不加 `./` ,导入 node_modules 内的模块
const fs = require('fs'); // 引用核心模块,不需要带路径
a.x;
a.y;
基于浏览器的模块化
AMD
CommonJS 用同步的方式加载模块(基于文件系统)。在服务端,模块文件都存放在本地磁盘,读取非常快,所以这样做不会有问题。
但是在浏览器端,限于网络原因,更合理的方案是使用异步加载,所以另外定义了适用于浏览器端的规范:AMD(Asynchronous Module Definition),github。
AMD 是 requireJS 在推广过程中对模块定义的规范化产出,它是一个概念,RequireJS 是对这个概念的实现。
requireJS
requireJS ,依赖前置、异步定义。
- 引用
requireJS
<!-- ./index.html -->
<!-- cdn -->
<script src="https://cdn.bootcss.com/require.js/2.3.6/require.min.js" data-main="./js/a.js"></script>
<!-- or -->
<!-- 本地引入 -->
<script src="./libs/require.min.js" data-main="./js/a.js"></script>
- 独立模块作用域
define
通过一个 define 方法来定义一个模块,在该方法内部模拟模块独立作用域:
// ./js/b.js
define(function() {
// 模块内部代码
})
- 导出模块内部数据
return
通过 return 导出模块内部数据:
// ./js/b.js
define(function() {
// 模块内部代码
let a = 1;
let b = 2;
// 通过 return 导出
return {
x: a,
y: b
}
})
- 导入外部模块数据
如果我们定义的模块本身依赖其他模块,那就需要将它们放在 [] 中作为 define() 的第一参数。通过前置依赖列表导入外部模块数据。
// ./js/a.js
// 定义一个模块,并导入 ./b 模块
define(['./b'], function(b) {
console.log('define', b);
})
// 引用 b 模块
require(['./b'], function(b) {
console.log('require', b);
});
requireJS 的 config 配置
首先我们需要引入 require.js 文件和一个入口文件 main.js。main.js 中配置 require.config() 并规定项目中用到的基础模块。
/** 网页中引入 requireJS 及 main.js **/
<script src="./libs/require.min.js" data-main="./js/main.js"></script>
/** main.js 入口文件/主模块 **/
// 首先用 config() 指定各模块路径和引用名
require.config({
baseUrl: "js/lib",
paths: {
"jquery": "jquery.min", // 实际路径为 js/lib/jquery.min.js
}
});
引用模块的时候,我们将模块名放在 [] 中作为 reqiure() 的第一参数
// ./js/math.js
// 定义 math.js 模块
define(function () {
var basicNum = 0;
var add = function (x, y) {
return x + y;
};
return {
add: add,
basicNum :basicNum
};
});
// 引用模块,将模块放在[]内
require(['jquery', 'math'],function($, math){
var sum = math.add(10,20);
$("#sum").html(sum);
});
requireJS 的 CommonJS 风格
require.js 也支持 CommonJS 风格的语法:
- 导出模块内部数据
// ./js/b.js
define(function(require, exports, module) {
let a = 1;
let b = 2;
// 通过 module.exports 对象导出
// exports 与 module.exports 是一样的
module.exports = {
x: a,
y: b
}
})
- 导入外部模块数据
// ./js/a.js
define(function(require, exports, module) {
// require 与 前置列表 相似
let b = require('./b')
console.log(b);
})
CMD
AMD的实现者require.js在申明依赖的模块时,会在第一时间加载并执行模块内的代码:
define(["a", "b", "c", "d", "e", "f"], function(a, b, c, d, e, f) {
// 等于在最前面声明并初始化了要用到的所有模块
if (false) {
// 即便没用到某个模块 b,但 b 还是提前执行了。**这就CMD要优化的地方**
b.foo()
}
});
CMD 是另一种 js 模块化方案,它与 AMD 很类似,不同点在于:AMD 推崇依赖前置、提前执行,CMD 推崇依赖就近、延迟执行。
通过 define() 定义,没有依赖前置,通过 require 导入。
define(function(require, exports, module) {
var a = require('./a'); // 在需要时申明
a.doSomething();
if (false) {
var b = require('./b');
b.doSomething();
}
});
CMD 是 SeaJS 在推广过程中对模块定义的规范化产出,是一个同步模块定义。SeaJS 是 CMD 概念的一个实现, SeaJS 是淘宝团队提供的一个模块开发的 js 框架。
SeaJS
/** sea.js **/
// 定义模块 math.js
define(function(require, exports, module) {
var $ = require('jquery.js');
var add = function(a,b){
return a+b;
}
exports.add = add;
});
// 加载模块
seajs.use(['math.js'], function(math){
var sum = math.add(1+2);
});
AMD 和 CMD 的优缺点
AMD 和 CMD 的优缺点,一个的优点就是另一个的缺点。
-
AMD- 优点:加载快速,尤其遇到多个大文件,因为并行解析,所以同一时间可以解析多个文件。
- 缺点:并行加载,异步处理,加载顺序不一定,可能会造成一些困扰,甚至为程序埋下大坑。
-
CMD- 优点:只有在使用的时候才会解析执行js文件,执行顺序在代码中是有体现的,是可控的。
- 缺点:使用时执行,没法利用空闲时间,执行等待时间会叠加。
UMD
严格来说,UMD 并不属于一套模块规范,它主要用来处理 CommonJS、AMD、CMD 的差异兼容,使模块代码能在前面不同的模块环境下都能正常运行。
随着 Node.js 的流行,前端和后端都可以基于 JavaScript 来进行开发,这个时候或多或少的会出现前后端使用相同代码的可能,特别是一些不依赖宿主环境(浏览器、服务器)的偏低层的代码。我们能实现一套代码多端适用(同构),其中在不同的模块化标准下使用也是需要解决的问题,UMD 就是一种解决方式。
// root 解决不同环境下全局变量不同的问题,如浏览器是 window,node.js 是 global
(function (root, factory) {
if (typeof module === "object" && typeof module.exports === "object") {
// Node, CommonJS-like
module.exports = factory();
}
else if (typeof define === "function" && define.amd) {
// AMD 模块环境下
define(factory);
} else {
// 不使用任何模块系统,直接挂载到全局
root.kkb = factory();
}
}(this, function () {
let a = 1;
let b = 2;
// 模块导出数据
return {
x: a,
y: b
}
}));
JavaScript 原生模块化
ESM
从 ECMAScript 6 开始,JavaScript 原生引入了模块概念,而且现在主流浏览器也都有了很好的支持,同时在 Node.js 也有了支持,所以未来基于 JavaScript 的程序无论是在前端浏览器还是在后端 Node.js 中,都会逐渐的被统一。
模块可以相互加载,并可以使用特殊的指令 export 和 import 来交换功能,从另一个模块调用一个模块的函数:
export关键字标记了可以从当前模块外部访问的变量和函数。import关键字允许从其他模块导入功能。
模块核心功能
-
独立模块作用域
一个文件就是一个模块,拥有独立的作用域,且导出的模块都自动处于
严格模式下,即:'use strict'。script标签需要声明type="module",以使import/export可以工作。<script type="module"> // 变量仅在这个 module script 内可见 let user = "John"; </script> <script type="module"> alert(user); // Error: user is not defined </script>模块只通过 HTTP(s) 工作,在本地文件则不行
如果你尝试通过
file://协议在本地打开一个网页,你会发现import/export指令不起作用。你可以使用本地 Web 服务器,例如 static-server,或者使用编辑器的“实时服务器”功能,例如 VS Code 的 Live Server Extension 来测试模块。 -
模块代码仅在第一次导入时被解析,然后将导出的内容 共享 给所有的导入
如果执行一个模块中的代码会带来副作用(side-effect),例如显示一条消息,那么多次导入它只会触发一次显示 —— 即第一次:
// alert.js alert("Module is evaluated!");// 在不同的文件中导入相同的模块 // 1.js import `./alert.js`; // Module is evaluated! // 2.js import `./alert.js`; // (什么都不显示)我们假设一个模块导出了一个对象。如果这个模块被导入到多个文件中,模块仅在第一次被导入时被解析,并创建
admin对象,然后将其传入到所有的导入。所有的导入都只获得了一个唯一的admin对象,如果某个地方修改了admin对象,其他的模块也能看到这个修改// admin.js export let admin = { name: "John" };// 1.js import {admin} from './admin.js'; admin.name = "Pete"; // 2.js import {admin} from './admin.js'; alert(admin.name); // Pete // 1.js 和 2.js 导入的是同一个对象 // 在 1.js 中对对象做的更改,在 2.js 中也是可见的 -
import.meta对象包含关于当前模块的信息。它的内容取决于其所在的环境。在浏览器环境中,它包含当前脚本的 URL,或者如果它是在 HTML 中的话,则包含当前页面的 URL。
<script type="module"> alert(import.meta.url); // 脚本的 URL(对于内嵌脚本来说,则是当前 HTML 页面的 URL) </script> -
在一个模块中,顶级
this是undefined。而非模块脚本的顶级this是全局对象:<script> alert(this); // window </script> <script type="module"> alert(this); // undefined </script>
浏览器特定功能
与常规脚本相比,拥有 type="module" 标识的脚本有一些特定于浏览器的差异。
-
模块脚本是延迟的,与
defer特性的影响相同。也就是说:
- 下载外部模块脚本
<script type="module" src="...">不会阻塞 HTML 的处理,它们会与其他资源并行加载。 - 模块脚本会等到 HTML 文档完全准备就绪(即使它们很小并且比 HTML 加载速度更快),然后才会运行。
- 保持脚本的相对顺序:在文档中排在前面的脚本先执行。
<script type="module"> alert(typeof button); // object:脚本可以“看见”下面的 button // 因为模块是被延迟的(deferred),所以模块脚本会在整个页面加载完成后才运行 </script> 相较于下面这个常规脚本: <script> alert(typeof button); // button 为 undefined,脚本看不到下面的元素 // 常规脚本会立即运行,常规脚本的运行是在在处理页面的其余部分之前进行的 </script> <button id="button">Button</button>请注意:上面的第二个脚本实际上要先于前一个脚本运行!所以我们会先看到
undefined,然后才是object。 - 下载外部模块脚本
-
async适用于内联脚本对于非模块脚本,
async特性(attribute)仅适用于外部脚本。异步脚本会在准备好后立即运行,独立于其他脚本或 HTML 文档。对于模块脚本,它也适用于内联脚本。
例如,下面的内联脚本具有
async特性,因此它不会等待任何东西。即使 HTML 文档还未完成,或者其他脚本仍在等待处理中。<!-- 所有依赖都获取完成(analytics.js)然后脚本开始运行 --> <!-- 不会等待 HTML 文档或者其他 <script> 标签 --> <script async type="module"> import {counter} from './analytics.js'; counter.count(); </script> -
外部脚本
-
具有相同
src的外部脚本仅运行一次,重复的外部脚本会被忽略:<!-- 脚本 my.js 被加载完成(fetched)并只被运行一次 --> <script type="module" src="my.js"></script> <script type="module" src="my.js"></script> -
从另一个源(例如另一个网站)获取的外部脚本需要 CORS header。换句话说,如果一个模块脚本是从另一个源获取的,则远程服务器必须提供表示允许获取的 header
Access-Control-Allow-Origin。<!-- another-site.com 必须提供 Access-Control-Allow-Origin --> <!-- 否则,脚本将无法执行 --> <script type="module" src="http://another-site.com/their.js"></script>默认这样做可以确保更好的安全性。
-
-
不允许裸模块(“bare” module)
在浏览器中,
import必须给出相对或绝对的 URL 路径。没有任何路径的模块被称为“裸(bare)”模块。在import中不允许这种模块。import {sayHi} from 'sayHi'; // Error,“裸”模块 // 模块必须有一个路径,例如 './sayHi.js' 或者其他任何路径某些环境,像 Node.js 或者打包工具(bundle tool)允许没有任何路径的裸模块,因为它们有自己的查找模块的方法和钩子(hook)来对它们进行微调。但是浏览器尚不支持裸模块。
-
兼容性,“nomodule”
旧时的浏览器不理解
type="module"。未知类型的脚本会被忽略。对此,我们可以使用nomodule特性来提供一个后备:<script type="module"> alert("Runs in modern browsers"); </script> <script nomodule> alert("Modern browsers know both type=module and nomodule, so skip this") alert("Old browsers ignore script with unknown type=module, but execute this."); </script>
导入导出
- 导出模块内部数据
使用 export 语句导出模块内部数据。
export可以导出多个;export default只能导出一个。
export导出的,导入时命名要保持一致;export default导出的,导入时命名可以自定义。
// 1. 直接导出
export let name1 = …, name2 = …, …, nameN;
export function FunctionName(){...}
export class ClassName {...}
// 2. 导出列表
export { name1, name2, …, nameN };
// 3. 重命名导出
// 关键字 "as" 重命名。as 前是原名,as 后是新名。注意不是解构赋值哦。
export { variable1 as name1, variable2 as name2, …, variableN as nameN };
// 4. 默认导出,只能导出一个
export default expression;
export default function (…) { … }
export default function name1(…) { … }
// 5. 模块重新导出
export * from …; // 通配符 "*" 导出,只导出了命名的导出,但是忽略了默认的导出
export { default } from …; // 重新导出默认的导出
export { name1, name2, …, nameN } from …;
export { import1 as name1, import2 as name2, …, nameN } from …;
-
导入外部模块数据
- 静态导入
import
使用
import语句导入模块,这种方式称为:静态导入。静态导入方式不支持延迟加载,
import必须在模块的最开始。// 直接导入,里面的内容直接用 import "module-name"; // export default 导出的,导入时命名可以自定义 import defaultExport from "module-name"; import {default as defaultExport} from 'module-name'; // 通配符 "*" 导入 import * from "module-name"; import * as name from "module-name"; // 将所有内容导入为一个对象 name,其 default 属性是默认的导出 // export 导出的,导入时命名要保持一致 import { export1 } from "module-name"; import { export1 as alias } from "module-name"; import { export1 , export2 } from "module-name"; import { export1 , export2 as alias2 , [...] } from "module-name"; // 前面是默认导出的,后面是其余导出的 import defaultExport, { export1 , export2 } from "module-name"; import defaultExport, * as name from "module-name"; import {default as defaultExport, export1} from 'module-name';函数内的静态导入,
import也要在函数内部最开始的位置document.onclick = function () { // import 必须放置在当前模块的最开始加载 import m from './m.js' console.log(m); }- 动态导入/按需导入
import()
此外,还有一个类似函数的动态
import(),它不需要依赖type="module"的script标签。关键字
import可以像调用函数一样来动态的导入模块。以这种方式调用,将返回一个promise。let res = import("./m.js"); console.log(res); import('./m.js').then(m => { console.log(m) }); // 也支持 await let m = await import('./m.js'); let say = m.default; // 模块默认导出对象通过
import()方法导入返回的数据会被包装在一个对象中,即使是default也是如此,为了访问它,我们可以使用模块对象的default属性。 - 静态导入
导出导入对应使用
/* 1. 直接导入 */
// ./a.js 导出
let a = 10;
// ./index.html 导入
import './a.js';
console.log("a"); // 10
/* 2. 导出列表 */
// ./a.js 导出
export let a = 10;
export let b = 20;
// index.html 导入
// export 导出的,命名要保持一致,可以重命名。
import {a as c, b} from './a.js';
console.log(b); // 20
console.log(c); // 10
/* 3. 默认导出 */
// ./a.js 导出
// export 导出:导出多个
export let a = 10;
export let b = 20;
// export default :导出一个
let obj = {
fn(){
console.log("fn");
}
}
export default obj;
// 等同于
// export {obj as default};
// ./index.html 导入
// export default 导出的,命名可以自定义
import myfn, {a, b} from './a.js';
myfn.fn(); // 'fn'
/* 4. 通配符 "*" 导入 */
// ./a.js 导出
export let obj = {
x: 1
}
// ./index.html 导入
import * as m1 from './a.js'
console.log(m1.obj.x) // 1
ESM 与 CommonJS 的差异
-
CommonJS输出的是一个值的拷贝,ESM输出的是值的引用-
CommonJS输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。 -
ESM的运行机制与CommonJS不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。因此,ESM是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
-
-
CommonJS是运行时加载,ES6 是编译时输出接口-
运行时加载:
CommonJS就是对象;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。 -
编译时加载:
ESM不是对象,而是通过export命令显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”。模块内部引用的变化,会反应在外部。
-
CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成。而 ESM 不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
首先看个 CommonJS 输出拷贝的例子:
/* CommonJS */
// a.js
let a = 1;
let b = { num: 1 }
setTimeout(() => {
a = 2;
b = { num: 2 };
}, 200);
module.exports = { a, b };
// main.js
let {a, b} = require('./a');
console.log(a); // 1
console.log(b); // { num: 1 }
setTimeout(() => {
console.log(a); // 1
console.log(b); // { num: 1 }
}, 500);
所谓输出拷贝,如果了解过 NodeJS 或者 webpack 对 CommonJS 的实现(不了解可以看这篇文章),就会知道:exports 对象是模块内外的唯一关联, CommonJS 输出的内容,就是 exports 对象的属性,模块运行结束,属性就确定了。
再看 ESM 输出的例子:
/* ESM */
// a.js
let a = 1;
let b = { num: 1 }
setTimeout(() => {
a = 2;
b = { num: 2 };
}, 200);
export { a, b };
// main.js
import {a, b} from './a';
console.log(a); // 1
console.log(b); // { num: 1 }
setTimeout(() => {
console.log(a); // 2
console.log(b); // { num: 2 }
}, 500);
ESM 模块内部引用的变化,会反应在外部。
总结
-
AMD/CMD/CommonJs是 js 模块化开发的规范,对应的实现是require.js/sea.js/Node.js。 -
UMD并不属于一套模块规范,它主要用来处理CommonJS、AMD、CMD的差异兼容,使模块代码能在前面不同的模块环境下都能正常运行。 -
CommonJs主要针对服务端,AMD/CMD/ESM主要针对浏览器端。 -
AMD推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。 -
CommonJs和ESM区别:CommonJs模块输出的是一个值的拷贝,ESM输出的是值的引用。
针对服务器端和针对浏览器端有什么区别?
服务器端一般采用 同步加载文件,也就是说需要某个模块,服务器端便停下来,等待它加载再执行。浏览器端要保证效率,需要采用 异步加载,这就需要一个预处理,提前将所需要的模块文件并行加载好。
构建工具
在实际开发中,浏览器模块很少被以“原始”形式进行使用。通常,我们会使用一些特殊工具,例如 Webpack,将它们打包在一起,然后部署到生产环境的服务器。
使用打包工具的一个好处是 —— 它们可以更好地控制模块的解析方式,允许我们使用裸模块和更多的功能,例如 CSS/HTML 模块等。
构建工具做以下这些事儿:
-
从一个打算放在 HTML 中的
<script type="module">“主”模块开始。 -
分析它的依赖:它的导入,以及它的导入的导入等。
-
使用所有模块构建一个文件(或者多个文件,这是可调的),并用打包函数(bundler function)替代原生的
import调用,以使其正常工作。还支持像 HTML/CSS 模块等“特殊”的模块类型。 -
在处理过程中,可能会应用其他转换和优化:
- 删除无法访问的代码。
- 删除未使用的导出(“tree-shaking”)。
- 删除特定于开发的像
console和debugger这样的语句。 - 可以使用 Babel 将前沿的现代的 JavaScript 语法转换为具有类似功能的旧的 JavaScript 语法。
- 压缩生成的文件(删除空格,用短的名字替换变量等)。
如果我们使用打包工具,那么脚本会被打包进一个单一文件(或者几个文件),在这些脚本中的 import/export 语句会被替换成特殊的打包函数(bundler function)。因此,最终打包好的脚本中不包含任何 import/export,它也不需要 type="module",我们可以将其放入常规的 <script>:
<!-- 假设我们从诸如 Webpack 这类的打包工具中获得了 "bundle.js" 脚本 -->
<script src="bundle.js"></script>
也就是说,原生模块也是可以使用的。