“错误“的官方
你或许看过这篇文章《你或许不需要 Redux》,它的作者是 Dan ,这篇文章总结起来就是:非必要不用 Redux。
Dan 是 Redux 的作者、React 的核心开发者以及前端大网红,他经常发表观点和博客,他的观点很大程度代表了官方的观点
而这个“非必要”在不同阶段的开发者理解完全不同,大多数人在看完这篇文章会有感觉: 应用尽用 useContext + useState (useReducer) 去管理状态,直到 cover 不住应用复杂度再考虑使用全局 状态管理库 。
React 旧文档中 共用状态 的一个例子更是印证了这一观点:
对于应用中的每一个 state:
- 找到根据这个 state 进行渲染的所有组件。
- 找到他们的共同所有者(common owner)组件(在组件层级上高于所有需要该 state 的组件)。
- 该共同所有者组件或者比它层级更高的组件应该拥有该 state。
- 如果你找不到一个合适的位置来存放该 state,就可以直接创建一个新的组件来存放该 state,并将这一新组件置于高于共同所有者组件层级的位置。
React 试图教你将多个共用的状态抽到一个祖先 组件,并将其称为”依赖反转“的技法。
不知道你有没有想过,会考虑这种技法前提,起码是 跨越两层以上的嵌套结构、状态被两个以上的 组件 消费的模块,这如何都不能称为一个“简单的”模块。
文档悄悄地传达给我们的潜意识:复杂的模块的状态也可以由 React 管理
状态由组件管理,有什么问题?
从实践经验中,状态由组件管理有什么问题?
- 祖先 组件 中,逻辑越来越多,难以维护。 一旦逻辑耦合严重,依赖反转往往是被迫的,因为互相引用难以分离,只能再往祖先组件加逻辑, 必须写在顶层组件通过 prop 或者 context 向下传递,最终成为一个巨石组件,且难以拆分。当业务逻辑变更,平行的模块又依赖这个模块呢?继续依赖反转吗?
最终结果是复杂度已经达到 “使用 组件 管理状态的极限”,想使用 状态管理库 重构却已 积重难返 。
-
组件状态共享和传递困难,context 除了有一定编写成本外,也存在不少缺点
- 引起整个组件树的重新渲染,存在性能问题
- 异步支持羸弱,变更后无法立即拿到最新的状态
-
react 的设计
- 闭包问题
- useEffect 问题 ,没有对状态变更的监听
-
关键请求和组件耦合
组件外使用状态不自由
总起来就是三个字:不自由。
回到问题本质, 组件 的嵌套结构、 单向数据流 、(React)数据更新模式都是为了 UI 服务的,而这些都是与业务模型思考模式大相径庭。
或许全局状态管理库是首选
前文已经解释了众多在组件内管理状态的弊端, 但是似乎并没有清晰界定什么是“必要”。以应用复杂度和开发流程来说,可以得出与 Dan 截然相反的结论,全局 状态管理库 管理状态应该是首选。
业务逻辑复杂度难以预测
初始需求组件化的状态可以 cover,一旦需求变更,即使是一个产品经理和设计师看来一个很小的改动,可能对 组件 和状态的层次结构产生很大的变化,特别是一个原本不关联两个模块变为依赖关系时,状态的组织形式、放置位置也不得不跟着重构。
我们也难以预测业务逻辑的复杂度在未来会膨胀到什么程度,使用状态管理库来管理,我们可以在一个业务逻辑初具规模时将它拆分成更为独立聚合的模块,避免写出”巨石模块“,也可以更容易地抽离一些共通的业务逻辑。
编写代码前细致思考业务逻辑
一旦采用组件化的状态,由于其难以在外部使用的缺点,编写业务逻辑必然是与 UI 一同编写的,在编写代码时,我们会有种被 UI 推着走的感觉:对照 UX 稿编写目标的 UI 模块,一步一步补上相应业务逻辑和 UI。
在技术方案设计时,使用组件化状态和全局状态管理的思考模式是完全不一样的:
使用组件化状态会关注某一个组件中处理哪些状态和业务逻辑,其对应的 UI 和嵌套关系等。在组件化思考模式下,我们难以以高屋建瓴的视角处理膨胀的业务复杂度,也往往会缺失对业务逻辑更细致的思考。
而使用全局状态管理时会更加注重数据模型设计,关注 “状态从哪里来、状态如何变化(action)以及副作用(effect) ”,而 “如何使用状态、 UI 如何编写” ,将是次要的问题,这样 UI 层变薄,React 回到它作为视图层的本位。
在思考“初始状态从哪里来”状态从哪里来时,考虑初始化和请求的时机,保证是数据是最早时机获取的,这样应用自身的首页性能是有保障的。
在思考“状态如何变化以及副作用”,我们可以细致思考每一个动作前置条件和可能的副作用,或者以代码推动思考,先写出数据模型的原型然后逐渐完善,并直接体现在方案中。数据模型设计也更容易描述出通用的实现方法,让产品、测试和后端同学更直观理解前端实现并找到可能的业务逻辑漏洞。
全局状态管理库足够简单易用
这不是一个必要条件,但是很大程度影响我们的思维。相比于 redux,需要手写基本的 reducer、dispatch,需要关注异步变更和状态不可变,以及手动连接组件等等操作,现代的状态管理库 可变的(mobx)、原子的(recoil、jotail)、redux 类的(zustand、reduck 等),都大大降低了写一个 model 的繁琐度,甚至心智成本与写一个 useState 没什么区别。
不要小看状态管理库们在进化路上对降低繁琐度的贡献,这会很大程度影响我们对于技术选型的判断,因为人天生就厌恶繁琐的,喜欢直观的,这时候潜意识将组件管理状态排在第一优先级,抗拒全局状态管理。
所以在下次出技术方案时,考虑一下全局状态管理?将它与组件管理状态同等优先级思考,至少不应再抗拒它。
容易被忽视的“大”问题
loading
放哪儿?
一个请求的 loading,我们会直观地认为它是一个 UI 状态,应该放在组件中管理。
可是我们一旦将请求放入全局状态管理,就会发现组件中同步请求的 loading 状态无比地繁杂,最终将 loading 放入全局状态管理,但是这又与它是一个 UI 状态的想法冲突,陷入两难的困境。
我们不得不思考:什么状态应该由 组件 管理?
有些可以放入组件状态/逻辑是可以确定的:
-
纯函数的计算逻辑,它们的结果是可以预测的,比如
style={{ marginBottom: errorCount ? 11 : 19 }}
-
为了 UI 而产生状态,比如点击位置等,与数据模型没有联系
再回头看看 loading 状态,我们可以看到它的一些特点:
- 与请求强绑定,请求写在哪儿它就在哪儿
- 它是请求状态的体现
还有一种场景,如果 loading 超出了请求结果消费组件的使用范围,比如其他模块需要根据 loading 过滤无必要的 action、除了消费组件的加载态其他组件也可能根据 loading 呈现加载、禁用态。
loading 与单纯 UI 意义的状态是有很大差别的,它事实上被赋予了业务上的意义。
有些复杂度是不能被消除的。我们不能眼睛一闭,假装几百毫秒的 loading 不存在,而应该把加载中状态和加载完成、加载失败等状态同等严肃地看待。
业务 UI 动作怎么处理?
想象一个场景:用户点击某个选择按钮后,需要弹出一个确认框,等待用户确认后再继续后续操作。
这显然是一个业务逻辑,但是目前它的实现是与 UI 强耦合的,不可能通过数据模型直接完成这一逻辑。
一种直观解决方式是在动作中插入弹窗相关的逻辑代码
import { confirm } from 'xx-ui'
import { ConfirmContent } from './';
// ...
actions:{
changeA(state, a){
// 前置动作
await confirm({ title: '确定吗', content: <ConfirmContent /> })
actions.setA(a)
// 后置动作
}
}
第二种方式,以参数形式传入操作
actions:{
changeA(state, a, confirm){
// 前置动作
await confirm();
actions.setA(a)
// 后置动作
}
}
第三种方式,提前将动作抽象
// model
export interface View {
abstract confirm(): Promise<any>;
}
export let view: View = {
confirm(){ return Promise.resolve(); }
};
// ...
actions:{
changeA(state, a){
// 前置动作
await view.confirm();
actions.setA(a)
// 后置动作
}
}
// xx.tsx
import { confirm } from 'xx-ui'
import { ConfirmContent } from './';
import { View } from './model'
// xxx 组件中
useEffect(()=>{
view.confirm = () => confirm({ title, content: <ConfirmContent /> })
},[title])
第一种方式弊端很明显,一旦妥协混入了 UI 层的内容,后面将充斥着大量的 UI 内容,UI 逻辑与 model 层逻辑交错,维护性大大降低;
第二种方式缺点是传入的参数无法区分是否是业务 UI 动作,UI 动作散落在各个 actions 的参数中;
所以我更推荐第三种,将所有所需的 UI 动作抽象在一个对象上,所有 UI 动作一目了然,更易于维护。
不光光是需要等待用户操作结果的动作需要抽象出来,当需要 UI 层面的能力都应该抽象出来,更多场景比如:提交前的校验,这是 UI 和 业务逻辑分离的必然要求。
全局状态管理的另一面
逻辑复用困境
React Hook 的优秀设计为逻辑复用提供了良好支持,将逻辑剥离出组件,实现更好的代码复用和可维护性。集通用逻辑的 hooks 库也如雨后春笋般涌现,比如 ahooks。
回头看看众多状态管理库,在设计之初就没有考虑到复用逻辑,何况社区仍为选择而争论不休,妄论复用逻辑的生态了(我目前没有看到以复用逻辑作为设计亮点的状态管理库)。
但是,在以状态管理库中写通用逻辑往往无法避免,我们考虑列表的 currentPage、pageSize 切换,切换后的数据请求,以及一些细节的逻辑,比如删除最后一页最后一个项时应该调到前一页等。
所以对于此类通用逻辑,使用 hooks 是比较好的选择,只是我们需要保持克制和思考,不能因为使用了一个 useTable 就将逻辑全部划入组件中,最好保证只有通用逻辑的部分。
在组件外使用 hook,use-hook-anywhere
手动的生命周期管理
如果把状态放在一个组件中,在组件创建时它会自动生成,组件销毁时也会被自动销毁。但如果将它放到全局状态管理中,我们就得手动管理它的生命周期,显得较为繁琐。如果没有及时清理,也容易产生内存泄漏的隐患。
事物的另一面,它给予了我们对状态更精细的控制能力。
-
预请求
-
组件复原
- 复原加载过的组件
- 多页标签
总结
回到初衷,我并不是鼓吹全局状态管理,而是经历过多个 react 项目的开发后,见证一个又一个组件膨胀,难以维护,不得已重构却付出如同重新开发的成本,寻求一种更好地解决方式。
实际上,现实的项目总是存在妥协和权衡,在 UI 层写一些逻辑也无伤大雅,效率当然也是很重要的。关键是是否有意识地思考如何控制一个 组件 的复杂度,避免大范围地重构。
相对于更深层次架构设计,本文谈论内容也只是业务逻辑和 UI 分离,以目前的现状来看,确立共识,脚踏实地走出第一步才是更重要的。
即使我们最终仍然选择将某个状态放到组件内部,我们也应当清楚地知道这么做的理由。是因为它纯用作一次性展示?还是与业务逻辑无关的简单交互?或是仅仅为了实现简单?都可以,但并不理所应当这样做。