在 Web 开发中,图片处理是一个常见需求。传统方案要么依赖服务端处理,要么使用 Canvas API,但前者增加服务器负担,后者在压缩质量上不尽人意。Google 的 Squoosh 项目提供了基于 WASM 的高质量图片编解码器,但直接使用比较繁琐。
于是我封装了 use-squoosh,一个零依赖的浏览器端图片转换库,通过 CDN 按需加载编解码器,开箱即用。
为什么需要这个库
现有方案的局限性
| 方案 | 优点 | 缺点 |
|---|---|---|
| 服务端处理 | 稳定可靠 | 增加服务器负担、网络开销 |
| Canvas API | 无依赖 | JPEG 质量差、不支持 WebP 编码 |
| 直接使用 @jsquash | 质量好 | 需要手动管理多个包、配置 WASM |
| 在线工具 | 简单 | 隐私风险、批量处理不便 |
Canvas 的质量问题
Canvas 的 toBlob() 和 toDataURL() 方法虽然简单,但存在明显缺陷:
1// Canvas 方式 2canvas.toBlob(callback, 'image/jpeg', 0.8); 3
问题:
- 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 加载。
1// 编解码器通过动态 import 从 CDN 加载 2const url = `${cdnConfig.baseUrl}/@jsquash/webp@${version}/encode.js`; 3const module = await import(/* @vite-ignore */ url); 4
好处:
- 库本身体积极小(< 5KB gzipped)
- 编解码器按需加载,不使用的格式不会下载
- 利用 CDN 缓存,多项目共享同一份 WASM
加载时机:
- 首次调用转换函数时加载对应格式的编解码器
- 加载后缓存到
window对象,页面内复用 - 支持预加载关键格式
Promise 缓存避免竞态
并发场景下可能同时触发多次加载:
1// 错误示例:可能重复加载 2async function getEncoder() { 3 if (!cache.encoder) { 4 cache.encoder = await import(url); // 并发时会多次触发 5 } 6 return cache.encoder; 7} 8
解决方案是缓存 Promise 而非结果:
1// 正确示例:缓存 Promise 2async function getCodec(type: CodecType): Promise<any> { 3 const cache = getCache(); 4 if (!cache[type]) { 5 // 缓存 Promise 本身,而非 await 后的结果 6 cache[type] = import(/* @vite-ignore */ url); 7 } 8 const module = await cache[type]; 9 return module.default; 10} 11
这样即使并发调用,也只会触发一次网络请求。
全局缓存支持多项目共享
编解码器挂载到 window 对象:
1function getCache(): CodecCache { 2 if (typeof window !== "undefined") { 3 const key = cdnConfig.cacheKey; 4 if (!(window as any)[key]) { 5 (window as any)[key] = createEmptyCache(); 6 } 7 return (window as any)[key]; 8 } 9 return moduleCache; // 非浏览器环境回退 10} 11
好处:
- 同一页面多个组件/库使用 use-squoosh,共享编解码器
- 页面导航不重新加载(SPA 场景)
- 可配置
cacheKey实现隔离
实现细节
格式自动检测
当输入是 Blob 或 File 时,自动从 MIME 类型检测格式:
1const FORMAT_MAP: Record<string, ImageFormat> = { 2 "image/png": "png", 3 "image/jpeg": "jpeg", 4 "image/webp": "webp", 5 // 同时支持扩展名 6 png: "png", 7 jpeg: "jpeg", 8 jpg: "jpeg", 9 webp: "webp", 10}; 11 12export async function convert( 13 input: ArrayBuffer | Blob | File, 14 options: ConvertOptions = {}, 15): Promise<ArrayBuffer> { 16 let buffer: ArrayBuffer; 17 let fromFormat = options.from; 18 19 if (input instanceof Blob || input instanceof File) { 20 buffer = await input.arrayBuffer(); 21 // 自动检测格式 22 if (!fromFormat && input.type) { 23 fromFormat = getFormat(input.type) ?? undefined; 24 } 25 } else { 26 buffer = input; 27 } 28 29 // ... 30} 31
解码 -> 编码流程
图片转换本质是:解码为 ImageData → 编码为目标格式。
1export async function decode( 2 buffer: ArrayBuffer, 3 type: ImageFormat, 4): Promise<ImageData> { 5 switch (type.toLowerCase()) { 6 case "png": { 7 const decoder = await getPngDecoder(); 8 return decoder(buffer); 9 } 10 case "jpeg": 11 case "jpg": { 12 const decoder = await getJpegDecoder(); 13 return decoder(buffer); 14 } 15 case "webp": { 16 const decoder = await getWebpDecoder(); 17 return decoder(buffer); 18 } 19 default: 20 throw new Error([`Unsupported decode type: ${type}`](https://xplanc.org/primers/document/zh/10.Bash/90.%E5%B8%AE%E5%8A%A9%E6%89%8B%E5%86%8C/EX.type.md)); 21 } 22} 23 24export async function encode( 25 imageData: ImageData, 26 type: ImageFormat, 27 options: { quality?: number } = {}, 28): Promise<ArrayBuffer> { 29 switch (type.toLowerCase()) { 30 case "png": { 31 const encoder = await getPngEncoder(); 32 return encoder(imageData); // PNG 无损,不需要 quality 33 } 34 case "jpeg": 35 case "jpg": { 36 const encoder = await getJpegEncoder(); 37 return encoder(imageData, { quality: options.quality ?? 75 }); 38 } 39 case "webp": { 40 const encoder = await getWebpEncoder(); 41 return encoder(imageData, { quality: options.quality ?? 75 }); 42 } 43 default: 44 throw new Error([`Unsupported encode type: ${type}`](https://xplanc.org/primers/document/zh/10.Bash/90.%E5%B8%AE%E5%8A%A9%E6%89%8B%E5%86%8C/EX.type.md)); 45 } 46} 47
CDN 配置系统
支持自定义 CDN 地址和版本:
1export interface CDNConfig { 2 baseUrl?: string; // CDN 基础路径 3 webpVersion?: string; // @jsquash/webp 版本 4 pngVersion?: string; // @jsquash/png 版本 5 jpegVersion?: string; // @jsquash/jpeg 版本 6 cacheKey?: string; // window 缓存 key 7} 8 9const defaultCDNConfig: Required<CDNConfig> = { 10 baseUrl: "https://cdn.jsdelivr.net/npm", 11 webpVersion: "1.5.0", 12 pngVersion: "3.1.1", 13 jpegVersion: "1.6.0", 14 cacheKey: "__ImageConverterCache__", 15}; 16
智能缓存清除: 只有 CDN 相关配置变更时才清除缓存:
1export function configure(config: CDNConfig): void { 2 const cdnKeys: (keyof CDNConfig)[] = [ 3 "baseUrl", "webpVersion", "pngVersion", "jpegVersion", 4 ]; 5 6 // 只有这些字段变更才清除缓存 7 const needsClearCache = cdnKeys.some( 8 (key) => key in config && config[key] !== cdnConfig[key], 9 ); 10 11 cdnConfig = { ...cdnConfig, ...config }; 12 13 if (needsClearCache) { 14 clearCache(); 15 } 16} 17
编解码器 URL 生成
统一管理编解码器的包名、版本和文件路径:
1const codecConfig: Record< 2 CodecType, 3 { pkg: string; version: keyof CDNConfig; file: string } 4> = { 5 webpEncoder: { pkg: "@jsquash/webp", version: "webpVersion", file: "encode.js" }, 6 webpDecoder: { pkg: "@jsquash/webp", version: "webpVersion", file: "decode.js" }, 7 pngEncoder: { pkg: "@jsquash/png", version: "pngVersion", file: "encode.js" }, 8 pngDecoder: { pkg: "@jsquash/png", version: "pngVersion", file: "decode.js" }, 9 jpegEncoder: { pkg: "@jsquash/jpeg", version: "jpegVersion", file: "encode.js" }, 10 jpegDecoder: { pkg: "@jsquash/jpeg", version: "jpegVersion", file: "decode.js" }, 11}; 12 13async function getCodec(type: CodecType): Promise<any> { 14 const cache = getCache(); 15 if (!cache[type]) { 16 const { pkg, version, file } = codecConfig[type]; 17 const url = `${cdnConfig.baseUrl}/${pkg}@${cdnConfig[version]}/${file}`; 18 cache[type] = import(/* @vite-ignore */ url); 19 } 20 const module = await cache[type]; 21 return module.default; 22} 23
使用方式
基本使用
1import { convert, pngToWebp, compress } from 'use-squoosh'; 2 3// 文件选择器获取图片 4const file = input.files[0]; 5 6// PNG 转 WebP 7const webpBuffer = await pngToWebp(file, { quality: 80 }); 8 9// 通用转换 10const result = await convert(file, { 11 from: 'png', // Blob/File 可省略,自动检测 12 to: 'webp', 13 quality: 85 14}); 15 16// 压缩(保持原格式) 17const compressed = await compress(file, { 18 format: 'jpeg', 19 quality: 70 20}); 21
配置 CDN
1import { configure } from 'use-squoosh'; 2 3// 使用 unpkg 4configure({ baseUrl: 'https://unpkg.com' }); 5 6// 使用自托管 CDN 7configure({ baseUrl: 'https://your-cdn.com/npm' }); 8 9// 锁定特定版本 10configure({ 11 webpVersion: '1.5.0', 12 pngVersion: '3.1.1', 13 jpegVersion: '1.6.0' 14}); 15
预加载优化首屏
1import { preload, isLoaded } from 'use-squoosh'; 2 3// 页面加载时预加载常用格式 4await preload(['webp', 'png']); 5 6// 检查加载状态 7if (isLoaded('webp')) { 8 // WebP 编解码器已就绪 9} 10
工具函数
1import { toBlob, toDataURL, download } from 'use-squoosh'; 2 3const buffer = await pngToWebp(file); 4 5// 转为 Blob 6const blob = toBlob(buffer, 'image/webp'); 7 8// 转为 Data URL(用于 img.src) 9const dataUrl = await toDataURL(buffer, 'image/webp'); 10 11// 触发下载 12download(buffer, 'converted.webp', 'image/webp'); 13
自托管 CDN
如果不想依赖公共 CDN,可以自托管编解码器文件。
目录结构要求
1your-cdn.com/npm/ 2 @jsquash/ 3 webp@1.5.0/ 4 encode.js 5 decode.js 6 png@3.1.1/ 7 encode.js 8 decode.js 9 jpeg@1.6.0/ 10 encode.js 11 decode.js 12
获取文件
从 npm 下载对应版本:
1# 下载 @jsquash 包 2npm pack @jsquash/webp@1.5.0 3npm pack @jsquash/png@3.1.1 4npm pack @jsquash/jpeg@1.6.0 5 6# 解压并部署到 CDN 7
配置使用
1configure({ 2 baseUrl: 'https://your-cdn.com/npm', 3 webpVersion: '1.5.0', 4 pngVersion: '3.1.1', 5 jpegVersion: '1.6.0' 6}); 7
压缩效果对比
以一张 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:完整类型定义,开发体验好
项目已开源:github.com/wsafight/us…
欢迎提出 issue 和 PR。
参考资料
- Squoosh - Google 的在线图片压缩工具
- jSquash - Squoosh 编解码器的 npm 封装
- WebAssembly - 浏览器端高性能运行时
《基于 Squoosh WASM 的浏览器端图片转换库》 是转载文章,点击查看原文。