【Vue.js】ドラッグアンドドロップでファイルを取得するコンポーネントを作る

# はじめに
最近ファイルアップロード画面を作った時に、ドラッグアンドドロップでファイルを取得するコンポーネントを作りました。この時得た知見をまとめてみたいと思います。

# 開発環境
- VueCLI
- Vue 2.x
- TypeScript
- vue-property-decorator

# シンプルな実装
![file-upload-1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/474068/8fc79ce5-c098-4510-1d01-3fae6f5465b6.gif)

``` FileUploadCard.vue
<template>
  <div
    class="file-upload-card"
    @dragover.prevent="drag = true"
    @dragleave.prevent="drag = false"
    @drop.prevent="onDrop"
  >
    <div v-if="!drag">
      ドラッグアンドドロップでファイルを追加
    </div>
    <div v-else>
      ドラッグ中
    </div>

    <div v-if="file">
      ファイル名： {{ file.name }}
      <button @click="file = null">
        クリア
      </button>
    </div>
  </div>
</template>

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

@Component
export default class FileUploadCard extends Vue {
  drag = false

  file: File | null = null

  onDrop(event: DragEvent): void {
    this.drag = false

    if (!event) {
      return
    }

    if (!event.dataTransfer) {
      return
    }

    if (event.dataTransfer.files.length === 0) {
      return
    }

    this.file = event.dataTransfer.files[0]
  }
}
</script>
<style lang="scss" scoped>
.file-upload-card {
  display: flex;
  flex-direction: column;
  border: solid 1px;
  padding: 1rem;
  width: 20rem;
}
button {
  color: white;
  background: gray;
  padding: 0.2rem;
}
</style>

```

## ポイント
### ドラッグ状態の取得
`dragover` は要素上でドラッグ操作をしているとき、`dragleave` は要素上からドラッグしているカーソルが離れたときに発行されるイベントです。これらのイベントで `drag` の true/false を切り替えることでドラッグ状態を取得できます。

### ドロップ時の処理
`drop` がドロップイベントです。この時 `dragleave` は発行されないので、`onDrop` でも `drag` を false にする処理を入れてあります。

### 必ず prevent を付ける
`@dragover.prevent` のように `.prevent` 修飾子を付けていますがこれは**このイベント時に行われるブラウザのデフォルトの動作を無効化するものです。**
この修飾子を付けなかった場合、**ブラウザ上でそのファイルが展開されてしまいます。**

### イベントの型は `DragEvent`
TypeScript を使っていると悩みがちなイベント型ですが、ドラッグ時のイベントは `DragEvent` を指定しておくといい感じにファイル取得の処理を書くことができます。

# 抽象化してみる
あなたのプロジェクトで、ドラッグアンドドロップ機能を持たせたいコンポーネントは一つとは限りません。複数ある場合に、毎回 `@dragover.prevent="drag = true"` や、ファイルをイベントから取り出す処理を書くのは面倒です。先ほどの `FileUploadCard` も下記のように書けると便利そうです。

```FileUploadCard.vue
<template>
  <FileDropArea
    class="file-upload-card"
    :drag.sync="drag"
    @drop="file = $event"
  >
    <div v-if="!drag">
      ドラッグアンドドロップでファイルを追加
    </div>
    <div v-else>
      ドラッグ中
    </div>

    <div v-if="file">
      ファイル名： {{ file.name }}
      <button @click="file = null">
        クリア
      </button>
    </div>
  </FileDropArea>
</template>

<script lang="ts">
import { Component, Vue } from "vue-property-decorator"
import FileDropArea from "./FileDropArea.vue"

@Component({
  components: {
    FileDropArea,
  },
})
export default class FileUploadCard extends Vue {
  drag = false

  file: File | null = null
}
</script>
// スタイルは省略
```

```FileDropArea.vue
<template>
  <div
    @dragover.prevent="drag = true"
    @dragleave.prevent="drag = false"
    @drop.prevent="onDrop"
  >
    <slot />
  </div>
</template>

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

@Component
export default class FileDropArea extends Vue {
  drag = false

  @Watch("drag")
  syncOnDragChanged(): void {
    this.$emit("update:drag", this.drag)
  }

  onDrop(event: DragEvent): void {
    this.drag = false

    if (!event) {
      return
    }

    if (!event.dataTransfer) {
      return
    }

    if (event.dataTransfer.files.length === 0) {
      return
    }

    this.$emit("drop", event.dataTransfer.files[0])
  }
}
</script>
```

## ポイント
### FileDropArea
ドラッグ/ドロップイベントの取得とファイルのオブジェクトの取り出しだけを行う抽象的な `FileDropArea` を定義します。
このコンポーネントは `slot` を持っているため、

```vue
<template>
  <FileDropArea
    class="file-upload-card"
    :drag.sync="drag"
    @drop="file = $event"
  >
    <!-- ファイルのドロップでの取得機能を持たせたい要素 -->
  </FileDropArea>
</template>
```
このように任意の要素を挟むだけでファイルのドロップでの取得機能を持たせることができる。

### :drag.sync でドラッグ状態を取得する
Vue.js では v-model 以外にも [.sync 修飾子](https://jp.vuejs.org/v2/guide/components-custom-events.html#sync-%E4%BF%AE%E9%A3%BE%E5%AD%90)を使った双方向バインディングができます。
このように

```FileDropArea.vue
  @Watch("drag")
  syncOnDragChanged(): void {
    this.$emit("update:drag", this.drag)
  }
```
子コンポーネント側で `update:[event]` というイベント名で値を `emit` すると、

```FileUploadCard.vue
    class="file-upload-card"
    :drag.sync="drag"
    @drop="file = $event"
  >
```
親コンポーネント側で `:[event].sync="value"` という形式でその値を同期することができます。
ちなみに、`.sync` を使った書き方は糖衣構文なので、下記と等価です。

```FileUploadCard.vue
  <FileDropArea
    class="file-upload-card"
    @update:drag="drag = $event"
    @drop="file = $event"
  >
```
この方法は、`FileDropArea` が**余計なイベントを定義しなくて良い**という点、**`drag` が変更された時だけイベントが発行され親要素に伝わる**という点で優れていると思います。


### drop イベントでファイルオブジェクトを emit
`FileDropArea` 側で

```FileDropArea.vue
  onDrop(event: DragEvent): void {
    this.drag = false

    if (!event) {
      return
    }

    if (!event.dataTransfer) {
      return
    }

    if (event.dataTransfer.files.length === 0) {
      return
    }

    this.$emit("drop", event.dataTransfer.files[0])
  }
```
この処理をしているため、親コンポーネント側では

```FileUploadCard.vue
    @drop="file = $event"
```

この１行のみでファイルを取得することが可能となります。

# おわりに
`.prevent` やイベントの型は知らないとハマりがちだと思います。同じ轍を踏まない人が少しでも増えると幸いです。

今回実際に使ったソースコードは以下です。
https://github.com/punkshiraishi/file-drop-sample