Node.jsとECMAScript Modules

2018 / 03 / 22

Edit
🚨 This article hasn't been updated in over a year
💁‍♀️ This post was copied from Hatena Blog

Node.js のバージョン 10 のリリースは 4/25 を予定しています。 また、ECMAScript Modules は Stability1(実験的)でリリースされます。

議論は以下で行われます。

GitHub - nodejs/modules: Node.js Modules Team Node.js Modules Team. Contribute to nodejs/modules development by creating an account on GitHub.
title

以下、ECMAScript Modules を ESM、 CommonJS Modules を CJS と略します。

覚えておくべきこと

ESM を使いたい場合は、拡張子を.mjsにする

.jsファイルでimport/exportは使えません。 ブラウザではtype="module"となりますが、Node.js では拡張子で判断します。

.mjsの拡張子は省略可能である

拡張子の探査順は ESM の時、.mjsが優先されます。 しかし、ブラウザでもそのコードを使いたい場合、拡張子の省略は Node 独自の機構であるため省略はしないほうがいいでしょう。

ESM のファイルをトップレベルでは CJS でインポート出来ない

トップレベルで、importを使うことは許されません。 しかし、dynamic importは例外的に CJS でも使えるので一応、CJS から ESM の読み込みは行えます。

CJS のファイルをインポートするのに named import は行えない

import {x} from 'y'の書き方はできません。 CJS のオブジェクトはdefaultで包まれるためです。 なので、もし行いたい場合は、一度defaultに alias をする必要があります。 webpack4 もそのように対応しています。

Babel でのトランスパイルはimport { readFile } from 'fs'等の書き方ができてしまうため、そのまま Node へ移すと壊れます。

ESM のパスは whatwg url に準拠している

Node9 で whawg url は Stability2 になり、ブラウザ同様にグローバルに URL オブジェクトが置かれました。 今までは、require('url').URLでした。 もしクエリー(?)が異なる場合は、たとえ同じファイルでも複数回ロードします。

import "./foo?query=1"; // ?query=1としてのfooがロードされる
import "./foo?query=2"; // ?query=2としてのfooがロードされる
import "file:///xxx/foo";
Node の変数である、__dirname__filename等が使えない

stage-3 のimport.metaを使いましょう。

GitHub - tc39/proposal-import-meta: import.meta proposal for JavaScript import.meta proposal for JavaScript. Contribute to tc39/proposal-import-meta development by creating...
title

// index.mjs
console.log(import.meta);
// { url: 'file:///xxx/index.mjs' }

.mjsは厳格モード(use strict)になる

仕様です。


その他、挙動が違う部分は下の早見表を参照してください。

実行方法

$ node -v
v10.0.0-pre
$ node --experimental-modules index.mjs

このように、今現在は--experimental-modulesというフラグが必要です。

パターンまとめ

インポートされるモジュール

// test.mjs
export const a = 1;
export default "dog";

ルートが CJS

// index.js

// === 🙅bad ===

// ESMのコードをCJSで呼び出すことはできません
const test = require("./test"); // Must use import to load ES Module

// CJSにESMのSyntaxは存在しません
import { a } from "./test"; // SyntaxError: Unexpected token {

// ---------------------------------------------------------

// === 🙆 good ===

console.log(this); // {}

// dynamic import
// CJS内でもdynamic importの使用は可能です
(async () => {
  const test = await import("./test");

  console.log(test); // [Module] { a: 1, default: 'dog' }
})();

ルートが ESM

// index.mjs

// === 🙅bad ===

// ESMにCJSのSyntaxは存在しません
const test = require("./test"); // ReferenceError: require is not defined

// __dirnameは定義されていないので、エラーとなります
console.log(__dirname);

// fsはNodeのネイティブモジュールであるため、CJSで書かれています
// モジュールがCJSの場合、named importは使えません
import { readdirSync } from "fs"; // syntaxError: The requested module 'fs' does not provide an export named 'readdirSync'

// ---------------------------------------------------------

// === 🙆 good ===

console.log(this); // undefined

import * as t from "./test";

console.log(import.meta); // { url: 'file:///xxxx/index.mjs' }

// whatwg urlに準拠しているので、urlと同様の書き方が可能です
import * as t from "file:///xxx/test";
console.log(t); // [Module] { a: 1, default: 'dog' }

// =========================
// CJSのモジュールをESMで入れる方法は以下のとおりです
import fs from "fs";
console.log(typeof fs.readdirSync);

// CJSはdefaultに包まれる
import * as fs from "fs";
console.log(typeof fs.default.readdirSync);

// defaultをfsにリネームする
import { default as fs } from "fs";
console.log(typeof fs.readdirSync);
// =========================

// dynamic import
(async () => {
  // whatwg urlでの指定が可能です
  // 今現在、file以外での取得は不可能です
  const baseURL = new URL("file://");
  baseURL.pathname = `${process.cwd()}/test.mjs`;

  const test = await import(baseURL);

  console.log(test); // [Module] { a: 1, default: 'dog' }
})();

挙動の早見表

CJS にはデフォルトで厳格モードがつかないので、このテーブルは CJS は厳格モードではない状態での比較です。

モジュール

CodeCJSESM
import('x')okok
import 'x';errorok
export {};errorok

タイミング

CodeTimingHoistedBlocking
require('x');syncnoyes
import 'x';untimed (async generally)yesyes
import('x');asyncnono

ESM では使えないメソッド・変数

以下のメソッド・変数は、CJS でのみ存在し、ESM では存在しないため、エラーになります。

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

予約語への操作

e.g. var let = 1;

CodeCJSESM
argumentsscope::localerror
arguments = []okerror
try {} catch (arguments) {}okerror
evalscope::localerror
eval = evalokerror
try {} catch (eval) {}okerror
implementsokerror
interfaceokerror
packageokerror
privateokerror
protectedokerror
publicokerror
staticokerror
awaitokerror
letokerror
returnerrorerror
yieldokerror
awaitokerror

厳格モード時に変数として使えなくなる予約語です。

  • arguments
  • eval
  • implements
  • interface
  • package
  • private
  • protected
  • public
  • static
  • let
  • yield
// index.js
var arguments;
arguments = [];

var eval = 1;
eval = eval;

// constは予約語ですが、letは違います
var implements, interface, package, private, protected, public,
    static, let, yield, await;

上記以外の許容されない記法

CodeCJSESMSCRIPT
with({}){}okerrorok
<!--\nokerrorok
-->\nokerrorok
0111okerrorok
(function (_, _) {})okerrorok
// index.js
// HTMLコメントはCJSでは使えますが、ESMでは使えません
<!--\n
-->\n
# CJS
$
# ESM
$ Module build failed: SyntaxError: Unexpected token (1:0)
> 1 | <!--\n
    | ^
  2 | -->\n
// index.js
0111;
# CJS
$
# ESM
$ SyntaxError: Octal literals are not allowed in strict mode.

評価に関する違い

パース、実行はできるが、評価結果が異なります。

CodeCJSESMSCRIPT
thismodule({})undefinedglobal
(function (){return this}())globalundefinedglobal
(function () {return typeof this;}).call(1)objectnumberobject
var xlocallocalglobal
var x = 0; eval('var x = 1'); x101
console.log(
  function () {
    return typeof this;
  }.call(1)
);
# CJS
$ object
# ESM
$ number

callthisを 1 として束縛し、スコープで包んだfunctionthisを返しその型を評価します。 thisの束縛がない場合は、スコープで包まれているため、typeof this === '[Function]'です。 CJS の場合は、[Number: 1]となり、ESM のときは 1 となります。 つまり、ESM のときはnew Number(1)とならないので、numberとなります。

さいごに

今後、Node 界隈では、.mjsという拡張子が主流になる未来が予想されます。(自分はあまり望んでませんでしたが。。。) このまま順当に行けば、12 には Stability2(安定的)に行ける気がするので、来年ぐらいから本番かなーと思っています:D