大家好,我是老纪。
某个同事突然联系我,说有段代码报错了,之前是好的,现在出了问题,是不是我的锅?
我定睛一看,看到/* @__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, configurable: true });
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, configurable: true });
坑爹啊!Esbuild作者打死也想不到还有这种用法吧!
解决方案
找到了问题所在,那下来就简单了。
方案一:修改配置
第一个方案是修改Esbuild配置,如果Esbuild有相关配置,改变这种编译处理,那就皆大欢喜。
遗憾的是,在 esbuild API 中没有找到相关配置项。如果再换回Rollup,只为这一个功能再折腾,没有必要(没错,我就是不想搞🐶)。
方案二:Polyfill
这次问题的根源是MyWorker内部的函数加了__name的处理,这是esbuild的 keepNames 配置项的功劳,如果不开启这个配置,在代码压缩时会将函数、变量名修改以减少体积:
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');
这个缺点很明显,将业务代码与打包工具耦合到了一起,假若将来Vite或Esbuild换了规则,这块可能仍是个雷。
当然,这雷到不了那一步就会爆!因为这个方案犯了一个致命的错误,那就是压缩的代码里,__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的,需要修改为self或globalThis:
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未定义的错误。经过一番调查,我发现问题出在开启了keepNames的Esbuild的函数名压缩上。无法通过修改配置修复,注入Polyfill在生产环境下会有问题,最终,我通过将函数直接绑定到self上,成功解决了这个问题。
归根结底,本次问题根源是本不该参与打包环节的Web Worker的JS通过toString的方式巧妙地与其它代码融合在一起,这样的好处是只需要对外提供一个SDK,不用在考虑加载Web Worker时的路径问题,缺点就是现在看到的,换了个打包工具就引发了Bug。思路虽巧,但我还是希望工作中少点儿这种非常规操作为妙。