基于 Rust 和 WASM 实现前端图片压缩库:imagequant 的自定义方案

369 阅读6分钟

图片压缩一直是前端优化中至关重要的一环,尤其是在提高响应速度和减少网络传输时。尽管已有许多开源工具,如 Squoosh 和 TinyPNG,但在压缩效果和最新功能支持方面,现有工具常常滞后。本文将介绍如何基于 Rust 和 WebAssembly(WASM)实现一个自定义的 PNG 图片压缩库,并通过 WebWorker 实现异步操作,避免主线程阻塞。

为什么选择 imagequant

在众多的图片压缩库中,imagequant 是一个专注于 PNG 格式的高效压缩工具。其压缩效果优于其他常见的库,如 Squoosh 和 oxiPNG。遗憾的是,Squoosh 使用的 imagequant 版本较老,因此我们决定从头开始编译最新版本,生成符合需求的 WASM 包。

图片类型压缩库结论
PNGoxiPNGsquoosh使用的png压缩库,压缩率很一般,15-25%左右
PNGimagequantcrates.io/crates/imag… 压缩效果≈70% squoosh编译出来的wasm太老了(v2.12.1), 需要自己再编译一次,最新的是(v4.3.0)
JPEGmozJPEGgithub.com/mozilla/moz… 压缩效果≈80%
WEBPlibwebpgithub.com/webmproject… 压缩效果>90%
SVGlibsvgogithub.com/svg/svgo 压缩效果10%~30% 原库svgo只支持node环境,libsvgo提供了浏览器的支持模式
AVIFavif-serializegithub.com/packurl/was… 压缩效果>90%,但当前的兼容性差 squoosh使用的也比较旧, 且Figma不支持SharedArrayBuffer 重新编译了最新的avif-serialize

压缩效果对比

为了确保压缩效果的质量,我们进行了大量的测试,使用对比工具(diffchecker)评估不同压缩工具和设置下的效果。经过测试,imagequant 提供了与 TinyPNG 类似的压缩效果,图片大小缩减至原图的 27.6%(减少 62.4%)。

image.png

使用 Rust 和 WASM 打包 imagequant

相关术语

squoosh:Chrome团队一个开源的客户端图片压缩网站

imagequant:一个处理png图像质量的库,可以减少图片的质量,本文用的是rust版本

wasm-pack:将rust打包成npm包的脚手架工具

crates:一个rust lib下载平台,类似于npm

WebAssembly的两种形态

首先简单介绍一下WebAssembly两种形态:

  • 机器码格式
  • 文本格式

这种文本形式更类似于处理器的汇编指令,因为WebAssembly本身是一门语言,一个小小的实例:

(module
 (table 2 anyfunc)
 (func $f1 (result i32)
  i32.const 42)
(func $f2 (result i32)
 i32.const 13)
(elem (i32.const 0) $f1 $f2)
  (type $return_i32 (func (result i32)))
  (func (export "callByIndex") (param $i i32) (result i32)
   local.get $i
call_indirect $return_i32)
)

一般很少有人直接写文本格式,而是通过其他语言、或者是现存lib来编译成浏览器可用的wasm,这样很多客户端的计算模块只需简单处理都能很快转译成WASM在浏览器使用的模块,极大丰富了浏览器的使用场景。

接着我们先从一个入门实例开始,逐步到自己动手编译一个Rust模块。

Rust在WebAssembly中的简单使用

配置 Rust 环境

首先,需要在 Rust 项目中添加 imagequantwasm-bindgen 依赖,以便将 Rust 代码编译为 WASM:


[package]
name = "tinypng-lib-wasm"
version = "1.0.50"
edition = "2021"
author = ["wacrne"]
description = "TinyPNG Rust WASM Library"
license = "MIT"

[dependencies]
imagequant = { version = "4.2.0", default-features = false }
wasm-bindgen = "0.2.84"
console_error_panic_hook = { version = "0.1.7", optional = true }
lodepng = "3.7.2"

编写 Rust 代码

接下来,编写 Rust 代码以将 imagequant 的功能暴露给 JavaScript。使用 wasm-bindgen 让 Rust 函数可以被调用:


#[wasm_bindgen]
impl Imagequant {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Imagequant {
        Imagequant {
            instance: imagequant::new(),
        }
    }

    pub fn new_image(data: Vec<u8>, width: usize, height: usize, gamma: f64) -> ImagequantImage {
        ImagequantImage::new(data, width, height, gamma)
    }
    // 省略其他实现
}

编译并打包为 NPM 包

通过 wasm-pack 工具将 Rust 代码编译为浏览器可用的 WASM 文件,并生成 NPM 包:

wasm-pack build --target bundler

打包生成npm package

使用 NPM 包

打包完成后,可以将 tinypng-lib-wasm 包作为依赖引入 JavaScript 项目,像下面这样使用:

import { Imagequant, ImagequantImage } from 'tinypng-lib-wasm';

// 获取图片信息
const { width, height, imageData } = await this.getImageBitInfo();
const uint8Array = new Uint8Array(imageData.data.buffer);
const image = new ImagequantImage(uint8Array, width, height, 0);
const instance = new Imagequant();

// 配置压缩质量
instance.set_quality(30, 85);

// 启动压缩
const output = instance.process(image);
const outputBlob = new Blob([output.buffer], { type: 'image/png' });

图片信息提取

使用 FileReadercanvas 获取图片的元数据,并将其传递给 imagequant 进行压缩:

// 获取图片信息:宽、高、像素数据、图片大小
const getImageBitInfo = (file) => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();

    // 创建一个 Image 对象
    const img = new Image();
    img.src = URL.createObjectURL(file);

    img.onload = () => {
      // 创建一个 canvas 元素
      const canvas = document.createElement('canvas');
      canvas.width = img.width;
      canvas.height = img.height;
      const ctx = canvas.getContext('2d');

      if (!ctx) {
        reject(new Error('无法获取 canvas 上下文'));
        return;
      }

      // 将图像绘制到 canvas 上
      ctx.drawImage(img, 0, 0);

      // 获取 ImageData
      const imageData = ctx.getImageData(0, 0, img.width, img.height);
      const data = imageData.data; // Uint8ClampedArray

      // 将 Uint8ClampedArray 转换为普通的 Uint8Array
      const buffer = new Uint8Array(data).buffer;

      // 确保缓冲区长度是 width * height * 4
      const expectedLength = img.width * img.height * 4;
      if (buffer.byteLength !== expectedLength) {
        reject(new Error(`缓冲区长度不匹配:期望 ${expectedLength} 字节,但得到 ${buffer.byteLength} 字节`));
        return;
      }

      resolve({
        buffer,
        width: img.width,
        height: img.height,
        size: file.size
      });

      // 释放对象 URL
      URL.revokeObjectURL(img.src);
    };

    img.onerror = () => {
      reject(new Error('图片加载失败'));
      URL.revokeObjectURL(img.src);
    };
  });
};

演示一下,压缩效果还不错,对于质量,还可以调整相关的参数。目前的参数设置为 instance.set_quality(35, 88);

压缩效果可以媲美tinify。

压缩为原来的 27.6% (-62.4%)

tinify压缩效果(-61%)

其他类型图片压缩库打包

其他库squoosh比如webp、jpg、avif已经帮忙打包好了,svg有现成的npm库,因此较为简单。

使用 WebWorker 避免主线程阻塞

在压缩大图的时候,发现浏览器有点卡,周围的按钮的动效都无法正常运行,点也点不动。这是因为我们如果直接调用wasm会直接阻塞js主线程,既然是计算密集型的工作,这个时候就只能拿出非常适合这种场景的特性了:Worker。

配置 WebWorker

  1. 安装 worker-loader 以便在 Webpack 中使用 WebWorker。
npm install worker-loader
  1. 配置 Webpack 来支持 Worker 文件。

module.exports = {
  module: {
    rules: [
      {
        test: /.worker.js$/,
        use: { loader: 'worker-loader' },
      },
    ],
  },
};
  1. 定义 WebWorker 代码:

// imageWorker.worker.js
import TinyPNG from 'tinypng-lib';

self.onmessage = async function (e) {
  const { image, options } = e.data;
  try {
    const result = await TinyPNG.compressWorkerImage(image, options);
    self.postMessage(result);
  } catch (error) {
    self.postMessage({ error: error.message });
  }
};

在组件中使用 WebWorker

在组件中启动 WebWorker,接收压缩结果,并将文件信息发送给 Worker 进行处理:


import ImageWorker from './imageWorker.worker.js';
import TinyPNG from 'tinypng-lib';

export default {
  data() {
    return {
      imgUrl: '',
      compressResult: {},
    };
  },
  mounted() {
    this.worker = new ImageWorker();
    this.worker.onmessage = (e) => {
      const result = e.data;
      if (result.error) {
        console.error("Compression failed:", result.error);
      } else {
        const url = URL.createObjectURL(result.blob);
        this.imgUrl = url;
        this.compressResult = result;
      }
    };
  },
  methods: {
    async uploadImg(e) {
      const file = e.file;
      const image = await TinyPNG.getImage(file);
      this.worker.postMessage({
        image,
        options: { minimumQuality: 30, quality: 85 }
      });
    }
  },
  beforeDestroy() {
    if (this.worker) {
      this.worker.terminate();
    }
  }
};

总结

编译imagequant的过程比较坎坷,主要是rust的语言机制确实跟平常使用的语言不一样,需要学习的概念会多一些。不过获得的效果还是很不错的:

  • 节省了服务器处理资源
  • 节省了图片网络传输的时间
  • 接入了WebWorker,可以并发执行任务且不阻塞
  • 接入Service worker后可以做到离线使用

小广告: