file

如各位所知,BPS 是一個用來輔助摺紙設計的應用程式,所以它自然有其檔案格式、並且提供了讓使用者進行存檔和開啟檔案等等的基本 IDE 功能。除此之外它在進行圖檔(SVG 或 PNG 格式)輸出的時候,也會需要讓使用者來存檔。然而,網頁的 File System Access API(簡單來說就是讓網頁有能力呼叫原生的檔案儲存對話方塊等等)一直到目前為止都還處於草案階段、只有很少的環境中已經有提供支援了,這使得要所謂的「存檔」動作實際上大多都暫時必須以「下載」的形式來進行。本篇當中,我來跟大家分享一下我自己開發 BPS 採用的存檔機制時嘗試過的一些方法、踩過的一些坑、以及我個人的最終結論。

而關於 File System Access API 的作法,可以參考本系列之後的這一篇

純前端的下載連結

既然存檔必須以下載的方式來進行,那麼理所當然地就應該要有一個由 <a> 標籤建立的連結(外觀上當然可以是按鈕或任何的東西),而在使用者按下之後、瀏覽器就把檔案下載至系統預設的下載資料夾(通常是叫 Download)之中。

然而,BPS 是一個純前端的程式,它並沒有任何後端 API 來提供檔案下載的網址,而要被下載的檔案內容都是在前端用 JavaScript 運算出來的。那麼我們要怎麼產生檔案下載的連結來下載存在於前端的資料?有兩種方法:

  1. 使用 data url
    這種網址是以 data: 開頭的,後面會把檔案的內容以 base64 編碼之後寫在網址之上。當使用者點選了這種連結的時候,就等於是打開了一個對應內容的檔案。不過這種作法的缺點是,不是非常適合內容很大的檔案。在 BPS 中,因為使用者可以一口氣把整個工作區打包存檔起來,檔案的大小有可能會很大,所以此方法並不理想。1
  2. 使用 object url
    這種網址是以 blob: 開頭的,後面只是接著一個 GUID,並沒有把檔案內容以任何方式寫出來。檔案的內容實際上是暫存在記憶體中的 Blob 物件,而這種網址是先有了 Blob 之後去呼叫 URL.createObjectURL() 方法產生出來的,而使用完了之後應呼叫 URL.revokeObjectURL() 方法以釋放掉 Blob 物件佔用的記憶體。這是現在比較推薦的連結產生方法。

在這兩種方法之中,我們都可以透過在 <a> 標籤上頭指定 download 屬性來設定下載檔案的預設檔名,而當使用者按下這種連結的時候,瀏覽器就會把對應的檔案內容以指定的檔名下載下來(也有可能可以讓使用者同時選擇下載的位置以及是否要改變檔名,視瀏覽器以及其設定而定)。

非同步的下載連結

到目前為止還好,但接下來馬上就有一個麻煩:這兩種連結都有一個前提,就是當使用者按下連結的時候檔案內容就必須已經是先準備好了的,或者,最遲最遲也必須在連結的 onclick 事件處理完之前把檔案內容產生出來、以便能夠把下載網址臨時寫進去,而只有完全同步的程式碼有可能做到這一點。然而,在 BPS 之中,有一些檔案類型(例如 PNG 或工作區壓縮檔)的內容是由耗時的非同步方法產生出來的,如此一來就不可能在 onclick 事件當中即時地把網址寫進去,但我也不可能隨時隨地都把連結產好在那邊等著,因為如此一來每當專案內容有修改、我就必須即時地去重新產生連結以確保連結的正確性,這樣絕對太吃效能了。

為了解決這個問題,一個在許多網頁應用程式中常見的解法是,當使用者按下「存檔」的時候不是直接立刻開始下載,而是先跳出一個對話方塊(在這段時間當中可以安心地執行非同步的程式碼)、裡面才是包含有一個下載連結的按鈕。確實,假如我願意妥協並且採用這種作法的話,那後面的討論就全部都不用了,但偏偏我覺得這樣的使用體驗不是很能夠被接受:使用者不會明白為什麼他一定得多按一次按鈕才能夠下載到檔案,尤其是當那個對話方塊除此之外並沒有任何其它功能的時候。

因此,最早在 BPS 之中,我設計了一個有點複雜的流程來解決這個問題,其概念大致是這樣的:在使用者按下按鈕的時候,先把原生行為阻擋掉,然後呼叫非同步的檔案產生程式碼,產完了之後把連結網址寫入,最後再次用程式碼去呼叫連結的 click() 方法以觸發點擊行為(也就是下載)。注意到這邊我不能使用 location.href 之類的方式去直接讀取那個網址,因為這樣做的話 <a> 標籤的 download 屬性就不會生效,所以我必須去再次觸發原生的行為。

這樣做乍看之下是沒有問題的,直到後來我才發現這種作法有一個很不容易被發現的重大缺陷。基本上,在我的執行流程中,第二次的點擊是由程式觸發的、而且其結果會是下載檔案,這樣的一種行為被稱為是「自動下載」,而現在的瀏覽器常常有一種機制是,同一則自動下載一旦曾經被使用者取消過,就再也不會繼續觸發了。也就是說,如果使用者在儲存某個檔案時,他曾經不小心按到了「取消」,那麼在我上面那樣的機制之下他就會再也無法儲存——除非下載連結的檔名變更了。儘管這不容易發生,但是一旦它發生了,對我的使用者來說就會是非常大的麻煩、以及很差的使用體驗。

使用 Service Worker 攔截下載

這個時候,我突然有了個新的點子,是利用 Service Worker(簡稱 SW)來做;關於 SW 我之前曾經在 BPS 開發分享之 2:Service Worker 中提到過,讀者可以回顧一下。

因為 BPS 剛好有使用 SW,而 SW 可以攔截掉任意的網址瀏覽操作、並且以非同步的方式替換成自訂的回傳結果,這個機制可以讓我做出「虛擬的下載 API」出來,其概念大致是這樣:

  • 首先我規定形如「/download/…」這樣的網址就是下載檔案用的,雖然伺服器上其實沒有這個路由存在。
  • 一旦 SW 接到以「/download」開頭的請求,它就會去聯絡 UI 執行緒,請 UI 執行產生檔案內容的非同步運算,然後把結果回傳給 SW。
  • 最後 SW 把檔案內容打包成一個 Response 物件,以回應起初的 fetch 請求。

由於 SW 回應 fetch 的處理函數本來就是非同步的,所以理論上這樣的解法是可以滿足我的需求的……

直到我實際做下去我才發現這裡面的坑多到不行——如字面上地不行。

Safari 對這套機制的支援非常差

在 UI 執行緒剛產生檔案的時候,檔案是以 Blob 形式的物件存在著的。由於 Blob 不能直接透過 postMessage() 方法傳遞給 SW,首先直覺會想到的辦法是先把它轉成 ArrayBuffer、然後因為 ArrayBuffer 物件是一種 Transferable 物件,如此一來就可以傳遞了。

但是首先,要把 Blob 轉成 ArrayBuffer 就必須讀取它的內容,而偏偏就只有 Safari 在這邊做了極嚴格的限制。雖然我沒找到說明文件,但根據我實驗的結果,只要 Blob 的產生跟轉換的程式碼是屬於不同的 script(即便它們都在同一個網域底下),在 Safari 裡面就會發生存取控制錯誤。但是我的 Blob 是第三方函式庫產出的,除非我去修改第三方函式庫、把轉換的程式碼寫進去,不然我根本拿不到 ArrayBuffer。

就算我這一關過了,後面也還沒完:Safari 一直到 14.1(iOS 上頭甚至要等到 14.5)才真的支援 Transferable 物件的傳輸,所以就算我們真的能成功轉成 ArrayBuffer,在一堆裝置上也根本沒辦法丟給 SW!

後來我才知道,好像還有一招可以解:概念是先用 Blob 產生 object url,然後純粹把這個 object url 丟給 SW,接著 SW 再自己去 fetch 一次這個 url 以取得資料。這聽起來好像可行,不過因為我最終已經定案採用了別的方法,所以我也沒去試試看這個方法是否真的在 Safari 中可行,這就留給各位參考了。

存在一些下載操作會完全跳過 SW

前一個坑如果還不夠大,那這個就真的可以讓人投降了:至少在 Chrome 當中,其實 SW 並不是真的 100% 所有的請求都攔截得到,有一些操作是會直接跳過 SW、以原生方式去請求的!這至少包括了兩種情況:

  • <a> 標籤加上了 download 屬性的時候(蛤!?這不是正好就是我們需要的嗎?)
  • 當使用者在連結上按下右鍵,並點選「另存連結為…」的時候(糟糕,這也是我允許使用者進行的一種操作!)

這是一個已經存在多年、而至今未解的問題;在 Chromium 的討論區上有一個很老的討論串在講這件事,但一直都沒有下文。

關於第一個問題,至少有兩種解法:

  1. 最新的解法是可以利用一個 http header 來指定下載檔名:

    Content-Disposition: attachment; filename=myFilename.ext

    可是問題是這個 Content-Disposition 實在太新了,甚至連 Chrome 都一直到今年五月的版本才開始正式支援它,所以短期之內根本不用指望使用這種作法。

  2. 直接使得下載網址本身就帶有預設的檔名,也就是說,規定我的下載網址為「/download/檔名」這樣,這倒是做得到,因為 SW 可以自訂任何規則的路由。

所以第一個問題還算是有解的,但是第二個問題卻是完全無解的。我並不打算捨棄掉那個功能,因為那個功能連我自己也都在用(我需要藉由那種作法來在桌機上指定存檔的位置)。基於這個緣故,最終我放棄了採用 SW 的方法來實現非同步下載。

回歸至最簡單的解答

而就在我一邊寫這邊心得的時候,我這才發現其實一直都存在著一個簡單得太多的解決方法。這個作法在 BPS 最初最初的版本(甚至比第一個正式發布版本還要更早的版本)中確實是不行的,因為當時「存檔」按鈕就明明白白底擺在最上層 UI 之上,我不可能無時無刻確保它都有著正確的 object url,這點我前面已經解釋過了。

可是後來的版本卻不是這樣,後來的版本中,「存檔」變成是「檔案」下拉選單當中的一個選項,所以使用者一定得先把「檔案」下拉選單打開才有可能按到「存檔」按鈕。如此一來,這個下拉選單等於就可以取代掉我稍早說的對話方塊的角色,使得當它打開的時候去通知「存檔」按鈕立刻備妥檔案連結來等使用者按。當然,確實有可能使用者的滑鼠操作得很快、使得使用者按下連結的時候檔案內容還沒運算完成,但是這種時候我們就呼叫 event.preventDefault() 擋掉點擊就好了;使用者頂多只會覺得非常些微的不自然,但是他接著再按一次基本上 99.99% 都會是已經準備好連結的了。連結的產生雖然說是「耗時」運算,但實務上也不會真的那麼久啦。

不過這裡面還有一些實作上的小細節。首先的問題是,我要在什麼時間點回收 object url?很合理的答案就是等到當下拉選單收起來的時候。不過,由於「按壓下拉選單中的任何一個選項」這個動作本身也會導致下拉選單收起,為了確保兩者不會打架,我選擇的方案是「等到下拉選單收起之後過了一秒再回收 object url」。

再來,我們必須考慮到一種情境是,使用者在功能表列上面快速移動滑鼠,使得檔案選單在很短的時間裡面開了又關、其速度甚至比檔案連結產出的速度更快,以至於 object url 沒有正確被回收掉。我避免這個問題的辦法是去紀錄選單關閉的行為;如果在檔案產生的過程之中曾經發生過選單關閉,那就根本不要生成 object url、而直接把 Blob 丟棄,如此就可以避免記憶體外洩了。

結語

很多時候人就是要把自己曾經思考過的方法都寫下來,才會看得出來自己漏想了些什麼。而那些行不通的辦法之所以行不通的原因,有的時候反而比行得通的辦法之所以行得通的原因還要更加重要。


  1. data url 比較適合用在把比較小的靜態檔案進行內嵌,例如把小圖示檔案內嵌在 CSS 裡面等等。 


分享此頁至:
最後修改日期: 2021/06/18

留言

撰寫回覆或留言

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