前端模块化
为什么需要模块化?
早期的开发没有模块化,会有两个灾难性的问题:即 全局污染 以及 依赖管理混乱 。
1. 全局污染:
A 引入 a.js,B 引入 b.js,这些代码最后都是存在于全局作用域里,难保不会出现变量命名冲突的问题。
2. 依赖管理混乱:
js 文件之间存在依赖关系,那么被依赖项必须出现在前面,也就是说要遵守一定的顺序。要是有几十个文件,那么就得先确定好互相之间的依赖关系,然后手动排序,累觉不爱。
早期解决方案:
IIFE
每个 js 文件中都用一个匿名自执行函数来封装数据。
// a.js (function(){ var num = 1; function add(){ num++; } })() // b.js (function(){ var num = 2; function sub(){ num--; } })()
nice,这样子 a.js 和 b.js 都有各自的 num,互不影响了。但是,我在全局作用域下好像拿不到函数里的东西???
IIFE 增强版
让 IIFE 返回一个对象,暴露给全局作用域
// a.js var moduleA = (function(){ var num = 1; return { gain:function(){ return num; }, add:function(){ num++; } } })()
这样,全局可以通过 moduleA
拿到函数里的变量。不过,要是 b.js 不小心脑袋抽筋,也将 IIFE 返回给一个叫做 moduleA
的变量呢?命名冲突的问题还是没解决。
这之后提出了 模块化 的概念。
模块化解决方案:
那么,模块化到底需要解决什么问题呢?我们先设想一下可能有以下几点:
- 安全地包装一个模块的代码,避免全局污染
- 唯一标识一个模块
- 优雅地将模块 api 暴露出去
- 方便地使用模块
- …….
1.CommonJS
1.1 介绍:
CommonJS 的一个模块就是一个脚本文件,通过执行该文件来加载模块
。CommonJS 规范规定,每个模块内部, module
变量代表当前模块。这个变量是一个对象,它的 exports
属性(即 module.exports
)是对外的接口。加载某个模块,其实是加载该模块的 module.exports
属性。
1.2 导出模块:
Node.js 是 CommonJS 规范的实现。为了方便,Node.js 为每个模块提供一个 exports
变量,指向 module.exports
。这等同在每个模块头部,有一行这样的命令:
var exports = module.exports;
所以,我们有两种导出模块的方式:
// module.js var num = 1; function print(){ num++; return num; } // 方式1 module.exports = { num, print } // 方式2 exports.num = num; exports.print = print;
1.3 加载模块:
另外,我们也有两种加载模块的方式:
// main.js // 方式1 var obj = require('./module.js'); console.log(obj.num); console.log(obj.print()); // 方式2(解构赋值) var { num,print } = require('./module.js'); console.log(num); console.log(print());
CommonJS 的特点是:
- 所有代码都运行在模块作用域,不会污染全局作用域。
- 独立性是模块的重要特点,模块内部最好不与程序的其他部分直接交互。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
- 模块加载的顺序,按照其在代码中出现的顺序。
2.AMD
CommonJS 是针对服务端的模块化解决方案,为何它不能用于前端呢?因为 CommonJS 是同步而不是异步的,在我们 require 模块的时候,如果迟迟没有返回结果,那么就会阻塞后面代码的执行,甚至会阻止页面的渲染。
所以这时候有了 AMD 规范,即 异步模块加载规范 。
AMD 与 CommonJS 的主要区别就是异步模块加载 —— 即使 require 的模块还没有获取到,也不会影响后面代码的执行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。
RequireJS 实现了这个规范。
当然,后面还出现了 CMD、UMD。
3. ES6 Module
3.1 介绍:
ES6 在语言规格层面上实现了模块功能,完全可以取代现有的 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。
3.2 导出模块
有三种方式可以导出模块:
// module.js // 方式一(声明的同时导出) export var num = 1; export function add(){ num++; } // 方式二(统一导出。推荐) var num = 1; function add(){ num++; } export { num,add } // 方式三(允许重命名,次数不限) var num = 1; function add(){ num++; } export { num as new_num; add as new_add; add as newer_add; }
3.3 加载模块
同样的,加载模块也有多种方式。其中,整体加载会把之前导出的变量和函数挂载在一个对象上。
// main.js // 方式一: import { num,add } from './module.js' // 方式二(允许重命名): import { num as new_num; add as new_add; } from './module.js' // 方式三(整体加载): import * as obj from './module.js'
3.4 export default
export default
其实用得更多。 import
在非整体加载的时候要求我们事先知道导出的变量或者函数的名字,但是如果使用 export default
导出,那么后续加载模块的时候,名字可以任取,也就是说,我们并不需要知道原模块中变量或者函数的名字。例如:
// module.js export default function(){ ..... } // main.js import func from './module.js'
此外,要注意两点:
-
export default
实际上是把后面跟着的东西赋值给default
变量,所以后面不能是变量的声明 -
因为
export default
是指定的 默认输出 ,这意味着一个模块文件中只能有一条export default
语句(当然,可以与export
一起用),也因为这样,import
后面不需要大括号,因为它只可能接受一个项。
ES6 模块与 CommonJS 模块的差异
-
CommonJS 模块输出的是值的拷贝,ES6 模块输出的是值的引用
-
CommonJS 模块是运行时加载,ES6 模块是编译时输出接口
其一
CommonJS 模块输出的是值的拷贝:
也就是说,输出之后,原模块内部该值怎么变化,都不会影响到导出去的那个值,两者在内存中有各自的空间。
关于这点,很多文章会用类似下面的方式去证明:
// module.js var num = 1; function add(){ num++; }; module.exports = { num,add }; // main.js var obj = require('./module.js'); console(obj.num); // 1 obj.add(); console.log(obj.num); // 1
因为这里是拷贝了 num
,所以 add
操作后只是 module.js
中的 num
加一(词法作用域), main.js
中拷贝得到的 num
不变。
这个证明方法其实有问题。因为 module.exports
对象中的 num
属性本来就有值的拷贝了,此方法并不能证明值的拷贝是由 CommonJS 的底层实现的。,而且,把上面代码改为对应的 es6 module 版本(此时本来应该是引用),会发现得到同样的结果,更证明了这一点。详情看:
如何正确证明 CommonJS 模块导出是值的拷贝,而 ES module 是值的引用?
ES6 模块输出的是值的引用:
JS 引擎对脚本静态分析的时候,遇到模块加载命令 import
,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。
这意味着,原模块中值的改变会动态映射到 main.js
中
// module.js var num = 1; function add(){ num++; } export { num.add }; // main.js import { num,add } from './module.js'; console.log(num); // 1 add(); console.log(num); // 2
注意这个引用是动态变化的。
另外,原模块导出的变量在 main.js
中表现为一个只读常量,也就是说我们不能在 main.js
中对它重新赋值,这会报错:
import { num,obj } from './module.js'; console.log(num); // 1 num++; // TypeError: Assignment to constant variable console.log(obj); // {.......} obj.name = "Sam"; // 没毛病 obj = {}; // TypeError: Assignment to constant variable
对于引用类型,可以给它添加属性,但赋值同样是不行的。
其二
运行时加载:
CommonJS 是运行时加载的。也就是说,在 require 时,先执行整个模块(加载里面所有的方法),生成一个对象,然后再从这个对象上面读取实际要用到的方法,这种加载称为“运行时加载”。
编译时加载:
ES6 模块是运行时加载的。也就是说,其设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量(只加载需要的方法)。这种加载称为“编译时加载”。
import 有提升现象,因为这是在编译阶段就执行的。
以这段代码为例:
//ES6模块 import { a,b,c } from 'module.js'; //CommonJS 模块 let { a,b,c } = require('module.js');
-
对于 CommonJS,当 require 模块时,原模块会运行一遍,并返回一个包含所有 api 的对象,并将这个对象缓存起来。此后,无论多少次加载这个模块都是取这个缓存的值,也就是第一次运行的结果,除非手动清除。
-
对于 ES6,在编译阶段遇到 import 时,不会像 CommonJS 一样去执行模块,而是生成一个动态的只读引用,当真正需要的时候再到模块里去取值,所以 ES6模块是动态引用,并且不会缓存值。