资讯动态

用Proxy实现轻量级React状态管理:重渲染诊断与调试实战

发布时间:2026/10/18 7:04:21 来源:尧图企业网站定制
如果你维护过稍微大一点的 React 项目肯定对状态管理这块深有体会。每次新起一个模块先要把 action、reducer、selector 的文件结构搭一遍写起来倒还好真正麻烦的是后续维护。组件一多重渲染的排查简直像大海捞针明明只改了一个字段整个页面却跟着抖三抖。我自己就栽过不少跟头后来干脆在业余时间做了个小工具名字就叫rea。rea 本身不是一个很宏大的框架它的定位非常小小到只有两个核心 API一个用来创建响应式状态仓库一个用在组件里订阅状态。但它顺手解决了我另一个特别烦的问题——重渲染诊断。平常我们排查性能问题要装插件、开 Profiler、看火焰图折腾一圈才能定位到是哪个组件在重复渲染。rea 把这块做成了内置能力组件消费状态的时候自动记录渲染次数和耗时数据直接打到控制台一眼就能看出问题。如果你正在为 React 项目的状态管理样板代码发愁或者想知道怎么用 Proxy 来做一套轻量级响应式系统再或者只是想找一个能快速定位重渲染问题的调试思路这篇内容应该都能对你有帮助。我会从设计思路一直讲到核心实现再给你一个可以直接跑起来的接入示例最后把踩过的坑都翻出来。1. 项目定位与设计思路1.1 为什么起了个这么短的名字名字rea没有特别高深的含义就是从 React 的单词里截了前三个字母读起来也顺口。我本来想起个能表达轻量、直接含义的名字但想了半天都不满意反而这个短名字越用越顺手像一个内部工具的代号。后来想想也合理小工具不需要背负太重的品牌意义名字越短越不容易被打错。这个项目最早其实只是我给自己写的一套调试工具后来使用场景稳定了才慢慢把状态管理部分独立出来。所以 rea 有一个和很多开源库不一样的出发点它不是先设计了完整的架构再去实现而是从我自己需要什么出发一点一点补出来的。好处是每个功能都有真实的痛点驱动坏处嘛后面会讲到就是早期版本在边界情况上处理得很粗糙。1.2 它到底解决了什么痛点如果让我用一句话概括rea 解决的是在中小型 React 项目里用最少的代码获得可预测的状态管理并且能直观看到组件为什么重渲染的问题。先说状态管理这块。日常开发里Redux 的样板代码问题是老生常谈虽然官方工具链已经简化了很多但对于一个内部管理系统来说写一堆 action type 依然很烦。React 内置的 Context 倒是省事但它有天生缺陷——只要 Context 的 value 变化所有消费者组件都会重渲染无论你消费的是不是变化的那部分数据性能隐患非常大。Zustand 这类库其实已经很接近我理想中的形态了但它把调试能力做成了一个需要单独安装的 DevTools 插件。我当时的诉求更朴素不想额外装浏览器扩展能不能直接在控制台展示每个组件的渲染情况于是 rea 就把这层能力内置了。再展开说一下重渲染问题在真实项目里有多隐蔽。很多时候你看到一个页面卡顿直觉上会去优化列表项给子组件包 memo然后发现效果甚微。真正的原因可能是某个父组件订阅了 store 的整个对象哪怕状态里一个无关字段更新了它也会被通知到。这种问题用 React DevTools 的 Profiler 也能查但每次都要录制操作、分析火焰图效率不高。rea 的做法是在订阅源头打点每个组件消费了哪些字段、每次渲染花了多久全部记录下来。这样排查路径就从猜测变成了看数据。1.3 和常见方案的差异我知道一提到自己写状态管理库很多人第一反应是NPM 上那么多轮子为什么不直接拿来用这个质疑是对的。我最初也这么想但实际对比下来每个方案都有不太合手的地方。方案样板代码量重渲染控制调试体验适用场景React Context少差value 一变全员重渲染依赖 DevTools极小型全局配置Redux Toolkit多好需要手动选择器强有完整 DevTools大型复杂应用Zustand少好按字段订阅需要额外插件中小型应用rea少好按字段订阅内置控制台诊断中小型应用偏重调试效率列这个对比不是要论证 rea 比谁强而是想说它的侧重点不一样。在状态管理层面rea 和 Zustand 的思路很接近都靠选择器 状态快照来精细控制订阅。差异主要体现在调试上Zustand 的 DevTools 需要你往 store 里挂中间件再单独装一个浏览器扩展配置步骤多一步。rea 把调试信息直接输出到 console你在业务代码里不需要额外引任何调试工具也不需要改环境变量默认就会收集关键指标。还有一个小差异是我刻意做的。rea 的 store 默认就是用setState修改状态的不接受直接对这个 store 对象重新赋值的写法。这在最初被同事吐槽过不够灵活但实际用下来这种限制反而让状态变更点变得非常清晰code review 的时候扫一眼setState调用就能理解状态流转路径。2. 核心实现状态代理与更新调度2.1 响应式状态是怎么做的rea 的响应式核心是 Proxy。如果你熟悉 Vue 3对这个应该不陌生Vue 3 的响应式系统也是基于 Proxy 重构的。Proxy 允许我们拦截对象的读取和写入操作这样就能在读的时候记录谁在读在写的时候通知依赖者。这个机制用大白话解释就是状态对象被一个观察员包了一层任何属性被访问时观察员会记下访问者属性被修改时观察员会召集所有关心的访问者开会。核心代码可以简化成下面这样function reactive(target) { return new Proxy(target, { get(obj, key, receiver) { // 收集依赖当前正在读取的组件会被登记到 key 的依赖列表里 track(obj, key) // 如果值是嵌套对象递归代理这样深层属性也是响应式的 if (typeof obj[key] object obj[key] ! null) { return reactive(obj[key]) } return Reflect.get(obj, key, receiver) }, set(obj, key, value, receiver) { const result Reflect.set(obj, key, value, receiver) // 触发更新通知所有依赖了 key 的组件重新执行选择器 trigger(obj, key) return result } }) }这里有几个细节值得说。第一reactive()函数每次读取嵌套对象都会创建一个新的 Proxy如果频繁读取深层属性会产生大量临时对象。为了解决这个问题rea 内部维护了一个WeakMap缓存同一个原始对象永远只对应同一个 Proxy 实例这样既省内存又保证了引用相等性。第二track和trigger的核心是依赖表。这个表的结构大概是以对象为 key对象里每个属性对应一个 SetSet 里存的是依赖回调函数。当组件执行useModel的选择器时选择器会访问状态属性这一步触发了get拦截器中的track并把当前活跃的依赖项收入对应的 Set。等到setState修改了某个属性trigger就把这个属性对应的回调全部执行一遍。2.2 更新调度与批量处理有了响应式基础第二个核心问题是状态变了怎么通知组件更新如果每次setState都立刻触发所有依赖回调同步执行那性能会很差。比如在一个事件处理函数里连续改三个字段理论上应该只触发一次组件更新而不是三次。rea 的处理思路是批量调度。具体做法是setState时先把一次更新请求登记到一个待处理队列里然后通过微任务统一执行。这样在同一轮事件循环里即使多次调用setState最终也只会触发一次刷新。这里用 React 的useSyncExternalStore来桥接 React 的更新机制。这个 API 是 React 18 引入的专门给外部 store 接入 React 使用。它的签名很简单接收subscribe和getSnapshot两个函数React 会负责在 store 变化时重新调getSnapshot比对快照如果快照变了就触发重渲染。那么问题来了getSnapshot返回什么返回值必须是不可变的每次返回同一个引用否则 React 会因为快照总是变而陷入无限循环。rea 的解决方式是如果传了选择器getSnapshot就返回选择器处理后的值如果没有传选择器就返回整个 state 对象。对于选择器返回的普通对象rea 内置了一个浅比较如果结构浅层没变会沿用上一次的引用这样就不会触发多余渲染。2.3 组件重渲染追踪是怎么注入的这一节是 rea 比较有特色的部分。重渲染追踪的朴素思路是在每次组件渲染时记录一个计数并测量渲染耗时但这些数据从哪来如果只是让使用者手动写console.log那侵入性太强了。rea 的做法是把它物化成一个Tracked组件内部使用 React 的ProfilerAPI。Profiler是 React 官方提供的性能测量组件它不接受任何 DOM 属性只会接收一个回调在子树每次提交更新时把渲染耗时传回来。你可以在任意组件树层级包裹多个 Profiler互不干扰。rea 的追踪组件用法如下import { Tracked } from rea function Dashboard() { return ( Tracked nameDashboard MyTable / StatusBar / /Tracked ) }Tracked内部把渲染数据累加到一个全局数组里每个条目包含组件名、渲染耗时、距离上次渲染的间隔、当前渲染序号。你可以在需要的时候执行reaDebug()函数它会把console.table的数据打印到控制台按渲染次数降序排列谁在频繁渲染一目了然。除了包裹式追踪rea 还有一个更细粒度的能力——在useModel内部记录选择器的执行时间。如果一个组件的选择器计算特别耗时即便它渲染次数不多也可能拖慢整体交互这个时间也会被单独标记出来。3. 实操把 rea 接进一个真实项目3.1 安装与最小示例rea 发布到包管理仓库时因为rea这个名字已经被占用了所以实际安装命令带了一个前缀这里用scope/rea表示。安装方式没什么特别npm install scope/rea安装完核心使用方式是createStore创建一个状态仓库然后在组件里通过useModel订阅。import { createStore } from scope/rea const store createStore({ count: 0, user: null }) export default store组件侧代码import { useModel } from scope/rea import store from ./store function Counter() { const count useModel(store, (state) state.count) return ( div span{count}/span button onClick{() store.setState((s) ({ count: s.count 1 }))} 增加 /button /div ) }注意setState的写法它接收一个更新函数参数是当前状态对象返回一个部分更新的对象。rea 会把返回的对象浅合并到状态里。如果你直接写store.setState({ count: state.count 1 })会遇到闭包陷阱因为state.count捕获的是上一次渲染时的值在连续快速点击时可能丢失更新。用函数式的写法能拿到最新状态这一点和 React 的setState是一致的。3.2 改造一个购物车模块光看计数器不够过瘾我用一个购物车模块来展示 rea 在真实场景中的组织方式。假设有一个清单列表和选中项合计金额这两个状态挂在一个 store 里const cartStore createStore({ items: [], selectedIds: new Set() })向购物车添加商品function addItem(item) { cartStore.setState((state) ({ items: [...state.items, item] })) }这里有个细节状态里的selectedIds我用的是Set。Proxy 是可以代理Set的但拦截方式不同需要拦截get方法里的add和delete等内部操作。为了提高可靠性和减少心智负担rea 内部对Set做了特殊处理它不直接代理Set实例而是要求你把它当作不可变值处理。也就是说你要是写cartStore.state.selectedIds.add(id)是无效的正确方式是const nextIds new Set(cartStore.state.selectedIds) nextIds.add(id) cartStore.setState({ selectedIds: nextIds })这种以新换旧的写法一开始会觉得别扭但它其实非常安全。每次状态变更都会产生一个新的引用React 的快速比较机制能天然地检测到变化也避免了 Proxy 代理Set内部方法可能出现的兼容性问题。这个决定是我在踩过几次坑之后才定下来的后面在常见问题里会展开说。合计金额的派生逻辑可以直接写在组件选择器里function TotalPrice() { const total useModel(cartStore, (state) { let sum 0 for (const id of state.selectedIds) { const item state.items.find((i) i.id id) if (item) sum item.price } return sum }) return div合计{total} 元/div }这个选择器每次执行都会做一次数组遍历。items数量少的时候没问题如果上千个商品这样的派生计算就比较浪费。优化办法是把合计金额在加入商品时就更新到 store 里或者用useMemo缓存选择器的结果rea 也允许你给useModel传一个deps数组指定当前选择器依赖的额外外部变量。3.3 异步请求的状态管理异步请求是前端状态管理里绕不开的场景。常见的结构是loading、data、error三个字段来回切换。用 rea 写起来还算直观const requestStore createStore({ loading: false, data: null, error: null }) async function fetchUser(id) { requestStore.setState({ loading: true, error: null }) try { const user await api.get(/user/ id) requestStore.setState({ loading: false, data: user }) } catch (err) { requestStore.setState({ loading: false, error: err.message }) } }组件里同时订阅这三个字段function UserPanel({ id }) { const loading useModel(requestStore, (s) s.loading) const data useModel(requestStore, (s) s.data) const error useModel(requestStore, (s) s.error) useEffect(() { fetchUser(id) }, [id]) if (loading) return div加载中.../div if (error) return div出错了{error}/div if (!data) return null return div{data.name}/div }这样一个简化的请求状态管理就算完成了。如果你想更进一步可以把fetchUser封装成自定义 hook把请求状态和业务组件解耦。实际项目里一个页面可能有多个并发的请求每个请求都自己管理一份loading/data/error状态会很分散更好的做法是写一个通用的runRequest工具函数内部维护一个按请求 id 区分状态的小 store。3.4 用内置调试能力定位慢组件接入完成后重点来了——怎么用 rea 的调试能力找到重渲染问题。假设你感觉到表格组件在输入框输入内容时很卡先在可疑组件外面包上Trackedfunction TablePage() { const keyword useModel(searchStore, (s) s.keyword) return ( Tracked nameTablePage SearchInput / DataTable / Pagination / /Tracked ) }在控制台执行initReaDebug()然后正常操作页面过一会儿调用flushReaLogs()输出会是类似下面这样的表格组件名渲染次数平均耗时(ms)最大耗时(ms)DataTable2618.243.1Pagination91.12.4TablePage20.81.1SearchInput10.90.9看到DataTable渲染了 26 次每次平均 18 毫秒基本可以断定问题所在。接下来要回答它为什么渲染了这么多次这时候可以看rea提供的字段级依赖追踪。在Tracked组件被包裹的前提下rea 会额外记录这个组件在最近一次渲染时实际访问了 store 的哪些字段。如果你怀疑某次输入变化不该影响表格就把两个时间点的字段依赖打印出来对比变化的字段就写在上面。这个排查流程比纯靠猜或者翻火焰图快很多因为它把渲染次数、组件耗时、字段依赖三个关键链路串在了一起。这也是我最初做 rea 时觉得最值回票价的功能。4. 常见问题与排查技巧到一个新工具里总会遇到各种奇奇怪怪的表现。我把实际使用中踩过的坑整理成了一张速查表再挑几个高频问题详细解释。现象可能原因解决方案状态改了界面不刷新忘记在组件里订阅对应字段确认useModel的选择器覆盖到该字段组件无限重渲染选择器返回了新对象返回值改为原始类型或保证引用稳定页面偶发白屏回调里读到了未定义属性初始化时给所有字段设置默认值修改 Set/Map 不生效内部方法未被拦截改用创建新实例的不可变更新严格模式下执行两次副作用React 18 开发模式特性保持 reducer 纯函数禁止在 setState 中塞副作用4.1 选择器返回新对象导致无限重渲染这是最常见的坑而且错误提示并不直观。看下面这段代码const info useModel(store, (state) ({ name: state.user.name, age: state.user.age }))每次getSnapshot执行时对象字面量{ name, age }都会生成一个新引用。React 的比较机制发现快照引用变了于是触发重渲染重渲染又执行选择器又生成新引用于是陷入死循环控制台会报Maximum update depth exceeded。rea 的解法是内部做一层浅比较选择器返回普通对象时比较对象第一层属性值是否全部相同如果相同就复用上一次的对象引用。这个机制能兜底大部分场景但它不是万能的。如果对象里嵌套了多层数据浅比较只能判断顶层深层属性变更时可能被误判为没变。所以我的建议是优先让选择器返回原始类型比如state.user.name如果确实需要组合多个值尽量把派生结果预先存到 store 里或者选择器里手动做useMemo式的缓存。4.2 直接修改 Set、Map 内部的坑前面购物车示例里提到过不让直接改 Set。原因是 JavaScript 的 Proxy 虽然能拦截get操作但Set.prototype.add这类方法是在this上操作的要正确拦截必须绑定 receiver实现起来比较绕。有些库直接代理了内置集合但代价是性能和兼容性问题都要自己扛。我最后选择了不拦截、只用不可变更新的路线。这样对使用者来说规则很清晰需要修改集合时必须产生一个新的集合实例再 setState。如果你一不留神写了cartStore.state.selectedIds.add(id)rea 会在开发模式下通过 Proxy 的set陷阱检查到selectedIds没有变化然后打印一条警告告诉你当前操作没有触发更新。这种温和报错 明确提示的方式比静默失效要好得多。4.3 React 严格模式下的双调用问题React 18 开发模式下组件函数、状态更新函数、以及传给setState的更新函数都有可能会被调用两次。这是 React 为了帮助你发现副作用而故意做的它会执行两遍逻辑并丢弃其中一次结果。rea 的设计原则是保持所有更新函数纯函数化所以严格模式下双调用不会导致数据错乱。比如store.setState((s) ({ count: s.count 1 }))就算执行了两遍React 也只会把第一遍的结果保留不会有叠加问题。但如果你的更新函数里有随机数、时间戳或者外部闭包变量就可能在严格模式下表现异常。排查这类问题的方式是在setState的更新函数里加一个console.trace()看调用栈能不能对得上。如果发现执行了两遍先确认这是不是 React 严格模式导致如果是只需要让更新函数保持纯函数即可。4.4 服务端渲染要注意什么useSyncExternalStore本身是兼容服务端渲染的React 在服务端不会订阅外部 store而是直接读取快照渲染。所以只要你的 store 在初始化时不会访问浏览器专属 API比如window、localStorage基本没有问题。但有个坑在于如果你的 store 初始化函数里加了持久化逻辑比如从localStorage恢复状态在服务端会因为找不到window而抛错。解决方式是判断执行环境只在客户端进行水合后再加载持久化数据。这里可以提供一个简单的方案createStore接受一个hydrate回调服务端不执行水合阶段才触发。5. 踩过几次坑之后的一点体会做完 rea 这个东西最大的体会是小工具要有小工具的样子。一开始我也想过把时间旅行调试、持久化中间件、异步状态编排全都做进去但越做越发现功能每多一个边界情况就多一倍而我自己真正高频使用的其实就那三四个能力。后来狠心砍掉了不少待做清单里的功能只保留状态管理和重渲染诊断两个主线反而用起来舒服了很多。另外一个体会是状态管理方案的选型本质上是权衡。Redux 给你的是强约束和完整生态Zustand 给你的是轻量和灵活rea 则把筹码押在了开箱即用的调试链路上。没有哪个方案是绝对最优的关键看你项目里最痛的环节是什么。如果你经常被重渲染问题折磨那 rea 这种把渲染日志打在控制台的工具可能就很对胃口如果你需要的是严格的时间旅行调试和团队规范约束那传统大而全的方案可能更合适。后续如果还有精力我会考虑给 rea 加一个轻量的持久化中间件。设计思路是在createStore的配置项里支持传入storage对象写入时用 JSON 序列化整个 state。这个中间件一定要保持可选、足够薄因为它一旦做重了就又偏离了 rea 最初只解决具体问题的定位。如果你也想自己动手写一套类似的小工具我建议先从一个特别聚焦的痛点切入把调试路径打通再回头补状态管理这样的开发顺序会顺畅很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑