这是一篇从去年开始就在构思的文章论鸽子如何把标题的 2021 改成 2022。因为总觉得难以体系化地把我的想法表达出来,但又不吐不快。
要说现代前端有哪些最混沌的领域,那状态管理必然占据了一席之地。似乎每个人都能插上两脚,知乎大佬们人均一个轮子。我今天也难以免俗地趟一趟浑水,不过我不会谈论那些具体的状态管理轮子,而是聚焦于【全局(集中式)状态 vs 本地(组件级)状态 】这一个话题上。
在我大学第一次接触 React 时,正是 Redux 如日中天的时候,那时使用 React 基本上等同于使用 Redux 全家桶。紧接着社区里出现了反思的声音,认为“你或许不需要 Redux”,不要无脑地把状态都交给 Redux 来管理。
随后,Hooks 的出现大大简化了状态和组件逻辑的复用。鉴于此,大家认为使用 useState
+ useReducer
+ context
足以完成状态管理的职责,或仅对它们做一个简单封装后使用。
从使用哲学上,也认为状态应尽量贴近于使用它的组件,以实现组件功能上的自治。支持组件级别的 local state 成为了状态管理库的标配功能,而 Redux 已然成为明日黄花,人人路过都能批评两句。
即使 React 的官方文档中也推荐将状态按上述的原则,自下而上定义。
对于应用中的每一个 state:
- 找到根据这个 state 进行渲染的所有组件。
- 找到他们的共同所有者(common owner)组件(在组件层级上高于所有需要该 state 的组件)。
- 该共同所有者组件或者比它层级更高的组件应该拥有该 state。
- 如果你找不到一个合适的位置来存放该 state,就可以直接创建一个新的组件来存放该 state,并将这一新组件置于高于共同所有者组件层级的位置。
那么接下来是我的暴论:似乎状态的原子化、组件化管理已经成为了一种趋势。但很遗憾这并不是一种好的做法。我们对全局状态的抵触有些矫枉过正了,恰恰相反,我们应当更多考虑把大多数的应用状态纳入全局状态管理之中。
状态与 UI 的分层优于状态的组件化
我在之前的一篇文章中反对了将 HTML、CSS 与(操作 UI 的)JavaScript 以传统的三分离形式看待,而应将它们纵切,通过组件化方式使用。但状态是否也应该被组件化呢?正相反,状态与 UI 之间应严格地保持分层。
分层架构的一个经典原则是单向依赖,而前端应用中的 UI 和状态正符合单向依赖的关系。UI 负责展示状态,提供修改状态的用户界面。
同一坨状态可能展示为不同的 UI,如 PC、移动端,甚至用户切换主题或分辨率时也会切换不同的 UI 展示。若将状态组件化,则意味着状态会与具体的 UI 框架挂钩,破坏了单向依赖原则,也会为 UI 在多平台、多分辨率下的适配带来不便。
组件化的状态难以应对频繁的需求变更
UI 是易变的,尤其是每个需求的最初版本 UI 往往并不是最理想的,会根据用户反馈不断修改。而很多时候在产品经理和设计师看来一个很小的改动,其实会对组件和状态的层次结构产生很大的变化。
如果我们根据初版的 UI 来划分组件,并把状态分散在组件中,那么在 UI 修改时,状态的组织形式、放置位置也不得不跟着重构。于是,要么我们花费了很多额外精力处理状态的重构和测试,要么在 DDL 的催促下打上 ref
等丑陋的命令式补丁,违反最佳实践来跨组件地操作内部状态,并逐渐陷入维护的噩梦。
UI 与状态的分层,可类比于前后端分离
早年前端还是整个后端工程的一个文件夹时,HTML 由服务端渲染而来,也没什么复杂的交互和逻辑。前端与其说是状态,不如说是后端数据在某一时刻的快照。那时我们讲 UI 和内容分离,CSS 与 HTML 分离。
前后端分离后,采用 API 交互,但如何设计合理的 API 又成了问题。如果完全按照页面结构来设计,API 就得跟着页面改动,也无法一套 API 适配多端。于是我们有了 RESTful API 规范。而前端在需要管理自身的状态之后,也面对着如何组织状态结构的问题。而答案就在被你们嫌又臭又长的 Redux 文档之中。
RESTful 虽好,但当页面复杂,需要多个数据源时,就得请求多次进行组合,甚至还有依赖关系。于是我们进一步发展了 GraphQL、BFF 等。同样,UI 层需要考虑如何从范式化的全局状态中组合出自己需要的数据,也就是很多状态管理库中所谓的 selectors、getters 。
我们按照代码运行的位置,划分出了前端和后端。但前端的状态管理,相对于 UI 层,甚至还与后端更接近些(这里再往深处想,还能得出更多有趣的结论,以后有时间可以写得更多)。毕竟在前后端不分离的时代,数据库不就是最大的 single source of truth 吗?我们不应该把在写 UI 上的经验,套用到状态管理上来。
将 UI 与状态分离后,我们可以抛开 UI,以整体的视角掌握一个前端应用所处的状态。这将帮助我们高屋建瓴地了解整个项目的业务逻辑,也有助于代码重构和单元测试。
你或许还是需要全局状态管理
在上文中我们从理论上阐述了集中式状态管理的合理性。此外,在实际业务中,我们会发现集中式的状态管理能够很方便地解决一些组件化状态难以解决的问题。下面就试举两例。
全局状态管理与回放系统
在大前端项目中,捕捉和定位线上问题一直是一个痛点。由于代码是在用户的设备上执行,我们无法还原问题现场。即使在用户的配合下拿到了日志,也会因为事前埋点不够完善等因素,收集不到定位问题所需的必要信息。
前几年,闲鱼团队公布了被称为“千人千面线上问题回放技术”的解决方案,希望能一劳永逸地解决这一痛点,把用户的操作和当时的数据完整地复现出来。
为了能够记录下用户的操作和操作后引发的状态变更,该方案在底层做了大量的工作,例如模拟触摸,hook 所有业务代码中的方法和闭包等等,甚至需要通过一些反汇编的手段来破译出系统底层的实现逻辑。
当时我所在的团队也深受线上问题困扰,故调研了一番。但该方案并不开源,且只支持单平台,加之本人能力有限,最终也不了了之。
时隔多年以后回顾这个案例,我们可以思考一下有没有更好的解决方案。难以复现的问题可大致分成两类。其一是老生常谈的兼容性问题,如有相同的设备大体还是好定位出来的。另一类,也是占大头的问题,根源还是在于应用状态的不确定性,例如用户特定的操作路径导致了状态异常,或是多线程等导致状态的流转顺序有问题。本质上,这是一个状态管理范畴的问题。
确定了问题的本质,再来看看我们的武器库中有哪些武器。假如我们在应用中采用了集中式的状态管理……相信大家已经反应过来了,这不就是“时间旅行”功能吗?只要记录下初始状态和用户操作、系统事件引发的一系列 action,就能推出之后任意时刻的应用状态。又因为 UI = f(state)
,我们就可以通过状态驱动 UI 变化,实现回放的目的。
而从状态管理的角度看待闲鱼的原方案,一般移动端老项目并没有现代前端这些成熟的状态管理方案,可以理解为组件化状态(状态分散在各个页面和组件中)、可变数据。由于状态分散,对状态的修改也十分随意,没有 action 等概念,所以才不得不使用 hook、反汇编等奇技淫巧,才能把散落的状态和状态变化收集起来,实现回放。这种解决方案不仅繁琐,且与平台强相关,想移植到不同的平台上就得重头再来。
当然,对闲鱼这种体量的老项目来说其实没得选,不可能为了一个锦上添花的功能去重构整个项目的状态管理。但如果你要起一个新项目,又有这方面的需求,那无疑选择全局状态管理更为方便。
全局状态管理与游戏存档
我们都知道游戏有存档功能,能够记录并恢复任一时刻的状态。但游戏类型不同,实现存档功能的做法和难度也不同。
之前我好奇有没有一种通用的游戏存读档机制,便向 Unity 社区寻求灵感。Unity 默认的范式不同于前端的声明式,采用的是实体-组件模式(Entity-Component)。场景中的物体称为 Entity,本质只是一个容纳 Component 的容器。而 Component 承载了各式各样不同的业务逻辑,是复用的基本单元。不同的 Component 组合形成了各种不同功能的 Entities。
这一模式拥有很大的灵活性,使引擎得以支持各种类型游戏的开发,但逻辑的分散也使得引擎无法自带一套统一且开箱即用的存档方案。
我买了一套 Unity 上的第三方游戏框架学习。它的做法是将存档功能也作为一个组件,挂载到所有需要保存状态的实体上。存档时,该组件遍历实体上的其他组件,反射收集它们的内部状态并保存起来。而读档时只需反向操作一下就可以了。
这种做法侵入性低,开发者无需改变自己熟悉的开发习惯,只需在最后加入存档逻辑即可,使用方式简单易懂。
但如果考虑到游戏更新呢?组件的状态可能有增删,甚至组件乃至实体本身都有可能已不复存在。假如这时我们去载入老版本的存档……没人愿意游戏每次更新都不兼容旧版本的存档吧?
那么能否考虑借鉴全局状态管理的思想,来实现存档功能呢?我们需要将有存档需求的状态都定义在全局,而非使用它的组件中。变量的定义处和使用处相距甚远,这乍看是非常“反最佳实践”的,且与大多数开发者的习惯相冲突。恐怕大多数人已经开始摇头了。
但是换个角度看问题,将状态定义在全局上,实际上是倒逼我们在开发的早期就想清楚整个应用的状态结构。而有了清晰的全局状态数据结构之后,存档和读档不过就是一个数据结构的序列化和反序列化而已,没有任何魔法。
版本更新的场景也是同理,只需将一个数据结构转化为另一个数据结构,就完成了存档版本的升级。而这一步只是一个纯函数,不依赖任何游戏逻辑,与任何游戏引擎和框架无关。我们优雅地做到了在状态分散管理时难以做到的事。
关于全局状态管理的一些「迷思」
在网上关于全局状态管理的评论中,经常能看到一些典型的观点。让我们对它们做一些分析。
“全局变量”是邪恶的
全局状态管理的方式往往容易让人联想到全局变量,而全局变量的坏处早已在大家心里根深蒂固了,因此对全局状态管理也会下意识产生抵触心理。
但请注意,虽然它们看上去很像,但全局状态管理并不等于全局变量,而区别正在于“管理”两字。全局变量的坏处很大程度上是因为它全局的可见性,导致我们可以在任意处定义一个全局变量,并在任意处修改它,以至于难以追踪它的变化。
而当我们使用一个全局状态管理库时,我们需要以合适的方式组织状态的结构,而非随意地定义一个全局变量。同时这些状态管理库都推荐我们不要直接修改全局状态,而是采用 action 等形式间接修改。这就使得修改状态的行为能够被追踪,也意味着为状态上了一层封装,隔离了状态和 UI,迫使我们思考如何设计一套合理的 API 向 UI 层暴露修改状态的入口。
倘若我们把全局状态的范畴再扩大些,虚拟机的堆、后端的数据库等,不也是全局状态和数据吗?但我们通过抽象、封装,在它们的基础上合理组织变量的数据结构和访问控制,而并不会去质疑它们本身是否邪恶。
领域(业务)状态由全局管理,UI 状态由组件内部管理
老实说这一说法并没有什么问题,而且比较中庸,能够得到大多数人的支持。但如果用它来指导开发,会发现有时我们很难界定一个状态究竟属于业务状态还是 UI 状态。有些东西,我们传统意义上认为是 UI 状态,但在交互、逻辑复杂的情况下,只放到组件内部管理也是远远满足不了需求的。
如果只是一个简单的 loading 状态,那么放到组件内部管理无可厚非。但很多时候伴随着某一处组件 loading 的,还有其他的组件状态改变,例如禁用个按钮,修改个文案之类。而这些组件在组件树上的位置可能相距甚远。
在类似这种复杂的情况下,loading 不能被视为一个单纯的 UI 状态了,它事实上被赋予了业务上的意义。
尽管前端正在逐渐接触领域驱动的概念,但并不能简单地把后端的领域模型照搬到前端。目前后端接口主流仍然采用无状态的模式,但前端则更像是一个状态机,在范式上存在着本质的区别。而有些复杂度是不能被消除的。我们不能眼睛一闭,假装几百毫秒的 loading 不存在,而应该把加载中状态和加载完成、加载失败等状态同等严肃地看待。
全局状态的生命周期管理很麻烦
如果把状态放在一个组件中,在组件创建时它会自动生成,组件销毁时也会被自动销毁。但如果将它放到全局状态管理中,我们就得手动管理它的生命周期,显得较为繁琐。如果没有及时清理,也容易产生内存泄漏的隐患。
但这有时也是一个优点。它给予了我们对状态更精细的控制能力。对于那些本来就需要跨组件共享的状态自不必说,即使是一般来说仅在单个组件中使用的状态,若加以合理的结构组织,也可以发掘出其他的用途。
一种用法是,我们可以把状态保留下来,在另一个需要相同数据源的页面中充当缓存。例如,当用户从列表页点击进入详情页,而详情页的数据还未获取到时,先展示一部分已有的字段,提升加载过程中的用户体验。
或是单纯用作已销毁组件的状态缓存,用于在 React 中实现类似 Vue 中 keep-alive
的效果 —— 虽然并不发生在 VDOM 层,但更具有普适性。
全局状态难以横向扩展, 实现“分形状态”
当状态保存在组件中时,每当组件被复用时,就会生成一份状态的副本。随着组件的复用,状态也自然而然地随着组件树的结构,形成了一棵状态树。而每一个组件也只需关注自身节点的状态,无需关心它被复用了几次,无需把它和在别处被复用的组件区分开来。
相反,如果使用了全局状态,那么每次一个组件被复用时,我们就需要在全局状态中为这个组件开辟一块新的地盘,并确保组件连接到正确的状态上。这似乎使全局状态依赖了 UI 的变更,不利于组件的复用和横向扩展。
这里的问题是,首先把状态按照组件树的层次结构进行组织本身就是不合理的。其次,虽然使用了组件化状态后,每个组件的状态自治了,我们在技术上无需关心每个组件被复用了几次,每次复用的区别是什么,但它们的区别在业务上是客观存在的。一旦业务需求中需要感知这些信息,组件化的状态就要抓瞎了。
举例来说,项目中有很多表格,希望能让用户自定义字段的排序、显隐等状态。第一想法是把这些状态放到表格组件中,这样所有使用该表格组件的地方都自然拥有了这些功能,调用方无需感知。
但有一天,新的需求希望能把用户自定义后的配置保存到 localstorage 或后端,以便记忆用户的自定义设置。业务上这很合理,但技术上你就不得不想办法把每个技术上相同的表格组件区分开来,接受它变得不那么组件化这个事实。你需要为每个表格分配一个业务唯一的 key,把配置存储到一个中心化的、扁平的 Map 结构中。
你说的这么多用途,我都用不上
可能有人说,我平时的需求就是进入一个页面,请求一次数据,展示出来而已。你上文举的例子都不怎么典型,不是我们常见的前端功能。纵使你说的千般合理,千般有用,对我来说却只是徒增麻烦罢了。
这确实是一个很现实的问题,它反映的正是我想在下文中提及的,目前前端领域所面临的一个“结构性矛盾”。
状态管理之争,实为前端应用化之争
从前端文档到前端应用,是现代前端发展的主要脉络之一。理解了这一脉络,就能理解现在前端发生的很多事情。
在脉络的一端,是我们熟知的传统博客、展示型官网等。这些前端只有数据,几乎没有状态一说,自然也不存在什么所谓的状态管理。
而脉络的另一端,则是放在十几年前只可能出现在桌面端的在线文档、视频编辑器、页游等应用。这些应用的开发者,他们的关注点早已超越了什么组件化与全局状态之争。他们早已更进一步,研究起了状态具体的组织形式、数据结构等,以适应其特殊的需求。
如果你需要处理复杂的动画或 AI 逻辑,你可能需要使用 xstate 把状态抽象为状态机;如果你需要多人协同,你可能需要 Yjs 处理状态的同步与冲突;如果是业务逻辑重前端的应用,你可能需要借助 MobX、Remesh 等实践前端 DDD:在这些场合中,我们熟悉的基于 Plain Object 的传统状态管理早已不够用了。
然而,纯粹前端应用的开发在如今的前端市场上毕竟是少数。而我们平时更为常见的官网、管理后台等需求,它们的形态更接近于前端被发明之初的用途——前端文档上。它们真的需要前后端分离,需要 SPA 吗?
尽管现代前端应用对这些需求早已有了成熟的解决方案,例如 SSR、SSG 等渲染方案,一堆基于 SPA 的 admin-template 等等。但在我们看不到的角落,在很多传统行业中,使用的还是古老的 JSP、ASP,甚至传统 C/S 架构。而它们的用户,关注更多的还是系统的功能是否全面,并不过于在意用户体验能用就行。若非特意指出,甚至感知不到它们与 SPA 的差别。这些老系统会继续运行很久很久,直到它们的维护团队跑路为止。
虽然很遗憾,但我们不得不承认,对于这种类型的网站来说,前后端分离与 SPA 更多提升的是开发体验,而不是用户体验。
这也是为什么我们老是争论状态管理的方式:并不是哪一方错了,只是我们虽然都名为前端,但解决的是不同的问题。
不过,前端终究还是会朝着应用化的方向发展。毕竟前端应用是前端文档的超集,后者能做的前者也能做。而即使是传统的前端文档也在逐渐融入应用的要素,例如在技术文档、博客中引入 MDX 等,以增强页面的交互性,以及在后台管理系统中各种复杂联动、交互的表单等等。如果还是按照传统的思维开发这些功能,迟早会走进死胡同。
回到主题,我并不鼓吹你们把所有的状态都放到全局管理,毕竟现实的项目中总是存在着权衡,没有银弹。但我希望能放下旧有的观念,重新思考状态的组织形式。
状态并不理所当然是本地优先的,实在满足不了需求才要被放到全局中。相反,我们应当规划好状态的数据结构与层次,优先考虑把它们放到组件以外的状态管理中,做好分层与隔离。
即使我们最终仍然选择将某个状态放到组件内部,我们也应当清楚地知道这么做的理由。是因为它纯用作一次性展示?还是与业务逻辑无关的简单交互?或是仅仅为了实现简单?都可以,但并不理所应当这样做。