保持钩子无头部:将警报行为与警报渲染分离
了解为何返回 JSX 的 React 钩子会隐藏组件树的一部分、将行为与呈现逻辑绑定在一起,以及如何重构它以暴露状态。
自定义钩子很擅长隐藏实现细节,但有时会隐藏得过多。一个返回现成JSX的钩子在调用处看似方便,但实际上会将部分UI结构隐藏在类似普通数据的API背后,并将特定呈现方式与功能绑定在一起。通过使用仪表板编辑器中的典型警告钩子,本文展示了如何识别这类问题、如何重构为无头钩子,以及多写几行代码能带来什么好处。
自行渲染UI的警告钩子
警告功能非常适合用钩子实现:它们需要状态、定时器、关闭处理函数以及队列机制。以下是使用此类钩子的示例:
const {
showSuccess,
showError,
alerts,
} = useAlerts();
return (
<>
{alerts}
<Dashboard />
</>
);
已经有一些迹象表明存在问题。alerts直接被放入JSX中,这强烈暗示该钩子返回的是已渲染的元素。由于该钩子负责提供showSuccess和showError函数,因此它显然也决定了警报的显示方式,同时承担着控制行为的功能。
实现代码也证实了这一点:
const useAlerts = () => {
const [alerts, setAlerts] = useState<Alert[]>([]);
const showSuccess = (message: string) => {
// update state
};
const showError = (message: string) => {
// update state
};
...
return {
showSuccess,
showError,
alerts: (
<AlertContainer>
{alerts.map(({ id, type, message }) => (
<Alert
key={id}
type={type}
message={message}
/>
))}
</AlertContainer>
),
};
};
useAlerts掌控着警报状态、用于修改状态的函数以及相关标记:即AlertContainer容器,以及每个条目对应的一个Alert元素。逻辑与表现层之间的界限已经消失。
为何返回的JSX具有误导性
它看起来像数据
从用户的角度来看,没有任何迹象表明 alerts 是一个 React 子树。它位于两个函数旁边,其复数形式的名称让人误以为它是一个记录数组,而且很容易被当作状态:
const {
showSuccess,
showError,
alerts,
} = useAlerts();
要了解真相的唯一方法就是打开该钩子。
它隐藏了 UI 的来源
组件树之所以有价值,部分原因在于它能记录每部分 UI 的来源。在查看控制面板时看到如下情况:
{alerts}
<Dashboard />
如果不深入研究该钩子,就无法知道 alerts 会渲染什么内容、它会挂载哪些组件、这些组件依赖何种上下文或属性,以及它们的样式是如何设置的。部分组件树结构被移到了看似与功能相关的代码之后。
它将两个无关的决策绑定在一起
现在,钩子若不同时确定警报组件,就无法再暴露警报行为。任何需要警报逻辑的界面都会继承警报的样式,无论该样式是否适合它。这两者之所以必须绑定在一起,并没有技术上的原因。
重构为无头钩子
钩子应仅负责管理状态及对状态的操作,除此之外不应承担其他功能。需暴露原始的警报列表及相关操作,包括一种关闭警报的方式:
const {
alerts,
showSuccess,
showError,
dismissAlert,
} = useAlerts();
然后在组件中渲染这些警报,因为渲染本就属于组件的职责:
<AlertContainer>
{alerts.map(({ id, type, message }) => (
<Alert
key={id}
type={type}
message={message}
onDismiss={() => dismissAlert(id)}
/>
))}
</AlertContainer>
<Dashboard />
这样一来,组件结构又变得清晰明了。任何查看该结构的人都能看到一个AlertContainer,每个条目根据id对应一个Alert,还有一个连接到钩子的关闭处理函数。钩子的职责十分明确:它负责提供状态和操作功能。
额外的代码会带来问题吗?
消费组件确实会变长,这没什么问题。人们很容易想要把 API 精心打磨到调用方几乎无需编写任何代码,但这样的优化可能会抹去有用的架构信息。五行结构清晰的代码比用一个 {alerts} 就隐藏整个 UI 要容易维护得多。
如果多个界面以相同方式显示警告信息,你仍然可以在不将 UI 重新整合进钩子函数的情况下避免重复代码:可以创建一个 AlertList 组件,将其 alerts 和 onDismiss 作为属性传入,或者创建一个直接调用该钩子函数的组件。无论哪种方式,渲染逻辑都位于某个组件中,这个组件会显示在组件树中,可以像其他组件一样进行性能分析、缓存处理和测试。
从而实现复用
由于 JSX 已内置,重复使用功能逻辑就意味着必须重复使用界面表现,但这一假设并不总是成立。在无头版本中,提示区域、内联横幅以及移动端布局可以共享完全相同的警告生命周期,只是呈现方式不同。此外,警告组件还可以在无需修改负责控制时机与关闭操作的钩子的情况下独立发展。
一个相关的陷阱:返回组件的钩子
与这种问题密切相关的是那种在钩子内部定义并返回组件函数的写法,例如 const AlertView = () => ...。由于每次渲染都会创建新的函数,React 会将其视为全新的组件类型,从而卸载并重新加载该子树,清除其内部状态和 DOM。像原始钩子那样直接返回元素可以避免这种特定错误,但上述设计问题依然存在。
核心要点
- 当钩子函数返回JSX时,通常意味着两种职责被合并到了同一个抽象层中。
- 那些看似数据实则为UI的返回值,会向读取者隐藏组件树的一部分结构。
- 应保持钩子函数的无头设计:在钩子中返回状态和操作逻辑,由组件负责渲染。
- 对于需要复用的界面元素,应提取为独立组件,而非直接从钩子函数返回标记代码。
- 审查此类钩子时,只需问一个问题:这个钩子究竟为何要决定UI的呈现方式?