纯前端实现可访问树形控件:键盘巡览、懒加载与长列表虚拟化

举报
yd_213843956 发表于 2026/11/07 09:01:09 2026/11/07
【摘要】 树形控件不只是嵌套列表加一个三角图标。这篇讲清 ARIA 结构、键盘巡览规则、展开状态来源、异步懒加载、虚拟化下的焦点保持与多选三态。

文件浏览器、组织架构、分类目录都离不开树形控件。它看起来只是嵌套列表加上展开箭头,但真正难的地方在键盘和状态:焦点如何在层级之间移动,展开状态由谁维护,异步子节点加载失败怎么恢复,长列表虚拟化后焦点会不会飞出可视区。这些问题处理不好,鼠标用户觉得能用,键盘和读屏用户则完全走不通。

一、用列表和分组表达层级

树的结构应当由容器和分组表达,而不是靠缩进样式暗示层级。常见做法是容器使用 tree 角色,每个节点是 treeitem,节点内部再放一个 group 承载子节点。每一层的 group 让辅助技术知道“这些是上一项的子集”,而不是一串平铺的条目。

<ul role="tree" aria-label="项目文件">
  <li role="treeitem" aria-expanded="false" aria-level="1">
    src
    <ul role="group" hidden>…</ul>
  </li>
</ul>

aria-level 可以省略,由嵌套结构推导,但混用两种方式容易出错。节点数量多时,建议在初始渲染时一次性算好层级和位置信息,而不是每次按键都遍历 DOM 重新计算。

二、键盘巡览要有固定规则

树不是普通列表,除了上下键,还要处理展开、折叠和进入子级。经验规则是:上下键在可见节点之间移动,右方向键展开当前节点或进入第一个子节点,左方向键折叠当前节点或回到父节点,Home 与 End 跳到首末节点。回车和空格用于选择或激活,具体语义由产品决定,但必须在整个应用内保持一致。

不要把焦点放在每个可点击的行容器上再额外加 tabindex。树整体采用一个可聚焦入口加活性焦点节点的模式更稳妥:整棵树只有一个 tabindex=0,其余节点为 -1,移动时同步更新两处属性,Tab 键才能整体进出树而不会陷入几十个节点。

function moveFocus(nodes, currentIndex, nextIndex) {
  nodes[currentIndex]?.setAttribute('tabindex', '-1');
  const next = nodes[nextIndex];
  next.setAttribute('tabindex', '0');
  next.focus({ preventScroll: true });
  next.scrollIntoView({ block: 'nearest' });
}

focus 后补一次 scrollIntoView 很重要,否则节点被移入焦点却停在可视区之外,读屏会播报一个用户看不到的条目。

三、展开状态只保留一份来源

展开状态同时影响 aria-expanded、group 的隐藏、箭头图标和请求逻辑。最忌讳四处各存一份布尔值:图标翻过来了,group 还开着;或者视觉上折叠了,aria-expanded 仍是 true。应把展开集合作为唯一状态,渲染时统一从集合推导所有表现。

用 Set 保存已展开节点的 ID,比在每个节点对象上挂 expanded 字段更容易做持久化。用户刷新后恢复展开路径时,按 ID 顺序还原,父节点必须比子节点先展开,否则子节点会因为父级隐藏而失效。

折叠父节点时,如果焦点正停在某个子节点上,需要把焦点移回父节点。这一步经常被遗漏:节点从可见变隐藏,焦点会掉到 body,之后的按键全部失效。

四、懒加载要有明确的中间态

目录很大时通常按需拉取子节点。点击展开的瞬间应先进入 loading 状态,显示一个可被读屏识别的“正在加载”提示,而不是先翻箭头再补内容。加载失败时保留展开意图并提供重试,不要静默回到折叠,否则用户会以为节点本来就没有子节点。

async function toggle(node) {
  if (!node.loaded && !node.loading) {
    node.loading = true;
    try {
      node.children = await fetchChildren(node.id);
      node.loaded = true;
    } catch (error) {
      node.error = '加载失败,重试';
      node.loading = false;
      return;
    }
    node.loading = false;
  }
  expanded.has(node.id) ? expanded.delete(node.id) : expanded.add(node.id);
}

并发展开多个节点时,返回顺序不可控。每个请求返回后只更新自己对应的节点,并检查节点是否仍存在、是否已被删除,避免迟到的响应把已关闭的分支又撑开。

五、虚拟化与焦点保持

几千个节点直接渲染会拖慢首屏。虚拟化只渲染可视区附近的若干行,但树的行高会随展开折叠变化,所以不能套用固定高度的列表方案。比较稳妥的是先把可见节点拍平成数组,记录每行的估算高度,滚动时根据累计偏移计算窗口,再异步测量真实高度并修正。

虚拟化最大的风险是焦点节点被移出渲染窗口。当光标所在行滚出范围后,DOM 被回收,焦点丢失,方向键随即失效。解决办法是让焦点永远跟随“逻辑当前项”,渲染窗口变化时如果当前项已不在 DOM 中,就在下一次按键后重建节点并重新聚焦;更简单的做法是给窗口留出足够缓冲,并保证当前项始终在渲染范围内。

读屏用户无法像鼠标用户那样借助视觉定位,因此不要在虚拟化时改变 aria-level 或节点顺序。对外暴露的结构必须与未虚拟化时完全一致。

六、多选与三态复选框

多选树常见于权限配置。父节点的勾选状态由子节点推导:全选为 checked,部分选择为 mixed,未选为 false。mixed 只能通过属性和 aria-checked 的 mixed 值表达,不能用视觉上的横杠代替。

切换父节点时应批量更新后代,切换子节点时向上逐级重算祖先。这个重算必须幂等,否则连续快速点击会出现父节点状态与子节点不一致。异步保存时提交完整的选择集合,而不是增量的加减操作,重试才不会重复生效。

七、搜索过滤与命中展开

搜索时应保留命中节点及其祖先路径,其余节点隐藏。直接把不匹配的节点从数据里删掉会丢失展开状态,清空搜索后无法还原。更合适的做法是维护一份可见集合,渲染时据此裁剪。

搜索命中后自动展开到第一个结果并把焦点移过去,能让键盘用户少走很多步。同时要播报命中数量,并在无结果时给出明确提示,而不是留一片空白。

八、异步操作与一致性

重命名、移动、删除可能改变层级和排序。不要在本地先改再等接口,因为接口失败后很难还原展开路径和焦点位置。可以先记录操作前的快照,失败时按节点 ID 回滚,并把焦点恢复到被操作节点;节点已被删除时,焦点落到相邻节点。

拖拽排序在树上比列表复杂,还需要判断放置位置是“成为子节点”还是“插到同级之前”。放置指示必须与最终结果一致,并在拖拽期间持续更新 aria-dropeffect 或等效的状态描述,让辅助技术知道当前可能的落点。

九、测试重点

覆盖首末节点、跨层移动、展开折叠、Home 与 End、懒加载成功与失败、并发展开、折叠时焦点回收、虚拟化滚动后按键、搜索过滤后还原、多选三态重算、删除后焦点落点。用键盘从根节点一路走到叶子再返回,往往比看十张截图更容易发现层级和状态问题。

十、小结

可访问树形控件的核心是把层级、展开状态和当前位置都收敛成清晰的数据模型,再由模型推导出 ARIA 属性、可见行和键盘行为。虚拟化和懒加载只是性能手段,不能改变对外的结构语义;只要焦点、展开和选择三者始终保持一致,树在鼠标、键盘和读屏下都能稳定工作。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。