JavaScript 语言诞生至今,模块规范化之路曲曲折折。社区前后出现了各类解决方案,包括 AMD、CMD、CommonJS 等,然后 ECMA 组织在 JavaScript 语言标准层面,增长了模块功能(由于该功能是在 ES2015 版本引入的,因此在下文中将之称为 ES6 module)。
今天咱们就来聊聊,为何会出现这些不一样的模块规范,它们在所处的历史节点解决了哪些问题?html
或根据功能、或根据数据、或根据业务,将一个大程序拆分红互相依赖的小文件,再用简单的方式拼装起来。前端
为了更好的理解各个模块规范,先增长一个简单的项目用于演示。node
# 项目目录: ├─ js # js文件夹 │ ├─ main.js # 入口 │ ├─ config.js # 项目配置 │ └─ utils.js # 工具 └─ index.html # 页面html
在刀耕火种的前端原始社会,JS 文件之间的通讯基本彻底依靠window
对象(借助 HTML、CSS 或后端等状况除外)。git
// config.js var api = 'https://github.com/ronffy'; var config = { api: api, }
// utils.js var utils = { request() { console.log(window.config.api); } }
// main.js window.utils.request();
<!-- index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>小贼先生:【深度全面】JS模块规范进化论</title> </head> <body> <!-- 全部 script 标签必须保证顺序正确,不然会依赖报错 --> <script src="./js/config.js"></script> <script src="./js/utils.js"></script> <script src="./js/main.js"></script> </body> </html>
浏览器环境下,在全局做用域声明的变量都是全局变量。全局变量存在命名冲突、占用内存没法被回收、代码可读性低等诸多问题。es6
这时,IIFE(匿名当即执行函数)出现了:github
;(function () { ... }());
用IIFE重构 config.js:typescript
;(function (root) { var api = 'https://github.com/ronffy'; var config = { api: api, }; root.config = config; }(window));
IIFE的出现,使全局变量的声明数量获得了有效的控制。npm
依靠window
对象承载数据的方式是“不可靠”的,如window.config.api
,若是window.config
不存在,则window.config.api
就会报错,因此为了不这样的错误,代码里会大量的充斥var api = window.config && window.config.api;
这样的代码。后端
这时,namespace
登场了,简约版本的namespace
函数的实现(只为演示,不要用于生产):api
function namespace(tpl, value) { return tpl.split('.').reduce((pre, curr, i) => { return (pre[curr] = i === tpl.split('.').length - 1 ? (value || pre[curr]) : (pre[curr] || {})) }, window); }
用namespace
设置window.app.a.b
的值:
namespace('app.a.b', 3); // window.app.a.b 值为 3
用namespace
获取window.app.a.b
的值:
var b = namespace('app.a.b'); // b 的值为 3 var d = namespace('app.a.c.d'); // d 的值为 undefined
app.a.c
值为undefined
,但由于使用了namespace
, 因此app.a.c.d
不会报错,变量d
的值为undefined
。
随着前端业务增重,代码愈来愈复杂,靠全局变量通讯的方式开始捉襟见肘,前端急需一种更清晰、更简单的处理代码依赖的方式,将 JS 模块化的实现及规范陆续出现,其中被应用较广的模块规范有 AMD 和 CMD。
面对一种模块化方案,咱们首先要了解的是:1. 如何导出接口;2. 如何导入接口。
异步模块定义规范(AMD)制定了定义模块的规则,这样模块和模块的依赖能够被异步加载。这和浏览器的异步加载模块的环境恰好适应(浏览器同步加载模块会致使性能、可用性、调试和跨域访问等问题)。
本规范只定义了一个函数define
,它是全局变量。
/** * @param {string} id 模块名称 * @param {string[]} dependencies 模块所依赖模块的数组 * @param {function} factory 模块初始化要执行的函数或对象 * @return {any} 模块导出的接口 */ function define(id?, dependencies?, factory): any
AMD 是一种异步模块规范,RequireJS 是 AMD 规范的实现。
接下来,咱们用 RequireJS 重构上面的项目。
在原项目 js 文件夹下增长 require.js 文件:
# 项目目录: ├─ js # js文件夹 │ ├─ ... │ └─ require.js # RequireJS 的 JS 库 └─ ...
// config.js define(function() { var api = 'https://github.com/ronffy'; var config = { api: api, }; return config; });
// utils.js define(['./config'], function(config) { var utils = { request() { console.log(config.api); } }; return utils; });
// main.js require(['./utils'], function(utils) { utils.request(); });
<!-- index.html --> <!-- ...省略其余 --> <body> <script data-main="./js/main" src="./js/require.js"></script> </body> </html>
能够看到,使用 RequireJS 后,每一个文件均可以做为一个模块来管理,通讯方式也是以模块的形式,这样既能够清晰的管理模块依赖,又能够避免声明全局变量。
更多 AMD 介绍,请查看文档。
更多 RequireJS 介绍,请查看文档。
特别说明:
先有 RequireJS,后有 AMD 规范,随着 RequireJS 的推广和普及,AMD 规范才被建立出来。
CMD 和 AMD 同样,都是 JS 的模块化规范,也主要应用于浏览器端。
AMD 是 RequireJS 在的推广和普及过程当中被创造出来。
CMD 是 SeaJS 在的推广和普及过程当中被创造出来。
两者的的主要区别是 CMD 推崇依赖就近,AMD 推崇依赖前置:
// AMD // 依赖必须一开始就写好 define(['./utils'], function(utils) { utils.request(); }); // CMD define(function(require) { // 依赖能够就近书写 var utils = require('./utils'); utils.request(); });
AMD 也支持依赖就近,但 RequireJS 做者和官方文档都是优先推荐依赖前置写法。
考虑到目前主流项目中对 AMD 和 CMD 的使用愈来愈少,你们对 AMD 和 CMD 有大体的认识就好,此处再也不过多赘述。
更多 CMD 规范,请查看文档。
更多 SeaJS 文档,请查看文档。
随着 ES6 模块规范的出现,AMD/CMD 终将成为过去,但毋庸置疑的是,AMD/CMD 的出现,是前端模块化进程中重要的一步。
前面说了, AMD、CMD 主要用于浏览器端,随着 node 诞生,服务器端的模块规范 CommonJS 被建立出来。
仍是以上面介绍到的 config.js、utils.js、main.js 为例,看看 CommonJS 的写法:
// config.js var api = 'https://github.com/ronffy'; var config = { api: api, }; module.exports = config;
// utils.js var config = require('./config'); var utils = { request() { console.log(config.api); } }; module.exports = utils;
// main.js var utils = require('./utils'); utils.request(); console.log(global.api)
执行node main.js
,https://github.com/ronffy
被打印了出来。
在 main.js 中打印global.api
,打印结果是undefined
。node 用global
管理全局变量,与浏览器的window
相似。与浏览器不一样的是,浏览器中顶层做用域是全局做用域,在顶层做用域中声明的变量都是全局变量,而 node 中顶层做用域不是全局做用域,因此在顶层做用域中声明的变量非全局变量。
咱们在看 node 代码时,应该会发现,关于接口导出,有的地方使用module.exports
,而有的地方使用exports
,这两个有什么区别呢?
CommonJS 规范仅定义了exports
,但exports
存在一些问题(下面会说到),因此module.exports
被创造了出来,它被称为 CommonJS2 。
每个文件都是一个模块,每一个模块都有一个module
对象,这个module
对象的exports
属性用来导出接口,外部模块导入当前模块时,使用的也是module
对象,这些都是 node 基于 CommonJS2 规范作的处理。
// a.js var s = 'i am ronffy' module.exports = s; console.log(module);
执行node a.js
,看看打印的module
对象:
{ exports: 'i am ronffy', id: '.', // 模块id filename: '/Users/apple/Desktop/a.js', // 文件路径名称 loaded: false, // 模块是否加载完成 parent: null, // 父级模块 children: [], // 子级模块 paths: [ /* ... */ ], // 执行 node a.js 后 node 搜索模块的路径 }
其余模块导入该模块时:
// b.js var a = require('./a.js'); // a --> i am ronffy
当在 a.js 里这样写时:
// a.js var s = 'i am ronffy' exports = s;
a.js 模块的module.exports
是一个空对象。
// b.js var a = require('./a.js'); // a --> {}
把module.exports
和exports
放到“明面”上来写,可能就更清楚了:
var module = { exports: {} } var exports = module.exports; console.log(module.exports === exports); // true var s = 'i am ronffy' exports = s; // module.exports 不受影响 console.log(module.exports === exports); // false
模块初始化时,exports
和module.exports
指向同一块内存,exports
被从新赋值后,就切断了跟原内存地址的关系。
因此,exports
要这样使用:
// a.js exports.s = 'i am ronffy'; // b.js var a = require('./a.js'); console.log(a.s); // i am ronffy
CommonJS 和 CommonJS2 常常被混淆概念,通常你们常常提到的 CommonJS 实际上是指 CommonJS2,本文也是如此,不过无论怎样,你们知晓它们的区别和如何应用就好。
CommonJS 和 AMD 都是运行时加载,换言之:都是在运行时肯定模块之间的依赖关系。
两者有何不一样点:
var a = require('./a.js');
时,在 a.js 文件加载完成后,才执行后面的代码。AMD 加载模块是异步的,全部依赖加载完成后以回调函数的形式执行代码。fs
和chalk
都是模块,不一样的是,fs
是 node 内置模块,chalk
是一个 npm 包。这两种状况在 CommonJS 中才有,AMD 不支持。var fs = require('fs'); var chalk = require('chalk');
Universal Module Definition.
存在这么多模块规范,若是产出一个模块给其余人用,但愿支持全局变量的形式,也符合 AMD 规范,还能符合 CommonJS 规范,能这么全能吗?
是的,能够如此全能,UMD 闪亮登场。
UMD 是一种通用模块定义规范,代码大概这样(假如咱们的模块名称是 myLibName):
!function (root, factory) { if (typeof exports === 'object' && typeof module === 'object') { // CommonJS2 module.exports = factory() // define.amd 用来判断项目是否应用 require.js。 // 更多 define.amd 介绍,请[查看文档](https://github.com/amdjs/amdjs-api/wiki/AMD#defineamd-property-) } else if (typeof define === 'function' && define.amd) { // AMD define([], factory) } else if (typeof exports === 'object') { // CommonJS exports.myLibName = factory() } else { // 全局变量 root.myLibName = factory() } }(window, function () { // 模块初始化要执行的代码 });
UMD 解决了 JS 模块跨模块规范、跨平台使用的问题,它是很是好的解决方案。
AMD 、 CMD 等都是在原有JS语法的基础上二次封装的一些方法来解决模块化的方案,ES6 module(在不少地方被简写为 ESM)是语言层面的规范,ES6 module 旨在为浏览器和服务器提供通用的模块解决方案。长远来看,将来不管是基于 JS 的 WEB 端,仍是基于 node 的服务器端或桌面应用,模块规范都会统一使用 ES6 module。
目前,不管是浏览器端仍是 node ,都没有彻底原生支持 ES6 module,若是使用 ES6 module ,可借助 babel 等编译器。本文只讨论 ES6 module 语法,故不对 babel 或 typescript 等可编译 ES6 的方式展开讨论。
CommonJS 中顶层做用域不是全局做用域,一样的,ES6 module 中,一个文件就是一个模块,文件的顶层做用域也不是全局做用域。导出接口使用export
关键字,导入接口使用import
关键字。
export
导出接口有如下方式:
export const prefix = 'https://github.com'; export const api = `${prefix}/ronffy`;
const prefix = 'https://github.com'; const api = `${prefix}/ronffy`; export { prefix, api, }
方式1和方式2只是写法不一样,结果是同样的,都是把prefix
和api
分别导出。
// foo.js export default function foo() {} // 等同于: function foo() {} export { foo as default }
export default
用来导出模块默认的接口,它等同于导出一个名为default
的接口。配合export
使用的as
关键字用来在导出接口时为接口重命名。
export { api } from './config.js'; // 等同于: import { api } from './config.js'; export { api }
若是须要在一个模块中先导入一个接口,再导出,可使用export ... from 'module'
这样的简便写法。
ES6 module 使用import
导入模块接口。
导出接口的模块代码1:
// config.js const prefix = 'https://github.com'; const api = `${prefix}/ronffy`; export { prefix, api, }
接口已经导出,如何导入呢:
import { api } from './config.js'; // or // 配合`import`使用的`as`关键字用来为导入的接口重命名。 import { api as myApi } from './config.js';
import * as config from './config.js'; const api = config.api;
将 config.js 模块导出的全部接口都挂载在config
对象上。
// foo.js export const conut = 0; export default function myFoo() {}
// index.js // 默认导入的接口此处刻意命名为cusFoo,旨在说明该命名可彻底自定义。 import cusFoo, { count } from './foo.js'; // 等同于: import { default as cusFoo, count } from './foo.js';
export default
导出的接口,可使用import name from 'module'
导入。这种方式,使导入默认接口很便捷。
import './config.js';
这样会加载整个 config.js 模块,但未导入该模块的任何接口。
上面介绍了 ES6 module 各类导入接口的方式,但有一种场景未被涵盖:动态加载模块。好比用户点击某个按钮后才弹出弹窗,弹窗里功能涉及的模块的代码量比较重,因此这些相关模块若是在页面初始化时就加载,实在浪费资源,import()
能够解决这个问题,从语言层面实现模块代码的按需加载。
ES6 module 在处理以上几种导入模块接口的方式时都是编译时处理,因此import
和export
命令只能用在模块的顶层,如下方式都会报错:
// 报错 if (/* ... */) { import { api } from './config.js'; } // 报错 function foo() { import { api } from './config.js'; } // 报错 const modulePath = './utils' + '/api.js'; import modulePath;
使用import()
实现按需加载:
function foo() { import('./config.js') .then(({ api }) => { }); } const modulePath = './utils' + '/api.js'; import(modulePath);
特别说明:
该功能的提议目前处于 TC39 流程的第4阶段。更多说明,请查看TC39/proposal-dynamic-import。
CommonJS 和 AMD 是运行时加载,在运行时肯定模块的依赖关系。
ES6 module 是在编译时(import()
是运行时加载)处理模块依赖关系,。
CommonJS 在导入模块时,会加载该模块,所谓“CommonJS 是运行时加载”,正因代码在运行完成后生成module.exports
的缘故。固然,CommonJS 对模块作了缓存处理,某个模块即便被屡次多处导入,也只加载一次。
// o.js let num = 0; function getNum() { return num; } function setNum(n) { num = n; } console.log('o init'); module.exports = { num, getNum, setNum, }
// a.js const o = require('./o.js'); o.setNum(1);
// b.js const o = require('./o.js'); // 注意:此处只是演示,项目里不要这样修改模块 o.num = 2;
// main.js const o = require('./o.js'); require('./a.js'); console.log('a o.num:', o.num); require('./b.js'); console.log('b o.num:', o.num); console.log('b o.getNum:', o.getNum());
命令行执行node main.js
,打印结果以下:
o init
module.exports
属性上。a o.num: 0
module.exports
。b o.num: 2
module.exports
。b o.getNum: 1
// o.js let num = 0; function getNum() { return num; } function setNum(n) { num = n; } console.log('o init'); export { num, getNum, setNum, }
// main.js import { num, getNum, setNum } from './o.js'; console.log('o.num:', num); setNum(1); console.log('o.num:', num); console.log('o.getNum:', getNum());
咱们增长一个 index.js 用于在 node 端支持 ES6 module:
// index.js require("@babel/register")({ presets: ["@babel/preset-env"] }); module.exports = require('./main.js')
命令行执行npm install @babel/core @babel/register @babel/preset-env -D
安装 ES6 相关 npm 包。
命令行执行node index.js
,打印结果以下:
o init
o.num: 0
o.num: 1
import
导入的接口只是值的引用,因此num
才会有两次不一样打印结果。o.getNum: 1
对于打印结果3,知晓其结果,在项目中注意这一点就好。这块会涉及到“Module Records(模块记录)”、“module instance(模快实例)” “linking(连接)”等诸多概念和原理,你们可查看ES modules: A cartoon deep-dive进行深刻的研究,本文再也不展开。
ES6 module 是编译时加载(或叫作“静态加载”),利用这一点,能够对代码作不少以前没法完成的优化:
你们在平常开发中都在使用 CommonJS 和 ES6 module,但不少人只知其然而不知其因此然,甚至不少人对 AMD、CMD、IIFE 等概览还比较陌生,但愿经过本篇文章,你们对 JS 模块化之路可以有清晰完整的认识。
JS 模块化之路目前趋于稳定,但确定不会止步于此,让咱们一块儿学习,一块儿进步,一块儿见证,也但愿能有机会为将来的模块化规范贡献本身的一点力量。
本人能力有限,文中可能不免有一些谬误,欢迎你们帮助改进,文章github地址,我是小贼先生。