从 DOM 编程到声明式 UI:React useRef 与 useState 底层全解析
本文带你从原生 JS DOM 编程的痛点出发,逐层深入 React 的 useState 数据绑定机制、useRef 非响应式设计,再到 Web Worker 多线程实战,在底层原理与实战代码之间建立完整的认知链路。
一、DOM 编程的前世今生
1.1 两个引擎,一座性能墙
JavaScript 运行在浏览器的 V8 引擎 (或其他 JS 引擎)里,而 DOM 活在渲染引擎中。它们是两个独立的世界:
scss
┌─────────────────┐ 通信开销 ┌─────────────────┐
│ V8 引擎 (JS) │ ◄──────────► │ 渲染引擎 (DOM) │
│ 你的 .js 代码 │ 跨桥调用 │ HTML 文档树 │
└─────────────────┘ └─────────────────┘
每一次 document.getElementById、element.textContent = xxx,都是一次跨引擎桥接调用------JS 要通过浏览器内核提供的接口去操作渲染引擎里的对象。频繁跨桥,就是原生 DOM 编程"耗性能"的根本原因。
1.2 命令式时代的痛点
在 React/Vue 出现之前,前端开发是这样的:
javascript
// 数据
let count = 0;
// 手动把数据"绑"到 DOM
document.getElementById('display').textContent = count;
// 按钮点击
button.addEventListener('click', () => {
count++; // 1. 改数据
document.getElementById('display').textContent = count; // 2. 手动更新 DOM
});
数据和 DOM 是完全分离的。 作为开发者,你需要自己维护这个映射关系------哪些 DOM 依赖了哪些数据,数据变了去更新哪些 DOM。随着页面交互增多,这套"手动同步"的代码迅速失控,变得像意大利面条一样难以维护。
1.3 React 的答案:不要操作 DOM
React 的核心理念是:
你只管描述 UI 长什么样,DOM 的事交给框架。
swift
之前:开发者 → 手动操作 DOM → 渲染引擎
之后:开发者 → 描述 UI(JSX)→ React → 自动操作 DOM → 渲染引擎
这带来了开发方式的根本性改变------从命令式 转向声明式。
二、useState:数据绑定 + 响应式编程
2.1 先看一段代码
javascript
const [count, setCount] = useState(0);
return (
<>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>增加</button>
</>
);
这短短几行背后蕴含了两个核心概念:数据绑定 和响应式编程。
2.2 什么是"数据绑定"?
<span>{count}</span> 就是数据绑定。count 这个 JS 变量被"嵌入"到了 UI 描述中。
编译后它变成:
csharp
React.createElement('span', null, count);
// 生成 VNode:
// { type: 'span', props: { children: 0 } }
数据 count 被写进虚拟 DOM 的描述对象中。数据成了 UI 的唯一真相源(Single Source of Truth)。
2.3 什么是"响应式编程"?
当你调用 setCount(count + 1),React 自动完成:
php
setCount(1)
│
▼
dispatchAction → 向 Fiber 的 Hook 队列推入更新
│
▼
scheduleUpdateOnFiber → 标记当前 Fiber 为 dirty
│
▼
Scheduler → 在浏览器空闲帧调度工作
│
▼
Reconciler → 重新执行组件函数,读取最新 state → 生成新 VNode
│
▼
Diff → 对比新旧 VNode,计算出最小的 DOM 变更
│
▼
Commit → 执行变更,更新真实 DOM
│
▼
浏览器重绘 → 用户看到新界面
你只做了一件事------改数据。视图自动响应了这个变化。 不需要知道 <span> 在 DOM 树里的位置,不需要手动 textContent。这就是"响应式"------数据变,视图自动变。
2.4 底层原理:状态存哪里?
很多人以为 useState 是"把值存在组件函数的闭包里"。这是个常见误读。真相是:
php
Fiber 节点(React 内部维护的组件实例对象)
│
└── memoizedState: Hook① ──► Hook② ──► Hook③ ──► null
(链表,每个 Hook 占一个节点)
调用 useState(0) 时,React 在 Fiber 的 Hook 链表上分配一个节点:
arduino
{
memoizedState: 0, // 当前状态值
queue: { // 更新队列
pending: null, // 待处理的 update 对象链表
dispatch: setCount, // setter 函数
},
next: Hook②, // 指向下一个 Hook 节点
}
组件每次重渲染都是重新执行一遍函数体 ,useState 的返回值是从 Fiber 链表的当前节点上读出来的,函数自己的闭包里并没有存着它。
这也是为什么 React Hooks 严格要求不能在条件/循环中调用------Hook 在链表上的位置必须在每次渲染中保持一致,否则值就对不上了。
2.5 关于"快照"
scss
function Component() {
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(() => {
console.log(count); // 3 秒后输出的是这次渲染的 count,不是最新的
}, 3000);
}, []);
}
每次渲染都是一张独立的快照 。count 是那次渲染执行时从 Fiber 上读取到的值,闭包里捕获的就是那个值。它不是"一直指向最新值的变量",而是一张"当时的照片"。
三、useEffect:副作用
javascript
useEffect(() => {
// 组件渲染到 DOM 之后执行的逻辑
// 比如:数据请求、订阅、操作 DOM
console.log('组件已挂载');
return () => {
// 清理函数:组件卸载前执行
console.log('组件将卸载');
};
}, []); // 依赖数组:空 = 只在挂载时执行一次
useEffect 的核心语义是"渲染之后再做某事"------请求数据、操作 DOM、开启定时器,这些不直接产出 UI 的事情 都叫副作用(Side Effect),放在 useEffect 里。
依赖数组 [] 为空 → 只执行一次(类似 class 组件的 componentDidMount)。如果放入依赖项,则依赖变化时重新执行。
四、useRef:非响应式的可变对象
4.1 什么时候你非要碰 DOM?
React 的设计哲学是"不要直接操作 DOM",但现实中有些需求绕不开:
- 页面加载后自动聚焦某个输入框
- 测量 DOM 元素的尺寸
- 集成第三方非 React 的库(比如图表库、地图 SDK)
这时候 useRef 登场了。
4.2 绑定 DOM 节点
ref-focus-demo/src/App.jsx 的核心逻辑:
ini
import { useRef, useEffect } from 'react';
const App = () => {
const inputRef = useRef(null);
useEffect(() => {
// 此时组件已挂载,ref.current 已经绑定了真实 DOM 节点
console.log(inputRef.current); // <input type="text" ...>
inputRef.current.focus(); // 自动获取焦点
}, []);
return (
<input
type="text"
placeholder="请输入用户名"
ref={inputRef} // 这一步完成了 ref 与 DOM 的绑定
/>
);
};
工作流程拆解:
sql
useRef(null) → 创建 { current: null },存在 Fiber 的 Hook 链表中
│
▼
JSX 中的 ref={inputRef} → React 在 Commit 阶段把真实 DOM 节点赋给 inputRef.current
│
▼
useEffect 回调执行 → 此时 inputRef.current 已经指向真实 <input> DOM
│
▼
inputRef.current.focus() → 执行原生 DOM API,光标自动定位到输入框
💡
useEffect空依赖数组[]意味着:回调只在首次渲染完成后 执行一次。此时真实 DOM 已生成,ref.current已经从null变成了<input>节点。
4.3 useRef 也可以"存值",但它不触发渲染
ref-focus-demo/src/App.jsx 另一段代码:
ini
const App = () => {
const numRef = useRef(0); // 创建一个持久可变对象
const [, forceRender] = useState(0); // 借 useState 的 setter 来手动触发渲染
console.log(numRef.current);
return (
<div
onClick={() => {
numRef.current += 1; // 修改 ref 的值,React 完全不知情
forceRender(); // 手动触发一次重渲染,让界面刷新
}}
>
{numRef.current}
</div>
);
};
这段代码非常直观地演示了核心差异:
| 行为 | numRef.current += 1 |
setState(...) |
|---|---|---|
| 值是否改变 | ✅ 是 | ✅ 是 |
| React 是否知道 | ❌ 否 | ✅ 是 |
| 是否触发重渲染 | ❌ 否 | ✅ 是 |
| 界面是否刷新 | ❌ 否(除非用 forceRender) | ✅ 是 |
注释里的 forceRender 是一个巧妙的技巧------它调用 useState 的 setter 传入 0(和当前值一样),"骗" React 触发一次重渲染。重渲染时,React 重新执行函数体,numRef.current 的最新值就被读出来并显示到界面上了。
4.4 useState 与 useRef 的底层对比
两者在 Fiber 上几乎是一样的------都有 memoizedState,都在 Hook 链表里占一个节点。区别在改值的动作上:
sql
useState 的 setState:
→ dispatchAction → 创建 update 对象 → 推入 queue → scheduleUpdateOnFiber
→ React 收到通知:"我要重渲染!"
useRef 的 .current = xxx:
→ 直接 mutate Fiber.memoizedState 上 { current: xxx } 对象
→ 完。React 毫不知情。
| useState | useRef | |
|---|---|---|
| 响应式 | ✅ 改值 → 自动渲染 | ❌ 改值 → 静默写入 |
| 用途 | 驱动视图的数据 | 不需要驱动视图的持久引用 |
| 典型场景 | 表单值、开关状态、列表数据 | DOM 节点、定时器 ID、Worker 实例 |
| 改值方式 | setState(newVal) |
ref.current = newVal |
| React 视角 | "这是 UI 的真相源" | "这是开发者自己维护的便签" |
4.5 总结定义
useRef是 React 提供的一个持久可变对象的 Hook,常用于引用 DOM 节点。它有一个current属性,可以指向任意值或对象,修改它不会触发组件重渲染。
五、进阶实战:useRef + Web Worker
5.1 JS 单线程的问题
JavaScript 是单线程的------主线程既要处理用户交互(点击、滚动),又要执行脚本逻辑。对于简单的页面交互,单线程保证了状态一致性和代码简洁性。但当页面需要处理大量计算时,问题来了:
ini
// 这段代码会彻底卡死页面
for (let i = 0; i < 1000000; i++) {
console.log(i);
}
console.timeEnd('主线程耗时');
执行这 100 万次循环期间,用户无法点击、无法滚动------主线程被这段同步代码完全阻塞了。
5.2 Event Loop 能救吗?
Event Loop 通过异步机制(setTimeout、Promise)让耗时操作"不阻塞主线程",但它本质上还是在同一个线程上排队执行------异步只是"不卡在那里等",并不是"把活分给另一个人干"。
javascript
主线程执行栈:
┌──────────────────────────────────────────────┐
│ 同步任务 → 微任务(Promise) → 宏任务(setTimeout) │
│ 但所有任务都在同一条线程上排队! │
└──────────────────────────────────────────────┘
当一个任务是计算密集型 的(LLM 推理、游戏逻辑、大量数据处理),即使把它变成异步的 setTimeout,它最终还是会占用主线程的执行时间,导致掉帧和交互延迟。
5.3 Web Worker:真正的多线程
HTML5 引入了 Web Worker------浏览器为 JS 提供了一条独立于主线程的线程,拥有自己的内存空间,通过消息机制与主线程通信。
┌─────────────────┐ 消息(postMessage) ┌─────────────────┐
│ 主线程 │ ◄─────────────────────────► │ Worker 线程 │
│ UI 渲染、交互 │ 数据传输 │ 复杂计算、LLM │
│ 不会卡顿 ✓ │ │ 不影响 UI ✓ │
└─────────────────┘ └─────────────────┘
ref-worker-demo/src/App.jsx 的实现:
javascript
import { useRef, useEffect } from 'react';
function App() {
const workerRef = useRef(null);
useEffect(() => {
// 组件挂载后,开启一个 Worker 线程
// 用 useRef 持有这个 Worker 实例------因为换不换 Worker 都不需要重渲染
workerRef.current = new Worker(
new URL('./worker.js', import.meta.url)
);
}, []);
return <>{/* Worker 在后台运行,不占用主线程 */}</>;
}
ref-worker-demo/src/worker.js:
arduino
console.log('worker online');
5.4 代码解析
第 1 步:声明一个空的 ref
ini
const workerRef = useRef(null);
// workerRef = { current: null }
此时 current 是 null------等待未来被赋值。
第 2 步:在 useEffect 中创建 Worker
javascript
useEffect(() => {
workerRef.current = new Worker(
new URL('./worker.js', import.meta.url)
);
}, []); // 空依赖:只在挂载时执行一次
new Worker(url) 浏览器原生 API------它会让浏览器开辟一个新的后台线程来执行 worker.js。import.meta.url 获取当前模块的 URL,new URL('./worker.js', import.meta.url) 构造出 worker.js 的完整路径。
为什么要用 useRef 而不是 useState 存这个 Worker 实例?
Worker 实例只是"在后台干活的引用",创建它、替换它都不需要刷新界面。用 useRef 恰到好处:改了 current 不触发渲染,但值仍然持久存在,随时可以 workerRef.current.postMessage(...) 发消息给它。
💡 注释里的
// 为组件的渲染 挂载让路意思是:useEffect在渲染完成之后才执行(Commit 阶段之后),此时 DOM 已经就绪,再去创建 Worker 不会阻塞首次渲染。这是一种性能意识------不让耗时操作挡住用户看到界面的路。
5.5 典型模式:主线程 ↔ Worker 通信
实际项目中加上消息通信才是完整用法:
ini
// 主线程
workerRef.current.postMessage({ type: 'CALCULATE', data: largeArray });
workerRef.current.onmessage = (e) => {
console.log('Worker 计算结果:', e.data);
};
// worker.js
self.onmessage = (e) => {
const result = heavyCalculation(e.data);
self.postMessage(result);
};
这样复杂计算在 Worker 线程跑,主线程始终保持流畅的交互体验。
六、完整知识图谱
sql
React Hooks
├── useState ───────────────── 响应式的 ── 驱动视图的数据
│ ├── 数据绑定:JSX 中 {state} → VNode → DOM
│ ├── 响应式:setState → 调度 → 协调 → Diff → Commit
│ └── 存储:Fiber.memoizedState 链表节点
│
├── useEffect ──────────────── 副作用 ── 渲染之后做的事
│ ├── 数据请求、订阅、DOM 操作
│ ├── 空依赖 []:只执行一次
│ └── 返回清理函数:组件卸载前调用
│
└── useRef ─────────────────── 非响应式的 ── 持久可变对象
├── DOM 引用:ref={xxx} → Commit 时自动绑定
├── 普通值引用:修改 .current 不触发渲染
├── vs useState:同存储、不同通知策略
└── Worker 引用:持有后台线程实例的最佳选择
跨线程
└── Web Worker
├── JS 单线程 ── 计算密集任务会阻塞 UI
├── Event Loop ── 异步 ≠ 多线程
├── Worker 线程 ── 独立内存空间 + 消息通信
└── useRef 持有 Worker 实例 ── 不触发渲染
七、一句话总结
React 用 useState 把"数据变了 UI 就自动变"这件事做成了系统默认行为,用 useRef 给开发者保留了一个"改了东西但别嚷嚷"的后门------两者共同构成了 React"数据驱动视图"的完整拼图。当主线程遇到计算瓶颈,useRef 搭配 Web Worker 又提供了一套干净的多线程解决方案:用 ref 持有一个不打扰 UI 的后台线程,主线程永远不卡顿。