cover_image

React 组件设计-避免条件渲染

ikoofe KooFE前端团队
2024年09月28日 02:56

本文翻译自 「Component Composition is great btw」,底部可查看原文。

当我第一次接触 React 时,我了解到的优点是:超级快的虚拟 DOM、可预测的单向数据流、以及有趣的语法扩展 JSX。但是随着时间的推移,我发现 React 最大的优点是:将组件组合成更多组件的能力。如果你已习惯了这种能力,就很容易忽略这个优点。也许你不信,在大约十年前,将组件逻辑、样式和标记组合到一个单一组件中被认为是亵渎神明的行为。

虽然关注点分离一直存在,但是分离方式不再相同。下面这张图中可以很好的总结了这一点:

图片

这一切都与代码内聚性有关。按钮的样式、按钮被点击时发生的逻辑以及按钮的标记自然地结合在一起形成那个按钮。这是一种比 “这里是你应用程序的所有样式都在一个单独的层中” 更好的分组方式。

我们花了一些时间才真正领会到这种 “以组件方式思考”,而且我认为有时候仍然很难找出这些边界在哪里。“新的” React 文档中有一个关于 “以 React 方式思考” 的很棒的部分,其中强调第一步应该始终是将用户界面分解为组件层次结构。我认为我们做得还不够,这就是为什么许多应用程序在某个点上停止了组件组合,然后继续使用它的天敌:条件渲染。

条件渲染

在 JSX 中,我们可以有条件地渲染其他组件。这并不是什么新鲜事,而且它本身也不是可怕或邪恶的。考虑下面这个组件,它渲染一个购物清单,并可选地添加一些关于分配到该清单的用户信息:

tsx

export function ShoppingList(props: {
content: ShoppingList
assignee?: User
}) {
return (
<Card>
<CardHeading>Welcome 👋</CardHeading>
<CardContent>
{props.assignee ? <UserInfo {...props.assignee} /> : null}
{props.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))}
</CardContent>
</Card>
)
}

我想说这完全没问题。如果购物清单没有分配给任何用户,我们就省略渲染的用户信息那部分内容。

有条件地渲染多种状态

我认为当我们在 JSX 中使用条件渲染来呈现组件的不同状态时,它就开始成为一个问题。假设我们重构这个组件,使其通过直接从查询中读取购物清单数据而变得自包含:

diff

export function ShoppingList() {
+ const { data, isPending } = useQuery(/* ... */)

return (
<Card>
<CardHeading>Welcome 👋</CardHeading>
<CardContent>
{data?.assignee ? <UserInfo {...data.assignee} /> : null}
+ {isPending ? <Skeleton /> : null}
+ {data
+ ? data.content.map((item) => (
+ <ShoppingItem key={item.id} {...item} />
+ ))
+ : null}
</CardContent>
</Card>
)
}

自包含的组件很棒,因为你可以在应用程序中自由地移动它们,并且它们只会读取自己的需求,就像在这种情况下,一个查询。这个内联的条件看起来还可以(其实不是),因为我们基本上想要渲染一个占位符(Skeleton)而不是数据。

组件的演变

这里的一个问题是这个组件不太容易良好地演变。是的,我们无法预见未来,但是让最常见的事情(添加更多功能)易于实现是一个非常好的主意。所以让我们添加另一个状态 —— 如果从 API 调用中没有返回数据,我们想要渲染一个特殊的<EmptyScreen /> 组件。改变现有的条件应该不难:

diff

export function ShoppingList() {
const { data, isPending } = useQuery(/* ... */)

return (
<Card>
<CardHeading>Welcome 👋</CardHeading>
<CardContent>
{data?.assignee ? <UserInfo {...data.assignee} /> : null}
{isPending ? <Skeleton /> : null}
{data ? (
data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))
) : (
+ <EmptyScreen />
)}
</CardContent>
</Card>
)
}

当然,你很快就会发现我们刚刚引入的错误🐞:这也会在待处理状态下显示 <EmptyScreen />,因为在那个状态下,我们也没有数据。通过添加另一个条件很容易修复:

diff

export function ShoppingList() {
const { data, isPending } = useQuery(/* ... */)

return (
<Card>
<CardHeading>Welcome 👋</CardHeading>
<CardContent>
{data?.assignee ? <UserInfo {...data.assignee} /> : null}
{isPending ? <Skeleton /> : null}
+ {!data && !isPending ? <EmptyScreen /> : null}
{data
? data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))
: null}
</CardContent>
</Card>
)
}

但这还是 “一个组件” 吗?这容易阅读吗?这个标记中有这么多问号和感叹号,让我的大脑有点疼。认知负荷才是关键。我不能轻易地看出用户在待处理状态或空状态下在屏幕上会看到什么,因为我首先必须解析所有这些条件。

我甚至还没有谈到在这里添加另一个状态,因为很明显,我们将不得不(在脑海中)经历每一个步骤,并检查在那个新状态下我们是否也想要渲染这部分内容。

回到绘图板

在这一点上,我建议听从 React 文档的建议,将用户在屏幕上实际看到的内容分解成一个个方框。这可能会给我们一个线索,让我们知道哪些内容的关联性足够强,可以成为它自己的组件。

图片

在所有三种状态下,我们都想呈现一个共享的 “布局”—— 红色部分。这就是我们一开始制作这个组件的原因 —— 因为我们有一些要呈现的共同部分。蓝色部分是这三种状态之间的不同之处。那么,如果我们将红色部分提取到一个接受动态子元素的独立布局组件中,重构会是什么样子呢?

diff

+ function Layout(props: { children: ReactNode }) {
+ return (
+ <Card>
+ <CardHeading>Welcome 👋</CardHeading>
+ <CardContent>{props.children}</CardContent>
+ </Card>
+ )
+ }

export function ShoppingList() {
const { data, isPending } = useQuery(/* ... */)

return (
+ <Layout>
{data?.assignee ? <UserInfo {...data.assignee} /> : null}
{isPending ? <Skeleton /> : null}
{!data && !isPending ? <EmptyScreen /> : null}
{data
? data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))
: null}
+ </Layout>
)
}

那…… 令人困惑。😕我们似乎什么都没做成 —— 这并没有真的更好。我们仍然像以前一样面临着同样混乱的有条件情况。那么我这样做的目的是什么呢?

尽早 return

让我们也思考一下我们最初为什么要添加所有这些条件呢?🤔这是因为我们在 JSX 内部,而在 JSX 内部,我们只能写表达式,不能写语句。但是现在,我们不再必须在 JSX 内部了。我们拥有的唯一 JSX 只是对 <Layout> 的一次调用。我们可以复制它并使用早期返回代替:

tsx

function Layout(props: { children: ReactNode }) {
return (
<Card>
<CardHeading>Welcome 👋</CardHeading>
<CardContent>{props.children}</CardContent>
</Card>
)
}

export function ShoppingList() {
const { data, isPending } = useQuery(/* ... */)

if (isPending) {
return (
<Layout>
<Skeleton />
</Layout>
)
}

if (!data) {
return (
<Layout>
<EmptyScreen />
</Layout>
)
}

return (
<Layout>
{data.assignee ? <UserInfo {...data.assignee} /> : null}
{data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))}
</Layout>
)
}

尽早 return 对于表现组件的不同状态非常有用,因为它们可以为我们实现以下几点:

  • 降低认知负荷:它们为开发人员展示了一条清晰的路径。没有任何东西是嵌套的。就像 async/await 一样,从上到下阅读时更容易理解。每个带有返回的 if 语句都代表了用户可以看到的一种状态。注意我们也将对 data.assignee 的检查移到了最后一个分支。这是因为只有在这个分支中我们才真正想要渲染 UserInfo。在之前的版本中这一点并不明显。

  • 易于扩展:我们现在还可以添加更多条件,比如错误处理,而不必担心会破坏其他状态。就像在我们的代码中添加另一个 if 语句一样简单。

  • 更好的类型推断:注意到最后对 data 的检查怎么不见了吗?那是因为在我们处理了 if (!data) 的情况后,TypeScript 知道 data 一定是已定义的。如果我们只是有条件地渲染某些东西,TypeScript 就无法帮助我们。

布局重复问题

我知道有些人担心在每个分支中重复渲染 <Layout> 组件。但我认为他们关注的点不对。这种重复不仅没问题,而且在可能存在细微差异的情况下,它还将有助于组件更好地发展。例如,让我们从我们的数据中添加一个标题属性到标题中:

tsx

function Layout(props: { children: ReactNode; title?: string }) {
return (
<Card>
<CardHeading>Welcome 👋 {title}</CardHeading>
<CardContent>{props.children}</CardContent>
</Card>
)
}

export function ShoppingList() {
const { data, isPending } = useQuery(/* ... */)

if (isPending) {
return (
<Layout>
<Skeleton />
</Layout>
)
}

if (!data) {
return (
<Layout>
<EmptyScreen />
</Layout>
)
}

return (
<Layout title={data.title}>
{data.assignee ? <UserInfo {...data.assignee} /> : null}
{data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))}
</Layout>
)
}

请注意,向布局组件添加更多条件可能表明这是错误的抽象。在这一点上,可能最好再次回到设计阶段重新思考设计。

也许这篇文章更多的是关于尽早 return,而不仅仅是关于组件组合。但我认为它两者都涉及。无论如何,它是关于避免对互斥状态进行有条件的渲染。没有组件组合我们无法做到这一点,所以一定要确保不要跳过设计阶段。设计阶段是你最好的朋友。


组件设计 · 目录
上一篇需求迭代中的组件设计下一篇React 开发中使用开闭原则
继续滑动看下一个
KooFE前端团队
向上滑动看下一个