本文是「Vue 页面行为拆解」系列的第 1 篇。
下面使用 Vue 3 + Composition API 的简化表单场景说明问题。
设想一个用户编辑页面。接口数据填入表单后,用户点击重置 ,页面应该恢复为空白状态。开发者重新创建了一份 reactive 对象,控制台没有报错,输入框却仍然显示原来的姓名和部门。
下面是表单组件中的关键代码。读者需要观察重置函数修改的是表单属性,还是保存表单对象的局部变量。
vue
<script setup>
import { reactive } from 'vue'
let form = reactive({
name: 'Alice',
department: '前端'
})
function resetForm() {
form = reactive({
name: '',
department: ''
})
}
</script>
<template>
<input v-model="form.name">
<input v-model="form.department">
<button @click="resetForm">重置</button>
</template>
页面初始显示:
text
姓名:Alice
部门:前端
点击重置后,两个输入框可能仍然保持原值。代码确实创建了一个新的响应式对象,但这次赋值没有修改模板上一次渲染时依赖的那组属性,也没有单独产生一次可追踪的状态更新。
一、首次渲染时,模板读取的是哪一个对象
reactive 接收一个对象,并返回这个对象的响应式代理。组件模板读取代理上的属性时,Vue 会记录本次渲染使用了哪些状态,这些属性后续发生变化时,组件才有依据安排更新。
下面把初始对象单独保存下来,观察模板使用的值来自哪里。
vue
<script setup>
import { reactive, isReactive } from 'vue'
const initialData = {
name: 'Alice',
department: '前端'
}
const form = reactive(initialData)
console.log(isReactive(form))
console.log(form === initialData)
</script>
<template>
<p>{{ form.name }} / {{ form.department }}</p>
</template>
控制台输出:
text
true
false
form 是响应式对象,并且与传入的原始对象不是同一个引用。模板渲染时读取的是响应式对象上的 name 和 department。
可以把第一次渲染简化为:
text
模板读取 form.name
→ Vue 记录当前渲染依赖这个属性
→ 模板读取 form.department
→ Vue 记录第二个属性
→ 页面显示 Alice / 前端
后续执行:
ts
form.name = 'Bob'
写入发生在同一个响应式对象上,Vue 能够把这次变化与之前记录的读取关系对应起来,组件随后重新渲染。
当前问题中的重置函数没有修改原对象上的属性,它把局部变量指向了另一个对象。两种操作看起来都能让 form.name 变为空字符串,进入响应式更新流程的方式却不同。
二、重新赋值改变了局部变量,却没有触发原对象更新
回到最初的重置函数:
ts
function resetForm() {
form = reactive({
name: '',
department: ''
})
}
这段代码完成了两件事:
text
创建一个新的响应式对象
→ 让局部变量 form 指向新对象
它没有修改旧对象的 name 和 department。
模板上一次渲染时读取的是旧对象的属性。局部变量重新赋值属于普通 JavaScript 变量赋值,变量本身并不是一个 ref,所以这一步不会单独通知组件重新渲染。
可以用两个对象表示这次变化:
text
点击前:
form ──→ 响应式对象 A
name: Alice
department: 前端
点击后:
form ──→ 响应式对象 B
name: ''
department: ''
模板已记录的依赖仍来自对象 A
这次问题同时出现了旧响应式对象、新响应式对象和模板依赖,只看赋值代码不容易确认页面仍在依赖哪一层。下面把重置前后的引用关系放在同一张图中。

这也解释了一个看起来不太稳定的现象:点击重置后页面没有变化,之后修改了组件中的其他响应式状态,页面又突然显示了新表单。
其他状态变化可能安排了一次新的渲染。重新渲染时,模板再次读取局部变量,此时它已经指向对象 B,于是页面显示空值,并开始记录对象 B 上的属性。
问题并没有自行消失。重置操作仍然依赖另一项状态碰巧触发渲染,页面行为因此难以预测。
同时 Vue 官方文档也明确说明,reactive 的响应式跟踪依赖属性访问,替换整个对象会丢失与原引用建立的响应式连接。
三、给原对象的属性赋值为什么能够更新页面
表单字段数量较少时,可以直接修改原响应式对象:
vue
<script setup>
import { reactive } from 'vue'
const form = reactive({
name: 'Alice',
department: '前端'
})
function resetForm() {
form.name = ''
form.department = ''
}
</script>
<template>
<input v-model="form.name">
<input v-model="form.department">
<button @click="resetForm">重置</button>
</template>
点击按钮后,页面结果为:
text
姓名输入框:空
部门输入框:空
这里的 form 始终指向同一个响应式对象。重置函数修改了模板读取过的两个属性,所以组件能够根据这些变化更新输入框。
执行顺序是:
text
用户点击重置
→ 修改 form.name
→ 修改 form.department
→ Vue 安排组件更新
→ 输入框显示空值
这套写法适合字段少、默认值稳定的表单。字段逐渐增加后,手动为每个属性赋值容易漏掉新增字段,因此通常还需要统一创建默认数据。
四、Object.assign 怎样在保留对象引用的同时重置字段
可以使用一个函数生成默认表单,再把默认值写入原响应式对象。
下面这段代码仍位于表单组件中。重点观察 form 没有被替换,变化发生在它的属性上。
vue
<script setup>
import { reactive } from 'vue'
function createDefaultForm() {
return {
name: '',
department: '',
enabled: true
}
}
const form = reactive(createDefaultForm())
function fillExample() {
Object.assign(form, {
name: 'Alice',
department: '前端',
enabled: false
})
}
function resetForm() {
Object.assign(form, createDefaultForm())
}
</script>
<template>
<input v-model="form.name">
<button @click="fillExample">填充</button>
<button @click="resetForm">重置</button>
</template>
点击填充按钮后,姓名变为 Alice,再点击重置按钮,姓名恢复为空字符串,enabled 恢复为 true。
Object.assign 会把默认对象中的属性逐项写入现有的响应式对象。每个写入仍然经过原来的响应式代理,因此模板依赖可以继续使用。
它解决了两个问题:
- 表单对象的引用保持不变;
- 默认值集中在一个函数中,新增字段时有统一修改位置。
这项处理仍有边界。Object.assign 只处理来源对象中存在的第一层属性,不会自动删除表单中额外出现的字段。
例如下面的表单在运行过程中增加了 temporaryNote,默认对象中没有这个字段:
vue
<script setup>
import { reactive } from 'vue'
const form = reactive({
name: 'Alice',
temporaryNote: '仅本次编辑使用'
})
function resetForm() {
Object.assign(form, {
name: ''
})
}
resetForm()
console.log(form)
</script>
控制台结果中仍然包含:
text
{
name: '',
temporaryNote: '仅本次编辑使用'
}
如果表单结构允许动态添加字段,重置逻辑还需要明确哪些字段应该删除,不能把 Object.assign 当作让对象与默认对象完全一致的操作。
五、嵌套对象重置时还要考虑哪些引用
假设表单中包含地址和标签:
vue
<script setup>
import { reactive } from 'vue'
function createDefaultForm() {
return {
name: '',
address: {
city: '',
detail: ''
},
tags: []
}
}
const form = reactive(createDefaultForm())
function resetForm() {
Object.assign(form, createDefaultForm())
}
</script>
<template>
<input v-model="form.address.city">
<span>标签数:{{ form.tags.length }}</span>
<button @click="resetForm">重置</button>
</template>
点击重置后,地址和标签会恢复为默认值。这里发生的是第一层属性写入:
text
form.address → 换成新的地址对象
form.tags → 换成新的数组
Vue 会让这些新对象继续参与响应式处理,当前模板可以跟着更新。
但 Object.assign 没有保留旧的嵌套对象引用。假设其他代码提前保存了:
ts
const currentAddress = form.address
重置后,currentAddress 仍然指向旧地址对象,form.address 已经指向新对象。继续修改旧引用,不会改变当前表单中的地址。
如果子组件始终通过 props 读取最新的 form.address,父组件更新后可以获得新对象。若子组件在初始化时又把地址复制成本地状态,则需要单独处理组件属性和本地状态的同步,这属于另一个数据流问题。
当业务要求保留嵌套对象引用时,可以分别修改嵌套属性:
vue
<script setup>
import { reactive } from 'vue'
const form = reactive({
address: {
city: '广州',
detail: '天河区'
},
tags: ['重点用户']
})
function resetForm() {
Object.assign(form.address, {
city: '',
detail: ''
})
form.tags.splice(0)
}
</script>
<template>
<p>{{ form.address.city }}</p>
<p>{{ form.tags.length }}</p>
<button @click="resetForm">重置</button>
</template>
点击重置后,城市为空,标签数量变为 0,同时地址对象和标签数组的原引用仍然保留。
代价是重置代码需要了解嵌套结构。表单层级较深时,逐层维护会增加修改成本,应结合组件是否真的共享这些引用再决定。
六、需要替换整个对象时,为什么 ref 更合适
有些页面把接口响应整体作为编辑草稿。切换用户或恢复快照时,代码希望直接替换整份数据,而不是逐个修改属性。
这类场景可以使用 ref。ref 保存一个可替换的响应式值,脚本中通过 .value 读取和赋值,模板会自动读取其中的值。
下面观察根对象替换后的页面结果:
vue
<script setup>
import { ref } from 'vue'
function createDefaultForm() {
return {
name: '',
department: ''
}
}
const form = ref({
name: 'Alice',
department: '前端'
})
function resetForm() {
form.value = createDefaultForm()
}
</script>
<template>
<input v-model="form.name">
<input v-model="form.department">
<button @click="resetForm">重置</button>
</template>
点击重置后,两个输入框恢复为空。
这次赋值修改的是:
ts
form.value
.value 本身是响应式状态。替换整个对象会触发依赖更新,新对象也会继续参与响应式处理。
reactive 和 ref 在当前场景中的区别可以整理为:
| 方案 | 更适合的更新方式 | 当前场景中的代价 |
|---|---|---|
reactive |
保持根对象不变,修改其中属性 | 整体重置需要写入原对象 |
ref |
允许替换整个根值 | 脚本中访问和赋值需要 .value |
| 嵌套属性逐项重置 | 保留嵌套对象与数组引用 | 重置代码需要了解具体结构 |
选择依据应该来自数据更新方式。如果表单从创建到销毁都围绕同一个对象修改,reactive 比较自然;如果业务频繁替换整份草稿,ref 能让更新动作更直接。
七、nextTick 为什么不能补上这次响应式更新
看到页面没有变化后,有时会在重置函数中加入 nextTick:
vue
<script setup>
import {
reactive,
nextTick
} from 'vue'
let form = reactive({
name: 'Alice'
})
async function resetForm() {
form = reactive({
name: ''
})
await nextTick()
console.log('DOM 更新等待结束')
}
</script>
<template>
<input v-model="form.name">
<button @click="resetForm">重置</button>
</template>
点击按钮后,控制台会打印:
text
DOM 更新等待结束
但输入框仍可能显示 Alice。
nextTick 用于等待当前已经安排的 DOM 更新完成。这里重新给普通局部变量赋值,没有因此安排组件更新,所以等待一次更新周期不会自动建立新的响应式关系。
它适合下面这种流程:
text
修改响应式状态
→ Vue 安排 DOM 更新
→ nextTick 等待该次更新完成
→ 读取更新后的 DOM
当前问题卡在第一步。状态修改没有进入原来依赖的响应式对象,后面增加等待无法补回这次触发。
为了让页面刷新而额外增加一个 watch,也会让数据流绕得更远。重置操作已经明确知道何时发生,优先在这个操作中更新正确的状态来源,会比再建立一层监听更容易维护。
八、表单重置时可以按什么顺序排查
遇到数据看起来已经变了,页面却没有更新这类情况的时候,可以先把表单的状态流写清楚:
- 默认数据从哪里创建
- 接口结果写入哪个对象
- 模板读取哪个对象
- 重置函数修改哪个对象
- 是否替换了根引用
第一步,检查表单最初由 reactive 还是 ref 创建。
ts
const form = reactive(defaultForm)
通常预期后续修改属性。
ts
const form = ref(defaultForm)
可以通过修改 form.value 替换整份数据。
第二步,检查重置函数是否重新赋值了保存 reactive 对象的局部变量:
ts
let form = reactive(defaultForm)
form = reactive(nextForm)
如果有这种写法,继续确认它是否依赖其他状态触发重新渲染。
第三步,检查默认对象是否每次重新创建。直接复用同一个可变默认对象,可能让上一次编辑污染下一次重置结果。
更稳妥的形式是:
ts
function createDefaultForm() {
return {
name: '',
department: ''
}
}
每次调用都得到新的默认数据。
第四步,检查表单是否包含动态字段、嵌套引用或子组件本地副本。根对象重置正确,不代表这些额外状态会自动按业务要求清理。
第五步,再检查校验组件、上传组件或第三方表单库是否维护了独立状态。清空数据字段可能不会同时清除校验提示、文件队列和内部缓存,需要调用对应组件提供的重置能力。
九、当前处理方式还存在哪些边界
本文只讨论 Vue 3 Composition API 中的响应式对象重置,没有把第三方表单组件的行为算进 Vue 响应式规则。
如果项目使用表单库,它可能在响应式数据之外保存校验结果、初始快照或字段注册信息。修改 form 后页面值已经更新,错误提示仍然存在,需要继续检查表单实例的重置接口。
接口数据也可能缺少部分字段。使用 Object.assign(form, response) 时,响应中不存在的字段会保留旧值。编辑不同用户时,这可能让前一个用户的数据残留在表单中。比较稳妥的顺序通常是先恢复默认结构,再合并当前接口数据:
vue
<script setup>
import { reactive } from 'vue'
function createDefaultForm() {
return {
name: '',
department: '',
remark: ''
}
}
const form = reactive(createDefaultForm())
function fillForm(response) {
Object.assign(
form,
createDefaultForm(),
response
)
}
fillForm({
name: 'Alice',
department: '前端'
})
console.log(form)
</script>
控制台结果为:
text
{
name: 'Alice',
department: '前端',
remark: ''
}
默认值先覆盖旧状态,接口数据再覆盖对应字段。这个顺序能处理当前固定字段表单,但仍然不会删除默认结构之外的动态属性。
较大的表单还要考虑性能、校验时机和组件拆分。是否使用 reactive、ref 或多个独立状态,应根据表单更新方式和组件边界选择,不能只凭字段数量决定。
十、小结
这次页面问题出现在表单重置阶段。模板最初读取了一个 reactive 对象上的属性,重置函数却让普通局部变量指向了另一份响应式对象。旧对象的属性没有变化,局部变量赋值也没有单独安排组件更新,所以输入框仍可能显示旧值。
表单需要保持同一个根对象时,可以直接修改属性,或者使用 Object.assign 把默认值写回原响应式对象。业务需要频繁替换整份数据时,可以使用 ref,通过修改 .value 让根对象替换进入响应式更新流程。
这些方案不会自动清理动态字段、嵌套引用、校验状态和第三方组件内部数据。下次遇到页面没有跟着表单变化,可以先确认模板读取的是哪一份状态、重置函数修改的是属性还是根引用,再考虑 DOM 更新时机。下一篇会继续分析响应式对象解构后,为什么局部变量不会跟随原属性变化。