file

前幾天我才剛寫完 BPS 開發分享之 7:檔案下載,我突然就發現原來之前眾人一直在敲碗的 File System Access API 最近已經可以在 Chromium 系列的瀏覽器以及其 PWA 當中使用了(暫時為桌機限定),雖然非常侷限,但是因為它的使用體驗真的比起檔案下載要好,所以還是值得實作。當然,因為不支援的環境太多了,暫時前一篇文章中的作法還是一樣必須保留。同時必須認知到這項功能仍然只算是草案階段,所以不排除未來還是有變更規格的可能。

使用前提

跟近來很多的 API 一樣,File System Access API 的使用前提是網頁一定必須是在 HTTPS 環境下運作的。除此之外,前面也提到暫時只有桌機版本的 Chromium 系列瀏覽器支援它(最新情況可參見 Can I Use 的報告),不過我們倒也不需要做很複雜的瀏覽器判別,只要去檢查 window 底下有沒有定義 API 提供的方法就好了,例如:

const FileApiEnabled = typeof window.showSaveFilePicker != 'undefined';

除此之外,此 API 提供的所有方法都必須在使用者手勢之下呼叫,也就是說它必須在使用者操作(點擊、觸碰等等)的事件生命週期之中被呼叫。很大的機會是我們會在非同步函數裡面呼叫它們,此時我們必須確保它們是非同步函數裡面第一個被等候的 Promise,才不會導致當我們準備呼叫它們的時候事件週期早已經結束了。

開發環境設置

對於有使用 TypeScript 的開發者來說,因為這套 API 太新了,一般的定義檔裡面是不會有那些對應的定義的。為了解決此問題,可以安裝 @types/wicg-file-system-access 這個 NPM 套件,裡面有提供絕大部份的定義。不過這個定義檔也沒有完全跟上此 API 的最新發展;截至本文寫成為止,至少 SaveFilePickerOptions 介面裡面就遺漏了最新的 suggestedName 屬性,不過除此之外我並沒有發現其它遺漏之處,還算 OK。1

另存新檔

我們先從大家敲碗最久的「另存新檔」對話方塊開始介紹,直接用一個範例程式(我用 TypeScript 寫)解說比較快:

async function saveAs(): Promise<void> {
    // 底下的程式碼都要用一個 try-catch 包起來,
    // 因為使用者如果按了對話方塊的取消是會丟出例外的
    try {
        // 顯示對話方塊,該方法會傳回一個 FileSystemFileHandle 物件
        let handle = await showSaveFilePicker({
            suggestedName: "建議的檔名",
            types: [
                // 這裡面接受一個陣列的檔案類別清單,
                // 可以高度自訂對話方塊要支援的檔案類別
                {
                    description: "要顯示在對話方塊中的檔案類別說明",
                    accept: {
                        // 這裡面是一個 MIME 代碼和對應副檔名陣列的字典。
                        // 要注意的是如果指定的 MIME 的代碼是瀏覽器認得的,
                        // 那麼所有瀏覽器認知中的對應副檔名都會被自動加上去;
                        // 如果不想要這個行為,那麼請指定一個自訂的 MIME 代碼
                        'text/plain': ['.txt'],
                    },
                }
            ],
            // 底下這個設為 true 的話就不會顯示「所有檔案 (*.*)」的類別選項,預設為 false
            excludeAcceptAllOption: false
        } as SaveFilePickerOptions); // 修正定義檔漏了 suggestedName 屬性的問題

        // 產生寫入用的 FileSystemWritableFileStream
        let writable = await handle.createWritable();

        // 看你要用什麼方式產生檔案內容的 Blob,非同步方法也 OK
        let blob: Blob = ...;

        // 把 Blob 寫進檔案之中;這個方法也可以用字串或 ArrayBuffer 當參數
        await writable.write(blob);

        // 關閉檔案;這一步很重要,沒這樣做的話你會看到檔案系統中有一個暫存檔在那邊
        await writable.close();

    } catch(e) {
        // 使用者取消對話方塊的話會跑到這邊來,看你要不要處理
    }
}

開啟檔案之後儲存修改

顯示存檔對話方塊當然不錯,不過更加提昇使用者體驗的功能應該是:開啟了一個檔案之後可以直接把修改儲存到該檔案當中,而不是「重新下載並且覆蓋掉既有檔案」這樣的體驗。當然,因為這個動作有一定的濫用危險性存在,所以目前在使用這項功能的時候,瀏覽器都會跳出一個類似這樣的原生對話方塊:

file

而使用者只要同意一次之後,在應用程式離開之前繼續儲存同一個檔案都不會需要繼續詢問。雖然這個對話方塊談不上很美觀,但是基於安全性考量其存在也是不可少的,而且整體而言的使用體驗也還是比起下載覆蓋要理想。

要做到這個機制,首先我們必須先取得 FileSystemFileHandle 物件:

async function open(): Promise<FileSystemFileHandle[]> {
    // 跟存檔一樣,必須 catch 使用者的取消行為
    try {
        // 此方法會傳回 FileSystemFileHandle 的陣列,對應於每一個開啟的檔案
        let handles = await showOpenFilePicker({
            // 開啟檔案對話方塊可以決定是否允許多檔案開啟
            multiple: true,
            types: [
                // 這邊的設定跟存檔對話方塊是一樣的
            ]
        });
        return handles;
    } catch(e) {
        return [];
    }
}

拿到這些 FileSystemFileHandle 物件之後,如果我們要開啟檔案,可以使用該物件的 .getFile() 方法,該方法會傳回對應的 File 物件,然後我們就可以用跟 <input type="file"> 一樣的方式去讀取檔案內容。這些 FileSystemFileHandle 物件必須設法儲存在記憶體中,然後當我們準備好要存檔的時候,就可以類似前面的作法這樣寫:

async function save(handle: FileSystemFileHandle): Promise<void> {
    try {
        // 這一行會顯示授權對話方塊,如果還沒授權的話
        let writable = await handle.createWritable();

        try {
            // 利用一行程式碼來檢查看看檔案是否存在;
            // 因為假如檔案在開啟到儲存的這段時間之中曾被刪除,
            // 預設的行為是裝作沒事繼續重建檔案並儲存,
            // 但我並不喜歡這個行為,我希望在這種情況中能中斷,
            // 而底下這一行在檔案找不到的時候會丟錯
            await handle.getFile();

            // 沒問題的話就繼續儲存
            let blob: Blob = ...;
            await writable.write(blob);
            await writable.close();
        } catch(e) {
            // 找不到檔案的時候會進到這邊來,
            // 此時要記得呼叫放棄方法,以清除暫存檔
            await writable.abort();

            // 繼續丟出例外讓上一層來接
            throw e;
        }
    } catch(e) {
        // 處理存檔的失敗,包括使用者拒絕授權、檔案無法寫入等等
        // 這邊可以改走「另存新檔」的流程,該流程無須使用者額外授權
    }
}

持續儲存 FileSystemFileHandle

如果我們能夠讓 FileSystemFileHandle 物件持續儲存直到網頁下一次開啟,那麼就能夠在檔案選單裡面寫出例如「最近開啟的檔案」這樣的功能。而厲害的是這點還真的有辦法做到,辦法就是把它儲存在 IndexedDB 裡頭。我之前曾經寫過一篇文章稍微提到過 IndexedDB,也提到了幾個常見的框架,那些都可以用,而這邊為了示範起見我改用另外一個超精簡的框架 idb-keyval 來寫範例;它是利用最單純的鍵值對應來存取資料。

例如若使用它的 IIFE 建置版本,它會產生一個全域的 idbKeyval 物件。此時我們就可以用如下的語法儲存我們的 FileSystemFileHandle

idbKeyval.set('myHandleKey', handle);

然後下次網頁開啟的時候,就可以用下列語法來取回:

let handle = idbKeyval.get('myHandleKey') as FileSystemFileHandle;

但是要注意一點,在網頁重新打開之後,取回的 handle 暫時是沒有任何權限的(連讀取都沒有)。此時我們必須先利用 requestPermission() 方法取得使用者同意,才能呼叫 getFile() 方法:

await handle.requestPermission({ mode: 'readwrite' })

或者如果只需要讀而不需要寫,上面的 mode 可以改成 'read'。這個方法會傳回一個狀態字串,其中 'granted' 就表示使用者同意權限了。

小秘訣:你也可以在 IndexedDB 的一個鍵值之上一口氣儲存一整個陣列的 FileSystemFileHandle,而不用每一個都各自佔用一個 key。

結語

File System Access API 提供了很多令人興奮的強大功能、可以大幅改善牽涉到檔案編輯的 web 應用程式的使用者體驗。這裡面的 API 還有很多可以持續改進的地方,例如更多的選項、更好的錯誤處理機制等等。當然也希望其它的瀏覽器以及手機環境可以跟進支援這個 API。這些都是可以期待的未來發展。


  1. 什麼?等待這個定義更新?省省吧,@types/wicg-file-system-access 的作者是 Ingvar Stepanyan,我對他的射後不理領教過太多了,等他更新還不如等官方定義出來比較快。 


分享此頁至:
最後修改日期: 2021/07/12

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。