Component Composition is great btw
组件组合非常不错,顺便说一下
— JavaScript, TypeScript, React — 5 min read
2024 年 9 月 21 日 — JavaScript,TypeScript,React — 5 分钟阅读
When I first learned about React, I heard about all of its advantages: Virtual DOM is super fast, one-way data flow is very predictable, and JSX is ... an interesting way to put markup into JavaScript.
当我第一次了解 React 时,我听说了它的所有优点:虚拟 DOM 非常快速,单向数据流非常可预测,而 JSX 是将标记放入 JavaScript 的一种有趣方式。
But the biggest advantage of React is one I only got to appreciate over time: The ability to compose components together into more components.
但 React 最大的优点是我只能随着时间推移才能欣赏到的:将组件组合在一起形成更多组件的能力。
It's easy to miss this advantage if you've always been used to it. Believe it or not, grouping component logic, styles and markup together into a single component was considered blasphemy about a decade ago.
如果你一直习惯这样做,很容易忽视这个优势。信不信由你,将组件逻辑、样式和标记组合成一个单一组件在大约十年前被认为是亵渎。
??? wHaT AbOuT sEpArAtIoN oF cOnCeRnS ???
??? 关于关注点分离呢???
Well yes, we still separate concerns, just differently (and arguably better) than before. This graphic, which I first saw in Max's tweet, summarizes it very well:
当然,我们仍然分离关注点,只是与之前不同(可以说是更好地)进行分离。这个图形,我第一次在 Max 的推文中见到,很好地总结了这一点:
Separation of concerns still exists, the question is just: where do you separate? Should the boundary really be the programming language we write in?
关注点分离仍然存在,问题只是:你应该在哪里进行分离?这个边界真的应该是我们编写代码的编程语言吗?
(attached graphic courtesy of @areaweb)
(附图由@areaweb 提供)
It's all about code cohesion. The styles of a button, the logic that happens when a button is clicked and the markup of a button naturally belong together to form that button. It's a much better grouping than "here are all your styles of all your application in a single layer".
这完全是关于代码的一致性。按钮的样式、按钮被点击时发生的逻辑和按钮的标记自然属于一起,形成那个按钮。这比“这里是您应用程序中所有样式的单一层”要好的多。
It took us some time to really appreciate this "thinking in components", and I think it's still hard to sometimes find out where those boundaries are. The "new" react docs have a great section about Thinking in React, where they highlight that the first step should always be to break the UI into a component hierarchy.
我们花了一些时间来真正理解“以组件思考”,而且我认为有时仍然很难找出这些边界在哪里。“新”的 React 文档中有一个关于在 React 中思考的精彩部分,他们强调第一步始终应该是将 UI 拆分为组件层次结构。
I don't think we do this enough, which is why many applications stop with component composition at a certain point and continue with it's natural enemy: conditional rendering.
我认为我们对此做得不够,这就是为什么许多应用在某个时刻停止了组件组合,并继续使用它的天敌:条件渲染。
Conditional rendering 条件渲染
Inside JSX, we can conditionally render other components. This is nothing new, and it's also not terrible or evil by itself. Consider the following component that renders a shopping list and optionally adds some user information about the person that's assigned to the list:
在 JSX 内部,我们可以有条件地渲染其他组件。这并不新鲜,也本身并不糟糕或邪恶。考虑以下组件,它渲染一个购物清单,并可选择添加一些有关分配给该清单的用户的信息:
1export function ShoppingList(props: {2 content: ShoppingList3 assignee?: User4}) {5 return (6 <Card>7 <CardHeading>Welcome 👋</CardHeading>8 <CardContent>9 {props.assignee ? <UserInfo {...props.assignee} /> : null}10 {props.content.map((item) => (11 <ShoppingItem key={item.id} {...item} />12 ))}13 </CardContent>14 </Card>15 )16}
I would say this is perfectly fine. If the shopping list isn't assigned to anyone, we'll just leave out that part of our rendering. So where's the problem?
我认为这完全没问题。如果购物清单没有分配给任何人,我们就会省略渲染的那部分。那么问题出在哪里呢?
Rendering multiple states conditionally
有条件地渲染多个状态
I think conditional rendering inside JSX starts to become a problem when we use it for rendering different states of a component. Suppose we refactor this component to become self contained by reading the shopping list data directly from a query:
我认为在 JSX 内部的条件渲染当我们用它来渲染组件的不同状态时,开始成为一个问题。假设我们重构这个组件,使其通过直接从查询中读取购物清单数据来变得自给自足:
1export function ShoppingList() {2 const { data, isPending } = useQuery(/* ... */)3
4 return (5 <Card>6 <CardHeading>Welcome 👋</CardHeading>7 <CardContent>8 {data?.assignee ? <UserInfo {...data.assignee} /> : null}9 {isPending ? <Skeleton /> : null}10 {data11 ? data.content.map((item) => (12 <ShoppingItem key={item.id} {...item} />13 ))14 : null}15 </CardContent>16 </Card>17 )18}
Self contained components are great because you can freely move them around in your application, and they will just read their own requirements, like in this case, a query. This inlined condition seems okay (it's not), as we basically want to render a Skeleton
instead of data
.
自包含组件非常好,因为您可以在应用程序中自由移动它们,它们只会读取自己的需求,比如在这种情况下的查询。这个内联条件看起来还可以(其实并不是),因为我们基本上想要渲染一个骨架而不是数据。
Evolving the component 演变组件
One problem here is that this component just doesn't evolve very well. Yes, we can't see in the future, but making the most common thing (adding more functionality) simple to do is a very good idea.
一个问题是这个组件发展得不好。是的,我们无法预见未来,但让最常见的事情(增加更多功能)变得简单是个很好的主意。
So let's add another state - if no data
comes back from the API call, we'd want to render a special <EmptyScreen />
. Shouldn't be hard to change the existing condition:
所以我们再添加一个状态——如果 API 调用没有返回数据,我们想渲染一个特殊的 。改变现有条件应该不难:
1export function ShoppingList() {2 const { data, isPending } = useQuery(/* ... */)3
4 return (5 <Card>6 <CardHeading>Welcome 👋</CardHeading>7 <CardContent>8 {data?.assignee ? <UserInfo {...data.assignee} /> : null}9 {isPending ? <Skeleton /> : null}10 {data ? (11 data.content.map((item) => (12 <ShoppingItem key={item.id} {...item} />13 ))14 ) : (15 <EmptyScreen />16 )}17 </CardContent>18 </Card>19 )20}
Of course you'll quickly spot the bug 🐞 we've just introduced: This will show the <EmptyScreen />
when we are in pending
state, too, because in that state, we also have no data. Easily fixable by adding another condition instead:
当然您会迅速发现我们刚刚引入的错误🐞:在待处理状态下,这也会显示,因为在该状态下,我们也没有数据。只需添加另一个条件即可轻松修复:
1export function ShoppingList() {2 const { data, isPending } = useQuery(/* ... */)3
4 return (5 <Card>6 <CardHeading>Welcome 👋</CardHeading>7 <CardContent>8 {data?.assignee ? <UserInfo {...data.assignee} /> : null}9 {isPending ? <Skeleton /> : null}10 {!data && !isPending ? <EmptyScreen /> : null}11 {data12 ? data.content.map((item) => (13 <ShoppingItem key={item.id} {...item} />14 ))15 : null}16 </CardContent>17 </Card>18 )19}
But is this still "one component"? Is this easy to read? There are so many question marks and exclamation marks in this markup it makes my brain hurt a bit. Cognitive Load is what matters. I can't easily see what the user will see on their screen if they are in pending
state, or if they are in empty
state, because I'd have to parse all these conditions first.
但是这仍然是“一个组件”吗?这容易阅读吗?这个标记中有这么多问号和感叹号,让我有点脑疼。认知负荷才是重要的。如果用户处于待处理状态或空状态,我无法轻松看到他们屏幕上会看到什么,因为我必须先解析所有这些条件。
I'm not even talking about adding another state here, because it should be clear that we would have to (mentally) go through each step and check if we would want to render this part in that new state as well.
我甚至不想在这里添加另一个状态,因为显然我们必须(在脑海中)逐步检查一下,看看我们是否想在那个新状态下渲染这一部分。
Back to the drawing board
回到起点
At this point, I would suggest to listen to the React docs and break down what the user actually sees on the screen into boxes. It might give us a clue about what is related enough to become it's own component:
在这一点上,我建议听取 React 文档,并将用户在屏幕上实际看到的内容分解成框。这样可能会给我们提供关于哪些内容足够相关以成为其独立组件的线索。
In all three states, we want to render a shared "layout" - the red part. That's why we made our component in the first place - because we have some common parts to render. The blue stuff is what's different between the three states. So how would a refactoring look like if we'd extract the red parts to their own layout component that accepts dynamic children
:
在这三种状态中,我们希望渲染一个共享的“布局”——红色部分。这就是我们最初制作组件的原因——因为我们有一些共同的部分需要渲染。蓝色的部分是这三种状态之间的不同。那么,如果我们将红色部分提取到自己的布局组件中,该组件接受动态子元素,重构的样子会是什么样的呢:
1function Layout(props: { children: ReactNode }) {2 return (3 <Card>4 <CardHeading>Welcome 👋</CardHeading>5 <CardContent>{props.children}</CardContent>6 </Card>7 )8}9
10export function ShoppingList() {11 const { data, isPending } = useQuery(/* ... */)12
13 return (14 <Layout>15 {data?.assignee ? <UserInfo {...data.assignee} /> : null}16 {isPending ? <Skeleton /> : null}17 {!data && !isPending ? <EmptyScreen /> : null}18 {data19 ? data.content.map((item) => (20 <ShoppingItem key={item.id} {...item} />21 ))22 : null}23 </Layout>24 )25}
That's ... confusing. 🫤 We seemingly haven't achieved anything - this isn't really better. We still have the same conditional mess as before. So where am I going with this?
这…让人困惑。🫤 我们似乎没有取得任何成就——这并没有真正改善。我们仍然有和之前一样的条件混乱。那么我这是什么意思呢?
Early returns to the rescue
早期返回救援
Let's also think about why we added all these conditions in the first place 🤔. It's because we are inside JSX, and inside JSX, we can only write expressions, not statements.
让我们也思考一下为什么一开始我们添加了所有这些条件 🤔。这是因为我们在 JSX 中,而在 JSX 中,我们只能写表达式,而不是语句。
But now, we don't have to be inside JSX anymore. The only JSX we have is a single call to <Layout>
. We could just duplicate that and use early returns instead:
但现在,我们不再需要在 JSX 内了。我们唯一的 JSX 是对 的一次调用。我们可以简单地复制它,并使用早期返回:
1function Layout(props: { children: ReactNode }) {2 return (3 <Card>4 <CardHeading>Welcome 👋</CardHeading>5 <CardContent>{props.children}</CardContent>6 </Card>7 )8}9
10export function ShoppingList() {11 const { data, isPending } = useQuery(/* ... */)12
13 if (isPending) {14 return (15 <Layout>16 <Skeleton />17 </Layout>18 )19 }20
21 if (!data) {22 return (23 <Layout>24 <EmptyScreen />25 </Layout>26 )27 }28
29 return (30 <Layout>31 {data.assignee ? <UserInfo {...data.assignee} /> : null}32 {data.content.map((item) => (33 <ShoppingItem key={item.id} {...item} />34 ))}35 </Layout>36 )37}
Early returns are great for representing different states of a component because they can achieve a couple of things for us:
提前返回非常适合表示组件的不同状态,因为它们可以为我们实现几件事:
Reduced cognitive load 降低认知负荷
They show a clear path for developers to follow. Nothing is nested. Like async/await
, it becomes easier to reason about when reading top-down. Every if statement with a return represents one state the user can see. Notice how we've also moved the data.assignee
check into the last branch. That's because it's the only one where we actually want to render the UserInfo
. That wasn't clear in the previous version.
它们为开发者提供了一条清晰的路径。没有任何嵌套。就像 async/await,当自上而下阅读时,更容易推理。每个带返回的 if 语句都代表用户可以看到的一个状态。请注意,我们还将 data.assignee 的检查移入了最后一个分支。这是因为这是我们实际上想要渲染 UserInfo 的唯一一个分支。在之前的版本中这并不清楚。
Easy to extend 易于扩展
We can now also add more conditions, like error handling, without having to fear that we're breaking other states. It becomes as simple as dropping another if statement into our code.
我们现在还可以添加更多条件,比如错误处理,而不必担心会破坏其他状态。这变得和在我们的代码中添加另一个 if 语句一样简单。
Better type inference 更好的类型推断
Notice how the last check for data
is just gone? That's because TypeScript knows that data
must be defined after we've handled the if (!data)
case. TypeScript can't help us if we only conditionally render something.
注意最后对数据的检查消失了吗?那是因为 TypeScript 知道在处理了 if (!data) 的情况后,数据必须被定义。如果我们只是有条件地渲染某些内容,TypeScript 无法帮助我们。
Layout duplication 布局重复
I know some people are concerned about the duplication of rendering the <Layout>
component in each branch. I think they are focusing on the wrong thing. The duplication is not only fine, it will also help the component evolve better in case there might be a slight differentiation. For example, let's add a title
property from our data
to the heading:
我知道有些人担心在每个分支中重复渲染 组件。我认为他们关注的方向是错误的。重复不仅没有问题,而且如果可能存在轻微的差异,它也会帮助组件更好地发展。例如,让我们从我们的数据中添加一个标题属性到标题中:
1function Layout(props: { children: ReactNode; title?: string }) {2 return (3 <Card>4 <CardHeading>Welcome 👋 {props.title}</CardHeading>5 <CardContent>{props.children}</CardContent>6 </Card>7 )8}9
10export function ShoppingList() {11 const { data, isPending } = useQuery(/* ... */)12
13 if (isPending) {14 return (15 <Layout>16 <Skeleton />17 </Layout>18 )19 }20
21 if (!data) {22 return (23 <Layout>24 <EmptyScreen />25 </Layout>26 )27 }28
29 return (30 <Layout title={data.title}>31 {data.assignee ? <UserInfo {...data.assignee} /> : null}32 {data.content.map((item) => (33 <ShoppingItem key={item.id} {...item} />34 ))}35 </Layout>36 )37}
This would've been another top-level condition to mentally parse in the old version. Just note that adding more conditions to the Layout
component might indicate that it's the wrong abstraction. At this point, it's probably best to go to the drawing board again.
这在旧版本中会是另一个需要心理解析的顶级条件。请注意,向布局组件添加更多条件可能表明它是错误的抽象。此时,可能最好再回到绘图板上。
Learnings 学习成果
Maybe this post is more about early returns than it is about component composition. I think it's about both. In any case, it's about avoiding conditional renderings for mutually exclusive states. We can't do that without component composition, so make sure to not skip the drawing board. It's your best friend.
也许这篇文章更多是关于早期返回,而不是关于组件组合。我认为两者都是。在任何情况下,这篇文章是关于避免在相互排斥的状态下进行条件渲染。我们无法在没有组件组合的情况下做到这一点,所以一定要确保不跳过设计阶段。它是你最好的朋友。
That's it for today. Feel free to reach out to me on twitter
if you have any questions, or just leave a comment below. ⬇️
今天就到这里。如果您有任何问题,欢迎在推特上联系我,或者在下面留言。⬇️