在 Web 開發中,圖片處理是一個常見需求。傳統方案要麼依賴服務端處理,要麼使用 Canvas API,但前者增加服務器負擔,後者在壓縮質量上不盡人意。Google 的 Squoosh 項目提供了基於 WASM 的高質量圖片編解碼器,但直接使用比較繁瑣。
於是我封裝了 use-squoosh,一個零依賴的瀏覽器端圖片轉換庫,通過 CDN 按需加載編解碼器,開箱即用。
為什麼需要這個庫
現有方案的侷限性
| 方案 | 優點 | 缺點 |
|---|---|---|
| 服務端處理 | 穩定可靠 | 增加服務器負擔、網絡開銷 |
| Canvas API | 無依賴 | JPEG 質量差、不支持 WebP 編碼 |
| 直接使用 @jsquash | 質量好 | 需要手動管理多個包、配置 WASM |
| 在線工具 | 簡單 | 隱私風險、批量處理不便 |
Canvas 的質量問題
Canvas 的 toBlob() 和 toDataURL() 方法雖然簡單,但存在明顯缺陷:
// Canvas 方式
canvas.toBlob(callback, 'image/jpeg', 0.8);
問題:
- JPEG 編碼器質量較差,同等文件大小下清晰度不如專業編碼器
- 不支持 WebP 編碼(部分舊瀏覽器)
- 無法精確控制編碼參數
Squoosh 的優勢
Squoosh 是 Google Chrome Labs 開發的圖片壓縮工具,其核心是一系列編譯為 WASM 的高性能編解碼器:
- MozJPEG:Mozilla 優化的 JPEG 編碼器,同等質量下文件更小
- libwebp:Google 官方 WebP 編解碼器
- OxiPNG:Rust 編寫的 PNG 優化器
@jsquash 將這些編解碼器封裝為獨立的 npm 包,但直接使用需要:
- 安裝多個包(@jsquash/webp、@jsquash/png、@jsquash/jpeg)
- 手動處理 WASM 文件加載
- 管理編解碼器的初始化
use-squoosh 解決了這些問題。
核心設計思路
零依賴 + CDN 加載
最核心的設計決策是:不打包編解碼器,運行時從 CDN 加載。
// 編解碼器通過動態 import 從 CDN 加載
const url = `${cdnConfig.baseUrl}/@jsquash/webp@${version}/encode.js`;
const module = await import(/* @vite-ignore */ url);
好處:
- 庫本身體積極小(< 5KB gzipped)
- 編解碼器按需加載,不使用的格式不會下載
- 利用 CDN 緩存,多項目共享同一份 WASM
加載時機:
- 首次調用轉換函數時加載對應格式的編解碼器
- 加載後緩存到
window對象,頁面內複用 - 支持預加載關鍵格式
Promise 緩存避免競態
併發場景下可能同時觸發多次加載:
// 錯誤示例:可能重複加載
async function getEncoder() {
if (!cache.encoder) {
cache.encoder = await import(url); // 併發時會多次觸發
}
return cache.encoder;
}
解決方案是緩存 Promise 而非結果:
// 正確示例:緩存 Promise
async function getCodec(type: CodecType): Promise<any> {
const cache = getCache();
if (!cache[type]) {
// 緩存 Promise 本身,而非 await 後的結果
cache[type] = import(/* @vite-ignore */ url);
}
const module = await cache[type];
return module.default;
}
這樣即使併發調用,也只會觸發一次網絡請求。
全局緩存支持多項目共享
編解碼器掛載到 window 對象:
function getCache(): CodecCache {
if (typeof window !== "undefined") {
const key = cdnConfig.cacheKey;
if (!(window as any)[key]) {
(window as any)[key] = createEmptyCache();
}
return (window as any)[key];
}
return moduleCache; // 非瀏覽器環境回退
}
好處:
- 同一頁面多個組件/庫使用 use-squoosh,共享編解碼器
- 頁面導航不重新加載(SPA 場景)
- 可配置
cacheKey實現隔離
實現細節
格式自動檢測
當輸入是 Blob 或 File 時,自動從 MIME 類型檢測格式:
const FORMAT_MAP: Record<string, ImageFormat> = {
"image/png": "png",
"image/jpeg": "jpeg",
"image/webp": "webp",
// 同時支持擴展名
png: "png",
jpeg: "jpeg",
jpg: "jpeg",
webp: "webp",
};
export async function convert(
input: ArrayBuffer | Blob | File,
options: ConvertOptions = {},
): Promise<ArrayBuffer> {
let buffer: ArrayBuffer;
let fromFormat = options.from;
if (input instanceof Blob || input instanceof File) {
buffer = await input.arrayBuffer();
// 自動檢測格式
if (!fromFormat && input.type) {
fromFormat = getFormat(input.type) ?? undefined;
}
} else {
buffer = input;
}
// ...
}
解碼 -> 編碼流程
圖片轉換本質是:解碼為 ImageData → 編碼為目標格式。
export async function decode(
buffer: ArrayBuffer,
type: ImageFormat,
): Promise<ImageData> {
switch (type.toLowerCase()) {
case "png": {
const decoder = await getPngDecoder();
return decoder(buffer);
}
case "jpeg":
case "jpg": {
const decoder = await getJpegDecoder();
return decoder(buffer);
}
case "webp": {
const decoder = await getWebpDecoder();
return decoder(buffer);
}
default:
throw new Error(`Unsupported decode type: ${type}`);
}
}
export async function encode(
imageData: ImageData,
type: ImageFormat,
options: { quality?: number } = {},
): Promise<ArrayBuffer> {
switch (type.toLowerCase()) {
case "png": {
const encoder = await getPngEncoder();
return encoder(imageData); // PNG 無損,不需要 quality
}
case "jpeg":
case "jpg": {
const encoder = await getJpegEncoder();
return encoder(imageData, { quality: options.quality ?? 75 });
}
case "webp": {
const encoder = await getWebpEncoder();
return encoder(imageData, { quality: options.quality ?? 75 });
}
default:
throw new Error(`Unsupported encode type: ${type}`);
}
}
CDN 配置系統
支持自定義 CDN 地址和版本:
export interface CDNConfig {
baseUrl?: string; // CDN 基礎路徑
webpVersion?: string; // @jsquash/webp 版本
pngVersion?: string; // @jsquash/png 版本
jpegVersion?: string; // @jsquash/jpeg 版本
cacheKey?: string; // window 緩存 key
}
const defaultCDNConfig: Required<CDNConfig> = {
baseUrl: "https://cdn.jsdelivr.net/npm",
webpVersion: "1.5.0",
pngVersion: "3.1.1",
jpegVersion: "1.6.0",
cacheKey: "__ImageConverterCache__",
};
智能緩存清除: 只有 CDN 相關配置變更時才清除緩存:
export function configure(config: CDNConfig): void {
const cdnKeys: (keyof CDNConfig)[] = [
"baseUrl", "webpVersion", "pngVersion", "jpegVersion",
];
// 只有這些字段變更才清除緩存
const needsClearCache = cdnKeys.some(
(key) => key in config && config[key] !== cdnConfig[key],
);
cdnConfig = { ...cdnConfig, ...config };
if (needsClearCache) {
clearCache();
}
}
編解碼器 URL 生成
統一管理編解碼器的包名、版本和文件路徑:
const codecConfig: Record<
CodecType,
{ pkg: string; version: keyof CDNConfig; file: string }
> = {
webpEncoder: { pkg: "@jsquash/webp", version: "webpVersion", file: "encode.js" },
webpDecoder: { pkg: "@jsquash/webp", version: "webpVersion", file: "decode.js" },
pngEncoder: { pkg: "@jsquash/png", version: "pngVersion", file: "encode.js" },
pngDecoder: { pkg: "@jsquash/png", version: "pngVersion", file: "decode.js" },
jpegEncoder: { pkg: "@jsquash/jpeg", version: "jpegVersion", file: "encode.js" },
jpegDecoder: { pkg: "@jsquash/jpeg", version: "jpegVersion", file: "decode.js" },
};
async function getCodec(type: CodecType): Promise<any> {
const cache = getCache();
if (!cache[type]) {
const { pkg, version, file } = codecConfig[type];
const url = `${cdnConfig.baseUrl}/${pkg}@${cdnConfig[version]}/${file}`;
cache[type] = import(/* @vite-ignore */ url);
}
const module = await cache[type];
return module.default;
}
使用方式
基本使用
import { convert, pngToWebp, compress } from 'use-squoosh';
// 文件選擇器獲取圖片
const file = input.files[0];
// PNG 轉 WebP
const webpBuffer = await pngToWebp(file, { quality: 80 });
// 通用轉換
const result = await convert(file, {
from: 'png', // Blob/File 可省略,自動檢測
to: 'webp',
quality: 85
});
// 壓縮(保持原格式)
const compressed = await compress(file, {
format: 'jpeg',
quality: 70
});
配置 CDN
import { configure } from 'use-squoosh';
// 使用 unpkg
configure({ baseUrl: 'https://unpkg.com' });
// 使用自託管 CDN
configure({ baseUrl: 'https://your-cdn.com/npm' });
// 鎖定特定版本
configure({
webpVersion: '1.5.0',
pngVersion: '3.1.1',
jpegVersion: '1.6.0'
});
預加載優化首屏
import { preload, isLoaded } from 'use-squoosh';
// 頁面加載時預加載常用格式
await preload(['webp', 'png']);
// 檢查加載狀態
if (isLoaded('webp')) {
// WebP 編解碼器已就緒
}
工具函數
import { toBlob, toDataURL, download } from 'use-squoosh';
const buffer = await pngToWebp(file);
// 轉為 Blob
const blob = toBlob(buffer, 'image/webp');
// 轉為 Data URL(用於 img.src)
const dataUrl = await toDataURL(buffer, 'image/webp');
// 觸發下載
download(buffer, 'converted.webp', 'image/webp');
自託管 CDN
如果不想依賴公共 CDN,可以自託管編解碼器文件。
目錄結構要求
your-cdn.com/npm/
@jsquash/
webp@1.5.0/
encode.js
decode.js
png@3.1.1/
encode.js
decode.js
jpeg@1.6.0/
encode.js
decode.js
獲取文件
從 npm 下載對應版本:
# 下載 @jsquash 包
npm pack @jsquash/webp@1.5.0
npm pack @jsquash/png@3.1.1
npm pack @jsquash/jpeg@1.6.0
# 解壓並部署到 CDN
配置使用
configure({
baseUrl: 'https://your-cdn.com/npm',
webpVersion: '1.5.0',
pngVersion: '3.1.1',
jpegVersion: '1.6.0'
});
壓縮效果對比
以一張 1920x1080 的 PNG 截圖為例:
| 輸出格式 | Quality | 文件大小 | 壓縮率 |
|---|---|---|---|
| 原始 PNG | - | 2.1 MB | - |
| WebP | 80 | 186 KB | 91% |
| WebP | 90 | 312 KB | 85% |
| JPEG | 80 | 245 KB | 88% |
| JPEG | 90 | 398 KB | 81% |
WebP 在同等視覺質量下,文件大小比 JPEG 小約 25-35%。
瀏覽器兼容性
需要支持 WebAssembly 和動態 import:
| 瀏覽器 | 最低版本 |
|---|---|
| Chrome | 57+ |
| Firefox | 52+ |
| Safari | 11+ |
| Edge | 16+ |
覆蓋全球 95%+ 的用户。
與其他方案對比
| 特性 | use-squoosh | browser-image-compression | 直接使用 @jsquash |
|---|---|---|---|
| 包大小 | < 5KB | ~50KB | ~2KB × 6 |
| 運行時依賴 | CDN 加載 | 打包在內 | 需手動配置 |
| WebP 支持 | ✅ | ✅ | ✅ |
| PNG 優化 | ✅ | ❌ | ✅ |
| 質量控制 | ✅ | ✅ | ✅ |
| 自動格式檢測 | ✅ | ✅ | ❌ |
| 預加載 | ✅ | ❌ | 需手動 |
| 自定義 CDN | ✅ | ❌ | ❌ |
| TypeScript | ✅ | ✅ | ✅ |
總結
use-squoosh 通過以下設計實現了易用的瀏覽器端圖片轉換:
- 零依賴設計:編解碼器按需從 CDN 加載,庫本身極輕量
- Promise 緩存:避免併發場景重複加載
- 全局共享:多組件/項目複用編解碼器
- 靈活配置:支持自定義 CDN 和版本鎖定
- TypeScript:完整類型定義,開發體驗好
項目已開源:https://github.com/wsafight/use-squoosh
歡迎提出 issue 和 PR。
參考資料
- Squoosh - Google 的在線圖片壓縮工具
- jSquash - Squoosh 編解碼器的 npm 封裝
- WebAssembly - 瀏覽器端高性能運行時