从零理解React:事件、组件与响应式——一个WebGPU Demo的前端笔记

前言

最近在接触一个有意思的项目------在浏览器里用 WebGPU 跑 DeepSeek-R1 蒸馏模型。项目本身涉及 AI 推理,但作为前端学习者,我更关注的是:这个页面的前端逻辑是怎么组织的?React 到底在解决什么问题?

本文是我在学习过程中的笔记整理,从一个真实的 demo 代码出发,一步步拆解 React 的核心概念。不堆术语,尽量用"人话"讲清楚每一个"为什么"。

读完你会理解:

  • React 和 Vue 到底有什么本质区别?
  • 事件监听从 DOM0 到 React 合成事件经历了什么?
  • 为什么要拆组件?Props 又是干什么的?
  • 数据状态驱动视图这句话到底是什么意思?

一、先看一个最原始的按钮

我们从最简单的 HTML 开始:

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 复制代码
<!-- 方式一: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

js 复制代码
target.addEventListener(type, listener, options);
//                    类型    监听函数    配置项

相比 DOM0 的优势:

js 复制代码
// 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 }); // 批量移除

事件流三阶段:

css 复制代码
用户点击按钮
    │
    ▼
① 捕获阶段(从上往下):window → document → html → body → div → button
② 目标阶段:到达实际被点击的 button
③ 冒泡阶段(从下往上):button → div → body → html → document → window

对于这个传播过程,有两个重要的控制方法:

js 复制代码
// 阻止事件继续传播(冒泡/捕获都停)
event.stopPropagation();

// 更强:连当前元素上的其他同类型监听器也阻断
event.stopImmediatePropagation();

// 阻止浏览器默认行为(链接跳转、表单提交等)
event.preventDefault();

1.4 React 合成事件 ------ onClick

来到 React 这边,写法变成了:

jsx 复制代码
<button onClick={() => setStatus("loading")}>Load Model</button>

几个关键点:

① React 选择沿用已有的概念,不发明新东西

Vue 用 @click,React 用 onClick。React 的理念是:"你本来就会 JavaScript,直接用 JS 的能力写 UI,不学额外的模板语法。"所以 React 没有 v-forv-if 这些指令------循环用 .map(),条件用 && 或三元表达式。

② React 里的事件并不是原生 DOM 事件,而是"合成事件"(SyntheticEvent)

React 在 DOM 事件之上包了一层自己的事件系统。原因:

  • 跨浏览器兼容:抹平不同浏览器之间的差异
  • 事件委托优化 :React 17+ 把事件统一绑定在 root 节点上,而不是每个元素各绑各的,内存更省
  • 与 Fiber 架构配合:React 的异步可中断渲染需要统一的事件调度
jsx 复制代码
// 你写的是这样
<button onClick={handler}>点击</button>

// React 内部实际上把事件委托到了根节点
// 然后通过合成事件系统分发给目标元素
// 但它对开发者透明,你完全可以用原生事件的思维来理解

③ 事件绑定是"声明式",不是"命令式"

js 复制代码
// 命令式(原生 JS):你要告诉浏览器"怎么做"------找到元素,然后绑定
document.getElementById('btn').addEventListener('click', handler);

// 声明式(React):你只需要说"这个按钮被点击时要做什么"
<button onClick={handler}>按钮</button>

你只管声明"点击后会发生什么",React 负责把事件绑定到 DOM 上。


二、React 的核心思维:数据驱动视图

2.1 "状态"到底是什么?

看这段代码:

jsx 复制代码
const [status, setStatus] = useState(null);
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState("开始加载");
const [progressItems, setProgressItems] = useState([...]);

这里引入了 React 最核心的概念------状态(State)

理解状态的三个层次:

复制代码
变量  →  会被改变的值
数据  →  驱动界面显示的值
状态  →  随着时间会变化、变化后界面自动跟着变的值

一个更直观的类比------川剧变脸

ini 复制代码
脸谱 = 界面显示(JSX)
状态 = 决定当前应该亮哪张脸

status === null      → 显示初始界面(Load Model 按钮)
status === "loading" → 显示进度条
status === "ready"   → 显示聊天界面
status === "error"   → 显示错误提示

数据只是值,状态是"会驱动视图自动变化的值"。

2.2 响应式的本质

jsx 复制代码
// 不需要这样写(命令式 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 用了哪个数据
类比 重画整幅画,和上一帧比较,描不同的地方 哪个灯坏了换哪个灯泡
jsx 复制代码
// 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 ------ 组件生命周期的"副作用"

jsx 复制代码
useEffect(() => {
    console.log('组件已经挂载完成');
    // 这里可以:发起网络请求、订阅事件、启动定时器...
    
    return () => {
        // 清理函数:组件卸载前执行
        // 用于取消请求、移除事件监听、清除定时器...
    }
}, []);  // 空依赖数组 = 只在挂载时执行一次

什么是副作用?

纯函数:输入决定输出,不产生额外影响 → 1 + 1 = 2

副作用:对函数外部产生了影响 → 修改 DOM、发网络请求、读写 localStorage......

React 组件的渲染本身是"纯"的(状态 → JSX),但真实应用必然需要副作用。useEffect 就是用来管理这些副作用的"容器"。

依赖数组的含义:

jsx 复制代码
useEffect(() => { ... }, []);       // 只在挂载时执行一次
useEffect(() => { ... }, [count]);  // count 变化时重新执行
useEffect(() => { ... });           // 每次渲染都执行(一般不这么用)

关于这个依赖数组,React 和 Vue 有一个关键差异:

React Vue
依赖追踪 手动 :开发者写 [deps],漏写会出现"闭包陷阱" 自动:框架自动追踪,不存在依赖缺失问题
闭包陷阱 useEffect 里引用的 state 可能是旧值 不会,响应式系统保证总是最新值

三、组件化 ------ 拆积木的艺术

3.1 为什么要拆组件?

在这个 demo 里,我们把进度条展示拆成了独立的 Progress.tsx 组件:

jsx 复制代码
// 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 里?

scss 复制代码
App.tsx(拆之前)              App.tsx(拆之后)
┌────────────────────┐        ┌────────────────────┐
│ 标题                │        │ 标题                │
│ 描述文字            │        │ 描述文字            │
│ 加载按钮            │        │ 加载按钮            │
│ ┌ 进度条 ────────┐  │        │ <Progress .../> ───┼──▶ Progress.tsx
│ │ 文件名、百分比   │  │        └────────────────────┘    只关心"怎么展示进度"
│ │ 总大小、样式    │  │
│ └───────────────┘  │
│ 错误提示            │
└────────────────────┘
  越来越臃肿,越来越难改
好处 说明
关注点分离 App 只管"什么时候显示进度",Progress 只管"进度长什么样"
可复用 多个地方都能用这个进度条组件
好维护 改进度条样式只改 Progress.tsx,不用动 App
团队协作 不同的人可以同时改不同组件,不冲突

3.2 Props ------ 组件的"参数"

jsx 复制代码
// 父组件(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

jsx 复制代码
{
    progressItems.map(({ text, percentage, total }, i) => (
        <Progress
            key={i}
            text={text}
            percentage={percentage}
            total={total}
        />
    ))
}

React 不用 v-for,直接用 .map()

这是 React 和 Vue 哲学差异的又一个体现:

html 复制代码
<!-- Vue:模板专属指令 -->
<div v-for="(item, i) in progressItems" :key="i">
    <Progress :text="item.text" />
</div>
jsx 复制代码
{/* 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)

现代前端看页面结构:组件树

css 复制代码
App
├── Header
├── ModelInfo
├── LoadButton
├── Progress(loading 时才显示)
│   ├── ProgressItem
│   ├── ProgressItem
│   └── ProgressItem
└── ErrorDisplay(出错时才显示)

组件树的意义:

  • 一眼看出页面结构:不需要在成百上千的 div 中找逻辑边界
  • 衡量组件化程度:树的深度和宽度反映了拆分的粒度
  • 团队协作边界:每个节点就是一个独立的工作单元
  • 复用粒度清晰:哪些是通用组件、哪些是业务组件一目了然

前端发展到今天,页面越来越复杂,组件成为开发的最小单元。这就是"组件化"的必然性。


四、实战中的细节拾遗

4.1 条件渲染

jsx 复制代码
// 错误状态时显示红色提示
{error && (
    <div className="text-red-500">
        <p>{error}</p>
    </div>
)}

// 加载中时显示进度条
{status === "loading" && (
    <Progress items={progressItems} />
)}

React 直接用 JavaScript 的 && 短路逻辑,还是那句话------"It's just JavaScript"。

4.2 !! 双取反 ------ 把任意值转成布尔

jsx 复制代码
const IS_WEBGPU_AVAILABLE = !!navigator.gpu;
// navigator.gpu 存在 → 转成 true
// navigator.gpu 不存在(undefined)→ 转成 false
js 复制代码
!!"hello"   // true
!!0         // false
!!undefined // false
!!{}        // true

// 第一次 ! 取反,转为布尔值并反转
// 第二次 ! 再反转回来
// 所以叫"双重否定等于肯定"

4.3 ??= 空值合并赋值

js 复制代码
percentage ??= 0;
// 等价于:
if (percentage === null || percentage === undefined) {
    percentage = 0;
}

?? 只关心 null / undefined,不像 || 会把 0""false 都当作"假值"。

js 复制代码
0 || 100     // 100(0 被当作假值,可能不是你想要的效果)
0 ?? 100     // 0  (只有 null/undefined 才会用右边的值)

4.4 disabled 按钮的正确用法

jsx 复制代码
<button
    disabled={status !== null || error !== null}
    onClick={() => setStatus("loading")}
>
    Load Model
</button>
  • disabled 是 HTML 原生属性,浏览器会自动灰掉按钮 + 阻止点击事件
  • disabled:cursor-not-allowed 是 Tailwind 的 disabled: 变体,在禁用时把光标变成 🚫
  • 前者管能不能点 ,后者管不能点时好不好看,两个层面

4.5 Tailwind 原子类速览

jsx 复制代码
<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 的整个核心体系。用一条主线来串联:

markdown 复制代码
事件监听进化史
    │
    ├── 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 的学习笔记整理而成。代码示例来自实际项目,有简化但保持核心逻辑。

相关推荐
weedsfly1 小时前
从类继承到自定义 Hook:模板方法模式的前端演进
前端·javascript·面试
Quz1 小时前
QML 线性布局:Row/RowLayout 与 Column/ColumnLayout
前端·qt
小陈工1 小时前
第8篇:Flask轻量级框架与扩展生态深度解析(下)
后端·python·面试
你为她披上外套时我正站在窗外1 小时前
用 Rust + WebAssembly 写了个字幕工具,文件全程不离开浏览器
前端
帅帅哥的兜兜1 小时前
AI模型初了解
前端
bonechips1 小时前
React + WebGPU(二):合成事件与进度条组件封装
前端·react.js
我是大卫1 小时前
【图】React源码解析-从底层数据结构、渲染调度到闭包原理深挖useRef的核心机制
前端·react.js·源码
王中阳Go1 小时前
面试拷打实录:候选人聊Agent/RAG时的典型误区,我给了这些“避坑指南”
后端·面试·agent
光影少年1 小时前
RN导航与路由
前端·react native·react.js