通过渲染快照和权衡来理解React Hooks的原理
用于React和React Native Hooks的高级思维模型:渲染快照、效应、引用、记忆化、自定义Hooks,以及如何在面试中解释各种权衡。
来源: React & React Native Hooks: The Complete Senior Interview Guide
高级 React 和 React Native 面试很少只停留在“这个 Hook 是做什么的?”这类问题。关于 useState 的提问很快就会延伸到批量处理和函数式更新;关于 useEffect 的提问则会涉及无限循环、过时闭包、取消机制以及何时根本不应使用该效应;而 useMemo 和 useCallback 则会引出“这真的能提升性能吗?”这样的问题。死记硬背的定义根本无法应对这类深入追问。真正经得起考验的是一套相互关联的模型,本指南正是构建了这样一套模型:从渲染开始,接着是状态、闭包、效应、引用、记忆化、自定义 Hook、性能优化,最后是架构设计,在涉及平台差异的部分还会加入 React Native 的示例。
所有其他内容都依赖的渲染模型
从一个想法开始并坚持下去:当 React 渲染一个组件时,它会从最顶层再次调用该组件的函数。 使用尽可能小的计数器。
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}
人们很容易认为点击按钮就能直接修改组件内的 count 变量。但实际上并非如此。setter 会请求 React 进行重新渲染,而下一次调用 Counter 时,新的值会作为独立的常量被传入。
Render 1
count = 0
↓
User clicks
↓
setCount(1)
↓
React schedules another render
↓
Render 2
count = 1
因此,每次渲染都会生成其属性和状态的快照。该快照中的内容之后不会发生变化。这一规律可以解释为何会有对旧值的闭包引用、定时器中存在的过时状态、效果依赖的存在原因、回调函数和事件处理程序的行为机制,以及为何记忆化功能依赖于标识符。那些深入探究 API 内部原理的面试官,通常是在考察你是否理解这一模型。
useState:属于渲染过程的状态
useState 为函数组件提供了一部分状态,用于决定其输出内容。
const [count, setCount] = useState(0);
返回的这对数据具有两种不同的作用:一个是当前渲染所需的值,另一个是用于请求状态更新的函数。
count
↓
current state
setCount
↓
request a state update
调用设置器函数并不会直接修改现有数据;它只是安排下一次渲染,在那次渲染中组件才会看到新的值。
问题难度的逐步提升
典型的学习进程如下:
- 基础阶段:
useState能解决什么问题? - 中级阶段:状态与普通的局部变量有何不同?
- 高级阶段:何时应该使用更新函数?
- 深入阶段:在一个处理函数中重复三次执行
setCount(count + 1)实际上会产生什么结果?
“它用于存储状态”仅能回答第一层问题。更完善的回答应涵盖整个模型:
useState用于保存会影响组件渲染结果的值。调用设置函数会将使用更新后值的新一轮渲染放入队列,因此状态最好被视为该渲染快照的一部分,而非可被修改的变量。当新值依赖于旧值时,使用更新函数是更安全的选择,因为React会批量处理更新,而此时捕获到的值可能已经过时。
相比之下,普通变量在每次渲染时都会用其初始值重新创建,修改它也不会要求 React 重新渲染,因此无法驱动 UI。
为什么函数式更新很重要
以下是针对已捕获值编写的三种递增方式。
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
这三种方式都从当前的快照中读取同一个 count 值,因此在批量处理后结果只有一次递增,而非三次。使用更新器形式可以解决这个问题:
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
React 会为每个更新器传递最新的待处理值,这样三次调用就能正确串联起来。每当有多个更新同时发生或被排队时,每当回调可能会使用过时的快照时,以及通常在新的值是从旧值计算得出时,都应采用这种形式。
在 React Native 应用中,购物车是一个典型的应用场景。由于需要显示数量,因此该数据应存储在状态中:
const [quantity, setQuantity] = useState(1);
处理函数会根据之前的值计算出下一个值,同时通过限制使数量不会小于1,这样购物车就不会显示零或负数。
const increaseQuantity = () => {
setQuantity(prev => prev + 1);
};const decreaseQuantity = () => {
setQuantity(prev => Math.max(1, prev - 1));
};
网页版与原生版没有区别
两种平台上的 Hook 实现完全相同。网页版的下拉菜单可能会记录其是否处于展开状态:
const [isOpen, setIsOpen] = useState(false);
React Native 的界面也可能以类似方式记录模态框的显示状态:
const [isModalVisible, setModalVisible] = useState(false);
状态机制是通用的,只有它所驱动的 UI 元素有所不同。
useEffect:与外部世界同步
没有哪种 Hook 能引发更多关于面试的讨论。那种“它在渲染后会执行代码”的常见描述虽然正确,但却没有说明其目的。更准确的表述是:某种效果能让你的组件与 React 无法控制的系统保持同步。
在网页环境中,这个系统可能是 DOM 事件、浏览器 API、WebSockets、订阅机制、计时器或网络请求;而在 React Native 中,则通常是 AppState、Keyboard 模块、原生模块、推送通知监听器、网络状态、设备 API、WebSockets 以及计时器。
问题如何逐步深入
- 基础级:
useEffect是用来做什么的? - 中级:详细讲解依赖数组的工作原理。
- 高级:是什么导致效果函数无限循环?
- 高级+:在哪些情况下使用效果函数是不合适的?
- 生产环境级:如何取消 WebSocket 订阅?
将“useEffect用于API调用”与以下内容进行比较:
useEffect的存在主要是为了使组件与外部系统保持同步。依赖数组列出了该效应所读取的响应式值,这些信息必须准确无误;Hooks的代码检查规则会检测到遗漏的情况。当效应开启订阅、启动计时器或添加监听器时,它会返回一个清理函数,以便在效应重新运行之前或组件卸载时释放旧资源。
理解依赖数组
存在依赖项时,效应会在首次渲染后运行,并且在每次渲染之间该值发生变化时也会再次运行。
useEffect(() => {
fetchUser(userId);
}, [userId]);
如果没有依赖数组,它会在每次渲染后都运行:
useEffect(() => {
// ...
});
如果依赖数组为空,它会在组件挂载后运行:
useEffect(() => {
// ...
}, []);
要小心“运行一次”这一说法。在开发模式下,严格模式会刻意进行挂载、清理后再重新挂载,以便发现那些未能正确清理的副作用。准确的说法是:空数组对应的副作用会在挂载之后执行,而开发模式下的严格模式可能会增加额外的设置和清理周期。那些在两次执行过程中出问题的代码通常存在真正的缺陷。
清理可防止资源泄漏
依赖userId的订阅会在效应体中建立连接:
useEffect(() => {
const subscription = subscribeToUpdates(userId);
并返回一个用于关闭该连接的函数:
return () => {
subscription.unsubscribe();
};
}, [userId]);
当userId发生变化时,React会在启动新的效应之前先执行之前的清理操作,因此始终只会有一个活跃的订阅存在:
Old effect
↓
cleanup old subscription
↓
new effect
↓
subscribe using new userId
在卸载时,会执行最后的清理操作,不会留下任何残留物:
component unmounts
↓
cleanup
↓
subscription removed
相同的机制也用于取消异步操作。对于 fetch 操作,你可以在清理阶段使用 AbortController 中止请求,或者设置 ignore 标志,这样来自旧 userId 的延迟响应就不会覆盖更新后的数据。
React Native 示例:响应 AppState
比单纯获取数据更令人信服的效果是将组件与原生应用生命周期绑定起来。这个监听器会在应用回到前台时刷新数据,并在组件卸载时自动移除。
useEffect(() => {
const subscription = AppState.addEventListener(
"change",
nextState => {
if (nextState === "active") {
refreshData();
}
}
);
return () => {
subscription.remove();
};
}, []);
这就是最直观的外部系统同步方式,它能向面试官展示你明白效应的用途。
错误:通过效应同步派生值
假设有一个表单用于存储名字和姓氏:
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
一种常见的反模式是增加第三个状态变量以及一个用于更新该状态的效应函数:
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
这会导致多余的state,并且在名称每次变化时都会触发额外的渲染,而且有一次渲染时的完整名称会是过时的。由于该值完全由现有状态决定,因此应在渲染时直接计算:
const fullName = `${firstName} ${lastName}`;
需要牢记的原则是:如果某个值可以从props或state中同步计算出来,那就应在渲染时直接生成它,而非先存储起来再通过效应函数进行更新。这一模式在停止使用useEffect同步状态一文中有更深入的探讨。
错误:不稳定的对象依赖关系
这里在组件内部创建了一个对象字面量,并将其列为依赖项。
const filters = {
status: "active",
};
useEffect(() => {
fetchUsers(filters);
}, [filters]);
每次渲染都会创建一个新的 filters 对象,且依赖关系是通过引用进行比较的,因此每次效果都会被视为“发生了变化”。如果该效果更新了状态,就会形成自我循环:
Render
↓
new filters object
↓
dependency changed
↓
effect
↓
setState
↓
render
↓
new filters object
↓
effect again
解决此问题的方法是使用记忆化机制来包裹该对象:
useMemo(...)
在这么做之前,先考虑是否真的需要在组件内部创建该对象。常量可以放在组件外部;由属性构建的值通常可以通过依赖其基本字段(例如 status)或在该效果内部重新构建来实现替代。优化数据流才是更高级的解决方案,而通过添加记忆化来掩盖问题并非如此。
useRef:不会触发渲染的内存
useRef 返回一个可在多次渲染间保持不变的可变容器,其变化永远不会触发重新渲染。
const valueRef = useRef(0);
可通过 current 属性读取和写入其内容:
valueRef.current
如下方所示,赋值新值虽会更新容器,但屏幕显示仍保持原样,直到有其他因素触发重新渲染。
valueRef.current = 10;
面试官通常会依次询问“引用与状态有何不同?”、“请给出实际例子”以及“为何在此处选择引用而非状态?”。一个出色的回答是:
状态是用于驱动渲染的数据,因此修改它会触发重新渲染。而引用则能在多次渲染之间保持可变值的有效性,即便
.current发生变化也不会立即重新渲染。引用适用于命令式处理、定时器 ID、之前的值以及界面不显示的其他可变数据。
在网页中,引用可为 DOM 节点提供命令式访问方式,例如用于聚焦输入框:
const inputRef = useRef<HTMLInputElement>(null);
inputRef.current?.focus();
在 React Native 中,refs 同样十分常见,因为移动端界面经常需要执行诸如将列表滚动回顶部之类的命令式操作:
const scrollRef = useRef<ScrollView>(null);
scrollRef.current?.scrollTo({
y: 0,
animated: true,
});
状态与 ref 的选择
决策的关键很简单:修改这个值是否需要改变用户看到的内容?如果需要,那就选择
useState
;如果不需要,则选择
useRef
当前激活的标签页显然会影响界面,因此属于状态:
const [selectedTab, setSelectedTab] = useState("home");
计时器句柄的存在只是为了日后能够清除它;没有任何内容会基于它进行渲染,因此 ref 才是合适的存储方式:
const timerRef = useRef<number | null>(null);
useMemo:缓存计算结果
useMemo 用于存储计算结果,只有当依赖项发生变化时才会重新计算。经过过滤和排序后的用户列表就是典型的例子:
const filteredUsers = useMemo(() => {
return users
.filter(user => user.active)
.sort(sortUsers);
}, [users]);
只要users保持相同的引用,后续渲染时就会跳过过滤和排序操作。需要注意一个隐藏要求:sortUsers也必须具有稳定性,或者被列为依赖项;此外sort函数是原地修改的,而此处之所以安全只是因为filter已经生成了新的数组。
人们常常会问“是否应该到处都使用它?”。对此的回答需要结合实际情况判断:
不必。useMemo并非无成本功能:它会增加依赖关系跟踪、内存占用以及代码读取量。只有当昂贵的计算被无端重复执行,或者稳定的引用对后续处理至关重要时,使用它才有意义;此外,在添加该功能之前应先进行性能分析。
对字符串拼接操作进行缓存是典型的过度使用场景:
const fullName = useMemo(
() => `${firstName} ${lastName}`,
[firstName, lastName]
);
由于这项工作很简单,直接使用表达式不仅更易阅读,运行速度也至少与之相当:
const fullName = `${firstName} ${lastName}`;
现在考虑一个基于 React Native 开发的交易应用,它需要显示数千笔交易记录,并根据股票代码和时间范围进行筛选,结果按最新顺序排列。
const visibleTrades = useMemo(() => {
return trades
.filter(trade => trade.symbol === selectedSymbol)
.filter(trade => trade.timestamp >= startTime)
.sort((a, b) => b.timestamp - a.timestamp);
}, [trades, selectedSymbol, startTime]);
当这种数据转换的耗时明显较高时,使用记忆化技术就显得十分必要。即便如此,也应通过性能分析工具来确认,而非凭猜测行事。
useCallback:保持函数的唯一性
useCallback会在组件重新渲染之间返回同一个函数对象,直到其依赖项发生变化。
const handlePress = useCallback(() => {
submitOrder();
}, [submitOrder]);
它与 useMemo 的区别仅在于缓存的内容不同:前者缓存的是数值:
useMemo
→ memoises a value
后者缓存的是函数引用:
useCallback
→ memoises a function reference
实际上,useCallback(fn, deps)的行为与useMemo(() => fn, deps)类似。“为了提升性能”这个答案对于“为何要使用它?”这个问题来说显得不够充分。更好的解释是:
当函数的依赖项保持不变时,
useCallback能确保该函数的标识稳定。在那些确实需要引用相等性的场景中,使用它是有意义的,比如传递给已缓存子组件的回调函数,或是被其他Hook列为依赖的函数。不过它不应默认添加,因为缓存机制本身会有成本,并且会让代码更难理解。
它与React.memo的关系
React.memo会在组件的所有属性都与之前的值浅比较相等时,跳过组件的重新渲染。
const UserRow = React.memo(function UserRow({
user,
onPress,
}) {
return (
// ...
);
});
如果父组件在每次渲染时都传递一个新的箭头函数,那么总会有一个属性发生变化:
<UserRow
user={user}
onPress={() => openUser(user.id)}
/>
由于引用发生了变化,浅比较失败,但缓存过的行仍会被渲染,这可能会悄悄抵消React.memo带来的好处。一个稳定的处理函数应如下所示:
const handlePress = useCallback(() => {
openUser(user.id);
}, [user.id]);
并且会用它来替代内联函数:
<UserRow
user={user}
onPress={handlePress}
/>
请记住这一点:只有在有证据表明子组件重新渲染的成本很高时才这样做,而非凭直觉行事。
闭包与过时值问题
闭包是JavaScript的特性而非Hook,但没有闭包的话理解Hook会十分困难。闭包是一种能够继续访问其创建时所在作用域中变量的函数。
function createLogger(name: string) {
return () => {
console.log(name);
};
}
即使在createLogger已经返回之后,内部函数仍然可以访问到name。
React中过时闭包的表现形式
考虑一个带有计数器的组件:
function Counter() {
const [count, setCount] = useState(0);
还有两个按钮,一个用于递增计数,另一个在一秒超时后记录当前计数值:
const handleClick = () => {
setTimeout(() => {
console.log(count);
}, 1000);
}; return (
<>
<button onClick={handleClick}>
Log Count
</button> <button onClick={() => setCount(c => c + 1)}>
Increment
</button>
</>
);
}
假设日志处理函数是在某个渲染过程中创建的
count = 0
如果你点击“记录计数”,然后在同一秒内多次递增计数,超时处理仍然会执行并输出结果
0
尽管后续的渲染已经继续进行
count = 1
count = 2
count = 3
超时回调函数会绑定创建它的那个渲染过程中的count变量,而该变量的值永远不会改变。
在面试中如何解释这一点
函数组件在渲染快照中运行:每次渲染都有各自的值和闭包。在一个渲染过程中创建的回调会捕获该次渲染的数值,因此像定时器这样的异步操作就能观察到旧状态。解决方案取决于具体需求:进行函数式更新、修正效应依赖关系,或者在没有引发重新渲染的情况下真正需要最新可变值时使用 ref。
这个回答指出了问题根源,并提出了三种解决方案及各自的适用条件,这正是高级面试官希望听到的内容。
自定义 Hook:行为封装
自定义 Hook 将前述理念结合在了一起。自定义 Hook 是一个以 use 开头的函数,它能够调用其他 Hook,从而帮助你提取带状态的行为并重复使用。配置加载器首先需要声明其状态:
function useUserProfile(userId: string) {
const [data, setData] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
接着,它在以userId为键的效应中执行获取操作,并返回三个值:
useEffect(() => {
// fetch user
}, [userId]); return {
data,
loading,
error,
};
}
某个界面在不知晓加载机制的情况下使用了这些值:
function ProfileScreen({ userId }) {
const {
data,
loading,
error,
} = useUserProfile(userId);
然后根据返回结果渲染所需的界面内容:
// UI
}
请注意,每个调用自定义钩子的组件都有其独立的状态;该钩子共享的是逻辑而非数据。
面试官真正想考察的内容
“你能编写自定义钩子吗?”这个问题实际上是在考察你是否能够识别出可复用的行为抽象。优秀的候选人应具备如下特点:
useAuth()
useUserProfile()
useNetworkStatus()
usePagination()
useDebounce()
useKeyboard()
useAppState()
usePermissions()
useInfiniteScroll()
在React Native中,网络状态钩子就是一个很好的例子。它会监听连接状态的变化,将相关标志存储在状态中,并作为清理操作返回取消订阅的函数。
function useNetworkStatus() {
const [isConnected, setIsConnected] = useState(true);
useEffect(() => {
const unsubscribe = subscribeToNetworkChanges(
state => {
setIsConnected(state.isConnected);
}
);
return unsubscribe;
}, []);
return {
isConnected,
};
}
结账界面会根据返回结果选择不同的处理路径:
function CheckoutScreen() {
const { isConnected } = useNetworkStatus();
if (!isConnected) {
return <OfflineMessage />;
}
return <Checkout />;
}
屏幕根本不知道网络订阅是如何实现的,而这种无知正是抽象层存在的价值所在。
Hook 工具箱的其他内容
高级面试往往不会只涉及那五种常见的 Hook,因此需要了解更广泛的种类。
useContext
它用于读取在组件树结构中更上层提供的值。
const theme = useContext(ThemeContext);
它适用于那些需要共享的、贯穿多个组件的功能,比如主题、身份验证、区域设置以及配置。它并非自动成为状态管理库;每当提供的值发生变化时,所有使用该值的组件都会重新渲染,这对于频繁更新数据的情况尤为重要。
useReducer
当多个状态同时发生变化时,单独的设置函数会让过渡逻辑变得分散:
setLoading(...)
setData(...)
setError(...)
Reducer 可以将这部分逻辑集中起来:
const [state, dispatch] = useReducer(reducer, initialState);
组件负责描述发生了什么,而 reducer 则决定下一个状态应该是什么:
dispatch({
type: "FETCH_SUCCESS",
payload: data,
});
将状态转换建模为命名动作,有助于更轻松地预测和测试复杂状态。
useLayoutEffect
它的结构与useEffect相同,但在React更新组件树之后、浏览器绘制之前同步执行,这正是需要在用户看到画面闪烁之前测量布局并进行调整时的理想选择。React Native中也存在类似的情况,即布局时机十分重要。但它并非更快的useEffect;由于它会阻塞绘制过程,过度使用会降低应用的响应速度。
useId
它用于生成稳定的唯一标识符,主要用于实现无障碍功能,比如将标签与输入框关联起来。它在网页环境中表现优异;而在没有浏览器DOM的React Native中,其应用场景则较为有限。
useImperativeHandle
它允许组件精确控制所绑定的引用暴露的内容,而不会泄露底层的节点。一个可重用的组件可以暴露聚焦功能:
ref.current?.focus();
或者滚动到顶部功能:
ref.current?.scrollToTop();
这种受控的命令式接口对于共享的 React Native 组件尤为实用。
useSyncExternalStore
这是从 React 之外的存储中读取数据的支持方式,即便在并发渲染的情况下也能保持状态快照的一致性。虽然较为复杂,但会在架构讨论中出现,也是各种状态管理库的实现基础。
useTransition
它将某些更新标记为非紧急任务。从概念上讲,紧急任务能确保界面保持响应:
Urgent update
↓
Keep UI responsive
而非紧急任务则可以以较低的优先级来处理:
Non-urgent update
↓
Can be scheduled with lower priority
在大型应用中,昂贵的重新渲染操作会与用户输入或点击操作产生竞争,这一点尤为重要。
useDeferredValue
该函数可以延迟值的处理,从而确保紧急操作仍能快速响应。典型场景如下:
User typing
↓
Keep input responsive
↓
Defer expensive filtering
输入内容会立即更新,而复杂的过滤操作则需要处理延迟后的查询副本。
Hooks的规则
有两个规则确保Hooks能够正常工作,因为React是通过调用顺序来识别每个Hooks的。
在顶层调用Hooks
绝不要将Hook放在条件语句、循环或提前返回语句中:
if (isLoggedIn) {
useEffect(() => {});
}
应将该条件放在Hooks内部:
useEffect(() => {
if (!isLoggedIn) {
return;
}
并列出它所依赖的值:
// logic
}, [isLoggedIn]);
仅从React函数中调用Hooks
Hooks应用于函数组件和自定义Hooks中,而不宜出现在任意的辅助函数或类方法中。
谈谈性能问题
“这个屏幕运行很慢,你会怎么做?”是个常见的问题。回答“添加useMemo和useCallback”表明了错误的思路。更好的答案是:
第一步是要找出时间实际上消耗在何处:组件多久渲染一次、哪些计算耗时较长、组件的边界在哪里、列表是如何渲染的、状态更新是如何传播的,以及涉及哪些平台相关的操作。然后对真正的瓶颈进行分析并加以解决。
React Native的性能问题不仅限于渲染
移动端屏幕的耗能分布在多个层面:
React rendering
↓
JavaScript execution
↓
Native platform work
↓
Layout
↓
Images
↓
Animations
↓
Lists
↓
Network
因此,当React Native屏幕出现卡顿时,问题根源可能并不在
useState
或者
useEffect
根本无法处理。问题可能出在繁重的 JavaScript 计算、过多的渲染操作、庞大的列表数据、图像处理、动画加载、原生操作、网络延迟,或是触发了过多组件的状态订阅上。能否准确判断该从哪些层面着手解决问题,正是区分资深移动端开发人员的关键。
大量的交易记录
想象一下某个金融应用需要渲染数千笔交易记录。简单的实现方式会在每次渲染时都重新计算整个处理流程:
const visibleTransactions = transactions
.filter(...)
.sort(...)
.map(...);
对过滤和排序操作进行缓存是合理的解决步骤之一:
const visibleTransactions = useMemo(() => {
return transactions
.filter(...)
.sort(...);
}, [transactions]);
这 rarely 是完整的解决方案。你还需要列表虚拟化、高效的项渲染、稳定的键值、已缓存的行组件、精简的状态订阅、合理的图片加载方式以及响应式交互功能。在 React Native 中,选择 FlatList 或 FlashList 并妥善配置它们,通常比使用一个 useMemo 更为重要。应该问的问题是瓶颈出在何处,而非该添加哪个 Hook。
状态所有权与架构
Context 与存储库
“何时应该使用 Context 而非 Redux 或 Zustand?”这个问题并没有标准答案。
这取决于应用状态。对于主题或配置这类相对稳定、通用的值,上下文管理方式很适用。而对于那些频繁更新、需要选择器、可预测的过渡效果或细粒度订阅功能的复杂应用状态,使用专门的状态管理库通常更为合适。
关键在于确定状态的归属以及更新频率。
并非所有内容都适合放入全局存储中
一种常见的架构错误是将所有值都集中到同一个存储中:
Everything
↓
Global store
应当先明确每部分状态的归属。本地组件状态适用于那些生命周期短暂的 UI 细节:
isModalOpen
inputValue
selectedTab
服务器状态是从 API 获取的数据,这类数据通常更适合由专门的服务器状态管理库来处理,该库能够负责缓存管理和重新获取操作:
user profile
products
orders
transactions
全局客户端状态指的是许多界面确实会共享的那小部分数值:
theme
authentication state
application preferences
在网页中,某些状态应包含在URL中,这样它们才能在页面重新加载后依然存在,并可通过链接共享:
filters
search
pagination
需要记住的规则是:状态应存在于最合适的最小作用域内。
在投资界面中的应用
想象一个需要市场数据、选定股票代码、图表数据、投资组合数据、AppState管理、滚动功能以及复杂图表处理的React Native投资界面。其简化结构如下:
InvestmentScreen
│
├── useMarketData()
│
├── usePortfolio()
│
├── useAppState()
│
├── useRef()
│
├── useMemo()
│
└── useCallback()
│
▼
UI
以下是该组件的结构示意图。有两点需要注意:代码片段以几行普通文本和分隔符结尾,这些并非有效代码,因此应将其视为伪代码;此外,useState 中的 setSymbol 已经是稳定实现的,因此这里的 useCallback 包装是可选的,可以直接传递设置函数。
function InvestmentScreen() {
const [symbol, setSymbol] = useState("AAPL");
const scrollRef = useRef<ScrollView>(null);
const { data: marketData } = useMarketData(symbol);
const { portfolio } = usePortfolio();
const chartData = useMemo(() => {
return transformMarketData(marketData);
}, [marketData]);
const handleSymbolChange = useCallback(
(nextSymbol: string) => {
setSymbol(nextSymbol);
},
[]
);
return (
<ScrollView ref={scrollRef}>
<MarketHeader
symbol={symbol}
onSymbolChange={handleSymbolChange}
/>
<PriceChart data={chartData} />
<PortfolioSummary
portfolio={portfolio}
/>
</ScrollView>
);
}
---
Now each concept has a clear responsibility.
---
现在每个 Hook 都只承担一个清晰可解释的职责:
useState
→ selected symboluseRef
→ imperative ScrollView accessuseMemo
→ expensive chart transformationuseCallback
→ stable handler where usefulCustom Hooks
→ market and portfolio behaviouruseEffect
→ internal synchronization within those Hooks
副作用处理被保留在自定义 Hook 内部,这样它们就能与市场数据流及应用生命周期保持同步,而屏幕内容本身几乎就像是对数据的描述。
以资深工程师的方式回答
超越问题本身进行思考
常见的错误只是回答被问到的内容。对于“什么是 useMemo?”这样的问题,回答“它用于缓存值”虽然正确但容易让人遗忘。更完整的回答应该是:
useMemo会根据其依赖项缓存计算结果。由于它会带来额外开销并增加复杂性,因此并非默认选项。当昂贵的计算被不必要地重复执行,或者需要一个稳定的引用时,使用它就很有意义——最好在性能分析确认存在瓶颈之后再采用。
仅用几句话,你就展现出了对API的了解、性能意识以及权衡判断能力。
五步回答结构
任何关于Hook问题的可靠模板:
1. What is it?
2. Why does it exist?
3. Real example
4. Trade-off / pitfall
5. React Native example when relevant
应用于useRef时:
- 是什么:不会触发重新渲染的持久可变值。
- 为什么:用于那些需要在多次渲染后仍然存在但并非UI组成部分的数据。
- 示例:计时器ID或命令式组件的处理标识。
- 权衡点:由于更新该值不会触发渲染,因此它不能存储UI状态。
需要准备的后续问题
关于 useState,需准备好讨论更新函数与多次调用的批量处理、为何修改普通变量不会改变界面、什么会触发重新渲染、特定状态应存储在何处,以及如何避免使用实际派生出的状态。
关于 useEffect,可能会被问到依赖数组的作用、过时的闭包问题、重复执行的情况、清理函数的功能、如何取消正在进行的异步操作、何时使用 effect 不合适,以及开发模式下严格模式导致的双重调用问题。
关于 useRef,需准备好解释为何写入 .current 不会触发渲染、在哪些情况下 ref 比状态更合适、ref 如何与 DOM 或原生视图关联,以及命令式 API 的含义。
对于 useMemo,需要明确它是否总能提升性能、与 useCallback 有何不同、引用相等性意味着什么、何时应该移除它,以及如何证明它确实起到了作用。
对于 useCallback,要了解为什么函数的标识性很重要、它如何与 React.memo 配合使用、在什么情况下它没有任何作用、它是否会拖慢性能,以及其依赖项列表中应包含哪些内容。
一个调试练习
面试官喜欢给出有缺陷的组件,这里就是一个存在多个问题的组件。
function UserList({ users }) {
const [search, setSearch] = useState("");
const filteredUsers = useMemo(() => {
return users.filter(user =>
user.name.includes(search)
);
}, [users]);
useEffect(() => {
console.log("Search:", search);
}, []);
return (
<List
data={filteredUsers}
renderItem={({ item }) => (
<UserRow
user={item}
onPress={() => selectUser(item.id)}
/>
)}
/>
);
}
useMemo 中缺少依赖项
该记忆化处理仅以用户信息作为键:
}, [users]);
但过滤操作还会读取
search
因此用户在输入时列表永远不会更新。依赖项必须同时包含这两部分内容:
}, [users, search]);
代码检查规则会立即标记这个问题。
效果被锁定在第一个值上
该效果的数组为空:
useEffect(() => {
console.log(search);
}, []);
因此它只在组件挂载后执行,并记录初始的search结果。如果目标是记录每次变化,就需要依赖它:
useEffect(() => {
console.log(search);
}, [search]);
带判断条件的内联回调
行处理函数
onPress={() => selectUser(item.id)}
在每次渲染时都是一个新的函数。这本身并非错误。首先需要确定
UserRow
该操作是否耗时,以及是否真的存在不必要的重新渲染。只有有了这些依据,才能考虑
useCallback
或对组件进行重构,例如将id和稳定的处理函数传递给行元素。展现出这种克制正是预期中的答案表现。
优秀、扎实且高水平的回答
这个进阶路径很容易记住。优秀的开发者了解useCallback,出色的开发者知道何时使用它,而资深开发者则明白何时不应使用,并能解释其中的权衡。这一规律适用于所有的Hook。目标从来不是
Maximum Hooks
而是
Correct abstraction
+
Correct data flow
+
Correct performance trade-offs
Web与原生应用共用一套概念
你无需为React和React Native分别学习Hook相关知识。在Web端,相关示例通常涉及
DOM
browser events
Web APIs
WebSockets
browser storage
而移动端的相关讨论则涉及
AppState
Keyboard
native modules
device APIs
push notifications
network state
native event subscriptions
不过两者的核心工具集都是React:
useState
useEffect
useRef
useMemo
useCallback
useContext
useReducer
Custom Hooks
closures
dependency arrays
因此应先解释React的概念,再展示如何在特定平台上应用它。这样才能在Web、移动端及混合应用领域都保持专业性。
快速参考手册
useState
指定在更新时触发重新渲染的机制。典型用途:
form values
selected tabs
modal visibility
loading state
local UI state
useEffect
与外部系统同步。典型用途:
subscriptions
timers
WebSockets
AppState
native events
network operations
对于可在渲染过程中获取的值,请避免使用它。
useRef
不会触发渲染的持久可变值。典型用途:
DOM/native refs
timers
previous values
imperative APIs
mutable non-UI data
useMemo
经过缓存的计算值。在需要时使用它
calculation is expensive
or
referential identity matters
并先进行测量。
useCallback
经过缓存的函数引用。在以下情况时值得使用:
function identity matters
React.memo is involved
another Hook depends on function identity
不要自动添加它。
useContext
读取共享的上下文数据,例如:
theme
locale
authentication
configuration
useReducer
用于模拟复杂的状态转换。在需要处理
many related state variables
complex transitions
state machine-like behaviour
自定义 Hook
可重用的带状态的行为,例如:
useAuth()
useNetworkStatus()
useAppState()
usePagination()
useKeyboard()
useUserProfile()
思维模型而非术语表
不必为每个 Hook 记住一个单词的释义,
useState = state
useEffect = side effect
useRef = reference
useMemo = memoisation
useCallback = callback
试想它们与渲染之间的关系:UI 状态通过 useState 导致组件重新渲染,派生值来自 useMemo,可变的非 UI 数据存储在 useRef 中,而 useEffect 则将渲染后的 UI 与 API、事件及原生系统相连,所有这些都被自定义 Hook 所封装。
React Render
│
┌──────────┼──────────┐
│ │ │
UI State Derived Mutable
│ Values Data
│ │ │
useState useMemo useRef
│
▼
Re-render
│
▼
UI
│
▼
External Systems
│
▼
useEffect
│
▼
APIs / Events / Native
│
▼
Custom Hooks
在这一切背后,有一些基本原理:
Rendering
+
Closures
+
Referential Equality
+
Data Flow
+
Performance
一旦掌握了这些原理,各个 Hook 就会变得容易理解了。
每次代码审查时应提出的问题
Hook 是用于在响应式应用中管理不同类型行为的工具,关键在于明白为何要使用每个 Hook。这些问题既适用于代码审查,也适用于面试:
- 这个状态真的有必要存在吗?还是可以由其它的值派生出来?
useState中的UI状态?总结
一旦不再将Hook视为独立的API,而是将其视为一条流程中的不同阶段,使用起来就会更简单:
Rendering
↓
State
↓
Closures
↓
Effects
↓
References
↓
Memoisation
↓
Custom Hooks
↓
Performance
↓
Architecture
有了这样的思路,切换到 React Web 和 React Native 就不会那么令人生畏了。当有人询问你在某个特定位置为何使用 useMemo 时,不必照本宣科地解释定义,而是讲述相关背景:你遇到的问题、问题的成因、为何选择这个 Hook、你权衡了哪些利弊,以及你是如何衡量结果的。
在面试前,请确保自己能够自然地谈论以下内容:
- 基础概念:渲染的触发原因、渲染快照是什么、状态与普通变量的区别、闭包、引用相等性以及同步机制。
useContext和useReducer,专门的Hook函数(useLayoutEffect、useId、useImperativeHandle、useSyncExternalStore、useTransition、useDeferredValue),以及如何设计自定义Hook。React.memo如何比较属性值、记忆化的成本、渲染性能分析、长列表处理、复杂计算,以及过度宽泛的存储订阅问题。目标并非记住所有答案,而是具备足够的理解力,能够在面试时分析陌生的问题。