LoginSignup
21
17

More than 5 years have passed since last update.

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

Posted at

はじめに

経緯

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

21
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
17