uhyohyo.net

JavaScript初級者から中級者になろう

十二章第五回 File API

このページの最終更新日:

次に、Web近代史(?)中で重要な役割を果たすFile APIについて紹介します。

これはその名の通りJavaScriptからファイルを扱うためのものです。今回は特に、ローカルのファイル、すなわちページを開いた利用者のパソコン(など)の中のファイルを扱う方法について紹介します。

ただし、自由にファイルを見られてはセキュリティも何もあったものではないので、ユーザーが認めたファイルのみ見ることができるという安全仕様です。

具体的には、JavaScriptでどのファイルを読み込めるのかをユーザーに選択してもらう必要があります。実は、HTMLにはこういうのがありましたね。


<input type="file">

これはinput要素で、アップロードするファイルを選択できるコントロールです。実は、このコントロールによって選択されたファイルはJavaScriptから読み込むことができます。

ファイルの取得

十二章第二回で紹介したとおりHTML5になってフォーム周りは大きく機能が強化されましたが、実はこのファイル選択においてもそうだったのです。

input要素から選択済みファイルを取得するには、このinput要素のfilesプロパティを調べます。このfilesプロパティに入っているのが、FileListオブジェクトです。

名前の通りリストですから、もしかしたら複数ファイルが選択されている可能性があるかもしれないのですね。

そして、このFileListオブジェクトは、もはやNodeList(getElementsByTagNameなどで出現)などなどでお馴染みのlengthプロパティitemメソッドを持っています。ピンと来ると思いますが、lengthがファイルの総数で、itemメソッドは数値を指定するとn番目のファイルを返してくれるというわけです。

ちなみに、 filelist.item(0) の代わりに、 filelist[0] という省略が可能なのも同じです。

さて、このようにして得たファイルの一つ一つを表すオブジェクトをFileオブジェクトといいます。

このFileオブジェクトというのはBlobオブジェクトの一種で(つまり、FileはBlobを継承しています)、ではBlobとは何かというとバイナリデータを表すオブジェクト、要するにファイルの中身そのものを表すオブジェクトです。

そして、ではFileオブジェクトはBlobオブジェクトと何が違うかというと、Blobオブジェクトの機能(これはあとで紹介します)に加えてnameプロパティlastModifiedDateプロパティを持っています。これは読んで字のごとく、前者はファイル名で後者は最終更新日です。

ファイル名は当然文字列で、あとの最終更新日というのはDateオブジェクトで返ってきます。

それでは一旦、ここまでをサンプルで振り返ります。サンプル

ソースを見てみると、さっそくファイル選択のinput要素があって、


onchange="change(event)"

とあります。onchangeということは変更されたときということですから、今回の場合はファイルが選択されたときですね。

そして問題のchange関数では、ev.target.filesを取得しています。この例ではev.targetが問題のinput要素ですね。

そして今回は最初のファイルに決め打ちして、console.logその名前を表示しているのです。

ちなみに、他にBlobのプロパティとして、size(そのファイルのバイト数)とtype(データのMIMEタイプ)があります。MIMEタイプはデータを扱うときに随所に現れる概念で、どんなデータかを表すものです。例えばHTMLファイルならば"text/html"というMIMEタイプになります。これはHTMLを表す文字列という意味です。PNGフォーマットの画像ファイルならば"image/png"となります。

ファイルの中身を取得する

上のサンプルではファイルの名前を取得しましたが、やはり我々が一番関心があるのはファイルの中身でしょう。そこで、次にファイル(あるいはBlob)の中身を取得する方法を紹介します。

そこで登場するのがFileReaderオブジェクトです。このオブジェクトがBlobの読み込みを担当します。

これの特徴は非同期読み込みであるということです。JavaScriptで非同期といった場合、意味するところはコールバックで結果を得るということです。(注:ご存知の読者のために補足しておくと、async/awaitはまだしばらく出てきません。)

例えば、読み込むためにはいくつは方法がありますが、まずはreadAsTextメソッドを紹介します。これはファイルの中身を文字列として取得することができます。テキストファイルを読み込むときに便利ですね。

ちなみに非同期の反対として同期というのもあります。同期ということは、その関数が全部処理をおこなってしまって、プログラム側は関数の処理が終わるまで待つということです。このようにプログラムが待たされることをブロックするといいます。ブロックする関数の典型的な例はalert(ユーザーがOKなどを押すまでプログラムは次に進まない)です。

ところが、今の時代同期的な処理は人気がありません。待ってる間はJavaScriptによる処理が一切行えないからです。非同期というのは、さっきも出てきたコールバックによって結果を出るのですが、要するにこれは「終わったら呼んでね」ということです。今回の場合、readAsTextを「終わったら呼んでね!」と言って呼び出して、自分はその後も悠々と他の処理を続けているわけです。それで、結果が出たら呼ばれます。

呼ばれるというのは、要するに関数です。「終わったらこの関数を呼んでね」ということです。

ところで、今までこのコールバックに似ているものを皆さんは何度も見ているとも言えます。それはイベントです。イベントは、発生したときにあらかじめ登録されていた関数(イベントハンドラ)が呼ばれるというものでした。コールバックとイベントの主な違いは、コールバックは基本的には1回だけ呼ばれるのに対し、イベントは何回でも発生する可能性が(少なくとも今まで見てきたようなイベントに関しては)あるという点です。逆に言えば、1回だけ発生するイベントがコールバックであるという見方もできます。

さて、そんなことを考えつつ、いよいよFileReaderを見て行きましょう。

これを使うには、まずFileReaderのインスタンスを作ります。


var reader=new FileReader();

次に、コールバック関数を設定してあげます。コールバックしてくれるタイミングというのもいくつかあるのですが(後述)、その中でも今回は読み込み完了のタイミングで関数を呼び出してもらうようにします。そのためには、onloadというプロパティに関数を代入します。


var reader=new FileReader();
reader.onload = function(e){
console.log("読み込みが終わりました");
};

そして、これで準備ができたのでいよいよreadAsTextを呼び出します。readAsTextは、第一引数にBlob(今回はFileなのでさっき読み込んだFile)を渡します。


var reader=new FileReader();
reader.onload = function(e){
  console.log("読み込みが終わりました");
};
reader.readAsText(file);

ちなみに、readAsTextを呼び出す前にonloadプロパティに関数を代入しましたが、この順番は逆でも構いません。人によっては、「readAsTextが一瞬で読み込み終わったらonloadプロパティを設定する前に呼ばれてしまうかもしれない」と思うかもしれませんが、その心配をする必要はありません。詳しくは解説しませんが、これは非同期的にコールバックが呼び出される場合、必ず今まさに実行中の一連のプログラムが最後まで実行されてから呼びだされるからです。

さて、今回のプロパティonloadですが、これはどう見てもイベントloadのイベントハンドラに見えます。上で「コールバックは1回だけ呼ばれるイベントである」というようなことを述べましたが、今回はまさにそうなっています。DOMの関連で非同期的な処理が行われる場合、このようにイベントの形でコールバックが設定できるようになっていることがよくあります。実は、おなじみのaddEventListenerメソッドを使ってイベントを登録する方法もあります。

ということは、コールバック関数の第1引数eはイベントオブジェクトです。この場合のe.targetはFileReaderオブジェクト(上の例だとreader)が入っています。e.targetは複数のFileReaderで同じコールバック関数を使いまわしたいときなどに利用できるかもしれません。

さて、読み込みが終わった時点で、読み込み結果はどこに入っているのかというと、FileReaderオブジェクトのresultプロパティに入っています。つまり、今回の場合reader.resultです(もちろん、e.target.resultでも同じです)。

ということで、ここまでのを試すサンプル2を見ましょう。

テキストファイルを選択するとコンソールに中身が表示されるのを確認できたと思います。

ところで、テキストというと文字コードの問題がつきまといます。そのファイルをどんな文字コードで読み込むかは第2引数で文字列で指定します。第2引数がない場合はUTF-8になります。他に"UTF-16"とか、"Shift_JIS"などなどが使えます。

バイナリファイルの読み込み

読み込みたいファイルがテキストでない場合はreadAsTextではなく別のメソッドを使うのがよいでしょう。

そこで次に紹介するのが、readAsArrayBufferです。これは、そのBlobをArrayBufferとして読み込むということです。もちろん読み込んだ結果はreadAsTextと同じように、resultプロパティに入っています。

このArrayBufferというのは要するに「バイナリデータが入っているオブジェクト」ということなのですが、それだけだとBlobとあまり変わりません。ArrayBufferの特徴は、実際にメモリ上に連続する領域が確保されているということです。ファイルをBlobとして得られた段階では実はBlobオブジェクトが作られただけで、まだそのファイルをメモリ上に読み込んでいないかもしれません。それを実際にメモリ上に(文字列やArrayBufferの形で)読み込むのがFileReaderなのだということですね。

このArrayBufferというのは先々また出てくるわりと汎用的なオブジェクトなのでここで慣れ親しんでおきましょう。実は、ArrayBufferはバイナリデータなのですが、実はその中身を読むにはさらに別のオブジェクトが必要なのです。面倒ですね。

それが型つき配列TypedArray)であり、このオブジェクトによってArrayBufferの中身をどのように(1バイトずつとか、4バイトずつとか)読むかが定まります。配列ということは、バイナリデータは配列(というより、データが並んだもの)として扱われるということです。

型つき配列にはいくつか種類があるのですが、まずはよく使いそうなUint8Arrayから紹介しましょう。Uint8とは、「8ビットで符号なし整数」ということです。C言語でいうunsigned charとかRustでいうu8にあたるものですね。

8ビットということはすなわち1バイトですから、Uint8Arrayを用いる場合はデータを1バイトずつに区切って並べた配列として扱われることになります。バイナリデータはバイト単位で区切って表示されることが多いように思いますから、これが最も自然なのではないでしょうか。

型付配列の他の種類も紹介しておきます。UがないInt8Arrayは符号あり8ビット整数、すなわち1バイトが-128〜127の範囲の整数として表された配列になります。ほかに、Uあり・なしそれぞれに16と32があります。例えばUint32Arrayはデータ4バイト(32ビット)ごとに配列の1要素となり、それらは32ビット符号なし整数です。他に浮動小数点数を扱えるFloat32ArrayとFloat64Arrayというのがあります。なお、このような型付き配列は総称してTypedArrayと言います。昔はArrayBufferViewという名前でしたがTypedArrayがECMA標準に組み入れられたあたりで過去のものとなりました。

それではUint8Arrayを例にして使い方を見ていきます。他のも使い方は同じです。


var arr=new Uint8Array(buffer);

型付き配列はこのようにnew演算子で作ることができます。第1引数にデータを持つArrayBufferを渡します。

さきにひとつ注意を述べておきます。それは、一つのArrayBufferに対して複数のTypedArrayを作った場合、一方を操作するともう一方にも反映されるということです。これは、Uint8Arrayをはじめとする型付き配列はArrayBufferが確保しているメモリを操作するインターフェースであり、インターフェースを複数作ったところで結局操作されるのは同じArrayBufferだからです。

つまり我々はUint8Arrayを通じてもとのArrayBufferを操作しているのです。

それでは型付き配列の機能を見ていきましょう。まずはlengthプロパティで配列の長さを取得できます。Uint8Arrayならば、1要素1バイトなのでArrayBufferのデータのバイト数(実はこれはArrayBufferのプロパティbyteLengthで取得できます)と一致しますが、16とか32になるとまた異なってくるでしょう。

また、配列という名に恥じず、arr[0]で0番目の要素を取得したり書き換えたりできます。

かなり端折った説明でしたが、ここまで分かれば読み込んだファイルのバイト列を取得することができるはずです。

ということでサンプル3を見ましょう。このサンプルは読み込んだファイルの最初の10バイトを表示しています。

結構長々と解説しましたが、実際のソースコードは結構単純です。とにかくこれで、バイナリファイルの読み込みも可能になりました。

型付き配列についてはもう少し紹介することがありますが、それは後回しにしてFireReaderの説明に戻ります。

readAsDataURL

実は、読み込み用メソッドはテキストとバイナリの2つだけかと思いきやまだ種類があります。それはreadAsDataURLです。

このDataURLというのは、http://などから始まるのではなく、data:から始まる特殊なURLのことです。一般に、URLというのはインターネット上にあるデータを指し示すもので、アクセスすることでデータを取得できるものです。DataURLというのは、データがURL内に全部書いてあり、インターネットアクセスをする代わりにURL内のデータを読むことで結果を得られるものです。

DataURLの例は次のようなものです。

data:text/html,%3c%21doctype%20html%3e%3chtml%3e%3cbody%3e%3ch1%3etest%3c/h1%3e%3c/body%3e%3c/html%3e

これをブラウザのアドレスバーに入力してみましょう。「test」と書いたページが開かれるはずです。そのページのソースを表示すると次のように出るはずです。


<!doctype html><html><body><h1>test</h1></body></html>

つまり、「data:text/html,%3c...(略)」というURLを開いたということは、 この「<!doctype...(略)」という内容のページを読み込んだのと同じ事だということです。

このように、ブラウザが読み込んだとき、あたかもどこかからその内容を取ってきたかのように振る舞うのがdataURLです。

さて、readAsDataURLでは、ファイルの内容をこのdataURLに変換させて読み込むことができるというのです。

これの意味するところはつまり、そのURLをブラウザが読むと該当ファイルの内容が読みこまれるということです。

ということで、これをやってみたのがサンプル4です。

画像を読み込ませてやるとその内容が表示されたと思います。できたimg要素のsrc属性を調べると、長いDataURLになっていることがわかります。

このように、dataURLというのはユーザーが選択したものをブラウザに読み込ませたいときに使える場合があります。

なお、DataURLはbase64でシリアライズしたバイナリデータに所定のヘッダーを付けただけなので、手動で作ろうと思えば作ることはできます。実際、これはFileReaderに特有の概念ではなく、色々な場面で使われています。例えばimg要素を用いて小さい画像を表示したいとき、src属性に(通常の)URLを記述する代わりに画像をDataURL化したものを記述することができます。これによりインターネットアクセスを行わずとも画像を表示することができます。

URL.createObjectURL

さて、しかし今回のように実際にはユーザーが選択したファイルを表示したい場合にreadAsDataURLを使うことは少ないでしょう。データが全部URL文字列に含まれるということは、データが大きいほどURLも長くなるということです。これは扱いにくいので、もっと便利な方法が用意されています。それがURL.createObjectURLです。これはURLというオブジェクトが存在して、それが持つcreateObjectURLメソッドであることは今更言うまでもありませんね。

このメソッドにBlobを引数として渡すと、オブジェクトURLと言われる特殊なURLが生成されます。これはDataURLとはまた異なる種類のURLです。このURLはブラウザが発行したURLであり、そのページが閉じられるまで有効です。このURLは、その内容(Blob)をブラウザが覚えておき、それを指し示すものです。DataURLとは異なり、データの本体はURLに書いてあるわけではなくブラウザの内部に保管されています。ということは、当然他の人に渡してもデータが渡せるわけではありません(URLだけ渡してもデータ本体を渡していませんね)。

readAsDataURLで得られたdataURLの代わりに、このcreateObjectURLで得られたオブジェクトURLをimg要素などに渡すことができます。そのように上のサンプル4を改造したのがサンプル5です。

この例ではURL.createObjectURLによって作られたURLをコンソールによって表示されます。結果はブラウザや場合によって異なるでしょうが、Google Chrome 61での結果は次のような感じです。


blob:http://uhyohyo.net/684ace23-5998-446a-9100-ae79bba25015

blob:で始まっており、何やらIDのようなものが含まれていますが、実際の画像データは含まれていません。このIDがどのBlobに対応するのかブラウザが覚えていることで、URLを読むと対応するデータを読み込むことができるのです。実際、(サンプル5を開いたままで)表示したURLをブラウザで開いてみましょう。該当の画像が表示されるはずです。

データをURLの形で表したいときは、DataURLを使うよりもURL.createObjectURLを使ったほうがよい場合が多いです。DataURLは先に説明した通り、データが全て文字列として表現されます。ということは必然的にデータがメモリに全部展開される必要があります。これは、例えばvideo要素で数十GBもある動画データを再生したい場合は現実的ではありません。

このような場合にURL.createObjectURLを使うと、数十GBある動画データを表すURLができますが、だからといってそれが全部メモリに読みこまれるわけではありません。それをvideo要素で再生しようとすると、ブラウザは必要に応じて動画をハードディスク(やSSD)から読み込むような動作をするはずです。

ちなみに、createObjectURLで得られたURLは、使い終わったらURL.revokeObjectURLメソッドを呼び出して(第1引数にオブジェクトURL)やると親切です。これは、そのURLはもう使い終わったとブラウザに宣言することで、ブラウザの記録から消去してやるメソッドです。これによりメモリの節約などの効果が期待できるかもしれません。

FileReaderのその他の機能

今回はFileReaderのイベントとして読み込み完了を示すloadイベントを紹介しましたが、実はほかにもイベントがあるので一気に紹介します。

loadstart
ファイルの読み込みを開始したとき。
progress
ファイルの読み込みが進行したとき。(何度も発生する可能性があります)
abort
ファイルの読み込みが中断したとき(後述)。
error
ファイルの読み込みに失敗したとき。
load
ファイルの読み込みが正常に最後まで完了したとき。
loadend
ファイルの読み込みが終了したとき(失敗した場合も含む)。

loadイベントのほかによく使いそうなのはerrorですね。また、読み込むのに時間かかるようなファイルの場合はprogressイベントを使う機会があるかもしれません。

abortイベントに関してですが、読み込みの中断はFileReaderのabortメソッド(引数なし)を呼ぶことで発生します。

型付き配列について

上で紹介した型付き配列(TypedArray)についてもう少し解説しておきます。

先に述べたようにデータの実体はArrayBufferであり、型付き配列はそれを読み書きするためのインターフェースでした。型付き配列はbufferプロパティを持ち、対応するArrayBufferが入っています。なお、このプロパティは読み取り専用であり、このプロパティに別のArrayBufferを入れることで読み書き先を変更するようなことはできません。

実は、背後にあるArrayBufferを意識せずに型付き配列が利用される機会が増えています。型付き配列を作る場合、コンストラクタの引数にArrayBufferを渡すかわりに次のように数値を渡すことができます。この場合、その数の要素数を持った型付き配列が作られます。


var arr = new Uint8Array(10);
console.log(arr);

こうすると、要素が0で初期化された要素数10のUint8Arrayがコンソールに表示されます。このとき、実は10バイト分のArrayBufferが新たに作られています。

また、TypedArrayは配列と同様のメソッドを多く持っています。例えばmapを使ってみましょう。


var arr = new Uint8Array(10);
var arr2 = arr.map(function(x, i){ return x+i;});
console.log(arr, arr2);

arr2は[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]というUint8Arrayになりました。配列のmapが新しい配列を返すのと同様、これも新しいUint8Arrayを返します。

ところで、Uint8Arrayということは、中身は0から255までのはずです。それを超える値を代入しようとしたらどうなるでしょうか。


var arr = new Uint8Array(10);
arr[0] = 256;
arr[1] = 300;
arr[2] = -5;
console.log(arr); // [0, 44, 251, 0, 0, 0, 0, 0, 0, 0]

こうすると0番目は0、1番目は44、2番目は251となるはずです。つまり、数値の下位8ビット以外は捨てられるということです(-5のビット表現は111...11011であり、下位8ビットは11111011(10進法で251)となる点に注意してください)。

実はUint8Arrayにはこの場合に違う挙動をするバリエーションが用意されています。それがUint8ClampedArrayです。これは値の範囲はUint8Arrayと同じく8ビット符号なし整数ですが、その範囲を超える値が代入された場合に、0未満なら0に、256以上なら255にされるという点で異なります。


var arr = new Uint8ClampedArray(10);
arr[0] = 256;
arr[1] = 300;
arr[2] = -5;
console.log(arr); // [255, 255, 0, 0, 0, 0, 0, 0, 0, 0]

最後になりますが、知識のある読者の方は「2バイト以上の場合エンディアンはどうなるのか」と思ったかもしれません。実はエンディアンは決まっていません。そのプラットフォームにおけるネイティブな方式になります。

このことが理由で、Uint32Arrayなどの2バイト以上のTypedArrayは使いどころが制限されます。エンディアンを制御しつつArrayBufferの読み書きを行うには、DataViewオブジェクトを使う必要があります。これについてはここでは解説しませんので、必要な方は調べてみてください。