popper —— движок позиционирования плавающих слоёв
tooltip, popover, popconfirm, dropdown, выпадающий список select, панель cascader, календарь date-picker — всё в lh-ui, что «парит рядом с триггером», получает свою позицию из этой единственной функции.
Почему не floating-ui / popper.js
Заголовок раздела «Почему не floating-ui / popper.js»Все четыре порта семьи (Vue3 / Vue2 / React / uniapp) подключают этот пакет, и объём сторонней библиотеки позиционирования вместе с темпом её обновлений превратился бы в четырёхкратное бремя. Движок позиционирования share покрывает только то подмножество, которое семья реально использует: 12 размещений, переворот по главной оси, кламп по поперечной оси, смещение стрелки — около 300 строк, без зависимостей, чистые функция на входе и выходе: не создаёт DOM, не слушает события, не запускает requestAnimationFrame. Планирование и рендеринг каждый UI-слой решает сам (Vue3 — watchEffect, React — useLayoutEffect, uniapp — события прокрутки страницы).
import { computePosition, parsePlacement, getWindowViewport, type LhPlacement, type LhComputePositionOptions,} from '@lh-ui/share/core/shared/popper'Типы и константы
Заголовок раздела «Типы и константы»// 12 размещений = 4 направления × 3 выравнивания (суффикс center можно опустить)export type LhPlacement = | 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end'
export type LhPlacementDir = 'top' | 'bottom' | 'left' | 'right'export type LhPlacementAlign = 'start' | 'center' | 'end'
// Минимальная структура, изоморфная DOMRect — в тестах создаётся обычным литераломexport interface LhDOMRectLike { top: number left: number width: number height: number right: number bottom: number}
export interface LhComputePositionOptions { placement?: LhPlacement // по умолчанию 'bottom' gap?: number // расстояние до триггера, по умолчанию 8 padding?: number // безопасный отступ от вьюпорта, по умолчанию 8 flip?: boolean // переворот при переполнении по главной оси, по умолчанию true shift?: boolean // кламп по поперечной оси, по умолчанию true}
export interface LhPopperPosition { top: number left: number placement: LhPlacement // фактически сработавшее размещение после переворота arrowOffset: number // смещение стрелки на поперечной оси (px, от начала поперечной оси плавающего слоя)}Таблица функций
Заголовок раздела «Таблица функций»| Функция | Сигнатура | Назначение |
|---|---|---|
parsePlacement |
(placement: LhPlacement) => { dir, align } |
расщепляет размещение на «направление + выравнивание»; при некорректном направлении откат к bottom, при некорректном выравнивании — к center |
buildPlacement |
(dir: LhPlacementDir, align: LhPlacementAlign) => LhPlacement |
восстанавливает размещение из направления и выравнивания (суффикс опускается при center) |
flipDir |
(dir: LhPlacementDir) => LhPlacementDir |
переворот по главной оси: top↔bottom, left↔right |
basePosition |
(dir, align, trigger, popper, gap) => { top, left } |
базовая расстановка по размещению (без учёта ограничений вьюпорта) |
fitsInViewport |
(dir, trigger, popper, viewport, gap, padding) => boolean |
помещается ли плавающий слой в вьюпорт целиком в указанном направлении (проверка по главной оси) |
computePosition |
(trigger, popper, viewport, options?) => LhPopperPosition |
главный вход: полное вычисление позиции |
getWindowViewport |
() => LhDOMRectLike |
строит rect вьюпорта из окна браузера; с SSR-предохранителем (при отсутствии window — rect нулевого размера) |
isOutsideViewport |
(rect, viewport) => boolean |
полностью ли rect покинул вьюпорт (при прокрутке, когда триггер стал невидим, плавающий слой следует закрыть) |
Порядок вычислений в computePosition
Заголовок раздела «Порядок вычислений в computePosition»flip (переворот по главной оси) → перевернуть, только если в новом направлении хватает места (избежать ещё худшего варианта) → расстановка по размещению (basePosition) → shift: кламп по поперечной оси (вдоль безопасного отступа padding) → кламп по главной оси (только когда flip включён и не помещает ни с одной стороны — прижать к той стороне, к которой можно) → нижний кламп как защитная сетка (подстраховка: отрицательных координат не бывает)Ключевые решения:
- Переворот условный. Распространённая ошибочная реализация — «переполнение → переворот»: в результате, когда триггер находится в центре вьюпорта, а плавающий слой высок, после переворота он помещается ещё хуже.
computePositionсначала черезfitsInViewportспрашивает «поместится ли после переворота», и если нет — не переворачивает, а переходит к клампу по главной оси. - Возвращается реально сработавший
placement. После переворота полеplacementстановится новым размещением, и UI-слой по нему переключает класс стрелки (is-top/is-bottom) — направление стрелки обязано совпадать с фактическим размещением, иначе визуально стрелка оторвётся от плавающего слоя. - Стрелка всегда указывает на центр триггера;
arrowOffset— смещение на поперечной оси, ограниченное рамками плавающего слоя; верхним пределомpaddingслужит половина плавающего слоя, чтобы верхняя и нижняя границы не поменялись местами (и стрелка не оказалась снаружи плавающего слоя).
Примеры использования
Заголовок раздела «Примеры использования»Чистая функция (можно unit-тестировать напрямую)
Заголовок раздела «Чистая функция (можно unit-тестировать напрямую)»const trigger = { top: 100, left: 50, width: 80, height: 32, right: 130, bottom: 132 }const popper = { top: 0, left: 0, width: 200, height: 120, right: 200, bottom: 120 }const viewport = { top: 0, left: 0, width: 375, height: 667, right: 375, bottom: 667 }
computePosition(trigger, popper, viewport, { placement: 'top' })// Над триггером хватает места:// { top: 132 - 8 - 120, left: ..., placement: 'top', arrowOffset: ... }Подключение во Vue3
Заголовок раздела «Подключение во Vue3»// внутри компонента плавающего слояfunction update() { if (!triggerEl.value || !popperEl.value) return const pos = computePosition( triggerEl.value.getBoundingClientRect(), popperEl.value.getBoundingClientRect(), getWindowViewport(), { placement: placementRef.value, gap: 8 }, ) popperEl.value.style.top = `${pos.top}px` popperEl.value.style.left = `${pos.left}px` actualPlacement.value = pos.placement}
onMounted(() => { watchEffect(update) // пересчёт при изменении реактивных зависимостей window.addEventListener('scroll', update, true)})Подключение в React
Заголовок раздела «Подключение в React»useLayoutEffect(() => { if (!triggerRef.current || !popperRef.current) return const pos = computePosition(/* ... */) setPos(pos)}, [open, placement])Подключение в uniapp
Заголовок раздела «Подключение в uniapp»Реализация getBoundingClientRect в uniapp различается по концам (в мини-программах нужен uni.createSelectorQuery); UI-слой измеряет rect и затем вызывает всё тот же computePosition — сам алгоритм полностью идентичен.
Замечания
Заголовок раздела «Замечания»- Параметры предварительно санитизируются на уровне полей: нефинитные поля (NaN / Infinity) обнуляются, и плохой rect не попадает в геометрические вычисления.
- SSR-безопасность:
getWindowViewport()в среде безwindowвозвращает rect нулевого размера;computePositionвообще не касается DOM и может один раз вычислить позицию на сервере. - Семантика
flip: false: вызывающая сторона сама принимает переполнение по главной оси; при этом по главной оси остаётся только нижний кламп (он не затолкнёт плавающий слой в отрицательные координаты). Это используется в сценариях «с фиксированным направлением» вродеselect/cascader. - Не обрабатывает Resize / Scroll: планирование — дело UI-слоя; share лишь гарантирует «при заданном входе выход определён».
LhPosition('top-left'/'bottom-right'и т. п., см. types) — это другая модель размещений:LhPositionиспользуется для семантики «прилипания к краю» в affix / backtop и не имеет выравнивающих суффиксов-start/-end; не смешивайте их.
@lh-ui/share/core/popper → computePosition( )(填好参数后点运行)Copyright © 2024-2026 FengMo WenXue