想象一下:你在浏览器里打开一个网页,下载一个 AI 模型,然后直接在浏览器里和它对话。整个过程数据不出你的电脑,不需要联网,不需要 API Key,完全免费。
这就是要做的事------在浏览器里运行 DeepSeek R1 推理模型,用 WebGPU 硬件加速。
一、端侧模型:为什么要在浏览器里跑 AI?
有别于 OpenAI / DeepSeek API 调用,LLM 在远程,和调用客户端不在一起。
- 贵
- 不安全(context 会随着请求发送到服务器)
这揭示了云 API 的两个核心痛点:
贵:每次调用都按 token 计费。一个重度用户一天轻松消耗上百万 token,一个月就是几百上千元。
不安全:你说的话、你传的文件、你的代码上下文,全部发到了第三方服务器。对于公司内部代码、用户隐私数据来说,这个风险不可接受。
于是有了端侧模型的概念------模型跑在用户自己的设备上:
markdown
云模型(API 调用) 端侧模型
───────────────── ─────────────────
你的电脑 ──数据──► 服务器 你的电脑(模型就在本地)
│ │
▼ ▼
模型推理 模型推理(数据不出设备)
│
▼
你的电脑 ◄──结果── 结果直接显示
端侧模型可以跑在:
- ollama 本地部署(开发者常用)
- 手机端(iOS / Android)
- 汽车端(车载 AI)
- 浏览器端(WebGPU)------这就是本项目的方式
浏览器的优势是:无需安装,随时随地,打开网页就能用。
二、WebGPU:浏览器的 GPU 加速能力
项目中检测 WebGPU 支持的代码:
javascript
const IS_WEBGPU_AVALABLE = !!navigator.gpu;
navigator.gpu 是浏览器提供的 API。如果浏览器支持 WebGPU,它返回一个 GPU 对象;如果不支持,返回 undefined。
!! 是双重取反,作用是把任意值转成布尔值。如果支持 WebGPU 则 IS_WEBGPU_AVALABLE 为 true,不支持的浏览器显示提示信息。
WebGPU 是 WebGL 的下一代,允许网页直接调用 GPU 进行并行计算。AI 模型推理本质上是大量矩阵运算,GPU 天生擅长这个。有了 WebGPU,浏览器里的 AI 推理速度可以接近原生应用。
三、技术栈选型:为什么是 React + TypeScript?
AI 时代的大型项目首选前端技术
- React 比 Vue 难入门
- 大型项目
- AI 训练代码 React 的偏多
这不是说 Vue 不好。事实上笔记也承认"Vue 好入门"。但在 AI 领域,React 生态系统更成熟------HuggingFace 的 Transformers.js、各种模型的 Demo 页面、社区组件库,几乎都是 React。学 React 不是为了"更好",而是为了"和整个生态站在同一边"。
项目依赖(package.json)的精简程度也值得关注:核心只有 React、Vite、Tailwind CSS、TypeScript。没有 Redux、没有 Router、没有 UI 组件库------因为现在还不需要。从最小的依赖开始,按需添加。
四、项目是怎么跑起来的?
4.1 HTML 入口
html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>web-gpu</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
这个文件非常干净。它只做一件事:提供一个空的 div(id="root"),然后加载 main.tsx。后面所有 React 生成的 HTML 都会塞进这个 div 里。
type="module" 告诉浏览器:"用 ES Module 的方式加载这个文件"。这意味着 main.tsx 里可以用 import 语法。
4.2 React 启动入口
javascript
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
逐行解释:
import { StrictMode } from 'react'------StrictMode 是 React 的"严格模式",开发时会帮你发现潜在问题(比如过时的 API、意外的副作用),生产环境不会生效。
import { createRoot } from 'react-dom/client'------createRoot 是 React 18 引入的新 API。它的作用是创建"React 的根节点"。之前 React 17 用的是 ReactDOM.render,18 之后统一用 createRoot。
createRoot(document.getElementById('root')!)------找到 HTML 里那个空的 div(id="root"),用 React 接管它。感叹号是 TypeScript 语法,告诉编译器"这个元素一定存在,不会是 null"。
.render(<StrictMode><App /></StrictMode>)------在这个"根"里渲染 App 组件。
五、第一次理解"组件":函数就是组件
函数有封装特性,组件的 HTML、CSS、JS 封装成一个组件
Vue 的组件是一个 .vue 文件,里面 <template>、<script>、<style> 三个区域分开写,像三明治。
React 的思路完全不同:组件就是一个函数,这个函数返回 HTML。
javascript
function App() {
return (
<div>Hello World</div>
)
}
这整个函数就是一个组件。它接受输入(props,暂时没用到)、返回界面(JSX)、可以有内部逻辑(数据、事件处理等)。
六、JSX:在 JavaScript 里写 HTML
JSX 是 React 专业语法(模板),能在 JS 代码里直接写 HTML 标签,编译后转为原生 DOM 操作。React 最为骄傲的一大特性之一,非常方便地表达 UI 界面。
javascript
return (
<div className="flex flex-col h-screen">
<h1>DeepSeek R1 WebGPU</h1>
</div>
)
这段代码既不是 HTML,也不是纯 JavaScript。它是 JSX------一种"看起来像 HTML 的 JavaScript 扩展语法"。Vite 在编译时会把它转成真正的 JavaScript:
javascript
// 编译前(JSX)
<div className="flex">Hello</div>
// 编译后(JavaScript 等价代码)
React.createElement('div', { className: 'flex' }, 'Hello')
为什么是 className 而不是 class?
class 是 JavaScript 里的关键字(用来声明类)。在 JSX 里写 class 会让编译器困惑------你到底是在写 HTML 的 class 属性,还是在声明一个 JavaScript 类?所以 React 统一用 className。这是历史原因,记住就行。
JSX 的本质 :它不是模板语言(像 Vue 的 {{ }} 或 Jinja 的 {% %})。JSX 就是 JavaScript,所以可以直接写 {变量名} 来嵌入表达式,可以直接写三元运算符,可以直接调用函数。
七、数据驱动界面:告别 DOM 编程
数据状态驱动界面状态,不需要 DOM 编程。数据(数据绑定 && 数据驱动)→ 数据状态(响应式,修改状态界面会跟着变)。数据有不同的状态,界面有不同的状态,川剧变脸一样。
传统写法是直接操作 DOM:
javascript
document.getElementById('status').innerText = '加载中...'
document.getElementById('error').style.display = 'block'
document.getElementById('progress').style.width = '35%'
React 的写法完全不同:
javascript
// 声明数据状态
const [status, setStatus] = useState(null)
const [error, setError] = useState(null)
const [progressItems, setProgressItems] = useState(...)
// 只修改数据,界面自动更新
setStatus('loading')
setError('模型加载失败')
// JSX 中根据状态显示不同界面
{status === 'loading' && <div>加载中...</div>}
{error && <div className="text-red-500">{error}</div>}
打个比方:DOM 编程就像你用手直接调整舞台上的每个演员------你走过去把演员 A 推到左边,把演员 B 的衣服换成红色。React 的数据驱动就像你改变剧本里的角色设定------"这个角色现在是受伤状态",然后舞台自动呈现受伤的效果。
八、useState:数据的三种状态
项目中定义了多个状态变量,每个代表界面的一个"变脸可能":
javascript
const [status, setStatus] = useState(null);
// null → 初始状态
// 'loading'→ 加载中
// 'ready' → 模型就绪
const [error, setError] = useState(null);
// null → 没有错误
// '出错了'→ 显示错误信息
const [loadingMessage, setLoadingMessage] = useState("");
// '' → 无消息
// '正在下载模型...' → 显示提示
const [progressItems, setProgressItems] = useState([{
file: 'model.onnx', // 文件名
progress: 0, // 已下载字节数
total: 147896325 // 总大小(约141MB)
}]);
// progress 从 0 到 total → 下载进度
// 用于显示进度条
useState 是 React 最基础的 Hook。调用它时会返回一个数组,第一项是当前值,第二项是修改值的函数。
为什么叫 Hook(钩子)? 因为它"钩住"了 React 的内部机制。当你调用 setStatus('loading') 时,React 知道你修改了状态,会自动重新渲染组件,界面随之更新。你不需要手动操作 DOM。
九、useEffect:组件什么时候做什么事
javascript
useEffect(() => {
console.log('组件已经挂载完成')
// setTimeout(() => {
// setStatus('ready')
// }, 2000)
}, [])
useEffect 叫"副作用 Hook"。副作用指的是那些与渲染无关、但需要在特定时机执行的操作------发网络请求、设置定时器、订阅事件等。
第二个参数 [](空数组)表示"只在组件第一次挂载时执行一次"。如果写成 [status],则每次 status 变化时都会执行。如果不传第二个参数,则每次渲染都执行。
三种情况对比:
| 第二个参数 | 执行时机 |
|---|---|
[] |
只在组件首次渲染后执行一次 |
[status] |
首次渲染 + 每次 status 变化时执行 |
| 不传 | 每次渲染后都执行(通常要避免) |
十、Tailwind CSS:不写 CSS 的 CSS 方案
原子类 CSS 框架,提供一堆 CSS 类名(原子类)。不用写 CSS 了,选择器、CSS rules 太低效了。Vite 插件就可以使用,将我们声明的类名对应的样式提取出来加到代码里。原子类名,简单语义化很好,特别适合自然语言编程。
传统写 CSS 需要:给元素起类名 → 写选择器 → 写属性 → 写值。一个简单的水平居中要写三行 CSS。
Tailwind 的方式:flex items-center justify-center 一个类名一行搞定。它把每个 CSS 属性都拆成了独立的"原子类"------flex 就是 display: flex,text-center 就是 text-align: center。
Tailwind 已经成为 vibe UI 的基本构成,自然语义编程
"自然语义编程"的意思是:你看到 flex items-center justify-center 就能直接理解"弹性布局、子元素水平居中、垂直居中",不需要去 CSS 文件里找对应的样式。
十一、App.tsx 完整结构拆解
11.1 状态声明区
javascript
const [status, setStatus] = useState(null);
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState("");
const [progressItems, setProgressItems] = useState([{...}]);
组件的第一部分:声明所有需要追踪的数据状态。这些状态决定了界面长什么样。
11.2 WebGPU 检测
javascript
const IS_WEBGPU_AVALABLE = !!navigator.gpu;
不需要 useState,因为浏览器支不支持 WebGPU 在页面加载时就确定了,不会变。
11.3 生命周期
javascript
useEffect(() => {
console.log('组件已经挂载完成');
}, []);
组件挂载后执行。这里将来会放模型加载的逻辑。
11.4 条件渲染
javascript
return (
IS_WEBGPU_AVALABLE ? (
// 支持的界面:标题、描述、模型链接
) : (
<div>您的浏览器还不支持 WebGPU 加速</div>
)
)
根据 WebGPU 是否可用,显示完全不同的界面。这是 React 最强大的能力之一------用一个变量控制整个页面的结构。
11.5 错误状态
javascript
{error && (
<div className="text-red-500 text-center mb-2">
<p className="mb-1">Unable to load mode due to the</p>
<p className="text-sm">{error}</p>
</div>
)}
error && (...) 是短路运算符:如果 error 有值(不为 null),就显示错误框。没有错误时,这部分完全不渲染。
11.6 模型链接
javascript
<a href="https://huggingface.co/onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX"
target="_blank" rel="noreferrer"
className="font-medium underline">
DeepSeek-R1-Distill-Qwen-1.5B
</a>
target="_blank" 在新标签页打开,rel="noreferrer" 是安全措施------防止新页面通过 window.opener 操控原页面。
链接指向的模型是 DeepSeek R1 的 1.5B 蒸馏版本,用 Qwen 架构,转成了 ONNX 格式。ONNX(Open Neural Network Exchange,开放神经网络交换格式)可以跨框架运行,让模型在浏览器里也能推理。
十二、两种界面状态完整对照

十三、关键概念速查
| 概念 | 一句话解释 | 本项目体现 |
|---|---|---|
| JSX | 在 JS 里写 HTML | <div className="flex">...</div> |
| className | 替代 class,因为 class 是 JS 关键字 | className="flex" |
| useState | 声明响应式数据 | const [status, setStatus] = useState(null) |
| useEffect | 在特定时机执行代码 | useEffect(() => {...}, []) |
| 条件渲染 | 根据数据决定渲染什么 | {error && <div>错误</div>} |
| 三元渲染 | 二选一的界面 | IS_WEBGPU_AVALABLE ? 界面A : 界面B |
| 数据驱动 | 改数据 → 界面自动变 | setStatus('ready') 无需操作 DOM |
| Tailwind | 原子类 CSS,不用写 CSS | flex flex-col items-center |
从 0 到 1 理解 React 确实比 Vue 更难------Vue 的三明治结构(template / script / style)更符合直觉。但 React 的"一切皆函数"在大型项目中能提供更强的抽象能力。正如笔记所说,AI 时代的趋势已经摆在这里,值得投入时间去掌握。