開発チームの井川です。


今度とある案件にて、項目数がとても多いデータ更新画面を作ることになりました。
まだ未定ですが200項目くらいはあるかもしれません。
弊社のサービスの管理画面として社内で利用される予定です。
管理画面なのでデザイナーによるデザイン制作はなく、エンジニアがUIを担当します。
そこで、ユーザにとって使い勝手がよく、開発としても余計な画面遷移がなく、繰り返しのコードが少なくなりそうなSPA(Single Page Application)のデータ更新画面を作ろうと考えました。


今回は項目数がとても多い画面を作るため、データの状態(変更やエラーがあるかなど)をDOMに反映させるコードを全部書くと、とんでもないコード量になりそうです。
SPAを作るには、データの状態に応じてDOMを操作してくれるライブラリがあった方が良さそうです。


そこで今回はVue.jsを使ってみました。


Vue.jsは学習コストが低いというWEB上での評判ですが、いざ実装を初めてみると結構つまづくところがありました。
この記事ではデータ更新機能の作成を通して、自分がつまづいたところを中心にVue.jsの様々な機能をどう使っていくのかをTipsとして紹介していきたいと思います。


想定読者
  • Vue.jsで実際にアプリケーションを作ってみたいと考えている方
  • Vue.jsの公式ガイドを読んでいろいろな機能があることはわかったけど、実際どう使うのかわからない方
  • Vue.jsのいろいろな実装サンプルを探している方

この記事で扱わないこと
  • Vue.jsの導入方法
  • Vue.jsの概要
  • Vue.js公式ガイドのはじめにに書かれている内容

この記事で扱うVue.jsの機能
  • dataオプション
  • computedオプション
  • コンポーネント
  • mixin


アプリケーションの要件


  1. 画面遷移なくデータを更新できる
  2. すでにあるデータを更新する画面を作成する(登録画面はなし)
  3. データ表示画面からデータ更新画面へ画面遷移なく切り替わる
  4. 重要データを更新するためなんらかの形で更新データを確認できるようにする
  5. データ更新も画面遷移なく行われる
  6. 入力チェックはサーバサイドで
  7. cssやJavascriptのビルド環境はない
  8. ターゲットデバイスはPCのみ

6.はクライアントサイドの入力チェックも入れたほうがよりユーザのストレスも減るかと思いますが、サーバサイドのチェックは必須としたいため、バリデーションがクライアントサイドとサーバサイドで重複するのが悩ましく、今後要検討です。


7.は管理画面が乗っている環境にないためで、今後導入の可能性もありますが、今回はとりあえず見送ることにしました。同じような境遇にある方の参考になれば…



さっそく作ってみました


ではさっそく画面を見てみましょう。


プロトタイプなので、わかりやすさのために項目は少なくしてあります。
弊社はBtoBの会社なので企業情報を更新できる画面、ということにしてみました。


レイアウトやボタン・エラー表示を簡単にするためにBootstrapを使っています。
自分で定義したいくつかのcssのクラスの他はすべてBootstrapのクラスを使っています。


まずは情報表示の状態です。


show1


更新ボタンを押すと入力フォームの状態になります。初期状態は更新ボタンはdisableです。


edit1


何か変更すると更新ボタンが押せるようになります。押したら変更点がモーダルで表示されます。


edit2


エラーがある場合は表示されます。


edit3


無事に更新が完了すると「更新しました」というメッセージが表示されます。5秒後に消えます。


show2


コードを見てみます。
まずは全文を掲載します。

html + JavaScript

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
    <link rel="stylesheet" href="./css/test.css">
    <title>デモページ</title>
  </head>
  <body>
    <div class="container-fluid">
      <div class="row">
        <div class="col offset-1 pt-5">
          <!-- *** ここから更新フォームです *** -->
          <form id="company-info">
            <div class="row">
              <h3 class="col-2">企業情報</h3>
              <div class="col-10">
                <!-- *** ボタンやメッセージです *** -->
                <button type="button" class="btn btn-primary" v-if="!isEdit" @click="edit">編集</button>
                <button type="button" class="btn btn-danger" v-if="isEdit" v-bind:disabled="noChange" data-toggle="modal" data-target="#companyModal">更新</button>
                <button type="button" class="btn btn-secondary" v-if="isEdit" @click="cancel">キャンセル</button>
                <transition name="fade">
                  <span class="alert alert-success" v-if="updated">更新しました。</span>
                </transition>
                <span class="alert alert-danger" v-if="hasError && isEdit">入力エラーがあります。</span>
              </div>
            </div>
            <div class="row pt-3">
              <div class="col-5">
                <div class="form-contents striped-form-row">
                  <!-- *** データのテキスト表示やinputフォームです *** -->
                  <div class="form-row">
                    <div class="col-3">
                      <label for="companyName" class="col-form-label">企業名</label>
                    </div>
                    <div class="col-9">
                      <span v-if="!isEdit">{{ companyInfo.companyName }}</span>
                      <input type="text" id="companyName" name="companyName" v-if="isEdit" v-model="companyInfo.companyName" class="form-control" :class="{'is-invalid': errors.companyName}">
                      <div class="invalid-feedback" v-if="errors.companyName">{{ errors.companyName }}</div>
                    </div>
                  </div>
                  <div class="form-row">
                    <div class="col-3">
                      <label for="companyNameKana" class="col-form-label">企業名カナ</label>
                    </div>
                    <div class="col-9">
                      <span v-if="!isEdit">{{ companyInfo.companyNameKana }}</span>
                      <input type="text" id="companyNameKana" name="companyNameKana" v-if="isEdit" v-model="companyInfo.companyNameKana" class="form-control" :class="{'is-invalid': errors.companyNameKana}">
                      <div class="invalid-feedback" v-if="errors.companyNameKana">{{ errors.companyNameKana }}</div>
                    </div>
                  </div>
                </div>
              </div>
            </div>
            <!-- *** ここから差分表示のモーダルです *** -->
            <div class="modal" id="companyModal" tabindex="-1">
              <div class="modal-dialog">
                <div class="modal-content">
                  <div class="modal-header">
                    <h4 class="modal-title">以下の項目を更新します</h4>
                    <button type="button" class="close" data-dismiss="modal">
                       ×
                    </button>
                  </div>
                  <div class="modal-body">
                    <div class="row" v-for="change in changes">
                      <div class="col-3">{{ change.label }}</div>
                      <div class="col-9">{{ change.value }}</div>
                    </div>
                  </div>
                  <div class="modal-footer">
                    <button type="button" class="btn btn-primary" @click="update" data-dismiss="modal">更新</button>
                    <button type="button" class="btn btn-secondary" data-dismiss="modal">キャンセル</button>
                  </div>
                </div>
              </div>
            </div>
          </form>
        </div>
      </div>
    </div>
    <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/vue"></script>
    <script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
    <script type="text/javascript">
      window.onload = function() {
        // *** テスト用のコード開始 ***
        const companyRepository = {
          companyName: '株式会社ラクーン',
          companyNameKana: 'カブシキガイシャラクーン'
        };
        function getCompanyInfo() {
          return Object.assign({}, companyRepository);
        }
        function updateCompanyInfo(companyInfo) {
          const res = { status: 'OK', errors: {}};
          if (companyInfo.companyName.length > 50) {
            res.status = 'NG';
            res.errors.companyName = '50文字以内で入力してください';
          }
          if (companyInfo.companyNameKana.length > 100) {
            res.status = 'NG';
            res.errors.companyNameKana = '100文字以内で入力してください';
          }
          if (res.status == 'OK') {
            Object.assign(companyRepository, companyInfo);
          }
          return res;
        }
        // *** テスト用コードの終了 ***

        // *** Vueインスタンスを作成します。 ***
        const companyInfoVm = new Vue({
          el: '#company-info',
          data: {
            companyInfo: getCompanyInfo(), // テスト用のコードからjsonが返されます。
            isEdit: false,
            updated: false,
            errors: {}
          },
          computed: {
            hasError: function() {
                return Object.keys(this.errors).length > 0;
            },
            changes: function() {
              const changes = [];
              const orig = getCompanyInfo();
              const dest = this.companyInfo;
              Object.keys(orig).forEach(function(key) {
                if (orig[key] != dest[key]) {
                  changes.push({name: key, label: $(`label[for='${key}']`).text(), orig: orig[key], value: dest[key]});
                }
              });
              return changes;
            },
            noChange: function() {
              // 何か情報がオリジナルと変わったときだけ、更新ボタンをアクティブにします。
              return !(this.isEdit && this.changes.length > 0);
            }
          },
          methods: {
            edit: function() {
              this.companyInfo = Object.assign({}, getCompanyInfo()); // 更新直前に情報を取り直します。
              this.isEdit = true;
              this.updated = false;
              this.errors = {};
            },
            update: function() {
              let res = updateCompanyInfo(this.companyInfo); // テスト用のコードからjsonが返されます。
              if (res.status == 'OK') {
                this.isEdit = false;
                this.updated = true;
                setTimeout(() => {this.updated = false;}, 5000); // 5秒後に「更新しました。」メッセージを消します。
              } else {
                this.errors = res.errors;
              }
            },
            cancel: function() {
              this.isEdit = false;
              this.companyInfo = getCompanyInfo(); // 更新をキャンセルした場合は情報をオリジナルに戻します。
            }
          }
        });
      };
    </script>
  </body>
</html>


cssの追加分のクラスです。


# test.css
.form-contents .form-row {
  -webkit-box-align: center !important;
  -ms-flex-align: center !important;
  align-items: center !important;
  padding-top: 2px;
  padding-bottom: 2px;
}
.form-contents .form-row .col-form-label {
  line-height: 2;
}
.striped-form-row .form-row:nth-of-type(odd) {
    background-color: rgba(0, 0, 0, 0.05);
}
.fade-enter-active, .fade-leave-active {
    transition: opacity .5s;
}
.fade-enter, .fade-leave-to {
    opacity: 0;
}


企業名(companyName)と企業名かな(companyNameKana)を更新できる画面です。
(本当は更新のためのキー(企業IDなど)が必要ですが省略しています。)


データの表示と編集を1画面でできるようにしているため、isEditという変数を定義して、全体の表示と編集を制御しています。


簡単に処理内容を説明します。

  1. 初期表示として、表示モードでデータの表示を行う
  2. 編集ボタンを押すと編集モードになり、ボタンが「更新」「(編集の)キャンセル」になり、テキスト表示がinputフォームになり、データが編集できるようになる
  3. キャンセルボタンを押すと表示モードに戻る
  4. 編集モードのときに何かデータを変更すると、更新ボタンをアクティブにする
  5. 更新ボタンを押すと、変更点がモーダルで表示される
  6. モーダルの更新ボタンを押すと、サーバ側にアクセスし(今回はテスト用の関数を呼んでいるだけです)レスポンスを受け取る
  7. レスポンスがOKだった場合は、表示モードに戻し、「更新しました」メッセージを表示する。メッセージは5秒後に非表示にする
  8. レスポンスがOKでなかった場合は、編集モードのままで、入力エラーになっている項目にエラーメッセージを表示する

以降では、工夫したところやつまづいたところを中心にコード解説していきます。



dataオプションの定義について


今回工夫した点の一つが、dataオプションの定義です。


フォームの項目数が多いことがわかっているので、dataオプションにフォームの項目をすべて定義するのは大変だと感じました。
そこで、フォームで扱うデータをひとつのオブジェクトとして扱い、個々のデータ(企業名や企業名かな)はオブジェクトのプロパティとし、JavaScriptのコードではプロパティは扱わず、プロパティを保持するオブジェクトのみを扱うようにしています。


data: {
  companyInfo: getCompanyInfo(),
  isEdit: false,
  updated: false,
  errors: {}
},

上記のコードのcompanyInfoは、companyNameとcompanyNameKanaをプロパティに持つオブジェクトです。
このように定義することで、companyNameとcompanyNameKanaの変更、つまりcompanyInfoのプロパティの変更を検知できます。


companyInfoオブジェクトを使うhtml側は以下のように書きます。


<span v-if="!isEdit">{{ companyInfo.companyName }}</span>
<input type="text" id="companyName" name="companyName" v-if="isEdit" v-model="companyInfo.companyName" class="form-control" :class="{'is-invalid': errors.companyName}">
<div class="invalid-feedback" v-if="errors.companyName">{{ errors.companyName }}</div>

companyInfo.companyNameのように.(ドット)でプロパティにアクセスできます。


dataオプションに定義してあるerrorsオブジェクトは、サーバサイドの入力チェックの結果を保持するオブジェクトです。
Vueインスタンスをnewした時点ではデータはありませんが、空データを定義しておく必要があります。
dataオプションに定義していないデータは変更を検知する対象にならないためです。
入力エラーの表示を制御するために使っています。
以下のコードのように、errors.[エラーのあった項目名]で参照できるようにオブジェクトで定義しています。


<input type="text" id="companyName" name="companyName" v-if="isEdit" v-model="companyInfo.companyName" class="form-control" :class="{'is-invalid': errors.companyName}">
<div class="invalid-feedback" v-if="errors.companyName">{{ errors.companyName }}</div>

エラーをオブジェクトで定義すると、全体として入力エラーがあったのかなかったのかを判定するためには、オブジェクトのキーが存在するかどうかを判定しなくてはなりません。
エラーのありなしについては、computedオプションに以下の関数を定義することで判定しています。


hasError: function() {
    return Object.keys(this.errors).length > 0;
}

項目についてもエラーについても、それぞれの項目名をdataオプションに定義することはしておらず、項目のまとまり、エラーのまとまりを定義しただけです。
Vue.jsがオブジェクト内の変更を検知してくれるため、このような定義が可能になっています。



フォーム内で何か変更があった場合に、更新ボタンをアクティブにしたい - computedオプションの利用 -


フォーム内で何か変更があった場合に、更新ボタンをアクティブにする処理を実装してみました。
「何か変更があった場合」というのを検知して更新ボタンのdisableを解除するためにcomputedオプションを使いました。


computedオプションとは、dataオプションに定義されたプロパティを使って何かを行う関数を定義できるオプションです。
computedオプションで指定したプロパティはdataオプションに指定したプロパティのように扱うことができます。
html内でcomputedオプションに指定されたプロパティを呼ぶと、プロパティに紐づけられた関数が呼び出されます。
関数内で参照しているdataオプションに定義されたプロパティが更新されれば、computedに定義された関数も再計算されます。


フォーム内のデータはcompanyInfoオブジェクトにまとまっているので、companyInfoオブジェクトを関数内で参照します。


computed: {
  changes: function() {
    const changes = [];
    const orig = getCompanyInfo();
    const dest = this.companyInfo;
    Object.keys(orig).forEach(function(key) {
      if (orig[key] != dest[key]) {
        changes.push({name: key, label: $(`label[for='${key}']`).text(), orig: orig[key], value: dest[key]});
      }
    });
    return changes;
  },
  noChange: function() {
    // 何か情報がオリジナルと変わったときだけ、更新ボタンをアクティブにします。
    return !(this.isEdit && this.changes.length > 0);
  }
}

上記のコードですが、this.companyInfoが変更されたときにchangesの結果が再計算され、さらにthis.changesを参照しているnoChangeの結果が再計算されます。
編集モードではない、もしくは変更がない場合にtrueが返されます。


html側のコードは以下です。


<button type="button" class="btn btn-danger" v-if="isEdit" v-bind:disabled="noChange" data-toggle="modal" data-target="#companyModal">更新</button>

これで、ボタンのdisable属性を、データの変更のありなしによって制御することができます。



コードの再利用 - コンポーネントの利用 -


今回は項目数が多い画面を作成する予定なので、なるべく重複のないコードを書きたいです。
繰り返しの多いコードは保守が大変です。
そこでVue.jsのコンポーネント機能を使ってみます。
最初に掲載したコードの全文の中の一部をコンポーネント機能を使って書き換えてみます。


コンポーネント機能とは、よく使うコードを、再利用できる部品としてまとめる機能です。
まとめられた部品は、自分が指定した名前でhtmlのタグとして利用することができます。
と、説明してもぴんとこないかもしれないのでコード例を示すことにします。


項目数が増えるとなると、以下のコードが何度も出てくることが予想されます。


<span v-if="!isEdit">{{ companyInfo.companyName }}</span>
<input type="text" id="companyName" name="companyName" v-if="isEdit" v-model="companyInfo.companyName" class="form-control" :class="{'is-invalid': errors.companyName}">
<div class="invalid-feedback" v-if="errors.companyName">{{ errors.companyName }}</div>

やっていることはデータの表示、inputタグの表示、エラーの表示くらいですが、クラスの指定や表示モードによる出し分けで、結構長いコードです。
テキスト入力フォームであれば、他の項目であっても書くことはほとんど同じはずです(チェックボックスなどは別ですが)
そこで上記のコードをコンポーネント化してみます。


まずは再利用したいhtmlをテンプレートとして登録します。


<script type="text/x-template" id="vue-input-template">
  <div>
    <div v-if="!isEdit">{{ value }}</div>
    <input type="input" :name="name" :id="name" :value="value" @input="updateValue($event.target.value)" class="form-control" :class="{'is-invalid': error}" v-if="isEdit">
    <span class="invalid-feedback" v-if="error">{{ error }}</span>
  </div>
</script>

もともとあるhtmlのコードの中で、項目によって値が変わるもののみ書き換えています(テキスト表示の部分やinputのname,id属性など)。
テンプレート内でもVue.jsのv-***ディレクティブや@inputが利用できます。
{{ value }}や、:id="name"のnameは使う側のhtmlのコードから渡す値です。
何をどういう変数名で受け取るかは、JavaScriptのコードに定義します。


今回はテンプレートの登録を行うために以下のスクリプトタグで囲み、id属性を付けています。


<script type="text/x-template" id="vue-input-template">

typeに設定されているtext/x-templateはブラウザが対応していないMIMEタイプとして認識され、この中に書かれているコードは処理されません。
ここにid属性を付与することによって、Vue.jsから利用できるようにします。


Vue.jsのコンポーネント機能では、この他にもインラインの文字列を使ってテンプレートを登録できます。
JavaScriptのビルド環境があれば、***.vueという拡張子のファイルを使ってテンプレートを外だしすることもでき、Vue.js公式ガイドではこのやり方を推奨しています。


次はJavaScriptでコンポーネントの登録をします。


Vue.component('vue-input', {
  template: '#vue-input-template',
  props: ['name', 'value', 'error', 'isEdit'],
  methods: {
    updateValue: function(inputValue) {
      this.$emit('input', inputValue);
    }
  }
});

Vue.componentを使って、'vue-input'という名前のコンポーネントを登録しています。
これでhtml内のどこでもvue-inputというタグが使えるようになります。


templateプロパティに、先ほどscriptタグで登録したテンプレートのidを指定します。


propsに使う側のhtmlのコードから受け取る値を定義します。
nameはinputタグのid属性やname属性の値を受け取ります。
valueはinputタグのvalue属性や表示用のテキストの値を受け取ります。
errorはエラー文言を、isEditは現在の表示モードのboolean値を受け取ります。


methodsに定義してあるupdateValueについては後述します。


最後に登録されたコンポーネントを使ってみます。


<vue-input name="companyName" v-model="companyInfo.companyName" :error="errors.companyName" :is-edit="isEdit" />

タグ名として、JavaScriptで登録した'vue-input'を使います。


JavaScriptでpropsとして定義したプロパティにそれぞれ値を渡しています。
値として単にリテラルを渡すだけの場合は、name="companyName"のようにそのままプロパティ名=リテラルとなります。
値として変数を渡したい場合は:error="errors.companyName"のようにプロパティ名の前に:(コロン)を付けます。
これはv-bind:errorと書いたのと同じことです。


:is-editは受け取る側のJavaScriptではpropsにisEditと定義されています。
これはhtmlの属性が大文字と小文字を区別しないことに起因するもののようで、JavaScriptでキャメルケースで定義したものは、html側ではハイフンで区切られた文字列にする必要があるようです。
参照:キャメルケース vs ケバブケース


html側ではv-modelを使ってvalue属性に値を設定しcompanyInfo.companyNameに入力値を反映させていますが、テンプレート側では以下のように書きます。


:value="value" @input="updateValue($event.target.value)"

v-modelはそもそも
・v-bindでvalue属性に値を設定する
・v-on:inputでinputイベントに決まったリスナー(v-modelで指定された変数に、inputイベントから渡ってきた値を代入するリスナー)を登録する
ということをまとめて書ける糖衣構文です。
v-model="companyInfo.companyName"と書くことは以下と同様です。


# vは入力された値です。
:value="companyInfo.companyName" @input="v => (companyInfo.companyName = v)"

ですが、コンポーネント機能を使った場合、ブラウザからのイベントを受け取るのはテンプレート側となります。
テンプレート側では、イベントを受け取り、入力された値を得ることができます。
しかしテンプレート側ではhtmlのv-modelで指定されたcompanyInfo.companyNameに直接アクセスすることはできず、入力値をcompanyInfo.companyNameに反映させることができません。
(propsを通じてhtmlから値を受け取ってはいますが、propsの値を更新してもhtmlのv-modelで指定した値は更新されません)
companyInfo.companyNameが更新されなければ、一時的に見た目だけはフォームの値が変わっても、Vue.jsによる再描画のタイミングで元の値に戻ってしまいます。
そこで、テンプレート側でイベントから受け取った値を、html側のcompanyInfo.companyNameに反映させるコードが必要になります。


それが以下のコードです。


methods: {
  updateValue: function(inputValue) {
    this.$emit('input', inputValue);
  }
}

updateValueはテンプレートで@input="updateValue($event.target.value)"と指定されており、inputイベントが発生したときに呼び出されます。
何をやっているかというと、emitで親のinputイベントを発生させ、値を渡しています。
コンポーネントは親子関係という構成を持っていて、親=タグを使う側、子=テンプレート側となっています。
今回のコード例でいうと、


# 親

<vue-input name="companyName" v-model="companyInfo.companyName" :error="errors.companyName" :is-edit="isEdit" />

# 子

<script type="text/x-template" id="vue-input-template">
  <div>
    <div v-if="!isEdit">{{ value }}</div>
    <input type="input" :name="name" :id="name" :value="value" @input="updateValue($event.target.value)" class="form-control" :class="{'is-invalid': error}" v-if="isEdit">
    <span class="invalid-feedback" v-if="error">{{ error }}</span>
  </div>
</script>

となります。


親が子に値を伝える手段がJavaScriptで定義したpropsで、子は親から渡ってきたpropsを使ってhtmlを表示します。


しかし、フォームにデータが入力された、などのイベントは子で発生するもので、それを親に伝え、親のデータを更新するためにVue.jsが提供している機能が、子から親のイベントを発生させる機能です。
親側でもv-modelにより、@input="v => (companyInfo.companyName = v)"としてinputイベントのリスナーが登録されているため、子からの情報が変数(ここではv)として渡ってきて設定されます。


ここら辺を理解するのに苦労しました。
コンポーネントには親子関係がある、というのを頭に入れて、やっと理解できるようになりました。
公式ガイドではコンポーネントの構成に記載されています。


これで再利用可能なコンポーネントを作ることができました。



Vueインスタンスを使いまわす - mixin機能の利用 -


項目数の多いデータ更新画面を作ることが目的ですが、最終的には以下のように項目のまとまりごとにデータを更新できるようにしたいなと思っています。


show3

画像の担当者情報も企業情報と同様の仕様で編集できるようにしたいです。
企業情報と同じVueインスタンスを作成し、扱うJSONデータを企業情報から担当者情報へ変更すれば実現できそうです。


しかし、同じようなVueインスタンスを作成するためには、また同じようなコードを書かなくてはならず、コードの繰り返しになってしまいます。
何とか共通化できないものでしょうか。


そんなときに使えるのがmixinの機能です。


mixinは他のVueインスタンスでも使いたいVueオプションをまとめておき、Vueインスタンスから使えるようにする機能です。
Javaの抽象クラスとか、Rubyのmixinのイメージです。


実際に使ってみます。
JavaScriptのコードを載せます。


まずは共通化したいdataオプションやwatchオプションなどをまとめたmixinされる側のオブジェクトの定義です。


const updateBase = {
  data: {
    info: null, // 上書きされます
    isEdit: false,
    updated: false,
    errors: {}
  },
  computed: {
    hasError: function() {
        return Object.keys(this.errors).length > 0;
    },
    changes: function() {
      const changes = [];
      const orig = this.getDataSpecified();
      const dest = this.info;
      Object.keys(orig).forEach(function(key) {
        if (orig[key] != dest[key]) {
          changes.push({name: key, label: $(`label[for='${key}']`).text(), orig: orig[key], value: dest[key]});
        }
      });
      return changes;
    },
    noChange: function() {
      // 何か情報がオリジナルと変わったときだけ、更新ボタンをアクティブにします。
      return !(this.isEdit && this.changes.length > 0);
    }
  },
  methods: {
    edit: function() {
      this.info = Object.assign({}, this.getDataSpecified()); // 更新直前に情報を取り直します。
      this.isEdit = true;
      this.updated = false;
      this.errors = {};
    },
    update: function() {
      let res = this.updateSpecified(this.info);
      if (res.status == 'OK') {
        this.isEdit = false;
        this.updated = true;
        setTimeout(() => {this.updated = false;}, 5000); // 5秒後に「更新しました。」メッセージを消します。
      } else {
        this.errors = res.errors;
      }
    },
    cancel: function() {
      this.isEdit = false;
      this.info = this.getDataSpecified(); // 更新をキャンセルした場合は情報をオリジナルに戻します。
    },
    getDataSpecified: function() {
      // 上書きされます、個々のVueインスタンスで実装します。
    },
    updateSpecified: function(updateInfo) {
      // 上書きされます、個々のVueインスタンスで実装します。
    }
  }
};

定義しているのはただのJavaScriptのオブジェクトです。
共通化したいオプションをまとめています。


最初に掲載した企業情報のVueインスタンスとほぼ同じコードですが、共通化にあたって、少しだけコードを書き換えています。
更新対象のデータを取得する部分や更新対象のデータを更新する部分は、更新したいデータごとに異なるので、それぞれのインスタンスで実装するよう、空実装を置いています。
空実装に意味はありませんが、必ず実装してほしいこと、実装しなければ何も行わない関数が呼び出されることを明示しています。


mixinとVueインスタンスに同じ関数の定義やデータの定義があった場合はVueインスタンス側の定義が優先されます。


次はmixinを使って書き直した企業情報のVueインスタンスです。


const companyInfoVm = new Vue({
  mixins: [updateBase],
  el: '#company-info',
  data: {
    info: getCompanyInfo()
  },
  methods: {
    getDataSpecified: function() {
      return getCompanyInfo();
    },
    updateSpecified: function(updateInfo) {
      return updateCompanyInfo(updateInfo);
    }
  }
});

mixinsオプションで、mixinするオブジェクトを指定しています。
あとは更新対象のデータを取得する関数と、更新対象のデータを更新する関数を定義しているだけです。
このくらいのコード量であれば、企業情報だけでなく、担当者情報も…となった場合にも重複が少なく書けると思います。



さいごに…


Vue.jsを使ったひとつのコードサンプルを見ながら、工夫した点や苦労した点を説明してきました。
Vue.jsを使ってアプリケーションを作ってみたいという方の参考になれば幸いです。


最後にコンポーネントとmixinを利用したコードの全文を載せます。
企業情報だけでなく、担当者情報も編集できるようになっています。


<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
    <link rel="stylesheet" href="./css/test.css">
    <title>デモページ</title>
  </head>
  <body>
    <div class="container-fluid">
      <div class="row">
        <div class="col offset-1 pt-5">
          <form id="company-info">
            <div class="row">
              <h3 class="col-2">企業情報</h3>
              <div class="col-10">
                <button type="button" class="btn btn-primary" v-if="!isEdit" @click="edit">編集</button>
                <button type="button" class="btn btn-danger" v-if="isEdit" v-bind:disabled="noChange" data-toggle="modal" data-target="#companyModal">更新</button>
                <button type="button" class="btn btn-secondary" v-if="isEdit" @click="cancel">キャンセル</button>
                <transition name="fade">
                  <span class="alert alert-success" v-if="updated">更新しました。</span>
                </transition>
                <span class="alert alert-danger" v-if="hasError && isEdit">入力エラーがあります。</span>
              </div>
            </div>
            <div class="row pt-3">
              <div class="col-5">
                <div class="form-contents striped-form-row">
                  <div class="form-row">
                    <div class="col-3">
                      <label for="companyName" class="col-form-label">企業名</label>
                    </div>
                    <div class="col-9">
                      <vue-input name="companyName" v-model="info.companyName" :error="errors.companyName" :is-edit="isEdit" />
                    </div>
                  </div>
                  <div class="form-row">
                    <div class="col-3">
                      <label for="companyNameKana" class="col-form-label">企業名カナ</label>
                    </div>
                    <div class="col-9">
                      <vue-input name="companyNameKana" v-model="info.companyNameKana" :error="errors.companyNameKana" :is-edit="isEdit" />
                    </div>
                  </div>
                </div>
              </div>
            </div>
            <div class="modal" id="companyModal" tabindex="-1">
              <div class="modal-dialog">
                <div class="modal-content">
                  <div class="modal-header">
                    <h4 class="modal-title">以下の項目を更新します</h4>
                    <button type="button" class="close" data-dismiss="modal">
                       ×
                    </button>
                  </div>
                  <div class="modal-body">
                    <div class="row" v-for="change in changes">
                      <div class="col-3">{{ change.label }}</div>
                      <div class="col-9">{{ change.value }}</div>
                    </div>
                  </div>
                  <div class="modal-footer">
                    <button type="button" class="btn btn-primary" @click="update" data-dismiss="modal">更新</button>
                    <button type="button" class="btn btn-secondary" data-dismiss="modal">キャンセル</button>
                  </div>
                </div>
              </div>
            </div>
          </form>
        </div>
      </div>
      <div class="row">
        <div class="col offset-1 pt-5">
          <form id="user-info">
            <div class="row">
              <h3 class="col-2">担当者情報</h3>
              <div class="col-10">
                <button type="button" class="btn btn-primary" v-if="!isEdit" @click="edit">編集</button>
                <button type="button" class="btn btn-danger" v-if="isEdit" v-bind:disabled="noChange" data-toggle="modal" data-target="#userModal">更新</button>
                <button type="button" class="btn btn-secondary" v-if="isEdit" @click="cancel">キャンセル</button>
                <transition name="fade">
                  <span class="alert alert-success" v-if="updated">更新しました。</span>
                </transition>
                <span class="alert alert-danger" v-if="hasError && isEdit">入力エラーがあります。</span>
              </div>
            </div>
            <div class="row pt-3">
              <div class="col-5">
                <div class="form-contents striped-form-row">
                  <div class="form-row">
                    <div class="col-3">
                      <label for="userName" class="col-form-label">担当者名</label>
                    </div>
                    <div class="col-9">
                      <vue-input name="userName" v-model="info.userName" :error="errors.userName" :is-edit="isEdit" />
                    </div>
                  </div>
                  <div class="form-row">
                    <div class="col-3">
                      <label for="userNameKana" class="col-form-label">担当者名カナ</label>
                    </div>
                    <div class="col-9">
                      <vue-input name="userNameKana" v-model="info.userNameKana" :error="errors.userNameKana" :is-edit="isEdit" />
                    </div>
                  </div>
                </div>
              </div>
            </div>
            <div class="modal" id="userModal" tabindex="-1">
              <div class="modal-dialog">
                <div class="modal-content">
                  <div class="modal-header">
                    <h4 class="modal-title">以下の項目を更新します</h4>
                    <button type="button" class="close" data-dismiss="modal">
                       ×
                    </button>
                  </div>
                  <div class="modal-body">
                    <div class="row" v-for="change in changes">
                      <div class="col-3">{{ change.label }}</div>
                      <div class="col-9">{{ change.value }}</div>
                    </div>
                  </div>
                  <div class="modal-footer">
                    <button type="button" class="btn btn-primary" @click="update" data-dismiss="modal">更新</button>
                    <button type="button" class="btn btn-secondary" data-dismiss="modal">キャンセル</button>
                  </div>
                </div>
              </div>
            </div>
          </form>
        </div>
      </div>
    </div>
    <script type="text/javascript" src="./js/vue/2.5.13/vue.js"></script>
    <script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
    <script type="text/x-template" id="vue-input-template">
      <div>
        <div v-if="!isEdit">{{ value }}</div>
        <input type="input" :name="name" :id="name" :value="value" @input="updateValue($event.target.value)" class="form-control" :class="{'is-invalid': error}" v-if="isEdit">
        <span class="invalid-feedback" v-if="error">{{ error }}</span>
      </div>
    </script>
    <script type="text/javascript">
      window.onload = function() {
        // *** テスト用のコード開始 ***
        const companyRepository = {
          companyName: '株式会社ラクーン',
          companyNameKana: 'カブシキガイシャラクーン'
        };
        function getCompanyInfo() {
          return Object.assign({}, companyRepository);
        }
        function updateCompanyInfo(companyInfo) {
          const res = { status: 'OK', errors: {}};
          if (companyInfo.companyName.length > 50) {
            res.status = 'NG';
            res.errors.companyName = '50文字以内で入力してください';
          }
          if (companyInfo.companyNameKana.length > 100) {
            res.status = 'NG';
            res.errors.companyNameKana = '100文字以内で入力してください';
          }
          if (res.status == 'OK') {
            Object.assign(companyRepository, companyInfo);
          }
          return res;
        }
        const userRepository = {
          userName: '担当者てすと',
          userNameKana: 'タントウシャテスト'
        };
        function getUserInfo() {
          return Object.assign({}, userRepository);
        }
        function updateUserInfo(userInfo) {
          const res = { status: 'OK', errors: {}};
          if (userInfo.userName.length > 50) {
            res.status = 'NG';
            res.errors.userName = '50文字以内で入力してください';
          }
          if (userInfo.userNameKana.length > 100) {
            res.status = 'NG';
            res.errors.userNameKana = '100文字以内で入力してください';
          }
          return res;
        }
        // *** テスト用コードの終了 ***

        // *** コンポーネントの定義 ***
        Vue.component('vue-input', {
          template: '#vue-input-template',
          props: ['name', 'value', 'error', 'isEdit'],
          methods: {
            updateValue: function(inputValue) {
              this.$emit('input', inputValue);
            }
          }
        });

        // *** mixinの定義 ***
        const updateBase = {
          data: {
            info: null, // 上書きされます
            isEdit: false,
            updated: false,
            errors: {}
          },
          computed: {
            hasError: function() {
                return Object.keys(this.errors).length > 0;
            },
            changes: function() {
              const changes = [];
              const orig = this.getDataSpecified();
              const dest = this.info;
              Object.keys(orig).forEach(function(key) {
                if (orig[key] != dest[key]) {
                  changes.push({name: key, label: $(`label[for='${key}']`).text(), orig: orig[key], value: dest[key]});
                }
              });
              return changes;
            },
            noChange: function() {
              // 何か情報がオリジナルと変わったときだけ、更新ボタンをアクティブにします。
              return !(this.isEdit && this.changes.length > 0);
            }
          },
          methods: {
            edit: function() {
              this.info = Object.assign({}, this.getDataSpecified()); // 更新直前に情報を取り直します。
              this.isEdit = true;
              this.updated = false;
              this.errors = {};
            },
            update: function() {
              let res = this.updateSpecified(this.info);
              if (res.status == 'OK') {
                this.isEdit = false;
                this.updated = true;
                setTimeout(() => {this.updated = false;}, 5000); // 5秒後に「更新しました。」メッセージを消します。
              } else {
                this.errors = res.errors;
              }
            },
            cancel: function() {
              this.isEdit = false;
              this.info = this.getDataSpecified(); // 更新をキャンセルした場合は情報をオリジナルに戻します。
            },
            getDataSpecified: function() {
              // 上書きされます、個々のVueインスタンスで実装します。
            },
            updateSpecified: function(updateInfo) {
              // 上書きされます、個々のVueインスタンスで実装します。
            }
          }
        };

        // *** Vueインスタンスを作成します ***
        const companyInfoVm = new Vue({
          mixins: [updateBase],
          el: '#company-info',
          data: {
            info: getCompanyInfo()
          },
          methods: {
            getDataSpecified: function() {
              return getCompanyInfo();
            },
            updateSpecified: function(updateInfo) {
              return updateCompanyInfo(updateInfo);
            }
          }
        });
        const userInfoVm = new Vue({
          mixins: [updateBase],
          el: '#user-info',
          data: {
            info: getUserInfo()
          },
          methods: {
            getDataSpecified: function() {
              return getUserInfo();
            },
            updateSpecified: function(updateInfo) {
              return updateUserInfo(updateInfo);
            }
          }
        });
      };
    </script>
  </body>
</html>