一段骚操作引发的 Web Worker 报错

382 阅读4分钟

大家好,我是老纪。

某个同事突然联系我,说有段代码报错了,之前是好的,现在出了问题,是不是我的锅?

我定睛一看,看到/* @__PURE__ */ __name,有了猜测,大概率是我的问题。这明显是Esbuild编译的风格。

之前的文章《Electron Node端使用Vite API打包的坑》里说过,为了让我们的插件支持TypeScript,我大动干戈,将原来的Rollup替换为Vite,本质上最终编译使用的是Esbuild

正常来说,Esbuild虽然一直苟着没有发布1.0版本,但从2020年初迭代至今,已经相当成熟了,不会有什么低级问题出现。那么这次的问题是什么原因造成的呢?

排查问题

细问之下,才知道是发生在Web Worker里的错误。虽然一直知道Web Worker是单独开启一个线程来避免主线程阻塞,我在《静态站点全文搜索实现原理之dumi篇》里也有提到过,但在实际工作中一直没有使用场景。

一开始我是有些懵逼的。在我看来,应该是该同事调用了我的API打包编译(这是个Electron项目),这个错看下来像是缺失了某些内容。但当我问他原文件是哪个,他给我看代码追踪,看到的报错文件是截图所示的127.0.0.1:3333/xxx,后面一长串看起来是个uuid。难道是在Electron里拦截了http请求,按照某种规则响应了文件?

同事说,不是。接着show了一段代码:

const useJS = typeof WebAssembly !== 'object' || config.type === 'js';
const librariesPending = [];

if (useJS) {
  librariesPending.push(this._loadLibrary('decoder.js''text'));
} else {
  librariesPending.push(this._loadLibrary('wasm_wrapper.js''text'));
  librariesPending.push(this._loadLibrary('decoder.wasm''arraybuffer'));
}

this.decoderPending = Promise.all(librariesPending)
  .then(libraries => {
    const jsContent = libraries[0];
    if (!useJS) {
      config.wasmBinary = libraries[1];
    }
    const fn = MyWorker.toString();
    const body = [
      '/* decoder */',
      jsContent,
      '',
      '/* worker */',
      fn.substring(fn.indexOf('{') + 1, fn.lastIndexOf('}'))
    ].join('\n');

    this.workerSourceURL = URL.createObjectURL(new Blob([body]));
  });

其中this.workerSourceURL就是Web Worker加载的URL,也就是说,它是通过Blob包装了字符串,转换为浏览器可以识别的URL,格式大致是这样:

blob:http://127.0.0.1:5500/c14d9b94-4d7a-4ee3-8f1d-8617b89b8ddd

在浏览器的控制台里,上面截图看到的就是它生成的代码了。

我下意识地问:那_loadLibrary干什么事了?是不是用到了wasm做了什么特殊处理(我们项目里有)?

接着看到了源码,就是调用了FileLoader加载资源,与wasm没什么关系,也没什么魔法:

_loadLibrary(url, responseType) {
  const loader = new FileLoader(this.manager);
  loader.setPath(this.decoderPath);
  loader.setResponseType(responseType);
  loader.setWithCredentials(this.withCredentials);

  return new Promise((resolve, reject) => {
    loader.load(url, resolve, undefined, reject);
  });
}

那就有点儿头疼了。

接着找报错的源码,去除敏感信息后类似于下面的代码:

function MyWorker() {
  onmessage = (event) => {
    a();
    postMessage(`Hi, ${event.data}`);
  };

  function a() {
    console.log("a");
  };
}

被编译成了下面的代码:

var __defProp = Object.defineProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurabletrue });

function MyWorker() {
  onmessage = /* @__PURE__ */ __name((event) => {
    a();
    postMessage(`Hi, ${event.data}`);
  }, "onmessage");
  function a() {
    console.log("a");
  }
  __name(a, "a");
}
__name(MyWorker"MyWorker");

再回头仔细看,MyWorker的函数体部分与其它JS拼接到了一起:

const fn = MyWorker.toString();
const body = [
  '/* decoder */',
  jsContent,
  '',
  '/* worker */',
  fn.substring(fn.indexOf('{') + 1, fn.lastIndexOf('}'))
].join('\n');

this.workerSourceURL = URL.createObjectURL(new Blob([body]));

而前两段由Esbuild注入的函数没有包含在内,所以报错__name未定义:

var __defProp = Object.defineProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurabletrue });

坑爹啊!Esbuild作者打死也想不到还有这种用法吧!

解决方案

找到了问题所在,那下来就简单了。

方案一:修改配置

第一个方案是修改Esbuild配置,如果Esbuild有相关配置,改变这种编译处理,那就皆大欢喜。

遗憾的是,在 esbuild API 中没有找到相关配置项。如果再换回Rollup,只为这一个功能再折腾,没有必要(没错,我就是不想搞🐶)。

方案二:Polyfill

这次问题的根源是MyWorker内部的函数加了__name的处理,这是esbuildkeepNames 配置项的功劳,如果不开启这个配置,在代码压缩时会将函数、变量名修改以减少体积:

function Kc() {
  postMessage("I'm working before postMessage('ali')"), onmessage = (e) => {
    r(), postMessage(`Hi, ${e.data}`);
  };
  function r() {
    console.log("a");
  }
}

但我们业务中许多类依赖于原始名称,不得不开启。只是Esbuild这个处理名称的方法也是真秀。

我突然想到:既然缺失了__name这个函数,那么把那两行代码注入到代码不就行了?反正只有两行,不费什么工夫。

const polyfill = `var __defProp = Object.defineProperty;
      var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
`;
const body = [
  '/* decoder */',
  jsContent,
  '',
  '/* worker */',
  polyfill,
  fn.substring(fn.indexOf('{') + 1, fn.lastIndexOf('}'))
].join('\n');

这个缺点很明显,将业务代码与打包工具耦合到了一起,假若将来ViteEsbuild换了规则,这块可能仍是个雷。

当然,这雷到不了那一步就会爆!因为这个方案犯了一个致命的错误,那就是压缩的代码里,__name也不是原来的名称!

function Td() {
  onmessage = /* @__PURE__ */ u((e) => {
    a(), postMessage(`Hi, ${e.data}`);
  }, "onmessage");
  function a() {
    console.log("a");
  }
  u(a, "a");
}
u(Td"MyWorker");

所以,方案二也是无效的!

方案三:白名单

前两条路都走不通,还有没有其它的方案呢?有没有办法让这几个函数不被__name处理呢?

有的,那就是将函数直接赋给window

function MyWorker() {
  window.onmessage = (event) => {};
}

这样编译后就是:

function Td() {
  window.onmessage = (a) => {};
}
_(Td"MyWorker");

当然,在Web Worker中是没有window的,需要修改为selfglobalThis

function MyWorker() {
  self.onmessage = (event) => {};
}

有点儿麻烦的是这几个函数都得这么修改,而且必须是直接赋值给self,如果改为下面后赋值:

function MyWorker() {
  const onmessage = (event) => {
  };
  self.onmessage = onmessage;
}

则仍有__name的存在:

function Td() {
  const a = /* @__PURE__ */ f((e) => {
  }, "onmessage");
  self.onmessage = a;
}
f(Td"MyWorker");

总结

某个项目中,我遇到了一个Web Worker的棘手问题:代码在使用Vite压缩后出现了__name未定义的错误。经过一番调查,我发现问题出在开启了keepNamesEsbuild的函数名压缩上。无法通过修改配置修复,注入Polyfill在生产环境下会有问题,最终,我通过将函数直接绑定到self上,成功解决了这个问题。

归根结底,本次问题根源是本不该参与打包环节的Web WorkerJS通过toString的方式巧妙地与其它代码融合在一起,这样的好处是只需要对外提供一个SDK,不用在考虑加载Web Worker时的路径问题,缺点就是现在看到的,换了个打包工具就引发了Bug。思路虽巧,但我还是希望工作中少点儿这种非常规操作为妙。