在程序化滚动完成后触发效果

当以编程方式滚动到某个部分或元素时(尤其是使用 behavior: 'smooth'),如果立即触发后续操作(例如闪烁突出显示状态、启动进入动画或移动焦点),会导致在目标仍处于屏幕外或滚动过程中时运行该效果。

以前,开发者必须使用 setTimeout() 猜测滚动时长(在长滚动时触发过早,在短滚动时触发过晚),或者手动将 scrollend 事件监听器封装在具有后备计时器的 Promise 中(因为当目标已在视图中且未发生滚动时,scrollend 不会触发)。

Element(scrollIntoView()、scroll()、scrollTo()、scrollBy())和 Window(scroll()、scrollTo()、scrollBy())上的程序化滚动方法会返回一个 Promise,该会在滚动操作完成时解析。如果目标已处于请求的滚动位置,无需滚动,则返回的 Promise 会立即解析,而不会挂起。

实现

  1. 将处理程序标记为 async:为触发滚动的事件监听器或导航处理程序使用 async 函数。
  2. 直接等待滚动方法:调用 await element.scrollIntoView({ behavior: 'smooth', ... })(或 await element.scrollTo(...)、await window.scrollTo(...) 等)。执行会暂停,直到浏览器完成滚动动画且滚动位置稳定下来。
  3. 应用滚动后效果:在 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);
  });
}