后台列表页里最容易在验收阶段返工的一个交互,往往是需求文档里那一句”行可展开看明细,要记住展开状态”。实现只写了十几行,测试翻到第二页再退回来,之前展开的那条明细出现在另一行上;或者切了一次排序,所有展开行的内容都串了位。这类问题通常不是组件库的缺陷,而是”展开”被当成一个单独的状态来设计,实际上它至少是三件事:状态存在哪里、内容什么时候取、状态能活多久。

把这三件事分开回答,很多分歧会在评审阶段就暴露出来。存哪里决定了排序、筛选、翻页之后会不会错位;什么时候取决定了首屏开销、点击后的等待和服务端接口压力;活多久则决定”记住”这两个字的边界——同页内、跨页、跨刷新、跨设备,是四个不同的方案。

展开行状态的三层归属:主键集合、取数时机与存活范围分别落在不同的层

一、状态存哪:索引和业务主键之间有一条分水岭

最省事的写法是把展开态存成行下标数组,翻页或排序时把整份数据换掉、下标重新从 0 开始。问题就在这里:下标不是行的属性,而是行在当前这份数组里的位置。排序变了位置就变,筛选把中间几行抽掉后面全部前移,服务端分页时第二页的第一行下标仍然是 0——这三种情况任何一个发生,存下来的下标就指向了另一条业务数据,而且是静默错位,界面上看不出异常。

要用一个能跨请求成立的标识,也就是业务主键。选主键时有三条硬要求:

  1. 唯一。分组表、树表里同一个业务 id 可能既出现在汇总行又出现在明细行,这时要用”层级前缀 + 主键”的复合 key,否则父行和子行的展开态互相覆盖。
  2. 稳定。不能在每次请求里重新生成。用行号、自增序号、Math.random()、时间戳拼出来的 key,跨页之后必然对不上。
  3. 可回传。展开区请求明细时,请求参数里带的标识和响应体里能对上账的字段必须是同一个东西,否则慢网络下连点两行,回来的结果会被贴到错误的行上。

下面这张表是我们排查这类问题时用的对照,四列都可直接执行。

症状 成因 怎么判 修法
翻页再回来,展开内容跑到别的行 状态用行下标存储 只改排序,看错位是否立刻复现 换成业务主键集合,状态里不出现下标
同一页正常,切走再回来全收起 组件被重建,非受控状态随实例销毁 给外层容器换稳定 key 与不稳定 key 各跑一次 状态提升到与视图生命周期无关的容器
展开后显示的是上一页某行的明细 明细缓存以下标为键,或响应未按主键对账 把网络压慢,连续点两行 缓存键用主键,回来先比对主键再落位
点”全部展开”只展开了当前页 用当前页数组算集合 集合大小恒等于页大小即为证据 由后端按筛选条件返回全部匹配主键
取消筛选后有些行仍是展开态 筛选期间未清理失效主键 看集合里是否含不在结果集中的 id 记录集合与筛选条件的对应关系,条件变更即收敛
读屏只念”已选中”不念”已展开” 展开控件没有 aria-expanded 关掉视觉,只用键盘与读屏跑一遍 按钮挂 aria-expanded 与 aria-controls
一次点击同时触发行选中和展开 两个动作都监听在行上,冒泡叠加 在两处各打一条日志,看是否同一事件都命中 行级单一入口判定命中区域

二、受控与非受控:状态会丢在两个不同的位置

组件库一般提供两种用法。非受控只给初始展开项,之后由组件自己内部持有;受控把展开集合交给调用方,每次变更通过回调同步回来。两种写法各有会漏掉的场景,而且漏的位置不同。

非受控的丢法很干脆:组件实例一销毁,状态就没了。重建实例的触发点比想象中多——外层 key 变化、路由离开再回来、虚拟滚动把这一屏回收、错误边界或挂起边界重挂、甚至是列配置面板里改了列宽触发整表重渲染。判法很直接:在展开集合所在组件的挂载点打一条日志,做一次翻页再回来的操作,看挂载日志是否重新输出;重新输出就说明实例被重建,状态跟着实例一起没了。

受控的丢法更隐蔽:状态确实存在组件外面,但存在了和当前页数据同一层的对象里,每次请求回来都重新赋值,等价于跟着分页一起被覆盖。规矩是一条——存放展开集合的位置,生命周期必须比分页长。它的理想形态是一个”与页无关的主键集合”,只增删元素、不整体替换;请求回来后拿集合去和当前页数据求交集决定渲染,而不是把集合本身改掉。

还有一个经常被忽略的分叉:收起时,展开区里的内容要卸载还是保留缓存。保留能避免重复取数,但收起状态下这段内容仍在 DOM 里,就必须显式声明不可达——用 hidden 或 inert,否则读屏和 Tab 会读到一个”看起来不存在”的区域。这件事在下一节是可访问性问题,在动画里则变成布局问题。

三、内容什么时候取:三种时机的代价不一样

时机 首屏代价 点击后等待 服务端压力 适合什么
展开即取 无 有一次请求,需要转置状态 只按用户真实兴趣付费 明细体量大、展开率低
悬停预取 少量 多数命中缓存 需要防抖与取消,否则放大 桌面端为主、误点率高
列表接口一次带全明细 传输量大 无 单次响应体积被明细行数撑住 明细很小、行数可控
翻页时批量取当前页明细 中等 首次展开可能仍在等 可预测,好做上限保护 展开率稳定、明细中等

选了”展开即取”就要接受它自带四个待办:加载中状态、失败重试、请求去重(同一行被连点两次不应发两次)、竞态处理(响应回来时这行可能已经被筛选掉了,落位前必须按主键确认它还在结果集里)。少了竞态这一条,就会出现第一节表格里”展开后显示的是上一页某行的明细”那种现场表现。

悬停预取有个容易漏的边界:触屏设备没有可靠的悬停语义。要么用媒体查询判断真正的悬停能力再启用,要么承认它在手机上退化成”展开即取”。预取请求要可取消,鼠标扫过一整行时不应该发出十个请求。

一次带全明细的写法在业务表上风险最大。一行主数据背后挂几十条子记录时,列表接口会被明细行数而不是主数据行数决定响应大小,翻页深度一上来就开始超时。这类页面的取舍我们放在方案阶段就确认,而不是等首屏慢到被投诉再拆。

四、可访问性:屏幕阅读器凭什么知道这行能展开

界面上看得到加号,不代表程序上”这行可展开”这件事被表达了。三条最基本的:

  • 触发器用真正的 <button type="button">,并带 aria-expanded 反映当前状态。type="button" 不是形式主义——在可编辑表格或表单内的表格里,默认类型的按钮会触发提交。
  • 展开出来的区域给一个 id,用 aria-controls 关联过去;如果展开区是独立块,给它 role="region" 和一个可朗读的名字。读屏用户在区域列表里能跳到这里,比只靠 Tab 顺序找有用得多。
  • 用原生语义时,<details> 与 <summary> 自带展开收起语义和键盘支持,不需要额外脚本;自己拼 div 时,这些都得手工补。

在把整张表声明为 role="grid" 的实现里,ARIA 的行本身也可以携带 aria-expanded,键盘操作模式下焦点落在行上就能听到展开状态。两种挂点都成立,但要选一个作为约定:组件库多数把状态挂在按钮上,如果同时又给行挂了一份,两边不同步就会产生自相矛盾的播报。

整行可点是把展开和可访问性一起丢掉的常见做法。文本选择会被行点击吞掉;行里的链接、按钮、复选框都要各自处理冒泡;更麻烦的是 <div> 拼出来的行本身不可聚焦,键盘用户根本到不了这个交互。真要支持”点行展开”,也应该保留一个可聚焦的按钮作为等价入口。

焦点顺序上有一条克制原则:展开之后不要把焦点搬进新区域。用户可能是用 Tab 一路走过来的,焦点被劫持会打乱后面的顺序;状态变化交给 aria-live="polite" 播报一句就够。唯一例外是展开区是编辑表单且交互约定就是”进去填”,那要在设计里写明并全表统一。

五、点击冲突与嵌套表格:两处最容易反复改的地方

冒泡冲突的直觉解法是在按钮的处理器里调 stopPropagation。它能用,但要注意作用范围:框架的合成事件树里调用它,只影响框架内部的分发;同一层还挂了原生监听或第三方脚本时,两边的传播互不干扰,很容易出现”这里挡住了、那里没挡住”。更稳的做法是把决策收到一个入口:行级处理器里判断事件目标是否落在展开控件上(例如向上查第一层带 data-expand-toggle 的祖先),命中就只展开、不选中。单一判定点的好处是行选中、复选框、展开三个动作互相之间永远有一条明确的优先级,嵌套再深也不用到处补 stopPropagation。

优先级建议写成:复选框列的点击只改选中,操作列的按钮只执行动作,加号列只展开,行空白区域才承担”选中这一行”。把”点行即展开”当成默认行为的项目,后期加行选中时几乎一定回炉。

嵌套表格有三个具体的坑。其一是列宽:展开区塞进一个 colspan 的单元格里,子表宽度会参与父表列宽计算,明细列一多就把整张父表撑变形——把子表放在一个自己的滚动容器里,配合固定的表格布局,让溢出发生在容器内部而不是父表。其二是粘性列:横向滚动容器会改变粘性定位的参照,父表冻结列在展开后可能出现错位或失效,验证时要带着横向滚动一起做。其三是命名空间:子表也是表格组件,它的行 key 会和父表处在同一个 key 空间,主键重复时父表状态被子表污染,所以复合前缀不只是唯一性问题,也是嵌套问题。

展开动画的塌陷则来自另一处:高度从 0 到”由内容决定”在 CSS 里不是可插值的两个值。可行的两类写法是网格轨道(grid-template-rows: 0fr 过渡到 1fr)或者先测高再显式给终值。前者要注意内部元素需要禁止溢出且收起时不可达;后者要注意内容变化后要重测,明细里嵌图表时这个成本很容易失控。

六、洹拓视角:把”记住展开状态”翻译成可验收的规格

需求原话和目标之间的差距,就是我们在这类页面上评审的主要对象。下面这张表可以直接拿去当评审模板。

需求表述 实际要补的规格 落在哪一层
记住展开状态 记到本页、跨页、刷新后、还是换设备后 组件状态、路由地址、本地存储、服务端偏好,四级选一
支持全部展开 是全量数据还是当前页;跨页时后端是否返回全部匹配主键 需要后端按筛选条件给主键集合,并设条数上限保护
展开看明细 明细接口按什么参数取、返回体里有没有可对齐的标识 请求与响应都要带同一个业务主键
明细很大 分页、虚拟滚动还是懒加载,超出多少行改成独立详情页 展开区内部自己的分页规则
要能分享现场 展开集合是否进地址;条目多时如何限长 折叠进 hash 或改用”查询条件 + 展开摘要”表达
无障碍要合规 键盘路径、读屏播报、焦点管理由谁验收 用读屏和纯键盘各跑一遍主流程作为验收条件

服务端分页下的”记住所有展开项”,要补的事比看上去多。落到实现上,我们的验收清单是这几条:

  1. 状态容器里只有主键集合,没有任何下标,也没有整份行数据。
  2. 切换排序、筛选、页大小、页码之后,展开行仍然指向同一批业务数据;筛选条件变更时集合按规则收敛,不留失效 id。
  3. 明细请求有加载中、失败重试、同键去重、竞态丢弃四条路径,且都能人工复现。
  4. 跨页展开不依赖把全量数据拉到前端;需要”全部展开”时走后端主键接口,并有超限提示。
  5. 只用键盘能完成展开、阅读明细、收起三步;读屏能听到状态变化。
  6. 刷新后行为符合约定——要么从地址或本地存储恢复,要么明确”不恢复”,不允许实现者自己决定。

回到那句”翻页后它还记不记得”:记不记得是存活范围的问题,记错才是存哪的问题。先把主键这条线立住,再谈取数时机和存活范围,展开收起这类交互基本就不再是返工点了。