如何正确的实现组件复用

前端人

共 6251字,需浏览 13分钟

 ·

2021-09-25 00:07

在业务开发过程中,我们总是会期望某些功能一定程度的复用。很基础的那些元素,比如按钮,输入框,它们的使用方式都已经被大部分人熟知,但是一旦某块功能复杂起来,成为一种业务组件的时候,就会陷入一些很奇怪的境况,最初是期望抽出来的这块组件能有比较好的复用性,但是,可能当另外一个业务想要复用它的时候,往往遇到很多问题:

  • 不能满足需求
  • 为了满足多个业务的复用需求,不得不把组件修改到很别扭的程度
  • 参数失控
  • 版本无法管理

诸如此类,时常使人怀疑,在一个业务体系中,组件化到底应该如何去做?

本文试图围绕这个主题,给出一些可能的解决思路。

组件的实现

状态与渲染

通常,我们会有一些简单而通用的场景,需要处理状态的存放:

  • 被单独使用
  • 被组合使用

一般来说,我们有两种策略来实现,分别是状态外置和内置。

有状态组件:

const StatefulInput = () => {
  const [value, setValue] = useState('')

  return <input value={value} onChange={setValue} />
}

无状态组件:

type StatelessInputProps = {
  value: string
  setValue(v: string) => void
}

const StatelessInput = (props: StatelessInputProps) => {
  const { value, setValue } = props

  return <input value={value} onChange={setValue} />
}

通常有状态组件可以位于更顶层,不受其他约束,而无状态组件则依赖于外部传入的状态与控制。有状态组件也可以在内部分成两层,一层专门处理状态,一层专门处理渲染,后者也是一个无状态组件。

一般来说,对于纯交互类组件,将最核心的状态外置通常是更好的策略,因为它的可组合性需求更强。

使用上下文管控依赖项

我们在实现一个相对复杂组件的时候,有可能面临一些外部依赖项。

比如说:

  • 选择地址的组件,可能需要外部提供地址的查询能力

一般来说,我们给组件提供外置配置项的方式有这么几种:

  • 通过组件自身的参数(props)传入
  • 通过上下文传入
  • 组件自己从某个全局性的位置引入

这三种里面,我们需要尽可能避免直接引入全局依赖,举例来说,如果不刻意控制外部依赖,就会存在许多在组件中直接引用 request 的情况,比如说:

import request from 'xxx'

const Component = () => {
  useEffect(() => {
    request(xxx)
  }, [])
}

注意这里,我们一般意识不到直接 import 这个 request 有什么不对,但实际上,按照这个实现方式,我们可能在一个应用系统中,存在很多个直接依赖 request 的组件,它的典型后果有:

  1. 一旦整体的请求方式被变更,比如添加了统一的请求头或者异常处理,那就可能改动每个组件。

这个问题,可能有的研发团队中会选择先封装一下 request,然后再引入,这是可以消除这种问题的。

  1. 如果多个不同的项目合并集成了,就存在多种不同的数据来源,不一定能做到直接统一这个请求配置。

因此,要尽量避免直接引入全局性的依赖,哪怕它当前真的是某种全局,也要假定未来是可能变动的,包括但不限于:

  • 请求方式
  • 用户登录状态
  • 视觉主题
  • 多语言国际化
  • 环境与平台相关的 API

需要尽可能把这些东西控制住,封装在某种上下文里,并且提供便利的使用方式:

// 统一封装控制
const ServiceContext = () => {
  const request = useCallback(() => {
    return // 这里是统一引入控制的 request
  }, [])

  const context: ServiceContextValue = {
    request,
  }

  return <ServiceContext.Provider value={context}>{children}</ServiceContext.Provider>
}

// 包装一个 hook
const useService = () => {
  return useContext(ServiceContext)
}

// 在组件中使用
const Component = () => {
  const { request } = useService()
  // 这里使用 request
}

这样,我们在整个大组件树上的视角就是:某一个子树往下,可以统一使用某种控制策略,这种策略在模块集成的时候会比较有用。

使用 Context,我们可以更好地表达整组的状态与操作,并且,当下层组件结构产生调整的时候,需要调整的数据连接关系较少(通常我们倾向于使用一些全局状态管理方案的原因也是这样)。

状态的可组合性

在实现组件的时候,我们往往发现它们之间存在很多共性,比如:

  • 所有的表单输入项,都可以控制是否禁用
  • 多选项卡组件与卡片组,都是在一个列表形态上的扩展

从更深的层次出发,我们可以意识到,几乎任意一个组件,它所使用的状态与控制能力都是由若干原子化的能力组合而出,这些原子能力可能是相关的,也可能是不相关的。

举例来说:

const Editable = (props: PropsWithChildren<{}>) => {
  const { children } = props
  const [editable, setEditable] = useState<boolean>(false)

  const context: EditableContextValue = {
    editable,
    setEditable,
  }

  return <EditableContext.Provider value={context}>{children}</EditableContext.Provider>
}

这样的一个组件,表达的就是对只读状态的读写操作。如果某个组件内部需要这么一些功能,可以选择直接将它组合进去。

更复杂的情况下,比如当我们想要表达这样一种特殊的表单卡片组,其主要功能包括:

  • 可迭代
  • 可动态添加删除项
  • 可设置是否能编辑
  • 可缓存草稿,也可以提交
  • 可多选

分析其特征,发现来自几种互相不相关的原子交互:

  • 通用列表操作
  • 编辑状态的启用控制
  • 可编辑项
  • 列表多选

它的实现就可能是这样:

const CardList = () => {
  const { list, setList, addItem } = useContext(ListContext)
  const { editable, setEditable } = useContext(EditContext)
  const { commit } = useContext(DraftContext)
  const { selectedItems, setSelectedItems } = useContext(ListSelectionContext)

  // 然后内部组合使用
}

由此,我们有可能在每个组件开发的时候,将其内部结构分解为若干独立原子交互的组合,在组件实现中,只是组合并且使用它们。

注意,有可能部分状态组之间存在组合顺序依赖关系,比如:“可选择”依赖于“列表”,必须被组合在它下层,这部分可以在另外的体系中进行约束。

分层复用

在业务中,组件的复用方式并不总是一样的。我们有可能需要:

  • 复用一个交互方式
  • 复用一段逻辑
  • 复用一个组合了逻辑与交互的“业务组件”

每当我们需要设计一个“业务组件”的时候,就需要慎重考虑了。可以尝试询问自己一些问题:

  • 我们在复用它的时候,会更改它的外部依赖吗?
  • 它内部的逻辑会被单独复用吗?
  • 这个交互形态会跟其他逻辑组合起来复用吗?

比如说,一个内置了选择省市县的多级地址选择器,它就是这么一种“业务组件”。我们以此为例,尝试重新解构它的可复用性。

  1. 存在外部依赖吗?它有可能被更改吗?

对于地址的查询,就是外部依赖。注意,尽管大部分情况下这个是不会改的,但是仍然存在这个可能性,需要提前考虑这类事情,通常,遇到有数据请求之类的东西,尽量去抽象一下。

  1. 逻辑会被单独复用吗?

如果需要建立另外一种选地址的组件,交互形态不同,但逻辑可以是一样的。

  1. 这个交互形态会跟其他逻辑组合起来复用吗?

有可能被用来选择其他东西。

所以,回答了这些问题之后,我们就可以设计组件结构了:

业务上下文

const Business = () => {
  const [state, setState] = useState()

  return <BusinessContext.Provider value={context}>{children}</BusinessContext.Provider>
}

交互上下文

const Interaction = () => {
  const [state, setState] = useState()

  return <InteractionContext.Provider value={context}>{children}</InteractionContext.Provider>
}

在组件的实现中:

const ComponentA = () => {
  const {} = useContext(BusinessContext)
  const {} = useContext(InteractionContext)

  // 在这里连接业务与交互
}

使用的时候:

const App = () => {
  // 下面每层传入各自需要的配置信息
  return (
    <Business>
      <Interaction>
        <ComponentA />
      </Interaction>
    </Business>

  )
}

在这个部分,总的原则是:

  • 业务状态与 UI 状态隔离
  • UI 状态与交互呈现隔离

在细分实现中,再考虑两个部分分别由什么东西组合而成。

在一些比较复杂的场景下,状态结构也很复杂,需要管理来自不同信息源的数据。在某些实践中,选择将一切状态聚合到一个超大结构中,然后分别订阅,这当然是可行的,但是对维护就提高了一些难度。

通常,我们有机会把状态去做一些分组,最容易理解的分组方式就是将业务和交互隔离。这种思考方式可以让我们的关注点更聚焦:

  • 写业务的时候,就不去思考交互形态
  • 写交互形态的时候,就不去思考业务逻辑
  • 然后剩下的时间花在把它们连接起来

❤️ 看完三件事

非常棒的一篇Ts实用文章,

如果你觉得这篇内容对你挺有启发,不妨:

  • 点个【在看】,或者分享转发,让更多的人也能看到这篇内容

  • 点击↓面关注我们,一起学前端

  • 长按↓面二维码,添加鬼哥微信,一起学前端



浏览 21
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报