内容简介:3D形象展示项目的图片及模型等资源以压缩包的形式提供,需要下载并解压后再用Three.js加载并展示出来,其中的解压缩环节使用的是GitHub上获得5.6k Star的JS开源组件库JSZip。经过不断的优化,解压缩的性能已经有了较大提升,从几百毫秒降低到一百多甚至几十毫秒。压缩和解压缩属于CPU密集型计算任务,相对于JavaScript这样的解释型语言来说,C作为编译型语言更加适合,于是有了尝试把C解压缩程序编译为WebAssembly替换JSZip解压缩环节的想法,看看性能是否还会有进一步的提升。Ems
奇技指南
WebAssembly提升前端应用解压缩性能的尝试
背景
3D形象展示项目的图片及模型等资源以压缩包的形式提供,需要下载并解压后再用Three.js加载并展示出来,其中的解压缩环节使用的是GitHub上获得5.6k Star的JS开源组件库JSZip。经过不断的优化,解压缩的性能已经有了较大提升,从几百毫秒降低到一百多甚至几十毫秒。
压缩和解压缩属于CPU密集型计算任务,相对于JavaScript这样的解释型语言来说,C作为编译型语言更加适合,于是有了尝试把C解压缩程序编译为WebAssembly替换JSZip解压缩环节的想法,看看性能是否还会有进一步的提升。
创建WebAssembly(Wasm)
Emscripten是一套用于把C/C++代码编译为Wasm的 工具 集合,通过这套工具集可以把C/C++代码编译为Wasm字节码加载进浏览器、转换为机器码运行,保证了相对较高的计算性能,并且可以与JavaScript互相调用和传递数据。
本着不轻易制造轮子的原则,开源的C压缩/解压缩程序库Zip正适合我们的需要,它是从MiniZ项目中剥离出来的,简单易用、功能强大,我们的场景会使用到它unzip部分的功能。
Zip库的主要源文件只有三个,分别是miniz.h、zip.h、zip.c,我们需要编写代码调用Zip提供的相关API来实现解压缩功能,代码很简单,只有短短数行
#include <stdio.h>
#include <stdlib.h>
#include <emscripten.h>
#include "zip/src/zip.h"
EMSCRIPTEN_KEEPALIVE
int load_zip_data(void (*callback)(void *buf, int, const char*, int, int)) {
struct zip_t *zip = zip_open("archive.zip", 0, 'r');
int i, n = zip_total_entries(zip);
void *buf = NULL;
size_t bufSize;
for (i = 0; i < n; i++) {
zip_entry_openbyindex(zip, i);
{
const char *name = zip_entry_name(zip);
zip_entry_open(zip, name);
{
zip_entry_read(zip, &buf, &bufSize);
}
callback(buf, bufSize, name, i, n);
}
zip_entry_close(zip);
free(buf);
}
zip_close(zip);
return n;
}
EMSCRIPTEN_KEEPALIVE是emscripten.h中定义的一个宏,用于防止C/C++编译器把没有被调用的函数或代码段删除,即DCE(Dead Code Elimination)。 从导出C函数的角度来说,它与在命令行里指定 -s EXPORTED_FUNCTIONS="['_load_zip_data']"具有相同的作用。
load_zip_data函数的调用参数是一个函数指针(Function Pointer),用于回调JavaScript方法,传回压缩包中的文件数据、文件名、文件索引index和压缩包中全部的文件数。
如果一个函数指针指向的函数需要在多个地方调用的话,也可以用typedef定义一个类型以方便复用,比如:
typedef void(*callback)(void *buf, int size, const char* name, int i, int n);
现在我们可以用emsdk提供的命令把上面的代码与Zip的源文件编译生成Wasm了,命令如下:
emcc c/unzip.c c/zip/src/zip.c \
-o unzip/unzip.js \
-O3 \
-s WASM=1 \
-s FORCE_FILESYSTEM=1 \
-s EXTRA_EXPORTED_RUNTIME_METHODS="['cwrap', 'addFunction', 'UTF8ToString', 'FS']" \
-s RESERVED_FUNCTION_POINTERS=1 \
-s MODULARIZE=1 \
-s ENVIRONMENT='worker' \
-s ASSERTIONS=1 \
-s EXPORT_ES6=1
上面的命令会在unzip目录下生成一个unzip.wasm和对应的胶水JS代码unzip.js,unzip.wasm支持操作一个虚拟的文件系统,支持ES6语法,预留一个存放函数指针的单元,支持在Web Worker内使用。编译出来的Wasm大小在65k,加载耗时在几十毫秒左右。
使用Web Worker加载WebAssembly
JavaScript运行时只有一个主线程(UI线程),而Wasm的加载、编译、实例化、下载压缩包、解压文件这些工作如果都放在主线程执行会严重影响页面性能,所以可以把这些都放进Web Worker中以单独的线程去执行,减轻主线程的压力。
使用Web Worker的好处显而易见,但同时也会有更高的初始启动成本和更多的内存占用,所以Web Worker的数量不宜过多,而且最好用于长生命周期功能的使用。
在我们的使用场景里,主线程会首先初始化一些Three.js的组件,比如Scene、Camera、Renderer等,之后才可以加载模型和素材资源,而压缩包的解压必须要在Wasm加载和初始化之后才能进行,解压出资源后才能提供给Three.js去处理,由此可见,主线程和Worker线程之间的交互时序非常重要。具体交互时序如下图所示:
Worker中下载、编译、实例化Wasm代码如下:
import getModule from '../unzip/unzip';
let wasmResolve;
let wasmReady = new Promise((resolve) => {
wasmResolve = resolve;
});
const Module = getModule({
onRuntimeInitialized() {
onWasmLoaded();
},
instantiateWasm(importObject, successCallback) {
self.fetch('unzip.wasm', {
mode: 'cors',
}).then((response) => {
if (response.ok) {
return response.arrayBuffer();
}
throw Error(response.status);
}).then((wasmBinary) => {
WebAssembly.instantiate(new Uint8Array(wasmBinary), importObject)
.then((output) => {
wasmResolve(output.instance);
successCallback(output.instance);
})
.catch((e) => {
console.warn(`[js] wasm instantiation failed! ${e}`);
});
});
return {};
},
print(text) {
console.log(text);
},
printErr(err) {
console.error(err);
},
});
当Wasm实例化完成之后,会调用onWasmLoaded方法,在这个方法里我们可以定义两个用于JavaScript调用Wasm内的C函数的方法和一个给Wasm回调传回解压后数据的回调函数指针,postMessage用于通知主线程Wasm已经初始化完毕:
function onWasmLoaded() {
self._loadZipEntryData = Module.cwrap('load_zip_data', 'number', ['number']);
self._addZipEntryDataPtr = Module.addFunction(addZipEntryData.bind(this));
postMessage({
type: 'inited'
});
}
cwrap是Emscripten提供的用于封装C函数给JavaScript调用的工具函数,类似功能的还有一个ccall,在用法上有一些不同。cwrap的三个参数分别是C函数名、返回值类型、调用参数类型数组,ccall的参数除了这三个之外还多一个实际参数的数组。cwrap很像是封装一个柯里化函数供JS调用,而ccall则是带实参的直接调用。
addFunction是另一个由Emscripten提供的工具函数,用于向Emscripten运行时的函数指针数组动态添加函数指针,与之对应的是移除函数指针的工具函数removeFunction,要使用这一组工具函数,需要在编译参数中指明:
-s EXTRA_EXPORTED_RUNTIME_METHODS="['addFunction','removeFunction']"
_loadZipEntryData 和 _addZipEntryDataPtr定义好之后,让我们来看看怎么使用它们。
Emscripten通过FS库提供对一个虚拟文件系统的读写操作,在我们的场景中,Fetch到的压缩包数据会被写入到这个虚拟文件系统中,并被命名为archive.zip,然后调用Wasm中的load_zip_data函数进行解压缩处理:
fetch(url).then((res) => {
res.arrayBuffer().then((buffer) => {
loadZipEntryData(buffer);
});
});
...
function loadZipEntryData(zipBuffer) {
Module.FS.writeFile('archive.zip', new Uint8Array(zipBuffer));
self._loadZipEntryData(self._addZipEntryDataPtr);
}
上面最后这一行就是调用Wasm中的load_zip_data函数,传入的参数是JavaScript里面用于接收解压出的文件数据的回调函数指针。
load_zip_data函数会遍历压缩包中的每一个文件,并调用回调函数传回每个文件数据在虚拟文件系统内的起始地址、数据大小、文件名、在压缩包中的索引i和压缩包中的全部文件数n,其中后两个参数用于判断当前压缩包是否已经全部解压完毕。
callback(buf, bufSize, name, i, n);
在JavaScript里面接收到文件数据后,根据业务需要做下一步处理,如过滤掉不需要的文件,并在一个压缩包解压完全部有效文件后通过postMessage把文件集合发送给主线程:
let obj = {};
function addZipEntryData(buff, size, namePtr, i, n) {
const outArray = Module.HEAPU8.subarray(buff, buff + size);
const fileName = Module.UTF8ToString(namePtr);
if(fileName.indexOf('__') === -1) {
const blob = new Blob([outArray]);
obj[fileName] = URL.createObjectURL(blob);
}
if(i === (n -1)) {
const o = {};
Object.assign(o, obj);
postMessage({
url: zipUrl,
files: o,
});
obj = {};
}
}
测试与结论
现在让我们来看一下Wasm版的解压有没有一些性能提升。
测试方法是通过页面加载3次资源并渲染,资源共有10个压缩包,大小从几百k到2M+不等,整个流程包括下载、解压、加载三个部分,重点关注解压部分,对比JSZip和Wasm两个版本的处理耗时数据如下(测试使用Chrome浏览器):
从数据对比可以看到,JSZip版的解压在一开始时由于还没有JIT编译器对关键代码段进行优化,所以性能与Wasm版本有较大差距。
Wasm作为字节码加载到浏览器之后,只需要再转换一次到机器码,即可开始稳定工作,不需要经过浏览器引擎优化器的优化,所以从一开始的解压性能就比较平稳,不会有大的波动。
随着JIT编译器优化的启动,JSZip版本解压部分的代码由于会频繁执行,所以会被JIT编译器优化,标记为warm/hot/very hot,进而转换为机器码运行,性能得到了大幅提升,与Wasm版本较为接近了。
参考资料或网站
-
WebAssembly https://webassembly.org/
-
Emscripten https://emscripten.org/
-
Zip https://github.com/kuba--/zip
扫码关注我们
360技术公众号
技术干货|一手资讯|精彩活动
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- 花椒前端用 WebAssembly 提升前端应用解压缩性能的尝试
- 前端科普系列(三):CommonJS 不是前端却革命了前端
- 前端科普系列(三):CommonJS 不是前端却革命了前端
- 前端技术演进(三):前端安全
- 【前端优化】前端常见性能优化
- 【前端学习笔记】前端安全详解
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。