2314 字
约 7 分钟
17
S14-B06 · Vue 3 响应式入门:ref、reactive 与更新时机

state.count++ 写完了,页面纹丝不动;换成 ref 一切正常。类似的还有:解构出来的 count 永远是旧值、watch 监听对象改属性不触发、v-model 在自定义组件上不生效。

这些都不是 API 记错,而是没搞清一件事:响应式靠"读的时候记下来、写的时候通知",任何绕过读写拦截的操作都会静默失效。


1. 三步机制:拦截、收集、触发

render 执行 → 读 state.count → Proxy.get 拦截
          → 记录:effect(count) 依赖了 count
state.count++ → Proxy.set 拦截 → 找到依赖 count 的 effect → 重新执行
维度 Vue 2(Object.defineProperty) Vue 3(Proxy)
拦截范围 已有属性,逐个 get/set 整个对象,含新增/删除键
新增属性 不响应,需 $set 响应
数组索引赋值 不响应,需 splice 响应
数组 length 部分拦截 响应
初始化成本 递归遍历全部属性 惰性,访问到才代理

Vue 3 的 reactive() 返回的是 Proxy,原对象仍在;改动原对象不经过代理,不会触发更新——这是"数据变了页面没变"最常见的物理原因。

2. ref 与 reactive 的边界

场景 推荐 原因
原始值(数字、字符串、布尔) ref reactive 只能代理对象
对象 / 数组 两者皆可,团队统一 reactive 省 .value,ref 换类型方便
需要整体替换 ref reactive 整体赋值会丢代理
从组合式函数返回 ref 解构后仍可保持响应
模板中直接使用 两者都可(模板自动解包 ref) 脚本里要写 .value
import { reactive, ref, toRefs } from 'vue'

const count = ref(0)
count.value++                    // 脚本里必须 .value
console.log(count.value)

const state = reactive({ n: 0, list: [] })
state.n++                        // 无需 .value

const { n } = state              // ✗ n 是普通数字,失去响应
const { n: nRef } = toRefs(state) // ✓ 得到指向 state.n 的 ref

toRefs 不复制值,它造一个"读写都转发到源对象"的 ref,所以解构后仍能联动。toRef(state, 'n') 是单个版本。

3. computed、watch、watchEffect 的分工

API 本质 惰性 副作用 用在哪
computed 派生值,有缓存 是(首次访问才算) 不该有 模板里算价格、过滤列表
watch 监听指定源 否(源变即回调) 是 请求、写 storage、改别的状态
watchEffect 自动收集依赖 否,默认立即跑一次 是 依赖多且不想逐个列
const price = ref(100)
const qty = ref(2)
const total = computed(() => price.value * qty.value)   // 派生

watch(total, (now, prev) => console.log('total', prev, '→', now))

watch(() => state.list, () => {/* 只有 list 被整体替换才触发 */})
watch(() => state.list, cb, { deep: true })             // 改内部元素也触发

watchEffect(() => localStorage.setItem('qty', qty.value))

deep: true 会递归遍历被监听对象,大对象上是实打实的开销;能监听到具体字段就别 deep。

4. 更新时机:批处理与 nextTick

同一轮事件循环里的多次修改会被合并成一次渲染:

const n = ref(0)
function bump() {
  n.value++; n.value++; n.value++
  console.log(document.querySelector('#n').textContent) // 仍是旧值
  await nextTick()
  console.log(document.querySelector('#n').textContent) // 已是新值
}
阶段 发生了什么
修改 n.value 标记依赖为 dirty,加入更新队列
微任务队列末尾 刷新队列,重新渲染
await nextTick() 之后 DOM 已更新,可测量、可聚焦

误用是把 nextTick 当"延迟执行"——它保证的是"DOM 已同步",不是"等网络回来"。拿它去等接口返回,只会拿到还没赋值的旧 DOM。

5. 模板为什么"不更新"

越界写法 表现 正确做法
直接改原始对象 raw.n++ 无更新 通过 reactive() 返回值改
解构 reactive 值变了视图不变 toRefs / toRef
state.obj = {...}(整体替换) 新对象未被代理 用 ref,或 Object.assign 改字段
直接改数组长度 arr.length = 0 视版本可能不触发 用 arr.splice(0) 或赋新数组
setup 外创建的普通变量 从不响应 放进 ref/reactive
shallowRef 改内部属性 只有整体替换才触发 明确需要时才用 shallow

6. 组件上的 v-model

v-model="x" 在组件上展开成两个东西,缺一不可:

<!-- 父组件 -->
<MyInput v-model="name" v-model:page="pageNo" />

<!-- 等价于 -->
<MyInput :modelValue="name" @update:modelValue="name = $event"
         :page="pageNo" @update:page="pageNo = $event" />
// 子组件
const props = defineProps({ modelValue: String, page: Number })
const emit = defineEmits(['update:modelValue', 'update:page'])
// 子组件内绝不能直接改 props.modelValue,只能 emit
emit('update:modelValue', 'new value')
修饰符 展开效果
v-model.trim 子组件收到 modelModifiers = { trim: true }
v-model:page.number pageModifiers = { number: true }
自定义 v-model:title.cap titleModifiers.cap,由子组件自行实现语义

修饰符不自动生效,子组件要读 props.modelModifiers 并自己处理。

7. 生命周期与副作用清理

import { onMounted, onUnmounted, ref } from 'vue'

const timer = ref(null)
let id = null
onMounted(() => { id = setInterval(() => tick(), 1000) })
onUnmounted(() => { clearInterval(id) })     // 不清会一直跑,组件树重挂载时叠加

watch/watchEffect 在组件内创建时会自动随组件卸载停止,但在 setup 之外(如独立模块)创建的不会——那种情况要手动调返回的 stop()。

8. props 的单向流

想法 能不能做 正确替代
子组件改 props.x 不能,开发期会警告 emit('update:x', v)
需要本地编辑再提交 可以 const local = ref(props.x),提交时 emit
父值变化要同步到本地 可以 watch(() => props.x, v => local.value = v)
深层对象直接改内部字段 技术上能,语义是错的 提升状态或 emit 事件

本地副本模式必须在父值变化时同步,否则会出现"父组件重置了、子组件还显示旧值"。

9. shallowRef:只关心整体替换

import { shallowRef, triggerRef } from 'vue'

const chart = shallowRef(null)
chart.value = echarts.init(el)      // 整体替换 → 触发
chart.value.setOption(opt)          // 内部变化 → 不触发(这是想要的行为)
场景 用什么 理由
第三方实例(图表、地图、编辑器) shallowRef 深层代理会破坏实例内部逻辑
超大只读列表 shallowRef + 整体替换 省掉递归代理开销
需要细粒度更新的深层表单 reactive 逐字段响应
手动控制更新 shallowRef + triggerRef 明确在何时刷新

10. 最小可跑示例

<script setup>
import { ref, reactive, computed, watch, nextTick } from 'vue'

const count = ref(0)
const state = reactive({ step: 1 })
const doubled = computed(() => count.value * 2)

watch(count, (v) => console.log('count →', v))

async function inc() {
  count.value += state.step
  await nextTick()
  console.log('DOM:', document.querySelector('#show').textContent)
}
</script>

<template>
  <p id="show">{{ count }} / {{ doubled }}</p>
  <button @click="inc">+{{ state.step }}</button>
</template>

验证点:点一次按钮,控制台先打印 count → 1,nextTick 之后打印 DOM: 1 / 2;把 state.step 改成普通对象解构出来的数字,按钮就失效——刚好复现第 5 节的越界写法。

11. 排错

现象 原因 处理
改数据视图不变 改的是原对象或解构出来的值 检查是否经过代理,改用 toRefs
watch 不触发 监听的是对象引用,内部变了引用没变 加 deep 或监听具体字段
computed 结果不刷新 内部读了非响应式变量 依赖必须是 ref/reactive
模板报 undefined ref 忘了 .value 脚本里补 .value,模板里不用
每次渲染都重复请求 请求写在 setup 顶层 移到 onMounted 或 watch
内存持续增长 定时器/监听器未清理 onUnmounted 里 clear 与 stop

12. 边界

需求 更合适的做法 理由
跨页面共享状态 Pinia 组件内响应式不跨树
服务端数据缓存 TanStack Query / SWR 类方案 手写 loading/error/去重成本高
大型不可变数据 shallowRef + 整体替换 深层代理不划算
高频动画属性 直接操作 DOM / CSS 变量 走响应式每帧都重新渲染
表单大批量校验 表单库 手写规则与联动难维护

13. 自测

  1. reactive() 返回的对象和传入的原对象,改哪个会触发更新?
  2. 为什么解构 reactive 会丢响应性,toRefs 又为什么不会?
  3. computed 与 watch 在"有没有副作用"上的分界是什么?
  4. 同一函数里连改三次 ref,DOM 会在第几次更新?nextTick 保证了什么?
  5. 组件上 v-model 展开成哪两个绑定?子组件为什么不能直接改 props?
  6. 什么时候该用 shallowRef 而不是 ref?
S14-B06 · Vue 3 响应式入门:ref、reactive 与更新时机
https://www.clxhxhhr.top/posts/6079/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。