Перейти к содержимому

affix закрепление —— Макет

При прокрутке страницы некоторые элементы нужно «приколоть»: верхнюю навигацию, нижнюю панель действий, боковое оглавление. affix переключает элемент в позиционирование fixed, когда он уходит из вьюпорта, и возвращает обратно при возврате. Сложность не в самом переключении, а в моменте срабатывания и плейсхолдере против схлопывания — core отвечает только за определение и стили, а слежение и плейсхолдер остаются за UI-слоем.

  • Реализация <LhAffix> на всех концах: закрепление навигации вверху страницы, панели действий формы внизу, бокового оглавления во вьюпорте.
  • Сценарии, где элемент должен оставаться видимым во время прокрутки.
  • Не следует использовать: на коротких страницах, где элемент и так в зоне видимости (нечего закреплять); для полноэкранных затемнений (используйте fullscreen из loading); для боковой панели с фиксированной раскладкой (проще напрямую CSS position: sticky — ценность affix в условном поведении «закрепиться только при достижении порога прокрутки»).
import {
getAffixClassNames,
isAffixFixed,
getAffixFixedStyle,
} from '@lh-ui/share/core/affix'
import type { LhAffixState } from '@lh-ui/share/core/affix'

top и bottom — два взаимоисключающих режима: при offsetTop > 0 проверяется «вышла ли верхняя грань за верхний порог вьюпорта», при offsetBottom > 0 — «вышла ли нижняя грань за нижний порог вьюпорта». Оба offset не могут действовать одновременно — isAffixFixed при if (offsetBottom > 0) сначала идёт по ветке нижнего режима. Семантически элемент можно закрепить только с одной стороны.

Для нижнего определения обязательно нужна высота вьюпорта: rect.bottom > viewportHeight - offsetBottom — нижняя грань плейсхолдера должна перейти за отметку «низ вьюпорта − порог», чтобы считаться закреплённой. Этот порог динамический (viewportHeight меняется при изменении размера окна), поэтому UI-слой должен передавать текущую высоту вьюпорта при каждом колбэке, без кэширования.

Функция определения отделена от механизма слежения: isAffixFixed принимает прямоугольный объект { top, bottom } и не интересуется, откуда он взялся — колбэк IntersectionObserver получает boundingClientRect из entry, либо отслеживание через resize/scroll-события с getBoundingClientRect() как запасной путь; оба пути используют одну и ту же логику определения.

top: 'auto' / bottom: 'auto' в getAffixFixedStyle: при закреплении вторая ось явно устанавливается в auto, чтобы элемент не имел одновременно и top, и bottom (при fixed-позиционировании значения по обеим осям растягивают высоту элемента).

Имя класса фиксированности, а не прямая правка стилей: lh-affix--fixed оставляет детали fixed-позиционирования (z-index, тень, переход) на SCSS, а getAffixFixedStyle из core считает лишь конкретные пиксели top/bottom — переключатель позиционирования и смещения разделены.

Функция Сигнатура Возврат Назначение
getAffixClassNames (state: LhAffixState) => string[] классы состояние fixed
isAffixFixed (rect: { top, bottom }, offsetTop, offsetBottom, viewportHeight) => boolean закреплён ли определение выхода из вьюпорта
getAffixFixedStyle (offsetTop, offsetBottom) => Record<string, string> инлайновые стили смещение fixed
export interface LhAffixState {
fixed: boolean // находится ли сейчас в закреплённом состоянии (управляет классом)
}

affix — это чисто визуальное позиционирование, не меняющее семантику содержимого, без собственных aria-атрибутов. Две сопутствующие практики:

  • В закреплённом состоянии не меняйте порядок DOM: после перехода в fixed элемент остаётся на прежнем месте в DOM, скринридер зачитывает его в порядке исходного кода. Если ради визуала перенести его в конец body (teleport), порядок озвучивания скачет — реализация affix должна менять только позиционирование, не трогая DOM.
  • Видимость закреплённого элемента: элемент в закреплённом состоянии всегда находится во вьюпорте, aria-hidden не должен меняться при прокрутке (он и так постоянно видим).
  • Риск ловушки фокуса: закреплённая внизу панель «сохранить / отправить» удобна в длинных формах, но помните, что fixed-элемент может перекрывать содержимое в нижней части страницы (кнопка вечно закрывает последнюю строку) — UI-слой должен добавить padding-bottom для body или контейнера содержимого; это проблема раскладки, а не доступности, но она влияет на управляемость.
import { isAffixFixed, getAffixFixedStyle } from '@lh-ui/share/core/affix'
// Верхний режим: верхняя грань перешла порог 80px
isAffixFixed({ top: 50, bottom: 200 }, 80, 0, 900) // false (50 < 80 для закрепления, 50 не перешло порог)
isAffixFixed({ top: 100, bottom: 300 }, 80, 0, 900) // true (100 > 80)
// Направление определения: top < offsetTop истинно — верхняя грань элемента
// **выше** пороговой линии (уехала вверх при прокрутке)
// Нижний режим: нижняя грань перешла за «низ вьюпорта − порог»
isAffixFixed({ top: 700, bottom: 950 }, 0, 60, 900) // true (950 > 900-60=840)
isAffixFixed({ top: 500, bottom: 700 }, 0, 60, 900) // false (700 < 840, ещё не перешла)
// Переданы оба offset: приоритет у нижнего
isAffixFixed({ top: 100, bottom: 950 }, 80, 60, 900) // true (идёт по нижней ветке)
getAffixFixedStyle(80, 0) // { position: 'fixed', top: '80px', bottom: 'auto' }
getAffixFixedStyle(0, 60) // { position: 'fixed', bottom: '60px', top: 'auto' }
getAffixFixedStyle(0, 0) // { position: 'fixed', top: '0px', bottom: 'auto' } (по умолчанию закрепление сверху)
// UI-слой: основной путь через IntersectionObserver + плейсхолдер против схлопывания
const placeholder = document.createElement('div') // в закреплённом состоянии держит исходное место, предотвращая скачки контента
let observer: IntersectionObserver | undefined
function observe() {
observer = new IntersectionObserver(
(entries) => {
const rect = entries[0].boundingClientRect
const fixed = isAffixFixed(
{ top: rect.top, bottom: rect.bottom },
props.offsetTop ?? 0,
props.offsetBottom ?? 0,
window.innerHeight,
)
if (fixed !== state.fixed) {
state.fixed = fixed
if (fixed) targetEl.before(placeholder) // плейсхолдер
else placeholder.remove() // снять плейсхолдер
}
},
{ threshold: [0, 1] },
)
observer.observe(targetEl)
}
<!-- Vue3 -->
<template>
<LhAffix :offset-top="64">
<nav class="page-nav">…</nav>
</LhAffix>
</template>
// React: useEffect регистрирует IntersectionObserver; при SSR пропускается
// (нет IntersectionObserver), подключается после гидратации на клиенте
// uniapp: IntersectionObserver на стороне мини-программ — это wx.createIntersectionObserver;
// на h5 — нативный API браузера. Функция определения из core общая для обоих концов
Класс Семантика
.lh-affix обёртка (исходное место)
.lh-affix--fixed закреплённое состояние (position: fixed + z-index + опциональные тень/переход)

z-index — --lh-z-index-affix; тень в закреплённом состоянии — --lh-box-shadow-affix (опционально, при закреплении сверху — тонкая полоса тени снизу для разделения слоёв).

  • Плейсхолдер обязателен: как только элемент становится fixed, он выпадает из потока документа, и последующий контент сдвигается вверх, занимая освободившееся место — страница резко прыгает. UI-слой должен разместить плейсхолдер того же размера (как в примере выше). Высота плейсхолдера должна быть равна высоте элемента до закрепления.
  • Высота вьюпорта в нижнем режиме должна быть актуальной: параметр viewportHeight функции isAffixFixed при каждом вызове должен получать текущее значение, а при resize окна нужно переопределять состояние (affix может перейти из закреплённого обратно в обычное).
  • offsetTop / offsetBottom — только один из двух: при передаче обоих приоритет у нижнего, верхний порог не работает. API хотя и позволяет передать оба, семантически следует настроить только один.
  • Схлопывание ширины в закреплённом состоянии: после перехода в fixed ширина элемента больше не ограничивается родительским контейнером (block-элемент в fixed сжимается до ширины содержимого). В SCSS обычно записывают и переиспользуют исходную ширину внутри --fixed, либо UI-слой записывает ширину в инлайновый стиль.
  • Вложенные прокручиваемые контейнеры: если на странице есть контейнер с локальной прокруткой (а не прокрутка window), root у IntersectionObserver нужно указать как этот контейнер — и параметр высоты вьюпорта у isAffixFixed нужно заменить на clientHeight контейнера. core этого не различает, согласованность обеспечивает вызывающая сторона.
  • Совместимость IntersectionObserver: все целевые концы уже поддерживают его; если нужно поддерживать очень старые среды, используйте scroll-события + getBoundingClientRect() как запасной путь (функцию определения можно переиспользовать напрямую, ведь она принимает только прямоугольный объект).
  • Выбор между affix и sticky: CSS position: sticky покрывает большинство задач «закрепить сверху» и не требует JS. Дополнительная ценность affix — управляемый порог (закрепление не у самого края, а после перехода за 64px) и нижний режим (sticky прилипает только к ближнему краю по направлению прокрутки). В простых сценариях не подключайте affix.
@lh-ui/share/core/affix → isAffixFixed( )
返回值
(填好参数后点运行)

Copyright © 2024-2026 FengMo WenXue