1
0
mirror of https://github.com/ruanyf/es6tutorial.git synced 2025-05-24 10:22:23 +00:00

docs(module-loader): update Node.js' esm support

This commit is contained in:
ruanyf 2019-12-16 16:11:16 +08:00
parent c4f4570f67
commit a49a10ca48

View File

@ -1,6 +1,6 @@
# Module 的加载实现
上一章介绍了模块的语法,本章介绍如何在浏览器和 Node 之中加载 ES6 模块,以及实际开发中经常遇到的一些问题(比如循环加载)。
上一章介绍了模块的语法,本章介绍如何在浏览器和 Node.js 之中加载 ES6 模块,以及实际开发中经常遇到的一些问题(比如循环加载)。
## 浏览器加载
@ -108,7 +108,7 @@ const isNotModuleScript = this !== undefined;
## ES6 模块与 CommonJS 模块的差异
讨论 Node 加载 ES6 模块之前,必须了解 ES6 模块与 CommonJS 模块完全不同。
讨论 Node.js 加载 ES6 模块之前,必须了解 ES6 模块与 CommonJS 模块完全不同。
它们有两个重大差异。
@ -271,48 +271,292 @@ $ babel-node main.js
这就证明了`x.js``y.js`加载的都是`C`的同一个实例。
## Node 加载
## Node.js 加载
### 概述
Node 对 ES6 模块的处理比较麻烦,因为它有自己的 CommonJS 模块格式,与 ES6 模块格式是不兼容的。目前的解决方案是将两者分开ES6 模块和 CommonJS 采用各自的加载方案。
Node.js 对 ES6 模块的处理比较麻烦,因为它有自己的 CommonJS 模块格式,与 ES6 模块格式是不兼容的。目前的解决方案是将两者分开ES6 模块和 CommonJS 采用各自的加载方案。从 v13.2 版本开始Node.js 已经默认打开了 ES6 模块支持。
Node 要求 ES6 模块采用`.mjs`后缀文件名。也就是说,只要脚本文件里面使用`import`或者`export`命令,那么就必须采用`.mjs`后缀名。`require`命令不能加载`.mjs`文件,会报错,只有`import`命令才可以加载`.mjs`文件。反过来,`.mjs`文件里面也不能使用`require`命令,必须使用`import`。
Node.js 要求 ES6 模块采用`.mjs`后缀文件名。也就是说,只要脚本文件里面使用`import`或者`export`命令,那么就必须采用`.mjs`后缀名。Node.js 遇到`.mjs`文件,就认为它是 ES6 模块,默认启用严格模式,不必在每个模块文件顶部指定`"use strict"`。
目前,这项功能还在试验阶段。安装 Node v8.5.0 或以上版本,要用`--experimental-modules`参数才能打开该功能。
```bash
$ node --experimental-modules my-app.mjs
```
为了与浏览器的`import`加载规则相同Node 的`.mjs`文件支持 URL 路径。
如果不希望将后缀名改成`.mjs`,可以在项目的`package.json`文件中,指定`type`字段为`module`
```javascript
import './foo?query=1'; // 加载 ./foo 传入参数 ?query=1
{
"type": "module"
}
```
一旦设置了以后,该目录里面的 JS 脚本,就被解释用 ES6 模块。
```bash
# 解释成 ES6 模块
$ node my-app.js
```
如果这时还要使用 CommonJS 模块,那么需要将 CommonJS 脚本的后缀名都改成`.cjs`。如果没有`type`字段,或者`type`字段为`commonjs`,则`.js`脚本会被解释成 CommonJS 模块。
总结为一句话:`.mjs`文件总是以 ES6 模块加载,`.cjs`文件总是以 CommonJS 模块加载,`.js`文件的加载取决于`package.json`里面`type`字段的设置。
注意ES6 模块与 CommonJS 模块尽量不要混用。`require`命令不能加载`.mjs`文件,会报错,只有`import`命令才可以加载`.mjs`文件。反过来,`.mjs`文件里面也不能使用`require`命令,必须使用`import`
### main 字段
`package.json`文件有两个字段可以指定模块的入口文件:`main``exports`。比较简单的模块,可以只使用`main`字段,指定模块加载的入口文件。
```javascript
// ./node_modules/es-module-package/package.json
{
"type": "module",
"main": "./src/index.js"
}
```
上面代码指定项目的入口脚本为`./src/index.js`,它的格式为 ES6 模块。如果没有`type`字段,`index.js`就会被解释为 CommonJS 模块。
然后,`import`命令就可以加载这个模块。
```javascript
// ./my-app.mjs
import { something } from 'es-module-package';
// 实际加载的是 ./node_modules/es-module-package/src/index.js
```
上面代码中运行该脚本以后Node.js 就会到`./node_modules`目录下面,寻找`es-module-package`模块,然后根据该模块`package.json``main`字段去执行入口文件。
这时,如果用 CommonJS 模块的`require()`命令去加载`es-module-package`模块会报错,因为 CommonJS 模块不能处理`export`命令。
### exports 字段
`exports`字段的优先级高于`main`字段。它有多种用法。
1子目录别名
`package.json`文件的`exports`字段可以指定脚本或子目录的别名。
```javascript
// ./node_modules/es-module-package/package.json
{
"exports": {
"./submodule": "./src/submodule.js"
}
}
```
上面的代码指定`src/submodule.js`别名为`submodule`,然后就可以从别名加载这个文件。
```javascript
import submodule from 'es-module-package/submodule';
// 加载 ./node_modules/es-module-package/src/submodule.js
```
下面是子目录别名的例子。
```javascript
// ./node_modules/es-module-package/package.json
{
"exports": {
"./features/": "./src/features/"
}
}
import feature from 'es-module-package/features/x.js';
// 加载 ./node_modules/es-module-package/src/features/x.js
```
如果没有指定别名,就不能用“模块+脚本名”这种形式加载脚本。
```javascript
// 报错
import submodule from 'es-module-package/private-module.js';
// 不报错
import submodule from './node_modules/es-module-package/private-module.js';
```
2main 的别名
`exports`字段的别名如果是`.`,就代表模块的主入口,优先级高于`main`字段,并且可以直接简写成`exports`字段的值。
```javascript
{
"exports": {
".": "./main.js"
}
}
// 等同于
{
"exports": "./main.js"
}
```
由于`exports`字段只有支持 ES6 的 Node.js 才认识,所以可以用来兼容旧版本的 Node.js。
```javascript
{
"main": "./main-legacy.cjs",
"exports": {
".": "./main-modern.cjs"
}
}
```
上面代码中,老版本的 Node.js (不支持 ES6 模块)的入口文件是`main-legacy.cjs`,新版本的 Node.js 的入口文件是`main-modern.cjs`
**3条件加载**
利用`.`这个别名,可以为 ES6 模块和 CommonJS 指定不同的入口。目前,这个功能需要在 Node.js 运行的时候,打开`--experimental-conditional-exports`标志。
```javascript
{
"type": "module",
"exports": {
".": {
"require": "./main.cjs",
"default": "./main.js"
}
}
}
```
上面代码中,别名`.``require`条件指定`require()`命令的入口文件(即 CommonJS 的入口),`default`条件指定其他情况的入口(即 ES6 的入口)。
上面的写法可以简写如下。
```javascript
{
"exports": {
"require": "./main.cjs",
"default": "./main.js"
}
}
```
注意,如果同时还有其他别名,就不能采用简写,否则或报错。
```javascript
{
// 报错
"exports": {
"./feature": "./lib/feature.js",
"require": "./main.cjs",
"default": "./main.js"
}
}
```
### ES6 模块加载 CommonJS 模块
目前,一个模块同时支持 ES6 和 CommonJS 两种格式的常见方法是,`package.json`文件的`main`字段指定 CommonJS 入口,给 Node.js 使用;`module`字段指定 ES6 模块入口,给打包工具使用,因为 Node.js 不认识`module`字段。
有了上一节的条件加载以后Node.js 本身就可以同时处理两种模块。
```javascript
// ./node_modules/pkg/package.json
{
"type": "module",
"main": "./index.cjs",
"exports": {
"require": "./index.cjs",
"default": "./wrapper.mjs"
}
}
```
上面代码指定了 CommonJS 入口文件`index.cjs`,下面是这个文件的代码。
```javascript
// ./node_modules/pkg/index.cjs
exports.name = 'value';
```
然后ES6 模块可以加载这个文件。
```javascript
// ./node_modules/pkg/wrapper.mjs
import cjsModule from './index.cjs';
export const name = cjsModule.name;
```
注意,`import`命令加载 CommonJS 模块,只能整体加载,不能只加载单一的输出项。
```javascript
// 正确
import packageMain from 'commonjs-package';
// 报错
import { method } from 'commonjs-package';
```
还有一种变通的加载方法,就是使用 Node.js 内置的`module.createRequire()`方法。
```javascript
// 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
```
上面代码中ES6 模块通过`module.createRequire()`方法可以加载 CommonJS 模块
### CommonJS 模块加载 ES6 模块
CommonJS 的`require`命令不能加载 ES6 模块,会报错,只能使用`import()`这个方法加载。
```javascript
(async () => {
await import('./my-app.mjs');
})();
```
上面代码可以在 CommonJS 模块中运行。
### Node.js 的内置模块
Node.js 的内置模块可以整体加载,也可以加载指定的输出项。
```javascript
// 整体加载
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);
}
});
```
### 加载路径
ES6 模块的加载路径必须给出脚本的完整路径,不能省略脚本的后缀名。`import`命令和`package.json`文件的`main`字段如果省略脚本的后缀名,会报错。
```javascript
// ES6 模块中将报错
import { something } from './index';
```
为了与浏览器的`import`加载规则相同Node.js 的`.mjs`文件支持 URL 路径。
```javascript
import './foo.mjs?query=1'; // 加载 ./foo 传入参数 ?query=1
```
上面代码中,脚本路径带有参数`?query=1`Node 会按 URL 规则解读。同一个脚本只要参数不同,就会被加载多次,并且保存成不同的缓存。由于这个原因,只要文件名中含有`:``%``#``?`等特殊字符,最好对这些字符进行转义。
目前Node 的`import`命令只支持加载本地模块(`file:`协议),不支持加载远程模块。
如果模块名不含路径,那么`import`命令会去`node_modules`目录寻找这个模块。
```javascript
import 'baz';
import 'abc/123';
```
如果模块名包含路径,那么`import`命令会按照路径去寻找这个名字的脚本文件。
```javascript
import 'file:///etc/config/app.json';
import './foo';
import './foo?search';
import '../bar';
import '/baz';
```
如果脚本文件省略了后缀名,比如`import './foo'`Node 会依次尝试四个后缀名:`./foo.mjs``./foo.js``./foo.json``./foo.node`。如果这些脚本文件都不存在Node 就会去加载`./foo/package.json``main`字段指定的脚本。如果`./foo/package.json`不存在或者没有`main`字段,那么就会依次加载`./foo/index.mjs``./foo/index.js``./foo/index.json``./foo/index.node`。如果以上四个文件还是都不存在,就会抛出错误。
目前Node.js 的`import`命令只支持加载本地模块(`file:`协议)和`data:`协议,不支持加载远程模块。另外,脚本路径只支持相对路径,不支持绝对路径(即以`/``//`开头的路径)。
最后Node 的`import`命令是异步加载,这一点与浏览器的处理方法相同。
@ -331,166 +575,6 @@ ES6 模块应该是通用的,同一个模块不用修改,就可以用在浏
- `__filename`
- `__dirname`
如果你一定要使用这些变量,有一个变通方法,就是写一个 CommonJS 模块输出这些变量,然后再用 ES6 模块加载这个 CommonJS 模块。但是这样一来,该 ES6 模块就不能直接用于浏览器环境了,所以不推荐这样做。
```javascript
// expose.js
module.exports = {__dirname};
// use.mjs
import expose from './expose.js';
const {__dirname} = expose;
```
上面代码中,`expose.js`是一个 CommonJS 模块,输出变量`__dirname`,该变量在 ES6 模块之中不存在。ES6 模块加载`expose.js`,就可以得到`__dirname`
### ES6 模块加载 CommonJS 模块
CommonJS 模块的输出都定义在`module.exports`这个属性上面。Node 的`import`命令加载 CommonJS 模块Node 会自动将`module.exports`属性,当作模块的默认输出,即等同于`export default xxx`
下面是一个 CommonJS 模块。
```javascript
// a.js
module.exports = {
foo: 'hello',
bar: 'world'
};
// 等同于
export default {
foo: 'hello',
bar: 'world'
};
```
`import`命令加载上面的模块,`module.exports`会被视为默认输出,即`import`命令实际上输入的是这样一个对象`{ default: module.exports }`
所以,一共有三种写法,可以拿到 CommonJS 模块的`module.exports`
```javascript
// 写法一
import baz from './a';
// baz = {foo: 'hello', bar: 'world'};
// 写法二
import {default as baz} from './a';
// baz = {foo: 'hello', bar: 'world'};
// 写法三
import * as baz from './a';
// baz = {
// get default() {return module.exports;},
// get foo() {return this.default.foo}.bind(baz),
// get bar() {return this.default.bar}.bind(baz)
// }
```
上面代码的第三种写法,可以通过`baz.default`拿到`module.exports``foo`属性和`bar`属性就是可以通过这种方法拿到了`module.exports`
下面是一些例子。
```javascript
// b.js
module.exports = null;
// es.js
import foo from './b';
// foo = null;
import * as bar from './b';
// bar = { default:null };
```
上面代码中,`es.js`采用第二种写法时,要通过`bar.default`这样的写法,才能拿到`module.exports`
```javascript
// c.js
module.exports = function two() {
return 2;
};
// es.js
import foo from './c';
foo(); // 2
import * as bar from './c';
bar.default(); // 2
bar(); // throws, bar is not a function
```
上面代码中,`bar`本身是一个对象,不能当作函数调用,只能通过`bar.default`调用。
CommonJS 模块的输出缓存机制,在 ES6 加载方式下依然有效。
```javascript
// foo.js
module.exports = 123;
setTimeout(_ => module.exports = null);
```
上面代码中,对于加载`foo.js`的脚本,`module.exports`将一直是`123`,而不会变成`null`
由于 ES6 模块是编译时确定输出接口CommonJS 模块是运行时确定输出接口,所以采用`import`命令加载 CommonJS 模块时,不允许采用下面的写法。
```javascript
// 不正确
import { readFile } from 'fs';
```
上面的写法不正确,因为`fs`是 CommonJS 格式,只有在运行时才能确定`readFile`接口,而`import`命令要求编译时就确定这个接口。解决方法就是改为整体输入。
```javascript
// 正确的写法一
import * as express from 'express';
const app = express.default();
// 正确的写法二
import express from 'express';
const app = express();
```
### CommonJS 模块加载 ES6 模块
CommonJS 模块加载 ES6 模块,不能使用`require`命令,而要使用`import()`函数。ES6 模块的所有输出接口,会成为输入对象的属性。
```javascript
// es.mjs
let foo = { bar: 'my-default' };
export default foo;
// cjs.js
const es_namespace = await import('./es.mjs');
// es_namespace = {
// get default() {
// ...
// }
// }
console.log(es_namespace.default);
// { bar:'my-default' }
```
上面代码中,`default`接口变成了`es_namespace.default`属性。
下面是另一个例子。
```javascript
// es.js
export let foo = { bar:'my-default' };
export { foo as bar };
export function f() {};
export class c {};
// cjs.js
const es_namespace = await import('./es');
// es_namespace = {
// get foo() {return foo;}
// get bar() {return foo;}
// get f() {return f;}
// get c() {return c;}
// }
```
## 循环加载
“循环加载”circular dependency指的是`a`脚本的执行依赖`b`脚本,而`b`脚本的执行又依赖`a`脚本。