关于原作者
Nadia 是一名前端架构师、编程人员、作者和教育工作者。
Nadia 曾在 Atlassian 工作,有机会接触各种不同类型的前端开发,包括Atlaskit、Jira 导航团队、Jira Ops 团队和 Jira 前端平台团队。最后一个团队负责整个 Jira 前端仓库(现在有数百万行代码!)的架构、构建、部署和最佳实践。所以那是有趣的时光 😅。
在为大型科技公司工作的愉悦和挑战之后,三年前,Nadia 决定换一个完全不同的视角,加入了一家名为 Pyn 的小型创业公司,担任创始工程师。
在过去的几年里,Nadia 不仅写了这个博客,还在世界各地的会议上发表演讲,从欧洲到澳大利亚:在阿姆斯特丹的 React Summit,在贝尔格莱德的 CODEstantine,以及在悉尼的 ReactConf 等等。
总之,说到React,Nadia 可以大胆地说她已经见过了一切。
原文链接 www.developerway.com/posts/compo…
本文涵盖的内容:
什么是组件组合?如何知道何时开始将大组件拆分成较小的部分,并如何正确组合它们?什么构成了一个好的组件?
目录
- React组件组合模式
- 什么时候是提取组件的好时机?
- 什么时候引入容器组件?
- 这个状态应该属于这个组件吗?
- 什么构成了一个好的组件?
- 结束总结要点
以下是译文:
在 React 中最有趣且具挑战性的事情之一,并不是掌握一些高级状态管理技巧或如何正确使用 Context。更难把握的是我们应该何时以及如何将代码分割成独立的组件,并正确组合它们。我经常看到开发者陷入两个陷阱:要么他们没有足够早地提取组件,最终形成了做太多事情的巨大组件“庞然大物”,维护起来是一场噩梦。或者,特别是在他们因之前的模式受挫几次后,他们过早地提取组件,这导致了多重抽象的复杂组合、过度设计的代码,再次,变成了维护的噩梦。
今天我想做的是提供一些技巧和规则,这些可以帮助识别何时以及如何及时提取组件,以及如何避免陷入过度设计的陷阱。但首先,让我们回顾一些基础知识:什么是组合,以及我们可以使用哪些组合模式?
React组件的组合模式
简单组件
简单组件是 React 的基本构建单元。它们可以接受属性(props),拥有一些状态 (state),尽管名字称之为简单组件,但它们可以相当复杂。一个接受标题(title)和点击事件处理函数(onClick)属性并渲染一个按钮标签的 Button 组件就是一个简单组件:
const Button = ({ title, onClick }) => <button onClick={onClick}>{title}</button>;
任何组件都可以渲染其他组件 - 这就是组合。一个导航(Navigation)组件可以渲染 Button(简单组件),那么它就组合了其他组件:
const Navigation = () => {
return (
<>
// 在 Navigatio 组件中 渲染 Button 组件. 这就是组合!
<Button title="Create" onClick={onClickHandler} />
... // some other navigation code
</>
);
};
通过这些组件及其组合,我们可以实现任意复杂的用户界面。从技术上讲,我们甚至不需要任何其他模式和技术,它们只是一些很好的附加功能,用于改善代码复用或仅解决特定的使用情况。
容器组件
容器组件是一种更高级的组合技术。与简单组件唯一的不同之处在于,它们除了其他属性之外,还允许传递特殊的 children 属性,对于这种属性,React 有其自己的语法。如果我们的前面示例中的 Button 组件接受的不是 title 而是 children,它会像这样写:
// the code is exactly the same! just replace "title" with "children"
// 这段代码几乎与上面的一致,只是用 chidren 替换了 title
const Button = ({ children, onClick }) => <button onClick={onClick}>{children}</button>;
这对于 Button 组件来说与 title 没有区别。区别在于使用组件的一方,children 语法是特殊的,看起来像普通的HTML标签:
const Navigation = () => {
return (
<>
<Button onClick={onClickHandler}>Create</Button>
... // some other navigation code
</>
);
};
children 中可以放入任何内容。例如,我们可以在文本之外添加一个 Icon 组件,然后 Navigation 就会由 Button 和 Icon 组件组合而成:
const Navigation = () => {
return (
<>
<Button onClick={onClickHandler}>
<!-- 在 Button 中 渲染 Icon 组件, 但是 Button 却不知情 -->
<Icon />
<span>Create</span>
</Button>
...
// some other navigation code
</>
)
}
Navigatino 组件控制 children 中包含什么内容,从 Button 的角度来看,它只是渲染消费者想要的内容。
我们将在文章中进一步探讨这种技术的实际示例。
还有其他组合模式,例如高阶组件、将组件作为属性传递或上下文,但这些应该仅用于非常特定的用例。简单组件和容器组件是 React 开发的两个主要支柱,最好在尝试引入更高级的技术之前完善它们的使用。
现在,你已经了解了它们,可以准备实现尽可能复杂的用户界面了!
好的,我在开个玩笑,我不会写一篇“如何画猫头鹰”的文章 😅
现在是时候提供一些规则和指南,以便我们可以轻松地构建复杂的 React 应用程序了。
什么时候是提取组件的好时机?
这些是我喜欢遵循的核心 React 开发和分解规则,而且我编写的代码越多,我对它们的坚持程度就越强:
- 总是从顶部开始实现。
- 只有在确实需要时才提取组件。
- 总是从“简单”组件开始,只有在确实需要时才引入其他组合技术。
任何尝试提前思考或从小型可重用组件“自底向上”开始的尝试,最终都会导致要么过于复杂的组件API,要么缺少必要功能的组件。
将组件分解为较小组件的最重要规则是,当一个组件太大时。对我来说,一个组件的合适大小是它可以完全显示在我的笔记本电脑屏幕上。如果我需要滚动才能阅读组件的代码 - 这是一个明显的迹象,表明它太大了。
现在让我们开始编码,看看在实践中如何实现这一点。今天我们将从头开始实现一个典型的 Jira 页面。
这是我个人项目中的一个 issue 页面的屏幕,我在其中保存了我在网上找到的喜爱的食谱 🍣。在这里,我们需要实现如下功能:
- 顶部带有 logo、一些菜单、"创建"按钮和搜索栏
- 左侧的侧边栏,包括项目名称、可折叠的"计划"和"开发"部分,内部有项目(也分为组),以及未命名的部分下面有菜单项
- 大的"页面内容"部分,显示当前问题的所有信息
那么,让我们从一个大组件开始编码所有这些功能。它可能会看起来像这样:
export const JiraIssuePage = () => {
return (
<div className="app">
<div className="top-bar">
<div className="logo">logo</div>
<ul className="main-menu">
<li>
<a href="#">Your work</a>
</li>
<li>
<a href="#">Projects</a>
</li>
<li>
<a href="#">Filters</a>
</li>
<li>
<a href="#">Dashboards</a>
</li>
<li>
<a href="#">People</a>
</li>
<li>
<a href="#">Apps</a>
</li>
</ul>
<button className="create-button">Create</button>
more top bar items here like search bar and profile menu
</div>
<div className="main-content">
<div className="sidebar">
<div className="sidebar-header">ELS project</div>
<div className="sidebar-section">
<div className="sidebar-section-title">Planning</div>
<button className="board-picker">ELS board</button>
<ul className="section-menu">
<li>
<a href="#">Roadmap</a>
</li>
<li>
<a href="#">Backlog</a>
</li>
<li>
<a href="#">Kanban board</a>
</li>
<li>
<a href="#">Reports</a>
</li>
<li>
<a href="#">Roadmap</a>
</li>
</ul>
<ul className="section-menu">
<li>
<a href="#">Issues</a>
</li>
<li>
<a href="#">Components</a>
</li>
</ul>
</div>
<div className="sidebar-section">sidebar development section</div>
other sections
</div>
<div className="page-content">... here there will be a lot of code for issue view</div>
</div>
</div>
);
};
现在,我甚至还没有实现一半所需的内容,更不用说任何逻辑了,这个组件已经太大了,无法一目了然地阅读。在 CodeSandbox 中查看它。看上去是符合预期的!因此,在继续之前,是时候将其拆分为更易管理的组成部分了。
我唯一需要做的就是创建一些新组件,并将代码复制粘贴到它们中。我(暂时)还没有使用任何高级技术,所以一切都将是简单组件。
我将创建一个 Topbar 组件,其中包含了与顶部栏相关的一切,Sidebar 组件,包含了与侧边栏相关的一切,还有一个 Issue 组件。这样,我们的主要 JiraIssuePage 组件将保留如下代码:
export const JiraIssuePage = () => {
return (
<div className="app">
<Topbar />
<div className="main-content">
<Sidebar />
<div className="page-content">
<Issue />
</div>
</div>
</div>
);
};
现在让我们来看一下新的 Topbar 组件的实现:
export const Topbar = () => {
return (
<div className="top-bar">
<div className="logo">logo</div>
<ul className="main-menu">
<li>
<a href="#">Your work</a>
</li>
<li>
<a href="#">Projects</a>
</li>
<li>
<a href="#">Filters</a>
</li>
<li>
<a href="#">Dashboards</a>
</li>
<li>
<a href="#">People</a>
</li>
<li>
<a href="#">Apps</a>
</li>
</ul>
<button className="create-button">Create</button>
more top bar items here like search bar and profile menu
</div>
);
};
如果我实现了所有的项目(搜索栏、所有子菜单、右侧的图标),那么这个组件也会变得太大,因此它也需要拆分。而且从技术上讲,我可以从中提取 MainMenu 组件,以使其足够小。这个情况可能比前一个更有趣,因为从技术上讲,我可以从中只提取 MainMenu 组件,以使其足够小。
export const Topbar = () => {
return (
<div className="top-bar">
<div className="logo">logo</div>
<MainMenu />
<button className="create-button">Create</button>
more top bar items here like search bar and profile menu
</div>
);
};
但只提取 MainMenu 组件使 Topbar 组件对我来说稍微难以阅读。之前,当我看 Topbar 时,我可以将其描述为“一个实现顶部栏各种内容的组件”,只有在需要时才关注细节。现在,描述将变为“一个实现顶部栏各种内容的组件,并组合了一些随机的 MainMenu 组件”。阅读流程被破坏了。
这让我想到了组件分解的第二条规则:在提取较小的组件时,不要半途而废。一个组件应该被描述为“实现各种内容的组件”或“将各种组件组合在一起的组件”,而不是两者兼而有之。
因此,Topbar组件的一个更好的实现将如下所示:
export const Topbar = () => {
return (
<div className="top-bar">
<Logo />
<MainMenu />
<Create />
more top bar components here like SearchBar and ProfileMenu
</div>
);
};
现在更容易阅读了!
Sidebar 组件也有相同的情况 - 如果我实现了所有项目,它也会变得太大,所以需要拆分它:
export const Sidebar = () => {
return (
<div className="sidebar">
<Header />
<PlanningSection />
<DevelopmentSection />
other sidebar sections
</div>
);
};
在 CodeSandbox 中查看完整示例。
然后,每当一个组件变得太大时,只需重复这些步骤。理论上,我们可以使用仅仅是简单组件来实现整个 Jira 页面。
何时引入容器组件?
现在来看看什么时候我们应该引入一些高级技巧以及为什么。首先,让我们再次看看设计。更具体地说,看一下侧边栏菜单中的“计划”和“开发”部分。
这两个部分不仅共享相同的标题设计,还共享相同的行为:单击标题会折叠部分,在“折叠”模式下会出现小箭头图标。而我们实际上将它们实现为两个不同的组件 - PlanningSection 和 DevelopmentSection。当然,我可以在它们两个中都实现“折叠”逻辑,毕竟这只是一个简单的状态问题:
const PlanningSection = () => {
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<div className="sidebar-section">
<div onClick={() => setIsCollapsed(!isCollapsed)} className="sidebar-section-title">
Planning
</div>
{!isCollapsed && <>...all the rest of the code</>}
</div>
);
};
但是:
- 即使在这两个组件之间也存在相当多的重复
- 这些部分的内容实际上对于每个项目类型或页面类型都是不同的,因此在不久的将来会有更多的重复
理想情况下,我希望封装折叠/展开行为的逻辑和标题的设计,同时让不同的部分对内部的内容有完全的控制。这是容器组件的一个完美用例。我只需从上面的代码示例中提取所有内容到一个组件中,并将菜单项作为 children 传递进去。我们将会有一个 CollapsableSection 组件:
const CollapsableSection = ({ children, title }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<div className="sidebar-section">
<div className="sidebar-section-title" onClick={() => setIsCollapsed(!isCollapsed)}>
{title}
</div>
{!isCollapsed && <>{children}</>}
</div>
);
};
而PlanningSection(以及DevelopmentSection和将来的所有其他部分)将变成这样:
const PlanningSection = () => {
return (
<CollapsableSection title="Planning">
<button className="board-picker">ELS board</button>
<ul className="section-menu">... all the menu items here</ul>
</CollapsableSection>
);
};
与我们的根 JiraIssuePage 组件将会有一个非常相似的情况。现在它看起来是这样的:
export const JiraIssuePage = () => {
return (
<div className="app">
<Topbar />
<div className="main-content">
<Sidebar />
<div className="page-content">
<Issue />
</div>
</div>
</div>
);
};
但是,一旦我们开始实现可以从侧边栏访问的其他页面,我们将看到它们都遵循完全相同的模式 - 侧边栏和顶部栏保持不变,只有“页面内容”区域发生变化。由于我们之前进行的分解工作,我们可以在每个页面上轻松复制粘贴该布局 - 毕竟这并不是太多的代码。但由于它们都完全相同,因此最好是提取实现所有共同部分的代码,只留下那些会根据特定页面而变化的组件。再次,这是“容器”组件的一个完美用例:
const JiraPageLayout = ({ children }) => {
return (
<div className="app">
<Topbar />
<div className="main-content">
<Sidebar />
<div className="page-content">{children}</div>
</div>
</div>
);
};
而我们的 JiraIssuePage(以及未来的 JiraProjectPage、JiraComponentsPage 等,所有从侧边栏访问的未来页面)将仅仅成为这样:
export const JiraIssuePage = () => {
return (
<JiraPageLayout>
<Issue />
</JiraPageLayout>
);
};
如果我想用一句话来总结这个规则,可以这样表述:当需要共享一些包装元素的可视化或行为逻辑,同时仍需要保留元素在“使用者”控制之下时,提取容器组件。
容器组件 - 性能用例
容器组件的另一个非常重要的用例是提高组件的性能。
在实际的 Jira 中,侧边栏组件是可拖拽的 - 你可以通过拖动其边缘左右调整其大小。我们如何实现类似的功能呢?可能我们会引入一个 Handle 组件,一些用于侧边栏宽度的状态,然后监听 mousemove 事件。一个初步的实现可能会看起来像这样:
export const Sidebar = () => {
const [width, setWidth] = useState(240);
const [startMoving, setStartMoving] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const changeWidth = (e: MouseEvent) => {
if (!startMoving) return;
if (!ref.current) return;
const left = ref.current.getBoundingClientRect().left;
const wi = e.clientX - left;
setWidth(wi);
};
ref.current.addEventListener('mousemove', changeWidth);
return () => ref.current?.removeEventListener('mousemove', changeWidth);
}, [startMoving, ref]);
const onStartMoving = () => {
setStartMoving(true);
};
const onEndMoving = () => {
setStartMoving(false);
};
return (
<div className="sidebar" ref={ref} onMouseLeave={onEndMoving} style={{ width: `${width}px` }}>
<Handle onMouseDown={onStartMoving} onMouseUp={onEndMoving} />
... the rest of the code
</div>
);
};
然而,这里存在一个问题:每次移动鼠标都会触发状态更新,然后将导致整个 Sidebar 组件重新渲染。虽然在我们的初步侧边栏上并不明显,但当组件变得更复杂时,这可能会导致拖动变得明显卡顿。容器组件是解决这个问题的完美方案:我们只需要在容器组件中提取所有繁重的状态操作,并通过 children 传递其他所有内容。
const DraggableSidebar = ({ children }: { children: ReactNode }) => {
// all the state management code as before
return (
<div
className="sidebar"
ref={ref}
onMouseLeave={onEndMoving}
style={{ width: `${width}px` }}
>
<Handle onMouseDown={onStartMoving} onMouseUp={onEndMoving} />
<!-- children will not be affected by this component's re-renders -->
{children}
</div>
);
};
而我们的 Sidebar 组件将变成这样:
export const Sidebar = () => {
return (
<DraggableSidebar>
<Header />
<PlanningSection />
<DevelopmentSection />
other Sections
</DraggableSidebar>
);
};
这样,DraggableSidebar 组件仍然会在每次状态更改时重新渲染,但它的代价非常低,因为它只是一个 div。而所有传递给 children 的内容都不会受到这个组件的状态更新的影响。
在这个 CodeSandbox 中查看所有容器组件的示例。如果要比较糟糕的重新渲染用例,请查看此 CodeSandbox。在这些示例中拖动侧边栏时,注意控制台输出 - 在“糟糕”的实现中,PlanningSection 组件会不断记录日志,而在“好”的实现中只会记录一次。
如果您想了解更多关于各种模式以及它们如何影响React性能的信息,您可能会发现以下文章很有趣:
这个状态是否应该属于这个组件?
除了组件的大小之外,另一个可能表明需要提取组件的因素是状态管理,或者更准确地说,与组件功能无关的状态管理。
在真实的 Jira 侧边栏中的一个项目是“添加快捷方式”,当你单击它时,它会打开一个模态对话框。在我们的应用程序中,你会如何实现它?模态对话框本身显然会成为它自己的组件,但是你会在哪里引入打开它的状态?类似这样吗?
const SomeSection = () => {
const [showAddShortcuts, setShowAddShortcuts] = useState(false);
return (
<div className="sidebar-section">
<ul className="section-menu">
<li>
<span onClick={() => setShowAddShortcuts(true)}>Add shortcuts</span>
</li>
</ul>
{showAddShortcuts && <ModalDialog onClose={() => setShowAddShortcuts(false)} />}
</div>
);
};
你可以在各处看到类似这样的实现,这种实现并没有什么不好。但如果我要实现它,并且如果我想从组合的角度使这个组件更加完美,我会将这个状态和与之相关的组件提取到外部。原因很简单 - 这个状态与 SomeSection 组件无关。这个状态控制一个模态对话框,当你单击快捷方式项时会出现。这让我稍微难以阅读这个组件 - 我看到一个 section 组件,下一行 - 与 section 毫不相干的某个随机状态。因此,与上面的实现不同,我会将实际属于这个项的项和状态提取到自己的组件中:
const AddShortcutItem = () => {
const [showAddShortcuts, setShowAddShortcuts] = useState(false);
return (
<>
<span onClick={() => setShowAddShortcuts(true)}>Add shortcuts</span>
{showAddShortcuts && <ModalDialog onClose={() => setShowAddShortcuts(false)} />}
</>
);
};
而且作为一个额外的好处,section 组件变得更简单了:
const OtherSection = () => {
return (
<div className="sidebar-section">
<ul className="section-menu">
<li>
<AddShortcutItem />
</li>
</ul>
</div>
);
};
在CodeSandbox上查看示例。
按照相同的逻辑,在 Topbar 组件中,我会将控制菜单的状态移到SomeDropdownMenu 组件中,将所有与搜索相关的状态移到 Search 组件中,将与打开“创建问题”对话框相关的一切移到 CreateIssue 组件中。
什么构成了一个好的组件?
在今天结束之前的最后一点,我想写下“编写可扩展的React应用程序的秘诀是在合适的时间提取好的组件”。关于“合适的时间”,我们已经讨论过了,但到底什么是“好的组件”呢?经过我们迄今所涵盖的有关组合的一切,我认为我已经准备好在这里写下一个定义和一些规则。
- 一个“好的组件”是一个我可以从第一眼轻松阅读和理解其功能的组件。
- 一个“好的组件”应该有一个具有良好自我描述性的名称。
- 对于渲染侧边栏的组件来说,
Sidebar是一个好的名称。 - 对于处理问题创建的组件来说,
CreateIssue是一个好的名称。 - 对于渲染特定“问题”页面的侧边栏项目的组件来说,
SidebarController不是一个好的名称(名称表明该组件具有某种通用目的,而不是特定于特定页面)。
- 对于渲染侧边栏的组件来说,
- 一个“好的组件”不会执行与其声明的目的无关的操作。
- 仅渲染顶部栏中的项目并仅控制顶部栏行为的
Topbar组件是一个好的组件。 - 控制各种模态对话框状态的
Sidebar组件则不是最佳的组件。
- 仅渲染顶部栏中的项目并仅控制顶部栏行为的
结束总结要点
在React中编写可扩展应用程序的秘诀就是在合适的时间提取好的组件,没有其他。
什么构成了一个好的组件?
- 大小,允许在不滚动的情况下阅读它
- 名称,指示其功能的名称
- 没有不相关的状态管理
- 易于阅读的实现
何时将一个组件拆分成较小的组件?
- 当一个组件太大时
- 当一个组件在执行复杂状态管理操作且可能影响性能时
- 当一个组件管理一个不相关的状态时
通常的组件组合规则是什么?
- 总是从最顶层开始实现
- 仅在真正需要时提取组件,而不是提前提取
- 始终从简单组件开始,只有在真正需要时才引入高级技术,而不是提前引入
今天就到这里,希望您喜欢阅读并找到它有用!下次见 ✌🏼