为了在浏览器里跑多线程 ONNX 推理,我把自己的支付浮层弄挂了

起因是个很具体的需求:帮家里人做身份证复印件。网上一搜,清一色"上传图片,在线生成"。

身份证这种东西,我自己是不愿意传的。但也不想为这个开 PS。于是想了个笨办法:把去背景模型直接塞进浏览器,图片一次都不出本机。

模型本身跑通不算难。难的是跑通之后,别把页面上别的东西砸坏。

一、先让模型下得动

用的是 BiRefNet。原版体积对网页来说不现实,换成 lite 加 int8 量化,输入 768²,最后落到 47MB,gzip 后 39MB。

这个体积仍然不算小,下载这块只能多想几步。

模型放 R2 加 CDN,多个源同时发请求,谁先返回 ok 就用谁,其余的 abort 掉:

js 复制代码
async _raceFetch() {
  const srcs = this.tier.sources;
  const controllers = srcs.map(() => new AbortController());
  const tries = srcs.map((url, i) =>
    fetch(url, { signal: controllers[i].signal, mode: "cors" }).then((r) => {
      if (!r.ok) throw new Error(`${url} -> ${r.status}`);
      controllers.forEach((c, j) => j !== i && c.abort());
      return r;
    })
  );
  return Promise.any(tries);
}

下完存进 Cache API,二次访问不再走网络。

还有一条是踩出来的:对象存储回的 content-encoding 头不可靠,有时候浏览器帮你解了,有时候没有。与其信头,不如自己认 gzip 的 magic number 1f 8b,是压缩的就用 DecompressionStream 在本地解,不是就直接用。

二、多线程 WASM 的门票

onnxruntime-web 的 WASM 后端要开多线程,得有 SharedArrayBuffer。而 SharedArrayBuffer 要求页面处于跨源隔离状态,也就是响应头得给全:

makefile 复制代码
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless

WASM 后端能不能开多线程,直接决定这条回落路径可不可用,所以这个头必须加。

加完当天,支付就挂了。

三、隔离态会打死页面上所有跨源 iframe

我的结账用的是 Paddle 的 overlay,本质是个跨源 iframe。页面一旦进入跨源隔离,任何跨源 iframe 想被嵌进来,得自己带 COEP 响应头。Paddle 不带,于是浮层直接打不开。

同样是收款,LemonSqueezy 那条完全没事,因为它是 <a> 跳到自己 hosted 的结账页,压根不在我的页面里开 iframe。受影响的只有嵌进来的那类第三方,跳走的不受影响。

第一次遇到这事我把头撤了。撤是对的,但撤了本地推理就没了。这次重开,前提是得把 iframe 这条路也保住。

四、更难查的那个:软导航不重新求值响应头

上一条一撞就发现,这条不会。

Next.js 的客户端软导航不会重新求值文档响应头。也就是说,用户从一个隔离页 <Link> 跳到 /pricing,浏览器并没有拿到 /pricing 那份不隔离的响应头,文档还是原来那份,crossOriginIsolated 残留为 true

结果就是:/pricing 单独打开一切正常,从首页点过去就打不开支付浮层。

这个故障形态很讨厌。它不报错,不进任何监控,只在真实用户要付钱那一刻发生。你自己测的时候多半直接敲地址栏进去,复现不到。

五、三层一起上,少一层都漏

最后的做法是三层:

一是头只配在需要推理的路由上。首页、工作台、身份证复印件这几个页面隔离,/pricing/account 永不隔离。

js 复制代码
const embedded = [
  { key: "Cross-Origin-Embedder-Policy", value: "credentialless" },
];
const isolated = [
  { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
  ...embedded,
];
return [
  { source: "/", headers: isolated },
  { source: "/studio", headers: isolated },
  { source: "/id-copy", headers: isolated },
  // /pricing 与 /account 不在此列
];

二是跨隔离边界的链接一律用 <a> 硬导航。从隔离页去 /pricing 要硬导航,从非隔离页进推理页同样要,否则推理页拿不到隔离态,多线程白配。

三是组件挂载时自检。因为上一条依赖"以后所有人都记得别把 <a> 改回 <Link>",这个假设迟早会破:

js 复制代码
useEffect(() => {
  if (typeof window !== "undefined" && window.crossOriginIsolated) {
    window.location.reload();
  }
}, []);

重载会走 /pricing 自己的响应头,crossOriginIsolated 变回 false,不会循环。多这几行,是因为故障模式是静默的付款失效,不能靠纪律去防。

六、COEP 选 credentialless,不选 require-corp

require-corp 更严,任何没带 CORP 头的跨源 no-cors 子资源都会被卡住。今天没问题,以后随手加一张外链图就白屏。

credentialless 只是去掉跨源请求的凭据,宽容得多。代价是 Safari 不支持,Safari 上拿不到隔离态。但这不算功能回退,因为它退回的正是加这个头之前的单线程行为,本来也能跑。

Worker 脚本自身也得递归带上 COEP,否则 Chromium 会在隔离页把它拦成 ERR_BLOCKED_BY_RESPONSE。页面能兜底不崩,但会悄悄退回主线程,你只会觉得"怎么好像没变快"。

模型 CDN 也要检查,得返回 access-control-allow-origin: *,并且用 mode: "cors" 去取,开隔离后才下得动。

七、ORT 的 WASM 后端是页面级单例

线程数必须在第一次创建 session 之前定下来,之后改不了。所以"多线程失败了就在同一个页面里退回单线程重试"这条路根本不存在:

js 复制代码
export function decideWasmSessionInit({ configuredThreads, storedProbeResult }) {
  if (configuredThreads <= 1 || storedProbeResult === "unavailable") {
    return { mode: "single-thread" };
  }
  return { mode: "multi-thread" };
}

于是流程变成:先用一个探针 Worker 试一下多线程能不能用,把 ok、error、超时统统归一化成一个可缓存的结果存起来,下次直接读,不用再探。

超时后的自愈只允许一次:写下单线程标记,然后整页 reload;重载后如果发现 guard 还在,说明这条路真的不通,交给 UI 报错,不再循环:

js 复制代码
export function decideWasmTimeoutRecovery({ reloadGuardPresent }) {
  return reloadGuardPresent
    ? { action: "throw" }
    : { action: "reload", writeSingleThreadFlag: true, writeReloadGuard: true };
}

八、档位策略:别信 deviceMemory 有值

WebGPU 路径直接走 768。WASM 路径只有在明确读到设备内存大于 4GB 时才走 768,否则退到 512。

js 复制代码
export function selectModelTier({ backend, deviceMemory }) {
  if (backend === "webgpu") return 768;
  return typeof deviceMemory === "number" &&
    Number.isFinite(deviceMemory) &&
    deviceMemory > 4
    ? 768
    : 512;
}

navigator.deviceMemory 在 Safari 和 Firefox 上经常是 undefined。未知一律按低端机处理,宁可慢一点也别让最弱的设备去扛 768 的峰值内存。

有个地方我一开始理解错了:两档用的是同一份权重,体积一样,512 省的不是下载量,是输入张量和中间特征图的大小。所以它降的是内存峰值和计算量,不是首屏等待。

收尾

绕这一圈,换来的是图片不出浏览器。这个不用信我,开 DevTools 的 Network 面板自己看,除了首次拉模型没有别的请求。

这套东西跑在 koutuxia.com,身份证复印件在 /id-copy。需要说清楚的是,站上还有一条云端去背景的路径,那条是真的会把图传到服务端处理的,页面上单独标了,没混在一起说。

模型选型和量化那块肯定还有优化空间,有做过类似事情的欢迎交流。

相关推荐
kisshyshy1 小时前
从 useRef 到 Web Worker:理解 React 可变对象与浏览器多线程
前端·javascript·react.js
fatcoder1 小时前
玩转Nginx 04 — 反向代理:给 nginx 接上后端
前端·后端·nginx
Data_Journal2 小时前
什么是 CAPTCHA,它是如何工作的?
java·大数据·服务器·前端·数据库
计算机魔术师2 小时前
我看了这个更新,把原来的检索方案推翻了
前端
zhanghaha13143 小时前
HTML系列教程:3_HTML 基础标签 — 标题、段落、超链接、图像
前端·html
李高钢3 小时前
C# WPF Prism 进阶(二):区域(Region)与模块化(Module)
java·前端·数据库
xyphf_和派孔明3 小时前
Vite 与 Webpack 对比及常见面试题
前端·webpack·vite
明月_清风3 小时前
Pi Agent 深度解析:开源极简终端 AI 编码代理的终极指南
前端·后端·ai编程
fatcoder3 小时前
玩转Nginx 03 — location 匹配规则:让不同的路径各回各家
前端·后端·nginx