当以编程方式滚动到某个部分或元素时(尤其是使用 behavior: 'smooth'),如果立即触发后续操作(例如闪烁突出显示状态、启动进入动画或移动焦点),会导致在目标仍处于屏幕外或滚动过程中时运行该效果。
以前,开发者必须使用 setTimeout() 猜测滚动时长(在长滚动时触发过早,在短滚动时触发过晚),或者手动将 scrollend 事件监听器封装在具有后备计时器的 Promise 中(因为当目标已在视图中且未发生滚动时,scrollend 不会触发)。
Element(scrollIntoView()、scroll()、scrollTo()、scrollBy())和 Window(scroll()、scrollTo()、scrollBy())上的程序化滚动方法会返回一个 Promise,该会在滚动操作完成时解析。如果目标已处于请求的滚动位置,无需滚动,则返回的 Promise 会立即解析,而不会挂起。
实现
- 将处理程序标记为
async:为触发滚动的事件监听器或导航处理程序使用async函数。 - 直接等待滚动方法:调用
await element.scrollIntoView({ behavior: 'smooth', ... })(或await element.scrollTo(...)、await window.scrollTo(...)等)。执行会暂停,直到浏览器完成滚动动画且滚动位置稳定下来。 - 应用滚动后效果:在
await表达式完成后,切换突出显示类、启动动画或移动焦点。
中断的滚动和焦点管理
- 处理快速/中断的滚动:如果正在进行的流畅滚动尚未完成,而同一滚动容器上又开始了新的滚动,浏览器会中止正在进行的流畅滚动,并解决(不拒绝)该容器上所有待处理的滚动 promise。如果您需要在用户快速点击时抑制被取代的滚动的滚动后效果,请跟踪递增的操作 ID,并在启动了更新的滚动时在
await之后提前返回:
let currentScrollId = 0;
const sections = document.querySelectorAll('section');
async function scrollToAndHighlight(targetElement) {
const scrollId = ++currentScrollId;
sections.forEach((sec) => sec.classList.remove('highlight'));
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Abort follow-up effect if another scroll was triggered while awaiting
if (scrollId !== currentScrollId) return;
targetElement.classList.add('highlight');
}
- 在滚动后聚焦目标:在
await targetElement.scrollIntoView(...)之后将键盘焦点移至目标元素(或其中带有tabindex="-1"的标题)时,请将{ preventScroll: true }传递给focus(),这样焦点调用就不会启动第二次竞争性滚动:
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetHeading.focus({ preventScroll: true });
限制和最佳实践
- 强制性:等待
scrollIntoView()、scroll()、scrollTo()或scrollBy()直接返回的Promise,而不是使用setTimeout()猜测滚动时长。 - 请勿将
scrollend封装在手动new Promise()中作为主要实现方式,因为当目标已在视图中且未发生滚动时,scrollend不会触发,而滚动方法 promise 在这种情况下会自动解析。 - 建议:在等待滚动方法后调用
.focus()时,传递{ preventScroll: true },这样浏览器就不会再次跳转或滚动。 - 建议:如果快速的用户互动可能会在同一容器上触发重叠的流畅滚动,请使用请求令牌来保护滚动后效果,因为中断流畅滚动会解析(而不是拒绝)之前的 promise。
- 根滚动条存在已知的 Chrome bug:Chrome 存在一个 bug,即当文档的根滚动条滚动时,
scrollIntoView()返回的Promise会过早解析。在嵌套滚动容器上等待scrollIntoView()会按预期运行。请勿将内容封装在滚动容器(例如具有overflow-y: auto的div)中,以此作为此 bug 的解决方法。
后备策略
在不支持滚动方法 promise 的浏览器中,scrollIntoView()、scroll()、scrollTo() 和 scrollBy() 会返回 undefined,而不是 Promise。
- 渐进增强(默认
await行为):由于await undefined是有效的 JavaScript,会在下一个微任务中立即解析,因此编写await element.scrollIntoView({ behavior: 'smooth' })不会在不受支持的浏览器中抛出错误。而是会在滚动开始后立即运行滚动后代码,从而实现平稳降级。对于非关键的视觉效果(例如临时突出显示),这通常就足够了。 - 可靠的后备方案(
scrollend+ 超时):如果您的基准目标不支持scroll-promises,并且在旧版浏览器中,滚动后操作必须等到滚动完成,请检查返回值是否为Promise(result instanceof Promise)。如果是undefined,请等待滚动容器上的scrollend事件,并设置一个较短的后备超时,以防元素已在视图中且未发生滚动:
function scrollIntoViewAsync(element, options, scroller = window) {
const result = element.scrollIntoView(options);
// Feature detect: in supporting browsers, scrollIntoView() returns a Promise
if (result instanceof Promise) {
return result;
}
// Fallback for browsers where scrollIntoView() returns undefined
return new Promise((resolve) => {
let timeoutId;
const finish = () => {
clearTimeout(timeoutId);
scroller.removeEventListener('scrollend', finish);
scroller.removeEventListener('scroll', onScroll);
resolve();
};
const onScroll = () => {
// Restart the idle timer on every scroll event. If scrollend never fires
// (browsers without scrollend support), this resolves once scrolling goes idle.
clearTimeout(timeoutId);
timeoutId = setTimeout(finish, 100);
};
scroller.addEventListener('scrollend', finish, { once: true });
scroller.addEventListener('scroll', onScroll, { passive: true });
// If no scroll event fires within the idle window (e.g., already in view), resolve.
// 100ms is an example value; it only needs to exceed the gap between scroll events.
timeoutId = setTimeout(finish, 100);
});
}