扔掉 Electron 後,我用 Tauri + Rust + Wasm 開發了一個圖片壓縮應用

語言: CN / TW / HK

summary: 使用 Tauri + Rust + Wasm 開發一個本地圖片壓縮應用,支援 png、jpg、gif 格式,從此告別圖片檔案過大的煩惱

tags: tauri、rust、wasm、webassembly、圖片壓縮

author: 大熊


前言

作為前端開發人員,你是否受夠了每次 UI 給到的切圖都大到讓你想提桶跑路的煩惱,在你打算提桶之前,請先留步,看完這篇文章再跑不遲~

先露個臉

duibi-1.png

我管它叫 Image Tiny

大致看一下壓縮率,png 和 jpg 格式基本都能達到 80% 左右,gif 能達到 11% 左右。

心動麼💓,是不是還不錯。

功能介紹

  • 支援的圖片格式包括 png、jpg、gif
  • 支援 5M 以上圖片壓縮
  • 不依賴網路,不依賴伺服器,基於客戶端本地壓縮
  • 支援拖拽圖片檔案進行壓縮
  • 支援壓縮質量引數調整
  • 支援視窗置頂
  • 支援單張圖片儲存
  • 支援一鍵打包,把列表中所有圖片打一個zip包儲存到本地

壓縮率對比 TinyPNG

TinyPNG 這個線上圖片壓縮網站想必作為前端開發人員一定很熟悉,它支援 png,jpg,webp 格式的圖片壓縮,而且壓縮率也很不錯,用過它的小夥伴一定不再少數。因此 Image Tiny 就選擇和 TinyPNG 進行對比。

以下是 4 張 png、2 張 jpg、2 張 gif 圖片的壓縮資料對比:

duibi-1.png

duibi-2.png

對於 png 格式的圖片,Image Tiny 的壓縮率基本能達到 TinyPNG 的水平;

對於 jpg 格式的圖片,經測試把 Image Tiny 壓縮質量調整到 80% 以下,是可以超越 TinyPNG 的;

對於 gif 格式的圖片,不好意思,TinyPNG 不支援;

對於 5 MB 及以上的圖片,不好意思,TinyPNG 也不支援;

綜合來看,我們的 Image Tiny 還是很不錯的麼,而且還完全免費、不依賴網路、不依賴伺服器。

技術實現

壓縮核心

藉助 libimagequant、libpng、libjpeg、gifsicle 這幾個 C 語言的庫實現圖片壓縮, 使用 Emscripten SDK (emsdk) 將 C 程式碼編譯為 wasm 檔案,供瀏覽器端呼叫。

具體壓縮的程式碼此處就不展示了,本專案有開源的計劃,到時候大家自然可以看到。

應用框架

Tauri + Rust + Vue3.0 + Vite

如果對 Tauri 還不熟悉,可以翻看之前發表的一篇文章:

掘金鍊接🔗:扔掉 Electron,擁抱基於 Rust 開發的 Tauri

程式碼小窺

1.視窗置頂功能

```javascript import { window } from '@tauri-apps/api';

// 視窗置頂 function handleWindowTop() { let curWin = window.getCurrent(); if (datas.winTop === '視窗置頂') { curWin.setAlwaysOnTop(true); datas.winTop = '取消置頂'; } else { curWin.setAlwaysOnTop(false); datas.winTop = '視窗置頂'; } } ```

@tauri-apps/api 引入 window api, 通過 window.getCurrent 方法獲取到當前視窗例項,例項上有一個 setAlwaysOnTop 方法,通過引數 true\false 可以控制視窗置頂或者取消置頂。

至於為什麼要給應用新增視窗置頂🔝功能,這裡先挖個坑,後面會填上。

2.應用選單項及快捷鍵新增

main.rs

```rust use tauri::{Menu, MenuItem, Submenu};

fn main() { let submenu_main = Submenu::new( "ImageTiny".to_string(), Menu::new() .add_native_item(MenuItem::Minimize) .add_native_item(MenuItem::Hide) .add_native_item(MenuItem::Separator) .add_native_item(MenuItem::CloseWindow) .add_native_item(MenuItem::Quit), );

let menu = Menu::new().add_submenu(submenu_main);

tauri::Builder::default() .menu(menu) .run(tauri::generate_context!()) .expect("error while running tauri application"); } ```

引入 Menu, MenuItem, Submenu,通過 Submenu::new() 方法新建一個選單項,呼叫 add_native_item 方法新增原生的 MenuItem 項在其中,然後新建一個 Menu,通過 Menu::new().add_submenu() 方法將 Submenu 新增到選單中,最後通過 tauri::Builder::default().menu() 將選單註冊到應用。

3.拖拽圖片進行壓縮功能

```html

...

```

javascript function dragenterEvent(event) { event.stopPropagation(); event.preventDefault(); } function dragoverEvent(event) { event.stopPropagation(); event.preventDefault(); } function dragleaveEvent(event) { event.stopPropagation(); event.preventDefault(); } function dropEvent(event) { event.stopPropagation(); event.preventDefault(); const files = event.dataTransfer.files; displayChsFile(files); }

使用的是瀏覽器的 drag 事件來監聽拖拽,在 drop 事件中拿到檔案資訊。

drop 事件返回的引數是一個 DragEvent 物件,該物件上有一個 dataTransfer 欄位,該欄位下面有一個 files 欄位 儲存的就是我們需要的 FileList,裡面就是我們拖拽的 File Object; 獲取到 FileList 後,傳遞給 displayChsFile 方法進行遍歷壓縮圖片檔案。

drop.png

tips:需要禁用掉 tauri 提供的檔案拖拽事件,程式碼如下

tauri.conf.json { ... "tauri": { "windows": [ { ... "fileDropEnabled": false, ... } ], } ... }

4.圖片檔案壓縮功能

為了方便公司其他專案接入圖片壓縮功能,我將這塊核心程式碼封裝了一個私有 npm 外掛,方便各個專案的接入,之後可以考慮開源出去,在 npm 上釋出一個公共的外掛,供所有小夥伴可以使用。下面大致看一下程式碼:

```javascript import pngtiny from '../plugins/pngtiny'

/* * @description: 影象壓縮 * @param {File} file 原始 File 檔案物件 * @param {Number} quality 壓縮質量,10-90,建議 80 * @return {Promise} 壓縮過的 File 檔案物件 / const imageTiny = (file, quality = 80) => { pngtiny.run() return new Promise((resolve, reject) => { try { const reader = new FileReader() reader.readAsArrayBuffer(file) reader.onload = function(e) { const fcont = new Uint8Array(e.target.result) const fsize = fcont.byteLength const dataptr = pngtiny._malloc(fsize) const retdata = pngtiny._malloc(4) pngtiny.HEAPU8.set(fcont, dataptr) pngtiny._tiny(dataptr, fsize, retdata, quality) let rdata = new Int32Array(pngtiny.HEAPU8.buffer, retdata, 1) const size = rdata[0] rdata = new Uint8Array(pngtiny.HEAPU8.buffer, dataptr, size) const blob = new Blob([rdata], { type: file.type }) let outFile = new File([blob], file.name, { type: file.type }) if (outFile.size === 0) { outFile = file } resolve(outFile) pngtiny._free(dataptr) pngtiny._free(retdata) } } catch (error) { reject(error) } }) } export default imageTiny ```

通過 emsdk 將 C 程式碼編譯為 WebAssembly 時,會生成一個 .wasm 檔案和一個 .js 的膠水程式碼,這個 js 膠水程式碼會處理 wasm 檔案,我們只需要使用匯出的 pngtiny 物件即可,上面包含了我們需要使用的方法。

imageTiny 方法入引數為:

  • file:File 檔案物件
  • quality:壓縮質量

輸出為:

  • 壓縮過的 File 檔案物件

5.儲存單個圖片功能

javascript import { writeBinaryFile } from '@tauri-apps/api/fs'; import { path, dialog } from '@tauri-apps/api'; // 儲存單個圖片 async function handleSaveFile(file) { datas.tip = '圖片儲存中...'; const basePath = await path.downloadDir(); let selPath = await dialog.save({ defaultPath: basePath, }); selPath = selPath.replace(/Untitled$/, ''); const reader = new FileReader(); reader.readAsArrayBuffer(file.data); reader.onload = function (e) { let fileU8A = new Uint8Array(e.target.result); writeBinaryFile({ contents: fileU8A, path: `${selPath}${file.data.name}` }); datas.tip = '圖片儲存成功'; }; }

引入 tauri 的 api:writeBinaryFile、path、dialog

使用 dialog.save() 方法開啟一個檔案儲存彈框,供使用者選擇儲存路徑, 該方法有一個 defaultPath 引數來設定預設的儲存路徑,方法返回值就是最終要儲存的檔案路徑。我們選擇系統的預設下載路徑作為預設儲存路徑,可以通過 path.downloadDir() 方法來獲取系統預設的下載路徑。

dialog-save.png

需要特別注意,由於檔案儲存對話方塊中預設提供的檔名是 Untitled,如果使用者沒有手動修改或刪除,那麼 dialog.save() 方法返回的路徑中就會包含一級 Untitled 目錄,我們可以在程式碼層面直接把它擷取掉。

handleSaveFile 方法接受到的引數是一個 File Object,裡面包含了當前圖片檔案的基本資訊以及我們自定義的一些資訊,如下圖:

file.png

通過 FileReader api 讀取到圖片檔案的 Uint8Array 資料,

通過 tauri 提供的 writeBinaryFile api,將檔案寫入本地。writeBinaryFile 接受一個物件引數,包含 contentspath 欄位, contents 就是檔案的 Uint8Array 資料,path 就是要儲存的路徑。

為了使用者的方便,我們在程式碼中將檔名拼接到了路徑上,這樣使用者就不需要在儲存檔案對話方塊中手動填寫檔名了,直接儲存即可。

6.一鍵儲存功能

```javascript import JSZip from 'jszip';

// 一鍵打包儲存 async function handleDownloadAll() { const len = datas.imgList.length; if (len === 0) { return; } datas.tip = 'zip 儲存中...'; const zip = new JSZip(); for (let i = 0; i < len; i++) { zip.file(datas.imgList[i].name, datas.imgList[i].data); } const date = new Date(); const mon = (date.getMonth() + 1 < 10 ? '0' + (date.getMonth() + 1) : date.getMonth() + 1) + ''; const day = date.getDate() + ''; const hour = date.getHours() + '_'; const min = date.getMinutes();

const basePath = await path.downloadDir(); let selPath = await dialog.save({ defaultPath: basePath, }); selPath = selPath.replace(/Untitled$/, '');

zip.generateAsync({ type: 'blob' }).then((content) => { let file = new FileReader(); file.readAsArrayBuffer(content); file.onload = function (e) { let fileU8A = new Uint8Array(e.target.result); writeBinaryFile({ contents: fileU8A, path: ${selPath}IMG_${mon + day + hour + min}.zip }); datas.tip = 'zip 儲存成功'; }; }); } ```

我們藉助 jszip 外掛,來將檔案打為 zip 包。

首先我們使用 new JSZip() 來新建一下 zip 例項,遍歷壓縮過的檔案列表,呼叫 zip.file() 方法將檔案新增進去,file() 方法接收兩個引數,一個是檔名,一個是檔案的內容資料。

接著通過 dialog.save() api 呼叫檔案儲存彈框,拿到需要儲存到的路徑,呼叫 zip.generateAsync 方法生成 zip 包的 ArrayBuffer 資料,通過 writeBinaryFile api,將檔案寫入本地。

tips:zip包的命名方式選擇了獲取年月日資訊來命名。

踩過的坑

1.tauri 版本的選擇

這可以說是最大的一個坑,何出此言,我們接著往下看。

起初在專案搭建初期,我滿心期待的選擇了最新版本的 tauri,可是當功能開發到檔案系統相關的 api 時,遇到了一個嚴重的問題:Unhandled Promise Rejection: cannot traverse directory。tauri 的 fs 相關的 api 不能讀取任意路徑下的檔案。什麼?這還怎麼愉快的玩耍?明明之前有用過還是可以的,難道是因為版本問題?我帶著疑問和 tauri 社群進行了溝通。最終得到的答案是:處於安全考慮,在新版本中 fs 相關的 api 做了安全限制,只能訪問 tauri 提供出來的幾個系統路徑下的檔案。

這肯定行不通,我們不可能讓使用者壓縮個圖片還得事先把圖片放到指定目錄下才能訪問吧。

於是便向 tauri 社群反饋了這個問題,他們表示後續版本會計劃將使用者選擇的路徑新增到白名單裡,來繞過這個限制。

得到了答案後,解決方案就是回退 tauri 版本,於是只能退回到了舊版,具體版本資訊如下:

package.json

json "@tauri-apps/api": "=1.0.0-beta.8", "@tauri-apps/cli": "=1.0.0-beta.10",

Cargo.toml

```rust [build-dependencies] tauri-build = {version = "=1.0.0-beta.4"}

[dependencies] serde = {version = "1.0", features = ["derive"] } serde_json = "1.0" tauri = {version = "=1.0.0-beta.8", features = ["api-all"] } ```

如果有其他小夥伴在使用 tauri 時也遇到了檔案訪問限制問題,可以參照回退到以上版本。

2.檔案上傳方式的選擇

總的來說有三種方式,我們一一來看。

一、input 上傳方式,被 tauri 給禁止掉了,根本打不開檔案選擇對話方塊

二、tauri 提供的全域性拖拽事件

javascript import { listen } from '@tauri-apps/api/event'; listen('tauri://file-drop', async (event) => { console.log(event); });

通過監聽 tauri 提供的 tauri://file-drop 事件,可以拿到事件的 event 物件,裡面會返回檔案路徑。沒錯,僅僅返回了檔案路徑列表,我們還需要遍歷檔案路徑列表使用 fs 相關 api 再來讀取每個路徑對應的檔案。此流程長且耗時,體驗及其不佳。

三、drap & drop 事件

通過監聽 drop 事件,可直接獲取到上傳的 FileList 物件,裡面包含有檔案的具體資訊,可謂方便快捷,所以這個方案也是本文采取的方案。

填個坑:

為什麼新增視窗置頂功能?

因為 Image Tiny 的圖片上傳方式是拖拽上傳,如果沒有視窗置頂功能的話,很容易被其他應用遮擋,勢必極大降低使用體驗。有了置頂功能,使用者就無需擔心遮擋的問題了。

安裝包貢獻

github.png

超鏈:GitHub

🔗:https://github.com/mxismean/image-tiny-package.git

.dmg 格式檔案為 Mac 安裝包

.msi 格式檔案為 Windows 安裝包

歡迎大家下載安裝使用,好用的話別忘了給文章點個贊👍🏻,如果能轉發就更好了,以便更多的小夥伴可以看到。

總結

整個應用差不多耗費了我近一週時間才開發完成,期間也是踩坑無數,不斷的摸爬滾打,尋找問題解決方案。但是當開發完成時,內心還是非常喜悅的,也希望後續能使用 Tauri 開發出來更多的小工具給大家帶來一點便利~