沐光

记录在前端之路的点点滴滴

ES6 模块

前言

作为 JS 中最重要的代码组织模式,了解 ES6 模块也是很有必要的。在最近的阅读学习过程中,对于模块这部分也略有心得,在此记录一下习得的知识。

知识点来自《你所不知道的 JavaScript 下卷》

旧方法

在程序编写过程中,我们有时候需要一个包含内部变量和函数的外层函数,来实现面向接口式的编程。例如:

1
2
3
4
5
6
7
8
9
10
11
// 目标功能
function Hello(name) {
function greeting() {
console.log('hello' + name + '!');
}

// 公共 API
return {
greeting: greeting
}
}

每一次调用 Hello 我们都会生成一个新的实例,如果我们仅需要单例的话,此常见的变形便是闭包(IIFE)了,如:

1
2
3
4
5
6
7
8
9
10
11
12
var hello = (function(name) {
function greeting() {
console.log('hello' + name + '!');
}

// 公共 API
return {
greeting: greeting
}
})('katty');

hello.greeting(); // hello katty !

新方法

“新方法”其实有很多,比如 UMD、AMD和CommonJS等,但是此处仅仅介绍 ES6 的模块方法,毕竟基于语言官方推荐的模块导出引入,必将成为未来的主流方法。

支撑起 ES6 模块的两个主要关键字是 export 和 import,此两方法必须出现在所有代码块和函数的外面(如:不能放在 if 方法内),下面详细介绍一下。

导出方法

export 关键字即表示模块的导出。导出的方式有很多种,比如:

1
2
3
4
5
// 命名导出
export const arrPos = 1;

// 默认导出
export default function bar() {}

对于一个 ES6 模块来说,没有 export 表示的任何变量或者函数,其都是作用域内部私有的。

此外,一个模块内(即一个 js 文件内)只有一个默认导出方法,但是可以有多个命名导出的方法,下面就介绍一下“命名导出”。

命名导出

首先我们得注意的是,命名导出导出的是变量的引用,而非值的拷贝。例如:

1
2
3
// bar.js
export let num = 2;
num = 3;

对于之后引用 bar.js 文件的 num 属性时,我们获得的 num 值为 3。

此外,命名导出还有许多变体,例如:

1
2
3
4
5
6
7
8
function foo() {}
function baz() {}

// ES6 的简略写法
export { foo, baz }

// 更改导出名称
export { foo as bar, baz }

既然有更改导出的名称的写法,那么我们能不能更改默认导出的绑定内容呢?答案是可以的。我将其称之为“动态默认导出”。

动态默认导出

所谓动态默认导出,即它能够动态的改变默认导出的内容而不影响其余代码的更改。例如:

1
2
3
4
5
6
7
let foo = 42;
export { foo as default };

export let bar = "hello world";

foo = 10;
bar = "cool";

当倒入这个模块时, default 和 bar 会默认绑定到局部变量 foo 和 bar,即它们会暴露更新后的值 10 和 “cool”。

注:导出时刻的值是无关紧要的,导入时候的值也是,绑定是活连接,所以重要的是访问这个绑定时刻的当前值。

导出内容能够动态改变,那么这样能否减少我们对模块导入时的代码量呢?那么看看“动态模块导出”吧。

动态模块导出

对于公共文件夹内文件来说,初期文件不多的情况我们经常会这么引用:

1
import ModuleA from './common/module-a';

然而,当文件越来越多时,这种写法就比较笨拙了,因此我们可以维护一个公共 index.ts 文件来中心化文件导出:

1
2
3
4
5
6
7
8
9
10
11
12
13
// index.ts
import ModuleA from './module-a';
import ModuleB from './module-b';

export {
ModuleA,
ModuleB
}

export default {
ModuleA,
ModuleB
}

当然,这种写法基本能解决模块引入问题,然而缺点是每次新增文件后,都需要改 3 处,变动的点相对太多了。因此为了减少改变的代码量,我们可以使用“动态模块导出”法:

1
2
3
// 动态模块导出
export { default as ModuleA } from './module-a';
export { default as ModuleB } from './module-b';

这样每次变动后,我们只需要新增一行即可,代码的改变量就很少了。

导入方法

与 export 相同,import 导入同样也有相应的变体,比如:

1
2
3
4
5
6
7
8
9
// 全量导入
import ModuleA from './module-a';
import { default as ModuleA } from './module-a';

// 部分导入
import { foo } from './module-a';

// 变名导入
import { bar as foo } from './module-a';

当然,我们还可以将默认导入和其他命名导入一起导入。加入我们有这么一个导出的模块,例如:

1
2
3
export default function foo() {}

export function bar() {}

那么导入这个模块的默认导出和它的命名导出的写法为:

1
import Foo, { bar } from './module-a';

ES6 模块这些强烈建议的方法是,只从模块导入需要的具体绑定。如果一个模块提供了 10 个 API 方法,但是你只需要其中的 2 个,有些人坚信将所有的 API 绑定都导入进了是一种浪费。

因此他们提倡的是“窄导入”(没有 export default),这样除了代码更加清晰外,其另一个好处是使得静态分析和错误检测(比如意外使用了错误的绑定名称)更加健壮。

然而这种方法的缺点是比较繁复、每次新增东西时都得更新 import 方法(特别是需要全量导入的情况),因此 import 又有一种语法变体可以支持这种模块的导入,称为“命名空间导入”

命名空间导入

命名空间导入就是将模块内所有的导出内容(包括默认导出的内容)都归纳在一个命名空间内,如:

1
2
3
4
5
6
7
8
// 导出模块
// foo.ts
export function bar() {}
export let name = "myName";
export function baz() {}

// 导入模块
import * as foo from "foo"

注意,如果对应的导出模块有 default 导出时,使用 * as xxx 全量引入时,我们可以通过 xxx.default 来获取/使用默认导出的内容。

模块依赖环

模块部分最令人疑惑的是 A 导入 B,然后 B 导入 A,这种情况是如何工作的。

首先“模块A”:

1
2
3
4
5
6
7
// A 模块
import bar from "B";

export default function foo(x) {
if (x > 10) return bar(x - 1);
return x * 2;
}

然后是“模块B”:

1
2
3
4
5
6
7
// B 模块
import foo from "A";

export default function bar(y) {
if (y > 5) return foo(y / 2);
return y * 3;
}

在 ES6 的模块下,改两声明处于不同的作用域内,因此其需要额外的工作来支持这样的循环引用。下面是粗略概念的意义上循环的 import 依赖如何生效和解析的过程:

  • 如果先加载“模块A”,第一步是扫描这个文件分析所有的导出,这样就可以注册所有可以导入的绑定。然后处理 import … from “B”。
  • 引擎加载“模块B”之后,会对它的导出绑定进行同样的分析。当看到 import … from “A”,它已经了解 “A” 的 API,所以可以验证 import 是否有效。现在它了解 “B” 的 API,就可以验证等待的 “A” 模块中 import … from “B” 的有效性。

现在让我们验证一下这两个模块:

1
2
3
4
5
6
7
// 引用 foo
import foo from "A";
foo(25); // 11

// 引用 bar
import bar from "bar";
bar(25); // 11.5