Vue.js

[Vue.js] コンポーネントTips


Vue.jsを使い始めて3年近くたったので、自分なりにコンポーネントに関するTipsをまとめてみた。なお内容的に以前投稿した記事に関連する部分も多いと思うので、そちらも参考にしていただきたい。ちなみに対象のVueのバージョンは2であって、3だと違うアプローチが取れるケースもあるかと思う。今後も思い付き次第追記していく。

captureによる権限チェックコンポーネント

例えば、未ログイン時にクリックした場合は「ログインが必要」という旨のメッセージを表示してログインダイアログを表示するボタンなんてのはよくあると思う。こういった場合、真っ先に思いつく実装は、ボタンの v-on:click イベントにログイン状態をチェックしてゴソゴソするという方法だ。

この実装自体に問題はないが、ログインを必要とする機能がアプリ内の各所に配置されている場合、各所で同様の実装をしないといけなくなる。mixinや素のjsに切り出すことである程度DRYになるが完全ではない。こういった場合に「権限をチェックしてゴソゴソする」コンポーネントでラップすることで簡単にチェック機能を提供できるようになる。

具体例を挙げると、次のような素敵な処理を提供するボタンがあったとして、

<button @click="doSomething">素敵な処理</button>

この doSomething 処理をログインしないと使えないようにするために、権限チェックコンポーネントでラップする。

<RequireAuth :loggedIn="loggedIn">
  <button @click="doSomething">素敵な処理</button>
</RequireAuth>

ここではログイン状態を loggedIn としているが、一般的にはvuex等で取得するところだろう。

実際の権限チェックコンポーネント RequireAuth のソースはこちら。

📝RequireAuth.vue
<template>
  <div
      v-on:[captureEvent].capture="handleEvent"
      style="display: contents;"
  >
    <slot></slot>
  </div>
</template>
<script>
export default {
  props: {
    loggedIn: {
      type: Boolean,
      required: true,
    },
    captureEvent: {
      type: String,
      default: 'click',
    },
  },
  methods: {
    handleEvent(e) {
      if (!this.loggedIn) {
        alert("ログインしてください");
        e.stopPropagation();
      }
    }
  },
}
</script>

ポイントは v-on.capture でイベントをキャプチャしている点。通常イベントは子供要素から親要素に向けてバブリングしていくが、captureすると先に親要素でイベントを捕捉できるようになる。これを利用してログイン状態をチェックしている。

ここでは alert にてメッセージを表示しているだけだが、ここに任意の処理が書けるので煮るなり焼くなり好きにすればいい。サンプルではログイン状態を props で貰うようにしているが、vuexなどで管理している場合は、RequireAuthコンポーネント内でstoreから取ってきてもいいかもしれない。そのあたりはご自由に。

コンポーネントでラップするだけで権限チェックが効くようになるので、「ラップされる側のコンポーネント」に一切影響を与えない。また、チェックを外すときもラップをやめるだけなので、取り回しもいい。

(ちなみにルートのdivの display: contents についてだが、このdivは要素として特に意味がない疑似要素のように扱えたほうが便利なので付けている)

一応、使用する側のサンプルコードも載せておく。

📝SamplePage.vue
<template>
  <div>
    <RequireAuth :loggedIn="loggedIn">
      <button @click="doSomething">素敵な処理</button>
    </RequireAuth>

    <div>
      <input type="radio" id="logged-in" :value="true" v-model="loggedIn">
      <label for="logged-in">ログイン済み</label>
      <input type="radio" id="not-logged-in" :value="false" v-model="loggedIn">
      <label for="not-logged-in">未ログイン</label>
    </div>
  </div>
</template>
<script>
import RequireAuth from "@/components/RequireAuth.vue";

export default {
  components: { RequireAuth },
  data() {
    return {
      loggedIn: false,
    };
  },
  methods: {
    doSomething() {
      alert('素敵な処理を実行しました!');
    }
  },
}
</script>

機能だけを提供してデザインは任意に設定できるコンポーネント

ちょっと分かりにくいタイトルかもしれない。具体例を挙げると、ページャーコンポーネントなんかが典型的じゃないかと思う。

1ページあたりに表示できる件数に基づき、前・次ページに切り替えることができるコンポーネントだ。このとき、よくやってしまいがちなのが、ページャーコンポーネント内でデザインを定義してしまうこと。アプリケーション内でデザインを(強制的に)統一するには、それでもいいかもしれない。しかし、複数のデザインに対応しないといけない場合やライブラリを作成している場合だとマズイ。

とは言っても、ページ切り替え処理自体はページャーコンポーネント内に置きたいので、やっぱりボタン類はページャーコンポーネント内に置かざるをえないじゃないか・・・と思いがちだが、実際には簡単に機能とデザインを分離できる。

以下はそんなページャーコンポーネントを実装例。

📝Pager.vue
<template>
  <div>
    <template v-for="item in currentItems">
      <slot name="item" :item="item"></slot>
    </template>

    <slot name="prevButton" :prev="prev" :disabled="!hasPrev"></slot>
    <slot name="currentPage" :current-page="currentPage"></slot>
    <slot name="nextButton" :next="next" :disabled="!hasNext"></slot>
  </div>
</template>
<script>
export default {
  props: {
    items: {
      type: Array,
      required: true,
    },
    perPage: {
      type: Number,
      default: 10,
    },
  },
  data() {
    return {
      currentPage: 1,
    };
  },
  computed: {
    currentItems() {
      return this.items.slice((this.currentPage - 1) * this.perPage, this.currentPage * this.perPage);
    },
    pageCount() {
      return Math.ceil(this.items.length / this.perPage);
    },
    hasPrev() {
      return this.currentPage > 1;
    },
    hasNext() {
      return this.currentPage < this.pageCount;
    },
  },
  methods: {
    prev() {
      if (this.hasPrev) {
        this.currentPage--;
      }
    },
    next() {
      if (this.hasNext) {
        this.currentPage++;
      }
    },
  },
}
</script>

ポイントはslot prevButton , nextButtonprev, next メソッドをスロットオプションで渡しているところだ。

    <slot name="prevButton" :prev="prev" :disabled="!hasPrev"></slot>
    <slot name="currentPage" :current-page="currentPage"></slot>
    <slot name="nextButton" :next="next" :disabled="!hasNext"></slot>

要するに、ボタンのデザインは呼び出し側に任せて、然るべきタイミングでprev, nextメソッドを呼び出してもらえばいいのだ。$refs経由でPager.vueのメソッドを叩いてもらう方法よりも、仕様が明示的かつスコープが局所的なので、こちらが優れている。

呼び出し側のサンプルコードは以下のような感じになる。

📝UsePagerPage.vue
<template>
  <div>
    <Pager :items="items">
      <template #item="{ item }">
        <div>{{ item }}</div>
      </template>

      <template #prevButton="{ prev, disabled }">
        <button @click="prev" :disabled="disabled">前のページ</button>
      </template>

      <template #currentPage="{ currentPage }">ページ: {{ currentPage }}</template>

      <template #nextButton="{ next, disabled }">
        <button @click="next" :disabled="disabled">次のページ</button>
      </template>
    </Pager>
  </div>
</template>
<script>
import Pager from "@/components/Pager.vue";

export default {
  components: { Pager },
  data() {
    return {
      items: Array(50).fill().map((_, i) => `Item ${i + 1}`),
    };
  },
}
</script>

ここでは大したデザインを施していないが、呼び出し側で好きなようにデザインすることが可能である。

補足:いや、デザインを統一したいんですけど?

前述の通りアプリケーション内でデザインを統一したい場合はどうすべきなのか。呼び出し側でデザインを定義するのであれば、デザインを共通化したい場合はどうするのか?やっぱりデザインと機能を同梱したページャーコンポーネントを実装すべきなのか?たぶん答えはNO。一度結合してしまった実装を切り離すのは結構面倒くさいので。

おすすめは、その「統一」をコンポーネント化してしまえばいい。PagerをラップしたAppPagerを作成してみる。

📝AppPager.vue
<template>
  <Pager
      :items="items"
      :per-page="20"
  >
    <template #item="{ item }">
      <slot name="item" :item="item"></slot>
    </template>

    <template #prevButton="{ prev, disabled }">
      <button @click="prev" :disabled="disabled" class="button">previous</button>
    </template>

    <template #currentPage="{ currentPage }">Page: {{ currentPage }}</template>

    <template #nextButton="{ next, disabled }">
      <button @click="next" :disabled="disabled" class="button">next</button>
    </template>
  </Pager>
</template>
<script>
import Pager from "@/components/Pager.vue";

export default {
  components: { Pager },
  props: {
    items: {
      type: Array,
      required: true,
    },
  },
}
</script>
<style scoped>
.button {
  color: blue;
}
</style>

基本的なことはすべてPager.vueに丸投げしてしまい、デザイン部分だけを自分で埋めるイメージのコンポーネントだ。ここではページャーのデザインと1ページに表示する件数を定義している。

アプリケーション内ではこのコンポーネントを使用するようにすれば解決だ。

...
    <AppPager :items="items">
      <template #item="{ item }">
        <div>{{ item }}</div>
      </template>
    </AppPager>
...

例外的に独自デザインのページャーを実装する必要が出てきたら、このAppPagerの兄弟分を作ってやればいい。これはPager.vueにもAppPager.vueにもいかなる変更を加えないので、安全に機能を拡張できるというわけだ。

このように、機能とデザインをきちんと分離できていれば、簡単かつ安全にバリエーションを増やすことが可能である。Vueの場合は、透過的なラッパーコンポーネントを作成することで実現できることが多いように思う。

deepセレクタで上書きを許可する場合はクラス名をpropsで貰う

Vueではstyleを scoped にしておくことが多いと思うが、deepセレクタはそのスコープを破壊する。一度deepセレクタがはびこってしまうと、おいそれとクラス名やdom構造を変更できなくなってしまう。

scopedを使用しているとクラスはプライベートなイメージになるので、たとえ変更したところで他所に影響は無い気がしてしまう。しかし、プロジェクトがdeepセレクタの使用を認めている場合は話は別だ。例えば label と書くつもりが lable とタイポしていたことに後になって気付き修正したら途端にデザインが崩れるということが起こりうる。

というわけで一切のdeepセレクタを禁止したいところだが、なかなかそうもいかない局面もある。例えば、次のようなユーザー一覧を出力するUserTableを実装していたとする。

    <tbody>
    <tr v-for="user in users" :key="user.id">
      <td>{{ user.id }}</td>
      <td>{{ user.name }}</td>
      <td>{{ user.deleted }}</td>
    </tr>
    </tbody>

このとき、テーブルの行である tr に任意のデザインを施せるようにするにはどうすればいいだろうか。tdがあるため、trだけをslotで切り出すことはできない。

望ましくない方法としては、trにクラス名を割り当て、deepで上書きしてもらうようにすることだ。

<tr v-for="user in users" :key="user.id" class="row">

もちろん正常に動作するけれど、問題がある。ひとつ目は「実装を読まない限りデザインの上書きの仕方が分からない」ということ。わざわざエディタでソースを開いて<template>の中身を解析しないといけない。(この例では解析というレベルではないけど。。。)

ふたつ目は、row というクラス名を変更しないといけなくなったときに大変ということ。”row”とありふれたクラス名をIDEでgrepした大量の結果を注意深く見ていかないといけないから。

これをもっとマシにするためには、クラス名をpropsで指定してもらうようにすればよい。以下の例ではtrに加え、tdのクラス名も指定できるようにした。

<template>
  <table>
    <thead>
    <tr>
      <th>Id</th>
      <th>Name</th>
      <th>Deleted</th>
    </tr>
    </thead>
    <tbody>
    <tr v-for="user in users" :key="user.id" :class="rowClass">
      <td :class="cellClass">{{ user.id }}</td>
      <td :class="cellClass">{{ user.name }}</td>
      <td :class="cellClass">{{ user.deleted }}</td>
    </tr>
    </tbody>
  </table>
</template>
<script>
export default {
  props: {
    users: {
      type: Array,
      required: true
    },
    rowClass: {
      type: String,
      default: '',
    },
    cellClass: {
      type: String,
      default: '',
    },
  },
}
</script>

「なんや、大して変わらんやん」と思うかもしれないが、外からクラス名を貰うということは、

  • 「その部分のデザインを自由に変更できる」ということを明示的にする
  • クラス名を指定する側がdeepセレクタを使用することになるので(比較的)安全

ということになる。

実際に使用する側のサンプルは以下のような感じ。

<template>
  <UserTable
      :users="users"
      class="user-table"
      row-class="row"
      cell-class="cell"
  />
</template>
<script>
import UserTable from "@/components/UserTable.vue";

export default {
  components: { UserTable },
  data() {
    return {
      users: [
        {
          id: 1,
          name: "山田 太郎",
          deleted: false,
        },
        {
          id: 2,
          name: "佐藤 次郎",
          deleted: true,
        },
        {
          id: 3,
          name: "田中 三郎",
          deleted: false,
        },
      ],
    };
  },
}
</script>
<style scoped>
.user-table /deep/ .row:hover {
  background-color: yellow;
}
.user-table /deep/ .cell {
  border: 1px solid gray;
}
</style>

ダメな例と同様に、”row”というクラス名を使用しているが、こちらのほうがより安全だということが分かる。

ちなみに上記例で、deleted の状態によってtrのデザインを変更したい場合は、props rowClass をFunctionで受けれるようにしてやればよい。

...
<tr v-for="user in users" :key="user.id" :class="rowClass(user)">
...
...
rowClass: {
  type: Function,
  default: (user) => '',
},
...
...
<UserTable
      :users="users"
      class="user-table"
      :row-class="(user) => ['row', user.deleted ? 'deleted' : '']"
      cell-class="cell"
  />
...
...
.user-table /deep/ .row:hover {
  background-color: yellow;
}
.user-table /deep/ .row.deleted {
  background-color: gray;
}
...

みたいな。

関連するpropはオブジェクトでまとめて貰う

コンポーネントの挙動を制御するためにpropsで値を貰うことはよくあることだが、その際に関連するpropであれば、オブジェクトとしてまとめて貰った方が筋が良い。例えば次のように最大で選択できる上限が定められたチェックリストを考えてみる。

このチェックリストをコンポーネント化するとして、「最大N個」という上限をpropsで「任意で」指定できるようにすることを考える。また最大値を越えようとした場合に表示するアラートメッセージ文もpropsで渡せるようにしたい。

ぱっと思いつく実装は↓こんな感じになるかと思う。

  props: {
    limit: {
      type: Number,
      default: Number.POSITIVE_INFINITY,
    },
    limitMessage: {
      type: String,
      default: '',
    },
  },

この実装には少し問題があって、それは「limit を指定した場合には limitMessage も指定しないといけない」という暗黙の条件がひと目で分からないことである。もちろんvalidatorなどを駆使して明示的にすることはできるが、少し面倒。

一番手っ取り早いのは、関連するオプションであれば、オブジェクトとしてまとめてしまうという手がある。

    limitOptions: {
      type: Object,
      default: () => ({
        max: Number.POSITIVE_INFINITY,
        message: '',
      }),
    },

この方法の方がメリットが多いと思うのは、

  • 2つのpropが関連していることが明示的
  • 名前空間的な使い方ができるので、各オプション値の命名がシンプルにできる
  • すごくオプション感がでる

といったところ。例では2つのオプション値だが、これが増えれば増えるほどメリットが享受できる。関連するオプション名に同一のプレフィックスを付ける・・といった訳の分からん実装も必要なし。

参考までに、選択できる最大数を設けたチェックリストのサンプルコードを掲載しておく。

📝CheckList.vue
<template>
  <div>
    <div v-for="item in items" :key="item.id">
      <input
          :id="`check-${item.id}`"
          type="checkbox"
          v-model="item.checked"
          @click="(e) => toggle(e, item)"
      />
      <label :for="`check-${item.id}`">{{ item.name }}</label>
    </div>
  </div>
</template>
<script>
export default {
  props: {
    items: {
      type: Array,
      required: true
    },
    limitOptions: {
      type: Object,
      default: () => ({
        max: Number.POSITIVE_INFINITY,
        message: '',
      }),
    },
  },
  computed: {
    checkedItems() {
      return this.items.filter(item => item.checked);
    }
  },
  methods: {
    toggle(e, item) {
      if (!item.checked && this.checkedItems.length >= this.limitOptions.max) {
        alert(this.limitOptions.message);
        e.preventDefault();
      }
    }
  },
}
</script>

使用する側のコードは↓↓こちら↓↓

📝UseCheckListPage.vue
<template>
  <div>
    <h1>興味のある分野を選択してください(最大3つ)</h1>
    <CheckList
        :items="items"
        :limit-options="{
          max: 3,
          message: '3つ以上選択することはできません',
        }"
    />
  </div>
</template>
<script>
import CheckList from "@/components/CheckList.vue";

export default {
  components: { CheckList },
  data() {
    return {
      items: [
        { id: 1, name: 'IT', checked: false },
        { id: 2, name: '政治', checked: false },
        { id: 3, name: '経済', checked: false },
        { id: 4, name: 'アート', checked: false },
        { id: 5, name: 'ファッション', checked: false },
      ],
    };
  },
}
</script>

ちょっと強引な例かもしれないが、エッセンスが伝われば。

関連する記事


コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA


このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください