前言
作为 JS 中最重要的代码组织模式,了解 ES6 模块也是很有必要的。在最近的阅读学习过程中,对于模块这部分也略有心得,在此记录一下习得的知识。
知识点来自《你所不知道的 JavaScript 下卷》
旧方法
在程序编写过程中,我们有时候需要一个包含内部变量和函数的外层函数,来实现面向接口式的编程。例如:
1 | // 目标功能 |
每一次调用 Hello 我们都会生成一个新的实例,如果我们仅需要单例的话,此常见的变形便是闭包(IIFE)了,如:
1 | var hello = (function(name) { |
新方法
“新方法”其实有很多,比如 UMD、AMD和CommonJS等,但是此处仅仅介绍 ES6 的模块方法,毕竟基于语言官方推荐的模块导出引入,必将成为未来的主流方法。
支撑起 ES6 模块的两个主要关键字是 export 和 import,此两方法必须出现在所有代码块和函数的外面(如:不能放在 if 方法内),下面详细介绍一下。
导出方法
export 关键字即表示模块的导出。导出的方式有很多种,比如:
1 | // 命名导出 |
对于一个 ES6 模块来说,没有 export 表示的任何变量或者函数,其都是作用域内部私有的。
此外,一个模块内(即一个 js 文件内)只有一个默认导出方法,但是可以有多个命名导出的方法,下面就介绍一下“命名导出”。
命名导出
首先我们得注意的是,命名导出导出的是变量的引用,而非值的拷贝。例如:
1 | // bar.js |
对于之后引用 bar.js 文件的 num 属性时,我们获得的 num 值为 3。
此外,命名导出还有许多变体,例如:
1 | function foo() {} |
既然有更改导出的名称的写法,那么我们能不能更改默认导出的绑定内容呢?答案是可以的。我将其称之为“动态默认导出”。
动态默认导出
所谓动态默认导出,即它能够动态的改变默认导出的内容而不影响其余代码的更改。例如:
1 | let foo = 42; |
当倒入这个模块时, default 和 bar 会默认绑定到局部变量 foo 和 bar,即它们会暴露更新后的值 10 和 “cool”。
注:导出时刻的值是无关紧要的,导入时候的值也是,绑定是活连接,所以重要的是访问这个绑定时刻的当前值。
导出内容能够动态改变,那么这样能否减少我们对模块导入时的代码量呢?那么看看“动态模块导出”吧。
动态模块导出
对于公共文件夹内文件来说,初期文件不多的情况我们经常会这么引用:
1 | import ModuleA from './common/module-a'; |
然而,当文件越来越多时,这种写法就比较笨拙了,因此我们可以维护一个公共 index.ts 文件来中心化文件导出:
1 | // index.ts |
当然,这种写法基本能解决模块引入问题,然而缺点是每次新增文件后,都需要改 3 处,变动的点相对太多了。因此为了减少改变的代码量,我们可以使用“动态模块导出”法:
1 | // 动态模块导出 |
这样每次变动后,我们只需要新增一行即可,代码的改变量就很少了。
导入方法
与 export 相同,import 导入同样也有相应的变体,比如:
1 | // 全量导入 |
当然,我们还可以将默认导入和其他命名导入一起导入。加入我们有这么一个导出的模块,例如:
1 | export default function foo() {} |
那么导入这个模块的默认导出和它的命名导出的写法为:
1 | import Foo, { bar } from './module-a'; |
ES6 模块这些强烈建议的方法是,只从模块导入需要的具体绑定。如果一个模块提供了 10 个 API 方法,但是你只需要其中的 2 个,有些人坚信将所有的 API 绑定都导入进了是一种浪费。
因此他们提倡的是“窄导入”(没有 export default),这样除了代码更加清晰外,其另一个好处是使得静态分析和错误检测(比如意外使用了错误的绑定名称)更加健壮。
然而这种方法的缺点是比较繁复、每次新增东西时都得更新 import 方法(特别是需要全量导入的情况),因此 import 又有一种语法变体可以支持这种模块的导入,称为“命名空间导入”
命名空间导入
命名空间导入就是将模块内所有的导出内容(包括默认导出的内容)都归纳在一个命名空间内,如:
1 | // 导出模块 |
注意,如果对应的导出模块有 default 导出时,使用 * as xxx 全量引入时,我们可以通过 xxx.default 来获取/使用默认导出的内容。
模块依赖环
模块部分最令人疑惑的是 A 导入 B,然后 B 导入 A,这种情况是如何工作的。
首先“模块A”:
1 | // A 模块 |
然后是“模块B”:
1 | // B 模块 |
在 ES6 的模块下,改两声明处于不同的作用域内,因此其需要额外的工作来支持这样的循环引用。下面是粗略概念的意义上循环的 import 依赖如何生效和解析的过程:
- 如果先加载“模块A”,第一步是扫描这个文件分析所有的导出,这样就可以注册所有可以导入的绑定。然后处理 import … from “B”。
- 引擎加载“模块B”之后,会对它的导出绑定进行同样的分析。当看到 import … from “A”,它已经了解 “A” 的 API,所以可以验证 import 是否有效。现在它了解 “B” 的 API,就可以验证等待的 “A” 模块中 import … from “B” 的有效性。
现在让我们验证一下这两个模块:
1 | // 引用 foo |