Nuxt.jsで Dropzone.jsを使ってみる(Vue2-Dropzone)

Nuxtでファイルアップロード機能の実装をやってみました。
以前(jQuery全盛期)使っていた Dropzone.js がVueで使えるように Vue2-Dropzone としてパッケージ化されていたので今回はこれを利用します。

Nuxt.js: 2.12.2
TypeScript: 3.8.3
Vue2-Dropzone: 3.6.0
TailwindCss: 1.4.1

機能要件:

単一ファイルのアップロードとディレクトリアップロードの2種類
上記2つを切り替えられるスイッチャーを配置
単一ファイルの場合は1ファイルのみのアップロードが可能
ディレクトリの場合は配下のファイル全てが対象(webkitdirectory属性を利用する)
独自のプレビューテンプレートを利用する
上限ファイルサイズは2MB
許容ファイルタイプは画像、PDF, PSD, AI, XD, Sketch

Demoはこちら

インストール

書くまでもないのですが、以下の通りです。
ちょっと残念なのがTypeScript対応がされておらず(typeファイルがない) // @ts-ignore を連発してタイプチェックを無効化せざるを得ませんでした。

yarn add vue2-dropzone

CSSのグローバル読み込み設定

dropzoneで用意されているcssを nuxt.config.js に設定します。
あくまでデフォルトのスタイルという意味合いで利用し、適度にカスタマイズをコンポーネント側で行いました。
また、buildするとどういうわけかいくつかのスタイルが読み込まれないという現象にぶつかり、そこもコンポーネント側でカバーしました。devでは問題ないのに。。

module.exports = {
  ...
  /*
   ** Global CSS
   */
  css: ['vue2-dropzone/dist/vue2Dropzone.min.css'],
  ...
}

pageファイル

page/dropzone.vue を作成し、中でDropzone.vueコンポーネント(後述)をインポートします。ここはこれだけです。

<template>
  <div class="container">
    <dropzone />
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator'
import Dropzone from '~/components/Dropzone.vue'

@Component({
  name: 'FileUpload',
  components: {
    Dropzone,
  },
})
export default class FileUpload extends Vue {}
</script>

<style>
.container {
  margin: 0 auto;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}
</style>

Dropzone.vueコンポーネント

こちらがメインのコンポーネントになります。

<template>
  <section class="map w-full max-w-xl">
    <div class="flex flex-wrap">
      <div class="w-full">
        <label
          class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2"
          for="grid-password"
        >
          Dropzone (<a href="#" @click.prevent="switchUploadType(true)"
            >Directory</a
          >
          | <a href="#" @click.prevent="switchUploadType(false)">File</a>)
        </label>

        <div v-show="isDirectoryUpload">
          <vue2-dropzone
            v-if="!loading"
            id="dz-directory"
            ref="dzDirectory"
            :options="dzOptionsDirectory"
            @vdropzone-files-added="handleFileAdded"
            @vdropzone-complete="handleFileComplete"
            @vdropzone-error="handleFileError"
            @vdropzone-success="handleFileSuccess"
          />
        </div>
        <div v-show="!isDirectoryUpload">
          <vue2-dropzone
            v-if="!loading"
            id="dz-file"
            ref="dzFile"
            :options="dzOptionsFile"
            @vdropzone-files-added="handleFileAdded"
            @vdropzone-complete="handleFileComplete"
            @vdropzone-error="handleFileError"
            @vdropzone-success="handleFileSuccess"
          />
        </div>

        <template v-if="errorFileList.length">
          <ul class="p-2">
            <li v-for="item in errorFileList" :key="item.file.name">
              <p class="text-red-500">{{ item.message }}</p>
              {{ item.file.name }}
            </li>
          </ul>
        </template>

        <template v-if="fileList.length">
          <ul class="flex items-center flex-col">
            <li
              v-for="item in fileList"
              :key="item.name"
              class="flex items-center p-2 w-full"
            >
              <img :src="item.dataURL" class="w-24 mr-4" />
              {{ getRelativeName(item) }}
              ({{ Math.round(item.size / 100000) / 10 }} MB)
            </li>
          </ul>
        </template>
      </div>
    </div>
  </section>
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator'
// @ts-ignore
import vue2Dropzone from 'vue2-dropzone'
import DropzonePreviewTemplate from '~/components/DropzonePreviewTemplate.vue'

type errorFileList = {
  file: File
  message: string
}

@Component({
  name: 'Dropzone',
  components: {
    vue2Dropzone,
    DropzonePreviewTemplate,
  },
})
export default class Dropzone extends Vue {
  private loading: boolean = true
  private fileList: File[] = []
  private errorFileList: errorFileList[] = []
  private tmpErrorFileList: errorFileList[] = []

  private isDirectoryUpload: boolean = false
  private dzOptions = {
    url: 'https://httpbin.org/put',
    method: 'put',
    maxFilesize: 2,
    dictFileTooBig:
      'ファイルサイズの上限は {{maxFilesize}} MB です\n(size: {{filesize}} MB)',
    dictInvalidFileType: 'ファイルタイプが不正です',
    previewTemplate: '',
    acceptedFiles: 'image/*,application/pdf,.psd,.sketch,.xd,.ai',
  }

  created() {
    const instance = new DropzonePreviewTemplate()
    instance.$mount()
    this.dzOptions.previewTemplate = instance.$el.outerHTML
    this.loading = false
  }

  get dzOptionsFile() {
    return {
      ...this.dzOptions,
      maxFiles: 1,
      thumbnailWidth: 250,
      hiddenInputContainer: '#dz-file',
    }
  }

  get dzOptionsDirectory() {
    return {
      ...this.dzOptions,
      dictDefaultMessage: 'Drop directory here to upload',
      hiddenInputContainer: '#dz-directory',
      init() {
        // @ts-ignore
        this.hiddenFileInput.setAttribute('webkitdirectory', true)
      },
    }
  }

  private handleFileAdded() {
    this.fileList.length = 0
    this.errorFileList = Array.from(this.tmpErrorFileList)
    this.tmpErrorFileList.length = 0
  }

  private handleFileComplete(file: File) {
    if (this.isDirectoryUpload) {
      const $ref = this.$refs.dzDirectory
      // @ts-ignore
      const $input = $ref.$el.getElementsByTagName('input')[0]
      $input.setAttribute('webkitdirectory', true)

      // @ts-ignore
      $ref.removeFile(file)
    } else {
      // @ts-ignore
      this.$refs.dzFile.removeFile(file)
    }
  }

  private handleFileSuccess(file: File) {
    this.fileList.push(file)
  }

  private handleFileError(file: File, message: string) {
    this.tmpErrorFileList.push({ file, message })
  }

  private switchUploadType(isDirectoryUpload: boolean) {
    this.isDirectoryUpload = isDirectoryUpload
    // @ts-ignore
    this.$refs.dzFile.removeAllFiles()
    // @ts-ignore
    this.$refs.dzDirectory.removeAllFiles()
    this.fileList.length = 0
    this.errorFileList.length = 0
  }

  private getRelativeName(file: File) {
    // @ts-ignore
    return file.webkitRelativePath || file.name
  }
}
</script>

<style lang="scss">
.dropzone {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 40px;
  min-height: 200px;
  border-color: theme('colors.gray.400');
  cursor: pointer;

  &.dz-started {
    .dz-message {
      display: none;
    }
  }

  .dz-message {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    margin: 0;
    color: theme('colors.gray.600');
  }

  .dz-preview {
    display: flex;
    align-items: center;
    min-height: 54px;
    width: 100%;
    margin: 0;

    .dz-progress {
      background: theme('colors.gray.300');

      .dz-upload {
        background: theme('colors.green.300');
      }
    }

    .dz-details {
      display: flex;
      align-items: center;
      justify-content: center;
      background: none;
      padding: 5px;
      top: auto;
      bottom: auto;
      color: theme('colors.green.700');
      opacity: 1;

      .dz-size {
        margin: 0;
      }
    }
  }
}
</style>

工夫した点はディレクトリ単位でのアップロードの際には init() 内で <input type="file"> 要素に webkitdirectory属性を付ければ良いとあったのでそのようにしました。しかし、一度ファイルを選択して、再度選択する場合にはこの属性が消えてしまうという現象にぶつかりました。まぁバグでしょう。
なので、毎度、選択が完了した事後処理として、webkitdirectory属性を付け直す処理をいれました。

  get dzOptionsDirectory() {
    return {
      ...this.dzOptions,
      dictDefaultMessage: 'Drop directory here to upload',
      hiddenInputContainer: '#dz-directory',
      init() {
        // @ts-ignore
        this.hiddenFileInput.setAttribute('webkitdirectory', true)
      },
    }
  }

Optionの設定

今回は単一ファイルとディレクトリ単位でのアップロードという要件なのでOptionオブジェクトをそれぞれに用意しました。
また、独自のPreviewテンプレートを利用するので created() 内でPreviewコンポーネントをmount()してそいつをセットします。

  private dzOptions = {
    url: 'https://httpbin.org/put',
    method: 'put',
    maxFilesize: 2,
    dictFileTooBig:
      'ファイルサイズの上限は {{maxFilesize}} MB です\n(size: {{filesize}} MB)',
    dictInvalidFileType: 'ファイルタイプが不正です',
    previewTemplate: '',
    acceptedFiles: 'image/*,application/pdf,.psd,.sketch,.xd,.ai',
  }

  created() {
    const instance = new DropzonePreviewTemplate()
    instance.$mount()
    this.dzOptions.previewTemplate = instance.$el.outerHTML
    this.loading = false
  }

  get dzOptionsFile() {
    return {
      ...this.dzOptions,
      maxFiles: 1,
      thumbnailWidth: 250,
      hiddenInputContainer: '#dz-file',
    }
  }

  get dzOptionsDirectory() {
    return {
      ...this.dzOptions,
      dictDefaultMessage: 'Drop directory here to upload',
      hiddenInputContainer: '#dz-directory',
      init() {
        // @ts-ignore
        this.hiddenFileInput.setAttribute('webkitdirectory', true)
      },
    }
  }

Previewテンプレート

アップロード時のファイル名、サイズ、プログレスバーだけを利用したかったので簡素なものにしています。

<template>
  <div class="dz-preview dz-file-preview">
    <div class="dz-details">
      <div class="dz-filename"><span data-dz-name></span></div>
      <div class="dz-size" data-dz-size></div>
    </div>
    <div class="dz-progress">
      <span class="dz-upload" data-dz-uploadprogress></span>
    </div>
  </div>
</template>

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

@Component({
  name: 'DropzonePreviewTeamplate',
})
export default class DropzonePreviewTeamplate extends Vue {}
</script>

イベントハンドラー

利用するハンドラーは以下の4つです。
https://rowanwins.github.io/vue-dropzone/docs/dist/#/events

  1. @vdropzone-files-added <- ファイルが追加された際に発火(fileオブジェクトを返す)
  2. @vdropzone-complete <- ファイルアップロード完了時に発火(fileオブジェクトを返す。エラーの場合も発火)
  3. @vdropzone-error <- エラーファイルの場合に発火(fileオブジェクト&エラーメッセージも返す)
  4. @vdropzone-success <- 正常終了時に発火(fileオブジェクトを返す)

handleFileAdded()

正常ファイルを保持する fileList、エラーファイルの一時待避先 tmpErrorFileListを初期化します。初期化の前にエラーファイルを保持する errorFileList にこのハードコピーをセットします。このtmpErrorFileListはこのあとの handleFileError() 内でエラーファイルをセットするのですが、これは、handleError() が handelAdded() の前に発火するための待避策です。errorFileList のみで処理しようとすると、せかっく handleError() でエラーファイルを捉えたのに、初期化されてしまうという悲しい結果になってしまいます。あまり美しくはない。。

  private handleFileAdded() {
    this.fileList.length = 0
    this.errorFileList = Array.from(this.tmpErrorFileList)
    this.tmpErrorFileList.length = 0
  }

handleFileComplete()

Dropzone.js は再選択の際にプレビューをリセットしてくれないので独自にやる必要があるのですが、ファイル追加前のライフサイクルhookがない、かつ、addedイベントとerrorイベントの順序が担保されないのでちょっとはまりました。(addedイベントのなかでリセット処理を入れると、errorが先に走った場合にそのエラープレビューもリセットしてしまうとか。。)
なので、都度complteイベントの中で、完了アップロードファイル一覧、エラーファイル一覧をdataで管理して、previewはここで消し去る(removeFile())という処理にしました。

このハンドラーはアップロード完了後に発火します。正常、エラー問わずfileオブジェクトを返します。
ディレクトリアップロードか、ファイルアップロードかで処理を分岐します。
共通するのは、完了後にファイルオブジェクトを削除(Previewも消える)する処理です。
ディレクトリの場合は、input要素に webkitdirectory 属性を追加します。

  private handleFileComplete(file: File) {
    if (this.isDirectoryUpload) {
      const $ref = this.$refs.dzDirectory
      // @ts-ignore
      const $input = $ref.$el.getElementsByTagName('input')[0]
      $input.setAttribute('webkitdirectory', true)

      // @ts-ignore
      $ref.removeFile(file)
    } else {
      // @ts-ignore
      this.$refs.dzFile.removeFile(file)
    }
  }

handleFileSuccess() & handleFileError()

正常ファイルリスト fileList と一時待避エラーファイルリスト tmpErrorFileList にFileオブジェクトを追加するだけです。これが、templateで結果として描画されます。

  private handleFileSuccess(file: File) {
    this.fileList.push(file)
  }

  private handleFileError(file: File, message: string) {
    this.tmpErrorFileList.push({ file, message })
  }

モード切り替えスイッチャー

単純にすべてを初期化する処理です。

  private switchUploadType(isDirectoryUpload: boolean) {
    this.isDirectoryUpload = isDirectoryUpload
    // @ts-ignore
    this.$refs.dzFile.removeAllFiles()
    // @ts-ignore
    this.$refs.dzDirectory.removeAllFiles()
    this.fileList.length = 0
    this.errorFileList.length = 0
  }

Vue2-Dropzone は便利ではありますが、TypeScript対応ができていない点、もう一歩のところで痒い所に手が届いてくれない点がおしいですね。さらなる進化を期待!!