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

この記事は、[Vue #2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/vue2) の6日目の記事です。

## はじめに

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

説明では`TypeScript`、`vue-property-decorator`、`Pug`、`Vuetify`を使用しています。

## 仕様例

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

- Vuetifyの[v-select](https://vuetifyjs.com/ja/components/selects)を拡張する
- 常に`color`を`red`とする
- ほかの`props`はすべて`v-select`に準じる

## 素直に`props`と`slot`を記述する

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

```vue: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`で定義されているすべての`props`と`slot`を用意しなければならないので、非常に冗長です。 :sob:

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

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

```vue: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`を使いながら、色を指定することができました。 :sunglasses: 

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

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

### `inheritAttrs`と`$attrs`

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

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

https://jp.vuejs.org/v2/api/index.html#inheritAttrs

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


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

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

**inheritAttrs: true**の場合のDOM
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97227/ce33bde0-0fbc-e0c9-fe0d-f59da2e95059.png)

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

### slotの渡し方

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

> プログラム的に[スロットにより配信された](https://jp.vuejs.org/guide/components.html#%E3%82%B9%E3%83%AD%E3%83%83%E3%83%88%E3%81%AB%E3%82%88%E3%82%8B%E3%82%B3%E3%83%B3%E3%83%86%E3%83%B3%E3%83%84%E9%85%8D%E4%BF%A1)コンテンツにアクセスするために使用されます。各[名前付きスロット](https://jp.vuejs.org/v2/guide/components-slots.html#%E5%90%8D%E5%89%8D%E4%BB%98%E3%81%8D%E3%82%B9%E3%83%AD%E3%83%83%E3%83%88) は自身に対応するプロパティを持ちます (例: `v-slot:foo` のコンテンツは `vm.$slots.foo` で見つかります)。`default` プロパティは、名前付きスロットに含まれない任意のノード、または `v-slot:default` のコンテンツを含みます。
>
> 注意: `v-slot:foo` は 2.6 以降でサポートされます。古いバージョンでは、[非推奨の構文](https://jp.vuejs.org/v2/guide/components-slots.html#%E9%9D%9E%E6%8E%A8%E5%A5%A8%E3%81%AE%E6%A7%8B%E6%96%87) を利用できます。
>
> `vm.$slots` のアクセスは、[描画関数](https://jp.vuejs.org/guide/render-function.html) によるコンポーネントを書くときに最も便利です。

https://jp.vuejs.org/v2/api/index.html#vm-slots

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

```ts
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プロジェクト導入前の３つの心得](https://qiita.com/hex2323/items/4cc780fafcdc08862c77)です！