RitoLabo

Vue.jsで画像やファイルの入力フォームを実装する(プレビュー・ドラッグアンドドロップ機能など)

  • 公開:
  • カテゴリ: JavaScript Vue.js
  • タグ: JavaScript,Vue,dnd

Vue.jsを導入しインタラクティブなフロント画面を構築するにあたり、アンケートや登録画面などの入力フォーム類も良く作られる機能の1つです。

今回は、ファイル入力フォームに焦点を当て、様々な機能を実装していきます。

アジェンダ
  1. 開発環境
  2. 作業に入る前に
  3. ベースの作成
  4. バリデーション実装
  5. ドラッグアンドドロップでの入力
  6. 画像プレビュー機能
    1. 共通処理の切り出し
    2. 汎用ファイル入力フォームコンポーネント
    3. 画像データ出力機能
    4. 画像ファイル入力フォームコンポーネント
    5. 親コンポーネント

開発環境

今回の開発環境は以下の通りです。

  • Vue.js 2.x
  • Vue CLI 3.x
  • Node.js 12.x
  • npm 6.5

SFC(単一ファイルコンポーネント)を単体で動かすので、 インスタントプロトタイピングの為のグローバルアドオン「cli-service-global」 をインストールしておいてください。

Vue CLIを使った開発環境構築の詳細については以下からも確認できます
Vue CLIでVue.jsの開発環境をサクッと構築する

尚、今回はフォームパーツとその機能を作成する事がメインテーマです。 状態管理については今回は扱いません。

作業に入る前に

まずは、どんなフォームにしたいかを考えてみます。

  • どこでも使い回せるようにしたい
  • バリデーション&エラーメッセージ
  • ドラッグアンドドロップでも入力できる
  • 画像はプレビューしたい

こんなところでしょうか。特にフォームパーツは1つのアプリケーションであちこちに出てくる場合もあるので、できるだけそのコンポーネントだけを使えば良いようにしておきたいです。 これらを踏まえて、作成していきます。

ベースの作成

まずは、処理を伴わない見た目だけを作成していきたいと思います。 最も基本部分を作るので、まずは以下を定義して、結果としてどう表示されるのかを俯瞰したいと思います。

  • 入力フォーム
  • セットしたファイル名
  • エラーメッセージ
FileInput.vue
<template>
<div>
<label>ファイルを選択
<input type="file">
</label> <span id="file_name" >sample.csv <span class="reset_file_ico">×</span></span>
<p id="error">csv ファイルのみアップロード可能です</p>
</div>
</template>

<script>
export default {
name: "FileInput",
}
</script>

<style scoped>
label {
font-size: 12px;
padding: 2px 3px;
border: 1px solid #c6c6c6;
}
label:hover {
cursor: pointer;
background-color: #cbdada;
}
label input {
display: none;
}
#file_name {
font-size: 14px;
margin-left: 20px;
}
.reset_file_ico {
padding: 4px;
font-size: 12px;
border: 1px solid #c6c6c6;
border-radius: 10px;
}
.reset_file_ico:hover {
cursor: pointer;
border-color: #5f6674;
}
#error {
color: red;
}
</style>

まずはシンプルに見た目だけです。以下のvueコマンドを叩いて、ブラウザから確認してみます。

vue serve FileInput.vue --open

ベースとなるフォームの確認

サンプルページ

ファイルをセットしたり、エラーメッセージが表示された場合にこんな風に出る感じでやっていきます。 というのをまずは自分自身の中で確認しました。

バリデーション実装

ではここから、機能を実装していきます。まずはバリデーションからです。

と、ここで、手を動かす前に一旦考えます。
ファイルのバリデーションて、容量と形式で評価できればひとまず良いかな。
とは言いつつも、評価より機能自他の設定が膨らみそうな気もする。

つまり、FileInputコンポーネントにバリデーションのコアを埋めるとファットになって後々管理が面倒になりそうだなと感じました。

という事で、この機能を切り出そうと思います。

ちなみに機能の切り出しって、一定のセオリーはありつつも結構性格出てくるなと思っていて、 私の場合は「入力フォームのコンポーネントにバリデーションがあっても良いけど、ファイル評価そのものの機能がそこにいるのは違う」と感じたりします。 切り出しもMixinで良いのか(classとか)を考えましたが、一定数設定値を保持しておきたい事と、最終的なコードのすっきり感から、今回はMixinにします。

/FormParts/Mixins/FileEvaluable.js
export const FileEvaluable = {
props: ['params'],

data () {
return {
limit: 0,
unit: '',
allow: '',
allowType: [],
lists: {
'mimeType': {
'gif': 'image/gif',
'jpg': 'image/jpeg',
'png': 'image/png',
'text': 'text/plain',
'tsv': 'text/tab-separated-values',
'csv': [ 'application/vnd.ms-excel', 'text/csv' ],
'pdf': 'application/pdf',
},
'unit': {
'kb': 1000,
'mb': 1000000
}
}
}
},

mounted () {
this.limit = parseInt(this.params.limit)
this.unit = this.params.unit
this.allow = this.params.allow.split(",")
this.allowType = this._getAllowMimeType(this.allow)
},

methods: {
isAllowFileType(type) {
return this.allowType.indexOf(type) !== -1
},
isAllowFileSize(size) {
return parseInt(size) < this._getLimitSizeByte()
},
isImage(type) {
return type.indexOf('image') !== -1
},
getErrorMessageSize() {
return this.limit + this.unit + '未満のファイルのみアップロード可能です'
},
getErrorMessageType() {
return this.allow.join('/') + ' ファイルのみアップロード可能です'
},
_getAllowMimeType(allow) {
let mimeTypes = []
for (let i = 0; i < allow.length; i++) {
let target = this.lists.mimeType[allow[i]]
if (typeof target === 'string') {
mimeTypes.push(target)
} else if (target instanceof Array) {
mimeTypes = mimeTypes.concat(target)
}
}
return mimeTypes;
},
_getLimitSizeByte() {
return this.limit * this.lists.unit[this.unit]
}
}
}

与えられた設定値で初期化して、ファイル評価を行うだけの機能です。

この機能を以て、入力フォームコンポーネントを実装します。

/FormParts/FileInput.vue
<template>
<div>
<label>ファイルを選択
<input @change="changeFile" ref="file" type="file">
</label> <span id="file_name" v-show="file.name">{{ file.name }} <span class="reset_file_ico" @click="resetFile">×</span></span>
<p id="error" v-show="error">{{ error }}</p>
</div>
</template>

<script>
import { FileEvaluable } from './Mixins/FileEvaluable'
export default {
name: "FileInput",
mixins: [ FileEvaluable ],

data () {
return {
file: {},
error: ''
}
},

computed: {
isError: function () {
return !!this.error !== ''
}
},

methods: {
changeFile: function (e) {
const files = e.target.files || e.dataTransfer.files

if (this.validation(files[0])) {
this.file = files[0]
} else {
this.file = {}
}
},
resetFile () {
const input = this.$refs.file;
input.type = 'text'
input.type = 'file'
this.file = {}
this.error = ''
},
validation: function (file) {
if (!this.isAllowFileType(file.type)) {
this.error = this.getErrorMessageType()
return false
}

if (!this.isAllowFileSize(file.size)) {
this.error = this.getErrorMessageSize()
return false
}

this.error = ''
return true
}
}
}
</script>

<style scoped>
label {
font-size: 12px;
padding: 2px 3px;
border: 1px solid #c6c6c6;
}
label:hover {
cursor: pointer;
background-color: #cbdada;
}
label input {
display: none;
}
#file_name {
font-size: 14px;
margin-left: 20px;
}
.reset_file_ico {
padding: 0 4px;
font-size: 12px;
border: 1px solid #c6c6c6;
border-radius: 10px;
}
.reset_file_ico:hover {
cursor: pointer;
border-color: #5f6674;
}
#error {
color: #d70035;
}
</style>

ファイルが選択されたらバリデーションを行い、通らなければエラーメッセージを表示する事と、セットしたファイルのリセットを行うシンプルな機能です。 ファイル評価を切り出したので、見通しも良くなりました。

また、バリデーションの設定値を外から与える関係で、パーツをまとめる親コンポーネントも作成します。

/FormSample.vue
<template>
<form>
<file-input :params="{ limit: 100, unit: 'kb', allow: 'csv' }"></file-input>
</form>
</template>

<script>
import FileInput from './FormParts/FileInput.vue'
export default {
name: "FormSample",
components: {
FileInput
}
}
</script>

template部分でFileInputコンポーネントを展開する再に、カスタムディレクティブで設定値を渡しています。 それぞれ、制限値・制限値単位・許容ファイル形式になっています。 (汎用的なフォームパーツを作るのであればこうなるのかなと思いつつ。良案随時募集。)

ではこれらを動かしてみます。以下のvueコマンドを叩いて、ブラウザから確認してみます。

vue serve FormSample.vue --open

バリデーション動作確認

サンプルページ

動作も問題ないようです。これでバリデーションの実装は完了しました。

ドラッグアンドドロップでの入力

続いては、ドラッグアンドドロップ(以下、dnd)でファイルをセットできるようにします。 この機能はFileInputコンポーネントにそのまま記述する事にします。

/FormParts/FileInput.vue
<template>
<div>
<div class="drop_area" :class="classesDragArea" @dragover.prevent="onArea" @drop.prevent="dropFile" @dragleave.prevent="offArea" @dragend.prevent="offArea">
<label>ファイルを選択
<input @change="changeFile" ref="file" type="file">
</label>
</div>
<p><span id="file_name" v-show="file.name">{{ file.name }} <span class="reset_file_ico" @click="resetFile">×</span></span></p>
<p id="error" v-show="error">{{ error }}</p>
</div>
</template>

<script>
import { FileEvaluable } from './Mixins/FileEvaluable'
export default {
name: "FileInput",
mixins: [ FileEvaluable ],

data () {
return {
file: {},
error: '',
inArea: false
}
},

computed: {
isError: function () {
return !!this.error !== ''
},
classesDragArea () {
return {
'drag_on': this.inArea
}
}
},

methods: {
dropFile(e) {
this.changeFile(e)
this.offArea()
},
changeFile (e) {
const files = e.target.files || e.dataTransfer.files

if (this.validation(files[0])) {
this.file = files[0]
} else {
this.file = {}
}
},
resetFile () {
const input = this.$refs.file;
input.type = 'text';
input.type = 'file';
this.file = {}
this.error = ''
},
validation (file) {
if (!this.isAllowFileType(file.type)) {
this.error = this.getErrorMessageType()
return false
}

if (!this.isAllowFileSize(file.size)) {
this.error = this.getErrorMessageSize()
return false
}

this.error = ''
return true
},
onArea () { this.inArea = true },
offArea () { this.inArea = false }
}
}
</script>

<style scoped>
label {
font-size: 12px;
padding: 2px 3px;
}
label:hover {
cursor: pointer;
}
label input {
display: none;
}
#file_name {
font-size: 14px;
margin-left: 20px;
}
.reset_file_ico {
padding: 0 4px;
font-size: 12px;
border: 1px solid #c6c6c6;
border-radius: 10px;
}
.reset_file_ico:hover {
cursor: pointer;
border-color: #5f6674;
}
#error {
color: #d70035;
}
.drop_area {
width: 200px;
padding: 10px;
text-align: center;
border: 1px dashed #c6c6c6;
background-color: #f9f9f9;
}
.drag_on {
border: 2px dashed #bcbcbc;
background-color: #fafdff;
}
</style>

template部分にdndできる領域を作ってあげて、そこに対してイベントを設定(dragover/dragleave/drop/dragend)していて、 イベントが発生するとそれぞれに指定したメソッドが呼ばれます。

ファイルセットに関しては従来のchangeFile()メソッドに処理を移譲しているだけなので、実装自体は対して増えていません。 どちらかといえば、見た目的な変更の方が作業は多い。つまり、dndであろうがVue.jsだとわりと簡単に実装できるという事です。

ではブラウザから動作確認します。以下のvueコマンドを叩いて、ブラウザから確認してみます。

vue serve FormSample.vue --open

ドラッグアンドドロップ動作確認

サンプルページ

これでドラッグアンドドロップでのファイル入力が実装できました。 ちなみに要件的には通常の選択も可能である事になっているので、「ファイルを選択」の文字列を押下すると通常のファイル選択も行えます。

画像プレビュー機能

最後に、画像のプレビュー機能を実装します。

共通処理の切り出し

画像ファイルではない場合はプレビュー機能は不要なのでコンポーネントは分けてしまった方が望ましいでしょう。 汎用ファイルと画像ファイルのコンポーネントを定義するにあたり、共通処理であるFileInput部分をMixinに切り出します。

/FormParts/Mixins/FileInputable.js
import { FileEvaluable } from './FileEvaluable'

export const FileInputable = {
mixins: [ FileEvaluable ],

data () {
return {
file: {},
error: '',
inArea: false
}
},

computed: {
isError: function () {
return !!this.error !== ''
},
classesDragArea () {
return {
'drag_on': this.inArea
}
}
},

methods: {
dropFile(e) {
this.changeFile(e)
this.offArea()
},
changeFile (e) {
const files = e.target.files || e.dataTransfer.files

if (this.validation(files[0])) {
this.file = files[0]
} else {
this.file = {}
}
},
resetFile () {
const input = this.$refs.file;
input.type = 'text'
input.type = 'file'
this.file = {}
this.error = ''
},
validation (file) {
if (!this.isAllowFileType(file.type)) {
this.error = this.getErrorMessageType()
return false
}

if (!this.isAllowFileSize(file.size)) {
this.error = this.getErrorMessageSize()
return false
}

this.error = ''
return true
},
onArea () { this.inArea = true },
offArea () { this.inArea = false }
}
}

簡単に言えば、これまでFileInputコンポーネントにあったものをそっくりこちらに移した形になります。 汎用ファイル入力フォーム+画像プレビューが画像ファイル入力フォームになる感じです。

汎用ファイル入力フォームコンポーネント

FileInputコンポーネントの処理をMixinに切り出したので、改めて定義すると以下になります。

/FormParts/FileInput.vue
<template>
<div>
<div class="drop_area" :class="classesDragArea" @dragover.prevent="onArea" @drop.prevent="dropFile" @dragleave.prevent="offArea" @dragend.prevent="offArea">
<label>ファイルを選択
<input @change="changeFile" ref="file" type="file">
</label>
</div>
<p><span id="file_name" v-show="file.name">{{ file.name }} <span class="reset_file_ico" @click="resetFile">×</span></span></p>
<p id="error" v-show="error">{{ error }}</p>
</div>
</template>

<script>
import { FileInputable } from './Mixins/FileInputable'

export default {
name: "FileInput",
mixins: [ FileInputable ],
}
</script>

ここでちょっと迷うのが、styleの置所です。
今再定義したFileInputコンポーネントとこれから作成するFileInputImageコンポーネントって、styleが一緒なんですね。 なのでscopedすると同じソースが2つ生成される事になるので極めて冗長。 かといって一方だけにscoped外して記述しても、styleが依存する感じになるので気持ち悪い。 結局、見た目はアプリケーションによって異なって然るべきものであるし、機能として汎用的なパーツが作れればそれが最適解と考え、 今回は親コンポーネントであるFormSampleコンポーネントにstyleを記述する事にして、ここでは除去しました。

結果、このコンポーネントはMixinを読み込むだけになりました。

画像データ出力機能

次に、画像プレビュー機能を持つ画像ファイル入力フォームコンポーネントを作成したいのですが、その前に考えます。

このコンポーネントもフォームパーツとしての機能をメインに持つコンポーネントです。 故に、画像を実体化する機能自体は有するべきではない。

よってコンポーネントを作成する前に画像を出力する機能だけを別で作成し、それをコンポーネント側で利用して画像プレビューを行うようにします。

/FormParts/Mixins/ImageCreatable.js
export const ImageCreatable = {
data () {
return {
imageData: ''
}
},

methods: {
createImage (file) {
let reader = new FileReader()
reader.onload = (e) => {
this.imageData = e.target.result
};
reader.readAsDataURL(file)
}
}
}

FileReaderオブジェクトを用いて、非同期的に画像データを実体化する流れで実装しています。

画像ファイル入力フォームコンポーネント

次は画像ファイル入力フォームコンポーネントです。先程実装した画像データ出力機能と合わせて、画像プレビュー機能を実装していきます。

/FormParts/FileInputImage.vue
<template>
<div>
<div class="drop_area" :class="classesDragArea" @dragover.prevent="onArea" @drop.prevent="dropFile" @dragleave.prevent="offArea" @dragend.prevent="offArea">
<label>ファイルを選択
<input @change="changeFile" ref="file" type="file">
</label>
</div>
<p><span id="file_name" v-show="file.name">{{ file.name }} <span class="reset_file_ico" @click="resetFile">×</span></span></p>
<p id="error" v-show="error">{{ error }}</p>
<p v-if="isPreview"><img :src="imageData" alt=""></p>
</div>
</template>

<script>
import { FileInputable } from './Mixins/FileInputable'
import { ImageCreatable } from './Mixins/ImageCreatable'

export default {
name: "FileInputImage",
props: ['params'],
mixins:[ FileInputable, ImageCreatable ],

data () {
return {
preview: true
}
},

mounted () {
const preview = this.params.preview;
this.preview = (typeof preview === 'boolean') ? preview : true
},

updated () {
if (typeof this.file.size === 'undefined') {
this.imageData = ''
return
}

if (this.isImage(this.file.type)) {
this.createImage(this.file)
}
},

computed: {
isPreview () {
return this.imageData !== '' && this.preview
}
}
}
</script>
  • ファイル選択の基本的な機能はMixinで実装済みなので、ここではプレビュー機能だけを実装しています。
  • templateにプレビューの為の領域を作成しています。
  • ライフサイクルメソッドであるupdatedオプションの中でファイル選択フォームが更新された再に、それが画像ファイルであれば画像の実体化処理を実行します。

あとはcomputedで画像データを監視して出力です。
ちなみにpreviewフラグメントは、そもそも画像のプレビュー機能を有効にするかどうかのフラグとして立てています。 これは、コンポーネントを利用する際に外から設定値を受け付けます。

親コンポーネント

最後に、フォームデータをまとめている親コンポーネントを定義します。 

/FormSample.vue
<template>
<form>
<file-input :params="{ limit: 100, unit: 'kb', allow: 'csv' }"></file-input>
<file-input-image :params="{ limit: 100, unit: 'kb', allow: 'png,jpg,gif', preview: true }"></file-input-image>
</form>
</template>

<script>
import FileInput from './FormParts/FileInput.vue'
import FileInputImage from './FormParts/FileInputImage.vue'
export default {
name: "FormSample",
components: {
FileInput,
FileInputImage
}
}
</script>

<style>
label {
font-size: 12px;
padding: 2px 3px;
}
label:hover {
cursor: pointer;
}
label input {
display: none;
}
#file_name {
font-size: 14px;
margin-left: 20px;
}
.reset_file_ico {
padding: 0 4px;
font-size: 12px;
border: 1px solid #c6c6c6;
border-radius: 10px;
}
.reset_file_ico:hover {
cursor: pointer;
border-color: #5f6674;
}
#error {
color: #d70035;
}
.drop_area {
width: 200px;
padding: 10px;
text-align: center;
border: 1px dashed #c6c6c6;
background-color: #f9f9f9;
}
.drag_on {
border: 2px dashed #bcbcbc;
background-color: #fafdff;
}
</style>

コンポーネントを展開する際にカスタムディレクティブで設定値を渡しています。 上のコンポーネントが汎用ファイル入力で、下が画像ファイル入力になります。

これで一通りの実装が完了したので、以下のvueコマンドを叩いて、ブラウザから確認してみます。

vue serve FormSample.vue --open

画像プレビュー機能動作確認

サンプルページ

画像プレビュー機能が実装できました。

まとめ

以上で作業は完了です。 今回はあくまでもファイル入力フォームパーツのコンポーネント作成として、ドラッグアンドドロップや画像プレビュー機能を実装しました。 ミニマムの機能としては成立しましたが、これらをフォームパーツとして組み合わせ実際にフォームで使用していく為には、状態管理を行う必要があります。 また別の機会で、その事に関しては触れたいと思います。
サンプルコード