HDE BLOG

コードもサーバも、雲の上

Vue.js ではじめるシングルページアプリケーションの開発

Vue.js は JavaScript フレームワークです。 ウェブアプリケーションのユーザーインターフェイス開発を支援する様々な仕組みを提供します。

f:id:gorotan35:20180228183927p:plain

管理画面はもちろん、HTMLエディタのようにユーザの入力に対して即応性が必要なアプリケーションを簡単に作ることができます。 例えば、テキストエリアに文字を入力すると、 デザインしたページの特定のDIV要素がリアルタイムに更新されるといったデータ反映の仕組みを備えています。

また、JavaScript で大規模なユーザーインターフェイスの開発を行う場合、HTMLファイルのテンプレート化、 JSファイルの依存関係、グローバル変数汚染など様々な課題に直面します。 Vue.js は、コンポーネントという仕組みと Webpack というモジュール管理ツールと組み合わせることで、 これらの課題にうまく対処できるようになっています。

今まで jQuery + Bootstrap でユーザーインターフェイスを開発していましたが、今回のプロジェクトではじめて Vue.js での開発にトライしました。 その経験をふまえて、Vue.js の開発環境や使い方、周辺ツールなどをまとめています。 これから Vue.js で開発を始める方の入門になれば幸いです。

開発環境

Vue.js は本家サイトのドキュメントが非常に充実しており、日本語にも翻訳されています。

jp.vuejs.org

Vue.js はプロブレッシブフレームワークという考え方で設計されており、 問い合わせフォームのような小規模なアプリケーションにも、Vue.js を適用できるように考慮されています。 このようなケースでは、jQuery と同様に <script> タグでロードすることで Vue.js を簡単に導入することができます。

しかし、中~大規模アプリケーションを開発する場合は、Node.js が提供する環境やツールを活用するため、 Node.js 上に開発環境を構築します。 例えば、ユーザーインターフェイスに必要な機能を実装するためには、 様々な JavaScript ライブラリを利用する必要があります。 これには npm という Node.js のパッケージ管理の仕組みを使用します。 また、JSファイルの依存管理やブラウザにロードするJSファイルを 作成するために、Webpack という Node.js 上で動作するツールを使用します。

Node.js を使いますので Linux サーバーがあると便利です。 幸い、Windows10 には Windows Subsystem for Linux (WSL) という便利なものがあるのでこれを使いましょう。 手元の環境は以下のバージョンです。

  • Windows10 (OS)
  • Ubuntu 16.04 (WSL)
  • Node.js 8.9.1
  • npm 5.6.0

Ubuntu (WSL)

このあたりを参考にインストールしてみてください。

qiita.com

Windows の Cドライブは、/mnt/c にマウントされます。 C:\dev フォルダを作成すれば、Linux からは、/mnt/c/dev でこのフォルダにアクセスできます。 Windows と Linux でシームレスにファイル参照・編集できるので、Windows 上のエディタでコードを書いて、 Linux 上で build & run できます。これは便利です。

Node.js

Ubuntu へ Node.js をインストールします。

$ curl -sL https://deb.nodesource.com/setup_8.x | sudo -E bash -
$ sudo apt-get install -y nodejs
$ node -v
v8.9.1
$ npm -v
5.5.1

本家のドキュメントはここにあります。ご参考まで。 Installing Node.js via package manager | Node.js

vue-cli

続いて vue-cli をインストールします。 vue-cli は、Vue.js プロジェクトを作成するための支援ツールです。

$ sudo npm install -g vue-cli
$ vue -V
2.9.1

Vue.js プロジェクトを作成する

vue-cli を使ってプロジェクトを作成します。

$ vue init webpack-simple your-project

? Project name your-project
? Project description A Vue.js project
? Author masahiro.okubo
? License MIT
? Use sass? No

   vue-cli · Generated "your-project".

   To get started:

     cd your-project
     npm install
     npm run dev

$

scaffold には webpack または webpack-simple が指定できます。 webpack を指定すると ESLint や Test Runner などの開発ツール一式をインストールしてくれるのですが、 構成が複雑であるために自分で webpack.config.js を編集するのが難しかったです。 まずは、webpack-simple で最小限のプロジェクトを作成し、少しづつ周辺ツールを理解して、 自分のプロジェクトに組み込んでいくのが良いかなと思います。

your-project の部分は、あなたの開発プロジェクトの名前を指定します。 vue-cli を実行すると、プロジェクト名のフォルダが作成されます。ここに移動して、scaffold から生成された ファイルとディレクトリを確認してみます。

$ tree
.
├── README.md
├── index.html
├── package.json
├── src
│   ├── App.vue
│   ├── assets
│   │   └── logo.png
│   └── main.js
└── webpack.config.js

なるほど。シンプルですね。

このディレクトリで、npm install した後、npm run dev とすると、ブラウザが起動して、 Vue.js アプリケーションが実行されます。素敵です。 しかし、このサンプルから管理画面のようなアプリをどうやって作っていくのか、、、 コードを読んだところ、index.html にほぼ静的なページを出力するだけのサンプルだということがわかりました(汗)

とりあえず、main.js 読みましょう。

import Vue from 'vue'
import App from './App.vue'

new Vue({
  el: '#app',
  render: h => h(App)
})

Vue.js は ES2015(ES6) で開発します。モジュール管理(import / export)、オブジェクトリテラル、アロー関数など、 慣れ親しんだ JavaScript (ES5) の知識では理解できない仕様追加が盛りだくさんです。

オブジェクトリテラルの表記については、ここに情報がまとまっていて参考になりました。

qiita.com

Vue.js は、ES2015 で開発するのですがブラウザ互換を考慮し、 babel というES2015⇒ES5 変換ツール(トランスパイラーといいます)で、ES5に変換したコードをブラウザ上で動かすのが今のところ一般的です。 Webpack は複数のJSファイルの依存関係を import / export を頼りに解決して、ブラウザにロードする bundle.js という1つのファイルを出力します。 この過程の中で babel も実行されます。

この仕組みを知った時に、ブラウザが提供している開発ツール(デバッカ)が使えないのではないか? という疑問が沸きました。 これに対しては、SourceMap という、変換前のES2015コードと変換後のES5コードの対応表のような仕組みがありまして、 Chrome や Firefox が対応しています。

SourceMap は、webpack.config.js で指定します。

 53   performance: {
 54     hints: false
 55   },
 56   devtool: '#eval-source-map'
 57 }

指定可能な SourceMap の種類はここに。 Devtool

私の環境ではこの設定で今のところ安定動作しています。

 53   performance: {
 54     hints: false
 55   },
 56   devtool: 'inline-source-map'
 57 }

ここまでをまとめます。

  • Vue.js は Node.js 上の開発環境でプロジェクトを構築していきます。
  • vue-cli というツールが最初のプロジェクト立ち上げを支援してくれます。
  • ES2015 で開発します(知らない場合は勉強します)。
  • プロジェクトの管理は Webpack で行います(知らない場合は勉強します)。
  • ES2015 で書いた複数のJSファイルは、Webpack が依存関係を解決して、babel がES5に変換してブラウザにロードされます。
  • SourceMap が必要ですが、うまく動けばブラウザ上でデバック可能です。

シンプルな管理画面を作ってみる

一般的なウェブアプリケーションを開発するには以下のような機能が必要かと思います。

  • ナビゲーション、サイドメニューなどの基本的な画面レイアウト
  • サーバーと通信するための Ajax ライブラリ
  • ナビゲーションやメニューからの画面遷移
  • 一覧(テーブル)、入力フォーム(ダイアログ)など CRUD に必要な部品

CSSフレームワークを導入する

まずは、CSSフレームワークを導入して基本的な画面レイアウトと、メニューやダイアログなどの部品を手に入れたいところです。 Bootstrap がメジャーどころですが、今回は Semantic-UI を使います。

Webpack は JSファイルのほか、CSSや画像など、 ウェブアプリケーションに必要な各種のリソースも管理できるのですが、 今回のプロジェクトでは、CSSフレームワークは Webpack の管理対象外として以下のように index.html に記述しました。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <meta http-equiv="x-ua-compatible" content="ie=edge">
      <link rel="stylesheet" href="/ext/semantic/semantic.css"/>
    <title>Sample App</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="/ext/jquery.js"></script>
    <script src="/ext/semantic/semantic.js"></script>
    <script src="/dist/bundle.js"></script>
  </body>
</html>

jQuery の使いどころ

【2018-03-12 訂正】
この記事では Semantic-UI + jQuery を使って、UIを実装していますが、 Bulma というCSSのみで実装された(=jQueryを使わない)UIフレームワークがありまして、現在はこちらを試しています。 Vue.js + Bulma を使った管理コンソールの実装は、vue-admin がとても参考になります。 また、Element, AT UI, Vuetify.js など、Vue.js には素晴らしいUIフレームワークがいくつもあります。 どのUIフレームワークの選定するかは要件によるかと思いますが、 これらのUIフレームワークはカスタムコンポーネントとして実装されており、 素のHTMLでUIデザインができないため、現時点での当方のプロジェクトでは採用を躊躇しています。

github.com

イベントハンドリングやデータバインディングの仕組みは Vue.js が提供しています。 Vue.js 2.x 系は、バーチャルDOMというレンダリング高速化の仕組みが裏で動いています。 このバーチャルDOMが実DOMに反映されるまでは、jQuery から DOM操作ができないなど、 Vue.js と jQuery の併用はちょっと注意が必要とのこと。 原則、jQuery は使わない方が良いようですが、Bootstrap や Semantic-UI など、CSSフレームワークのコンポーネントは、 jQuery で実装されているので、まったく使わないというわけにもいきません。

Vue.js のインスタンスは、ライフサイクルフックという関数があり、バーチャルDOMが実DOMに反映された際に、 mounted というフック(関数)が呼ばれるそうです。また、バーチャルDOMが再描画された際には updated というフックが 呼ばれます。これらのタイミングで、CSSフレームワークが提供する JavaScript を実行したり、 jQuery を使って CSSを操作して画面の見た目を制御するといった使い方に限定するのが安全ではないかと思います。

jp.vuejs.org

【2018-03-06 訂正】
取り消し線の部分について、Vue.js はCSS操作ができないような印象を与えてしまう書き方でしたので訂正します。 Vue.js では v-bind:class または v-bind:style を用いて CSSのクラスやスタイルを操作することができます。 後述のサンプルではページネーションのボタン制御に v-bind:class を使用する例があります。

jp.vuejs.org

Ajax 通信はどうするの?

以前は vue-resource という Ajax リクエストを処理するパッケージがあったそうですが引退されたようです。 鉄板である jQuery はできるだけ使うなと。google 先生に聞くと axios が良いとおっしゃられるので素直に axios を使いましょう。

github.com

"Promise based HTTP client for the browser and node.js" とありますが、JavaScript で非同期処理をやるには Promise 便利ですね。 インストールも npm install --save axios で OK. 使い方も簡単です。mock-adapter があるので単体試験もできます。使わない理由がありません。

github.com

開発が少し進むと、「computed で非同期処理できないの?」と思うことがあります。 vue-async-computed というパッケージがあって、Promise を返してあげればちゃんと動作するので、これいいかなーと思ったのですが、 関連する値の変化でリアクティブに動作する computed で、Ajax 通信するので、サーバー側に意図せぬ処理負荷を発生させそうな気がするのと、 Ajax通信が失敗した場合の再実行(computed は値変化がないと実行されない)などの考慮が必要だなと思いました。 computed で非同期処理はできるのですが、用途をよく考えた方が良いように思います。

GitHub - foxbenjaminfox/vue-async-computed: Async computed properties for Vue.js

画面遷移はどうするの?

Vue.js 単体では画面遷移はできません。 vue-router というパッケージを導入します。

はじめに · vue-router

vue-router の使い方は簡単です。CSSフレームワークのナビバーやサイドメニューを配置して、 <a> タグの部分を <router-link> タグに置き換えます。あとは、JavaScript で パスとコンポーネントを紐付けする定義を書いてやれば OK。 特に悩むところなく使えました。 バーチャルDOMの効果もあるのか、画面遷移はものすごい高速です。 これぞ、SPA(シングルページアプリケーション)だ!という感じでちょっと感動します。

画面はどうやって作るの?

CRUDの入り口になる一覧画面や、データ編集のためのダイアログは、Vue.js の単一ファイルコンポーネントで作っていきます。

jp.vuejs.org

単一ファイルコンポーネントは、.vue ファイルに、HTMLと CSS と JavaScript をまとめて書けるので依存関係の可視性が良いです。 どのような単位でコンポーネント化していくかは、プロジェクトの方針やアプリケーションの特性によるかと思います。 今回のプロジェクトでは、CRUDの画面単位にコンポーネント化することにしました。

画面はどのように制御するの?

単一ファイルコンポーネントで実装したユーザ一覧画面をサンプルに、Vue.js での画面の制御を見ていきます。

f:id:gorotan35:20180228172633p:plain
ユーザー一覧画面

赤枠の部分がコンポーネントになっています。

<template>
  <div class="ui segment">
    <!-- loader -->
    <div id="loader" class="ui inverted dimmer">
      <div class="ui text loader">Loading</div>
    </div>
    <!-- title -->
    <h3 class="ui dividing title header">ユーザー管理</h3>
    <!-- control -->
    <div class="ui small stackable control form">
      <div class="fields">
        <div class="four wide field">
          <button class="ui blue button" v-on:click="add">追加</button> ・・・①
        </div>
        <div class="four wide field">
          <select class="ui fluid dropdown" v-model="cond.workspace" v-on:change="search">・・・②
            <option value="all" selected="selected">全てのワークスペース</option>
            <option value="wid1">ワークスペース#1</option>
            <option value="wid2">ワークスペース#2</option>
          </select>
        </div>
        <div class="four wide field">
          <input type="text" placeholder="アカウント"
            v-model="cond.account" v-on:keyup.enter="search">・・・③
        </div>
        <div class="two wide field">
          <button class="ui button" v-on:click="search">検索</button>
        </div>
        <div class="two wide right aligned field">
          <div class="ui narrow buttons">
            <button class="ui icon button" v-bind:class="{'disabled': isMinPage}" v-on:click="prev">・・・④
              <i class="left chevron icon"></i>
            </button>
            <div class="ui button">{{ page }}</div>
            <button class="ui icon button" v-bind:class="{'disabled': isMaxPage}" v-on:click="next">
              <i class="right chevron icon"></i>
            </button>
          </div>
        </div>
      </div><!-- ./fields -->
    </div><!-- ./ui form -->
    <!-- content -->
    <table class="ui celled striped table">
      <thead>
        <tr>
          <th class="four wide">アカウント</th>
          <th class="three wide">権限</th>
          <th class="four wide">氏名</th>
          <th class="three wide">最終ログイン</th>
          <th class="one wide center aligned">2FA</th>
          <th class="one wide center aligned">操作</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="user in users" v-bind:key="user.id">・・・⑤
          <td><div class="ui link" v-on:click="edit(user)">{{ user.account }}</div></td>・・・⑥
          <td>{{ user.role }}</td>
          <td>{{ user.name }}</td>
          <td>{{ user.lastlogin }}</td>
          <td>{{ user.tfa | labeled2fa }}</td>
          <td class="center aligned">
            <button class="ui icon mini orange button" data-content="削除する" v-on:click="remove(user)">
              <i class="remove icon"></i>
            </button>
          </td>
        </tr>
      </tbody>
    </table>
    <users-edit v-bind:user="target"></users-edit>・・・⑦
    <users-remove v-bind:user="target"></users-remove>
  </div><!-- ./ui segment -->
</template>

<script src="./list.js"></script>

HTMLソースに①~⑧までの番号があります(横スクロールして確認してください)。 それぞれ何をしているのか説明します。

①ボタン
Vue.js では、HTMLタグに、「v-on:イベント名="処理(または関数)"」という書式でイベントハンドラを登録します。 この例では、「追加」ボタンが押されたら、add 関数を呼び出しています。 (JavaScript については、後述を参照ください)

②リスト
select タグの値は、v-model="cond.workspace" でバインディングしています。 これにより選択リストの値が変更されると、Vue.js が cond.workspace に自動的に値を代入してくれます。 また、v-on:change="search" で、リストの選択値が変更された場合、search 関数を呼び出して一覧を更新します。

③入力フィールド
select タグと同様、v-model="cond.account" でデータバインディングします。 また、 v-on:keyup.enter="search" で、改行キーが入力された場合、search 関数を呼び出して一覧を更新します。 (自分で実装したら結構面倒なコードを書く必要がありますよね。これは便利だと思います)

④ページング
ページングの「前へ」「次へ」ボタンの制御です。 v-bind:class="{'disabled': isMinPage}" は、 isMinPage が true を返したら disabled というCSSクラスを追加する。 という制御をしています。Semantic-UI のボタンは、disabled が指定されると見た目を制御し、かつ、 クリックイベントも発生しなくなるので、これだけでページングボタンの制御はおしまいです。 v-on:click="prev" で「前へ」ボタンがクリックされたら、prev 関数を呼び出して前ページを表示します。 同じ制御を、「次へ」ボタンの方でも行います。

⑤テーブル(行)
v-for="user in users" で、コンポーネントが持つ、users 配列から user オブジェクトを取得します。

⑥テーブル(列)
⑤で取得した user オブジェクトを使って一覧に必要な情報を表示します。 td タグの中に、{{ user.account }} と書けば、user オブジェクトの account プロパティが表示されます。 また、v-on:click="edit(user)" の部分では、アカウントのリンクをクリックしたら、編集ダイアログを表示する制御をしています。 edit 関数には、※この行の user オブジェクト※が渡されます。

⑦ダイアログ
アカウントのリンクをクリックした際に表示されるダイアログは、別コンポーネントとして実装しています。 ※この v-bind がうまく動いておりません。ただいま調査中

続いて、JavaScript です。 単一ファイルコンポーネントでは、script タグに直接 JavaScript を書けますが、 .vue ファイルは、SourceMap に対応していないようなので、ブラウザ上でのデバックを考慮して、JSファイルに記述しています。

import UsersEdit from './edit.vue';
import UsersRemove from './remove.vue';
import UsersAPI from '@/apis/setting/users.js';

export default {
  components: {
    UsersEdit,
    UsersRemove
  },
  data () {・・・①
    return {
      cond: {
        offset: 0,
        limit: 10,
        workspace: 'all',
        account: ''
      },
      users: [],
      target: {}
    }
  },
  filters: {・・・②
    labeled2fa: function(tfa) {
      return fta ? "有効" : "無効";
    }
  },
  computed: {・・・③
    page () {
      return (this.cond.offset / this.cond.limit) + 1;
    },
    isMinPage () {
      return this.page <= 1;
    },
    isMaxPage () {
      return this.page == 10 || this.users.length < this.cond.limit;
    }
  },
  methods: {・・・④
    add () {
      $('#setting-users-edit').modal('show');
    },
    edit (user) {
      this.target = user;
      $('#setting-users-edit').modal('show');
    },
    remove (user) {
      this.target = user;
      $('#setting-users-remove').modal('show');
    },
    prev () {
      this.cond.offset -= this.cond.limit;
      this.find();
    },
    next () {
      this.cond.offset += this.cond.limit;
      this.find();
    },
    search () {
      this.cond.offset = 0;
      this.find();
    },
    find () {
      var self = this;
      $('#loader').dimmer('show');
      UsersAPI.find(this.cond,
        function(users) {
          $('#loader').dimmer('hide');
          self.users = users;
        },
        function(message) {
          $('#loader').dimmer('hide');
          // TODO show error message
        });
    }
  },
  mounted () {・・・⑤
    this.find();
    $('.ui.orange.button').popup();
  },
  updated () {
    $('.ui.orange.button').popup();
  }
}

①データ
Vue.js のコンポーネントは、data プロパティで取り扱うデータを定義します。 マニュアルにもありますが、コンポーネント化する場合は、取り扱うデータオブジェクトを返す関数として定義します。 cond プロパティには、検索条件とページングのための offset / limit を持っています。 cond.workspace と cond.account はHTML部分で説明した通り、v-model でバインドされているので勝手に値が入ってきます。 users は初期値は空配列ですが、UsersAPI.find() で、サーバーからデータを取得して users にセットします。 すると、v-for が反応して users の内容がテーブルにレンダリングされます。

②フィルタ
user.tfa は2要素認証する/しないの boolean 値なので、フィルタを使って表示用のラベルに変換しています。 HTMLの該当箇所が、{{ user.tfa | labeled2fa }} という表記になっていますが、これで、user.tfa が labeled2fa(tfa) の引数 tfa に渡ります。

③算出プロパティ
これが Vue.js の一番面白い機能ではないかなと思います。 算出プロパティの中で、data に定義した値を使った処理を実装します。 data の値が変化すると、算出プロパティが自動で再計算されます。 このサンプルでは、cond.offset と cond.limit からページ番号を計算する部分と、最小/最大ページの判定に、 算出プロパティを使っています。

④メソッド
イベントハンドラから呼び出したい関数や、このコンポーネントの内部処理は、methods プロパティに記述します。 メソッドは、Vue インスタンスの this に直接登録されるらしく、this.メソッド名でアクセスできます。 ダイアログの表示は、Semantic-UI のライブラリを呼び出しており、このあたりで jQuery を使う必要があります。

⑤インスタンスライフサイクルフック
ここがベストのタイミングなのか悩んでいますが、mounted した際にサーバーからユーザリストを取得して、 users に値代入しています。users に値代入するとリアクティブに v-for が動いてテーブルがレンダリングされます。 $('.ui.orange.button').popup() は、データ削除のための「×」ボタンにマウスオーバーすると「削除します」というツールチップ を表示させるために実行しています。 検索条件を変えて、再度一覧を表示すると、行列要素( tr / td タグ)が書き換えられるので、 updated で再描画があったら、popup を再登録するようにしています。 このあたりが jQuery との併用に注意しなければならない部分だと思います。

まとめ

学習コストはけっして安くはありませんが、 非常によく考えられたフレームワークだと思います。 複雑になりがちなフロントエンドのソースコードを、 とてもシンプルに記述することができるという印象を持ちました。

少ないコード量で業務に必要な処理が実装ができれば開発速度も上がりますし、 バグ混入リスクが減るため品質も上がるのではないかと期待しています。

最後に、HDEではフロントエンドエンジニアを募集しています。 ぜひ、当社リクルートサイトもご覧ください。

recruit.hde.co.jp