import、export、require
実務でアプリケーションを作る場合、複数のJavaScriptファイルを組み合わせて、ひとつのアプリケーションを成すことが多いです。いわゆるモジュール指向の開発です。ここではJavaScriptとTypeScriptでのモジュールと、モジュール同士を組み合わせるためのimport、export、requireについて説明します。
スクリプトとモジュール#
JavaScriptのファイルは大きく分けて、スクリプトとモジュールに分類されます。スクリプトは普通のJavaScriptファイルです。
スクリプトjsconstfoo = "foo";
スクリプトjsconstfoo = "foo";
モジュールは、importまたはexportを1つ以上含むJavaScriptファイルを言います。importは他のモジュールから変数、関数、クラスなどインポートするキーワードです。exportは他のモジュールに変数、関数、クラスなどを公開するためのキーワードです。
モジュールjsexport constfoo = "foo";
モジュールjsexport constfoo = "foo";
したがって、importやexportが無かったスクリプトファイルでも、後からimportやexportを追加すると、それはモジュールファイルになります。
値の公開と非公開#
JavaScriptのモジュールは、明示的にexportをつけた値だけが公開され、他のモジュールから参照できます。たとえば、次の例のpublicValueは他のモジュールから利用できます。一方、privateValueは外部からは利用できません。
jsexport constpublicValue = 1;constprivateValue = 2;
jsexport constpublicValue = 1;constprivateValue = 2;
JavaScriptのモジュールでは、デフォルトで変数や関数などを非公開になるわけです。Javaなどの他の言語では、モジュール(パッケージ)のメンバーがデフォルトで公開になり、非公開にしたいものにはprivate修飾子をつける言語があります。そういった言語と比べると、JavaScriptは基本方針が真逆なので注意が必要です。
モジュールは常にstrict mode#
モジュールのJavaScriptは常にstrict modeになります。strict modeでは、さまざまな危険なコードの書き方が禁止されます。たとえば、未定義の変数への代入はエラーになります。
jsfoo = 1; // 未定義の変数fooへの代入export constbar =foo ;
jsfoo = 1; // 未定義の変数fooへの代入export constbar =foo ;
モジュールはimport時に一度だけ評価される#
JavaScriptでは、モジュールのコードが評価されるのは、1回目のimportのときだけです。2回目以降のimportでは、最初に評価した内容が使われます。言い換えると、モジュールは初回importでキャッシュされるとも言えますし、モジュールはいわゆるシングルトン(singleton)的なものとも言えます。
たとえば、module.jsというモジュールを3回読み込んだとしても、このmodule.jsが評価されるのは最初の1回目だけです。
js// @filename: module.jsconsole .log ("モジュールを評価しています");// このログが出力されるのは1回だけexport constvalue = 1;// @filename: main.jsimport "./module.js";import "./module.js";import "./module.js";
js// @filename: module.jsconsole .log ("モジュールを評価しています");// このログが出力されるのは1回だけexport constvalue = 1;// @filename: main.jsimport "./module.js";import "./module.js";import "./module.js";
JavaScriptのモジュールシステム#
JavaScriptにはES Modules(ESM)という言語公式のモジュールシステムがあります。import構文やexport構文を使ったものがES Modulesです。
ESMが登場したのが2015年ごろです。それまで、JavaScriptには公式的なモジュールシステムがありませんでした。
そのため、非公式のモジュールシステムがいくつか存在します。その中でも今も広く使われるのが、CommonJS(CJS)です。CommonJSはrequire関数とexports/module.exports変数を用いたものです。
業界としてはES Modulesに統一されるべきですが、CommonJSのライブラリ資産もまだ多く残っているため、CommonJSに触れる機会はまだまだあります。
モジュールの歴史的経緯#
かつての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も)を読み込む機能です。基本は次の構文です。
typescriptconst package1 = require("package1");
typescriptconst package1 = require("package1");
これは、パッケージのpackage1の内容を定数package1に持ってくることを意味しています。このときpackage1は(組み込みライブラリでなければ)現在のプロジェクトのnode_modulesというディレクトリに存在する必要があります。
自分で作った他の.js, .tsファイルを読み込むこともできます。呼び出すファイルから見た、読み込みたいファイルの位置を相対パスで書きます。たとえ同じ階層にあっても相対パスで書く必要があります。このとき.js, .jsonとTypeScriptなら加えて.tsを省略することができます。TypeScriptでの開発においては最終的にJavaScriptにコンパイルされることを考慮すると書かないほうが無難です。
typescriptconst myPackage = require("./MyPackage");
typescriptconst myPackage = require("./MyPackage");
.jsを.tsと同じ場所に出力するようにしているとTypeScriptにとって同じ名前の読み込ことができるファイルがふたつ存在することになります。このときTypeScriptは.jsを優先して読み込むので注意してください。いくらTypeScriptのコードを変更しても変更が適用されていないようであればこの問題の可能性があります。
また指定したパスがディレクトリで、その中にindex.js(index.ts)があれば、ディレクトリの名前まで書けばindex.js(index.ts)を読み込んでくれます。
module.exports#
他のファイルを読む込むためにはそのファイルは何かを出力している必要があります。そのために使うのがこの構文です。
typescript// increment.jsmodule.exports = (i) => i + 1;
typescript// increment.jsmodule.exports = (i) => i + 1;
このような.jsのファイルがあれば同じ階層で読み込みたい時は次のようになります。
typescript// index.jsconst increment = require("./increment");console.log(increment(3)); //=> 4
typescript// index.jsconst increment = require("./increment");console.log(increment(3)); //=> 4
このとき、読み込んだ内容を受ける定数incrementはこの名前である必要はなく変更が可能です。
このmodule.exportsはひとつのファイルでいくらでも書くことができますが、適用されるのは最後のもののみです。
typescript// dayOfWeek.jsmodule.exports = "Monday";module.exports = "Tuesday";module.exports = "Wednesday";module.exports = "Thursday";module.exports = "Friday";module.exports = "Saturday";module.exports = "Sunday";
typescript// dayOfWeek.jsmodule.exports = "Monday";module.exports = "Tuesday";module.exports = "Wednesday";module.exports = "Thursday";module.exports = "Friday";module.exports = "Saturday";module.exports = "Sunday";
typescript// index.jsconst day = require("./dayOfWeek");console.log(day); //=> 'Sunday'
typescript// index.jsconst day = require("./dayOfWeek");console.log(day); //=> 'Sunday'
exports#
module.exportsだと良くも悪くも出力しているモノの名前を変更できてしまいます。それを避けたい時はこのexportsを使用します。
typescript// util.jsexports.increment = (i) => i + 1;
typescript// util.jsexports.increment = (i) => i + 1;
読み込み側では次のようになります。
typescript// index.jsconst util = require("./util");console.log(util.increment(3)); //=> 4
typescript// index.jsconst util = require("./util");console.log(util.increment(3)); //=> 4
分割代入を使うこともできます。
typescript// index.jsconst { increment } = require("./util");console.log(increment(3)); //=> 4
typescript// index.jsconst { increment } = require("./util");console.log(increment(3)); //=> 4
こちらはincrementという名前で使用する必要があります。他のファイルに同じ名前のものがあり、名前を変更する必要がある時は、分割代入のときと同じように名前を変更することができます。
typescript// index.jsconst { increment } = require("./other");const { increment: inc } = require("./util");console.log(inc(3)); //=> 4
typescript// index.jsconst { increment } = require("./other");const { increment: inc } = require("./util");console.log(inc(3)); //=> 4
ES Module#
主にフロントエンド(ブラウザ)で採用されているファイルの読み込み方法です。ES6で追加された機能のため、あまりにも古いブラウザでは動作しません。
import#
require()と同じく他の.js, .tsファイルを読み込む機能ですが、require()はファイル内のどこにでも書くことができる一方でimportは必ずファイルの一番上に書く必要があります。
なお、書き方が2とおりあります。
typescriptimport * as package1 from "package1";import package2 from "package2";
typescriptimport * as package1 from "package1";import package2 from "package2";
使い方に若干差がありますので以下で説明します。
export default#
module.exportsに対応するものです。module.exportsと異なりひとつのファイルはひとつのexport defaultしか許されていなく複数書くと動作しません。
typescript// increment.jsexport default (i) => i + 1;
typescript// increment.jsexport default (i) => i + 1;
この.jsのファイルは次のようにして読み込みます。
typescript// index.jsimport increment from "./increment";console.log(increment(3)); //=> 4
typescript// index.jsimport increment from "./increment";console.log(increment(3)); //=> 4
typescript// index.jsimport * as increment from "./increment";console.log(increment.default(3)); //=> 4
typescript// index.jsimport * as increment from "./increment";console.log(increment.default(3)); //=> 4
export#
exportsに相当するものです。書き方が2とおりあります。
typescript// util.jsexport const increment = (i) => i + 1;
typescript// util.jsexport const increment = (i) => i + 1;
typescript// util.jsconst increment = (i) => i + 1;export { increment };
typescript// util.jsconst increment = (i) => i + 1;export { increment };
なお1番目の表記は定数宣言のconstを使っていますがletを使っても読み込み側から定義されているincrementを書き換えることはできません。
次のようにして読み込みます。
typescript// index.jsimport { increment } from "./util";console.log(increment(3)); //=> 4
typescript// index.jsimport { increment } from "./util";console.log(increment(3)); //=> 4
typescript// index.jsimport * as util from "./util";console.log(util.increment(3)); //=> 4
typescript// index.jsimport * as util from "./util";console.log(util.increment(3)); //=> 4
1番目の場合のimportで名前を変更するときは、requireのとき(分割代入)と異なりasという表記を使って変更します。
typescript// index.jsimport { increment as inc } from "./util";console.log(inc(3)); //=> 4
typescript// index.jsimport { increment as inc } from "./util";console.log(inc(3)); //=> 4
import()#
ES Moduleではimportをファイルの先頭に書く必要があります。これは動的に読み込むファイルを切り替えられないことを意味します。このimport()はその代替手段にあたります。
require()と異なる点としてはimport()はモジュールの読み込みを非同期で行います。つまりPromiseを返します。
typescript// index.jsimport("./util").then(({ increment }) => {console.log(increment(3)); //=> 4});
typescript// index.jsimport("./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の拡張子に変更します。
typescript// increment.mjsexport const increment = (i) => i + 1;
typescript// increment.mjsexport const increment = (i) => i + 1;
読み込み側は以下です。
typescript// index.mjsimport { increment } from "./increment.mjs";console.log(increment(3)); //=> 4
typescript// index.mjsimport { 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に変更しなくてもそのまま.jsでES Moduleを使えるようになります。なお"type": "module"の省略時は"type": "commonjs"と指定されたとみなされます。これは今までとおりのNode.jsです。
typescript// increment.jsexport const increment = (i) => i + 1;
typescript// increment.jsexport const increment = (i) => i + 1;
typescript// index.jsimport { increment } from "./increment.js";console.log(increment(3)); //=> 4
typescript// index.jsimport { increment } from "./increment.js";console.log(increment(3)); //=> 4
.jsではありますが読み込む時は拡張子を省略できなくなることに注意してください。
.cjs#
CommonJSで書かれたJavaScriptを読み込みたくなったときはCommonJSで書かれているファイルをすべて.cjsに変更する必要があります。
typescript// increment.cjsexports.increment = (i) => i + 1;
typescript// increment.cjsexports.increment = (i) => i + 1;
読み込み側は以下です。
typescript// index.jsimport { createRequire } from "module";const require = createRequire(import.meta.url);const { increment } = require("./increment.cjs");console.log(increment(3)); //=> 4
typescript// index.jsimport { 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のページに説明がありますのでそちらをご覧ください。
require? import?#
ブラウザ用、サーバー用の用途で使い分けてください。ブラウザ用であればES Moduleを、サーバー用であればCommonJSが無難な選択肢になります。どちらでも使えるユニバーサルなパッケージであればDual Packageを目指すのもよいでしょう。
default export? named export?#
module.exportsとのexport defaultはdefault exportと呼ばれ、exportsとexportは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はこのようになっていました。
typescript// taxIncluded.tsexport default (price) => price * 1.08;
typescript// taxIncluded.tsexport default (price) => price * 1.08;
もちろん呼び出し側はそのまま使うことができます。
typescript// index.tsimport taxIncluded from "./taxIncluded";console.log(taxIncluded(100)); //=> 108
typescript// index.tsimport taxIncluded from "./taxIncluded";console.log(taxIncluded(100)); //=> 108
ここで、ある国が消費税を10%に変更したとします。このときこのシステムではtaxIncluded.tsを変更すればこと足ります。
typescript// taxIncluded.tsexport default (price) => price * 1.1;
typescript// taxIncluded.tsexport default (price) => price * 1.1;
この変更をこのファイル以外は知る必要がありませんし、知ることができません。
今回の問題点#
システムがある年月日当時の消税率を元に金額の計算を多用するようなものだとこの暗黙の税率変更は問題になります。過去の金額もすべて現在の消費税率である10%で計算されてしまうからです。
named exportだと#
named exportであればexportする名称を変更することで呼び出し側の変更を強制させることができます。
typescript// taxIncluded.tsexport const taxIncludedAsOf2014 = (price) => price * 1.08;
typescript// taxIncluded.tsexport const taxIncludedAsOf2014 = (price) => price * 1.08;
typescript// index.tsimport { taxIncludedAsOf2014 } from "./taxInclude";console.log(taxIncludedAsOf2014(100)); //=> 108
typescript// index.tsimport { taxIncludedAsOf2014 } from "./taxInclude";console.log(taxIncludedAsOf2014(100)); //=> 108
税率が10%に変われば次のようにします。
typescript// taxIncluded.tsexport const taxIncludedAsOf2019 = (price) => price * 1.1;
typescript// taxIncluded.tsexport const taxIncludedAsOf2019 = (price) => price * 1.1;
typescript// index.tsimport { taxIncludedAsOf2019 } from "./taxIncluded";// this is no longer available.// console.log(taxIncludedAsOf2014(100));console.log(taxIncludedAsOf2019(100)); //=> 110
typescript// index.tsimport { taxIncludedAsOf2019 } from "./taxIncluded";// this is no longer available.// console.log(taxIncludedAsOf2014(100));console.log(taxIncludedAsOf2019(100)); //=> 110
名前を変更したため、呼び出し元も名前の変更が強制されます。これはたとえasを使って名前を変更していたとしても同じく変更する必要があります。
ロジックが変わったこととそれによる修正を強制したいのであればnamed exportを使う方がわかりやすく、そしてエディター、IDEを通して見つけやすくなる利点があります。逆に、公開するパッケージのようにAPIが一貫して明瞭ならばdefault exportも価値があります。