reactive 对象重新赋值后,表单为什么没有恢复默认值

本文是「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 是响应式对象,并且与传入的原始对象不是同一个引用。模板渲染时读取的是响应式对象上的 namedepartment

可以把第一次渲染简化为:

text 复制代码
模板读取 form.name
→ Vue 记录当前渲染依赖这个属性
→ 模板读取 form.department
→ Vue 记录第二个属性
→ 页面显示 Alice / 前端

后续执行:

ts 复制代码
form.name = 'Bob'

写入发生在同一个响应式对象上,Vue 能够把这次变化与之前记录的读取关系对应起来,组件随后重新渲染。

当前问题中的重置函数没有修改原对象上的属性,它把局部变量指向了另一个对象。两种操作看起来都能让 form.name 变为空字符串,进入响应式更新流程的方式却不同。

二、重新赋值改变了局部变量,却没有触发原对象更新

回到最初的重置函数:

ts 复制代码
function resetForm() {
  form = reactive({
    name: '',
    department: ''
  })
}

这段代码完成了两件事:

text 复制代码
创建一个新的响应式对象
→ 让局部变量 form 指向新对象

它没有修改旧对象的 namedepartment

模板上一次渲染时读取的是旧对象的属性。局部变量重新赋值属于普通 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 更合适

有些页面把接口响应整体作为编辑草稿。切换用户或恢复快照时,代码希望直接替换整份数据,而不是逐个修改属性。

这类场景可以使用 refref 保存一个可替换的响应式值,脚本中通过 .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 本身是响应式状态。替换整个对象会触发依赖更新,新对象也会继续参与响应式处理。

reactiveref 在当前场景中的区别可以整理为:

方案 更适合的更新方式 当前场景中的代价
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: ''
}

默认值先覆盖旧状态,接口数据再覆盖对应字段。这个顺序能处理当前固定字段表单,但仍然不会删除默认结构之外的动态属性。

较大的表单还要考虑性能、校验时机和组件拆分。是否使用 reactiveref 或多个独立状态,应根据表单更新方式和组件边界选择,不能只凭字段数量决定。

十、小结

这次页面问题出现在表单重置阶段。模板最初读取了一个 reactive 对象上的属性,重置函数却让普通局部变量指向了另一份响应式对象。旧对象的属性没有变化,局部变量赋值也没有单独安排组件更新,所以输入框仍可能显示旧值。

表单需要保持同一个根对象时,可以直接修改属性,或者使用 Object.assign 把默认值写回原响应式对象。业务需要频繁替换整份数据时,可以使用 ref,通过修改 .value 让根对象替换进入响应式更新流程。

这些方案不会自动清理动态字段、嵌套引用、校验状态和第三方组件内部数据。下次遇到页面没有跟着表单变化,可以先确认模板读取的是哪一份状态、重置函数修改的是属性还是根引用,再考虑 DOM 更新时机。下一篇会继续分析响应式对象解构后,为什么局部变量不会跟随原属性变化。

相关推荐
米饭同学i1 小时前
微信小程序 input maxlength 遇上输入法拼音被截断?这是最完整的解决方案
前端
董员外1 小时前
RAG 系统进化论(二):Naive RAG,检索增强生成的最小闭环
前端·人工智能·后端
HeiSenBerg1 小时前
Android Handler 机制完全解析:从源码到内存泄漏,一篇就够了
面试
脾气有点小暴1 小时前
ECharts 伪 3D 柱状图完整注释 + 实现原理说明
前端·3d·信息可视化·vue·echarts
掘金一周1 小时前
看看大家每月的成本有多少 | 沸点周刊 7.30
前端·人工智能·后端
ji_shuke2 小时前
从零深入:基于 Playwright + Pytest + Allure 的企业级 Web 端到端自动化测试框架实战
前端·自动化测试·docker·jenkins·pytest·allure·playwright
陆枫Larry2 小时前
用 CSS Mask + background-color 给图标「换色」
前端
古韵2 小时前
你的 uni-app 分页,一个 hook 搞定竞态和加载
javascript·vue.js·uni-app
幼儿园技术家3 小时前
原来不用发版也可以做到版本更新
前端·js or ts