一個 web app 處理檔案的方式,傳統的做法是把檔案 post 到後端,經過一番處理後再吐回來,以現代的角度看,這種原始的方式最大的問題是 UX 不佳,用戶得經歷上傳、等待處理、網頁更新、下載這幾個過程,幸虧現代化的瀏覽器和 JavaScript 的發展大爆棚,以往要在後端處理檔案的開銷也可以在前端處理,更惡質一點還能拿用戶的瀏覽器來挖礦。

以 PDF 為例,以往我們都要靠 Adobe Acrobat Reader 來開啟,但現在我們都只用瀏覽器開 PDF,不管是 Mozilla 的 PDF.js 或是 Google 的 PDFium,儘管他們的功能不比 Acrobat Reader 完整,但對於最普通的閱讀 PDF 的需求已是足夠滿足大部分人的,只剩下那些需要筆記、標注功能的少部分用戶還會想用 Acrobat Reader。

這裡我們談談最近對 web app 處理檔案上一些工程和設計面的觀點。

檔案上傳元件

在上傳元件的 UI 方面,原生的 HTML file input 元件在現代的眼光看來過於陽春:

在這樣陽春的背後,卻又親切度不足,沒有使用提示、進度提示、錯誤提示等所有能幫助降低使用者疑惑的元素,於是重視 UX 的設計師們看不下去了,腦洞大開的發表各式重新改造過的上傳元件:

File Upload- Daily UI 031

來源:Aarushi Mishra

這些設計的共同特徵是會有個拖放區,這個拖放區除了接受空投檔案以外,點擊也會觸發開啟檔案的對話框。

像這樣拖放型的 UI,在桌面端早已實現已久,幾乎我們想得到的任何一個 app 都有這樣的特性,特別是那些小工具型的 app,他們並不需要複雜的 UI,僅需要快速簡單直覺的完成特定的任務,這種 app 的典型是那些轉檔工具,例如 macOS 上的壓縮工具 Keka:

Keka

相信任何一位有拖拉過檔案的初學者都可以對Keka 的介面輕鬆上手。

若是進一步延伸觀察到日常生活中,相較於傳統遙控器,客廳內的 Apple TV 遙控器也是橫空出世就大刀砍了那些使用率不到兩成的按鈕(是說,那麼多按鈕到底是要考驗誰呢?),像這些經過簡化的設計背後的哲學可以用兩個類似的概念解釋——「Worse is better」&「KISS」。

拖放元件

回頭說拖放上傳。像這種拖放型的檔案上傳元件,追求事半功倍的我輩開發人,當然是找人家封裝好的元件來用,例如 UppyFilePond

Uppy 範例:

Uploaded files:

    自幹拖放元件

    如果想要自己實現呢?那我們得先搞懂與拖放有關的事件:

    Drag and Drop Event Flow

    來源:〈使用 Drag and Drop 給 Web 應用提升交互體驗

    乍看好像很複雜,其實並不會,我們用 drop 事件處理空投檔案的邏輯部分,而 dragenter 事件、dragleave 事件、dragend 事件用於改變拖放區的外觀,讓用戶感知到元件的狀態變更,其他的各式細節請直接參考〈使用 Drag and Drop 給 Web 應用提升交互體驗〉,我就不多造口業了。

    如果拖放區也想要有較傳統的點擊開啟檔案的行為,則是監聽 click 事件,並呼叫開啟檔案的方法。

    以一個很簡單的 HTML 為例:

    <div id="dropbox-container">
      <div class="normal" id="dropbox">
        <p id="dropbox-inner">餵我吃檔案<br>
        <span id="secondary-action">或點我開啟檔案</span></p>
      </div>
    </div>
    

    搭配 CSS:

    #dropbox-container {
      background-color: #EFF6FF;
      padding: 4vmax;
      margin-top: 1rem;
      margin-bottom: 1rem;
      user-select: none;
    }
    
    #dropbox {
      border: 4px dashed #BFDBFE;
      border-radius: 20px;
      display: grid;
      place-content: center;
      aspect-ratio: 1;
    }
    
    .normal {
      background-color: #EFF6FF;
    }
    
    .dragenter {
      background-color: #DBEAFE;
    }
    
    #dropbox-inner {
      text-align: center;
      color: #93C5FD;
      font-size: x-large;
      margin: unset;
      line-height: 120%;
    }
    
    #secondary-action {
      font-size: medium;
      color: hsla(0, 0%, 60%, 1.0);
    

    JavaScript 處理換 CSS 和檔案的邏輯:

    const dropbox = document.getElementById("dropbox");
    
    function setBackgroundNormal() {
      dropbox.classList.add("normal");
      dropbox.classList.remove("dragenter");
    }
    
    function setBackgroundDragenter() {
      dropbox.classList.add("dragenter");
      dropbox.classList.remove("normal");
    }
    
    function handleFiles(files) {
      console.log(files);
      setBackgroundNormal()
    }
    
    async function click(e) {
      const arrFileHandle = await window.showOpenFilePicker({
        multiple: true,
      });
    
      const files = [];
      for (const fileHandle of arrFileHandle) {
        files.push(await fileHandle.getFile());
      }
    
      handleFiles(files);
    }
    
    function dragenter(e) {
      e.stopPropagation();
      e.preventDefault();
      setBackgroundDragenter();
    }
    
    function dragover(e) {
      e.stopPropagation();
      e.preventDefault();
      setBackgroundDragenter();
    }
    
    function dragleave(e) {
      e.stopPropagation();
      e.preventDefault();
      setBackgroundNormal();
    }
    
    function drop(e) {
      e.stopPropagation();
      e.preventDefault();
      handleFiles(e.dataTransfer.files);
    }
    
    function dragend(e) {
      e.stopPropagation();
      e.preventDefault();
      setBackgroundNormal();
    }
     
    dropbox.addEventListener("dragenter", dragenter, false);
    dropbox.addEventListener("dragover", dragover, false);
    dropbox.addEventListener("dragleave", dragleave, false);
    dropbox.addEventListener("drop", drop, false);
    dropbox.addEventListener("dragend", dragend, false);
    dropbox.addEventListener("click", click, false);
    

    餵我吃檔案
    或點我開啟檔案

    drop() 內,瀏覽器會把拖進來的檔案生成 dataTransfer.files 物件,這是一個含有空投檔案的陣列物件,而在 click(),我們呼叫的是 window.showOpenFilePicker(),它是 File System Access API 提供的函式之一,我們把 drop()click() 得到的檔案統一交給 handleFiles() 印出到 console 內。

    然而要注意的是, Safari 還不支援這組較新的 File System Access API,於是得用些奇技淫巧來對付它--在畫面上放個 file input,把這 file input 藏起來,例如用 z-index 把它放在某個元素背面,再把別的 <div> 的 click 事件傳遞給那 file input,用這種迂迴的方式達成開啟檔案的目的,相較於上面的做法,這就顯得沒那麼優雅了。

    在 Tauri 與 Electron 開啟檔案

    如果是把 web app 用 Tauri 或 Electron 打包成桌機 app 的情況,由於安全性考量,走網頁 API 或 file input 取得的檔案,都是不含真實路徑的,因此這兩個框架也各自提供了開啟檔案以及拖放的 API 讓我們得以取得檔案的真實路徑。

    以 Tauri 為例,調用的是 open() 函式Electron 則是調用 showOpenDialog()

    同樣地,拖放行為也是改用框架提供的 API 來獲得檔案的真實路徑,Tauri 讓我們用 listen() 去監控拖放事件的發生Electron 則是監聽一系列的拖放事件配合 startDrag(),這部分的分享就點到為止,待有朝一日本人真的有親自用過再來分享。

    檔案上傳了,接著是前端該怎麼處理檔案的問題。