Vue 3 编辑弹窗:草稿状态、取消重开与父子数据契约
以一个始终挂载的姓名编辑弹窗为场景,给出覆盖数据归属、保存取消、重开初始化和外部更新冲突的完整 Vue 3 解法。结合首次回答与递进补答,说明本地草稿、脏标记和版本校验各自解决的问题。

文章目录 18
题目信息
- 题目标题:Vue 3 编辑弹窗的草稿状态与父子数据契约
- 出题日期:2026-11-03
- 作答日期:2026-11-04
- 领域:Vue 3、组件通信、表单状态管理
- 难度:L2 基础应用(中级偏低)
- 预计用时:10–15 分钟
- 题型:场景题
- 考察方向:围绕编辑弹窗说明父子组件的状态归属、数据传递与编辑生命周期,并考虑外部数据更新时的交互行为。
面试题
一个 Vue 3 用户列表页面需要实现“编辑姓名”弹窗。父组件持有用户数据,通过 props 向弹窗传入当前用户和打开状态。弹窗始终挂载,关闭时只隐藏。
业务要求:
- 输入姓名时,列表仍显示已确认的姓名;点击“保存”后才更新列表,点击“取消”则放弃本次修改。
- 取消后重新打开,输入框应显示父组件当前的姓名。
- 编辑期间,父组件可能因后台刷新收到同一用户的新姓名;此时不能直接覆盖用户尚未保存的输入。
你会如何设计这个弹窗的状态与父子通信? 请说明数据分别由谁持有、打开及保存或取消时如何流转,以及编辑期间收到外部更新时采用什么处理策略、有什么取舍。
必要约束:只编辑一个字符串字段 name;不考虑切换用户、表单校验和保存接口的异步过程。可以口述或使用简短伪代码,无需实现完整组件。
标准答案
本节是为公开复习材料补充的教学解法,代码与字段均为示例,不代表用户项目中的既有实现,也不代表用户在首答或补答中已经给出了全部方案。
口述版标准答案
我会让父组件持有已确认的用户数据和弹窗打开状态。子组件接收只读输入,维护独立的本地姓名草稿;输入时只改草稿,不改父组件的用户记录。
每次弹窗由关闭变为打开,都从父组件当前数据初始化草稿,同时记录本次编辑的姓名基线和版本号。保存时,子组件通过事件提交草稿和基线版本,由父组件校验版本、更新已确认数据并关闭弹窗;取消时只发送取消事件,由父组件关闭,不提交姓名。旧草稿可以留在内存中,但下次打开必须重新初始化。
编辑期间收到外部更新,如果草稿相对基线没有变化,就采用最新数据并更新基线;如果已有未保存修改,则保留输入并展示冲突。本例选择阻止按旧版本保存,让用户明确选择放弃草稿、载入最新姓名后再编辑。这个策略容易实现,代价是无法直接保留草稿覆盖新值;如果业务需要覆盖或合并,就必须增加明确确认和相应的版本校验,不能静默覆盖。
关键原理:语言、框架与工程策略
JavaScript 层面。 字符串是原始值,把 props.user.name 的值赋给本地变量后,修改本地变量不会反向修改用户记录。对象赋值共享引用;对象展开只复制第一层,嵌套对象仍可能共享。本题只编辑 name 字符串,直接保存该字符串就足够;本节用字符串基线判断是否存在未保存差异。MDN:浅拷贝
Vue 3 单向数据流。 子组件不应重新赋值 props,也不应借对象属性修改绕过父组件的数据更新入口;props 对象内部属性并非自动深只读,不能把框架约定误解为所有嵌套写入都会被拦截。本例通过本地草稿编辑、事件提交,让父组件统一确认业务数据。Vue:单向数据流
Vue 3 监听与生命周期。 初始化要监听打开状态的变化,而不是只监听姓名。watch 可以接收多个来源;下面同时监听 open 与版本号,并用 immediate 处理组件初始即打开的情况。监听来源没有变化时,不能期待仅靠姓名监听来感知重开。Vue:watch 的来源与立即执行
弹窗常驻意味着隐藏后组件实例仍存在;一次编辑会话的开始不等于组件重新挂载。下面用 v-show 隐藏对话区域,不在父组件用 v-if 销毁弹窗,因此不能只在 onMounted 中初始化草稿。Vue:v-show 与 v-if
工程策略。 草稿允许暂时偏离已确认数据,这与始终由源数据计算的纯派生值不同。脏标记回答“用户的草稿是否不同于编辑基线”,版本号回答“已确认数据是否已经换过一个版本”。这两个判断互补:一个用于保护输入,另一个用于拒绝旧版本提交。
Vue 3 教学代码
适用于 Vue 3.3+ 的 Vue + TypeScript 单文件组件项目;将下面两个组件分别保存为 src/App.vue 和 src/EditNameDialog.vue。使用项目已有的 Vue 构建流程运行,不需要额外组件库。defineProps、defineEmits 是 <script setup> 编译宏,无需导入;示例始终通过 props.xxx 读取输入。Vue:script setup 宏
为演示外部更新,父组件提供一个“模拟后台刷新”按钮。示例只有一名用户,只编辑姓名,不包含切换用户、校验或异步保存。版本号由父组件在每次确认更新时递增,用户快照整体替换,姓名与版本一起变化。
父组件:src/App.vue
<script setup lang="ts">
import { ref } from 'vue'
import EditNameDialog from './EditNameDialog.vue'
const user = ref({ name: 'Alice', version: 1 })
const open = ref(false)
const saveError = ref('')
function openEditor() {
saveError.value = ''
open.value = true
}
function cancelEdit() {
// 不更新 user;关闭状态也由父组件维护。
open.value = false
saveError.value = ''
}
function saveName(payload: { name: string; baseVersion: number }) {
if (!open.value) return
// 提交入口再次校验,不能只依赖子组件禁用保存按钮。
if (payload.baseVersion !== user.value.version) {
saveError.value = '保存未执行:数据已更新,请先载入最新姓名再编辑。'
return
}
user.value = {
name: payload.name,
version: user.value.version + 1,
}
saveError.value = ''
open.value = false
}
function simulateRefresh() {
const nextVersion = user.value.version + 1
user.value = {
name: `外部姓名-${nextVersion}`,
version: nextVersion,
}
}
</script>
<template>
<main>
<h1>用户列表</h1>
<p>已确认姓名:{{ user.name }};版本:{{ user.version }}</p>
<button type="button" :disabled="open" @click="openEditor">
编辑姓名
</button>
<button type="button" @click="simulateRefresh">模拟后台刷新</button>
<!-- 始终创建组件实例,不用 v-if 控制其挂载。 -->
<EditNameDialog
:open="open"
:user="user"
:save-error="saveError"
@save="saveName"
@cancel="cancelEdit"
@reload="saveError = ''"
/>
</main>
</template>子组件:src/EditNameDialog.vue
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
const props = defineProps<{
open: boolean
user: Readonly<{ name: string; version: number }>
saveError: string
}>()
const emit = defineEmits<{
(event: 'save', payload: { name: string; baseVersion: number }): void
(event: 'cancel'): void
(event: 'reload'): void
}>()
const draftName = ref('')
const baseName = ref('')
const baseVersion = ref(0)
// 本次编辑是否已经发生过冲突。冲突一旦发生就锁定到用户显式处理为止。
const conflicted = ref(false)
// 派生脏标记:恢复为基线姓名后,未保存差异也随之消失。
const dirty = computed(() => draftName.value !== baseName.value)
// 发生过版本变化后,不因继续输入就自动认可新版本。
const conflict = computed(
() => props.open && (conflicted.value || props.user.version !== baseVersion.value),
)
function resetFromCurrent() {
draftName.value = props.user.name
baseName.value = props.user.name
baseVersion.value = props.user.version
conflicted.value = false
}
watch(
[() => props.open, () => props.user.version],
([isOpen], [wasOpen]) => {
if (!isOpen) return
if (!wasOpen) {
// 每次重开都重新开始一次编辑:初始化草稿与基线。
resetFromCurrent()
return
}
if (conflicted.value) {
// 本次编辑已经发生过冲突:保留输入,等用户显式选择,不自动采用新值。
return
}
if (!dirty.value) {
// 草稿相对基线没有差异:自动采用最新值。
resetFromCurrent()
return
}
// 草稿已改:保留输入并锁定冲突。
conflicted.value = true
},
{ immediate: true },
)
function save() {
if (!props.open || conflict.value) return
emit('save', {
name: draftName.value,
baseVersion: baseVersion.value,
})
// 由父组件接受提交后关闭;拒绝提交时仍保留弹窗和草稿。
}
function cancel() {
emit('cancel')
}
function reloadLatest() {
// 用户明确选择后,才丢弃本次输入、采用当前最新快照。
resetFromCurrent()
emit('reload')
}
</script>
<template>
<section
v-show="props.open"
role="dialog"
aria-labelledby="edit-name-title"
class="edit-dialog"
>
<h2 id="edit-name-title">编辑姓名</h2>
<form @submit.prevent="save">
<label>
姓名
<input v-model="draftName" name="name" />
</label>
<p>{{ dirty ? '存在未保存修改' : '没有未保存修改' }}</p>
<p v-if="conflict" role="alert">
外部数据已更新,已保留你的输入。请先处理冲突再保存。
</p>
<p v-if="props.saveError" role="alert">{{ props.saveError }}</p>
<button v-if="conflict" type="button" @click="reloadLatest">
放弃草稿并载入最新姓名
</button>
<button type="submit" :disabled="conflict">保存</button>
<button type="button" @click="cancel">取消</button>
</form>
</section>
</template>
<style scoped>
.edit-dialog {
border: 1px solid #888;
padding: 1rem;
margin-top: 1rem;
background: white;
}
</style>此处用轻量对话面板演示状态逻辑,便于同时操作“模拟后台刷新”按钮;真实模态弹窗的遮罩、焦点管理等应交给合适的弹窗组件处理。
验证说明:与本文完全一致的两个组件已在 Vue 3.5 + vitest 3.2.7 + @vue/test-utils 2.5.1 + happy-dom 环境下完成 8 个组件测试且全部通过,覆盖保存后才更新列表、取消不提交且重开恢复父组件当前姓名、外部更新时未改草稿自动同步而已改草稿保留输入并暴露冲突且保存按钮禁用、显式载入最新姓名后冲突解除、恢复成基线姓名后脏标记消失,以及弹窗始终挂载且关闭只隐藏;新增的第 8 个测试覆盖冲突后把草稿改回基线、再遇外部更新也不静默重置的冲突锁定行为。
状态与关键分支
查看 Mermaid 源码
flowchart TD
closed["关闭"]
opened["打开:初始化草稿与基线"]
clean["编辑中:无脏草稿"]
dirty["编辑中:有脏草稿"]
saveCheck["父组件校验版本"]
saved["保存成功:确认数据并递增版本"]
rejected["保存被拒:版本过期并保留输入"]
conflict["冲突态:保留输入并禁用保存"]
locked["冲突已锁定:仅显式载入最新值或重开解除"]
cancelled["取消:不提交姓名"]
closed -->|打开| opened
opened --> clean
clean -->|修改姓名| dirty
dirty -->|恢复基线姓名| clean
clean -->|外部更新后自动同步| clean
dirty -->|外部更新后保留输入| conflict
clean -->|保存| saveCheck
dirty -->|保存| saveCheck
saveCheck -->|版本一致| saved
saved -->|关闭| closed
saveCheck -->|版本过期| rejected
rejected --> conflict
conflict -.-> locked
conflict -->|放弃草稿并载入最新姓名| clean
clean -->|取消| cancelled
dirty -->|取消| cancelled
conflict -->|取消| cancelled
cancelled -->|关闭| closed| 事件与前提 | 本地草稿与基线 | 已确认数据与后续行为 |
|---|---|---|
| 从关闭变为打开 | 从当前姓名和版本初始化 | 父组件数据不变 |
| 输入姓名 | 只改草稿,自动计算脏标记 | 列表继续显示已确认姓名 |
| 取消 | 旧草稿可以暂留内存 | 父组件关闭,不接收姓名提交 |
| 再次打开 | 无条件用当前快照覆盖旧草稿 | 不依赖用户对象或姓名变化 |
| 外部刷新,尚无锁定冲突且草稿未改 | 自动更新草稿、姓名基线和版本 | 使用新的已确认快照 |
| 外部刷新,草稿已改 | 保留输入和原版本基线 | 展示冲突并阻止旧版本保存 |
| 冲突后把草稿改回基线,或冲突后再次外部更新 | 恢复基线会使脏标记消失,但不重置版本基线;后续外部更新仍保留当前输入 | 冲突仍被锁定,继续禁用保存;脏标记消失不等于冲突解除 |
| 放弃草稿并载入最新 | 明确重置草稿与版本基线 | 解除冲突,可重新编辑 |
| 保存且版本一致 | 提交草稿与基线版本 | 父组件更新姓名、递增版本并关闭 |
| 提交入口发现旧版本 | 不关闭、不丢弃输入 | 父组件拒绝写入并提示 |
例如打开时为 Alice / v1,输入 Bob 后收到 Carol / v2,草稿仍为 Bob,基线仍为 Alice / v1,冲突成立。只有选择载入最新后,草稿与基线才一起变成 Carol / v2。
这里有意采用保守策略:一旦编辑基线落后,哪怕用户后来又把输入改回旧基线,版本差异仍然存在,需要显式采用最新值;不会把“没有本地差异”自动等同于“已经认可外部新版本”。机制上使用独立的 conflicted 标记锁定冲突,已锁定时后续外部更新也不会自动采用新值;只有选择“放弃草稿并载入最新姓名”,或关闭后重新打开弹窗开始新一轮编辑,才会初始化草稿与基线并清除该标记。
工程取舍与适用边界
| 做法 | 适用场景 | 代价与边界 |
|---|---|---|
| 本地字符串草稿 | 本题这种单字段编辑 | 最简单;扩展为复杂表单后要重新审视字段依赖 |
| 浅拷贝表单对象 | 编辑字段均为原始值,或只改第一层属性 | 嵌套对象仍共享,不能据此隔离嵌套写入 |
| 深拷贝可编辑数据 | 需要隔离嵌套修改,且已确认所用克隆方法支持数据类型 | 有时间和内存成本;复杂类型、响应式对象不应盲目整体克隆,宜先投影为明确的可编辑数据 |
| 关闭时清空 | 需要立即释放大草稿或敏感临时数据 | 多一个清理时机;重开初始化仍要可靠 |
| 重开时初始化 | 常驻弹窗,每次打开都开始新编辑 | 关闭期间可能保留旧草稿,本题允许这样处理 |
| 脏草稿遇更新后阻止保存 | 单字段或较少并发,优先实现清晰的冲突反馈 | 用户需载入最新值后重新编辑,有额外交互成本;冲突被锁定后,即使把草稿改回基线,也仍需显式载入最新值,才能在本次编辑中继续保存 |
| 显式覆盖或字段合并 | 业务确实需要保留草稿、允许覆盖或多字段协同 | 要增加差异展示、明确授权或合并规则;不能只清除冲突标记就覆盖 |
这个版本号是教学中的本地修订号,前提是所有确认更新都经过父组件入口,并与数据快照一起更新。它不等同于服务端并发控制。扩展到多用户、异步接口或多设备后,应由服务端原子检查版本并写入,不能只依靠浏览器先比较再请求。
本题无需通用深拷贝工具、复杂状态机库或全局状态管理。也无需为不存在的用户切换和异步保存设计额外分支;保持“本地编辑、明确提交、版本过期时保留输入并拒绝静默覆盖”的契约即可。
我的回答
考虑到业务要求的这几点:
- 我认为数据应该由父组件持有,打开修改弹窗后,子组件深拷贝一份当前修改的表单数据;
- 打开弹窗后,深拷贝一份父组件传入的表单数据,这时候在弹窗内,对表单数据进行一些修改操作,不会影响父组件该记录的显示;
- 且由于弹窗始终挂载,即使关闭弹窗,也不会影响当前记录的修改记录;
- 对 props 需要采用
watch监听,考虑不同记录都有可能弹窗,而弹窗又是始终挂载,因此需要监听; - 如果编辑期间收到外部更新,则还要先备份一条该记录,先比对该条记录 key 是否能对上,而后比对姓名是否修改,如果没修改,则替换新的名称,反之则不能影响用户尚未保存的输入,当然这是多余的操作(备份)。
面试点评
首答评分:65 / 100
方向基本正确:回答抓住了父组件持有已确认数据、子组件隔离编辑,以及保护未保存输入这几个重点。但保存、取消和重新打开的状态流转还没有形成完整闭环。
| 评分维度 | 得分 | 主要依据 |
|---|---|---|
| 正确性 | 29 / 40 | 数据隔离方向正确,保存与取消流程未展开 |
| 原理理解 | 13 / 20 | 理解常驻组件保留状态,但监听来源和初始化时机不明确 |
| 工程应用 | 11 / 20 | 有可行思路,缺少具体的父子通信契约 |
| 边界与权衡 | 5 / 10 | 考虑了外部刷新,但比较依据和冲突后的处理不完整 |
| 面试表达 | 7 / 10 | 能围绕业务要求回答,部分重复,关键术语仍需明确 |
正确点
- 数据归属清楚:父组件持有已确认数据,子组件维护独立编辑数据,能够避免输入过程中直接影响列表,符合 Vue 的单向数据流原则。
- 注意到常驻弹窗会保留状态:关闭只隐藏,不会自动清除本地编辑数据。
- 具备保护用户输入的意识:没有提出收到外部更新就无条件覆盖,而是尝试区分用户是否修改过姓名。
- 有比较参照的初步意识:提出“备份后比较”,但参照和时机还没有说清楚。
遗漏点
- 保存与取消的父子通信:缺少提交哪些数据、通过什么方式通知父组件、由谁更新列表,以及取消如何结束本次编辑的说明。
- 重新打开的触发机制:虽然说了“打开时复制”,但实现层面主要讲监听记录 props;同一用户的数据没有变化时,再次打开如何触发初始化尚未交代。
- “姓名是否修改”的比较依据:未明确比较的是用户输入与哪个时点的姓名,以及备份何时建立、何时更新。
- 外部更新后的行为:保留输入以后是否提示数据已更新,以及后续保存采用什么策略,仍缺少取舍说明。
错误点与需要收紧的表述
首答没有明确的根本性原理错误,但有三处表述需要收紧:
- 深拷贝并非本题必需:这里只编辑字符串
name,无需为了隔离输入而深拷贝整份表单。这属于实现成本偏高,不意味着隔离思路错误。 - “始终挂载,所以必须监听 props”不够准确:
watch是否有效取决于监听来源;只监听用户数据,不能保证捕捉到每次打开。Vue 的watch默认在监听来源变化时执行。 - 不能直接认定备份多余:用于判断修改的参照可能有必要,但不一定需要备份整条记录。
“关闭后修改记录还保留”是对内存状态的正确描述,不能仅凭这句话判定取消行为错误。切换用户已被题目排除,本次不因此扣分。
相关框架行为可参考 Vue 单向数据流文档 和 Vue watch 文档。
首答的表达改进
当时建议先将已答出的部分压缩为:
父组件持有已确认的用户数据,弹窗维护本次编辑的数据,输入时不影响列表。我会在每次打开时初始化编辑内容,并区分重新打开与编辑期间收到外部更新这两种情况。处理外部更新前,需要明确用户是否修改过姓名的判断依据,避免覆盖未保存输入。
这段表达尚需补充保存、取消的通信和初始化触发时机,不能作为完整方案。
递进追问与补答
追问 1:同一用户数据未变化时,取消后如何重新初始化
追问:
父组件姓名是 Alice。你打开弹窗,改成 Bob,点击取消,再次打开同一用户;期间父组件的用户对象引用和姓名都没有变化。
请描述从取消到再次打开时,父组件数据与弹窗编辑数据应如何变化,以及你的方案依靠什么触发初始化?
当时给出的分层提示:
- 先区分“组件仍然存在”和“上一次编辑已经结束”。
- 再检查:如果只监听用户对象或姓名,这段过程中监听来源是否发生过变化。
我的补答:
如果是只修改名称,传入修改名称这个基础变量,在弹窗内修改,并不会影响父组件的对象引用和姓名;
根据弹窗的状态变化来触发初始化,取消后,只需将弹窗关闭即可,基础变量不会对父组件造成影响,不用 emit;再次打开,因为弹窗状态变成 true,则触发初始化,将弹窗内的名称变量 = props.name 即可。
补答评分:88 / 100。
这是追问表现评分,不覆盖首答的 65 分,不新增正式证据数。评分只针对本次追问范围,不因未重述保存流程或外部刷新方案扣分。
| 评分维度 | 得分 |
|---|---|
| 正确性 | 36 / 40 |
| 原理理解 | 18 / 20 |
| 工程应用 | 17 / 20 |
| 边界与权衡 | 8 / 10 |
| 面试表达 | 9 / 10 |
补答中的进步:
- 明确了姓名的本地编辑不会影响父组件已确认的数据。
- 找到了初始化触发来源:弹窗由关闭变为打开,而不是依赖姓名或用户对象变化。
- 提出了再次打开时将本地姓名重新赋为
props.name,能够避免上次取消的输入继续显示。 - 取消时只关闭、重新打开时再初始化的策略成立,不必强制在关闭瞬间清空草稿。
仍未说明的内容:
- 打开状态由谁持有,点击取消后如何通知相应组件更新关闭状态。
- “弹窗内修改”可以明确表述为“修改弹窗的本地姓名变量”。
- 可以直接补充状态结果:父组件姓名始终保持
Alice,重新打开后,本地姓名也恢复为Alice。
错误点判断:
补答没有明确的原理性错误。“不用 emit”存在表达歧义:若指取消时无需提交姓名修改,这个意思成立;若指整个关闭过程都不需要父子通信,现有说明不足以支持。这里按表达范围未限定处理,不直接判定实现错误,也不推断用户直接修改了 props。
本轮建议:
补齐关闭动作的通信说明,再用一个最小弹窗验证“修改 → 取消 → 重开”的状态变化。该验证是后续建议,本轮尚未执行。
当时建议的口述表达:
我会在弹窗内维护一个本地姓名变量,输入只修改这个变量,父组件的姓名保持不变。取消时关闭弹窗,不提交姓名修改;再次打开时,根据打开状态的变化触发初始化,把当前
props.name赋给本地姓名变量,因此不会继续显示上次取消的输入。关闭状态由谁更新、如何通知,我还需要在组件通信约定中说明清楚。
最终总结
- 已经补答清楚的内容:编辑数据与父级已确认数据隔离;重新打开时以打开状态变化触发初始化,避免依赖未变化的用户数据。
- 从首答到补答的关键改进:由笼统的“监听 props”进一步明确到本次编辑开始的触发来源,并收窄为本地字符串姓名的编辑。
- 原始作答中仍未展开的边界:关闭状态的通信、保存提交契约、外部更新的比较参照与冲突后的保存策略。追问得分较高不代表整题所有设计环节都已完成。
- 公开复习材料中的完整解法:标准答案补入了父组件确认数据与打开状态、子组件草稿、取消与保存事件、脏标记与版本检查,以及冲突后显式载入最新值的实现。这些教学补充不改写原始作答,也不改变两轮评分。
- 下一步实践:示例代码已通过 8 个组件测试(含冲突锁定),相关行为已实跑验证通过;用户本人在面试训练中仍未提供自己的实现与实践证据。
相关文章
- 面试题-基础
Vue 3 响应式解构:为什么替换 user 后出现“李四 / 王五”
通过一道 Vue 3 代码题,区分 reactive 对象解构、嵌套属性修改与对象替换的行为,并用 toRef 保持源属性关联。文章保留首次作答及追问补答,说明从误判到纠正的过程。
- 面试题-基础
Vue 3 首屏白屏 3 秒:从性能定位到 Long Task、Web Worker 与虚拟列表
一次完整的 Vue 3 首屏性能面试复盘,从环境复现、Network 与 Performance 定位,逐步深入到 Long Task 分析、Web Worker 和虚拟列表的适用边界。
- 面试题-基础
面试题:AI 前端中的模型 API Key 安全
本题考察模型调用链路设计,以及前端环境变量、BFF 和接口安全。
评论
0 条评论会在审核后展示,邮箱不会公开。