Vue2 中一个由 v-if 和无 key 节点复用导致的组件状态丢失问题
最近在项目中遇到了一个看起来很"玄学"的问题:
同一个流程画布组件,仅仅调整了它在模板中的位置,保存结果就完全不同。
- 流程画布放在"高级设置"下面,编辑并保存后可以正常回显。
- 流程画布放在"高级设置"上面,编辑并保存后画布变成空白。
- 组件参数、事件和保存逻辑都没有变化。
- 唯一的区别,就是组件在 DOM 中的位置发生了变化。
最终发现,问题并不在画布组件本身,而是 Vue 2 在更新一组没有 key 的同级节点时,复用了错误的 DOM 节点,导致画布组件被销毁并重新创建。
一、问题代码
页面中主要有三个区域:
- 基础设置
- 流程配置
- 高级设置
简化后的代码如下:
xml
<div class="addseats_con">
<!-- 基础设置 -->
<div class="addseats_con_left" v-if="!flag">
<el-form>
<!-- 基础配置项 -->
</el-form>
</div>
<!-- 流程配置 -->
<div
class="addseats_con_right"
v-if="form.template === 2"
>
<canvaFlow
ref="canvaFlowRef"
:flag="flag"
:FlowItem="FlowItem"
@handleEdit="handleEdit"
@handleFlow="handleFlow"
/>
</div>
<!-- 高级设置 -->
<div class="addseats_con_left" v-if="!flag">
<el-form>
<!-- 高级配置项 -->
</el-form>
</div>
</div>
其中:
javascript
handleEdit() {
this.flag = true
}
点击流程配置中的"编辑"按钮后,flag 会从 false 变成 true。
编辑完成后,画布组件会向父组件传递流程数据:
ini
handleFlow(item) {
this.form.flowAttDto = item.jsonItem
this.form.flowBranchAttDtos = item.sideArr
this.form.xml = item.flow
this.flag = false
}
从代码上看,数据传递和保存过程没有明显问题。
但只要把流程配置放到两个设置区域之间,保存后画布就可能变成空白。
二、先区分两个不同的状态
这个页面中有两个容易混淆的状态。
1. 工单类型
通过 form.template 控制:
xml
<div v-if="form.template === 2">
<canvaFlow />
</div>
当 template === 1 时是简易模式,没有画布:
基础设置
高级设置
当 template === 2 时是画布模式:
基础设置
流程配置
高级设置
2. 是否正在编辑画布
通过 flag 控制:
xml
<div v-if="!flag">
基础设置
</div>
<div>
流程配置
</div>
<div v-if="!flag">
高级设置
</div>
未进入画布编辑时:
ini
flag = false
基础设置
流程配置
高级设置
进入画布编辑后:
ini
flag = true
流程配置
因为基础设置和高级设置都有 v-if="!flag",所以进入画布编辑后,这两个区域会从 DOM 中移除。
问题主要发生在第二种状态切换中。
三、为什么改变组件位置会影响结果?
编辑前的虚拟 DOM 结构可以简化为:
css
第一个 div:基础设置
第二个 div:流程配置
第三个 div:高级设置
进入编辑状态后,只剩下:
css
第一个 div:流程配置
这三个节点的外层标签都是 <div>,并且都没有设置 key。
对于 Vue 来说,它看到的大致是:
xml
<!-- 更新前 -->
<div>基础设置</div>
<div>流程配置</div>
<div>高级设置</div>
<!-- 更新后 -->
<div>流程配置</div>
在没有 key 的情况下,Vue 无法直接知道更新后的 <div> 对应更新前的哪一个 <div>。
Vue 可能按照节点位置和标签类型进行复用:
css
更新前第一个节点:div
更新后第一个节点:div
因为两者都是 <div>,Vue 可能将第一个"基础设置 div"复用为新的"流程配置 div"。
与此同时,原本位于第二个位置的流程组件可能被销毁。
四、画布组件被销毁后发生了什么?
流程画布通常不是一个纯展示组件,它内部可能保存了很多运行时状态,例如:
kotlin
this.lf
this.xmlData
this.xmlDataDisable
this.flowAttDto
this.flowBranchAttDtos
其中 this.lf 可能是 LogicFlow、BPMN.js 或其他画布库创建的实例。
当 canvaFlow 被销毁时:
markdown
原画布实例销毁
↓
内部图节点和连线状态丢失
↓
重新创建 canvaFlow
↓
重新执行 mounted
↓
重新初始化画布实例
如果新组件没有及时取得刚刚编辑的数据,或者仍然使用旧的 FlowItem 初始化,就会出现:
- 画布为空
- 画布回显旧数据
- XML 已经生成,但页面没有图像
- 节点配置数据和画布显示不一致
这也是为什么业务保存代码看起来没有问题,但界面仍然会出现异常。
五、为什么画布放在最后面却可能正常?
当画布位于最后时,结构是:
css
编辑前:
基础设置 div
高级设置 div
流程配置 div
进入编辑状态后:
css
流程配置 div
Vue 2 的虚拟 DOM Diff 并不只是简单地从前往后比较,它会对新旧子节点进行双端比较。
画布位于最后时,更新前的最后一个节点和更新后的目标节点更容易被识别为同一个节点:
旧节点末尾:流程配置
新节点:流程配置
因此,原来的画布组件实例有机会被保留下来。
而画布位于中间时:
基础设置
流程配置
高级设置
更新后的唯一节点可能先与第一个普通 <div> 匹配,从而导致真正的画布实例被移除。
这就造成了一个非常迷惑的现象:
业务代码完全相同,仅仅调整模板位置,组件行为就发生变化。
实际上,位置改变影响的是虚拟 DOM Diff 的匹配过程。
六、解决方法:为同级动态节点添加唯一 key
修复方式并不复杂,只需要给三个区域添加稳定且唯一的 key:
xml
<div class="addseats_con">
<!-- 基础设置 -->
<div
key="basic-settings"
class="addseats_con_left"
v-if="!flag"
>
<el-form>
<!-- 基础配置项 -->
</el-form>
</div>
<!-- 流程配置 -->
<div
key="flow-config"
class="addseats_con_right"
v-if="form.template === 2"
>
<canvaFlow
ref="canvaFlowRef"
:flag="flag"
:FlowItem="FlowItem"
@handleEdit="handleEdit"
@handleFlow="handleFlow"
/>
</div>
<!-- 高级设置 -->
<div
key="advanced-settings"
class="addseats_con_left"
v-if="!flag"
>
<el-form>
<!-- 高级配置项 -->
</el-form>
</div>
</div>
添加 key 后,相当于明确告诉 Vue:
arduino
basic-settings 是基础设置
flow-config 是流程配置
advanced-settings 是高级设置
即使这些节点:
- 使用相同的
<div>标签 - 拥有相同或相似的 class
- 因为
v-if被动态添加和删除 - 在更新前后改变了所在位置
Vue 也可以通过 key 正确识别它们,不会把基础设置错误地复用成流程配置。
七、这里的 key 不是缓存开关
很多人会把这类问题理解为"Vue 缓存导致的",但严格来说并不准确。
key 的主要作用是标识虚拟 DOM 节点的身份。
可以将它理解为节点的身份证:
ini
<div key="basic-settings"></div>
<div key="flow-config"></div>
<div key="advanced-settings"></div>
有了 key,Vue 可以判断:
- 哪个节点需要保留
- 哪个节点需要移动
- 哪个节点需要新增
- 哪个节点需要销毁
没有 key 时,Vue只能更多地根据节点位置和标签类型进行判断。
因此,这个问题更准确的描述是:
无
key的同级动态节点发生了错误复用,导致有状态子组件被销毁并重新创建。
而不是:
Vue 缓存了错误的页面。
八、为什么列表中经常强调要写 key?
我们最常在 v-for 中看到 key:
ini
<div
v-for="item in list"
:key="item.id"
>
{{ item.name }}
</div>
但 key 并不只对 v-for 有意义。
当同级节点满足以下条件时,也应该考虑使用 key:
- 多个节点标签相同
- 使用了
v-if或v-else-if - 节点会动态添加或删除
- 节点顺序可能变化
- 子组件内部保存了复杂状态
- 子组件包装了第三方实例
例如:
xml
<div v-if="showForm">
<ComplexForm />
</div>
<div v-if="showChart">
<ComplexChart />
</div>
如果两个区域可能在同级结构中动态替换,更稳妥的写法是:
xml
<div key="form" v-if="showForm">
<ComplexForm />
</div>
<div key="chart" v-if="showChart">
<ComplexChart />
</div>
尤其是富文本编辑器、地图、图表和流程画布等组件,内部通常维护着独立实例。一旦被意外销毁,重新初始化不一定能恢复到原来的状态。
九、排查这类问题的方法
以后遇到"改变 DOM 顺序后行为就不同"的问题,可以从以下几个方向排查。
1. 检查同级节点是否使用了 v-if
ini
<div v-if="conditionA"></div>
<div v-if="conditionB"></div>
<div v-if="conditionC"></div>
重点关注条件变化后,节点数量和顺序是否发生改变。
2. 检查同级节点是否标签相同
例如全部都是:
css
<div>...</div>
<div>...</div>
<div>...</div>
即使内部内容完全不同,对虚拟 DOM 来说,外层节点仍然具有很高的相似性。
3. 检查是否缺少唯一 key
如果动态节点没有 key,可以先为它们补充稳定标识,再观察问题是否消失。
4. 观察组件是否被重新挂载
可以临时添加生命周期日志:
javascript
created() {
console.log('canvaFlow created')
},
mounted() {
console.log('canvaFlow mounted')
},
beforeDestroy() {
console.log('canvaFlow beforeDestroy')
},
destroyed() {
console.log('canvaFlow destroyed')
}
如果只是切换页面状态,却看到组件执行了:
destroyed
created
mounted
说明组件实例没有被保留。
5. 不要只盯着接口和保存参数
画布不显示不一定代表后端没有保存成功。
需要分别检查:
接口参数是否正确
后端是否保存成功
父组件数据是否更新
子组件是否被销毁
画布实例是否重新初始化
初始化时使用的是新数据还是旧数据
否则很容易在接口层排查很久,却忽略真正的问题发生在组件生命周期中。
十、总结
这个问题的关键并不是流程画布放在上面还是下面,而是位置变化影响了 Vue 2 对无 key 同级节点的匹配。
完整过程可以概括为:
css
flag 发生变化
↓
v-if 添加或移除多个同级 div
↓
同级 div 没有唯一 key
↓
Vue 复用了错误的 div
↓
原 canvaFlow 组件被销毁
↓
画布内部状态丢失
↓
保存后画布无法正常回显
最终修复方式:
ini
<div key="basic-settings">...</div>
<div key="flow-config">...</div>
<div key="advanced-settings">...</div>
虽然只是增加了三个 key,但背后涉及 Vue 2 虚拟 DOM Diff、节点复用以及有状态组件生命周期等问题。
如果一个组件仅仅因为调整模板位置就出现不同结果,可以优先检查:
动态同级节点是否缺少稳定且唯一的
key。