原数组里有三条附件记录,其中两条都叫"报价.pdf"。把文件名直接作为 Map 的键之后,索引只剩两项。文件并没有从原数组消失,丢掉的是"一个名称对应多个附件"这层关系。
以米米商聊为背景,已整理的手机端资料包含文件发送。本文借附件元信息建立独立的 JavaScript 示例,不代表产品采用这些字段、索引或实现,也不推断其原生 App、桌面客户端及服务端。文中只有合成元信息,没有读取、上传或修改用户文件。
1. 先复现:重复名称变成同一个 Map 键
javascript
const files = [
{ id: "f1", name: "报价.pdf" },
{ id: "f2", name: "会议.txt" },
{ id: "f3", name: "报价.pdf" }
];
const byName = new Map(files.map(file => [file.name, file]));
console.log(byName.size); // 2
console.log(byName.get("报价.pdf").id); // "f3"
console.log([...byName.keys()]); // ["报价.pdf", "会议.txt"]
MDN 对 Map.set() 的说明明确区分新增键和更新已有键:键已经存在时,会更新对应值。构造 Map 时逐项写入相同键,也不会为每次出现额外保留一条记录。
因此,问题不是 Map 随机漏数据,而是代码选择了"一键一个值"的结构,却又期望它保存同名附件的全部记录。f3 是最后遍历到的同名记录,并不因此自动成为"最新版本"或"应该保留的文件"。
上面还有一个容易忽略的结果:"报价.pdf"仍在第一个键的位置。已有键更新值,与新增键插入顺序不是一回事;相应行为可对照 ECMAScript 的 Map.set 算法。不能根据索引位置推断文件更新时间。

2. 唯一查找和按名称分组,回答不同问题
先决定索引要回答什么,再选键和值。按 ID 查找回答"我要哪一条附件";按名称分组回答"这个名称下有哪些附件"。后者不能只保存一个附件对象。
| 结构 | 键的含义 | 相同键再次出现时的策略 |
|---|---|---|
Map<name, file> |
一个名称只留一个值 | 默认后写覆盖;仅在业务确实允许时采用 |
Map<id, file> |
一个标识定位一条记录 | 本文要求 ID 唯一,重复就拒绝构建 |
Map<name, file[]> |
一个名称对应一个附件列表 | 同名放入同一组,保留全部记录 |
同名与重复 ID 也不能混为一谈。两个不同 ID 的同名附件,在本文中都是有效记录;两个相同 ID 的条目,则需要另有去重或版本协议。这个示例选择直接拒绝重复 ID,避免在索引层悄悄作出合并决定。
3. 先校验元信息,再构建严格的 ID 索引
下面约定输入是普通记录数组,每条记录有非空、首尾无空白的字符串 ID,以及非空字符串名称。名称按原值保留,不替调用方自动裁剪、改大小写或规范化字符。它不是文件内容或完整文件名安全校验器。
javascript
function checkedFiles(files) {
if (!Array.isArray(files)) {
throw new TypeError("Expected a file record array");
}
for (const file of files) {
if (file === null || typeof file !== "object" || Array.isArray(file)
|| typeof file.id !== "string" || file.id === "" || file.id.trim() !== file.id
|| typeof file.name !== "string" || file.name === "") {
throw new TypeError("Invalid file metadata");
}
}
return files;
}
function indexFilesById(files) {
const index = new Map();
for (const file of checkedFiles(files)) {
if (index.has(file.id)) {
throw new Error("Duplicate file id");
}
index.set(file.id, file);
}
return index;
}
这里明确检查 has(),而不是先写入再猜测是否覆盖。重复 ID 即使两条对象内容相同,也按本文契约拒绝,不偷偷把"这可能是重试"当作已经确认的事实。
索引在函数内部新建,完整成功后才返回。如果调用方希望保留旧索引,就在新索引成功返回之后再替换原引用;不要先清空旧索引,再把一个可能校验失败的新批次往里面塞。
代码只读取输入,不改写数组或记录;Map 的值仍引用原记录对象,并不是深复制。建立索引后若又原地修改记录的 id,键与对象字段可能不再一致。接入时应保持身份字段约定,数据发生相关变化时重建索引。
4. 需要保留同名项时,把值改成数组
分组函数先复用严格的 ID 索引,保证输入没有违反唯一身份约定;再按名称把记录放进不同数组。这样两个索引对重复 ID 采用相同策略,不会一处报错、另一处却无声接受。
javascript
function groupFilesByName(files) {
const byId = indexFilesById(files);
const groups = new Map();
for (const file of byId.values()) {
if (!groups.has(file.name)) {
groups.set(file.name, []);
}
groups.get(file.name).push(file);
}
return groups;
}
对开头的合成记录,indexFilesById(files).size 为 3;名称分组只有两个键,但"报价.pdf"的组包含 f1 和 f3,另一个组包含 f2。分组键数量变少,并不意味着附件数量应该变少。
Map 文档描述了插入顺序及各类遍历方法。本文按原记录的遇见顺序构建索引,分组键保持名称首次出现的顺序,组内记录也保持遇见顺序;没有按文件名、时间或版本排序。
为保持两种输出契约一致,这份实现临时建立了 ID 索引,再建立分组。它没有声称这是最低内存或最快方案;实际规模较大时,可以在保持相同输入约束的前提下评估别的构建方式。没有基准测试,就不把简单样例写成性能提升结论。
5. 别让索引过程替业务决定重复记录的含义
一条重复 ID 记录,可能来自分页重叠、重试,也可能携带较新的字段。仅凭"最后遍历到",无法知道哪种含义成立。需要保留首条、覆盖末条、按版本合并还是直接拒绝,应由数据协议明确决定。
本文选择拒绝重复 ID,是为了让异常在建立查找表时暴露;它不实现消息去重、版本合并或服务端约束。若实际接口允许同 ID 多版本,应该先完成有依据的版本处理,再交给要求唯一 ID 的索引阶段。
名称比较也要有明确边界。本例中 A.pdf 与 a.pdf 是不同组,首尾空格和规范等价但原始序列不同的名称也不会被自动合并。这不等于各操作系统都这样识别文件名,只是本文使用原始字符串键的契约。
显示时可以按名称组织候选项,但最终选择哪一条附件,仍应依据相应记录的标识和上下文。名称相同不证明内容相同;索引命中也不证明访问者有权下载,权限和文件有效性需要由真实业务检查。
6. 验证数量守恒,也验证不该接受的输入
独立验证从本文三段 JavaScript 原样提取代码,用本地 Node.js 和合成元信息运行。先复现名称单值索引覆盖,再验证严格 ID 索引和名称分组的预期。
| 检查场景 | 本文预期 |
|---|---|
| 不同 ID、相同名称 | ID 索引保留两项,名称组保留两条 |
| 相同 ID、不同名称或相同对象再次出现 | 两种构建函数都拒绝 |
| 非数组、空记录、错误字段类型 | 拒绝,不悄悄转换 |
| 空数组 | 两种索引都为空 |
| 输入对象与数组被冻结 | 仍可构建,输入不被改写 |
| 相同输入重新构建 | 内容顺序一致,各组数组为新容器 |
| 新批次校验失败 | 调用方此前持有的旧索引不受修改 |
| 大量不同 ID 共享少量名称 | 组内总记录数等于原始条数 |
其中批量样例使用 1,000 个不同 ID 和 20 个重复名称,检查每个 ID 都保留一次、全部分组的条数合计仍为 1,000。这是在检查关系是否完整,不是做吞吐、内存或实际文件系统测试。
本轮没有读取真实附件、调用产品接口或验收客户端列表。需要接入页面时,还应继续核对输入来源、列表渲染、选择对象与请求回传是否使用了相同的身份约定。
Map 会按它的键规则工作。能否保留正确的业务关系,取决于我们是否先说清楚:什么必须唯一,什么允许重名,以及重复出现时究竟应该怎么处理。
本文为独立教学示例,部分内容由 AI 辅助整理,代码与发布内容经独立核对。