页面上的数字还停在 0,控制台却已经打印出 1。点击事件确实执行了,数据也确实变了,唯独界面没有跟上。
第 2 章已经确认,模板会被编译成渲染逻辑,{{ count }} 这样的表达式会读取组件数据。这里的模板语法完全正确,因此可以沿着这次“读取”继续追查:保留 state.count 时页面正常更新;把 reactive 对象的 count 解构成局部变量后,控制台中的 state.count 会变,模板读到的 count 却不再跟着变。把 ref 换成普通 let 也能复现同类现象:JavaScript 变量的值确实改了,但没有任何东西通知模板重新执行。
所以这不是模板语法问题,也不是点击函数没运行。应该追的就是这个“读取”动作:模板读到的究竟是一个能持续发出变化通知的入口,还是解构时拿走的一份普通快照?
先把故障压缩成一个连接完整的最小版本:
<script setup>
import { ref } from 'vue'
const count = ref(0)
function addOne() {
count.value++
}
</script>
<template>
<button @click="addOne">点击次数:{{ count }}</button>
</template>这段代码不是为了再演示一次“点击后数字会变”,而是给调试现场放上一组对照。脚本通过 count.value 写入,模板通过 count 读取,两端都没有离开同一个 ref 入口。页面能跟上,关键并不是变量名叫 count,而是这条读写路径没有断。
Vue 不会轮询普通变量,也不会因为一个数字变化就粗暴地刷新整张页面。它做的事更像登记联系人:某段界面在读取响应式数据时,Vue 记下“这段界面用过它”;数据被写入时,Vue 再通知真正用过它的地方安排更新。 普通变量和解构后的快照之所以安静,正是因为这个登记与通知的入口不存在,或已经被绕开。
ref(0) 也就不再神秘了。它给你的不是一个普通数字,而是一个带读写入口的“盒子”。读 .value 时可以登记依赖,写 .value 时可以发出变化通知。模板把 .value 省略了,看起来轻巧;盒子和通知仍然真实存在。
沿着模板中的这次读取继续往里追,调试证据会逐步落到准确术语上。暂时不用记住每个底层名词,只要始终抓住三件事:谁被读取了,谁记住了这次读取,变化发生后谁会重新执行。

上一章的调试顺序是“事件 → 数据 → 表达式 → DOM”。当事件已经发生、数据也已改变,就要在“数据 → 表达式”这一段检查读取入口。下面这段原生 JavaScript 不是要重新教一次 DOM 操作,而是为了单独暴露一个事实:普通变量和页面之间没有可持续追踪的联系。变量已经变成 1,页面仍然显示 0:
<button id="counter">点击次数:0</button>
<script>
let count = 0
const button = document.querySelector('#counter')
button.addEventListener('click', () => {
count++
console.log(count) // 1
// 忘了更新 button.textContent,页面仍然是 0
})
</script要让它工作,我们还得手动补上一句:
button.textContent = `点击次数:${count}`单独补上 button.textContent 能修好这个例子,却不能解释 Vue 为什么能知道哪一段界面需要更新。一旦页面同时显示剩余数量、完成比例、空状态提示和按钮是否禁用,每次改数据都要想一遍“哪些 DOM 也要改”。真正难维护的不是 querySelector 本身,而是同一份业务状态被保存成两份:一份在 JavaScript 变量里,一份在 DOM 里。只更新其中一份,界面就和数据对不上。
Vue 让我们只声明界面与状态的关系:
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button @click="count++">点击次数:{{ count }}</button>
</template>模板不是运行时做字符串拼接。构建工具会把模板编译成渲染函数。组件渲染时,这个函数读取了 count,于是 Vue 知道当前组件的渲染依赖 count。点击按钮后,模板表达式中的 count++ 修改同一个响应式状态,Vue 便把这个组件的更新放入队列。稍后重新渲染时,它会比较前后结果,只把需要变化的 DOM 更新为新内容。
所以“声明式界面”可以翻译成一句很朴素的话:你负责描述数据处于某个值时界面应该是什么样,Vue 负责让真实 DOM 追上这个描述。
响应式不是说“任何 JavaScript 变量一改,页面都会更新”。Vue 只能追踪经过响应式 API 包装、并且在响应式上下文中被读取的数据。普通的 let 变量仍然只是普通变量。
Vue 3 同时支持 Options API 和 Composition API。你以后阅读老项目时,可能会见到这样的 Options API:
<script>
export default {
data() {
return {
count: 0
}
},
methods: {
addOne() {
this.count++
}
}
}
</script>这里 data() 返回的属性也会进入 Vue 的响应式系统,模板和方法通过组件实例使用它们。这套写法没有过时,也不是错误答案,许多已有项目仍在使用。
不过从本课开始,示例的主线统一采用 Composition API:
<script setup>
import { ref } from 'vue'
const count = ref(0)
function addOne() {
count.value++
}
</script>这样选择有三个实际原因。第一,ref、reactive、computed 和后面会学到的监听 API 都能放在同一条逻辑线上理解。第二,<script setup> 中顶层声明的变量和函数可以直接用于模板,不必写 return。第三,当前 Vue 工程里,单文件组件配合 <script setup> 是很常见的组织方式。
你可以把 .vue 文件的三段结构看成组件自己的小房间:
<script setup>
// 状态和行为
</script>
<template>
<!-- 由状态决定的界面 -->
</template>
<style scoped>
/* 只服务于当前组件的样式 */
</style>这一节重点放在 <script setup> 与 <template> 之间的数据联系。样式只是帮助我们看清效果,不参与响应式追踪。
我们先从最常用的 ref 开始:
import { ref } from 'vue'
const count = ref(0)此时 count 不是数字 0,而是一个 ref 对象。真正的数字放在它的 .value 属性中:
console.log(count) // Ref 对象
console.log(count.value) // 0
count.value = 5
console.log(count.value) // 5为什么偏偏多出一个 .value?因为 JavaScript 没有提供拦截局部变量读写的通用办法。假如写 let count = 0,之后执行 count++,Vue 无法站在一旁捕获这次赋值。把值放到对象属性里,情况就不同了:属性可以有 getter 和 setter。读取属性会经过 getter,写入属性会经过 setter,Vue 因而有机会在两个入口分别做“记录依赖”和“触发更新”。
先用概念化代码看这个盒子。下面不是 Vue 的真实源码,但它保留了最关键的结构:
function makeBox(initialValue) {
let innerValue = initialValue
return {
get value() {
track()
return innerValue
},
set value(nextValue) {
innerValue = nextValue
trigger()
}
}
}track() 可以理解为“当前是谁在读取我,我把它记下来”;trigger() 可以理解为“我的内容变了,通知先前记下的人”。真实实现还要处理重复依赖、嵌套 effect、值是否真的变化、调度顺序等细节,但核心并没有偏离这两个动作。
ref 可以装数字、字符串和布尔值,也可以装数组、对象,甚至一开始装 null:
const score = ref(90)
const title = ref('响应式练习')
const isOpen = ref(false)
const tasks = ref([])
const profile = ref({ name: '小林', level: 1 })
const selectedElement = ref(null)因此不要把 ref 记成“基本类型专用”。更准确的说法是:ref 能包装任何值,并提供一个稳定、可追踪、可整体替换的 .value 入口。

.value 时登记使用者,写 .value 时通知使用者;这就是“盒子”比普通变量多出来的能力。下面这个组件里,同一个 count 出现了两种写法:
<script setup>
import { ref } from 'vue'
const count = ref(0)
function addOne() {
count.value++
}
</script>
<template>
<button @click="addOne">
点击次数:{{ count }}
</button>
</template>在 JavaScript 中,count 指向盒子,count.value 才是盒子里的数字。漏写 .value 往往不会立刻报出容易理解的错误:
function addWrong() {
count++
}这里尝试给用 const 声明的 count 重新赋值,而且对象参加自增运算也不是我们想要的行为。正确写法始终是:
count.value++模板由 Vue 编译,编译器知道哪些顶层绑定是 ref,可以在常见位置帮我们自动解包。因此模板写 {{ count }},得到的是内部数字;事件表达式写 count++,也会按 ref 的值处理。这项便利只属于模板语境,并没有改变 JavaScript 本身。
为了少踩坑,可以记住这张小表:
自动解包还有边界。比如把 ref 放入普通对象后再做复杂表达式,嵌套成员不一定按顶层 ref 那样自动处理:
<script setup>
import { ref } from 'vue'
const book = {
price: ref(59)
}
</script>
<template>
<!-- 不要依赖嵌套 ref 在复杂表达式里自动解包 -->
<p>{{ book.price.value + 1 }}</p>
</template>初学阶段最省心的做法是:需要在模板频繁使用的 ref,就把它保持为 <script setup> 的顶层变量;对象数据要么整个用 ref 包装,要么交给稍后介绍的 reactive。
“模板不用 .value”不等于“项目里永远不用 .value”。判断标准不是这个变量看起来像不像 ref,而是代码此刻运行在 JavaScript 还是模板表达式中。
现在把“盒子记住使用者”换成更准确的术语。
组件第一次渲染时,Vue 会执行这个组件的渲染逻辑。我们把会因依赖变化而重新执行的工作称为响应式副作用,常简写为 effect。组件渲染就是一个重要的 effect:它读取状态,最后让 DOM 与状态保持一致。
假设模板是:
<template>
<p>数量:{{ count }}</p>
<p>课程:{{ courseName }}</p>
</template>渲染过程中读取 count 和 courseName 时,Vue 会执行依赖追踪,也就是 track。可以把形成的关系想成:
count.value ──订阅者──> 当前组件的渲染 effect
courseName.value ──订阅者──> 当前组件的渲染 effect以后 count.value 被写入,Vue 执行触发,也就是 trigger,找到订阅它的 effect,并把组件更新加入调度队列。没有读取过 count 的组件不会因为这次变化被无缘无故通知。
更接近真实结构的概念图可以写成三层映射:
响应式目标 -> 被读取的属性 -> 订阅这个属性的 effect 集合同一个属性可以被多个 effect 使用,一个 effect 也可以依赖多个属性。Vue 会用集合避免把同一个 effect 重复登记。对象目标通常保存在 WeakMap 中,目标不再被使用时,不会仅仅因为依赖表还在就永远占住内存。
条件分支会让依赖发生变化:
<script setup>
import { ref } from 'vue'
const showDetail = ref(false)
const detail = ref('这里是详细说明')
</script>
<template>
<p>{{ showDetail ? detail : '详情已收起' }}</p>
</template>当 showDetail 是 false 时,当前渲染主要需要知道它的值;切换为 true 后,重新渲染会读取 detail,detail 才成为当前分支的依赖。响应式关系不是一张永远不变的静态表,effect 每次执行都会根据真正走过的读取路径更新依赖。

watchEffect 会立即执行传入的函数,并自动追踪函数同步执行期间读取的响应式数据:
<script setup>
import { ref, watchEffect } from 'vue'
const price = ref(20)
const amount = ref(2)
watchEffect(() => {
console.log(`合计:${price.value * amount.value} 元`)
})
function addOne
首次运行会输出“合计:40 元”。点击按钮修改 amount 后,这个函数因为读取过 amount.value 而再次执行,输出“合计:60 元”。这和组件渲染 effect 的依赖追踪思路相通,只是 effect 的具体工作从“生成界面”换成了“打印合计”。
这里先把 watchEffect 当观察窗口,不急着用它处理所有业务。能通过模板或计算属性表达的派生结果,后面通常会有更合适的写法。
如果“追踪”和“触发”仍然有些抽象,我们可以自己拼一个只处理单个值的微型版本。它不会覆盖 Vue 的边界情况,但足够让通知链路跑起来:
let activeEffect = null
function createSignal(initialValue) {
let value = initialValue
const subscribers = new Set()
return {
get value() {
if (activeEffect) {
subscribers.add(activeEffect)
}
return value
},
set value(nextValue) {
第一次调用 runEffect 时,传入的函数先成为 activeEffect。函数为了拼出日志文字而读取 count.value,getter 发现此刻有一个正在运行的 effect,于是把它加入 subscribers。这一步对应依赖追踪。
随后执行 count.value++。自增其实包含一次读取和一次写入,setter 收到新值后遍历订阅集合,再执行先前登记的函数,于是日志从“页面数字:0”变成“页面数字:1”。这一步对应触发。
真实系统为什么比这段代码复杂得多?先看三个日常问题。一个 effect 可能先读 a 再读 b,所以依赖不能只放在某个全局数组里;条件分支变化后,旧分支的依赖需要清理,否则已经不再显示的内容仍会触发工作;一个 effect 在执行时还可能间接启动另一个 effect,因此当前 effect 不能只用一个没有栈结构的变量保存。
另外,真实 Vue 不会在每次 setter 里立即粗暴执行组件渲染。触发阶段更像把需要工作的任务交给调度器。调度器负责去重、确定先后关系,并把同一轮的组件更新合并。这就是前面说“记录联系”,后面又说“把更新放进队列”的连接处。
理解这个小实现的目的不是自己重写 Vue。它帮你建立一条检查线:getter 有没有真的执行,当前有没有 effect,订阅有没有登记到正确属性,setter 有没有从同一个响应式入口经过。以后看到 computed、watchEffect 和组件渲染,它们只是执行内容与调度策略不同,依赖关系仍从读取中产生。
ref 是盒子,reactive 则更像在对象外面套了一层透明代理:
import { reactive } from 'vue'
const form = reactive({
name: '',
email: '',
agreed: false
})
form.name = '林小满'
form.agreed = truereactive() 返回的是 JavaScript Proxy。读取 form.name 时,代理的 get 拦截可以做依赖追踪;写入 form.name 时,代理的 set 拦截可以触发更新。我们不需要 .value,因为被拦截的入口就是对象属性本身。
一个表单组件可以这样写:
<script setup>
import { reactive } from 'vue'
const form = reactive({
nickname: '',
city: '杭州',
newsletter: true
})
function clearForm() {
form.nickname = ''
form.city = '杭州'
form.newsletter = false
}
</script>
reactive 适合一组关系紧密、经常按属性修改的状态,例如表单、筛选条件或坐标。它支持对象、数组,以及 Map、Set 这样的集合;不能直接包装数字、字符串和布尔值:
const count = reactive(0) // 不成立:0 不是对象数组的索引赋值在 Vue 3 中可以被代理捕获,不必沿用旧教程里“数组下标不会响应”的结论:
const colors = reactive(['红', '绿', '蓝'])
colors[0] = '橙' // 可以触发相关更新
colors.push('紫') // 也可以触发相关更新选择 splice 还是索引赋值,应当由代码表达的业务动作决定。替换一个明确位置可以直接写索引;插入、删除一段元素时,数组方法通常更清楚。
数组之所以值得单独说,是因为它同时涉及“某个索引”“数组长度”和“遍历结果”。模板执行 v-for 时关心的不只是 items[0],还关心当前有哪些项。调用 push 会增加索引,也会改变长度,Vue 会通知依赖这些变化的渲染工作。直接写 items[0] 则主要影响这个位置上的值。你不必手工选择通知谁,代理会根据实际读写操作维护对应联系。
Map 和 Set 也能进入响应式系统:
const selectedIds = reactive(new Set())
const scores = reactive(new Map())
selectedIds.add(3)
selectedIds.delete(3)
scores.set('小林', 92)
scores.set('小周', 88)当模板或 effect 读取 selectedIds.size、执行 selectedIds.has(3),或遍历 scores 时,Vue 会记录相应依赖。后续新增、删除、清空集合可以触发相关更新。集合方法仍然保持原来的 JavaScript 语义,响应式只是让这些操作能够参与通知链。
reactive 的深层转换还会处理嵌套对象:
const rawProfile = {
address: {
city: '苏州'
}
}
const profile = reactive(rawProfile)
console.log(profile === rawProfile) // false
console.log(profile.address === rawProfile.address) // false外层代理和原对象不是同一个身份,访问到的嵌套对象也会以代理形式参与响应式。业务代码应稳定地使用 profile 这条代理路径。不要一会儿拿 rawProfile.address 比较,一会儿又修改 profile.address,这种混用很容易把“对象内容相同”和“对象身份相同”混为一谈。
如果把 ref 放进一个深层 reactive 对象的普通属性里,访问该属性时通常会自动解包:
const level = ref(1)
const player = reactive({ level })
console.log(player.level) // 1
player.level = 2
console.log(level.value) // 2这里 player.level 看起来像普通数字,背后仍与 level 这个 ref 相连。不过数组元素和 Map 的值不会以同样方式自动解包:
const list = reactive([ref('待完成')])
console.log(list[0].value) // 需要 .value初学时没必要刻意把 ref 塞进各种响应式容器。知道这条边界,可以在控制台看到“怎么这里又是 Ref 对象”时迅速解释;日常状态结构仍以清楚、直白为先。

ref 提供统一的 .value 入口,reactive 直接拦截对象属性;两者都服务于同一套依赖系统。这个问题没有“对象永远 reactive、其他永远 ref”那么死。两者都能管理对象:
const userA = ref({ name: '阿青', age: 20 })
userA.value.name = '小青'
userA.value = { name: '新用户', age: 18 }
const userB = reactive({ name: '阿青', age: 20 })
userB.name = '小青'ref 装入对象时,内部对象默认也会被转成深层响应式数据,所以 userA.value.name 的修改可以更新界面。真正影响选择的是你希望如何操作这份状态。
这门课采用一个容易长期坚持的策略:
ref。reactive。ref,它能接收任何值,重构时也较少遇到“不能替换整对象”的限制。例如请求结果通常会整体替换:
const article = ref(null)
async function loadArticle() {
const data = await fetchArticle()
article.value = data
}筛选表单经常原地改多个字段:
const filters = reactive({
keyword: '',
onlyFinished: false,
sortBy: 'updatedAt'
})这不是性能高低的简单比较,也没有必要为了“统一风格”把所有状态强塞进一个巨大 reactive 对象。响应式 API 的选择应该让后续修改更直白,让变量的所有权更清楚。
比起盯着初始值长什么样,更有效的办法是先问“它以后怎么变”。
假设我们有一个分页查询结果。每次请求回来,服务器都会给出一批全新的记录。用 ref 表达整体替换很自然:
const result = ref({
items: [],
total: 0
})
async function search() {
result.value = await fetchResult()
}再看一个拖拽位置。横坐标和纵坐标总是一起描述同一个点,拖动过程会频繁改属性:
const position = reactive({ x: 0, y: 0 })
function move(event) {
position.x = event.clientX
position.y = event.clientY
}两种写法都可以改成另一种 API,但当前形式把业务动作说得更清楚。分页结果是“一批新结果替换上一批”,位置是“同一个点的两个坐标不断变化”。
也不要把整个组件的状态全部塞进一个 state:
const state = reactive({
keyword: '',
loading: false,
dialogOpen: false,
selectedUser: null,
result: []
})这种写法一开始少敲几个 ref,组件变大后却很难看出哪些状态共同变化、哪些会被组合函数接管、哪些可以整体替换。更清楚的拆法可能是:
const keyword = ref('')
const loading = ref(false)
const dialogOpen = ref(false)
const selectedUser = ref(null)
const result = ref([])如果筛选条件确实是一组,可以单独使用一个 reactive(filters)。响应式状态的颗粒度不必越细越好,也不必越集中越好。让一次业务动作需要改到的状态靠近,让拥有不同生命周期的状态分开,代码会更容易移动和测试。
还有一个实用判断:状态要穿过函数边界时,稳定的 ref 很方便。把 loading 传给组合函数,接收方始终能通过 .value 取得最新内容;普通数字只传递当时的值。这个差异会在后面抽取复用逻辑时反复出现。
这是 reactive 最经典、也最容易被误讲的坑。先看问题:
const state = reactive({
count: 0,
title: '今日任务'
})
let { count, title } = state
state.count++
console.log(count) // 仍然是 0执行解构时,JavaScript 读取了一次 state.count,然后把当时的普通数字 0 交给局部变量 count。之后读写 count,已经不再经过 state 的 Proxy,也就没有 get 和 set 拦截。你拿走的是盒子里当时的值,不是还连着原属性的电线。
对象属性本身没有失去响应性,断掉的是这个新的局部变量绑定:
state.count++ // 仍然响应
count++ // 只修改局部普通数字最直接的解决方法是不解构,始终通过对象访问:
<template>
<p>{{ state.title }}:{{ state.count }}</p>
</template>如果确实想解构,例如一个组合函数要返回多个状态,就用 toRefs 把每个现有属性转换成与原属性相连的 ref:
import { reactive, toRefs } from 'vue'
function useCounter() {
const state = reactive({
count: 0,
step: 1
})
function increase() {
state.count += state.step
}
return {
...toRefs(state),
increase
}
}
const { count,
toRefs(state) 返回一个普通对象,但其中每个属性都是 ref。count.value 与 state.count 指向同一条响应式联系:改任意一边,另一边读到的都是新值。
如果只需要一个属性,可以使用 toRef:
import { reactive, toRef } from 'vue'
const settings = reactive({
volume: 40
})
const volume = toRef(settings, 'volume')
volume.value = 60
console.log(settings.volume) // 60toRefs 只会处理调用时已经存在、且可以枚举的属性。某个可选属性可能稍后才出现时,用 toRef(object, key) 更合适。
还有一个细节:如果解构出来的是对象而不是基本类型,局部变量仍可能指向那个响应式嵌套对象,所以修改它的内部属性可以更新。但局部变量本身与源属性的“重新指向”仍然断开。为了让规则容易判断,不要靠这种边缘差异猜测;需要保留属性级联系时,明确使用 toRef 或 toRefs。
解构不是唯一会断开的写法。把响应式对象的基本类型属性直接传给普通函数,也只会传入当前数字或字符串:
const settings = reactive({
pageSize: 20
})
function printLater(value) {
setTimeout(() => {
console.log(value)
}, 1000)
}
printLater(settings.pageSize)
settings.pageSize = 50一秒后打印的仍然是 20。调用 printLater 时已经读取了 settings.pageSize,参数 value 得到的是普通数字。它不知道这个数字曾经来自响应式对象,更不可能自己回到对象上读取新值。
如果函数需要在未来取得最新值,可以传 getter:
function printLatest(getValue) {
setTimeout(() => {
console.log(getValue())
}, 1000)
}
printLatest(() => settings.pageSize)
settings.pageSize = 50这次延时回调执行时才调用 getter,因此会重新读取 settings.pageSize,得到 50。如果接收方本来就按 ref 设计,也可以传 toRef(settings, 'pageSize'):
function printRefLater(valueRef) {
setTimeout(() => {
console.log(valueRef.value)
}, 1000)
}
printRefLater(toRef(settings, 'pageSize'))getter 适合只读地“需要时再取”,ref 适合保留一个明确的响应式值入口,并允许调用方按约定修改。直接传整个 settings 也能保持联系,但函数会获得比实际所需更多的字段。选择哪一种,要看函数是否应当知道整个对象,以及它有没有修改权限。
这也解释了为什么响应式问题经常出现在“抽成工具函数以后”。原组件里写 settings.pageSize 时,每次都经过代理;抽取时若只把当前数字传出去,联系就在函数边界处消失了。调试时沿着参数向上找,通常能看到是哪一行把动态读取变成了静态快照。

toRefs 创建的 ref 仍与源属性双向连接。不要写出“reactive 解构后仍然响应”这样的结论。判断是否还响应,要看后续读写有没有继续经过原代理,或有没有通过 toRef、toRefs 保留连接。
reactive 返回的是原对象的代理。响应式联系建立在这个代理及其属性上:
const raw = { count: 0 }
const state = reactive(raw)
console.log(state === raw) // false只有 state 这个代理能拦截属性读写。直接修改原对象不会经过代理:
raw.count++ // 不要这样改,相关界面不会收到代理的触发通知
state.count++ // 通过代理修改整体替换还会产生另一种断线:
let state = reactive({ count: 0 })
state = reactive({ count: 10 })新的 state 指向另一个代理,但先前已经订阅旧代理属性的使用者不会凭空迁移到新代理。局部变量看起来还是叫 state,响应式系统看到的目标已经换了。
如果业务动作本来就是“用服务器返回的新对象替换旧对象”,使用 ref 最自然:
const state = ref({ count: 0 })
state.value = { count: 10 }此时稳定不变的是 ref 盒子,订阅发生在 .value 上;把盒子里的对象换掉,仍然会从同一个 setter 发出通知。
如果你确定要保留 reactive 代理,也可以修改现有属性:
const state = reactive({
count: 0,
status: 'idle'
})
Object.assign(state, {
count: 10,
status: 'done'
})不过 Object.assign 只覆盖传入的键,不会自动删除旧对象多出来的键。需要完全替换对象结构时,ref 通常更准确,也更不容易留下过期字段。
Vue 常用的 ref 和 reactive 默认是深层的。嵌套对象和数组继续具有响应性:
const project = ref({
title: 'Vue 入门',
progress: {
finished: 2,
total: 8
},
tags: ['前端']
})
project.value.progress.finished++
project.value.tags.push('响应式')这两次深层修改都能被追踪。reactive 也是如此:
const board = reactive({
columns: [
{ name: '待处理', cards: [] }
]
})
board.columns[0].cards.push({ id: 1, title: '完成练习' })“深层”不代表 Vue 在创建状态的一瞬间粗暴地遍历世界上所有后代。你真正需要形成的认知是:当嵌套对象通过深层响应式数据被访问时,Vue 会让后续的嵌套属性读写也经过响应式代理。
有时我们只想关心最外层指向是否被替换。比如接入一个自己管理内部状态的编辑器对象,或保存一个很大的不可变数据结构。这时可以使用 shallowRef:
import { shallowRef } from 'vue'
const snapshot = shallowRef({
rows: [{ id: 1, value: 'A' }]
})
snapshot.value.rows[0].value = 'B'
// 只改内部属性,不会因为 shallowRef 自动触发依赖
snapshot.value = {
rows: [{ id: 1, value: 'B' }]
}
// 替换 .value,会触发依赖shallowReactive 则只让根属性响应:
const state = shallowReactive({
page: 1,
options: {
pageSize: 20
}
})
state.page++ // 根属性,响应
state.options.pageSize = 50 // 内层普通对象,不由 shallowReactive 追踪浅层 API 不是“更高级所以应该尽早用”,也不是日常性能优化开关。它们会让同一棵状态树出现不同响应深度,使用者必须清楚哪些修改靠替换、哪些修改能原地触发。普通组件先用默认深层响应式,只有数据由谁管理、更新采用什么规则都很明确时,再选择浅层 API。
shallowRef 最容易理解的使用方式是把内部数据当成只读快照。每次修改都创建一个新对象,再替换 .value:
const preferences = shallowRef({
theme: 'light',
fontSize: 16
})
function increaseFont() {
preferences.value = {
...preferences.value,
fontSize: preferences.value.fontSize + 1
}
}这里没有直接改 preferences.value.fontSize。旧快照保持不动,新对象进入 .value,根入口的 setter 能明确发出通知。这种写法也让“变化前”和“变化后”成为两个不同对象,便于做撤销、时间旅行或历史记录。
相反,下面这段代码虽然在 JavaScript 对象层面修改成功,却不会依靠 shallowRef 的深层追踪触发界面:
preferences.value.fontSize++控制台能读到新数字,不代表相关 effect 收到通知。这个现象特别容易被误判为 Vue 丢数据:数据其实变了,缺少的是触发根入口的动作。
默认深层 ref 与浅层 shallowRef 可以用一句话区分:深层 ref 允许你沿着对象路径原地修改,嵌套代理负责通知;浅层 ref 主要观察 .value 自身是否被替换,内部对象交给你自己的更新约定。
shallowReactive 更需要谨慎。它的根属性响应,嵌套对象不响应,状态树里会形成一条可见的深度边界:
const panel = shallowReactive({
open: false,
layout: {
width: 320
}
})
panel.open = true // 会从根属性触发
panel.layout.width = 400 // 不会由 shallowReactive 深入追踪如果另一个开发者只看到 panel 是响应式对象,很可能自然地认为 layout.width 也能原地更新界面。因此浅层对象最好停留在状态树根部,变量命名和模块边界也要让更新规则清楚,不要把它嵌进另一个深层 reactive 后制造忽深忽浅的结构。
什么时候值得考虑浅层?一个外部库已经用自己的代理管理大型对象;数据量很大而项目明确采用不可变更新;某个第三方实例根本不应该被 Vue 深层代理。什么时候不值得?普通表单、十几项的列表、刚开始学习时担心“Proxy 会不会很慢”。先让状态行为正确、统一,再对真实测到的问题做选择。

响应式状态的赋值是同步的,DOM 更新通常不是同步完成的。看这个例子:
<script setup>
import { ref } from 'vue'
const count = ref(0)
const output = ref(null)
function increase() {
count.value++
console.log(count.value) // 1,新状态已经可读
console.log(output.value.textContent) // 可能仍是“0”
}
</script>
为什么要等?一次用户操作里,代码可能连续修改许多状态:
count.value++
count.value++
count.value++
status.value = 'done'如果每写一次就立即重新渲染,组件会在同一轮代码尚未结束时反复工作。Vue 会把组件更新缓冲到队列,在当前同步代码完成后统一处理。无论同一轮里改了多少次,只要是同一个组件,通常只需要按最终状态更新一次。
这叫更新批处理。它带来两个结论:
确实需要等待 DOM 跟上时,使用 nextTick:
<script setup>
import { nextTick, ref } from 'vue'
const count = ref(0)
const output = ref(null)
async function increaseAndMeasure() {
count.value++
await nextTick()
console.log(output.value.textContent) // 已更新为“1”
console.log(output.value.getBoundingClientRect
nextTick 的意思不是“固定等几毫秒”,而是等待 Vue 把当前这轮排队的 DOM 更新处理完。适合它的场景包括:新增列表项后滚动到末尾、展开面板后测量高度、切换输入框后让它获得焦点。
如果只是继续计算业务数据,不要为了“保险”到处 await nextTick()。响应式数据已经同步更新,只有下一步明确依赖更新后的 DOM 时才需要等待。
下面这段事件处理函数可以帮助我们区分“状态时间”和“页面时间”:
async function submit() {
status.value = 'saving'
message.value = '正在保存'
console.log('甲', status.value)
const request = saveForm()
console.log('乙', messageElement.value.textContent)
await nextTick()
console.log('丙', messageElement.value.textContent)
await request
status.value = 'done'
“甲”一定能读取到新的状态 saving,因为 ref 的 setter 已经同步保存新值。“乙”发生在当前同步调用栈里,真实 DOM 可能仍显示上一次文字。“丙”位于 nextTick 之后,能看到“正在保存”。随后等待网络请求是另一段异步过程;请求完成又修改状态时,会开启新一轮 Vue 更新。
nextTick 不会等待 saveForm(),也不会替你判断请求成功。它只关心在调用它之前已经排队的 Vue DOM 工作。把这几个异步边界混在一起,是“为什么 nextTick 后数据还没回来”的常见根源。
再看一个展开面板的例子:
<script setup>
import { nextTick, ref } from 'vue'
const open = ref(false)
const panel = ref(null)
async function openPanel() {
open.value = true
await nextTick()
panel.value.focus()
}
</script>
修改 open 后,脚本状态已经是 true,但由 v-if 控制的 <section> 还要等渲染后才真实存在。如果不等待,panel.value 仍可能是 null。等一轮更新完成,再聚焦就是可靠的顺序。
如果面板一直存在,只用 CSS 隐藏,模板 ref 可能早已可用,但布局尺寸仍会受显示状态影响。想读取展开后的高度,同样应该等待更新完成。是否需要 nextTick 不由 API 名称决定,而由下一步要访问的东西是否必须等 DOM 变化决定。
批处理也不等于所有组件永远只渲染一次。它的范围是当前更新周期:同一轮同步代码里的重复任务会去重;如果你先等待一个 Promise,再继续修改状态,那已经可能进入后续轮次。写代码时不必计算每一轮的微任务细节,只需避免依赖“赋值后 DOM 当场变化”,并在确实需要时明确等待。
有时页面结果不对,开发者会连续加几个 nextTick:
await nextTick()
await nextTick()如果一次等待后仍不对,先检查状态是否真的驱动了目标模板、条件分支是否允许元素出现、模板 ref 是否放在正确元素上。多等一轮可能偶然掩盖竞态,却不会接回已经断掉的响应式连接。
同样,不要用 setTimeout(fn, 0) 猜 Vue 什么时候更新。定时器等待的是浏览器任务队列,语义比“等待 Vue 当前更新完成”宽得多,也更容易受其他任务影响。需要 Vue DOM 时机就用 nextTick;需要动画下一帧可能用 requestAnimationFrame;需要延迟业务动作才用定时器。把工具和它真正等待的事件对应起来,代码才容易解释。

nextTick 位于更新完成之后。现在把这些知识放进一个单文件组件。这个清单会添加任务、切换完成状态、删除任务,并在新增后滚动到最后一项,再把输入框重新聚焦。状态选择也有意体现本课的判断:输入文字和任务数组都可能整体替换,使用 ref;滚动目标必须等新列表项进入 DOM,使用 nextTick。
<script setup>
import { computed, nextTick, ref } from 'vue'
let nextId = 3
const newTitle = ref('')
const titleInput = ref(null)
const taskList = ref(null)
const tasks = ref([
{ id: 1, title: '理解 ref 的盒子'
初始界面会显示两项任务,其中一项带删除线,摘要是“已完成 1 项,剩余 1 项”。输入“完成课后练习”并提交后,列表变成三项,剩余数量自动变成 2,输入框内容被清空并重新获得焦点。勾选新任务,完成数与剩余数再次联动变化。
这里没有任何一段代码直接修改摘要文字。computed 在计算时读取 tasks,模板又读取计算结果。任务变化后,依赖链让派生数据与界面一起更新。删除任务时,我们选择整体替换数组:
tasks.value = tasks.value.filter(task => task.id !== id)如果 tasks 是 reactive([]),就不能用重新赋值替换代理,而要在原数组上修改。这个差异也说明为什么“可能整体替换的数据优先用 ref”能让实际代码更顺手。

你可能会想到另一种写法:再创建 finishedCount 和 remainingCount 两个 ref,每次添加、勾选、删除时手动修改它们。这样做会重新制造开头提到的“两份状态”问题。
假设删除的是一条已经完成的任务,我们既要删数组,又要给完成数量减一;删除的是未完成任务,则要改剩余数量。只要某个分支漏掉一次,摘要就与列表不一致。完成数量本来可以从 tasks 计算出来,它就不应成为需要人工维护的第二份原始状态。
当前组件只保存用户真正能直接改变的事实:输入框文字和任务数组。finishedCount 与 remainingCount 是派生结果。任务变了,计算结果自然失效并重新求值;模板再根据新结果显示摘要。这条链比“每个事件里同时维护三四个数字”可靠得多。
添加任务时发生三次状态动作。tasks.value.push(...) 修改深层响应式数组,newTitle.value = '' 清空输入状态,随后 nextTick 等列表项与输入框文字一起更新。虽然代码先后执行多次 setter,组件更新会在同一轮合并。
勾选任务时,v-model 修改的是某个任务对象的 done 属性。任务对象位于 ref 包装的深层响应式数组中,所以嵌套写入也能触发。finishedCount 依赖每个任务的完成状态,模板依赖计算结果与列表类名,于是摘要和删除线一起变化。
删除任务采用整体替换:
tasks.value = tasks.value.filter(task => task.id !== id)filter 返回一个新数组,赋值经过 ref 的 .value setter。这里不用担心旧数组仍被模板订阅,因为稳定的订阅入口是 tasks.value。下一次渲染会读取新数组。
当最后一项被删除,tasks.length 变成 0。模板条件从列表分支切到空状态分支,旧 <ul> 被移除,空提示出现。响应式系统负责通知“需要重新执行”,模板编译出的渲染逻辑负责判断应该生成哪一支结构,这两部分各司其职。
列表上的 :key="task.id" 不负责让数组响应。即使没有 key,数组变化仍会触发渲染。key 的工作是帮助 Vue 在新旧列表之间辨认每个任务的稳定身份:哪个任务留下、哪个被删除、哪个移动。响应式回答“什么时候需要更新”,key 帮助列表更新阶段回答“这些节点分别是谁”。
这一区分很实用。页面完全不更新,先查状态入口和依赖;页面更新了,但输入状态错位或某一行复用了错误节点,再查 key 是否稳定。不要把两类问题都归结为“Vue 没有响应”。
把 addTask 暂时改成连续添加三项:
tasks.value.push({ id: nextId++, title: '任务甲', done: false })
tasks.value.push({ id: nextId++, title: '任务乙', done: false })
tasks.value.push({ id: nextId++, title: '任务丙', done: false })然后在开发调试钩子里观察触发事件,再看页面是否直接出现最终三项。你会发现状态数组在同步代码结束前已经包含新任务,而 DOM 统一追到最终结果。接着把 await nextTick() 前后的 taskList.value.children.length 打印出来,就能亲眼看到“状态先变、DOM 稍后跟上”的时间差。
以勾选第二项任务为例,我们可以完整走一遍:
模板首次渲染列表时读取 tasks,计算属性统计数量时也读取 tasks,相关响应式依赖在读取过程中被记录。
用户勾选复选框,v-model 把对应任务的 done 属性写成 true。这个嵌套对象处在深层响应式数组中,写入会触发相关依赖。
完成数量的计算结果被标记为需要重新求值,组件渲染也被安排进更新队列。同一轮里即使还有别的状态变化,组件更新会被合并调度。
Vue 重新执行需要的渲染逻辑,得到新列表样式和新摘要,再把实际发生变化的 DOM 部分更新出来。
“我明明改了数据,页面为什么没动?”不要反复刷新碰运气。按读取、连接、写入、时机四个方向检查,通常很快就能定位。
在修改前后打印值和响应式身份:
import { isReactive, isRef } from 'vue'
console.log(isRef(count), count.value)
console.log(isReactive(form), form.name)如果 isRef(count) 是 true,脚本里就应该修改 count.value。如果打印的是另一个同名局部变量,或者你改的是 reactive 的原始对象,界面自然收不到预期通知。
看到下面的代码就要警觉:
const { count } = reactiveState如果 count 是基本类型,这里已经成为普通值。改成 reactiveState.count,或使用 toRef、toRefs。
count.value++
console.log(count.value) // 新值
console.log(element.value.textContent) // 可能是旧 DOM前者正确、后者暂时不对,问题通常是更新时机,不是响应性失效。确实需要读新 DOM 时再 await nextTick()。
Vue 提供了组件级调试钩子,可以看到渲染追踪了什么、又是什么操作触发了更新:
<script setup>
import { onRenderTracked, onRenderTriggered } from 'vue'
onRenderTracked((event) => {
console.log('渲染读取了:', event.key, event.type)
})
onRenderTriggered((event) => {
console.log('这次修改触发了更新:', event.key, event.type)
})
</script>它们只用于开发环境,不应该拿来编写业务逻辑。事件里的 key 能帮助你确认哪个属性被读取或修改,type 则可能表示读取、设置、新增、删除或遍历等操作。若日志太多,可以先缩小组件,只保留可疑状态和最小模板。
调试的核心仍然是那条线:模板或 effect 有没有读到响应式入口,后续修改有没有从同一个入口经过,更新后的 DOM 是否已经到达正确时机。
假设用户资料卡显示“等级 1”,点击升级后控制台打印“等级 2”,页面却没有变化:
const rawUser = {
profile: {
level: 1
}
}
const user = reactive(rawUser)
function upgrade() {
rawUser.profile.level++
console.log(rawUser.profile.level)
}第一步确认模板读谁。如果模板写 {{ user.profile.level }},渲染读取经过代理,依赖建立在代理的嵌套属性上。
第二步确认事件改谁。upgrade 修改的是 rawUser.profile.level,它绕过了 user 代理。数字确实变成 2,所以日志看似正确;但这次写入没有从 Proxy 的 setter 经过,相关渲染没有收到触发通知。
修复并不需要加监听器或 nextTick,只需从同一条代理路径修改:
function upgrade() {
user.profile.level++
}如果改完后状态是 2,页面紧接着读取仍是 1,再判断读取发生在 DOM 更新前还是后。此时才轮到 nextTick。这套顺序能避免把“入口断线”和“时机未到”混在一起。
再看另一类故障:界面能更新一次,后来不再变化。
const state = reactive({
count: 0
})
let current = state
function replace() {
current = { count: 10 }
}模板如果最初读取的是 state.count,current 后来指向普通对象与它没有关系。变量名变化、对象内容变化都不会自动迁移旧依赖。调试时打印 isReactive(current),就能看到它已经不是响应式代理。业务确实需要整体替换时,应让稳定入口成为 ref。
最后是“某个字段更新,另一个字段不更新”:
const form = reactive({
name: '小林',
age: 18
})
const { name } = form模板若使用 form.age 与局部 name,年龄仍经过代理,姓名却是快照。部分正常、部分失效并不说明响应式随机工作,而是两段表达式走了不同入口。把模板字段逐个对应到脚本声明,通常能找到解构发生的位置。
可以把排查顺序压缩成五问:
ref、reactive 或计算属性提供吗?nextTick?按这个次序记录证据,比在代码里盲目加 console.log 更有效。每条日志都应回答一个问题:我读的对象身份是什么、值在何时改变、触发动作来自哪个属性。证据一旦连成线,问题通常会落到某个具体入口,而不是“Vue 偶尔失灵”。
let count = 0
function increase() {
count++
}如果 count 要驱动模板,改为 const count = ref(0),并在脚本中写 count.value++。
const count = ref(0)
count = 1 // 错误:既丢掉盒子,又试图给 const 重新赋值正确写法:
count.value = 1const user = reactive({ age: 18 })
const { age } = userage 是当时的普通数字。保留 user.age,或写 const { age } = toRefs(user)。
let user = reactive({ name: '小林' })
user = { name: '小周' }第二行让变量离开原代理,新对象也是普通对象。需要整体替换时,把它改成 ref:
const user = ref({ name: '小林' })
user.value = { name: '小周' }const raw = { count: 0 }
const state = reactive(raw)
raw.count++始终通过 state.count 读写。除非你正在处理明确的底层集成,否则不要在业务代码里长期保留并混用 raw 和 proxy。
await nextTick()
// 这里不是“等了固定时间”,而是当前 Vue DOM 更新已经完成如果下一步与 DOM 无关,就不需要它。请求完成、动画延时、定时轮询各有自己的异步机制,不能由 nextTick 代替。
shallowRef 内部属性改变不自动触发,是它的设计,不是 bug。团队没有明确采用不可变更新时,默认的 ref 与 reactive 更符合直觉。
遇到响应式问题时,把“Vue 怎么没感应到”换成三个具体问题:读取经过哪个入口,写入经过哪个入口,订阅者何时执行。所谓玄学 bug,往往只是其中一条线断了。
下面的按钮点击后,模板中的数字没有按预期更新。找出问题并修复:
<script setup>
let count = 0
function increase() {
count++
}
</script>
<template>
<button @click="increase">{{ count }}</button>
</template>阅读代码,写出两次输出,并说明为什么:
const state = reactive({ count: 0 })
const { count } = state
state.count++
console.log(state.count)
console.log(count)为下面三份状态选择 ref 或 reactive,并给出理由:
补全代码,让控制台先看到新的响应式值,再在 DOM 更新完成后看到新的页面文字:
<script setup>
import { nextTick, ref } from 'vue'
const count = ref(0)
const output = ref(null)
async function run() {
count.value++
count.value++
console.log('状态:', ______)
await ______
console.log(
学到这里,可以把本章分散的读取、登记、写入和调度补全成一条准确链路:
用 ref 或 reactive 创建可拦截的状态
↓
组件渲染或其他 effect 读取状态
↓
track 记录“属性—effect”的依赖关系
↓
业务代码从同一个响应式入口写入
↓
trigger 找到相关 effect,并交给调度器
↓
同一轮更新被批处理,Vue 更新必要的 DOMref 的 .value 不是多余仪式,它给普通值提供了可拦截入口。reactive 的 Proxy 也不是对象换了一个花哨名字,它让属性读取和写入可以被追踪。解构、替换对象、混用原对象之所以容易失效,都是因为后续操作绕开了原本建立联系的入口。nextTick 则回答了另一个问题:通知已经发出后,真实 DOM 在什么时候追上新状态。
到这里,响应式系统已经回答了“数据变化后,谁需要重新执行”。可通知一旦抵达渲染阶段,一个更具体的问题就出现了:当条件让节点出现或消失、列表让节点增删或换序时,一条业务记录与一个 DOM 节点怎样继续正确对应? 条件渲染与列表渲染,正是沿着这道结构更新的难题展开。