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
- 使用上面的
computed(map) 数据作为 TrHistory.data。
- 打开历史项
A 的操作菜单。
- 观察历史项的菜单激活状态。
- 点击“重命名”。
- 观察历史项是否进入行内编辑状态。
还可以在编辑或菜单打开期间执行以下不可变更新:
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 管理交互状态:
这些组件的共同原则是:
数据对象可以被重新创建,但只要稳定唯一标识不变,交互状态就应继续关联同一个逻辑项。
建议方案
建议统一 TrHistory 的内部身份模型。
1. 有 id 时按 id 管理状态
菜单和编辑状态应保存逻辑标识,而不是完整对象:
判断状态时使用:
item.id === editingId
item.id === menuTriggerId
id 应被视为稳定、唯一且非空的逻辑标识。
2. 无 id 时保留对象引用回退
当前 HistoryItem.id 是可选字段:
interface HistoryItem {
id?: string
title: string
}
因此,不能简单地全面改成 editingId?: string,否则无 id 的历史项将无法区分。
建议采用以下规则:
- item 具有有效
id 时,按 id 匹配;
- item 没有
id 时,保留当前对象引用匹配;
- 文档明确说明:无
id 数据无法保证对象重建后的交互状态延续。
这可以保持现有 API 的向后兼容性。
3. 事件应使用当前数据中的 item
当前菜单和重命名状态可能保存旧对象。触发事件时不应直接发送旧对象快照。
建议在触发以下事件前,根据保存的 id 从最新 props.data 中查找当前 item:
item-action
item-title-change
这样事件得到的是当前数据中的逻辑项,而不是菜单打开或编辑开始时保存的旧对象。
4. 区分数据身份和 DOM 锚点
菜单定位仍然需要保存触发按钮元素:
但 DOM 锚点和数据目标应分开管理:
menuTriggerEl -> 菜单定位
menuTriggerId -> 菜单对应的数据项
不应让一个完整 item 对象同时承担数据身份和菜单状态职责。
5. 处理目标项消失的情况
如果编辑或菜单对应的 id 已经不在最新数据中,建议清理对应状态:
- 关闭菜单;
- 取消编辑;
- 清空编辑草稿;
- 不再触发携带旧对象的事件。
如果只是标题变化、对象重新创建、列表排序或分组变化,而 id 仍然存在,则应保留状态。
补充说明
这个问题的核心不是 computed 本身,而是组件使用对象引用表示逻辑身份。
computed 映射、对象展开和不可变更新只是让该依赖暴露出来。只要组件内部状态保存完整对象并使用 === 匹配,调用方就必须额外维护对象引用稳定性。
建议将 id 作为选中、菜单和编辑状态的统一逻辑身份;无 id 时保留引用回退。这样可以在不改变外部 API 的前提下兼容常见的数据适配和不可变更新方式。
Tiny Robot Version
0.4.1
Vue Version
3.3.11
Link to minimal reproduction
TrHistory当前使用完整的 item 对象保存菜单和重命名状态,并通过严格相等===判断当前渲染项是否处于对应状态。这会让组件隐式依赖稳定的对象引用。当父组件通过以下常见方式生成
data时,即使历史项的id没有变化,菜单和重命名状态也可能失效:computed(() => source.map(...))对于受控列表组件,外部通常只需要保证逻辑标识稳定,而不应额外保证每个 item 对象的引用永久不变。
最小复现
Steps to reproduce
computed(map)数据作为TrHistory.data。A的操作菜单。还可以在编辑或菜单打开期间执行以下不可变更新:
更新后,每个历史项仍然具有相同的
id和内容,但对象引用已经改变。What is expected
只要历史项具有相同、稳定且唯一的
id,以下状态就应继续关联同一个逻辑项:item-action事件目标item-title-change事件目标What is actually happening
菜单和编辑状态通过对象引用进行判断:
相关源码:
packages/components/src/history/index.vuepackages/components/src/history/composables/useRenameEditor.tspackages/components/src/history/index.type.ts这可能导致:
active状态;item-action收到过期对象;item-title-change收到过期对象;indexOf(item)或对象引用更新数据时无法找到目标项。What is your project name
demo
Any additional comments (optional)
根因分析
当前菜单状态保存完整对象:
重命名状态同样保存完整对象:
模板随后使用严格相等判断状态:
:class="{ editing: editingItem === item, active: menuTriggerItem === item, }" <input v-if="editingItem === item" />这里存在两个不同层面的问题。
1.
ref()可能改变对象引用Vue 的普通
ref()会对写入的普通对象进行深层响应式转换。当
computed(map)返回普通对象时:上述比较可能为
false,因为state.value是 Vue 创建的响应式代理,而渲染列表中的item仍然是原始对象。因此,即使
computed尚未重新计算,只要映射结果是普通对象,内部状态和渲染项就可能已经不是同一个引用。2. 父组件重新创建对象后引用必然失效
即使将普通
ref()改为shallowRef(),也只能避免第一次写入时的代理转换。当父组件执行以下操作时:
当前渲染项会被新对象替换,之前保存的对象引用仍然失效。
因此,仅将
ref改为shallowRef不能完整解决问题。当前身份模型不一致
TrHistory内部已经在部分功能中使用id:但菜单和编辑状态使用对象引用:
这意味着同一个组件同时存在两套身份模型:
id同一个逻辑项可能保持选中状态,但同时丢失菜单或编辑状态。
与其他组件的设计对比
主流列表和数据表组件通常使用稳定 Key 管理交互状态:
id,也支持getRowId,并明确使用行标识跨更新跟踪状态。getRowId提供稳定且唯一的行标识。dataKey,内部编辑状态按 Key 管理。row-key保存选择和树形状态。rowKey和selectedRowKeys管理行状态。这些组件的共同原则是:
建议方案
建议统一
TrHistory的内部身份模型。1. 有
id时按id管理状态菜单和编辑状态应保存逻辑标识,而不是完整对象:
判断状态时使用:
id应被视为稳定、唯一且非空的逻辑标识。2. 无
id时保留对象引用回退当前
HistoryItem.id是可选字段:因此,不能简单地全面改成
editingId?: string,否则无id的历史项将无法区分。建议采用以下规则:
id时,按id匹配;id时,保留当前对象引用匹配;id数据无法保证对象重建后的交互状态延续。这可以保持现有 API 的向后兼容性。
3. 事件应使用当前数据中的 item
当前菜单和重命名状态可能保存旧对象。触发事件时不应直接发送旧对象快照。
建议在触发以下事件前,根据保存的
id从最新props.data中查找当前 item:这样事件得到的是当前数据中的逻辑项,而不是菜单打开或编辑开始时保存的旧对象。
4. 区分数据身份和 DOM 锚点
菜单定位仍然需要保存触发按钮元素:
但 DOM 锚点和数据目标应分开管理:
不应让一个完整 item 对象同时承担数据身份和菜单状态职责。
5. 处理目标项消失的情况
如果编辑或菜单对应的
id已经不在最新数据中,建议清理对应状态:如果只是标题变化、对象重新创建、列表排序或分组变化,而
id仍然存在,则应保留状态。补充说明
这个问题的核心不是
computed本身,而是组件使用对象引用表示逻辑身份。computed映射、对象展开和不可变更新只是让该依赖暴露出来。只要组件内部状态保存完整对象并使用===匹配,调用方就必须额外维护对象引用稳定性。建议将
id作为选中、菜单和编辑状态的统一逻辑身份;无id时保留引用回退。这样可以在不改变外部 API 的前提下兼容常见的数据适配和不可变更新方式。