React 可组合 API 的设计原则
组件化是 React 的核心思想。组件化的影响力已经扩散到非常多的前端框架,成为了现代前端应用的通用开发方式。把独立组件组合起来使用,是对抗项目增长过程中复杂性快速上升的主要手段,它有助于将复杂的问题拆解成易于理解的问题。
这种开发方式也已经用于移动端的开发,包括 IOS 的 Swift UI 和 Andriod 的 Jetpack Compose。很显然,对组件组合是前端很好的一种开发方式。
组件组合的反面就是巨石组件,随着时间的推移,它们十分臃肿并且很难组合使用,而且修改这些代码的风险较大。为了能够复用这些组件,通常会将它们完整的复制到其他地方,然后做一些细微的变更来支持新的功能。
在本文中,我们将深入探讨用于分解组件和设计可组合 API 的主要原则。这样当我们在组件复用场景中,就可以更有效的使用组件组合。在介绍完这些原则之后,我们将尝试设计和实现一个可信任和复用的组件,即 Tabs 组件。并以 Tabs 组件为例,来了解我们需要解决的核心问题和做出的各种权衡。
什么是基于组合的 API
HTML 作为一种声明式编程,通过元素组合描述出网页上的具体内容。下面的示例是用 select 实现的下拉列表:
<select id="cars" name="cars">
<option value="audi">Audi</option>
<option value="mercedes">Mercedes</option>
</select>
当我们把这种组合应用在 React 的开发中时,它被称作 “复合组件” 模式。它的核心思想是,将多个组件协同工作,以实现某个具体的功能。
当我们谈到 API 时,可以将组件的 props 看作是组件的 公共 API,而组件可以看做是一个包 (package) 的 API。良好的 API 设计,就像那些困难且不明确的事情一样,需要经常根据反馈不断地花时间去迭代。
其中的一个挑战就是,API 会有多种不同类型的消费者。一些消费者只需要简单的应用场景;另外一些则对灵活性有更高的要求;除此之外,还有一些消费者要对那些难以预见的应用场景进行深度的定制。对于那些常用的前端组件,基于组合的 API,可以很好的抵御那些不可预见的应用场景以及不断变化的需求。
设计可组合的组件
我们如何将组件分解到正确的级别?在纯的自底向上的方法中,我们可能会创建太多的小组件,实际上并不需要创建这么多。自顶向下的方法(这往往更常见)也是不够的,这会导致巨石组件,这样的组件会有太多的 props 和太多的功能,很快就变得难于管理和维护。
当我们面临一个模棱两可的问题时,从最终用户开始着手去解决,以终为始,不失为一个好的办法。我们可以从消费者使用的组件 API 开始,去分解和设计组件。
稳定依赖原则可以帮助我们设计组件的 API,它有两个核心思想:
- 作为组件或包的消费者,我们希望我们依赖的东西能够保持稳定,不会随着时间的推移而发生变化,在此基础上完成我们的工作。
- 作为组件或包的开发者,我们希望将可能发生变化的东西封装起来,以保护消费者免于因任何变化而受到影响。
我们将在 Tabs 组件的设计开发中使用这个原则。
如何确定哪些组件是稳定的?
假如我们对什么是 Tabs 没有完整的概念,那么用视觉上看到的主要元素来设计组件,在某种程度上也是靠谱的。与更抽象的实体相比,这也是为 UI 元素设计 API 的便利之处。
在这种情况下,我们可以把 Tabs 想象成一个 list 列表(单击可以改变展示的内容)和 一个 content 内容区(根据当前选择的 tab 展示不同的内容)。基于此,我们把 Tabs 组件库的 API 设计成下面的样子(与 Reakit 和其他类似的开源组件库中的 API 相同)。
import { Tabs, TabsList, Tab, TabPanel } from '@mycoolpackage/tabs'
<Tabs>
<TabsList>
<Tab>first</Tab>
<Tab>second</Tab>
</TabsList>
<TabPanel>
hey there
</TabPanel>
<TabPanel>
friend
</TabPanel>
</Tabs>
与 HTML 的 select 元素类似,这些组件组合在一起实现某个功能,并且将状态分配给各个组件处理。遵循稳定依赖原则,意味着当我们调整组件的内部实现时,使用这些组件的消费者不需要做任何处理。
对消费者来说它既简单又灵活,但我们如何实现这些组件的底层逻辑呢?这才是我们需要面对的关键挑战。
要解决的潜在的问题
组件之间的内部逻辑
当我们完成对组件拆解之后,如何实现组件的交互逻辑,是我们面临的第一个问题。我们希望组件能够解耦,也就是说,它们感知不到彼此的存在。但我们也需要它们能够在一起实现特定的功能。
比如,微服务面临的挑战之一,是在不产生耦合的情况下,连接所有节点以使它们协作。同理,微型前端也是如此。
在我们的 Tabs 组件中,tab panels 按顺序嵌入到顶层 Tabs 中。我们需要实现组件内部交互逻辑,根据所选的 tab 来渲染出对应的内容。
渲染任意的子元素
另一个问题是,如何处理那些包裹在我们组件外面的组件。
<Tabs>
<TabsList>
<CustomTabComponent />
// etc ...
<ToolTip message="cool">
<Tab>Second</Tab>
</ToolTip>
</TabsList>
// etc ...
<AnotherCustomThingHere />
<Thing>
<TabPanel>
// etc ..
</TabPanel>
</Thing>
//...
</Tabs>
由于 Tabs 和它所包含的组件要按顺序在子树中进行关联,因此我们需要记录不同的索引,以便能够正确的选择上一项和下一项。
我们也需要处理焦点管理、键盘导航之类的事情,有多少组件来包裹我们的组件是不确定的。
消费者可以在组件之间插入任意数量组件或元素,这对于我们也是个挑战。
我们需要忽略那些不是我们的组件,来保持正确的相对顺序。这里有两种实现方式:
1. 在 React 中跟踪所有我们的组件
在这种方法中,我们将元素及其相对顺序存储在某个数据结构中。但是这种处理方法有点复杂。因为在默认情况下,我们不知道组件在树中的位置,所以我们需要以某种方式跟踪所有子组件。
一个可行的方法是,在子组件装载和卸载时,对它们进行“注册”和“注销”,这样顶级的父组件可以直接访问它们。Reach UI 和 Chakra 组件库采用的就是这种方法,这种方法的底层实现比较复杂,但对消费者来说很灵活。我们在这里不深入探讨这种方法。
2. 从 DOM 读取
在这种方法中,我们在组件底层的 HTML 上附加唯一 ID 或 data-attribute。我们使用这些存储在 HTML 属性中的索引来查询 DOM,就可以获取下一个或上一个元素。
在本例中,我们将在 React 中访问 DOM,虽然它与常用的React 代码风格背道而驰,但是胜在实现起来比较简单。
首先我们需要深入了解规则,然后知道什么时候可以打破规则。封装可以让消费者不必关注实现的细节,消费者只需将组件组合在一起,就可以实现想要的功能。
封装和稳定依赖原则的最大好处是,把所有混乱的细节进行隐藏。本文将采用这种方法,因为它最简单。
完成我们的 Tabs 组件开发
到目前为止,我们已经完成对组件的分解,并了解了我们需要解决的主要挑战。现在让我们看看如何实现这些独立组件的内部逻辑,以便它们能够协同工作,最终实现 Tabs 功能。
处理逻辑
这里有一些我们底层处理元素行为的方案,而且也能保持外部 API 简单易用。让我们来看一下这些方案。
元素复制
React 提供了一个用于元素复制的 API,可方便我们传递一些 props。我们的 Tabs 组件可以把所有属性和事件传递给子组件,而组件的使用者不会看到任何代码。
React.Children.map(children, (child, index) => (
React.cloneElement(child, {
isSelected: index === selectedIndex,
// other stuff we can pass as props "under the hood"
// so consumers of this can simply compose JSX elements
})
))
这是教程中用于介绍复合组件模式的最常见的示例。
元素复制的局限
元素复制的主要问题是不够灵活,例如它不能适用于组件被包裹的场景:
<Tabs>
<TabsList>
// can't do this
<ToolTip message="oops"><Tab />One</Tooltip>
</TabsList>
</Tabs>
在这种场景下我们复制的是 ToolTip 组件而不是 Tab 组件。我们也可以深度拷贝所有的元素,但是需要遍历所有子组件才能找到正确的 Tab 组件,增加了实现成本,而且对于那些不是以子组件渲染的场景也不适用:
const MyCustomTab = () => <Tab>argh</Tab>
<TabsList>
<Tab>hey</Tab>
<MyCustomTab />
</TabsList>
如果项目使用了 Typescript,cloneElement 也不能保证类型安全。这个方法尽管是最简单的,由于不够灵活,所以我们在这里不使用它。
Render props
另外一种方法是用 props,props 将所有必要的数据和属性(例如内部的 onChange)暴露给消费者,消费者可以使用这些属性来 “连接” 那些自定义的组件。
这是在实践中使用了控制反转的一个例子,两个独立的组件能够灵活地一起工作,最终实现了某些功能,换句话说这就是 -- 组合。
比如,假设我们有一个通用的内联可编辑的功能组件(InlineEdit),该字段具有只读视图(readView),单击时会切换到编辑视图(editView),消费者可以在其中插入自定义组件:
<InlineEdit
editView={(props) => (
// expose the necessary attributes for consumers
// so they can place them directly where they need to go
// in order for things to work together
<SomeWrappingThing>
<AnotherWrappingThing>
<TextField {...props }/>
</AnotherWrappingThing>
</SomeWrappingThing>
)}}
// ... etc
/>
对于这种的独立组件来说,这是一种很好的方法。对于我们的Tabs 复合组件,也是可行的;但对于那些想要简单明了的Tabs 组件的消费者来说,这些是额外的负担。
值得注意的是,Reach UI 的 tab API 允许常规 children 和函数作为 children,以实现其 API 的最大程度的灵活性。
使用 React Context
这是一种灵活而直接的方法。其中子组件从共享 Context 中读取数据。我们可以采用这种方法,但是前提是要解决 Context 的渲染问题。要将 state 分解为合适的大小,来优化重新渲染。在拆解组件的时候,我们要先回答 “每个组件的完整且最小的状态是什么”。
开发组件
为了让示例简单且易于理解,在这里省略了实际需要的一些东西,比如元素样式、类型检查、memoization 优化等。我们首先从 state 开始,将 state 分解到每个单独的 Context 中。
const TabContext = createContext(null)
const TabListContext = createContext(null)
const TabPanelContext = createContext(null)
export const useTab = () => {
const tabData = useContext(TabContext)
if (tabData == null) {
throw Error('A Tab must have a TabList parent')
}
return tabData
}
export const useTabPanel = () => {
const tabPanelData = useContext(TabPanelContext)
if (tabPanelData == null) {
throw Error('A TabPanel must have a Tabs parent')
}
return tabPanelData
}
export const useTabList = () => {
const tabListData = useContext(TabListContext)
if (tabListData == null) {
throw Error('A TabList must have a Tabs parent')
}
return tabListData
}
将这些 state 分解成较小的 state,而不是单一的大型 context,我们可以获得以下几点收益:
-
较小的状态更容易优化重新渲染。
-
状态管理的边界比较清晰(单一职责)。
-
如果消费者需要实现一个完全自定义的 Tab 版本,他们可以导入这些状态管理 Hooks,以便进行逻辑复用。因此,至少我们可以复用公共状态管理逻辑。
这些 context provider 中的每一个都将提供数据和可访问性属性,这些属性将传递到 UI 组件中,以连接所有内容并实现我们的 Tabs 组件。
Tabs 为 TabPanel 提供了数据,TabsList 为 Tab 提供了 context。因此,在示例中,需要确保在预期的父 context 中渲染各个组件。
Tab 和 TabPanel
我们的 Tab 和 TabPanel 是简单的 UI 组件,它们从 context 中获得必要的状态并渲染子组件。
export const Tab = ({ children }) => {
const tabAttributes = useTab()
return (
<div {...tabAttributes}>
{children}
</div>
)
}
export const TabPanel = ({ children }) => {
const tabPanelAttributes = useTabPanel()
return (
<div {...tabPanelAttributes}>
{children}
</div>
)
}
TabsList
这里是 TabsList 组件的简化版本。它负责管理 Tabs 中的列表,用户可以与它交互来更改展现的内容。
export const TabsList = ({ children }) => {
// provided by top level Tabs component coming up next
const { tabsId, currentTabIndex, onTabChange } = useTabList()
// store a reference to the DOM element so we can select via id
// and manage the focus states
const ref = createRef()
const selectTabByIndex = (index) => {
const selectedTab = ref.current.querySelector(
`[id=${tabsId}-${index}]`
)
selectedTab.focus()
onTabChange(index)
}
// we would handle keyboard events here
// things like selecting with left and right arrow keys
const onKeyDown = () => {
// ...
}
// .. some other stuff - again we're omitting styles etc
return (
<div role="tablist" ref={ref}>
{React.Children.map(children, (child, index) => {
const isSelected = index === currentTabIndex
return (
<TabContext.Provider
// (!) in real life this would need to be restructured
// (!) and memoized to use a stable references everywhere
value={{
key: `${tabsId}-${index}`,
id: `${tabsId}-${index}`,
role: 'tab',
'aria-setsize': length,
'aria-posinset': index + 1,
'aria-selected': isSelected,
'aria-controls': `${tabsId}-${index}-tab`,
// managing focussability
tabIndex: isSelected ? 0 : -1,
onClick: () => selectTabByIndex(index),
onKeyDown,
}}
>
{child}
</TabContext.Provider>
)
}
)}
</div>
)
}
Tabs
最后是我们的顶层组件。它将渲染 tab 列表和当前选中的 tab 面板。它将必要的数据传递给 TabsList 和 TabPanel 组件。
export const Tabs = ({ id, children, testId }) => {
const [selectedTabIndex, setSelectedTabIndex] = useState(0)
const childrenArray = React.Children.toArray(children)
// with this API we expect the first child to be a list of tabs
// followed by a list of tab panels that correspond to those tabs
// the ordering is determined by the position of the elements
// that are passed in as children
const [tabList, ...tabPanels] = childrenArray
// (!) in a real impl we'd memoize all this stuff
// (!) and restructure things so everything has a stable reference
// (!) including the values pass to the providers below
const onTabChange = (index) => {
setSelectedTabIndex(index)
}
return (
<div data-testId={testId}>
<TabListContext.Provider
value={{ selected: selectedTabIndex, onTabChange, tabsId: id }}
>
{tabList}
</TabListContext.Provider>
<TabPanelsContext.Provider
value={{
role: 'tabpanel',
id: `${id}-${selectedTabIndex}-tab`,
'aria-labelledby': `${id}-${selectedTabIndex}`,
}}
>
{tabPanels[selectedTabIndex]}
</TabPanelsContext.Provider>
</div>
)
}
本文是在介绍组件如何在一起组合成更复杂的结构,为了简单起见,我们省略了许多实际所需的实现细节,需要考虑的其他方面:
- 添加一个“受控”版本,消费者可以连接到该版本,以实现自己的 onChange 事件
- 扩展 API 以包括默认选定的 tab、tab 对齐样式等
- 优化重新渲染
- 处理 RTL 样式和国际化
- 确保类型安全
- 缓存被访问过的 tab 的选项(当我们点选其他 tab 时,将卸载当前选定的 tab)
在我们的示例中,我们假设 TabsList 是 Tabs 的第一个子元素。这里假定 Tabs 始终位于顶层。虽然灵活性受限,但如果不加以管理,过多的灵活性会成为一种诅咒。这里需要有一个很好的平衡。
如果这是一个组织中更大系统中的一部分,那么在保持体验视觉的一致性是很重要的。因此,我们可能希望强制 Tabs 始终位于顶部。
如果要追求完全的灵活(如 Reach),需要做更多的工作,同时灵活性会带来用户体验上的变化。这算是一种权衡。
稍后,我们将讨论如将分层与组合结合到一起工作,在理想情况下,我们将有一个灵活的基础组件库,用于构建更特定类型的组件。就像 tabs 总是在顶部一样。
稍后,我们将讨论分层的概念如何与组合一起工作,理想情况下,我们将有一个灵活的基础组件,用于构建更特定类型的组件,并加入意见。就像标签总是在顶部一样。
考虑到这一点,您可能会想,对于一个简单的 tabs 组件来说,这需要做很多的工作。是的,组件 API 设计可能是一个艰难的平衡。在构建真正可访问和可重用的组件时,就是要在底层实现上做很多事情。
这也是为什么 Web Components 有如此大潜力的原因之一,它可以使我们不必不断地重新实现这些类型的组件。由于篇幅有限,这里就不再展开进行讨论。
测试我们的组件
我们如何测试这些组件?多个独立组件要在一起工作。通常,我们希望从最终用户的角度进行测试。这种测试最佳实践称为黑盒测试。
因此,我们将创建测试用例,将各种组件组合在一起,作为基础用例,以及一些特殊场景,如消费者渲染自定义组件。
这类测试可以参照 Reach UI 的 Tabs 实现,那里有非常详细的例子。
如何扩展
这里需要涉及两个方面,一个是在大型项目中扩展重用,另一个是性能:
- 团队之间共享代码
一种常见的情况是,团队需要使用已有的组件,但有一些变化。如果这些组件在底层没有组合得很好,那么通常很难进行重构和扩展以支持新的场景。
为了减少风险,通常对代码复制粘贴会更加保险,然后按需作出调整,就可以使用这些代码了。这会导致大量相似但又略有不同的组件。这现象是一种常见的反模式,被称为 “散弹式修改”。
基于控制反转实现的组合 API,让我们放弃了组件可以处理所有场景的想法,也放弃了 100% 开箱即用。相反,我们将聚焦于那些稳定的、核心的底层复用,带来的效果会更加显著。
- 性能
首先是 bundle 包的大小。使用边界清晰的、较小的独立组件,可以更容易地对不立即需要的组件进行代码拆分,或者在交互中按需加载这些组件。消费者应该只为他们使用的东西付费。
其次是运行时性能(React重新渲染)。与每次重新渲染一个巨石组件相比,独立的组件更容易被 React 看到那些内容需要被重新渲染。
向上组合
我们的想法是从一个顶级组件开始,然后朝着它自底向上地构建。在自底而上构建之前,我们需要先确定一个目标,以了解我们正在朝着什么方向构建。
我们的 Tabs 组件是由更小的组件组成的。这种模式可以一直应用到应用程序的顶部。功能源于不同组件之间的组合;应用程序则是不同功能的组合。组合不断地上升。
让我们探究一下组合与软件工程中的分层原则之间的关系。我们可以通过分层的方式,来理解 React 应用程序中更高层级的组合:
-
基础层:一系列通用设计标记、常量和变量,这些会被可共享复用的组件库使用。
-
基础组件:组件库使用的类库,这些类库是基础层的组合,用于帮助构建组件库中可用的组件。比如,Pressable 组件被 Button 和 Link 使用。
-
可共享(可复用)组件库:共享类库和基础组件的组合,以提供一组常用的 UI 元素,如 Button 和 Tabs 等。这些是支撑更上层的 “地基”。
-
特定产品中通用可共享组件的适配:例如,产品中通常使用的“有机体”,可能将几个组件库组件包装在一起。在组织内的共享功能。
-
特定产品中的专用组件:例如,在我们的产品中,Tabs 组件可能需要调用 API 来确定要渲染的 tab 和内容。可以将其包装为 <ProductTabs/>,在它里面使用我们的 Tab 组件。
这些并不是死规定,只是用来说明这些原则如何用来设计实现可复用的组件库,以及如何在整个前端架构的更高层级中应用这些原则。不难发现,较高的层是由较低的层暴露的 API 组合而成。
总结
我们在本文中介绍了很多内容。最后,让我们回顾一下分解组件和设计组合 API 的基本原则:
- 稳定依赖原则:始终牢记为最终用户创建 API 和组件。我们希望依赖稳定的部分,隐藏混乱的部分。
- 单一责任原则:封装单一的关注点。更易于测试、维护,更重要的是 -- 组合。
- 控制反转:放弃我们可以预见未来场景的想法,并授权消费者插入他们自己的东西。
与其他问题一样,这里没有银弹,灵活性意味着权衡。其关键是要了解优化的目的和原因,这样就可以减少决策带来的权衡。或者简单的接受它们,将其作为投入时间和资源的最佳选择。
在本文中,我们实现了一个可以跨团队可灵活复用的组件。对于这样的组件,主要的权衡是消费者为了按预期的方式使用组件,需要完成外部逻辑的处理。另外,清晰的指导方案、详细的文档以及可复制粘贴的示例代码同样有助于减轻这种权衡。
转载自:https://juejin.cn/post/7152528132027711502