React拆分组件的最佳方式

704 阅读12分钟

原文地址:www.developerway.com/posts/compo…

React常见问题

  • 如何以及何时拆分代码为单独组件
  • 合理组合组件

两个陷阱:

  • 一是不够及时地拆分组件,导致一个巨大的组件承载了很多功能,对后续维护是个噩梦
  • 过度、过早拆分,导致大量拆分组件的复杂组合,过度工程化,对维护来说同样很费力

这篇文章的目的是提供一些技巧和规则,能够帮助判断何时以及如何适时地提取组件,同时不会陷入过度工程化的陷阱。

首先来回顾下基础知识:什么是组合以及哪种组合模式适用于编程?

React组件的组合模式

简单组件

  • React的基础组件
  • props, state

props和state的组合同样会使简单组件同样能变得很复杂

举例:比如说接收 title 和 onClick 事件属性,并能够渲染标签的 Button 组件,就是一个简单组件。

const Button = ({ title, onClick }) => <button onClick={onClick}>{title}</button>;

任何组件都能够渲染其他组件-这就是组合。一个渲染按钮(Button)组件的导航(Navigation)组件-同样也是一个简单组件,组成了其他组件:

const Navigation = () => {
  return (
    <>
      // Rendering out Button component in Navigation component. Composition!
      <Button title="Create" onClick={onClickHandler} />
      ... // some other navigation code
    </>
  );
};

通过这些组件和它们的组合,我们可以实现任何需要的复杂UI。技术上来讲,我们甚至不需要其他模式和技术,这些只是改善代码重用或只解决特定用例的方法。

容器组件

与简单组件的唯一不同是,props 中允许传入特殊的一种-children。比如先前例子中的Button接收的不是 title 而是 children,那么写法就会是这样:

// the code is exactly the same! just replace "title" with "children"
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 组件,那么导航组件就成为 Button 和 Icon 的组合组件:

const Navigation = () => {
  return (
    <>
      <Button onClick={onClickHandler}>
        <!-- Icon component is rendered inside button, but button doesn't know -->
        <Icon />
        <span>Create</span>
      </Button>
      ...
      // some other navigation code
    </>
  )
}

导航组件控制传入 children 的元素类型,从 Button 的视角来看,它只是渲染了使用者端需要的元素。

这篇文章会进一步研究组合技术的实际示例。

还有其他的组合技术,比如高阶组件、将组件作为 props 或 context 传入,但是这些应该只用于非常特殊的用例。简单组件和容器组件是React开发技术的两大支柱,因此在试图介绍更高级的技术之前,最好摸清这两种的使用方式。

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

React开发和分解核心规则是:

  • 总是从顶层开始实现
  • 只在实际需要的时候才提取组件
  • 总是从「简单」组件开始写起,只在实际需要的时候引入其他组合技术

任何试图“提前”思考或从小型可重用组件“自下而上”开始的尝试,最终要么产生过于复杂的组件API,要么产生缺少一半必要功能的组件。

当一个组件需要分解时,第一个需要遵循的规则就是:组件太大。对我来讲组件的合适尺寸是组件代码行数可以完全适配于我笔记本电脑的屏幕。如果需要滚动阅读组件的代码-很明显它太大了

接下来开始用代码说明这在实际中是什么样的。我们会实现一个典型的 Jira 页面。

image.png

这是我个人项目的一张屏幕截图,分析一下图的结构:

  • 带有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>
  );
};

现在这段代码连必要内容的一半都没有实现,更不用提任何逻辑了,而组件已经足够大了,无法一眼读完。codeshandbox

这正是我们期待的!所以在继续之前,是时候将它分为几部分。

需要做的就是创建几个新的简单组件,然后将代码复制粘贴过来。

我打算创建 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组件使它足够小。

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 页的功能。

使用容器组件的最佳时机是什么时候?

现在来到了有趣的部分-我们来看下何时以及为何应该使用更高级的技术。从容器组件开始。

首先重新看下页面设计,具体来说,看下侧边栏的规划和发展部分(Planning and Development)

image.png 这俩不仅 Title 的设计相同,行为也相同:点击标题折叠区域,「折叠」模式下出现小箭头。我们将它们实现为两个不同的组件-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、JiraComponentPage等)会变成这样:

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 组件的整体重新渲染。尽管这在我们的基础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>
  );
};




这里可以看到所有容器组件的示例。渲染性能差的例子,在这里。在拖动侧边栏的时候注意看控制台的输出,在提取容器组件之前的例子中 PlanningSection 组件会非常频繁地打印,在修改之后的例子中只会在初始渲染时打印一次。

这个状态属于这个组件吗?

除了组件大小之外,另外一个表示组件应该分拆的信号是状态管理。或者准确地说,与组件功能无关的状态管理。

Jira侧边栏的一项是 「Add shortcut」,点击的时候会打开一个模态框。你会怎么实现呢?很明显模态框本身是个独立组件,但是你会在哪里定义控制它开关的状态呢?

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 没有关系,它控制的是在点击 shortcuts 的时候打开模态框。而且这种方式在可读性上不友好-首先看到的是个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>
  );
};

这里可以查看示例代码

同样的逻辑,我会将Topbar组件中控制菜单属性的状态移到 SomeDropdownMenu 组件中,与搜索相关的状态移动到 Search 组件中,以及与创建事务对话框相关的状态移动到CreateIssue组件中。

什么是好的组件?

最后进行总结:「在React中编写可扩展应用的秘诀是在合适的时机提取好的组件」。我们讨论了「合适时机」,但是到底什么样的组件才是一个「好的组件」?在我们讨论了关于组合的所有内容之后,我想我已经准备好在这里写一个定义和一些规则了:

  • 「好的组件」是我一眼就能轻松阅读和理解它的功能的组件
  • 「好的组件」应该有一个好的自我描述名称。Sidebar 用于呈现侧边栏的组件是一个好名字;处理问题创建的组件, CreateIssue 是一个好名字;用于呈现特定于事务页面的侧边栏项目的组件,SidebarController不是一个好名字(该名称表明该组件具有某种通用目的,而不是特定于特定页面)
  • 「好的组件」不会做与其声明目的无关的事情。Topbar组件只会渲染呈现在顶端栏的内容以及顶端栏行为,是一个好的组件;Sidebar组件,如果控制模态框的状态,那就不是一个好的组件。

构成「好的组件」的规则:

  • 组件大小,要能够允许不滚动屏幕就可阅读其实现代码
  • 名称,要能够表明组件的作用
  • 内部没有与组件功能无关的状态
  • 易读的实现

提取小组件的最佳时机

  • 组件太大的时候
  • 组件繁重的状态管理可能会影响到组件性能时
  • 组件维护了与组件自身功能无关的状态

一般组件的组合规则

  • 总是从顶部实现
  • 只在有实际用例的时候再提取组件,而非提前提取
  • 总是从简单组件开始实现,只在实际需要的时候引入高级技巧,而不是提前使用高级技巧

更多

关于更多模式以及对react性能影响的内容,可以阅读这几篇文章:

How to write performant React code: rules, patterns, do's and don'ts

Why custom react hooks could destroy your app performance

How to write performant React apps with Context