ローカルの画像を読み込み、ブラウザ上でプレビュー表示する

この記事は [株式会社 ACCESS Advent Calendar 2019](https://qiita.com/advent-calendar/2019/access) 11 日目の記事です。

こんばんは！ @diescake です。

2 日前の [IndexedDB を単一のデータを格納するストレージとして扱うラッパー実装](https://qiita.com/diescake/items/bdf5a3096443209b3065) に少し関連するんですが、今回は、ローカルの画像を読みだした際に、ブラウザ上で表示する実装を紹介します。

こちらも、へーしゃのお仕事で必要になりそうだったので、
プライベートの時間で実装検証したものを別リポジトリに整理しました。
特に問題なく扱えているので紹介します。

## リポジトリ

- load-local-images
  - https://github.com/diescake/load-local-images
  - [DEMO](https://load-local-images.netlify.com/)

## なぜ必要になったか？

ローカルの選択した画像をブラウザ上でプレビュー表示するというユースケースがありました。

具体的には、Twitter や Facebook のメッセージ投稿機能をイメージして貰うとわかりやすいと思います。
このとき、写真添付ボタンをクリックしてローカルの写真を選択すると、選択した画像が投稿画面内にサムネイル表示されると思います。

ローカルファイルへアクセスするためには、`<input type="file" />` 要素を利用する必要があります。
ここで、選択した画像をそのまま POST することは難しくはありませんが、ブラウザ上で表示するには一工夫が必要です。

ちなみに、その他にドラッグ&ドロップという手段もありますが、今回は要件に合わなかったため除外して考えています。

## 実装の紹介

リポジトリを見て貰っても良いですが、然程長くないので以下に掲載します。

```html:HTML
<!-- 前略 -->
<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>
```

```ts:TypeScript
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
  - https://developers.google.com/web/updates/2019/07/nic76#reading_blobs_is_easier

このリリースノートでは blob と記述されていますが、Blob は File の親クラスであるため、file に対してもこれらの API は呼び出しが可能です。

ただし、[ブラウザのサポート状況はまだ弱い](https://developers.google.com/web/updates/2019/07/nic76#reading_blobs_is_easier)ので、広いブラウザサポートが不要であったり、ポリフィルが効く状況でなければ従来通り、`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](https://load-local-images.netlify.com/) のページを各種端末のブラウザで開いて試してみるとわかると思います。

従って、Android のサポートが必要な場合は、プレビュー表示を諦めたり、あるいは、ファイル選択時に先行して S3 などのファイルサーバにアップロードしてしまい、そのアップロード先 URL を `<img src=...` で開くような仕組みを検討するのが良さそうです。

Qiita や GitHub issues などはこちらの仕組みを利用していますね。

さて、次回は、

――ええ、遠慮はいらないわ。
　　全てのバグを潰してやって、 @illypong