各位未来的全栈架构师,下午好!
今天我们不聊那些花里胡哨的 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 给比对了一遍,发现数字变了,它就把屏幕上的数字擦掉,重新写一个新的数字。
这个过程,我们叫它重新渲染。
现在,我们引入 useRef。useRef 是什么?它就是一个盒子。一个不可见的盒子。
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 节点。之后不管你怎么改 username,inputRef.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 的变化不触发渲染,所以你很难察觉到数据什么时候变了。
最佳实践:
- 用于 DOM 引用:
ref={inputRef}。 - 用于跨渲染周期的数据:比如表单输入历史、定时器 ID。
- 用于缓存计算结果:避免重复计算。
- 用于存储不需要渲染的变量。
- 千万别用
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 覆盖)。但它不触发渲染。它静静地躺在那里,指挥着时间的流逝。
第十部分:useRef 与 useImperativeHandle —— 进阶玩法
当我们把 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 的方方面面。
我们回顾了:
- 它是什么:一个不触发渲染的持久化存储容器。
- 它为什么存在:为了操作 DOM,为了跨生命周期保存数据,为了管理副作用(如定时器),为了性能优化。
- 它怎么用:
useRef(initialValue),然后通过.current属性访问。 - 它的坑:容易造成内存泄漏(忘记清理定时器),容易误用导致逻辑混乱。
最后,我想留给大家一个思考题:
在一个非常复杂的列表组件中,列表项很多(比如 1000 行)。每次用户滚动列表,父组件都会重新渲染。你如何利用 useRef 来优化这个性能,避免父组件因为列表项的滚动而频繁重新渲染?
提示:列表项的滚动位置数据,是应该放在 useState 里(触发渲染),还是放在 useRef 里(不触发渲染)?
(思考一下,下节课我们揭晓答案。记住,useRef 是 React 里的“影子”,它不显山露水,但无处不在。善用它,你就能写出更高效、更优雅的 React 代码。)
下课!