我一个前端切图仔,凭什么能在浏览器里跑大模型?

1 阅读13分钟

🎬 剧情提要:一个普通前端,某天突然发现浏览器已经能直接调用 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 这些原生图形接口。

打个比方:

WebGL  = 一辆 2011 年的五菱宏光,拉点货还行,跑赛道就算了
WebGPU = 一台 2023 年的电动跑车,油门一踩直接起飞

WebGPU 给了浏览器真正的 GPU 计算能力,而 AI 模型推理恰好就是 GPU 的拿手好戏。

怎么知道用户的浏览器支不支持?

一行代码:

const IS_WEBGPU_AVAILABLE = !!(navigator as any).gpu;

来,拆开嚼碎了讲:

第一步:navigator.gpu 是啥?

navigator 是浏览器自带的一个全局对象,里面装了浏览器自己能干什么的各种信息。比如 navigator.geolocation 是定位能力,navigator.gpu 就是 WebGPU 能力。

如果浏览器支持 WebGPU,navigator.gpu 是一个 GPU 对象;如果不支持,它是 undefined

第二步:!! 双重否定是干嘛的?

!!undefined  // → false   (不支持)
!!{}         // → true    (支持,因为 gpu 是一个对象)

第一个 ! 把值转成布尔值再取反,第二个 ! 再取反回来。效果就是把任何值干净利落地转成 truefalse

第三步: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 行,但每一行都有故事:

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 那边只有一句话

<script type="module" src="/src/main.tsx"></script>

📦 知识点type="module" 让浏览器用 ES Module 方式加载 JS,这样你才能在代码里用 importexport。Vite 在开发时原生支持 ES Module,不用打包就能跑。


第三章:React 组件化——函数就是组件

3.1 Vue 选手的困惑:React 的组件在哪?

Vue 的组件是一个 .vue 文件,里面 <template><script><style> 三个标签分得明明白白:

<!-- 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 语法糖):

// 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 行:

const Progress = ({text, percentage, total}) => {
    return (
        <div>
            <p>{text}</p>
            <p>{percentage}%</p>
            <p>{total}</p>
        </div>
    )
}
export default Progress

语法糖拆解{text, percentage, total} 是 ES6 的解构赋值

// 不用解构(啰嗦版)
const Progress = (props) => {
    return (
        <div>
            <p>{props.text}</p>
            <p>{props.percentage}%</p>
            <p>{props.total}</p>
        </div>
    )
}

// 用解构(优雅版)—— 一步到位
const Progress = ({text, percentage, total}) => {
    // ...
}

父组件这样调用:

<Progress text="model.onnx" percentage={50} total={34353543453} />

写法跟 HTML 标签属性一模一样,没有任何学习成本。

📦 知识点{大括号} 是 JSX 里的"JavaScript 表达式插槽"。大括号里可以写任何 JS 表达式——变量、三元运算、函数调用……只要它返回一个值就能用。


第四章:Hooks——给组件装上"大脑"

如果组件只是返回一段 HTML,那它跟一张静态图片没区别。真正让组件"活过来"的,是 Hooks。

4.1 useState:组件有了"记忆"

const [status, setStatus] = useState(null);

很多人第一次看到这个语法会懵。拆开看:

// useState 返回一个数组,数组里有两个元素
const result = useState(null);
const status = result[0];     // 第一个元素:当前的值
const setStatus = result[1];  // 第二个元素:修改值的函数

// 用数组解构一步拿到两个元素 ← 就是上面那句的简写
const [status, setStatus] = useState(null);

生活类比

useState = 你买了一个"带显示屏的水杯"

status       → 显示屏上显示的水位(当前值)
setStatus    → 水龙头开关(唯一能改水位的方式)
useState(null) → 初始水位是空的(null

关键规则:你不能直接 status = "loading" 这样改值——必须通过 setStatus("loading") 来改,这样 React 才知道"哦,状态变了,该刷新界面了"。

在项目里,我们用四个状态变量管住了整个应用的界面变化:

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 }
]);
// 模型文件列表,每个文件有名字、下载进度、总大小

数据驱动视图就是这样:

数据状态变化 → React 检测到变化 → 自动更新界面

你不用写一行 document.getElementById().innerHTML = xxx

4.2 useState 的类型标注

注意这行:

const [error, setError] = useState<string | null>(null);
//                            ↑ 这是 TypeScript 的泛型标注

<string | null> 告诉 TypeScript:这个状态的值要么是字符串(错误信息),要么是 null(没错误)。如果你不小心把数字塞进去,编辑器马上就会红线警告——在写代码的时候就拦住了 bug,不用等到运行时

4.3 useEffect:组件"出生"后自动干点事

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

翻译:组件渲染到页面上之后,执行这个函数。

生活类比:你买了一部新手机(组件挂载),开机后自动跑一遍初始化设置(useEffect)。之后你就正常用手机,初始化不会再跑了。

第二个参数 [](空数组)是关键:

第二个参数含义什么时候执行
[]不依赖任何变量只在组件出生时执行一次
[status]依赖 status出生时 + status 每次变化时
不传没限制每次渲染都执行(慎用,容易死循环)

📦 知识点useEffect 的名字来源于"side effect(副作用)"。React 组件的主业是"根据数据渲染 UI",而"发起网络请求"、"操作本地存储"这些额外的事就是"副作用",交给 useEffect 处理。


第五章:事件交互——从"看板"到"应用"

之前说了,数据驱动视图。那谁来驱动数据呢?用户的操作

5.1 React 的 onClick

App.tsx 第 123-129 行 的 Load Model 按钮:

<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 + 驼峰事件名:

原生: click  → React: onClick
原生: change → React: onChange
原生: submit → React: onSubmit

② 为什么要用箭头函数包起来?

这是新手最常掉的坑。对比:

// ❌ 错误写法:渲染时就执行了,点击反而没反应
onClick={setStatus('loading')}

// ✅ 正确写法:点击时才执行
onClick={() => setStatus('loading')}

setStatus('loading') 是一个函数调用,它会立刻执行并返回结果。而 () => setStatus('loading') 是一个函数定义,它只是定义了一个"等会儿要做的事",等用户点击了才真正执行。

disabled={status !== null}

按钮不是写死的 disabled={false},而是根据状态动态判断:

  • 初始态 status = nulldisabled={false} → 按钮可点
  • 加载中 status = "loading"disabled={true} → 按钮灰掉,防止重复点击

④ TailwindCSS 的状态变体

hover:bg-blue-500disabled:cursor-not-allowed 这种写法叫状态变体: 前面是状态,后面是该状态下生效的样式。

5.2 条件渲染:不同状态展示不同界面

App.tsx 第 111 行 的错误提示:

{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 的短路求值:

false && <div>这段永远不会出现</div>   // → false(不渲染)
true  && <div>这段会显示</div>          // → <div>这段会显示</div>

等价于:

if (error) {
  return <div>显示错误信息</div>
}

只是 && 更简洁,适合"有就显示,没有就不显示"的场景。

同样,第 134 行 的进度条区域:

{status === "loading" && (
  <div>
    {/* 渲染进度条 */}
  </div>
)}

📦 核心逻辑串起来:用户点按钮 → setStatus('loading') → status 变了 → React 重新渲染 → 按钮变灰 + 进度条出现。这就是事件 → 状态 → 界面的完整闭环。


第六章:TailwindCSS——你甚至不用写 CSS 文件

6.1 传统 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 行 开始:

<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]最大宽度400max-width: 400px
text-center文字居中text-align: center

为什么 AI 时代 TailwindCSS 这么火?

大模型写 TailwindCSS 太顺手了——它不需要"想一个有意义的名字",只需要"把效果翻译成类名"。

而且 整个项目 CSS 只有一行

@import "tailwindcss";

TailwindCSS v4 把配置精简到了极致,不需要 tailwind.config.js、不需要 PostCSS 配置。Vite 插件一行引入

plugins: [react(), tailwindcss()],

搞定。

6.3 className 而不是 class

React 组件里写 JSX 的时候,样式用 className 而不是 class

<div className="bg-white">  ← 不是 class="bg-white"

因为 JSX 本质是 JavaScript 的语法糖,而 class 在 JavaScript 里是定义类的关键字。为了避免冲突,React 用 className 代替。

可以理解成:className = "这个东西的 class 名字是 xxx"。


第七章:把代码串起来看——完整的数据流

整个应用目前的核心逻辑就这一条链路,10 秒钟走完:

用户打开页面
    ↓
navigator.gpu 检测 → 支持/不支持 (不支持直接显示提示)
    ↓(支持)
显示 "Load Model" 按钮 + 模型介绍
    ↓
用户点击按钮 → onClick 触发 → setStatus('loading')
    ↓
React 检测到 status 变了 → 重新渲染
    ↓
按钮变灰(disabled) + 进度条区域出现
    ↓
(未来:模型下载完 → setStatus('ready') → 对话界面出现)

每一行代码都在干什么

文件行数角色
index.html11 行提供一个 <div id="root"> 空壳
main.tsx10 行把 React 应用"挂"到那个空壳上
App.tsx160 行整个应用的大脑——状态、逻辑、UI 全在这
Progress.tsx13 行一个纯展示的小组件,只负责显示进度信息
index.css1 行引入 TailwindCSS
vite.config.ts8 行告诉 Vite:用 React 插件 + TailwindCSS 插件

第八章:项目里那些你可能忽略的小细节

8.1 ! 非空断言的两种用法

// 用法1:DOM 元素一定存在
createRoot(document.getElementById('root')!);

// 用法2:某个值一定不是 null/undefined
const value = possiblyNull!;

⚠️ 用的时候要想清楚——如果断言错了,运行时会炸。这是你和 TypeScript 之间的"君子协定"。

8.2 map 遍历渲染列表

{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}

这不是写死的 truefalse,而是一个动态计算的布尔表达式。界面会自动跟着数据变——这就是声明式编程的魅力。


结尾:前端工程师的新身份

回头看这个项目,你可能觉得它现在还"不完整"——模型下载和推理的逻辑还没写进去,只有 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+