Node.jsでimport・export(ES6の構文)を使えるようにwebpack × Babelの設定をやってみた

はじめに

こんにちは、SHIFT の開発部門に所属しているKatayamaです。今期から転属になり、開発を担当していくことになりました。

今回はNode.jsに関しての記事を書いていこうと思います。Node.js の公式サイト(v14.8.x)を見てわかるように、以下の通り require/module.export を使った実装になっていると思います。

const http = require("http");

const hostname = "127.0.0.1";
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader("Content-Type", "text/plain");
  res.end("Hello, World!\n");
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

require(上記にはないが module.export) はモジュール管理に関する実装ですが、Node.js ではこの書き方でないとコードの実行できません。

そのためNode.jsで import/export (ES Modules)などES6の構文で実装したい場合には、

Determining module systemに書かれている設定をする
・Babelでコードの変換をする

といった追加の設定が必要になります。今回はフロントエンドでも使われているBabelでコードの変換する、という方法でNode.jsでも import/export を使えるようにしてみたので、それについて見ていきたいと思います。

Node.js で import/export を使う

どうすればいいか?の結論から、どうしてそうすれば Node.js で import/export を使った実装ができるのか?の理由・詳細についてみていく。

結論:どうすればいいか?

tree -I node_modules
.
├── dist
│   └── main.js
├── .babelrc
├── package.json
├── src
│   └── index.js
├── webpack.config.js
└── yarn.lock
// webpack.config.js
module.exports = {
  target: "node",
  entry: "./src/index.js",
  module: {
    rules: [
      {
        test: /\.m?js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
        },
      },
    ],
  },
};
// .babelrc
{
  "presets": ["@babel/preset-env"]
}

webpack で build するコードには以下を追記(npm install または yarn add も)。

import "core-js/stable";
import "regenerator-runtime/runtime";
yarn add --dev core-js
yarn add --dev regenerator-runtime

webpack コマンドを実行(build)するコードの全体としては以下のような感じになる。

// index.js
import "core-js/stable";
import "regenerator-runtime/runtime";

import express from "express";

const app = express();
app.use(express.json());

app.get("/", async (req, res) => {
  const reqTime = Date.now();
  console.log(Array.from("foo"));

  await new Promise((resolve) => {
    setTimeout(() => {
      resolve("sleep");
    }, 500);
  });

  res.status(200).send({
    msg: "hello world!",
    elaptime: Date.now() - reqTime,
  });
});

app.listen(3000, () => console.log("listening on port 3000!"));

後は "npx webpack" コマンドを実行して webpack で build をし、出力されるファイル "./dist/main.js" を node コマンドで実行すればいい。

npx webpack
node dist/main.js

以降の項で上記の実装、webpack.config.js や.babelrc(babel.config.js)が上記のようになる理由について順にみていく。

JavaScript の歴史から Node.js とそのモジュール管理の仕組みについて理解する

JavaScript の歴史から見ていくと分かりやすいと思ったので歴史から Node.js とはについてみていく事にする。

まず歴史の全体感は25 年に渡る JavaScript の歴史のサイトが分かりやすいのでそちらを参照。この歴史の中で特に関係する部分としては、

2009 年  Node.js の誕生
2015 年  ES2015:大規模な ECMAScript のアップデート

の 2 つ。また、同時に JavaScript の version についても知っておく必要があるが、それはJavaScript Versionsに一覧が載っており分かりやすい。

年表を見ると分かるが 2009 年に Node.js が誕生する。一応経緯みたいな事も補足しておくと、発端は Chrome に搭載された V8Engine と呼ばれるものがとても良いという事で、これを Java とか Ruby、Python と同じようにサーバーサイドの言語として使おうとして Node.js が生まれた。

ここで JavaScript の歴史と JavaScript の version を並べてみると、

・1997-1999 年   ES1 ES2 ES3
・2009 年~  ES5
・2015 年~  ES6
・2016 年~  ES2016

のようになっており、Node.js が生まれた時(2009 年)の JavaScript の version は ES5(ECMAScript 5)と呼ばれるものである事が分かる。となると必然的にその当時の JavaScript の version で Node.js が実装される事になる。そして表題の import/export の話に関わるが、この ES5 のモジュール管理の方法は CommonJS という仕様で定義されており、それがまさしく

const http = require("http");

という require/module.export の構文。一方、import/export の構文は ES6(以降で使えるようになった)構文であり、本質的に ES5 の構文とは大きく違っている。

こういった事情があり、Node.js では import/export が使えず、 require/module.export という構文で実装する必要がある(※Node.js もES6 の機能を取り込むなど変化し続けておりES Modulesにも対応しているが、ES Module対応のための追加設定をしなければ、基本的にNode.jsは ES5 の仕様であるという意味)。

・参考:Node.js とは何か

Node.js を import/export のモジュール管理の仕組みで実装するとどうなるか?

上記の項で見てきたように、Node.js では そのモジュール管理の仕組みが require/module.export という構文で、import/export という構文とは全く違う。実際にそれを体感するためわざと Node.js で import/export の構文で実装してみる。実装したコードは以下の通りで、これを "node index.js" で実行してみると、

// src/index.js
import express from "express";

const app = express();
// 省略
app.listen(3000, () => console.log("listening on port 3000!"));
[root@localhost node-express]# node src/index.js
(node:9816) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)
/root/workspace/node-express/src/index.js:1
import express from 'express';
^^^^^^

SyntaxError: Cannot use import statement outside a module
    at ...

というようにエラーが出てしまう。一部のみに抜粋しているが、import 文の所(モジュール管理の実装)でエラーが出てしまう事が分かる。

Q:ではどうするのか?  →   A:コードを変換してしまえ!

上記の項で見たように Node.js の仕様として import/export の構文は使えないのでどうしようもないと思うかもしれないが、そんな事はなくソースコードを変換するという発想でどうにかしてしまう。

コードの変換をするために登場するのが Babel で、すごく簡単に言ってしまえば JavaScript の コード(構文)の version を変えるツール。Babelとはオープンソースのライブラリで、これを使うと実装されたコードの JavaScript の構文を変える事ができる。このツールはUsing Babelのページを見ると分かるように、様々な方法で実行する事ができるが、大抵はwebpackという build ツールに組み込まれる形で使われる事が多いようで、build を行う時に JavaScript の構文 を変えるという事が行われる。

これにより例えば、ES6(2015 年の JavaScript version)の構文(import/export の構文)で実装されたコードを ES5(2009 年の JavaScript version)の構文(require/module.export の構文)に変換する、という事ができる。これにより Node.js でも import/export の構文を使って実装ができるようになる。

※webpack は公式のページの Top の図が示すように、JavaScript とか CSS とかその他色々なものをバンドルして 1 つにまとめるのがツールの本質だが、そのバンドルしてまとめる時に JavaScript の構文の version を変えるという事が良く行われる。

At its core, webpack is a static module bundler for modern JavaScript applications.

と書かれているように、webpack は基本的にはモジュールのバンドラーだが、それを実行する際にいろいろな追加の処理を行え、その処理に Babel を取り込ませる事でバンドル(Build)の過程で JavaScript の構文の変換を実行できる。そのため、webpack × Babel の組み合わせがよく見られる。

コード変換の仕組みを導入する

コードの変換をするには、上記で見てきたように webpack × Babel の組み合わせで設定をしていけばいい。設定は公式のbabel-loaderに沿って行えばよい。

// webpack.config.js
module.exports = {
  target: "node",
  entry: "./src/index.js",
  module: {
    rules: [
      {
        test: /\.m?js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
        },
      },
    ],
  },
};
// .babelrc
{
  "presets": ["@babel/preset-env"]
}

この設定を行った後に、webpack コマンドを実行してみると、

$ webpack
asset main.js 949 KiB [emitted] (name: main)
runtime modules 1.04 KiB 5 modules
cacheable modules 716 KiB
  javascript modules 455 KiB 56 modules
  json modules 261 KiB
    ...
15 modules

Done in 3.77s.

のように コマンドは成功(build 成功)する。build が成功すると "./dist/main.js" が作成されるがこれが build の成果物で、このファイルを "node dist/main.js" で実行してみると、

$ node dist/main.js
...
webpack://my-webpack-project/./src/index.js?:13
  var _ref = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee(req, res) {
                                             ^

ReferenceError: regeneratorRuntime is not defined
    at eval (webpack://my-webpack-project/./src/index.js?:13:46)
    ...

のようになぜかエラーが出てしまう・・・。なぜこうなるのか?次の項で見ていく。

※補足として target: 'node' についてだが、これは webpack の公式ドキュメント(Targets)を読んでみると、

In the example above, using node webpack will compile for usage in a Node.js-like environment (uses Node.js require to load chunks and not touch any built in modules like fs or path).

と書かれているように、Node.js の場合は target: 'node' の設定が必要なため設定している。

core-js と regenerator-runtime を使う

上記の項で出たエラー『regeneratorRuntime is not defined】は、 ES6 の構文で aysnc/await やgenerator 関数というものを ES5 に変換するために必要になる翻訳(ES6 の構文の〇〇は ES5 の構文だと △△ になる)を担うものがないために起こっている。

まずそもそも JavaScript には標準組み込みオブジェクトと呼ばれるものが存在し、ライブラリの依存なしに使える標準機能のようなものがある。例えば、 "Array.from('foo')" とかが使えるのはこの標準組み込みオブジェクトのおかげ。この標準組み込みオブジェクトも JavaScript の version が変わるごとに変わっており、ES6 にはあるが ES5 にはないものがある。例えば、 "String.replaceAll()" は ES2021 から使えるようになった標準組み込みオブジェクト。

ECMAScript 2021, the 12th edition, introduces the replaceAll method for Strings;
cf. https://tc39.es/ecma262/

そこでpolyfillが登場する。これは JavaScript の version 間の構文の差異を埋めてくれるもので、例えば、ES6 にある標準組み込みオブジェクトで実装したコードを ES5 のコードに変換する役割を担ってくれる。具体的に何をするかというと、Array.from  ポリフィルにあるような形で、"Array.from()" を使わない場合の実装に Array.from を置き換えてくれる。

この polyfill だが、以前は@babel/polyfillというものがありこれを利用していた。ただ、公式のサイトの注意書きの通りこのライブラリは非推奨になり、代わりに "core-js/stable" と "regenerator-runtime/runtime" を使い構文の変換を行う。@babel/polyfillのページに書かれている通り、それぞれ、

  • core-js
    polyfill ECMAScript features

  • regenerator-runtime/runtime
    transpiled generator functions

の役割がある。

実装としては Babel のサイトにあるように、index.js に

index.jsimport "core-js/stable";
import "regenerator-runtime/runtime";

のように import を追加するだけでよい。

この実装を追加した状態で webpack コマンドを実行して、build 後のファイルを "node dist/main.js" で実行してみると、

$ node dist/main.js
listening on port 3000!

のようにサーバの起動ができるようになる事が確認できる。

※『結論:どうすればいいか?』の index.js でDate.now(), Array.from, async/awaitなどを書いているがこれは意図的にそうしており、

  • Date.now(), Array.from
    標準組み込みオブジェクトの ES5 への変換を確認するため(polyfill ECMAScript features)

  • async/await
    generator 関数や async/await の ES5 への変換を確認するため(use transpiled generator functions)

の意味合いで実装している。

・ 参考:@babel/polyfill
・ 参考:Babel7.4 で非推奨になった babel/polyfill の代替手段と設定方法

おまけ

Babel の設定

JavaScript configuration filesに書かれているように、Babel の Configuration は JavaScript でも書ける。その場合は以下のようになる。

// babel.config.js
module.exports = { presets: [["@babel/preset-env"]] };

フロントエンドでも同じ事をする必要がある

今回は Node.js(サーバサイド)について見てきたが、フロントエンドでも古いブラウザ(IE11)などでES6を使いたい場合は、同じように変換が必要で webpack × Babel で構文の変換を行う必要がある。

※『はじめに』で React・Vue・Angular では import/export が使えるという話をしたが、これはそれらの JavaScript のプロジェクトのセットアップでBabelが設定されているため。

まとめとして

今回はNode.jsでも import/export を使った実装をするためにはどうすればいいか?についてみてきた。次回はこのNode.jsで実装したコードに対して静的解析とコードフォーマッティング(ESLint・Prettier)の導入をやってみたいと思います。

__________________________________

執筆者プロフィール:Katayama Yuta
SaaS ERPパッケージベンダーにて開発を2年経験。 SHIFTでは、GUIテストの自動化やUnitテストの実装などテスト関係の案件に従事したり、DevOpsの一環でCICD導入支援をする案件にも従事。 最近開発部門へ異動し、再び開発エンジニアに。座学で読み物を読むより、色々手を動かして試したり学んだりするのが好きなタイプ。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/