Module
在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器
ES6 模块跟 CommonJS 模块的不同,主要有以下两个方面:
ES6 模块输出的是值的引用,输出接口动态绑定,而 CommonJS 输出的是值的拷贝
ES6 模块编译时执行,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成而 CommonJS 模块总是在运行时加载
静态模块和动态模块
使用 CommonJS
加载的就是动态模块
// 粟子
var my;
if (Math.random() > 0.4) {
my = require('foo');
} else {
my = require('bar');
}
import
是静态执行,所以不能使用表达式和变量
// 粟子
// 报错
if (x === 1) {
import { foo } from 'module1';
} else {
import { foo } from 'module2';
}
静态模块的好处
在编译的时候消除未引用的代码,参考 Webpack 的
tree shaking
更快地找到 imports
静态模块结构下的 imports 就好像是一个变量引用一样,可以在编译的时候就确定,而动态引用在使用时就需要在源模块中进行一番查找
两种模式的区别
先来一个问题:在 CommonJS 和 ES6 模块两种场景下,两个文件都引入同一个模块,如果一个文件把导出的值改了,那么在另外文件中这个值会被改变嘛?
分别分析在两种模式下的表现:
CommonJS
要注意 CommonJS 模块输出的是一个值的拷贝, 而且是个浅铐钡
// module
var obj = []
module.exports = obj
// page1
let obj = require('../utils/commonJS')
console.log(obj) // []
obj = 'lanjz'
console.log(obj) // lanjz
// page2
let obj = require('../utils/commonJS')
console.log(obj) // []
从上面的例子可以觉得一些讯息:
- 注意
CommonJS
的用法:let obj =
, 这不就是普通的变量声明嘛,只是这里赋值是从require('../utils/commonJS')
得到的,所以从这里是不是可以很好得理解为什么说 CommonJS 模块输出的是一个值的拷贝。所以上面的例子等价于:
var _obj = []
let obj = _obj
console.log(obj)
obj = 'lanjz'
因为是直接修改了当前页面的 obj
变量,所以对模块中的变量没有造成影响。再看另个例子
// module
var obj = []
module.exports = obj
// page1
let obj = require('../utils/commonJS')
console.log(obj) // []
obj.push('lanjz')
console.log(obj) // ['lanjz']
// page2
let obj = require('../utils/commonJS')
console.log(obj) // ['lanjz']
因为引用 CommonJS
模块时,只是一个铐钡而且还是一个浅铐钡,所以当直接修改引用的属性时,其它模块再次取这个模块时,自然也会发生改变
ES6模块
ES6 模块输出的是值的引用
// module
export var obj = []
// page1
import { obj } from '../utils/index'
console.log(obj) // []
obj = 'lanjz' // 将报错 obj is undefined
从上面的例子可以看到使用 import
导出的值是不能直接改变的,上文提到过 ES6 模块是以接口的形式输出的, 也就是 export
输出的是一个接口,import
得到这个接口的引用,而且借用断点调试时,在作用域中也确实没有找到 obj
这个变量
虽然不能直接修改这个值,但如果这个值是引用类型的话,会是怎样的呢?
// module
export var obj = []
// page1
import { obj } from '../utils/index'
console.log(obj) // []
obj.push('lanjz') //
// page2
import { obj } from '../utils/index'
console.log(obj) // ['lanjz']
从上面例子可以看到,如果是引用类型还是可以修改,表现跟 CommonJS
一致
问题二:上面例子是引用模块的文件做修改,如果是模块内容对变量做修改会是怎样的呢?
CommonJS 模块
// module
var obj = []
var number = 1
function add(){
obj.push(1)
number = number + 1
}
module.exports = {
obj,
add
}
//page1
let {obj, add, number} = require('../utils/commonJS')
console.log(number) // 1
console.log(obj) // []
add()
//page2
let {obj, add, number} = require('../utils/commonJS')
console.log(obj) // [1]
console.log(number) //1
从引用 CommonJS 模块其实做是个浅铐钡这个特点,可以理解上面输出结果, 对于引用类型会受到影响,如果是基本类型则不受影响
ES6 模块
// module
export var obj = 1
export function add() {
obj = obj + 1
}
//page1
import { add, obj } from '../utils/index'
console.log(obj) // 1
add()
//page2
import { obj } from '../utils/index'
console.log(obj) // 2
即使修改的是基本类型,其它页面也会受到影响,因为上文提过 ES6 模块导出的是一个引用
但要注意下面这个例子:
// module
var obj = 1
export default obj
export function add() {
obj = obj + 1
}
//page1
import obj, { add } from '../utils/index'
console.log(obj) // 1
add()
//page2
import obj from '../utils/index'
console.log(obj) // 1
引用的值并没受到影响,因为模块中 export default obj
相当于导出是的 obj
的值,此时跟 obj
这个变量名是没关系的
CommonJS
CommonJS 模块就是对象,输入时必须查找对象属性
// CommonJS模块
let { stat, exists, readfile } = require('fs');
// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;
上面代码的实质是整体加载 fs
模块(即加载 fs
的所有方法),生成一个对象( _fs
),然后再从这个对象上面读取 3 个方法。这种加载称为“运行时加载”,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。
ES6 模块不是对象, export
命令显式指定输出的代码,再通过 import
命令输入
// ES6模块
import { stat, exists, readFile } from 'fs';
上面代码的实质是从 fs
模块加载 3 个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。当然,这也导致了没法引用 ES6 模块本身,因为它不是对象
export命令
export
命令用于规定模块的对外接口
// profile.js
export var firstName = 'Michael';
export var lastName = 'Jackson';
export var year = 1958;
// 或
export { firstName, lastName, year };
需要特别注意的是,export
命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系
// 报错
export 1;
// 报错
var m = 1;
export m;
// 报错
function f() {}
export f;
上面两种写法都会报错,因为没有提供对外的接口。第一种写法直接输出 1,第二种写法通过变量m,还是直接输出 1。1只是一个值,不是接口。正确的写法是下面这样
// 写法一
export var m = 1;
// 写法二
var m = 1;
export {m};
// 写法三
var n = 1;
export {n as m};
// 正确
export function f() {};
// 正确
function f() {}
export {f};
重命名导出模块
export
输出的变量就是本来的名字,但是可以使用 as
关键字重命名
function v1() { ... }
function v2() { ... }
export {
v1 as streamV1,
v2 as streamV2,
v2 as streamLatestVersion
};
export
语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值
export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);
上面代码输出变量 foo
,值为 bar
,500 毫秒之后变成 baz
所以如果在一个页面中修改了模块内某个属性的值,那么其它页面读取这个这模块的时候,取到的将会修改后的值
这一点与 CommonJS 规范完全不同。CommonJS 模块输出的是值的缓存,不存在动态更新
export
命令只能处于模块顶层。如果处于块级作用域内,就会报错
function foo() {
export default 'bar' // SyntaxError
}
foo()
export default 命令
export default
命令,为模块指定默认输出
// export-default.js
export default function () {
console.log('foo');
}
// import-default.js
import customName from './export-default';
customName(); // 'foo'
export default命令用在非匿名函数前,也是可以的
// export-default.js
export default function foo() {
console.log('foo');
}
// 或者写成
function foo() {
console.log('foo');
}
export default foo;
本质上,export default
就是输出一个叫做 default
的变量或方法,然后系统允许你为它取任意名字。所以,下面的写法是有效的
// modules.js
function add(x, y) {
return x * y;
}
export {add as default};
// 等同于
// export default add;
// app.js
import { default as foo } from 'modules';
// 等同于
// import foo from 'modules';
正是因为 export default
命令其实只是输出一个叫做 default
的变量,所以它后面不能跟变量声明语句
// 正确
export var a = 1;
// 正确
var a = 1;
export default a;
// 错误
export default var a = 1;
上面代码中,export default a
的含义是将变量 a
的值赋给变量 default
。所以,最后一种写法会报错
同样地,因为 export default
命令的本质是将后面的值,赋给 default
变量,所以可以直接将一个值写在 export default
之后
// 正确
export default 42;
// 报错
export 42
import 命令
import
命令用于输入其他模块提供的功能
当 import
命令使用一对大括号时,类似使用解构赋值的方式获取指定的变量名
当 import
命令直接使用一个变量名时,表示获取模块的默认导出的模块(export default
)
由于 import
是静态执行,所以不能使用表达式和变量
// 报错
import { 'f' + 'oo' } from 'my_module';
// 报错
let module = 'my_module';
import { foo } from module;
// 报错
if (x === 1) {
import { foo } from 'module1';
} else {
import { foo } from 'module2';
}
重命令名模块
如果想为输入的变量重新取一个名字,import
命令要使用 as
关键字,将输入的变量重命名
import { lastName as surname } from './profile.js'
模块的整体加载
除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面
// circle.js
export function area(radius) {
return Math.PI * radius * radius;
}
export function circumference(radius) {
return 2 * Math.PI * radius;
}
export default function sum(){}
// main.js
import * as circle from './circle';
console.log(circle)
//Module
// default: {sum: ƒ}
// circumference: (...)
// area
注意,模块整体加载所在的那个对象(上例是 circle
),应该是可以静态分析的,所以不允许运行时改变。下面的写法都是不允许的
import * as circle from './circle';
// 下面两行都是不允许的
circle.foo = 'hello';
circle.area = function () {}
export 与 import 的复合写法
如果在一个模块之中,先输入后输出同一个模块,import
语句可以与 export
语句写在一起
export { foo, bar } from 'my_module';
// 可以简单理解为
import { foo, bar } from 'my_module';
export { foo, bar };
上面代码中,export
和 import
语句可以结合在一起,写成一行。但需要注意的是,写成一行以后,foo
和 bar
实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,导致当前模块不能直接使用 foo
和 bar
模块的接口改名和整体输出,也可以采用这种写法
// 接口改名
export { foo as myFoo } from 'my_module';
// 整体输出
export * from 'my_module';
默认接口的写法如下
export { default } from 'foo';
具名接口改为默认接口的写法如下
export { es6 as default } from './someModule';
// 等同于
import { es6 } from './someModule';
export default es6;
同样地,默认接口也可以改名为具名接口
export { default as es6 } from './someModule';
其它例子二
export * as ns from "mod";
// 等同于
import * as ns from "mod";
export {ns};
模块的继承
模块之间也可以继承。
假设有一个 circleplus
模块,继承了 circle
模块
// circleplus.js
export * from 'circle';
export var e = 2.71828182846;
export default function(x) {
return Math.exp(x);
}
上面代码中的 export *
,表示再输出 circle
模块的所有属性和方法。注意,export *
命令会忽略 circle
模块的 default
方法。然后,上面代码又输出了自定义的 e
变量和默认方法。
这时,也可以将 circle
的属性或方法,改名后再输出
// circleplus.js
export { area as circleArea } from 'circle';
上面代码表示,只输出 circle
模块的 area
方法,且将其改名为 circleArea
。
加载上面模块的写法如下
// main.js
import * as math from 'circleplus';
import exp from 'circleplus';
console.log(exp(math.e));
上面代码中的 import exp
表示,将 circleplus
模块的默认方法加载为 exp
方法
import()
前面介绍过, import
命令会被 JavaScript 引擎静态分析,先于模块内的其他语句执行。所以也无法实现动态加载
ES2020提案 引入 import()
函数,支持动态加载模块
import(specifier)
上面代码中,import
函数的参数 specifier
,指定所要加载的模块的位置。import
命令能够接受什么参数,import()
函数就能接受什么参数,两者区别主要是后者为动态加载。
import()
返回一个 Promise 对象。下面是一个例子
const main = document.querySelector('main');
import(`./section-modules/${someVariable}.js`)
.then(module => {
module.loadPageInto(main);
})
.catch(err => {
main.textContent = err.message;
});
import()
加载模块成功以后,这个模块会作为一个对象,当作 then
方法的参数。因此,可以使用对象解构赋值的语法,获取输出接口
import('./myModule.js')
.then(({export1, export2}) => {
// ...·
});
上面代码中,export1
和 export2
都是 myModule.js
的输出接口,可以解构获得
如果模块有 default
输出接口,可以用参数直接获得
import('./myModule.js')
.then(myModule => {
console.log(myModule.default);
});
import()也可以用在 async 函数之中
async function main() {
const myModule = await import('./myModule.js');
const {export1, export2} = await import('./myModule.js');
const [module1, module2, module3] =
await Promise.all([
import('./module1.js'),
import('./module2.js'),
import('./module3.js'),
]);
}
main();
同时加载多个模块
如果想同时加载多个模块,可以采用下面的写法
Promise.all([
import('./module1.js'),
import('./module2.js'),
import('./module3.js'),
])
.then(([module1, module2, module3]) => {
//···
});
import()
函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import()
函数与所加载的模块没有静态连接关系,这点也是与 import
语句不相同。import()
类似于 Node 的 require
方法,区别主要是前者是异步加载,后者是同步加载
使用场景
按需加载
import()
可以在需要的时候,再加载某个模块
button.addEventListener('click', event => {
import('./dialogBox.js')
.then(dialogBox => {
dialogBox.open();
})
.catch(error => {
/* Error handling */
})
});
条件加载
import()
可以放在 if
代码块,根据不同的情况,加载不同的模块
if (condition) {
import('moduleA').then(...);
} else {
import('moduleB').then(...);
}
动态的模块路径
import()
允许模块路径动态生成
import(f())
.then(...);