Skip to content

🐛 [Bug]: TrHistory 的菜单和重命名状态在 item 对象引用变化时失效 #376

Description

@SonyLeo

Tiny Robot Version

0.4.1

Vue Version

3.3.11

Link to minimal reproduction

TrHistory 当前使用完整的 item 对象保存菜单和重命名状态,并通过严格相等 === 判断当前渲染项是否处于对应状态。

这会让组件隐式依赖稳定的对象引用。当父组件通过以下常见方式生成 data 时,即使历史项的 id 没有变化,菜单和重命名状态也可能失效:

  • computed(() => source.map(...))
  • 对 item 使用对象展开运算符
  • 数据标准化或字段适配
  • 为标题等字段注入默认值
  • Pinia Getter
  • 不可变数据更新
  • 接口数据转换层
  • 分组或过滤映射

对于受控列表组件,外部通常只需要保证逻辑标识稳定,而不应额外保证每个 item 对象的引用永久不变。

最小复现

<script setup lang="ts">
import { computed, ref } from 'vue'
import { TrHistory } from '@opentiny/tiny-robot'

const source = ref([
  { id: '1', title: 'A' },
  { id: '2', title: 'B' },
])

const data = computed(() =>
  source.value.map(item => ({
    ...item,
    title: item.title || 'Untitled',
  })),
)

const handleTitleChange = (title: string, item: { id: string }) => {
  const target = source.value.find(current => current.id === item.id)

  if (target) {
    target.title = title
  }
}
</script>

<template>
  <TrHistory
    :data="data"
    @item-title-change="handleTitleChange"
  />
</template>

Steps to reproduce

  1. 使用上面的 computed(map) 数据作为 TrHistory.data
  2. 打开历史项 A 的操作菜单。
  3. 观察历史项的菜单激活状态。
  4. 点击“重命名”。
  5. 观察历史项是否进入行内编辑状态。

还可以在编辑或菜单打开期间执行以下不可变更新:

source.value = source.value.map(item => ({ ...item }))

更新后,每个历史项仍然具有相同的 id 和内容,但对象引用已经改变。

What is expected

只要历史项具有相同、稳定且唯一的 id,以下状态就应继续关联同一个逻辑项:

  • 菜单激活状态
  • 行内重命名状态
  • 当前编辑草稿
  • 菜单操作目标
  • item-action 事件目标
  • item-title-change 事件目标

What is actually happening

菜单和编辑状态通过对象引用进行判断:

menuTriggerItem.value === item
editingItem === item

相关源码:

这可能导致:

  • 菜单已经打开,但历史项没有 active 状态;
  • 点击“重命名”后没有任何当前历史项进入编辑状态;
  • 编辑过程中数据更新后,输入框突然消失;
  • 编辑草稿仍保留在内部,但界面已经退出编辑状态;
  • 菜单操作使用旧的 item 对象;
  • item-action 收到过期对象;
  • item-title-change 收到过期对象;
  • 外部通过 indexOf(item) 或对象引用更新数据时无法找到目标项。

What is your project name

demo

Any additional comments (optional)

根因分析

当前菜单状态保存完整对象:

const menuTriggerItem = ref<T | null>(null)

menuTriggerItem.value = item

重命名状态同样保存完整对象:

const editingItem = ref<T | undefined>(undefined)

editingItem.value = item

模板随后使用严格相等判断状态:

:class="{
  editing: editingItem === item,
  active: menuTriggerItem === item,
}"

<input v-if="editingItem === item" />

这里存在两个不同层面的问题。

1. ref() 可能改变对象引用

Vue 的普通 ref() 会对写入的普通对象进行深层响应式转换。

computed(map) 返回普通对象时:

const item = data.value[0]
const state = ref(item)

state.value === item

上述比较可能为 false,因为 state.value 是 Vue 创建的响应式代理,而渲染列表中的 item 仍然是原始对象。

因此,即使 computed 尚未重新计算,只要映射结果是普通对象,内部状态和渲染项就可能已经不是同一个引用。

2. 父组件重新创建对象后引用必然失效

即使将普通 ref() 改为 shallowRef(),也只能避免第一次写入时的代理转换。

当父组件执行以下操作时:

source.value = source.value.map(item => ({ ...item }))

当前渲染项会被新对象替换,之前保存的对象引用仍然失效。

因此,仅将 ref 改为 shallowRef 不能完整解决问题。

当前身份模型不一致

TrHistory 内部已经在部分功能中使用 id

selected: item.id && item.id === props.selected
:key="item.id || index"

但菜单和编辑状态使用对象引用:

editing: editingItem === item
active: menuTriggerItem === item

这意味着同一个组件同时存在两套身份模型:

  • 选中状态和列表渲染:主要按 id
  • 菜单和编辑状态:按对象引用

同一个逻辑项可能保持选中状态,但同时丢失菜单或编辑状态。

与其他组件的设计对比

主流列表和数据表组件通常使用稳定 Key 管理交互状态:

  • MUI Data Grid 默认使用 id,也支持 getRowId,并明确使用行标识跨更新跟踪状态。
  • AG Grid 推荐通过 getRowId 提供稳定且唯一的行标识。
  • TanStack Table 的选择状态以 Row ID 为键。
  • PrimeVue DataTable 的行编辑要求提供 dataKey,内部编辑状态按 Key 管理。
  • Element Plus Table 使用 row-key 保存选择和树形状态。
  • Ant Design Vue Table 使用 rowKeyselectedRowKeys 管理行状态。

这些组件的共同原则是:

数据对象可以被重新创建,但只要稳定唯一标识不变,交互状态就应继续关联同一个逻辑项。

建议方案

建议统一 TrHistory 的内部身份模型。

1. 有 id 时按 id 管理状态

菜单和编辑状态应保存逻辑标识,而不是完整对象:

editingId
menuTriggerId

判断状态时使用:

item.id === editingId
item.id === menuTriggerId

id 应被视为稳定、唯一且非空的逻辑标识。

2. 无 id 时保留对象引用回退

当前 HistoryItem.id 是可选字段:

interface HistoryItem {
  id?: string
  title: string
}

因此,不能简单地全面改成 editingId?: string,否则无 id 的历史项将无法区分。

建议采用以下规则:

  1. item 具有有效 id 时,按 id 匹配;
  2. item 没有 id 时,保留当前对象引用匹配;
  3. 文档明确说明:无 id 数据无法保证对象重建后的交互状态延续。

这可以保持现有 API 的向后兼容性。

3. 事件应使用当前数据中的 item

当前菜单和重命名状态可能保存旧对象。触发事件时不应直接发送旧对象快照。

建议在触发以下事件前,根据保存的 id 从最新 props.data 中查找当前 item:

item-action
item-title-change

这样事件得到的是当前数据中的逻辑项,而不是菜单打开或编辑开始时保存的旧对象。

4. 区分数据身份和 DOM 锚点

菜单定位仍然需要保存触发按钮元素:

menuTriggerEl

但 DOM 锚点和数据目标应分开管理:

menuTriggerEl  -> 菜单定位
menuTriggerId  -> 菜单对应的数据项

不应让一个完整 item 对象同时承担数据身份和菜单状态职责。

5. 处理目标项消失的情况

如果编辑或菜单对应的 id 已经不在最新数据中,建议清理对应状态:

  • 关闭菜单;
  • 取消编辑;
  • 清空编辑草稿;
  • 不再触发携带旧对象的事件。

如果只是标题变化、对象重新创建、列表排序或分组变化,而 id 仍然存在,则应保留状态。

补充说明

这个问题的核心不是 computed 本身,而是组件使用对象引用表示逻辑身份。

computed 映射、对象展开和不可变更新只是让该依赖暴露出来。只要组件内部状态保存完整对象并使用 === 匹配,调用方就必须额外维护对象引用稳定性。

建议将 id 作为选中、菜单和编辑状态的统一逻辑身份;无 id 时保留引用回退。这样可以在不改变外部 API 的前提下兼容常见的数据适配和不可变更新方式。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions