📁

FileReader を Promise で扱いやすくするライブラリを作った

2023/08/23に公開

はじめに

こんにちは、株式会社TERASSの myrear です。
あるサービスの開発で FileReader を使うことがあったのですが、いくつかの点で扱いづらさを感じました。
そこで扱いやすくするための小さいライブラリを作ったのでご紹介します。
結論だけ知りたい!という方はリポジトリをご覧ください。
スターを付けてくれると喜びます⭐

https://github.com/myrear/file_reader_promise

FileReader とは

FileReader とはざっくり言うと FileBlob を読み取るための Web API です。
以下 MDN より抜粋です。

FileReader オブジェクトを使用すると、ウェブアプリケーションは、ユーザーのコンピューターに保存されているファイル(または生のデータバッファー)の内容を非同期に読み取ることができます。File または Blob オブジェクトを使用して、読み込むファイルまたはデータを指定します。

FileReader の扱いづらさ

まずは FileReader のどういったところが扱いにくいと感じたのかをサンプルコードとともに見ていきます。
以下は input[type="file"] な要素から選ばれた画像を img 要素に表示する React コンポーネントの例です。

export const ImageInput = () => {
  const [dataUrl, setDataUrl] = React.useState<string>()
  const onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
    const file = e.target.files?.item(0)
    if (!file) return

    const reader = new FileReader()
    reader.readAsDataURL(file)
    reader.onload = () => setDataUrl(reader.result as string)
  }

  return (
    <>
      <input type="file" onChange={onChange} />
      <img src={dataUrl} />
    </>
  )
}

簡単に処理の流れを追うと、

  1. 選択されたファイルを取得
  2. FileReader インスタンスの作成
  3. readAsDataURL メソッドでファイルの読み込みを開始する
  4. load イベントを待ち受けて result をステートに保持する

となっています。
最低限の処理だけですが雰囲気だけ掴んでもらえればOKです。

Promise ではない

1つ目のイマイチポイントとして Promise ではないことが挙げられます。
今回の例では readAsDataURL を使っていますが、その他にも FileReader には readAsArrayBuffer readAsBinaryString readAsText とデータを読み込むためのメソッドが全4種類あります。
これらすべて戻り値は void であり、データの読み込みが完了したことを知るためには load イベントを別途監視しなければなりません。
もし戻り値が load イベント発火により解決する Promise であれば、処理を一つにまとめられそうです。

- reader.readAsDataURL(file)
- reader.onload = () => setDataUrl(reader.result as string)
+ reader.readAsDataURL(file).then(() => setDataUrl(reader.result as string))

result の型が曖昧

2つ目のイマイチポイントは result の型の曖昧さです。
FileReader.result の型は string | ArrayBuffer | null と定義されています。

https://github.com/microsoft/TypeScript/blob/83efc9f0d646bf86a3469e00c5ef5e4f7ab7cb95/lib/lib.dom.d.ts#L5100

というのも呼び出される readAs... メソッドによって result が変化するからですね。
読み込みが完了するまでは null なのでこの型なのも納得はできます。
ただ、今回の例のように readAsDataURL で読み込んだ場合は string として扱いたいのに、型が曖昧だと型アサーションなどで対応しなければならず少し面倒です。
load イベント発火により対応する型の値で解決する Promise にできれば取り回しやすそうです。

- reader.readAsDataURL(file).then(() => setDataUrl(reader.result as string))
+ reader.readAsDataURL(file).then(setDataUrl)

扱いやすくするためには

以上を踏まえると下記2点を満たすことができれば扱いやすくなりそうです。

  • readAs... メソッドは load イベントの発火に合わせて解決する Promise とする
  • 呼び出された readAs... メソッドに対応した型の値で Promise を解決する

というわけで、冒頭でも紹介しましたがこれらを満たすライブラリを作りました。

https://github.com/myrear/file_reader_promise

このライブラリは readAs 関数と FileReader クラスの2種類のモジュールを提供します。

readAs

本ライブラリのコア部分とも言える関数です。
オーバーロードによって上述の要件を表現しています。

https://github.com/myrear/file_reader_promise/blob/main/src/readAs.ts

load イベントでの解決の他に errorabort イベントでのリジェクトにも対応しています。
つまり FileReader.abort() が呼び出されたり読み込みに失敗すると Promise はリジェクトします。

FileReader

本ライブラリの本体です。
とはいっても各種 readAs... メソッドをオーバーライドして上記の readAs 関数を呼び出すようにしただけの薄いラッパークラスです。

https://github.com/myrear/file_reader_promise/blob/main/src/FileReader.ts

globalThis.FileReader つまり Web API の FileReader を継承したクラスなので既存のコードベースを大きく変更することなく使えると思います。

適用してみる

上の方に挙げたコード例を本ライブラリを使って書き換えてみます。

+ import { FileReader } from '@myrear/file_reader_promise'

export const ImageInput = () => {
  const [dataUrl, setDataUrl] = React.useState<string>()
  const onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
    const file = e.target.files?.item(0)
    if (!file) return

    const reader = new FileReader()
-   reader.readAsDataURL(file)
-   reader.onload = () => setDataUrl(reader.result as string)
+   reader.readAsDataURL(file).then(setDataUrl)
  }

  return (
    <>
      <input type="file" onChange={onChange} />
      <img src={dataUrl} />
    </>
  )
}

readAsDataURLonload を別々に書いていたのが一つになり、 as による型アサーションも消えてコードの見通しが良くなりました。

類似ライブラリとの比較

実は似たようなことをやっているライブラリは(筆者が把握している限りでは)3種類あります。
簡単にですがそれぞれとの比較をしてみます。

promise-file-reader

https://github.com/jahredhope/promise-file-reader

readAsDataURL readAsText readAsArrayBuffer の3種類の関数をエクスポートしています。

  • readAsBinaryString に非対応
  • readAsText の文字コードが指定できない
  • 引数に渡せるのは Blob のみ
  • FileReader インスタンスを内部で作成・保持しておりアクセスできない( abort などができない)

@tanker/file-reader

https://github.com/TankerHQ/sdk-js/tree/master/packages/file-reader

Web API の FileReader に酷似した FileReader クラスをエクスポートしています。

  • コンストラクタ引数で File を受け取ることが可能
  • Web API の FileReader を継承したクラスではない( abort メソッドは実装している模様)
  • ArrayBuffer のチャンク単位での読み込みに対応

file-reader-promise

https://github.com/En777/file-reader-promise

FileReader ライクなオブジェクトをエクスポートする CommonJS ライブラリです。

  • 型定義ファイルがない
  • Promise をイベントオブジェクトと result のどちらで解決するかを設定できる
  • FileReader インスタンスを内部で作成・保持しておりアクセスできない( abort などができない)

おわりに

以上、 FileReader を扱いやすくするためのライブラリを作成・紹介しました。
実は Node ではなく Deno で作成したのですが、 Deno での開発体験は結構快適でした。
Deno で開発をする上で得た知見はまた別の機会に共有できればと思います。
最後までお読みいただきありがとうございました。


雑談

本題とは全く関係のない話になりますが、弊社には労働環境構築のために最大10万円の購入費が支給される Best Productivity という制度があります。(その他詳細はこちら)
筆者は最近この制度を利用しまして、 Kinesis Advantage360 Keyboard を購入いたしました。
パームレスト込でだいたい85000円くらい(!)でした。

初めての分割キーボードどころかこれまでずっとラップトップで生きてきた人間なので慣れるまでが大変でした(届いて2週間近く経った今でもタイポが多いです)が、肩が縮こまらず使えるので肩への負担を抑えられてかなり満足しています。
以上、人生初の高級キーボードを自慢したかっただけの雑談でした。

Terass Tech Blog

Discussion