【Vue】登録済みのコンポーネントを拡張する


この記事は、Vue #2 Advent Calendar 2019 の6日目の記事です。

はじめに

すでにグローバルに登録されているコンポーネントに対して、独自のスタイルを当てたり、特定のpropsに固定の値を流したりして再利用したい場合のやり方です。
主にライブラリやフレームワークで提供されているコンポーネントに対して、プロジェクトで統一したスタイルや動きを反映させたい時に使われるイメージです。

説明ではTypeScriptvue-property-decoratorPugVuetifyを使用しています。

仕様例

今回の説明のための、簡単な仕様です。

  • Vuetifyのv-selectを拡張する
  • 常にcolorredとする
  • ほかのpropsはすべてv-selectに準じる

素直にpropsslotを記述する

まずは普通に記述するパターン。

MySelect.vue
<template lang="pug">
v-select(v-bind="$props" color="red")
  template(v-slot:append)
    slot(name="append")
  template(v-slot:default)
    slot
  //- ...
  //- 多いので省略
</template>

<script lang="ts">
import Vue from "vue";
import { Component, Prop } from "vue-property-decorator";

@Component({})
export default MySelect class extends Vue {

  @Prop() appendIcon: string;
  @Prop() appendOuterIcon: string;
  @Prop() attach: any;
  @Prop({ type: Boolean, default: false }) autofocus: boolean;
  // ...
  // 多いので省略

}
</script>

<style scoped>
/* 独自のスタイル */
</style>

出来なくはありませんが、v-selectで定義されているすべてのpropsslotを用意しなければならないので、非常に冗長です。

$attrsと描画関数(render)を使う

こちらが本題の、$attrsrender関数を使って受け取ったものをすべてv-selectに流してしまうパターンです。

MyRenderSelect.vue
<script lang="ts">
import Vue, { CreateElement } from "vue";
import { Component, Prop } from "vue-property-decorator";

@Component({
  inheritAttrs: false
})
export default MyRenderSelect class extends Vue {
  // 独自に受け取りたいpropsだけ定義
  @Prop({ type: Boolean, default: false }) hoge: boolean;

  render(h: CreateElement) {
    const children = Object.keys(this.$slots).map(slot =>
      h("template", { slot }, this.$slots[slot])
    );

    return h("v-select", {
      class: {
        "my-select--hoge": this.hoge,
      },
      props: {
        ...this.$attrs,
        ...this.$props,
        color: "red",
      },
      on: this.$listeners,
      scopedSlots: this.$scopedSlots
    }, children);
  }
}
</script>

<style scoped>
/* 独自のスタイル */
</style>

必要最低限の記述だけで、v-selectを使いながら、色を指定することができました。

ここでポイントとなるのは、以下の2点です。

  1. inheritAttrs$attrs
  2. slotの渡し方

inheritAttrs$attrs

inheritAttrsは2.4.0から追加されたオプションで、プロパティとして定義されていないバインディングがどのように扱われるのかを制御できるものです。
以下公式の説明です。

デフォルトでは、親スコープのバインディングはプロパティとして認識されず”フォールスロー”され、子コンポーネントのルート要素に通常の HTML 属性として適用されます。ターゲット要素または別のコンポーネントをラップ (wrap) するコンポーネントを著作する場合は、これは常に望ましい動作ではないかもしれません。inheritAttrsfalse を設定することで、このデフォルトの動作を無効にできます。属性は、$attrs インスタンスプロパティ (2.4 での新規) を介して利用でき、v-bind を使用してルートではない要素に明示的にバインドできます。

注意: このオプションは classstyle のバインディングには効果がありません。

より具体的な動きはこちらの記事が参考になると思います。
Vue.js inheritAttrsの挙動を調べる - http://tic40.hatenablog.com/entry/2018/07/25/080000

今回のMyRenderSelectv-selectで使用するプロパティを定義していません。
そのため、デフォルトの状態ではv-selectのHTML属性として適用されてしまいます。

my-render-select(:items="['hoge', 'fuga']")

inheritAttrs: trueの場合のDOM

これを回避するために、inheritAttrsfalseにしています。
また、受け取った属性(v-selectに渡すもの)は$attrsから取り出すことができるので、そのままv-selectpropsに渡してあげます。

slotの渡し方

次に、スロットの渡し方ですが、$slotsから取り出して、描画関数を用いてVNodeを構築します。
$slotsの公式の説明です。

プログラム的にスロットにより配信されたコンテンツにアクセスするために使用されます。各名前付きスロット は自身に対応するプロパティを持ちます (例: v-slot:foo のコンテンツは vm.$slots.foo で見つかります)。default プロパティは、名前付きスロットに含まれない任意のノード、または v-slot:default のコンテンツを含みます。

注意: v-slot:foo は 2.6 以降でサポートされます。古いバージョンでは、非推奨の構文 を利用できます。

vm.$slots のアクセスは、描画関数 によるコンポーネントを書くときに最も便利です。

$slotsの型は{ [name: string]: ?Array<VNode> }でキーにスロット名、値にスロットのVNodeが入っているので、すべてのスロットについて、templateを使用したVNodeを構築します。

const children = Object.keys(this.$slots).map(slot =>
    h("template", { slot }, this.$slots[slot])
);

これをv-slotの子要素として渡してあげることで、1つ1つスロットを定義しなくてもv-slotにスロットを渡すことができます。
ちなみに、scopedSlots$scopedSlotsを渡すだけで問題ありません。

まとめ

すでにグローバルに登録されているコンポーネントの拡張方法について紹介しました。
望ましいのはライブラリ等を使わずに、プロジェクトのスタイルガイドに沿ったコンポーネントを作成することだと思いますが、現実そこまで工数を割けないことも多いかと思います。(ライブラリが良く出来ているとなおさら)
そんな時に少しでもこの記事がお役に立てれば幸いです。

次の記事

明日は @hex2323 さんの Laravel + Vue.jsプロジェクト導入前の3つの心得です!