TypeScript
vue.js

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

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