最近ようやく Angular & TypeScript でソースコードを書き始めました。型のおかげでどのような値が入る変数なのか推測しやすくなり、とても便利です。 さらに angular-cli を使うとターミナル上でプロジェクトやコンポーネントの雛形を作る事ができるため、同じようなコードを何度も繰り返し書かなくても良くなりました。
$ ng new my-project
$ cd my-project
$ ng serve -o
たったの3行で Angular のアプリケーションが動き出しブラウザに Welcome to app!!
のテキストが表示されます。
何がどう動いているのか不思議に思いませんか?
今回の記事は angular-cli のコマンドと設定を元に Angular + TypeScript のプロジェクトがブラウザ上で動作するまでに何が起こっているのかを順に解説していきます。
2017/07/21 追記
タイトルを Angular2 として公開していましたが、現在の最新バージョンである Angular@4.0 で行った検証内容だったため訂正させていただきました。 Ryota Murakami さん、ご指摘ありがとうございました。
この記事を書いた環境
- macOS Sierra
- node v6.9.0
補足
angular-cli のコマンドを実行するには node >= 6.9 が必要です
1) angular-cliでプロジェクトを作成してみる
まず angular-cli をインストールします。
この記事を書いた時のバージョンは @angular/cli@1.2.1
です。
$ npm install -g @angular/cli
冒頭のコマンドを実行してプロジェクトを作成してみましょう。
$ ng new my-project
$ cd my-project
$ ng serve -o
angular-cli の詳しい使い方は公式ドキュメントをご覧ください。
angular-cli/wiki
https://github.com/angular/angular-cli/wiki
ディレクトリ構成
$ ng new {PROJECT_NAME}
で作成された雛形は下記のようなディレクトリ構成になっています。
TypeScript に馴染みがないとディレクトリ構成だけ見ても何がどうなっているのか理解が難しいですよね。私もそうでした。
├── README.md
├── e2e
│ └── ...
├── karma.conf.js
├── node_modules
│ └── ...
├── package.json
├── protractor.conf.js
├── src
│ ├── index.html
│ ├── main.ts
│ └── ...
├── tsconfig.json
└── tslint.json
ルートディレクトリにはパッケージのインストールやユニットテストなど、さまざまな設定ファイルが集まっています。 これらを一度に理解しようとすると混乱してしまいます。まずは angular-cli のプロジェクトで使われている TypeScript について理解しましょう。
2) TypeSciptのトランスパイルを理解する
TypeScript
https://www.typescriptlang.org/index.html
TypeScript は JavaScript を拡張した言語です。構文は JavaScript によく似ていますが変数は静的型づけされ interface
や class
などの構文を使う事ができます。
JavaScript の場合、例えば <script>
タグでファイルを参照するだけでブラウザがソースコードを解釈し実行してくれますが TypeScript はブラウザが解釈する事ができません。
このため TypeScript から JavaScript に トランスパイル したものを参照する必要があります。
tsc
を使ったトランスパイル
// greeter.ts
class greeter {
greet(name: string): string {
return "Hello, " + name;
}
}
$ npm install -g typescript
$ tsc greeter.ts
typescript には tsc というツールが付随しています。これが TypeScript を JavaScript に変換してくれる トランスパイラ です。 ファイルパスを渡すと JavaScript にトランスパイルしたファイルが出力されます。
// greeter.js
var greeter = (function () {
function greeter() {
}
greeter.prototype.greet = function (name) {
return "Hello, " + name;
};
return greeter;
}());
tsc
にオプションを渡す
tsc コマンドを実行した時のカレントディレクトリに tsconfig.json
が存在する場合、トランスパイルのオプションとして使用されます。
angular-cli のディレクトリ構成にもこのファイルは存在していますね。
// tsconfig.json
{
"compileroptions": {
"outdir": "./dist/out-tsc",
"baseurl": "src",
"sourcemap": true,
...
[詳しい解説] TypeScript in 5 minutes
https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html
tsc
だけでは開発が難しい
tsc にはいくつかオプションがありますが、複数ファイルをまとめて処理したり、ひとつのファイルに集約するなどの複雑な動作はできません。 開発が進むたびに増えるファイルをひとつずつコマンドでトランスパイルするのは現実的ではないですよね。 そこで Gulp や webpack などのツールを使って開発の手助けをする必要があります。
方法その1:Gulp
開発用のタスクランナーです。
ソース(ファイルおよびディレクトリ)とそれらに対する連続した動作をタスクとして定義し gulp
コマンドから呼び出します。
Gulp 自身は TypeScript のトランスパイル機能は持っていないためプラグインを組み合わせて使います。
設定を gulpfile.js
に書く
// gulpfile.js
var gulp = require("gulp");
var ts = require("gulp-typescript");
var browserify = require("browserify");
// トランスパイルのオプションを読み込む
var tsProject = ts.createProject("tsconfig.json");
gulp.task("default", function () {
return tsProject.src() // 対象となるソース
.pipe(tsProject()) // トランスパイラにソースを渡す
.js // トランスパイル
.pipe(browserify({})) // ひとまとめにする
.pipe(gulp.dest("dist/bundle.js")); // ファイル出力
...
ターミナルで実行
$ gulp
# ---> dist/bundle.js が出力される
[詳しい解説] TypeScript Tutorials Gulp
https://www.typescriptlang.org/docs/handbook/gulp.html
方法その2:webpack
アセット(JavaScript・HTML・CSS)を読み込んでバンドルする(ひとまとめにする)ツールです。 Gulp と同じように TypeScript のトランスパイル機能は持っていないため、ローダーとプラグインの組み合わせでトランスパイルを行います。
設定を webpack.conf.js
に書く
// webpack.conf.js
module.exports = {
entry: "./src/index.ts", // エントリポイント、このファイルから依存関係を辿る
module: {
rules: {
"test": /\.ts$/, // 拡張子が.tsだったらローダーを使って読み込む
"loader": "awesome-typescript-loader" // 読み込んだ時にトランスパイル
},
},
output: {
filename: "bundle.js", // ファイル出力
path: __dirname + "/dist"
},
...
ターミナルで実行
$ webpack
# ---> dist/bundle.js が出力される
[詳しい解説] TypeScript Tutorials React & Webpack
https://www.typescriptlang.org/docs/handbook/react-&-webpack.html
3) Angularはどのようにトランスパイルしているのか
ここまでで TypeScript がどのようにトランスパイルされるのかをご理解いただけたと思います。 では Angular は tsc でトランスパイルしているのでしょうか?それとも Gulp ? webpack ?
もう少し探るために $ ng serve
コマンドの動作を追いかけてみましょう。
ng
コマンドの正体
$ ng
って何でしょうか。まずはここから探ってみます。
$ which ng
$ /usr/local/bin/ng
私の環境では ng
コマンドは上記の場所に格納されていました。
このファイルは Node で実行される JavaScript のソースコードのため、テキストエディタで開いて読む事ができます。
ng serve
ソースコードを眺めると $ ng serve
を実行した時に以下のような順番でファイルが呼び出されていました。
{$NODE_PATH}/@angular/cli/bin/ng
{$NODE_PATH}/@angular/cli/commands/serve.js
{$NODE_PATH}/@angular/cli/tasks/serve.js
3番目の tasks/serve.js
のソースコードに注目してみます。
// {$NODE_PATH}/@angular/cli/tasks/serve.js
const WebpackDevServer = require('webpack-dev-server');
...
const server = new WebpackDevServer(webpackCompiler, webpackDevServerConfiguration);
return new Promise((_resolve, reject) => {
server.listen(serveTaskOptions.port, serveTaskOptions.host, (err, _stats) => {
...
});
})
webpack-dev-server のインスタンスを作成し接続を開いている事が分かります。
webpack-dev-server は Node 製の開発用サーバーです。 ローカルでwebサーバーを立ち上げ開発中のアプリケーションを動作させることができ、ソースコードの変更に伴うオートリロードなどの機能を持ちます。
webpack DevServer
https://webpack.js.org/configuration/dev-server/#components/sidebar/sidebar.jsx
つまり $ ng serve
コマンドは下記のような動作をしている事になります。
webpack.conf.js
はどこに?
webpack は webpack.conf.js
に設定を定義するのが一般的です。
Angular もこのファイルを持っているのでしょうか?
ディレクトリ構成を見る限り webpack.conf.js
は見当たらず $ ng serve
は特定の webpack.conf.js
を読み込んでいる訳ではないようです。
$ ng eject
コマンドでスケルトンを取り出す機能が提供されているため、今回はこちらを使って webpack でどのような動作が行われるのかを調べてみましょう。
注意
eject
コマンドを実行した後はそのプロジェクトにおいて $ ng serve
コマンドを使うことができなくなります。
webpack.conf.js
を開発者自身でカスタマイズし $ npm start
でブラウザが開くように変更が加えられるためです。
この点にご注意ください。
$ ng eject
// package.json
"scripts": {
"ng": "ng",
- "start": "ng serve",
- "build": "ng build",
- "test": "ng test",
+ "start": "webpack-dev-server --port=4200",
+ "build": "webpack",
+ "test": "karma start ./karma.conf.js",
...
"devDependencies": {
"@angular/cli": "1.2.0",
...
"typescript": "~2.3.3"
+ "webpack-dev-server": "~2.4.5",
+ "webpack": "~2.4.0",
+ "autoprefixer": "^6.5.3",
+ "css-loader": "^0.28.1",
...
eject
コマンド実行後にルートディレクトリを確認すると webpack.conf.js
が追加されています。
このファイルには Angular がどのように処理を行なっているのかが記述されています。
4) webpack.conf.js
のスケルトンを探る
$ ng eject
コマンドで取り出した webpack.conf.js
に書かれた内容を探っていきましょう。
450行とボリュームの大きいファイルですので部分的に引用して紹介していきます。また説明のために実際のファイルとは異なる順番になっていますのでご了承ください。
各設定項目の詳細は下記をご覧ください。
webpack configuration
https://webpack.js.org/configuration/
require
const fs = require('fs');
const path = require('path');
const ProgressPlugin = require('webpack/lib/ProgressPlugin');
webpack は Node で実行されるため設定ファイルは CommonJS 形式で記述されています。
require()
は他のファイルから関数やオブジェクトをインポートする機能で、以下のいずれかを文字列で渡します。
- Node のビルトインモジュール名
node_modules/
にインストールされたパッケージ名- ファイルパス
module.exports
module.exports = {
...(設定内容)
}
設定はオブジェクトリテラルで定義し module.exports
で他のファイルから参照できるようにします。
これも CommonJS 形式の記述です。
entry
"entry": {
"main": [
"./src/main.ts"
],
"polyfills": [
"./src/polyfills.ts"
],
"styles": [
"./src/styles.css"
]
},
アプリケーションのエントリポイントを定義します。
オブジェクトが指定された場合はキーの数だけ Dependency Graph が作成され、それぞれのファイルを再帰的に辿ってファイル間の依存関係をツリー構造に分析します。
先頭の main.ts
は Angular のトップレベルの依存が記述されたファイルです。
resolve.modules
"resolve": {
"modules": [
"./node_modules",
...
],
依存関係を辿る時に、パッケージはこのフォルダから検索されます。
TypeScript のソースコードと照らし合わせて説明すると import { A } from 'foo'
と記述されている場合 node_modules/foo
が検索されるイメージです。
resolve.extensions
"resolve": {
"extensions": [
".ts",
".js"
],
インポート文に拡張子が記述されない場合は、この拡張子を使ってファイル名の解決を試みます。
TypeScript の ソースコードでimport { A } from './foo'
と記述されている場合 foo.ts
foo.js
の順番で検索され最初に一致したファイルが読み込まれます。
angular-cli で作成したプロジェクトの雛形は TypeScript のソースコードと JavaScript のパッケージで構成されているため .ts
および .js
が検索されるように設定されています。
module.rules
"module": {
"rules": [
{
"test": /\.html$/,
"loader": "raw-loader"
},
{
"test": /\.css$/,
"use": [
{
"loader": "css-loader",
"options": ...
},
{
"loader": "postcss-loader",
"options": ...
}
],
},
{
"test": /\.ts$/,
"loader": "@ngtools/webpack"
}
...
],
解決したファイルは、拡張子ごとにローダーが割り振られ読み込まれます。
test
にパターンマッチすると loader
が割り振られる仕組みで、それぞれのローダーは node_modules/
から検索されます。
拡張子 .ts
は @ngtools/webpack
という Angular 専用のローダーが読み込んでいます。
このローダーがトランスパイルを行なっているはずです。
ソースコードを見てみましょう。
// node_modules/@ngtools/webpack/src/loader.js
Object.defineProperty(exports, "__esModule", { value: true });
const path = require("path");
const ts = require("typescript");
const plugin_1 = require("./plugin");
...
if (plugin && plugin instanceof plugin_1.AotPlugin) {
const refactor = new refactor_1.TypeScriptFileRefactor(sourceFileName, plugin.compilerHost, plugin.program, source);
...
const result = refactor.transpile(compilerOptions);
...
}
else {
...
const compilerHost = ts.createCompilerHost(compilerOptions);
...
}
それっぽい箇所だけ抜き出してみました。
require()
で読み込んだ typescript
モジュールは Compiler API という機能を提供しています。
@ngtools/webpack
はこの機能を利用して、2通りのトランスパイルを行なっています。
typescript Using the Compiler API
https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API
plugin
ビルドパイプラインを指定します。トランスパイルされたソースコードは上から順に次々にプラグインに渡され、処理されていきます。
"plugins": [
...
new HtmlWebpackPlugin({
"template": "./src/index.html",
"filename": "./index.html",
...
}
}),
new BaseHrefWebpackPlugin({}),
...
new AotPlugin({
"mainPath": "main.ts",
...
"exclude": [],
"tsConfigPath": "src/tsconfig.app.json",
})
...
],
HtmlWwebpackPlugin
https://github.com/jantimon/html-webpack-plugin
HTMLを生成するプラグインです。tempalte
から読み込んだものを filename
に出力しています。
index.html
は Angular アプリケーションが起動した時にブラウザに一番最初に表示されるページですね。
BaseHrefWebpackPlugin
https://github.com/dzonatan/base-href-webpack-plugin
アプリケーションのURIの基準になる <BASE>
タグを出力します。 Angular アプリケーションでは <BASE>
タグの設置が必須となっています。
HTMLに関するプラグインは他にも定義されていますが、これらを組み合わせる事により以下のようなHTMLが動的に生成されます。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>MyProject</title>
<base href="/">
...
</head>
<body>
<app-root></app-root>
...
<script type="text/javascript" src="main.bundle.js"></script></body>
さきほど @ngtools/webpack
のソースコードに記述されていた AotPlugin
が出てきました。
Angular には just-in-time (JIT) と ahead-of-time (AOT) の2種類のコンパイラが存在します。 両者ともテンプレートのコンパイルを行うものですが、コンパイルを行うタイミングにより用途が分かれます。 それぞれの違いについて軽く理解しておきましょう。
詳細は公式ドキュメントをご覧ください
https://angular.io/guide/aot-compiler
JITコンパイラ
ブラウザからランタイムで呼ばれるコンパイラです。 コンパイル結果はファイル出力されずメモリ上でやり取りします。 ランタイムのため何度もコンパイル処理が実行されますが、修正がリアルタイムに反映されるメリットがあり開発環境で使用されます。
AOTコンパイラ
任意のタイミングで呼び出されるコンパイラです。 コンポーネントのテンプレートもコンパイラのチェック対象となり、記述ミスがある場合はコンパイルが失敗します。 コンパイル結果はファイル出力され、次にコンパイルが実行されるまで上書きされません。
コンパイラの使い分け
$ ng serve
ローカル開発用コマンド、JITコンパイラが使われます。$ ng serve --aot
AOTコンパイラを使うようにオプション指定できます。$ ng build
プロダクション環境に配布するファイルを生成、AOTコンパイラが使われます。
output
"output": {
"path": path.join(process.cwd(), "dist"),
"filename": "[name].bundle.js",
...
},
webpack が処理した結果をファイル出力します。
[name]
はプレースホルダでエントリポイントのキー名で埋められます。
まとめ
$ ng new my-project
$ cd my-project
$ ng serve -o
コマンドを実行してからブラウザが起動するまでの数秒の間に、何が行われているのかご理解いただけましたでしょうか。 簡単にまとめると、下図のように Angular が webpack を使って TypeScript のトランスパイルからファイルのバンドルまでを行い、またテンプレートなど独自のシンタックスを解析するためにオリジナルのプラグインを呼び出すように設定されています。
ローカル開発環境
プロダクション環境用の配布ファイル
かくいう私も Angular を触ったばかりですので、認識の間違いやドキュメントの読み違えがあるかもしれません。 間違いに気づかれましたら記事訂正のためご指摘をいただけると有難いです。
仕組みを理解する事は開発を進める上で大きな助けになると思っています。 Angular をこれから学習する方、またはトラブル解決の小さな助けになれば幸いです。