阅读视图

发现新文章,点击刷新页面。

我的状态管理哲学

背景

简单讲讲 react 状态管理的演进过程

React状态管理的演进始终围绕“组件通信”与“状态复用”两大核心需求展开。

早期类组件时代,开发者依赖props实现组件间传值,通过state管理组件内部状态,但跨层级组件通信需借助“props drilling”(属性透传),代码冗余且维护成本高。

为解决这一问题,Redux、MobX 等第三方状态管理库应运而生,通过集中式存储、统一状态更新机制,实现了全局状态共享,但其繁琐的配置与概念(如reducer、action、中间件)也增加了开发门槛。

随着React 16.8推出Hook特性,函数组件得以拥有状态管理能力,useState、useContext等原生Hook的出现,为轻量型状态管理提供了可能,也推动开发者探索更简洁、无依赖的状态管理思路,逐步打破对第三方库的依赖。

React生态核心路由工具react-router(常用v6版本)虽不直接管理状态,但与状态管理深度关联。其路由参数、查询参数及导航状态需与全局/局部状态联动(如通过路由参数获取详情ID、同步登录状态控制跳转权限)。React Router v6适配Hook,提供useParams、useSearchParams等方法,可便捷操作路由状态,后续自定义的原生Hook状态管理方案可与其兼容,实现路由与业务状态的协同管控。

第三方状态管理库中,react-query(现更名TanStack Query)极具代表性,它跳出传统全局集中存储思路,专注服务端状态管理,补齐了传统库与原生Hook在异步数据处理上的短板。不同于Redux等通用库,它专为接口请求、数据缓存等服务端状态场景设计,无需手动维护加载、错误等冗余状态,大幅简化异步逻辑。但它不擅长客户端状态(如主题、弹窗),需搭配客户端状态管理方案使用,这也印证了状态管理无万能方案,需结合场景选型。

Zustand是一款轻量的第三方状态管理库,基于Hook设计,兼顾简洁性与实用性。它无需Context嵌套,通过自定义Hook即可便捷获取和修改全局状态,规避了Context重渲染的问题,同时简化了Redux等库的繁琐配置。

取舍

只要经过长年多个项目的开发经历,就会发现,没有哪个方案非常适用于所有的场景。

  1. Redux:优点是状态集中可追溯、生态完善、适合大型项目团队协作;缺点是配置繁琐、概念多(reducer/action等)、上手成本高,小型项目使用显冗余。

  2. Mobx:优点是响应式更新、编码灵活、无需手动编写大量模板代码;缺点是依赖装饰器语法(存在兼容问题)、状态变化隐性化,复杂项目易失控。

  3. Zustand:优点是轻量简洁、基于Hook设计、无Context嵌套、规避重渲染问题;缺点是生态不如Redux完善,大型项目复杂状态管控能力稍弱。

  4. react-query(TanStack Query) :优点是专注服务端状态、自动处理缓存/重试/加载状态、大幅简化异步逻辑;缺点是不擅长客户端状态管理,需搭配其他方案使用。

  5. rxjs:优点是擅长处理复杂异步流、状态联动能力强、可复用性高;缺点是学习曲线陡峭、概念抽象,简单场景使用成本过高。虽然 rxjs 本身不是状态管理,但其处理异步流的能力可以轻松构造出灵活的状态管理方案。

  6. react-use、ahooks:优点是封装大量通用Hook(含状态管理相关)、复用性强,简化重复开发,贴合React Hook生态;缺点是侧重通用Hook合集,无专属全局状态管理体系,复杂状态联动需基于其二次封装。

实际使用会将方案组合使用,这里我们会发现存在两种矛盾:

  1. 如果你倾向于使用 react hook 去开发逻辑,那么共享状态采用 context,会出现 context 套 context,逻辑混合在UI 组件树上,极难看懂,复杂应用中容易陷入性能优化又劣化的循环中。

  2. 如果不想使用 react context 作为状态共享的方案,通常是希望应用的业务状态能与 UI 框架解耦,选择 redux 和 zustand。这时候又会发现,这些方案并没有提供与 react hook 类似的逻辑组合复用能力,进入堆叠面条代码 “大力出奇迹” 的陷阱。

本文并不打算完全解决这种矛盾,这是我经验上判断 react 状态管理存在的问题,或许有些大佬有这方面的解决方案也说不一定。

沉思与创造

相信一些对状态管理或者应用架构设计感兴趣的人,必然设计过自己趁手的状态管理库。

我理想中的状态管理库应该能够做到以下的事情:

  1. 响应式状态:存储状态、读取状态、更新状态、订阅状态变更。

  2. 状态类:一组状态可以形成一个模版类型,并创建实例。

  3. 副作用管理:一个状态实例会管理自己的副作用(如定时器、监听器等),实例销毁(destroy方法)会清除其所有的副作用。

  4. 层次管理:一个状态实例A可能被另一个状态实例B持有,这是一种附属关系(如某种插件机制),实例B销毁的时候,实例A也会销毁(连带副作用清除)。

  5. 聚合事件:是对 “多个分散状态变更 / 副作用触发” 的统一收口与联动管理

  6. 组合复用机制:就像 react 自定义 Hook 一样,一些纯工具能力应该能轻易的复用并组合。

我曾自己尝试过很多种方案组合并用在自己维护的项目上,上面可以说就是无数次错误尝试与痛苦挣扎的总结。

一切不优雅的 hack 方案和 shit 代码都是源于某些能力没有提供,而你没有办法让库的提供者提供你想要的能力,可能得到的回答就是 “我们需要保持简洁、纯净”,你可以通过某某方式间接实现。

为什么要委屈自己,去接受这草台般的世界?最终我决定了放下一切信仰,自己开宗立派。

让我们一步步推演出这个方案的摸样(仅考虑 API 的设计,因为实现不复杂且一直在变化)。

状态类

一组状态可以形成一个模版类型,并创建实例。

虽然这是第二点,但还是先说说这个,存在 API 的依赖。

基于理想中“状态类”的诉求,我们先定义状态模版的创建方式——通过 createModel 方法封装状态的初始化逻辑,支持传入 id 和自定义参数 param,让同一种状态模版可以生成多个独立实例,兼顾复用性与灵活性。

const StateModel = createModel({
    initState: (id, params)=>({
        count: params.count
    }),
});

响应式状态

存储状态、读取状态、更新状态、订阅状态变更。

有了状态类的模版定义,接下来就要落地响应式核心能力——毕竟光有模版不能读写更新,跟空架子没区别。响应式API要足够简洁,还得兼顾灵活性,不用搞一堆冗余配置,直接在状态实例上挂载核心方法就行,具体用法如下:

// 创建状态实例,调用 create 方法 (id,  param) 传入 initState
const stateIns = StateModel.create('default', { count: 1 });
stateIns.getState();
stateIns.setState({ count: 2 }); 
// 或者 
setState(s => ({count: s.count + 1}));
stateIns.subscribe((state, prevState)=>{ /* 状态变更回调 */ });

这就够了吗?到这里看起来就是跟 Zustand 的 store 一样,没什么特别的。

不够!只有信奉极简主义者才会觉得这是够的。

subscribe 是一种极其简陋的实现,它存在以下问题:

  1. 订阅粒度太粗,没法精准订阅某个字段,哪怕只改了状态里的一个字段,所有订阅者都会被触发,跟Context的重渲染坑一模一样,大型应用里纯属性能灾难。
  2. 只是订阅了状态的变化,应该有场景需要对初值进行回调,因此需要分开它们。

可以使用 rxjs 提供 Subject 来暴露订阅接口。

stateIns.states // 分离字段的 BehaviorSubjects
stateIns.fullState // 整体状态 BehaviorSubject
stateIns.updates  // 分离字段的更新事件 Subject
stateIns.fullUpdate // 整体状态的更新事件 Subject

副作用管理

一个状态实例会管理自己的副作用(如定时器、监听器等),实例销毁(destroy方法)会清除其所有的副作用。

先说说计算属性吧,计算属性作为 state 的衍生。需要追踪 state 变更并重新计算,为了减少重复计算,如果没有像 vue proxy 响应式机制,那么就只能自己手动给到了。

const StateModel = createModel({
    initState: (id, params) => ({
        count: params.count,
        others1: 123,
        others2: 456,
    }),
    // 添加 computed 
    computed: {
        double: {
            dep: s => [s.count],
            run: s => s.count * 2,
        }
    },
});

const stateIns = StateModel.create('default', { count: 1 });
stateIns.getState().double // 2

这个设计,我只能说,很丑陋,实际也没想象中那么实用,后面再说。

再说说副作用。

const StateModel = createModel({
    initState: (id, params)=>({
        count: params.count,
        others1: 123,
        others2: 456,
    }),
    // 添加 effects
    effects: {
        effect1: {
            dep: s => [s.count],
            run: (state, setState)=>{
                const t = setTimeout(() => {
                    console.log('count is ', state.count)
                }, 3000);
                return () => clearTimeout(t);
            }
        }
    }
});

const stateIns = StateModel.create('default', { count: 1 });
stateIns.setState({count: 2});
stateIns.setState({count: 3}); // 3s 后打印 "count is 3"

表达了当 count 更新并稳定 3s 后打印它。

到这里,其实我也不知道自己在写什么了,effects 的存在究竟为了什么?为了模拟 react 的 useEffect 吗?不管怎么样,存在即合理,如果要把代码从 react 屎山迁过来,结果发现没有 effect 能力该是多头疼。

可以对比一下 Zustand,它根本就没有这两个东西。Zustand 直接返回状态和封装过的方法,不直接让使用方调用 setState,而是把状态更新逻辑封装在自定义方法里,更侧重“状态操作收口”。

但是代码写起来就是另一种意义上的丑陋了。

// Zustand 典型用法
import { create } from 'zustand';
import { debounce } from 'lodash-es';

// 直接创建store,封装状态和更新方法,不暴露setState
const useCountStore = create((set, get) => {
  const logCount = debounce(() => {
    console.log('count is ', get().count);
  }, 3000);

  return ({
    count: 1,
    double: 2,
    // 封装更新逻辑,使用方直接调用方法,无需手动setState
    increment: () => {
      set((state) => ({ count: state.count + 1, double: (state.count + 1) * 2 }))
      logCount(); // 手动调用
    },
    decrement: () => {
      set((state) => ({ count: state.count - 1, double: (state.count - 1) * 2 }))
      logCount(); // 手动调用
    },
    setCount: (val) => {
      set({ count: val, double: val * 2 })
      logCount(); // 手动调用
    },
  })
});

这大概就是为什么我始终没有大规模使用 Zustand(或许是用法不对吧)。

再回到自己的设计上来,我意识到直接让状态实例订阅自己的状态再执行计算属性变更和副作用,也能达到一样的效果。同时受到 Zustand 的启发,setState 就不应该暴露给外部使用,应该直接封死在内部的 actions 里面。

import { debounceTime } from 'rxjs';

const StateModel = createModel({
  initState: (id, params) => ({
      count: params.count,
      double: params.count * 2,
   }),
  actions: (get, set) => ({
    inc: () => set({ count: get().count + 1 }),
    dec: () => set({ count: get().count - 1 }),
    setCount: (val) => set({ count: val }),
  }),
  // 处理 computed 和 effect 的地方
  setup(self) {
    return [
      // 1. 直接通过同步监听 count 来设置 double
      self.states.count
        .subscribe(c => thisInstance.setState({ double: c * 2 })),
      // 2. 监听 count 再 pipe 一个 rxjs 防抖操作符
      self.states.count
        .pipe(debounceTime(3000))
        .subscribe(c => console.log('count is ', c)),
    ]
  }
});

const stateIns = StateModel.create('default', { count: 1 });
stateIns.actions.inc();
stateIns.actions.inc(); // 3s 后打印 "count is 3"

这个方案已经足够好用了,曾经我也这么认为,并在一个项目里面大规模使用,直到有人接手了我的代码并开始了吐槽。

我并不觉得 rxjs 是多么高深的技术,但事实如此 …… 不是谁都能接受的。

这个问题先按下不管,接着看。

createModel 内部通过返回 subscription 数组并在 destroy 的时候取消即可实现副作用的清理

ins._subscriptions = setup(ins);

ins.destroy = ()=>{
    ins._subscriptions.forEach((sup)=>{
        sup.unsubscribe();
    })
}

上面的例子只在 setup 中实现了静态的副作用管理,当然还需要考虑动态添加副作用的情况。

比如在调用 action 方法的时候,开启一个定时器 interval 执行一些操作,同时还要考虑多次调用时对上一次副作用的清理。

这就需要引入一个动态副作用管理的工具了。

import { Subscription, Observable } from 'rxjs';
type FunctionClean = () => void;
type Cleanup = Subscription | FunctionClean;

type EffectHandle = 
    Observable 
    | (() => FunctionClean); 

class EffectManager {
    addEffect(cleanup: Cleanup): FunctionClean;
    runEffect(handle: EffectHandle): Cleanup;
    cycleEffect(name: string, cb: () => EffectHandle | void): void;
    cleanAll(): void;
}

结合 EffectManager 使用示例,这时候静态副作用和动态副作用都可以用 EffectManager 管理,setup 里面也就可以显式添加副作用。

// 核心使用示例:结合状态模型的action动态管理副作用
const StateModel = createModel({
  initState: (id, params) => ({
    count: params.count,
    double: params.count * 2,
  }),
  actions: (get, set, self) => {
    return {
      inc: () => set({ count: get().count + 1 }),
      dec: () => set({ count: get().count - 1 }),
      // 动态副作用场景:调用action时开启定时器,多次调用自动清理上一次
      startCountLog: () => {
        self.effectManager.cycleEffect('countLog', () => {
          return self.states.count.pipe(
              debounceTime(3000), 
              tap((c)=>console.log('log', c))
          );
        });
      },
      stopCountLog: ()=>{
          // 传空即可清除
          self.effectManager.cycleEffect('countLog', ()=>{});
      }
    };
  },
  setup(self) {
    // 静态副作用:初始化时监听count,同步更新double
    const sub = self.states.count.subscribe(c => {
      self.setState({ double: c * 2 });
    });
    // 将静态副作用交给EffectManager管理
    self.effectManager.addEffect(sub);
  },
});

// 组件/业务中使用
const stateIns = StateModel.create('default', { count: 1 });
// 调用动态副作用
stateIns.actions.startCountLog();
stateIns.actions.inc();  // 3s 后 log 2

createModel 内部的 destroy 也就变成了

ins.effectManager = new EffectManager;
setup(ins);

ins.destroy = () => {
    ins.effectManager.cleanAll();
}

当然上面这个 destroy 是被简化过了,实际上还需要阻止 ins 上所有 rxjs Subject 继续被订阅。

层次管理

一个状态实例A可能被另一个状态实例B持有,这是一种附属关系(如某种插件机制),实例B销毁的时候,实例A也会销毁(连带副作用清除)。

类似组件树,状态实例也可以是一个树状的组合关系,父节点调用 destroy,子节点递归调用 destroy,完成副作用的全面清理。

const a = StateModel.create('a', { count: 1 });
//             通过调用 create 时第三个参数传入父节点
const b = StateModel2.create('b', { other: 0 }, a);

a.destroy() // 连带触发 b.destroy() 

上面的例子已经说明了层次管理的含义,在插件化的设计中,围绕核心实体去挂载其他插件实体,可以确保核心实体销毁时插件实体也被销毁。

不过我想这一节可以顺便聊聊依赖注入。

基于层次管理实现依赖注入

// ContextModel 很重要,可以作为内部的一个导出
const ContextModel = createModel({...})

const AppModel = createModel({...})

const FeatureModel1 = = createModel({...})

比如 FeatureModel1 依赖某个存储方法,通过一个 createDep 创建这个依赖。

export const StoreDepToken = Symbol('StoreDepToken');
export type StoreDepType = {
   get(name: string): string;
   set(name: string, content: string): void;
}

export const FeatureStoreDep = createDependency<StoreDepType>(StoreDepToken);

export const FeatureModel1 = = createModel({...})

然后通过 ContextModel 上的 setDep 来提供它。

const context = ContextModel.create('', {});

context.actions.setDependency(
    FeatureStoreDep.provide({
        get(name){
           localstorage.getItem(name);
        },
        set(name, content){
            localStorage.setItem(name, content);
        }
    })
);

FeatureModel 可以在 setup 中获取到,进而实现了依赖注入。

export const FeatureModel1 = = createModel({
    ...,
    setup(self){
       // 获取父节点中类型为 ContextModel 的 实例
       const context = self.getParent(ContextModel);
       // 取出设置的依赖
       const storage = context.actions.getDependency(FeatureStoreDep)
                           || {...} ; // 注意兜底
       // 使用它们
       storage.get
       storage.set
    }
})

聚合事件

对 “多个分散状态变更 / 副作用触发” 的统一收口与联动管理

因为我构思的状态管理是多实例的,不同状态实例有其独有的事件流,实际开发中是有聚合事件的使用场景的。

  1. 日志统一打印
  2. 错误事件统一处理
StateModel.aggregateEvent.updates
    .subscribe(({key, value, preValue, instance})=>{

    });

StateModel.aggregateEvent.events
    .subscribe(({eventName, data, instance})=>{

    })

写到这里,其实我还意识到,actions 也应该提供聚合事件,actions 其实是一种事件输入,其处理逻辑应该放在 setup 内部同样使用事件流订阅处理。actions 被实现为一个 proxy 并对 key 创建出一个触发输入事件的函数。

//                            定义泛型 状态、输入事件、输出事件
const StateModel = createModel<{count,double}, {inc}, {event}>({
  initState: (id, params) => ({
    count: params.count,
    double: params.count * 2,
  }),
  // 这块就不要了
  // actions: (get, set, self) => {
  //   return {
  //    inc: () => set({ count: get().count + 1 }),
  //  },
  setup(self) {
    // 改为一个 actions.inc 事件订阅
    self.effectManager.addEffect(
      self.actions.inc.subscribe(() => {
        self.setState({
            count: self.state.count + 1,
        })
      })
    );
    
    // 这个是 memo 
    self.effectManager.addEffect(
      self.states.count.subscribe(c => {
        self.setState({ double: c * 2 });
      }));
  },
});

// 组件/业务中使用
const stateIns = StateModel.create('default', { count: 1 });
stateIns.actions.inc();

这样实现有什么作用呢?当你需要对输入事件做流处理如防抖的时候,就可以直接复用到 rxjs 操作符了。

组合复用机制

就像 react 自定义 Hook 一样,一些纯工具能力应该能轻易的复用并组合。

如果你觉得前面那些还看得过去,并且用起来还不错。

对不起,到了这个地方,不破不立,我要推翻一些东西了。

本质原因是,我想用完全不同的思路去实现它,创造另外一个东西。

我开始思考成本问题

  1. 迁移成本,已有代码使用 react hook 实现状态管理,迁移到任何一种外部状态管理库方案时,如何保证实现的逻辑是一样的?尤其是一个大量使用了社区 hook 的自定义 hook 实现?

  2. 维护成本,redux 和 zustand,很难找到类似 react hook 一样的组合逻辑的能力,这大大增加了维护成本。

如果你们用过 vue 的 pinia 状态管理方案,大概就知道了,pinia store 的 setup 方法是可以在里面使用 vue composition api 的。

虽说实现框架无关是状态管理的共识,但是实现上总是以某种方式实现的,只要实现方式不影响最终的 UI 层,那么以什么方式实现,就没那么重要了。

这里我脑子里蹦出一个惊人的想法,外部状态管理,就不能使用 react hook 吗?react 的铁律告诉我们, hook 只能在组件里面使用!

先抛开固有限制,以前面实现 computed 和 effect 的例子来讲,为什么不能是这样的?使用一个 hook 方法,每次 state 变更重新跑 useMemo 和 useEffect ,并将结果合并到 hookState 中给外部使用。

const StateModel = createModel({
  initState: (id, params) => ({
      count: params.count,
   }),
  hook({state, setState, self}) {
      const double = useMemo(() => state.count * 2, [state.count]);

      const inc = useCallback(() => {
          setState(s=>({count: s.count+1}));
      }, []);
      
      useEffect(()=>{
          const t = setTimeout(()=>{
              console.log('log count', state.count);
          }, 3000);
          
          return () => clearTimeout(t);
      }, [state.count]);
      
      // hook 返回,对象合并到 hookState 里面
      return {
          double,
          inc,
      }
  }
});

const stateIns = StateModel.create('default', { count: 1 });

await stateIns.hookState.inc();
await stateIns.hookState.inc(); // 3s 后 log count 3
stateIns.hookState.double // 6

有人会说:“这不能吧,hook 只能在组件树上使用,例子上这样做会不会破坏 react 的规则?”

我的想法是,react 组件树并不一定要产生 UI 输出,也可以单纯维护状态实例树。

有什么好处?好处可太大了!

你可以使用 react-query 发起请求,它帮你维护了请求状态(data, loading, fetching, error, time),但是这是 hook 的用法,你把请求放在 Zustand store 里面,你将失去一切!

但是,在一个底层以 react hook 实现的外部状态管理库中,你得到了这一切。

image.png

外部状态库可以直接使用 react hook 是一种巨大的吸引力,逻辑复用直接就是 react hook 的思路。react-use、ahooks,这些都能复用上了,像呼吸一样简单。

总结:复杂 or 简洁

做稍微复杂的设计,是为了在结构上承载复杂的逻辑,让转移后的复杂度变得可控、可维护。前文设计的状态实例层次管理、聚合事件、动态副作用管控等特性,看似增加了方案本身的设计复杂度,实则是为了承接业务中多实例联动、多状态协同、异步流处理等复杂场景的需求——如果为了追求方案表面的简洁,省略这些设计,复杂度并不会消失,反而会转移到业务代码中,变成分散的冗余逻辑、难以维护的硬编码,最终形成“表面简洁、内在混乱”的代码困境。这种有目的的复杂设计,核心是通过结构化的方案设计,将业务复杂度收纳在合理的框架内,兼顾扩展性与可维护性,避免复杂度无序扩散。

❌