Skip to content
ABFree Studio

无阻塞、高性能:将Rust的imagequant库带入Web前端的奇妙之旅

我们先看下使用效果吧:exportx.dev

图片压缩一直是前端/客户端必须优化的问题之一,高质量低尺寸的文件能够提高响应速度,减少流量,目前不同规模的团队在不同场景可能有以下方式来解决这个问题:

  • 服务端压缩:比如云对象存储 Cloudflare Images
  • 代码自动压缩:比如前端脚手架压缩
  • 手动压缩:比如Squoosh,TinyPng,当然还有exportx.dev

因为压缩目前还是最主流的压缩方式,并且Squoosh png压缩效果不佳(包太旧了),于是想自己重新再编译一套最新的wasm。

术语

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

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

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

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

WebAssembly简单入门

如果对WebAssembly的基础知识还不了解,可以先参考下MDN的文档。

https://developer.mozilla.org/zh-CN/docs/WebAssembly/Rust_to_wasm

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在浏览器使用的模块,极大丰富了浏览器的使用场景。

Untitled

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

编译 Rust 为 WebAssembly

编译

这里用官方文档文档种的 lib.rs 举例,从rust中可以看到:

  • wasm是可以从js中获取函数调用,比如调用alert,或者拿到浏览器的DOM对象,比如大名鼎鼎的 https://yew.rs/ 框架是可以用Rust来开发网页。
  • wasm也可以将自己的函数暴露给js
extern crate wasm_bindgen;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern {
pub fn alert(s: &str); // 从js浏览器环境获取到的alert方法
}
#[wasm_bindgen]
pub fn greet(name: &str) {
alert(&format!("Hello, {}!", name)); // 暴露rust的greet函数 实际上调用了浏览器的
}

所以上面的代码属于套娃调用。

接下来就要将代码编译成js package,从Rust编译到wasm有比较成熟的脚手架工具使用:wasm-pack,文档参考

胶水代码

下面是从生成的js package里面截取一部分核心代码:

function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8Memory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8Memory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8Memory0().subarray(ptr + offset, ptr + len);
const ret = encodeString(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
/**
* @param {string} name
*/
export function greet(name) {
const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
wasm.greet(ptr0, len0);
}

wasm-pack生成的胶水代码主要帮我们做了以下几件事:

  1. 加载和实例化WebAssembly模块(生命周期):处理模块的导入和导出,使它们能在JavaScript中使用。
  2. 类型转换:处理JavaScript和WebAssembly之间的类型转换,例如将复杂类型(如字符串、数组)进行编码/解码。
  3. 错误处理:将WebAssembly的错误转换为JavaScript可以识别的错误。
  4. 生成JavaScript API:为Rust WebAssembly模块创建一个JavaScript API,方便在JavaScript中调用。
  5. 生成npm包:创建一个可以发布到npm的包,方便其他JavaScript项目使用。

总的来说,胶水代码就是使WebAssembly模块能在JavaScript环境中更容易使用的一种代码。

浏览器使用

在浏览器种使用,wasm的加载和调用和js一样,因为通过d.ts描述wasm胶水层的代码,在开发上基本和平常一样。

const js = import("./node_modules/@yournpmusername/hello-wasm/hello_wasm.js");// // 换成esm也ok
js.then((js) => {
js.greet("WebAssembly");
});

兼容性

2024年了,应该不会还有人去兼容IE吧?

Untitled

imagequant打包成npm包

压缩库选型

简单聊一下为什么选择imagequant,这也是调研得出来的结论,squoosh是开发者使用最多的一个的一个开源图片图片的网站(3年未更新),因此对于其他格式的压缩,可以部分copy其中一些比较优异的压缩库,不满意的部分比如png的可以自己编译wasm。

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

压缩效果是如何鉴别的?

纯肉眼拖动观察肯定不够客观全面,所以我用了多张色彩对鲜明、和业务相关图片进行测验。

图片对比工具: https://www.diffchecker.com/image-compare/

PNG压缩打包

前面说到,sqoosh的oxipng压缩效果差、imagequant版本老,因此这里需要自己手动来打包

首先需要找到imagequant的rust库(crates类似npm)

https://crates.io/crates/imagequant

然后将依赖加入到Cargo.toml (这个类似package.json)

[package]
name = "imagequant-wasm"
version = "0.1.0"
authors = ["yakirteng <[email protected]>"]
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = ["console_error_panic_hook"]
[dependencies]
imagequant = { version = "4.2.0", default-features = false }
lodepng = "=3.7.2"
wasm-bindgen = "0.2.84"
# The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
# code size when deploying.
console_error_panic_hook = { version = "0.1.7", optional = true }
[dev-dependencies]
wasm-bindgen-test = "0.3.34"
[profile.release]
# Tell `rustc` to optimize for small code size.
opt-level = "s"

然后编写部分导出代码,将处理图片的函数暴露给js调用

#[wasm_bindgen]
impl Imagequant {
#[wasm_bindgen(constructor)]
pub fn new() -> Imagequant {
Imagequant {
instance: imagequant::new(),
}
}
/// Make an image from RGBA pixels.
/// Use 0.0 for gamma if the image is sRGB (most images are).
pub fn new_image(data: Vec<u8>, width: usize, height: usize, gamma: f64) -> ImagequantImage {
ImagequantImage::new(data, width, height, gamma)
}
//. 省略
}

打包生成npm package

Untitled

可以先从d.ts中看生成的代码如何调用,从文件中看到需要输入uint8Array和图片尺寸大小,于是我们可以这样调用:

import { Imagequant, ImagequantImage } from '@/imageCompressor/imagequant-wasm'
// 获取图片元信息
const { width, height, imageData } = await this.getImageBitInfo()
// 将 Uint8Array 数据从发给 Imagequant/WASM
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' })

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

压缩效果可以媲美tinypng,但是尺寸会比tinypng略大一些。

Untitled

其他压缩库打包

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

使用Worker避免阻塞js主线程

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

Untitled

先实现一下woker中需要执行的代码,他完成了2件事情

  • 在worker中执行压缩任务
  • 监听主线程发送的文件,传输文件到主线程
import { ImageCompressor } from "./ImageCompressor";
self.addEventListener("message", async (event) => {
const { file, format } = event.data;
try {
const compressedFile = await ImageCompressor.compressAndConvertFile(
file,
format,
);
self.postMessage({ success: true, compressedFile });
} catch (error) {
self.postMessage({ success: false, error: error });
}
});

然后我们需要在主线程通过postmessage发送压缩异步任务

import Worker from "./compressWorker?worker";
import { Format } from "./types";
export const compressImageWorker = async (
file: File,
format: Format,
): Promise<File> => {
// @ts-ignore
const worker = new Worker("./compressWorker.ts", { type: "module" });
return new Promise((resolve, reject) => {
worker.postMessage({ file, format });
worker.onmessage = (event) => {
const { success, compressedFile, error } = event.data;
if (success) {
resolve(compressedFile);
} else {
reject(new Error(error));
}
worker.terminate();
};
});
};

这样,压缩任务不占用主线程,并且可以并行多个压缩任务。

总结

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

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

感谢大家的阅读~

小广告:

附录

Rust 镜像配置

# vim ~/.cargo/config.toml
[source.crates-io]
registry = "https://github.com/rust-lang/crates.io-index"
replace-with = 'tencent'
[source.tencent]
registry = "http://rust.mirrors.tencent.com/index"
[net]
git-fetch-with-cli = true

代码参考:

https://github.com/tyaqing/imagequant-wasm


© 2024 ABFree Co. All rights reserved.