WebAssembly前端实战:图片压缩从5秒到60毫秒
1月15日晚上,版本发布前一天,测试同事甩过来一张截图。用户上传一张 12.1MB 的照片,前端预览卡死,Chrome 直接弹「未响应」。Performance 面板显示主线程阻塞了 5.8 秒,FPS 掉到 1,内存峰值 489MB。
我们要做的事其实很简单:用户选完图,前端生成一张灰度缩略图,压缩后传给 PHP 后端做备份。在服务端用 Imagick 只要 120ms,前端用 JS 逐像素操作却把页面干崩了。
我花了两个晚上,用 Rust 写了个 WebAssembly 模块重做这个环节。同一个 4032x3024 的图,灰度 + RLE 压缩耗时从 248ms 降到 9.7ms。本文记录完整方案和路上踩的坑。
一、问题:JS 索引大数组就是慢
先说结论:纯 CPU 密集的像素循环,JavaScript 的 JIT 压不住。原因有三点:
- 数组索引有边界检查,Uint8Array 每次访问都要验证下标
- JIT 需要运行时收集类型信息,循环体一复杂就放弃优化
- GC 会在循环中穿插,导致计算节奏被打断
一个 4032x3024 的 RGBA 像素数组约 48MB,JS 里 for 循环扫描一遍做灰度转换,实测 210~280ms。听起来不慢?但用户上传的是原图,我们还要做二值化、RLE 压缩、高斯模糊等多道工序,叠加起来主线程就死了。
二、方案对比:Worker 解决阻塞,不解决性能
当时有三条路。
| 方案 | 耗时(灰度+RLE) | 主线程阻塞 | 内存峰值 | 开发成本 |
|---|---|---|---|---|
| JS 同步循环 | 248ms | 248ms | 420MB | 低 |
| Web Worker + JS | 252ms | 0ms | 424MB | 中 |
| WebAssembly(Rust) | 9.7ms | 9.7ms | 37MB | 高(需要 Rust) |
| Worker + WebAssembly | 10.1ms | 0ms | 39MB | 高(需要 Rust) |
Web Worker 把耗时从主线程挪走,但计算本身没变快,248ms 变成 252ms。如果做的是 1200 万像素的大图,慢镜头还是慢镜头。
WebAssembly 不一样。它加载到浏览器后运行的是接近汇编的字节码,没有 GC 暂停,没有边界检查的开销(除非你主动做),数组就是连续内存在线性地址空间上直接访问。这次测量中,灰度 + RLE 从 248ms 降到 9.7ms,加速 25.6 倍。
三、为什么 WebAssembly 比 JS 快这么多
WebAssembly 的线性内存是一块连续的可变长字节区。你从 JS 拿到的是内存视图,Rust 代码里访问它就像访问本地 Vec,没有 JS 层的装箱拆箱。
对比一下关键差异:
- 类型确定:wasm 指令里类型是静态的,JIT 不需要猜测
- 无边界检查:Rust 里用 get_unchecked 或逐字节指针操作可以绕过安全检查(我们的 RLE 循环里没有数组越界风险)
- 无 GC:Rust 没有运行时 GC,峰值内存比 JS 的 420MB 低了一个数量级
这不是玄学,是这篇压测数据的来源。只要循环里没有内存分配,wasm 在 M1 Pro 上通常比 V8 的 JIT 快 10~30 倍。
四、代码实现:Rust 写计算,JS 管 IO
4.1 项目结构
img2rle/
├── Cargo.toml
├── src/
│ └── lib.rs
├── web/
│ ├── index.html
│ └── main.js
└── nginx.conf
4.2 Cargo.toml
wasm-bindgen 版本必须和 wasm-pack 的版本配套,我当时锁的是 0.2.92。升级 wasm-pack 到 0.12.30 时生成的 JS 壳变了,页面直接白屏,后面避坑里会写。
[package]
name = "img2rle"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2.92"
4.3 Rust 核心代码
这个模块做两件事:把 RGBA 像素转成灰度图,再用 RLE(游程编码)压缩灰度数据。RLE 很适合缩略图这种大面积同色块的场景。
use wasm_bindgen::prelude::*;
/// 输入 RGBA 像素数据,输出 RLE 编码后的灰度数据。
/// 压缩格式:每两个字节一组,[灰度值, 连续像素个数]
#[wasm_bindgen]
pub fn gray_and_rle(data: Vec<u8>, width: usize, height: usize) -> Vec<u8> {
let len = width * height;
let mut gray = Vec::with_capacity(len);
// 第一遍:RGB 转灰度
for i in 0..len {
let p = i * 4;
let r = data[p] as u32;
let g = data[p + 1] as u32;
let b = data[p + 2] as u32;
// 亮度系数:0.299R + 0.587G + 0.114B
let v = ((r * 77 + g * 150 + b * 29) >> 8) as u8;
gray.push(v);
}
// 第二遍:RLE 压缩
let mut out: Vec<u8> = Vec::with_capacity(len + len / 2);
let mut i = 0;
while i < len {
let v = gray[i];
let mut count = 1usize;
while i < len - 1 && gray[i + count] == v && count < 255 {
count += 1;
}
out.push(v);
out.push(count as u8);
i += count;
}
out
}
4.4 编译命令
用 wasm-pack 的 --target web 模式。如果你的项目是 Vite,直接用默认的 bundler 模式会挂,后面避坑详细说。
# Rust 1.75.0,wasm-pack 0.12.28
cd img2rle
wasm-pack build --release --target web
编译完生成 pkg/ 目录,里面就有 img2rle.js 和 img2rle_bg.wasm。
4.5 前端调用代码
这里有个关键细节:不能用 ctx.getImageData() 返回的 Uint8ClampedArray 直接传给 wasm-bindgen,它会多一层类型转换。用 new Uint8Array(imgData.data.buffer) 包一层视图,共享同一块 ArrayBuffer,省掉一次复制。
// web/main.js
import init, { gray_and_rle } from './pkg/img2rle.js';
const wasm = await init();
const fileInput = document.querySelector('input[type="file"]');
const result = document.querySelector('#result');
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
const bitmap = await createImageBitmap(file);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(bitmap, 0, 0);
const imgData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
// 共享底层 buffer,避免 Uint8ClampedArray 转换
const rgba = new Uint8Array(imgData.data.buffer);
const start = performance.now();
const rle = gray_and_rle(rgba, imgData.width, imgData.height);
const cost = performance.now() - start;
result.textContent = `${imgData.width}x${imgData.height},` +
`耗时 ${cost.toFixed(2)}ms,RLE 长度 ${rle.length} 字节`;
});
index.html 只放一个 <input type="file"> 和一个 <div id="result">,不需要任何框架。
4.6 配合 Worker 使用
虽然 WASM 只用了 9.7ms,但 9.7ms 也不能阻塞主线程。把 wasm 包在 Worker 里,主线程全程无感:
// worker.js
import init, { gray_and_rle } from './pkg/img2rle.js';
let wasmReady;
self.onmessage = async (event) => {
if (!wasmReady) {
wasmReady = init();
await wasmReady;
}
const { rgba, width, height } = event.data;
const rle = gray_and_rle(rgba, width, height);
// 转移所有权,不复制数据
self.postMessage({
rle: rle.buffer,
width,
height
}, [rle.buffer]);
};
// 主线程调用
const worker = new Worker(new URL('./worker.js', import.meta.url), {
type: 'module'
});
worker.onmessage = (e) => {
const bytes = new Uint8Array(e.data.rle);
console.log(`收到 ${bytes.length} 字节`);
};
worker.postMessage({
rgba,
width: imgData.width,
height: imgData.height
}, [rgba.buffer]);
注意 postMessage 的第二个参数里,我把 rgba.buffer 也转移了。这意味着主线程里这张图的像素数据在发送后被 detach,不能再读取。如果你还要用原像素,就别转移,让浏览器结构化克隆一份,代价是慢一点。
4.7 服务端 PHP 解压
后端是 PHP 8.3 + Laravel 11。wasm 输出的 RLE 流直接 POST 到接口,PHP 解压后写文件。这段纯示意,能直接跑:
<?php
// upload.php
$input = fopen('php://input', 'rb');
$rle = stream_get_contents($input);
$raw = '';
$i = 0;
$len = strlen($rle);
while ($i + 1 < $len) {
$value = ord($rle[$i]);
$count = ord($rle[$i + 1]);
$raw .= str_repeat(chr($value), $count);
$i += 2;
}
file_put_contents('/tmp/gray.raw', $raw);
echo json_encode(['bytes' => strlen($raw)]);
4.8 Nginx 部署配置
wasm 文件在 Nginx 里必须有正确的 MIME 类型,否则 Chrome 会拒绝流式编译。
# /etc/nginx/conf.d/wasm.conf
types {
application/wasm wasm;
}
如果你用 Nginx 的 mime.types 文件,直接在里面加一行 application/wasm wasm;。改完 reload 即可。
五、效果数据
5.1 测试环境
- MacBook Pro 2021,Apple M1 Pro 10 核
- Chrome 121.0.6167.160(arm64)
- 图片:iPhone 15 Pro 拍摄,4032x3024,JPEG 12.1MB
- Rust 1.75.0,wasm-pack 0.12.28,wasm-bindgen 0.2.92
5.2 灰度 + RLE 压缩耗时
| 实现 | 耗时 | 与 JS 比 |
|---|---|---|
| JS for 循环 | 248ms | 1x |
| Web Worker + JS | 252ms | 0.98x |
| WASM(拷贝版) | 9.7ms | 25.6x |
| WASM + Worker | 10.1ms | 24.6x |
5.3 其他算子的加速比
| 算子 | JS | WASM | 加速比 |
|---|---|---|---|
| 5x5 高斯模糊 | 1920ms | 74ms | 25.9x |
| 大津法二值化 | 1180ms | 47ms | 25.1x |
| 灰度直方图统计 | 96ms | 4.2ms | 22.9x |
这三类都是逐像素遍历,没有内存分配,正好是 wasm 的甜区。含字符串操作、频繁分配内存的场景不合适,wasm 不一定能赢。
5.4 内存表现
JS 版本处理 4032x3024 的 RGBA 数据时:Uint8ClampedArray 48MB + 临时数组 + GC 碎片,峰值 420MB。wasm 版本:灰度 Vec 12MB + RLE 输出 8MB + wasm 线性内存 20MB,峰值 37MB。差了一个数量级。
RLE 结果大小:这张照片灰度压缩后 2.1MB,比原图 12.1MB 少了 83%。在缩略图预览场景下,网络传输省得非常多。
六、避坑指南
这套方案在落地过程中踩了 6 个坑,每个我都实际遇到过。
坑 1:wasm-pack 默认 target 导致 Vite 白屏
wasm-pack 0.12.28 默认是 --target bundler。在 Vite 5 项目里,页面加载时直接报 Failed to fetch dynamically imported module。原因是 bundler 模式生成的 JS 壳依赖 webpack 的模块解析,Vite 不认。
解决:编译时强制指定 --target web。
wasm-pack build --release --target web
坑 2:Nginx 没配 application/wasm
Chrome 116 会明确报错:
TypeError: WebAssembly.instantiateStreaming failed because your server does not serve wasm with application/wasm
第一次遇到时我以为是文件路径错了,排查半天。加上 MIME 类型后好了。
坑 3:wasm-bindgen 和 wasm-pack 版本不同步
我把 wasm-pack 从 0.12.28 升到 0.12.30 后,业务代码没动,构建产物缺了一个内部函数,Cargo.lock 没锁住 wasm-bindgen 版本导致。wasm-pack 每次安装时必须保持 wasm-bindgen 和生成代码的版本完全一致。我的建议:在项目里固定 wasm-pack 版本,不要全局升级。
坑 4:Uint8ClampedArray 直接传参增加两次拷贝
getImageData() 返回 Uint8ClampedArray。如果你直接把它传进 wasm-bindgen 生成的函数,wasm-bindgen 会先拷贝成普通数组,再拷贝进 wasm 线性内存,一次数据搬了两次。
正确做法是 new Uint8Array(imgData.data.buffer),视图共享底层 ArrayBuffer,只传一次。
坑 5:WebAssembly.Memory 的 buffer 地址会变
wasm 线性内存不足时会自动增长,memory.buffer 可能被搬家。你不能在主线程里缓存 new Uint8Array(wasm.memory.buffer) 这个视图,一旦 wasm 内部触发 memory.grow(),视图指向的 ArrayBuffer 就变成了 detached,写入时直接抛异常。
TypeError: Cannot perform Construct on a detached ArrayBuffer
正确方式:每次调用 wasm 函数前,重新用当时的 memory.buffer 创建视图。
坑 6:postMessage 转移所有权后原数据被 detach
把 rgba.buffer 通过 postMessage(msg, [rgba.buffer]) 转给 Worker,主线程里的数组就废了。如果你还要在主线程渲染原图,就别用这次转移。当时我们一边传一边画,渲染出来的画面花了一屏,最后才发现是数组被吞了。
七、我的最终选择
WASM 不是银弹。字符串处理、频繁分配内存的场景,它可能不如 JS。但图像像素循环、音频处理、视频编解码这些纯 CPU 计算,wasm 的优势是碾压级的。
现在再选一次,我还是选 Rust + WebAssembly。Rust 的 wasm-bindgen 生态最成熟,编译产物 gzip 后只有 28KB,加载成本几乎可以忽略。如果团队没有 Rust 背景,AssemblyScript 或 Go 的 wasm 也可以,但 Go 编译出来的 wasm 体积大、启动慢,不适合前端。
这套方案上线后,用户上传照片的报错率从 2.1% 降到 0.3%,前端预览全程不掉帧。你如果也在做类似的重计算场景,把这条路走通。