import、export、require
実務でアプリケーションを作る場合、複数のJavaScriptファイルを組み合わせて、ひとつのアプリケーションを成すことが多いです。いわゆるモジュール指向の開発です。ここではJavaScriptとTypeScriptでのモジュールと、モジュール同士を組み合わせるためのimport
、export
、require
について説明します。
#
スクリプトとモジュールJavaScriptのファイルは大きく分けて、スクリプトとモジュールに分類されます。スクリプトは普通のJavaScriptファイルです。
スクリプトjs
constfoo = "foo";
スクリプトjs
constfoo = "foo";
モジュールは、import
またはexport
を1つ以上含むJavaScriptファイルを言います。import
は他のモジュールから変数、関数、クラスなどインポートするキーワードです。export
は他のモジュールに変数、関数、クラスなどを公開するためのキーワードです。
モジュールjs
export constfoo = "foo";
モジュールjs
export constfoo = "foo";
したがって、import
やexport
が無かったスクリプトファイルでも、後からimport
やexport
を追加すると、それはモジュールファイルになります。
#
値の公開と非公開JavaScriptのモジュールは、明示的にexport
をつけた値だけが公開され、他のモジュールから参照できます。たとえば、次の例のpublicValue
は他のモジュールから利用できます。一方、privateValue
は外部からは利用できません。
js
export constpublicValue = 1;constprivateValue = 2;
js
export constpublicValue = 1;constprivateValue = 2;
JavaScriptのモジュールでは、デフォルトで変数や関数などを非公開になるわけです。Javaなどの他の言語では、モジュール(パッケージ)のメンバーがデフォルトで公開になり、非公開にしたいものにはprivate
修飾子をつける言語があります。そういった言語と比べると、JavaScriptは基本方針が真逆なので注意が必要です。
#
モジュールは常にstrict modeモジュールのJavaScriptは常にstrict modeになります。strict modeでは、さまざまな危険なコードの書き方が禁止されます。たとえば、未定義の変数への代入はエラーになります。
js
foo = 1; // 未定義の変数fooへの代入export constbar =foo ;
js
foo = 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
も)を読み込む機能です。基本は次の構文です。
typescript
const package1 = require("package1");
typescript
const package1 = require("package1");
これは、パッケージのpackage1
の内容を定数package1
に持ってくることを意味しています。このときpackage1
は(組み込みライブラリでなければ)現在のプロジェクトのnode_modules
というディレクトリに存在する必要があります。
自分で作った他の.js, .ts
ファイルを読み込むこともできます。呼び出すファイルから見た、読み込みたいファイルの位置を相対パスで書きます。たとえ同じ階層にあっても相対パスで書く必要があります。このとき.js, .json
とTypeScriptなら加えて.ts
を省略することができます。TypeScriptでの開発においては最終的にJavaScriptにコンパイルされることを考慮すると書かないほうが無難です。
typescript
const myPackage = require("./MyPackage");
typescript
const 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とおりあります。
typescript
import * as package1 from "package1";import package2 from "package2";
typescript
import * 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});
ES Module
を使う#
Node.jsで先述のとおり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
も価値があります。