MVVMの実装例

では実際にMVVMの仕組みを作ってみましょう。

サンプルは以前のFormのサンプルを元に、入力欄を増やしたものとなります。

<form id="form">
  <div class="form-example">
    <label for="name">Enter your name: </label>
    <input type="text" name="name" id="name" required>
  </div>
  
  <div class="form-example">
    <label for="firstFriendName">Enter your first friend name: </label>
    <input type="text" name="firstFriendName" id="firstFriendName" required>
  </div>
  
  <div class="form-example">
    <label for="secondFriendName">Enter your second friend name: </label>
    <input type="text" name="secondFriendName" id="secondFriendName" required>
  </div>

  <br />
  <button id="random">Randomize</button>
 
  <br />
  <button type="submit">Submit</button>
</form>

<div id="result"></div>

HTMLを見てみると、入力欄を増やした分だけinput要素が増えています。それぞれ 固有の(重複しない)name, id属性 を指定しています。JSの方を見ていきます。

const getRandomName = () => {
  // 1~5のランダムな番号を取得する。
  const i = Math.floor(Math.random() * 5);
  // ランダムな番号に該当する要素を名前として利用する。
  return ["John Do", "Jane Doe", "Taro Yamada", "Hanako Suzuki", "Saburo Tanaka"][i];
}

// Proxyを使った独自のMVVMバインディング用関数
// ※ ProxyはIE11では動かないので注意
// SEE: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
// SEE: https://caniuse.com/#search=proxy
const mount = (data) => {
  // 要素の参照の保存先オブジェクト
  let $refs = {}
  
  const model = new Proxy(data, {
    // dataに値が代入された時に呼ばれる処理を定義
    set(obj, prop, newval) {
      // オブジェクトに値をセットする(デフォルトの挙動)
      obj[prop] = newval;

      // もし関連する要素が存在するなら
      if ($refs[prop]) {
        // 値を書き換える。(Model -> Viewの反映)
        $refs[prop].$el.val(newval);        
      }
      
      // trueを返すことで、正常終了として扱われる。
      return true;
    }
  });
  
  // キー名(prop)と対応する要素のinputイベントハンドラをセットする。
  for (const prop in data) {
    // もし当該の要素が見つからなかったら無視する。
    const $el = $(`input#${prop}`)
    if ($el.length > 0) {
      // イベントハンドラ()
      const handler = (event) => {
        model[prop] = event.target.value;
        console.log('input', prop, model[prop]);
      }
      // イベントハンドラを登録(View -> Modelの反映)
      $el.on("input", handler)
      $refs[prop] = {
        $el,
        // イベントハンドラの削除用関数を定義しておく。
        unmount: () => $el.off("input", handler)
      }     
    }
  }
  
  console.log('mounted!');
  
  return {
    model,
    unmount () {
      // イベントハンドラの削除用関数を呼び出すことで、登録したinputイベントハンドラをすべて削除する。
      for (const prop in $refs) {
        $refs[prop].unmount()
      }
      console.log('unmounted...');
    }
  };
};

$(() => {
  const { model, unmount } = mount({
    name: "Taro Yamada",
    firstFriendName: "Jiro Yamada",
    secondFriendName: "Saburo Yamada"
  });
  
  // 各要素への参照を取得。
  const $form = $("#form");
  const $random = $("#random");
  
  // ボタンのクリック(click)イベントにイベントリスナー(ハンドラー)を登録
  $random.on("click", (event) => {
    // デフォルトのフォーム送信の動きを止める(prevent)
    event.preventDefault();
    // ランダムな番号に該当する要素をそれぞれの名前として利用する。
    model.name = getRandomName();
    model.firstFriendName = getRandomName();
    model.secondFriendName = getRandomName();
  });
  
  // form要素の送信(submit)イベントにイベントリスナー(ハンドラー)を登録
  $form.on("submit", (event) => {
    // 1. デフォルトのフォーム送信の動きを止める(prevent)
    event.preventDefault();

    // 2. モデルのnameの最新の値(= 入力値)を取得。
    const data = model;
    
    // 3. AJAXを使ってデータ送信する。
    $.ajax({
      // 送信先のURL(HTMLのform要素のactionに相当する部分)
      url: "https://httpbin.org/anything",
      // 送信するデータ。デフォルトでは "GET" リクエストなので、クエリ文字列(パラメータ)として付与される。
      data: data
    }).then((response) => {
      // id="result"な要素のテキストを取得したAPIのレスポンスで置き換える。
      $("#result").text(JSON.stringify(response));
    });
  });
  
  // unmountを呼び出すと、以降は値が更新されなくなる。
  // unmount();
});

コード量はそれなりにありますが、いつものように上から順に見ていきます。

mount() 関数の仕組み

今回重要なのは独自の mount() 関数です。この関数はモデルとして扱う初期データ(オブジェクト)を引数として受け取り、生成されたモデルunmount() 関数を返却しています。mountの中の処理を順に説明していきます。

  1. 引数のデータを元にProxy(※)のインスタンス(以降、モデルと呼ぶ)を生成する。Proxyのコンストラクタの第二引数に渡した set() 関数はsetterとして扱われる(= モデルのプロパティに値を代入した時に呼び出される) ※ Proxyはブラウザ標準のAPIですが、IE11では動かないので案件で利用する場合は注意してください。

  2. set() 関数の中では、変更のあったプロパティ名(キー)と関連するinput要素を取得する。関連する要素が存在した場合のみ、その要素の値を更新する。(= モデルからビューへの反映)

  3. for (const prop in data) {...} の部分ではデータの各キー毎に、関連するinput要素を取得する。要素が存在したらinput要素のinput(キーボード入力)イベントにイベントハンドラをセットする。そのイベントハンドラ内ではキーボード入力された最新の入力値を、モデルにセットする。(= ビューからモデルへの反映)

  4. 戻り値として生成されたモデルunmount() 関数を返却する。

$(document).ready(...) の使い方

先程の mount() 関数は、 $(() => {...}) の中で呼び出されています。これは $(document).ready(...) と同じ意味で、HTML内にある要素がすべて描画されたタイミング(= DOMContentLoaded / DOM Readyとも呼ぶ)で引数に渡した関数を実行する。という意味になります。

JavaScriptはHTML内にある要素の描画後に呼び出されるとは限らないので、こういったHTML内の要素の描画を待ってから実行する書き方 をすることがあります。これはReactやVue.jsなどでも良く使われる書き方です。 $(document).ready(...) はjQueryのAPIですが、ブラウザの標準APIで言い換えるとこういう書き方になります。

document.addEventListener('DOMContentLoaded', (event) => {...})

mount() を使った処理の流れ

  1. $(document).ready(...) の中に書く(= DOMContentLoadedイベントのハンドラとして登録する)ことで、HTML内の要素の描画を待つ。 

  2. 描画されたら、 mount() 関数を呼び出してモデルを取得する。

  3. id="random" なボタンを押した時に、モデルのそれぞれのプロパティ(name/firstFriendName/secondFriendName)をランダムな値( getRandomName() の戻り値 )で更新する。プロパティの更新時にモデルによって自動でHTML(ビュー/UI)への変更後の値の反映(描画)が行われる。

  4. フォームの送信(submit)イベントのイベントハンドラの中で、モデルの値を引数としてAJAXを使ってAPIにリクエストを送信する。

  5. 以前のサンプルと同様にAPIからのレスポンスを画面に描画する。

まとめ

以上がMVVMに準拠した実装のサンプルとなります。コードが長く感じたかもしれませんが mount() 関数の部分はライブラリ(= 再利用するもの)として使えるので、一度書いてしまえば、かんたんに複雑な入力フォームを作る事が可能となります。

ただ今回の mount() 関数は基本的な仕様しか実装出来てないので、input要素がちゃんと存在するかのチェック(エラーチェック)や、クロスブラウザでの動作検証などを経た上で商用レベル(開発案件で使えるレベル)にはならないかなとは思います。(= そのままではお仕事には使えません)

なので、実際の案件ではこういう機能を自前で書かずに React/Vue.js/Angular といった既存のライブラリ(/ フレームワーク)を使うことで同等の機能を実現する事が多いです。とはいえ内部的にどういった事が行われているのかを正しく理解しておくと、デバッグやトラブルシューティングの際に役に立つので、是非理解しておきましょう!

最終更新