一個 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 的設計師們看不下去了,腦洞大開的發表各式重新改造過的上傳元件:
這些設計的共同特徵是會有個拖放區,這個拖放區除了接受空投檔案以外,點擊也會觸發開啟檔案的對話框。
像這樣拖放型的 UI,在桌面端早已實現已久,幾乎我們想得到的任何一個 app 都有這樣的特性,特別是那些小工具型的 app,他們並不需要複雜的 UI,僅需要快速簡單直覺的完成特定的任務,這種 app 的典型是那些轉檔工具,例如 macOS 上的壓縮工具 Keka:
相信任何一位有拖拉過檔案的初學者都可以對 Keka 的介面輕鬆上手。
若是進一步延伸觀察到日常生活中,相較於傳統遙控器,客廳內的 Apple TV 遙控器也是橫空出世就大刀砍了那些使用率不到兩成的按鈕(是說,那麼多按鈕到底是要考驗誰呢?),像這些經過簡化的設計背後的哲學可以用兩個類似的概念解釋——「Worse is better」&「KISS」。
拖放元件
回頭說拖放上傳。像這種拖放型的檔案上傳元件,追求事半功倍的我輩開發人,當然是找人家封裝好的元件來用,例如 Uppy 和 FilePond。
Uppy 範例:
Uploaded files:
自幹拖放元件
如果想要自己實現呢?那我們得先搞懂與拖放有關的事件:
乍看好像很複雜,其實並不會,我們用 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()
,這部分的分享就點到為止,待有朝一日本人真的有親自用過再來分享。
檔案上傳了,接著是前端該怎麼處理檔案的問題。