为了账号安全,请及时绑定邮箱和手机立即绑定

中年回顾:我眼中的状态管理那些事儿

标签:
vuex redux

在设计系统和组件库上工作了两年后,我现在正在一个动态产品上工作,这促使我对之前初创公司的技术决策有了新的思考。

我在这篇文章里回顾了使用或不使用一些常见的普通状态管理库的一些心得。这些库基于flux模式,用于管理应用程序的状态,尽管它们看似普通,但实际上却非常有用。

🍿 以下讲述了2018年至2024年间三个前端架构的故事,每个架构都采用了不同的技术栈和状态管理方案,这些方案都符合监管要求。

I. 拥有几千名试用版用户的金融科技App

技术栈:React 14 版本,React-Redux,Yup,Bootstrap,Express,MongoDB,Redis

尼克拉·恩斯特为Fintory(尼克拉·恩斯特设计)的用户界面

来自Dribbble的尼克拉·恩斯特(Niclas Ernst)制作的基金对比设计的图片

那时我在一家B2B金融科技公司使用React-Redux,我们用React 16来构建双模块应用程序,状态都是通过几个connect()函数管理的。

Redux 解决了在视图层面建立一个数据来源的需求,以便管理特定功能的状态,特别是在应用程序的不同部分需要访问特定功能的信息时。

以下是简化的视图,使用户能够将股票添加到股票组合中,并联系客户支持。

每个特性都有自己的 reducer,然后 store 会把这些 reducer 合并起来。

一个功能导向型大型商店的示意图

// store.js
const configureStore = () => {
  const store = createStore(
    rootReducer: {
        account: accountReducer,
        stock: stockReducer,
        support: supportReducer,
    }
 ...
  );

  return store;
};

全屏模式 退出全屏

这些计划体现了业务需求。

  • 查找并添加股票至个人股票组合的能力
  • 查看并比较已购股票的表现
  • 与客服人员私下交流投资组合的能力

就像你不会每次需要显示数据的时候都向服务器发起 API 请求一样,store 作为中间层来存储和操作展示数据。当你需要时,可以向服务器发送 POSTDELETE 请求,响应会更新 store。

每个应用功能都有自己的商店,特定模块的管理因此更容易维护……只要我们计划周全。

如何使手术排序系统更现代化

The Stack: Angular 8, 动态表单(Dynamic Forms),RxJS 和 Angular Material、FHIR 服务器端

患者信息表

2020年,我加入了一家专注于健康科技的公司,该公司以临床数据存储为强项,但正在尝试拓展健康应用程序领域。在我团队参与的一个项目里,我们被要求将一个旧的.NET手术优先级系统重构为Angular 9应用程序,供一个省级卫生系统使用。该系统的主要使用者将是医院的行政人员。

该应用程序会根据患者填写的8个部分、共120个字段的表格中的答案评估患者需要多快接受手术。

虽然它不像医疗设备那样严重,但它对患者的健康结果有很大影响,特别是在决定谁优先进行脑部扫描时。

Angular MVC 架构图 一个 Angular MVC 架构图

Angular的MVC架构意味着的是服务类组件在视图间传递状态。

我们的表格表现得非常灵活。

  • 显示的字段根据问答模型来决定,基本上由 Formbuilder 控制
  • 后续显示的字段则取决于其他字段输入值的组合

哦,实际上,相关方决定每个字段都具有实时的 oninput 验证功能,这些验证依赖于其他字段的值的变化。

这不正适合用状态管理来解决吗?

我们没用到它。

相反,我们用RxJS的可观察对象来订阅异步事件数据流中的数据,并通过服务类在视图间传递信息。

只使用服务来共享状态会怎样?

    // question.service.ts
    @Injectable({
      providedIn: 'root'
    })
    export class QuestionService {
      // 获取患者基本信息问题列表: { ...}
      getPatientIntakeQuestions(): QuestionBase<string>[] {...}

      // 获取患者手术史问题列表: { ...}
      getPatientProcedureHistory(): QuestionBase<string>[] {...}

      // 获取患者就诊史问题列表: { ...}
      getPatientEncounterHistory(): QuestionBase<string>[] {...}
    }

切换到全屏、退出全屏

  • 服务类越来越长,因为要添加越来越多的自定义方法来检查特定条件的方法。

  • 状态是暂时的,只能通过内联控制台输出来调试。

    在你需要根据条件添加字段的情况下,你必须遍历表单组来检查特定嵌套表单组中的 AbstractControl 是否已被用户操作。

  • 当条件满足时展示的内容和实时检查依赖于一个包含 280 种组合的自定义表单验证规则的 JSON 文件来进行。

  • 业务逻辑开始渗入控制器,不清楚应该把哪些加载、更新状态或产生副作用的函数放在哪里。

  • 我们误用了 post 和 fetch 请求来记录状态变化,因为在没有 sink 点的情况下,无法保存这些变化。

当时,Angular 还没有独立的组件,我们也并不了解 Angular Universal 的 SSR 能力。我们是依次进行多次 API 调用以获取并填充登录后的问题和之前的答案,而不是通过服务器端渲染这些问题的静态表单。

由于表单很长,需要在填写过程中保存进度。由于患者健康信息的敏感性,LocalStorage 不是一个可行的解决方案。我们定期在用户不活跃时将“自动保存”的版本发布到另一个端点。

    // dynamic-form.component.ts
    export class DynamicFormComponent implements OnInit {
      form: FormGroup;
      constructor(private formBuilder: FormBuilder, private questionService: QuestionService) {}

      ngOnInit() {
        this.form = this.formBuilder.group({
          patientIntake: this.formBuilder.group({
            questions: this.questionService.getPatientIntakeQuestions()
          }),
          patientProcedureHistory: this.formBuilder.group({
            questions: this.questionService.getPatientProcedureHistory()
          }),
          patientEncounterHistory: this.formBuilder.group({
            questions: this.questionService.getPatientEncounterHistory()
          }),
          patientSurgeryHistory: this.formBuilder.group({
            questions: this.questionService.getPatientSurgeryHistory()
          }),
          ...// 以此类推...
        });
      }
      ...
      onInputChange() {
        // 检查是否有字段被修改
        // 检查是否有字段所属的表单组需要添加实例对象
        // 实时验证表单字段
        // 在表单字段上方显示错误
      }
      ngOnSubmit(){
        // 遍历所有表单并
        // 在表单上方显示表单级验证错误
      }
      onSubmit() {
        // 实际提交表单,发送 POST 请求
        this.formService.submitForm(this.form.value).then(...
        // 通知用户任何响应错误
        )
      }
    }

进入全屏 退出全屏

从应用架构来看,我们采用了一种奇怪的混合结构,这种结构结合了按照领域划分的模块和Angular的“共享”文件夹结构。

动态表单和反应式表单满足了渲染不同表单数据模型的需求,但一旦我们需要更复杂的表单交互或动态内容时,这种情况就变得有些麻烦了。

  • 根据条件渲染嵌套的表单组或字段
  • 根据条件禁用已填字段
  • 编辑或填写表单时增加另一个字段
  • 根据其他字段的值来验证表单字段

没用状态管理方案带来的麻烦:代码冗长拖沓、命令式过重(比如‘if abc,else if xyz,else if jkl...’),维护和调试起来相当困难。

III. 这个七年的老购物应用一直没有被重构过

旧的技术栈:Nuxt 2,Vue 2,VueX,Tailwind CSS,Vercel,MySQL,Laravel

新的技术组合:Nuxt 3,Vue 3,Pinia(状态管理库),Tailwind,Cloudflare,MySQL,Laravel

去年,我一直在将 Vue 2 和 Vuex 迁移和适配到 Nuxt 3 中的 Vue 3 和 Pinia,这两个库,用于一个电商应用。

想象一个销售精美小物的应用,每当用户登录时,都会更新他们的所有相关信息。

一开始,这家公司只在加拿大开展业务。有一家店已经足够好了。

之后,这款应用决定在美国扩大业务,并销售美国本土商品,还给美国用户特定的优惠和折扣。

开发人员决定创建一个订单存储来应对用户可能使用多种货币和支付方式购物的可能。(这虽然是一个牵强的例子,但与我实际遇到的情况相差无几。)

该项目团队正忙着推出一个新的商店来处理美国订单,但加拿大订单还是要靠原来的商店来处理。

出什么事了?

Animated talking muppet gif reading

这家店被分成了几个模块,每个模块对应不同的功能区,但也有不少奇怪的设置或安排:

  • 同名的状态属性和操作。orderStore().confirmOrders()accountStore.purchaseItems() 执行类似的功能,但它们位于不同的store中。accountStore 有一个 items 属性,用于跟踪购物车中预结账时的 order.data.items,而 orderStore 中也有一个 orderStore.items 属性用于跟踪结账后购物车中的项目。

  • 重复数据状态:用户登录后,账户存储会立即填充相关信息;在处理美国用户的会话时,每当实例化订单存储时,它都会随之更新。

  • 知识循环缺失:应用程序的许多功能仍然依靠旧的 order.data.items 属性,但 orderStore 中的 order.items 库存需要以美元货币显示。如果不更新依赖于旧商店的这些部分,正确的购物车显示将继续出现问题,无法正常工作。

  • 滥用 Getter:accountStore 有一些 getters,这些 getters 描述了随着时间逐渐添加的简化的用户状态细节:

  • isSubscribedToNewsletter() 已经由 user.hasSubscribed 属性表示,该属性来自 API 调用。
  • isVegetable() 对素食用户可能很有帮助,就像 ordersWithoutPeanuts() 会帮助所有不含花生的购物车商品一样,这可能是为了快速实现一个功能请求,以服务有食物过敏的用户,但并不是所有用户都需要。这两个属性最好作为 useDietaryRestrictions() 组件中的计算属性。
  • 两个仓库都没有完全迁移到 Vue 3 的 Composition API。

因此,添加的功能越多,团队就越忙于修复和同步两个存储的状态。

多个显示器通过菊花链连接到笔记本电脑的示意图

设计糟糕的商店可能会像牵牛花串的信息流那样运作,但这绝对不是你想要的样子!

最后,总得有人下决心行动,才能继续前进:

停止使用 accountStore,并将来自加拿大和美国的订单移至 OrderStore 中显示。

如果这种分割对于长远发展有益,那么两家店铺就可以各自独立运作,共享同样的数据。

  • 我们可以让第二个 store 在加载时订阅第一个 store 的状态,然后在状态变化时触发相应的动作。
  • 或者,我们不再为第一个 store 进行初始化状态,而是改为在加载时加载第二个 store 的数据,这样就不用管同步两个 store 的数据了。
开发者为啥不喜欢分发平台

处理繁琐的设置和样板代码和重复性工作

对于初学者来说,设置商店环境、定义动作、编写reducer和使用dispatcher所需的代码量可能让人感到不知所措。

显示Vue库状态管理的图示

VueX本身在提交突变之前分发操作的过程中特别冗长,

要有效地与状态管理配合,开发人员需要掌握框架的渲染机制,并权衡复杂的设置和样板代码以实现视图间状态共享。

每个操作都必须通过 reducer 创建和分发,所有使用 store 的容器组件都必须连接起来。我记不清写了多少次 mapStateToPropsmapDispatchToProps

在今天的前端生态系统中,如果你正在构建一个最小可行产品(MVP),Redux 可能过于复杂了,而且最近越来越多的人更倾向于使用 redux-toolkituseReducer 来更新状态。

过早的优化会导致更高的维护工作

为每个应用功能创建一个 store 可能会导致不必要的复杂性和耦合性。当多个 store 共享同一个数据源时,开发人员必须确保状态同步并且知道哪些 store 互相依赖。过早地拆分代码可能会让团队感到困惑,并使维护变得更加困难。

你拥有的商店越多,就越需要管理的情况越多!

存起来还是不存起来?

当使用商店进行状态管理时,这是理想的。

  • 当用户在不同视图间浏览时,需要勤快地维护作为更大产品或平台一部分的特定领域的复杂逻辑。

  • 在一个或两个组件中管理状态变得越来越困难,你会发现需要在多个组件间传递同样的数据属性。(即所谓的prop-drilling 现象)

  • 需要展示不同类型的数据显示或更新不同类型的数据,或者需要处理深层嵌套的数据以更新UI。

如果有多个状态维度需要跟踪,单一的 store 可能不足以精确地定义状态转换。可以考虑使用状态机,比如 XStateImmer

例如,如果你需要通过多步骤表单或引导程序来更改交互,或者像我之前提到的表单字段组合。

备注:我还没有用到这些库。

很容易到达一个地方,在那里没有任何单一模式被使用,但总会有人在某个时刻对是否使用了某种模式提出质疑。

  • 容器组件模式{:#container-component-pattern .tooltip}
    <!-- 注释:容器组件模式,英文为"Container-component pattern",是一种在React中使用的设计模式。 -->

(注释位置可以根据具体需求调整,这里使用了注释形式,也可以选择使用脚注等形式。)

  • 一个封装组件树状态和操作的提供者(是Context API的基础) (提供者模式)

  • 一个基于观察者模式的事件枢纽,

  • 可以在应用的任何地方更新的单例模式(React的Context和Vue的组合式可能是实现这种方法的轻量级方案,但组合式并不能很好地替代你对单例模式的期望行为。)
当代状态管理方法

如今的前端框架提供了轻量级的状态管理功能。对于小型到中型的应用程序,使用内置机制就已经足够应对。

React的钩子(useStateuseEffectuseEffectuseContext)使得封装反应式业务逻辑更容易。例如,Context API通过使状态在任何组件级别均可访问,减少了 prop 传递。

Vue的computed()ref()watch()函数,还包括组合式 (composables),可以实现类似 React 钩子 (hooks) 和上下文 (context) 的功能。

像 Svelte 和 Angular 中提供的 signals 这样的新功能也出现了。

框架的目标是:

  1. 当数据发生变化时,动态更新 UI
  2. 订阅和取消订阅数据变化的更新
  3. 管理副作用
  4. 区分本地状态、共享状态和全局状态

当你需要它的时候,最好是在混乱之前就定下一个策略。

……此处省略……

  1. 这篇帖子的内容是基于我的工作中的工作经验,但我修改了具体细节。 ↩

  2. React的基本原则促进了关于数据单向流动通过组件的思考,开发人员可以轻松地通过诸如componentDidMountcomponentDidUpdate等方法来控制渲染及交互。↩

  3. Wassim Chegham. "Angular Universal 简明指南",参见 https://medium.com/google-developer-experts/angular-universal-for-the-rest-of-us-922ca8bac84

  4. Dan Abramov. 你可能根本不需要 Redux(一个状态管理库)。2016年9月19日发布于Medium。 ↩
点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
微信客服

购课补贴
联系客服咨询优惠详情

帮助反馈 APP下载

慕课网APP
您的移动学习伙伴

公众号

扫描二维码
关注慕课网微信公众号

举报

0/150
提交
取消