Vue.jsのカスタムコンポーネントを拡張する

# Vue.jsのカスタムコンポーネントを拡張する

既存のコンポーネントの機能を生かしつつ、機能追加する方法のまとめ。
Vuetifyの`VTextField`を拡張して、数値をカンマ区切りで表示する入力フィールドを作ります。

## 仕様
* 入力（focus）時は`type="number"`としてカンマなしの数値を入力する
* 表示（非focus）時は`type="text"`として3桁のカンマ区切りで表示する
* valueの型は`Number|null`とする
* `v-model`での双方向バインディングが出来るようにする

## ラッパーコンポーネントの作成

まずプロパティ、イベント、スロットを透過的に`VTextField`に渡すコンポーネントを作成します。

### プロパティ

プロパティを`VTextField`に受け渡すために属性の継承を無効化して、`v-bind`を指定します。

デフォルトの挙動はテンプレートのルート要素に属性が継承されますが、`$props`に受け渡されるわけではなく、`$attrs`に直接渡されてしまうため、期待した動作をしません。
※この辺りの詳細は参考の記事に分かりやすくまとめられています

また、`VTextField`をルート以外の要素にしたい場合はデフォルトの継承では出来ないので`v-bind`で渡す必要があります。

```vue
<template>
  <v-text-field
    v-bind="$attrs"
  >
  </v-text-field>
</template>

<script>
export default {
  inheritAttrs: false,
}
</script>
```

参考
 
* [Vue.js公式 | プロパティ#属性の継承の無効化](https://jp.vuejs.org/v2/guide/components-props.html#%E5%B1%9E%E6%80%A7%E3%81%AE%E7%B6%99%E6%89%BF%E3%81%AE%E7%84%A1%E5%8A%B9%E5%8C%96)
* [Vue.js の inheritAttrs に関する大きな勘違い](https://qiita.com/mpyw/items/d3dc2745b6808b0158c3)

### イベント

`v-on`にイベントリスナーを受け渡します。
後の`v-model`の実装を分かりやすくするため`:value` `props` `listeners()`を実装していますが、子がカスタムコンポーネントなので、inputイベントの内容を変更する必要が無ければ`v-on="$listeners"`を追加するだけでも動作します。

```vue
<template>
  <v-text-field
    v-bind="$attrs"
    :value="value"
    v-on="listeners"
  >
  </v-text-field>
</template>

<script>
export default {
  inheritAttrs: false,
  props: ['value'],
  computed: {
    listeners() {
      const vm = this;
      return {
        ...vm.$listenres,
        // 子がカスタムイベントなのでeventに直接値が入っている
        input: event => vm.$emit('input', event)
      };
    },
  }
}
</script>
```

参考

* [Vue.js公式 | カスタムイベント](https://jp.vuejs.org/v2/guide/components-custom-events.html)

### スロット

$slotsからスロット名を取り出して、`VTextField`に渡します。

```vue
<template>
  <v-text-field
  >
    <template v-for="(value, name) in $slots" v-slot:[name]>
      <slot :name="name"/>
    </template>
  </v-text-field>
</template>
```

参考

* [Vue.js公式 | スロット](https://jp.vuejs.org/v2/guide/components-slots.html)

これで作成したコンポーネントが`VTextField`の機能をすべて利用できるようになりました。

## 実装

続いて追加機能を実装します。

### 入力時は`type="number"`、表示時は`type="text"`にする。

dataに入力／表示の状態を保持する`editing`を追加して、`@focusin` `@focusout`で制御します。

```vue
<template>
  <v-text-field

    :type="type"
    @focusin="edit"
    @focusout="display"
  >

  </v-text-field>
</template>

<script>
export default {

  data() {
    return {
      editing: false
    };
  },
  computed: {

    type() {
      if (this.editing) {
        return "number";
      } else {
        return "text";
      }
    }
  },
  methods: {
    display() {
      this.editing = false;
    },
    edit() {
      this.editing = true;
    }
  }
};
</script>
```

### valueの型をNumberに。入力時はカンマ無し、表示時はカンマ区切り。

valueをNumberにして、このコンポーネントが受け取る場合はNumber、`VTextField`に渡す場合はstringにします。
受け渡し用に算出プロパティの`innerValue`を追加して、getterでNumber -> stringとカンマ区切りの変換、setterでstring -> Number の変換を行います。
算出プロパティを使うことでその中で使用している変数（value、editing）が変更された場合に算出プロパティに反映されます。

```vue
<template>
  <v-text-field
    v-bind="$attrs"
    :value="innerValue" // VTextFieldには算出プロパティを渡す
    v-on="listeners"

  >

  </v-text-field>
</template>

<script>
export default {
  inheritAttrs: false,
  props: {
    value: {
      type: Number,
      default: null
    }
  },
  data() {
    return {
      editing: false
    };
  },
  computed: {
    listeners() {
      const vm = this;
      return {
        ...vm.$listenres,
        input: event => vm.innerValue = event // 直接emitせずにinnerValueを通すように変更
      };
    },
    innerValue: {
      get() {
        if (this.editing) {
          return this.value != null ? this.value.toString() : "";
        } else {
          return this.value != null ? this.value.toLocaleString() : "";
        }
      },
      set(newValue) {
        const value = newValue.length > 0 ? Number(newValue) : null;
        this.$emit("input", value);
      }
    },
  },

};
</script>
```

これで、`v-model`に値を渡せば双方向バインディングでリアルタイムに書き換わるようになります。

呼び出し元コンポーネント

```vue
<template>
  <div id="app">
    <my-number-field v-model="val" label="Number Field">
      <template v-slot:append>円</template>
    </my-number-field>
  </div>
</template>

<script>
import MyNumberField from "./components/MyNumberField.vue";

export default {
  name: "App",
  components: {
    MyNumberField
  },
  data() {
    return {
      val: 1000
    };
  },
}
</script>
```

## コード

全体コードは[こちら](https://codesandbox.io/s/crazy-dhawan-9zqq1)。