模块化的背景
Javascript 程序本来很小——在早期,它们大多被用来执行独立的脚本任务,在你的 web 页面需要的地方提供一定交互,所以一般不需要多大的脚本。过了几年,我们现在有了运行大量 Javascript 脚本的复杂程序,还有一些被用在其他环境(例如 Node.js)。因此,近年来,有必要开始考虑提供一种将 JavaScript 程序拆分为可按需导入的单独模块的机制。Node.js 已经提供这个能力很长时间了,还有很多的 Javascript 库和框架 已经开始了模块的使用(例如, CommonJS 和基于 AMD 的其他模块系统 如 RequireJS, 以及最新的 Webpack 和 Babel)。好消息是,最新的浏览器开始原生支持模块功能了,这会是一个好事情 — 浏览器能够最优化加载模块,使它比使用库更有效率:使用库通常需要做额外的客户端处理。
什么是模块化?
将一个复杂的程序依据一定的规则封装成代码块(文件), 并进行组合在一起,代码块的内部数据与实现是私有的, 只是向外部暴露一些接口(方法)与其它模块通信
原生如何实现模块化
function 模式
优点
- 将不同的功能封装成不同的全局函数
- 天生的模块化
缺点
- 污染全局命名空间, 易引起命名冲突
- 模块成员之间看不出直接关系
例子
function function() { //... } function function2() { //... }
namespace 模式
优点
减少了全局变量,解决命名冲突
缺点
数据不安全(外部可以直接修改模块内部的数据)
例子
let module = { name: 'Faker', getName() { console.log(`${this.name}`) } } module.name = 'other Name' // 能直接修改模块内部的数据 module.getName() // other Name
IIFE(立即调用函数表达式) 模式
优点
数据是私有的, 外部只能通过暴露的方法操作
缺点
如果当前这个模块依赖另一个模块怎么办?
引入 script
优点
相比于使用一个 js
文件,这种多个 js
文件实现最简单的模块化的思想是进步的。
缺点
请求过多,如果我们要依赖多个模块,那样就会发送多个请求,导致请求过多
依赖关系不明显,我们不知道他们的具体依赖关系是什么,因为不了解他们之间的依赖关系导致加载先后顺序出错。
难以维护,以上两种原因就导致了很难维护,很可能出现牵一发而动全身的情况导致项目出现严重的问题。
例子
<script src="jquery.js"></script> <script src="jquery_scroller.js"></script> <script src="main.js"></script> <script src="other1.js"></script> <script src="other2.js"></script> <script src="other3.js"></script>
模块化规范
CommonJS
Node 应用由模块组成,采用 CommonJS 模块规范。每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。在服务器端,模块的加载是运行时同步加载的;在浏览器端,模块需要提前编译打包处理。
特点
- 所有代码都运行在模块作用域,不会污染全局作用域。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
- 模块加载的顺序,按照其在代码中出现的顺序。
- 对外抛出的变量,是一个值的地址,不是引用地址,内部修改的变量对外导出的变量不影响。
基本语法
- 暴露模块:
module.exports = value
或exports.xxx = value
- 引入模块:
require(xxx)
, 如果是第三方模块,xxx
为模块名;如果是自定义模块,xxx
为模块文件路径
此处我们有个疑问:CommonJS
暴露的模块到底是什么? CommonJS
规范规定,每个模块内部,module
变量代表当前模块。这个变量是一个对象,它的 exports
属性(即 module.exports)是对外的接口。加载某个模块,其实是加载该模块的 module.exports
属性。
// example.js const count = 5; const add = function () { return count; }; module.exports.count = count; module.exports.add = add;
上面代码通过 module.exports
输出变量 count
和函数 add
。
const example = require('./example.js'); console.log(example.count); // 5 console.log(example.add()); // 5
CommonJS 模块的加载机制
require
命令是 CommonJS
规范之中,用来加载其他模块的命令。它其实不是一个全局命令,而是指向当前模块的 module.require
命令,而后者又调用 Node
的内部命令 Module.\_load
Module._load = function(request, parent, isMain) { // 1. 检查 Module._cache,是否缓存之中有指定模块 // 2. 如果缓存之中没有,就创建一个新的Module实例 // 3. 将它保存到缓存 // 4. 使用 module.load() 加载指定的模块文件, // 读取文件内容之后,使用 module.compile() 执行文件代码 // 5. 如果加载/解析过程报错,就从缓存删除该模块 // 6. 返回该模块的 module.exports };
上面的第 4 步,采用 module.compile()执行指定模块的脚本,逻辑如下。
Module.prototype._compile = function(content, filename) { // 1. 生成一个require函数,指向module.require // 2. 加载其他辅助方法到require // 3. 将文件内容放到一个函数之中,该函数可调用 require // 4. 执行该函数 };
上面的第 1 步和第 2 步,require
函数及其辅助方法主要如下。
- require(): 加载外部模块
- require.resolve():将模块名解析到一个绝对路径
- require.main:指向主模块
- require.cache:指向所有缓存的模块
- require.extensions:根据文件的后缀名,调用不同的执行函数
一旦 require
函数准备完毕,整个所要加载的脚本内容,就被放到一个新的函数之中,这样可以避免污染全局环境。该函数的参数包括 require
、module
、exports
,以及其他一些参数。
Module.\_compile
方法是同步执行的,所以 Module.\_load
要等它执行完成,才会向用户返回 module.exports
的值。
// main.js let counter = require('./lib').counter; let incCounter = require('./lib').incCounter; console.log(counter); // 3 incCounter(); console.log(counter); // 3
上面代码说明,counter
输出以后,lib.js
模块内部的变化就影响不到 counter
了。这是因为 counter
是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。
CommonJS 模块的缓存
第一次加载某个模块时,Node
会缓存该模块。以后再加载该模块,就直接从缓存取出该模块的 module.exports
属性。
require('./example.js'); require('./example.js').message = "hello"; require('./example.js').message // "hello"
上面代码中,连续三次使用 require
命令,加载同一个模块。第二次加载的时候,为输出的对象添加了一个 message
属性。但是第三次加载的时候,这个 message
属性依然存在,这就证明 require
命令并没有重新加载模块文件,而是输出了缓存。
如果想要多次执行某个模块,可以让该模块输出一个函数,然后每次 require
这个模块的时候,重新执行一下输出的函数。
所有缓存的模块保存在 require.cache
之中,如果想删除模块的缓存,可以像下面这样写。
// 删除指定模块的缓存 delete delete require.cache[require.resolve("./a.js")]; // 删除所有模块的缓存 Object.keys(require.cache).forEach(function(key) { delete delete require.cache[require.resolve(key)]; })
注意,缓存是根据绝对路径识别模块的,如果同样的模块名,但是保存在不同的路径,require
命令还是会重新加载该模块。
CommonJS 模块的循环加载
如果发生模块的循环加载,即 A 加载 B,B 又加载 A,则 B 将加载 A 的不完整版本。
// a.js exports.x = 'a1'; console.log('a.js ', require('./b.js').x); exports.x = 'a2'; // b.js exports.x = 'b1'; console.log('b.js ', require('./a.js').x); exports.x = 'b2'; // main.js console.log('main.js ', require('./a.js').x); console.log('main.js ', require('./b.js').x);
上面代码是三个 JavaScript
文件。其中,a.js
加载了 b.js
,而 b.js
又加载 a.js
。这时,Node
返回 a.js
的不完整版本,所以执行结果如下。
$ node main.js b.js a1 a.js b2 main.js a2 main.js b2
修改 main.js
,再次加载 a.js
和 b.js
。
// main.js console.log('main.js ', require('./a.js').x); console.log('main.js ', require('./b.js').x); console.log('main.js ', require('./a.js').x); console.log('main.js ', require('./b.js').x);
执行上面代码,结果如下。
$ node main.js b.js a1 a.js b2 main.js a2 main.js b2 main.js a2 main.js b2
上面代码中,第二次加载 a.js
和 b.js
时,会直接从缓存读取 exports
属性,所以 a.js
和 b.js
内部的 console.log
语句都不会执行了。
ES6 模块化
ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。
特点
ES6
模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。
基本语法
export
命令用于规定模块的对外接口,import
命令用于输入其他模块提供的功能。
export
let basicNum = 0; let add = function(a, b) { return a + b; }; export { basicNum, add };
import
import { basicNum, add } from './math'; function test(ele) { ele.textContent = add(99 + basicNum); }
如上例所示,使用 import
命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到 export default
命令,为模块指定默认输出。
export default function () { console.log('foo'); }
ES6 模块与 CommonJS 模块的差异
CommonJS
模块输出的是一个值的拷贝,ES6
模块输出的是值的引用。CommonJS
模块是运行时加载,ES6
模块是编译时输出接口。
第二个差异是因为 CommonJS
加载的是一个对象(即 module.exports
属性),该对象只有在脚本运行完才会生成。而 ES6
模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
总结
CommonJS
规范主要用于服务端编程,加载模块是同步的,并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的。
ES6
在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS
和 AMD
规范,成为浏览器和服务器通用的模块解决方案。