最近在做前端项目时,状态管理这块总是绕不过去。从早期的Redux,到后来的Mobx、Zustand,方案越出越多,但项目里的状态代码却越来越重。尤其在维护一个中大型后台系统时,跨组件共享数据、复杂异步更新、表单联动这些场景堆在一起,写起来总有一种"为了管理状态而管理状态"的别扭感。
闲下来的时候我写了一个轻量工具,代号就叫"rea",全称往大了说是Reactive Engine,实际就是一个解决React应用里状态同步痛点的响应式数据层。核心思路很简单:用原生Proxy拦截数据读写,自动追踪依赖,修改数据后精准通知用到它的组件重新渲染。不需要手动维护Action、Reducer或者Selector,也不用手写订阅发布逻辑。
这篇文章从设计思路、核心原理到完整实现,把rea的来龙去脉写清楚,顺手记录几个实际开发中踩过的坑。如果你也在为前端状态管理发愁,或者想了解如何用Proxy做一套简单的响应式系统,这篇文章应该对你有用。
1. 内容整体设计与思路拆解
1.1 从痛点出发:为什么不再用Redux/Zustand
先说背景。我维护的那个后台项目,早期用的是Redux Toolkit,全局store里有用户信息、权限列表、多个业务模块的缓存数据。理论上很规范,但实际开发中问题不少。
第一个问题是模板代码太多。每加一个数据字段,基本要走"定义slice -> 写reducer -> 注册store -> 组件里useSelector + useDispatch"这条链路。小组件只想改一个弹窗的visible状态,也得老老实实走完整个流程。时间久了,团队里开始出现"能不放store就不放store"的倾向,那全局状态的意义就打了折扣。
第二个问题是组件粒度与重渲染控制。useSelector用不好,一个数据更新可能引起大片组件重渲染。React.memo、useCallback、useMemo这些优化手段得上各种组合拳,否则性能直接拉胯。调了一个星期性能,最后发现大部分时间都在和"哪个selector没写对导致页面卡顿"搏斗。
第三个问题,也是Zustand这类方案也没完全解决的——异步数据的依赖关系。业务场景里经常出现"B接口需要A接口返回值的一部分字段做参数,C组件又要同时展示A和B的结果"。用Redux写,需要先dispatch A的请求,然后then里再dispatch B的请求,再维护一个loading状态。写多了就会发现,数据流的本质是"因变果",而不是"事件序列"。
我想要的方案其实很朴素:把状态当成一个普通对象,组件声明"我用到了哪些数据",然后数据变了,组件就自动更新。这正好是响应式编程的领域。
1.2 rea的设计目标与方案选型
所以设计rea时,我把目标定得很明确:
- 状态就是一个普通JavaScript对象,该怎么读写就怎么读写,不需要Action、Reducer这些中间层
- 组件通过类似useReactive的钩子拿到数据,数据变化时自动触发组件重渲染,粒度精确到"用到了哪些数据就只更新哪些组件"
- 支持异步更新场景,数据修改是同步生效的,不涉及批量调度
- 体积尽量小,不依赖额外库,方便在任何React版本里接入
选型上,核心是ES6的Proxy。相比Object.defineProperty,Proxy能拦截整个对象的读取、写入、删除、枚举等操作,而且天然支持深层嵌套对象的代理。配合WeakMap做依赖收集和存储,可以做到"访问即收集、修改即触发"。
这里有个关键选择:不仿照Mobx做"包装类",而是直接代理原生对象。原因有两个,一是用户代码不需要改造数据结构,二是和TypeScript的配合更顺畅,不需要一堆装饰器或mobx-react的桥接层。这样rea的接入成本其实很低——直接把状态对象放进createStore,组件里调useStore就行。
2. 核心技术原理与细节拆解
2.1 用Proxy实现getter/setter拦截
rea最核心的机制,是"读取时收集依赖,写入时触发更新"。
// 简化的核心逻辑 function createReactive(target, currentStore, path = []) { const proxy = new Proxy(target, { get(obj, key, receiver) { // 依赖收集:记录"当前正在渲染的组件"依赖了这个key track(currentStore, path.concat(key)); const value = Reflect.get(obj, key, receiver); // 递归代理子对象,保证深层数据也是响应式的 if (value !== null && typeof value === 'object') { return createReactive(value, currentStore, path.concat(key)); } return value; }, set(obj, key, value, receiver) { const oldValue = Reflect.get(obj, key, receiver); const result = Reflect.set(obj, key, value, receiver); // 触发更新:通知所有依赖了这个key的组件重新渲染 if (oldValue !== value) { trigger(currentStore, path.concat(key)); } return result; } }); return proxy; }这段代码看着简单,但有两个细节值得展开。
第一个是依赖收集的时机——"当前正在渲染的组件"是核心变量。React函数组件执行时,会有一个全局标记指向"当前组件的更新函数"。组件里读取store.something,Proxy的getter就会把这层关联记到依赖表里。读取了哪些key,组件就依赖哪些key。
第二个是触发更新的粒度。setter里做了oldValue的比较,值没变就不触发,避免无意义的重渲染。这是写响应式系统最基本的性能意识,但很多方案在处理引用类型时容易忽略——比如state.obj = { ...state.obj, a: 1 },如果新旧引用不同,oldValue !== value 是true,自然会触发更新。
2.2 依赖表的组织方式与WeakMap的作用
依赖表是响应式系统的命脉。我用了三层结构:
- WeakMap<组件更新函数, Set<依赖路径>>:记录每个组件依赖了哪些数据路径
- WeakMap<数据路径字符串, Set<组件更新函数>>:记录每个数据路径被哪些组件依赖
两套映射互相配合,组件卸载时自动清理第一层,第二层通过引用计数方式同步清理。
为什么用WeakMap?因为WeakMap的key是弱引用,不阻止垃圾回收。组件函数组件卸载后,如果没有其他地方引用它,就会自然被回收,不会造成内存泄漏。这是响应式系统能不能长期稳定运行的关键指标——尤其单页应用里路由频繁切换,组件不断卸载重建,如果依赖表还残留着旧组件的引用,页面会越跑越卡。
_checking依赖路径时,我用了点分路径字符串,比如"user.profile.name"。这样做有两个好处:一是路径容易调试,打印出来就是可读的字符串,不愁排查不了问题;二是trigger的时候,可以精确匹配"谁依赖了这段路径"。
依赖路径的组织有个小坑:如果组件只读取了深层子对象,set的时候触发的是中间层的key,依赖是否能正确匹配?比如组件依赖了user.profile,而实际修改发生在user.profile.name,按理说也应该让组件更新。我在trigger里做了一部分"祖先路径"优化——触发user.profile.name时,同时检查user.profile和user的依赖者。这算是一个比较实际的取舍,精度上有一点点牺牲,换来的是逻辑简单且不会漏通知。
2.3 与React渲染机制的衔接
Proxy只负责追踪数据变化,最终要让React组件更新,需要桥接层。
每个store内部维护一个version计数器和订阅者列表。数据变化时,version递增,所有订阅者(其实就是组件的强制更新函数)被依次调用。
function useStore(store, selector) { const [, forceUpdate] = useReducer(c => c + 1, 0); const lastSnapshot = useRef(); useEffect(() => { // 订阅store变化 const unsubscribe = store.subscribe(() => { forceUpdate(); }); return unsubscribe; }, [store]); return useMemo(() => { // 重新执行selector,返回需要的切片数据 return selector ? selector(store.state) : store.state; }, [store.version, selector]); }这里用useReducer而不是useState,是因为forceUpdate这种"不管值是什么,反正要刷新"的场景里,useReducer更语义化,也不会被React的bail-out机制吞掉更新。
另一个考虑是selector。很多响应式方案喜欢"完全自动",组件读取啥就跟踪啥,不需要用户写selector。但我最终保留了可选的selector参数,原因是"性能隔离"。项目里遇到过一个情况:某个组件依赖store.config,但config是个大对象,里面任何一个字段变化,组件都跟着重渲染。这时候用户写useStore(store, s => s.config.theme),依赖粒度就是精确到theme这一个字段,其他字段变了根本不触发。这个方案兼顾了"默认好用"和"极致可控",实际用下来最顺手。
3. 实操过程与核心环节实现
3.1 定义Store工厂函数
rea的入口是一个createStore函数,调用方式类似这样:
const store = createStore({ user: null, config: { theme: 'light', locale: 'zh-CN' }, list: [], loading: false });createStore内部干了三件事:
- 用Proxy包装传入的初始数据,生成响应式state
- 初始化依赖表和订阅列表
- 把必要的方法(getState、setState、subscribe等)挂到store对象上
function createStore(initialState) { const state = createReactive(initialState, store); const store = { state, version: 0, listeners: new Set(), getState() { return this.state; }, setState(partial) { Object.assign(this.state, partial); }, subscribe(listener) { this.listeners.add(listener); const unsubscribe = () => this.listeners.delete(listener); return unsubscribe; }, emit() { this.version++; this.listeners.forEach(listener => listener()); } }; return store; }注意setState的实现——直接Object.assign到this.state上。因为this.state本身是Proxy,每个key的set都会被拦截,所以assign就是批量触发响应式更新的过程。不需要额外做合并或者深度merge逻辑。
这里有个体验很好的点:修改状态的方式和修改普通对象一模一样。store.state.user.name = '张三'这种写法,在Redux时代是不可想象的,但rea里就是合法且响应式的。
3.2 在React组件中使用useStore
组件里的使用方式,经历了两个版本的迭代。第一版只提供了useStore(store),返回整个state对象。用起来简单,但前面提到的"大对象依赖"问题很快就暴露了,于是补了selector版本。
实际开发中,我发现比较顺手的使用姿势是:
function UserProfile() { const user = useStore(store, s => s.user); if (!user) return <div>未登录</div>; return ( <div> <h2>{user.name}</h2> <p>{user.email}</p> </div> ); }后续加需求,想展示用户角色和权限标签:
function UserPermissions() { const permissions = useStore(store, s => s.user?.permissions ?? []); return ( <div className="tag-list"> {permissions.map(p => ( <span key={p.id} className="tag">{p.name}</span> ))} </div> ); }注意一个细节:s.user?.permissions ?? []。这个表达式里用到了可选链和空值合并,如果user是null,返回的是空数组。依赖收集发生在运行时,如果user为null时组件没有深入permissions,那user从null变成对象时,依赖还是"user"这个路径,组件会正常触发更新——这是响应式系统对"可能不存在的深层数据"天然的处理方式。不需要像Redux那样,select里得写一堆存在性判断还要担心漏了某个层级导致不更新。
3.3 异步更新与数据流编排
后台项目里,异步数据流是最常见的场景。rea在异步场景下的体验,比事件式状态管理舒服很多。
拿一个典型的"用户列表 + 筛选 + 详情"场景举例:
async function loadUserList() { store.setState({ loading: true }); try { const data = await api.fetchUserList(); store.setState({ list: data, loading: false }); } catch (e) { store.setState({ error: e.message, loading: false }); } }组件里:
function UserListPage() { const list = useStore(store, s => s.list); const loading = useStore(store, s => s.loading); useEffect(() => { loadUserList(); }, []); if (loading) return <div>加载中...</div>; // ... }你会发现,异步函数和组件完全不耦合。loadUserList是纯函数,它只操作store,不关心谁在监听、谁在渲染。组件只负责声明"我需要什么数据",数据来了就渲染,loading变了就显示加载态——IO和UI的边界被切得很干净。
更复杂的场景是"多个接口结果关联"。比如用户详情页需要同时请求用户基本信息和操作日志,两个接口独立,但页面要同时展示。传统写法通常是Promise.all,然后把结果整体setState。rea的写法更自然——每个接口分别setState自己负责的字段:
async function loadPageData(userId) { api.fetchUserInfo(userId).then(info => { store.setState({ userInfo: info }); }); api.fetchUserLogs(userId).then(logs => { store.setState({ userLogs: logs }); }); }页面里只需要:
const userInfo = useStore(store, s => s.userInfo); const userLogs = useStore(store, s => s.userLogs);谁先回来谁先渲染,互不阻塞。这种"数据分片加载 + 组件按需组合"的体验,在Redux时代需要手动维护多套loading状态和Promise,在rea里就是顺手的事。
3.4 深入场景:跨组件联动
跨组件联动是状态管理方案最见真章的场景。我用一个实际的联动案例来说。
项目里有个筛选区,包含"城市"下拉框和"店铺"下拉框。选择城市会清空并重新加载店铺列表;选择店铺会把店铺相关信息填到其他组件里。
传统写法,要在多个组件间同步状态,要么把状态提升到共同父组件,一层一层传props和回调;要么用Redux,写两个action、三个reducer,再在相关组件里dispatch。rea的写法:
const store = createStore({ cityList: [], selectedCity: '', shopList: [], selectedShop: '' }); // 组件A:城市选择器 function CitySelect() { const cityList = useStore(store, s => s.cityList); const selectedCity = useStore(store, s => s.selectedCity); const handleChange = (cityId) => { store.setState({ selectedCity: cityId, selectedShop: '', // 城市变了,清空选中的店铺 shopList: [] // 店铺列表先清空 }); // 触发异步加载新城市的店铺 fetchShopsOfCity(cityId).then(list => { store.setState({ shopList: list }); }); }; // 渲染下拉框... } // 组件B:店铺选择器 function ShopSelect() { const shopList = useStore(store, s => s.shopList); const selectedShop = useStore(store, s => s.selectedShop); // 直接依赖shopList,城市一变,这里自动更新 // 渲染下拉框... }这里踩过一个典型问题:如果用Redux,CitySelect的handleChange里要dispatch一个action,异步的fetchShopsOfCity又得在另一个action里处理,还要考虑"城市切换太快导致旧请求覆盖新请求"的竞态问题。rea这边,因为状态是同步更新的,handleChange里先同步把selectedCity、selectedShop、shopList三个字段一次setState,然后异步再更新shopList。组件层面感知到的状态变化是"整块的"——城市变了,店铺选择器的数据也一起变了,不需要额外的调用链条。
竞态问题的处理也简单:在fetchShopsOfCity里检查当前selectedCity是否还是发起请求时的值,如果已经不是了,直接丢弃结果:
async function fetchShopsOfCity(cityId) { const data = await api.fetchShops(cityId); if (store.state.selectedCity !== cityId) return; // 过期请求,不要覆盖 store.setState({ shopList: data }); }这就是直接读实时状态的威力——不需要在action层放一个"当前请求id"来防止过期回包。
4. 常见问题与排查技巧实录
4.1 依赖收集失效:为什么某个组件不更新
实际开发中,最常遇到的问题就是"明明数据变了,组件就是没反应"。
我调试过最多的一个原因:在组件里读取数据的路径和修改数据的路径不一致。比如组件里是这样写的:
const user = useStore(store, s => s.user); // 然后使用 user.name假设s.user返回的是一个普通对象(非响应式),那么依赖收集就只停留在"user"这个key上。如果另一个组件修改了store.state.user.name = '新名字',触发的是user.name的更新,但依赖表里"user.name"这个路径没有组件依赖,只有"user"有组件依赖——如果没做之前提到的祖先路径优化,组件就不会更新。
解决这个问题有两个办法。一是组件里尽量明确依赖到具体字段:
const userName = useStore(store, s => s.user?.name ?? '');二是在createReactive的getter里对对象类型直接递归代理,这样读取user时拿到的本身就还是Proxy,后续读取user.name时也会被getter拦截,依赖自然收集到user.name路径上。两个办法结合,基本能杜绝这类失效问题。
4.2 对象替换与引用保持的血泪教训
还有一类问题出在"对象整体替换"上。比如某个业务场景是:
store.setState({ user: { name: '张三', age: 30 } });看起来没问题,但如果组件里缓存了旧user的引用,比如用了useMemo依赖了user对象,那user对象整体替换后,组件能用新值更新。但这个过程中的开销,取决于你的组件是多频繁地引用user对象。
实操中我的建议是:频繁改动的数据,尽量用扁平结构。比如用户信息,拆成userName、userAge这种单独字段,而不是一个大对象。否则对象里的任何一个子字段变化,所有依赖这个大对象的组件都会重渲染——这是精度损失的来源。扁平结构配合selector,才能把重渲染范围控制到最优。
4.3 调试技巧:如何快速定位"谁改了状态"
状态管理方案最让人头疼的问题,就是状态被改了,但不知道是谁改的。Redux有Redux DevTools可以追溯action,rea没有这个便利。我后来加了一个调试模式,在createStore里加一个选项,开启后在所有setter里打印调用栈:
function createStore(initialState, { debug = false } = {}) { // 内部在set拦截时: if (debug) { const callStack = new Error().stack.split('\n').slice(2).join('\n'); console.warn(`[rea] ${path.join('.')} 被修改了,调用栈:\n${callStack}`); } }实际排查时,打开debug模式跑一遍操作,哪个组件改的状态,调用栈一目了然。这个功能我日常开发都关着,出问题时才开,基本一次定位。
4.4 性能优化心得:三件小事
第一件:selector返回新对象时必须配合useMemo或者useEffect,否则组件每次渲染都会拿到新引用,容易引起无限循环或者重复渲染。我踩过一个坑:组件里直接useStore(store, s => ({ list: s.list, filter: s.filter }))——每次渲染都返回新对象,useMemo依赖了它就会一直不相等,导致连续渲染。后来改成返回原始字段,或者用useMemo包裹selector的返回值。
第二件:列表组件的key一定要稳定。依赖了store.list的列表组件,如果list里的每项是稳定对象引用,key用item.id即可;如果list整体被替换了,那重新渲染也是正常的,不用过度优化。倒是注意不要让key直接依赖index,避免数据更新时出现一些让人迷惑的DOM复用问题。
第三件:store拆分。如果你发现有一个store特别大,几十个字段,几乎所有组件都依赖它,那性能再好也扛不住。我会按照业务模块拆成多个store——用户store、配置store、业务数据store——互不干扰。组件只订阅它业务相关的那一两个store。这和"一个store管全局"的Redux哲学不同,但我实际用下来,可维护性和性能都更舒服。
4.5 与React 18/19并发特性共存的注意事项
React 18之后,Concurrent Mode(并发模式)成为默认,意味着组件渲染可能被中断、恢复、丢弃。这对响应式系统有一些影响。rea的核心是外部store——组件里调useStore,本质上还是订阅后强制更新,与React自身的渲染调度是兼容的。
实际操作中有一个注意点:不要在render阶段直接修改store。React的render应该是纯净的,没有任何副作用。我见过有人为了"根据数据自动同步状态",在组件渲染过程中直接调store.setState,结果触发了其他组件的setState,引起"Renderer更新时更新了其他组件"的警告。正确做法是把这种同步逻辑放在useEffect或者事件回调里,保证render的纯净性。
另一个和React 19的配合——React 19里use这个新hook能处理Promise,和rea没有直接关系,但在异步数据场景下,我有一次尝试用use直接消费异步请求结果,效果也不错。不过更灵活的场景还是rea配合事件回调更好——因为直接在render时写use,会让"每次渲染都去请求"变成一种需要compiler优化才能避免的模式,而rea在事件回调里主动setState,哪个请求该发、什么时候发,逻辑都更显式可控。
实操总结
回头看,rea这套方案,核心价值就是把"状态即对象"这个理念落地了。它不适合所有项目,如果项目架构很规范,数据流方向很明确,Redux/RTK这一套其实也没什么毛病。但如果你遇到一个业务需求经常变、组件交互复杂的后台系统,希望状态管理代码量小一点、更新粒度细一点、异步编排自然一点,那这种轻量响应式方案是值得尝试的。
整个实现下来,团队里新同学三天就能上手,不需要学概念、记模板,改数据就是改对象,页面就会跟着变。以后有余力,可以再往rea里补两个方向:一是持久化中间件,自动将store内容同步到localStorage或IndexedDB;二是时间旅行调试功能,把每次修改的diff记录下来,回放到任意时刻。不过那都是后话了,先把当前这套用好,状态管理的问题解决掉,就已经能省下不少力气了。