用 React 搭一个 WebGPU 模型加载页:从状态驱动到可复用进度条

用 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 插件则扫描代码中的工具类,例如 flextext-centerbg-blue-500,生成页面真正需要的 CSS。

WebGPU 检测到底检测了什么

WebGPU 是浏览器提供的图形与通用计算接口。浏览器端模型可以借助 GPU 执行大量并行计算,避免把输入内容发送到远程推理服务。不过,"浏览器端运行"并不等于"打开页面就自动离线":模型通常仍需首次下载,之后能否离线还取决于浏览器缓存和应用的离线策略。

最小检测代码如下:

javascript 复制代码
const isWebGPUAvailable = Boolean(navigator.gpu);

navigator 是浏览器提供的全局对象,navigator.gpu 存在时表示当前环境暴露了 WebGPU 入口。Boolean(...) 将取得的值明确转换成 truefalse。也可以写成 !!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.maxMath.min 把百分比限制在 0100 之间,避免异常数据生成负宽度或超过容器的进度条。

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 语法更容易理解:

  1. 浏览器读取 index.html,Vite 加载入口模块,React 把 App 渲染到 #root 节点。
  2. React 第一次执行 App,三个 useState 分别提供初始阶段、错误值和文件数组。
  3. 程序读取 navigator.gpu,将结果转换为布尔值。若不支持,组件提前返回提示界面。
  4. 支持 WebGPU 时,React 根据 status === "idle" 渲染介绍文字和可点击按钮,不渲染进度区域。
  5. 用户点击按钮,React 调用 handleLoad。函数清空错误、重置文件数据,并把阶段改为 "loading"
  6. 状态更新后 App 重新执行。按钮变为禁用状态,条件表达式开始渲染进度区域。
  7. progressItems.map(...) 为每个文件创建一个 Progress 组件,属性对象被传给组件并解构。
  8. status 变为 "loading" 后,useEffect 创建定时器。定时器每 300 毫秒调用一次状态更新函数。
  9. setProgressItems((currentItems) => ...) 中的参数由 React 传入,它始终代表最近一次文件状态,能避免异步回调读到过期数据。
  10. map 为每个文件创建新对象,而不是直接修改旧对象。React 收到新数组后重新渲染,进度条宽度和文件大小随之变化。
  11. 另一个 Effect 使用 every 检查全部文件。达到 100% 后,它把状态设为 "ready",页面显示完成信息。
  12. 状态改变会触发前一个 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 项目应让配置包含 jsjsx,并为 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 的入口,进度条也只是加载过程的视图。先把两者之间的数据流设计清楚,再接入真实推理库,错误处理、进度更新和组件维护都会自然得多。

相关推荐
雪隐2 小时前
个人电脑玩AI-13让5060 Ti给你打工——我用 0.9B 小模型终结了"谁来记会议纪要"这个世纪难题
前端·人工智能·后端
橘子星2 小时前
我一个前端切图仔,凭什么能在浏览器里跑大模型?
前端·javascript·前端框架
70asunflower2 小时前
初学者理解 Web 工作原理(完全教程)
前端
爱勇宝2 小时前
《道德经》第 7 章:真正厉害的领导者,不抢主角
前端·后端·程序员
观远数据2 小时前
Excel到数据资产池:文件数据入湖的治理规范怎么建
前端·javascript·excel
CoderWeen2 小时前
我写了个能一步步点着看的 Dijkstra 可视化项目(Vue3 + Leaflet + Generator)
前端·javascript·vue.js
两点王爷3 小时前
一个快速加载面和多面数据的html测试页面(可以导出geojson数据文件)
前端·html·gis
热爱前端的小张3 小时前
第四章 接口和类型兼容性
前端
hunterandroid3 小时前
[鸿蒙从零到一] HarmonyOS 资源管理与多语言适配实战
前端