本文翻译自 A Better Frontend Component Structure: Component Trees,作者:William Bernting, 略有删改。
自很久以前遵循互联网上的建议以来,我一直采用了某种“能工作就行
”的组件结构。
场景
让我们首先想象一个简化的前端应用程序目录结构,如下所示:
public/
some-image.jpg
pages/
index.tsx
components/
Heading.tsx
Logo.tsx
Layout.tsx
BoxContainer.tsx
Footer.tsx
问题所在
上面的简单应用程序结构很难解释这些组件之间是如何相互作用的。
例如,您可能会猜测Layout.tsx
导入Footer.tsx
和Header.tsx
,而这又可能导入BoxContainer.tsx
。但这仅仅从文件结构上是不清楚的。
更糟糕的是,随着应用程序的增长,组件列表将变得越来越难以推断它们是如何依赖的。
简单方法:扁平组件结构
通常首先想到的是将组件组织到语义正确的目录中。
下面是这种方法的典型结果:
public/
some-image.jpg
pages/
index.tsx
components/
layout/
Layout.tsx
Heading.tsx
Footer.tsx
common/
Heading.tsx
BoxContainer.tsx
问题#1:很难扩展好名字
作为一个开发人员,您会尝试为每个目录创建好的名称和分类,如containers
、headings
等。
问题是您需要为目录考虑更多的分类,而不仅仅是组件名称。
你经常会忍不住说,“我就把这个移到公共目录吧。”拥有common目录是你所追求的目标相悖的反模式,但是在这种结构下,很容易被吸引进入其中。
而且当应用程序变得足够大时,您可能不得不开始考虑创建另一级目录来保持内容的组织性。
这需要创建更多的名称,增加了存储库用户的认知负荷。最终这种方法无法很好地扩展。
问题#2:目录名称的认知负荷增加
在此之前,那些浏览代码库的人首先会通过组件的名称以及它们之间的关系来初步了解每个组件的功能。
现在他们还需要理解你创建的目录名称,如果这些名称在语义上不符合整体,这可能会使他们更加困惑。
更好的方法:组件树模式
使用这种方法,您的重点是拥有命名良好的组件,这些组件隐式地解释了它们的组成,而不用特意对具有不同名称的组件组进行分类。
组件导入规则
- 可以向上导入,除了它自己的父级
- 可以导入同级
- 无法导入同级组件
- 无法导入其父级
public/
some-image.jpg
pages/
index.tsx
components/
Layout/
components/
Heading/
components/
Logo.tsx
Menu.tsx
Heading.tsx
CopyrightIcon.tsx
Footer.tsx
Layout.tsx
BoxContainer.tsx
让我们展示Footer.tsx
的内容,并使用上面列出的规则作为示例:
// components/Layout/components/Footer.tsx
// Can import upwards, except its own parent
import { BoxContainer } from '../../BoxContainer.tsx';
// Can import siblings
import { CopyrightIcon } from './CopyrightIcon.tsx';
// WRONG: Cannot import sibling's components
// import { Menu } from './Heading/components/Menu.tsx';
// WRONG: Cannot import its parent
// import { Layout } from '../Layout.tsx';
export const Footer = () => (
<BoxContainer>
<CopyrightIcon />
<p>All rights reserved, etc.</p>
</BoxContainer>
)
优点#1:明显的子组件关系
组件树模式消除了猜测;组件之间的关系立即变得清晰明了。例如,Menu.tsx
作为 Heading.tsx
的内部依赖被整齐地嵌套在其中。
同样清晰的是Menu.tsx
没有被其他任何组件使用,这有助于您在日常开发任务中清理代码时尽早忽略它。
优点2:可重用性的定义更加细致入微
在简单的方法中,组件被分为“常见”和“非常见”两种。考虑到可重用性,组件树有助于避免这种无效的二元思维。
components/
Layout/
components/
Heading/
components/
- Logo.tsx
Menu.tsx
Heading.tsx
+ Logo.tsx
CopyrightIcon.tsx
Footer.tsx
Layout.tsx
BoxContainer.tsx
在上面的例子中,如果Logo.tsx
对于更多的组件变得必要,而不仅仅是Menu.tsx
,我们可以简单地将其上移一级。对于BoxContainer.tsx
来说,它可能没有足够的可重用性(或“通用性”),但在Layout.tsx
组件的上下文中它是足够可重用的。
优点#3:尽量减少命名
由于您有组件树,因此不需要将目录名分类在组件名之上。组件名称是分类,当您看到组件由哪些内部组件组成时,为组件确定好的名称也会更容易。
额外的好处:从组件中提取代码到单独的文件中,而无需考虑名称
现在我们考虑一种情况,您希望从Footer.tsx
中提取一些实用程序函数,因为文件变得有点大,并且您认为可以从中分解一些逻辑,而不是分解更多的UI。
虽然你可以创建一个utils/
目录,但这会迫使你选择一个文件名来放置你的实用函数。
相反,选择使用文件后缀,如Footer.utils.tsx
或Footer.test.tsx
。
components/
Layout/
components/
Heading/
components/
Logo.tsx
Menu.tsx
Heading.tsx
CopyrightIcon.tsx
+ Footer.utils.tsx
Footer.tsx
Layout.tsx
BoxContainer.tsx
这样你就不必去想一个很合适的名字,如emailFormatters.ts
或非常模糊的东西,如helpers.ts
。避免命名带来的认知负担,这些实用程序属于Footer.tsx
,可以由Footer.tsx
及其内部组件使用(再次向上导入)。
组件树的反驳观点
“太多的组件目录了”
第一次看到这个结构,这是大多数人的下意识反应。
是的,有很多“组件”目录。但当我与团队一起确定项目结构时,我总是强调清晰度的重要性。
我在一个代码库中衡量成功的方法之一是高级和初级开发人员对于清晰度的看法,而在这方面,我发现组件树总是对实现这个目标起到重要作用。
“呃:import … from ./MyComponent/MyComponent.tsx?
虽然import … from ./MyComponent/MyComponent.tsx
可能看起来不漂亮,但它直接指示组件来自哪里带来的清晰度更重要。
关于导入字符串,以下是为开发人员增加认知负荷的示例。
- 使用像
import ... from 'common/components'
这样的导入别名对开发人员来说是一种精神负担 - 到处都有
index.ts
文件,只需要写import ... from './MyComponent'
。但对于按文件搜索的开发人员来说,找到正确的文件可能需要更多的时间。
最终比较:复杂场景
多亏了像ChatGPT这样的工具,为更复杂的场景测试这样的模式非常容易。
在解释了结构之后,我让ChatGPT在左列生成“平面”目录结构,在右边生成我称为“组件树”的结构。
Flat Structure | Component Trees
------------------------------------+---------------------------------------------------
pages/ | pages/
index.tsx | index.tsx
shop.tsx | shop.tsx
product/ | product/
[slug].tsx | [slug].tsx
cart.tsx | cart.tsx
checkout.tsx | checkout.tsx
about.tsx | about.tsx
contact.tsx | contact.tsx
login.tsx | login.tsx
register.tsx | register.tsx
user/ | user/
dashboard.tsx | dashboard.tsx
orders.tsx | orders.tsx
settings.tsx | settings.tsx
|
components/ | components/
layout/ | Layout/
Layout.tsx | components/
Header.tsx | Header/
Footer.tsx | components/
Sidebar.tsx | Logo.tsx
Breadcrumb.tsx | NavigationMenu.tsx
common/ | SearchBar.tsx
Button.tsx | UserIcon.tsx
Input.tsx | CartIcon.tsx
Modal.tsx | Header.tsx
Spinner.tsx | Footer/
Alert.tsx | components/
product/ | SocialMediaIcons.tsx
ProductCard.tsx | CopyrightInfo.tsx
ProductDetails.tsx | Footer.tsx
ProductImage.tsx | Layout.tsx
ProductTitle.tsx | BoxContainer.tsx
ProductPrice.tsx | Button.tsx
AddToCartButton.tsx | Input.tsx
filters/ | Modal.tsx
SearchFilter.tsx | Spinner.tsx
SortFilter.tsx | Alert.tsx
cart/ | ProductCard/
Cart.tsx | components/
CartItem.tsx | ProductImage.tsx
CartSummary.tsx | ProductTitle.tsx
checkout/ | ProductPrice.tsx
CheckoutForm.tsx | AddToCartButton.tsx
PaymentOptions.tsx | ProductCard.tsx
OrderSummary.tsx | ProductDetails/
user/ | components/
UserProfile.tsx | ProductSpecifications.tsx
UserOrders.tsx | ProductReviews.tsx
LoginBox.tsx | ProductReviewForm.tsx
RegisterBox.tsx | ProductDetails.tsx
about/ | SearchFilter.tsx
AboutContent.tsx | SortFilter.tsx
contact/ | Cart/
ContactForm.tsx | components/
review/ | CartItemList.tsx
ProductReview.tsx | CartItem.tsx
ProductReviewForm.tsx | CartSummary.tsx
address/ | Cart.tsx
ShippingAddress.tsx | CheckoutForm/
BillingAddress.tsx | components/
productInfo/ | PaymentDetails.tsx
ProductSpecifications.tsx | BillingAddress.tsx
cartInfo/ | ShippingAddress.tsx
CartItemList.tsx | CheckoutForm.tsx
userDetail/ | PaymentOptions.tsx
UserSettings.tsx | OrderSummary.tsx
icons/ | UserProfile/
Logo.tsx | components/
SocialMediaIcons.tsx | UserOrders.tsx
CartIcon.tsx | UserSettings.tsx
UserIcon.tsx | UserProfile.tsx
| LoginBox.tsx
| RegisterBox.tsx
| AboutContent.tsx
| ContactForm.tsx
这是一个没有任何测试文件、实用程序文件或类似文件的示例。
对于组件树结构,您可以在组件目录中添加后缀为的实用程序或测试文件。
至于平面结构,你可能需要创建一个单独的 utils
目录来理解已经相当复杂的认知负荷。
最后
有机会的话可以尝试这个组件结构。你会发现它是如此的直观和高效,以至于不会再回到其他更复杂的结构,它们没有简化组件管理的能力。
看完本文如果觉得有用,记得点个赞支持,收藏起来说不定哪天就用上啦~
专注前端开发,分享前端相关技术干货,公众号:南城大前端(ID: nanchengfe)