让网页更包容从理解无障碍技术核心开始
又踩坑了,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-label 和 aria-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 字段,方便前端统一处理提示文案。
