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. 自测
reactive()返回的对象和传入的原对象,改哪个会触发更新?- 为什么解构
reactive会丢响应性,toRefs又为什么不会? computed与watch在"有没有副作用"上的分界是什么?- 同一函数里连改三次
ref,DOM 会在第几次更新?nextTick保证了什么? - 组件上
v-model展开成哪两个绑定?子组件为什么不能直接改 props? - 什么时候该用
shallowRef而不是ref?
S14-B06 · Vue 3 响应式入门:ref、reactive 与更新时机
https://www.clxhxhhr.top/posts/6079/ 评论
0 条
还没有评论,先写一条吧。