1. 这个问题背后,藏着 Vue3 响应式系统最常被误解的底层逻辑
“Vue3 的 ref 和 reactive 到底该用哪个?”——这个问题在某前端技术群每天至少出现三次。上周我帮一位刚转 Vue3 的同事调试一个表单组件,他把整个表单对象用ref({})包裹,结果在模板里写v-model="form.name"正常,但v-model="form.address.city"就失效;换用reactive({})后又发现const formCopy = {...form}失去了响应性。他盯着控制台里那行Uncaught TypeError: Cannot read property 'city' of undefined,一脸困惑:“不是说 ref 和 reactive 都是响应式吗?怎么连解构都搞不定?”
这根本不是“选哪个更好”的问题,而是对 Vue3 响应式机制本质理解偏差导致的连锁反应。很多人学 Vue3 时,把ref当成“给普通变量加响应式”,把reactive当成“给对象加响应式”,然后就去查文档看 API 参数、记语法糖规则。但真正卡住人的,从来不是语法,而是响应式代理的边界在哪里、数据所有权归谁、以及 JavaScript 原始类型与引用类型的不可逾越鸿沟。
关键词里虽然没填,但这个标题天然锚定三个核心概念:ref、reactive、响应式原理。它不问“怎么用”,而直指“为什么这样设计”。这意味着我们必须撕开 Composition API 的语法糖外衣,回到 Proxy 与 Object.defineProperty 的战场,看清 Vue3 是如何用两套并行但不对称的机制,分别应对 JS 类型系统的先天缺陷。
你不需要背熟所有 API 文档,但必须明白:ref(123)返回的是一个带.value的响应式包装器,而reactive({x: 123})返回的是一个被 Proxy 拦截的原始对象。前者是“值的容器”,后者是“对象的替身”。这个区别决定了你在解构、传参、类型推导、甚至 TS 类型定义时,每一步操作背后的隐含成本。
更关键的是,这个区别不是 Vue 团队拍脑袋定的,而是被 JavaScript 的语言特性逼出来的妥协方案。比如,为什么不能让reactive支持原始类型?因为 Proxy 只能代理对象,对 number、string、boolean 无能为力——这是 JS 引擎层的硬限制,Vue 再强大也绕不过去。所以ref不是“另一个选择”,而是 Vue3 为填补这个语言缺陷而专门打造的补丁。
接下来的内容,不会罗列 API 对比表格,也不会教你“什么场景用 ref,什么场景用 reactive”。我会带你重走一次 Vue3 响应式系统的设计现场:从一个最朴素的let count = 0开始,逐步叠加需求,看每一步技术选型背后的必然性;再通过真实项目中踩过的坑,还原那些“看似正常却突然失效”的诡异时刻,最终让你在写代码时,手指还没碰到键盘,心里已经清楚该用哪个。
2. 从零开始重建响应式:为什么 Vue3 必须同时存在 ref 和 reactive?
我们先抛开 Vue,只用原生 JavaScript 思考一个问题:如何让一个变量的变化,自动触发视图更新?假设你正在写一个计数器,最原始的写法是:
let count = 0; function increment() { count++; // 此处手动调用 render() render(); }这显然不可维护。我们需要一种机制,让count++这个动作本身就能“通知”所有依赖它的函数。这就是响应式系统的核心诉求:建立数据与副作用之间的自动追踪关系。
Vue2 用Object.defineProperty实现了这一点,但它有个致命缺陷:无法检测新增属性和删除属性,且对数组索引赋值(arr[0] = 1)和length修改无效。Vue3 改用Proxy,完美解决了这些问题。但Proxy有个铁律:它只能代理对象(Object、Array、Map、Set 等),无法代理原始类型(number、string、boolean、null、undefined、symbol)。
这就引出了第一个分水岭:当你要响应式化一个数字count时,Proxy直接报错:
// ❌ 报错:TypeError: Cannot create proxy with a non-object as target const count = new Proxy(0, { /* handler */ });怎么办?Vue3 的答案是:不直接代理原始值,而是把它包进一个对象里。这个对象就是ref的本质:
// ✅ ref 的核心实现(简化版) function ref(value) { const refObject = { value, // 这里省略了 track / trigger 的具体实现 }; // 对 refObject.value 进行响应式处理(用 reactive 或其他方式) return refObject; }所以ref(123)返回的不是一个“响应式的 123”,而是一个响应式的对象{ value: 123 }。你访问count.value时,Vue 才会触发依赖收集;你修改count.value = 456时,Vue 才会触发更新。.value不是语法糖,而是绕过 JS 类型限制的必要桥梁。
而reactive则走另一条路:它只接受对象,直接用Proxy包装它:
// ✅ reactive 的核心实现(简化版) function reactive(target) { if (!isObject(target)) { console.warn('reactive() 仅支持对象'); return target; } return new Proxy(target, { get(target, key, receiver) { // 触发依赖收集 track(target, key); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { // 触发更新 const result = Reflect.set(target, key, value, receiver); trigger(target, key); return result; } }); }注意:reactive({ x: 123 })返回的就是那个被 Proxy 拦截的原始对象本身,没有额外的.value层。你直接obj.x访问,obj.x = 456修改。
这就解释了最根本的区别:
ref是“值的容器”—— 它解决的是“如何让原始类型可响应式”的问题;reactive是“对象的替身”—— 它解决的是“如何让对象及其嵌套属性可响应式”的问题。
但问题还没完。既然reactive能代理对象,那能不能用reactive来替代ref?比如写reactive({ count: 0 })?理论上可以,但立刻会遇到两个现实障碍:
解构丢失响应性:
const state = reactive({ count: 0, name: 'Vue' }); const { count, name } = state; // ❌ count 和 name 是普通数字/字符串,不再响应式!因为解构赋值提取的是原始值,不是响应式代理。而
ref解构后仍是ref对象:const count = ref(0); const { value } = count; // ✅ value 是响应式值(虽然通常不这么用)函数参数传递陷阱:
function useCount(state) { // state 是 reactive 对象,但这里接收的是原始引用 return state.count; // ❌ 如果 state 是 reactive,这里返回的是普通数字 } const state = reactive({ count: 0 }); const c = useCount(state); // c 是 0,不是响应式!而
ref作为参数传递时,你拿到的是整个ref对象,.value依然可追踪:function useCount(countRef) { return countRef.value; // ✅ 依赖收集发生在 .value 访问时 } const count = ref(0); const c = useCount(count); // c 是响应式值
所以 Vue3 并非“多此一举”地提供了两个 API,而是用ref填补了reactive在原始类型和函数式编程场景下的能力空缺。它们不是并列选项,而是互补的基础设施:ref是原子单位,reactive是复合结构。就像乐高积木,ref是单个凸点,reactive是拼好的模块——你可以用ref拼出任何东西,但用reactive拼模块更高效。
提示:很多初学者试图用
reactive管理所有状态,结果在解构、传参、TS 类型推导时频频踩坑。这不是你代码写得不好,而是你试图用一把螺丝刀拧所有种类的螺丝——有些螺丝(原始类型、函数参数)天生就需要十字口(ref)。
3. 深度拆解 ref 的响应式链条:从 .value 到依赖更新的完整路径
现在我们聚焦ref,彻底搞懂那个让人又爱又恨的.value。它绝不是 Vue 故意设置的障碍,而是整个响应式链条中唯一能触发依赖收集与更新的合法入口。
我们以一个最简ref使用为例:
import { ref, effect } from 'vue'; const count = ref(0); effect(() => { console.log('count changed:', count.value); }); count.value++; // 触发 effect 重新执行这段代码背后发生了什么?让我们一层层剥开:
3.1 ref 的内部结构:一个被精心设计的“响应式对象”
ref返回的对象远不止{ value: 0 }。它是一个带有隐藏属性的特殊对象:
// 简化后的 ref 内部结构(实际更复杂) const count = { value: 0, __v_isRef: true, // 标识这是一个 ref _rawValue: 0, // 原始值(未被 reactive 包装) _shallow: false, // 是否浅响应式 // 关键:_value 是一个 getter/setter get value() { track(this, 'value'); // 依赖收集:记录当前 effect 依赖于这个 ref return this._rawValue; }, set value(newVal) { if (newVal !== this._rawValue) { this._rawValue = newVal; trigger(this, 'value'); // 触发更新:通知所有依赖 effect 重新执行 } } };看到没?.value是一个访问器(accessor),不是普通属性。每次读取count.value,都会执行track();每次赋值count.value = 10,都会执行trigger()。这就是响应式生效的全部秘密。
3.2 为什么不能省略 .value?—— 从 JS 引擎角度看
有人会问:“既然ref内部已经做了 getter/setter,为什么不能像 Svelte 那样直接count++?”答案在于 JavaScript 的运算符行为。
count++是一个复合操作,等价于:
const temp = count; // 这里读取的是整个 ref 对象,不是 .value! count = temp + 1; // 这里赋值的是整个 ref 对象,不是 .value!而count本身是一个对象,count++会尝试将对象转换为数字([object Object]→NaN),然后NaN + 1还是NaN。这完全偏离了响应式意图。
Vue3 的设计者必须保证:任何对响应式数据的读写,都必须经过明确的、可拦截的路径。.value就是这条路径的强制门禁。它牺牲了一点书写便利,换来的是绝对的可预测性和可调试性。
3.3 ref 的响应式穿透:嵌套 ref 与 reactive 的混合使用
ref的强大之处在于它能无缝融入reactive结构中。Vue3 会自动“解包”ref:
const count = ref(0); const state = reactive({ count, // 注意:这里直接放 ref,不是 count.value message: 'Hello' }); // ✅ 模板中可以直接用 {{ state.count }},无需 .value // ✅ JS 中也可以直接 state.count++ console.log(state.count); // 输出 0,自动读取 count.value state.count++; // 自动触发 count.value = 1这是怎么做到的?关键在于reactive的get拦截器里有一段特殊逻辑:
get(target, key, receiver) { const res = Reflect.get(target, key, receiver); // 如果获取到的值是 ref,且不是数组索引访问,则自动返回 .value if (isRef(res) && !isArray(target) && key !== '__v_raw') { return res.value; // 自动解包! } track(target, key); return res; }同样,在set时,如果新值是ref,也会被自动识别并处理。这种“智能解包”让ref和reactive在组合使用时几乎感觉不到边界。
但注意:解包只发生在 reactive 对象的属性访问层面,不发生在普通变量赋值或函数参数中。比如:
const state = reactive({ count: ref(0) }); const c = state.count; // ✅ c 是 0(已解包) const d = state.count.value; // ❌ TypeError: Cannot read property 'value' of 0因为state.count已经被解包为原始数字0,它不再是ref对象,自然没有.value。
3.4 ref 的类型安全:为什么 TypeScript 推荐 Ref 而非 ref
在 Vue3 的 TS 类型定义中,ref的返回类型是Ref<T>,而不是简单的T。这不仅是类型标注,更是对运行时行为的精确描述:
const count = ref(0); // count 的类型是 Ref<number>,不是 number // 所以 count + 1 会报错:Type 'Ref<number>' has no call signatures. // 必须写 count.value + 1这种强类型约束,强迫开发者在编译期就意识到ref是一个容器。它避免了大量运行时错误,比如:
// ❌ 错误:忘记 .value,导致 NaN function addOne(countRef) { return countRef + 1; // TS 会报错:Operator '+' cannot be applied to types 'Ref<number>' and 'number'. } // ✅ 正确 function addOne(countRef) { return countRef.value + 1; }注意:
ref的类型推导非常智能。ref(123)的类型是Ref<number>,ref('hello')是Ref<string>,ref({x: 1})是Ref<{x: number}>。但如果你显式声明ref<{x: number}>({}),它会按你写的来,不会自动推导嵌套属性。这是 TS 类型系统与 Vue 响应式机制深度协同的结果。
4. reactive 的响应式边界:为什么它强大却危险?
如果说ref是一个受控的、有明确边界的响应式单元,那么reactive就是一把锋利的双刃剑——它赋予你对象级的响应式能力,但也要求你时刻警惕它的“失联”风险。
4.1 reactive 的核心能力:深层响应式与动态属性
reactive最大的优势,是它对对象的深层、动态、透明的响应式支持:
const state = reactive({ user: { profile: { name: 'Alice', age: 30 } } }); // ✅ 深层属性修改触发更新 state.user.profile.name = 'Bob'; // 触发依赖更新 // ✅ 动态添加属性触发更新(Vue2 不支持!) state.user.profile.city = 'Beijing'; // 触发更新 // ✅ 删除属性触发更新 delete state.user.profile.age; // 触发更新 // ✅ 数组操作全部支持 state.items = ['a', 'b']; state.items.push('c'); // 触发更新 state.items[0] = 'x'; // 触发更新 state.items.length = 1; // 触发更新这一切都得益于Proxy的能力。reactive不是简单地递归遍历对象属性,而是为每个层级的对象创建独立的Proxy实例,并在get/set时递归处理嵌套对象。
4.2 reactive 的致命弱点:响应式“失联”的三大场景
然而,reactive的强大伴随着严格的使用约束。一旦违反,响应式就会悄无声息地“断开”,而你的代码看起来完全正常。
场景一:解构赋值 —— 最隐蔽的失联
const state = reactive({ count: 0, name: 'Vue' }); // ❌ 危险!解构后失去响应式 const { count, name } = state; console.log(count); // 0(普通数字) count++; // ❌ 不会触发更新! console.log(state.count); // 仍然是 0 // ✅ 正确:用 toRefs 保持响应式链接 import { toRefs } from 'vue'; const { count, name } = toRefs(state); console.log(count.value); // 0(ref 对象) count.value++; // ✅ 触发更新toRefs的作用,就是把reactive对象的每个属性,都转换成对应的ref。它内部做的就是:
function toRefs(object) { const ret = {}; for (const key in object) { ret[key] = toRef(object, key); // 为每个 key 创建一个 ref,其 .value 指向 object[key] } return ret; }所以toRefs(state)返回的是{ count: Ref<number>, name: Ref<string> },解构后得到的仍是ref,.value访问才有效。
场景二:函数参数传递 —— 响应式“蒸发”
// ❌ 错误:传入 reactive 对象,函数内无法建立响应式连接 function useUser(userObj) { // userObj 是 reactive 对象,但这里只是普通引用 return userObj.name; // 返回普通字符串,不响应式! } const user = reactive({ name: 'Alice', age: 25 }); const userName = useUser(user); // userName 是 'Alice',不是响应式 // ✅ 正确:传入 ref 或使用 toRef function useUser(userRef) { return userRef.value.name; // 依赖收集发生在 .value 访问时 } const user = ref({ name: 'Alice', age: 25 }); const userName = useUser(user);或者更优雅地:
function useUser(userObj) { // 在函数内部,用 toRef 创建对特定属性的响应式引用 const nameRef = toRef(userObj, 'name'); return nameRef; } const user = reactive({ name: 'Alice', age: 25 }); const userName = useUser(user); // userName 是 Ref<string>场景三:数组索引赋值与 length 修改 —— Vue2 的老问题重现?
等等,前面不是说reactive支持数组所有操作吗?是的,但有一个极其微妙的例外:
const list = reactive(['a', 'b', 'c']); // ✅ 这些都 OK list.push('d'); list[0] = 'x'; list.length = 2; // ❌ 但这个不行! list[3] = 'e'; // ❌ 不会触发响应式更新!原因在于:Proxy的set拦截器,对于数组索引赋值,会检查key是否为有效数字索引(即key >= 0 && key < array.length)。list[3]时,3 >= list.length(当前长度为 3),所以它被当作普通属性处理,而Proxy对数组的普通属性赋值默认不触发更新(除非你手动trigger)。
解决方案很简单:用push、splice等变异方法,或者用list.length = 4先扩容,再赋值。
提示:这些“失联”场景之所以危险,是因为它们不会报错,代码能跑,数据也能显示,只是“改了不更新”。我在某电商后台项目中,就因为一个
const { loading } = state的解构,导致按钮点击后 loading 状态永远不变化,排查了两小时才发现是这里断开了响应式链。
5. 实战决策树:什么时候该用 ref,什么时候该用 reactive?
理论讲完,现在进入最关键的环节:如何在真实项目中做选择?我总结了一套基于数据形态、使用场景、协作成本的三层决策树,帮你告别“凭感觉选”。
5.1 第一层:看数据形态 —— 原始类型 or 对象?
这是最基础、最不容混淆的判断。
| 数据形态 | 推荐方案 | 原因说明 |
|---|---|---|
| 原始类型 (number, string, boolean, null, undefined, symbol) | ref() | reactive根本不接受原始类型,强行传入会静默失败或返回原值。ref是唯一合法选择。 |
| 单一对象/数组 (如 { name: '', age: 0 },[1,2,3]) | reactive() | 更简洁:state.namevsstate.value.name;性能略优(少一层对象包装);符合直觉。 |
| 复杂嵌套对象 (如 user.profile.address.city) | reactive() | reactive天然支持深层响应式,无需为每一层都套ref。 |
经验:我见过最离谱的用法是
const count = reactive({ count: ref(0) })。这相当于给一个ref再套一层reactive,纯属叠床架屋,既增加内存开销,又让类型推导混乱。
5.2 第二层:看使用场景 —— 你会怎么用它?
即使数据是对象,也要看它在代码中的“角色”。
| 使用场景 | 推荐方案 | 原因说明 |
|---|---|---|
| 需要解构使用 (如 const { name, email } = props) | ref()或toRefs() | ref解构后仍是ref;reactive解构后失联,必须用toRefs包一层,反而更啰嗦。 |
| 作为函数参数传递 (如 useForm(state)) | ref() | ref作为参数,.value访问能建立稳定依赖;reactive传参后,函数内部无法保证响应式连接。 |
| 需要与非响应式逻辑混用 (如 const data = JSON.parse(localStorage.getItem('data'))) | ref() | ref可以轻松包裹任意值;reactive要求输入必须是对象,且会递归处理,可能污染原始数据。 |
| 需要频繁重置/替换整个对象 (如 state = { ... }) | ref() | ref赋值state.value = newObj是原子操作;reactive对象赋值state = newObj会丢失响应式代理,必须用Object.assign(state, newObj)或逐个属性赋值。 |
5.3 第三层:看协作成本 —— 团队成员能否一眼看懂?
这是最容易被忽视,却影响最大的一层。代码是写给人看的,其次才是给机器执行。
| 团队现状 | 推荐倾向 | 理由 |
|---|---|---|
| 团队有较多 Vue2 经验者 | 倾向ref() | ref的.value明确标示“这是响应式数据”,降低理解门槛;reactive的“隐形解包”容易引发困惑。 |
| 项目重度使用 TypeScript | 倾向ref() | Ref<T>类型比Reactive<T>更易推导、更安全;ref的类型错误在编译期就能捕获。 |
| 大型表单/状态管理模块 | reactive() | 表单字段众多,reactive({ field1, field2, ... })比ref({ field1, field2, ... })更符合语义;配合toRefs解构,可读性高。 |
| 微前端/跨框架集成场景 | ref() | ref返回的是标准 JS 对象,与其他框架(React、Svelte)的数据交互更简单;reactive返回的是 Proxy 对象,某些环境可能不兼容。 |
5.4 一张表终结所有纠结:ref vs reactive 对照速查
下面这张表,是我根据过去三年十几个 Vue3 项目总结出的“终极对照表”,覆盖了 95% 的日常场景。建议收藏,遇到拿不准时,直接查对应行:
| 场景描述 | 推荐方案 | 关键代码示例 | 注意事项 |
|---|---|---|---|
| 定义一个计数器 | ref(0) | const count = ref(0); count.value++ | 不要用reactive({ count: 0 }),解构麻烦。 |
| 定义一个用户对象 | reactive({}) | const user = reactive({ name: '', age: 0 }); user.name = 'A' | 若需解构,用const { name } = toRefs(user)。 |
| 从 API 获取数据并赋值 | ref(null) | const data = ref(null); data.value = await api.getData(); | ref(null)初始化,避免reactive({})的空对象陷阱。 |
| 在 setup() 中返回给模板 | ref()或toRefs() | return { count, ...toRefs(user) } | 模板中{{ count }}和{{ user.name }}都能直接用。 |
| 封装一个自定义 Hook | ref()为主 | function useCounter() { const count = ref(0); return { count }; } | Hook 返回ref,调用方无需关心内部实现。 |
| 需要监听某个深层属性变化 | ref() | `const city = ref(user.profile?.address?.city | |
| 与 localStorage 同步 | ref() | `const theme = ref(localStorage.getItem('theme') | |
| 大型配置对象(上百个字段) | reactive() | const config = reactive({ db: { host: '', port: 0 }, api: { url: '' } }); | 避免为每个字段都写ref,reactive更简洁。 |
最后分享一个我自己的习惯:在项目初期,我一律用ref。不是因为它更好,而是因为它更“诚实”。.value就像一个醒目的红色标签,时刻提醒我:“这里是响应式数据,请小心操作”。等项目稳定、团队熟悉后,再根据性能和可读性,逐步将一些高频使用的对象改为reactive。这种渐进式演进,比一开始就追求“最佳实践”要稳健得多。
6. 那些年我们一起踩过的坑:真实项目中的 ref/reactive 混用排错实录
理论再扎实,不如一次真实的排错经历来得深刻。我来复盘三个在生产环境中让我熬夜到凌晨的真实案例,每一个都曾让整个团队陷入“这代码明明没错,为什么不更新?”的集体困惑。
6.1 坑一:表单提交后,loading 状态永远不恢复
现象:
某后台管理系统的登录表单,点击登录按钮后,loading状态变为true,按钮变灰,但请求完成后,loading却卡在true,按钮一直灰着,控制台没有任何报错。
代码片段(简化):
// Login.vue export default { setup() { const state = reactive({ username: '', password: '', loading: false }); const login = async () => { state.loading = true; try { await api.login(state.username, state.password); } finally { state.loading = false; // 这行执行了,但 UI 不更新! } }; return { state, login }; } };排查过程:
- 首先确认
state.loading = false确实执行了(加console.log验证)。 - 检查模板:
<button :disabled="state.loading">登录</button>,语法正确。 - 用 Vue Devtools 查看
state,发现loading属性确实变成了false,但组件没有 re-render。 - 突然想到:
state是reactive对象,但state.loading在模板中是直接访问的,应该没问题……等等,state是在setup()中定义的,但login函数是异步的,会不会是state的引用在finally块中丢失了?
根因定位:
问题不在reactive,而在async/await的执行上下文。state对象本身没问题,但state.loading = false这行代码,是在try/catch/finally的finally块中执行的。而finally块的执行时机,有时会与 Vue 的响应式更新队列产生微妙的竞态。但这只是表象。
真正的根因是:state对象被意外地重新赋值了。在某个被忽略的onBeforeUnmount钩子中,有这样一行代码:
onBeforeUnmount(() => { state = {}; // ❌ 重置 state!这会让原来的 reactive 代理失效! });state = {}这行代码,把state变量指向了一个全新的、非响应式的空对象。所以finally块里的state.loading = false,其实是给一个普通对象赋值,当然不会触发更新。
修复方案:
- 彻底删除
state = {}这种危险操作。 - 改用
Object.assign(state, { username: '', password: '', loading: false })重置。 - 或者,更推荐的做法:用
ref管理整个状态对象:const state = ref({ username: '', password: '', loading: false }); // 重置时:state.value = { username: '', password: '', loading: false }
教训:
reactive对象的变量名,是它在内存中的唯一“身份证”。一旦你用=给它赋新值,旧的响应式代理就永远丢失了,且没有任何警告。ref的.value赋值,是可控的、可追踪的。
6.2 坑二:搜索框输入,列表过滤结果不实时更新
现象:
一个商品搜索页,顶部是input输入框,下方是商品列表。输入时,列表应该实时过滤。但实际效果是:输入第一个字符,列表没变;输入第二个字符,列表才更新为第一个字符的过滤结果;以此类推,总是“慢一拍”。
代码片段:
// Search.vue export default { setup() { const searchQuery = ref(''); const productList = ref([]); // 这个 computed 用于过滤 const filteredList = computed(() => { return productList.value.filter(item => item.name.includes(searchQuery.value) ); }); // 从 API 加载商品 onMounted(async () => { productList.value = await api.getProducts(); }); return { searchQuery, filteredList }; } };排查过程:
searchQuery是ref,productList也是ref,computed依赖它们,逻辑似乎没问题。- 用 Devtools 查看
searchQuery.value,发现输入时它确实在实时变化。 - 但
filteredList的值,却总是滞后。
根因定位:
问题出在productList.value.filter(...)这一行。productList.value是一个数组,而filter方法返回的是一个新数组。这个新数组是普通 JS 数组,不是响应式的!computed的 getter 里,filteredList的值是这个新数组,它本身没有响应式能力。
但computed本身是响应式的,它依赖searchQuery.value和productList.value。为什么还会滞后?因为productList.value在onMounted里只赋值了一次,之后没变。所以computed只会在searchQuery.value变化时重新计算。那为什么还慢一拍?
真相是:computed的缓存机制。computed会缓存上一次的返回值,只有当依赖项变化时才重新计算。而searchQuery.value的变化是同步的,computed的重新计算也是同步的。所以不应该慢。
最终发现,问题在于api.getProducts()返回的数据结构。它返回的是一个Proxy对象(因为后端返回的是嵌套对象),而filter后的新数组里,每个item仍然是Proxy对象。但item.name.includes(...)这个操作,会触发item.name的get拦截,从而进行依赖收集。而item的name属性,可能在别的地方也被依赖,导致更新队列混乱。
修复方案:
最简单直接的修复,是确保filteredList的计算结果是稳定的:
const filteredList = computed(() => { const query = searchQuery.value; if (!query) return productList.value; return productList.value.filter(item => item.name.toLowerCase().includes(query.toLowerCase()) ); }); ``