この記事は 株式会社 ACCESS Advent Calendar 2019 11 日目の記事です。
こんばんは! @diescake です。
2 日前の IndexedDB を単一のデータを格納するストレージとして扱うラッパー実装 に少し関連するんですが、今回は、ローカルの画像を読みだした際に、ブラウザ上で表示する実装を紹介します。
こちらも、へーしゃのお仕事で必要になりそうだったので、
プライベートの時間で実装検証したものを別リポジトリに整理しました。
特に問題なく扱えているので紹介します。
リポジトリ
- load-local-images
なぜ必要になったか?
ローカルの選択した画像をブラウザ上でプレビュー表示するというユースケースがありました。
具体的には、Twitter や Facebook のメッセージ投稿機能をイメージして貰うとわかりやすいと思います。
このとき、写真添付ボタンをクリックしてローカルの写真を選択すると、選択した画像が投稿画面内にサムネイル表示されると思います。
ローカルファイルへアクセスするためには、<input type="file" />
要素を利用する必要があります。
ここで、選択した画像をそのまま POST することは難しくはありませんが、ブラウザ上で表示するには一工夫が必要です。
ちなみに、その他にドラッグ&ドロップという手段もありますが、今回は要件に合わなかったため除外して考えています。
実装の紹介
リポジトリを見て貰っても良いですが、然程長くないので以下に掲載します。
<!-- 前略 -->
<body>
<h1>Load local images through input elements.</h1>
<div id="root"></div>
<input id="file-input" type="file" multiple />
<label class="button-add" for="file-input">+</label>
</body>
const fetchDataUrl = (file: File) =>
new Promise<string>((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(typeof fr.result === "string" ? fr.result : "");
fr.onerror = err => reject(err);
fr.readAsDataURL(file);
});
const appendNewImage = (dataUrl: string) => {
const e = document.createElement("img");
e.src = dataUrl;
document.body.appendChild(e);
};
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
input.addEventListener("change", async () => {
if (!input.files) {
console.error("no files");
return;
}
const files = Array.from(input.files);
const dataUrls = await Promise.all(files.map(fetchDataUrl)).catch(() => []);
dataUrls.forEach(appendNewImage);
});
ポイントは fetchDataUrl
関数の実装ですね。
fetchDataUrl の中で input 要素から受け取った File の配列を渡して FileReader#readAsDataURL に食わせています。
FileReader の interface は古き良きコールバック(あるいはイベントリスナ)をセットするスタイルで使いづらいので Promise を返すようにラップしています。
ちなみに、Chrome 76 からは fetchDataUrl のように Promise を返すラップ実装をせずとも、ネイティブで Promise を返す API が実装されています。
- Reading blobs is easier
このリリースノートでは blob と記述されていますが、Blob は File の親クラスであるため、file に対してもこれらの API は呼び出しが可能です。
ただし、ブラウザのサポート状況はまだ弱いので、広いブラウザサポートが不要であったり、ポリフィルが効く状況でなければ従来通り、FileReader#read*
系の関数をを Promise でラップして利用するのが無難に思います。
その後、Promise.all で全ての fetchDataUrl の返した Promise の解決を待ってから、ブラウザ上に表示して終わりです。
最後に、input
要素の属性に multiple
を指定しています。
この属性を指定すると、ユーザはファイルを選択する際に複数選択が可能となります。
単純ですが、ユーザビリティ上非常に重要なので忘れないようにしたいところです。
ローカルでバリデーションする際の実装が若干面倒になるけど……。
パフォーマンス上の注意点
少し試した範囲ですが Android 実機上の Chrome ブラウザではファイルの読み出しに非常に時間がかかる(写真数枚で10秒に達するくらい)ことを確認しました。
(実機や OS のバージョン明確ではないのですが、2019/8 月頃で、それなりに新しい Android 実機と OS バージョン)
iOS Safari (iPhone XS Max / iOS11)と比較すると 5 倍以上読み込み時間に開きが出ました。
なので、Android の場合は、複数枚画像をアップロードする場合は、実用的なレベルに達しない可能性があります。
DEMO のページを各種端末のブラウザで開いて試してみるとわかると思います。
従って、Android のサポートが必要な場合は、プレビュー表示を諦めたり、あるいは、ファイル選択時に先行して S3 などのファイルサーバにアップロードしてしまい、そのアップロード先 URL を <img src=...
で開くような仕組みを検討するのが良さそうです。
Qiita や GitHub issues などはこちらの仕組みを利用していますね。
さて、次回は、
――ええ、遠慮はいらないわ。
全てのバグを潰してやって、 @illypong