勾选了列表里的第二条消息,新消息插到顶部后,勾选却跑到了另一条消息上;翻到下一页再回来,选择又消失了。这类问题不一定来自点击事件,而可能来自选择状态保存了“位置”,没有保存“对象的身份”。
以米米商聊为背景,已整理的手机端消息操作资料包含“多选”。本文只借这一交互讨论客户端状态设计,不断言产品存在下述问题,也不推断产品的多选上限、批量操作或实现框架。以下代码和消息身份都是独立合成示例。
1. 数组下标指向位置,不指向同一条消息
初始列表按顺序包含 m1、m2、m3,用户选择第二条,即数组下标 1 对应的 m2。随后在顶部插入 m0,列表变成 m0、m1、m2、m3,下标 1 现在指向 m1。
如果选择集合保存的是下标 1,界面就可能把 m1 显示为已选择;如果保存的是消息 ID m2,新列表中的 m2 仍能被识别。筛选、排序、插入和删除都可能改变位置,不能只测试静止列表中的一次点击。
另一个容易遗漏的情况是重新请求列表后,每条消息都变成了新对象。若直接把旧消息对象放进 Set,新的同名对象未必能匹配已有选择。集合中的对象按引用身份比较,而本例保存的是稳定字符串 ID,参见 MDN 的 Set 说明。

2. 先明确选择范围与翻页规则
本例约定一个选择实例只服务一个会话,消息 ID 在该会话内唯一且不复用。重新加载同一消息时,可以产生新的对象,但必须保持相同身份。选择保存为字符串 ID 集合;当前页面只是选择状态的一次投影。
翻页策略也要明确。本例跨页保留选择:页面里没有出现某个 ID,不代表它已删除,也不代表用户取消了选择。因此,读取一个局部页面不会自动清除其他页面中的选择。
| 数据变化 | 本例的选择处理 | 不能据此推断的事情 |
|---|---|---|
| 顶部插入或列表重新排序 | 按 ID 标记新位置 | 原数组下标仍代表原消息 |
| 翻页或筛选后暂时不显示 | 保留已选 ID | 未出现的消息已经删除 |
| 收到明确的移除决定 | 显式取消对应 ID | 局部列表可以代替完整状态 |
| 切换会话或账号 | 清理旧选择并建立新实例 | 同名 ID 可以跨范围复用选择 |
产品也可以选择“仅当前页保留”的交互策略,但应明确向用户说明,并通过显式操作实现。不要把“页面只加载了一部分”偷偷解释为“这些选择全部失效”。
3. 一个只保存消息身份的选择模型
下面的模型接收普通消息行对象,使用会话身份校验范围。它不保存正文,也不保存数组位置。setSelected 接收明确的布尔值,重复设置为 true 不会增加第二个条目;project 只生成当前页的显示状态。
function createMessageSelection(conversationId, limit = 20) {
const validId = value => typeof value === "string"
&& /^[A-Za-z0-9_-]{1,64}$/.test(value);
if (!validId(conversationId)) throw new TypeError("invalid conversationId");
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) {
throw new RangeError("invalid selection limit");
}
const selected = new Set();
function idOf(message) {
if (message === null || typeof message !== "object"
|| message.conversationId !== conversationId || !validId(message.id)) {
throw new TypeError("invalid message scope or id");
}
return message.id;
}
return {
setSelected(message, value) {
if (typeof value !== "boolean") throw new TypeError("boolean required");
const id = idOf(message);
if (value) {
if (!selected.has(id) && selected.size >= limit) {
throw new RangeError("selection limit reached");
}
selected.add(id);
} else {
selected.delete(id);
}
return selected.has(id);
},
project(page) {
if (!Array.isArray(page)) throw new TypeError("page must be an array");
const seen = new Set();
return page.map(message => {
const id = idOf(message);
if (seen.has(id)) throw new Error("duplicate id in page");
seen.add(id);
return {
id, selected: selected.has(id) };
});
},
snapshot() {
return [...selected];
},
clear() {
selected.clear();
}
};
}
ID 的字符范围、64 字符上限、默认最多选择 20 条,以及可配置范围 1~1000 都是教学约定,不是产品限制。非法输入或超限新增会抛错,已有选择保持不变;达到上限后,重复设置已经选中的 ID 仍可成功。
这里故意拒绝一个页面内的重复 ID。重复行可能来自分页合并或数据源问题,如果默默用一条选择状态覆盖两条无法区分的行,就可能掩盖身份冲突。项目应先确认数据的唯一性,再决定是否合并重复记录。
这个模型只维护选择状态,不验证消息是否存在、是否可访问或是否允许某项操作。合法格式的同会话 ID 可以被加入集合,接入时仍应从真实消息行发起选择,并在服务端执行实际操作前校验当前对象与权限。
4. 同时验证插入、暂时隐藏和提交快照
将下面演示与上一段放在同一个 JavaScript 文件中执行。它只模拟列表数据与选择状态,没有真实消息内容,也不会向任何接口提交操作。
const row = id => ({
conversationId: "c1", id });
const before = [row("m1"), row("m2"), row("m3")];
const selection = createMessageSelection("c1");
selection.setSelected(before[1], true);
const after = [row("m0"), ...before];
console.assert(after[1].id === "m1");
console.assert(selection.project(after).find(item => item.selected).id === "m2");
console.assert(selection.project([row("m3")])[0].selected === false);
console.assert(selection.snapshot()[0] === "m2");
const batch = selection.snapshot();
selection.setSelected(row("m3"), true);
console.assert(batch.length === 1 && batch[0] === "m2");
console.assert(selection.snapshot().length === 2);
console.log(batch);
顶部插入后,旧下标确实指向了 m1,选择模型仍标记 m2。局部页面只显示 m3 时,m2 的选择也没有被删除。
最后的 batch 是在某一时刻复制出来的 ID 数组。后续再选择 m3,会改变当前选择集合,但不会悄悄改变已经取出的数组。这有助于把用户确认的操作目标与后续交互分开。
这里的“快照”是独立副本,不是不可变对象:调用者仍可修改 batch 本身,只是不会通过这份数组修改模型内部集合。生产代码应在确认后妥善保管请求目标,不能在等待异步响应时反复读取一个正在变化的选择集合。
5. 页面选择正确,还不等于批量操作可以直接执行
提交前需要确定用户确认的是哪些消息。确认后应使用那份目标快照;消息可能已经撤回、删除或变更可操作条件,服务端仍需逐项校验。前端的勾选状态不是权限证明,也不保证所有目标都能成功处理。
| 本例能够给出的信息 | 仍需接入层处理的事情 |
|---|---|
| 当前选择了哪些同会话 ID | 这些消息当前是否存在、可访问和可操作 |
| 哪些当前页行应显示为选中 | 页面是否加载完整,是否仍有离屏或未加载目标 |
| 某次取出快照时的目标集合 | 实际请求结果、部分失败、重试与并发冲突 |
| 选择进入集合的顺序 | 业务要求的时间顺序或服务端执行顺序 |
Set 保留插入顺序,因此快照默认反映选择进入集合的顺序。它不自动变成消息时间顺序,也不自动按 ID 排序。若操作要求按时间排列,应从可信消息数据取得相应顺序,再明确组织请求。
收到部分成功、部分失败的结果时,应向用户展示明确结果,再按交互约定更新选择。不能把一次成功回调简单解释为所有目标都已处理,也不能用响应时的新选择集合替代请求时的目标集合。
此外,列表组件自己的身份映射也要正确。以 React 为例,官方 列表 key 文档建议在插入、删除和重排场景下使用稳定身份,避免依赖数组下标。本文没有实现或测试 React 页面,也不据此推断产品使用 React;这里引用的是独立的框架接入注意事项。
6. 测试动态列表,而不只测试一个复选框
配套验证覆盖插入、重排、跨页暂时隐藏、新对象重新加载、同页重复 ID、跨会话输入、选择上限及快照被修改等情况,并使用固定种子的 500 组重排与局部列表检查选择身份是否保持一致。
这些检查针对独立的同步状态模型,没有接入真实客户端、列表组件、虚拟滚动或批量接口。它们不能替代页面验收:实际界面还要检查组件 key、复选框状态、退出选择模式、切换账号和操作确认流程。
接入时可以先检查三点:选择集合是否保存稳定身份,局部页面是否只投影而没有意外清空选择,确认后的请求目标是否保持独立。再验证顶部新消息、筛选变化和分页加载,让消息位置主动发生变化。
多选状态的目标是让勾选始终指向用户选择的对象。数组位置负责排版,稳定 ID 负责识别,提交快照负责记录一次确认的目标;分别明确它们的范围,才能在列表变化时维持一致的交互。
本文由 AI 辅助整理,独立示例经过本地验证。产品背景仅引用已整理的手机端多选操作资料;代码与通用接入建议不代表产品内部实现、操作权限或多选上限。