React useRef 跨生命周期引用保持机制

各位未来的全栈架构师,下午好!

今天我们不聊那些花里胡哨的 Redux、Context 或者 GraphQL,我们聊聊 React 里的“隐秘角落”。大家平时写 React,是不是觉得 useState 是主角?没错,它是个大明星,天天站在聚光灯下,每次它变个心情(状态改变),整个组件就得重新粉刷一遍(重新渲染)。

但是,各位,光有主角是不行的,还得有群演,还得有道具组,还得有那些藏在后台不露脸的工作人员。今天我们要聊的这个主角,就是那个最不起眼、最容易被忽略,但关键时刻能救命的神器——useRef

有人说,useRef 就是那个“我不渲染,但我有想法”的家伙。听起来很酷,对吧?今天我们就来扒一扒这个“跨生命周期引用保持机制”的底裤,看看它到底是怎么在 React 的世界里“隐身”的。


第一部分:React 的“洁癖”与 useRef 的“垃圾桶”

首先,我们要理解 React 的设计哲学。React 是个什么性格?它是个重度强迫症患者,是个洁癖狂。

当你写一个组件,比如这个:

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h1>当前数字是: {count}</h1>
      <button onClick={() => setCount(count + 1)}>点我</button>
    </div>
  );
}

当你点击按钮,count 变了。React 大脑里想:“哦,数据变了,UI 得变。” 于是,它把整个组件的虚拟 DOM 给比对了一遍,发现数字变了,它就把屏幕上的数字擦掉,重新写一个新的数字。

这个过程,我们叫它重新渲染

现在,我们引入 useRefuseRef 是什么?它就是一个盒子。一个不可见的盒子。

function Counter() {
  const [count, setCount] = useState(0);
  // 这是一个看不见的盒子,里面可以装任何东西:数字、DOM节点、甚至是一段代码
  const hiddenBox = useRef(0);

  return (
    <div>
      <h1>当前数字是: {count}</h1>
      {/* 我们可以访问这个盒子里的东西,但React不会因为这个盒子的变化而重新渲染界面 */}
      <button onClick={() => {
        hiddenBox.current = 100; // 嘘!React看不见这一步,不会重新渲染
        console.log("盒子里的数字变成了", hiddenBox.current);
      }}>偷偷改数字</button>
    </div>
  );
}

你看,当我们修改 hiddenBox.current 的时候,控制台会打印 100,但屏幕上的数字还是 0。React 依然在它的世界里,认为 count 还是 0,所以它根本懒得动一下。

这就是 useRef 的核心哲学: 它是持久化的,但它不可见。它不参与 React 的响应式系统。

那它有什么用呢? 它适合放那些不需要触发 UI 变化的数据。比如,你正在计算一个巨大的数字,算出来之后你不需要显示在屏幕上,只是用来做下一次计算的依据,那你就把它扔进 useRef 里,省得每次渲染都重新算一遍(除非你不想省那一点点 CPU)。


第二部分:DOM 引用——与上帝对话的钥匙

既然 useRef 能拿到 DOM 节点,那它最大的用途就是操作 DOM。因为 React 虽然说“声明式渲染”,但有时候我们就是想手撸一下 DOM,比如给输入框自动聚焦。

假设我们有个登录表单:

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

function LoginForm() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const inputRef = useRef(null); // 我们给输入框准备了一把钥匙

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('提交了', username, password);
  };

  // 这个 useEffect 就像是组件刚出生时的“自我介绍”
  useEffect(() => {
    // 这里的 inputRef.current 就等于那个真实的 <input> 元素
    if (inputRef.current) {
      inputRef.current.focus(); // 给它一把钥匙,打开门,聚焦!
      console.log('输入框已经自动聚焦了');
    }
  }, []); // 空依赖数组,意味着这个 effect 只在组件挂载的时候运行一次

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>用户名:</label>
        {/* ref 属性把我们的钥匙插进了这个输入框里 */}
        <input 
          ref={inputRef} 
          type="text" 
          value={username}
          onChange={(e) => setUsername(e.target.value)}
        />
      </div>
      <div>
        <label>密码:</label>
        <input 
          ref={useRef(null)} // 哎呀,这里写错了,应该用变量
          type="password" 
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      <button type="submit">登录</button>
    </form>
  );
}

你看,这把钥匙 inputRef,在组件挂载的那一刻,就拿到了真实的 DOM 节点。之后不管你怎么改 usernameinputRef.current 永远指向那个真实的 DOM 元素,不会变。

为什么不用 useState 做这个?
如果你用 useState 去存输入框的值,那你每次输入都要触发组件重新渲染,虽然 React 很快,但那是多余的。而 useRef 存的值,不渲染,直接操作 DOM,性能极佳。


第三部分:跨生命周期——它是个“时间旅行者”

这是 useRef 最神奇的地方。我们来看一个经典场景:组件卸载又重新挂载

想象一下,你有个组件叫 Count,它里面有个计数器。

useState

function CounterWithState() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h2>我是组件:{count}</h2>
      <button onClick={() => setCount(c => c + 1)}>点我</button>
      <button onClick={() => window.location.reload()}>刷新页面(模拟卸载重挂载)</button>
    </div>
  );
}

当你疯狂点击“点我”到 10,然后点击“刷新页面”。页面刷新了,组件重新挂载,count 变回了 0。它的记忆只有一瞬间。

useRef

function CounterWithRef() {
  const countRef = useRef(0);

  return (
    <div>
      <h2>我是组件:{countRef.current}</h2>
      <button onClick={() => {
        countRef.current++;
        console.log('内部记忆:', countRef.current);
      }}>点我</button>
      <button onClick={() => window.location.reload()}>刷新页面</button>
    </div>
  );
}

当你疯狂点击“点我”到 10,然后点击“刷新页面”。页面刷新了,组件重新挂载。你以为 countRef.current 会变回 0 吗?
错! 它还是 10!

为什么?因为 useRef 返回的对象,在组件的整个生命周期内(从挂载到卸载),它指向的内存地址是同一个。它就像是你钱包里的钱包,钱包丢了(组件卸载),但里面的钱(值)还在,等你换个新钱包(重新挂载),钱还是在那儿。

这有什么用?
假设你在做一个复杂的表单编辑器。用户输入了半天,填了 50 行数据。突然网络断了,或者路由跳转到了错误页,然后用户又切回来。
如果用 useState,用户的数据全没了。
useRef,你的数据还在!你只需要在组件卸载的时候,把 useRef 的数据存到 localStorage,挂载的时候再读出来,完美!


第四部分:定时器与副作用——闭包的终结者

这是 useRef 最硬核的用法。在 React 的 useEffect 中,我们经常遇到“闭包陷阱”。

看这个例子:

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const intervalRef = useRef(null); // 用来存定时器ID

  useEffect(() => {
    intervalRef.current = setInterval(() => {
      // 这里有个大坑!
      setSeconds(s => s + 1);
    }, 1000);

    // 清理函数
    return () => {
      clearInterval(intervalRef.current);
    };
  }, []); // 依赖数组为空

  return <div>计时器:{seconds}秒</div>;
}

这个例子看起来没问题,对吧?但其实它隐藏着一个更深层的问题。

假设我们把代码改成这样,我们想在定时器里打印当前的秒数:

function BadTimer() {
  const [seconds, setSeconds] = useState(0);
  const intervalRef = useRef(null);

  useEffect(() => {
    intervalRef.current = setInterval(() => {
      console.log("当前秒数:", seconds); // 这里打印的 seconds 是什么?
      setSeconds(s => s + 1);
    }, 1000);
    return () => clearInterval(intervalRef.current);
  }, [seconds]); // 依赖数组里有 seconds

  return <div>计时器:{seconds}秒</div>;
}

大坑! 因为 seconds 在依赖数组里,每次 seconds 变化,useEffect 都会重新执行。这意味着,你的定时器会被销毁、重建。虽然 setInterval 每秒执行一次,但 seconds 变化导致 useEffect 重建,导致定时器频繁重置。

解决方案:把定时器的 ID 存进 useRef,不要把它放进依赖数组!

function GoodTimer() {
  const [seconds, setSeconds] = useState(0);
  // 这个 intervalRef 是不会触发重新渲染的
  const intervalRef = useRef(null);

  useEffect(() => {
    // 初始化定时器
    intervalRef.current = setInterval(() => {
      // 在这里,我们使用 useRef 来打破闭包,或者至少避免触发重渲染逻辑
      console.log("当前秒数:", seconds);
      setSeconds(s => s + 1);
    }, 1000);

    // 清理函数
    return () => {
      clearInterval(intervalRef.current);
    };
  }, []); // 依赖数组是空的!定时器一旦建立,就不受 seconds 变化的影响了

  return <div>计时器:{seconds}秒</div>;
}

在这个 GoodTimer 里,intervalRef.current 永远指向那个正在运行的定时器对象。无论 seconds 怎么变,useEffect 都不会重新执行,定时器稳如老狗。

但是! 如果在定时器里你需要用到最新的 seconds,而 seconds 是通过 setSeconds 更新的,闭包还是会让你拿到旧的值。这时候,useRef 也可以作为“临时存储最新值”的仓库。

function AdvancedTimer() {
  const [seconds, setSeconds] = useState(0);
  const latestSecondsRef = useRef(seconds); // 专门用来存最新值的“小抄”

  useEffect(() => {
    // 每次渲染,把最新的 seconds 存到 ref 里
    latestSecondsRef.current = seconds;

    const interval = setInterval(() => {
      // 在这里,我们通过 ref 拿到了最新的秒数,而不是闭包里的旧秒数
      const current = latestSecondsRef.current;
      console.log("正在计时,当前最新值:", current);
      setSeconds(c => c + 1);
    }, 1000);

    return () => clearInterval(interval);
  }, [seconds]);

  return <div>计时器:{seconds}秒</div>;
}

这里 latestSecondsRef 就像一个“备忘录”。虽然它不渲染,但它时刻更新。我们在定时器回调里查这个备忘录,就能拿到最新的数据。


第五部分:兄弟组件通信——没有 Redux 的“过家家”

在 React 中,父子组件通信很容易,父传子用 props,子传父用回调函数。但是兄弟组件之间怎么通信?

通常我们得找爸爸(父组件)当传声筒。这就像两个住在隔壁房间的人,每次说话都要经过客厅(父组件),效率太低。

useRef 给我们提供了一种“直连”的可能性。

场景: 左边有个计数器,右边有个显示器。左边点一下,右边变一下。

function Parent() {
  // 我们在父组件里创建一个 ref,作为“公共电话亭”
  const sharedRef = useRef(null);

  return (
    <div>
      <ChildA ref={sharedRef} /> {/* 把钥匙给左边 */}
      <ChildB dataRef={sharedRef} /> {/* 把钥匙给右边 */}
    </div>
  );
}

function ChildA(props) {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h3>左边:我点一下</h3>
      <button onClick={() => {
        setCount(c => c + 1);
        // 左边修改数据后,直接修改父组件传来的 ref
        if (props.dataRef) {
          props.dataRef.current = count + 1;
        }
      }}>
        +1
      </button>
    </div>
  );
}

function ChildB(props) {
  const [display, setDisplay] = useState(0);

  // 监听 ref 的变化
  useEffect(() => {
    const ref = props.dataRef;
    if (!ref) return;

    // 这里有个小技巧:我们监听 ref.current 的变化
    // 注意:直接监听对象引用变化在 React 里比较麻烦,通常我们监听它的属性
    const interval = setInterval(() => {
      if (ref.current !== undefined) {
        setDisplay(ref.current);
      }
    }, 100);

    return () => clearInterval(interval);
  }, [props.dataRef]); // 依赖 ref 对象本身

  return (
    <div>
      <h3>右边:我显示</h3>
      <p>当前值:{display}</p>
    </div>
  );
}

在这个例子里,sharedRef 就是一条隐形的管道。左边把数据塞进去,右边从里面把数据拿出来。虽然这有点“破坏封装”,但在某些特定场景下,比如极高性能要求的实时数据流,或者不想引入 Redux 这种重型库的简单场景,这招很管用。


第六部分:性能优化——不仅仅是存数据

除了存储数据,useRef 还有一个隐藏的用途:避免昂贵的计算

假设我们有一个超级复杂的函数,计算量很大,每次渲染都要跑一遍:

function ExpensiveComponent() {
  const [input, setInput] = useState('');
  const expensiveValueRef = useRef(null);

  // 这是一个超级复杂的计算函数
  const computeExpensive = (val) => {
    console.log("我在计算..."); // 只有真的计算了才会打印
    let result = 0;
    for(let i=0; i<1000000; i++) {
      result += Math.sqrt(val);
    }
    return result;
  };

  // 每次输入框变化,这里都会重新运行
  const result = computeExpensive(input);

  return (
    <div>
      <input value={input} onChange={e => setInput(e.target.value)} />
      <div>结果:{result}</div>
    </div>
  );
}

每次你敲键盘,input 变了,computeExpensive 就被调用,控制台疯狂打印“我在计算…”。这用户体验好吗?不好。

我们可以用 useRef 来缓存结果:

function OptimizedExpensiveComponent() {
  const [input, setInput] = useState('');
  // 我们用一个 ref 来存计算结果,这个结果不会触发重渲染
  const resultRef = useRef(null);
  // 记录上一次的输入,防止不必要的重复计算
  const prevInputRef = useRef('');

  // 只有当输入真正改变时,才重新计算
  if (prevInputRef.current !== input) {
    console.log("我在计算..."); // 只有输入真的变了才计算
    resultRef.current = computeExpensive(input);
    prevInputRef.current = input;
  }

  return (
    <div>
      <input value={input} onChange={e => setInput(e.target.value)} />
      {/* 这里直接读 ref,不会触发重新渲染 */}
      <div>结果:{resultRef.current}</div>
    </div>
  );
}

在这个优化版里,虽然 resultRef.current 变了,但 React 觉得 UI 不需要变(因为 input 没变,UI 还是显示原来的结果),所以它根本不重新渲染。

这就是 useRef 的“静默”力量。


第七部分:陷阱与最佳实践——别被它坑了

虽然 useRef 很好用,但如果你不当心,它也会变成“内存泄漏”的帮凶。

陷阱一:忘记清理定时器

还记得我们刚才的定时器吗?如果你在 useEffect 里启动了一个定时器,并把 ID 存进了 useRef。但是,如果用户离开了这个页面,组件卸载了。

useEffect(() => {
  const id = setInterval(() => console.log("滴"), 1000);
  myRef.current = id; // 存起来了
  return () => {
    clearInterval(myRef.current); // 必须清理!
  };
}, []);

如果你忘了写 return () => clearInterval(...),那么即使组件没了,那个定时器还在后台跑。它在后台不断打印日志,不断消耗 CPU,还不断尝试更新已经销毁的组件状态。这就是经典的内存泄漏

陷阱二:误用 useRef 做状态管理

很多新手喜欢把所有东西都往 useRef 里塞,以为这样就不会重渲染了。

function BadState() {
  const userRef = useRef({ name: 'Tom', age: 18 });

  const changeName = () => {
    userRef.current.name = 'Jerry';
  };

  // ... 很多逻辑
}

然后你在渲染里用 userRef.current.name
如果你只改一次,没问题。但如果你在组件里有很多地方在改这个对象,而且每次改都去读它,你会发现逻辑非常混乱。因为 useRef 的变化不触发渲染,所以你很难察觉到数据什么时候变了。

最佳实践:

  1. 用于 DOM 引用ref={inputRef}
  2. 用于跨渲染周期的数据:比如表单输入历史、定时器 ID。
  3. 用于缓存计算结果:避免重复计算。
  4. 用于存储不需要渲染的变量
  5. 千万别用 useRef 存那些需要用来触发 UI 更新的数据。那应该用 useState

第八部分:深入底层——为什么它不渲染?

为了真正理解 useRef,我们得看看 React 的源码逻辑(简化版)。

React 在渲染一个组件时,会创建一个虚拟 DOM。它会根据 useState 生成的值来决定 DOM 的样子。

但是,当它遇到 useRef 时,它的逻辑是这样的:

// 伪代码
function render(Component) {
  const instance = new Component(); // 实例化组件
  const hooks = instance.$$hooks;    // 获取 hooks 列表

  // 如果是 useState
  if (hooks.type === 'state') {
    const value = hooks.value; // 从状态池取值
    hooks.value = hooks.value + 1; // 更新状态池
    return <div>{value}</div>; // 返回 UI
  }

  // 如果是 useRef
  if (hooks.type === 'ref') {
    // 关键点来了!
    // useRef 不把值放到渲染逻辑里!
    // 它只是把一个对象引用扔到 instance 里
    // 渲染函数根本不关心这个对象有没有变!
    return <div>{hooks.value.current}</div>; 
  }
}

因为 useRef 返回的对象引用在组件实例的整个生命周期内是不变的,React 在渲染循环里根本检测不到它的变化。所以,它不会触发重新渲染。

这就好比你在画漫画。useState 是漫画里的角色,角色一变,下一页就得重画。useRef 是画在角落里的备注,备注改了,漫画里的画面不需要变。


第九部分:实战演练——手写一个“防抖”工具

最后,我们来个实战。手写一个防抖函数,并把结果存进 useRef

防抖的意思是:用户快速点击 10 次,我们只执行最后一次。

function DebounceInput() {
  const [value, setValue] = useState('');
  const timerRef = useRef(null); // 存定时器

  const handleChange = (e) => {
    const val = e.target.value;
    setValue(val); // 这个会触发重渲染

    // 每次输入都清空之前的定时器
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }

    // 设置一个新的定时器,1秒后执行
    timerRef.current = setTimeout(() => {
      console.log("防抖触发,实际值为:", val);
      // 这里可以调用 API,比如搜索请求
    }, 1000);
  };

  return (
    <div>
      <input 
        type="text" 
        value={value} 
        onChange={handleChange} 
        placeholder="快速输入试试..." 
      />
    </div>
  );
}

在这个例子中,timerRef.current 在组件的整个生命周期内不断变化(被 clearTimeout,又被 setTimeout 覆盖)。但它不触发渲染。它静静地躺在那里,指挥着时间的流逝。


第十部分:useRefuseImperativeHandle —— 进阶玩法

当我们把 ref 传给子组件时,默认情况下,子组件暴露给父组件的是整个子组件实例。

// 父组件
const childRef = useRef(null);
<ChildComponent ref={childRef} />

// 子组件
function ChildComponent() {
  const sayHello = () => alert('Hello');
  return <div>...</div>;
}

当你访问 childRef.current 时,你会得到整个 ChildComponent 实例。这很危险,因为你可能会不小心改到子组件内部不该改的东西。

为了安全起见,我们可以用 useImperativeHandle 来控制 ref 暴露什么。

import React, { useRef, useImperativeHandle } from 'react';

const FancyInput = React.forwardRef((props, ref) => {
  const inputRef = useRef(null);

  // 这个 hook 告诉 React:当父组件拿到我的 ref 时,它只能看到这个对象
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    },
    // 你可以暴露任意方法,但不能暴露内部实现细节
    getValue: () => inputRef.current.value
  }));

  return <input ref={inputRef} type="text" />;
});

function Parent() {
  const inputRef = useRef(null);

  return (
    <div>
      <FancyInput ref={inputRef} />
      <button onClick={() => inputRef.current.focus()}>聚焦</button>
    </div>
  );
}

这里,useImperativeHandle 配合 useRef,实现了一种受控的 API 暴露机制。这是 React 高级开发中非常常见的模式。


总结与思考

好了,各位同学,今天的讲座接近尾声。我们聊了 useRef 的方方面面。

我们回顾了:

  1. 它是什么:一个不触发渲染的持久化存储容器。
  2. 它为什么存在:为了操作 DOM,为了跨生命周期保存数据,为了管理副作用(如定时器),为了性能优化。
  3. 它怎么用useRef(initialValue),然后通过 .current 属性访问。
  4. 它的坑:容易造成内存泄漏(忘记清理定时器),容易误用导致逻辑混乱。

最后,我想留给大家一个思考题:

在一个非常复杂的列表组件中,列表项很多(比如 1000 行)。每次用户滚动列表,父组件都会重新渲染。你如何利用 useRef 来优化这个性能,避免父组件因为列表项的滚动而频繁重新渲染?

提示:列表项的滚动位置数据,是应该放在 useState 里(触发渲染),还是放在 useRef 里(不触发渲染)?

(思考一下,下节课我们揭晓答案。记住,useRef 是 React 里的“影子”,它不显山露水,但无处不在。善用它,你就能写出更高效、更优雅的 React 代码。)

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注