webassembly/worker避免加载根域文件通用hack直载法(另思考gif操作现状)

前言

使用过 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 :

对于这种复杂的库直接从打包链路源代码入手是很无力的,因此我们直接从产物入手。

本地建一个新的 npm 空项目安装一下 wasm-imagemagick 看看 node_modules 产物如下:

由于我们是身经百战的老手(理直气壮),所以瞬间判断出来 esm 的 module 入口产物在 dist/bundles/* 下,即使他有可能写错了。

所以我们需要梳理的产物只有三个:

  • magick.js :worker 本体,负责拉起 wasm

  • magick.wasm :ImageMagick 的 wasm 产物本体

  • magickApi.js :包含 wasm-imagemagick 的 js 执行函数主业务部分,从这个入口拉起 worker

等等,你问我为什么知道 magick.js 或者 magickApi.js 是干什么的,因为他的产物是很易读的 typescript 转译 js 后未压缩的版本:

凭借丰富的经验,我们一眼就看到了 __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 了。

不过模板字符串既然能支持换行,那他在此处的缺点我们必定不容忽视:那就是反斜线在模板字符串内用于转义,但对于我们来说,我们希望反斜线 \ 就是一个实实在在的 \ 反斜线结果,不需要转义,从而需要手动做一步全文件反斜线的替换,将单反斜线替换为两个反单斜线:

如此一来,代码内 \\ 到了文本中就会变成实实在在的 \

hack wasm

上文我们已经 hack 了 worker ,下面我们 hack wasm ,在 magick.js 也就是拉起 wasm 的 worker 文件内搜索去 url 下载 wasm 的相关内容:

很好,找到了拉起 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>
}

结果:

总结

又到了深度思考的环节,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 界烂大街了),拥抱变化,持续探索才是真正的真理。

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值