从 DOM 编程到声明式 UI:React useRef 与 useState 底层全解析

从 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.getElementByIdelement.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 通过异步机制(setTimeoutPromise)让耗时操作"不阻塞主线程",但它本质上还是在同一个线程上排队执行------异步只是"不卡在那里等",并不是"把活分给另一个人干"。

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 }

此时 currentnull------等待未来被赋值。

第 2 步:在 useEffect 中创建 Worker

javascript 复制代码
useEffect(() => {
  workerRef.current = new Worker(
    new URL('./worker.js', import.meta.url)
  );
}, []);  // 空依赖:只在挂载时执行一次

new Worker(url) 浏览器原生 API------它会让浏览器开辟一个新的后台线程来执行 worker.jsimport.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 的后台线程,主线程永远不卡顿。

相关推荐
小月土星1 小时前
19.LeetCode 删除链表的倒数第 N 个节点 超详细题解(JS 版)
javascript
kisshyshy1 小时前
前端路由进化史:从刷新白屏到SPA,手写一个Hash路由就懂了!
前端·javascript·react.js
保加利亚的风2 小时前
Docker 学习文档(Mac + Docker Desktop 版)
前端·后端
java1234_小锋2 小时前
Vue3专题 - 条件渲染
前端·javascript·vue.js
明月_清风2 小时前
🚀 AI Agent 完全入门指南:从 LLM 到生产落地,新手必懂的 34 个核心概念
前端·后端·ai编程
嘟嘟07172 小时前
从零理解 useRef:React 中操作 DOM 与持久化变量的唯一桥梁
javascript
鸽鸽2 小时前
Vue 3 API 完全指南:从 Options 到 Composition 的进阶之路
前端·vue.js
名字还没想好☜2 小时前
React 用 Portal + Context 实现全局 Toast:一次调用、自动消失与队列管理
前端·javascript·react.js·react·next.js