Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

vue-routerのナビゲーションガードを利用してページ遷移時のローディング処理をまとめる

これは、モバイルファクトリー Advent Calendar 2018 の16日目の記事です。

こんにちは、エンジニアのid:tenmihiです。

今日の記事は、vue-routerのナビゲーションガードを利用してページ遷移時のローディング処理を実装する方法のご紹介です。

概要

業務で次のような要件を満たすフォームをvueを用いて実装しました。

  • フォームが複数のページで構成されている(いわゆるウィザード、ステップ形式)
  • 各ページに遷移するタイミングでフォームの構成に必要なデータをfetchしてきて、結果が返るまではフォームの操作をローディング画面を挟んで制限する

ローディング処理についてcreatedフック内のfetch処理の前後でloadingフラグを操作する方法を考えましたが、毎ページでフラグ操作を記述するのが冗長に感じました。

<template>
  <div>
    <form>
      ...
    </form>
  </div>
</template>

<script>
export default {
  ...
  created () {
    // loadingフラグを元に上位のcomponentでローディング画面を表示させる
    commit('setIsLoading', true)
    fetch()
    commit('setIsLoading', false)
  }
}
</script>

理想としてはfetchだけをcreatedフックあるいは相当の処理内で行い、loadingフラグは別でよしなに操作されてほしいです。

そこで今回は、vue-routerのナビゲーションガードを利用してページ遷移時のローディング処理をまとめてみました。

ナビゲーションガード

ナビゲーションガードとはvue-routerの機能の1つです。

// 例
async beforeRouteEnter(to, from, next) {
    await fetchHoge();
    next()
}

ガードはページの遷移前や後にフックされ、そのタイミングで任意の処理が可能です。

また、同期的な処理も可能でapiの結果によって遷移をキャンセルするようなことも可能となっています。

vue-routerに対するグローバルガードだけでなく、各コンポーネントに対して個別にガード処理を実装することができます。

利用したモジュール

  • vue@2.5.21
  • vue-router@3.0.2
  • vuex@3.0.1

実装

注意

  • vue-cli(3.2.1)で作成したプロジェクトで実装しています
  • 単一ファイルコンポーネント(.vue)で書くことを想定しています
  • vue, vue-router, vuex の利用方法については触れません

App.vue

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Hoge</router-link> |
      <router-link to="/piyo">Piyo</router-link>
    </div>
    <span v-show="is_loading">loading...</span>
    <router-view v-show="!is_loading"/>
  </div>
</template>

<script>
export default {
  computed: {
    is_loading () {
      return this.$store.state.is_loading
    }
  }
}
</script>

Appコンポーネントはrouter-viewを持っていて、パスによって表示する中身を変えます。

ただし、state.is_loadingが立っている場合はrouter-viewではなく"loading..."を表示します。

router.js

import Vue from 'vue'
import Router from 'vue-router'
import store from './store'

import Hoge from './views/Hoge.vue'
import Piyo from './views/Piyo.vue'


Vue.use(Router)

const router = new Router({
  routes: [
    {
      path: '/',
      name: 'hoge',
      component: Hoge
    },
    {
      path: '/piyo',
      name: 'piyo',
      component: Piyo,
    }
  ]
})

router.beforeEach((to, from, next) => {
  store.commit('setIsLoading', true)
  next()
})

router.afterEach(() => {
  store.commit('setIsLoading', false)
})

export default router

パスが/のときHogeコンポーネントが、/piyoのときPiyoコンポーネントが表示されるようなルーティングとなっています。

router.beforeEachとrouter.afterEachはグローバルガードと呼ばれ、どのページでも呼ばれます。

ここではページ遷移前(beforeEach)にis_loadingフラグを立てて、ナビゲーションガードが解決されて遷移する直前(afterEach)にis_loadingフラグを下ろしています。

store.js

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

const sleep = msec => new Promise(resolve => setTimeout(resolve, msec))

const store = new Vuex.Store({
  state: {
    message: ''
  },
  mutations: {
    setMessage(state, message) {
      state.message = message
    }
  },
  actions: {
    async fetch({ commit }) {
      await sleep(1000)
      store.commit('setMessage', 'piyo')
    }
  }
})

export default store

messageとis_loadingをstateとして持ち、1秒待って'piyo'をmessageにsetするようなfetch actionが実装されています。

今回はこのfetch actionをページ遷移前に叩くapiに例えて利用します。

views/Hoge.vue

<template>
  <div>
    <h1>hoge</h1>
  </div>
</template>

Hogeコンポーネントは単にhogeを表示するだけのシンプルなコンポーネントです。

views/Piyo.vue

<template>
  <div>
    <h1>{{ message }}</h1>
  </div>
</template>

<script>
import store from '../store'

export default {
  data () {
    return {
      message: this.$store.state.message
    }
  },
  async beforeRouteEnter(to, from, next) {
    await store.dispatch('fetch')
    next()
  }
}
</script>

Piyoコンポーネントはdata初期化時にstate.messageと同値になるmessageをバインドしています。

ナビゲーションガードであるbeforeRouteEnterメソッドを実装しており、ここで先程のfetch actionを同期的にdispatchします。

ここではfetch actionのdispatchが終わった後にnextメソッドを呼んでページを遷移させるようにしています。

動作

/hogeから/piyoに遷移してみます。

f:id:tenmihi:20181212180959g:plain
ページ遷移時の動作

リンクをクリックしてからloadingが表示されて1秒経った後にpiyoが表示されています。

パスを見てもfetchが終わった後にpiyoページへ遷移していることが分かりますね。

注意点

当然ですが、同期的なデータ取得となりapiを叩いてレスポンスが帰ってくる時間だけページ遷移に時間がかかるようになるので、結果UXとしては悪くなります。

今回のように遷移が遅いと実感できるような処理を挟む場合は、非同期的なデータ取得で実現できないかを考えたほうが良いです。

まとめ

ナビゲーションガードを利用することで、ページ遷移時のローディング処理をまとめることが出来ました。

今回例に上げた以外にも、ページ遷移前に認証済みかどうかをチェックしてリダイレクトさせたりとナビゲーションガードは様々な用途で利用できそうです。

明日は mizuki_r さんです!

Vue.js + TypeScript で IndexedDB にCSVデータを入れる

こんにちは、フロントエンド寄りのエンジニアの @1derful です。

2018年モバイルファクトリーアドベントカレンダー 15日目の記事です。
前日は mattak さんの「フリーランスになって変化したこと」でした。

はじめに

フロントエンド分野の技術は移り変わりが激しいですね。 とはいえ、流行の速いトレンドもあれど、W3Cが策定したWeb標準技術はメンテナンスが止まらない限り使い続けられます。 そこで、Vue.jsTypeScript などの新しい技術に FileReaderIndexedDB の古くからあるWeb APIを織り交ぜて使ってみたいと思います。

まず開発環境を作ります

今回は Vue CLI を使います。 Vue.js の迅速な開発環境構築のため、TypeScript から JavaScript に変換するトランスパイラや、各モジュールを束ねるバンドラのインストールを一括で行い、雛形をスキャフォールディングしてくれるツール群です。 また、TypeScript を公式サポートしていますので Vue CLI の設定時に TypeScript 使用するよう選択します。

f:id:i1derful:20181214101228p:plain
Vue CLIでの今回の記事の設定

それでは、

  1. Vue CLI をインストール
  2. Dexie.js をインストール
  3. PapaParse をインストール
  4. PapaParse の型定義ファイルをインストール*1
  5. プロジェクトを作る

の手順で行います。

ちなみに、IndexedDB はクライアントサイドで使える NoSQL のデータベースです。
IndexedDB API をそのまま使うと複雑な作りになるのでシンプルに扱える Dexie.js を使うことにします。他にも IndexedDB のライブラリはあるので、使い比べてみるのがよいと思います。

そして、CSV のパースを自力で行うこともできますが、CSVライブラリがあるので楽するために使いましょう。 そこでメンテナンスが継続されていて、使用方法が簡単で思った形に整形しやすい PapaParse を使うことにしました。

それでは、以下のコマンドでインストールしてプロジェクトを立ち上げます。

yarn add @vue/cli --dev
yarn add dexie --dev
yarn add papaparse --dev
yarn add @types/papaparse --dev
vue create csv-hangar
vue serve

結果、以下のバージョンの環境が作れました。

node: 11.4.0
vue-cli: 3.2.1
typescript: 3.2.2

実際にコードを書く

 <!-- Bootstrapを使ってます -->
<template>
  <div class='excel-uploader'>
    <div class='input-group-btn'>
      <label class='btn btn-primary'>
        アップロード
        <input type='file' accept='.csv,text/csv' @change='handleUpload' class='file-upload'>
      </label>
    </div>
  </div>
</template>

f:id:i1derful:20181214104003p:plain
味気ないのでロゴを付けてみました

それでは Dexie を使って IndexedDB にデータベースを作っていきます。

@Component
export default class CSVUploader extends Vue {
  /** data: メンバ変数 */
  public csv: string = '';
  public db: AppDatabase = new Dexie('AppDatabase') as AppDatabase;

  /** Vueのライフサイクルフック */
  public created(): void {
    /** テーブル定義 */
    this.db.version(1).stores({
      customer: '++id',    // auto_increment
    });

    /** デフォルトとしてダミーのリストをIndexedDBに追加 */
    this.db.customer.bulkAdd([
      { name : '名無しの権兵衛', age: 67, email: 'foo@foo.foo' },
      { name : 'ジョン・ドウ',     age: 32, email: 'bar@bar.bar' },
    ]);
  }

ここで Chrome をお使いならば Developer Tool を開きます。 Developer Tool の Application タブ から IndexedDB を見てみると、命名指定した「AppDatabase」が作られているのがわかります。 無事に「customer」テーブルも作成されているのが確認できました。

f:id:i1derful:20181214112038p:plain
ブラウザ立ち上げ時にダミーが入ります

以下でアップロード時の処理を追加していきます。

  /** methods: CSVアップロード時の処理 */
  public handleUpload(ev: HTMLElementEvent<HTMLInputElement>): void {
    const files = ((ev.target as any) as HTMLInputElement).files!;
    if (!files.length) {
        return;
    }
    this.createCSV(files[0]);
  }

  /** methods: CSVをパース */
  public createCSV(file: File): void {
    const reader = new FileReader();
    const vm = this;
    reader.onload = (ev: ProgressEvent): void => {
      const csv: string = (ev.target as any).result;
      const collection: any = Papa.parse(csv, { header: true });
      vm.addRecord(collection);
    };
    reader.readAsText(file);
  }

  /** methods: IndexdDBに追加 */
  public addRecord(collection: any): void {
    this.db.customer.bulkAdd(collection.data);
  }
}

以下のようなCSVファイルをアップロードしてみます。

name,age,email
山田太郎,35,hoge@hoge.hoge
ジョン・スミス,42,fuga@fuga.fuga
イワン・イワノヴィッチ・イワノフ,21,piyo@piyo.piyo

アップロード後に再びDeveloper Toolを開きます。
更新されていない場合は、Developer Toolを再起動するといいです。

f:id:i1derful:20181214112955p:plain
CSVのレコードが追加されているのがわかります

型定義ファイル

TypeScript で作る際は、以下の型定義ファイルをインポートする必要があります。

import { Component, Vue } from 'vue-property-decorator';
import { AppDatabase, HTMLElementEvent } from '../libs/definitions';

前者は Vue.js 上で TypeScript を使うのに必ず必要なデコレータです。
Vue CLI のインストールで TypeScript を選択すると、自動でインストールされます。

後者は、自作するしかないので以下に内容を示します。

/**
   IndexedDB
*/
import Dexie from 'dexie';

export type DexieDatabase = {[P in keyof Dexie]: Dexie[P]};
/** データベースの型定義 */
export interface AppDatabase extends DexieDatabase {
  customer: Dexie.Table<ICustomer, number>;
}
/** テーブルの型定義 */
export interface ICustomer {
  id?: number;
  name: string;
  age: number;
  email: string;
}

/**
  Event
*/
export interface HTMLElementEvent<T extends HTMLElement> extends Event {
  target: T;
}

TypeScript 2.1 · TypeScript 等を参考にしています。
HTMLElementEvent は定義されておらず TypeScript のコンパイラでコケますので定義します。


それでは駆け足でしたが、お元気で!
明日は tenmihi です。お楽しみに!!

*1:TypeScript のコンパイラに型を教えてあげないとビルドできないので、型定義ファイルが必要になります。