浏览器跑大模型(三):你的浏览器到底支不支持 WebGPU?
本文为原创。基于真实项目
webgpu-deepseek(WebGPU + Transformers.js 在浏览器本地跑 DeepSeek-R1-Distill-Qwen-1.5B)讲解。
系列第 3 篇 / 共 5 篇 · 上篇:《点了 Load 之后,那个 800MB 的下载进度条去哪了?》(模型下载进度状态机) · 下篇预告:《封装成 React Hook:把 WebGPU 推理收进一个 useLLM》
开篇:一个"假支持"的真实事故
你在 Chrome 里打开你的 WebGPU 应用,页面正常渲染,按钮能点,UI 一切如常。你放心地让用户点 "Load model"------然后模型加载失败,报错 WebGPU is not supported。
奇怪,你不是在最开头就检测了 WebGPU 吗?const IS_WEBGPU_AVAILABLE = !!navigator.gpu;------这个值是 true,页面才会进到正常 UI 的。怎么到了真正用的时候又说不支持了?
因为这个检测只回答了"API 在不在",没回答"硬件能不能"。
这是 WebGPU 开发中最常见的一个认知陷阱:navigator.gpu 存在,不代表这台机器真的能跑 WebGPU。 Chrome 可能在你的机器上暴露了 navigator.gpu 接口(因为浏览器编译时带了这个特性),但底层显卡驱动太旧、虚拟显卡不支持、或者被列入黑名单------requestAdapter() 返回 null,真正要用的时候才发现"假支持"。
这篇文章就讲一件事:WebGPU 的支持检测必须做两层------特性检测决定 UI 骨架,能力检测确认硬件真能跑。 读懂这两层的分工,你的"不支持"提示才能出现在该出现的时候,而不是等模型加载到一半才炸。
一、先看清全貌:两层检测在哪
我们的项目里,WebGPU 检测分散在两个文件、两个时机:
| 层次 | 代码位置 | 代码 | 时机 | 回答的问题 |
|---|---|---|---|---|
| 第一层:特性检测 | App.tsx 顶部 |
const IS_WEBGPU_AVAILABLE = !!navigator.gpu; |
模块加载时(同步) | 浏览器有没有 WebGPU 这个 API? |
| 第二层:能力检测 | worker.js 的 check() |
const adapter = await navigator.gpu.requestAdapter(); |
Worker 启动后(异步) | 这台机器的硬件真的能创建 GPU 适配器吗? |
两层缺一不可。只做第一层,你会遇到"假支持"事故;只做第二层,你在不支持 WebGPU 的浏览器里连 navigator.gpu 都访问不到,直接报 TypeError。
二、第一层:!!navigator.gpu ------ 同步的特性检测
来看 App.tsx 顶部这行真实的代码:
jsx
// App.tsx ------ 模块顶层,页面加载就执行
const IS_WEBGPU_AVAILABLE = !!navigator.gpu;
🔑 这行代码做了什么?
navigator.gpu 是 WebGPU API 的入口对象。如果浏览器实现了 WebGPU,这个属性就存在(是个对象,truthy);如果没实现,就是 undefined(falsy)。!! 把它转成干净的布尔值。
它的作用是"决定 UI 骨架"------看 App 的 return:
jsx
return (
IS_WEBGPU_AVAILABLE ? (
<div className="..."> {/* 正常的聊天 UI */}
{/* logo、Load 按钮、聊天界面... */}
</div>
) : (
<div className="fixed w-screen h-screen bg-black ...">
WebGPU is not supported
<br />
by this browser :(
</div>
)
);
🔑 关键设计:用三元运算符切换整棵组件树,而不是"渲染正常 UI + 弹个提示"。 因为如果浏览器根本没 WebGPU API,后面所有依赖 navigator.gpu 的代码都会报错。与其让 UI 渲染出来再崩,不如在入口处直接分流------不支持的走黑屏提示,连正常 UI 的代码都不执行。
⚠️ 为什么这一层必须同步?因为它决定的是"渲染哪棵树"。如果做成异步(比如
useEffect里检测再setState),React 第一次渲染会先渲染正常 UI,等异步结果回来再切换------那一帧里正常 UI 的代码可能就崩了。决定骨架的检测,必须在第一次渲染之前完成。
三、第二层:requestAdapter() ------ 异步的能力检测
第一层只保证了"API 存在",但 API 存在 ≠ 硬件能跑。所以项目在 Worker 启动后,紧接着做第二层检测。看 App.tsx 的 useEffect:
jsx
// App.tsx ------ useEffect 里,Worker 创建后立即检测
useEffect(() => {
if (!worker.current) {
worker.current = new Worker(new URL("./worker.js", import.meta.url), {
type: "module",
});
// 🔑 创建完 Worker,第一件事就是发 check 消息
worker.current.postMessage({ type: "check" });
}
// ...注册消息监听
}, []);
Worker 收到 check 消息后,执行 check() 函数------这才是真正的"能力检测":
js
// worker.js ------ 真实代码
async function check() {
try {
// adapter 是 GPU 适配器的抽象
// 后续所有 WebGPU 计算/渲染操作都通过 device 执行
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
// 抛出错误
throw new Error("WebGPU is not supported (no adapter found)");
}
// fp16_supported = adapter.features.has("shader-f16") // ← 留了扩展点
} catch (e) {
self.postMessage({
status: "error",
data: e.toString(),
});
}
}
🔑 这段代码做了三件事:
第一,navigator.gpu.requestAdapter() 是异步的,返回 Promise。 它会真正去问底层的显卡驱动:"你能给我一个 GPU 适配器吗?" 如果驱动不支持、显卡被禁用、或者被浏览器黑名单了,它返回 null。
第二,if (!adapter) 显式检查 null 并抛错。 这是"能力检测"的核心------requestAdapter() 不抛异常,它返回 null 表示"找不到可用的适配器"。如果你不检查,后面 adapter.requestDevice() 就会炸在 null 上。异步 API 返回 null 比 throw 更隐蔽,必须显式拦截。
第三,错误通过 self.postMessage({ status: "error", data: e.toString() }) 回传主线程。 主线程的 onMessageReceived 收到 error 状态后 setError(e.data.data),UI 上就会显示那段红色的错误提示。
注意那行注释
// fp16_supported = adapter.features.has("shader-f16")------这是开发者留的扩展点。拿到 adapter 后,你还能进一步探测它支持哪些特性(比如 fp16 半精度浮点)。这是"第三层检测":不只问"能不能跑",还问"能跑到什么程度"。starter 把它注释掉了,但它揭示了一个完整的能力探测阶梯。
四、为什么必须两层?一个对比说清楚
❌ 只做第一层(特性检测)
jsx
const IS_WEBGPU_AVAILABLE = !!navigator.gpu;
// 如果 IS_WEBGPU_AVAILABLE === true,就放心地渲染正常 UI
// 用户点 Load → worker 调 requestAdapter() → 返回 null → 崩
// 用户看到:"Loading model..." 然后莫名报错,不知道为什么
问题:navigator.gpu 存在但 requestAdapter() 返回 null 的情况比你以为的常见------旧版 Chrome、虚拟机显卡、Linux 某些驱动、被黑名单的 GPU 型号。
✅ 两层都做
jsx
// 第一层:同步,决定 UI 骨架
const IS_WEBGPU_AVAILABLE = !!navigator.gpu;
// IS_WEBGPU_AVAILABLE === false → 直接黑屏,连 Worker 都不创建
// 第二层:异步,在 Worker 里确认硬件
// IS_WEBGPU_AVAILABLE === true → 创建 Worker → check() → requestAdapter()
// adapter === null → postMessage error → UI 显示明确错误
// adapter 存在 → 一切正常,可以 Load
两层分工的本质:第一层是"门卫",把完全不支持 WebGPU 的浏览器挡在门外(连 Worker 都不创建,省资源);第二层是"体检",对进了门的浏览器再做一次硬件级确认。
五、TypeScript 类型治理:navigator.gpu 的类型从哪来
如果你用 TypeScript 写 navigator.gpu,默认会报红:Property 'gpu' does not exist on type 'Navigator'。因为 WebGPU 的类型定义不在 TS 标准库里。
这个项目是怎么解决的?看 tsconfig.app.json:
json
{
"compilerOptions": {
"lib": ["ES2023", "DOM"],
"types": ["vite/client", "@webgpu/types"], // 🔑 关键
// ...
}
}
🔑 @webgpu/types 是一个独立的 NPM 包,专门提供 WebGPU 的 TypeScript 类型声明。 它会给 Navigator 接口扩展 gpu 属性,给 navigator.gpu 补上 requestAdapter()、requestDevice() 等方法的类型签名。
为什么需要单独装? 因为 WebGPU 还在演进中,TS 标准库(lib.dom.d.ts)不会内置一个尚未完全定稿的 API 的类型。@webgpu/types 跟着规范走,版本独立更新。等 WebGPU 正式进入标准后,它才会被合并进 lib.dom.d.ts。
⚠️ 一个真实会踩的坑:
@webgpu/types必须放在tsconfig的types数组里才生效。如果你只npm install @webgpu/types但没配types,TS 不会自动加载它(不像@types/react那样会被自动发现)。types数组是"白名单"------一旦你写了它,TS 就只加载列出来的包,不再自动扫描node_modules/@types。 这也是为什么这里同时列了vite/client(Vite 的客户端类型,提供import.meta.env等)。
vite.config.ts 本身不需要任何 WebGPU 特殊配置------Vite 不关心你用不用 WebGPU,它只管打包。WebGPU 的"支持"是运行时的事,不是构建时的事。
六、Worker 里检测 vs 主线程检测:为什么放 Worker
你可能注意到:第二层检测 check() 是在 Worker 里执行的,不是在主线程。为什么?
因为 requestAdapter() 是个重操作------它要初始化 GPU 上下文、查询驱动、可能还会触发显卡唤醒。如果在主线程做,这个异步操作期间如果主线程还有别的渲染任务,体验会卡顿。
更重要的是:WebGPU 的后续操作(创建 device、提交计算命令)全都在 Worker 里执行(模型推理也在 Worker)。把检测也放 Worker,让"检测 → 加载 → 推理"形成一条完整的 Worker 内链路,主线程只管收消息、更新 UI。这和前两篇讲的"Worker 负责干活、主线程负责展示"的职责切分一脉相承。
📌 对比一下:第一层检测在主线程(因为要同步决定 UI 骨架),第二层在 Worker(因为是重操作且后续操作都在 Worker)。不是"所有检测都放一个地方",而是"按职责和时机分流"。 这个判断比"在哪检测"本身更值得记住。
七、一张图看懂两层检测的决策流
顺着这张图走一遍:第一层在主线程同步分流(C 的分叉),第二层在 Worker 异步确认(J 的分叉)。 两个分叉,一个挡掉"没 API 的浏览器",一个挡掉"有 API 但硬件不行"的机器。任何一条路走不通,用户都能看到明确的提示,而不是一个莫名的崩溃。
结尾:检测的本质是"对用户诚实"
很多人把兼容性检测当成"防御性编程"的边角料,随便 if 一下就过去了。但在 WebGPU 这种"依赖硬件能力"的场景里,检测的质量直接决定用户体验的下限。
回过头看这两层设计,它的哲学很简单:
- 第一层
!!navigator.gpu对浏览器诚实------"你有没有这个 API?" - 第二层
requestAdapter()对硬件诚实------"你真的能跑吗?"
两层之间,不信任、不假设。navigator.gpu 存在不代表能用,requestAdapter() 不报错不代表能跑------还得看返回值是不是 null。每一层都只回答自己那个问题,不越界、不打包票。
这种"分层不信任"的思路,不止适用于 WebGPU。你做任何依赖运行时能力的功能(WebSocket、IndexedDB、Service Worker、FileSystem API),都该问自己:我是检测了"API 在不在",还是检测了"它真的能用"?
留个开放问题:如果
requestAdapter()成功了,但后续adapter.requestDevice()因为显存不足失败了,你该怎么处理? 这其实就是"第三层检测"的起点。评论区聊聊你的思路。
觉得有用,点赞 + 收藏不迷路。系列还剩最后两篇,下篇讲怎么把前面三层(单例管理、下载进度、兼容检测)收进一个useLLMHook,让调用方一行代码就能用上本地大模型。关注我别掉队。