Menu

React Prop Drilling 是什么?如何避免

Prop drilling 是指把一个 prop 穿过并不使用它的组件往下传,只为了让更深处的某个组件能用到它。通过可运行的示例看看它是什么样子、应用变大后为什么会成为负担,以及按顺序尝试的解决办法:组合、context,然后是状态管理库。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

Prop drilling 是指把一个 prop 穿过并不使用它的组件往下传,只为了让组件树更深处的某个组件能用到它。在这里,user 从 App 出发,经过 Page 和 Sidebar,才到达唯一读取它的组件 Avatar。

一共四层,只有最后一层关心 user。控制台显示,Page 和 Sidebar 为一个它们从不显示的值运行了一遍。点击按钮,它们会再次打印日志。

为什么麻烦

两三层没问题。随着组件树变大,prop drilling 就会成为问题:

  • 中间组件带着它们不用的 props。 Page 和 Sidebar 的签名里现在都有一个 user prop,读代码时你总会疑惑它是干什么用的。
  • 每次修改都要动每一层。 加一个 user.avatarUrl,或者再加一个 onLogout 这样的值,你都得修改路径上的每个组件,而不只是两端。
  • 移动组件会打断传递链。 把 Avatar 移到另一个分支,你就必须沿新路径把 user 重新传一遍。
  • 很容易漏传 prop。 只要某一层忘了转发,深层组件拿到的就是 undefined,而漏传的那一层不会报任何错。

试一试:在 App 中加一个应该由 Avatar 调用的 onLogout 函数。你会发现四个组件都要改。

解决办法 1:用 children 组合

这是大家最容易忽略的办法。与其把数据往下传,让深层组件拿着它去渲染,不如让拥有数据的组件自己渲染这个深层组件,再把做好的元素作为 children(或任何其他 prop)传下去。中间层只负责渲染一个插槽,完全看不到 user。

Page 和 Sidebar 不再知道有 user 这回事。App 在 user 所在的地方创建 Avatar,把它作为现成的元素传下去。这样的布局组件变得可以复用,现在加一个 onLogout 只需要改 App 和 Avatar。每次点击时两者仍然会打印日志,因为 App 每次渲染都会传给它们一个新元素:组合简化的是代码,而不是渲染次数。children 一页介绍了更多插槽写法。

当拥有数据的组件也能决定深层部分长什么样时,组合就能奏效。如果深层组件位于某个库内部,或位于数据拥有者并不渲染的结构中,组合就帮不上忙了。

解决办法 2:context

当不同深度的很多组件都需要同一个值时(主题、当前登录的用户、当前语言),就把它放进 context。provider 之下的任何组件都能用 useContext 读取它,中间的组件一处都不用改。

Avatar 和 Greeting 在两个不同的深度读取 user,而 Page 和 Sidebar 不接收任何 props。在 Page 之下任意位置再加第三个读取者,无需改动其他代码就能工作。代价是:数据流不再体现在 props 中,而且值变化时每个读取者都会重新渲染。useContext 一页介绍了 provider、默认值以及这些重新渲染。

解决办法 3:状态管理库

对于很多组件都会读取、而且经常变化的大型应用 state(购物车、文档编辑器、实时数据),Redux Toolkit、Zustand 或 Jotai 等状态管理库可以让每个组件只订阅它用到的那一部分。这样既避免了 prop drilling,也避免了一个大 context 带来的“所有使用者都重新渲染”的代价。但它也意味着多一个依赖、多一套概念,所以要在前两种办法不够用时才使用,而不是为了一个只传三层的 prop。

// Zustand, for comparison (not available in the editor here)
const useCart = create((set) => ({
    items: [],
    add: (item) => set((s) => ({ items: [...s.items, item] })),
}));

function CartCount() {
    const count = useCart((s) => s.items.length); // renders only when the count changes
    return <span>{count}</span>;
}

什么时候 prop drilling 没问题

传递 props 本来就是 React 的数据流方式,也是最容易阅读的写法:只看 JSX,你就能从上到下跟踪一个值。一个 prop 经过一两个会用到其中一部分的组件,或者经过一小串紧密相关的组件,这都不是需要解决的问题。把 state 提升到最近的共同父组件,往下传,只有当中间层开始携带它们根本用不上的 props 时,再换别的方法。

常见问题

React 中的 prop drilling 是什么?

把一个 prop 穿过好几层并不使用它的组件往下传,只为了让组件树深处的某个组件能读取它。中间的每个组件都接收这个 prop,再把它转发下去。

prop drilling 不好吗?

本身并不坏。把一个值往下传两三层,清晰明确,也容易跟踪。当很多层都带着它们从不使用的 props 时,它才会成为问题,因为每次改名或新增字段,都要改动一堆与之无关的文件。

如何避免 prop drilling?

先试试组合:让父组件自己构建深层组件,再作为 children 或其他 prop 传下去,这样中间层就完全接触不到这些数据。如果不同深度的很多组件都需要这个值,就用 context。对于体量大且变化频繁的 state,可以考虑状态管理库。

context 是解决 prop drilling 的唯一办法吗?

不是,而且往往不是最好的办法。用 children 进行组合,无需新增 context 就能消除 prop drilling,还能让数据流在代码中保持清晰可见。

Coddy 编程语言插图

和 Coddy 一起学编程

开始