Node中的ES6模块规范实现

Node中的ES6模块规范实现

概述

Node 对 ES6模块的处理比较麻烦,因为它有自己的 CommonJS模块格式,与 ES6模块格式是不兼容的。目前的解决方案是,将两者分开,ES6模块和 CommonJS 采用各自的加载方案。

Node 要求 ES6模块采用.mjs后缀文件名。也就是说,只要脚本文件里面使用import或者export命令,那么就必须采用.mjs后缀名。require命令不能加载.mjs文件,会报错,只有import命令才可以加载.mjs文件。反过来,.mjs文件里面也不能使用require命令,必须使用import

目前,这项功能还在试验阶段。安装 Node v8.5.0 或以上版本,要用--experimental-modules参数才能打开该功能。

$ node --experimental-modules my-app.mjs

为了与浏览器的import加载规则相同,Node 的.mjs文件支持 URL 路径。

import './foo?query=1'; // 加载 ./foo 传入参数 ?query=1

上面代码中,脚本路径带有参数?query=1,Node 会按 URL 规则解读。同一个脚本只要参数不同,就会被加载多次,并且保存成不同的缓存。由于这个原因,只要文件名中含有:%#?等特殊字符,最好对这些字符进行转义。

目前,Node 的import命令只支持加载本地模块(file:协议),不支持加载远程模块。

如果模块名不含路径,那么import命令会去node_modules目录寻找这个模块。

import 'baz';
import 'abc/123';

如果模块名包含路径,那么import命令会按照路径去寻找这个名字的脚本文件。

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.jsonmain字段指定的脚本。如果./foo/package.json不存在或者没有main字段,那么就会依次加载./foo/index.mjs./foo/index.js./foo/index.json./foo/index.node。如果以上四个文件还是都不存在,就会抛出错误。

最后,Node 的import命令是异步加载,这一点与浏览器的处理方法相同。

内部变量

ES6模块应该是通用的,同一个模块不用修改,就可以用在浏览器环境和服务器环境。为了达到这个目标,Node 规定 ES6模块之中不能使用 CommonJS模块的特有的一些内部变量。

首先,就是this关键字。ES6模块之中,顶层的this指向undefined;CommonJS模块的顶层this指向当前模块,这是两者的一个重大差异。

其次,以下这些顶层变量在 ES6模块之中都是不存在的。

  • arguments
  • require
  • module
  • exports
  • __filename
  • __dirname

如果你一定要使用这些变量,有一个变通方法,就是写一个 CommonJS模块输出这些变量,然后再用 ES6模块加载这个 CommonJS模块。但是这样一来,该 ES6模块就不能直接用于浏览器环境了,所以不推荐这样做。

// 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模块。

// a.js
module.exports = {
  foo: 'hello',
  bar: 'world'
};

// 等同于
export default {
  foo: 'hello',
  bar: 'world'
};

import命令加载上面的模块,module.exports会被视为默认输出,即import命令实际上输入的是这样一个对象{ default: module.exports }

所以,一共有三种写法,可以拿到 CommonJS模块的module.exports

// 写法一
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.exportsfoo属性和bar属性就是可以通过这种方法拿到了module.exports

下面是一些例子。

// 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

// 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 加载方式下依然有效。

// foo.js
module.exports = 123;
setTimeout(_ => module.exports = null);

上面代码中,对于加载foo.js的脚本,module.exports将一直是123,而不会变成null

由于 ES6模块是编译时确定输出接口,CommonJS模块是运行时确定输出接口,所以采用import命令加载 CommonJS模块时,不允许采用下面的写法。

// 不正确
import { readfile } from 'fs';

上面的写法不正确,因为fs是 CommonJS 格式,只有在运行时才能确定readfile接口,而import命令要求编译时就确定这个接口。解决方法就是改为整体输入。

// 正确的写法一
import * as express from 'express';
const app = express.default();

// 正确的写法二
import express from 'express';
const app = express();

CommonJS模块加载 ES6模块

CommonJS模块加载 ES6模块,不能使用require命令,而要使用import()函数。ES6模块的所有输出接口,会成为输入对象的属性。

// es.mjs
let foo = { bar: 'my-default' };
export default foo;
foo = null;

// cjs.js
const es_namespace = await import('./es');
// es_namespace = {
//   get default() {
//     ...
//   }
// }
console.log(es_namespace.default);
// { bar:'my-default' }

上面代码中,default接口变成了es_namespace.default属性。另外,由于存在缓存机制,es.mjsfoo的重新赋值没有在模块外部反映出来。

下面是另一个例子。


// 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;}
// }