import / export /require

Node.jsが出てからというものの、フロントエンドの開発もNode.jsを通して行うことができるようにはなりましたが、実際に動く場所がブラウザとサーバーと事情が異なります。 TypeScriptは最終的にどの場面で使われるか、その用途に適した出力に変えることができます。

かつてのJavaScript

かつてJavaScriptがブラウザでのみ動いていた時代は、モジュール分割と言う考え自体はあったもののそれはあくまでもブラウザ上、さらにはhtmlでの管理となっていました。かつて猛威をふるったjQueryといライブラリがあるとすば、それは以下のようにhtmlに書く必要がありました。

<script src="https://ajax.googleapis.com/ajax/libs/jquery/x.y.z/jquery.min.js"></script>

もしjQueryに依存するライブラリがあるとすれば、それより下に書く必要があります。

<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/x.y.z/jquery-ui.min.js"></script>

ライブラリが少なければまだしも、増えてくると依存関係が複雑になります。もしも読み込む順番を間違えるとそのhtmlでは動作がしなくなるでしょう。

Node.jsが登場してから

Node.jsが登場しnpmが登場してから、使いたいパッケージを持ってきてそのまま使うことが主流になりました。

CommonJS

require()

Node.jsでは現在でも主流の他の.jsファイル(TypeScriptでは.tsも)を読み込む機能です。基本は以下の構文です。

const package1 = require('package1');

これは、パッケージのpackage1の内容を定数package1に持ってくることを意味しています。この時package1は現在のプロジェクトのnode_modulesというディレクトリに存在する必要があります。

自分で作った他の.js, .tsファイルを読み込むこともできます。呼び出すファイルから見た読み込みたいファイルの位置を相対パスで書きます。たとえ同じ階層にあっても相対パスで書く必要があります。このとき.js, .jsonとTypeScriptなら加えて.tsを省略することができます。TypeScriptでの開発においては最終的にJavaScriptにトランスパイルされることを考慮すると書かないほうが無難です。

const myPackage = require('./MyPackage');

.js.tsと同じ場所に出力するようにしているとTypeScriptにとって同じ名前の読み込ことができるファイルがふたつ存在することになります。この時TypeScriptは.jsを優先して読み込むので注意してください。いくらTypeScriptのコードを変更しても変更が適用されていないようであれば、この問題の可能性があります。

またディレクトリの中にindex.js (index.ts)があるのであれば、ディレクトリの名前まで書けばindex.js (index.ts)を読み込んでくれます。

module.exports

他のファイルを読む込むためにはそのファイルは何かを出力している必要があります。そのために使うのがこの構文です。

// increment.js
module.exports = i => i + 1;

このような.jsのファイルがあれば同じ階層で読み込みたい時は以下のようになります。

// index.js
const increment = require('./increment');
console.log(increment(3));
// 4

この時、読み込んだ内容を受ける定数incrementはこの名前である必要はなく変更が可能です。

このmodule.exportsはひとつのファイルでいくらでも書くことができますが、適用されるのは最後のもののみです。

// dayOfWeek.js
module.exports = 'Monday';
module.exports = 'Tuesday';
module.exports = 'Wednesday';
module.exports = 'Thursday';
module.exports = 'Friday';
module.exports = 'Saturday';
module.exports = 'Sunday';
// index.js
const day = require('./dayOfWeek');
console.log(day);
// 'Sunday'

exports

module.exportsだと良くも悪くも出力しているモノの名前を変更できてしまいます。それを避けたい時はこのexportsを使用します。

// util.js
exports.increment = i => i + 1;

読み込み側では以下のようになります。

// index.js
const util = require('./util');
console.log(util.increment(3));
// 4

分割代入を使うこともできます。

// index.js
const { increment } = require('./util');
console.log(increment(3));
// 4

こちらはincrementという名前で使用する必要があります。他のファイルに同じ名前のものがあり、名前を変更する必要がある時は、分割代入の時と同じように名前を変更することができます。

// index.js
const { increment } = require('./other');
const { increment: inc } = require('./util');
console.log(inc(3));
// 4

ES Module

主にフロントエンド(ブラウザ)で採用されているファイルの読み込み方法です。ES6で追加された機能のため、あまりに古いブラウザでは動作しません。

import

require()と同じく他の.js, .tsファイルを読み込む機能ですが、require()はファイル内のどこにでも書くことができる一方でimport必ずファイルの一番上に書く必要があります。 なお、書き方が2通りあります。

import * as package1 from 'package1';
import package2 from 'package2';

使い方に若干差がありますので以下で説明します。

export default

module.exportsに対応するものです。module.exportsと異なり、ひとつのファイルはひとつのexport defaultしか許されていなく複数書くと動作しません。

// increment.js
export default i => i + 1;

この.jsのファイルは以下のようにして読み込みます。

// index.js
import increment from './increment';
console.log(increment(3));
// 4
// index.js
import * as increment from './increment';
console.log(increment.default(3));
// 4

export

exportsに相当するものです。書き方が2通りあります。

// util.js
export const increment = i => i + 1;
// util.js
const increment = i => i + 1;
export { increment };

なお1番目の表記は定数宣言のconstを使っていますがletを使っても読み込み側から定義されているincrementを書き換えることはできません。

以下のようにして読み込みます。

// index.js
import { increment } from './util';
console.log(increment(3));
// 4
// index.js
import * as util from './util';
console.log(util.increment(3));
// 4

1番目の場合のimportで名前を変更する時は、requireの時(分割代入)と異なりasという表記を使って変更します。

// index.js
import { increment as inc } from './util';
console.log(inc(3));
// 4

import()

ES Moduleではimportをファイルの先頭に書く必要があります。これは動的に読み込むファイルを切り替えられないことを意味します。このimport()はその代替手段にあたります。

require()と異なる点としてはimport()は読み込み終えたあとPromiseを返します。

// index.js
import('./util').then(({increment}) => {
console.log(increment(3));
// 4
});

Node.jsでES Moduleを使う

先述の通りNode.jsではCommonJSが長く使われていますが、13.2.0でついに正式にES Moduleもサポートされました。

しかしながら、あくまでもNode.jsはCommonJSで動作することが前提なのでES Moduleを使いたい時はすこし準備が必要になります。

.mjs

ES Moduleとして動作させたいJavaScriptのファイルをすべて.mjsの拡張子に変更します。

// increment.mjs
export const increment = i => i + 1;

読み込み側は以下です。

// index.mjs
import { increment } from './increment.mjs';
console.log(increment(3));
// 4

importで使うファイルの拡張子が省略できないことに注意してください。

"type": "module"

package.jsonにこの記述を追加するとパッケージ全体がES Moduleをサポートします。

{
"name": "YYTS",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"license": "Apache-2.0"
}

このようにすることで拡張子を.mjsに変更しなくてもそのまま.jsES Moduleを使えるようになります。なお"type": "module"の省略時は"type": "commonjs"と指定されたとみなされます。これは今まで通りのNode.jsです。

// increment.js
export const increment = i => i + 1;
// index.js
import { increment } from './increment.js';
console.log(increment(3));
// 4

.jsではありますが読み込む時は拡張子を省略できなくなることに注意してください。

.cjs

"type": "module"を採用すると今度はCommonJSで書かれたJavaScriptを読み込みたくなります。その時はCommonJSで書かれているファイルを全て.cjsに変更する必要があります。

// increment.cjs
exports.increment = i => i + 1;

読み込み側は以下です。

// index.js
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { increment } = require('./increment.cjs');
console.log(increment(3));
// 4

ES Moduleにはrequire()がなく、一手間加えて作り出す必要があります。

"type": "module"の問題点

全てをES Moduleとして読み込むこの設定は、多くのパッケージがまだ"type": "module"に対応していない現状としては非常に使いづらいです。

例えばlinterやテストといった各種開発補助のパッケージの設定ファイルを.jsで書いていると動作しなくなってしまいます。かといってこれらを.cjsに書き換えても、パッケージが設定ファイルの読み込み規則に.cjsが含んでいなければそれらのパッケージは設定ファイルがないと見なします。そのため"type": "module"は現段階では非常に扱いづらいものとなっています。

TypeScriptでは

TypeScriptでは一般的にES Module方式に則った記法で書きます。これはCommonJSを使用しないというわけではなく、トランスパイル時の設定でCommonJS, ES Moduleのどちらにも対応した形式で出力できるのであまり問題ではないと言えます。ここまでの経緯などはTypeScriptでは意識することがあまりないでしょう。

また、執筆時(2020/06)ではTypeScriptのトランスパイルは.jsのみを出力でき.cjs, .mjsを出力する設定はありません。ブラウザでもサーバーでも使えるJavaScriptを出力したい場合は一手間加える必要があります。

出力の方法に関してはtsconfig.jsonの頁に説明がありますのでそちらをご覧ください。

require? import?

ブラウザ用、サーバー用の用途で使い分けてください。ブラウザ用であればES Moduleを、サーバー用であればCommonJSが無難な選択肢になります。どちらでも使えるユニバーサルなパッケージであればDual Packageを目指すのも良いでしょう。

default export? named export?

CommonJSmodule.exportsES Moduleexport defaultdefault exportと呼ばれ CommonJSexportsES Moduleexportnamed exportと呼ばれています。 どちらも長所と短所があり、たびたび議論になる話題です。どちらか一方を使うように統一するコーディングガイドを持っている企業もあるようですが、どちらかが極端に多いというわけでもないので好みの範疇です。

default export

Pros

  • importする時に名前を変えることができる

  • そのファイルが、他のexportに比べ何を最も提供したいのかがわかる

Cons

  • エディタ、IDEによっては入力補完が効きづらい

  • 再エクスポートの際に名前を付ける必要がある

named export

Pros

  • エディタ、IDEによる入力補完が効く

  • ひとつのファイルから複数exportできる

Cons

  • (名前の変更はできるものの)基本的に決まった名前でimportして使う必要がある

  • exportしているファイルが名前を変更すると動作しなくなる

ここで挙がっている名前を変えることができるについてはいろいろな意見があります。

ファイルが提供したいもの

たとえばある国の会計ソフトウェアを作っていたとして、その国の消費税が8%だったとします。その時のあるファイルのexportはこのようになっていました。

// taxIncluded.ts
export default price => price * 1.08;

もちろん呼び出し側はそのまま使うことができます。

// index.ts
import taxIncluded from './taxIncluded';
console.log(taxIncluded(100));
// 108

ここで、ある国が消費税を10%に変更したとします。この時このシステムではtaxIncluded.tsを変更すればこと足ります。

// taxIncluded.ts
export default price => price * 1.1;

この変更をこのファイル以外は知る必要がありませんし、知ることができません。

今回の問題点

システムがある年月日当時の消税率を元に金額の計算を多用するようなものだとこの暗黙の税率変更は問題になります。過去の金額も全て現在の消費税率である10%で計算されてしまうからです。

named exportだと

named exportであればexportする名称を変更することで呼び出し側の変更を強制させることができます。

// taxIncluded.ts
export const taxIncludedAsOf2014 = price => price * 1.08;
// index.ts
import { taxIncludedAsOf2014 } from './taxInclude';
console.log(taxIncludedAsOf2014(100));
// 108

税率が10%に変われば以下のようにします。

// taxIncluded.ts
export const taxIncludedAsOf2019 = price => price * 1.1;
// index.ts
import { taxIncludedAsOf2019 } from './taxIncluded';
// this is no longer available.
// console.log(taxIncludedAsOf2014(100));
console.log(taxIncludedAsOf2019(100));
// 110

名前を変更したため、呼び出し元も名前の変更が強制されます。これは例えasを使って名前を変更していたとしても同じく変更する必要があります。

ロジックが変わったこととそれによる修正を強制したいのであればnamed exportを使う方がわかりやすく、そしてエディタ、IDEを通して見つけやすくなる利点があります。逆に、公開するパッケージのようにAPIが一貫して明瞭ならばdefault exportも価値があります。