资讯动态

在React中复刻Vue的watch与computed:自定义Hooks实践指南

发布时间:2026/10/2 22:32:53 来源:尧图企业网站定制
如果你所在的团队刚从 Vue 全家桶切到 React 技术栈你一定听过这样的对话“这个数据变了我想监听一下做点事在 Vue 里写个 watch 就行了React 怎么写”——答useEffect。“我想要一个根据列表和关键词算出来的过滤结果难道每个组件都要复制一遍”——答useMemo。道理是这个道理但真写起来总有那么几处别扭依赖数组漏一个闭包拿旧值回调时机和想象中不一样。于是有人开始琢磨能不能在 React 里复刻 Vue 的 watch 和 computed我就是其中一个。先说清楚前提我并不是想把 Vue 的写法强行塞进 React让代码变成四不像。真正有价值的是 Vue 在“响应式派生状态”和“副作用侦听”这两个场景下交出的开发体验——少声明依赖、多自动跟踪、缓存按需失效。这两件事 React 其实都有对应方案只是需要自己动手打磨。如果封装得好React 代码依然是 React只是用起来更顺手。下面所有内容都是我实际调研、试错、压测之后沉淀下来的做法既包括可以直接拿去用的成品实现也包括实现背后的思考以及一些翻车现场。不一定是最优解但至少是我目前用过最顺手的版本。1. 为什么要在 React 里复刻 Vue 的 watch 和 computed1.1 从 Vue 切到 React 后的“手写依赖”阵痛我见过太多从 Vue 转 React 的同事写第一周组件时被 useEffect 咬了一口又一口。印象最深的一个场景一个搜索面板输入关键词、拉远程数据、根据结果过滤列表。Vue 那边一行 computed 加一个 watch 就能搞定的事React 这边要 useEffect useMemo useRef 三个 Hook 一起上依赖数组还得反复核对。问题不是 React 做不到而是把复杂度交给了开发者。Vue 的响应式系统在背后默默帮你维护了“谁变了、谁依赖谁”的关系React 则需要你在每一个 Hook 后面手动管理依赖。人肉维护依赖数组就像手工记账记多了浪费性能记漏了拿到的就是过期数据。这种痛苦在组件复杂起来之后会被无限放大。1.2 React 不是没有而是把复杂度交给了开发者公平地说useMemo 和 useEffect 分别覆盖了 computed 与 watch 的九成能力。useMemo 有缓存useEffect 能感知依赖变化两者都经过 React 调度器的统一管理。但拆开看就会发现和 Vue 的语义差着细节useMemo 的依赖是浅比较对象内部变化不会触发重新计算useEffect 拿不到新旧值只能自己在 render 阶段存 refuseEffect 默认 mount 后也会执行一次Vue 的 watch 默认不执行两者都要求你把依赖列表写全写错又是另一种 bug。于是很自然想到能不能把这些 React 的“痛点”封装成 Vue 那种表达的 Hook让 watch 就是 watchcomputed 就是 computed。我做这件事也不是为了标新立异纯粹是为了提高团队开发和维护效率。1.3 我要复刻的不是 API是语义封装之前我给自己定了几条原则对外 API 尽量贴近 Vue 的 watch/computed让迁移过来的同学零学习成本内部实现必须符合 React 的调度模型不搞破坏并发安全的黑魔法缓存和侦听的边界要清晰不能为了像 Vue 而放弃 React 的性能模型。所以我最终交出的是两个 HookuseWatch和useComputed。它们不是 Vue 响应式系统的移植而是借了 Vue 的“壳”套上 React 的“核”。下面先把 Vue 的语义拆清楚再看我怎么一点一点实现。2. 先把语义对齐Vue 的“依赖跟踪”与 React 的“手动依赖”2.1 computed 的本质带缓存的声明式派生值Vue 的 computed 有三个特性声明式、惰性求值、依赖跟踪。你在模板里写{{ total }}它不会立刻执行只有真正被读取时才开始计算。计算过程中访问到的响应式属性会被记录下来当这些属性发生变化下一次读取时才会重新计算否则直接返回上一次的结果。这就是一个很典型的“缓存函数”输入没变输出直接命中缓存输入变了下次调用重新跑一遍。关键是这个“输入”不是你自己声明的而是函数体里每一次属性访问自动收集的。这个体验用 React 的 useMemo 是无法直接复刻的因为 useMemo 只能靠你手动给它一个依赖数组它自己完全不关心函数内部读取了什么。2.2 watch 的本质带新旧值的异步副作用调度器watch 则是另一种东西它不产生派生值只在数据变化后执行一段副作用。它的回调里带(newValue, oldValue)这看起来很简单但背后有两个容易被忽略的机制一是异步调度同一次事件循环里的多次数据变化会合并成一次回调二是深度监听传入对象时可以监听到内部属性的变化。在 React 里useEffect 勉强对应“数据变化后执行副作用”但旧值拿不到、深度监听不支持、immediate 没有异步合并的时机也由 React 的批处理机制决定。几个细节差距叠加起来才让人总觉得 useEffect 用起来不如 watch 顺手。2.3 一张表看清四者区别能力Vue computedReact useMemoVue watchReact useEffect依赖声明方式自动收集手动数组自动/显式手动数组缓存机制有有无无回调参数无无newValue/oldValue无首次是否执行访问时执行挂载时执行默认否可配 immediate挂载后执行深度监听依赖到属性不支持支持不支持执行时机同步、按需渲染期同步响应式异步commit 后异步从表里能直观看出computed 与 useMemo 最像watch 与 useEffect 最像。但“最像”不等于“就是”差的正是那些让开发痛苦的细节。2.4 Vue 能做到自动依赖跟踪的底层原因Vue 3 的自动依赖跟踪靠的是 Proxy。它把响应式对象包装了一层任何属性的读取都会被拦截并记录到“当前正在运行的计算”里任何属性的写入都会触发依赖该属性的更新队列。React 没有这个它的数据流是显式的你 setState组件重渲染Hooks 根据依赖数组决定要不要重跑。所以要在 React 里实现 Vue 的 watch 和 computed最关键的就是补上这套“读取感知”的能力。要么用手动依赖收窄感知范围要么自己造一个 Proxy 工具箱。这也是第三、第四章的核心差异我会分别展开。3. useWatch 完整实现从零写出带新旧值、immediate、deep 的侦听器3.1 最小可用版old/new 与首次不触发先写最核心的部分当被侦听的值发生变化时回调能拿到新旧值。这里的难点在于useEffect 本身没有旧值概念我们得用一个 ref 把上一次的值存下来在每次 effect 触发时对比更新。import { useEffect, useRef } from react; type WatchCallbackT (newValue: T, oldValue: T | undefined) void; interface WatchOptions { immediate?: boolean; deep?: boolean; } export function useWatchT( value: T, callback: WatchCallbackT, options: WatchOptions {} ) { const callbackRef useRef(callback); callbackRef.current callback; const optionsRef useRef(options); optionsRef.current options; const isFirstRun useRef(true); const oldValueRef useRefT | undefined(undefined); const watchTarget options.deep ? JSON.stringify(value) : value; useEffect(() { const oldValue oldValueRef.current; if (isFirstRun.current) { isFirstRun.current false; if (optionsRef.current.immediate) { callbackRef.current(value, oldValue); } } else { callbackRef.current(value, oldValue); } oldValueRef.current value; }, [watchTarget]); }这个版本的关键在于 oldValueRef第一次 effect 执行时它还是 undefined回调触发后立刻把本次的 value 写回 ref这样下一次变化时 ref 里自然就是旧值。闭包问题也被 callbackRef 解决了——即使外层 callback 在每次渲染中都是新函数我们的 effect 总是调用最新的那个。默认情况下组件挂载后不会触发回调这符合 Vue watch 的行为。想要首次执行就打开immediate: true。3.2 immediate首帧就执行从代码里能看到 immediate 的实现逻辑isFirstRun 这个标记在首次 effect 执行时被消费如果选项里带了 immediate就在那个时刻主动调用一次 callback。注意此时 oldValue 传的是 undefined和 Vue 3 的处理一致。这个能力在 React 里用 useEffect 实现时经常被忽略很多人直接用 useEffect 的首次执行充当 immediate但随后依赖变化时又会重复触发导致逻辑写两遍。useWatch 把“挂载时只跑一次”和“变化时跑”统一进同一个入口代码自然就简洁了。3.3 deep监听对象内部变化以及绕不开的 oldValue 问题deep 的实现是这一段最需要谨慎的地方。React 的依赖数组做的是Object.is比较对象引用不变化时 useEffect 不会触发所以当options.deep为 true我用了JSON.stringify(value)作为依赖数组的项。对象内部属性变化序列化字符串跟着变effect 就会被触发。但这里必须提醒几个坑每次 render 都会重新执行JSON.stringify如果被侦听对象很大这个成本不能忽视如果对象的键顺序不稳定比如后端返回字段顺序每次都不同stringify 结果会变化导致不必要的触发oldValue 在 deep 模式下只有“上一次渲染时保存的引用”意义并不是真正的旧状态快照。因为 React 没有在 setter 执行时拍快照的能力对象在两次 render 之间被原地修改oldValueRef 里存的其实是同一个对象引用。所以我的建议是deep 模式尽量用在“我只关心变化本身不依赖旧值做精细对比”的场景比如同步到 localStorage、上报日志、请求接口。如果确实需要旧值快照手动在修改前JSON.parse(JSON.stringify(obj))存一份成本自己评估。3.4 实测监听搜索词写入历史记录拿一个最常见的搜索框场景验证一下。用户输入关键词触发一个 handleSearch这段副作用的触发时机和参数都和 Vue watch 保持了一致function SearchPanel() { const [keyword, setKeyword] useState(); useWatch( keyword, (newValue, oldValue) { console.log(搜索词从 ${oldValue} 变为 ${newValue}); // 在这里发请求、写本地历史、埋点上报 }, { immediate: true } ); return input value{keyword} onChange{(e) setKeyword(e.target.value)} /; }实际跑起来你会发现和 Vue 的体验几乎一致首次渲染就执行一次之后每次变化都能拿到新旧值组件周边代码没有任何多余的状态。如果换成原生 useEffect你得在 render 阶段写一个 ref 保存值还得判断是否是第一次挂载代码至少多出七八行。4. useComputed 实现从手动依赖版到 Proxy 自动追踪版4.1 为什么 useMemo 不等于 computeduseMemo 看起来是 React 官方给 computed 的答案但它和 computed 的差距主要在两个地方一是依赖数组需要人肉维护。漏一个依赖内部闭包拿到的就是旧值多一个依赖缓存命中率下降性能白白损耗。这个问题的隐蔽性在于它不报错只是结果偶尔不对排查成本很高。二是依赖的粒度是“值”而不是“属性”。你对一个对象做 useMemo只要引用变化整个对象上的任何字段修改都会触发重算。而 Vue 的 computed 在字段粒度上做依赖追踪可以精细到user.name。所以我想做的 useComputed重点就是解决这两个问题把依赖数组的维护成本降下来让缓存失效规则更可控。4.2 手动依赖版把缓存失效规则握在自己手里先给一个适合大多数场景的版本。它的实现逻辑很简单用 ref 存上次计算的依赖数组和结果每次 render 时比较依赖是否变化变化就重算否则返回缓存值。import { useRef } from react; export function useComputedT(factory: () T, deps: unknown[]): T { const cacheRef useRef{ deps: unknown[]; value: T }(); if ( cacheRef.current null || cacheRef.current undefined || !depsEqual(cacheRef.current.deps, deps) ) { cacheRef.current { deps, value: factory() }; } return cacheRef.current.value; } function depsEqual(a: unknown[], b: unknown[]): boolean { if (a.length ! b.length) return false; return a.every((dep, index) Object.is(dep, b[index])); }这个版本就是“自己实现的 useMemo”它的价值不在技术含量而在你可以继续往上加逻辑。比如按深度比较对象、打印缓存命中日志、手动失效缓存。我最初封装它是为了让团队里从 Vue 过来的人更容易理解“派生值”这个概念代码里没有 Hook 的魔法只有缓存和比较反而更好讲。4.3 自动依赖版用 Proxy 让 computed 自己知道依赖了什么要真正做到“自动收集依赖”就得在 React 里重建一个轻量级响应式容器。我用 Proxy 包装状态对象在 get 时记录当前计算读取了哪些属性在 set 时通知订阅者重新渲染。核心思路和 Vue 3 一致只是范围收窄到我们自己创建的状态容器。import { useEffect, useRef, useState } from react; type Listener () void; type AnyRecord Recordstring, any; interface ReactiveStoreT extends AnyRecord { proxy: T; version: number; subscribe: (listener: Listener) () void; } let activeComputation: MapReactiveStoreany, number | null null; export function createReactiveStoreT extends AnyRecord( initialData: T ): ReactiveStoreT { let version 0; const listeners new SetListener(); const proxy new Proxy(initialData, { get(target, key) { // 当前处于计算函数执行中时记录这个 store 被读取了 if (activeComputation) { activeComputation.set(store, version); } return target[key]; }, set(target, key, newValue) { target[key] newValue; version 1; listeners.forEach((listener) listener()); return true; }, }); const store: ReactiveStoreT { proxy, get version() { return version; }, subscribe(listener) { listeners.add(listener); return () listeners.delete(listener); }, }; return store; } export function useComputedT( factory: () T, stores: ReactiveStoreany[] ): T { const [, forceUpdate] useState(0); const cacheRef useRef{ deps: MapReactiveStoreany, number; value: T; }(); useEffect(() { const unsubscribers stores.map((store) store.subscribe(() forceUpdate((n) n 1)) ); return () unsubscribers.forEach((unsubscribe) unsubscribe()); }, []); const previous cacheRef.current; let shouldRecompute true; if (previous) { shouldRecompute false; for (const [store, cachedVersion] of previous.deps) { if (store.version ! cachedVersion) { shouldRecompute true; break; } } } if (shouldRecompute) { activeComputation new Map(); const value factory(); const depsSnapshot new Map(activeComputation); activeComputation null; cacheRef.current { deps: depsSnapshot, value }; } return cacheRef.current!.value; }核心机制是两个部分配合createReactiveStore 负责状态容器useComputed 负责缓存和订阅。当计算函数执行时所有被访问的属性都会被记录到 activeComputation 中计算完成后我们把依赖的版本号快照存下来。之后任何 store 的 version 变化订阅者触发的重渲染都会走到版本比对逻辑只有真正被读取过的 store 版本变了才会重新执行 factory。说白了这里的 Proxy 就是给 React 补上了“读取感知”能力computed 从此不需要手动声明依赖。4.4 实测过滤列表与购物车小计我拿电商项目的一个常见列表做了压测商品列表和关键词都在响应式 store 里过滤结果用 useComputed 计算。const store createReactiveStore({ products: [ { name: React 进阶, price: 59 }, { name: Vue 实战, price: 49 }, { name: TypeScript 入门, price: 39 }, ], keyword: , currency: CNY, }); function ProductList() { const filtered useComputed( () store.proxy.products.filter((p) p.name.includes(store.proxy.keyword)), [store] ); const total useComputed( () filtered.reduce((sum, p) sum p.price, 0), [store] ); return ( div input value{store.proxy.keyword} onChange{(e) { store.proxy.keyword e.target.value; }} / ul {filtered.map((p) ( li key{p.name}{p.name}/li ))} /ul footer合计{total}/footer /div ); }在真实运行中当你输入一个字符只会触发两次计算filtered 重算一次total 因为 filtered 数组引用变了跟着重算一次。而当你修改 currency 字段时两个计算都不会重跑因为它们的函数体根本没读过 currency。这种精度是手动依赖数组很难稳定达到的尤其是在团队里大家水平参差不齐的情况下。这个实现默认假定创建 store 后依赖列表不发生变化所以订阅只注册一次。如果你需要动态增删 store需要像维护普通副作用一样去维护订阅逻辑。5. 实测对比与取舍什么时候用自定义 Hook什么时候别用5.1 这些 Hook 适合解决的场景从我几个项目的实际使用来看useWatch和useComputed最适合解决以下问题从 Vue 迁移过来的老项目重构期间保持业务逻辑不变降低切换阵痛组件内派生逻辑嵌套较深比如过滤、排序、分组多个组件共享同一份派生结果需要监听某个状态变化并触发一系列外部副作用请求、存储、埋点又希望代码可读性高一些团队里新人较多想要减少 useMemo/useEffect 误用率的时候。第三个场景尤其值得一提。原本一个监听多处触发的代码用 useEffect 写很容易出现首帧触发、依赖遗漏、多次调用等问题。收敛成 useWatch 后心智负担显著降低新人在 code review 时也不需要反复推敲依赖数组的口径。5.2 不建议使用的场景我必须强调不是所有地方都适合换这套 Hook。React 的官方设计不是摆设下面这些情况我强烈建议保留原始写法简单的一次性副作用比如组件挂载时请求一次数据直接用 useEffect 空依赖最清晰简单的派生值比如const fullName first last这种用变量就够了任何缓存都是浪费需要和第三方非响应式库深度联动时比如接入 D3、Leaflet 这类外部生态原始 useEffect 更可控团队还处于理解 React 心智的初期过早引入“类 Vue”抽象反而会造成概念混淆。说到底useWatch 和 useComputed 是锦上添花的工具不是银弹。核心目标永远是让代码清晰、易维护而不是为了“像 Vue”而牺牲 React 本身的模型。5.3 和现成状态管理方案的对比如果你已经在用 zustand、valtio 这样的状态库它们多半自带衍生状态能力比如 zustand 的 selector、valtio 的派生 proxy。在这种情况下我不建议再引入一套自研的 useComputed否则一个项目里存在两套“响应式状态系统”维护来维护去反而成了新的复杂度。我做这套 Hook 的场景是项目本身没有引入全局状态管理库纯粹靠组件本地 state props 传递。在那种情况下这套封装既不增加额外依赖又能把编写体验拉回 Vue 时代收益和成本十分划算。6. 接入团队前的几个实战提醒6.1 watch 回调里更新被 watch 的值小心死循环这是我认为最容易翻车又最像“传统 Vue 坑”的地方。如果在 useWatch 的回调里无脑修改同一个被侦听的值会引发“变化 - 回调 - 再变化 - 再回调”的循环。Vue 里有watchEffect的自动依赖同样会踩这个坑React 里尤其要小心因为 useEffect 的依赖比较是值比较绕不开。我的处理办法是在回调里加条件判断或者把更新逻辑迁移到事件处理器里而不是放在 watch 回调中。比如搜索词的防抖请求不要在 keyword 变化后直接 setState keyword而是 setState 一个 loading 标志、写请求状态。6.2 开发模式 StrictMode 下回调可能触发两次React 18 的 StrictMode 会在开发模式下故意让 effect 执行两次用来暴露副作用问题。useWatch 底层是 useEffect所以首次挂载和每次依赖变化时回调在开发环境可能被调用两次。这不是 bug是 React 有意为之。应对方法回调必须是幂等的重复执行不会产生重复副作用网络请求在回调里要做防重例如用 AbortController 取消上一次如果只是开发环境调试时多打一条日志不影响线上行为可以不管。6.3 deep 模式的性能与语义陷阱前面已经说了 JSON.stringify 的开销和 oldValue 的引用问题这里再补一个实际教训千万不要对超大对象、循环引用对象盲开 deep。有一回我侦听一个包含上传文件预览 base64 的图片对象开 deep 后每输入一个字就序列化一次整个对象页面直接卡顿。如果只是要监听“对象结构是否变化”但不在乎变化的路径可以用自定义比较器替代 deep比如 “序列化 hash” 或者只抽几个关键字段做对比。这比无脑 deep 更可控。6.4 在 computed 里做副作用的后果computed 函数应该在渲染阶段被安全地调用不应该有网络请求、DOM 操作、定时器这些副作用。我在自动依赖版里computed 的执行发生在 render 阶段如果里面塞了副作用一旦依赖频繁变化副作用可能被多次执行且 StrictMode 下还会双跑。我把“计算”和“副作用”的边界理解为计算只负责从输入得到输出副作用交给 watch 去执行。这正是 Vue 里 computed 不写副作用的设计哲学React 里同样适用。6.5 团队接入时的约定最后分享一个团队协作层面的经验。如果你决定把这些 Hook 引入团队一定要先约定两条硬规则第一条自定义 Hook 的命名必须统一建议统一前缀比如useWatch、useComputed在代码 review 时一眼就能认出这是自定义响应式封装而不是 React 原生 Hook。第二条必须在项目文档里写清楚它们和 useEffect/useMemo 的区别与适用边界。否则过三个月新来的同事看到 useWatch 可能一脸茫然甚至把它当成 useEffect 的别名来用。我见过最夸张的情况是有人把 useComputed 的 factory 当 effect 使用在计算里发起请求把组件搞出了内存泄漏。这两条约定不需要很复杂却能避免很大一部分“看起来像 Vue 又不像 Vue”的混乱。最后再分享一个我的个人体会。写完这套 Hook 后的很长一段时间里我并没有感到“React 变得像 Vue”了反而觉得对 React 的调度模型和数据流有了更清晰的认识。Vue 的自动依赖跟踪确实舒服但 React 的显式数据流也有它自己的安全感。这套封装最好的结局不是让你从此不用理解 useEffect 和 useMemo而是在你真正理解它们之后多一个趁手的工具可用。如果你也在做类似的迁移项目希望这篇内容能帮你少踩几个我曾经踩过的坑。

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

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

免费获取报价 →
↑