TypeScript, Vue.js導入記 - モダンなクライアントサイドWeb開発に挑戦した話#2

# はじめに

## 経緯

[前回の記事「TypeScript, React, Sass, webpack, gulp導入記 - モダンなクライアントサイドWeb開発に挑戦した話」](http://qiita.com/data9824/items/9c943049f78ab707d974)で、ビューのライブラリとしてReactを用いましたが、Vue.jsについても検証してみたいと思い、TypeScript上でVue.jsを利用してみました。

## 筆者の環境

* Windows 10 Pro 64ビット
* IntelliJ IDEA 15.0 （無くても構いません）

# 各種ツールのインストール

[前回の記事](http://qiita.com/data9824/items/9c943049f78ab707d974)と同じです。

# プロジェクトの作成

## プロジェクトディレクトリの作成

[前回の記事](http://qiita.com/data9824/items/9c943049f78ab707d974)から、以下のみ異なります。

プロジェクトディレクトリ名は`hello-vuejs`となります。

## package.jsonの作成

[前回の記事](http://qiita.com/data9824/items/9c943049f78ab707d974)と同じです。

## ビルド用モジュールのインストール

[前回の記事](http://qiita.com/data9824/items/9c943049f78ab707d974)と同じです。

## IntellJ IDEAのプロジェクトの作成

[前回の記事](http://qiita.com/data9824/items/9c943049f78ab707d974)から、以下のみ異なります。

* [File]-[Settings]で設定ダイアログを開きます。
    * [Language & Frameworks > TypeScript]を開きます。
        * [Command line options]に`--module commonjs --experimentalDecorators`を入力します。

## 最低限必要なソースファイルの作成

```html:src/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link href="css/common.css" rel="stylesheet" type="text/css">
    <script src="js/bundle.js" type="text/javascript"></script>
</head>
<body>
<div id="content">
    <router-view></router-view>
</div>
</body>
</html>
```

```ts:src/js/main.ts
// とりあえず空で良い
```

## tslint.jsonの作成

[前回の記事](http://qiita.com/data9824/items/9c943049f78ab707d974)と同じです。

## gulpfile.jsの作成とgulpの実行

[前回の記事](http://qiita.com/data9824/items/9c943049f78ab707d974)から、以下のみ異なります。

`gulpfile.js`の内容は以下のようになります。

```js:gulpfile.js
var gulp = require('gulp');
var ts = require('gulp-typescript');
var sass = require('gulp-sass');
var del = require('del');
var webpack = require('gulp-webpack');

gulp.task('clean', function() {
	del(['dist']);
	del(['build']);
});

gulp.task('copy', function() {
	gulp.src(
		[ 'src/**/*', '!src/js/*', '!src/css/*' ],
		{ base: 'src' }
	).pipe(gulp.dest('dist'));
});

gulp.task('ts', function() {
	return gulp.src(['src/js/*.{ts,tsx}'])
		.pipe(ts({
			target: "ES5",
			module: "commonjs",
			experimentalDecorators: true
		}))
		.js
		.pipe(gulp.dest('build/js'));
});

gulp.task('bundle', ['ts'], function() {
	gulp.src('./build/js/*.js')
		.pipe(webpack({
			entry: ['./build/js/main.js'],
			output: {
				filename: 'bundle.js',
				library: 'app'
			},
			devtool: 'source-map',
			resolve: {
				extensions: ['', '.js']
			},
		}))
		.pipe(gulp.dest('dist/js'));
});

gulp.task('scss', function() {
	gulp.src(['src/css/*.scss'])
		.pipe(sass())
		.pipe(gulp.dest('dist/css'));
});

gulp.task('watch', function() {
	gulp.watch(['src/**/*', '!src/js/*', '!src/css/*'], ['copy']);
	gulp.watch('src/js/*.{ts,tsx}', ['bundle']);
	gulp.watch('src/css/*.scss', ['scss']);
});

gulp.task('default', ['copy', 'bundle', 'scss', 'watch']);
```

# 実装


## アプリケーション概要

ダミーのRESTful APIである[JSONPlaceholder](http://jsonplaceholder.typicode.com/)の`/posts`に接続し、CRUD操作を行います。

実装するクラスの構成は以下の通りです。

![hello-vuejs.png](https://qiita-image-store.s3.amazonaws.com/0/12670/93ab5758-ba9c-1e75-ec84-b3ef185a6c4b.png)

## ライブラリの取得

```
>npm install jquery vue vue-class-component vue-router --save
```

`--save`オプションにより、配備するNodeモジュールがインストールされ、`package.json`に記録されます。

## 型定義ファイルの取得

```
>typings init
>typings install jquery --ambient --save
>typings install vue --ambient --save
>typings install vue-router --ambient --save
```

`--ambient`オプションにより[DefinitelyTyped](http://definitelytyped.org/)から、型定義ファイルが検索されます。

`--save`オプションにより、取得した型定義ファイルが、`typings.json`に記載されます。

なお、`typings.json`に記録された型定義ファイルを改めて取得したいときは、以下で取得できます。

```
>typings install
```

## サブモジュールの実装

```ts:src/js/typebone.ts
/// <reference path="../../typings/browser.d.ts" />

import * as $ from 'jquery';

export interface ICollectionListener<T> {
	onUpdateCollection: (sender: Collection<T>) => void;
}

export class Collection<T> {
	private models: T[];
	private url: string;
	private idAttribute: string;
	private listeners: ICollectionListener<T>[];
	constructor(url: string) {
		this.url = url;
		this.idAttribute = 'id';
		this.listeners = [];
	}
	public addListener(listener: ICollectionListener<T>): void {
		this.listeners.push(listener);
	}
	public removeListener(listener: ICollectionListener<T>): void {
		let index: number = this.listeners.indexOf(listener);
		if (index >= 0) {
			this.listeners.splice(index, 1);
		}
	}
	public get(): T[] {
		return this.models;
	}
	public add(item: T): JQueryPromise<any> {
		let d: JQueryDeferred<any> = $.Deferred();
		$.ajax({
			url: this.url,
			dataType: 'json',
			type: 'post',
			data: item,
			success: (data: any): void => {
				if (!(this.idAttribute in data)) {
					console.log('response body must contain id attribute.');
				}
				this.models.push(data);
				d.resolve();
				for (let i: number = 0; i < this.listeners.length; ++i) {
					this.listeners[i].onUpdateCollection(this);
				}
			},
			error: (xhr: JQueryXHR, status: string, err: string): void => {
				d.reject(xhr, status, err);
			},
		});
		return d.promise();
	}
	public fetch(): JQueryPromise<any> {
		let d: JQueryDeferred<any> = $.Deferred();
		$.ajax({
			url: this.url,
			dataType: 'json',
			cache: false,
			success: function(data: any): void {
				this.models = data;
				d.resolve();
				for (let i: number = 0; i < this.listeners.length; ++i) {
					this.listeners[i].onUpdateCollection(this);
				}
			}.bind(this),
			error: function(xhr: JQueryXHR, status: string, err: string): void {
				d.reject(xhr, status, err);
			}.bind(this),
		});
		return d.promise();
	}
	public set(item: T): JQueryPromise<any> {
		let d: JQueryDeferred<any> = $.Deferred();
		let id: string = item[this.idAttribute];
		$.ajax({
			url: this.url + '/' + id,
			dataType: 'json',
			type: 'put',
			data: item,
			success: (data: any): void => {
				for (let i: number = 0; i < this.models.length; ++i) {
					if (this.models[i][this.idAttribute].toString() === id) {
						this.models[i] = item;
						break;
					}
				}
				d.resolve();
				for (let i: number = 0; i < this.listeners.length; ++i) {
					this.listeners[i].onUpdateCollection(this);
				}
			},
			error: (xhr: JQueryXHR, status: string, err: string): void => {
				d.reject(xhr, status, err);
			},
		});
		return d.promise();
	}
	public remove(id: string): JQueryPromise<any> {
		let d: JQueryDeferred<any> = $.Deferred();
		$.ajax({
			url: this.url + '/' + id,
			dataType: 'json',
			type: 'delete',
			success: (data: any): void => {
				for (let i: number = 0; i < this.models.length; ++i) {
					if (this.models[i][this.idAttribute].toString() === id) {
						this.models.splice(i, 1);
						break;
					}
				}
				d.resolve();
				for (let i: number = 0; i < this.listeners.length; ++i) {
					this.listeners[i].onUpdateCollection(this);
				}
			},
			error: (xhr: JQueryXHR, status: string, err: string): void => {
				d.reject(xhr, status, err);
			},
		});
		return d.promise();
	}
}
```

## メインモジュールの実装

```ts:src/js/main.ts
/// <reference path="../../typings/browser.d.ts" />
/// <reference path="../../node_modules/vue-class-component/index.d.ts" />
/// <reference path="typebone.ts" />

import * as Vue from 'vue';
import * as VueRouter from 'vue-router';
import VueComponent from 'vue-class-component';
import * as $ from 'jquery';
import * as Typebone from './typebone';

class AppModel {
	public collection: Typebone.Collection<IPostItem> = new Typebone.Collection<IPostItem>('http://jsonplaceholder.typicode.com/posts');
}

let appModel: AppModel = new AppModel();

interface IPostItem {
	userId: number;
	id: number;
	title: string;
	body: string;
}

@VueComponent({
	template: `
		<div class="post">
			<input type="text" v-model="item.title"/>
			<input type="text" v-model="item.body"/>
			<input class="update" type="button" value="Update" v-on:click="updatePost(item)"/>
			<input class="delete" type="button" value="Delete" v-on:click="deletePost(item)"/>
		</div>
	`,
	props: ['item'],
})
class Post extends Vue {
	public updatePost(post: IPostItem): void {
		appModel.collection.set(post).fail(
			(xhr: JQueryXHR, status: string, err: string) => {
				console.error(xhr.statusText, status, err);
			}
		);
	}
	public deletePost(post: IPostItem): void {
		appModel.collection.remove(post.id.toString()).fail(
			(xhr: JQueryXHR, status: string, err: string) => {
				console.error(xhr.statusText, status, err);
			}
		);
	}
}

@VueComponent({
	template: `
		<div class="postList" v-for="post in posts">
			<post :item="post"></post>
		</div>
	`,
	props: ['posts'],
})
class PostList extends Vue {
}

@VueComponent({
	template: `
		<form class="postForm" v-on:submit.prevent="onPostSubmit">
			<input type="text" placeholder="title" v-model="title"/>
			<input type="text" placeholder="body" v-model="body"/>
			<input type="submit" value="Post"/>
		</form>
	`,
})
class PostForm extends Vue {
	private title: string;
	private body: string;
	public data(): any {
		return {
			title: '',
			body: '',
		};
	}
	public onPostSubmit(): void {
		if (this.title === '' || this.body === '') {
			return;
		}
		this.$dispatch('post-submit', {
			title: this.title,
			body: this.body,
		});
		this.title = '';
		this.body = '';
	}
}

@VueComponent({
	template: `
		<div class="postBox">
			<h1>Posts</h1>
			<post-list :posts="posts"></post-list>
			<post-form v-on:post-submit="addPost"></post-form>
		</div>
	`,
})
class PostBox extends Vue implements Typebone.ICollectionListener<IPostItem> {
	private posts: IPostItem[];
	public data(): any {
		return {
			posts: [],
		};
	}
	public attached(): void {
		appModel.collection.addListener(this);
		appModel.collection.fetch().fail(
			(xhr: JQueryXHR, status: string, err: string) => {
				console.error(xhr.statusText, status, err);
			}
		);
	}
	public detached(): void {
		appModel.collection.removeListener(this);
	}
	public addPost(item: IPostItem): void {
		appModel.collection.add(item).fail(
			(xhr: JQueryXHR, status: string, err: string) => {
				console.error(xhr.statusText, status, err);
			}
		);
	}
	public onUpdateCollection(sender: Typebone.Collection<IPostItem>): void {
		this.posts = appModel.collection.get();
	}
}

@VueComponent({})
class App extends Vue {
}

Vue.component('post', Post);
Vue.component('post-list', PostList);
Vue.component('post-form', PostForm);
Vue.use(VueRouter);
let router: any = new VueRouter();
router.map({
	'/': {
		component: PostBox,
	},
});

$(function() {
	router.start(App, '#content');
});
```

## SCSSの実装

```scss:css/common.scss
$primary-color: #111188;
$background-color: #eeeeee;

body {
	color: $primary-color;
	background-color: $background-color;
}
```

## 改めてビルド

```
>gulp clean
>gulp
```

以上で、`dist/index.html`を閲覧できるようになりました。なお、ダミーのRESTful APIを用いているため、期待通りに動作しない機能もあります。

# まとめ

Reactの時と同様、Vue.jsをTypeScriptから利用するためには各種情報を調査せねばならず、ハードルが高く思われました。ただ、一度導入をしてしまえれば、後は快適にTypeScriptによる静的型付けを活かしたプログラミングができそうです。