前言
最近在接触一个有意思的项目——在浏览器里用 WebGPU 跑 DeepSeek-R1 蒸馏模型。项目本身涉及 AI 推理,但作为前端学习者,我更关注的是:这个页面的前端逻辑是怎么组织的?React 到底在解决什么问题?
本文是我在学习过程中的笔记整理,从一个真实的 demo 代码出发,一步步拆解 React 的核心概念。不堆术语,尽量用"人话"讲清楚每一个"为什么"。
读完你会理解:
- React 和 Vue 到底有什么本质区别?
- 事件监听从 DOM0 到 React 合成事件经历了什么?
- 为什么要拆组件?Props 又是干什么的?
- 数据状态驱动视图这句话到底是什么意思?
一、先看一个最原始的按钮
我们从最简单的 HTML 开始:
<button id="btn" onclick="console.log('点击了按钮')">按钮</button>
<script>
document.getElementById('btn')
.addEventListener('click', function() {
console.log('点击了按钮')
})
</script>
同一个按钮,两种事件绑定方式同时存在。这就引出了第一个问题:onclick 和 addEventListener 到底有什么不同?
1.1 DOM0 级事件 —— onclick
onclick 是最原始的事件绑定方式,属于 DOM Level 0(1998 年之前的标准,甚至不算正式标准)。
写法:
<!-- 方式一:HTML 内联,直接写在标签上 -->
<button onclick="handleClick()">按钮</button>
<!-- 方式二:JS 中赋值 -->
<script>
btn.onclick = function() { console.log('点击了'); };
</script>
致命缺点:
| 问题 | 说明 |
|---|---|
| 同类型事件只能绑定一个 | 后绑定的覆盖前一个,btn.onclick = fn1; btn.onclick = fn2; 最终只有 fn2 生效 |
| 无法控制事件流阶段 | 只能在冒泡阶段触发,无法选择捕获阶段 |
| 无法精细配置 | 没有 once(只触发一次)、passive(性能优化)、signal(批量移除)等能力 |
| 移除不便 | 只能 btn.onclick = null,不知道之前绑了什么函数的话无从下手 |
| HTML/CSS/JS 耦合 | 内联写法把结构、样式、行为混在一起,违反关注点分离原则 |
一句话总结:DOM0 事件是现代前端要避免的写法,只存在于遗留代码和老教程中。
1.2 DOM Level 的演进
DOM 标准并不是一蹴而就的,它经历了多个版本的迭代:
DOM Level 0(非正式)
└── 浏览器各自实现,onclick 就是这个时期的产物
DOM Level 1(1998)
└── 定义了核心的 DOM 操作 API(节点增删改查)
└── 但没有更新事件相关内容
DOM Level 2(2000)
└── 引入 addEventListener / removeEventListener
└── 定义了事件流三阶段(捕获 → 目标 → 冒泡)
└── 这是现代事件处理的基石
DOM Level 3(2004)
└── 扩展了更多事件类型(键盘、鼠标滚轮等)
└── 引入了 CustomEvent 自定义事件
1.3 DOM2 级事件 —— addEventListener
target.addEventListener(type, listener, options);
// 类型 监听函数 配置项
相比 DOM0 的优势:
// 1. 同一事件可以绑定多个处理函数 — 互不覆盖
btn.addEventListener('click', fn1);
btn.addEventListener('click', fn2);
// 两个都触发!
// 2. 可以选择捕获或冒泡阶段触发
parent.addEventListener('click', fn, true); // 捕获阶段,从上往下
parent.addEventListener('click', fn, false); // 冒泡阶段,从下往上(默认)
// 3. 丰富的配置项
btn.addEventListener('click', fn, { once: true }); // 触发一次后自动移除
btn.addEventListener('scroll', fn, { passive: true }); // 不阻止默认行为,性能更好
btn.addEventListener('click', fn, { signal: controller.signal }); // 批量移除
事件流三阶段:
用户点击按钮
│
▼
① 捕获阶段(从上往下):window → document → html → body → div → button
② 目标阶段:到达实际被点击的 button
③ 冒泡阶段(从下往上):button → div → body → html → document → window
对于这个传播过程,有两个重要的控制方法:
// 阻止事件继续传播(冒泡/捕获都停)
event.stopPropagation();
// 更强:连当前元素上的其他同类型监听器也阻断
event.stopImmediatePropagation();
// 阻止浏览器默认行为(链接跳转、表单提交等)
event.preventDefault();
1.4 React 合成事件 —— onClick
来到 React 这边,写法变成了:
<button onClick={() => setStatus("loading")}>Load Model</button>
几个关键点:
① React 选择沿用已有的概念,不发明新东西
Vue 用 @click,React 用 onClick。React 的理念是:"你本来就会 JavaScript,直接用 JS 的能力写 UI,不学额外的模板语法。"所以 React 没有 v-for、v-if 这些指令——循环用 .map(),条件用 && 或三元表达式。
② React 里的事件并不是原生 DOM 事件,而是"合成事件"(SyntheticEvent)
React 在 DOM 事件之上包了一层自己的事件系统。原因:
- 跨浏览器兼容:抹平不同浏览器之间的差异
- 事件委托优化:React 17+ 把事件统一绑定在
root节点上,而不是每个元素各绑各的,内存更省 - 与 Fiber 架构配合:React 的异步可中断渲染需要统一的事件调度
// 你写的是这样
<button onClick={handler}>点击</button>
// React 内部实际上把事件委托到了根节点
// 然后通过合成事件系统分发给目标元素
// 但它对开发者透明,你完全可以用原生事件的思维来理解
③ 事件绑定是"声明式",不是"命令式"
// 命令式(原生 JS):你要告诉浏览器"怎么做"——找到元素,然后绑定
document.getElementById('btn').addEventListener('click', handler);
// 声明式(React):你只需要说"这个按钮被点击时要做什么"
<button onClick={handler}>按钮</button>
你只管声明"点击后会发生什么",React 负责把事件绑定到 DOM 上。
二、React 的核心思维:数据驱动视图
2.1 "状态"到底是什么?
看这段代码:
const [status, setStatus] = useState(null);
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState("开始加载");
const [progressItems, setProgressItems] = useState([...]);
这里引入了 React 最核心的概念——状态(State)。
理解状态的三个层次:
变量 → 会被改变的值
数据 → 驱动界面显示的值
状态 → 随着时间会变化、变化后界面自动跟着变的值
一个更直观的类比——川剧变脸:
脸谱 = 界面显示(JSX)
状态 = 决定当前应该亮哪张脸
status === null → 显示初始界面(Load Model 按钮)
status === "loading" → 显示进度条
status === "ready" → 显示聊天界面
status === "error" → 显示错误提示
数据只是值,状态是"会驱动视图自动变化的值"。
2.2 响应式的本质
// 不需要这样写(命令式 DOM 编程):
document.getElementById('message').innerText = newMessage;
// 而是这样写(声明式):
const [message, setMessage] = useState("初始");
// 界面中直接用 {message}
// setMessage 一调用,界面自动更新
这就是数据绑定 + 响应式:
- 你在 JSX 里声明了
{message}(绑定) - 当
setMessage被调用时,React 会重新渲染用了这个状态的组件(响应)
2.3 React 和 Vue 响应式机制的本质差异
这是两个框架最根本的不同,理解了这个就理解了 80%:
| React | Vue | |
|---|---|---|
| 更新方式 | Pull(拉取) | Push(推送) |
| 怎么知道数据变了 | 你显式调用 setState | Proxy 自动拦截 |
| 怎么知道要更新哪里 | 重跑整个组件函数 → Virtual DOM diff | 编译时标记 + 依赖追踪,精确知道哪个 DOM 用了哪个数据 |
| 类比 | 重画整幅画,和上一帧比较,描不同的地方 | 哪个灯坏了换哪个灯泡 |
// React:必须显式调用 setter
const [count, setCount] = useState(0);
setCount(count + 1); // 不调就不会更新
// Vue:直接赋值即可
const count = ref(0);
count.value += 1; // 框架自动感知
React 的思路:UI = f(state) —— UI 是状态的纯函数,状态一变,整个函数重跑一遍算出新 UI。
Vue 的思路:在编译阶段就知道 count 被这个 <span> 用了,count 一变,直接找到这个 <span> 更新。
两种思路没有绝对的好坏,React 的"重型渲染 + 精细 diff"和 Vue 的"编译优化 + 精准更新"是不同场景下的不同选择。
2.4 useEffect —— 组件生命周期的"副作用"
useEffect(() => {
console.log('组件已经挂载完成');
// 这里可以:发起网络请求、订阅事件、启动定时器...
return () => {
// 清理函数:组件卸载前执行
// 用于取消请求、移除事件监听、清除定时器...
}
}, []); // 空依赖数组 = 只在挂载时执行一次
什么是副作用?
纯函数:输入决定输出,不产生额外影响 → 1 + 1 = 2
副作用:对函数外部产生了影响 → 修改 DOM、发网络请求、读写 localStorage……
React 组件的渲染本身是"纯"的(状态 → JSX),但真实应用必然需要副作用。useEffect 就是用来管理这些副作用的"容器"。
依赖数组的含义:
useEffect(() => { ... }, []); // 只在挂载时执行一次
useEffect(() => { ... }, [count]); // count 变化时重新执行
useEffect(() => { ... }); // 每次渲染都执行(一般不这么用)
关于这个依赖数组,React 和 Vue 有一个关键差异:
| React | Vue | |
|---|---|---|
| 依赖追踪 | 手动:开发者写 [deps],漏写会出现"闭包陷阱" | 自动:框架自动追踪,不存在依赖缺失问题 |
| 闭包陷阱 | useEffect 里引用的 state 可能是旧值 | 不会,响应式系统保证总是最新值 |
三、组件化 —— 拆积木的艺术
3.1 为什么要拆组件?
在这个 demo 里,我们把进度条展示拆成了独立的 Progress.tsx 组件:
// Progress.tsx — 返回 JSX 的函数就是组件
const Progress = ({ text, percentage, total }) => {
percentage ??= 0; // 空值合并赋值:如果是 null/undefined,给个默认值
return (
<div>
<p>{text}</p>
<p>{percentage}%</p>
<p>{total}</p>
</div>
);
};
export default Progress;
为什么不直接写在 App 里?
App.tsx(拆之前) App.tsx(拆之后)
┌────────────────────┐ ┌────────────────────┐
│ 标题 │ │ 标题 │
│ 描述文字 │ │ 描述文字 │
│ 加载按钮 │ │ 加载按钮 │
│ ┌ 进度条 ────────┐ │ │ <Progress .../> ───┼──▶ Progress.tsx
│ │ 文件名、百分比 │ │ └────────────────────┘ 只关心"怎么展示进度"
│ │ 总大小、样式 │ │
│ └───────────────┘ │
│ 错误提示 │
└────────────────────┘
越来越臃肿,越来越难改
| 好处 | 说明 |
|---|---|
| 关注点分离 | App 只管"什么时候显示进度",Progress 只管"进度长什么样" |
| 可复用 | 多个地方都能用这个进度条组件 |
| 好维护 | 改进度条样式只改 Progress.tsx,不用动 App |
| 团队协作 | 不同的人可以同时改不同组件,不冲突 |
3.2 Props —— 组件的"参数"
// 父组件(App.tsx):通过 HTML 属性的方式传数据
<Progress text="model.onnx" percentage={50} total={34353543453} />
// 子组件(Progress.tsx):通过参数接收
const Progress = ({ text, percentage, total }) => {
// text = "model.onnx", percentage = 50, total = 34353543453
};
Props 就是函数的参数,只不过它是通过类 HTML 属性的方式传进来的。
Props 是单向的——只能从父组件流向子组件,子组件不能修改 props。这保证了数据流向清晰可追溯。
3.3 列表渲染 —— React 用原生 JS
{
progressItems.map(({ text, percentage, total }, i) => (
<Progress
key={i}
text={text}
percentage={percentage}
total={total}
/>
))
}
React 不用 v-for,直接用 .map()。
这是 React 和 Vue 哲学差异的又一个体现:
<!-- Vue:模板专属指令 -->
<div v-for="(item, i) in progressItems" :key="i">
<Progress :text="item.text" />
</div>
{/* React:就 JS,你本来就会 */}
{progressItems.map((item, i) => (
<Progress key={i} text={item.text} />
))}
React 说:你不会 .map() 就别写前端了,我不给你造轮子。
Vue 说:我给你 v-for,看着像 HTML 属性,后端转前端的同学也看得懂。
两种理念没有对错,取决于团队背景和个人偏好。
3.4 组件树 —— 新一代的"DOM 树"
传统前端看页面结构:DOM 树(div 套 div 套 div)
现代前端看页面结构:组件树
App
├── Header
├── ModelInfo
├── LoadButton
├── Progress(loading 时才显示)
│ ├── ProgressItem
│ ├── ProgressItem
│ └── ProgressItem
└── ErrorDisplay(出错时才显示)
组件树的意义:
- 一眼看出页面结构:不需要在成百上千的 div 中找逻辑边界
- 衡量组件化程度:树的深度和宽度反映了拆分的粒度
- 团队协作边界:每个节点就是一个独立的工作单元
- 复用粒度清晰:哪些是通用组件、哪些是业务组件一目了然
前端发展到今天,页面越来越复杂,组件成为开发的最小单元。这就是"组件化"的必然性。
四、实战中的细节拾遗
4.1 条件渲染
// 错误状态时显示红色提示
{error && (
<div className="text-red-500">
<p>{error}</p>
</div>
)}
// 加载中时显示进度条
{status === "loading" && (
<Progress items={progressItems} />
)}
React 直接用 JavaScript 的 && 短路逻辑,还是那句话——"It's just JavaScript"。
4.2 !! 双取反 —— 把任意值转成布尔
const IS_WEBGPU_AVAILABLE = !!navigator.gpu;
// navigator.gpu 存在 → 转成 true
// navigator.gpu 不存在(undefined)→ 转成 false
!!"hello" // true
!!0 // false
!!undefined // false
!!{} // true
// 第一次 ! 取反,转为布尔值并反转
// 第二次 ! 再反转回来
// 所以叫"双重否定等于肯定"
4.3 ??= 空值合并赋值
percentage ??= 0;
// 等价于:
if (percentage === null || percentage === undefined) {
percentage = 0;
}
?? 只关心 null / undefined,不像 || 会把 0、""、false 都当作"假值"。
0 || 100 // 100(0 被当作假值,可能不是你想要的效果)
0 ?? 100 // 0 (只有 null/undefined 才会用右边的值)
4.4 disabled 按钮的正确用法
<button
disabled={status !== null || error !== null}
onClick={() => setStatus("loading")}
>
Load Model
</button>
disabled是 HTML 原生属性,浏览器会自动灰掉按钮 + 阻止点击事件disabled:cursor-not-allowed是 Tailwind 的disabled:变体,在禁用时把光标变成 🚫- 前者管能不能点,后者管不能点时好不好看,两个层面
4.5 Tailwind 原子类速览
<div className="w-full max-w-[500px] text-left mx-auto p-4 bottom-0 mt-auto">
| 类名 | 对应 CSS | 含义 |
|---|---|---|
w-full | width: 100% | 宽度占满父容器 |
max-w-[500px] | max-width: 500px | 最大宽度 500px,[] 是任意值语法 |
text-left | text-align: left | 文字左对齐 |
mx-auto | margin-left/right: auto | 水平居中 |
p-4 | padding: 1rem(16px) | 四周内边距,1 单位 = 4px |
bottom-0 | bottom: 0 | 贴底(需定位) |
mt-auto | margin-top: auto | Flex 布局中把元素推到底部 |
五、总结:一条主线串起来
这篇文章从一个按钮的事件监听开始,延伸到 React 的整个核心体系。用一条主线来串联:
事件监听进化史
│
├── DOM0(onclick):一对一的原始阶段,已淘汰
├── DOM2(addEventListener):一对多、可选事件流、精细配置
└── React 合成事件(onClick):跨浏览器兼容,声明式绑定
组件化思维
│
├── 函数组件 = 返回 JSX 的函数
├── Props = 函数的参数,通过类 HTML 属性的方式传入
├── 组件树 代替 DOM 树,成为页面的结构蓝图
└── React 用原生 JS(.map / && / 三元),不发明额外语法
数据驱动视图
│
├── 状态(State)= 会随时间变化、变化后视图自动跟上的数据
├── React 是 Pull 模式:setState → 重跑函数 → diff → 更新 DOM
├── Vue 是 Push 模式:Proxy 拦截 → 依赖追踪 → 精确更新
└── useEffect 管理副作用(网络请求、定时器、订阅等)
React 不是魔法,它只是把"状态 → 视图"这个映射关系做得足够纯粹。
理解了这一点,剩下的 JSX、Hooks、组件通信……都只是这句话的延伸。
本文基于一个真实的 WebGPU + DeepSeek-R1 浏览器推理 demo 的学习笔记整理而成。代码示例来自实际项目,有简化但保持核心逻辑。