业务中的前端组件化体系

本文使用

Zhihu On VSCode

创作并发布

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

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

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

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

组件的实现

状态与渲染

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

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

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

有状态组件:

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 状态与交互呈现隔离

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

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

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

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

多级子树

在很多时候,一整块复杂的业务交互包含的内容过多,涉及多个交互块的流转,或者存在比较复杂的数据共享关系,如果非要集中管理,维护起来会很难。​

当前社区的技术方案,对这块是比较欠缺考虑的,绝大部分人采用的是两种比较极端的策略:​

  • 状态逻辑完全推到组件树顶层,采用全局性的状态管理方案
  • 状态逻辑分散在组件树的叶子部分

但是考虑到在一个业务体系中,有可能有的模块的组件树深度过大,交互过于复杂。又或者,项目之间的集成关系不是一成不变的,经常有单个项目整体下沉为被集成方。诸如此类的需求,会对状态逻辑、组件结构提出更多需求。​

我们可以这样的策略:​

  • 把部分交互划分为子树
  • 子树内部采用集中状态管理
  • 子树和上级之间使用一级连接器去控制整个子树与上层的交互

整体结构形如:​

  • 应用
    • 主视图状态管理
      • 主视图的渲染树
        • 子视图 1 的状态连接器
          • 子视图 1 的状态
            • 子视图 1 的渲染树
        • 子视图 2 的状态连接器
          • 子视图 2 的状态
            • 子视图 2 的渲染树

这个体系下:​

  • 从单个子视图的视角看,它的实现是:隔离了外部依赖项的一棵普通组件树
  • 从整体视角看,它是一个减少了深度的大块组件树(单个子视图对它而言是黑盒)

这样,实现的只关注于实现,集成的只关注于集成,两者的视角相对是分离的,主要的适配逻辑都集中在各自的适配器上。

状态的依赖关系

在 hooks 推出之前,React 中管理状态之间依赖关系的机制是有所欠缺的。以其他技术栈为例,往往提供了一种称为 computed 的机制,使得可以定义出一些无副作用的依赖计算链路,例如:​

const firstName = ref('')
const lastName = ref('')

const fullName = computed({
  get: () => `${firstName.value},${lastName.value}`,
  set: (val) => {
    const [first, last] = val.split(',')
    firstName.value = first
    lastName.value = last
  },
})

早期,React 体系只能额外借助类似 RxJS 这样的工具库来实现类似功能,在 hooks 和 Recoils 推出之后,有了更多选择。​

当我们认为“组合若干个独立状态分组来实现组件,其灵活性更高”的同时,就需要面临一些将组合结果再次暴露出去的场景。在这样的场景下,有可能需要对状态依赖关系的隐式或者显式表达。

组合状态提供了一种视角:从使用者的角度看待状态数据的来源和变换关系。这对于复杂场景下,追踪状态的变化链路来说,非常有用。我们可以对于视图上每一个状态,都追溯到它是由什么业务状态所关联计算的交互状态,从而在跟踪问题的时候,能以最快的方式定位到问题。​

它的视角是:​

  • 自身的状态
    • 来源 1
      • 来源 11
      • 来源 12
      • 组合计算规则
    • 来源 2
      • 来源 21
      • 来源 22
      • 组合计算规则
    • 组合计算规则

此外,以这种视角出发,还有机会把一些动态的业务计算规则通过注入的方式加进来,类似 Excel 里面的一些公式,从而更容易支持业务上的一些配置化需求。 在开发过程中,也要注意尽量以状态驱动的视角去解决问题,尽可能少用 ref 去获取“组件引用”。

工程链路

除了常规的组件化生产链路,还可以关注另外一些工程方面的视角。

组件依赖形态

前端组件的发布方式也是值得考虑的,与早期静态的前端工程链路不同,组件的依赖存在两种不同的方式:​

  • 以包的方式依赖
  • 以服务的方式依赖 ​ 这两者的使用方式有很大不同。​

以包形态依赖的组件,其构建与发布链路是跟随主应用的,主应用与它们是比较强的耦合关系,会需要在代码结构、交互呈现方面,都结合得更紧密一些。​

以服务方式依赖的组件,有单独的构建与发布链路,主引用与它们是松散耦合关系,一般来说,会采用某种微前端方案来集成它们。​

这两者在业务上都是可能出现的,需要从业务集成关系的角度来判断。​

在一个相对可控的体系中,建设组件依赖体系的时候,需要多考虑一些其他环节,比如依赖的反向管控。所谓依赖关系的反向管控,是指,从一个组件出发,知道依赖它的有哪些组件或者产品。通常,在以服务方式集成的组件上,这一点非常重要,否则,被多个业务依赖的组件服务要单独发版了,可能影响的范围都难以精确定位。​

这个部分的方法论可以参照其他体系,比如后端的服务依赖监控策略去建设。​

跨技术栈集成

当前,前端技术栈的分化比较严重,对于行业软件公司,这样的情况尤其严重,因为产品周期都更长。实时翻新所有组件是不现实的,因此,我们需要寻求更通用、更长远的集成方案。​

当前主流的前端框架都是数据驱动,而技术栈集成的组合是可以穷举的,比如说,我们可以有:​

  • 将 React 组件集成到 Vue 体系的加载器
  • 将 Vue 组件集成到 React 体系的加载器

类似这样,就可以不必过于强求组件自身的实现方式。​

业务组件的使用方式变成:​

  • 根据当前主应用技术栈选择合适加载器
  • 指定被加载的组件

整体来看,一个应用可能是一个比较复合的组合:​

  • 主框架
    • React 业务 1
      • React 组件 1
      • Vue 组件 1
    • Vue 业务 2
      • React 组件 2
      • Vue 组件 2

整个这块,就是“前端微服务”,但是在不同场景下,存在不一样的实现策略。一般来说,如果对所有被集成方的生产过程能够有一定约束,整体实现就可以比较好一些。​

需要注意的是,当前一些“微前端方案”侧重于解决的一部分场景是历史遗留问题,或者是对生产者缺乏有力约束的场景,如果是整个应用都处于可控范围,异构框架的集成就相对比较友好一些,有机会做得更好。​

如果我们能够把状态管理与交互实现隔离得比较好,甚至很容易做出技术栈中立的状态管理方案,并且能够更好地隔离 UI 框架可能带来的影响。 总的来说,从交互和产品角度看,优先期望能有完整的交互集,但具体组件实现允许有异构方案。​

测试与分析

一般的业务团队中,前端自动化测试都是一个基本无法推进的事情,主要原因是逻辑和状态过于分散,覆盖所有情况的自动化测试用例,数量可能庞大到超过想象,并且,每次需求变更,需要变动的测试也非常多。​

但是,在合适的方法论下,这个事情也不是完全无解的。我们需要尽量去做到交互与业务逻辑的隔离,当组合关系比较清晰的时候,业务和交互是可以分别测试的。​

在测试业务的时候,交互细节可以忽略,例如,我们在测试一个使用表格承载的业务的时候,可以检验它的数据结果始终满足某种形态的对象数组就可以了,无需关注是否正确显示为表格(这是另外一个问题)。​

甚至,我们有机会造出一组专门用于测试的渲染器,专门用来配合业务测试。

此外,需要注意到,我们之前的整个探讨,都在强调一个理念:业务与交互隔离。在隔离到比较好的情况下,把交互全部视为黑盒,就可以得到很纯净的业务形态,据此,有机会去做到基于状态组合的语义化业务埋点。

小结

总的来说,组件开发的方法论可能是相对中立和普适的,但组件库的整体建设方案,与所在的行业有不小的关系。如果是从事行业软件领域,对交互集的掌控就是非常重要的事情。​

考虑方案的时候,如果优先从产品的集成关系角度出发看待问题,有可能是比较好的,它至少保证业务的可用性尽可能不被技术方案限制。​

本文述及的一些策略,从另外一些视角看,可能有另外一些认知。比如说,在提到管控依赖项的策略中,如果把“基础组件”也视为是一种可注入的能力,那整个业务部分就可以变成另外一种奇特的形态:类似某种“小程序”体系。​

篇幅所限,本文所提及的都是很初步的内容,更多细节需要单独展开。

发布于 2021-06-23 09:41