摘要
React+TS+Tailwind搭建DeepSeek-R1-WebGPU端侧推理。拆解函数组件、JSX、合成事件、useState、Progress封装、组件树,展示AI时代前端工程化实践。
端侧模型:为什么要把LLM搬到浏览器里
调用OpenAI或DeepSeek的云端API是目前最主流的方式------模型在远程服务器上,客户端通过HTTP请求发送prompt并接收响应。这条路有三个绕不开的问题:贵 (每次调用都计费)、不安全 (context随请求发送到服务端)、依赖网络(离线不可用)。
端侧模型(On-Device Model)提供了一条不同的路径。Ollama支持本地部署开源模型,小参数模型就能完成特定任务。更进一步,借助WebGPU的浏览器端加速能力,模型可以直接在浏览器中下载、加载、推理------数据不出设备,离线也能用,完全免费。
(预留图片位置:云端API vs 端侧模型对比图------左侧标注"贵/不安全/依赖网络",右侧标注"免费/数据本地/离线可用",中间标注WebGPU+Transformers.js作为桥梁)
这个项目加载的是DeepSeek-R1-Distill-Qwen-1.5B,一个15亿参数的推理模型蒸馏版,通过HuggingFace的Transformers.js和ONNX Runtime Web在浏览器中运行。
技术选型:React + TypeScript + TailwindCSS
AI时代的大型项目,React + TypeScript的组合已逐渐成为首选。React比Vue入门门槛更高,但大型项目中React的训练代码和社区生态更丰富。TypeScript在React中的类型约束让代码更健壮,ESLint则保证团队代码风格一致。
项目通过Vite创建,依赖结构如下:
json
{
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"typescript": "~6.0.2",
"vite": "^8.0.12",
"eslint": "^10.3.0",
"@vitejs/plugin-react": "^6.0.1"
}
}
vite.config.ts 中同时注册了React和TailwindCSS两个插件:
typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss()
],
})
TailwindCSS作为Vite插件运行,在构建时扫描代码中使用的原子类名,提取对应的CSS规则注入到样式表中------不需要手写任何CSS文件,index.css 里只有一行 @import "tailwindcss"。
函数即组件:React的组件哲学
Vue用 .vue 单文件把template、script、style三块放在一个文件里,对新手友好。React的组件则是一个返回JSX的函数------函数就是组件,函数的return之前是JS逻辑区域,return之后是UI表达。
入口文件 main.tsx 三行完成挂载:
tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
Vue挂载到 #app,React挂载到 #root------这是两个框架的一个微妙差异。index.html 中 <div id="root"></div> 配合 <script type="module" src="/src/main.tsx"> 完成启动链路。
JSX:在JavaScript里写HTML
JSX是React最引以为傲的特性之一。它让开发者在JS代码中直接写HTML标签,编译后转为原生DOM操作。<div></div> 这种XML语法嵌入在JS中,本质上是一种语法糖。
一个小细节:JSX中写CSS类名用 className 而不是 class。因为 class 是JavaScript的OOP关键字(声明类),React选择用 className 来避免冲突------不发明新概念,沿用已有的Web标准命名。
tsx
<div className="flex flex-col h-screen mx-auto items-center">
<h1 className="text-4xl font-bold mb-1">Deepseek R1 WebGPU</h1>
</div>
TailwindCSS:原子类替代手写CSS
TailwindCSS提供了一套原子CSS类------每个类名对应一个CSS属性,组合使用即可构建任意样式。flex 对应 display: flex,text-4xl 对应 font-size: 2.25rem,font-bold 对应 font-weight: 700。
方括号语法支持任意值:max-w-[400px] 生成 max-width: 400px,mb-1 中 1 代表 0.25rem(4px)。这种语义化的类名特别适合自然语言编程------看到类名就能猜到效果。
tsx
<button
className="border px-4 py-2 rounded-lg bg-blue-400 text-white
hover:bg-blue-500 disabled:cursor-not-allowed select-none"
disabled={status !== null || error !== null}
onClick={() => setStatus('loading')}>
Load Model
</button>
hover:bg-blue-500 控制悬停态,disabled:cursor-not-allowed 控制禁用态------状态变体作为类名前缀,语义清晰。TailwindCSS已经成为Vibe UI的基本构成,开发效率远高于手写选择器和CSS规则。
合成事件:React对DOM事件的封装
DOM事件体系经历了多个版本迭代。DOM 0级用 onclick 属性直接写在HTML标签上,但这种方式把HTML、CSS、JS耦合在一起,不符合模块化分离原则。DOM 1级没有更新事件相关内容。DOM 2级引入了 addEventListener,同一个DOM元素可以多次监听同一事件。
React没有发明新的事件绑定语法(Vue用 @click),而是直接用已有的概念------onClick。但React中的事件并不是原生DOM事件,而是合成事件(SyntheticEvent)。React在原生事件之上封装了一层,统一了跨浏览器的事件行为:
tsx
<textarea
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
setInput(target.value);
}}
onKeyDown={(e) => {
if (input.length > 0 && e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onEnter();
}
}}
/>
TypeScript环境下,e.target 的类型需要手动断言------as HTMLTextAreaElement 告诉编译器这个事件目标一定是textarea元素,上面有 value 属性。React不像Vue那样提供 v-model 双向绑定,需要用 value + onInput 手动实现受控组件。
useState:数据状态驱动界面状态
React的核心思想是数据状态驱动界面状态。数据有不同的状态,界面就呈现不同的样子------不需要手动操作DOM,修改状态,界面自动更新。
tsx
const [input, setInput] = useState('')
const [status, setStatus] = useState("ready")
const [error, setError] = useState(null)
const [loadingMessage, setLoadingMessage] = useState("开始加载")
const [progressItems, setProgressItems] = useState([])
useState 返回一个数组:第一个元素是当前状态值,第二个是修改状态的函数。项目中定义了多种状态:
status:null(初始)→"loading"(加载中)→"ready"(模型就绪),驱动整个页面的界面切换error:错误对象,非空时渲染红色错误提示progressItems:下载进度数组,驱动进度条列表渲染input:用户输入,受控组件的值
状态驱动的条件渲染:
tsx
// 报错界面状态
{error && (
<div className="text-red-500 text-center mb-2">
<p>Unable to load model due to the following error:</p>
<p>{error}</p>
</div>
)}
// 加载状态------显示进度条
{status === 'loading' && (
<div className="w-full max-w-[500px]">
{progressItems.map((item, i) => (
<Progress key={i} text={item.text}
percentage={item.percentage} total={item.total} />
))}
</div>
)}
useEffect 是React的生命周期钩子,组件挂载后执行副作用:
tsx
useEffect(() => {
console.log('组件已经挂载完成')
}, [])
空依赖数组 [] 表示只在挂载时执行一次,相当于Vue的 onMounted。
进度条组件:Props与组件封装
Progress 是一个独立的、可复用的业务模块,从App中抽离出来作为子组件。
tsx
const Progress = ({ text, percentage, total }) => {
percentage ??= 0
return (
<div className="w-full bg-gray-100 text-left rounded-lg overflow-hidden mb-0.5">
<div
style={{width: `${percentage}%`}}
className="bg-blue-400 whitespace-nowrap px-1 text-sm">
{text}
{percentage.toFixed(2)}%
{isNaN(total) ? "" : `of ${formatBytes(total)}`}
</div>
</div>
)
}
percentage ??= 0 使用了ES12的空值合并运算符。当 percentage 为 null 或 undefined 时,赋值为0;如果传入了有效值,不覆盖。封装者多考虑一步,使用者就少踩一个坑------这是组件健壮性的体现。
父组件通过props向子组件传递数据:
tsx
<Progress
key={i}
text={item.text}
percentage={item.percentage}
total={item.total}
/>
key={i} 是React列表渲染的必需属性,帮助React追踪每个元素的身份,优化DOM更新。
formatBytes 函数负责将字节数转换为可读格式(B/kB/MB/GB/TB),通过 Math.log(size) / Math.log(1024) 计算量级,从单位数组中选取对应单位。
两种数据:State vs Props
React组件中有两种本质不同的数据:
State(状态) :组件自有数据,通过 useState 声明,组件自己管理和修改。比如 input、status、error,都是App组件内部的状态,修改通过 setInput、setStatus 等函数触发。
Props(属性) :从父组件传递给子组件的属性,子组件不能修改 。Progress 组件接收 text、percentage、total 作为props,它只负责展示,不负责修改------这是React单向数据流的核心约束。
子组件主要负责展示,父组件给我什么props,我就显示成什么样子。
组件树:前端发展的必然
基于组件封装,页面形成了一个组件树,替代了传统的DOM树。从JSX结构一眼就能看出页面的组件构成:
scss
App
├── Header (标题区域)
├── Load Button (模型加载按钮)
├── Error Display (条件渲染的错误提示)
├── Progress[] (下载进度条列表)
└── Chat Input (对话输入框)
页面交付越来越复杂,组件作为开发的最小单元,让团队协作更高效、代码更易复用和维护。组件化的粒度反映了前端工程化的成熟度。
WebGPU检测与条件渲染
项目用一行代码检测浏览器是否支持WebGPU:
tsx
const IS_WEBGPU_AVAILABLE = !!(navigator as any).gpu
navigator.gpu 在不支持时为 undefined,!! 双重否定将其转为确定的布尔值。TypeScript中 navigator.gpu 还不是标准类型,用 as any 绕过类型检查。
整个页面根据这个标志做条件渲染:支持WebGPU时渲染完整的模型加载界面,不支持时显示一行提示文字。
核心收获
这个项目展示了一条完整的"端侧AI"技术路线:React + TypeScript负责前端工程化,TailwindCSS提供原子化样式,Transformers.js + WebGPU让浏览器端运行LLM推理。从组件设计到状态管理,从合成事件到类型断言,从Props约束到组件树架构,每个细节都在回答一个问题:如何用现代前端技术栈构建一个AI应用。
相比Vue的模板语法,React的函数式组件更接近原生JavaScript------函数就是组件,JSX就是HTML,Props就是函数参数。这种"不发明新概念"的设计哲学,让React在大型项目中保持了高度的灵活性和可维护性。
本文基于React + TypeScript + Vite + TailwindCSS的DeepSeek-R1-WebGPU项目实践,所有代码均来自实际项目文件。从端侧模型概念到组件封装,完整展示了AI时代前端工程师的技术选型与工程化实践。