用 React 搭一个 WebGPU 模型加载页:从状态驱动到可复用进度条
第一次做浏览器端 AI 页面时,很容易把注意力全放在"模型有多大、WebGPU 有多快"上。真正开始写界面后,却会先遇到一组更基础的问题:怎样判断浏览器能否使用 WebGPU?点击按钮后,如何让页面从"等待"切换到"加载中"?多个模型文件的进度怎样渲染?为什么修改普通变量不会更新页面?
这篇文章先不接入真正的模型推理库,而是完成一个可以独立运行的加载界面原型。它包含 WebGPU 能力检测、React 状态、点击事件、条件渲染、列表渲染和组件拆分。理解这条数据流以后,再接入 Transformers.js 或 ONNX Runtime Web,就不会把网络下载、推理引擎和界面状态混在一起。
最终页面有三种主要状态:浏览器不支持 WebGPU 时给出提示;支持时显示介绍和加载按钮;点击按钮后,显示若干模型文件的加载进度。
先搭好可以运行的项目
使用 Vite 创建 React 项目:
bash
npm create vite@latest webgpu-demo -- --template react
cd webgpu-demo
npm install
npm install tailwindcss @tailwindcss/vite
这里选择的是 JavaScript 模板,因此组件文件使用 .jsx。如果选择 react-ts 模板,才需要保留 TypeScript 的 tsc -b 构建检查。JavaScript 项目的 package.json 可以使用下面的脚本:
json
{
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
}
}
在 vite.config.js 中注册 React 和 Tailwind CSS 插件:
javascript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});
然后在 src/index.css 中引入 Tailwind CSS:
css
@import "tailwindcss";
Vite 负责开发服务器和打包,React 插件负责转换 JSX,Tailwind 插件则扫描代码中的工具类,例如 flex、text-center 和 bg-blue-500,生成页面真正需要的 CSS。
WebGPU 检测到底检测了什么
WebGPU 是浏览器提供的图形与通用计算接口。浏览器端模型可以借助 GPU 执行大量并行计算,避免把输入内容发送到远程推理服务。不过,"浏览器端运行"并不等于"打开页面就自动离线":模型通常仍需首次下载,之后能否离线还取决于浏览器缓存和应用的离线策略。
最小检测代码如下:
javascript
const isWebGPUAvailable = Boolean(navigator.gpu);
navigator 是浏览器提供的全局对象,navigator.gpu 存在时表示当前环境暴露了 WebGPU 入口。Boolean(...) 将取得的值明确转换成 true 或 false。也可以写成 !!navigator.gpu,但前一种写法对初学者更直观。
这只是"能力入口检测",不是完整的设备初始化。真正推理时还要调用 navigator.gpu.requestAdapter()、创建设备,或者交给上层推理库处理。即使属性存在,适配器申请、显存分配或模型执行仍可能失败,所以实际应用必须保留错误状态。
WebGPU 通常还要求安全上下文。开发时使用 localhost,部署时使用 HTTPS;否则在支持 WebGPU 的浏览器里也可能拿不到 navigator.gpu。
用状态描述页面,而不是手动修改 DOM
加载页至少需要保存三类会变化的数据:当前阶段、错误信息和文件进度。React 使用 useState 保存这些数据:
javascript
const [status, setStatus] = useState("idle");
const [error, setError] = useState(null);
const [progressItems, setProgressItems] = useState([
{ name: "model.onnx", percentage: 0, loaded: 0, total: 320_000_000 },
{ name: "tokenizer.json", percentage: 0, loaded: 0, total: 2_000_000 },
]);
以第一行为例,useState("idle") 返回一个数组,数组中有两个值:
status是当前这一次渲染所看到的状态;setStatus是更新状态的函数,调用它会安排下一次渲染;"idle"是组件首次出现时的初始值。
这里用 "idle"、"loading"、"ready" 表示明确的阶段,比用 null 同时表达"尚未开始""没有错误"等多种含义更容易维护。
点击按钮时,不需要查找 DOM 再修改它的文本或隐藏属性,只要更新状态:
jsx
<button onClick={handleLoad}>加载模型</button>
javascript
function handleLoad() {
setError(null);
setStatus("loading");
}
onClick 接收的是函数本身,而不是函数调用结果。写成 onClick={handleLoad()} 会在渲染期间立即执行。用户点击后,React 调用 handleLoad,状态变化触发组件重新执行,新的 JSX 再决定页面应该显示什么。这就是"状态驱动界面"。
React 中写 className 而不是 class,因为 JSX 属性采用 JavaScript 风格命名。onClick 也采用驼峰形式,并接收函数。React 会通过自己的事件系统把它接到浏览器事件上;对普通点击逻辑来说,可以把参数当作一个事件对象使用。
把进度条拆成可复用组件
模型通常由多个文件组成。如果把每个文件的结构都写在主组件里,代码很快会重复。创建 src/components/Progress.jsx:
jsx
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const unitIndex = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
);
const value = bytes / 1024 ** unitIndex;
return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
export default function Progress({ name, percentage = 0, loaded = 0, total = 0 }) {
const safePercentage = Math.min(100, Math.max(0, percentage));
return (
<div className="mb-4">
<div className="mb-1 flex justify-between gap-4 text-sm">
<span className="truncate">{name}</span>
<span>{safePercentage.toFixed(0)}%</span>
</div>
<div className="h-2 overflow-hidden rounded bg-gray-200">
<div
className="h-full bg-blue-500 transition-[width]"
style={{ width: `${safePercentage}%` }}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
{formatBytes(loaded)} / {formatBytes(total)}
</p>
</div>
);
}
组件参数本质上是一个 props 对象。函数签名中的 { name, percentage = 0, loaded = 0, total = 0 } 是对象解构,容易理解的展开写法是:
javascript
function Progress(props) {
const name = props.name;
const percentage = props.percentage ?? 0;
// ...
}
默认值只会在属性为 undefined 时生效。随后用 Math.max 和 Math.min 把百分比限制在 0 到 100 之间,避免异常数据生成负宽度或超过容器的进度条。
style 接收的是 JavaScript 对象,因此外层花括号表示"进入 JavaScript 表达式",内层花括号表示对象。模板字符串 ${safePercentage}% 把数字变成 CSS 可以识别的百分比宽度。
完整实现:让数据流真正连起来
下面的 src/App.jsx 是可直接运行的界面原型。为了让交互可观察,它用定时器模拟下载;这段模拟不会下载模型,也不会执行 AI 推理。以后接入推理库时,只需用真实的进度回调替换模拟逻辑。
jsx
import { useEffect, useState } from "react";
import Progress from "./components/Progress";
const initialFiles = [
{ name: "model.onnx", percentage: 0, loaded: 0, total: 320_000_000 },
{ name: "tokenizer.json", percentage: 0, loaded: 0, total: 2_000_000 },
];
export default function App() {
const [status, setStatus] = useState("idle");
const [error, setError] = useState(null);
const [progressItems, setProgressItems] = useState(initialFiles);
const isWebGPUAvailable = Boolean(navigator.gpu);
useEffect(() => {
if (status !== "loading") return undefined;
const timerId = window.setInterval(() => {
setProgressItems((currentItems) => {
return currentItems.map((item) => {
const nextPercentage = Math.min(100, item.percentage + 5);
return {
...item,
percentage: nextPercentage,
loaded: Math.round(item.total * nextPercentage / 100),
};
});
});
}, 300);
return () => window.clearInterval(timerId);
}, [status]);
useEffect(() => {
const isComplete = progressItems.every((item) => item.percentage === 100);
if (status === "loading" && isComplete) {
setStatus("ready");
}
}, [progressItems, status]);
function handleLoad() {
setError(null);
setProgressItems(initialFiles.map((item) => ({ ...item })));
setStatus("loading");
}
if (!isWebGPUAvailable) {
return (
<main className="grid min-h-screen place-items-center p-6 text-center">
<div>
<h1 className="mb-2 text-2xl font-bold">当前环境无法使用 WebGPU</h1>
<p>请尝试最新版浏览器,并确认页面运行在 HTTPS 或 localhost 下。</p>
</div>
</main>
);
}
return (
<main className="flex min-h-screen flex-col items-center justify-center p-6 text-gray-800">
<section className="w-full max-w-xl text-center">
<h1 className="mb-3 text-4xl font-bold">浏览器端模型加载器</h1>
<p className="mb-6 leading-7">
页面使用 WebGPU 能力检测,并用 React 状态展示模型文件的加载过程。
</p>
{error && <p className="mb-4 text-red-600">加载失败:{error}</p>}
<button
type="button"
disabled={status === "loading"}
onClick={handleLoad}
className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{status === "loading" ? "加载中..." : status === "ready" ? "重新加载" : "加载模型"}
</button>
</section>
{status !== "idle" && (
<section className="mt-8 w-full max-w-xl" aria-live="polite">
<p className="mb-4 text-center">
{status === "ready" ? "模型文件已准备好" : "正在准备模型文件"}
</p>
{progressItems.map((item) => (
<Progress key={item.name} {...item} />
))}
</section>
)}
</main>
);
}
列表渲染中的 map 接收一个回调函数。数组里的每个 item 都会调用一次回调,回调返回一个 Progress 元素,最终得到一组可渲染的元素。key={item.name} 帮助 React 识别同一个列表项;文件名在这组数据中必须唯一。
{...item} 是对象展开,相当于分别写 name={item.name}、percentage={item.percentage}、loaded={item.loaded} 和 total={item.total}。属性较少时逐项书写更明确;属性与组件参数一一对应时,展开写法能减少重复。
程序从打开页面到加载完成经历了什么
把代码按运行顺序串起来,会比孤立地记 Hook 语法更容易理解:
- 浏览器读取
index.html,Vite 加载入口模块,React 把App渲染到#root节点。 - React 第一次执行
App,三个useState分别提供初始阶段、错误值和文件数组。 - 程序读取
navigator.gpu,将结果转换为布尔值。若不支持,组件提前返回提示界面。 - 支持 WebGPU 时,React 根据
status === "idle"渲染介绍文字和可点击按钮,不渲染进度区域。 - 用户点击按钮,React 调用
handleLoad。函数清空错误、重置文件数据,并把阶段改为"loading"。 - 状态更新后
App重新执行。按钮变为禁用状态,条件表达式开始渲染进度区域。 progressItems.map(...)为每个文件创建一个Progress组件,属性对象被传给组件并解构。status变为"loading"后,useEffect创建定时器。定时器每 300 毫秒调用一次状态更新函数。setProgressItems((currentItems) => ...)中的参数由 React 传入,它始终代表最近一次文件状态,能避免异步回调读到过期数据。map为每个文件创建新对象,而不是直接修改旧对象。React 收到新数组后重新渲染,进度条宽度和文件大小随之变化。- 另一个 Effect 使用
every检查全部文件。达到 100% 后,它把状态设为"ready",页面显示完成信息。 - 状态改变会触发前一个 Effect 的清理函数;组件卸载时也一样。清理函数会清除定时器,避免后台任务继续修改已经离开的组件。
开发环境中若使用 StrictMode,可能看到 Effect 在挂载阶段经历额外的一次"执行---清理---再执行"。这是 React 用来暴露不完整清理逻辑的开发期检查,并不代表生产环境会重复下载。正因为如此,真实模型加载任务需要支持取消或复用,不能假定 Effect 永远只执行一次。
常见错误与排查
构建时提示找不到 tsconfig.json
典型错误是:
text
error TS5083: Cannot read file '.../tsconfig.json'.
原因通常是项目使用 JavaScript 模板,却把构建命令写成了 TypeScript 模板的 tsc -b && vite build。如果代码都是 .js/.jsx,将脚本改为 vite build;如果确实要使用 TypeScript,则补齐根 tsconfig.json、应用配置和 .tsx 文件,不要只保留一半配置。
ESLint 成功,但 JSX 根本没有被检查
若 ESLint 配置只有下面的匹配规则:
javascript
files: ["**/*.{ts,tsx}"]
那么 .jsx 文件不会进入这组规则,"没有报错"不等于代码通过了检查。JavaScript React 项目应让配置包含 js 和 jsx,并为 JSX 启用合适的 React、Hooks 与浏览器全局变量规则:
javascript
files: ["**/*.{js,jsx}"]
排查时可以故意在一个 .jsx 文件中加入明显的未定义变量,再运行 npm run lint,确认规则确实命中了目标文件,验证后再撤销测试代码。
点击按钮后立即执行,或者完全没反应
错误写法把调用结果交给了 onClick:
jsx
<button onClick={handleLoad()}>加载模型</button>
正确写法是传入函数:
jsx
<button onClick={handleLoad}>加载模型</button>
需要附带参数时再包一层回调,例如 onClick={() => handleLoad(modelId)}。
进度数据变了,界面却没有更新
不要直接写 progressItems[0].percentage = 50。这会修改原对象,却没有通过状态更新函数告诉 React。应返回新数组和新对象:
javascript
setProgressItems((items) =>
items.map((item) =>
item.name === targetName ? { ...item, percentage: 50 } : item,
),
);
排查时依次确认:是否调用了 setProgressItems;是否返回了新数组;目标文件名是否匹配;传给 Progress 的属性名是否一致。
WebGPU 检测始终失败
依次检查浏览器版本和平台是否支持 WebGPU、页面是否位于 HTTPS 或 localhost、硬件加速是否开启。还要区分"没有 navigator.gpu"与"后续申请适配器失败":前者属于环境能力问题,后者应捕获具体异常并显示在错误区域。
从界面原型走向真实模型加载
当前实现刻意只模拟进度,限制也很清楚。接入真实模型时,可以沿着现有状态边界逐步替换:
- 将定时器替换为推理库提供的文件下载进度回调,在回调里按文件名更新
progressItems。 - 把模型初始化包在
try...catch中:开始时设为"loading",成功后设为"ready",失败时保存可读错误并切换到"error"。 - 使用 Web Worker 承担模型初始化与推理,避免长计算阻塞主线程;主线程只接收消息并更新 React 状态。
- 为加载任务增加取消机制,组件卸载或用户切换模型时终止网络请求和后台工作。
- 不要只依赖下载百分比。真实初始化还可能经历"读取缓存""编译计算图""预热模型"等阶段,可以增加独立的阶段文本。
- 模型文件可能达到数百 MB 甚至数 GB。上线前要处理存储空间、移动网络、缓存失效和显存不足,而不是只判断 WebGPU 是否存在。
当这些功能逐步加入时,核心原则仍然不变:外部加载过程产生数据,状态保存数据,React 根据状态生成界面。组件不负责猜测模型发生了什么,只负责把收到的状态准确展示出来。
总结
一个看似简单的模型加载页,串起了 React 最重要的实践链路:用 useState 描述可变数据,用事件函数触发状态变化,用条件表达式切换界面,用 map 把数组转换成组件列表,再用 useEffect 管理需要清理的外部任务。
WebGPU 检测只是浏览器端 AI 的入口,进度条也只是加载过程的视图。先把两者之间的数据流设计清楚,再接入真实推理库,错误处理、进度更新和组件维护都会自然得多。