今風の画像アップローダーを作る

2019/12/1 532hit

この記事はZOZOテクノロジーズ #1 Advent Calendar 2019 2日目の記事になります。

また、今年は全部で5つのAdvent Calendarが公開されています。

ZOZOテクノロジーズ #2 Advent Calendar 2019
ZOZOテクノロジーズ #3 Advent Calendar 2019
ZOZOテクノロジーズ #4 Advent Calendar 2019
ZOZOテクノロジーズ #5 Advent Calendar 2019

Advent Calendarは業務と関係ない内容でも良いとのことだったので、個人的に運営しているFirespeedの画像アップローダーを作り直した話をしてみたいと思います。

画像アップローダーを作り直した経緯

Blog や SNS など、多くの場面で画像をアップロードする機会が増えてきました。
FirespeedのBlogでも画像を使うケースが多いのですが、画像アップローダーが使いづらく記事を作成するときのネックになってきていました。
そこで、思い切って画像のアップローダーを1から作り直し、現在的な使い勝手のアップローダーとするように変更しました。



1 章 古典的アップロード

1-1 古典的 HTML によるアップロード

ファイルアップロードの古典的な方法は forminput type="file" を使ったものでした。

<!DOCTYPE html>
<html lang="ja">
<body>
<form action="api/upload.py">
<input type="file" name="image_file" />
<input type="submit" />
</form>
</body>
</html>

しかし、この仕組みにはアップロードするたびにページ全体の遷移が発生するという問題があり、多くのファイルをアップロードするには不便なものでした。

1-2 インラインフレームを使った例

そこで考え出されたのが 上記のアップローダーを インラインフレーム内で呼び出すという方法です。
これにより呼び出し元の親ページでは画面更新が発生することなく連続してファイルをアップロードすることが出来るようにはなりました。

Firespeed では長くこの方法を使っていました。
それでも、インラインフレームは扱いが難しくユーザーにとっても開発者にとっても使いやすいとは言えない代物。

upload.html

<!DOCTYPE html>
<html lang="ja">

<body>
<iframe src="iframe.html">
</body>

</html

iframe.html

<!DOCTYPE html>
<html lang="ja">
<body>
<form action="api/upload.py">
<input type="file" name="image_file" />
<input type="submit" />
</form>
</body>
</html>

2 章 モダンな設計への変更

2-1 スマートフォンに対応

現在の技術を使えば上記の方法よりもずっと便利に使える画像アップローダーを作れるはずです。

ひとまず、スマートフォンに対応させましょう。
viewport を指定することでスマートフォン対応のサイトであることを宣言しページが極端に縮小されずに表示されるようになります。

upload.html

<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />

スタイルシートでは text-size-adjust:100% を指定して画面回転時のサイズ変更を抑制します。

upload.css

body {
text-size-adjust: 100%;
}

詳しい仕組みが知りたい方は以下の記事を参考にしてください。

未知の端末に対応するレスポンシブデザイン

inputタグも機能が増えており multiple を指定することで複数ファイルを選択可能となりました。
accept を指定すればユーザーがファイルを選択する時に許可するファイルタイプを指定出来るようになっています。
ファイルを選択する時点で、対応可能なファイルだけに絞り込まれた状態で表示されるので選択しやすくなります。
必ず指定しておきたいですね。

upload.html

<input type="file" name="image_file" id="image_file" multiple accept="image/jpeg, image/gif, image/png" />

2-2 JavaScript による POST

POST をフォームではなく JavaScript で送信するようにします。これでファイルをアップロードするたびに画面遷移を行う必要がなくなります。
もちろん、インラインフレームにする必要もなくなります。

JavaScript ファイルは upload.js というファイル名で作ることにします。
HTML ファイルを書き換え script タグで JavaScirpt ファイルを読み込みます。

upload.html

<!DOCTYPE html>
<html lang="ja">
...中略...
</body>
<script src="upload.js"></script>
</html>

upload.js でdocument.getElementById()により#image_fileを取得しimageFileFieldに格納します。
imageFileFieldchangeイベントをリッスンしてファイルが選択されたタイミングで処理を開始するようにします。

選択されたファイルはimageFileFieldfilesで取得できます。
ファイルは複数渡されることがありますが、filesにはforEachメソッドが無いため Array.from(files) として、ArrayforEachメソッドを使用します。
各ファイルごとにfileUpload() を呼びし、アップロード処理を呼び出します。

upload.js

const imageFileField = document.getElementById("image_file");
imageFileField.addEventListener("change", event => {
const files = imageFileField.files;
Array.from(files).forEach(file => {
fileUpload(file);
});
});

fileUpload()内では、fetch()を使ってファイルを POST します。
第 1 引数に URL、第 2 引数に送信するメソッドやデータを送ります。
fetch()Promiseを返すので非同期で実行されます、後続処理をthenに記載します。

結果を確認するために、showResultに結果を渡し、showResult内でとりあえずコンソールに出力してみます。

upload.js

function fileUpload(file) {
Promise.resolve(file)
.then(fetch("/api/upload.py", { method: "POST", body: file }))
.then(showResult)
.catch(showResult);
}

function showResult(result) {
return new Promise(resolve => {
console.log(result);
});
}

2-3 アップロードした結果を表示

これまでの方法で複数ファイルのアップロード処理は実装できたのですが、画面遷移が行われなくなり、結果はコンソールに出力されるようになったため、ユーザーにとってはファイルアップロードが行われたのかどうかを確認しづらくなりました。

そこで、アップロードを行った画像を一覧で表示するようにします。
画像の一覧を表示する領域としてdivを追加します。

upload.html

<div id="upload_list" class="gallery"></div>

スタイルシートに gallery のスタイルを設定します。
width: 100pxheight: 100pxを指定し、object-fit: cover;を指定することで画像を100x100に縮小して中央部分をクロップして表示されるようになります。

クロップを行うため、実際にアップロードされたファイルと見た目が変わってしまいますが、このあたりはお好みで

upload.css

.gallery > * {
margin: 2px;
display: inline-block;
width: 100px;
height: 100px;
text-align: center;
position: relative;
}

.gallery img {
width: 100%;
height: 100%;
object-fit: cover;
position: relative;
}

fileUpload()にローカルのファイルをDataURLとして取得するためにloadImage()メソッドを追加します。
画像を #upload_list に出力しつつ、アップロードも並行して行うために Promise.all()を使い、showPreview()でプレビューしつつ同時に fetchでアップロードします。

upload.js

function fileUpload(file) {
Promise.resolve(file)
.then(loadImage)
.then(dataUrl => {
return Promise.all([showPreview(dataUrl), fetch("/api/upload.py", { method: "POST", body: file })]);
})
.then(showResult)
.catch(showResult);
}

loadImage()FileRader#readAsDataURL() を呼びファイルを開きそれを使用するための URL を取得します。

upload.js

function loadImage(file) {
return new Promise(resolve => {
const reader = new FileReader();
reader.onload = element => {
resolve(element.target.result);
};
reader.readAsDataURL(file);
});
}

showPreview()では、new Image()imgを作りsrcDataURLを設定します。
これでローカルの画像ファイルがimgに表示されます。
div.frameを作りimgdivに詰めて、そのdivuploadListに追加しています。

div.frameが余計に見えるかもしれませんが、次章で使います。

upload.js

function showPreview(dataUrl) {
return new Promise(resolve => {
const frame = document.createElement("div");
const image = new Image();
image.src = dataUrl;
frame.append(image);
uploadList.append(frame);
resolve(frame);
});
}



2-4アップロード状態を表示

2-3でアップロードしたファイルを表示することが出来たように見えますが、これは正確では有りません。

2-3でのプレビュー表示はアップロードと並行して行っているため、アップロードがまだ終わっていない画像も表示されてしまいます。
そこでファイルがまだアップロード中のときはそれとわかるようにします。

アップロード中の画像にはworkingクラスを設定するようにします。
showPreview()で2-3のときに追加したdiv.frameclassworkingを追加します。

upload.js

function showPreview(dataUrl) {
return new Promise(resolve => {
const frame = document.createElement("div");
const image = new Image();
image.src = dataUrl;
frame.classList.add("working");
frame.append(image);
uploadList.append(frame);
resolve(frame);
});
}

アップロードが終わった画像はworkingをアップロードが完了したことがわかるようにします。
showResult()workingクラスを外しします。
今回はcatchでもshowResult()を呼んでいるため正常系もエラー系も同じように動くようにしているのでご注意ください。
より使いやすくするにはshowError()を作りエラーの場合はそれとわかる表示に変えるのも有りだと思います。

upload.js

function showResult(result) {
return new Promise(resolve => {
console.log(result[1]);
result[0].classList.remove("working");
});
}

スタイルシートで.workingのスタイルを作成しましょう。

blurをつけてぼやかし

upload.css

.gallery .working img {
filter: blur(2px);
}

さらに、半透明の白を乗せてベールがかかったように演出します。

UPLOADING の文字も重ねて表示することで何が起きているのかわかりやすくします。

upload.css

.gallery .working::after {
content: "UPLOADING";
font-size: 10px;
line-height: 100px;
text-align: center;
display: block;
background: rgba(255, 255, 255, 0.8);
width: 100px;
height: 100px;
position: absolute;
top: 0px;
}



2-5 画像サイズの縮小

ブロードバンド全盛の時代と違い、現在はギガを大切にしないといけない時代です。
それに対してスマートフォンもデジカメも数千万から億画素の高解像度時代となりファイルサイズは膨大になりつつあります。

そこで、ファイルをアップロードする前にクライアント側で画像の縮小と再圧縮を行いファイルをコンパクトにしてアップロードを行います。

ユーザーにとってもメリットがありますし、運営者にとってはサーバーの負荷を減らせるメリットがあります。
コンパクト化の処理はresize()メソッドを作りそこで行います。

loadImageの後に、resize を呼ぶようにします。
resize 後のデータはdataUrlではなくblobを返すようにするので引数の名前も変えておきます。

upload.js

function fileUpload(file) {
Promise.resolve(file)
.then(loadImage)
.then(resize)
.then(blob => {
return Promise.all([showPreview(blob), fetch("/api/upload.py", { method: "POST", body: blob })]);
})
.then(showResult)
.catch(showResult);
}

アップロードした画像と同じ形式で再圧縮を行うためにloadImage()で画像形式(file.type)も取得するようにします。

upload.js

function loadImage(file) {
return new Promise(resolve => {
const reader = new FileReader();
reader.onload = element => {
const img = new Image();
img.onload = () => {
resolve({ img: img, filetype: file.type });
};
img.src = element.target.result;
};
reader.readAsDataURL(file);
});
}

resize()では最初に変更後の画面サイズを決定します。
今回は長辺を 1200px として、縦横比を維持したまま縮小するようにしています。
長辺が 1200px に満たない場合は画像の縮小は行わず再圧縮のみを行います。

upload.js

const MAX_SIZE = 1200;
const JPEG_QUALITY = 0.6;

function resize(loadedImage) {
return new Promise(resolve => {
const img = loadedImage["img"];
var resizedWidth;
var resizedHeight;
if (MAX_SIZE > img.width && MAX_SIZE > img.height) {
resizedWidth = img.width;
resizedHeight = img.height;
} else if (img.width > img.height) {
const ratio = img.height / img.width;
resizedWidth = MAX_SIZE;
resizedHeight = MAX_SIZE * ratio;
} else {
const ratio = img.width / img.height;
resizedWidth = MAX_SIZE * ratio;
resizedHeight = MAX_SIZE;
}
// つづく

画像の縮小はcanvasに描画することで行います。
リサイズ後のサイズのcanvasを作成し、drawImage()で元画像の全体を縮小後のサイズで描画します。

upload.js


const canvas = document.createElement("canvas");
canvas.width = resizedWidth;
canvas.height = resizedHeight;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, resizedWidth, resizedHeight);
// つづく

縮小された画像が描画されているcanvastoBlob()を呼ぶことでファイルを再圧縮できます。
今回は 0.6 としていますが、旅行記やレビューブログなどではこれくらいで十分じゃないかと思っています。

カメラのレビューや写真作品を紹介するときはもう少し圧縮度を抑えて 0.7〜0.9 くらいでもいいかも

なお、Firespeed ではアップロード後にサーバーサイドで低解像度端末用の更に圧縮された画像を生成しています。

upload.js


var quality;
const filetype = loadedImage["filetype"];
if (filetype == "image/jpeg") {
quality = JPEG_QUALITY;
} else {
quality = 1;
}
canvas.toBlob(
blob => {
resolve(blob);
},
filetype,
quality
);
});
}

2-6 EXIF 情報を見て画像を回転

実は2 − 5には スマートフォンなどでよくある 縦向きの写真も横向きの写真も撮れるカメラの場合に正しく向きが反映されないという重大な問題があります。
これは、それらのカメラでは写真の向きを縦向きと横向きを代えるのに、実際にはどちらの場合でも横向きで記録し、傾きセンサーから受け取った値を EXIF に格納するという仕組みのためです。

通常はビューアーがこの Exif 情報を見て正しい向きに直して描画します。
ところが、縮小のためにCanvasdrawすることでビットマップの情報しか含まれなくなり、Exif 情報はすべて失われるため、画面の向き情報も失われてしまいます。

そこで、Exif 情報に頼らずに正しい向きで描画を行えるようにするために、事前に Exif 情報を読み込み情報に応じて適切に画像を回転させる必要があります。
今回は Exif 情報の読み込みのためにExif.jsを使用します。

参考 Exif.js Exif に設定されている回転情報に対応した input 要素(画像)のプレビュー

upload.html

<script async src="https://cdn.jsdelivr.net/npm/exif-js"></script>

Exif.js は ArrayBuffer の情報を読み込むため、base64 を ArrayBuffer に置き換えます。

upload.js

function base64ToArrayBuffer(base64) {
base64 = base64.replace(/^data\:([^\;]+)\;base64,/gim, "");
var binaryString = atob(base64);
var len = binaryString.length;
var bytes = new Uint8Array(len);
for (var i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}

画像の角度はラジアンで指定する必要があるため、角度に PI/180 を掛けてラジアンに変換します。

そのための定数を用意しておきます

upload.js

const TO_RADIANS = Math.PI / 180;

画像の回転はresize処理に含めてしまいます。
Exif 情報の、Orientationを取得します。
Orientationには 1〜8 の値が入ります。
それぞれの角度に 2 つの値が割り振られているのはインカメラなどで左右反転した場合に備えてのことです。

今回はそこは意識せずに、反転したものはそのままにしておこうと思います。

  • 3,4 なら上下反転
  • 6,5 なら右に 90 度
  • 8,7 なら左に 90 度
  • 1,2 なら回転なし
    とします。

upload.js

function resize(loadedImage) {
const img = loadedImage["img"];
return new Promise(resolve => {
var arrayBuffer = base64ToArrayBuffer(img.src);
const exif = EXIF.readFromBinaryFile(arrayBuffer);
if (exif && exif.Orientation) {
switch (exif.Orientation) {
case 3:
case 4:
rotate = 180;
break;
case 6:
case 5:
rotate = 90;
break;
case 8:
case 7:
rotate = -90;
break;
default:
rotate = 0;
}
} else {
rotate = 0;
}
var resizedWidth;
var resizedHeight;
if (MAX_SIZE > img.width && MAX_SIZE > img.height) {
resizedWidth = img.width;
resizedHeight = img.height;
} else if (img.width > img.height) {
const ratio = img.height / img.width;
resizedWidth = MAX_SIZE;
resizedHeight = MAX_SIZE * ratio;
} else {
const ratio = img.width / img.height;
resizedWidth = MAX_SIZE * ratio;
resizedHeight = MAX_SIZE;
}
// つづく

回転の向きに応じてキャンバスの縦横を反映させます。

upload.html


const canvas = document.createElement("canvas");
if (rotate == 90 || rotate == -90) {
canvas.height = resizedWidth;
canvas.width = resizedHeight;
} else {
canvas.width = resizedWidth;
canvas.height = resizedHeight;
}
// つづく

求めた角度に応じてキャンバスを回転させます。
回転の中心座標は標準では 0,0 になっているのですが、これだと、あまり直感的ではないので
一回キャンバスの中心に座標を移動して回転させています。

90 or -90 度回転した場合、元の座標に戻す際に縦横の移動幅が逆転するのに注意が必要です。
今回はキャンバスを回転後のサイズで作成したので、最初はキャンバスの座標をもとに移動して回転、その後元画像を元に座標を戻しています。


const ctx = canvas.getContext("2d");
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(rotate * TO_RADIANS);
ctx.translate(-resizedWidth / 2, -resizedHeight / 2);
ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, resizedWidth, resizedHeight);
var quality;
const filetype = loadedImage["filetype"];
if (filetype == "image/jpeg") {
quality = JPEG_QUALITY;
} else {
quality = 1;
}
canvas.toBlob(
blob => {
resolve(blob);
},
filetype,
quality
);
});
}

これで、画像を縮小してアップロードが可能になりました。

ここから先は見た目や使い勝手を改善していきます。

2-7 アップロードボタンのデザイン編集

標準の<input type=file>の UI はあまりおしゃれとは言い難いです。

ブラウザにより実装が大きく変わるのもあまり嬉しくないところです。
そこでスタイルシートにより作り直してしまいます。
今回は画像が並ぶのでそれに合わせて画像サイズの アップロードボタンを作成し画像と並べて表示するようにしてみます。

ところが、 <input type=file> はどうしたことかスタイルシートの修正がほとんど不可能です。
そこで、<input type=file>を非表示にして<label>を使います。

inputlabelでかこい、labeldiv#upload_listの中に移動します。

ラベルの内容については今回はmaterial iconの Web フォントを使わせてもらっています。

追加の css を読み込むだけで Google がホストする Material icon の Web フォントが使用可能です。
upload.html

<link rel="stylesheet" href="upload.css" />

add_photo_alternateが適任そうだったので、それを適用します。

upload.html

<div id="upload_list" class="gallery">
<label class="input_image_file">
<i class="material-icons">add_photo_alternate</i>
<input type="file" name="image_file" id="image_file" multiple accept="image/jpeg, image/gif, image/png" />
</label>
</div>

css で<input type="file">を非表示にします。

これで<label>のみしか表示されなくなりますが、<label>をクリックすることで<inputy type="file>をクリックしたのと同じ動きが行われるので実質的には<inputy type="file">の見た目をlabelに変更したのと同じ動きとなります。

upload.css

.input_image_file input[type="file"] {
display: none;
}

.input_image_fileに対してそれぞれ標準時、カーソルが乗っている時、クリック時のスタイルを適用します。
最近のタッチパネルではhoverが発動しにくいためhoverなしの状態でそれなりにクリック可能であることを示しておく必要があります。

upload.css

.input_image_file {
background: rgba(0, 0, 0, 0.1);
color: rgba(0, 0, 0, 0.7);
box-shadow: 0 2px 8px;
transition-duration: 0.4s;
}
.input_image_file:hover {
background: rgba(228, 100, 30, 0.1);
color: rgba(228, 100, 30, 0.8);
transition-duration: 0.2s;
}
.input_image_file:active {
background: rgba(255, 60, 10, 0.1);
color: rgba(255, 60, 10, 0.8);
transition-duration: 0s;
}



2-8 アップロードした結果の順番を変更

append()でアイテムを追加した場合、アイテムは項目の最後尾に追加されるので、
アップロードボタン、最初にアップロードしたボタン、二度目にアップロードしたボタン というようにアップロードを繰り返すたびにアップロードボタンとアップロードされたボタンの距離が離れていきます。
そこで、画像の追加をappend()はなくinsertBefore()を使い最後に追加された画像がimageFileLabelの直後に追加されるようにします。

upload.js

function showPreview(blob) {
return new Promise(resolve => {
const dataUrl = URL.createObjectURL(blob);
const frame = document.createElement("div");
const image = new Image();
image.src = dataUrl;
frame.classList.add("working");
frame.append(image);
uploadList.insertBefore(frame, imageFileLabel.nextSibling);
resolve(frame);
});
}

2-9 ドラッグアンドドロップに対応

ファイルアップロードはクリックだけでなくドラッグアンドドロップでもできると便利です。

ドロップ可能とする項目のaddEventListener()を呼びdragoverevent.preventDefault() を呼び、デフォルトの動作をキャンセルし、dropEffectcopyを指定します。

upload.js

uploadList.addEventListener("dragover", event => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
uploadList.classList.add("dragover");
});

次にdropにもListenerを追加して同様にevent.preventDefault()を呼び、
event.dataTransfer.filesにドロップされたファイルの情報が格納されているので<input type="file">のときと同様にforEachで各ファイルを引数にfileUpload()を呼びます。

upload.js

uploadList.addEventListener("drop", event => {
event.preventDefault();
Array.from(event.dataTransfer.files).forEach(file => {
fileUpload(file);
});
});

これだけでもファイルアップロードは可能なのですがドラッグしてドロップする前の状態でファイルアップロードが可能であることを示すためのリアクションがほしいところです。
dragoverでエフェクトを掛けるためにdragoverクラスを追加します。
ドロップ完了時dropそれに、ドラッグをやめたときのdragoverでそのクラスを削除します。

upload.js

uploadList.addEventListener("dragover", event => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
uploadList.classList.add("dragover");
});
uploadList.addEventListener("dragleave", event => {
event.preventDefault();
uploadList.classList.remove("dragover");
});
uploadList.addEventListener("drop", event => {
event.preventDefault();
uploadList.classList.remove("dragover");
Array.from(event.dataTransfer.files).forEach(file => {
fileUpload(file);
});
});

あとは css で.dragover に対してエフェクト処理を行います。

upload.css

.dragover {
background: rgb(255, 228, 200);
}


出来上がり

最終的な完成形は次の通り
Github

Form を作るだけに加えて随分と手順が増えてしまいますが、これによりユーザーは使いやすくサーバーの負荷は少ないファイルアップローダーを作ることができます。

前:北京に行ってきました(二日目) 

コメントを投稿する