Почему горизонтальный скролл по прокрутке дёргается в Тильде
Горизонтальную ленту, которая едет от прокрутки страницы, делают в Тильде своим кодом — и она почти всегда дёргается. Причина не в устройстве и не в количестве элементов: три ошибки в обработчике превращают одну строчку работы в шестьсот пересчётов раскладки. Все три измерены счётчиками браузера. А дальше вылезает второй сюжет: контейнер, которым обрезают вылет, отменяет липкость — и Тильда делает то же самое со всей страницей.
Короткий ответ
Горизонтальный скролл дёргается из-за трёх вещей: сдвиг свойством left вместо transform, чтение
геометрии сразу после записи и работа в каждом событии прокрутки вместо кадра.
Замер: у худшей реализации 620 пересчётов раскладки и 19 мс на одной и той же
прокрутке, у правильной — 1 пересчёт и 0 мс. Порядок один: слушать пассивно,
считать раз в кадр, двигать трансформом.
Пять реализаций одного эффекта
Стенд: секция высотой 3000 пикселей, двадцать элементов, сдвиг пропорционально
прогрессу прокрутки. Прокрутка одинаковая для всех — шестьдесят колёсиков по
сорок пикселей, 2400 пикселей. Считает не глаз, а счётчики браузера через
протокол: LayoutCount, RecalcStyleCount, LayoutDuration.
| Реализация | Пересчётов раскладки | Пересчётов стилей | На раскладку, мс |
|---|---|---|---|
transform, чтение сразу после записи | 20 | 620 | 1 |
transform, все чтения, потом записи | 1 | 31 | 0 |
transform, раз в кадр через rAF | 1 | 31 | 0 |
left, все чтения, потом записи | 31 | 31 | 3 |
left, чтение сразу после записи | 620 | 620 | 19 |
Одна и та же лента, одна и та же прокрутка, разница в шестьсот раз по пересчётам раскладки. Три вывода читаются прямо из таблицы.
Свойство сдвига. transform не меняет раскладку: строка «все чтения, потом
записи» даёт 1 пересчёт против 31 у left. Единица — это первый, неизбежный.
left заставляет пересчитывать раскладку на каждом событии прокрутки, потому что
положение элемента в потоке меняется по-настоящему.
Чтение сразу после записи. Записали transform, тут же спросили
getBoundingClientRect() — и браузер обязан посчитать всё немедленно, посреди
обработчика, потому что иначе не сможет ответить. Двадцать элементов, тридцать
одно событие: 620 пересчётов стилей вместо 31. В паре с left то же самое
превращается в 620 пересчётов раскладки и 19 миллисекунд.
Кадр. Между «в обработчике, но без чтений» и «раз в кадр» счётчики совпали:
1 и 31 у обоих. Само по себе requestAnimationFrame не ускоряет — оно
дисциплинирует. Как только в обработчике появляется чтение (а оно появляется:
прогресс считают по getBoundingClientRect секции), кадр становится единственным
местом, где чтения и записи можно развести гарантированно.
Порядок, который не дёргается
var section = document.querySelector('#rec123456 .t396__artboard');
var items = section.querySelectorAll('.js-lane-item');
var queued = false;
function paint() {
queued = false;
/* Сначала читаем — один раз на кадр, а не на элемент. */
var box = section.getBoundingClientRect();
var travel = box.height - window.innerHeight;
var progress = travel > 0 ? Math.min(1, Math.max(0, -box.top / travel)) : 0;
/* Потом пишем и больше ничего не читаем. */
for (var i = 0; i < items.length; i++) {
items[i].style.transform = 'translate3d(' + -progress * 1200 + 'px,0,0)';
}
}
window.addEventListener('scroll', function () {
if (queued) return;
queued = true;
requestAnimationFrame(paint);
}, { passive: true });
Четыре вещи, каждая из таблицы выше: passive: true, флаг вместо работы в
обработчике, одно чтение на кадр до всех записей, сдвиг трансформом. Прогресс
считается от секции, а не от window.scrollY — тогда лента не зависит от того,
что стоит на странице выше.
Двадцать элементов сдвигаются одним циклом. Если их больше сотни, дешевле сдвигать один контейнер, а не каждый элемент: пересчёт стилей платится за каждый изменённый узел, и это видно в разнице 620 против 31.
Второй сюжет: после ленты перестала работать липкость
Горизонтальный вылет обрезают контейнером с overflow. И вот что при этом
происходит с position: sticky внутри него — замер, пять контейнеров, липкий
элемент с top: 20px, прокрутка до места, где он обязан прилипнуть:
| Контейнер | overflow-x | overflow-y | top липкого | Прилип |
|---|---|---|---|---|
| без ограничений | visible | visible | 20 | да |
overflow: hidden | hidden | hidden | −380 | нет |
overflow-x: hidden | hidden | auto | −380 | нет |
как .t396__artboard | hidden | hidden | −380 | нет |
overflow-x: clip | clip | visible | 20 | да |
overflow-x: hidden — обычный способ убрать горизонтальную полосу — отменяет
липкость. Причина в том, что hidden создаёт прокручиваемый контейнер, и липкий
элемент начинает считать свою границу по нему, а не по окну. clip контейнера не
создаёт: то же обрезание, липкость на месте.
Четвёртая строка — это артборд Zero Block. Его собственное правило:
.t396__artboard { position: relative; width: 100%; height: 100vh; overflow: hidden; }
Внутри артборда position: sticky не работает никогда, и это не поправить своим
кодом изнутри.
Тильда делает это сама, со всей страницей
Самое неожиданное — не артборд, а вот эта функция из рантайма Zero Block:
function t396__setOverflowToAllRecords() {
var t = document.getElementById('allrecords');
if (t) {
var e = ['.t951__sidebar_sticky', '.t1206__content_sticky', '.t754__col_fixed',
'.t776__col_fixed', '.t786__col_fixed',
'.t-store__prod-popup__col_fixed', '.t-catalog__sidebar_sticky'];
t396__generateOverflowStyle();
var i = Boolean(document.querySelector(e.join(', ')));
t.classList.remove('t-records__overflow-clip', 't-records__overflow-hidden');
t.classList.add(i ? 't-records__overflow-clip' : 't-records__overflow-hidden');
}
}
Стили она вставляет сама, в <style id="t396__overflow-styles">:
.t-records__overflow-clip { overflow: clip; }
.t-records__overflow-hidden { overflow: hidden; }
Читается так: Тильда вешает overflow на #allrecords — контейнер всей
страницы. clip она выбирает, только если на странице нашёлся один из семи её
собственных липких блоков: липкая колонка в t951, липкое содержимое в t1206,
фиксированные колонки t754, t776, t786, липкая колонка в карточке товара и липкая
боковина каталога. Своих липких элементов в этом списке, разумеется, нет — на них
достаётся overflow: hidden на всю страницу, и они перестают прилипать.
Вызывается это не всегда, а при одном условии — когда у артборда включён вылет:
var n = r.getAttribute('data-artboard-ovrflw');
if (['visible', 'visibleX'].includes(n) && (t396__setOverflowToAllRecords(), …
В четырёх снятых страницах Тильды атрибута data-artboard-ovrflw нет ни разу:
по умолчанию его не ставят. Он появляется, когда в настройках артборда
разрешают содержимому выходить за границы — а разрешают его как раз для
горизонтальных лент и вылетающих элементов. То есть последовательность обычная:
включили вылет у артборда, чтобы лента не обрезалась, — и на всей странице
появился overflow: hidden, от которого перестала прилипать шапка, сделанная
своим кодом.
Что делать. Если на странице есть свой липкий элемент и артборд с вылетом
одновременно — вернуть странице clip вместо hidden, тем же способом, каким это
делает Тильда для своих блоков:
#allrecords.t-records__overflow-hidden { overflow: clip; }
Одно правило, обрезание сохраняется, липкость возвращается — по замеру выше
clip её не отменяет. Проверить, что именно стоит на странице, можно одной
строкой из консоли; это в проверках ниже.
Что проверить у себя
// 1. Кто вообще ограничивает прокрутку над липким элементом
(function (el) {
var out = [];
for (var p = el.parentElement; p; p = p.parentElement) {
var s = getComputedStyle(p);
if (s.overflow !== 'visible' || s.overflowX !== 'visible' || s.overflowY !== 'visible')
out.push((p.id || p.className) + ' → ' + s.overflowX + ' / ' + s.overflowY);
}
return out;
})(document.querySelector('.js-my-sticky'));
// 2. Что Тильда поставила на всю страницу
document.getElementById('allrecords').className.match(/t-records__overflow-\w+/);
// 3. Включён ли вылет у артборда — из-за чего это и произошло
[...document.querySelectorAll('.t396__artboard')]
.map(a => a.id + ' → ' + a.getAttribute('data-artboard-ovrflw'));
// 4. Пассивный ли обработчик — видно по тому, сколько их вообще
getEventListeners(window).scroll?.map(l => l.passive); // только в Chrome DevTools
// 5. Появилась ли горизонтальная полоса у страницы
document.documentElement.scrollWidth + ' против ' + window.innerWidth;
Если во второй проверке стоит t-records__overflow-hidden, а на странице есть
свой липкий элемент — это оно.
Граница MOD-178
План M7 отдельно ставил вопрос, не является ли липкий горизонтальный проход режимом MOD-055 или MOD-127. После технической раскладки это разные продукты:
- MOD-055 управляет переходом между целыми вертикальными секциями;
- MOD-127 относится к глубине движения обложки;
- MOD-178 удерживает одну секцию и переводит её вертикальный прогресс в горизонтальный путь конкретной ленты.
У них можно переиспользовать планировщик прокрутки, но разметка, параметры и критерий результата разные. Поэтому M7 получил отдельный id, а не третий режим в карточке, где дизайнеру пришлось бы разбираться в несовместимых сценариях.
Спецификация MOD-178 закрепляет измеренные требования этой статьи: transform
вместо left, чтения до записей, один кадр, overflow: clip, проверка родной
липкости и обычная прокручиваемая лента при prefers-reduced-motion.
Свободные моды библиотеки — в каталоге; проявление по прокрутке собрано в MOD-041. Если нужна лента с инерцией, синхронная с прокруткой на телефоне, или липкая шапка, которая уживается с артбордом на вылете — это работа под задачу: мод под задачу.