Posted at

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

More than 3 years have passed since last update.


はじめに


経緯

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


筆者の環境


  • Windows 10 Pro 64ビット

  • IntelliJ IDEA 15.0 (無くても構いません)


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

前回の記事と同じです。


プロジェクトの作成


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

前回の記事から、以下のみ異なります。

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


package.jsonの作成

前回の記事と同じです。


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

前回の記事と同じです。


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

前回の記事から、以下のみ異なります。


  • [File]-[Settings]で設定ダイアログを開きます。


    • [Language & Frameworks > TypeScript]を開きます。


      • [Command line options]に--module commonjs --experimentalDecoratorsを入力します。






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


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>


src/js/main.ts

// とりあえず空で良い



tslint.jsonの作成

前回の記事と同じです。


gulpfile.jsの作成とgulpの実行

前回の記事から、以下のみ異なります。

gulpfile.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/postsに接続し、CRUD操作を行います。

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

hello-vuejs.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から、型定義ファイルが検索されます。

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

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

>typings install


サブモジュールの実装


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();
}
}



メインモジュールの実装


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の実装


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による静的型付けを活かしたプログラミングができそうです。