JS模块
本文主要内容翻译自《Exploring ES6-upgrade to the next version of javascript》
模块系统
- 定义模块接口,通过接口
- 隐藏模块的内部实现,使模块的使用者无需关注模块内部的实现细节。同时,隐藏模块的内部实现,避免有可能产生的副作用和对bug的不必要修改
ES6之前
- CommonJS模块规范:主要在Node.js中实现(Node中也有一些超出CommonJS的特性)。主要特征
- 紧凑的语法(可以理解为简单的语法)
- 为同步加载和服务端所设计
- Asynchronous Module Definition(AMD):最流行的实现是RequireJS。主要特征:
- 略微复杂的语法,使得AMD能够脱离eval()(或者编译步骤)运行
ES6 Modules
ES6的目标是设计能够使CommonJS和AMD用户都满意的规范:
- 类似CommonJS,语法简单,偏好单一导出,支持循环依赖
- 类似AMD,直接支持异步加载,支持可配置加载
内置在该语言允许ES6模块超越CommonJS和AMD(细节稍后解释):
- 语法比CommonJS更简单
- 结构能够被静态分析(可用于静态检查,优化等等)
- 对于循环依赖的支持比CommonJS做得更好
ES6模块分为两部分:
- 声明式语法(导入和导出)
- 程序加载器API:配置如何加载模块,并有条件的加载模块
ES6模块基础
有两种导出:命名导出(每个模块可有多个)和默认导出(每个模块只一个)
命名导出
模块可以通过在声明前加上关键字export来导出多个内容。这些导出由它们的名称来区分,称为命名导出。
//------ lib.js ------ export const sqrt = Math.sqrt; export function square(x) { return x * x; } export function diag(x, y) { return sqrt(square(x) + square(y)); } //------ main.js ------ import { square, diag } from 'lib'; console.log(square(11)); // 121 console.log(diag(4, 3)); // 5
这是最简单的一种导出方式,当然还有其他方式(稍后详细介绍)。
如果你愿意,你也可以导入整个模块,并通过属性符号引用它的命名导出:
//------ main.js ------ import * as lib from 'lib'; console.log(lib.square(11)); // 121 console.log(lib.diag(4, 3)); // 5
同样的功能用CommonJS完成,如下所示
//------ lib.js ------ var sqrt = Math.sqrt; function square(x) { return x * x; } function diag(x, y) { return sqrt(square(x) + square(y)); } module.exports = { sqrt: sqrt, square: square, diag: diag, }; //------ main.js ------ var square = require('lib').square; var diag = require('lib').diag; console.log(square(11)); // 121 console.log(diag(4, 3)); // 5
默认导出(每个模块一个)
只导出单个值的模块在Node.js社区中非常流行。但是它们在前端开发中也很常见,在前端开发中,您通常有用于模型和组件的类,每个模块有一个类。ES6模块可以选择默认导出,即主导出值。默认导出尤其容易导入。
下面的例子导出一个单独函数
//------ myFunc.js ------ export default function () {} // no semicolon! //------ main1.js ------ import myFunc from 'myFunc'; myFunc();
如果导出一个类
//------ MyClass.js ------ export default class {} // no semicolon! //------ main2.js ------ import MyClass from 'MyClass'; const inst = new MyClass();
默认导出有两种方式:
- 标签声明导出
- 直接导出值
标签声明导出
你可以将关键字exports放在任何function声明前(或者genarator function声明前面),从而默认导出该函数
export default function foo() {} // no semicolon! export default class Bar {} // no semicolon!
在这种情况下,也可以省略名称。这使得default导出成为JavaScript唯一具有匿名函数声明和匿名类声明的地方:
export default function () {} // no semicolon! export default class {} // no semicolon!
为什么是匿名函数声明而不是匿名函数表达式?
当您查看前面两行代码时,您会期望export默认操作数是表达式。它们只是出于一致性的原因而声明:操作数可以被命名为声明,将它们的匿名版本解释为表达式将会令人困惑(甚至比引入新的声明类型更令人困惑)。
如果你期望操作数被解释为表达式,可以使用括号:
export default (function () {}); export default (class {});
直接导出值
这些值是表达式产生的:
export default 'abc'; export default foo(); export default /^xyz$/; export default 5 * 7; export default { no: false, yes: true };
他们都遵循这样的结构
export default «expression»;
等同于
const __default__ = «expression»; export { __default__ as default }; // (A)
为什么有两种默认导出的形式?
引入第二种默认导出风格是因为如果声明多个变量,变量声明就不能有意义地转换为默认导出:
export default const foo = 1, bar = 2, baz = 3; // not legal JavaScript!
三个变量foo、bar和baz中的哪一个是默认导出?
Imports和Exports必须在顶层
稍后将更详细地解释,ES6模块的结构是静态的,您不能有条件地导入或导出东西。这带来了很多好处。
这一限制是通过只允许在模块的顶层导入和导出来实现的:
if (Math.random()) { import 'foo'; // SyntaxError } // You can’t even nest `import` and `export` // inside a simple block: { import 'foo'; // SyntaxError
import命令被提升
模块的imports会被提升(在内部被提升到当前作用域的最开始)。因此,import放在哪里都可以
foo();
import { foo } from 'my_module';
Imports是exports的只读视图
ES6模块的导入是导出实体上的只读视图。这意味着到模块主体中声明的变量的连接仍然是活动的,如下面的代码所示:
//------ lib.js ------ export let counter = 3; export function incCounter() { counter++; } //------ main.js ------ import { counter, incCounter } from './lib'; // The imported value `counter` is live console.log(counter); // 3 incCounter(); console.log(counter); // 4
后面的部分将解释它是如何工作的。
Imports作为视图有点如下:
- 支持循环依赖,即使是无效的导入
- 有效导入和无效 导入工作原理是相同的(他们都是双向的)
- 您可以将代码分割成多个模块,它将继续工作(只要您不尝试更改导入的值)
支持循环依赖
两个模块,模块A和模块B,如果A导入B,B导入A,即形成循环依赖。应该尽量避免出现这种情况——模块A和模块B会紧密耦合,他们只能被一起使用和进化。
为什么支持循环依赖呢?因为这是一个无法绕开的问题(当模块较多的情况下,难免出现循环依赖),稍后详解说明
我们来看一下CommonJS和ES6是如何支持循环依赖的。
CommonJS的循坏依赖
下面的代码能够正确的处理两个循环依赖的模块a和b
//------ a.js ------ var b = require('b'); function foo() { b.bar(); } exports.foo = foo; //------ b.js ------ var a = require('a'); // (i) function bar() { if (Math.random()) { a.foo(); // (ii) } } exports.bar = bar;
如果在第i行,首先导入模块a,模块b会在a的exports属性被赋值之前就导出了a的exprots。因此,b无法访问a.foo方法,但是一旦a执行完毕该属性就会存在。如果bar()在后面被调用,那么第ii行中的方法调用就可以工作。
一般而言,使用循环依赖时候需要注意,不能在模块主体中访问导入。这个道理同样适用于ES6模块。
CommonJS方法的局限性:
- Nodejs形式的的导出单个值不起作用。在这里,导出的是单个值而不是对象:(这里说的就是CommonJS导出的是值的拷贝,而不是值的引用)
module.exports = function () { ··· };
如果模块a向上面这样导出,模块b中的变量a一旦被赋值将不会再发生改变。始终指向最初导出的对象。
这里说的就是CommonJS导出的是值的拷贝,而不是值的引用
- 不能直接使用命名导出。所以,模块b不能像下面这样导入foo
var foo = require('a').foo;
foo将会是undefined。换句话说,你只能通过a.foo引用foo。
ES6的循环依赖
ES6自动支持循环依赖。也就是说,它们没有上一节提到的CommonJS模块的两个限制,默认导出,以及未限定名称的导入(下面第i行和第iii行)。因此,您可以实现如下循环依赖于彼此的模块。
//------ a.js ------ import {bar} from 'b'; // (i) export function foo() { bar(); // (ii) } //------ b.js ------ import {foo} from 'a'; // (iii) export function bar() { if (Math.random()) { foo(); // (iv) } }
这段代码是有效的,因为正如前一节所解释的,导入是关于导出的视图。这意味着即使是不合格的导入(如第2行中的bar和第4行中的foo)也是引用原始数据的间接引用。因此,面对循环依赖关系,无论您是通过非限定导入访问命名导出,还是通过它的模块访问命名导出,都没有关系:在这两种情况中都涉及到间接,而且它总是有效的。
导入和导出的细节
未完待续……
参考
- 《javascript 忍者秘籍—https://livebook.manning.com/#!/book/secrets-of-the-javascript-ninja-second-edition/
- 《exploring Es6-upgrade to the next version of javascript》—https://exploringjs.com/es6/
- 《understanding ECMAScript》—https://github.com/nzakas/understandinges6
- 《You-Dont-Know-JS》—https://github.com/getify/You-Dont-Know-JS
- 《pritical modern javascript》—https://github.com/mjavascript/practical-modern-javascript