Vue 3.5.42 的 select 一挂载就栈溢出:选项值含循环 Map 或 Set
v-model 对选项做宽松比较时可能递归进入循环集合。用稳定标识符作为表单值,业务图数据另行查找,避免破坏循环关系。
<select v-model> 尚未交互,一挂载就报 RangeError: Maximum call stack size exceeded,栈里反复出现 looseEqual、looseCompareCollections。若 option 的 :value 是含循环引用的 Map 或 Set,检查比较选项值的递归路径。
Vue 3.5.42 加入集合成员的宽松比较后,会深入比较两个 Map 或 Set 的内容。集合指回自身、成员对象互相引用时,已核对的正式实现没有记录已经比较过的对象对,递归可能无法终止。普通不含循环的数据不是同一个条件。
两份业务图可以结构相同,却不能直接递归比完
例如两个 Set 各自保存一个指回自身的对象,把它们作为原生 select 的两个值。选择初值与候选值不具相同引用时,比较过程可能进入成员的循环。
这不是 v-model 没声明,也不是需要把事件推迟一帧。先保留 Vue 实际运行版本、option 值的类型及循环关系;如果栈里是自己的递归函数或计算属性自我更新,应沿实际调用处理,不要只凭 RangeError 归因。
不要通过 JSON.stringify 深拷贝来“去响应式”:循环数据本身就无法按普通 JSON 序列化,Map 和 Set 还有各自的数据丢失风险。退回 3.5.41 也不能算正确集合比较已恢复,旧版对集合的比较本来有其他限制。
让原生表单只保存稳定的选项 ID
可把表单模型与复杂图分开,选择时保存标识,再从原业务集合中取得对象。下面是完整的结构示例,循环关系仍保留在业务数据内:
<script setup>
import { computed, ref } from 'vue';
function makeGraph(id) {
const node = { id };
node.self = node;
return new Set([node]);
}
const graphs = new Map([
['a', makeGraph(1)],
['b', makeGraph(2)],
]);
const selectedId = ref('b');
const selectedGraph = computed(() => graphs.get(selectedId.value));
</script>
<template>
<select v-model="selectedId">
<option value="a">A</option>
<option value="b">B</option>
</select>
<p>已选 {{ selectedId }},集合大小 {{ selectedGraph?.size }}</p>
</template>
ID 必须唯一、稳定,并与实际选项对应。业务若原来读取 selected 直接得到 Set,需要一起调整为读取查找结果;只改 option 值却保留旧调用方,会引入类型错误。异步删除选项时,查找结果可能为空,应按业务决定清空选择还是保留提示。
这项改动规避的是原生表单对复杂循环对象的比较,并没有让框架的 looseEqual 变得支持循环。其他仍把集合对象交给相同比较路径的位置,也要分别检查。
验收覆盖首次挂载、切换两个选项、恢复保存的 ID,以及不存在的 ID。确认业务拿到正确图对象,原循环关系和引用身份未被修改;生产构建中也要检查。后续正式修复应以原循环 Map/Set 路径回归,不能把一个未发布的比较实现当作当前能力。回退表单模型时同步恢复调用方与存储格式,避免一边存 ID、另一边仍期待整个 Set。


