普通视图
V8 引擎垃圾回收机制详解
V8 引擎垃圾回收机制详解
V8 是 Google 开发的高性能 JavaScript 和 WebAssembly 引擎,用于 Chrome 浏览器和 Node.js 等环境。JavaScript 是一种自动管理内存的语言,开发者无需手动分配和释放内存,这个过程由 V8 引擎的垃圾回收器(Garbage Collector, GC)自动完成。理解 V8 的 GC 机制有助于编写更高效、性能更好的 JavaScript 代码。
1. V8 的内存管理哲学:分代假说
V8 的垃圾回收策略基于分代假说 (Generational Hypothesis) ,这个假说包含两个核心观点:
- 大部分对象生命周期很短:很多对象在创建后很快就不再被需要。
- 存活时间长的对象,会倾向于存活更久:如果一个对象经历了几次 GC 仍然存活,那么它可能在未来很长一段时间内都会继续存活。
基于这个假说,V8 将内存堆(Heap)分为两个主要区域:
- 新生代 (Young Generation / New Space) :存放生命周期较短的对象。这个区域空间较小(通常 1-8MB),垃圾回收非常频繁且速度快。
- 老生代 (Old Generation / Old Space) :存放生命周期较长的对象,或者在新生代 GC 中存活下来的对象。这个区域空间较大,垃圾回收频率较低,但单次回收耗时较长。
除了这两个主要区域,V8 堆内存还包括:
- 大对象空间 (Large Object Space) :存放体积非常大的对象(如 TypedArray、SourceCode 等)。这些对象直接分配在老生代,且不会被移动(避免拷贝大对象的开销)。它们有自己的 GC 逻辑。
- 代码空间 (Code Space) :存放即时编译器 (JIT) 生成的可执行代码。
- Map 空间 (Map Space) :存放对象的隐藏类(Hidden Classes)或称为 Map,用于描述对象结构。
2. 新生代垃圾回收:Scavenger (副垃圾回收器)
新生代的 GC 主要采用 Scavenger 算法,这是一种基于 Cheney 算法 的实现,核心思想是空间换时间。
-
结构:新生代内存被平均分成两个相等的半空间(Semi-space):From-Space (对象当前所在空间) 和 To-Space (空闲空间,用于复制)。
-
过程:
- 触发:当 From-Space 即将被写满时,触发 Scavenge GC。
- 标记与复制:从根对象(如全局对象、调用栈上的变量等)出发,遍历 From-Space 中的对象。将所有存活的对象(可达对象)复制到 To-Space 中,并保持它们之间的引用关系。
- 对象晋升 (Promotion) :如果在 Scavenge GC 中,一个对象已经是第二次存活(即它经历过一次 Scavenge GC 并被复制到了 To-Space,现在又在新的 Scavenge GC 中存活),它将被晋升到老生代空间。此外,如果 To-Space 的使用率超过 25%(一个阈值),后续对象即使是第一次存活也会直接晋升到老生代,以防止 To-Space 过早写满。
- 清空与交换:复制完成后,From-Space 中剩下的都是不再需要的垃圾对象。直接清空 From-Space。然后,From-Space 和 To-Space 的角色互换。原来的 To-Space 变为新的 From-Space,原来的 From-Space 变为新的 To-Space,等待下一次 GC。
-
特点:
- 速度快:只复制存活对象,对于存活对象少的场景效率极高。
- "Stop-the-World" :Scavenge GC 会暂停 JavaScript 的执行,但由于新生代空间小且算法高效,暂停时间通常非常短(几毫秒)。
- 并行 Scavenging:为了进一步缩短暂停时间,V8 使用多个辅助线程并行执行复制操作。
3. 老生代垃圾回收:Major GC (主垃圾回收器)
老生代的 GC 负责回收存活时间长或体积大的对象,由于对象数量多且复杂,采用不同的策略。主要算法是 Mark-Sweep-Compact。
-
触发:当老生代空间不足,或者根据 V8 的启发式算法判断需要进行 GC 时触发。
-
过程:
-
标记 (Marking) :
-
目标:从根对象出发,找出所有可达的(存活的)对象,并进行标记。
-
算法:采用三色标记法 (Tri-color Marking) 。对象有三种状态:
- 白色 (White) :初始状态,表示尚未访问。GC 结束后,白色对象即为垃圾。
- 灰色 (Gray) :已访问,但其引用的对象尚未完全访问。灰色对象是待处理的中间状态。
- 黑色 (Black) :已访问,且其引用的所有对象都已被完全访问。黑色对象是确定存活的。
-
优化 - 减少暂停时间:
- 增量标记 (Incremental Marking) :将完整的标记过程分解成许多小步骤,穿插在 JavaScript 执行的间隙进行。这显著减少了单次 GC 暂停的总时间,但可能增加 GC 的总时长。
- 并发标记 (Concurrent Marking) :V8 的 Orinoco 项目引入了并发标记。主 JavaScript 线程执行一小段标记后,启动多个辅助线程在后台并发进行大部分标记工作,主线程可以继续执行 JavaScript。这需要写屏障 (Write Barrier) 机制来跟踪在并发标记期间 JavaScript 对对象引用关系的修改,确保标记的正确性。
-
-
清除 (Sweeping) :
- 目标:遍历整个老生代堆,清除所有未被标记(白色)的对象,回收它们占用的内存。
- 实现:将回收的内存块添加到空闲链表 (Free List) 中,方便后续内存分配。
- 优化 - 并发清除 (Concurrent Sweeping) :清除操作也可以由辅助线程在后台并发执行,不阻塞主线程。
-
整理 (Compaction) :
- 目标:解决 Mark-Sweep 后产生的内存碎片问题。内存碎片会导致即使总可用空间足够,也可能无法分配较大的连续内存块。
- 过程:将所有存活的对象(黑色对象)向内存空间的一端移动,消除对象之间的空隙,使内存变得连续。
- 实现:这是一个成本较高的操作,因为它需要移动大量对象并更新指向它们的指针。V8 通常不会每次 Major GC 都进行整理,而是根据碎片化程度决定。
- 优化 - 并发/并行整理:V8 也在探索和实现并发/并行整理技术,以减少整理阶段的暂停时间。
-
-
特点:
- 耗时较长:处理的对象多,算法复杂。
- "Stop-the-World" :虽然有增量和并发优化,但某些阶段(如标记的开始和结束、整理)仍然需要暂停 JavaScript 执行。不过,优化目标是让这些暂停尽可能短。
4. 其他 GC 优化技术
- 空闲时间 GC (Idle-Time GC) :V8 利用浏览器的空闲时间(如用户无操作时)执行一些 GC 工作(主要是增量标记),进一步减少对主线程的影响。
-
写屏障 (Write Barrier) :在并发标记或增量标记期间,如果 JavaScript 修改了对象的引用关系(例如
obj.field = anotherObj;
),写屏障会记录这个修改,通知 GC 可能需要重新扫描相关对象,保证标记的准确性。
总结
V8 的垃圾回收机制是一个复杂但高效的系统,通过分代回收、多种算法(Scavenger、Mark-Sweep-Compact)以及各种优化(增量、并发、并行、空闲时间 GC),努力在内存回收效率和 JavaScript 执行流畅度之间取得平衡。理解这些机制有助于开发者编写出内存使用更合理、性能更优的代码。
十万字总结所有React hooks(含简单原理)
React Hooks 是什么?为什么需要 Hooks?
React Hooks 是 React 16.8 版本引入的一项重大特性。它们允许你在不编写 class 的情况下使用 state 以及 React 的其他特性(如生命周期、context 等)。
在 Hooks 出现之前,如果你想在组件中使用 state 或者生命周期方法,你必须将函数组件重构成 class 组件。这带来了一些问题:
-
this
指向问题: 在 class 组件中,你需要经常处理this
的绑定,容易出错。 - 难以复用状态逻辑: 传统的复用状态逻辑的方式(如 Render Props 和 高阶组件 HOC)通常会导致组件层级嵌套过深(Wrapper Hell),使得代码难以理解和维护。
-
复杂组件难以理解: 生命周期方法(如
componentDidMount
,componentDidUpdate
)常常包含不相关的逻辑(比如在componentDidMount
中同时进行事件监听和数据获取),而相关的逻辑(比如事件监听的添加和移除)却分散在不同的生命周期方法中,使得组件逻辑混乱。
Hooks 的出现旨在解决这些问题,让函数组件也能拥有 class 组件的能力,同时保持代码的简洁和逻辑的清晰。
Hooks 的核心原理 (简化理解)
React 在内部维护着每个组件实例对应的 Fiber 节点。当你首次渲染一个使用了 Hooks 的组件时,React 会按照 Hooks 的调用顺序,在 Fiber 节点上创建一个链表来存储这些 Hooks 的信息(包括 state、effect 等)。
当组件进行后续渲染时,React 再次按照相同的顺序执行这些 Hooks。通过这个顺序,React 能够准确地从链表中获取到对应 Hook 上一次的状态或信息。这就是为什么必须在组件的顶层调用 Hooks,并且不能在循环、条件或嵌套函数中调用 Hooks(即Hooks 的规则),因为这会打乱调用顺序,导致 React 无法正确匹配 Hooks。
1. useState
用途: 在函数组件中添加和管理 state。
语法:
const [state, setState] = useState(initialState);
-
initialState
: 状态的初始值。可以是任意类型的值,也可以是一个函数(该函数只会在初始渲染时执行一次,用于计算初始值,适合开销较大的计算)。 -
state
: 当前的状态值。 -
setState
: 更新状态的函数。你可以直接传入新的状态值,或者传入一个接收前一个状态并返回新状态的函数(推荐用于基于前一个状态计算新状态的场景,可以避免闭包陷阱)。
原理:
-
首次渲染:
- 调用
useState(initialState)
。 - React 在当前组件 Fiber 节点的 Hooks 链表中创建一个新的节点,存储
initialState
。 - 返回
[initialState, dispatchSetState]
。dispatchSetState
是一个与该 state 关联的更新函数。
- 调用
-
后续渲染:
- 再次调用
useState
。 - React 根据调用顺序找到对应的 Hook 节点。
- 返回
[currentState, dispatchSetState]
,currentState
是该 Hook 节点当前存储的值。
- 再次调用
-
状态更新 (
setState
):- 调用
setState(newState)
或setState(prevState => newState)
。 - React 会将这个更新操作加入到一个更新队列中,并计划一次新的渲染。
- 在下一次渲染时,React 会处理队列中的更新,计算出新的 state 值,并将其存储回对应的 Hook 节点。
- 如果新的 state 值与旧的 state 值相同(使用
Object.is
比较),React 会跳过这次渲染(优化)。
- 调用
代码示例:
import React, { useState } from 'react';
// 示例 1: 简单的计数器
function Counter() {
// 首次渲染时,count 初始化为 0
// rerender 时,useState 返回当前的 count 值
const [count, setCount] = useState(0);
console.log('Counter 渲染了, count:', count);
const handleIncrement = () => {
// 直接传递新值
// setCount(count + 1);
// setCount(count + 1); // 多次调用,在同一事件循环中会被合并,只会+1
// 使用函数式更新:保证基于最新的状态进行更新
setCount(prevCount => prevCount + 1);
// setCount(prevCount => prevCount + 1); // 多次调用,会依次执行,最终+2
console.log('Increment clicked');
};
const handleDecrement = () => {
setCount(prevCount => prevCount - 1);
console.log('Decrement clicked');
};
// 示例:惰性初始 state (计算开销大时使用)
const [expensiveValue, setExpensiveValue] = useState(() => {
console.log('计算昂贵的初始值...');
// 假设这里有一个复杂的计算
let sum = 0;
for (let i = 0; i < 1000; i++) {
sum += i;
}
return sum; // 这个函数只会在首次渲染时执行一次
});
return (
<div>
<h2>计数器</h2>
<p>当前计数值: {count}</p>
<button onClick={handleIncrement}>增加</button>
<button onClick={handleDecrement}>减少</button>
<p>昂贵的初始值: {expensiveValue}</p>
<button onClick={() => setExpensiveValue(v => v + 1)}>改变昂贵值</button>
</div>
);
}
// 示例 2: 管理对象状态
function UserProfileForm() {
const [profile, setProfile] = useState({ name: '', email: '' });
console.log('UserProfileForm 渲染了, profile:', profile);
const handleInputChange = (event) => {
const { name, value } = event.target;
// 更新对象状态时,需要确保合并之前的状态
setProfile(prevProfile => ({
...prevProfile, // 展开之前的状态
[name]: value // 更新变化的字段
}));
console.log(`Input changed: ${name} = ${value}`);
};
const handleSubmit = (event) => {
event.preventDefault();
console.log('提交 Profile:', profile);
alert(`提交成功: Name: ${profile.name}, Email: ${profile.email}`);
};
return (
<form onSubmit={handleSubmit}>
<h2>用户资料</h2>
<div>
<label>
姓名:
<input
type="text"
name="name"
value={profile.name}
onChange={handleInputChange}
/>
</label>
</div>
<div>
<label>
邮箱:
<input
type="email"
name="email"
value={profile.email}
onChange={handleInputChange}
/>
</label>
</div>
<button type="submit">提交</button>
</form>
);
}
function App() {
return (
<div>
<Counter />
<hr />
<UserProfileForm />
</div>
);
}
export default App;
// --- 代码行数统计:
// Counter: ~35 行
// UserProfileForm: ~35 行
// App: ~10 行
// 总计: ~80 行 (仅示例部分)
2. useEffect
用途: 处理副作用(Side Effects),如数据获取、设置订阅、手动更改 DOM 等。它类似于 class 组件中的 componentDidMount
, componentDidUpdate
, 和 componentWillUnmount
的组合。
语法:
useEffect(() => {
// 副作用逻辑代码
// ...
// 可选的清理函数
return () => {
// 清理逻辑,在组件卸载或下一次 effect 执行之前运行
// ...
};
}, [dependency1, dependency2, ...]); // 依赖项数组
- 第一个参数(effect 函数): 包含副作用逻辑的函数。
-
第二个参数(依赖项数组
deps
,可选):-
不提供
deps
: effect 函数在每次组件渲染完成后都会执行。 -
提供空数组
[]
: effect 函数只在组件首次渲染后执行一次(类似于componentDidMount
)。清理函数只在组件卸载时执行一次(类似于componentWillUnmount
)。 -
提供包含依赖项的数组
[dep1, dep2]
: effect 函数在首次渲染后执行,并且在任何一个依赖项发生变化后的渲染完成后再次执行(类似于componentDidUpdate
中对特定 props 或 state 的检查)。清理函数会在组件卸载前或下一次 effect 执行前运行。
-
不提供
原理:
-
调度: 在组件渲染完成后,React 不会立即执行
useEffect
中的函数。它会将 effect 函数推迟到浏览器完成绘制之后执行,这样可以避免阻塞浏览器渲染。 -
执行与清理:
- React 记录下传入的 effect 函数和依赖项数组。
- 在浏览器绘制完成后,React 检查依赖项数组。
- 首次渲染: 执行 effect 函数。如果 effect 返回了一个清理函数,React 会存储它。
-
后续渲染: React 会比较本次渲染的依赖项数组和上一次渲染的依赖项数组中的每一项(使用
Object.is
比较)。- 如果依赖项没有变化,跳过 effect 的执行。
- 如果依赖项有变化,React 会先执行上一次 effect 返回的清理函数(如果存在),然后再执行本次的 effect 函数,并存储新的清理函数(如果本次 effect 返回了的话)。
- 组件卸载: React 会执行最后一次 effect 返回的清理函数。
-
与
useState
的关系:useEffect
常常依赖于useState
管理的状态。当setState
导致状态变化并触发重新渲染后,useEffect
会根据其依赖项决定是否重新执行副作用。
代码示例:
import React, { useState, useEffect } from 'react';
// 示例 1: 组件挂载时获取数据
function FetchDataComponent({ userId }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
console.log(`WorkspaceDataComponent 渲染了, userId: ${userId}, loading: ${loading}`);
// useEffect 用于处理副作用:数据获取
useEffect(() => {
console.log(`Effect: 开始获取 userId=${userId} 的数据`);
setLoading(true); // 开始加载
setError(null); // 重置错误状态
// 定义一个异步函数来获取数据
const fetchData = async () => {
try {
// 模拟 API 请求
const response = await new Promise(resolve => setTimeout(() => resolve({
ok: true,
json: async () => ({ id: userId, name: `User ${userId}`, timestamp: Date.now() })
}), 1000));
if (!response.ok) {
throw new Error('网络响应错误');
}
const result = await response.json();
console.log(`Effect: 数据获取成功 for userId=${userId}`, result);
setData(result);
} catch (err) {
console.error(`Effect: 数据获取失败 for userId=${userId}`, err);
setError(err.message);
} finally {
setLoading(false); // 加载结束
console.log(`Effect: 加载状态结束 for userId=${userId}`);
}
};
fetchData(); // 执行数据获取
// 清理函数 (可选)
// 在这个例子中,如果 userId 变化非常快,我们可能想取消之前的请求
// 这里简化处理,没有添加请求取消逻辑
return () => {
console.log(`Cleanup: Effect for userId=${userId} 即将重新运行或组件卸载`);
// 可以在这里执行清理操作,例如取消正在进行的 fetch 请求
// controller.abort(); // 如果使用了 AbortController
};
}, [userId]); // 依赖项数组:只有 userId 变化时,effect 才重新执行
if (loading) {
return <p>正在加载用户 {userId} 的数据...</p>;
}
if (error) {
return <p>加载数据出错: {error}</p>;
}
return (
<div>
<h2>用户数据 (ID: {data?.id})</h2>
<p>姓名: {data?.name}</p>
<p>获取时间戳: {data?.timestamp}</p>
</div>
);
}
// 示例 2: 监听窗口大小变化
function WindowSizeReporter() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
console.log('WindowSizeReporter 渲染了, size:', windowSize);
useEffect(() => {
// 定义处理窗口大小变化的函数
const handleResize = () => {
console.log('窗口大小发生变化');
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
console.log('Effect: 添加 resize 事件监听器');
// 添加事件监听器
window.addEventListener('resize', handleResize);
// 清理函数:在组件卸载时移除事件监听器
return () => {
console.log('Cleanup: 移除 resize 事件监听器');
window.removeEventListener('resize', handleResize);
};
}, []); // 空依赖数组,表示这个 effect 只在挂载和卸载时运行
return (
<div>
<h2>窗口大小</h2>
<p>宽度: {windowSize.width}px</p>
<p>高度: {windowSize.height}px</p>
</div>
);
}
// 示例 3: 依赖项变化触发的 Effect
function TimerComponent() {
const [seconds, setSeconds] = useState(0);
const [isActive, setIsActive] = useState(true);
console.log(`TimerComponent 渲染了, seconds: ${seconds}, isActive: ${isActive}`);
useEffect(() => {
console.log(`Effect: 当前 isActive=${isActive}`);
let intervalId = null;
if (isActive) {
console.log('Effect: 启动定时器');
intervalId = setInterval(() => {
// 注意:这里使用函数式更新,避免依赖 seconds
setSeconds(s => s + 1);
}, 1000);
} else {
console.log('Effect: 定时器未激活或已暂停');
}
// 清理函数
return () => {
if (intervalId) {
console.log(`Cleanup: 清除定时器 (intervalId: ${intervalId})`);
clearInterval(intervalId);
} else {
console.log('Cleanup: 无需清除定时器');
}
};
}, [isActive]); // 依赖于 isActive 状态
return (
<div>
<h2>定时器</h2>
<p>秒数: {seconds}</p>
<button onClick={() => setIsActive(!isActive)}>
{isActive ? '暂停' : '启动'}
</button>
<button onClick={() => setSeconds(0)}>
重置
</button>
</div>
);
}
function App() {
const [currentUserId, setCurrentUserId] = useState(1);
const [showTimer, setShowTimer] = useState(true);
return (
<div>
<h1>useEffect 示例</h1>
<button onClick={() => setCurrentUserId(id => id + 1)}>
加载下一个用户 (ID: {currentUserId + 1})
</button>
<FetchDataComponent userId={currentUserId} />
<hr />
<WindowSizeReporter />
<hr />
<button onClick={() => setShowTimer(s => !s)}>
{showTimer ? '隐藏定时器' : '显示定时器'}
</button>
{showTimer && <TimerComponent />}
</div>
);
}
export default App;
// --- 代码行数统计:
// FetchDataComponent: ~50 行
// WindowSizeReporter: ~30 行
// TimerComponent: ~40 行
// App: ~20 行
// 总计: ~140 行 (仅示例部分) + 上一部分 ~80 行 = ~220 行
3. useContext
用途: 订阅 React Context,获取当前 Context 的值。这允许你在组件树中深层传递数据,而无需手动一层层地传递 props。
语法:
const value = useContext(MyContext);
-
MyContext
: 由React.createContext()
创建的 Context 对象。 -
value
: 组件从上层最近的<MyContext.Provider>
获取到的value
prop 的值。如果上层没有对应的 Provider,则返回createContext
时指定的默认值。
原理:
-
创建 Context: 使用
React.createContext(defaultValue)
创建一个 Context 对象。它包含 Provider 和 Consumer 两个组件(虽然useContext
让我们通常不需要直接使用 Consumer)。 -
提供 Context 值: 在组件树的上层使用
<MyContext.Provider value={...}>
包裹子组件,通过value
prop 提供数据。 -
订阅 Context: 在需要消费数据的子组件中调用
useContext(MyContext)
。 -
查找与订阅: React 会沿着组件树向上查找最近的
<MyContext.Provider>
,并读取其value
。该组件会订阅这个 Context。 -
更新: 当 Provider 的
value
prop 发生变化时,所有订阅了该 Context 的组件(即调用了useContext(MyContext)
的组件)都会自动重新渲染,并获取到新的 Context 值。
代码示例:
import React, { useState, useContext, createContext } from 'react';
// 1. 创建 Context 对象
const ThemeContext = createContext('light'); // 默认值 'light'
const UserContext = createContext({ name: 'Guest', loggedIn: false });
console.log('Context 对象已创建');
// 2. 创建提供 Context 的组件
function AppProvider({ children }) {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState({ name: 'Alice', loggedIn: true });
console.log('AppProvider 渲染了, theme:', theme, 'user:', user.name);
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
console.log('Theme toggled');
};
const logout = () => {
setUser({ name: 'Guest', loggedIn: false });
console.log('User logged out');
}
const login = () => {
setUser({ name: 'Alice', loggedIn: true });
console.log('User logged in');
}
// 使用 Provider 包裹子组件,并通过 value 传递数据和方法
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<UserContext.Provider value={{ user, login, logout }}>
{children}
</UserContext.Provider>
</ThemeContext.Provider>
);
}
// 3. 创建消费 Context 的组件
function Toolbar() {
// 使用 useContext 订阅 Context
const { theme, toggleTheme } = useContext(ThemeContext);
const { user, logout, login } = useContext(UserContext);
console.log('Toolbar 渲染了, theme:', theme, 'user:', user.name);
const styles = {
background: theme === 'light' ? '#eee' : '#333',
color: theme === 'light' ? '#000' : '#fff',
padding: '10px',
border: '1px solid #ccc',
marginBottom: '10px',
};
return (
<div style={styles}>
<h2>工具栏</h2>
<p>当前主题: {theme}</p>
<button onClick={toggleTheme}>切换主题</button>
<hr/>
<p>用户信息: {user.name} ({user.loggedIn ? '已登录' : '未登录'})</p>
{user.loggedIn ? (
<button onClick={logout}>登出</button>
) : (
<button onClick={login}>登录</button>
)}
</div>
);
}
function Content() {
const { theme } = useContext(ThemeContext); // 只订阅 ThemeContext
const { user } = useContext(UserContext); // 只订阅 UserContext
console.log('Content 渲染了, theme:', theme, 'user:', user.name);
const styles = {
padding: '10px',
border: `1px solid ${theme === 'light' ? '#ccc' : '#666'}`,
background: theme === 'light' ? '#fff' : '#555',
color: theme === 'light' ? '#000' : '#fff',
};
return (
<div style={styles}>
<h2>内容区域</h2>
<p>这里是应用的主要内容。</p>
<p>当前登录用户: {user.name}</p>
</div>
);
}
// 根组件
function App() {
console.log('App 渲染了');
return (
// 使用 Provider 包裹整个应用或需要共享状态的部分
<AppProvider>
<h1>useContext 示例</h1>
<Toolbar />
<Content />
</AppProvider>
);
}
export default App;
// --- 代码行数统计:
// Context 创建: ~5 行
// AppProvider: ~30 行
// Toolbar: ~35 行
// Content: ~25 行
// App: ~10 行
// 总计: ~105 行 (仅示例部分) + 上一部分 ~220 行 = ~325 行
4. useReducer
用途: useState
的替代方案,用于管理更复杂的 state 逻辑。特别适合 state 之间有关联或者下一个 state 依赖于前一个 state 的场景。
语法:
const [state, dispatch] = useReducer(reducer, initialArg, init);
-
reducer
: 一个形如(state, action) => newState
的纯函数,接收当前 state 和一个 action 对象,返回新的 state。 -
initialArg
: 初始状态值。 -
init
(可选): 一个用于计算初始状态的函数。如果提供,初始状态将被设置为init(initialArg)
。这允许将计算初始状态的逻辑提取到 reducer 外部。 -
state
: 当前的状态值。 -
dispatch
: 一个函数,用于触发 action。调用dispatch(action)
时,React 会调用reducer(currentState, action)
来计算新状态,并触发组件重新渲染。
原理:
-
首次渲染:
- 调用
useReducer(reducer, initialArg, init?)
。 - React 计算初始状态(如果提供了
init
函数,则调用init(initialArg)
,否则使用initialArg
)。 - 在 Fiber 节点的 Hooks 链表中创建节点,存储初始状态和 reducer 函数。
- 返回
[initialState, dispatch]
。dispatch
函数负责接收 action 并将其传递给 reducer。
- 调用
-
后续渲染:
- 再次调用
useReducer
。 - React 根据调用顺序找到对应的 Hook 节点。
- 返回
[currentState, dispatch]
。
- 再次调用
-
状态更新 (
dispatch
):- 调用
dispatch(action)
。 - React 将 action 和当前的 state 传递给
reducer
函数:newState = reducer(currentState, action)
。 - React 将计算出的
newState
存储回 Hook 节点,并计划一次重新渲染。 - 同样地,如果
newState
与currentState
相同,React 会跳过渲染。
- 调用
优势 vs useState
:
-
逻辑集中: 将状态更新逻辑(如何根据不同操作改变状态)集中在
reducer
函数中,使得组件本身更简洁。 -
可测试性:
reducer
是纯函数,易于单独测试。 -
复杂状态管理: 对于包含多个子值的 state 对象或 state 转换逻辑复杂的场景,
useReducer
通常更清晰。 -
优化:
dispatch
函数的标识是稳定的,不会在每次渲染时改变。这意味着可以将dispatch
作为依赖项传递给子组件或useEffect
等,而不会导致不必要的重新渲染或 effect 执行(相比于直接传递setState
函数,如果setState
是通过内联函数创建的,则每次渲染都会是新的函数实例)。
代码示例:
import React, { useReducer, useState } from 'react';
// 示例 1: 使用 useReducer 实现计数器 (对比 useState)
// Reducer 函数
const counterReducer = (state, action) => {
console.log(`Reducer 接收到 Action: ${action.type}, 当前 State:`, state);
switch (action.type) {
case 'increment':
// 可以包含更复杂的逻辑,比如增加 action.payload || 1
return { count: state.count + (action.payload || 1) };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: action.payload }; // 使用 payload 设置初始值
default:
console.warn('未知的 Action 类型:', action.type);
// throw new Error('未知的 action type'); // 或者抛出错误
return state; // 对于未知的 action,返回原 state
}
};
// 惰性初始化函数 (可选)
const initCounter = (initialCount) => {
console.log('执行惰性初始化函数 initCounter, initialCount:', initialCount);
// 可以进行一些计算
return { count: initialCount >= 0 ? initialCount : 0 };
};
function CounterWithReducer({ initialCount = 0 }) {
// 使用 useReducer
// const [state, dispatch] = useReducer(counterReducer, { count: initialCount }); // 普通初始化
const [state, dispatch] = useReducer(counterReducer, initialCount, initCounter); // 惰性初始化
console.log('CounterWithReducer 渲染了, state:', state);
const handleIncrement = () => {
// dispatch 一个 action 对象
dispatch({ type: 'increment', payload: 2 }); // 示例:一次增加 2
console.log('Dispatching increment');
};
const handleDecrement = () => {
dispatch({ type: 'decrement' });
console.log('Dispatching decrement');
};
const handleReset = () => {
dispatch({ type: 'reset', payload: initialCount }); // 重置回初始值
console.log('Dispatching reset');
};
return (
<div>
<h2>计数器 (useReducer)</h2>
<p>当前计数值: {state.count}</p>
<button onClick={handleIncrement}>增加 2</button>
<button onClick={handleDecrement}>减少 1</button>
<button onClick={handleReset}>重置</button>
</div>
);
}
// 示例 2: 管理复杂的表单状态
const formReducer = (state, action) => {
console.log(`Form Reducer Action: ${action.type}`, action.payload);
switch(action.type) {
case 'UPDATE_FIELD':
return {
...state,
values: {
...state.values,
[action.payload.field]: action.payload.value
},
errors: { // 简单示例:清空对应字段错误
...state.errors,
[action.payload.field]: null
}
};
case 'SET_ERRORS':
return {
...state,
errors: action.payload, // 假设 payload 是 { field1: 'error msg', ... }
isSubmitting: false // 出错时停止提交状态
};
case 'SUBMIT_START':
return {
...state,
isSubmitting: true,
submitError: null
};
case 'SUBMIT_SUCCESS':
return {
...state,
isSubmitting: false,
submitError: null,
// 可以选择重置表单
// values: { name: '', email: '' }
};
case 'SUBMIT_FAILURE':
return {
...state,
isSubmitting: false,
submitError: action.payload // 假设 payload 是错误信息字符串
};
default:
return state;
}
};
const initialFormState = {
values: { name: '', email: '' },
errors: { name: null, email: null },
isSubmitting: false,
submitError: null
};
function ComplexForm() {
const [state, dispatch] = useReducer(formReducer, initialFormState);
console.log('ComplexForm 渲染了, state:', state);
const handleInputChange = (event) => {
const { name, value } = event.target;
dispatch({
type: 'UPDATE_FIELD',
payload: { field: name, value: value }
});
console.log(`Input changed: ${name} = ${value}`);
};
const handleSubmit = async (event) => {
event.preventDefault();
console.log('Form submit triggered');
dispatch({ type: 'SUBMIT_START' });
// 模拟异步提交和验证
await new Promise(resolve => setTimeout(resolve, 1000));
// 简单验证
const errors = {};
if (!state.values.name) errors.name = '姓名不能为空';
if (!state.values.email || !/\S+@\S+\.\S+/.test(state.values.email)) {
errors.email = '邮箱格式不正确';
}
if (Object.keys(errors).length > 0) {
console.log('表单验证失败:', errors);
dispatch({ type: 'SET_ERRORS', payload: errors });
} else {
// 模拟提交成功或失败
const shouldSucceed = Math.random() > 0.3; // 70% 成功率
if (shouldSucceed) {
console.log('模拟提交成功:', state.values);
dispatch({ type: 'SUBMIT_SUCCESS' });
alert('提交成功!');
// 可以在成功后重置表单,或导航到其他页面等
} else {
const errorMsg = '服务器错误,请稍后重试';
console.log('模拟提交失败:', errorMsg);
dispatch({ type: 'SUBMIT_FAILURE', payload: errorMsg });
}
}
};
return (
<form onSubmit={handleSubmit}>
<h2>复杂表单 (useReducer)</h2>
<div>
<label>
姓名:
<input
type="text"
name="name"
value={state.values.name}
onChange={handleInputChange}
disabled={state.isSubmitting}
/>
</label>
{state.errors.name && <p style={{color: 'red'}}>{state.errors.name}</p>}
</div>
<div>
<label>
邮箱:
<input
type="email"
name="email"
value={state.values.email}
onChange={handleInputChange}
disabled={state.isSubmitting}
/>
</label>
{state.errors.email && <p style={{color: 'red'}}>{state.errors.email}</p>}
</div>
<button type="submit" disabled={state.isSubmitting}>
{state.isSubmitting ? '提交中...' : '提交'}
</button>
{state.submitError && <p style={{color: 'red'}}>提交错误: {state.submitError}</p>}
</form>
);
}
function App() {
return (
<div>
<CounterWithReducer initialCount={5}/>
<hr />
<ComplexForm />
</div>
);
}
export default App;
// --- 代码行数统计:
// CounterWithReducer + Reducer/Init: ~50 行
// ComplexForm + Reducer/Init: ~100 行
// App: ~10 行
// 总计: ~160 行 (仅示例部分) + 上一部分 ~325 行 = ~485 行
5. useRef
用途:
获取一个持久的可变对象,该对象的 .current
属性被初始化为传入的参数 (initialValue
)。返回的对象在组件的整个生命周期内保持不变。useRef
主要有两个用途:
-
访问 DOM 元素或 React 组件实例: 将 ref 对象附加到 JSX 元素的
ref
属性上,就可以通过ref.current
访问底层的 DOM 节点或组件实例(对于 class 组件或使用forwardRef
的函数组件)。 -
存储可变值,且该值的变化不触发重新渲染: 类似于在 class 组件中创建实例属性。当你需要一个值在多次渲染之间保持不变,但又不想它的变化引起组件重新渲染时(例如存储上一次的 state/props 值、存储定时器 ID 等),可以使用
useRef
。
语法:
const refContainer = useRef(initialValue);
// 访问: refContainer.current
// 修改: refContainer.current = newValue; (不会触发 re-render)
原理:
-
首次渲染:
- 调用
useRef(initialValue)
。 - React 创建一个简单的 JavaScript 对象
{ current: initialValue }
。 - 在 Fiber 节点的 Hooks 链表中创建一个节点,存储这个 ref 对象。
- 返回这个 ref 对象。
- 调用
-
后续渲染:
- 再次调用
useRef
。 - React 根据调用顺序找到对应的 Hook 节点,直接返回同一个 ref 对象。
- 再次调用
-
关键点:
useRef
返回的对象本身是持久的(在多次渲染中是同一个对象引用)。修改ref.current
属性不会通知 React,因此不会触发组件的重新渲染。这与useState
不同,setState
会触发渲染。
代码示例:
import React, { useState, useEffect, useRef } from 'react';
// 示例 1: 访问 DOM 元素 (例如:聚焦输入框)
function FocusInput() {
// 1. 创建 ref 对象
const inputRef = useRef(null);
console.log('FocusInput 渲染了, inputRef.current:', inputRef.current); // 初始渲染时为 null
useEffect(() => {
// effect 在 DOM 更新后执行,此时 inputRef.current 已经指向 input 元素
console.log('Effect: inputRef.current is now:', inputRef.current);
if (inputRef.current) {
// 2. 使用 ref.current 访问 DOM API
inputRef.current.focus();
console.log('Effect: Input focused');
}
// 这个 effect 只想在挂载时运行一次
}, []); // 空依赖数组
const handleButtonClick = () => {
if (inputRef.current) {
console.log('Button clicked, focusing input again');
inputRef.current.focus(); // 再次聚焦
inputRef.current.value = "Focused by button"; // 也可以直接操作 DOM value
}
};
return (
<div>
<h2>useRef 访问 DOM</h2>
{/* 3. 将 ref 对象附加到 DOM 元素的 ref 属性 */}
<input ref={inputRef} type="text" placeholder="我会自动聚焦" />
<button onClick={handleButtonClick}>点击按钮聚焦输入框</button>
</div>
);
}
// 示例 2: 存储可变值 (例如:存储上一次的 state 值)
function PreviousValueDisplay({ value }) {
const [currentRenderValue, setCurrentRenderValue] = useState(value); // 用于触发渲染
const previousValueRef = useRef();
console.log(`PreviousValueDisplay 渲染了, 当前 value: ${value}, 上次 ref 值: ${previousValueRef.current}`);
useEffect(() => {
// 这个 effect 在每次渲染之后执行
console.log(`Effect: 更新 previousValueRef.current 从 ${previousValueRef.current} 为 ${value}`);
// 在 effect 中更新 ref,此时 value 是本次渲染的值
// 下一次渲染时,previousValueRef.current 就会是上一次渲染的 value
previousValueRef.current = value;
}, [value]); // 依赖于 value
// 获取上一次的值
const previousValue = previousValueRef.current;
return (
<div>
<h2>useRef 存储上一次的值</h2>
<p>当前传入值 (value): {value}</p>
<p>上一次传入值 (previousValueRef.current): {previousValue === undefined ? 'N/A' : previousValue}</p>
{/* 这个按钮只是为了方便触发父组件的渲染,从而改变传入的 value */}
<button onClick={() => setCurrentRenderValue(Math.random())}>触发自身重渲染(不改变props)</button>
</div>
);
}
// 示例 3: 存储定时器 ID
function RefTimer() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null); // 使用 ref 存储 interval ID
console.log('RefTimer 渲染了, seconds:', seconds, 'intervalRef.current:', intervalRef.current);
const startTimer = () => {
if (intervalRef.current === null) { // 防止重复启动
console.log('启动定时器');
intervalRef.current = setInterval(() => {
// 使用函数式更新,这样 useEffect 或回调就不需要依赖 seconds
setSeconds(s => s + 1);
}, 1000);
console.log('定时器已启动, intervalId:', intervalRef.current);
} else {
console.log('定时器已在运行中');
}
};
const stopTimer = () => {
if (intervalRef.current !== null) {
console.log('停止定时器, intervalId:', intervalRef.current);
clearInterval(intervalRef.current);
intervalRef.current = null; // 清除 ref 中的 ID
} else {
console.log('定时器已经停止');
}
};
const resetTimer = () => {
stopTimer(); // 先停止
console.log('重置定时器');
setSeconds(0); // 重置秒数
};
// 组件卸载时确保清除定时器
useEffect(() => {
// 返回的清理函数会在组件卸载时执行
return () => {
console.log('Cleanup: 组件卸载,清除定时器');
stopTimer();
};
}, []); // 空依赖数组,仅在卸载时清理
return (
<div>
<h2>useRef 管理定时器</h2>
<p>秒数: {seconds}</p>
<button onClick={startTimer}>启动</button>
<button onClick={stopTimer}>停止</button>
<button onClick={resetTimer}>重置</button>
</div>
);
}
function App() {
const [parentValue, setParentValue] = useState(10);
const [showRefTimer, setShowRefTimer] = useState(true);
return (
<div>
<FocusInput />
<hr />
<button onClick={() => setParentValue(v => v + 1)}>增加父组件的值</button>
<PreviousValueDisplay value={parentValue} />
<hr />
<button onClick={() => setShowRefTimer(s => !s)}>
{showRefTimer ? '卸载 RefTimer' : '挂载 RefTimer'}
</button>
{showRefTimer && <RefTimer />}
</div>
);
}
export default App;
// --- 代码行数统计:
// FocusInput: ~35 行
// PreviousValueDisplay: ~30 行
// RefTimer: ~50 行
// App: ~20 行
// 总计: ~135 行 (仅示例部分) + 上一部分 ~485 行 = ~620 行
6. useCallback
用途: 缓存(Memoize)函数实例。它返回一个 memoized 版本的回调函数,该回调函数仅在某个依赖项改变时才会更新。
语法:
const memoizedCallback = useCallback(
() => {
// 回调函数逻辑,可能会用到依赖项
doSomething(a, b);
},
[a, b], // 依赖项数组
);
- 第一个参数: 你想要 memoize 的函数。
-
第二个参数(依赖项数组): 数组中的值被回调函数闭包捕获。只有当数组中的某个值发生变化时,
useCallback
才会返回一个新的函数实例。如果传入空数组[]
,则返回的函数实例在组件生命周期内永远不会改变。
原理:
-
首次渲染:
- 调用
useCallback(fn, deps)
。 - React 存储传入的函数
fn
和依赖项deps
。 - 返回
fn
本身。
- 调用
-
后续渲染:
- 再次调用
useCallback(newFn, newDeps)
。 - React 比较
newDeps
和上一次存储的deps
。 -
如果依赖项没有变化: React 不使用
newFn
,而是返回上一次存储的旧的函数实例。 -
如果依赖项有变化: React 存储
newFn
和newDeps
,并返回新的函数实例newFn
。
- 再次调用
为什么需要它?性能优化!
在 JavaScript 中,函数是对象。每次组件渲染时,在组件内部定义的函数(没有被 useCallback
包裹)都会重新创建。这意味着即使函数体完全相同,它们也是不同的函数引用。
当满足以下条件时,useCallback
非常有用:
- 将回调函数作为 prop 传递给子组件。
- 该子组件使用了
React.memo
或PureComponent
或shouldComponentUpdate
进行了性能优化。
如果父组件每次渲染都传递一个新的函数实例给子组件,即使子组件被 React.memo
包裹,它也会因为接收到的 prop(回调函数)发生了变化(引用地址不同)而重新渲染,导致 React.memo
的优化失效。使用 useCallback
包装传递给子组件的回调函数,可以确保只有在依赖项真正改变时,才传递新的函数实例,从而让子组件的 React.memo
生效。
注意: 不要滥用 useCallback
。如果回调函数逻辑简单,或者传递给的子组件没有进行 React.memo
优化,使用 useCallback
可能带来的开销(存储函数和比较依赖项)会超过其收益。
代码示例:
import React, { useState, useCallback, memo } from 'react';
// 一个“昂贵”的子组件,我们希望尽可能避免它重渲染
// 使用 React.memo 进行浅比较 props
const ExpensiveChildComponent = memo(({ label, onClick }) => {
console.log(`子组件 ${label} 渲染了!`);
// 假设这里有复杂的渲染逻辑
let startTime = performance.now();
while (performance.now() - startTime < 50) {
// 模拟耗时计算阻塞主线程 50ms
}
return (
<button onClick={onClick} style={{ margin: '5px', padding: '10px' }}>
{label} (点击我)
</button>
);
});
ExpensiveChildComponent.displayName = 'ExpensiveChild'; // DevTools友好
function ParentComponent() {
const [countA, setCountA] = useState(0);
const [countB, setCountB] = useState(0);
console.log('父组件 ParentComponent 渲染了');
// --- 没有使用 useCallback 的回调 ---
const handleClickA_NoCallback = () => {
console.log('Button A (No Callback) clicked');
setCountA(c => c + 1);
};
// 这个函数在 ParentComponent 每次渲染时都是新创建的
// --- 使用 useCallback 的回调 ---
// 这个回调函数依赖于 countB (假设逻辑需要)
const handleClickB_WithCallback = useCallback(() => {
console.log('Button B (With Callback) clicked, current countB:', countB);
// 假设点击 B 需要基于 B 的当前值做一些事,然后更新 A
// 即使 B 没变,但函数引用变了,也会导致子组件重渲染(如果没有 useCallback)
setCountA(c => c + countB); // 只是一个示例依赖
// 由于这个函数没有直接依赖项(除了隐式的setCountA),
// 我们可以用空数组,使其引用永不改变
// setCountA(c => c + 1);
}, [countB]); // 依赖于 countB,只有 countB 变化时,才会返回新的函数实例
// --- 另一个使用 useCallback 的回调,无依赖项 ---
const handleClickC_WithCallbackEmptyDeps = useCallback(() => {
console.log('Button C (With Callback, Empty Deps) clicked');
setCountB(c => c + 1); // setCountB 本身是稳定的,不需要作为依赖项
}, []); // 空依赖数组,这个函数实例永远不会改变
return (
<div>
<h2>useCallback 示例</h2>
<p>Count A: {countA}</p>
<p>Count B: {countB}</p>
<button onClick={() => setCountA(c => c + 1)}>增加 A (父组件状态)</button>
<button onClick={() => setCountB(c => c + 1)}>增加 B (父组件状态)</button>
<hr />
<p>传递给 Memoized 子组件的回调:</p>
{/* 子组件 A: 接收没有 useCallback 的回调 */}
{/* 每次父组件渲染(即使是countB变化引起的),这个子组件都会重渲染,因为 handleClickA_NoCallback 是新函数 */}
<ExpensiveChildComponent label="按钮 A (No Callback)" onClick={handleClickA_NoCallback} />
{/* 子组件 B: 接收依赖 countB 的 useCallback 回调 */}
{/* 只有当 countB 变化时,父组件渲染后传递的是新函数,子组件 B 才重渲染 */}
{/* 如果是 countA 变化引起的父组件渲染,子组件 B 不会重渲染 */}
<ExpensiveChildComponent label="按钮 B (Callback Dep [countB])" onClick={handleClickB_WithCallback} />
{/* 子组件 C: 接收无依赖的 useCallback 回调 */}
{/* 这个子组件几乎永远不会因为 onClick prop 的变化而重渲染 (除非父组件卸载) */}
{/* 即使父组件因为 countA 或 countB 变化而渲染,传递给它的函数引用也是稳定的 */}
<ExpensiveChildComponent label="按钮 C (Callback Dep [])" onClick={handleClickC_WithCallbackEmptyDeps} />
</div>
);
}
function App() {
return <ParentComponent />;
}
export default App;
// --- 代码行数统计:
// ExpensiveChildComponent: ~15 行
// ParentComponent: ~50 行
// App: ~5 行
// 总计: ~70 行 (仅示例部分) + 上一部分 ~620 行 = ~690 行
运行上述示例并观察控制台输出:
- 点击 "增加 A (父组件状态)":父组件渲染,子组件 A、B、C 都重新渲染(A 因为回调是新的,B 因为依赖 countB 的回调没有变但父组件渲染了导致检查还是判定变了?不对,B 的回调函数因为依赖countB,countB没变,函数引用没变,B不该渲染。C因为回调稳定,也不该渲染。 修正思考:React.memo 是浅比较。当父组件渲染时,它会重新调用
useCallback
。useCallback
内部比较依赖项。如果依赖项没变,它返回缓存的函数实例。React.memo
比较onClick
prop 时发现引用地址没变,所以 B 和 C 不会因为onClick
而重新渲染。 A 会重新渲染,因为handleClickA_NoCallback
总是新函数。 实际情况:点击 A 按钮,父组件渲染。A 子组件因为收到新函数而渲染。B 子组件因为收到的函数引用没变而不渲染。C 子组件因为收到的函数引用没变而不渲染。 - 点击 "增加 B (父组件状态)":父组件渲染,子组件 A 重新渲染(新函数)。子组件 B 也重新渲染,因为它的依赖项
countB
变化了,useCallback
返回了新的函数实例。子组件 C 不重新渲染(函数引用稳定)。 - 点击子组件按钮:会触发对应的
onClick
回调。例如点击子组件 C 的按钮,会调用handleClickC_WithCallbackEmptyDeps
,它会调用setCountB
,触发父组件渲染,然后走上面 "增加 B" 的逻辑。
7. useMemo
用途: 缓存(Memoize)计算结果。它返回一个 memoized 值。该 Hook 在每次渲染时会先检查依赖项是否改变,如果没改变,则返回上一次计算出的值,否则会重新执行传入的函数,计算新的值并返回。
语法:
const memoizedValue = useMemo(
() => {
// 执行开销大的计算
return computeExpensiveValue(a, b);
},
[a, b], // 依赖项数组
);
- 第一个参数: 一个“创建”函数,用于执行计算并返回需要被 memoized 的值。
-
第二个参数(依赖项数组): 数组中的值被创建函数使用。只有当数组中的某个值发生变化时,
useMemo
才会在渲染期间重新调用创建函数来计算新值。如果传入空数组[]
,则创建函数只会在初始渲染时执行一次。
原理:
-
首次渲染:
- 调用
useMemo(computeFn, deps)
。 - React 执行
computeFn()
,得到结果value
。 - React 存储
value
和依赖项deps
。 - 返回
value
。
- 调用
-
后续渲染:
- 再次调用
useMemo(newComputeFn, newDeps)
。 - React 比较
newDeps
和上一次存储的deps
。 -
如果依赖项没有变化: React 不执行
newComputeFn
,直接返回上一次存储的旧的value
。 -
如果依赖项有变化: React 执行
newComputeFn()
,得到新的结果newValue
。React 存储newValue
和newDeps
,并返回newValue
。
- 再次调用
为什么需要它?性能优化!
useMemo
主要用于优化以下场景:
-
避免在每次渲染时执行开销大的计算: 如果一个计算非常耗时(例如,对一个大数组进行排序、过滤或复杂计算),并且它的输入(依赖项)不经常变化,使用
useMemo
可以缓存结果,避免在每次渲染时重复进行昂贵的计算。 -
避免子组件的不必要渲染(类似
useCallback
): 如果你将一个通过计算得到的对象或数组作为 prop 传递给一个React.memo
包裹的子组件,即使计算结果的内容没变,但每次渲染都会创建一个新的对象/数组引用,导致子组件重新渲染。使用useMemo
可以确保只有在依赖项变化导致计算结果真正需要更新时,才创建新的对象/数组引用。
useMemo
vs useCallback
:
-
useCallback(fn, deps)
等价于useMemo(() => fn, deps)
。 -
useCallback
是专门用来 memoize 函数的。 -
useMemo
是用来 memoize 任意类型的值(包括函数执行的结果,如对象、数组、数字、字符串等)。
代码示例:
import React, { useState, useMemo, useEffect } from 'react';
// 模拟一个开销大的计算函数
const expensiveCalculation = (num) => {
console.log(`执行昂贵的计算: calculating for ${num}...`);
const startTime = performance.now();
// 模拟耗时操作
let result = 0;
for (let i = 0; i < num * 1e6; i++) {
result += Math.sqrt(i) * Math.sin(i);
}
const duration = performance.now() - startTime;
console.log(`计算完成 (${num}), 耗时: ${duration.toFixed(2)}ms`);
return result;
};
function MemoExampleComponent() {
const [count, setCount] = useState(10); // 用于触发昂贵计算的依赖项
const [unrelatedState, setUnrelatedState] = useState(0); // 用于触发渲染,但不影响计算
console.log('MemoExampleComponent 渲染了');
// --- 没有 useMemo 的计算 ---
// const calculationResult_NoMemo = expensiveCalculation(count);
// 这个计算在每次组件渲染时都会执行,即使 count 没有变化 (比如 unrelatedState 变化时)
// --- 使用 useMemo 的计算 ---
const calculationResult_WithMemo = useMemo(() => {
// 这个函数只会在 count 变化时执行
return expensiveCalculation(count);
}, [count]); // 依赖项是 count
// --- 使用 useMemo 缓存对象 ---
// 假设我们有一个配置对象,它依赖于 count
const configObject = useMemo(() => {
console.log('创建新的 config 对象...');
return {
id: count,
value: Math.random(), // 为了演示每次都不同
timestamp: Date.now()
};
}, [count]); // 只有 count 变化时才创建新对象
// 模拟一个接收对象 prop 的子组件
const ChildComponent = ({ config }) => {
console.log('子组件渲染了, config.id:', config.id);
useEffect(() => {
console.log('子组件 Effect: config 对象引用发生变化或首次渲染');
}, [config]); // 依赖整个 config 对象引用
return <p>子组件收到的 Config ID: {config.id}</p>;
};
// 对比:没有 useMemo 的对象
const configObject_NoMemo = {
id: count,
value: Math.random(),
timestamp: Date.now()
};
// 每次渲染都会创建新对象,即使 count 没变
return (
<div>
<h2>useMemo 示例</h2>
<div>
<p>计数 (影响计算): {count}</p>
<button onClick={() => setCount(c => c + 1)}>增加 Count</button>
<button onClick={() => setCount(10)}>重置 Count</button>
</div>
<div>
<p>无关状态 (触发渲染): {unrelatedState}</p>
<button onClick={() => setUnrelatedState(s => s + 1)}>改变无关状态</button>
</div>
<hr />
<h3>昂贵计算结果:</h3>
{/* <p>未使用 useMemo: {calculationResult_NoMemo}</p> */}
<p>使用 useMemo: {calculationResult_WithMemo}</p>
<p>(观察控制台,只有 Count 变化时才会执行昂贵的计算)</p>
<hr />
<h3>传递对象给子组件:</h3>
<p>使用 useMemo 缓存的对象:</p>
<ChildComponent config={configObject} />
<p>(观察控制台,只有 Count 变化时,子组件的 Effect 才会提示 config 变化)</p>
{/* <p>未使用 useMemo 缓存的对象:</p>
<ChildComponent config={configObject_NoMemo} />
<p>(每次父组件渲染,子组件的 Effect 都会执行,因为 config 是新对象)</p> */}
</div>
);
}
function App() {
return <MemoExampleComponent />;
}
export default App;
// --- 代码行数统计:
// expensiveCalculation: ~15 行
// MemoExampleComponent + Child: ~70 行
// App: ~5 行
// 总计: ~90 行 (仅示例部分) + 上一部分 ~690 行 = ~780 行
运行上述示例并观察控制台:
- 点击 "增加 Count" 或 "重置 Count":父组件渲染,
expensiveCalculation
会被执行,"创建新的 config 对象..." 会打印,子组件会渲染并且其useEffect
会执行(因为configObject
是新的)。 - 点击 "改变无关状态":父组件渲染。
-
对于
calculationResult_WithMemo
:由于依赖项count
没有变,expensiveCalculation
不会被执行,useMemo
返回缓存的值。 -
对于
configObject
:由于依赖项count
没有变,"创建新的 config 对象..." 不会打印,useMemo
返回缓存的对象引用。 -
对于
ChildComponent
(接收configObject
):由于configObject
的引用没有变,子组件的useEffect
不会打印 "config 对象引用发生变化"。如果ChildComponent
被React.memo
包裹,它甚至可能不会重新渲染。 - (如果取消注释未使用
useMemo
的部分)calculationResult_NoMemo
会重新计算,configObject_NoMemo
会创建新对象,导致接收它的子组件的 Effect 总是执行。
-
对于
8. useLayoutEffect
用途: 与 useEffect
类似,用于处理副作用,但它的执行时机不同。useLayoutEffect
在所有 DOM 变更之后,浏览器进行绘制之前同步执行。
语法: 与 useEffect
完全相同。
useLayoutEffect(() => {
// 副作用逻辑
return () => {
// 清理逻辑
};
}, [dependencies]);
原理与 useEffect
的关键区别:
-
useEffect
(异步执行):- React 完成渲染(更新 DOM)。
- 浏览器绘制屏幕。
-
useEffect
中的函数执行。
- 优点:不会阻塞浏览器绘制,用户界面感觉更流畅。
- 缺点:如果在 effect 中需要读取 DOM 布局并同步更新某些状态(可能再次影响布局),用户可能会看到短暂的闪烁(先看到旧布局,然后 effect 执行后快速变成新布局)。
-
useLayoutEffect
(同步执行):- React 完成渲染(更新 DOM)。
-
useLayoutEffect
中的函数立即同步执行。React 会等待其执行完毕。 - 浏览器绘制屏幕。
- 优点:可以在浏览器绘制前读取 DOM 布局信息并同步地重新渲染。适合需要精确测量 DOM 元素尺寸、位置或需要同步执行 DOM 操作以避免视觉不一致的场景。
- 缺点:如果 effect 中的代码执行时间过长,会阻塞浏览器绘制,可能导致页面卡顿。
何时使用 useLayoutEffect
?
绝大多数情况下,你应该优先使用 useEffect
。只有当你需要在 DOM 更新后、浏览器绘制前同步地执行某些操作(通常是读取布局信息并据此更新状态或样式)时,才考虑使用 useLayoutEffect
。
一个常见的用例是测量 DOM 元素的尺寸或位置,并根据这些信息调整样式或执行其他依赖布局的操作。
代码示例:
import React, { useState, useLayoutEffect, useEffect, useRef } from 'react';
function LayoutEffectTooltip() {
const tooltipRef = useRef(null);
const buttonRef = useRef(null);
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
console.log('LayoutEffectTooltip 渲染了, showTooltip:', showTooltip);
const handleButtonClick = () => {
console.log('按钮点击,切换 Tooltip 显示状态');
setShowTooltip(s => !s);
};
// --- 使用 useLayoutEffect 读取布局 ---
useLayoutEffect(() => {
console.log('useLayoutEffect 执行');
if (showTooltip && tooltipRef.current && buttonRef.current) {
console.log('useLayoutEffect: Tooltip 可见,开始测量位置');
// 获取按钮的位置和尺寸
const buttonRect = buttonRef.current.getBoundingClientRect();
// 获取 tooltip 的尺寸 (假设它刚被渲染出来)
const tooltipRect = tooltipRef.current.getBoundingClientRect();
// 计算 tooltip 的位置 (例如,放在按钮上方)
const top = buttonRect.top - tooltipRect.height - 5; // 按钮顶部 - tooltip高度 - 间距
const left = buttonRect.left + (buttonRect.width / 2) - (tooltipRect.width / 2); // 按钮中心
console.log('useLayoutEffect: 计算出的位置', { top, left });
// 同步更新 tooltip 的位置状态
// 因为这是在 useLayoutEffect 中同步执行的,
// 所以在浏览器绘制下一帧之前,位置就已经设置好了,不会闪烁。
setTooltipPosition({ top, left });
console.log('useLayoutEffect: 位置状态已更新');
} else {
console.log('useLayoutEffect: Tooltip 不可见或 Ref 未准备好');
}
// 依赖于 showTooltip 状态
}, [showTooltip]);
// --- 对比:如果使用 useEffect ---
/*
useEffect(() => {
console.log('useEffect 执行');
if (showTooltip && tooltipRef.current && buttonRef.current) {
console.log('useEffect: Tooltip 可见,开始测量位置');
const buttonRect = buttonRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const top = buttonRect.top - tooltipRect.height - 5;
const left = buttonRect.left + (buttonRect.width / 2) - (tooltipRect.width / 2);
console.log('useEffect: 计算出的位置', { top, left });
// 更新状态会触发另一次渲染,可能在浏览器绘制后发生,导致闪烁
setTooltipPosition({ top, left });
console.log('useEffect: 位置状态已更新');
} else {
console.log('useEffect: Tooltip 不可见或 Ref 未准备好');
}
}, [showTooltip]);
*/
return (
<div>
<h2>useLayoutEffect 示例: Tooltip 定位</h2>
<button ref={buttonRef} onClick={handleButtonClick} style={{ marginTop: '50px', marginLeft: '100px' }}>
{showTooltip ? '隐藏' : '显示'} Tooltip
</button>
{showTooltip && (
<div
ref={tooltipRef}
style={{
position: 'fixed', // 使用 fixed 定位,以便 top/left 生效
top: `${tooltipPosition.top}px`,
left: `${tooltipPosition.left}px`,
background: 'black',
color: 'white',
padding: '5px 10px',
borderRadius: '4px',
whiteSpace: 'nowrap',
// 初始可能在 (0,0),useLayoutEffect 会快速将其移动到正确位置
// 如果用 useEffect,可能会先看到它在 (0,0) 一闪而过
border: '1px solid red', // 突出显示
}}
>
这是一个 Tooltip! 位置: ({tooltipPosition.top}, {tooltipPosition.left})
</div>
)}
<p style={{marginTop: '20px'}}>(观察控制台 useLayoutEffect 的执行时机,它在渲染后、绘制前同步执行)</p>
</div>
);
}
function App() {
return <LayoutEffectTooltip />;
}
export default App;
// --- 代码行数统计:
// LayoutEffectTooltip: ~70 行 (包含注释掉的 useEffect 对比)
// App: ~5 行
// 总计: ~75 行 (仅示例部分) + 上一部分 ~780 行 = ~855 行
9. useImperativeHandle
用途: 自定义暴露给父组件的 ref 实例值。通常与 forwardRef
结合使用。它允许子组件决定哪些内部的属性或方法可以通过 ref 被父组件访问。
语法:
useImperativeHandle(ref, createHandle, [dependencies]);
-
ref
: 从forwardRef
接收到的 ref 对象。 -
createHandle
: 一个函数,它返回一个对象,这个对象会作为 ref 的.current
值暴露给父组件。 -
dependencies
(可选): 依赖项数组。如果提供,只有当依赖项改变时,createHandle
函数才会重新执行,更新暴露给父组件的 ref 值。
原理:
- 父组件创建一个 ref (通常使用
useRef
) 并将其通过ref
prop 传递给子组件。 - 子组件必须使用
React.forwardRef
来接收这个ref
。 - 在子组件内部,调用
useImperativeHandle(ref, () => ({ /* 自定义暴露的内容 */ }), deps?)
。 -
useImperativeHandle
会拦截通常直接将 DOM 节点或组件实例赋给ref.current
的行为。 - 它会调用
createHandle
函数,并将该函数的返回值赋给父组件 ref 的.current
属性。
为什么需要它?
- 封装内部实现: 子组件不希望父组件直接访问其内部的 DOM 节点或所有方法,只想暴露一个有限的、稳定的接口。
- 控制 ref 句柄: 提供更清晰、更受控的组件间交互方式。
代码示例:
import React, { useRef, useImperativeHandle, forwardRef, useState } from 'react';
// 子组件: FancyInput,使用 forwardRef 接收 ref
// 并使用 useImperativeHandle 自定义暴露的方法
const FancyInput = forwardRef((props, ref) => {
const inputRef = useRef(null); // 内部 ref,用于访问真实 input DOM
const [internalValue, setInternalValue] = useState('');
console.log('FancyInput 渲染了, internalValue:', internalValue);
const handleChange = (e) => {
console.log('FancyInput handleChange:', e.target.value);
setInternalValue(e.target.value);
// 如果父组件传递了 onChange prop
if (props.onChange) {
props.onChange(e);
}
};
// 使用 useImperativeHandle 自定义暴露给父组件的 ref 内容
useImperativeHandle(ref, () => {
console.log('useImperativeHandle createHandle 执行');
// 返回一个对象,这个对象将成为父组件 ref.current 的值
return {
// 暴露一个 focus 方法
focusInput: () => {
console.log('父组件调用了 focusInput, 正在聚焦内部 input');
inputRef.current?.focus();
},
// 暴露一个清空方法
clearInput: () => {
console.log('父组件调用了 clearInput, 正在清空内部 input');
setInternalValue(''); // 更新内部状态
if (inputRef.current) {
inputRef.current.value = ''; // 直接操作 DOM value
}
},
// 暴露一个获取当前值的方法 (示例)
getValue: () => {
console.log('父组件调用了 getValue');
return internalValue;
},
// 注意:这里没有直接暴露 inputRef.current (DOM 节点)
// 可以选择性暴露 DOM 节点,但不推荐直接暴露整个节点
// getInputElement: () => inputRef.current
};
}, [internalValue]); // 依赖 internalValue,如果需要基于最新值暴露方法
console.log('FancyInput render 完成,准备返回 JSX');
return (
<div>
<label>{props.label || '输入框'}: </label>
<input
ref={inputRef} // 将内部 ref 关联到真实 input
type="text"
value={internalValue}
onChange={handleChange}
placeholder={props.placeholder}
/>
</div>
);
});
FancyInput.displayName = 'FancyInput'; // DevTools 友好
// 父组件: 使用子组件并访问其暴露的方法
function ParentComponentUsingRef() {
// 1. 创建 ref
const fancyInputRef = useRef(null);
const [inputValue, setInputValue] = useState('');
console.log('ParentComponentUsingRef 渲染了');
const handleFocusClick = () => {
console.log('父组件: 点击 Focus 按钮');
// 2. 通过 ref.current 调用子组件暴露的方法
fancyInputRef.current?.focusInput();
};
const handleClearClick = () => {
console.log('父组件: 点击 Clear 按钮');
fancyInputRef.current?.clearInput();
setInputValue(''); // 同步父组件状态(如果需要)
};
const handleGetValueClick = () => {
console.log('父组件: 点击 Get Value 按钮');
const value = fancyInputRef.current?.getValue();
alert(`从子组件获取到的值: ${value}`);
};
const handleParentChange = (e) => {
console.log('父组件: handleParentChange', e.target.value);
setInputValue(e.target.value);
}
console.log('父组件 render 完成,准备返回 JSX');
return (
<div>
<h2>useImperativeHandle 示例</h2>
{/* 3. 将 ref 传递给子组件 */}
<FancyInput
ref={fancyInputRef}
label="自定义输入框"
placeholder="输入一些文字..."
// onChange={handleParentChange} // 可以选择是否让父组件也监听 change
/>
<div style={{ marginTop: '10px' }}>
<button onClick={handleFocusClick}>聚焦子组件输入框</button>
<button onClick={handleClearClick} style={{ marginLeft: '5px' }}>清空子组件输入框</button>
<button onClick={handleGetValueClick} style={{ marginLeft: '5px' }}>获取子组件值</button>
</div>
{/* <p>父组件维护的值 (如果监听了 onChange): {inputValue}</p> */}
</div>
);
}
function App() {
return <ParentComponentUsingRef />;
}
export default App;
// --- 代码行数统计:
// FancyInput: ~50 行
// ParentComponentUsingRef: ~40 行
// App: ~5 行
// 总计: ~95 行 (仅示例部分) + 上一部分 ~855 行 = ~950 行
10. useDebugValue
用途: 在 React DevTools 中显示自定义 Hook 的标签。它主要用于帮助调试自定义 Hook。
语法:
useDebugValue(value, format?);
-
value
: 你想在 React DevTools 中显示的值。 -
format
(可选): 一个格式化函数。只有当 DevTools 被打开且正在检查该 Hook 时,这个函数才会被调用。它接收value
作为参数,并应返回一个格式化后的显示值。这有助于避免在不需要时执行潜在的昂贵格式化操作。
原理:
- 当你在自定义 Hook 中调用
useDebugValue
时,React 会将这个值与该 Hook 关联起来。 - 当你在 React DevTools 中检查使用了这个自定义 Hook 的组件时,DevTools 会显示
useDebugValue
提供的值,作为该 Hook 的标签。
注意:
- 只应该在自定义 Hook 内部调用
useDebugValue
。 - 不要过度使用,它主要用于共享库中的复杂自定义 Hook。对于简单的 Hook,其内部状态通常已经足够清晰。
代码示例:
import React, { useState, useEffect, useDebugValue } from 'react';
// 自定义 Hook: useFriendStatus
function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);
console.log(`Custom Hook useFriendStatus 运行 for friendID: ${friendID}`);
useEffect(() => {
console.log(`Effect in useFriendStatus: 模拟订阅好友 ${friendID} 状态`);
// 模拟 API 调用或订阅
const handleStatusChange = (status) => {
console.log(`Status changed for ${friendID}:`, status);
setIsOnline(status.isOnline);
};
// 假设有一个全局的 ChatAPI
// ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
// 模拟:1秒后设置在线状态
const timeoutId = setTimeout(() => handleStatusChange({ isOnline: Math.random() > 0.5 }), 1000);
console.log(`Effect in useFriendStatus: 订阅完成 for ${friendID}`);
// 清理函数
return () => {
console.log(`Cleanup in useFriendStatus: 取消订阅好友 ${friendID} 状态`);
// ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
clearTimeout(timeoutId);
};
}, [friendID]); // 依赖 friendID
// --- 使用 useDebugValue ---
// 1. 基本用法:直接显示 isOnline 的值
// useDebugValue(isOnline ? '在线' : '离线');
// 2. 使用格式化函数:仅在 DevTools 检查时才格式化
// 这样可以避免在每次 Hook 运行时都执行格式化逻辑
useDebugValue(isOnline, status => {
console.log('useDebugValue 格式化函数执行'); // 只有打开 DevTools 并检查时才打印
return status === null ? "加载中..." : (status ? "在线" : "离线");
});
// 在 React DevTools 中,这个 Hook 会显示类似 "FriendStatus: 在线" 或 "FriendStatus: 离线"
console.log(`Custom Hook useFriendStatus 返回 for ${friendID}, isOnline: ${isOnline}`);
return isOnline;
}
// 使用自定义 Hook 的组件
function FriendListItem({ friend }) {
// 使用自定义 Hook
const isOnline = useFriendStatus(friend.id);
console.log(`FriendListItem 渲染 for ${friend.name}, isOnline: ${isOnline}`);
return (
<li style={{ color: isOnline === null ? 'grey' : (isOnline ? 'green' : 'red') }}>
{friend.name} ({isOnline === null ? '加载中...' : (isOnline ? '在线' : '离线')})
{/* 在 React DevTools 中检查这个组件时,可以看到 useFriendStatus Hook 的调试值 */}
</li>
);
}
function ChatStatusList() {
const friends = [
{ id: 1, name: 'Phoebe' },
{ id: 2, name: 'Rachel' },
{ id: 3, name: 'Ross' },
];
console.log('ChatStatusList 渲染');
return (
<div>
<h2>useDebugValue 示例: 好友在线状态</h2>
<p>(请在 React DevTools 中检查 FriendListItem 组件以查看 useFriendStatus 的调试值)</p>
<ul>
{friends.map(friend => (
<FriendListItem key={friend.id} friend={friend} />
))}
</ul>
</div>
);
}
function App() {
return <ChatStatusList />;
}
export default App;
// --- 代码行数统计:
// useFriendStatus: ~35 行
// FriendListItem: ~15 行
// ChatStatusList: ~20 行
// App: ~5 行
// 总计: ~75 行 (仅示例部分) + 上一部分 ~950 行 = ~1025 行
11. useId
(React 18+)
用途: 生成在服务端和客户端之间稳定的唯一 ID。主要用于解决服务器渲染 (SSR) 和客户端 hydration 过程中 ID 不匹配的问题,尤其是在需要生成关联 ID 的无障碍属性(如 htmlFor
和 id
)时。
语法:
const id = useId();
- 返回一个不透明的、稳定的字符串 ID,例如
:r0:
,:r1:
等。
原理:
- React 内部为每个
useId
调用维护一个计数器。 - 生成的 ID 在组件的整个生命周期内是稳定的。
- 在 SSR 时,React 会生成一套 ID。在客户端 hydration 时,React 会确保使用相同的 ID 序列,从而避免 mismatch 错误。
- ID 的格式是故意不透明的(以冒号开头和结尾),不应该依赖其具体内容。
为什么需要它?
在 React 18 之前,如果在 SSR 和 CSR 中都使用类似 Math.random()
或基于组件渲染顺序的计数器来生成 ID,很容易出现服务端生成的 HTML 中的 ID 与客户端 hydration 时生成的 ID 不一致的情况,导致 hydration 失败或错误。useId
解决了这个问题。
代码示例:
import React, { useId } from 'react';
function AccessibleForm() {
// 为 email 输入框及其标签生成唯一的、稳定的 ID
const emailId = useId();
// 为 password 输入框及其标签生成唯一的、稳定的 ID
const passwordId = useId();
console.log('AccessibleForm 渲染');
console.log('Email ID:', emailId); // 例如 :r0:
console.log('Password ID:', passwordId); // 例如 :r1:
return (
<form>
<h2>useId 示例: 无障碍表单</h2>
<div>
{/* 使用生成的 ID 将 label 与 input 关联起来 */}
<label htmlFor={emailId}>邮箱:</label>
<input id={emailId} type="email" name="email" />
</div>
<div style={{ marginTop: '10px' }}>
<label htmlFor={passwordId}>密码:</label>
<input id={passwordId} type="password" name="password" />
</div>
{/* 即使组件多次渲染,emailId 和 passwordId 也保持不变 */}
{/* 在 SSR 和客户端 hydration 时也能保证一致性 */}
<p style={{fontSize: '0.8em', color: 'grey', marginTop: '10px'}}>
Generated Email ID: {emailId}<br/>
Generated Password ID: {passwordId}
</p>
<button type="submit">登录</button>
</form>
);
}
function App() {
return <AccessibleForm />;
}
export default App;
// --- 代码行数统计:
// AccessibleForm: ~30 行
// App: ~5 行
// 总计: ~35 行 (仅示例部分) + 上一部分 ~1025 行 = ~1060 行
12. useTransition
(React 18+)
用途: 允许将某些状态更新标记为“过渡”(Transition),即非紧急更新。这使得 React 可以在执行这些非紧急更新时保持用户界面的响应性,例如,在用户输入时,可以优先处理输入框的更新,而将过滤大型列表等耗时更新作为过渡来处理。
语法:
const [isPending, startTransition] = useTransition();
-
isPending
(boolean): 一个状态值,指示当前是否有过渡正在进行中。你可以用它来显示加载指示器等。 -
startTransition
(function): 一个函数,用于包裹触发非紧急状态更新的代码。
原理:
- 当你调用
startTransition(() => { setState(newValue); })
时,被包裹的setState
更新会被标记为非紧急。 - React 会优先处理紧急更新(如用户输入)。
- 在处理过渡更新时,React 可以中断渲染以响应更紧急的更新,并在稍后恢复。这利用了 React 18 的并发渲染(Concurrent Rendering)能力。
- 在过渡更新完成之前,
isPending
会是true
。
代码示例:
import React, { useState, useTransition, useEffect } from 'react';
// 模拟一个包含大量条目的列表
const generateListItems = (query) => {
console.log(`生成列表项 for query: "${query}"`);
if (!query) return [];
const items = [];
for (let i = 0; i < 10000; i++) { // 故意创建大量项以模拟耗时
if (String(i).includes(query) || `Item ${i}`.toLowerCase().includes(query.toLowerCase())) {
items.push(`结果 ${i} (包含 ${query})`);
}
}
console.log(`生成完成,找到 ${items.length} 项`);
return items;
};
function SearchFilterList() {
const [inputValue, setInputValue] = useState(''); // 输入框的值 (紧急更新)
const [filterQuery, setFilterQuery] = useState(''); // 用于过滤列表的值 (非紧急更新)
const [listItems, setListItems] = useState([]);
// 使用 useTransition
const [isPending, startTransition] = useTransition();
console.log(`SearchFilterList 渲染, inputValue: "${inputValue}", filterQuery: "${filterQuery}", isPending: ${isPending}`);
const handleInputChange = (e) => {
const newValue = e.target.value;
console.log('Input Change (紧急):', newValue);
// 1. 立即更新输入框的值 (紧急)
setInputValue(newValue);
console.log('调用 startTransition 更新 filterQuery');
// 2. 使用 startTransition 将过滤查询的更新标记为非紧急
startTransition(() => {
console.log('Transition 开始: 设置 filterQuery 为', newValue);
// 这个 setState 是非紧急的,React 可以在执行它时保持页面响应
setFilterQuery(newValue);
console.log('Transition 内: filterQuery 状态已请求更新');
// 注意:这里的 setFilterQuery 只是请求更新,
// filterQuery 的实际变化和后续的列表生成可能被推迟
});
console.log('startTransition 调用结束');
};
// 这个 Effect 依赖于 filterQuery,当 filterQuery 变化时(在 Transition 完成后),
// 它会执行,生成新的列表项
useEffect(() => {
console.log(`Effect: filterQuery 变化为 "${filterQuery}",开始生成列表`);
const items = generateListItems(filterQuery);
setListItems(items);
console.log(`Effect: 列表状态已更新,包含 ${items.length} 项`);
}, [filterQuery]);
return (
<div>
<h2>useTransition 示例: 搜索大型列表</h2>
<p>尝试快速输入数字 (例如 123) 或 "item",观察输入框响应和列表更新</p>
<input
type="text"
value={inputValue}
onChange={handleInputChange}
placeholder="输入搜索词..."
style={{ fontSize: '1.2em', padding: '5px' }}
/>
{/* 显示加载状态 */}
{isPending && <p style={{ color: 'blue' }}>正在加载列表结果...</p>}
<p style={{ fontWeight: 'bold', marginTop: '10px' }}>
显示与 "{filterQuery}" 相关的结果 ({listItems.length} 项):
</p>
{/* 渲染列表 */}
{/* 为了性能,这里只渲染前 100 项 */}
<ul style={{ maxHeight: '200px', overflowY: 'auto', border: '1px solid #ccc' }}>
{listItems.slice(0, 100).map((item, index) => (
<li key={index}>{item}</li>
))}
{listItems.length > 100 && <li>... (还有 {listItems.length - 100} 项)</li>}
</ul>
</div>
);
}
function App() {
return <SearchFilterList />;
}
export default App;
// --- 代码行数统计:
// generateListItems: ~15 行
// SearchFilterList + Effect: ~65 行
// App: ~5 行
// 总计: ~85 行 (仅示例部分) + 上一部分 ~1060 行 = ~1145 行
运行示例并快速输入: 你会发现输入框的响应非常流畅,即使在生成列表项比较耗时的情况下。列表的更新可能会稍微滞后(在 isPending
为 true
时),但输入体验不会卡顿。
13. useDeferredValue
(React 18+)
用途: 延迟更新某个值。它接收一个值,并返回该值的一个新副本。这个新副本的更新会被推迟,允许优先渲染更重要的更新。它类似于 useTransition
,但用于处理值本身,而不是状态更新函数。
语法:
const deferredValue = useDeferredValue(value, { timeoutMs });
-
value
: 你想要延迟更新的值 (通常来自 props 或 state)。 -
timeoutMs
(可选): 一个配置对象,可以指定最大延迟时间(毫秒)。React 会尝试尽快更新延迟值,但如果超过这个时间,即使主线程繁忙,也会强制更新。 -
deferredValue
: 返回的延迟值。在初始渲染时,它等于value
。在后续更新中,当value
改变时,deferredValue
会保持旧值一段时间(直到浏览器空闲或超时),然后才更新为新的value
。
原理:
- 当
value
发生变化时,React 启动一个非紧急的渲染来更新deferredValue
。 - 当前的渲染会继续使用旧的
deferredValue
。 - 当浏览器有空闲时间时,React 会用新的
value
重新渲染,此时deferredValue
才会更新。 - 这允许 UI 的一部分(使用
value
)立即更新,而另一部分(使用deferredValue
)则延迟更新,从而保持界面响应。
与 useTransition
的区别:
-
useTransition
包裹的是状态更新代码,让你控制何时启动非紧急更新。 -
useDeferredValue
包裹的是值,让你延迟响应该值的变化。当值的来源不受你控制时(例如来自父组件的 prop),useDeferredValue
很有用。
代码示例: (复用上一个示例的列表生成逻辑)
import React, { useState, useDeferredValue, useMemo } from 'react';
// 模拟一个包含大量条目的列表渲染组件 (这次直接接收 query)
const generateAndRenderList = (query) => {
console.log(`生成并渲染列表 for query: "${query}"`);
if (!query) return <p>请输入搜索词...</p>;
const items = [];
// 模拟耗时计算
const startTime = performance.now();
for (let i = 0; i < 8000; i++) {
if (String(i).includes(query) || `Item ${i}`.toLowerCase().includes(query.toLowerCase())) {
items.push(`结果 ${i} (包含 ${query})`);
}
}
const duration = performance.now() - startTime;
console.log(`列表生成完成 for "${query}", 耗时: ${duration.toFixed(2)}ms, 找到 ${items.length} 项`);
// 渲染列表 (只渲染部分)
return (
<>
<p style={{ fontWeight: 'bold', marginTop: '10px' }}>
显示与 "{query}" 相关的结果 ({items.length} 项):
</p>
<ul style={{ maxHeight: '200px', overflowY: 'auto', border: '1px solid #ccc' }}>
{items.slice(0, 100).map((item, index) => (
<li key={index}>{item}</li>
))}
{items.length > 100 && <li>... (还有 {items.length - 100} 项)</li>}
</ul>
</>
);
};
// 使用 useMemo 包装列表渲染,以便只有 query 变化时才重新生成
const MemoizedListComponent = React.memo(({ query }) => {
return generateAndRenderList(query);
});
MemoizedListComponent.displayName = "MemoizedList";
function DeferredSearchList() {
const [inputValue, setInputValue] = useState('');
console.log(`DeferredSearchList 渲染, inputValue: "${inputValue}"`);
// 使用 useDeferredValue 延迟 inputValue 的更新
// 当 inputValue 快速变化时,deferredQuery 会暂时保持旧值
const deferredQuery = useDeferredValue(inputValue, { timeoutMs: 500 }); // 最多延迟 500ms
console.log(` -> deferredQuery: "${deferredQuery}"`);
// 检查 deferred 值是否落后于原始值
const isStale = inputValue !== deferredQuery;
console.log(` -> isStale: ${isStale}`);
const handleInputChange = (e) => {
const newValue = e.target.value;
console.log('Input Change:', newValue);
setInputValue(newValue); // 立即更新输入框
// deferredQuery 会在稍后(浏览器空闲时)自动更新
};
// 列表组件使用 deferredQuery 进行渲染
// 当 deferredQuery 更新时,列表才会重新生成和渲染
// 注意:这里使用 useMemo 是为了确保 generateAndRenderList 只有在 deferredQuery 变化时才执行,
// 否则即使 deferredQuery 没变,父组件渲染也可能导致它重新执行(虽然 React 可能优化)。
// 更好的做法是将列表渲染本身做成一个 memoized 组件。
// const listComponent = useMemo(() => generateAndRenderList(deferredQuery), [deferredQuery]);
// 改为使用 MemoizedListComponent
return (
<div>
<h2>useDeferredValue 示例: 搜索列表</h2>
<p>尝试快速输入,观察输入框和下方列表的更新</p>
<input
type="text"
value={inputValue}
onChange={handleInputChange}
placeholder="输入搜索词..."
style={{
fontSize: '1.2em',
padding: '5px',
// 当列表内容过时时,给输入框一个视觉提示
opacity: isStale ? 0.7 : 1,
transition: 'opacity 0.2s'
}}
/>
{/* 如果列表内容过时,可以显示一个提示 */}
{isStale && <span style={{ marginLeft: '10px', color: 'grey' }}>正在加载新结果...</span>}
{/* 列表组件使用延迟后的查询值 */}
{/* {listComponent} */}
<MemoizedListComponent query={deferredQuery} />
</div>
);
}
function App() {
return <DeferredSearchList />;
}
export default App;
// --- 代码行数统计:
// generateAndRenderList: ~25 行
// MemoizedListComponent: ~5 行
// DeferredSearchList: ~45 行
// App: ~5 行
// 总计: ~80 行 (仅示例部分) + 上一部分 ~1145 行 = ~1225 行
运行示例并快速输入: 输入框会立即响应你的输入 (inputValue
更新)。列表 (MemoizedListComponent
使用 deferredQuery
) 的更新则会被推迟,直到你停止输入或达到 timeoutMs
。在列表更新前,你可能会看到输入框变暗(isStale
为 true 时的样式)和“正在加载”提示。
14. useSyncExternalStore
(React 18+)
用途: 让你能够安全地订阅外部数据源(如浏览器 API、第三方状态管理库等),同时兼容 React 18 的并发特性(如 useTransition
, useDeferredValue
)。它解决了在并发渲染中可能出现的"撕裂"(Tearing)问题,即 UI 显示了不同版本的数据。
语法:
const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);
-
subscribe
: 一个函数,接收一个回调函数onStoreChange
作为参数。它应该订阅外部数据源,并在数据变化时调用onStoreChange
。它还必须返回一个用于取消订阅的清理函数。(onStoreChange) => { store.subscribe(onStoreChange); // 订阅 return () => store.unsubscribe(onStoreChange); // 返回取消订阅函数 }
-
getSnapshot
: 一个函数,返回外部数据源当前状态的快照(一个不可变的值)。在组件首次渲染和每次 store 变化(由subscribe
中的回调触发)时,React 会调用它来获取最新的状态。() => store.getState()
-
getServerSnapshot
(可选): 一个函数,仅在服务器渲染 (SSR) 和客户端 hydration 时使用。它应该返回服务器上 store 的初始快照。如果提供,React 会在 hydration 时比较服务器快照和初始客户端快照,如果不匹配会发出警告。如果省略,React 会在 hydration 时强制进行一次客户端渲染(可能导致短暂的 UI 闪烁)。
原理:
-
订阅:
subscribe
函数负责连接 React 组件和外部 store。当 store 变化时,它通知 React。 -
获取快照:
getSnapshot
负责在需要时(渲染或 store 更新后)读取 store 的当前值。 -
同步保证: React 内部使用这个 Hook 来确保在并发渲染的不同阶段(例如,一个渲染被中断并稍后恢复)读取到的
getSnapshot
值是一致的。React 会在渲染前强制调用getSnapshot
来检查自上次渲染以来 store 是否发生了变化,如果变化了,会强制重新渲染,从而避免“撕裂”。
何时使用?
- 当你需要将 React 组件连接到一个不属于 React state 管理的外部可变数据源时。
- 例如:
- 连接到 Redux, Zustand, Jotai 等外部状态管理库(这些库通常会提供自己的 React 绑定,内部可能就使用了
useSyncExternalStore
)。 - 订阅浏览器的
window.matchMedia
来响应媒体查询变化。 - 订阅浏览器的
navigator.onLine
来获取网络状态。 - 连接到自定义的、基于事件的 store。
- 连接到 Redux, Zustand, Jotai 等外部状态管理库(这些库通常会提供自己的 React 绑定,内部可能就使用了
代码示例: (订阅浏览器窗口宽度)
import React, { useSyncExternalStore } from 'react';
// 1. 定义 subscribe 函数
function subscribeToWindowWidth(onStoreChange) {
console.log('Subscribing to window resize');
window.addEventListener('resize', onStoreChange);
// 返回取消订阅函数
return () => {
console.log('Unsubscribing from window resize');
window.removeEventListener('resize', onStoreChange);
};
}
// 2. 定义 getSnapshot 函数
function getWindowWidthSnapshot() {
console.log('Getting window width snapshot:', window.innerWidth);
return window.innerWidth;
}
// 自定义 Hook: 使用 useSyncExternalStore 订阅窗口宽度
function useWindowWidth() {
console.log('Custom Hook useWindowWidth executing useSyncExternalStore');
const windowWidth = useSyncExternalStore(
subscribeToWindowWidth, // 订阅逻辑
getWindowWidthSnapshot, // 获取当前快照
() => { // getServerSnapshot (简单示例,假设服务器不知道宽度)
console.log('Getting server snapshot (returning undefined)');
return undefined; // 或者返回一个默认的服务器宽度(如 1024)
}
);
console.log('Custom Hook useWindowWidth received width:', windowWidth);
return windowWidth;
}
// 使用自定义 Hook 的组件
function WindowWidthDisplay() {
const width = useWindowWidth();
console.log('WindowWidthDisplay 渲染, width:', width);
// 模拟一些可能导致并发渲染的操作(例如,使用 useTransition)
// const [isPending, startTransition] = useTransition();
// const [text, setText] = useState('');
// onChange = (e) => startTransition(() => setText(e.target.value));
return (
<div>
<h2>useSyncExternalStore 示例: 浏览器窗口宽度</h2>
<p>当前窗口宽度: <strong>{width === undefined ? 'Loading (SSR/Initial Client Render)' : `${width}px`}</strong></p>
<p>(尝试调整浏览器窗口大小,观察宽度变化)</p>
{/* <input value={text} onChange={onChange} placeholder="Type for transition"/> */}
{/* {isPending && <p>Transition pending...</p>} */}
</div>
);
}
function App() {
return <WindowWidthDisplay />;
}
export default App;
// --- 代码行数统计:
// subscribe/getSnapshot/getServerSnapshot: ~20 行
// useWindowWidth: ~15 行
// WindowWidthDisplay: ~20 行
// App: ~5 行
// 总计: ~60 行 (仅示例部分) + 上一部分 ~1225 行 = ~1285 行
运行示例并调整窗口大小: 你会看到组件能够正确响应窗口宽度的变化。useSyncExternalStore
确保了即使在 React 18 的并发模式下,组件也能安全、一致地读取到最新的窗口宽度。
总结与 Hooks 规则
我们已经详细讲解了 React 中最常用和最重要的 Hooks:
-
基础 Hooks:
useState
,useEffect
,useContext
-
附加 Hooks:
useReducer
,useCallback
,useMemo
,useRef
,useImperativeHandle
,useLayoutEffect
,useDebugValue
-
React 18 并发 Hooks:
useId
,useTransition
,useDeferredValue
,useSyncExternalStore
再次强调 Hooks 的两条规则:
- 只在顶层调用 Hook: 不要在循环、条件判断或嵌套函数中调用 Hook。这确保了每次渲染时 Hooks 的调用顺序都是相同的,React 才能正确地将状态和 Hook 关联起来。
- 只在 React 函数中调用 Hook: 只能在 React 函数组件或自定义 Hook 中调用 Hook。不要在普通的 JavaScript 函数中调用。