【译】React组件组合:如何做到恰到好处

435 阅读16分钟

关于原作者

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 就会由 ButtonIcon 组件组合而成:

const Navigation = () => {
  return (
    <>
      <Button onClick={onClickHandler}>
        <!-- 在 Button 中 渲染 Icon 组件, 但是 Button 却不知情 -->
        <Icon />
        <span>Create</span>
      </Button>
      ...
      // some other navigation code
    </>
  )
}

Navigatino 组件控制 children 中包含什么内容,从 Button 的角度来看,它只是渲染消费者想要的内容。

我们将在文章中进一步探讨这种技术的实际示例。

还有其他组合模式,例如高阶组件将组件作为属性传递上下文,但这些应该仅用于非常特定的用例。简单组件和容器组件是 React 开发的两个主要支柱,最好在尝试引入更高级的技术之前完善它们的使用。

现在,你已经了解了它们,可以准备实现尽可能复杂的用户界面了!

好的,我在开个玩笑,我不会写一篇“如何画猫头鹰”的文章 😅

image.png

现在是时候提供一些规则和指南,以便我们可以轻松地构建复杂的 React 应用程序了。

什么时候是提取组件的好时机?

这些是我喜欢遵循的核心 React 开发和分解规则,而且我编写的代码越多,我对它们的坚持程度就越强:

  1. 总是从顶部开始实现。
  2. 只有在确实需要时才提取组件。
  3. 总是从“简单”组件开始,只有在确实需要时才引入其他组合技术。

任何尝试提前思考或从小型可重用组件“自底向上”开始的尝试,最终都会导致要么过于复杂的组件API,要么缺少必要功能的组件。

将组件分解为较小组件的最重要规则是,当一个组件太大时。对我来说,一个组件的合适大小是它可以完全显示在我的笔记本电脑屏幕上。如果我需要滚动才能阅读组件的代码 - 这是一个明显的迹象,表明它太大了。

现在让我们开始编码,看看在实践中如何实现这一点。今天我们将从头开始实现一个典型的 Jira 页面。

image.png

这是我个人项目中的一个 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 中查看它。看上去是符合预期的!因此,在继续之前,是时候将其拆分为更易管理的组成部分了。

image.png

我唯一需要做的就是创建一些新组件,并将代码复制粘贴到它们中。我(暂时)还没有使用任何高级技术,所以一切都将是简单组件。

我将创建一个 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 页面。

何时引入容器组件?

现在来看看什么时候我们应该引入一些高级技巧以及为什么。首先,让我们再次看看设计。更具体地说,看一下侧边栏菜单中的“计划”和“开发”部分。

image.png

这两个部分不仅共享相同的标题设计,还共享相同的行为:单击标题会折叠部分,在“折叠”模式下会出现小箭头图标。而我们实际上将它们实现为两个不同的组件 - PlanningSectionDevelopmentSection。当然,我可以在它们两个中都实现“折叠”逻辑,毕竟这只是一个简单的状态问题:

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(以及未来的 JiraProjectPageJiraComponentsPage 等,所有从侧边栏访问的未来页面)将仅仅成为这样:

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 组件会不断记录日志,而在“好”的实现中只会记录一次。

image.png

如果您想了解更多关于各种模式以及它们如何影响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中编写可扩展应用程序的秘诀就是在合适的时间提取好的组件,没有其他。

什么构成了一个好的组件?

  • 大小,允许在不滚动的情况下阅读它
  • 名称,指示其功能的名称
  • 没有不相关的状态管理
  • 易于阅读的实现

何时将一个组件拆分成较小的组件?

  • 当一个组件太大时
  • 当一个组件在执行复杂状态管理操作且可能影响性能时
  • 当一个组件管理一个不相关的状态时

通常的组件组合规则是什么?

  • 总是从最顶层开始实现
  • 仅在真正需要时提取组件,而不是提前提取
  • 始终从简单组件开始,只有在真正需要时才引入高级技术,而不是提前引入

今天就到这里,希望您喜欢阅读并找到它有用!下次见 ✌🏼