轮播图组件怎样设计?无缝循环和自动播放要注意什么?
无缝循环常用的做法是在首尾各复制一张,滑到复制的那张后关掉过渡、瞬间跳回真实的那张,用户看不出跳转;也可以只渲染当前和相邻几张,按下标取模。自动播放必须提供暂停和继续按钮,键盘焦点进入轮播或鼠标悬停时要停止轮播,页面隐藏时也应暂停。容器用 aria-roledescription="carousel",每张用 role="group" 加 aria-roledescription="slide",自动播放时 aria-live 为 off,停止后改为 polite。
简答
首页的 banner 轮播,几张图横着排在一条轨道上,轨道用
transform: translateX移动,每次露出一张。难点是“无缝循环”:从最后一张往后翻,要像是自然地滑到第一张,而不是倒着滑回去。常用做法是在首尾各复制一张:真实顺序是 1、2、3,轨道上放 3、1、2、3、1。从真实的 3 往后滑到复制的 1,等过渡结束,关掉过渡,瞬间把位置换到真实的 1,因为两张图一样,用户看不出来。
自动播放靠定时器切换下标,但 WAI-ARIA 的轮播模式要求必须有暂停和继续的按钮,键盘焦点进入轮播、鼠标悬停时也要停止轮播。页面切到后台时,按
visibilitychange暂停,回来再继续。要注意几个细节。跳回真实位置要在
transitionend里做,但过渡被打断时这个事件不会触发,要同时监听transitioncancel;用户开了“减少动态效果”时应关掉滑动动画;容器和每一张要按 APG 标上aria-roledescription,自动播放期间aria-live设为off。
首尾复制为什么能做到无缝
先看下标怎样换算。轨道上的位置比真实下标多 1,因为最前面多了一张复制的最后一张。下面的例子模拟连续往后翻和往前翻,看每一步轨道位置、是否需要瞬间跳回,以及最终显示的是哪张。
const slides = ['A', 'B', 'C'];
const track = [slides[slides.length - 1], ...slides, slides[0]];
console.log(track.join(',')); // C,A,B,C,A
let position = 1;
function go(step) {
position += step;
const shown = track[position];
let jump = '';
// 过渡结束后才做这一步:滑到复制的那张,就无过渡地换到真实的那张。
if (position === track.length - 1) {
position = 1;
jump = ',跳回 1';
} else if (position === 0) {
position = slides.length;
jump = ',跳回 ' + slides.length;
}
return '显示 ' + shown + jump;
}
console.log(go(1)); // 显示 B
console.log(go(1)); // 显示 C
console.log(go(1)); // 显示 A,跳回 1
console.log(go(-1)); // 显示 C,跳回 3
第三次往后翻,轨道滑到第 4 格,那里是复制的 A。用户看到的是 C 自然地滑到 A,接着代码把位置换回第 1 格的真实 A。两张一样,所以这次跳转看不出来。往前翻也是同理,从真实的 A 往前滑到第 0 格复制的 C,再换到第 3 格真实的 C。
在浏览器里,“换回真实位置”要分两步:先把过渡关掉再改 transform,然后在下一帧重新打开过渡,否则浏览器会把这次跳转也做成动画,用户就会看到画面倒着滑回去。
为什么跳回要放在 transitionend 里
如果滑动一开始就跳,动画还没播完画面就变了。所以要等过渡结束,也就是 transitionend 事件触发时再跳。
可是 MDN 写明,过渡在完成前被移除,比如 display 被设成 none,transitionend 不会触发;触发了 transitioncancel 就不会再触发 transitionend。轮播被隐藏或者动画被打断时,如果只听 transitionend,位置就会停在复制的那张上,下一次再往后翻就越界了。所以两个事件都要监听,并且在处理函数里按当前位置判断是否需要跳回。
用户快速连点“下一张”时,前一次过渡还没结束就开始了下一次。简单的做法是在过渡进行中忽略点击,或者在点击时先把位置规范到真实范围内再继续。
另一种不复制的做法是只渲染当前张和左右相邻的一两张,下标按 (index + n) % n 取模,每次切换后重新排列这几张。它不需要复制 DOM,适合每张内容很重的情况,代价是要自己管理这几张的位置和进出动画。
自动播放要满足哪些要求
自动播放就是一个定时器,每隔几秒调用一次 go(1)。可是 WAI-ARIA 的 Carousel 模式对它有明确要求。
第一,自动轮播必须提供一个停止和重新开始的按钮,最好用原生 button。按钮的文字随状态变化,比如“停止轮播”和“开始轮播”,因为文字已经说明了状态,就不要再加 aria-pressed。这个按钮在轮播内部的 Tab 顺序里排第一个。
第二,键盘焦点进入轮播里的任何元素时,轮播要停止,并且只有用户点了开始按钮才恢复;鼠标悬停在轮播上时也要停止。原因是用户正在读某一张,内容突然换掉会打断阅读。
第三,点击上一张、下一张或者停止按钮后,焦点留在按钮上,方便连续按。
另外,MDN 说明用户切换标签页、最小化浏览器时页面会触发 visibilitychange,document.hidden 变成 true,这时适合停掉用户不需要在后台运行的任务。轮播在后台没人看,可以在这里清掉定时器,回到前台再继续。用户在系统里开了“减少动态效果”时,prefers-reduced-motion: reduce 会匹配,这时应关掉滑动过渡,直接切换。
ARIA 怎样标注
APG 规定的标注方式如下:
<section aria-roledescription="carousel" aria-label="热门活动">
<button type="button">停止轮播</button>
<button type="button" aria-label="上一张">‹</button>
<button type="button" aria-label="下一张">›</button>
<div aria-live="off" aria-atomic="false">
<div role="group" aria-roledescription="slide" aria-label="1 / 3">...</div>
<div role="group" aria-roledescription="slide" aria-label="2 / 3">...</div>
<div role="group" aria-roledescription="slide" aria-label="3 / 3">...</div>
</div>
</section>
容器的角色用 region 或 group,section 有可访问名称时就是 region;名称里不要再写“carousel”或“轮播”,因为 aria-roledescription 已经说了。每一张是 role="group" 加 aria-roledescription="slide",没有独特标题时可以用“3 / 10”这样的位置当名称,因为 group 不支持 aria-setsize 和 aria-posinset。
aria-live 要跟着自动播放切换:自动轮播时设成 off,否则屏幕阅读器每几秒就会播报一次;停止以后改成 polite,用户手动翻页时才会读出新内容。首尾复制出来的那两张不是真实内容,应该加 aria-hidden="true" 并让里面的链接不可聚焦,否则屏幕阅读器会读到重复的内容。
常见追问
手机上的滑动手势怎样做?
用 Pointer Events 记录按下时的位置,移动时让轨道跟着手指偏移,此时关掉过渡;松开时看偏移距离或速度是否超过阈值,超过就翻页,否则回到原位,再把过渡打开。还要设置
touch-action: pan-y,让纵向滚动仍交给浏览器。
轮播里的图片怎样优化加载?
第一张可能是首屏最大内容,应该正常加载;其余几张用
loading="lazy"或者在即将切换到时再加载。复制出来的那两张不是真实内容,也可以等到需要显示时再加载。