メインコンテンツまでスキップ

import、export、require

実務でアプリケーションを作る場合、複数のJavaScriptファイルを組み合わせて、ひとつのアプリケーションを成すことが多いです。いわゆるモジュール指向の開発です。ここではJavaScriptとTypeScriptでのモジュールと、モジュール同士を組み合わせるためのimportexportrequireについて説明します。

スクリプトとモジュール

JavaScriptのファイルは大きく分けて、スクリプトとモジュールに分類されます。スクリプトは普通のJavaScriptファイルです。

スクリプト
js
const foo = "foo";
スクリプト
js
const foo = "foo";

モジュールは、importまたはexportを1つ以上含むJavaScriptファイルを言います。importは他のモジュールから変数、関数、クラスなどインポートするキーワードです。exportは他のモジュールに変数、関数、クラスなどを公開するためのキーワードです。

モジュール
js
export const foo = "foo";
モジュール
js
export const foo = "foo";

したがって、importexportが無かったスクリプトファイルでも、後からimportexportを追加すると、それはモジュールファイルになります。

値の公開と非公開

JavaScriptのモジュールは、明示的にexportをつけた値だけが公開され、他のモジュールから参照できます。たとえば、次の例のpublicValueは他のモジュールから利用できます。一方、privateValueは外部からは利用できません。

js
export const publicValue = 1;
const privateValue = 2;
js
export const publicValue = 1;
const privateValue = 2;

JavaScriptのモジュールでは、デフォルトで変数や関数などを非公開になるわけです。Javaなどの他の言語では、モジュール(パッケージ)のメンバーがデフォルトで公開になり、非公開にしたいものにはprivate修飾子をつける言語があります。そういった言語と比べると、JavaScriptは基本方針が真逆なので注意が必要です。

モジュールは常にstrict mode

モジュールのJavaScriptは常にstrict modeになります。strict modeでは、さまざまな危険なコードの書き方が禁止されます。たとえば、未定義の変数への代入はエラーになります。

js
foo = 1; // 未定義の変数fooへの代入
ReferenceError: foo is not defined
export const bar = foo;
js
foo = 1; // 未定義の変数fooへの代入
ReferenceError: foo is not defined
export const bar = foo;

モジュールはimport時に一度だけ評価される

モジュールのコードが評価されるのは、1回目のimportのときだけです。2回目以降のimportでは、最初に評価した内容が使われます。言い換えると、モジュールは初回importでキャッシュされるとも言えますし、モジュールはいわゆるシングルトン(singleton)的なものとも言えます。

たとえば、module.jsというモジュールを3回読み込んだとしても、このmodule.jsが評価されるのは最初の1回目だけです。

module.js
js
console.log("モジュールを評価しています");
// このログが出力されるのは1回だけ
export const value = 1;
module.js
js
console.log("モジュールを評価しています");
// このログが出力されるのは1回だけ
export const value = 1;
main.js
js
import "./module.js";
"モジュールを評価しています"
import "./module.js";
import "./module.js";
main.js
js
import "./module.js";
"モジュールを評価しています"
import "./module.js";
import "./module.js";

モジュールの歴史的経緯

かつてのJavaScript

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

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

もしjQueryに依存するパッケージがあるとすれば、jQueryの宣言より下に書く必要があります。

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

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

Node.jsが登場してから

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

CommonJS

require()

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

ts
const package1 = require("package1");
ts
const package1 = require("package1");

これは、パッケージのpackage1の内容を定数package1に持ってくることを意味しています。このときpackage1は(組み込みライブラリでなければ)現在のプロジェクトのnode_modulesというディレクトリに存在する必要があります。

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

ts
const myPackage = require("./MyPackage");
ts
const myPackage = require("./MyPackage");

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

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

module.exports

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

increment.js
ts
module.exports = (i) => i + 1;
increment.js
ts
module.exports = (i) => i + 1;

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

index.js
ts
const increment = require("./increment");
 
console.log(increment(3));
index.js
ts
const increment = require("./increment");
 
console.log(increment(3));

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

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

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

exports

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

util.js
ts
exports.increment = (i) => i + 1;
util.js
ts
exports.increment = (i) => i + 1;

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

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

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

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

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

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

ES Module

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

import

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

ts
import * as package1 from "package1";
import package2 from "package2";
ts
import * as package1 from "package1";
import package2 from "package2";

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

export default

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

increment.js
ts
export default (i) => i + 1;
increment.js
ts
export default (i) => i + 1;

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

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

export

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

util.js
ts
export const increment = (i) => i + 1;
util.js
ts
export const increment = (i) => i + 1;
util.js
ts
const increment = (i) => i + 1;
export { increment };
util.js
ts
const increment = (i) => i + 1;
export { increment };

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

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

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

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

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

import()

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

require()と異なる点としてはimport()はモジュールの読み込みを非同期で行います。つまりPromiseを返します。

index.js
ts
import("./util").then(({ increment }) => {
console.log(increment(3));
// @log: 4
});
index.js
ts
import("./util").then(({ increment }) => {
console.log(increment(3));
// @log: 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
ts
export const increment = (i) => i + 1;
increment.mjs
ts
export const increment = (i) => i + 1;

読み込み側は以下です。

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

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

"type": "module"

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

json
{
"name": "YYTS",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"license": "Apache-2.0"
}
json
{
"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
ts
export const increment = (i) => i + 1;
increment.js
ts
export const increment = (i) => i + 1;
index.js
ts
import { increment } from "./increment.js";
 
console.log(increment(3));
4
index.js
ts
import { increment } from "./increment.js";
 
console.log(increment(3));
4

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

.cjs

CommonJSで書かれたJavaScriptを読み込みたくなったときはCommonJSで書かれているファイルをすべて.cjsに変更する必要があります。

increment.cjs
ts
exports.increment = (i) => i + 1;
increment.cjs
ts
exports.increment = (i) => i + 1;

読み込み側は以下です。

index.js
ts
import { createRequire } from "module";
const require = createRequire(import.meta.url);
 
const { increment } = require("./increment.cjs");
 
console.log(increment(3));
4
index.js
ts
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では意識することがあまりないでしょう。

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

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

📄️ tsconfig.jsonを設定する

Node.jsはそれ自身ではTypeScriptをサポートしているわけではないため、TypeScriptの導入をする時はTypeScriptの設定ファイルであるtsconfig.jsonが必要です。

require? import?

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

📄️ デュアルパッケージ開発者のためのtsconfig

フロントエンドでもバックエンドでもTypeScriptこれ一本!Universal JSという考えがあります。確かにフロントエンドを動的にしたいのであればほぼ避けて通れないJavaScriptと、バックエンドでも使えるようになったJavaScriptで同じコードを使いまわせれば保守の観点でも異なる言語を触る必要がなくなり、統一言語としての価値が大いにあります。

default export? named export?

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

default export

default exportのPros

  • importする時に名前を変えることができる
  • そのファイルが他のexportに比べ何をもっとも提供したいのかがわかる

default exportのCons

  • エディター、IDEによっては入力補完が効きづらい
  • 再エクスポートの際に名前をつける必要がある

named export

named exportのPros

  • エディター、IDEによる入力補完が効く
  • ひとつのファイルから複数exportできる

named exportのCons

  • (名前の変更はできるものの)基本的に決まった名前でimportして使う必要がある
  • exportしているファイルが名前を変更すると動作しなくなる

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

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

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

taxIncluded.ts
ts
export default (price) => price * 1.08;
taxIncluded.ts
ts
export default (price) => price * 1.08;

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

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

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

taxIncluded.ts
ts
export default (price) => price * 1.1;
taxIncluded.ts
ts
export default (price) => price * 1.1;

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

今回の問題点

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

named exportだと

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

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

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

taxIncluded.ts
ts
export const taxIncludedAsOf2019 = (price) => price * 1.1;
taxIncluded.ts
ts
export const taxIncludedAsOf2019 = (price) => price * 1.1;
index.ts
ts
import { taxIncludedAsOf2019 } from "./taxIncluded";
 
// this is no longer available.
// console.log(taxIncludedAsOf2014(100));
console.log(taxIncludedAsOf2019(100));
110
index.ts
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も価値があります。

  • 質問する ─ 読んでも分からなかったこと、TypeScriptで分からないこと、お気軽にGitHubまで🙂
  • 問題を報告する ─ 文章やサンプルコードなどの誤植はお知らせください。