让网页更包容从理解无障碍技术核心开始

Code°继芳 移动 阅读 2,914
赞 25 收藏
二维码
手机扫码查看
反馈

又踩坑了,iOS VoiceOver 滑动切换页面直接失效

上周上线了个 H5 活动页,主打大屏滑动展示,左右滑动手势切换内容。功能测得挺顺,Android 上用 TalkBack 切换也正常,结果灰度发出去不到两小时,客服就收到反馈:iOS 用户说 VoiceOver 根本没法操作,手指在屏幕上划来划去没反应,页面卡死一样。

让网页更包容从理解无障碍技术核心开始

我第一反应是“不可能啊,之前 demo 里试过能切”,赶紧拿手边的 iPhone 打开 Safari 跑本地环境一试——还真不行。VoiceOver 启动后,左划右划只读当前元素,根本不会触发页面切换。这里我踩了个大坑:之前测试只关注了“能读内容”,没模拟真实用户靠手势导航的流程。

排查过程像在盲人摸象

一开始我以为是 touch 事件被拦截了。毕竟这种全屏滑动组件,一般都会监听 touchstart/touchmove/touchend 来判断滑动方向。我看了下代码:

element.addEventListener('touchmove', (e) => {
  e.preventDefault();
  // 计算位移、触发切换逻辑...
});

这里加了 preventDefault() 是为了防止页面原生滚动干扰滑动判断。但问题来了:iOS VoiceOver 在启用时,用户的“滑动手势”其实是通过系统代理发出的语义化操作,不是原始 touch 事件。你把 touchmove 拦了,等于把 VoiceOver 的指令通道也堵死了。

折腾了半天发现,只要这个 preventDefault() 存在,VoiceOver 的 swipe 手势就收不到任何响应。关掉?那 Android 手指乱滑的时候页面会跟着抖,体验更差。这下尴尬了,功能性 vs 可访问性直接冲突。

后来查文档才意识到一个关键点:VoiceOver 用户根本不需要用“滑动屏幕”来翻页,他们应该通过 双指左/右滑 或者 转子选择“下一个区域” 来导航。但我这个页面所有内容都在一个 div 里,用 transform 做位移,DOM 结构压根没变。对屏幕阅读器来说,它看到的始终是同一个静态块,自然没有“下一页”的概念。

核心问题:结构语义缺失 + 手势劫持

归结下来两个问题:

  • 页面内容没有语义分隔,全是 role="none" 或无角色的 div
  • touch 事件粗暴阻止默认行为,导致辅助技术无法介入

解决思路也就清晰了:既要保留正常用户的滑动手感,又不能影响辅助设备的操作自由度。

最终方案:条件性阻止 touch 默认行为

关键在于判断当前是否处于可访问模式。iOS 提供了一个私有属性 window.navigator.userAgent.includes('CriOS') || window.AccessibilityInfo,但这不通用。后来找到个更实际的办法——检测是否有辅助设备激活的迹象。

其实简单点,我们可以反过来想:只有当用户真正在做“滑动操作”时才阻止默认行为,否则放开让系统处理。于是改成这样:

let startX = 0;
let isSwiping = false;

element.addEventListener('touchstart', (e) => {
  startX = e.touches[0].clientX;
  isSwiping = false; // 重置状态
});

element.addEventListener('touchmove', (e) => {
  const currentX = e.touches[0].clientX;
  const diff = startX - currentX;

  // 阈值判断,轻微移动不算 swipe
  if (Math.abs(diff) > 10) {
    isSwiping = true;
  }

  // 只有确认是 swipe 动作才阻止默认行为
  if (isSwiping) {
    e.preventDefault();
  }
});

这样一来,如果用户只是轻划(比如 VoiceOver 的操作),isSwiping 不会被触发,preventDefault 就不会执行,系统就能正常处理辅助手势。而真正的左右滑动超过阈值后才会接管事件流。

但这还不够。还得让屏幕阅读器知道这是个“可切换的内容组”。于是加上 ARIA 属性:

<div 
  role="region" 
  aria-roledescription="slider" 
  aria-label="内容轮播 第1页 共3页"
  aria-live="polite"
  tabindex="0"
>
  <!-- 页面内容 -->
</div>

每次切换页面时动态更新 aria-labelaria-live,让 VoiceOver 能播报当前页数。虽然不如原生路由跳转那么清晰,但至少能让视障用户感知到位置变化。

更进一步:提供键盘和焦点支持

别忘了还有键盘用户。现在很多人用蓝牙键盘配合 iPad 浏览网页。我们加了两个隐藏按钮用于键盘导航:

<button 
  aria-label="上一页" 
  class="sr-only"
  onclick="goToPrev()"
>
</button>
<button 
  aria-label="下一页" 
  class="sr-only"
  onclick="goToNext()"
>
</button>

CSS 里 .sr-only 是经典隐藏样式:

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

这样键盘用户 tab 键能聚焦到这两个按钮,实现非触摸环境下的导航。顺便还修复了一个 bug:之前完全依赖 touch,外接鼠标都没法操作。

改完后仍有小问题

目前最大的遗留问题是:当页面自动轮播时,aria-live="polite" 有时会延迟播报,甚至漏读一次。试过改成 assertive,结果太吵,打断用户当前操作。最后妥协成只在用户手动切换时触发播报,自动轮播静默处理。也算权衡吧,毕竟不能为了无障碍牺牲整体体验。

另一个细节是 aria-roledescription="slider" 并非所有安卓读屏软件都支持,部分国产 ROM 会直接忽略。但 iOS VoiceOver 显示“滑块”描述是有效的,至少主战场没问题。

总结一下我踩过的坑

1. **不要无差别 preventDefault**:尤其在移动端,这会断送辅助设备的控制权。

2. **结构决定可访问性**:哪怕视觉上是一个连续动画,DOM 也要尽量体现语义层级。

3. **测试必须用真设备+开启读屏**:模拟器看 DOM 没用,得亲手划一划才知道哪里卡住。

4. **渐进增强思维很重要**:基础功能(如按钮)优先保证可用,再叠加手势优化。

这个方案不是最优解,比如理想情况应该是每页用 <section> 包裹并配合 aria-current,但我们项目时间紧,改结构成本太高。现在的做法算是折中:保持原有交互不动,在边缘做兼容补救。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。特别是关于自动轮播时如何优雅播报状态,我还挺头疼的。另外,fetch(‘https://jztheme.com/api/page-info’) 这种接口返回的页码信息,也可以考虑加个 accessible_hint 字段,方便前端统一处理提示文案。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
轩辕瑞雪
这篇文章的排版和结构太用心了,重点突出,让我能快速抓住核心,阅读效率很高。
点赞
2026-03-20 15:25