WebAssembly前端实战:图片压缩从5秒到60毫秒
发布日期: 2026/08/05 阅读总量: 0

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.jsimg2rle_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%,前端预览全程不掉帧。你如果也在做类似的重计算场景,把这条路走通。