HTMLのファイルに関するAPIの最新の事情

# はじめに

　いくつかのウェブアプリを現在制作中である。いずれもファイルを扱うものなので、ファイルに関するAPIが必須である。
　自分のこれまでの知識としては、HTML5と言われてた頃のFile APIやA要素による擬似的な保存テクニックくらいしか知らなかった。
　ふとGoogle のブログを眺めていたら、**FileSystemAccess API** なる、気になるAPIがすでに実装されているとのことで調べてみた。本記事はその忘備録としてまとめたものである。

## 参考

[The File System Access API: simplifying access to local files](https://web.dev/file-system-access/)
[MDN - File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API)
[MDN - window.showOpenFilePicker](https://developer.mozilla.org/en-US/docs/Web/API/window/showOpenFilePicker)
[MDN - window.showSaveFilePicker](https://developer.mozilla.org/en-US/docs/Web/API/Window/showSaveFilePicker)

## 試したブラウザ

Chrome 99.0.4844.51
Edge 99.0.1150.36

※本記事で扱うAPIは **まだ非標準** です。使う際はブラウザを明確に限定する必要があります。

# おさらい

　いきなりおさらいである。何をというと、すでにおなじみのHTML5のFile APIである。登場してだいぶ時代が経っておりなんの問題もなく使えるAPIである。
　合わせてファイルを開く・ファイルを保存するテクニックも一度はやったであろうサンプルとして紹介する。

```javascript:test.js
//---ファイルを開く
var inp = document.querySelector("input[type='file']");
inp.accept = ".txt,.json";
inp.click();
inp.addEventListener("change",(event)=>{
    // このあとはevent.target.filesを使って別の処理へ...
    anyfunction(event.target.files);
});

//---ファイルを読み込む
var reader = new FileReader();
reader.onload = (event) => {
    //なにか処理...
}
//tmpfile はFile オブジェクト
reader.readAsText(tmpfile);

//---ファイルを保存する
var a = document.querySelector("a#hoge");
var data = {hoge:123, foo: "test!"};
var bb = new Blob([JSON.stringify(data)], {type: "application/json" });
var burl = URL.createObjectURL(bb);
a.href = burl;
a.download = "test.json";
a.click();

```

　上記サンプルをどこかに組み込めばとりあえずウェブアプリでもローカルファイルを扱うことができる。
　また、VueのライブラリであるvuetifyやQuasarなどを使うと、ファイルピッカーのコンポーネントが使えたりして、それらを介してでもFileオブジェクトを扱うことができる。

　しかし・・・HTML側とjavascript側両方（そしてVueだとリアクティブなどこかのオブジェクトでも）でファイルを意識した実装をしなくてはいけない。実装場所をきちんと管理しておかないと後で見たときにわからなくなること必至であろう。

# File System Access API を使ってみる

　そこで新しいAPIを使ってみる。これを使えば、次のことができるようになる。

* ファイルをダイアログを使って開く（input type=fileなしに）
* ダイアログなしでファイルを開くことができる（明示されたファイルのみ）
* ファイルをダイアログを使って保存する（a要素なしで）
* ファイルを上書き保存できる
* 何というファイルを使っているのか、ユーザーに明示しやすくなる

　ファイルを開く、読み込む、保存する（書き込む）をこのAPIを使った例は次の通り。

## ファイルを開く

```javascript:test.js
async function test1() {
    if ("showOpenFilePicker" in window) {
        const fileoptions = {
            multiple : true, //複数のファイルを選択する場合
            excludeAcceptAllOption : false,  //使い道が見いだせないのでとりあえず無視
            types : [ //ファイルの種類のフィルター
                {
                    //ファイルの説明
                    description : "Application config file",  
                    //MIME typeと対象の拡張子
                    accept : {"application/json": [".json",".hogehoge"]} 
                }
            ]
        };
        try {
            /**
             * @type {Array<FileSystemFileHandle>}
             */
            const fileHandles = await window.showOpenFilePicker(fileoptions);
            for (var handle of fileHandles) {
                console.log("ファイルの名前は",handle.name);
                console.log("ファイルの種類は",handle.kind); 
                //getFile()でFileオブジェクトを取得できる
                /**
                 * @type {File}
                 */
                var file = await handle.getFile();
                //ファイルを使った何か処理へ
                anyfunction(file);
            }
        }catch(e) {
            //ファイルが選択されなかった場合は例外が出力される
            alert("ファイルが選択されませんでした！");
        }
    }
}

//---ドラッグアンドドロップの場合
document.querySelector("何か要素").addEventListener("drop",(event)=>{
    var items = event.dataTransfer.items;
    if (items[0].kind == "file") {
        items[0].getAsFileSystemHandle();
    }
});
```

　最初のポイントは `window.showOpenFilePicker` だ。これを使えば、もはやHTMLにinput要素がなくてもファイルダイアログを開くことができるようになる。これの引数は types さえ入っていればとりあえずよい。目的のファイルを開かせるのに適切に定義しておく。
　返されるのは FileSystemFileHandle の配列だ。ここからファイルを取り出すには、 getFile()メソッドを使う。そうすると従来扱っていたFileオブジェクトが取得できるので、目的の処理に使えばよいだけだ。
　これをIndexedDBなどに保存しておくと色々便利である。

　ドラッグしてきたファイルの場合は `getAsFileSystemHandle` を使えばFileSystemFileHandleを取得することができるので、後は他の処理に合わせて使えば良い。

　なお、ここで返される FileSystemFileHandle はあくまでファイルへの参照である。参照を取得した後に実体を移動・リネームしてしまうと当然エラーになる。


## ファイルの権限を変更する

　このAPIを通して読み込んだファイルは最初は読み込みのみとなる。アクセス権限を変更したり、アプリの使用途中でアクセス権限がどうなっているかを確認することができる。

```javascript:test.js
    var fileHandle = IndexedDBなどから読み込む処理();
    var options = {
        mode : "readwrite"  //read または readwrite
    };
    //---アクセス権限を問い合わせて結果を取得する
    const checked = await fileHandle.queryPermission(options);

    alert("このファイルへの権限は",checked);
    //granted  許可されている
    //denied   許可されていない
    //prompt   別途プロンプトから許可を得る必要がある

    if (checked != "granted") {
        //---モードに応じたアクセス権限を取得する
        var isOK = await fileHandle.requestPermission(options);
        //granted, denied, prompt のいずれかが返ってくる
        console.log("このファイルへの権限は次のようになりました。", isOK);

    }
```

　まずは `queryPermission`で、該当のファイル（ハンドル）に対してアクセス権限をチェックする。そしてもし"granted"でなければ `requestPermission` でアクセス権限を付与してあげることになる。
　実際のプロンプトや表示は次の通り。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38416/4396f3f1-dbe4-e39d-7d1d-f180fac2719a.png)

　ここまで実装すればあとは目的に応じてファイルを読み書きするだけだ。

## ファイルの中身を読み込む

　これはFile System Access APIというよりも従来のFileの最近の仕様の一つであろう。これを知ってるだけでもファイルの読み込みに関しては実装の簡略化ができるはず。（Blobから継承されたメソッド）

```javascript:test.js
async function hoge(file) {  //Fileオブジェクト
    //文字列として全部読み込む場合
    var data = await file.text();
    //任意のファイルタイプとして指定の範囲を読み込む場合（MIME typeを指定する）
    var bytes = await file.slice(0, 100, "text/plain");
    //ストリームオブジェクトを取得する場合
    var stream = await file.stream();
    //Blobなどバイナリ形式で取得する場合
    var arrbuf = await file.arrayBuffer();
}
```

　これまでのようにFileReaderを使ってシコシコと・・・は不要なのでかなりスッキリするはず。なお、上記はPromiseで返ってくるのでasync/await を使わない場合はthen()を使う方法で使おう。


## ファイルを書き込む

　いよいよこのAPIの真骨頂というべき機能だ。使うのはFileSystemFileHandleだ。

```javascript:saveas.js
//---ファイルダイアログを使ってファイルを指定して保存する例
if ("showSaveFilePicker" in window) {
const options = {
    suggestedName : "hoge.json", //デフォルトのファイル名
    types : ...  //showOpenFilePickerのtypesと同じ
};
try {
    //---ファイルダイアログを表示し、ファイルを指定する。FileSystemFileHandleが返される
    const fileHandle = await window.showSaveFilePicker(options);
    //---書き込みを扱う FileSystemWritableFileStream を返す
    const writer = await fileHandle.createWritable();
    //---JSONなどテキストをそのまま全部書き込む
    await writer.write(JSON.stringify(hoge));
    //---オプションを指定して書き込む
    await writer.write({
        //write, seek, truncate いずれか
        type : "write",
        //書き込むデータ（type=writeで指定可能）
        data : JSON.stringify(hoge),
        //書き込む開始位置（type=seek, writeで指定可能）
        position : 15,
        //ストリームのサイズ（type=truncateで指定可能）
        size : JSON.stringify(hoge).length / 2
    });
    //---書き込む位置を変更する。（上記positionオプションと同等）
    await writer.seek(15);
    await writer.write(JSON.stringify(hoge));
    //---イメージをBlobデータにして書き込むことも
    var imgblob = new Blob([imgdata], {type: "image/png" });
    await writer.write(imgblob);
    //---ストリームを閉じて変更を確定する
    await writer.close();
}catch(e) {
    //---ファイルが選択されなかった・ファイル名が入力されなかったなど
    alert("ファイルが入力・指定されませんでした！");
}

```

```javascript:save.js
//---IndexedDBなどに保存したファイルハンドルを使ってダイアログを出さずに保存する例
/**
 * @type {FileSystemFileHandle}
 */
const handle = IndexedDBなどから取得する処理();
//---書き込みの許可を取得する（この直後に画面上にポップアップメッセージが表示される）
const checked = await handle.requestPermission({mode : "readwrite" });
if (checked === "granted") {
    //---ここから先は同じ使い方
    const writer = await handle.createWritable();
    await writer.write(JSON.stringify(hoge));
    //---100バイトのサイズに変更する。（上記sizeオプションと同等）
    //（上記hogeが220byteの場合は100byteとなる）
    await writer.truncate(100);
    await writer.close();
}
```

　ポイントは `window.showSaveFilePicker` である。基本的にはこれを使ってユーザーに明示的に保存するファイルを決定されることになる。これを覚えれば、もうa要素を使って擬似的な保存処理を作る必要はなくなる。わざわざダウンロードフォルダに置かれてしまうもない。

　そして次が真骨頂だ。事前に保存したFileSystemFileHandleを用意する。ここで `requestPerission` を使ってそのファイル（ハンドル）に書き込み権限を取得しよう。このときに上図のような確認のポップアップメッセージが表示される。この後の使い方は showSaveFilePickerのときと同じだ。

　それから一度アクセス権限を得たファイルは、ウェブアプリが **再読み込み・クローズされない限りずっと同じ権限を持ち続ける** ので、次に上記処理を呼び出したときは **ポップアップメッセージは出ずに即座に保存** される。そのためアプリ側で何か通知の実装が必要だ。

　writeメソッドは書き込むデータを直接指定することができる。その場合は現在の書き込むオフセット位置からになる。書き込めばオフセットは進むので再度呼び出す際は注意したい。closeすればファイルの実体に反映され、オフセットももとに戻るのでwriteとcloseはセットで使ったほうが間違いがなくて済むかもしれない。
　writeのほか、seekやtruncateもある。
　seekはwriteのpositionオプションと同じだ。
　truncateは指定されたサイズにまで現在のストリームのサイズを変更する。オフセット位置も合わせて変更される（サイズを超えたらそのサイズぴったりにまで移動）ので、必要に応じてseekで再度書き込む位置を調整することになるだろう。


# 終わりに

　以上、File System Access API を見てきた。つい先日このAPIを知って、あまりの便利さに今開発中のウェブアプリに使ってみようと興奮冷めやらぬまま使い、忘れないように記事にした次第である。
　なにせ、ウェブアプリでinputやaを使わずにファイルを扱え、さらに上書き保存ができるのだ。アイデア次第でネイティブアプリを作ることなくPWA化したウェブアプリだけで相当いろいろなアプリを実現できるだろう。（こうしてみると古くはActiveXObjectのFileSystemObjectを思い出す・・・）
　なおかつ許可を得たファイルをユーザーに明示することができるという、セキュリティに関した面も忘れてはいけない。この機能を正しく周知することで、ユーザーはこの機能を使うアプリに遭遇したときに使い続けるか否かの判断が正しくできるようにもなる。

　登場して一定期間は経っているのですでにChromeとEdgeでは当然のように使える。ただ他のブラウザでは広く使えるわけではなさそうなので、実際に使う際にはそこを注意したい。（inputやaを併用すれば互換性もある程度確保できるのでうまく活用したいところ）
　なお上記はブラウザで使用した例であるが、同じ処理をElectron(17.1.0)で試したところ問題なく機能した。ElectronではNode.js由来のAPIがあるのであまり必要ではないが、一切の変更・追加せず同じコードでブラウザとElectron両方対応できるのも魅力的かもしれない。

　今回取り上げなかったいくつかのメソッドはまだOrigin trial中らしくまだ使うには危うい段階のものがあるのでそれらは試していない。
　このAPIが早く標準化されることが楽しみである。