LoginSignup
12
8

More than 3 years have passed since last update.

Vue.js 削除ボタンを作ってスロットを理解する

Posted at

スロットとは

スロット(slot)は、Vue.jsにおける親コンポーネントから子コンポーネントにデータを渡す手段の一つです。
スロットという名前はハーデスリゼロなど様々な機種がある遊技マシン...のことではなく「差し込み口」という意味で使われています。

つまり、コンポーネントに外からコンテンツの差し込みを受け付けるという目的で使用されます。

スロットはその性質上、再利用の高いコンポーネントによく使用されます。再利用の高いコンポーネントは、Atomic DesignにおけるAtomのようなものが代表されます。

まずはさっそくスロットの基礎的な使い方を見ていきましょう。

スロットコンテンツ

スロットを使用コンポーネントとして、汎用的に利用する削除ボタンを実装します。
コンポーネントの内容は、Vuetifyのv-buttonをラップしただけ簡単なものです。

DeleteBtn.vue
<template>
  <v-btn
    color="error"
    dark
    min-width="300"
    rounded
  >
    <slot></slot>
  </v-btn>
</template>

<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
  name: 'DeleteBtn',
})
</script>

見た目はこんな感じです。
小さなコンポーネントですが、このボタンはいろんなページで使用するためコンポーネント化することでデザインを共通化することができます。

スクリーンショット 20200712 15.22.48.png

<slot></slot>を置換する

子コンポーネント側のテンプレートに<slot>タグを記述すると、その場所ではスロットコンテンツが埋め込まれます。
今はボタンのテキストが表示されていない状態ですが、親からスロットコンテンツを渡して表示させてみましょう。

親からスロットコンテンツを渡すには、以下のようにスロットコンテンツをテンプレート上でタグで囲います。

App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn>削除ボタン</delete-btn>
    </v-col>
  </v-row>
</template>

<script lang="ts">
import Vue from 'vue'
import DeleteBtn from '~/components/atom/DeleteBtn.vue'
export default Vue.extend({
  components: {
    DeleteBtn
  },
})
</script>

これは次のように出力されます。

スクリーンショット 20200712 15.57.50.png

スロットには、HTML要素やコンポーネントを入れることもできます。

App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn><h1>削除ボタン</h1></delete-btn>
    </v-col>
    <v-col class="text-center">
      <delete-btn><v-icon color="black">fab fa-github</v-icon></delete-btn>
    </v-col>
  </v-row>
</template>

<script lang="ts">
import Vue from 'vue'
import DeleteBtn from '~/components/atom/DeleteBtn.vue'
export default Vue.extend({
  components: {
    DeleteBtn
  },
  auth: false,
  layout: 'unauthorized'
})
</script>

スクリーンショット 20200712 16.02.48.png

フォールバックコンテンツ

スロットに対して、コンテンツがない場合に描画されるデフォルトのコンテンツを指定することができます。
フォールバックコンテンツを使用するには、<slot>タグの中に記述します。

DeleteBtn.vue
<template>
  <v-btn
    color="error"
    dark
    min-width="300"
    rounded
  >
    <slot>DELETE</slot>
  </v-btn>
</template>

親コンポーネントでスロットを指定しなかった場合にはDELETEというテキストが出力され、指定された場合にはその文字が出力されます。

App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <!-- スロットコンテンツを指定せず -->
      <delete-btn></delete-btn>
    </v-col>
    <v-col class="text-center">
      <!-- スロットコンテンツを指定 -->
      <delete-btn>削除</delete-btn>
    </v-col>
  </v-row>
</template>

<script lang="ts">
import Vue from 'vue'
import DeleteBtn from '~/components/atom/DeleteBtn.vue'
export default Vue.extend({
  components: {
    DeleteBtn
  }
})
</script>

スクリーンショット 20200712 16.09.20.png

名前付きスロット

この削除ボタンにさらに機能を追加しましょう、
ボタンをクリックしたときに、「本当に削除しますか?」というダイアログが出現するようにします。

とりあえず簡単にVuetifyのダイアログを追加します。

DeleteBtn.vue
<template>
  <span>
    <v-btn color="error" dark min-width="300" rounded @click.stop="openDialog">
      <slot>DELETE</slot>
    </v-btn>
    <v-dialog v-model="dialog" max-width="290">
      <v-card>
        <v-card-title class="subtitle-1">
          削除します。よろしいですか?
        </v-card-title>
        <v-card-text>
          この操作は取り消せません。
        </v-card-text>
        <v-divider></v-divider>
        <v-card-actions>
          <v-spacer></v-spacer>
          <v-btn color="primary" text @click="clickCancel">
            キャンセル
          </v-btn>
          <v-btn color="error" text @click="clickOK">
            削除する
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </span>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  name: 'DeleteButton',
  data() {
    return {
      dialog: false
    }
  },
  methods: {
    openDialog() {
      this.dialog = true
    },
    closeDialog() {
      this.dialog = false
    },
    clickCancel() {
      this.closeDialog()
      this.$emit('clickCancel')
    },
    clickOK() {
      this.closeDialog()
      this.$emit('clickOK')
    }
  }
})
</script>

このコンポーネントを汎用的に利用するために、ダイアログのメッセージもスロット化したいはずです。
しかし、次のようにそのまま<slot>タグで囲むとうまく動作しません。

DeleteBtn.vue
<!-- 誤ったスロットの使いかた -->
<template>
  <span>
    <v-btn color="error" dark min-width="300" rounded @click.stop="openDialog">
      <slot>DELETE</slot>
    </v-btn>
    <v-dialog v-model="dialog" max-width="290">
      <v-card>
        <v-card-title class="subtitle-1">
          <slot>削除します。よろしいですか?</slot>
        </v-card-title>
        <v-card-text>
          <slot>この操作は取り消せません。</slot>
        </v-card-text>
        <v-divider></v-divider>
        <v-card-actions>
          <v-spacer></v-spacer>
          <v-btn color="primary" text @click="clickCancel">
            <slot>キャンセル</slot>
          </v-btn>
          <v-btn color="error" text @click="clickOK">
            <slot>削除する</slot>
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </span>
</template>

なぜなら、スロットが複数あるため親コンポーネントからどのスロットに対してコンテンツを差し込めばよいか判断することができないからです。

このように、スロットが複数ある場合にはスロットに名前を付けて利用します。<slot>要素はnameという属性を持っているのでこれを利用して名前を定義します。

DeleteBtn.vue
<template>
  <span>
    <v-btn color="error" dark min-width="300" rounded @click.stop="openDialog">
      <slot>DELETE</slot>
    </v-btn>
    <v-dialog v-model="dialog" max-width="290">
      <v-card>
        <v-card-title>
          <slot name="dialogTitle">削除します。よろしいですか?</slot>
        </v-card-title>
        <v-card-text>
          <slot name="dialogText">この操作は取り消せません。</slot>
        </v-card-text>
        <v-divider></v-divider>
        <v-card-actions>
          <v-spacer></v-spacer>
          <v-btn color="primary" text @click="clickCancel">
            <slot name="cancelBtn">キャンセル</slot>
          </v-btn>
          <v-btn color="error" text @click="clickOK">
            <slot name="okBtn">削除する</slot>
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </span>
</template>

name属性を持たない<slot>は暗黙的にdefaultという名前を持ちます。

親コンポーネントからスロットコンテンツを指定するには、<template>に対してv-slotディレクティブでスロット名を与えます。

App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn>
        削除
        <template v-slot:dialogTitle>
          {{ item.id }}のデータを本当に削除しますか?
        </template>

        <template v-slot:okBtn>
          はい、削除します。
        </template>
      </delete-btn>
    </v-col>
  </v-row>
</template>

<script lang="ts">
import Vue from 'vue'
import DeleteBtn from '~/components/atom/DeleteBtn.vue'
export default Vue.extend({
  components: {
    DeleteBtn
  },
  data() {
    return {
      item: {
        id: '12345'
      }
    }
  }
})
</script>

<template>で囲まれていな要素は、デフォルトスロットに対するものとして扱われます。
これは、次のように描画されます。

スクリーンショット 20200712 17.10.14.png

名前付きスロットの省略記法

v-slotディレクティブにも省略記法が使用できます。
省略記法では、v-slot:の代わりに#を使用します。

App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn>
        削除
        <template #dialogTitle>
          {{ item.id }}のデータを本当に削除しますか?
        </template>

        <template #okBtn>
          はい、削除します。
        </template>
      </delete-btn>
    </v-col>
  </v-row>
</template>

Propsとスロットの違い

ここまでは基礎的なスロットの使い方をみてきました。しかし、ある程度Vue.jsを触ったことがある人なら、次のように思ったんじゃないでしょうか。

「結局これってPropsとやってることは変わらないんじゃ・・・わざわざスロットを使わなくてもいいのでは🤔」

例えば、先程の例では次のようにPropsを使ったコンポーネントに書き換えることもできそうです。

DeleteBtn.vue
<template>
  <span>
    <v-btn color="error" dark min-width="300" rounded @click.stop="openDialog">
      {{ btnText }}
    </v-btn>
    <v-dialog v-model="dialog" max-width="290">
      <v-card>
        <v-card-title>
          {{ dialogTitle }}
        </v-card-title>
        <v-card-text>
          {{ dialogText }}
        </v-card-text>
        <v-divider name="dialogText"></v-divider>
        <v-card-actions>
          <v-spacer></v-spacer>
          <v-btn color="primary" text @click="clickCancel">
            {{ cancelBtn }}
          </v-btn>
          <v-btn color="error" text @click="clickOK">
            {{ okBtn }}
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </span>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  name: 'DeleteBtn',
  props: {
    btnText: {
      type: String,
      required: false,
      default: 'DELETE'
    },
    dialogTitle: {
      type: String,
      required: false,
      default: '削除します。よろしいですか?'
    },
    dialogText: {
      type: String,
      required: false,
      default: 'この操作は取り消せません。'
    },
    cancalBtn: {
      type: String,
      required: false,
      default: 'キャンセル'
    },
    okBtn: {
      type: String,
      required: false,
      default: '削除'
    }
  },
  data() {
    return {
      dialog: false
    }
  },
  methods: {
    openDialog() {
      this.dialog = true
    },
    closeDialog() {
      this.dialog = false
    },
    clickCancel() {
      this.closeDialog()
      this.$emit('clickCancel')
    },
    clickOK() {
      this.closeDialog()
      this.$emit('clickOK')
    }
  }
})
</script>
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn
        btnText="削除"
        :dialogTitle="`${item.id}のデータを本当に削除しますか?`"
        okBtn="はい、削除します。"
      />
    </v-col>
  </v-row>
</template>

このような仕事をさせるのなら、Propsとスロットに違いはないようにも思えます。
実際にはどのようにPropsとスロットを使い分けていくのでしょうか?

Propsは値を渡し、スロットは描画内容を渡す

Propsと比較したときに挙げられるスロットの役割とし、親コンポーネントに描画内容を任せるという点があります。

例えば、作成中の削除ボタンに次のような機能を持たせたいとします。

  • 重要なデータAを削除する際には、ダイアログの文字を大きく太文字で表示させる
  • そこそこ大切なデータBを削除するときはダイアログの文字の前にアイコンを表示させる
  • それ以外のデータを削除するときにはデフォルトのダイアログを表示

この機能をPropsを使って実装しようする場合、データの状態に関するロジックを子コンポーネントに記述しなくてはいけなくなってしまいます。

DeleteBtn.vue
<v-card-title v-if="dataLevel === 3" class="display-1">
  {{ dialogTitle }}
</v-card-title>
<v-card-title v-else-if="dataLevel === 2">
  <v-icon>fas fa-exclamation-triangle</v-icon>{{ dialogTitle }}
</v-card-title>
<v-card-title v-else>
  {{ dialogTitle }}
</v-card-title>

このように、データの状態が増えるたびに分岐が増えてしまい、コンポーネントが肥大化してしまします。さらには親の状態の増加によって本来手を加えるべきではない子コンポーネントに記述しなければいけない状態となり、汎用的に使用できるコンポーネントとは言えなくなっていしまいます。

この時、スロットを利用すればコンポーネントを使う側が描画内容を決定すること可能です。

ただこれなら、それぞれのデータの状態に合わせた削除ボタンコンポーネントを作成すれば、子コンポーネント側の肥大化は解消することができます。(DataLevel3DeleteBtn.vueとDataLevel2DeleteBtn.vueを作成して、それぞれに描画内容をもたせる)

ただし、その場合には動作は同じだが描画内容だけが違うコンポーネントを作成することになり、DRY原則からしてあまりイケてない実装になってしまいます。

スロットは、再利用の高いコンポーネントを作成するときに効果を発揮するともいえるでしょう。

スコープ付きスロット

通常のスロットのスコープを確認する

ここからは、基礎的な内容から更に一歩進んだものとなります。
通常、v-slotディレクティブからアクセスできるデータはそのコンポーネント自身のデータになります。

次の例で確認してみましょう。親コンポーネントと子コンポーネントは、同じuserというプロパティをもっています。

Hello.vue
<template>
  <div>Hello, <slot></slot></div>
</template>

<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
  data() {
    return {
      user: {
        name: 'aaa'
      }
    }
  }
})
</script>
App.vue
<template>
  <div>
    <Hello>{{ user.name }}</Hello>
  </div>
</template>

<script lang="ts">
import Vue from 'vue'
import Hello from '~/components/atom/Hello.vue'
export default Vue.extend({
  components: {
    Hello
  },
  data() {
    return {
      user: {
        name: 'bbb'
      }
    }
  },
})
</script>

この時、描画される内容は子のもつuserプロパティなのか、親の持つuserプロパティなのかどちらでしょうか?
スロットは子コンポーネントに描画されるので、一見子コンポーネントプロパティが使われるようにも思えますが、実際に使用されるのは親コンポーネントの持つプロパティです。

スクリーンショット 20200712 20.32.59.png

これは、デフォルトの動作では例えスロットを使用したとしても、子コンポーネントのプロパティにはアクセスできないことを意味します。
試しに、親コンポーネントのuserプロパティを削除してみると、エラーが発生します。

親から子のプロパティを参照する

一切子のプロパティを親から参照できないとなると不便になることも多いので、スロットには子のプロパティを参照するための機能が提供されています。

子コンポーネントから親コンポーネントのスロットコンテンツとしてプロパティを渡す場合、<slot>要素の属性としバインドします。

Hello.vue
<template>
  <div>Hello, <slot :user="user"></slot></div>
</template>

<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
  data() {
    return {
      user: {
        name: 'aaa'
      }
    }
  }
})
</script>

<slot>要素にバインドされた属性は、スロットプロパティ と呼ばれます。これは、親コンポーネント内でスロットの名前を指定することでスロットプロパティを受け取ることができます。

App.vue
<template>
  <div>
    <Hello>
      <!-- slotPropという名前はコンポーネント内で一意である名前であれば好きな名前を使用することができます。 -->
      <template #default="slotProp">
        {{ slotProp.user.name }}
      </template>
    </Hello>
  </div>
</template>

子コンポーネントのプロパティが描画されています。

スクリーンショット 20200712 20.50.09.png

デフォルトスロットの省略記法

スロットがデフォルトスロットだけの場合には、<template>タグで名前を指定せずとも、次のように記述することができます。

App.vue
<template>
  <div>
    <Hello v-slot="slotProp">
      {{ slotProp.user.firstName }}
    </Hello>
  </div>
</template>

スロットプロパティの分割代入

v-slotでは、JavaScriptの式を記述することができます。ですので、分割代入を利用すれば、よりきれいにプロパティを取得することができます。

App.vue
<template>
  <div>
    <Hello v-slot="{ user }">
      {{ user.firstName }}
    </Hello>
  </div>
</template>

実践的な例

スコープ付きスロットを利用した、より実践的な例を見ていきましょう。
もう一度、先程の削除ボタンコンポーネントに登場していただきます。

今度は、ダイアログ自体をスロットとして提供できるようにしましょう。
新たに、<v-dialog>全体をスロットで囲んでいます。

DeleteBtn.vue
<template>
  <span>
    <v-btn color="error" dark min-width="300" rounded @click.stop="openDialog">
      <slot>DELETE</slot>
    </v-btn>
    <slot name="dialog">
      <v-dialog v-model="dialog" max-width="290">
        <v-card>
          <v-card-title>
            <slot name="dialogTitle">削除します。よろしいですか?</slot>
          </v-card-title>
          <v-card-text>
            <slot name="dialogText">この操作は取り消せません。</slot>
          </v-card-text>
          <v-divider name="dialogText"></v-divider>
          <v-card-actions>
            <v-spacer></v-spacer>
            <v-btn color="primary" text @click="clickCancel">
              <slot name="cancelBtn">キャンセル</slot>
            </v-btn>
            <v-btn color="error" text @click="clickOK">
              <slot name="okBtn">削除する</slot>
            </v-btn>
          </v-card-actions>
        </v-card>
      </v-dialog>
    </slot>
  </span>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  name: 'DeleteBtn',
  data() {
    return {
      dialog: false
    }
  },
  methods: {
    openDialog() {
      this.dialog = true
    },
    closeDialog() {
      this.dialog = false
    },
    clickCancel() {
      this.closeDialog()
      this.$emit('clickCancel')
    },
    clickOK() {
      this.closeDialog()
      this.$emit('clickOK')
    }
  }
})
</script>

親コンポーネントから異なるタイプのダイアログを描画するために、次のように記述したいはずでしょう。

App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn>
        <template #dialog>
          <v-dialog v-model="dialog" persistent min-width="500">
            <v-card>
              <v-card-title class="headline">
                本当に削除しますか?
              </v-card-title>
              <v-card-actions>
                <v-spacer></v-spacer>
                <v-btn color="primary" @click="clickCancel">
                  キャンセル
                </v-btn>
                <v-btn color="error" @click="clickOK">
                  削除する
                </v-btn>
              </v-card-actions>
            </v-card>
          </v-dialog>
        </template>
      </delete-btn>
    </v-col>
  </v-row>
</template>

お察しの通り、これはうまく動作しませんなぜならダイアログの表示を制御するdialogプロパティは子コンポーネントが持っているからです。(v-modelで渡しているところです。)
親にスロットプロパティとして渡してみます。

DeleteBtn.vue
<template>
  <span>
    <v-btn color="error" dark min-width="300" rounded @click.stop="openDialog">
      <slot>DELETE</slot>
    </v-btn>
    <slot name="dialog" :dialog="dialog">
      <v-dialog v-model="dialog" max-width="290">
        <v-card>
          <v-card-title>
            <slot name="dialogTitle">削除します。よろしいですか?</slot>
          </v-card-title>
          <v-card-text>
            <slot name="dialogText">この操作は取り消せません。</slot>
          </v-card-text>
          <v-divider name="dialogText"></v-divider>
          <v-card-actions>
            <v-spacer></v-spacer>
            <v-btn color="primary" text @click="clickCancel">
              <slot name="cancelBtn">キャンセル</slot>
            </v-btn>
            <v-btn color="error" text @click="clickOK">
              <slot name="okBtn">削除する</slot>
            </v-btn>
          </v-card-actions>
        </v-card>
      </v-dialog>
    </slot>
  </span>
</template>
App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn>
        <template #dialog="{ dialog }">
          <v-dialog v-model="dialog" persistent min-width="500">
            <v-card>
              <v-card-title class="headline">
                本当に削除しますか?
              </v-card-title>
              <v-card-actions>
                <v-spacer></v-spacer>
                <v-btn color="primary" @click="clickCancel">
                  キャンセル
                </v-btn>
                <v-btn color="error" @click="clickOK">
                  削除する
                </v-btn>
              </v-card-actions>
            </v-card>
          </v-dialog>
        </template>
      </delete-btn>
    </v-col>
  </v-row>
</template>

しかし、残念なことにこれは好ましい実装ではありません。なぜなら、スロットプロパティの値をv-modelで直接更新してしまっているからです。スロットプロパティで渡された値はあくまで参照だけに留めるべきで、直接値を更新してしまうのは禁じ手です。

親のスロット内での変更を、どのように子に伝えればよいのでしょうか?

スロットプロパティでメソッドを渡す

実は、スロットプロパティには子のメソッドを渡すことも可能です。
スロットプロパティの中では渡したメソッドは子コンポーネントのものなので、子の値を変更することができるというわけです。

メソッドの渡し方は通常の記法変わりません。

DeleteBtn.vue
<template>
  <span>
    <v-btn color="error" dark min-width="300" rounded @click.stop="openDialog">
      <slot>DELETE</slot>
    </v-btn>
    <slot
      name="dialog"
      :dialog="dialog"
      :closeDialog="closeDialog"
      :clickCancel="clickCancel"
      :clickOK="clickOK"
    >
      <v-dialog v-model="dialog" max-width="290">
        <v-card>
          <v-card-title>
            <slot name="dialogTitle">削除します。よろしいですか?</slot>
          </v-card-title>
          <v-card-text>
            <slot name="dialogText">この操作は取り消せません。</slot>
          </v-card-text>
          <v-divider name="dialogText"></v-divider>
          <v-card-actions>
            <v-spacer></v-spacer>
            <v-btn color="primary" text @click="clickCancel">
              <slot name="cancelBtn">キャンセル</slot>
            </v-btn>
            <v-btn color="error" text @click="clickOK">
              <slot name="okBtn">削除する</slot>
            </v-btn>
          </v-card-actions>
        </v-card>
      </v-dialog>
    </slot>
  </span>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  name: 'DeleteButton',
  data() {
    return {
      dialog: false
    }
  },
  methods: {
    openDialog() {
      this.dialog = true
    },
    closeDialog() {
      this.dialog = false
    },
    clickCancel() {
      this.closeDialog()
      this.$emit('clickCancel')
    },
    clickOK() {
      this.closeDialog()
      this.$emit('clickOK')
    }
  }
})
</script>

ついでにキャンセルボタンをOKボタンをクリックした際のイベントも渡しておきましょう。
親側の記述は次のようになります。
v-modelを分解して:value@inputを使用します。

App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn>
        <template #dialog="{ dialog, closeDialog, clickCancel,clickOK }">
          <v-dialog :value="dialog" @input="closeDialog" dark min-width="500">
            <v-card>
              <v-card-title class="headline">
                本当に削除しますか?
              </v-card-title>
              <v-card-actions>
                <v-spacer></v-spacer>
                <v-btn color="primary" @click="clickCancel">
                  キャンセル
                </v-btn>
                <v-btn color="error" @click="clickOK">
                  削除する
                </v-btn>
              </v-card-actions>
            </v-card>
          </v-dialog>
        </template>
      </delete-btn>
    </v-col>
  </v-row>
</template>

これで、親からスロットを利用してダイアログを差し替えることができました。

タイトルなし.gif

終わりに

私自身も、Propsとスロットの違いがよくわかっておらず、スロットからは避けていました。
スロットを利用すると、再利用性の高いコンポーネントが作成できたりと、作成の幅が広がります。
さらに、ライブラリのコンポーネントを使用する機会も多いと思いますがそういったものはAPIとしてスロットを公開していることが多いです。
スロットを理解することで、ライブラリをより効果的に使用することも期待できます。

12
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
8