Если вы используете программную прокрутку к разделу или элементу (особенно с помощью 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.
Прерывание прокрутки и управление фокусом
- Обработка быстрой или прерванной прокрутки. Если новая прокрутка начинается в том же контейнере до завершения текущей плавной прокрутки, браузер прерывает текущую плавную прокрутку и разрешает (не отклоняет) все ожидающие обещания прокрутки в этом контейнере. Если вам нужно подавить эффект после прокрутки для замененных прокруток, когда пользователь быстро нажимает на экран, отслеживайте идентификатор операции увеличения и возвращайтесь к началу после
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');
}
- Фокусировка на целевом элементе после прокрутки. При перемещении фокуса клавиатуры на целевой элемент (или заголовок внутри него с помощью
tabindex="-1") послеawait targetElement.scrollIntoView(...)передайте{ preventScroll: true }вfocus(), чтобы вызов фокуса не инициировал вторую, конкурирующую прокрутку:
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetHeading.focus({ preventScroll: true });
Ограничения и рекомендации
- ОБЯЗАТЕЛЬНО. Ожидайте
Promise, возвращаемый непосредственноscrollIntoView(),scroll(),scrollTo()илиscrollBy(), а не угадывайте длительность прокрутки с помощьюsetTimeout(). - НЕ используйте
scrollendв качестве основного метода реализацииnew Promise().scrollendне срабатывает, если целевой элемент уже находится в области просмотра и прокрутка не выполняется, тогда как метод прокрутки в этом случае выполняется автоматически. - РЕКОМЕНДУЕТСЯ передавать
{ preventScroll: true }при вызове.focus()после ожидания метода прокрутки, чтобы браузер не переходил и не прокручивал страницу во второй раз. - РЕКОМЕНДУЕТСЯ. Если быстрые действия пользователя могут вызвать наложение плавной прокрутки в одном контейнере, защитите эффекты после прокрутки с помощью токена запроса, поскольку прерывание плавной прокрутки разрешает (а не отклоняет) предыдущее обещание.
- Известная ошибка Chrome с корневым скроллером. В Chrome есть ошибка, из-за которой
Promise, возвращаемыйscrollIntoView(), разрешается слишком рано, когда прокручивается корневой скроллер документа. ОжиданиеscrollIntoView()во вложенном контейнере прокрутки работает правильно. Не помещайте контент в контейнер с прокруткой, напримерdivсoverflow-y: auto, чтобы обойти эту ошибку.
Резервные стратегии
В браузерах, которые не поддерживают промисы методов прокрутки, scrollIntoView(), scroll(), scrollTo() и scrollBy() возвращают undefined вместо Promise.
- Прогрессивное улучшение (поведение
awaitпо умолчанию). Посколькуawait undefined– это допустимый код JavaScript, который выполняется сразу же при следующей микрозадаче, использованиеawait element.scrollIntoView({ behavior: 'smooth' })не приведет к ошибке в неподдерживаемых браузерах. Вместо этого он выполняет код, который должен был сработать после прокрутки, сразу же, как только она начинается. Для некритичных визуальных эффектов, таких как временная подсветка, этого часто бывает достаточно. - Надежный резервный вариант (
scrollend+ тайм-аут). Если целевой браузер Baseline не поддерживает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);
});
}