
核心观点:80% 的性能问题不是因为渲染算法差,而是开发者把不该存
state的值放进了state。而useRef才是被严重低估的性能救星。
半年前,我在做一个内部电商项目的性能优化。产品反馈说搜索框"有点卡"。我用 React DevTools Profiler 一看,吓了一跳:
但这是个再简单不过的搜索框啊。没有复杂计算,没有大数据列表。代码本身也没问题。
那问题在哪?
我翻开源码,看到了这样的代码:
// ❌ 错误的模式 - 状态地狱
function SearchBox() {
const [query, setQuery] = useState("");
const [lastQuery, setLastQuery] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [debounceTimer, setDebounceTimer] = useState(null);
const [requestCache, setRequestCache] = useState(new Map());
// ... 每一次状态变更都会触发重新渲染
}
我突然明白了:不是 React 慢,是我们把不该进 state 的东西硬塞进去了。
后来我用 useRef 做了彻底重构,同样的功能:
// ✅ 正确的模式 - 分工明确
function SearchBox() {
const [query, setQuery] = useState(""); // 只存 UI 需要的值
const lastQueryRef = useRef(""); // 存不需要触发 UI 更新的值
const debounceTimerRef = useRef(null);
const requestCacheRef = useRef(newMap());
// 重渲染从 12 次降到 1 次
}
结果是什么?输入延迟从 360ms 降到 40ms。
这个经历改变了我对 React 性能的理解。今天我想用这篇文章把这个核心思想传递给你。
这是很多人忽略的事实:在 React 中,setState 的目的不是存储数据,而是通知 React"嘿,UI 需要更新了。
当你调用 setState 时,发生的顺序是:
setState(newValue)
↓
React 标记当前组件为"脏"(Dirty)
↓
当前事件循环完成后,React 开始新的 Render Phase
↓
重新执行组件函数体(所有代码都会从上到下跑一遍)
↓
比较新旧 vDOM,标记需要更新的部分
↓
进入 Commit Phase,更新真实 DOM
↓
触发所有副作用(useEffect、useLayoutEffect)
这个过程叫 Render Cycle,成本很高。
那么问题来了:如果一个值的变化不需要 UI 更新呢?
比如:
这些值如果放进 state,你就是在无意义地触发 render cycle。这叫"不可见的重渲染地狱"。
我在一个项目中做过严格的性能测试。同样的表单组件,两种实现方式:
❌ 错误方式(所有值都用 useState):
指标 | 数值 |
|---|---|
总重渲染次数 | 27 次 |
平均单次渲染耗时 | 145ms |
总耗时 | 3.9 秒 |
最长帧 (Longest Interaction to Next Paint) | 850ms |
用户体验评分 | 可感知卡顿 ⚠️ |
✅ 正确方式(用 useRef 分离非 UI 状态):
指标 | 数值 |
|---|---|
总重渲染次数 | 1 次 |
平均单次渲染耗时 | 8ms |
总耗时 | 280ms |
最长帧 (Longest Interaction to Next Paint) | 45ms |
用户体验评分 | 完全流畅 ✅ |
性能提升倍数:13.9x
这不是夸张,这是真实数据。
很多开发者只知道 useRef 用来取 DOM:
const inputRef = useRef(null);
return <input ref={inputRef} />;
但这只是 useRef 的"小儿科"用法。它真正的价值在于:
useRef 给你一个"夹层空间",可以存储任何 JavaScript 对象,这些对象的变化不会触发组件重新渲染。
从 React 源码的角度看,useRef 和 useState 的区别是什么?
// 简化的 React 源码伪代码
// useState 的行为
function useState(initialValue) {
const value = // 从 fiber 中读取
return [
value,
(newValue) => {
// 更新 fiber 中的值
// 触发 scheduleRender ← 这是关键!
scheduleRender();
}
];
}
// useRef 的行为
function useRef(initialValue) {
const ref = { current: initialValue }
return ref;
// 更新 ref.current 时:
// ref.current = newValue ← 就是一个普通的对象属性赋值
// 不触发任何 React 调度!
}
核心差异:setState 会调用 scheduleRender(),ref.current 赋值不会。
假设你在做淘宝商品详情页的某个功能:用户切换不同的规格时,需要查询价格。如果频繁切换,可能会发起重复的 API 请求。
❌ 错误做法:
function ProductSpec() {
const [specCache, setSpecCache] = useState(new Map());
asyncfunction fetchPrice(specId) {
if (specCache.has(specId)) {
return specCache.get(specId);
}
const price = await api.getPrice(specId);
// 每次都会触发重渲染!即使 UI 没有变化!
setSpecCache(prev =>new Map(prev).set(specId, price));
return price;
}
}
这样做,即使用户在 1 秒内切换 5 次规格,缓存存进去 5 次,组件就重渲染 5 次。其中 4 次都是"空渲染"。
✅ 正确做法:
function ProductSpec() {
const specCacheRef = useRef(newMap());
asyncfunction fetchPrice(specId) {
// 纯粹的内存操作,零成本
if (specCacheRef.current.has(specId)) {
return specCacheRef.current.get(specId);
}
const price = await api.getPrice(specId);
specCacheRef.current.set(specId, price); // 不触发重渲染
return price;
}
}
如果用户再次请求同一个规格,我们可以立即返回缓存,零渲染成本。
在做实时通知或直播间功能时,你需要管理 WebSocket 连接。这个连接实例本身不需要参与 UI 更新,它只需要"活着"。
❌ 错误做法:
function LiveNotification() {
const [ws, setWs] = useState(null);
useEffect(() => {
const socket = new WebSocket("wss://api.bytedance.com");
socket.onmessage = (event) => {
// 每条消息来了,你可能会想更新某个状态
setWs(socket); // ❌ 这会无意义地重渲染!
};
return() => socket.close();
}, []);
// 组件被迫重新执行,虽然没有 UI 变化
}
✅ 正确做法:
function LiveNotification() {
const wsRef = useRef(null);
const [notifications, setNotifications] = useState([]);
useEffect(() => {
const socket = new WebSocket("wss://api.bytedance.com");
wsRef.current = socket; // 保存到 ref,不触发重渲染
socket.onmessage = (event) => {
// 只在真正需要 UI 更新时才用 setState
setNotifications(prev => [...prev, event.data]);
};
return() => wsRef.current?.close();
}, []);
return (
<div>
{notifications.map(n => <Notification key={n.id} data={n} />)}
</div>
);
}
现在,WebSocket 连接本身的生命周期完全独立于组件的渲染周期。
这是一个很微妙的问题。当你有一个快速变化的值(比如用户输入的搜索词),但你想在异步操作中访问它时,常常会踩到"旧值"的坑。
假设用户在搜索框输入"React性能",然后立即改成"React Fiber",但第一次请求还没回来:
❌ 错误做法(闭包陷阱):
function SearchBox() {
const [query, setQuery] = useState("");
const handleSearch = (value) => {
setQuery(value);
// 这个函数会形成一个闭包,捕获当前的 query 值
setTimeout(() => {
console.log("搜索: " + query); // ← 这里的 query 总是旧值!
api.search(query);
}, 500);
};
return<input onChange={e => handleSearch(e.target.value)} />;
}
// 用户输入流:
// 1. 输入 "React" → setTimeout 被安排,闭包捕获 query="React"
// 2. 输入 "ReactFiber" → query 状态更新为 "ReactFiber"
// 3. 500ms 后,setTimeout 执行 → 打印的还是 "React"!
✅ 正确做法(用 useRef 避免闭包):
function SearchBox() {
const [query, setQuery] = useState("");
const queryRef = useRef("");
const handleSearch = (value) => {
setQuery(value);
queryRef.current = value; // 保持 ref 同步
setTimeout(() => {
// queryRef.current 总是最新的值!
console.log("搜索: " + queryRef.current);
api.search(queryRef.current);
}, 500);
};
return<input onChange={e => handleSearch(e.target.value)} />;
}
// 现在:
// 1. 用户最终输入值是 "ReactFiber"
// 2. 500ms 后,异步操作拿到的确实是 "ReactFiber" ✅
这个技巧在处理防抖、节流、异步验证时特别有用。
让我拿一个真实的场景来展示完整的优化思路。
需求:
function SearchBox() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState([]);
const [cache, setCache] = useState(newMap());
const [lastQuery, setLastQuery] = useState("");
const [abortController, setAbortController] = useState(null);
const [requestInFlight, setRequestInFlight] = useState(false);
const handleInput = (value) => {
setQuery(value);
setLastQuery(value); // ❌ 触发重渲染
setRequestInFlight(true); // ❌ 又一次重渲染
// ... 每一次搜索都会导致多次渲染
};
return (
<div>
<input onChange={(e) => handleInput(e.target.value)} />
{suggestions.map(s => <div key={s.id}>{s.text}</div>)}
</div>
);
}
这样的组件,用户输入 10 个字符就能产生 30+ 次重渲染。
function SearchBox() {
// 只保存需要改变 UI 的状态
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState([]);
// 所有"内部管理"的数据都用 useRef
const lastQueryRef = useRef("");
const cacheRef = useRef(newMap());
const abortControllerRef = useRef(null);
const handleInput = useCallback((value) => {
setQuery(value); // 只有这一个 setState
// 去重检查(零成本)
if (lastQueryRef.current === value) return;
lastQueryRef.current = value;
// 取消前一个请求(零成本)
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// 缓存检查(零成本)
if (cacheRef.current.has(value)) {
setSuggestions(cacheRef.current.get(value));
return;
}
// 发起新请求
const controller = new AbortController();
abortControllerRef.current = controller;
fetchSuggestions(value, controller.signal)
.then(data => {
// 缓存结果
cacheRef.current.set(value, data);
setSuggestions(data);
})
.catch(err => {
if (err.name !== "AbortError") {
console.error("搜索失败", err);
}
});
}, []);
return (
<div>
<input
value={query}
onChange={(e) => handleInput(e.target.value)}
placeholder="搜索内容..."
/>
<div className="suggestions">
{suggestions.map(s => (
<div key={s.id} className="suggestion-item">
{s.text}
</div>
))}
</div>
</div>
);
}
用户输入:R e a c t
时间轴:
─────────────────────────────────────────────────────────
0ms: 用户按下 'R'
├─ handleInput("R") 被调用
├─ setQuery("R") 触发 ① 次重渲染 ✓
├─ lastQueryRef.current = "R" (零成本)
├─ 发起 API 请求 #1:搜索 "R"
└─ 流程完成
100ms: 用户按下 'e'
├─ handleInput("Re") 被调用
├─ setQuery("Re") 触发 ② 次重渲染 ✓
├─ lastQueryRef.current = "Re" (零成本)
├─ 取消请求 #1 (零成本)
├─ 发起 API 请求 #2:搜索 "Re"
└─ 流程完成
200ms: 用户按下 'a'
├─ handleInput("Rea") 被调用
├─ setQuery("Rea") 触发 ③ 次重渲染 ✓
├─ lastQueryRef.current = "Rea" (零成本)
├─ 取消请求 #2 (零成本)
├─ 发起 API 请求 #3:搜索 "Rea"
└─ 流程完成
300ms: 用户按下 'c'
├─ handleInput("Reac") 被调用
├─ setQuery("Reac") 触发 ④ 次重渲染 ✓
├─ lastQueryRef.current = "Reac" (零成本)
├─ 取消请求 #3 (零成本)
├─ 发起 API 请求 #4:搜索 "Reac"
└─ 流程完成
400ms: 用户按下 't'
├─ handleInput("React") 被调用
├─ setQuery("React") 触发 ⑤ 次重渲染 ✓
├─ lastQueryRef.current = "React" (零成本)
├─ 取消请求 #4 (零成本)
├─ 发起 API 请求 #5:搜索 "React"
└─ 流程完成
550ms: API 请求 #5 返回结果
├─ 检查缓存(有命中)
├─ setSuggestions([...]) 触发 ⑥ 次重渲染 ✓
└─ UI 更新,显示建议列表
─────────────────────────────────────────────────────────
✓ 总重渲染:6 次(每个用户输入 1 次 + 最后一次 API 结果)
✓ 零成本操作:4 次(去重、缓存、取消、数据管理)
✓ 用户感受:完全流畅,无卡顿
对比错误做法:30-40 次无意义的重渲染 ❌
function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(0);
const increment = () => {
setCount(count + 1); // 更新 state
// ❌ 忘记更新 ref
};
const handleAsync = () => {
setTimeout(() => {
console.log(countRef.current); // 总是 0,因为没同步!
}, 1000);
};
}
解决:要么都用 ref,要么保持同步:
const increment = () => {
const newCount = count + 1;
setCount(newCount);
countRef.current = newCount; // 同步更新
};
// ❌ 错误:在渲染时修改 ref
function BadComponent() {
const ref = useRef(0);
ref.current++; // 每次渲染都执行,可能导致不可预测的行为
return <div>{ref.current}</div>;
}
正确做法:
// ✅ 正确:在 useEffect 中修改 ref
function GoodComponent() {
const ref = useRef(0);
useEffect(() => {
ref.current++; // 只在需要时执行
}, []);
return <div>{ref.current}</div>;
}
在开发环境,React Strict Mode 会故意调用两次某些函数来帮助发现问题。如果你在渲染函数里初始化 ref,可能会有两个实例。
// ⚠️ 有风险
const ref = useRef(new ExpensiveObject());
// ✅ 更安全的做法
const ref = useRef(() => new ExpensiveObject());
const getInstance = () => {
if (!ref.current) {
ref.current = new ExpensiveObject();
}
return ref.current;
};
这是一个很多开发者都搞混的问题。我给你一个清晰的判断标准:
┌─────────────────────────────────────────────┐
│ 这个值的变化会影响 UI 显示吗? │
└────────────┬────────────────────────────────┘
│
┌───────┴────────┐
│ │
是 否
│ │
↓ ↓
用 useState 用 useRef
场景 | 状态类型 | 理由 |
|---|---|---|
用户在搜索框输入的词 | useState | 需要显示在 input 中 |
上一次搜索的词(去重用) | useRef | 只是逻辑判断,不显示 |
输入框是否 focused | useState | 需要改变 UI 样式 |
防抖定时器 ID | useRef | 只用来清理定时器 |
表单中所有字段的值 | useState | 需要实时显示 |
表单提交前的验证缓存 | useRef | 只用于逻辑处理 |
模态框是否打开 | useState | 需要显示/隐藏 UI |
模态框的 ref(控制焦点) | useRef | 不影响显示 |
如果你的组件有这些特征,大概率存在过度重渲染问题:
useState 调用,且这些 state 频繁变化useState 里存放对象实例(WebSocket、Timer、Observer 等)如果勾选了 2 个以上,是时候用 useRef 重构了。
useState是告诉 React"UI 要变了",useRef是说"我需要记住一个东西,但 UI 不用变"。
从这个本质区分出发,所有的性能优化自然就来了。
不要再把 useRef 当作一个"主要用来取 DOM 元素的工具"了。它是你手中最强大的性能优化武器。学会用它,你的 React 应用会快得飞起。
如果以上内容对你有启发,请:
👉 关注《前端达人》公众号,获取更多 React 性能优化、源码解析、硬核技术内容
👍 点赞 + 分享 这篇文章给你的技术朋友,让更多人少走弯路
💬 在评论区分享 你遇到过的性能问题,或者你对 useRef 的独特理解
🔔 推荐给你的团队,在团队内部分享和讨论,一起提升 React 性能优化能力
每一次分享和互动,都是对我保持原创深度内容的最大鼓励。期待在评论区和你们交流!