🎬 剧情提要:一个普通前端,某天突然发现浏览器已经能直接调用 GPU 跑 AI 模型了,后端都不用写。以下是他从"啊?"到"卧槽!"的全过程。
开篇:前端仔的焦虑
2025 年了,前端圈子卷成麻花。
你刚学会 Webpack,Vite 已经出到第 8 版了。你刚搞懂 Vue 3,React 已经到 19 了。你好不容易能写全栈,老板说"我们要做 AI 产品"。
你一搜教程------好家伙,Python、PyTorch、CUDA、显卡、服务器......全是要现学的。
等等。 如果我告诉你:只用 JavaScript,在浏览器里就能跑大模型,你不用学 Python、不用买显卡、不用搭服务器------你信不信?
一开始我也不信。直到我把 DeepSeek-R1 跑在了 Chrome 浏览器里。
这篇文章带你走一遍全过程。零 Python、零后端、纯前端。
第一章:浏览器什么时候偷偷变强了?
WebGPU 是个什么东西?
以前浏览器想用 GPU 只有一个通道:WebGL。那是 2011 年的老古董,本来是给网页 3D 游戏用的,性能和功能都跟不上 AI 推理的需求。
2023 年,Chrome 113 发布,带来了 WebGPU------一套全新的浏览器 GPU API,直接对标 Vulkan、Metal、DX12 这些原生图形接口。
打个比方:
ini
WebGL = 一辆 2011 年的五菱宏光,拉点货还行,跑赛道就算了
WebGPU = 一台 2023 年的电动跑车,油门一踩直接起飞
WebGPU 给了浏览器真正的 GPU 计算能力,而 AI 模型推理恰好就是 GPU 的拿手好戏。
怎么知道用户的浏览器支不支持?
一行代码:
ts
const IS_WEBGPU_AVAILABLE = !!(navigator as any).gpu;
来,拆开嚼碎了讲:
第一步:navigator.gpu 是啥?
navigator 是浏览器自带的一个全局对象,里面装了浏览器自己能干什么的各种信息。比如 navigator.geolocation 是定位能力,navigator.gpu 就是 WebGPU 能力。
如果浏览器支持 WebGPU,navigator.gpu 是一个 GPU 对象;如果不支持,它是 undefined。
第二步:!! 双重否定是干嘛的?
ts
!!undefined // → false (不支持)
!!{} // → true (支持,因为 gpu 是一个对象)
第一个 ! 把值转成布尔值再取反,第二个 ! 再取反回来。效果就是把任何值干净利落地转成 true 或 false。
第三步:as any 是什么?
TypeScript 的类型定义有时候跟不上浏览器的新特性。navigator.gpu 可能不在 TypeScript 的默认类型里,编译器会报错 "gpu 不存在"。
as any 就是告诉 TypeScript:"别检查了,我确定这玩意儿在运行时会存在,你放我过去。"
📦 知识点打包 :
!!是最简洁的布尔转换技巧,as any是绕过 TypeScript 类型检查的逃生舱。两个都很实用,面试也爱问。
第二章:React 19 + Vite 8,先把架子搭起来
2.1 为什么选 React?
Vue 好入门,但 AI 生态里 React 的项目和组件库多到离谱。你要复用别人的 AI 对话组件、模型加载组件,大概率它们都是用 React 写的。
而且 React 19 的 Hooks 设计在处理"加载模型"这种多状态流转 的场景时特别顺手------加载中、加载成功、加载失败、推理中、推理完成......每种状态用一个 useState,清晰明了。
2.2 从入口文件看 React 怎么启动
main.tsx 只有 10 行,但每一行都有故事:
tsx
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>,
)
逐行翻译:
| 代码 | 翻译成人话 |
|---|---|
createRoot(document.getElementById('root')!) |
找到 HTML 里那个 <div id="root"></div>,把它设为 React 的"根据地" |
! |
TypeScript 非空断言:"这个元素百分百存在,别报类型错误" |
.render(<App />) |
把 <App /> 这个组件渲染到根据地里 |
<StrictMode> |
React 的开发模式"督察组",会在控制台多打一些警告,帮你发现潜在问题 |
HTML 那边 只有一句话:
html
<script type="module" src="/src/main.tsx"></script>
📦 知识点 :
type="module"让浏览器用 ES Module 方式加载 JS,这样你才能在代码里用import和export。Vite 在开发时原生支持 ES Module,不用打包就能跑。
第三章:React 组件化------函数就是组件
3.1 Vue 选手的困惑:React 的组件在哪?
Vue 的组件是一个 .vue 文件,里面 <template>、<script>、<style> 三个标签分得明明白白:
vue
<!-- Vue 组件:三明治结构 -->
<template>
<button @click="handleClick">{{ text }}</button>
</template>
<script setup>
const handleClick = () => { console.log('clicked') }
</script>
<style scoped>
button { background: blue; }
</style>
React 的组件就是一个 JavaScript 函数,返回 JSX(长得像 HTML 的 JavaScript 语法糖):
tsx
// React 组件:一个函数搞定一切
function MyButton({ text }) {
const handleClick = () => { console.log('clicked') };
return (
<button onClick={handleClick} className="bg-blue-500">
{text}
</button>
);
}
规律:
return之前 → JavaScript 逻辑区(变量、状态、函数、条件判断......)return之后 → UI 区(JSX 标签)
📦 知识点 :React 组件名必须大写字母开头 。因为 JSX 里小写标签会被当成原生 HTML(
<div>、<span>),大写标签才会被当成自定义组件。
3.2 子组件:Progress 进度条
来看看 Progress.tsx------整个项目最小的组件,总共 13 行:
tsx
const Progress = ({text, percentage, total}) => {
return (
<div>
<p>{text}</p>
<p>{percentage}%</p>
<p>{total}</p>
</div>
)
}
export default Progress
语法糖拆解 :{text, percentage, total} 是 ES6 的解构赋值。
tsx
// 不用解构(啰嗦版)
const Progress = (props) => {
return (
<div>
<p>{props.text}</p>
<p>{props.percentage}%</p>
<p>{props.total}</p>
</div>
)
}
// 用解构(优雅版)------ 一步到位
const Progress = ({text, percentage, total}) => {
// ...
}
父组件这样调用:
tsx
<Progress text="model.onnx" percentage={50} total={34353543453} />
写法跟 HTML 标签属性一模一样,没有任何学习成本。
📦 知识点 :
{大括号}是 JSX 里的"JavaScript 表达式插槽"。大括号里可以写任何 JS 表达式------变量、三元运算、函数调用......只要它返回一个值就能用。
第四章:Hooks------给组件装上"大脑"
如果组件只是返回一段 HTML,那它跟一张静态图片没区别。真正让组件"活过来"的,是 Hooks。
4.1 useState:组件有了"记忆"
tsx
const [status, setStatus] = useState(null);
很多人第一次看到这个语法会懵。拆开看:
tsx
// useState 返回一个数组,数组里有两个元素
const result = useState(null);
const status = result[0]; // 第一个元素:当前的值
const setStatus = result[1]; // 第二个元素:修改值的函数
// 用数组解构一步拿到两个元素 ← 就是上面那句的简写
const [status, setStatus] = useState(null);
生活类比:
csharp
useState = 你买了一个"带显示屏的水杯"
status → 显示屏上显示的水位(当前值)
setStatus → 水龙头开关(唯一能改水位的方式)
useState(null) → 初始水位是空的(null)
关键规则 :你不能直接 status = "loading" 这样改值------必须通过 setStatus("loading") 来改,这样 React 才知道"哦,状态变了,该刷新界面了"。
在项目里,我们用四个状态变量管住了整个应用的界面变化:
tsx
const [status, setStatus] = useState(null);
// null → 初始态 → 显示"Load Model"按钮
// "loading" → 加载中 → 显示进度条
// "ready" → 就绪 → 显示对话界面(未来实现)
const [error, setError] = useState<string | null>(null);
// 有值时 → 显示红色错误提示
const [loadingMessage, setLoadingMessage] = useState("开始加载");
// 加载时显示的文字
const [progressItems, setProgressItems] = useState([
{ text: 'model.onnx', percentage: 0, total: 34353543453 },
{ text: 'model2.onnx', percentage: 10, total: 416416416 }
]);
// 模型文件列表,每个文件有名字、下载进度、总大小
数据驱动视图就是这样:
ini
数据状态变化 → React 检测到变化 → 自动更新界面
你不用写一行 document.getElementById().innerHTML = xxx
4.2 useState 的类型标注
注意这行:
tsx
const [error, setError] = useState<string | null>(null);
// ↑ 这是 TypeScript 的泛型标注
<string | null> 告诉 TypeScript:这个状态的值要么是字符串(错误信息),要么是 null(没错误)。如果你不小心把数字塞进去,编辑器马上就会红线警告------在写代码的时候就拦住了 bug,不用等到运行时。
4.3 useEffect:组件"出生"后自动干点事
tsx
useEffect(() => {
console.log('组件已经挂载完成');
}, []);
翻译:组件渲染到页面上之后,执行这个函数。
生活类比:你买了一部新手机(组件挂载),开机后自动跑一遍初始化设置(useEffect)。之后你就正常用手机,初始化不会再跑了。
第二个参数 [](空数组)是关键:
| 第二个参数 | 含义 | 什么时候执行 |
|---|---|---|
[] |
不依赖任何变量 | 只在组件出生时执行一次 |
[status] |
依赖 status | 出生时 + status 每次变化时 |
| 不传 | 没限制 | 每次渲染都执行(慎用,容易死循环) |
📦 知识点 :
useEffect的名字来源于"side effect(副作用)"。React 组件的主业是"根据数据渲染 UI",而"发起网络请求"、"操作本地存储"这些额外的事就是"副作用",交给useEffect处理。
第五章:事件交互------从"看板"到"应用"
之前说了,数据驱动视图。那谁来驱动数据呢?用户的操作。
5.1 React 的 onClick
App.tsx 第 123-129 行 的 Load Model 按钮:
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>
逐点拆解:
① onClick 而不是 onclick
React 用的是驼峰命名的合成事件,不是原生 DOM 事件。React 封装了一层事件系统,处理了浏览器兼容性问题。
记住这个规律:所有 React 事件都是 on + 驼峰事件名:
makefile
原生: click → React: onClick
原生: change → React: onChange
原生: submit → React: onSubmit
② 为什么要用箭头函数包起来?
这是新手最常掉的坑。对比:
tsx
// ❌ 错误写法:渲染时就执行了,点击反而没反应
onClick={setStatus('loading')}
// ✅ 正确写法:点击时才执行
onClick={() => setStatus('loading')}
setStatus('loading') 是一个函数调用 ,它会立刻执行并返回结果。而 () => setStatus('loading') 是一个函数定义,它只是定义了一个"等会儿要做的事",等用户点击了才真正执行。
③ disabled={status !== null}
按钮不是写死的 disabled={false},而是根据状态动态判断:
- 初始态
status = null→disabled={false}→ 按钮可点 - 加载中
status = "loading"→disabled={true}→ 按钮灰掉,防止重复点击
④ TailwindCSS 的状态变体
hover:bg-blue-500、disabled:cursor-not-allowed 这种写法叫状态变体 。: 前面是状态,后面是该状态下生效的样式。
5.2 条件渲染:不同状态展示不同界面
App.tsx 第 111 行 的错误提示:
tsx
{error && (
<div className="text-red-500 text-center mb-2">
<p>Unable to load model due to the following error:</p>
<p className="text-sm">{error}</p>
</div>
)}
&& 是什么操作?
这是 JavaScript 的短路求值:
tsx
false && <div>这段永远不会出现</div> // → false(不渲染)
true && <div>这段会显示</div> // → <div>这段会显示</div>
等价于:
tsx
if (error) {
return <div>显示错误信息</div>
}
只是 && 更简洁,适合"有就显示,没有就不显示"的场景。
同样,第 134 行 的进度条区域:
tsx
{status === "loading" && (
<div>
{/* 渲染进度条 */}
</div>
)}
📦 核心逻辑串起来 :用户点按钮 →
setStatus('loading')→ status 变了 → React 重新渲染 → 按钮变灰 + 进度条出现。这就是事件 → 状态 → 界面的完整闭环。
第六章:TailwindCSS------你甚至不用写 CSS 文件
6.1 传统 CSS 有多痛苦?
css
/* 第1步:给元素起个有意义的名字(想 5 分钟) */
.model-loading-container {
display: flex;
flex-direction: column;
align-items: center;
max-width: 400px;
text-align: center;
margin-bottom: 0.25rem;
}
/* 第2步:去 HTML 里加 class */
/* 第3步:两个文件来回切换 */
/* 第4步:类名被同事覆盖了,加 !important */
/* 第5步:!important 大战 */
6.2 TailwindCSS 怎么做的?
App.tsx 第 67 行 开始:
tsx
<div className="flex flex-col items-center mb-1 max-w-[400px] text-center">
类名即样式,不需要任何 CSS 文件。翻译对照表:
| 类名 | 翻译 | 对应的 CSS |
|---|---|---|
flex |
弹起来 | display: flex |
flex-col |
竖着排 | flex-direction: column |
items-center |
居中对齐 | align-items: center |
mb-1 |
下边距1单位 | margin-bottom: 0.25rem |
max-w-[400px] |
最大宽度400 | max-width: 400px |
text-center |
文字居中 | text-align: center |
为什么 AI 时代 TailwindCSS 这么火?
大模型写 TailwindCSS 太顺手了------它不需要"想一个有意义的名字",只需要"把效果翻译成类名"。
而且 整个项目 CSS 只有一行:
css
@import "tailwindcss";
TailwindCSS v4 把配置精简到了极致,不需要 tailwind.config.js、不需要 PostCSS 配置。Vite 插件一行引入:
ts
plugins: [react(), tailwindcss()],
搞定。
6.3 className 而不是 class
React 组件里写 JSX 的时候,样式用 className 而不是 class:
tsx
<div className="bg-white"> ← 不是 class="bg-white"
因为 JSX 本质是 JavaScript 的语法糖,而 class 在 JavaScript 里是定义类的关键字 。为了避免冲突,React 用 className 代替。
可以理解成:
className= "这个东西的 class 名字是 xxx"。
第七章:把代码串起来看------完整的数据流
整个应用目前的核心逻辑就这一条链路,10 秒钟走完:
scss
用户打开页面
↓
navigator.gpu 检测 → 支持/不支持 (不支持直接显示提示)
↓(支持)
显示 "Load Model" 按钮 + 模型介绍
↓
用户点击按钮 → onClick 触发 → setStatus('loading')
↓
React 检测到 status 变了 → 重新渲染
↓
按钮变灰(disabled) + 进度条区域出现
↓
(未来:模型下载完 → setStatus('ready') → 对话界面出现)
每一行代码都在干什么:
| 文件 | 行数 | 角色 |
|---|---|---|
index.html |
11 行 | 提供一个 <div id="root"> 空壳 |
main.tsx |
10 行 | 把 React 应用"挂"到那个空壳上 |
App.tsx |
160 行 | 整个应用的大脑------状态、逻辑、UI 全在这 |
Progress.tsx |
13 行 | 一个纯展示的小组件,只负责显示进度信息 |
index.css |
1 行 | 引入 TailwindCSS |
vite.config.ts |
8 行 | 告诉 Vite:用 React 插件 + TailwindCSS 插件 |
第八章:项目里那些你可能忽略的小细节
8.1 ! 非空断言的两种用法
tsx
// 用法1:DOM 元素一定存在
createRoot(document.getElementById('root')!);
// 用法2:某个值一定不是 null/undefined
const value = possiblyNull!;
⚠️ 用的时候要想清楚------如果断言错了,运行时会炸。这是你和 TypeScript 之间的"君子协定"。
8.2 map 遍历渲染列表
tsx
{progressItems.map(({text, percentage, total}, index) => {
return (
<Progress
key={index}
text={text}
percentage={percentage}
total={total}
/>
)
})}
map是数组的遍历方法,把数组里每个元素"映射"成一个 JSX 组件({text, percentage, total})是对每个数组元素做解构- React 要求列表渲染时给每个元素加
key属性(用于高效更新)
8.3 disabled={status !== null || error !== null}
这不是写死的 true 或 false,而是一个动态计算的布尔表达式。界面会自动跟着数据变------这就是声明式编程的魅力。
结尾:前端工程师的新身份
回头看这个项目,你可能觉得它现在还"不完整"------模型下载和推理的逻辑还没写进去,只有 UI 框架。
但框架的价值就在于把基础设施搭好。
当 @huggingface/transformers(HuggingFace 的 JavaScript SDK,让浏览器能加载和推理 AI 模型)接入进来的时候,status 从 "loading" 变成 "ready" 的那一刻,它就是一个真正能在浏览器里跑 DeepSeek-R1 的应用了。
你不需要成为 AI 研究员也能做 AI 产品。 前端工程师在 AI 时代的角色,不是被替代,而是把 AI 能力包装成用户真正能用的产品。
用你熟悉的 React + TypeScript + TailwindCSS,加上一点点 WebGPU 知识,你就能造出一个不需要后端、不需要服务器、不需要 API Key、断网也能跑的 AI 应用。
🍺 前端的边界,远比你以为的宽广。
附:如果面试官看了你的简历问这些问题
| 可能的问题 | 你现在就能回答 |
|---|---|
"useState 的原理?" |
React 用闭包和链表管理每个组件的状态队列,调用 setter 时触发重新渲染 |
"为什么用 !! 而不是 Boolean()?" |
效果一样,!! 更简洁;不过有些团队代码规范要求显式用 Boolean(),可读性更好 |
| "TailwindCSS 的缺点?" | HTML 类名很长(但可以用拆分组件或 cn() 工具函数解决);需要熟悉类名映射 |
| "WebGPU 和 WebGL 的区别?" | WebGL 是为图形渲染设计的,WebGPU 是为通用 GPU 计算设计的,后者更适合 AI 推理 |
| "React 合成事件是什么?" | React 自己封装的事件系统,做了浏览器兼容和性能优化(事件委托到 root),API 和原生保持一致 |
项目代码:ai_doubao_dcx: 走向AGI,走向豆包 - Gitee.com
启动:pnpm install && pnpm dev
浏览器:Chrome/Edge 113+