一次位置调整引发的画布失忆:深入 Vue2 虚拟 DOM 复用机制

Vue2 中一个由 v-if 和无 key 节点复用导致的组件状态丢失问题

最近在项目中遇到了一个看起来很"玄学"的问题:

同一个流程画布组件,仅仅调整了它在模板中的位置,保存结果就完全不同。

  • 流程画布放在"高级设置"下面,编辑并保存后可以正常回显。
  • 流程画布放在"高级设置"上面,编辑并保存后画布变成空白。
  • 组件参数、事件和保存逻辑都没有变化。
  • 唯一的区别,就是组件在 DOM 中的位置发生了变化。

最终发现,问题并不在画布组件本身,而是 Vue 2 在更新一组没有 key 的同级节点时,复用了错误的 DOM 节点,导致画布组件被销毁并重新创建。

一、问题代码

页面中主要有三个区域:

  1. 基础设置
  2. 流程配置
  3. 高级设置

简化后的代码如下:

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-ifv-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

相关推荐
Sterting1 小时前
条件渲染与列表渲染
前端·vue.js
__sjfzllv___1 小时前
在职前端Leader学习/转行 AI Agent -DAY28
前端
不爱说话郭德纲1 小时前
uni-app x iOS 扫码模糊怎么办?AVFoundation 灰尘级别摄像头助你超广角聚焦
前端·uni-app·app
柒和远方1 小时前
V062:WebGPU 端侧推理与单例模式:从 Transformers.js 把大模型装进浏览器,到 Worker 线程的流式对话
前端
huabuyu1 小时前
INP 自动化归因与自愈:如何自动定位、修复并验证一次卡顿
前端·javascript
何时梦醒1 小时前
第一篇:项目概览 — 在浏览器里跑大模型,端侧 AI 的革命来了
前端·人工智能
何时梦醒1 小时前
第二篇:工程化搭建 — Vite + React + TypeScript + TailwindCSS 全解析
前端·人工智能
只一1 小时前
React 性能优化精讲:useCallback 与 useMemo 彻底吃透(附实战案例)
前端·react.js
橘子星1 小时前
浏览器也能跑大模型:WebGPU + Transformers.js 本地运行 DeepSeek-R1
前端·人工智能