关于 React App 业务逻辑的组织
技术
上篇文章 聊到了 GUI 应用开发的一些参考原则,接下来打算结合我这几年的 React App 开发经验,总结一些在 React 生态下,一些比较好的实践经验和方法论。
一切的原则在于用尽可能少的代码及其潜在的沟通成本去实现业务,让各个模块向高内聚低耦合状态靠近;在维护者的精力分配上,可以更多聚焦于业务逻辑以及对计算机本身的思考,减少不必要的沟通摩擦。
相较于 UI 实现,本文更侧重于业务逻辑的组织上,主要讨论几个点:
- 渲染逻辑的拆分
- 代码文件的组织
- 业务 Flow 的表达
- 业务状态的划分策略
渲染逻辑的拆分
在 React App 的开发中,随着组件要渲染的 UI 元素的增多,写代码时很容易不自觉会延续过往写 class 组件的习惯,写一些 render function 拆分渲染逻辑。这种做法看似简洁,实则会因各个拆分后的 render function 依赖的业务逻辑相互交叉,导致组件愈发复杂,越来越不好维护。
一个好的做法是,优先考虑拆子 UI 组件,即使子组件的逻辑非常简单。这么做看似文件和组件数量会变多,但后续扩展无需再动上层的组件。业务快速迭代的背景下,从可维护性的角度来说,采用灵活度更高的方案,要比过早优化好一些。业务逻辑日渐复杂,拆分子组件给运行过程带来的性能损耗,其实远远小于把业务逻辑混杂在一起的维护成本,总的来说还是划算的。
拆子 UI 组件的一个蛋疼点在于,我们要建新的 jsx
/ tsx
文件、写一堆重复的组件定义代码,手打会比较烦躁。这些烦恼可以借助于 IDE 来改善,VSCode 自带的 snippets
和 Move to new file
的能力,可以说为此节约了不少的精力和时间。
那么,render function 写法是否就真的一无是处呢?其实不然,在一些涉及到条件渲染、皮肤定制等等的场景,render function 相比子组件会更加灵活,它可以自由地把渲染逻辑插入到父组件的合适地方。甚至你可以考虑把 render function 及其依赖的上下文状态,封装到一个 hook,再用 render function 将其加入到父组件的渲染流程,如例子附上的 FooBarCounter
的 hook 和 render 函数。这样的做法也非常适合需要在历史包袱很重的组件渲染新的 UI 元素、但不想大改旧组件逻辑的场景。
// hook
const useFooBarCounter = () => {
const [count, setCount] = useState(0);
return {
reset: () => setCount(0),
renderFooBarCounter: () => <div>{count}</div>
};
};
// app
const App = () => {
const {
reset: resetCounter,
renderFooBarCounter,
} = useFooBarCounter();
return (
<div>
<button onClick={() => resetCounter()}>Reset</button>
<div>balabalabala</div>
{renderFooBarCounter()}
</div>
);
};
抛开那些纷繁复杂骚操作,复杂组件渲染逻辑拆分的核心目的,大概在于把控模块间的边界感,避免给交织的业务 Flow 在代码的表达上发生太多相互交叉的情况。
代码文件的组织
众所周知,React 官方并未规定 App 在代码文件的组织策略,开发者写 React App 通常八仙过海各显神通,当业务增长到一定的规模,找代码这件事情的心智负担也随即增大,容易让项目代码走向混乱。这时候就需要一个稳定统一的策略,降低心智负担成本。
大原则是避免过度设计,从组件业务逻辑开发自简单向复杂演进的视角,我们可以划分出几个阶段,每个阶段针对性引入一些的概念和工具去辅助处理。
按业务复杂度大概可以拆成三个阶段,分别起名 “简单组件”、“小型组件”、“大型组件”:
- 简单组件:纯 UI 逻辑,非常简单,一个文件就可以写完
- 小型组件:由数个模块(组件 / hooks / utils 等)构成,考虑引入目录来划分
- 大型组件:由数个 小型组件 构成,按模块类型拆分目录,并引入
modules
的概念来管理子组件
对于 小型组件 来说,我们可以直接用 组件名.模块类型.ts/x
的模式来组织文件名,借助编辑器默认按文件名排序的能力去归类组件本身、样式文件、工具函数 / hooks、子组件 等,整体来看大概是这样的格局:
- TestComponent
- TestSub.tsx
- TestSub.styles.ts
- TestComponent.tsx
- TestComponent.styles.ts
- TestComponent.xxxxx.ts/x
- TestComponent.styles.css
演进到 大型组件 的复杂度,可以进一步引入目录辅助拆分,然后把对外的根组件相关的模块放在根目录:
- TestComponent
- components
- hooks
- modules
- contexts
- store
- TestComponent.tsx
- TestComponent.styles.ts
- TestComponent.xxxxx.ts/x
- TestComponent.styles.css
React App 的概念模型,是一棵由组件和子组件以递归的模式构造出来的树。我们对三个阶段的组件的文件组织策略都有了定义,我们可以递归地把它们应用在整个 React App 的组件树设计上,以此实现文件系统的目录树和 React App 的组件树的大致对应。带着一定的思维惯性,读/找代码时的认知负担也随之大大降低。
针对 大型组件,我的一个特殊做法是拆分出 modules
和 components
的概念。主要考虑点在于子组件的可复用性会有强弱之分,有的子组件仅仅是逻辑的拆分,仍然和父组件有着千丝万缕联系,适合放在 modules
里,有的与父组件关联较弱,有机会被其他组件复用,近似于纯 UI 组件,就适合放在 components
下。
对于复用性强的组件,由于业务需求的不确定性,在开发过程我们其实没法预测到它最后会发展到一个什么样的通用程度,有时候会做一些不必要的过度设计,更好的做法是允许它有一个能不断“成长”、“升级”的预期。在开发一个组件时,若发现一个组件可以被通用化,但暂时没有别的组件引用,可以先把它放在当前的 components
;随着业务发展,部分子组件可能需要被其他模块引用,这个时候我们可以进一步把它挪到更上层的 components
。这样子开发者对组件当下所能作用的 scope 也会一目了然,让文件系统能在潜移默化之间给维护者传递更多关于项目的背景信息。
需要指出的是,这里分三阶段走并非严格策略,核心目的仍在于降低找代码 / 读代码的负担。对于文件/目录的组织,可以抓大放小,在个人直觉可以接受的范围内,是可以允许一定的杂乱存在的。
业务 Flow 的表达
当一个软件承载的业务 Flow 变得多而复杂,代码的维护负担也随之越来越重。相信开发过复杂 React 应用的朋友,应该体会过代码迭代过程中要梳理各种飞来飞去的状态变化、订阅、事件的痛苦。一个比较迫切的需求也在于,如何能在代码里简洁清晰地表达出业务 Flow 的逻辑实现,并保留未来基于此做自动化测试的可能性。
状态机的封装
抽象角度来看,实现一段业务逻辑,实质上是在实现一个状态机,伴随着一系列的 action
,驱动 state
的初始化、state
的转移和 state
的结束。大方向上,我们需要一个载体去承载这样的 state
和 action
。
在 React 的世界,我们可以用 hook 去承载这样概念的落地,一个 hook 可以定义 state
,也可以定义修改 state
的各种 action
。并且,借助于 JS 函数闭包的特性,我们可以通过 hook 导出自带 state
上下文的 callback 函数,实现对外暴露 action
的作用。借助于这样的能力,我们可以显式地把一条 Flow 从开始到结束的所有动作,都集中在一个 hook 里面。
继续以 useFooBarCounter
为例,我们可以围绕着 count
的 state,分别暴露出 reset
、add
、sub
这几个 action,业务可以不用关心内部的变化,直接调用就可以驱动 count
的 state 的更新。
// hook
const useFooBarCounter = () => {
const [count, setCount] = useState(0);
return {
count,
reset: () => setCount(0),
add: () => setCount(count => count + 1),
sub: () => setCount(count => count + 1),
};
};
Event based -> 命令式
对于一些相对较长的 Flow,尽可能用命令式的写法去组织代码,可以让业务逻辑的表达更加直观清晰。但有时候涉及到类似 Modal 组件 visible,用户交互 confirm 后、再继续往下走的流程,代码上就很容易出现前面说到的业务逻辑跳来跳去的情况。
一个可以参考的做法是,把这种类似 Modal 交互的 Event based 逻辑转换为命令式的写法。代码层面可以用 Promise 构造函数 + Ref 的方式来实现,外层 Flow 直接 await 这个 Promise,当用户在 Modal 确认后,通过 resolve / reject 结束 Modal 的 Flow,再把流程交还给上层 Flow 继续往下走。
如以下例子代码,这里忽略了一些定义、传值的细节。在实际的操作上,你还可以把这类逻辑封装成一个 hook 实现通用化。
const TestComponent = () => {
const [modalVisible, setModalVisible] = useState<boolean>(false);
const resolveRef = useRef(null);
const rejectRef = useRef(null);
const startModalFlow = () => new Promise((resolve, reject) => {
resolveRef.current = resolve;
rejectRef.current = reject;
setModalVisible(true);
});
const startMainFlow = async () => {
// before modal flow logic...
await startModalFlow();
// follow up logic...
};
return (
<TestModal
visible={modalVisible}
onConfirm={() => {
resolveRef.current?.();
setModalVisible(false);
}}
onCancel={() => {
rejectRef.current?.();
setModalVisible(false);
}}
onClose={() => {
rejectRef.current?.();
setModalVisible(false);
}}
);
};
当我们能把业务流程用命令式的写法串起来以后,即使涉及到流程结束、报错的情况,我们也可以很轻松地兜住,正常结束我们可以用 return,异常结束可以直接 throw 一个定义好的 Exception / Error,只需要简单地用 try / catch 等语法就能实现错误的统一处理。并且,这样的做法也可以促使开发者多多思考业务的错误降级、兜底逻辑,对用户使用过程可能遇到的各种场景都有预期去做兜底,获得更加丝滑的体验。
Flow 的分阶段组织
对于一些要跨越多个不同交互界面的 Flow(比较常见是页面跳转),并不好直接在一个函数就完全处理完,这时候可以把这个 Flow 拆分为多阶段的 Flow 分步处理。
当其中一个 Flow 结束,我们可以在其中安插一些启动新的 Flow 的逻辑。或者也可以通过全局渲染状态的大水漫灌,在需要的模块订阅这个状态的更新,实现下一阶段 Flow 的启动。这属于 React App 开发的常规操作,就不多赘述了。
业务状态的划分策略
状态管理是个老生常谈的话题,之所以老生常谈,也是大家对于应用大小状态的划分不清晰导致,有的 useState
一把梭,有的将其存在外部 store
,然后 atom
和 action
/ selector
满天飞,阅读代码负担也特别重。相较于徘徊于各种状态库实现的眼花缭乱,我更倾向于把概念搞清晰,具体的实现,根据项目情况因地制宜即可。
针对状态的划分,可以按复杂程度分两种情况去考虑,再通过 hook 对业务暴露 API。
对于逻辑简单的业务,不需要怎么去管理状态,直接放在顶层组件的 state
即可,再通过 Context
来传递状态的方法。一个比较好的做法是直接封装一个业务逻辑组件,类似上面的 useFooBarCounter
那样定义好 state
和 action
,然后通过 Context Provider
嵌入组件树,再对应暴露一些 hook 给下层 UI 组件去触发业务逻辑组件内的 action
。
对于逻辑复杂的业务,状态通常是存在外部(或者最顶层组件)的 store
上,也由于 store
和 selector
/ action
等能力比较底层,直接暴露出去,很容易发生引用的互相交叉,降低可维护性。
因此我们应该尽可能减少这类底层 API 在上层的直接使用,比较好的做法也是,按照对应业务模块划分 module,module 之间通过 hook 来给业务组件,或其他 module 提供对其业务生命周期方法和状态的引用。
关于对业务组件的 hook API 代码的设计,思路上也类似于楼上说的 useFooBarCounter
,我们可以封装一些诸如 fetchXXX
,initXXX
,handleXXX
的方式,供上层业务去调用。业务依赖 hook 提供的业务接口概念,而非依赖于 store 的底层实现,以此可以减少因为不同业务模块与 store 逻辑互相交叉增大复杂度的情况。
小结
以上简单展开了一些 React App 业务逻辑组织的经验和策略,总的来说,核心都围绕着 “如何减小业务 Flow 之间的交叉” 而进行,实现它的方式有很多,也许你有更好的方案。
从中可以发现,React Hooks 的抽象所提供的业务逻辑拆分能力,可以说是降低复杂 React 应用上手门槛、提升可维护性的一个有力工具,如何用好它,背后有着不少的学问。
值得注意的是,即使是在当下业界强调函数式、响应式编程去实现 UI 的背景,就业务流程的组织而言,命令式的写法仍然十分有用且实用。