使用 Prop Drilling 并不是安装 Redux 或 Zustand 的理由
在现有的 React 代码中测试添加状态库的四个常见原因:用于属性穿透的 Context、useState、useSyncExternalStore,以及 Context 的重新渲染开销。
询问团队为何在 React 应用中使用 Redux、Zustand 或 MobX,通常得到的第一个答案就是属性钻取问题。这确实令人烦恼,但并非添加依赖的正当理由,随后出现的那三种理由同样不成立。下文将通过一个小型可运行示例来检验这四种理由,同时展示 React 本身已提供的解决方案,以及状态管理库真正能发挥作用的场景。
四种常见的理由
这些论点通常会按固定的顺序出现:
- 通过那些不使用该值的组件传递数据十分麻烦,因此应用需要一个状态存储。
- 像深色/浅色主题切换这类会实际变化的客户端状态,肯定需要相应的库来管理。
- 要想让所有读取该状态的组件自动更新,而无需手动连接属性,就必须使用这样的库。
一旦查看具体代码,这些方法都会暴露出问题。
Prop drilling:React多年前就已解决的问题
Prop drilling指的是将值层层传递,仅仅为了让深层嵌套的组件能够读取它,而中间的所有层级都无需使用该值。四个小文件展示了其结构。App在状态中保存一个user对象,并将其传递给Dashboard。
// App.jsx
import { useState } from 'react';
import Dashboard from './components/Dashboard';
function App() {
const [user, setUser] = useState({ name: 'Akshat', avatarUrl: '/me.png' });
return <Dashboard user={user} />;
}
export default App;
Dashboard除了将user传递给Sidebar外,没有对其做任何处理。
// components/Dashboard.jsx
import Sidebar from './Sidebar';
function Dashboard({ user }) {
return <Sidebar user={user} />;
}
export default Dashboard;
Sidebar也做类似操作,只为Avatar解包两个字段。
// components/Sidebar.jsx
import Avatar from './Avatar';
function Sidebar({ user }) {
return <Avatar name={user.name} avatarUrl={user.avatarUrl} />;
}
export default Sidebar;
只有Avatar真正渲染了这些数据。
// components/Avatar.jsx
function Avatar({ name, avatarUrl }) {
return <img src={avatarUrl} alt={name} />;
}
export default Avatar;
Dashboard.jsx和Sidebar.jsx都没有在自己的逻辑中读取user数据。它们只是传递数据的中介。即便avatarUrl被重命名为photoUrl,这两个文件仍需修改,因为它们仍在传递本不应由自己管理的数据,尽管它们的功能并未改变。
上下文直接提供所需值
React内置的解决方案就是上下文。首先在专门的模块中创建一个上下文对象。
// context/UserContext.js
import { createContext } from 'react';
const UserContext = createContext(null);
export default UserContext;
随后,App会将子树包裹在上下文的Provider中,并将user作为其值传递,而非以属性的形式传递。
// App.jsx
import { useState } from 'react';
import UserContext from './context/UserContext';
import Dashboard from './components/Dashboard';
function App() {
const [user, setUser] = useState({ name: 'Akshat', avatarUrl: '/me.png' });
return (
<UserContext.Provider value={user}>
<Dashboard />
</UserContext.Provider>
);
}
export default App;
Dashboard和Sidebar仅负责布局展示,完全不再提及user数据。
// components/Dashboard.jsx
import Sidebar from './Sidebar';
function Dashboard() {
return <Sidebar />;
}
export default Dashboard;
// components/Sidebar.jsx
import Avatar from './Avatar';
function Sidebar() {
return <Avatar />;
}
export default Sidebar;
最后,Avatar组件会直接读取该值。
// components/Avatar.jsx
import { useContext } from 'react';
import UserContext from '../context/UserContext';
function Avatar() {
const user = useContext(UserContext);
return <img src={user.avatarUrl} alt={user.name} />;
}
export default Avatar;
这三个组件各自承担一项任务。createContext会创建一个与props无关的通道。Provider则能将其管理的user值同时提供给整个子树。useContext(UserContext)会从组件上方的最近匹配的Provider处读取该值,中间层都会被跳过。由于这些中间组件根本不需要user,因此它们就不再提及它了。顺便提一下,React 19还允许直接将上下文对象作为Provider来使用,但此处展示的.Provider形式依然有效。
为何“prop钻取”这一说法会长期存在
这段历史解释了为何这一争论一直存在。Redux诞生于2015年6月,由Dan Abramov和Andrew Clark创建,基于Facebook的Flux架构:一个中央存储库,对状态的变化有严格的规则限制。而React的Context API直到2018年3月的16.3版本才成为稳定且官方支持的特性,比Redux晚了近三年。在Redux初期发展阶段,使用存储库确实是让树结构中的任何地方都能访问到值的实用方法,无需将值传递给每个组件。
一旦Context变得稳定,这种情况就不再成立了,但相关教学内容却未能跟上变化。教程仍然以prop drilling作为需要解决的痛点,因为它在白板上很容易演示,而这种习惯在其存在的理由消失后依然持续了很久。
不过,prop drilling 关注的是值的传递,而非变化。接下来的问题是:当客户端上的分布式值开始发生变化时会发生什么?
状态变化正是 useState 的功能
以主题切换为例:有一个标志位,存储着 light 或 dark 的值,通过位于显示该值的文本旁边的按钮来切换。
// components/SettingsPanel.jsx
import { useState } from 'react';
function SettingsPanel() {
const [theme, setTheme] = useState('light');
return (
<div>
<p>Current theme: {theme}</p>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</div>
);
}
export default SettingsPanel;
点击按钮后会调用 setTheme,SettingsPanel 会使用新值重新渲染,段落内容也会随之更新。这里没有用到任何库,也无需使用库。掌控一个值、修改它的值并重新渲染相关组件,正是 useState 的存在意义,而每个 React 应用都在不断执行这一操作。
通常,示例会以一种带有特殊设计的形式呈现,从而完成实际的工作。难点不在于数值会发生变化,而在于该数值是在其他地方读取的。想象一下,按钮仍位于 SettingsPanel.jsx 中,而 Header.jsx 和 Sidebar.jsx 这两个与 SettingsPanel 既无导入关系也无被导入关系的独立文件,也需要显示当前主题。使用 useState 创建的状态仅属于某个组件实例,除非有共享的父组件将其传递下去,否则该组件之外的任何代码都无法读取该状态或获知其变化,而这又会引发属性传递层层嵌套的问题。
因此 useState 完全能够很好地处理状态变化。目前尚未解决的问题是,两个没有共同父组件的组件该如何在没有属性传递的情况下自动更新。
使用 useSyncExternalStore 在无关组件间共享状态
如果 Header 和 Sidebar 都无法拥有该值,那么它就必须存在于组件树之外:即放在一个在文件首次被导入时仅初始化一次的模块级变量中,而非某个组件函数内部。React 无法检测到普通变量的重新赋值,因此当该值发生变化时它也无法自动重新渲染任何内容。一个小型状态管理模块可以填补这一空白。
// store/themeStore.js
import { useSyncExternalStore } from 'react';
let state = { theme: 'light' };
const listeners = new Set();
export function setTheme(theme) {
state = { ...state, theme };
listeners.forEach((listener) => listener());
}
function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useTheme() {
return useSyncExternalStore(subscribe, () => state.theme);
}
该模块包含一个state对象以及一组监听器。setTheme函数会用新对象替换原有的state,并调用所有监听器。一个私有的注册函数会将监听器添加到该集合中,同时返回一个用于移除该监听器的清理函数。useTheme则通过专为处理组件外部状态且被多个组件读取的情况而设计的钩子useSyncExternalStore,将这一切与React连接起来。
该钩子在这里接受两个参数。第一个参数即注册函数,它告诉React如何在外部值发生变化时触发回调,并且必须返回一个对应的取消订阅函数以便进行清理。第二个参数是getSnapshot,此处为箭头函数() => state.theme,它用于在React需要时获取当前的值。
有两个消费者导入了该钩子,且彼此互不知情。
// components/Header.jsx
import { useTheme } from '../store/themeStore';
function Header() {
const theme = useTheme();
return <header className={theme === 'dark' ? 'header-dark' : 'header-light'}>Site Header</header>;
}
export default Header;
// components/Sidebar.jsx
import { useTheme } from '../store/themeStore';
function Sidebar() {
const theme = useTheme();
return <aside className={theme === 'dark' ? 'sidebar-dark' : 'sidebar-light'}>Navigation</aside>;
}
export default Sidebar;
第三个组件负责显示按钮,并从同一个存储中导入了该钩子以及setTheme函数。
// components/ThemeToggleButton.jsx
import { setTheme, useTheme } from '../store/themeStore';
function ThemeToggleButton() {
const theme = useTheme();
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
);
}
export default ThemeToggleButton;
逐步发生的情况
如果运行代码,以下每一步都可以被观察到:
- 在组件挂载时,
Header和Sidebar都会调用useTheme()。React会为这两个组件分别调用getSnapshot,获取到‘light’值后用它来渲染组件。 - React还会为每个组件调用一次注册函数,因此会有两个内部的React回调被添加到共享的
listeners集合中。这些组件在该集合中仍为独立的条目。
ThemeToggleButton 会调用 setTheme('dark')。首先,state 会被重新赋值为一个全新的对象 { theme: 'dark' };这只是普通的 JavaScript 代码,并不包含任何 React 特有的功能。setTheme 会遍历该集合并调用每一个监听器。由于这些监听器属于 React,调用它们后会促使 React 检查所有已注册的组件。Header 调用 getSnapshot,发现其值为 'dark' 而非 'light',于是重新渲染它。同样的检查也会导致 Sidebar 重新渲染。这个 setTheme 并非由 useState 返回的设置函数。 它只是普通的自定义代码,通过一次调用完成两项任务:更新状态数据,然后通知正在监听的状态变化。
没有向任何地方传递属性。整个接线结构完全由导入项构成。这大致也是 Zustand 内部的工作方式:同一种注册与通知模式的小型封装版本。
值得了解的注意事项
- 当没有任何变化时,
getSnapshot必须返回相同的值。返回像state.theme这样的原始值是安全的;但若每次调用都创建新的对象或数组,会让 React 认为状态在不断变化。 - 如果在服务器端渲染,
useSyncExternalStore接受第三个参数getServerSnapshot用于生成初始 HTML。服务器端的模块级状态会在不同请求之间共享,因此请将用户专属数据排除在外。
为何 Context 不是安全的折中方案
使用模块存储时,无需提供任何内容。themeStore.js中的state从未存在于组件树中;组件是通过钩子函数来访问它,而不涉及任何父级Provider。将App包裹在ThemeProvider中只会增加一层并不会传递任何数据。
上下文本身也有其合理的用途:将某个值限制在某个子树范围内,或是在测试中通过渲染不同的Provider来替换依赖项。不过它通常被建议作为库的谨慎替代方案,而在这种角色下它的成本比看上去要高。可以考虑使用一个上下文同时存储主题信息和购物车数据。
// context/AppContext.jsx
import { createContext, useState } from 'react';
const AppContext = createContext();
export function AppProvider({ children }) {
const [state, setState] = useState({
theme: 'light',
cart: ['book'],
});
return <AppContext.Provider value={state}>{children}</AppContext.Provider>;
}
export default AppContext;
一个小型标签仅从中读取主题信息。
// components/ThemeLabel.jsx
import { useContext } from 'react';
import AppContext from '../context/AppContext';
function ThemeLabel() {
const { theme } = useContext(AppContext);
return <span>{theme}</span>;
}
export default ThemeLabel;
useContext(AppContext)会将ThemeLabel绑定到Provider所持有的整个对象上,而非仅绑定到theme属性。当其他地方修改了cart时,Provider会收到一个新的state对象,由于引用不同,即使theme并未改变且该组件也从未操作过cart,ThemeLabel仍会重新渲染。上下文本身没有内置机制可以实现“仅在该字段变化时唤醒我”的功能;所有消费者都会在值发生任何变化时被触发。
前文提到的存储方案已经避免了这个问题。useTheme()会通过其getSnapshot读取一个数据片段,只有当该片段的返回值发生变化时,组件才会重新渲染。使用Context作为中间层虽然能减少初始化开销,但会导致更多的重新渲染。可以通过将状态拆分到多个独立的Context中来缓解这一问题,但这样一来就相当于手动构建了基于选择器的存储方案所能免费提供的功能。
状态管理库真正发挥作用的地方
这些情况并不会让 Redux、Zustand 或 MobX 失去意义。上述四种论据站不住脚,但这些库本身并无问题。属性传递的问题可以通过 Context 解决;状态修改的问题则由 useState 处理。对于无关组件中的自动更新,可以使用 React 自带的 useSyncExternalStore。而原本被视为折中方案的 Context,实际上对于需要共享且频繁变化的少量值而言,其开销反而高于使用小型状态管理库。
真正需要使用这类库的情况是,当共享状态存在众多独立写入者而非主要是读取者时,或者当使用这些状态的组件数量超出了少数手动实现的模块化状态管理库所能维持一致性的范围时。在如此规模下协调更新、中间件、开发工具以及实现可预测的状态变更跟踪,正是成熟库能够发挥价值的地方,也值得专门进行介绍。
核心要点
- 当唯一的问题是在不使用这些值的层之间传递数据时,应使用 Context 而非状态存储。
useState是修改单个组件所拥有状态的合适工具。- 对于没有共同父组件的组件之间共享的状态,基于
useSyncExternalStore构建的小型状态存储通常就足够了。 - 单一的广泛 Context 会在任何变化时重新渲染所有使用它的组件;对于频繁变化的数据,建议使用更具体的 Context 或基于选择器的状态存储。
- 只有在存在众多独立的写作者且读取者数量不断增加时才应使用状态管理库,而非因为属性传递层级过深的问题。
相关阅读
- 精简 React 的属性注入与庞大组件 — 介绍七种具体的重构模式,通过隔离状态、数据获取、权限控制和加载逻辑来拆分臃肿的 React 组件,而不仅仅是简单分割文件。
- React-Redux 如何决定重新渲染:选择器、相等性判断与类型化钩子 — 一份关于 React-Redux 内部机制的学习指南,涵盖 Provider、useSelector 的相等性判断、记忆化选择器、connect() 函数、带有 withTypes() 的类型化钩子以及一些罕见的边缘情况。