コンパイル結果で考えるTypeScriptのimport / export / namespace
import * as module from 'module'
vs import module from 'module'
TypeScriptでモジュールのimportをしていたら、読み込むモジュールによって怒られたり怒られなかったりしたので色々試して調べた。
大前提
TypeScriptにはexternal moduleとinternal moduleという2つのモジュールの概念がある。
external module
トップレベルにimport
export
を持つファイルのこと。他のファイルからimport
されることを前提にしている。
モジュールと名前がついているが、単位はファイル。classや他の言語のPackageみたいに特別な宣言をするとかではなく、とにかくimport
export
があったらexternal module。
なんだかややこしい。
internal module
名前空間を宣言するもの。変数名などの衝突を防ぐために使う。
宣言にはnamespace
を使う。
基本的によそのモジュールから使われるために記述するわけではないが、namespaceそのものをexportすれば外部から利用することはできる。(それが意味のあるコードかは別として)
そもそもなんでモジュールの概念がある?
もともとJavaScriptは別ファイルを参照し、その中の公開された関数を使うという概念がなく、ブラウザ上でHTMLに記述された<script src=... />
タグに記述されたJavaScriptファイルを上から順にどんどんロードしていってグローバル変数やグローバル関数をガンガン宣言しまくるといった所業がなされていた。
なので、HTML上で各ファイルのJavaScriptを読み込む順番によっては正しく動かない、というのがよくあった。
こういう理由でグローバル汚染が蔓延していて、よく名前衝突が起こって色々と終わり込んでいたらしい。
そこでJavaScriptでも何らかのファイル間参照機能が必要だよね、というお気持ちからできたのがモジュールの概念。
https://qiita.com/mizchi/items/6b569cc75dbcc26a1f15
で、そのモジュールの参照方法が昔のTypeScript(~1.4)とNode.js(CommonJS)とES6で異なるのが原因。うーん最高。
CommonJSとは
ブラウザ外のJavaScriptの各種仕様を定めることを目的としたプロジェクト、およびその仕様のこと。 昔はServerJSとかいう名前だったらしい。
https://ja.wikipedia.org/wiki/CommonJS
http://wiki.commonjs.org/wiki/CommonJS
で、このCommonJSの仕様に従ってる(ように見える)のがNode.js。
TypeScriptの場合
TypeScriptはES6の仕様策定前にこの辺の仕様が決まったため、module
というキーワードでモジュールを定義する手法を取っていた。(現在ではmodule
キーワードはnamespace
キーワードに変更されている)
https://www.typescriptlang.org/docs/handbook/modules.html
//modA.ts module modA { export class classA {} } //modB.ts /// <reference path="./modA.ts" /> module modB { import clsA = modA.classA; }
さて、冒頭でnamespace
が内部モジュールを表すという話があったが、(おそらく)この時点では外部モジュールという概念はなく、単に「モジュール」という概念しかどうやらなかったらしい。
一方ES6
module
というキーワードを用いずにモジュールを表す方式を取っている。
//classA.js export class classA {} export function hoge() {} //classB.js import { classA, hoge } from "./classA" const objA = new classA(); hoge(); //あるいは一括で読み込んでClsAという名前空間に放り込む import * as ClsA from "./ClassA.js" const objA_2 = new ClsA.classA(); ClsA.hoge();
ちなみにNode.js
require
とexports
というキーワードでモジュールを取り扱っている。最高。
//classA.js class classA {} module.exports = classA //classB.js const classA = require('./classA'); const objA = new classA();
なので
こういった背景があり、TypeScriptでモジュールと一口に言っても昔のTypeScriptのモジュールなのかES6以降のモジュールなのかNode.jsのモジュールなのかという話がついて回るようになった。
ちなみにTypeScriptには、モジュールをどのように取り扱うか(TSファイルのコンパイル時、コンパイル後のJSファイル)のモード指定ができる。
TypeScriptのコンパイルを行うtsc
コマンドを使い、tsc --init
でコンパイルオプションを指定するtsconfig.json
というファイルを生成できるが、
デフォルトではCommonJSに互換性のあるモードが指定されている。
namespaceとeternal moduleはどっちを使うべきか?
namespaceはJSにコンパイルすると分かるが、下位の変数や関数を無名関数にラップしてクロージャを生成することでグローバル汚染を防ぐ、といったようなJSが生成される。
例えばこんな感じ。
//classA.ts namespace NameSpaceA { export class classA { sayHello() { const objB = new classB(); objB.sayHello(); } } class classB { sayHello() { console.log('hello'); } } }
コンパイルするとこう。
//classA.js "use strict"; var NameSpaceA; (function (NameSpaceA) { class classA { sayHello() { const objB = new classB(); objB.sayHello(); } } NameSpaceA.classA = classA; class classB { sayHello() { console.log('hello'); } } })(NameSpaceA || (NameSpaceA = {}));
TypeScript/ES6では、あくまでexport
句が書かれたモジュールをimport
句で指定することで初めて参照が可能になるため、このままでは外部から利用することができない。
C#やJavaと違っていわゆる「パッケージ」とか「プロジェクト」という単位で参照して名前空間を共有、というのができないので、各モジュールで同じ名前空間を定義しても大した恩恵は得られない気がする。
例えば、export namespace hoge
とすることでexportすることはできるが、C#やJavaと違い同名ではあるものの独自の名前空間を作り出す。
これはどういうことかというと、以下のようなコードを書いたとして、ModA.ts
とModB.ts
でファイル毎に独立したShareNS名前空間を生成するので、相互に参照といったことができない。
逆に、同一ファイル内であれば、ModC.ts
のように別の場所で宣言された名前空間同士で相互参照ができる場合がある。
//ModA.ts export namespace ShareNS { export class A {} } //ModB.ts export namespace ShareNS { export class B {} } //ModC.ts //TSの仕様として、同一のroot containerを持つnamespaceはマージされる export namespace ShareNS { class A{ } } export namespace ShareNS { class B{ } } //上記二つのNSは以下と同値 /* export namespace ShareNS{ class A{ } class B{ } } */ //exportをつける/つけないでroot containerが変わるので、以下のnamespaceはマージされない namespace ShareNS { }
TSにしろES6にしろ、class記法がサポートされているので、個人的にはほぼ全ての自作モジュールは以下の記述方法に統一している。 サーバもクライアントも共通のクラスを使いたいみたいな場合はまた話が変わるかも。
//Hoge.ts class Hoge { sayHello() { console.log('hello from Hoge') } } export default Hoge //hogeController.ts import Hoge from './hoge' const hoge = new Hoge(); hoge.sayHello();
コンパイルするとこうなる
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); class Hoge { sayHello() { console.log('hello from Hoge'); } } exports.default = Hoge; //hogeController.js "use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const hoge_1 = __importDefault(require("./hoge")); const hoge = new hoge_1.default(); hoge.sayHello();
ほとんど変わりない。
コンパイル結果から見るモジュールの挙動
ここからが本題。
前提条件
tsconfig.json
は以下の通り。とりあえず"target": "es2017", "module": "commojs"
以外はあまり関係ない。
{ "compilerOptions": { /* Basic Options */ "target": "es2017", "module": "commonjs", "lib": [ "es2017", "dom" ], /* Specify library files to be included in the compilation. */ "declaration": false, /* Generates corresponding '.d.ts' file. */ "outDir": "./dist", /* Redirect output structure to the directory. */ /* Strict Type-Checking Options */ "strict": true, /* Enable all strict type-checking options. */ "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ "strictNullChecks": true, /* Enable strict null checks. */ /* Module Resolution Options */ "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ "typeRoots": [ "node_modules/@types", "./src/typings" ], /* List of folders to include type definitions from. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ /* Experimental Options */ "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */ } }
namespaceをexportしなかった場合
上記でも少し触れたが、namespaceをexportしなかった場合。
//NameSpaceA.ts namespace NameSpaceA { export class NameSpaceA_ClassA { constructor() { } } } //controller.ts //NameSpaceA名前空間にClassAを宣言しておくと、importしなくても使える... //と思いきや、コンパイルは通るが、実行時エラーとなる。 //NameSpaceA.tsはトップレベルにexport/importがないので、外部モジュールではない const classA_obj = new NameSpaceA.NameSpaceA_ClassA();
コメントの通り、これはコンパイルは通るがJSの実行時エラーが発生する。 exportしないnamespaceは同一ファイル間での名前衝突避けぐらいにしか使うことができない。
コンパイル結果は以下の通り。
//NameSpaceA.js "use strict"; //クロージャを作ってNameSpaceAというオブジェクトにスコープを限定している var NameSpaceA; (function (NameSpaceA) { class NameSpaceA_ClassA { constructor() { } } NameSpaceA.NameSpaceA_ClassA = NameSpaceA_ClassA; })(NameSpaceA || (NameSpaceA = {})); //# sourceMappingURL=NameSpaceA.js.map //controller.js "use strict"; //何もrequireしていないので当然参照できない const classA_obj = new NameSpaceA.NameSpaceA_ClassA(); //# sourceMappingURL=controller_namespaceA.js.map
実行時エラーが起きるのはまあ当然なんだけど正直これはコンパイルエラーにしてほしい。 (オプションによってはなるかも?)
namespaceをexportした場合
今度はexportしてみる。
//NameSpaceB.ts export namespace NameSpaceB{ class InternalClass { constructor(){} sayHello(){ console.log("Say hello from InternalClass"); } } export class ExternalClass{ sayHello(){ const internal = new InternalClass(); internal.sayHello(); } sayInternalNamespace() { const internal_intenal = new MyInternalSpace.MyInternalClass(); internal_intenal.sayInternalHello(); } } //MyInternalSpaceはexportされていないので、このモジュールより外からは参照できない namespace MyInternalSpace { //このクラスはNameSpaceBからは参照可能 export class MyInternalClass { sayInternalHello() { const secretObj = new MyInternal_InternalClass(); secretObj.secretSayHello(); } } //このクラスはNameSpaceBからでも参照不可能 class MyInternal_InternalClass { secretSayHello(){ console.log("say hello from internal namespace!") } } } } //controller.ts //NameSpaceBはdefault exportを持っていないので、importの対象を指定するか、 //import * as NameSpaceB from ... と書く必要がある。 //ただし、namespace-style importではnsbという名前空間の中にNameSpaceBがネストされるので冗長。 import { NameSpaceB } from './NameSpaceB' import * as nsb from './NameSpaceB' const classB_obj = new NameSpaceB.ExternalClass(); classB_obj.sayHello(); classB_obj.sayInternalNamespace(); const classB_obj_2 = new nsb.NameSpaceB.ExternalClass(); classB_obj_2.sayHello(); classB_obj_2.sayInternalNamespace();
コンパイル結果は以下の通り。
//NameSpaceB.js "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); //namespaceはNameSpaceBオブジェクトに変換され、内部のclassなどはそのオブジェクト内に押し込まれる //クロージャを生成してスコープを限定している var NameSpaceB; (function (NameSpaceB) { class InternalClass { constructor() { } sayHello() { console.log("Say hello from InternalClass"); } } class ExternalClass { sayHello() { const internal = new InternalClass(); internal.sayHello(); } sayInternalNamespace() { const internal_intenal = new MyInternalSpace.MyInternalClass(); internal_intenal.sayInternalHello(); } } NameSpaceB.ExternalClass = ExternalClass; //exportされなかったnamespaceはlet演算子でスコープが限定され、この無名関数より外側から参照できなくなる let MyInternalSpace; (function (MyInternalSpace) { //exportされたclassは、上位のMyInternalSpaceを参照できるトップレベルの無名関数からは参照可能 class MyInternalClass { sayInternalHello() { const secretObj = new MyInternal_InternalClass(); secretObj.secretSayHello(); } } MyInternalSpace.MyInternalClass = MyInternalClass; //exportされなかったclassは、この無名関数より外側から参照不可能 class MyInternal_InternalClass { secretSayHello() { console.log("say hello from internal namespace!"); } } })(MyInternalSpace || (MyInternalSpace = {})); })(NameSpaceB = exports.NameSpaceB || (exports.NameSpaceB = {})); //# sourceMappingURL=NameSpaceB.js.map //controller.js "use strict"; var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; result["default"] = mod; return result; }; Object.defineProperty(exports, "__esModule", { value: true }); //"target": "commonjs"オプションが指定されているので、importは全てconst requireに変換されている。 //module importとnamespace-style importではコンパイル結果が違うが、 //いずれにせよ、モジュールをいったんオブジェクトに格納して、オブジェクトのプロパティとして取得している const NameSpaceB_1 = require("./NameSpaceB"); const nsb = __importStar(require("./NameSpaceB")); const classB_obj = new NameSpaceB_1.NameSpaceB.ExternalClass(); classB_obj.sayHello(); classB_obj.sayInternalNamespace(); const classB_obj_2 = new nsb.NameSpaceB.ExternalClass(); classB_obj_2.sayHello(); classB_obj_2.sayInternalNamespace(); //# sourceMappingURL=controller_namespaceB.js.map
実行結果
Say hello from InternalClass say hello from internal namespace! Say hello from InternalClass say hello from internal namespace!
関数やオブジェクトをexportした場合
今度は、napespaceではなく関数やオブジェクトをexportしてみる。
//ModuleA.ts //トップレベルにオブジェクトや関数のexportを直接記述 export var foo: string = 'abc' export function baz() { return false; } //controller.ts //default exportがない外部モジュールをimportするには二通りの方法がある //1. { } で、対象の外部モジュール内に宣言されている変数名や関数名を指定する import { foo, baz } from '../ModuleA' //2. import * as ABC from... という形で、その外部モジュールをABCという名前空間にロードする //この記述方法をnamespace-style importと呼ぶ import * as bar from '../ModuleA' bar.baz(); //fooは外部モジュールのメンバを直接importしているのでそのまま使える console.log(foo); //barはあくまで名前空間なので、メンバ名を指定してやる必要がある console.log(bar.foo); console.log(bar);
コンパイル結果。
tsconfig.jsonの設定で"module": "commonjs"
を指定したので、Node.jsスタイルのexportに変換されていることがわかる。
//ModuleA.js "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); //exportsのプロパティにセットされている exports.foo = 'abc'; function baz() { return false; } exports.baz = baz; //# sourceMappingURL=ModuleA.js.map //controller.js "use strict"; var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; result["default"] = mod; return result; }; Object.defineProperty(exports, "__esModule", { value: true }); const ModuleA_1 = require("../ModuleA"); const bar = __importStar(require("../ModuleA")); bar.baz(); console.log(ModuleA_1.foo); console.log(bar.foo); console.log(bar); //# sourceMappingURL=controller_moduleA.js.map
実行結果
abc abc # barにはfooプロパティとbazプロパティが格納されていることがわかる { foo: 'abc', baz: [Function: baz] }
ちなみに"module": "ES2015"
を指定した場合はこんな感じのコンパイル結果になる。
//ModuleA.js export var foo = 'abc'; export function baz() { return false; } //controller.js import { foo } from './ModuleA'; import * as bar from './ModuleA'; bar.baz(); console.log(foo); console.log(bar.foo); console.log(bar);
スッキリしていて良いが、Node.jsで読み込めない。悲しい。
classをexportした場合
お次はclass。
//class1.ts //Top-Levelにexportがあるのでこれは外部モジュール export class class1 { constructor(){ } /* /クラス内でnamespaceは定義できない namespace internalNameSpace1{ } */ } //controller.ts import { class1 } from './class1' const obj1 = new class1();
コンパイル結果
//class1.js "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); class class1 { constructor() { } } exports.class1 = class1; //# sourceMappingURL=Class1.js.map //controller.js const Class1_1 = require("./class1"); const obj1 = new Class1_1.class1();
default export
今度はdefault export
句を使った場合。
default export
はその外部モジュールが1つしか関数、オブジェクト、クラス、名前空間などをexportしない場合に、利用側に「このモジュールはたった1つしかexportしませんよ」と伝えるためのキーワード。
この書き方で外部モジュールを書いた場合、普通だったら
//foo.ts export function foo () {} //bar.ts export function bar () {} //controller.ts import { foo } from './foo'; import * as bar from './bar';
と書かなければならないところを、
//foo.ts function foo () {} export default foo; //bar.ts function bar () {} export default bar; //controller.ts import foo from './foo'; import bar from './bar'; foo(); bar();
と書くことができる。
こちらはやや長めのサンプルコード。
//defaultExport.ts function defaultExport() { console.log('hello this is default export') } export default defaultExport; //defaultExport_class.ts class defaultExport{ constructor() { } sayDefault(){ console.log("hello this is default export class") } } export default defaultExport //defaultExport_namespace.ts namespace DefaultNameSpace { //default exportでDefaultNameSpaceが指定されているので、他のモジュールからこのクラスは参照可能 export class ExternalClass { sayHello() { const external_internal = new InternalClass(); external_internal.sayInternal(); } } //このクラスはDefaultNameSpaceの内部でしか参照できない。 class InternalClass { sayInternal() { const internal_external = new InternalNameSpace.InternalExternalClass(); internal_external.sayInternalExternal(); } } //exportされたnamespaceの内部にnamespaceをネストすると、こちらはDefaultNameSpaceより外から参照不可能 namespace InternalNameSpace { export class InternalExternalClass { sayInternalExternal() { const internal_internal = new InternalInternalClass(); internal_internal.sayInternalInternal(); } } //こちらはInternalNameSpace内からしかアクセスできない class InternalInternalClass { sayInternalInternal() { console.log('hello from internal-internal class') } } } } export default DefaultNameSpace //contoller.ts import default_function from '../defaultExport' import default_class from '../defaultExport_class' //ちなみにdefault-style import/namespace-style importのどっちで読み込んでもいい import default_NameSpace from '../defaultExport_namespace' import * as default_NameSpace2 from '../defaultExport_namespace' default_function() const default_obj = new default_class(); default_obj.sayDefault(); //ExternalClassにしかアクセスできない const default_namespace = new default_NameSpace.ExternalClass(); default_namespace.sayHello(); const def_ns = new default_NameSpace2.default.ExternalClass(); def_ns.sayHello();
コンパイル結果
//defaultExports.js "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); function defaultExport() { console.log('hello this is default export'); } exports.default = defaultExport; //# sourceMappingURL=defaultExport.js.map //defaultExports_class.js "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); class defaultExport { constructor() { } sayDefault() { console.log("hello this is default export class"); } } exports.default = defaultExport; //# sourceMappingURL=defaultExport_class.js.map //defaultExports_namespace.js "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var DefaultNameSpace; (function (DefaultNameSpace) { class ExternalClass { sayHello() { const external_internal = new InternalClass(); external_internal.sayInternal(); } } DefaultNameSpace.ExternalClass = ExternalClass; class InternalClass { sayInternal() { const internal_external = new InternalNameSpace.InternalExternalClass(); internal_external.sayInternalExternal(); } } let InternalNameSpace; (function (InternalNameSpace) { class InternalExternalClass { sayInternalExternal() { const internal_internal = new InternalInternalClass(); internal_internal.sayInternalInternal(); } } InternalNameSpace.InternalExternalClass = InternalExternalClass; class InternalInternalClass { sayInternalInternal() { console.log('hello from internal-internal class'); } } })(InternalNameSpace || (InternalNameSpace = {})); })(DefaultNameSpace || (DefaultNameSpace = {})); exports.default = DefaultNameSpace; //# sourceMappingURL=defaultExport_namespace.js.map //controller.js "use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; result["default"] = mod; return result; }; Object.defineProperty(exports, "__esModule", { value: true }); const defaultExport_1 = __importDefault(require("../defaultExport")); const defaultExport_class_1 = __importDefault(require("../defaultExport_class")); const defaultExport_namespace_1 = __importDefault(require("../defaultExport_namespace")); const default_NameSpace2 = __importStar(require("../defaultExport_namespace")); defaultExport_1.default(); const default_obj = new defaultExport_class_1.default(); default_obj.sayDefault(); const default_namespace = new defaultExport_namespace_1.default.ExternalClass(); default_namespace.sayHello(); const def_ns = new default_NameSpace2.default.ExternalClass(); def_ns.sayHello(); //# sourceMappingURL=controller_defaultExport.js.map
実行結果
hello this is default export hello this is default export class hello from internal-internal class hello from internal-internal class
まとめ
なんだかたくさん書いた気がするけど、やっぱりexport default class{}
が一番わかりやすように感じる。class単位にしておけば内部でpublic/privateの設定もできるし、しかも責務の範囲が明確になりやすい。
逆にutil.ts
とか作ってexport function util1(){}
とかやり始めるとutilモジュールが無限に肥大化していってよくないな、となんだか良くない方向になりそう。
ちなみに今回は触れなかったが、esModuleIterop
というオプションがあり、これまたJavaScriptが大好きになってしまう(うわー、JavaScript、だーいすき)ので次回紹介していきたい。(感情、無し)
おわりに
た、TypeScript最高・・・