前端知库

高阶组件、Render Props 和自定义 Hook 怎样选择?

三者都用来复用组件逻辑。高阶组件是接收组件、返回新组件的函数;Render Props 是把“怎么渲染”作为函数 prop 传进去;自定义 Hook 把有状态的逻辑抽成以 use 开头的函数,不增加组件层级。新代码复用逻辑首选自定义 Hook,Render Props 适合把渲染权交给使用方,高阶组件主要出现在老代码和部分库里。

参考答案

简答

好几个组件都要知道“网络是否在线”,这段监听 online、offline 事件的逻辑想写一次到处用。React 里有三种写法:高阶组件 withOnlineStatus(Component) 返回一个包了一层的新组件,把 isOnline 作为 prop 传进去;Render Props 写一个 <OnlineStatus>,让使用方传一个函数,拿到 isOnline 后自己决定渲染什么;自定义 Hook 写一个 useOnlineStatus(),组件里直接调用拿到返回值。

前两种是 Hooks 出现前的方案,它们都要多套一层组件。层数多了,DevTools 里会出现一层层的包装,旧版 React 文档称之为“wrapper hell”;高阶组件传进来的 prop 还看不出来自哪一层,多个高阶组件可能用了同一个 prop 名。自定义 Hook 不改变组件层级,数据从哪个函数返回一目了然。

所以新代码复用有状态的逻辑,首选自定义 Hook。Render Props 仍适合“逻辑在我这里,长什么样由你定”的场景,比如列表组件接收一个 renderItem。高阶组件主要出现在类组件时代的老代码和一些库的 API 里,旧版文档也说它在现代 React 代码里已不常用。

要注意,自定义 Hook 共享的是逻辑,不是状态,两个组件调用同一个 Hook,各有一份独立的状态。高阶组件不能在渲染过程中创建,否则每次都是新组件,整个子树会被卸载重建,状态全部丢失。

同一段逻辑的三种写法

下面用“网络是否在线”这个例子,把三种写法放在一起,重点看使用方的代码分别长什么样。

JSX
// 自定义 Hook:逻辑写成函数,组件直接调用
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    const on = () => setIsOnline(true);
    const off = () => setIsOnline(false);
    window.addEventListener('online', on);
    window.addEventListener('offline', off);
    return () => {
      window.removeEventListener('online', on);
      window.removeEventListener('offline', off);
    };
  }, []);
  return isOnline;
}

function SaveButton() {
  const isOnline = useOnlineStatus();
  return <button disabled={!isOnline}>保存</button>;
}

// Render Props:组件负责逻辑,渲染交给传进来的函数
function OnlineStatus({ render }) {
  const isOnline = useOnlineStatus();
  return render(isOnline);
}

<OnlineStatus render={(isOnline) => <button disabled={!isOnline}>保存</button>} />;

// 高阶组件:接收一个组件,返回一个注入了 isOnline 的新组件
function withOnlineStatus(Component) {
  return function WithOnlineStatus(props) {
    const isOnline = useOnlineStatus();
    return <Component {...props} isOnline={isOnline} />;
  };
}

const SaveButtonWithStatus = withOnlineStatus(function ({ isOnline }) {
  return <button disabled={!isOnline}>保存</button>;
});

为了对比,这里后两种也借用了 useOnlineStatus。在 Hooks 出现之前,它们内部是用类组件的 state 和生命周期实现的,那时这两种模式是复用有状态逻辑的主要办法。

三种写法的差别在使用方。自定义 Hook 的版本里,isOnline 就是一个普通变量,从哪里来一眼能看到。Render Props 的版本多了一层 <OnlineStatus>,数据通过函数参数传出来,嵌套几个这样的组件,代码就会一层套一层往右缩进。高阶组件的版本里,SaveButtonWithStatus 的 props 里突然多了 isOnline,读组件本身看不出它从哪来;再套上 withRouter、withTheme,就要去查每个高阶组件注入了什么,名字重复时后面的会覆盖前面的。

Hooks 为什么能替代大部分场景

旧版 React 文档在介绍 Hooks 的动机时说,React 没有提供把可复用行为“附加”到组件上的办法,Render Props 和高阶组件试图解决这个问题,但使用时要重新组织组件结构,代码更难读;DevTools 里常能看到 Provider、Consumer、高阶组件、Render Props 一层层包起来的“wrapper hell”。Hooks 让你在不改变组件层级的情况下复用有状态的逻辑。

有人会担心:两个组件都调用 useOnlineStatus,它们是不是共用一个 isOnline?不是。React 文档说明,自定义 Hook 只共享有状态的逻辑,不共享状态本身,每次调用 Hook 都与其他调用完全独立。两个组件显示同一个值,只是因为它们都同步了同一个外部数据源,也就是浏览器的网络状态。想要真正共享一份状态,要把状态提升到共同父组件或用 Context。

Render Props 还适合什么时候用

自定义 Hook 只交出数据,渲染完全由调用方负责。有些组件除了逻辑还有自己的结构,只想把其中一块的样子交给使用方,这时 Render Props 更合适。React 文档在讲 cloneElement 的替代方案时就推荐了这种写法:列表组件接收一个 renderItem 函数,组件自己负责遍历和高亮哪一项,每一项长什么样由使用方决定。

JSX
<List
  items={products}
  renderItem={(product, isHighlighted) => (
    <Row key={product.id} title={product.title} isHighlighted={isHighlighted} />
  )}
/>

文档解释了名字的由来:renderItem 是一个指定“怎么渲染某样东西”的 prop,所以叫 render prop。旧版文档也说,只要是组件用来决定渲染什么的函数 prop,严格来说都是 render prop,它不一定叫 render,把 children 写成函数也是同一种模式。

高阶组件要注意什么

高阶组件就是一个函数,接收一个组件,返回一个新组件。它在类组件时代很常见,比如给组件注入 store 里的数据。旧版文档列出了几个容易踩的坑。

最重要的一条是不要在渲染过程中调用高阶组件。React 靠组件的引用判断是不是同一个组件,每次渲染都调用 withOnlineStatus(Button),得到的都是一个新组件,React 会把旧的子树整个卸载、再挂载新的,组件和它所有子组件的状态都会丢失。所以高阶组件要在组件定义之外调用,只创建一次。另外,包装后的新组件不会自动带上原组件的静态方法,需要手动复制。

常见追问

自定义 Hook 能完全取代高阶组件吗?

复用有状态的逻辑基本可以。高阶组件还有一些用法 Hook 做不到,比如在外面包一层布局、错误边界,或者给第三方组件统一加上某些 props,这些本质上是“包一层组件”,用普通的包装组件或高阶组件实现更直接。

自定义 Hook 为什么必须以 use 开头?

React 文档要求 Hook 的名字以 use 开头、后面跟一个大写字母,这样人和 lint 工具一眼就能看出这个函数内部调用了 Hook,要遵守 Hook 的调用规则。反过来,函数内部不调用任何 Hook,就不要用 use 开头。

Render Props 会不会有性能问题?

旧版文档提醒过,在渲染时创建的内联函数每次都是新的,会让 PureComponent 的浅比较失效,用 memo 也是同样的道理。大多数情况下这不是瓶颈;确实需要时,可以用 useCallback 让函数保持同一个引用。