React Hook 全家桶核心逻辑(最底层原理)
所有 Hook 都存在组件的链表 (Hook Chain) 中,React 根据调用顺序来读取状态。
1.useState和useEffect:
1.1 useState 和 useEffect 的执行时机
关键时间线:
javascript
function Component() {
console.log('1. 组件函数执行');
const [count, setCount] = useState(0);
console.log('2. useState 返回值');
useEffect(() => {
console.log('4. useEffect 执行(DOM 已更新)');
return () => console.log('5. 清理函数(下次 effect 前或卸载时)');
}, [count]);
console.log('3. return JSX 前');
return <div>{count}</div>;
}
// 输出顺序:1 → 2 → 3 → 渲染 DOM → 4
浏览器永远是:
- 先执行完所有同步 JS 代码
- 再执行一次浏览器渲染(解析HTML+CSS+合成渲染树+布局 + 绘制)
- 最后才执行异步队列(微任务 → 宏任务)
并且:
- useEffect 属于异步任务
- DOM 渲染在同步代码之后
时间线:
javascript
同步 JS 执行阶段(阻塞渲染)
1. 组件执行
2. useState
3. 注册 useEffect(不执行)
4. return JSX
同步代码执行完毕
浏览器渲染阶段(JS 暂停)
5. React 更新真实 DOM
6. 浏览器布局、绘制
DOM 渲染完毕
异步任务阶段
7. 执行 useEffect 回调
实战案例:聊天室滚动到底部
javascript
function ChatRoom({ messages }) {
const bottomRef = useRef(null);
useEffect(() => {
// √此时 DOM 已渲染,可以安全操作
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]); // 新消息到达时滚动
return (
<div>
{messages.map(msg => <div key={msg.id}>{msg.text}</div>)}
<div ref={bottomRef} />
</div>
);
}
常见错误:
javascript
// 错误:在渲染阶段操作 DOM
function Bad() {
const ref = useRef(null);
ref.current?.focus(); // 此时 DOM 可能还没渲染!
return <input ref={ref} />;
}
// 正确:在 useEffect 中操作
function Good() {
const ref = useRef(null);
useEffect(() => {
ref.current?.focus(); // DOM 已就绪
}, []);
return <input ref={ref} />;
}
2. useRef:稳定引用与缓存
- 核心 :返回一个永远不变 的对象
{ current: 值 }。 - 特性 :修改
.current不会触发组件重渲染。 - 本质 :它是类组件
this的替代方案,专门用于存值 而不是渲染。 - 两大场景 :
- 获取 DOM 节点 :
ref={inputRef}。 - 存储可变值 :定时器 ID、上一次的 props/state(通过
.current存取以避开闭包陷阱)。
- 获取 DOM 节点 :
3. useRef vs useState:什么时候用谁?
核心区别:
| 特性 | useState | useRef |
|---|---|---|
| 改变时重新渲染 | 是的 | 不是 |
| 保存跨渲染的值 | 是的 | 是的 |
| 适用场景 | UI 状态 | DOM 引用、定时器 ID、上一次的值 |
实战案例 1:保存定时器 ID
javascript
function Stopwatch() {
const [time, setTime] = useState(0);
const timerRef = useRef(null); // 不需要触发渲染
const start = () => {
timerRef.current = setInterval(() => {
setTime(t => t + 1);
}, 1000);
};
const stop = () => {
clearInterval(timerRef.current); // 访问最新的 timer ID
};
useEffect(() => {
return () => clearInterval(timerRef.current); // 清理
}, []);
return (
<div>
<p>{time}s</p>
<button onClick={start}>开始</button>
<button onClick={stop}>停止</button>
</div>
);
}
为什么不用 useState 存 timer ID?
javascript
// ❌ 错误示范
const [timerId, setTimerId] = useState(null);
const start = () => {
setTimerId(setInterval(...)); // 触发重新渲染,浪费性能
};
只有这 3 种东西能触发组件重渲染:
- useState 里的 setXxx
- useReducer 的 dispatch
- 父组件重新渲染
👉 useEffect 绝对不能触发重渲染! 它只是个副作用回调,本身不触发渲染。
- setState 调用 → 一定重渲染(组件函数跑一遍)
- 值真的变了 ( React 用
Object.is对比:值变化)→ 才更 DOM→ 才重新执行useEffect - 值没变 → 组件重跑,但 DOM 不动(React 优化),也不重新执行useEffect
实战案例 2:保存上一次的值
javascript
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // 渲染后更新
});
return ref.current; // 返回上一次的值
}
// 使用
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>当前: {count}</p>
<p>上一次: {prevCount}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
4. useMemo & useCallback:缓存优化(语法糖关系)
两者底层逻辑一致,都是基于**Object.is** 做浅比较缓存。
useMemo:缓存计算结果(值) 。- 作用:避免复杂逻辑在每次渲染时重复执行(如大数据过滤)。
- 公式:
const value = useMemo(() => calc(bigList), [bigList])
useCallback:缓存函数引用(函数本身) 。- 作用:保证函数地址不变,防止子组件(被
memo包装)不必要的重渲染。 - 本质:它就是
useMemo(() => () => {}, [])的语法糖。 - 公式:
const fn = useCallback(() => {}, [])
- 作用:保证函数地址不变,防止子组件(被
javascript
// 等价于
const handleClick = useMemo(() => () => doSomething(a, b), [a, b]);
什么时候用?什么时候不用?
应该用的场景:
场景 1:昂贵的计算
javascript
function ProductList({ products, filters }) {
// ✅ 过滤和排序很耗时,缓存结果
const filteredProducts = useMemo(() => {
console.log('重新计算...');
return products
.filter(p => p.category === filters.category)
.sort((a, b) => b.price - a.price);
}, [products, filters]);
return (
<ul>
{filteredProducts.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
场景 2:避免子组件不必要的重新渲染
javascript
const ExpensiveChild = React.memo(({ data, onUpdate }) => {
console.log('子组件渲染');
return <div>{data.name}</div>;
});
function Parent() {
const [count, setCount] = useState(0);
const [user, setUser] = useState({ name: 'Alice' });
// ✅ 缓存函数,避免每次渲染创建新函数
const handleUpdate = useCallback(() => {
setUser({ ...user, name: 'Bob' });
}, [user]);
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
{/* count 变化时,ExpensiveChild 不会重新渲染 */}
<ExpensiveChild data={user} onUpdate={handleUpdate} />
</div>
);
}
- 父组件渲染 → 默认所有子组件都会跟着渲染
- React.memo 让子组件变成 "浅比较 props",只有 props 变了才渲染
- 但函数是引用类型,每次父组件重渲染都会创建新函数 → 导致 memo 失效
- useCallback 就是用来 "缓存函数",让函数引用不变 → memo 才能生效
- 最终:count 变化时,子组件不渲染!
不应该用的场景:
javascript
// ❌ 过度优化:简单计算不需要 useMemo
const doubled = useMemo(() => count * 2, [count]); // 浪费
const doubled = count * 2; // 直接算更快
// ❌ 依赖项太多,缓存失效频繁
const result = useMemo(() => a + b + c + d + e, [a, b, c, d, e]); // 没意义
// ❌ 子组件没用 React.memo,缓存函数无效
function Child({ onClick }) { // 没有 React.memo
return <button onClick={onClick}>点击</button>;
}
function Parent() {
const handleClick = useCallback(() => {}, []); // 白费力气
return <Child onClick={handleClick} />;
}
5. memo:组件层面的门禁
- 作用 :包裹函数组件,默认阻止重渲染。
- 逻辑 :只有传给子组件的
props发生变化(Object.is对比),子组件才会重新渲染。 - 配合 :必须和
useCallback配合使用,否则函数引用变化依然会触发子组件重渲染。
6. 自定义 Hook:封装可复用逻辑
实战案例 1:useDebounce(防抖值)
javascript
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 使用
function SearchBox() {
const [input, setInput] = useState('');
const debouncedInput = useDebounce(input, 500);
useEffect(() => {
if (debouncedInput) {
fetch(`/api/search?q=${debouncedInput}`)
.then(res => res.json())
.then(setResults);
}
}, [debouncedInput]); // 只在防抖后的值变化时请求
return <input value={input} onChange={e => setInput(e.target.value)} />;
}
- 用户输入 →
input立刻更新 useDebounce监测到input变化- 开启 500ms 定时器
- 500ms 内再次输入 → 清除旧定时器,重新计时
- 停手 500ms →
debouncedInput才更新 debouncedInput变化 → 发送搜索请求
**只发一次请求,完美避免频繁请求!**useDebounce = 让值延迟更新,专门解决输入搜索、频繁请求的防抖问题,是 React 最实用的自定义 Hook 之一。
实战案例 2:useLocalStorage(持久化状态)
javascript
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setStoredValue = useCallback((newValue) => {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
}, [key]);
return [value, setStoredValue];
}
// 使用,主题变化:
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
当前主题: {theme}
</button>
);
}
逐行拆解:
javascript
function useLocalStorage(key, initialValue) {
// 1. 初始化:优先从 localStorage 取,取不到就用默认值
const [value, setValue] = useState(() => {
try {
const item = localStorage.getItem(key); // 从本地存储拿数据
return item ? JSON.parse(item) : initialValue; // 有就解析,没有就用默认值
} catch {
return initialValue; // 解析失败也用默认值
}
});
// 2. 缓存一个更新函数:改 state 同时自动存 localStorage
const setStoredValue = useCallback((newValue) => {
setValue(newValue); // 先更新内部 state
localStorage.setItem(key, JSON.stringify(newValue)); // 再同步存到本地
}, [key]); // 依赖 key,key 不变函数就不变
// 3. 返回:用法和 useState 一毛一样!
return [value, setStoredValue];
}
完整执行流程:
我用主题切换的例子走一遍:
第一次进入页面(初始化):
- 执行
useLocalStorage('theme', 'light')- 进入
useState初始化函数- 去 localStorage 找
theme这个 key
- 第一次:找不到
- 所以
value = 'light'- 准备好
setStoredValue函数- 返回
['light', 函数]点击按钮切换主题:
- 执行
setTheme('dark')- 调用
setStoredValue('dark')- 内部执行:
setValue('dark')→ 状态更新localStorage.setItem('theme', 'dark')→ 存到本地- 页面重渲染 → 显示 dark
刷新页面!(最关键):
- 再次执行
useLocalStorage('theme', 'light')- 去 localStorage 找
theme
- 找到了!值是
'dark'JSON.parse解析后返回value = 'dark'- 返回
['dark', ...]- 页面直接显示 dark,不会变回 light!
这就是持久化状态。
| 原生 localStorage | useLocalStorage Hook | |
|---|---|---|
| 是什么 | 浏览器原生存储 API | React 自定义 Hook |
| 是否响应式 | 不触发渲染 | 自动触发渲染 |
| 是否自动同步 | 手动存、手动取 | 改状态自动存 |
| JSON 处理 | 自己写 | 自动处理 |
| 异常处理 | 容易崩 | 自带 try/catch |
| 适合谁 | 原生 JS、简单场景 | React 项目、状态持久化 |
实战案例 3:useIntersectionObserver核心hook:
javascript
function useIntersectionObserver(ref, options) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
setIsVisible(entry.isIntersecting);
}, options);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, [ref, options]);
return isVisible;
}
懒加载:监听图片是否可见
javascript
function LazyImage({ src }) {
const imgRef = useRef(null);
const isVisible = useIntersectionObserver(imgRef, { threshold: 0.1 });
return (
<div ref={imgRef}>
{isVisible ? <img src={src} /> : <div>加载中...</div>}
</div>
);
}
无限滚动组件:
思路:页面底部放一个看不见的小 div → 它一出现就加载下一页
javascript
function InfiniteList() {
const [list, setList] = useState([]);
const [page, setPage] = useState(1);
const loading = useRef(false);
// 底部的"加载触发器"
const loadMoreRef = useRef(null);
const isVisible = useIntersectionObserver(loadMoreRef, {
threshold: 0.1,
});
// 模拟加载数据
const loadMore = useCallback(async () => {
if (loading.current) return;
loading.current = true;
// 模拟接口请求
const newData = await fetchList(page);
setList(prev => [...prev, ...newData]);
setPage(prev => prev + 1);
loading.current = false;
}, [page]);
// 监听底部元素是否出现 → 出现就加载
useEffect(() => {
if (isVisible) {
loadMore();
}
}, [isVisible, loadMore]);
return (
<div>
{/* 列表 */}
{list.map(item => (
<div key={item.id}>{item.content}</div>
))}
{/* 👇 这就是无限滚动的"触发器" */}
<div ref={loadMoreRef} style={{ height: 10 }} />
{loading.current && <p>加载中...</p>}
</div>
);
}
执行流程:
- 页面渲染,展示第一页数据
- 你往下滚动
- 快到底部时,
loadMoreRef这个 div 进入屏幕 isVisible变成trueuseEffect监听到isVisible变化- 自动调用
loadMore() - 加载新数据,拼接到列表
- 列表变长,继续滚动 → 循环
为什么这样写超级强?
- 不用监听 scroll(性能爆炸好)
- 不用算距离、不用算高度
- 浏览器原生优化,丝滑不卡顿
- 逻辑干净,不容易出 bug
- 懒加载、无限滚动 一套 Hook 通用
绝对不能踩的致命坑:
坑 1:数组变异方法 setState(push 陷阱)
- 错误 :
setState(arr.push(newItem))- 原因 :
push返回的是新长度(数字),不是新数组。 - 后果 :state 变成数字,后续
.map直接报错。 - 原则 :React 状态必须是不可变数据 (Immutable)。
- 原因 :
- 正确 :
setState([...arr, newItem])(扩展运算符生成新数组)。 - 进阶 :
setState(prev => [...prev, newItem])(函数式更新,依赖前一个状态)。
坑 2:useCallback 空依赖陷阱
- 现象 :
useCallback(() => { console.log(count) }, [])。 - 问题 :空依赖会永久捕获 首次渲染的变量值。即使
count变了,函数里拿到的永远是初始值(闭包陷阱)。 - 解法 :
- 把变量加入依赖数组
[count]。 - 或用
useRef存储最新值中转。
- 把变量加入依赖数组
坑 3:memo 与普通函数的区别
- 现象 :子组件加了
memo,但点击父组件状态还是重渲染了。 - 原因 :父组件每次渲染都会重新创建函数,导致传给子组件的
props引用变了。 - 解法 :函数必须套上
useCallback,保证引用稳定。
黄金组合公式(开发必背)
想要实现高性能子组件,必须组合使用:
-
子组件 :用
memo包裹。javascriptconst Child = memo(({ onClick }) => <button onClick={onClick}>按钮</button>); -
父组件 :函数用
useCallback缓存。javascriptconst handleClick = useCallback(() => { /* 逻辑 */ }, [依赖]); -
传参 :将缓存后的函数传给子组件。
javascript<Child onClick={handleClick} />
总结与记忆口诀
- 存值不重渲染用 useRef;
- 缓存计算值用 useMemo;
- 缓存函数用 useCallback;
- 组件门禁用 memo;
- setState 永远用新引用,拒绝 push。