接上篇 require()、import、import()加载模块详解(一)
ES6 Module 的 import
通过 import 静态地导入另一个通过 export 导出的模块。
区分于 CJS 运行时才和导入模块建立关系,ESM 在转化成中间代码时(编译阶段) import
语句就和模块建立了静态引用关系,在运行时导入和导出是不可更改的。这就意味着我们只能在顶层进行导入和导出 (比如绝不能嵌套在条件语句中),同时 import 和 export 语句不能有「具有逻辑或含有变量」的动态部分,即不能依赖于运行时计算的任何内容 (如import foo from './module' + 变量;
),不然编译时就会报错。而 require 可以在运行时通过 if 判断决定导入哪个模块。
在编译期,import 语句会被内部移动至当前作用域最开头 (类似 var 和 function 的变量提升),先于其他代码执行。JS 解析器编译到 import 语句时,会生成一个接口标识符或默认导出接口对应的引用。如 import { a } from './module-a'
,a 指向的是export const a = xxx
接口中的 a;而 import defaultB from './module-b'
,defaultB 指向的是 export default b
中的 b (默认接口导入时的名称可以自定义)。到了运行期,也不会去执行完整模块,只有在调用 a / defaultB 的时候才会加载模块中相应的接口取值。
换句话说,ESM模块规则有点像Unix系统的“符号连接”,原始值变了,import 输入的值也会跟着变。导入的变量绑定其所在的模块,不会缓存值。不同脚本加载同一个模块得到的是同一个实例。因此ESM设定了不能修改导入值的只读规则。
CJS 导入的是导出值的浅拷贝副本,而ESM导入是导出值的实时只读引用。
静态型的 import
是初始化加载依赖项的最优选择, 静态模块结构 更容易从代码静态分析工具和 tree shaking 中受益。而且自动支持模块间的循环依赖。
在用 webpack、Rollup 这样的模块打包器时,证明ESM模块可以更高效地组合:
- 加载所有模块时,import 查找变量是静态检索,比 require() 的动态检索快很多。
- 压缩绑定的文件比压缩单独的文件效率更高。
- 在绑定过程中,通过删除未使用的出口代码,从而节省大量空间。
在浏览器中,import 语句只能在 <script type="module"></script>
标签中使用 (<script type="module"> 拥有自己的局部作用域)。或者写在.mjs
扩展名的文件里。
语法:
ESM模块有两种导出方式:命名导出(每个模块可以几个)和默认导出(每个模块一个)。可以同时使用两者,但通常最好将它们分开。
命名导出:export
// 1. 关键字标记声明
// 导出单个声明常量/变量
export const name1 = … // 用 let, var 定义变量也可,不过通常还是常量
export let name2 = …
// 导出声明函数
export function functionName() {...}
// 导出声明类
export class className {...}
// 2. 用对象列出要导出的所有内容
// name1,name2... 是事先定义好的标识符。如果在一个模块要导出多个值,同时数量不算多时推荐这样做,代码结构会比较清晰
const name1 = …
const name2 = …
export { name1, name2, …, nameN }
// 重命名导出
export { variable1 as name1, variable2 as name2, …, nameN }
- name1… nameN、functionName、className —— 要导出的“标识符”。在其他脚本
import
时需要用这些“标识符”进行针对性的导入
直接在 export 关键字后面声明的语句叫 内联导出
export const name1 = 11
export function foo() {}
// 等效于
const name1 = 11
function foo() {}
export { name1, foo };
同时不能直接 export 一个对象,如export { name1: 1, name2: 2 }
,export { ... }
只允许放用,
分隔的标识符。因为不能通过对象强制执行静态关联,从而失去所有静态模块结构相关的优势。
默认导出:export default
实质上是个语法糖。export default 命令就是将输出内容赋值给名为 default 的 变量,导出内容可以是任意表达式 (函数或Class也在内),在导入时可以随意为这个 default 更名。因为已经声明变量 default 了,后面就不能跟变量声明语句了,这一点要和 export 区分开。
expression(表达式) 属于 satement(语句),但 expression 是可以通过 evaluation 产生结果的。也就是说这个结果不是马上产生,而是需要时才会被evaluated。
简单判断:可以被当作参数传递的就是expression,一般是放在小括号里的(expression)
,而 statement 一般是放在大括号里的{ statement }
。expression 被放到函数体内就变成了 satement。
// 导出
// a.js
export default «expression»;
// 等效于
const a = «expression»;
export { a as default };
// 导入时:
import b from './a.js'
// 等效于
import { default as b } from './a';
默认导出的本意是让 import 时不受限于接口名称任意命名模块,通常用于整个模块的导出,如 React 组件。Vue组件则是把组件的数据和逻辑以一个对象的形式导出。默认导出简单类型的常量意义不大,几乎不用。命名导出和默认导出混用也存在,比如一个库是单个函数,但通过该函数的属性提供了其他服务:import _, { each } from 'underscore';
。
为了快速区分不同模块,以及导入时命名的统一,默认导出类和函数的时候还是建议命名 (尽管可以匿名)。
同时一个js只能有一个 export default,多个并存只有最后一个生效。以下为演示故没有将多个注释掉。
个人推荐的方式有以下几种:
// 导出函数
export default function fun() {}
// 如果是箭头函数,我写 React 组件都这样用
const funArrow = () => {}
export default funArrow
// 导出类
export default class Dog {}
// 导出对象
const foo = 'foo1'
const bar = 'bar2'
export default { foo, bar } // 实际导出的是 { foo: foo, bar: bar }
// 这里的 foo 和 bar 不是 标识符,只是键值对同名的简写,有本质区别, 注意区分
// 也可以直接将值写在对象里,Vue组件的做法
export default {
name: 'foo',
data: {...}
}
导入 import 类型:
默认导入:对应默认导出,导入名可以自定义
import customName from 'src/my_lib';
// src/my_lib.js
export default anyThing // 任意类型,函数、类、对象 及表达式
命名空间导入:通过 *
导入完整的模块,把模块中的全部属性和方法放到一个对象中 (每个命名导出为一个属性) 进行导入。
import * as my_lib from 'src/my_lib';
console.log(my_lib) // { a, fun }
console.log(my_lib.a) // 'aaa'
my.lib.fun()
// src/my_lib.js
export const a = 'aaa'
export function fun() { ... }
命名导入,可以通过 as 重命名导出标识符:
import { name1, name2 as fun } from 'src/my_lib';
console.log(name1)
fun()
// src/my_lib.js
export const name1 = 'aaa'
export function name2() { ... }
空导入:仅加载模块,不导入任何内容。程序中的第一个此类导入将执行模块的主体。
import 'src/my_lib';
组合导入:导入顺序是固定的,默认导出必须始终在第一个。
// 将默认导入与名称空间导入相结合:
import theDefault, * as my_lib from 'src/my_lib';
// 将默认导入与命名导入结合
import theDefault, { name1, name2 } from 'src/my_lib';
-
as
—— 重命名导出“标识符”。比如需要同时导入两个同名的 export 接口,用 as 重命名其中一个就可以解决冲突 -
from
后面的字符串是要导入的模块。通常是包含目标模块的.js文件的相对或绝对路径。
每次 import 都是到导出数据的实时连接。
//------ lib.js ------
export let counter = 3;
export function incCounter() {
counter++;
}
//------ main1.js ------
import { counter, incCounter } from './lib';
// The imported value `counter` is live
console.log(counter); // 3
incCounter();
console.log(counter); // 4
// The imported value can’t be changed
counter++; // TypeError
如果通过*
导入模块对象,会得到相同的结果:
//------ main2.js ------
import * as lib from './lib';
// The imported value `counter` is live
console.log(lib.counter); // 3
lib.incCounter();
console.log(lib.counter); // 4
// The imported value can’t be changed
lib.counter++; // TypeError
请注意,虽然不允许直接更改导入的值 (即重新赋值),但是可以修改它们引用的对象。例如:
//------ lib.js ------
export let obj = {};
//------ main.js ------
import { obj } from './lib';
obj.prop = 123; // OK
obj = {}; // TypeError
ES6 Module 的 import()
静态 import 命令会被JS引擎静态分析,先于其他代码执行,做不到运行时加载。而且 import 和 export 语句都必须始终位于模块的顶层,无法按需执行。为了实现类似于require的动态加载,从而提高首屏加载速度,就出现了一个import()函数方法。import()
括号内接收的参数和import
语句from
后面的一致。
按照一定的条件或者按需加载模块的时候,动态import() 是非常有用的。
import()
函数是动态按需加载,它返回一个 Promise 对象。import()
是运行时执行,什么时候运行到这一句,才会加载指定的模块。因此通过 if 判断可以实现按条件import()
模块 。除了模块,还可以用来加载非模块的脚本。
import()
与所加载的模块没有静态连接关系,这点也与 import 语句不同 (import语句会建立静态引用)。import()
类似于 Node 的require()
,但区别是import()
为异步加载,而require()
是同步加载。
当出现以下的情况,一般就可以用动态import()
代替静态 import 了:
- 静态导入的模块很明显地降低了代码的加载速度/占用了大量系统内存并且被使用的可能性很低,或者并不需要马上使用它。
- 被导入的模块在加载时还不存在,需要异步获取
- 导入模块的标识符需要动态构建。(静态导入只能使用静态标识符)
- 被导入的模块有副作用(这个副作用,可以理解为模块中会直接运行的代码),这些副作用只有在触发了某些条件才被需要时。
另外请只在必要情况下采用动态导入。静态框架能更好地初始化依赖,而且更有利于静态分析工具和tree shaking发挥作用。
import('./modules/my-module.js')
.then(module => {
// Do something with the module.
});
因为是一个 promise,import()
也支持 await 关键字。
let module = await import('./modules/my-module.js');
获取模块接口
import()
加载模块成功以后,这个模块会作为一个对象,当作then
方法的参数。因此,可以使用对象解构赋值的语法,获取输出的命名接口。
import('./modules/my-module.js')
.then(({export1, export2}) => {
// ...
});
如上,export1
和export2
都是my-module.js
用export
导出的输出具名接口,可以直接解构获得。
如果要获取 default 默认导出,需要用default
属性获取:
import('./modules/my-module.js')
.then(module => {
console.log(module.default)
});
// 或者这样
import('./modules/my-module.js')
.then(({default: theDefault}) => {
console.log(theDefault);
});
总结
CJS 的 require() 和 exports
-
require()
为同步导入。 -
动态结构:导入和导出的对象可以在运行时通过变量动态生成,也可以把
require()/exports
放在 if 语句之类的代码块内实现按需加载/导出。 - 代码执行到
require()
会先把()
内的模块代码执行一遍,返回值是模块导出对象的浅拷贝副本。 -
require()
进来的属性副本,可以修改和删除,简单类型不会影响被导入模块,引用类型会改变导入模块数据。但require()
的目的主要是导入一些供使用的函数或常量,这样显然是不合理的,因此尽量不要试图修改模块源数据,并在导入时表明引入的是常量,如:const path = require('path')
- 需要用
exports.属性
导出并仔细地规划, 才能使模块循环依赖正常工作
ESM 的 import 和 export
- import 语句为同步导入。
- 静态模块结构(可以利用于消除无效代码,优化,静态检查等):导入和导出的关联关系在运行时不可更改。
- 在代码编译阶段(而非执行阶段)
import
语句就和模块建立了只读静态引用关系,且代码运行到import
不会执行模块的内容,而是当导出值被调用时才会真正执行对应模块。 - 不能修改 import 进来的对象,因为
import/export
输出的模块是动态绑定的常量,是只读的。但修改对象引用地址的属性还是可以的。如无特殊需要请不要这么做。 -
import/export
不能嵌套在任何块级作用域或函数作用域内,必须写在模块顶层(因为 import 会先于其他任何代码执行) -
import/export
语句不能有动态计算部分 - 不能直接在浏览器执行,需要写在
<script type="module"></script>
内 - 自动支持模块之间的循环依赖关系
尽管ESM模块规范大有优势,但鉴于很多库还在广泛使用CJS,我们仍需要理解require
和module.exports/exports
。自己在日常开发中使用import
和export default/export
即可,webpack 会帮你做兼容处理 (可以看到webpack自身是遵循CJS的,因此会在打包过程中先把esm转成cjs) 。期待全面支持ESM的一天~
参考:es6 modules