
场景复现
在我们日常 Vue3 开发当中,总是会离不开 Element UI 组件库,组件库中还是得属表单和表格的使用频率最高。比如,后台管理系统中,会大量地使用表格和表单。而且,我们通常都会在开发 CRUD 接口的时候复用同一个表单和对话框来实现查看详情、新增数据、修改数据的效果,这样可以大大减少冗余视图结构代码,提高代码复用性。
具体一点讲,在点击新增按钮的时候,会弹出对话框显示添加数据的表单。在编辑数据的时候会弹出对话框提示你修改数据。当对话框关闭的时候,我们还需要清空表单,防止在点击新增按钮的时候之前编辑的数据还出现表单中这种低级错误。
但是有时候,你会发现在第一次 点击新增按钮的时候然后再点击编辑按钮 ,再点击新增按钮 表单是可以正常清空的。但是如果你第一次点击编辑按钮,表单数据回显,关闭窗口再点击新增按钮 发现编辑的数据竟然还在,就很玄乎。而且,你点击编辑其他数据再点击新增按钮发现竟然是第一次点击编辑的数据!
这究竟是为什么呢?真是太抽象了。就如下图所示:
在一个 Tab 组件内部嵌套了一个用户列表组件,因为列表组件可能代码逻辑会特别多,所以封装了组件。

App.vue:
js
<template>
<el-tabs v-model="tabName" @tab-change="handleTabChange">
<el-tab-pane label="你好世界" name="hello">
HelloWorld
</el-tab-pane>
<el-tab-pane label="用户列表" name="user">
<!-- 加了一个key为了让组件在切换tab时刷新 -->
<UserTable :key="tabKey" />
</el-tab-pane>
</el-tabs>
</template>
<script setup>
import UserTable from './views/UserTable.vue'
import { ref } from 'vue'
const tabKey = ref(0)
const tabName = ref('hello')
function handleTabChange(tab) {
if (tab === 'user') {
tabKey.value++
}
}
</script>
<style scoped>
</style>
UserTable:
js
<template>
<el-space>
<el-link :icon="Plus" type="primary" @click="handleInsert">新增</el-link>
<el-link :icon="Close" type="primary" @click="handleDelete">删除</el-link>
</el-space>
<el-table :data="tableData" border stripe>
<el-table-column type="index" label="序号" width="100" />
<el-table-column prop="name" label="姓名" />
<el-table-column prop="age" label="年龄" />
<el-table-column prop="address" label="地址" />
<el-table-column label="操作">
<template #default="{ row }">
<el-space>
<el-link type="primary" @click="handleView(row)">详情</el-link>
<el-link type="primary" @click="handleEdit(row)">编辑</el-link>
</el-space>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogProps.visible" :title="dialogProps.title" width="500" @closed="handleClosed(dialogFormRef)">
<el-form :model="dialogForm" ref="dialogFormRef">
<el-form-item label="姓名: " required prop="name">
<el-input v-model="dialogForm.name" />
</el-form-item>
<el-form-item label="年龄: " required prop="age">
<el-input v-model="dialogForm.age" />
</el-form-item>
<el-form-item label="地址: " required prop="address">
<el-input v-model="dialogForm.address" />
</el-form-item>
</el-form>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
import { Plus, Close } from '@element-plus/icons-vue'
const tableData = ref([
{ id: 1, name: '洛必达', age: 12, address: '艾泽拉斯' },
{ id: 2, name: '高斯', age: 20, address: '铁炉堡' },
{ id: 3, name: '伯努利', age: 100, address: '诺森德' },
{ id: 4, name: '欧拉', age: 99, address: '卡利姆多' }
])
const dialogProps = ref({
visible: false,
title: '',
})
const dialogForm = ref({
name: '',
age: '',
address: ''
})
const dialogFormRef = ref(null)
function handleView(row) {
dialogProps.value.visible = true
dialogProps.value.title = '查看用户数据'
Object.assign(dialogForm.value, row)
}
function handleEdit(row) {
dialogProps.value.visible = true
dialogProps.value.title = '修改用户数据'
Object.assign(dialogForm.value, row)
}
function handleInsert() {
dialogProps.value.visible = true
dialogProps.value.title = '新增用户数据'
}
function handleDelete() {
}
function handleClosed(formRef) {
formRef.resetFields()
}
</script>
<style scoped>
</style>
第一次点击编辑或详情:

然后点击新增按钮:

你会发现第一次编辑的数据还在!你会疑问为啥还在呢?我不是在窗口关闭的时候绑定了回调函数吗,还传入了表单的引用,调用了 resetFields 方法清空表单:
js
function handleClosed(formRef) {
formRef.resetFields()
}
原因分析
经过大量查阅资料了解到,el-dialog 组件是懒加载的,在这种 Tab 页内部嵌套组件的做法会导致 el-form 和 el-dialog 加载时机不一致。在 el-dialog 显示出来以后才会加载内部的组件,在加载完成之前,调用 Object.assign(form, row)
方法给表单绑定值拷贝属性赋值操作会先完成,最后在加载完成之后,表单对象就会得到一个错误的初始值,相当于把用户定义的初始值给覆盖了。简单来讲就是用户定义的初始值还没来得及绑定呢就被覆盖了。
具体来讲就是 hanldeEdit
方法在 const form = ref({...})
之前发生,所以用户定义的初始值就没用了。在后面的清空表单操作中,都是以第一次编辑的数据为初始值来清空。
所以得让拷贝数据赋值操作延后执行。
解决方法
在 Vue3 中,nextTick 函数可以让一些操作延后执行,当你修改了响应式状态时,DOM 会被自动更新。但是需要注意的是,DOM 更新不是同步的。Vue 会在"next tick"更新周期中缓冲所有状态的修改,以确保不管你进行了多少次状态修改,每个组件都只会被更新一次。
要等待 DOM 更新完成后再执行额外的代码,可以使用 nextTick() 全局 API:
js
function nextTick(callback?: () => void): Promise<void>
当你在 Vue 中更改响应式状态时,最终的 DOM 更新并不是同步生效的,而是由 Vue 将它们缓存在一个队列中,直到下一个"tick"才一起执行。这样是为了确保每个组件无论发生多少状态改变,都仅执行一次更新。
nextTick()
可以在状态改变后立即使用,以等待 DOM 更新完成。你可以传递一个回调函数作为参数,或者 await 返回的 Promise。
js
<script setup>
import { ref, nextTick } from 'vue'
const count = ref(0)
async function increment() {
count.value++
// DOM 还未更新
console.log(document.getElementById('counter').textContent) // 0
await nextTick()
// DOM 此时已经更新
console.log(document.getElementById('counter').textContent) // 1
}
</script>
<template>
<button id="counter" @click="increment">{{ count }}</button>
</template>
所以可以使用 nextTick
函数优化拷贝赋值操作:
js
function handleView(row) {
dialogProps.value.visible = true
dialogProps.value.title = '查看用户数据'
nextTick(() => {
Object.assign(dialogForm.value, row)
})
}
function handleEdit(row) {
dialogProps.value.visible = true
dialogProps.value.title = '修改用户数据'
nextTick(() => {
Object.assign(dialogForm.value, row)
})
}
最终效果如下,第一次先点击编辑:

再点击新增:

成功清空表单数据。
思考
为什么这里 DOM 的修改没有同步更新呢,因为这边嵌套了一个 UserTable 组件,子组件的状态修改不一定会及时更新,所以得要等待下一个"钩子"来临的时候一起更新。