React+TS+Tailwind实战:在浏览器里跑DeepSeek-R1端侧模型

0 阅读8分钟

摘要

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创建,依赖结构如下:

{
  "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两个插件:

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 三行完成挂载:

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标准命名。

<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: flextext-4xl 对应 font-size: 2.25remfont-bold 对应 font-weight: 700

方括号语法支持任意值:max-w-[400px] 生成 max-width: 400pxmb-11 代表 0.25rem(4px)。这种语义化的类名特别适合自然语言编程——看到类名就能猜到效果。

<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在原生事件之上封装了一层,统一了跨浏览器的事件行为:

<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,修改状态,界面自动更新。

const [input, setInput] = useState('')
const [status, setStatus] = useState("ready")
const [error, setError] = useState(null)
const [loadingMessage, setLoadingMessage] = useState("开始加载")
const [progressItems, setProgressItems] = useState([])

useState 返回一个数组:第一个元素是当前状态值,第二个是修改状态的函数。项目中定义了多种状态:

  • statusnull(初始)→ "loading"(加载中)→ "ready"(模型就绪),驱动整个页面的界面切换
  • error:错误对象,非空时渲染红色错误提示
  • progressItems:下载进度数组,驱动进度条列表渲染
  • input:用户输入,受控组件的值

状态驱动的条件渲染:

// 报错界面状态
{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的生命周期钩子,组件挂载后执行副作用:

useEffect(() => {
  console.log('组件已经挂载完成')
}, [])

空依赖数组 [] 表示只在挂载时执行一次,相当于Vue的 onMounted


进度条组件:Props与组件封装

Progress 是一个独立的、可复用的业务模块,从App中抽离出来作为子组件。

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的空值合并运算符。当 percentagenullundefined 时,赋值为0;如果传入了有效值,不覆盖。封装者多考虑一步,使用者就少踩一个坑——这是组件健壮性的体现。

父组件通过props向子组件传递数据:

<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 声明,组件自己管理和修改。比如 inputstatuserror,都是App组件内部的状态,修改通过 setInputsetStatus 等函数触发。

Props(属性):从父组件传递给子组件的属性,子组件不能修改Progress 组件接收 textpercentagetotal 作为props,它只负责展示,不负责修改——这是React单向数据流的核心约束。

子组件主要负责展示,父组件给我什么props,我就显示成什么样子。


组件树:前端发展的必然

基于组件封装,页面形成了一个组件树,替代了传统的DOM树。从JSX结构一眼就能看出页面的组件构成:

App
├── Header (标题区域)
├── Load Button (模型加载按钮)
├── Error Display (条件渲染的错误提示)
├── Progress[] (下载进度条列表)
└── Chat Input (对话输入框)

页面交付越来越复杂,组件作为开发的最小单元,让团队协作更高效、代码更易复用和维护。组件化的粒度反映了前端工程化的成熟度。


WebGPU检测与条件渲染

项目用一行代码检测浏览器是否支持WebGPU:

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时代前端工程师的技术选型与工程化实践。