用类写状态、顺手带上 IoC:我做了一个叫 easy-model 的 React 状态管理库

7 阅读7分钟

做前端几年下来,我始终有两个感觉:

  • 我其实更习惯用“类 + 方法”来表达业务模型
  • 但在 React 里,主流状态管理方案(Redux / MobX / Zustand)要么过于 ceremony,要么在 IoC / 深度监听上不够顺手

具体来说:

  • Redux:生态强大,但需要 action / reducer / dispatch / selector,一写就是一堆模板代码,很多项目最后是为 Redux 的心智模型服务,而不是为业务服务。
  • MobX:写起来很爽,但响应式系统比较“黑盒”,依赖收集和更新路径藏在内部;想做一些 IoC / 命名空间隔离 / 深度监听时,需要自己拼好多工具。
  • Zustand:非常轻量好用,但本质还是一个“函数式 store”,在类模型、依赖注入、全局异步 loading 管理、深度 watch这些场景上,不是它的设计重点。

所以我想要一个东西:

能不能只用 TypeScript 的类来写业务模型,顺手就能:

  • 在 React 里直接用 hooks 创建 / 注入模型实例;
  • 按参数自动缓存实例,天然按业务 key 分区;
  • 深度监听模型及其嵌套字段;
  • 自带一个 IoC 容器和依赖注入能力;
  • 还要有不错的性能。

于是就有了这个库:easy-model

easy-model 是什么?

一句话:

围绕「类模型(Model Class)+ 依赖注入 + 精细化变更监听」构建的 React 状态管理与 IoC 工具集。

你可以用普通的 TypeScript 类描述业务模型,通过少量 API 即可:

  • 在函数组件中直接创建 / 注入模型实例useModel / useInstance
  • 跨组件共享同一实例,支持按参数分组实例缓存:provide
  • 监听模型及其嵌套属性的变化watch / useWatcher
  • 用装饰器和 IoC 容器做依赖注入Container / CInjection / VInjection / inject
  • 统一管理异步调用的加载状态loader / useLoader

npm 包:@e7w/easy-model
GitHub:https://github.com/ZYF93/easy-model

用法长什么样?

1)最基础的:类 + useModel + useWatcher

import { useModel, useWatcher } from "@e7w/easy-model";

class CounterModel {
  count = 0;
  label: string;

  constructor(initial = 0, label = "计数器") {
    this.count = initial;
    this.label = label;
  }

  increment() {
    this.count += 1;
  }

  decrement() {
    this.count -= 1;
  }
}

function Counter() {
  const counter = useModel(CounterModel, [0, "示例"]);

  useWatcher(counter, (keys, prev, next) => {
    console.log("changed:", keys.join("."), prev, "->", next);
  });

  return (
    <div>
      <h2>{counter.label}</h2>
      <div>{counter.count}</div>
      <button onClick={() => counter.decrement()}>-</button>
      <button onClick={() => counter.increment()}>+</button>
    </div>
  );
}
  • 状态就是字段countlabel
  • 业务就是方法increment / decrement
  • useModel 负责在组件中创建并订阅实例
  • useWatcher 可以拿到变更路径 + 前后值

2)跨组件共享 + 参数分组:provide + useInstance

import { provide, useModel, useInstance } from "@e7w/easy-model";

class CommunicateModel {
  constructor(public name: string) {}
  value = 0;

  random() {
    this.value = Math.random();
  }
}

const CommunicateProvider = provide(CommunicateModel);

function A() {
  const { value, random } = useModel(CommunicateModel, ["channel"]);
  return (
    <div>
      <span>组件 A:{value}</span>
      <button onClick={random}>改变数值</button>
    </div>
  );
}

function B() {
  const { value } = useInstance(CommunicateProvider("channel"));
  return <div>组件 B:{value}</div>;
}
  • 相同参数("channel")拿到的是同一个实例
  • 不同参数会拿到不同实例
  • 用起来不需要自己设计 Context / key-value 容器,provide 会帮你做。

3)React 外的深度监听:watch

import { provide, watch } from "@e7w/easy-model";

class WatchModel {
  constructor(public name: string) {}
  value = 0;
}

const WatchProvider = provide(WatchModel);
const inst = WatchProvider("watch-demo");

const stop = watch(inst, (keys, prev, next) => {
  console.log(`${keys.join(".")}: ${prev} -> ${next}`);
});

inst.value += 1;
// 不需要时取消
stop();
  • 可以在 React 组件外 做监听(例如日志、埋点、状态同步到别的系统)
  • keys 是精确到字段路径的,如 ["child2", "value"]

4)统一管理异步加载状态:loader + useLoader

import { loader, useLoader, useModel } from "@e7w/easy-model";

class LoaderModel {
  constructor(public name: string) {}

  @loader.load(true)
  async fetch() {
    return new Promise<number>(resolve =>
      setTimeout(() => resolve(42), 1000)
    );
  }
}

function LoaderDemo() {
  const { isGlobalLoading, isLoading } = useLoader();
  const inst = useModel(LoaderModel, ["loader-demo"]);

  return (
    <div>
      <div>全局加载状态:{String(isGlobalLoading)}</div>
      <div>当前加载状态:{String(isLoading(inst.fetch))}</div>
      <button onClick={() => inst.fetch()} disabled={isGlobalLoading}>
        触发一次异步加载
      </button>
    </div>
  );
}
  • @loader.load(true) 装饰器会把这个方法纳入“全局 loading”管理
  • useLoader 提供:
    • isGlobalLoading:是否有任意一个被 loader 管理的方法在执行
    • isLoading(fn):某个具体方法是否在执行

5)IoC 容器 + 依赖注入:Container / CInjection / VInjection / inject

import {
  CInjection,
  Container,
  VInjection,
  config,
  inject,
} from "@e7w/easy-model";
import { object, number } from "zod";

const schema = object({ number: number() }).describe("测试用schema");

class Test {
  xxx = 1;
}

class MFoo {
  @inject(schema)
  bar?: { number: number };
  baz?: number;
}

config(
  <Container>
    <CInjection schema={schema} ctor={Test} />
    <VInjection schema={schema} val={{ number: 100 }} />
  </Container>
);
  • 用 zod 的 schema 做“依赖描述”
  • Container 里用:
    • CInjection 注入构造函数
    • VInjection 注入常量值
  • 业务类里用 @inject(schema) 直接拿到依赖

这一块更多是后期复杂项目 / 多模块协作时的味道,前期不用也没关系。

和 Redux / MobX / Zustand 的对比

编程模型 / 心智负担 / 性能三个角度简单对比一下:

方案编程模型典型心智负担内建 IoC / DI性能特征(本项目场景)
easy-model类模型 + Hooks + IoC写类 + 写方法即可,少量 API(provide / useModel / watch 等)在极端批量更新下仍为个位数毫秒
Redux不可变 state + reducer需要 action / reducer / dispatch 等模板代码在同场景下为数十毫秒级
MobX可观察对象 + 装饰器对响应式系统有一定学习成本,隐藏的依赖追踪否(偏响应式而非 IoC)性能优于 Redux,但仍是十几毫秒级
ZustandHooks store + 函数式更新API 简洁,偏轻量,适合局部状态在本场景下是最快,但不提供 IoC 能力

项目视角下:

  • 对比 Redux
    • 不拆 action / reducer / selector,业务逻辑直接写在类方法里;
    • 模板代码大幅减少,类型推断更直接;
    • 实例缓存 + 变更订阅都在 easy-model 内部处理,不用自己写 connect / useSelector。
  • 对比 MobX
    • 一样是“类 + 装饰器”的感觉,但用更显式的 API(watch / useWatcher)暴露依赖;
    • 内置 IoC / 命名空间 / clearNamespace,做服务注入、配置管理更顺手。
  • 对比 Zustand
    • 性能接近(下面有 benchmark),但功能更偏向“中大型业务的领域建模 + IoC”,不是简单的局部状态 store 替代品。

简单 benchmark:极端场景下的粗略对比

我在 example/benchmark.tsx 里写了一个刻意极端、但容易复现的 benchmark,核心场景是:

  1. 初始化一个包含 10,000 个数字的数组
  2. 点击按钮后,对所有元素做 5 轮自增
  3. 使用 performance.now() 统计这段同步计算与状态写入时间;
  4. 不计入 React 首屏渲染时间,只看每次点击带来的计算 + 写入耗时。

在一台普通开发机上的一次测试(取单次代表值,大致水平):

实现耗时(ms)
easy-model≈ 3.1
Redux≈ 51.5
MobX≈ 16.9
Zustand≈ 0.6

说明几点:

  • 这是一个刻意放大的“批量更新”场景,主要用来放大架构上的差异;
  • 结果受浏览器 / Node、硬件、打包模式等影响,只能作为趋势参考
  • Zustand 在这个场景下最快,符合它“极简 store + 函数式更新”的定位;
  • easy-model 虽然比不上 Zustand,但仍然明显快于 Redux / MobX,同时换来:
    • 类模型 + IoC + 深度监听等高级能力;
    • 更适合中大型项目的结构化建模体验。

如果你感兴趣,欢迎直接把仓库拉下来,在 example/benchmark.tsx 里自己跑一跑。

适合什么项目用 easy-model?

我个人觉得,easy-model 比较适合下面几类场景:

  • 有比较清晰的领域模型,希望用类来承载状态和方法;
  • 项目里有不少“服务 / 仓储 / 配置 / SDK 封装”之类的抽象,希望用 IoC 管理;
  • 对“监听某个模型 / 某个嵌套字段的变化”有刚需(比如审计、埋点、状态镜像等);
  • 想在保证结构化 & 可维护性的前提下,拿到接近轻量状态库的性能

不太适合的情况:

  • 只是非常简单的小组件状态,用 Zustand 这种“hooks store”会更轻;
  • 团队对装饰器 / IoC 天然排斥,或者项目不方便启用对应 TS / Babel 配置。

未来准备做的几件事

也坦白说一下目前的不足,和打算补的方向:

  • DevTools:希望做一个简单的可视化面板,把 watch 的变更、模型树、时间线跑出来。
  • 更细粒度的订阅能力:在 React 渲染维度做更精细的 selector / 局部订阅,进一步减少不必要重渲染。
  • SSR / Next.js / React Native 等场景的最佳实践:把现在脑子里的用法沉淀成文档或示例仓库。
  • 模板项目 / 脚手架:降低上手门槛,不用大家一上来就先调 tsconfig / Babel / decorator 配置。

如果你在实际项目中踩到什么坑,或者有什么“这块能不能做得更爽一点”的想法,也欢迎在 GitHub 提 issue 或 PR。

最后

如果你:

  • 正在用 Redux / MobX / Zustand 做中大型项目;
  • 觉得模板代码有点多、心智模型有点绕;
  • 又习惯用“类 + 方法”去表达业务逻辑;

不妨试试 easy-model,先把其中一个模块迁过来感受一下:

欢迎 Star、试用、吐槽、提 issue,一起把这个“类模型 + IoC + watch”的方向打磨得更好一点。