es6-Module的语法

es6-Module 的语法

Module 的语法

概述

ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。

1
2
3
4
5
6
7
8
// 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 命令输入。

1
2
// ES6模块
import { stat, exists, readFile } from "fs";

上面代码的实质是从 fs 模块加载 3 个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。当然,这也导致了没法引用 ES6 模块本身,因为它不是对象。

严格模式

ES6 的模块自动采用严格模式,不管你有没有在模块头部加上”use strict”;。

export 命令

export 命令用于规定模块的对外接口

1
2
3
4
// profile.js
export var firstName = "Michael";
export var lastName = "Jackson";
export var year = 1958;

可以使用 as 关键字重命名。

1
2
3
4
5
6
7
8
function v1() { ... }
function v2() { ... }

export {
v1 as streamV1,
v2 as streamV2,
v2 as streamLatestVersion
};

export 语句输出的接口,与其对应的值是动态绑定关系。

1
2
export var foo = "bar";
setTimeout(() => (foo = "baz"), 500);

上面代码输出变量 foo,值为 bar,500 毫秒之后变成 baz。
这一点与 CommonJS 规范完全不同。CommonJS 模块输出的是值的缓存,不存在动态更新

最后,export 命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错,import 命令也是如此。这是因为处于条件代码块之中,就没法做静态优化了,违背了 ES6 模块的设计初衷。

import 命令

通过 import 命令加载模块。

1
import { lastName as surname } from "./profile.js";

import 命令具有提升效果。

1
2
3
foo();

import { foo } from "my_module";

模块的整体加载

1
import * as circle from "./circle";

export default 命令

1
2
3
4
// 输出
export default function() {
console.log("foo");
}
1
2
// 输入
import crc32 from "crc32";

export 与 import 的复合写法

1
2
3
4
5
export { foo, bar } from "my_module";

// 可以简单理解为
import { foo, bar } from "my_module";
export { foo, bar };

模块的继承

假设有一个 circleplus 模块,继承了 circle 模块。

1
2
3
4
5
6
7
// circleplus.js

export * from "circle";
export var e = 2.71828182846;
export default function(x) {
return Math.exp(x);
}

import()

require 是运行时加载模块,import 命令无法取代 require 的动态加载功能。ES2020 提案 引入 import()函数,支持动态加载模块。
import()返回一个 Promise 对象。

1
2
3
4
5
6
7
8
9
const main = document.querySelector("main");

import(`./section-modules/${someVariable}.js`)
.then(module => {
module.loadPageInto(main);
})
.catch(err => {
main.textContent = err.message;
});

import()类似于 Node 的 require 方法,区别主要是前者是异步加载,后者是同步加载。

Module 的加载实现

浏览器加载

<script>标签打开 defer 或 async 属性,脚本就会异步加载。
defer 与 async 的区别是:defer 要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行;async 一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。一句话,defer 是“渲染完再执行”,async 是“下载完就执行”。另外,如果有多个 defer 脚本,会按照它们在页面出现的顺序加载,而多个 async 脚本是不能保证加载顺序的。

浏览器加载 ES6 模块,也使用<script>标签,但是要加入 type=”module”属性。
带有 type=”module”的<script>,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了<script>标签的 defer 属性。

<script>标签的 async 属性也可以打开,这时只要加载完成,渲染引擎就会中断渲染立即执行。执行完成后,再恢复渲染。

ES6 模块与 CommonJS 模块的差异

它们有两个重大差异

  1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
  2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

第二个差异是因为 CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

第一个差异。CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。

Node.js 加载

概述

Node.js 对 ES6 模块的处理比较麻烦,因为它有自己的 CommonJS 模块格式,与 ES6 模块格式是不兼容的。目前的解决方案是,将两者分开,ES6 模块和 CommonJS 采用各自的加载方案。从 v13.2 版本开始,Node.js 已经默认打开了 ES6 模块支持。

.mjs 文件总是以 ES6 模块加载,.cjs 文件总是以 CommonJS 模块加载,.js 文件的加载取决于 package.json 里面 type 字段的设置。

main 字段

package.json 文件有两个字段可以指定模块的入口文件:main 和 exports。比较简单的模块,可以只使用 main 字段,指定模块加载的入口文件。

1
2
3
4
5
// ./node_modules/es-module-package/package.json
{
"type": "module",
"main": "./src/index.js"
}

上面代码指定项目的入口脚本为./src/index.js,它的格式为 ES6 模块。如果没有 type 字段,index.js 就会被解释为 CommonJS 模块。

exports 字段

exports 字段的优先级高于 main 字段。它有多种用法。
(1)子目录别名

package.json 文件的 exports 字段可以指定脚本或子目录的别名。

1
2
3
4
5
6
// ./node_modules/es-module-package/package.json
{
"exports": {
"./submodule": "./src/submodule.js"
}
}

上面的代码指定 src/submodule.js 别名为 submodule,然后就可以从别名加载这个文件。
(2)main 的别名
exports 字段的别名如果是.,就代表模块的主入口,优先级高于 main 字段,并且可以直接简写成 exports 字段的值。

1
2
3
4
5
6
7
8
9
10
{
"exports": {
".": "./main.js"
}
}

// 等同于
{
"exports": "./main.js"
}

(3)条件加载
利用.这个别名,可以为 ES6 模块和 CommonJS 指定不同的入口。目前,这个功能需要在 Node.js 运行的时候,打开–experimental-conditional-exports 标志。

1
2
3
4
5
6
7
8
9
{
"type": "module",
"exports": {
".": {
"require": "./main.cjs",
"default": "./main.js"
}
}
}

ES6 模块加载 CommonJS 模块

目前,一个模块同时支持 ES6 和 CommonJS 两种格式的常见方法是,package.json 文件的 main 字段指定 CommonJS 入口,给 Node.js 使用;module 字段指定 ES6 模块入口,给打包工具使用,因为 Node.js 不认识 module 字段。

有了上一节的条件加载以后,Node.js 本身就可以同时处理两种模块。

1
2
3
4
5
6
7
8
9
// ./node_modules/pkg/package.json
{
"type": "module",
"main": "./index.cjs",
"exports": {
"require": "./index.cjs",
"default": "./wrapper.mjs"
}
}

ES6 模块可以加载这个文件。

1
2
3
// ./node_modules/pkg/wrapper.mjs
import cjsModule from "./index.cjs";
export const name = cjsModule.name;

import 命令加载 CommonJS 模块,只能整体加载,不能只加载单一的输出项。

还有一种变通的加载方法,就是使用 Node.js 内置的 module.createRequire()方法。

1
2
3
4
5
6
7
8
9
10
// cjs.cjs
module.exports = "cjs";

// esm.mjs
import { createRequire } from "module";

const require = createRequire(import.meta.url);

const cjs = require("./cjs.cjs");
cjs === "cjs"; // true

CommonJS 模块加载 ES6 模块

CommonJS 的 require 命令不能加载 ES6 模块,会报错,只能使用 import()这个方法加载。

1
2
3
(async () => {
await import("./my-app.mjs");
})();

Node.js 的内置模块

Node.js 的内置模块可以整体加载,也可以加载指定的输出项。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 整体加载
import EventEmitter from "events";
const e = new EventEmitter();

// 加载指定的输出项
import { readFile } from "fs";
readFile("./foo.txt", (err, source) => {
if (err) {
console.error(err);
} else {
console.log(source);
}
});

循环加载

CommonJS 模块的循环加载

CommonJS 的一个模块,就是一个脚本文件。require 命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象。

1
2
3
4
5
6
{
id: '...',
exports: { ... },
loaded: true,
...
}

以后需要用到这个模块的时候,就会到 exports 属性上面取值。即使再次执行 require 命令,也不会再次执行该模块,而是到缓存之中取值。

CommonJS 模块的重要特性是加载时执行,即脚本代码在 require 的时候,就会全部执行。一旦出现某个模块被”循环加载”,就只输出已经执行的部分,还未执行的部分不会输出。

ES6 模块的循环加载

ES6 处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用 import 从一个模块加载变量(即 import foo from ‘foo’),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。

请看下面这个例子。

1
2
3
4
5
6
7
8
9
10
11
// a.mjs
import { bar } from "./b";
console.log("a.mjs");
console.log(bar);
export let foo = "foo";

// b.mjs
import { foo } from "./a";
console.log("b.mjs");
console.log(foo);
export let bar = "bar";

上面代码中,a.mjs 加载 b.mjs,b.mjs 又加载 a.mjs,构成循环加载。执行 a.mjs,结果如下。

1
2
3
$ node --experimental-modules a.mjs
b.mjs
ReferenceError: foo is not defined
0%