JavaScript FileReader API でブラウザでファイルを扱う (TypeScript 対応)
ブラウザの FileReader API を使用したファイルの扱いについて TypeScript での書き方を含めて紹介します。
やりたいこと
ファイルアップロード時に UX 向上のために、ファイルをサーバ送信する前にプレビュー表示したり、ブラウザ側でファイルの内容を検証したりとファイルを取り扱いたいことが増えてきていると思います。
ブラウザ側 JavaScript でファイルを開くにはブラウザの FileReader API (FileReader - Web API | MDN) を使用します。
今回はその FileReader API の使い方と TypeScript での書き方、使いやすくしてみた例やこちらの記事で紹介した Result 型を使った書き方を紹介します。
FileReader の使い方
基本的な使い方
<input type="file"> で選択した画像ファイルをプレビュー表示する例を以下に示します。
<input type="file" accept="image/*" />
<img />
<script>
const reader = new FileReader();
reader.addEventListener("load", ({ target }) => {
document.querySelector("img").src = target.result;
});
document
.querySelector("input[type=file]")
.addEventListener("change", ({ target }) => {
if (target.files.length > 0) reader.readAsDataURL(target.files[0]);
});
</script>まず new FileReader() で FileReader のインスタンスを生成しています。
次に FileReader インスタンスに load イベントのイベントハンドラを設定しています。
load イベントは FileReader が正常にファイルを読み取れたときに発生するイベントです。
FileReader: load イベント
loadイベントは、ファイルが正常に読み込めたときに発生します。
- FileReader: load イベント - Web API | MDN
他に正常に読み込めたかどうかによらず完了時に発生する loadend イベントもあります。
FileReader: loadend イベント
loadend イベントは、ファイル読み込みが、成功したかどうかにかかわらず完了したときに発生します。
- FileReader: loadend イベント - Web API | MDN
load イベントのコールバック関数が受け取るイベントターゲット(ここでは分割代入している target)は FileReader インスタンスで、target.result すなわち FileReader.result を img 要素の src 属性に設定しています。
FileReader.result には読み込まれたファイルの内容が格納されています。
FileReader.result
FileReaderのresultプロパティは、ファイルの内容を返します。このプロパティは、読み取り操作が完了した後でのみ有効で、データの形式は、読み取り操作を開始するために使用されたメソッドによって異なります。
- FileReader.result - Web API | MDN
最後に <input type="file"> が change イベントを発生させた際に FileReader インスタンスの readAsDataURL() メソッドに選択されたファイルを渡しています。これでファイル読み込みが完了すると先ほどの load イベントが発生するという動きです。
FileReader のファイル読み込みメソッドは他に ArrayBuffer として読み込む readAsArrayBuffer() (FileReader.readAsArrayBuffer() - Web API | MDN)、バイナリ文字列として読み込む readAsBinaryString() (FileReader.readAsBinaryString() - Web API | MDN) 、文字列として読み込む readAsText() (FileReader.readAsBinaryText() - Web API | MDN) がありますが、今回は data URL として読み込む readAsDataURL() メソッドを使用します。
FileReader.readAsDataURL()
readAsDataURLメソッドは、指定されたBlobまたはFileの内容を読み込むために使用されます。読み込み操作が終了すると、readyStateがDONEとなり、loadendが発生します。このとき、result属性には、ファイルのデータを表す、base64 エンコーディングされた data: URL の文字列が格納されます。
- FileReader.readAsDataURL() - Web API | MDN
その理由は img 要素の src 属性は URL であればよいため、ファイルのロケーションを示す URL ではない、URL がファイルのデータである data URL をそのまま指定して使えるからです。
例えばアップロードする CSV ファイルをプレビュー表示するといったユースケースでは readAsText() を使います。
TypeScript で書いてみる
さて先ほどのコードをそのまま TypeScript にしてみます。
なお、FileReader API や DOM を型推論するには tsconfig.json の compilerOptions.lib に DOM を含めるようにしてください。
{
"compilerOptions": {
/* : */
"lib": ["DOM"],
/* : */
}
}const reader = new FileReader();
reader.addEventListener("load", ({ target }) => {
document.querySelector("img")?.setAttribute("src", <string>target?.result);
});
document
.querySelector("input[type=file]")
?.addEventListener("change", ({ target }) => {
const files = (<HTMLInputElement>target).files;
if (files && files.length > 0) reader.readAsDataURL(files[0]);
});load イベントのイベントハンドラ内で target.result に <string> と型アサーションを入れていますが、これは FileReader の読み込みメソッドの種類によって string であったり ArrayBuffer だったりするためです。また、読み込み完了前は null になっています。実際 FileReader.result の型は以下のようになっています。
(property) FileReader.result: string | ArrayBuffer | null | undefined今回は readAsDataURL() で読み込みますので <string> と型アサーションを入れています。
余談ですが、querySelector("img") で取得した要素のイベントターゲットは HTMLImageElement に型推論されているため、型アサーションがありませんが、querySelector("input[type=file]") で取得した要素のイベントターゲットは HTMLInputElement と明示的に型アサーションをしています。この理由は こちらの記事 で解説していますが、querySelector() の引数の CSS セレクターが単純 HTML タグ名である場合はその要素の HTMLElement 継承インターフェースに型解決してくれるのですが、input[type=file] のように属性セレクタが付いていたりすると Element に型解決されてしまうからです。(まあ今回の場合、input 要素は一つしかないので属性セレクタ付けている意味はないのですが)
上記コードではイベントハンドラを addEventListener() 内に直接書いていましたが、別途定義した関数を渡す場合は、イベントターゲットに型推論が効かなくなるため、以下のようにイベントターゲットに <FileReader> 型アサーションを付けます。
const loadHandler = ({ target }: ProgressEvent) => {
document
.querySelector("img")
?.setAttribute("src", <string>(<FileReader>target)?.result);
};
reader.addEventListener("load", loadHandler);読み込みの進捗状況を取得する
FileReader は progress イベントを発行し、ファイルの読み込み進捗を取得することができます。
progress イベントによらず FileReader が発行するイベントは ProgressEvent です。
ProgressEvent
ProgressEventインターフェイスは、プロセスの進捗、例えば HTTP リクエスト(XMLHttpRequest、または<img>,<audio>,<video>,<style>,<link>のような基本的なリソースの読み込み)などを計測するイベントを表します。
- ProgressEvent - Web API | MDN
ProgressEvent が持っている以下の属性を使用して進捗をパーセンテージで表したり、プログレスバーを表現したりすることが可能です。
ProgressEvent.loaded読取専用
64 ビット符号なし整数値で、基礎となるプロセスで既に実行された作業の量を示す。ProgressEvent.total読取専用
64 ビット符号なし整数で、基礎となるプロセスが実行中の作業の総量を表す。
- ProgressEvent - Web API | MDN
以下は進捗をパーセンテージでコンソールに出すだけの簡単な例です。
reader.addEventListener("progress", ({ total, loaded }) =>
console.log(`${Math.round((loaded / total) * 100).toString()}%`)
);TypeScript でイベントハンドラー関数を別定義する場合は引数が ProgressEvent 型であることをアノテーションしましょう。
const progressHandler = ({ total, loaded }: ProgressEvent) => {
console.log(`${Math.round((loaded / total) * 100).toString()}%`);
};
reader.addEventListener("progress", progressHandler);もっと簡単に書けないの?
ここまでで FileReader の基本的な使い方を紹介しましたが、こう思いませんでしたでしょうか。
「ファイル読み込むだけなのにインスタンス化して、イベントリスナー設定してから読み込みとか面倒臭い」
私はこの面倒臭さどこかで感じたことがあり、XMLHttpRequest API と同じだと思い至りました。
昨今では XMLHttpRequest API を使うことはほぼなく、fetch API を使うことがほとんどだと思います。
その理由として XMLHttpRequest と fetch でもちろん機能的な違いもあるのですが、開発者体験としてはやはり fetch が簡単に使えるという点は小さくはないのではないでしょうか。
非同期関数化してみる
というわけで Promise でラップして XMLHttpRequest のように使いづらい FileReader を fetch のように使いやすくしてみます。
const readFileAsDataUrl = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", ({ target }) => resolve(target.result));
reader.addEventListener("error", ({ target }) => reject(target.error));
reader.readAsDataURL(file);
});FileReader が error イベントを発生させる際、エラー内容は FileReader.error プロパティーに DOMException 型として格納されています。
FileReader.error
FileReaderのerrorプロパティは、ファイルの読み取り中に発生したエラーを返します。 値DOMErrorに関連するエラーが含まれています。 Chrome 48 以降/Firefox 58 以降では、DOMErrorが DOM 標準から削除されているため、このプロパティはDOMExceptionを返します。
- FileReader.error - Web API | MDN
作成した readFileAsDataUrl() 関数の使い方は以下の通り。
document
.querySelector("input[type=file]")
.addEventListener("change", async ({ target }) => {
if (target.files.length > 0) {
const dataUrl = await readFileAsDataUrl(target.files[0]);
document.querySelector("img").setAttribute("src", dataUrl);
}
});progress イベントは使えなくなりますが、よほど大きなファイルでない限り 1 回目の progress イベントで 100% 達していることがほとんどなので、あまり使いどころはないと考え、捨てています。
TypeScript で書く
TypeScript にするとこんな感じになります。
特筆すべきところはあまりありませんが、readFileAsDataUrl 関数が (file: File) => Promise<string> 型であることを明示的にアノテーションするところでしょうか。
const readFileAsDataUrl: (
file: File
) => Promise<string> = (file: File) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", ({ target }) =>
resolve(<string>target?.result)
);
reader.addEventListener("error", ({ target }) => reject(target?.error));
reader.readAsDataURL(file);
});呼び出す側の TypeScript 版はこんな感じになります。
document
.querySelector("input[type=file]")
?.addEventListener("change", async ({ target }) => {
const files = (<HTMLInputElement>target).files;
if (files && files.length > 0) {
const dataUrl = await readFileAsDataUrl(files[0]);
document.querySelector("img")?.setAttribute("src", dataUrl);
}
});(おまけ) Result 型を返すようにしてみる
こちらの記事 で紹介した Result 型を使用してさらに改造してみます。
import { Ok, Err, type Result } from "./result";
const readFileAsDataUrl: (
file: File
) => Promise<Result<string, DOMException>> = (file: File) =>
new Promise((resolve) => {
const reader = new FileReader();
reader.addEventListener("load", ({ target }) =>
resolve(new Ok(<string>target?.result))
);
reader.addEventListener("error", ({ target }) =>
resolve(new Err(<DOMException>target?.error))
);
reader.readAsDataURL(file);
});
document
.querySelector("input[type=file]")
?.addEventListener("change", async ({ target }) => {
const files = (<HTMLInputElement>target).files;
if (files && files.length > 0) {
const dataUrl = await readFileAsDataUrl(files[0]);
if (dataUrl.isOk())
document.querySelector("img")?.setAttribute("src", dataUrl.value);
}
});あとがき
FileReader API の使い方やより簡単に使える方法を TypeScript も交えて紹介しました。従前よりフロントエンドが担う役割が増えてきており、ファイルを取り扱うことも多くなっています。FileReader API は使いやすいとは言い難いですが、使いやすい方法も紹介したのでお役に立てれば幸いです。