Vueで再利用可能な全選チェックボックス指令の実装

背景と目的

Vue.js を用いた開発中に、複数のプロジェクトで「全選・個別選択・反転」機能が必要になりました。最初はコンポーネント内に computed プロパティを用いて実装しましたが、コードの再利用性が低く、保守が困難でした。そこで、より汎用的かつシンプルに使えるよう、カスタムディレクティブを活用した新しいアプローチを採用しました。

computed による初期実装の課題

以下のような方法で最初に実装しました:

  • サーバーから取得したデータ配列の各要素に checked フラグを追加
  • selectCount を算出して、すべて選択されているか判定
  • selectAll の状態変化に応じて全項目のチェック状態を一括更新
  • 選択済みアイテムを checkedGroups として抽出
data() {
  return {
    items: []
  }
},
computed: {
  selectAll: {
    get() {
      return this.selectedCount === this.items.length;
    },
    set(value) {
      this.items.forEach(item => {
        item.checked = value;
      });
    }
  },
  selectedCount() {
    return this.items.filter(item => item.checked).length;
  },
  checkedItems() {
    return this.items.filter(item => item.checked);
  }
}

この方法では、itemschecked フィールド名が固定されており、他のコンポーネントで流用する際に同じ構造を強制されてしまいます。また、毎回同様の computed 論理を記述する必要があり、DRY 原則に反します。

カスタムディレクティブによる改善

再利用性を高めるために、v-select-all というカスタムディレクティブを設計しました。このディレクティブは次の特徴を持ちます:

  • 監視対象の配列をパラメータで指定可能
  • チェック状態のプロパティ名は固定せず、オブジェクト構造に依存しない
  • v-model と連携して双方向バインディングを実現

ディレクティブの実装

以下のモジュールとして定義します:

export default {
  'select-all': {
    // 双方向バインディングを有効化
    twoWay: true,

    // HTML属性から値を受け取る
    params: ['targetList'],

    bind() {
      // 監視対象の配列が変更された場合に反応
      this.vm.$watch(
        () => this.vm[this.params.targetList] || [],
        (list) => {
          const allChecked = list.length > 0 && list.every(item => item.checked);
          this.set(allChecked);
        },
        { deep: true }
      );
    },

    update(newValue) {
      const target = this.vm[this.params.targetList];
      if (!target || !Array.isArray(target)) return;

      target.forEach(item => {
        item.checked = newValue;
      });
    }
  }
};

使用方法

テンプレート側では次のように使います:

<!-- 全選チェックボックス -->
<input 
  type="checkbox" 
  v-model="isAllSelected" 
  v-select-all 
  target-list="userList">

<!-- 各アイテム -->
<ul>
  <li v-for="user in userList" :key="user.id">
    <input type="checkbox" v-model="user.checked">
    {{ user.name }}
  </li>
</ul>

JavaScript 側でディレクティブを登録:

import Vue from 'vue';
import directives from './directives/select-all';

// グローバルに登録
Object.keys(directives).forEach(key => {
  Vue.directive(key, directives[key]);
});

内部動作の解説

bind フックでは、$watch を使って targetList 配列の内容変化を深く監視(deep watcher)しています。配列内の任意の checked 値が変化すると、すべてがチェックされているか評価し、全選ボックスの状態を自動更新します。

update メソッドは、全選チェックボックスの状態が変わったときに呼び出されます。その値(true/false)に基づき、対象配列内のすべてのアイテムの checked フラグを一括設定します。

params と $watch の違い

当初、paramWatchers を使って属性値の変化を検出しようと試みましたが、deep: true がサポートされていないため、配列内部のオブジェクト変更を検知できません。そのため、明示的に this.vm.$watch を使うことで、ネストされたデータの変更も確実に拾えるようにしています。

利点のまとめ

  • 再利用性:複数のコンポーネントで同じディレクティブを使いまわせる
  • 柔軟性:対象の配列名やモデル名を自由に設定可能
  • 簡潔さ:テンプレートに宣言するだけで機能が追加される
  • 保守性:共通ロジックが一元管理される

タグ: vue.js Custom Directive Two-way Binding $watch Reusability

6月2日 18:10 投稿