前言
使用过 web worker / webassembly 的同学相信都不会陌生,worker 虽好但使用很麻烦!
那就是 worker 必须要求同域,而且还要把他专门拷贝到 /public
跟随 copy 到打包产物根目录去,保证直接在根域下加载,而 wasm 要求更严格,还需要指明响应头安全策略。
用过 gif.js
/ pdf.js
等老库的同学相信都十分头大,到了 1202 年我们还不能直接 import
一下从 npm 包直接使用?还要自己 copy 文件吗,不可思议。
不过办法总比困难多,我们在之前 gif.js
的策略中讨论过一种 worker 转 blob url 的 hack 法:
《 gif.js 通过类 npm 包的方式使用方法(摆脱 worker 限制和全局引入)》
实际上这种原理我们可以进一步拓展,大肆疯狂的拓展实现我们收束万物的野心。
正文
我们这里以一个涉及 worker + wasm 的实战例子来梳理。
此处我们选用的库就是老牌图片处理库 ImageMagick 的 wasm 版本 :wasm-imagemagick
此库如果直接使用,其对 worker 脚本 copy 位置要求十分严格,必须处于根域,且其判断 url 逻辑不太健全,非根域很容易加载不到,使用困难重重,下面,看我们如何 hack 这些加载难题。
梳理产物
首先我们梳理产物,为什么是梳理产物而不是源码呢?一般情况 wasm 的仓库都十分复杂,多语言交汇,打包工具复杂,更何况是 ImageMagic 这种 C 写的图片处理库,更是包括了好几个 git submodule :
![](https://i-blog.csdnimg.cn/blog_migrate/edf1d8edb34ded596936e1488b8921ca.png)
对于这种复杂的库直接从打包链路源代码入手是很无力的,因此我们直接从产物入手。
本地建一个新的 npm 空项目安装一下 wasm-imagemagick
看看 node_modules
产物如下:
![](https://i-blog.csdnimg.cn/blog_migrate/786834180c7fec5cd689d526f42a80e5.png)
由于我们是身经百战的老手(理直气壮),所以瞬间判断出来 esm 的 module 入口产物在 dist/bundles/*
下,即使他有可能写错了。
所以我们需要梳理的产物只有三个:
-
magick.js
:worker 本体,负责拉起 wasm -
magick.wasm
:ImageMagick 的 wasm 产物本体 -
magickApi.js
:包含 wasm-imagemagick 的 js 执行函数主业务部分,从这个入口拉起 worker
等等,你问我为什么知道 magick.js
或者 magickApi.js
是干什么的,因为他的产物是很易读的 typescript 转译 js 后未压缩的版本:
![](https://i-blog.csdnimg.cn/blog_migrate/24e7b7ea404226f0e81745514ccba29c.png)
凭借丰富的经验,我们一眼就看到了 __awaiter
这种异步兼容函数,太易懂了吧,回想起我们当时 hack gif.js
的压缩版本的经验,这种未压缩的简直就是 so easy 。
hack worker
下面我们 hack worker 入口,按照之前 gif.js
的 hack 思路,我们核心是要做这一件事:
new Worker(url) -> new Worker(URL.createObjectURL(new Blob([workerContent])))
简而言之就是把以前我们使用 url 加载 worker js 转换为把 worker 的 js 文本构造成 blob url 再直接加载 worker ,由此便克服了 worker 的所有使用域限制难题。
在 magickApi.js
搜索 new Worker(
,寻找到唯一的 Worker 启动入口片段:
let magickWorker;
if (currentJavascriptURL.startsWith('http')) {
// if worker is in a different domain fetch it, and run it
magickWorker = new Worker(window.URL.createObjectURL(new Blob([GenerateMagickWorkerText(magickWorkerUrl)])));
}
else {
magickWorker = new Worker(magickWorkerUrl);
}
所以你会怎么做?当然是火烧连营、片甲不留,所有分支通通不要:
// 文件开头,从 worker 文件导入 workerContent 即文本内容
import { workerContent } from './magick.js'
// ....
let magickWorker;
magickWorker = new Worker(window.URL.createObjectURL(new Blob([workerContent])));
这一步奇妙操作还是比较易懂的,只是利用了 Worker 的构造器支持性而已 ( Worker MDN )。
下一步我们将 worker 的本体内容作为字符串导出:
// magick.js
export const workerContent = `
// This file helps make the compiled js file be imported as a web worker by the src/magickApi.ts file
const stdout = []
const stderr = []
let exitCode = 0
..... (省略)
`
将整个文件内容包在反引号 es6 模板字符串里,这样就可以直接内部加载 worker 文本形成 blob url 了。
不过模板字符串既然能支持换行,那他在此处的缺点我们必定不容忽视:那就是反斜线在模板字符串内用于转义,但对于我们来说,我们希望反斜线 \
就是一个实实在在的 \
反斜线结果,不需要转义,从而需要手动做一步全文件反斜线的替换,将单反斜线替换为两个反单斜线:
![](https://i-blog.csdnimg.cn/blog_migrate/4534a70171deebf66eb4290b875783a9.png)
如此一来,代码内 \\
到了文本中就会变成实实在在的 \
。
hack wasm
上文我们已经 hack 了 worker ,下面我们 hack wasm ,在 magick.js
也就是拉起 wasm 的 worker 文件内搜索去 url 下载 wasm 的相关内容:
![](https://i-blog.csdnimg.cn/blog_migrate/d70f777b3d963a721fadce6b2ee1476e.png)
很好,找到了拉起 wasm 的入口,为了方便查看,我们弄一份新的 magick.js
并把他格式化了再看,此时运用我们强大的第六感进行感知,迅速得到几个关键链路:
var dataURIPrefix = 'data:application/octet-stream;base64,'
function isDataURI(filename) {
return String.prototype.startsWith
? filename.startsWith(dataURIPrefix)
: filename.indexOf(dataURIPrefix) === 0
}
function integrateWasmJS() {
var wasmTextFile = 'magick.wast'
// ! 很明显只有这个 wasmBinaryFile 有用,其他的都没有对应的 sourcemap 文件 😅
// 而且上文 isDataURI 函数告诉我们,要读一个 base64 是比较符合预期的,比较好
var wasmBinaryFile = 'magick.wasm'
var asmjsCodeFile = 'magick.temp.asm.js'
if (!isDataURI(wasmTextFile)) {
wasmTextFile = locateFile(wasmTextFile)
}
if (!isDataURI(wasmBinaryFile)) {
wasmBinaryFile = locateFile(wasmBinaryFile)
}
if (!isDataURI(asmjsCodeFile)) {
asmjsCodeFile = locateFile(asmjsCodeFile)
}
搜索此变量 wasmBinaryFile
,发现有多处兼容加载入口,任意确认一处:
function getBinaryPromise() {
if (
!Module['wasmBinary'] &&
(ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) &&
typeof fetch === 'function'
) {
// ! 这里直接 fetch 拉了之后老生常谈的 arrayBuffer 方法
// 真的是没毛病一点毛病没有,完全按照我们预期进行
return fetch(wasmBinaryFile, { credentials: 'same-origin' })
.then(function (response) {
if (!response['ok']) {
throw "failed to load wasm binary file at '" + wasmBinaryFile + "'"
}
return response['arrayBuffer']()
})
.catch(function () {
return getBinary()
})
你问我为什么 fetch 之后我就知道 arrayBuffer
了?不用说 arrayBuffer
,我还知道后文要拉 wasm 的 instance 方法 load wasm,强大的第六感来自于朴实的积累!
function instantiateArrayBuffer(receiver) {
getBinaryPromise()
.then(function (binary) {
return WebAssembly.instantiate(binary, info)
})
.then(receiver)
.catch(function (reason) {
err('failed to asynchronously prepare wasm: ' + reason)
abort(reason)
})
好了,不出所料,这就是 wasm 链路的全部了。
所以我们的对策是什么?思考一下我们的线索:
-
fetch 可以拉 url 可以拉 base64
-
原来从 url 拉有很多同域困境
-
isDataURI
方法判断了假如是 base64 开头就不拉了
好了,到此为止,我们的思路很明显了,就是我们把 wasm 变成 base64 进行内部加载!
纸上谈兵做来难,如何 wasm 转 base64 ?当然是万能的 node fs
神器:
// convert.js
const fs = require('fs')
fs.writeFileSync(
'./magick-base64.js',
fs.readFileSync('./magick.wasm', {
encoding: 'base64',
})
)
写下我们神奇的一行行代码,之后将得到的 base64 前面拼一下识别前缀 data:application/octet-stream;base64,
再导出:
// magick-base64.js
export const wasmContent = "data:application/octet-stream;base64,AGFzbQEAAAABzQdxYAN/f38AYAF/AX9gAn9/AX9gAnx8AXxgAn9......"
那么下一步就很简单了吧,将这个变量按之前我们 hack worker 同样的方法赋值给 wasmBinaryFile
:
import { wasmContent } from './magick-base64'
export const workerContent = `
...
var wasmBinaryFile="${wasmContent}"
...
`
大吉大利,我们终于 hack 完了 wasm 。
项目实践
话不多说,立即开启实践,参照 官方文档 对 wasm-imagemagick
核心函数的用法,我们这里只示范一个 identify
最简单的使用 ImageMagick 的例子:
import { useEffect, useCallback } from 'react'
import imgUrl from './img.gif'
import { buildInputFile, execute } from './magick/magickApi'
function App() {
const lookGifInfo = useCallback(async () => {
const { stdout, stderr, exitCode, outputFiles } = await execute({
inputFiles: [await buildInputFile(imgUrl, 'image.gif')],
commands: `identify image.gif`,
})
console.log(stdout)
}, [])
useEffect(() => {
lookGifInfo()
}, [])
return <div></div>
}
结果:
![](https://i-blog.csdnimg.cn/blog_migrate/9d3b3b37968ec77c2906ccd6bf635a2f.png)
总结
又到了深度思考的环节,hack 虽好,但细节却细致入微,需要格外注意。
体积优化
谈起绝对优化实践,那么其中 worker 的字符串是不是可以改好后,用代码压缩工具压缩成一行再放进去当字符串进行体积优化?
但实际上 ImageMagick 为了处理不同图片的 encode 和 decode ,整个 wasm 有足足近 4.2 M ,base64 化后有 5 M ,转不转 base64 其实差距不大,但是门槛 size 还是有的,其实再换个方向思考,当今很多图片一张就 几 M ,一个视频就几百 M ,谁会在意一个 5 M 的 js 文件呢?或者说我再把他挂到 cdn 上,在页面开始加载的时候就把他读取挂到全局上(使用 async 非阻塞方式加载 scripts),其实优化还是可以突破一些。
参数调优
有些时候,我们还需要深度思考这个 ImageMagick 工具本身他好不好,其实当转码 60 帧的 gif 图到 1000 宽度的 size 时,不用说 wasm 了,原生 C 命令行都特别慢,当然结果也有 30 多 M ,所以你要在网页上放一张 30 M 的图?那么其实极限场景还是需要增加一些 --layer
、--simple
等 ImageMagick 的调优参数。
Gif 操作现状
再深度发散思维,当今 gif 裁剪一直是难点,node 层的 jimp 已经没有了 gif 解码能力,图片王者 sharp 也是严重依赖苛刻的 libvips 环境,为了这个还要改动 docker 或者 AWS 伺服器不成?其他的处理工具更是残破不堪,只能处理一帧 gif , 所以放到浏览器其实是大势所趋,那你告诉我 photoshop 这种 C 写的工具或者某些 C 的图片处理库你要在浏览器跑?但现实确实是如此,ps 已经开发了浏览器 wasm 版本,各大 C 库也逐渐出现了 wasm 化(比如 ffmpeg 已经算在 wasm 界烂大街了),拥抱变化,持续探索才是真正的真理。