在业务开发过程中,我们总是会期望某些功能一定程度的复用。很基础的那些元素,比如按钮,输入框,它们的使用方式都已经被大部分人熟知,但是一旦某块功能复杂起来,成为一种“业务组件”的时候,就会陷入一些很奇怪的境况,最初是期望抽出来的这块组件能有比较好的复用性,但是,可能当另外一个业务想要复用它的时候,往往遇到很多问题:
- 不能满足需求
- 为了满足多个业务的复用需求,不得不把组件修改到很别扭的程度
- 参数失控
- 版本无法管理
诸如此类,时常使人怀疑,在一个业务体系中,组件化到底应该如何去做?
本文试图围绕这个主题,给出一些可能的解决思路。
组件的实现
状态与渲染
通常,我们会有一些简单而通用的场景,需要处理状态的存放:
- 被单独使用
- 被组合使用
一般来说,我们有两种策略来实现,分别是状态外置和内置。
有状态组件:
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 的组件,它的典型后果有:
- 一旦整体的请求方式被变更,比如添加了统一的请求头或者异常处理,那就可能改动每个组件。
这个问题,可能有的研发团队中会选择先封装一下 request,然后再引入,这是可以消除这种问题的。
- 如果多个不同的项目合并集成了,就存在多种不同的数据来源,不一定能做到直接统一这个请求配置。
因此,要尽量避免直接引入全局性的依赖,哪怕它当前真的是某种全局,也要假定未来是可能变动的,包括但不限于:
- 请求方式
- 用户登录状态
- 视觉主题
- 多语言国际化
- 环境与平台相关的 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)
// 然后内部组合使用
}
由此,我们有可能在每个组件开发的时候,将其内部结构分解为若干独立原子交互的组合,在组件实现中,只是组合并且使用它们。
注意,有可能部分状态组之间存在组合顺序依赖关系,比如:“可选择”依赖于“列表”,必须被组合在它下层,这部分可以在另外的体系中进行约束。
分层复用
在业务中,组件的复用方式并不总是一样的。我们有可能需要:
- 复用一个交互方式
- 复用一段逻辑
- 复用一个组合了逻辑与交互的“业务组件”
每当我们需要设计一个“业务组件”的时候,就需要慎重考虑了。可以尝试询问自己一些问题:
- 我们在复用它的时候,会更改它的外部依赖吗?
- 它内部的逻辑会被单独复用吗?
- 这个交互形态会跟其他逻辑组合起来复用吗?
比如说,一个内置了选择省市县的多级地址选择器,它就是这么一种“业务组件”。我们以此为例,尝试重新解构它的可复用性。
- 存在外部依赖吗?它有可能被更改吗?
对于地址的查询,就是外部依赖。注意,尽管大部分情况下这个是不会改的,但是仍然存在这个可能性,需要提前考虑这类事情,通常,遇到有数据请求之类的东西,尽量去抽象一下。
- 逻辑会被单独复用吗?
如果需要建立另外一种选地址的组件,交互形态不同,但逻辑可以是一样的。
- 这个交互形态会跟其他逻辑组合起来复用吗?
有可能被用来选择其他东西。
所以,回答了这些问题之后,我们就可以设计组件结构了:
业务上下文
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的渲染树
- 子视图1的状态
- 子视图2的状态连接器
- 子视图2的状态
- 子视图2的渲染树
- 子视图2的状态
- 子视图1的状态连接器
- 主视图的渲染树
- 主视图状态管理
这个体系下:
- 从单个子视图的视角看,它的实现是:隔离了外部依赖项的一棵普通组件树
- 从整体视角看,它是一个减少了深度的大块组件树(单个子视图对它而言是黑盒)
这样,实现的只关注于实现,集成的只关注于集成,两者的视角相对是分离的,主要的适配逻辑都集中在各自的适配器上。
状态的依赖关系
在 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
- 组合计算规则
- 组合计算规则
- 来源1
此外,以这种视角出发,还有机会把一些动态的业务计算规则通过注入的方式加进来,类似 Excel 里面的一些公式,从而更容易支持业务上的一些配置化需求。
在开发过程中,也要注意尽量以状态驱动的视角去解决问题,尽可能少用 ref 去获取“组件引用”。
工程链路
除了常规的组件化生产链路,还可以关注另外一些工程方面的视角。
组件依赖形态
前端组件的发布方式也是值得考虑的,与早期静态的前端工程链路不同,组件的依赖存在两种不同的方式:
- 以包的方式依赖
- 以服务的方式依赖
这两者的使用方式有很大不同。
以包形态依赖的组件,其构建与发布链路是跟随主应用的,主应用与它们是比较强的耦合关系,会需要在代码结构、交互呈现方面,都结合得更紧密一些。
以服务方式依赖的组件,有单独的构建与发布链路,主引用与它们是松散耦合关系,一般来说,会采用某种微前端方案来集成它们。
这两者在业务上都是可能出现的,需要从业务集成关系的角度来判断。
在一个相对可控的体系中,建设组件依赖体系的时候,需要多考虑一些其他环节,比如依赖的反向管控。所谓依赖关系的反向管控,是指,从一个组件出发,知道依赖它的有哪些组件或者产品。通常,在以服务方式集成的组件上,这一点非常重要,否则,被多个业务依赖的组件服务要单独发版了,可能影响的范围都难以精确定位。
这个部分的方法论可以参照其他体系,比如后端的服务依赖监控策略去建设。
跨技术栈集成
当前,前端技术栈的分化比较严重,对于行业软件公司,这样的情况尤其严重,因为产品周期都更长。实时翻新所有组件是不现实的,因此,我们需要寻求更通用、更长远的集成方案。
当前主流的前端框架都是数据驱动,而技术栈集成的组合是可以穷举的,比如说,我们可以有:
- 将 React 组件集成到 Vue 体系的加载器
- 将 Vue 组件集成到 React 体系的加载器
类似这样,就可以不必过于强求组件自身的实现方式。
业务组件的使用方式变成:
- 根据当前主应用技术栈选择合适加载器
- 指定被加载的组件
整体来看,一个应用可能是一个比较复合的组合:
- 主框架
- React 业务1
- React 组件1
- Vue 组件1
- Vue 业务2
- React 组件2
- Vue 组件2
- React 业务1
整个这块,就是“前端微服务”,但是在不同场景下,存在不一样的实现策略。一般来说,如果对所有被集成方的生产过程能够有一定约束,整体实现就可以比较好一些。
需要注意的是,当前一些“微前端方案”侧重于解决的一部分场景是历史遗留问题,或者是对生产者缺乏有力约束的场景,如果是整个应用都处于可控范围,异构框架的集成就相对比较友好一些,有机会做得更好。
如果我们能够把状态管理与交互实现隔离得比较好,甚至很容易做出技术栈中立的状态管理方案,并且能够更好地隔离 UI 框架可能带来的影响。
总的来说,从交互和产品角度看,优先期望能有完整的交互集,但具体组件实现允许有异构方案。
测试与分析
一般的业务团队中,前端自动化测试都是一个基本无法推进的事情,主要原因是逻辑和状态过于分散,覆盖所有情况的自动化测试用例,数量可能庞大到超过想象,并且,每次需求变更,需要变动的测试也非常多。
但是,在合适的方法论下,这个事情也不是完全无解的。我们需要尽量去做到交互与业务逻辑的隔离,当组合关系比较清晰的时候,业务和交互是可以分别测试的。
在测试业务的时候,交互细节可以忽略,例如,我们在测试一个使用表格承载的业务的时候,可以检验它的数据结果始终满足某种形态的对象数组就可以了,无需关注是否正确显示为表格(这是另外一个问题)。
甚至,我们有机会造出一组专门用于测试的渲染器,专门用来配合业务测试。
此外,需要注意到,我们之前的整个探讨,都在强调一个理念:业务与交互隔离。在隔离到比较好的情况下,把交互全部视为黑盒,就可以得到很纯净的业务形态,据此,有机会去做到基于状态组合的语义化业务埋点。
小结
总的来说,组件开发的方法论可能是相对中立和普适的,但组件库的整体建设方案,与所在的行业有不小的关系。如果是从事行业软件领域,对交互集的掌控就是非常重要的事情。
考虑方案的时候,如果优先从产品的集成关系角度出发看待问题,有可能是比较好的,它至少保证业务的可用性尽可能不被技术方案限制。
本文述及的一些策略,从另外一些视角看,可能有另外一些认知。比如说,在提到管控依赖项的策略中,如果把“基础组件”也视为是一种可注入的能力,那整个业务部分就可以变成另外一种奇特的形态:类似某种“小程序”体系。
篇幅所限,本文所提及的都是很初步的内容,更多细节需要单独展开。