导读:本期聚焦于弥生美月创作的《如何使用TypeScript实现一个基于发布订阅模式的全局状态管理?》,敬请观看详情。全局状态管理是前端开发中绕不开的话题,虽然Redux、Pinia这类成熟库已经很好用,但理解其底层实现原理依然非常重要。本文将用TypeScript从零手写一个基于发布订阅模式的全局状态管理器,内容涵盖发布订阅模式的核心思想、泛型约束下的类型安全store设计与实现、模块化的Action和Reducer拆分方式,以及依赖收集带来的性能优化思路。文章给出完整的类型定义和代码示例,并分析了unsubscribe、内存泄漏、泛型推导等常见坑点。读完之后你不仅能写出一个可用的状态管理工具,也能更深入理解主流状态管理库的设计取舍。

状态管理的本质是什么?其实就是解决“数据在一处修改,视图在多处响应”这个问题。Redux、MobX、Pinia这些库的核心机制都离不开发布订阅模式(Publish/Subscribe Pattern)。与其停留在会用的层面,不如动手用TypeScript实现一个类型安全的全局状态管理器,这样再去读主流库的源码会顺畅很多。本文会从模式原理讲到完整实现,再到工程化拆分,一步步展开。

发布订阅模式的核心思想与类型设计

发布订阅模式的核心是三个角色:事件中心(EventBus)、发布者(Publisher)和订阅者(Subscriber)。订阅者向事件中心注册回调,发布者只负责在状态变化时通知事件中心,由事件中心统一分发。这种设计让状态的所有者和状态的消费者完全解耦,消费者不需要知道状态在哪里被修改。

映射到状态管理场景中,store就是事件中心,setState就是发布动作,subscribe就是订阅动作。用TypeScript实现时,最关键的是通过泛型让整个流程保持类型安全。先定义核心的类型结构:

type Listener<S> = (state: S, prevState: S) => void;

type Unsubscribe = () => void;

interface Store<S> {
  getState(): S;
  setState(partial: Partial<S>): void;
  subscribe(listener: Listener<S>): Unsubscribe;
}

这里有几个设计细节值得注意。第一,Listener接收当前状态和前一份状态,方便订阅者做差异比较;第二,subscribe返回一个取消订阅的函数,这是防止内存泄漏的关键,后面会详细说;第三,setState接收Partial<S>,允许局部更新而不是每次都要传入完整状态,这和React的setState语义一致,使用体验更好。

手写一个类型安全的Store实现

有了类型定义,实现本身就非常直观了。内部用一个数组保存所有监听器,setState时先浅合并生成新状态对象,再遍历通知所有监听器:

function createStore<S extends object>(initialState: S): Store<S> {
  let state = initialState;
  const listeners = new Set<Listener<S>>();

  return {
    getState() {
      return state;
    },

    setState(partial) {
      const prevState = state;
      state = { ...state, ...partial };
      listeners.forEach((listener) => listener(state, prevState));
    },

    subscribe(listener) {
      listeners.add(listener);
      return () => {
        listeners.delete(listener);
      };
    },
  };
}

// 使用示例
interface AppState {
  user: { name: string; loggedIn: boolean };
  cartCount: number;
}

const store = createStore<AppState>({
  user: { name: '张三', loggedIn: false },
  cartCount: 0,
});

store.subscribe((state, prev) => {
  if (state.cartCount !== prev.cartCount) {
    console.log(`购物车数量变化: ${prev.cartCount} -> ${state.cartCount}`);
  }
});

store.setState({ cartCount: 1 }); // 输出: 购物车数量变化: 0 -> 1

使用Set而不是数组来存监听器有两个好处:天然去重,避免同一个回调被重复注册;删除操作是O(1)复杂度。另外注意状态更新必须生成新对象({ ...state, ...partial }),绝不能直接修改原对象。因为监听器拿到的是同一个引用的话,state === prevState永远成立,订阅者就无法感知变化,这正是React等框架依赖不可变数据做引用比较的基础。

还有一个容易被忽略的边界情况:如果在某个listener回调执行过程中,又触发了setState,或者某个listener在回调里调用了unsubscribe,直接遍历会导致意外行为。更健壮的做法是在通知前拷贝一份监听器列表:

setState(partial) {
  const prevState = state;
  state = { ...state, ...partial };
  // 拷贝一份再遍历,避免回调中订阅或取消订阅影响本轮通知
  [...listeners].forEach((listener) => listener(state, prevState));
}

工程化拆分:模块化的状态与更新逻辑

单一大对象在项目变大后会变得难以维护,所有模块的状态挤在一起,命名冲突和误改他人状态的风险都会上升。更工程化的做法是按模块拆分,每个模块维护自己的状态和更新逻辑,最后通过组合函数合并成一个根store:

// modules/cart.ts
export interface CartState {
  items: string[];
}

export const cartModule = {
  name: 'cart' as const,
  initialState: { items: [] } as CartState,
  // 模块级的更新方法,语义清晰且类型安全
  addItem(get: () => CartState, item: string): Partial<CartState> {
    return { items: [...get().items, item] };
  },
};

// index.ts 组合根store
const rootStore = createStore({
  [cartModule.name]: cartModule.initialState,
  // 其他模块...
});

这种拆分方式和Redux的combineReducers思路一致:视图层只依赖自己关心的模块,类型系统会保证你只能访问和修改声明的字段。TypeScript在这里的价值体现得淋漓尽致——如果拼错了模块名或者传入了非法的partial对象,编译期就会直接报错,而不是等到运行时才发现状态被污染。

性能优化与常见坑点

当前实现的.notify是广播式的:任何一次setState都会通知所有监听器,即使它们关心的字段根本没变。当订阅者数量增多后,这里有明显优化空间。第一种方案是selector订阅,让订阅者声明自己关心的切片,通知前先比较该切片是否变化:

function subscribeSelector<T>(
  selector: (state: S) => T,
  listener: (slice: T, prevSlice: T) => void
): Unsubscribe {
  let prevSlice = selector(state);
  return this.subscribe((nextState) => {
    const nextSlice = selector(nextState);
    // 浅比较,相同则跳过通知
    if (!Object.is(prevSlice, nextSlice)) {
      const p = prevSlice;
      prevSlice = nextSlice;
      listener(nextSlice, p);
    }
  });
}

这基本就是Redux中useSelector、Zustand中selector用法的基本原理。需要注意selector必须保持纯函数,且返回新对象时要小心引用比较失效的问题,必要时提供自定义的相等函数。

第二个大坑是内存泄漏。SPA中组件反复挂载卸载,如果订阅时没有保存并调用unsubscribe返回的清理函数,卸载后的组件回调仍然持有引用,不仅造成内存泄漏,还会对已卸载组件执行setState导致报错。在React中的正确做法是配合useEffect的清理函数:

useEffect(() => {
  const unsubscribe = store.subscribe((state) => {
    setCartCount(state.cart.count);
  });
  return unsubscribe; // 组件卸载时自动取消订阅
}, []);

第三个坑是泛型推导。如果createStore的泛型约束写成<S>而不加extends object,传入原始类型也能编译通过,后续做展开运算就会报错。加上extends object约束后,类型收窄更严格,配合as const使用模块名,可以让组合后的根状态类型保持字面量精确类型而不是宽泛的string,编辑器提示和类型检查都会准确得多。

总结一下,一个健壮的状态管理器核心就三件事:不可变更新保证变更可感知,订阅返回清理函数防止泄漏,selector机制减少无效通知。理解了这些,再去看Redux Toolkit、Zustand、Pinia的源码,你会发现它们都是在这些基础机制上叠加工程化封装而已。动手实现一遍,远比背API有效。

TypeScript发布订阅模式状态管理修改时间:2026-08-31 07:01:00

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。