useRef 的三个段位:从 DOM 引用到 Web Worker,彻底搞懂 React 的"非响应式"利器

0 阅读10分钟

useRef 的三个段位:从 DOM 引用到 Web Worker,彻底搞懂 React 的"非响应式"利器

useRef 是 React 中最容易被低估的 Hook。很多人只知道它能"获取 DOM",却不知道它还能存储可变对象、持有 Worker 线程、做性能优化。本文从 DOM 编程的性能困境出发,带你逐层深入 useRef 的三个段位——DOM 引用 → 可变对象 → Web Worker 管理,并对比 useState 揭示"响应式 vs 非响应式"的本质差异。全文代码可直接运行,建议收藏后动手实践。


一、为什么 React 要规避 DOM 编程?

1.1 JS 引擎与渲染引擎的分离

浏览器内部有两个核心引擎,它们运行在不同的世界中:

┌──────────────────────────────────────────┐
│              浏览器进程                    │
│                                          │
│  ┌──────────────┐    ┌──────────────┐   │
│  │  V8 引擎      │    │  渲染引擎     │   │
│  │              │    │              │   │
│  │  执行 JS 代码 │    │  解析 HTML   │   │
│  │  运行业务逻辑 │    │  构建 DOM 树 │   │
│  │  计算和处理   │    │  CSSOM + 渲染│   │
│  └──────┬───────┘    └──────┬───────┘   │
│         │                   │            │
│         └───────桥接────────┘            │
│           (跨引擎通信开销大)               │
└──────────────────────────────────────────┘

JS 操作 DOM 为什么慢?

JS 引擎(V8)              渲染引擎
    │                          │
    │  document.getElementById │
    │  ──────────────────────▶ │  跨引擎调用
    │                          │  查找 DOM 节点
    │  ◀────────────────────── │  返回 DOM 对象
    │                          │
    │  element.style.color     │
    │  ──────────────────────▶ │  跨引擎修改
    │                          │  触发重排/重绘
    │                          │

每次 JS 操作 DOM,都要跨越引擎边界通信——这是 DOM 编程耗性能的根本原因。

1.2 React 的解法:规避 DOM 编程

传统 JS 开发(jQuery 时代):
  直接操作 DOM
  document.getElementById('app').innerHTML = '...'
  性能瓶颈在跨引擎通信

React / Vue 开发:
  不直接操作 DOM
  ┌──────────┐    ┌──────────────┐    ┌──────────┐
  │ useState │───▶│ React 虚拟DOM │───▶│ 真实 DOM │
  │ 数据状态  │    │  Diff 算法    │    │  自动更新 │
  └──────────┘    └──────────────┘    └──────────┘
  你只管改数据,React 帮你改 DOM

前端开发方式的根本转变:从"命令式操作 DOM"到"声明式数据驱动"。


二、useRef 第一段位:DOM 引用

2.1 场景:自动聚焦输入框

有些场景必须直接操作 DOM,比如页面加载后自动 focus 输入框——把用户当小白,不用让他们手动点一下。

import { useRef, useEffect, useState } from 'react';

const App = () => {
  const [count, setCount] = useState(0);
  // ref 对象引用,初始值为 null
  // 未来它会引用真实的 DOM 节点
  const inputRef = useRef(null);

  useEffect(() => {
    // 组件挂载后,inputRef.current 已指向 input DOM 节点
    inputRef.current.focus();
  }, []);

  return (
    <>
      <input
        type="text"
        placeholder="请输入用户名"
        ref={inputRef}  {/*  DOM 节点绑定到 ref */}
      />
      {count}
      <button onClick={() => setCount(count + 1)}>增加</button>
    </>
  );
};

2.2 useRef 操作 DOM 的三步法

步骤一:声明 ref,初始值为 null
  const inputRef = useRef(null);
       │
       ▼
步骤二:在 JSX 中通过 ref 属性绑定
  <input ref={inputRef} />
       │
       ▼  React 挂载组件时,自动将 DOM 节点赋值给
       │  inputRef.current
       ▼
步骤三:在 useEffect 中使用
  useEffect(() => {
    inputRef.current.focus();  // current 指向 DOM 节点
  }, []);

2.3 为什么在 useEffect 中操作 DOM?

// ❌ 错误:在渲染阶段直接操作
const App = () => {
  const inputRef = useRef(null);
  inputRef.current.focus();  // 此时 DOM 可能还没挂载!
  return <input ref={inputRef} />;
};

// ✅ 正确:在 useEffect 中操作
const App = () => {
  const inputRef = useRef(null);
  useEffect(() => {
    inputRef.current.focus();  // DOM 已挂载,安全操作
  }, []);
  return <input ref={inputRef} />;
};

渲染时序:

组件函数执行(渲染阶段)     DOM 还不存在
    │                          inputRef.current = null
    ▼
React 创建 DOM 节点           inputRef.current = <input>
    │
    ▼
useEffect 执行                此时可以安全操作 DOM
    │
    ▼
inputRef.current.focus()      ✅ DOM 已就绪

三、useRef 第二段位:可变对象(非响应式状态)

3.1 场景:计数器但不需要响应式

有时候你需要存储一个可变的值,但不希望它触发重新渲染

import { useRef, useState } from 'react';

const App = () => {
  const numRef = useRef(0);           // 非响应式可变值
  const [, forceRender] = useState(0); // 响应式触发器

  console.log(numRef.current);

  return (
    <>
      <div
        onClick={() => {
          numRef.current += 1;  // 修改 ref 不会触发渲染
          forceRender();        // 手动触发渲染来更新 UI
        }}
      >
        {numRef.current}
      </div>
    </>
  );
};

3.2 useRef vs useState:响应式 vs 非响应式

useState(响应式):
  setCount(1) ──▶ React 检测到变化 ──▶ 重新渲染组件 ──▶ UI 更新
  ✅ 数据变化自动反映到 UI

useRef(非响应式):
  ref.current = 1 ──▶ React 不关心 ──▶ 不触发渲染 ──▶ UI 不变
  ✅ 数据变化但不触发渲染(性能优化)
维度useStateuseRef
响应式✅ 修改触发重新渲染❌ 修改不触发渲染
用途业务数据状态DOM 引用、可变对象、Worker 等
渲染时保留✅ 每次渲染保留最新值✅ 每次渲染保留同一个对象
更新方式setState(newValue)ref.current = newValue
触发渲染✅ 是❌ 否
适用场景需要驱动 UI 更新的数据不需要驱动 UI 的持久引用

3.3 什么时候用 useRef 存值?

// 场景一:定时器 ID(不需要触发渲染)
const timerRef = useRef(null);
timerRef.current = setInterval(() => { ... }, 1000);
clearInterval(timerRef.current);

// 场景二:上一次的状态值(用于比较)
const prevCountRef = useRef(0);
const [count, setCount] = useState(0);
useEffect(() => {
  prevCountRef.current = count;  // 记录上一次的值
}, [count]);

// 场景三:是否是首次渲染的标记
const isFirstRender = useRef(true);
useEffect(() => {
  if (isFirstRender.current) {
    isFirstRender.current = false;
    return;  // 首次渲染跳过
  }
  // 非首次渲染的逻辑
}, [someValue]);

3.4 useRef 的核心定义

useRef 是 React 提供的一个持久可变对象的 Hook 函数,经常用来引用 DOM 节点对象。它有一个 current 属性,可以指向任何值或对象,不会触发渲染

useRef 返回的对象生命周期:

组件挂载 ──▶ 创建 ref 对象 { current: initialValue }
    │
    │  每次重新渲染:返回同一个对象(不会重新创建)
    │
组件更新 ──▶ ref.current 可以被修改,但不会触发渲染
    │
组件卸载 ──▶ ref 对象被销毁

四、JS 单线程与 Event Loop 的局限

4.1 为什么 JS 是单线程?

// 如果 JS 是多线程:
// 线程 A:document.getElementById('app').innerHTML = 'Hello'
// 线程 B:document.getElementById('app').innerHTML = 'World'
// → 冲突!DOM 到底显示什么?

JS 设计为单线程的原因是保证一致性——浏览器中的交互(滚动、点击、输入)如果由多个线程同时操作 DOM,就会产生冲突。单线程保证了简单和安全。

4.2 Event Loop:单线程的异步魔法

┌──────────────────────────────────────────────┐
│              Event Loop 机制                   │
│                                              │
│  ┌─────────────────┐                         │
│  │   Call Stack     │  主线程执行栈            │
│  │  (同步代码)       │                         │
│  └────────┬────────┘                         │
│           │                                   │
│           ▼                                   │
│  ┌─────────────────┐    ┌─────────────────┐  │
│  │  Web APIs        │    │  Callback Queue │  │
│  │  (异步任务)       │    │  (回调队列)      │  │
│  │                  │    │                 │  │
│  │  setTimeout      │───▶│  回调函数排队    │  │
│  │  fetch           │───▶│  等待执行        │  │
│  │  DOM Events      │───▶│                 │  │
│  └─────────────────┘    └────────┬────────┘  │
│                                  │            │
│                          Call Stack 空闲时     │
│                          ◀──取出回调执行        │
└──────────────────────────────────────────────┘

Event Loop 的问题:

普通异步任务(fetch、setTimeout):
  ✅ 不阻塞主线程,Event Loop 能处理

耗时计算任务(大循环、LLM 推理、游戏物理引擎):
  ❌ 虽然是同步代码,但执行时间太长
  ❌ 占用主线程,阻塞所有交互
  ❌ 用户点击、滚动全部无响应
// 这段代码会冻结页面 3 秒
console.time('主线程');
for (let i = 0; i < 100000000; i++) {
  // 耗时计算,主线程被完全阻塞
}
console.timeEnd('主线程');
// 在此期间,用户无法点击、滚动、输入

五、useRef 第三段位:管理 Web Worker

5.1 Web Worker:浏览器的多线程方案

┌──────────────────────────────────────────────────┐
│                 浏览器                             │
│                                                  │
│  ┌────────────────┐     ┌──────────────────┐    │
│  │   主线程        │     │   Worker 线程     │    │
│  │  (UI 渲染+交互) │     │  (复杂计算)       │    │
│  │                │     │                  │    │
│  │  React 渲染    │     │  LLM 推理        │    │
│  │  用户交互      │     │  游戏物理引擎     │    │
│  │  DOM 操作      │     │  大量数据处理     │    │
│  │                │     │                  │    │
│  │    postMessage │────▶│   onmessage     │    │
│  │                │◀────│   postMessage    │    │
│  └────────────────┘     └──────────────────┘    │
│                                                  │
│  独立内存空间,互不阻塞                            │
└──────────────────────────────────────────────────┘

Web Worker 的特点:

特性主线程Worker 线程
能操作 DOM❌ 不能
能渲染 UI❌ 不能
耗时计算❌ 会阻塞✅ 不阻塞主线程
内存空间主线程内存独立内存
通信方式-postMessage 消息机制

5.2 用 useRef 持有 Worker 线程

import { useRef, useState, useEffect } from 'react';

function App() {
  // Worker 线程的引用,持久化存储
  const workerRef = useRef(null);

  useEffect(() => {
    // 开启一个 Worker 线程(开销较大,只创建一次)
    workerRef.current = new Worker(
      new URL('./worker.js', import.meta.url)
    );

    // 组件卸载时清理 Worker
    return () => {
      workerRef.current.terminate();
    };
  }, []);

  return (
    <>
      <button onClick={() => {
        // 向 Worker 发送消息
        workerRef.current.postMessage('start');
      }}>
        开始计算
      </button>
    </>
  );
}

export default App;

worker.js:

// Worker 线程中的代码
console.log('worker online');

// 接收主线程消息
self.onmessage = function(event) {
  console.log('收到主线程消息:', event.data);

  // 执行耗时计算
  let result = 0;
  for (let i = 0; i < 1000000000; i++) {
    result += i;
  }

  // 将结果发送回主线程
  self.postMessage({ result });
};

5.3 为什么用 useRef 持有 Worker?

如果用 useState 存储 Worker:

const [worker, setWorker] = useState(null);
useEffect(() => {
  setWorker(new Worker(...));  // 触发重新渲染
}, []);

❌ 问题:
1. Worker 创建后不需要触发渲染
2. 每次 setWorker 都会导致组件重新渲染
3. Worker 对象不是"状态",是"引用"


如果用 useRef 存储 Worker:

const workerRef = useRef(null);
useEffect(() => {
  workerRef.current = new Worker(...);  // 不触发渲染
}, []);

✅ 优势:
1. 不触发不必要的渲染
2. Worker 对象在整个组件生命周期内持久存在
3. 符合 useRef 的语义——引用非响应式对象

5.4 Worker 创建语法解析

workerRef.current = new Worker(
  new URL('./worker.js', import.meta.url)
);
new URL('./worker.js', import.meta.url)
         │              │
         │              └── 基准路径:当前模块的 URL
         │
         └── 相对路径:Worker 脚本文件

最终解析为绝对路径:
  http://localhost:5173/src/worker.js (Vite 开发环境)
  https://example.com/assets/worker-abc123.js (生产构建)

new Worker(url)
  → 浏览器下载该脚本
  → 在独立线程中执行
  → 返回 Worker 实例

为什么用 new URL + import.meta.url

这是 Vite/Webpack 等构建工具推荐的方式。构建工具会识别这种写法,将 Worker 脚本单独打包成一个文件,而不是内联到主 bundle 中。

5.5 主线程与 Worker 的完整通信

import { useRef, useState, useEffect } from 'react';

function App() {
  const workerRef = useRef(null);
  const [result, setResult] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    workerRef.current = new Worker(
      new URL('./worker.js', import.meta.url)
    );

    // 监听 Worker 返回的消息
    workerRef.current.onmessage = function(event) {
      setResult(event.data.result);
      setLoading(false);
    };

    return () => {
      workerRef.current.terminate();
    };
  }, []);

  const handleCalculate = () => {
    setLoading(true);
    // 向 Worker 发送任务
    workerRef.current.postMessage({ task: 'calculate' });
  };

  return (
    <>
      <button onClick={handleCalculate} disabled={loading}>
        {loading ? '计算中...' : '开始计算'}
      </button>
      {result && <p>结果: {result}</p>}
    </>
  );
}

通信流程图:

主线程                          Worker 线程
   │                                │
   │  postMessage({task:'calc'})    │
   │───────────────────────────────▶│
   │                                │
   │                                │  执行耗时计算
   │                                │  (不阻塞主线程)
   │   用户可以正常交互              │
   │   页面滚动/点击正常              │
   │                                │
   │  postMessage({result: xxx})    │
   │◀───────────────────────────────│
   │                                │
   │  onmessage 接收结果             │
   │  setResult(xxx) 更新 UI         │
   ▼                                ▼

六、useRef 的三个段位总结

段位一:DOM 引用(最常用)
  ┌─────────────────────────────┐
  │ const inputRef = useRef(null) │
  │ <input ref={inputRef} />     │
  │ inputRef.current.focus()     │
  └─────────────────────────────┘
  场景:focus、滚动定位、测量元素尺寸、操作 canvas

段位二:可变对象(非响应式存储)
  ┌─────────────────────────────┐
  │ const timerRef = useRef(null) │
  │ timerRef.current = setInterval│
  │ clearInterval(timerRef.current)│
  └─────────────────────────────┘
  场景:定时器 ID、上一次的值、首次渲染标记

段位三:Worker 线程管理(高级)
  ┌─────────────────────────────┐
  │ const workerRef = useRef(null)│
  │ workerRef.current = new Worker│
  │ workerRef.current.postMessage │
  └─────────────────────────────┘
  场景:复杂计算、LLM 推理、游戏物理引擎

七、最佳实践

7.1 什么时候该用 useRef?

场景用 useState用 useRef
需要驱动 UI 更新的数据
DOM 节点引用
定时器/订阅 ID
Worker 实例
不需要触发渲染的中间值
跨渲染周期保留的引用

7.2 useRef 使用规范

// ✅ 初始值要明确
const inputRef = useRef(null);       // DOM 引用,初始 null
const countRef = useRef(0);          // 数值,初始 0
const workerRef = useRef(null);      // Worker,初始 null

// ✅ 修改 current 属性,不要重新赋值整个 ref
ref.current = newValue;              // ✅
// ref = { current: newValue };      // ❌ 不要这样做

// ✅ 清理副作用
useEffect(() => {
  workerRef.current = new Worker(...);
  return () => {
    workerRef.current.terminate();   // 卸载时清理
  };
}, []);

// ❌ 不要在渲染阶段读写 ref.current
const App = () => {
  const ref = useRef(0);
  ref.current = 1;  // ❌ 渲染阶段不要修改 ref
  return <div />;
};

// ✅ 在事件处理或 useEffect 中修改
const App = () => {
  const ref = useRef(0);
  useEffect(() => {
    ref.current = 1;  // ✅ 在 useEffect 中修改
  }, []);
  return <div />;
};

八、总结

8.1 知识体系图

useRef(持久可变对象 Hook)
├── 段位一:DOM 引用
│   ├── useRef(null) → ref 属性绑定 → current 操作 DOM
│   └── 必须在 useEffect 中操作(等 DOM 挂载完成)
├── 段位二:非响应式可变值
│   ├── 不触发渲染的数据存储
│   ├── 定时器 ID、上一次的值、首次渲染标记
│   └── vs useState:响应式 vs 非响应式
└── 段位三:Worker 线程管理
    ├── JS 单线程 → Event Loop → 耗时任务阻塞
    ├── Web Worker 独立线程,postMessage 通信
    └── useRef 持有 Worker 实例,不触发渲染

8.2 核心概念速查

概念要点
DOM 编程为什么慢JS 引擎与渲染引擎跨引擎通信开销
React 规避 DOMuseState 数据驱动,React 自动更新 DOM
useRef 定义持久可变对象,有 current 属性,不触发渲染
useRef vs useState非响应式 vs 响应式
JS 单线程保证 DOM 操作一致性
Event Loop 局限异步无阻塞,但耗时同步任务会卡死主线程
Web Worker浏览器多线程方案,独立内存,postMessage 通信
useRef + Worker用 ref 持有 Worker 实例,不触发渲染

8.3 React Hooks 的分工

useState   → 响应式业务数据(触发渲染)
useEffect  → 副作用管理(请求、订阅、DOM 操作)
useRef     → 非响应式持久引用(DOM、Worker、可变值)

三个 Hook 各司其职:useState 管数据状态,useEffect 管副作用,useRef 管引用。理解它们的边界,才能写出高性能、可维护的 React 组件。


如果这篇文章对你有帮助,欢迎点赞收藏