6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Vue + TypeScript + Babel + Bulma + webpackで作るSPAでレスポンシブなフロント開発3(JavaScript Framework編)

Last updated at Posted at 2018-03-11

リンク

前回は開発ツール周りを説明しました。
今回からは実際の実装部分についてです。
最初はJavaScript Frameworkについて説明します。

github

JavaScript FrameworkはVue.jsを使います。
Vue.jsはReact.jsにかなり似ています。
あくまで個人的な感想ですが、React.jsの微妙な部分をよくしたのがVue.jsみたいな認識です。
(jsx、reduxとvuex、formまわりなどなど)
Vue.jsは単独でも動作しますが、ライブラリとしてVue-Router.jsやVuexを使うとさらに複雑なことができます。
今回はルーター、Vuex、イベント、双方向バインディング、ネットワークのサンプルソースを作りました。

開発用のhtml

開発用のhtmlはwebRoot/index.htmlです。
webRootLive/index.htmlは本番用になる想定です。

http://localhost:3000/ にアクセスするとindex.htmlを開きます。

webRoot/index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>

<div id="navigation"></div>
<div id="app"></div>
<div id="footer"></div>

<script src="/public/main.min.js" type="text/javascript"></script>
<script src="/public/style.min.js" type="text/javascript"></script>

</body>
</html>

head にはレスポンシブの設定viewportがあるのと、divが3つあって、webpack dev serverで作られたjs読み込んでいるくらいです。

ルートファイル

style.ts

開発用のファイルになります。
SASSファイルをJSとして読み込んでいます。

style.ts
import "../scss/index.scss"

main.ts

javascriptのルートファイルになります。
実際では、カテゴリーごとに複数のルートファイルを作ることになるかと思います。

main.ts
/// <reference path="./index.d.ts"/>
import Vue from 'vue'
import Vuex from 'vuex'
import VueRouter from 'vue-router'

import {MainComponent} from "./view/component/MainComponent";
import {FooterComponent} from "./view/component/FooterComponent";
import {NavigationComponent} from "./view/component/NavigationComponent";

Vue.use(Vuex)
Vue.use(VueRouter)

function start() {
    const navigation = new NavigationComponent()
    navigation.$mount('#navigation')

    const footer = new FooterComponent()
    footer.$mount('#footer')

    const app = new MainComponent()
    app.$mount('#app')
}

start()

index.htmlの3つのdivにDOMを設定するようにしています。

TypeScriptの型宣言

index.tsはTypeScriptの型宣言を設定するクラスです。
TypeScriptのアンビエント宣言を記述しています。

index.ts
declare var process: any

declare module '*.vhtml' {
	import Vue = require('vue')
	interface WithRender {
		<V extends Vue>(options: Vue.ComponentOptions<V>): Vue.ComponentOptions<V>
		<V extends typeof Vue>(component: V): V
	}
	const withRender: WithRender
	export = withRender
}

htmlのLoaderのアンビエント宣言はここを参考にさせていただきました。
拡張子はhtmlでもよかったのですが、vueのhtmlなのでvhtmlにしました。
別にhtmlでもいいんですがね。

ルーター(vue router)

今回はmainRouter.tsのみになります。
ルーターを設定するとURLとComponentをマッピングしてくれます。

mainRouter.ts
import VueRouter from 'vue-router'

import {SampleComponent} from "../component/SampleComponent";
import {PageNotFoundComponent} from "../component/PageNotFoundComponent";
import {SampleButtonComponent} from "../component/SampleButtonComponent";
import {SampleParameterComponent} from "../component/SampleParameterComponent";
import {SampleBindComponent} from "../component/SampleBindComponent";
import {SampleLoadComponent} from "../component/SampleLoadComponent";
import {SampleModalButtonComponent} from "../component/SampleModalButtonComponent";
import {SampleColumnComponent} from "../component/SampleColumnComponent";

export default new VueRouter({
    mode: 'history',
    routes:[
        { path: "/", component:  SampleComponent},
        { name: 'sample', path: '/sample', component: SampleComponent},
        { name: 'sample-button', path: '/sample-button', component: SampleButtonComponent},
        { name: 'sample-parameter', path: '/sample-parameter/:id', component: SampleParameterComponent},
        { name: 'sample-bind', path: '/sample-bind', component: SampleBindComponent},
        { name: 'sample-load', path: '/sample-load', component: SampleLoadComponent},
        { name: 'sample-modal-button', path: '/sample-modal-button', component: SampleModalButtonComponent},
        { name: 'sample-column', path: '/sample-column', component: SampleColumnComponent},
        { path: '*', component: PageNotFoundComponent},
    ]
})

URLとクラスのマッピングは以下のようになります。

http://localhost:3000/の場合はSampleColumnComponent.ts
http://localhost:3000/sample-buttonの場合はSampleButtonComponent.ts

どれにもマッチングしなかった場合は、404としてPageNotFoundComponentにマッピングするようになっています。

詳しくはVue Router HTML5 History モード
を参考にしてください。

データストア(Vuex)

データストアのライブラリはVuexを使います。
データストアのライブラリを使わなくてもWEBアプリは開発できますが、SPAで開発する場合に必須ではと思います。
iOSやandroidアプリにライフサイクルがあるようにWEBアプリをSPAで作る場合にもライフサイクルがでてきます。
その際にビュー側のライフサイクルによるコンポーネントの生存期間は画面遷移の度などに削除されますが、データの保存期間は画面がリロードするまでずっとメモリに残り続けたりします。
SPAで作らない場合は、画面遷移でデータが消失され、コンポーネントとデータの削除のタイミングが同じになりがちです。
その際に、便利にデータを管理するのがVuexとなるのです。
たぶん。

Vuex(Store)

MainStore.tsはVuexのクラスです。

MainStore.ts
/// <reference path="../../index.d.ts"/>

import Vue from 'vue'
import Vuex, {MutationTree} from 'vuex'
import EnvironmentUtility from "../../utility/EnvironmentUtility";
import sampleLoadModule from '../module/SampleLoadModule'
import sampleBindModule from '../module/SampleBindModule'
import sampleModalModule from '../module/SampleModalModule'

const NAMESPACE = 'main';
const NAMESPACE_ACTION = `${NAMESPACE}/a/`
const NAMESPACE_GETTER = `${NAMESPACE}/g/`
const NAMESPACE_MUTATION = `${NAMESPACE}/m/`

Vue.use(Vuex)

export const ActionKey = {
}

export const GetterKey = {
}

export const MutationKey = {
}

export class MainState {
    public sampleLoad
    public sampleBind
    public sampleModal
}

function createStore():any{
    const mutations = {
    } as MutationTree<MainState>

    return new Vuex.Store<MainState>({
        state: new MainState(),
        mutations,
        strict: EnvironmentUtility.isDevelopment(),
        modules:{
            sampleLoad:sampleLoadModule,
            sampleBind:sampleBindModule,
            sampleModal:sampleModalModule,
        }
    })
}

export default createStore()

今回は3つモジュールを読み込んでいるだけのクラスになっています。
あと、strictの設定をしています。
strictの設定とは厳格モードにするためです。
本番では厳格モードを有効してはいけないです。
詳しくはVuex 厳格モードを参考にしてください。

EnvironmentUtility.ts

開発用と本番用の設定を切り替えられるクラスです。
webpackでtrue falseの設定しています。
開発ツール編を参考にしてください。

EnvironmentUtility.ts
/// <reference path="../index.d.ts"/>

export default class EnvironmentUtility {
    static isDevelopment(){
        return !this.isProduction();
    }

    static isProduction(){
        return process.env.NODE_ENV === 'production';
    }
}

Vue(コンポーネント)

Vueのコンポーネントは表示をコントロールするためのクラスです。

枠のコンポーネント

今回の枠のコンポーネントはMainComponent.tsになります。

MainComponent.ts
/// <reference path="../../index.d.ts"/>
import Vue from 'vue'
import Component from 'vue-class-component'
import HtmlTemplate from '../../../vhtml/main.vhtml'

import store from '../store/MainStore';
import router from '../router/mainRouter'

@HtmlTemplate
@Component({
  router,
  store,
})
export class MainComponent extends Vue {
  message: string = 'Hello Vue.js typescript!!'
}
main.vhtml
<section class="section">
    <div class="container">
        <h1 class="title">
            {{message}}
        </h1>

        <router-view></router-view>
    </div>
</section>

MainComponent.tsのmesseageプロパティに文字列が定義されています。
main.vhtmlの{{message}}で出力されます。

あと、今回、vue-routerを使っているので、
パスに対してマッチしたコンポーネントをrouter-viewに描画してくれます。

Vue Hello World

Vue Hello Worldって自分で書いておきながらよくわからないですが、
SampleComponent.tsはrouter-viewに簡単に描画したいと思った場合のサンプルになります。

SampleComponent.ts
/// <reference path="../../index.d.ts"/>
import Vue from 'vue'
import Component from 'vue-class-component'
import HtmlTemplate from '../../../vhtml/sample.vhtml'

@HtmlTemplate
@Component({
})
export class SampleComponent extends Vue {
}
sample.vhtml
<div>
    <h1>sample</h1>
</div>

vhtmlには1つのタグをルートとしたdomしか記述できません。

Vueイベント

今回はとりあえずクリックイベントのみ扱います。
http://localhost:3000/sample-button

SampleButtonComponent.ts
/// <reference path="../../index.d.ts"/>
import Vue from 'vue'
import Component from 'vue-class-component'
import HtmlTemplate from '../../../vhtml/sample-button.vhtml'

@HtmlTemplate
@Component({
})
export class SampleButtonComponent extends Vue {
    clickByAlertButton() {
        alert("clicked test");
    }
}
sample-button.vhtml
<div>
    <h1>sample-click</h1>
    <a class="button is-light" @click="clickByAlertButton">test</a>
</div>

ポイントは@clickです。

Vueの双方向バインディング

SampleBindComponent.ts

SampleBindComponent.ts
/// <reference path="../../index.d.ts"/>
import Vue from "vue";
import Component from "vue-class-component";
import HtmlTemplate from "../../../vhtml/sample-bind.vhtml";
import {MutationKey} from "../module/SampleBindModule";

@HtmlTemplate
@Component({
})
export class SampleBindComponent extends Vue {
  get comment() {
    return this.$store.state.sampleBind.comment
  }

  set comment(value) {
    this.$store.commit(MutationKey.SET_COMMENT, value)
  }
}

SampleBindModuleへのアクセッサーです。

SampleBindModule.ts
/// <reference path="../../index.d.ts"/>

import {ActionTree, GetterTree, ModuleTree, MutationTree} from "vuex";
import {MainState} from "../store/MainStore";

const NAMESPACE = 'sample-bind';
const NAMESPACE_ACTION = `${NAMESPACE}/a/`
const NAMESPACE_GETTER = `${NAMESPACE}/g/`
const NAMESPACE_MUTATION = `${NAMESPACE}/m/`

export const ActionKey = {
}

export const GetterKey = {
}

export const MutationKey = {
    SET_COMMENT: `${NAMESPACE_MUTATION}SET_COMMENT`,
}

function createStore(){
    class State {
        constructor(
            public comment: String = null,
        ) { }
    }

    const getters = {
    } as GetterTree<State, MainState>

    const actions = {
    } as ActionTree<State, MainState>

    const mutations = {
        [MutationKey.SET_COMMENT] (state, comment:String) {
            state.comment = comment;
        },
    } as MutationTree<State>

    return {
        state: new State(),
        getters,
        actions,
        mutations
    } as ModuleTree<State>
}

export default createStore()

SET_COMMENTというメソッドがあります。
stateのcommentを引数の値に変更しています。
stateの変数はmutation内でしか変更してはいけません。
これは大事です。
action内やgetter内、他では変更できないのです。
詳しくはVuex フォームの扱いを参考にしてください。

sample-bind.vhtml
<div>
    <h1>sample-bind</h1>
    comment: <input type="text" v-model="comment"><br>
    comment: {{comment}}<br>
</div>

ポイントはv-modelです。
これでSampleBindComponent.tsのcommentへアクセスしています。

VueのパスパラメータとQueryパラメータ

SampleParameterComponent.ts

http://localhost:3000/sample-parameter/123
http://localhost:3000/sample-parameter/aaa?test=bbb

SampleParameterComponent.ts
/// <reference path="../../index.d.ts"/>
import Vue from 'vue'
import Component from 'vue-class-component'
import HtmlTemplate from '../../../vhtml/sample-parameter.vhtml'

@HtmlTemplate
@Component({
})
export class SampleParameterComponent extends Vue {
    get pathVariable() {
        return this.$route.params.id;
    }

    get getVariable() {
        return this.$route.query.test;
    }
}
sample-parameter.vhtml
<div>
    <h1>sample-parameter</h1>
    <div>path variable:{{pathVariable}}</div>
    <div>get variable:{{getVariable}}</div>
</div>

http://localhost:3000/sample-parameter/123
http://localhost:3000/sample-parameter/aaa?test=bbb

1つ目のURLはid、123が取得できます。
2つ目のURLはaaaを取得しています。
さらに2つ目のURLにはクエリパラメータ付きです。
キーはtest値はbbbにとなっています。

ネットワーク

ネットワーク周りをやる上で押さえておきたいのがPromiseとasync/awaitです。
SampleLoadComponent.tsではちょっと複雑に作ってしますが、引き続きよろしくお願いします。

SampleLoadComponent.ts
/// <reference path="../../index.d.ts"/>
import Vue from 'vue'
import Component from 'vue-class-component'
import HtmlTemplate from '../../../vhtml/sample-load.vhtml'
import {ActionKey} from "../module/SampleLoadModule";

@HtmlTemplate
@Component({
})
export class SampleLoadComponent extends Vue {
  /**
   * ライフサイクル
   */
  async created(){
    console.log("load 1");
    console.log(`message:${this.$store.state.sampleLoad.message}`);
    await this.$store.dispatch(ActionKey.LOAD_MESSAGE)
    console.log(`message loaded:${this.$store.state.sampleLoad.message}`);
    console.log("load 2");
    await this.$store.dispatch(ActionKey.LOAD_TITLE, "fff")
    console.log("load 3");
  }

  /**
   * プロパティ
   */
  get message(){
    return this.$store.state.sampleLoad.message;
  }

  get title(){
    return this.$store.state.sampleLoad.title;
  }
}
ブラウザ実行時のconsoleログ
load 1
message:null
message loaded:sample message
load 2
load 3

consoleログには順番にログが出ています。
順番にログが出ているのがポイントです。
通信もしているのにです。

sample-load.vhtml
<div>
    <h1>sample-load</h1>
    message: {{message}}<br>
    title: {{title}}<br>
</div>
SampleLoadModule.ts
/// <reference path="../../index.d.ts"/>

import {ActionTree, GetterTree, ModuleTree, MutationTree} from "vuex";
import SampleAPI from "../../network/api/SampleAPI";
import {SampleEntity} from "../../network/entity/SampleEntity";
import {MainState} from "../store/MainStore";
import {Sample2Parameter} from "../../network/parameter/Sample2Parameter";
import {Sample2Entity} from "../../network/entity/Sample2Entity";

const NAMESPACE = 'sample-load';
const NAMESPACE_ACTION = `${NAMESPACE}/a/`
const NAMESPACE_GETTER = `${NAMESPACE}/g/`
const NAMESPACE_MUTATION = `${NAMESPACE}/m/`

export const ActionKey = {
    LOAD_MESSAGE: `${NAMESPACE_ACTION}LOAD_MESSAGE`,
    LOAD_TITLE: `${NAMESPACE_ACTION}LOAD_TITLE`,
}

export const GetterKey = {
}

export const MutationKey = {
    SET_LOAD_MESSAGE: `${NAMESPACE_MUTATION}SET_LOAD_MESSAGE`,
    SET_LOAD_TITLE: `${NAMESPACE_MUTATION}SET_LOAD_TITLE`,
}

function createStore(){
    class State {
        constructor(
            public message: String = null,
            public title: String = null,
        ) { }
    }

    const getters = {
    } as GetterTree<State, MainState>

    const actions = {
        [ActionKey.LOAD_MESSAGE]: ({ commit }) => {
            return SampleAPI.getSample()
                .then((res) =>{
                    commit(MutationKey.SET_LOAD_MESSAGE, res)
                })
                .catch(function(error) {
                    alert(error);
                })
        },
        [ActionKey.LOAD_TITLE]: ({ commit }, p1) => {
            const parameter:Sample2Parameter = {
                abc:p1
            };
            return SampleAPI.getSample2(parameter)
                .then((res) =>{
                    commit(MutationKey.SET_LOAD_TITLE, res)
                })
                .catch(function(error) {
                    alert(error);
                })
        },
    } as ActionTree<State, MainState>

    const mutations = {
        [MutationKey.SET_LOAD_MESSAGE] (state, payload:SampleEntity) {
            state.message = payload.message;
        },
        [MutationKey.SET_LOAD_TITLE] (state, payload:Sample2Entity) {
            state.title = payload.title;
        },
    } as MutationTree<State>

    return {
        state: new State(),
        getters,
        actions,
        mutations
    } as ModuleTree<State>
}

export default createStore()
SampleAPI.ts
import BaseAPI from "./BaseAPI";

import {SampleEntity} from "../entity/SampleEntity";
import {Sample2Entity} from "../entity/Sample2Entity";
import {Sample2Parameter} from "../parameter/Sample2Parameter";
import {WebUrlParams} from "../WebUrlParams";

export default class SampleAPI extends BaseAPI {
    static getSample() {
        const apiUrl = `/data1.json`;

        return this.get<SampleEntity>(apiUrl);
    }

    static getSample2(parameter:Sample2Parameter) {
        const apiUrl = `/data2.json`;

        var params = new WebUrlParams();
        params.append("abc", parameter.abc);

        return this.get<Sample2Entity>(apiUrl, params);
    }
}
Sample2Entity.ts
export interface Sample2Entity {
    title
}
Sample2Parameter.ts
export interface Sample2Parameter {
    abc
    ddd?
}

abcは必須、ddd?はnullable変数になります。

BaseAPI.ts
import {WebParams} from "../WebParams";
import {WebUrlParams} from "../WebUrlParams";

export default class BaseAPI {
    static post<T>(apiUrl:string, params:WebParams = null){
        let body = params ? params.data : null;
        return this.send<T>(apiUrl, {
            credentials: 'include',
            method: "POST",
            body: body
        })
    }

    static get<T>(apiUrl:string, params:WebUrlParams = null){
        var url = apiUrl;
        if(params){
            url += "?" + params.data;
        }
        return this.send<T>(url, {
            credentials: 'include'
        })
    }

    private static send<T>(apiUrl, config){
        return new Promise<T>((resolve, reject) => {
            fetch(apiUrl, config).then(res => {
                res.json().then(json => {
                    if(res.status >= 400 && res.status < 500){
                        reject(res)
                        return
                    }

                    resolve(json);
                });
            }).catch(e => {
                reject(e)
            });
        });
    }
}

一番大事なところなのに説明が少ないところが多々ありますが、
とりあえずこんな感じでおわりにします。。

github

6
4
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
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?