导读:本期聚焦于宋琮安创作的《如何构建一个无依赖的、轻量级的JavaScript状态管理库?》,敬请观看详情。当项目里引入Redux或MobX后打包体积陡增,又觉得它们的概念太重,能不能用不到一百行代码自己写一个状态管理核心?本文从发布订阅原理出发,说明如何用纯JavaScript实现状态的集中存储、变更通知与按需订阅。我们会对比框架自带方案与自己实现的差异,给出避免重复渲染和内存泄漏的实操细节,并提供一个可直接用于生产环境的最小实现,帮助你在中小项目中彻底摆脱外部依赖。

在前端开发中,状态管理常常被等同于引入 Redux、Zustand 等成熟框架,但并非所有场景都需要这些库带来的体积和概念负担。对于中小型后台、嵌入式页面或实验性项目,使用原生 JavaScript 的发布订阅模式,完全可以构建一个无依赖、轻量且可扩展的状态管理库。这类库通常只关注三件事:集中存储状态、通过统一入口修改状态、状态变化后通知所有相关订阅者。理解这一实现过程,也有助于更深入地掌握前端框架状态模块的底层工作方式。

如何构建一个无依赖的、轻量级的JavaScript状态管理库?

一、用发布订阅建立最小状态容器

发布订阅模式是这类状态库的核心。所谓“发布”,指的是状态发生变更时向外部广播;所谓“订阅”,则是外部代码注册一个回调函数,表示希望在未来状态变化时收到通知。为了不让外部随意修改内部状态,通常将状态保存在闭包中,只暴露 getStatesubscribesetState 三个方法。

基础实现非常直接:使用一个 Set 保存监听器函数,subscribe 负责把监听器加入集合并返回取消函数,setState 负责根据传入的对象或更新函数生成新状态,再遍历监听器逐个通知。这样外部就无法绕过统一入口修改状态,为后续的追踪和优化打下基础。

function createStore(initialState) {
  let state = initialState;
  const listeners = new Set();

  function getState() {
    return state;
  }

  function subscribe(listener) {
    listeners.add(listener);
    return function unsubscribe() {
      listeners.delete(listener);
    };
  }

  function setState(partial) {
    // 支持直接传对象,也支持根据旧状态计算新状态
    const next = typeof partial === 'function'
      ? partial(state)
      : partial;
    state = Object.assign({}, state, next);
    listeners.forEach(function (fn) {
      fn(state);
    });
  }

  return { getState, subscribe, setState };
}

这个版本已经可以用于简单场景:所有订阅者会在任意状态字段变化时收到完整状态。它的优点是逻辑简单、容易调试,缺点则是无法区分订阅者真正关心的数据。如果视图只依赖 count 字段,当 name 字段修改时也会被通知,可能引发无意义的渲染。

二、引入选择器订阅与浅比较

为了解决“所有通知无差别发送”的问题,可以在订阅时接收一个选择器函数。选择器负责从完整状态中取出订阅者关注的片段,例如 function(s) { return s.count; }。内部保存该片段的上一次值,在通知前用新状态重新计算,并通过浅比较判断片段是否真正发生变化。

这种做法让状态库从“广播完整状态”升级为“按需通知”。组件只会在自己依赖的数据变化时执行回调,从而有效避免重复渲染。实现上,订阅项不再只是一个回调函数,而是一个包含 selectorlistener 和上一次选中值的对象。

function createStore(initialState) {
  let state = initialState;
  const entries = new Set();

  function getState() {
    return state;
  }

  function subscribe(selector, listener) {
    // 记录初始选中值,作为后续比较基准
    let previous = selector(state);
    const entry = {
      selector: selector,
      listener: listener,
      previous: previous
    };
    entries.add(entry);

    return function unsubscribe() {
      entries.delete(entry);
    };
  }

  function setState(partial) {
    const next = typeof partial === 'function'
      ? partial(state)
      : partial;
    state = Object.assign({}, state, next);

    entries.forEach(function (entry) {
      const current = entry.selector(state);
      // 浅比较差异,只有变化时才通知
      if (current !== entry.previous) {
        entry.previous = current;
        entry.listener(current, state);
      }
    });
  }

  return { getState, subscribe, setState };
}

需要特别注意的是,浅比较并不能识别对象或数组内部属性的变化。如果选择器返回一个新对象,即使内容相同,引用不同也会触发通知。因此建议选择器尽可能返回原始值或稳定引用,避免不必要的更新。

三、与主流状态方案对比

与 Redux 相比,自研轻量库缺少中间件、时间旅行、开发者工具等高级特性,但换来的是接近零依赖和更低的认知负担。对于没有复杂副作用、不需要严格流程控制的项目,这类库往往会让代码更直观,也更容易交接维护。

与 React 的 useState 相比,自研 store 是跨组件共享的全局状态容器,不需要通过 props 逐层传递。它并不绑定任何 UI 框架,只要在合适生命周期里调用订阅和取消订阅,就可以在原生 JavaScript、Vue、React 甚至小程序中复用。

下表从依赖体积、学习成本和适用场景等方面做了简单比较。

方案依赖体积学习成本适用场景
Redux约 2KB 以上加中间件大型复杂应用
自研轻量库小于 1KB中小项目、嵌入式
框架内置 State随框架单组件或局部

从表中可以看出,自研轻量库并不是为了替代成熟框架,而是在特定边界内提供更简洁的选择。当项目逐渐扩大,出现跨模块异步流程、严格调试需求时,再迁移到成熟方案也不会因为当前结构简单而变得困难。

四、内存管理与工程实践

发布订阅模式最常见的隐患是内存泄漏。当组件或模块销毁时,如果没有及时移除注册的回调,监听器集合会持续持有已经无用的函数引用,导致内存无法被回收。因此 subscribe 必须返回一个取消订阅函数,并在组件卸载生命周期中调用。

另一个实践要点是不要绕过 setState 直接修改状态对象。虽然 getState 返回的是内部状态引用,但一旦外部直接修改其属性,后续通知和选择器比较就失去了准确性。可以通过约定和代码审查来保证所有变更走统一入口,也可以在 getState 中返回浅拷贝来隔离内部对象,代价是额外的性能损耗。

下面示例展示了组件化使用时的基本流程:创建 store、订阅某个字段变化、更新状态、最后在组件卸载时取消订阅。

// 创建全局 store
const store = createStore({ count: 0, name: 'test' });

// 订阅 count 字段变化
const unsubscribe = store.subscribe(
  function (state) { return state.count; },
  function (count, state) {
    console.log('count changed:', count);
  }
);

// 修改状态
store.setState({ count: 1 });
store.setState(function (state) {
  return { count: state.count + 1 };
});

// 组件卸载时取消订阅
unsubscribe();

在这个流程中,即使内部状态还包括 name 字段,回调也只会在 count 的浅比较结果发生变化时执行。这

五、在 React 中使用选择器订阅

这意味着如果 name 字段发生变化而 count 没有变化,订阅回调不会执行。这种精确性对减少无谓的渲染和副作用处理非常有价值。在 React 等组件框架中,手动订阅虽然可用,但每次组件挂载与卸载都要重复编写注册和清理逻辑。React 18 提供的 useSyncExternalStore 可以将这一过程标准化。

借助该 API,可以把 store 的订阅逻辑封装成自定义 Hook,组件只需声明自己关心的选择器即可。下面是一个基础封装:

function useSelector(store, selector) {
  const subscribe = React.useCallback(
    function (onStoreChange) {
      return store.subscribe(selector, onStoreChange);
    },
    [store, selector]
  );

  const getSnapshot = function () {
    return selector(store.getState());
  };

  return React.useSyncExternalStore(subscribe, getSnapshot);
}

组件中只需调用这个 Hook,并传入从状态中提取所需字段的选择器函数。store 内部的浅比较会过滤掉无关变化,因此组件只在选择器返回值变化时重新渲染。

function Counter() {
  const count = useSelector(store, function (state) { return state.count; });
  const increment = function () {
    store.setState(function (state) {
      return { count: state.count + 1 };
    });
  };

  return React.createElement(
    'button',
    { onClick: increment },
    'count: ' + count
  );
}

useSelector 内部使用 useCallback 保证 subscribe 引用稳定,避免每次渲染都重新订阅。getSnapshot 返回选择器计算出的切片值,React 会基于该值决定是否触发组件更新。由于 store 内部已经用浅比较过滤了无关变化,组件层面的更新频率和选择器的返回粒度保持一致。

六、异步状态更新与调试

实际项目中,状态变更经常来自异步请求。只要将请求流程封装为普通函数,在适当的时机调用 setState,订阅方就能自动收到加载、成功或失败等状态变化。关键在于不要让回调函数直接修改内部状态,而应始终通过 setState 派发。

async function loadUser() {
  store.setState({ loading: true, error: null });
  try {
    const response = await fetch('/api/user');
    const user = await response.json();
    store.setState({ user: user, loading: false });
  } catch (error) {
    store.setState({ loading: false, error: error.message });
  }
}

如果需要在每次状态变化时输出日志或与调试工具通信,可以对 setState 做轻量包装,而不是修改 createStore 本身。

const rawSetState = store.setState.bind(store);

store.setState = function (partial, replace) {
  console.log('prev state:', store.getState());
  rawSetState(partial, replace);
  console.log('next state:', store.getState());
};

这种包装方式保持了原始调用签名的兼容性,同时为所有状态变更增加了一个可观测的切面。对于更复杂的需求,也可以将日志、持久化等功能拆分为独立函数,在执行 setState 前后依次调用,但核心原则仍然是所有状态修改都经过统一入口。

七、总结

发布订阅模式用于前端状态管理时,最核心的价值不是简单的回调通知,而是通过选择器与浅比较形成的精确依赖追踪。订阅方只需要声明关心的数据切片,状态变更时由 store 自动判断是否触发通知,从而将渲染压力控制到最小范围。统一的状态修改入口保证了可变性被约束在可控范围,而取消订阅函数则保证了长期运行时的内存安全。

这套机制虽然轻量,但已经包含了 Redux、Zustand 等成熟方案的基础思想。在实际项目中,完全可以根据需要从这种最小实现出发,逐步加入异步中间层、调试工具和持久化逻辑。理解其内部原理,有助于在遇到性能瓶颈或状态流混乱时迅速定位问题,而不是被动地依赖框架的既有行为。

JavaScriptstate_managementpublish_subscribe修改时间:2026-08-03 21:06:15

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