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

anchor якорь —— Навигация

Якорь — это «оглавление длинного документа» на боковой панели документации: «базовое использование / API / замечания» справа на сайте документации; клик прокручивает к соответствующему заголовку, а при прокрутке подсвечивается текущий раздел. Отличие от menu в том, что якорь связан с позицией содержимого на этой же странице, а не с разными страницами.

  • Реализация <LhAnchor> на всех концах: оглавление сайта документации, навигация по длинной статье, навигация по разделам API-документации, навигация по блокам лендинга.
  • Содержимое страницы настолько длинное, что нужен быстрый переход, а у блоков есть чёткие заголовки.
  • Не следует использовать: навигация между страницами сайта (используйте menu / breadcrumb); короткие страницы (в один экран оглавление не нужно); блоки без заголовков (якорь опирается на текст заголовка как на текст ссылки).
import {
flattenAnchorItems,
collectAnchorIds,
resolveActiveAnchor,
getAnchorClassNames,
getAnchorLinkClassNames,
} from '@lh-ui/share/core/anchor'
import type { LhAnchorItem, LhFlatAnchorItem } from '@lh-ui/share/core/anchor'

Дерево → плоский список: конфигурация якорей — дерево (разделы с подразделами), а рендерится он одномерным списком (с отступами). flattenAnchorItems выполняет обход в глубину, сохраняя порядок документа — оглавление с нарушенным порядком хуже, чем отсутствие оглавления. level хранит исходную глубину и управляет стилем отступа.

Цепочка предков seen для защиты от циклических ссылок: seen — это множество предков на текущем пути DFS (не глобальное множество). В комментарии прямо сказано «цепочка предков для защиты от циклических ссылок» — если в данных children узла A содержит сам A (грязные данные), глобальный seen лишь уберёт дубликаты, а цепочка предков прервёт рекурсию напрямую и не даст зациклиться. Это защита от структурных ошибок в древовидных данных.

«Последний перешедший порог» в resolveActiveAnchor: среди упорядоченных по документу смещений ищется последний якорь, для которого offset <= currentTop. Ключевая деталь: при первом же offset > currentTop делается break — смещения упорядочены, и последующие будут только больше, дальнейший перебор — трата. Это O(n) с досрочным выходом.

Промах — возврат к первому элементу: при прокрутке выше всех якорей (вершина страницы) возвращается ids[0] — первый элемент всегда подсвечен, что избегает неприятного состояния «нет активного элемента». Для пустого массива возвращается пустая строка (UI-слой не рендерит подсветку).

offsets и ids должны быть согласованы по порядку: сигнатура функции требует два массива — смещения измеряются UI-слоем через getBoundingClientRect() для каждого элемента и передаются в том же порядке. При нарушении порядка получится неверный активный элемент; это контракт на стороне вызывающего, core не может его проверить.

Функция Сигнатура Возврат Назначение
flattenAnchorItems (items, level?, seen?) => LhFlatAnchorItem[] плоский список обход в глубину с сохранением порядка
collectAnchorIds (items) => string[] список id массив id в порядке уплощения
resolveActiveAnchor (ids, offsets, currentTop) => string активный id определение при прокрутке
getAnchorClassNames (state: { direction }) => string[] классы направление контейнера
getAnchorLinkClassNames (state: { active, level }) => string[] классы активное состояние + отступ уровня
export interface LhAnchorItem {
id: string // id целевого элемента
title: string // отображаемый текст
children?: LhAnchorItem[]
}
export interface LhFlatAnchorItem {
id: string
title: string
level: number // исходная глубина (корень = 0), управляет отступом
}

Якорные ссылки указывают на якоря на этой же странице; стандартный подход — нативный <a href="#section">:

<a href="#usage" class="lh-anchor__link">Примеры использования</a>
...
<section id="usage" tabindex="-1">…</section>
  • Нативный переход по якорю + tabindex=“-1”: при клике по <a href="#id"> браузер прокручивает страницу и пытается передать фокус целевому элементу. Если цель не может получить фокус, после перехода фокус остаётся на месте — добавьте цели tabindex="-1", чтобы она могла принимать программный фокус, и скринридер начнёт чтение с этого места. Это рекомендованный WAI-ARIA шаблон доступных якорей.
  • aria-current отмечает текущий раздел: --active в getAnchorLinkClassNames — визуальный класс; для доступности рекомендуется, чтобы UI-слой добавлял активной ссылке aria-current="location" (семантика позиции на странице, в отличие от aria-current="page" в breadcrumb).
  • Переход не должен менять URL hash: изменение hash попадает в историю браузера — после пяти кликов по якорям придётся пять раз нажать «назад», чтобы покинуть страницу. UI-слой может использовать history.replaceState или scrollIntoView вместо перехода по умолчанию — это компромисс в взаимодействии, core его не касается.
  • Обёртка nav + aria-label: как и в breadcrumb, <nav aria-label="Содержание страницы"> объявляет это зоной навигации.
import {
flattenAnchorItems, collectAnchorIds, resolveActiveAnchor,
} from '@lh-ui/share/core/anchor'
const items = [
{ id: 'intro', title: 'Введение', children: [
{ id: 'why', title: 'Зачем использовать' },
] },
{ id: 'usage', title: 'Использование' },
]
flattenAnchorItems(items)
// [ { id: 'intro', title: 'Введение', level: 0 },
// { id: 'why', title: 'Зачем использовать', level: 1 },
// { id: 'usage', title: 'Использование', level: 0 } ]
collectAnchorIds(items) // ['intro', 'why', 'usage']
// Связь с прокруткой: offsets — позиции якорей относительно верха контейнера (в порядке ids)
resolveActiveAnchor(['intro', 'why', 'usage'], [0, 300, 600], 350) // 'why'
resolveActiveAnchor(['intro', 'why', 'usage'], [0, 300, 600], 50) // 'intro' (не перешёл why)
resolveActiveAnchor(['intro', 'why', 'usage'], [0, 300, 600], 0) // 'intro' (промах — возврат к первому)
resolveActiveAnchor([], [], 0) // '' (пустой список)
// UI-слой: слежение за прокруткой + измерение смещений
const activeId = ref('')
function refresh() {
const flat = flattenAnchorItems(props.items)
const ids = flat.map((item) => item.id)
const offsets = ids.map((id) => {
const el = document.getElementById(id)
return el ? el.getBoundingClientRect().top - containerTop : Infinity
})
activeId.value = resolveActiveAnchor(ids, offsets, scrollTop.value)
}
onMounted(() => window.addEventListener('scroll', refresh, { passive: true }))
<!-- Vue3 -->
<template>
<LhAnchor :items="items" :offset-top="80" @click="onAnchorClick" />
</template>
// React: useEffect регистрирует слушатель scroll; измерение смещений через массив ref
// и getBoundingClientRect; троттлинг через requestAnimationFrame или throttle
// (scroll срабатывает с высокой частотой)
// uniapp: <scroll-view scroll-into-view="id"> — нативный переход по якорю
// в мини-программе; связь с прокруткой — через событие scroll объекта scroll-view,
// откуда берётся scrollTop
Класс Семантика
.lh-anchor контейнер
.lh-anchor--{vertical|horizontal} направление (вертикальная боковая панель / горизонтальная верхняя навигация)
.lh-anchor__link якорная ссылка
.lh-anchor__link--level-{0..n} отступ уровня (padding-left увеличивается)
.lh-anchor__link--active активное состояние (основной цвет + индикаторная полоса слева)

Цвет активного состояния --lh-primary-color; обычная ссылка --lh-text-regular; единица отступа уровня --lh-anchor-indent (например, 12px/уровень).

  • Измерение смещения должно вычитать смещение контейнера: если якори находятся внутри прокручиваемого внутреннего контейнера (не прокрутка window), из getBoundingClientRect().top нужно вычесть позицию верхней границы контейнера, иначе подсветка будет постоянно смещена. Это самая частая ошибка.
  • getBoundingClientRect принудительно вызывает layouts: вызов для N элементов при каждом событии scroll вызывает N рефлоу. Оптимизация: кэшировать смещения и пересчитывать только при изменении размеров содержимого; либо наблюдать за каждым элементом через IntersectionObserver.
  • Компенсация смещения при перекрытии закреплённой шапки: если на странице есть фиксированный заголовок (affix), после перехода по якорю цель может быть им закрыта. Добавьте целевому элементу scroll-margin-top (CSS) либо вычитайте высоту шапки из прокрутки в UI-слое — это точка совместной работы раскладок.
  • Смещения устаревают при динамическом содержимом: после изменения высоты содержимого (свёртывание/разворачивание, загрузка картинок) кэшированные смещения становятся некорректными. При изменении содержимого нужно измерять заново.
  • Семантика цепочки предков в seen: третий параметр flattenAnchorItems — это предки на текущем пути, по умолчанию new Set(). Бизнес-сторона обычно его не передаёт — его главная задача: защита от зацикливания на грязных данных. Если два сестринских поддерева ссылаются на один и тот же узел (совместное использование в DAG), цепочка предков позволит ему появиться по одному разу в каждом пути (тот же компромисс, что и в tree с цепочкой предков против глобального seen в table).
  • Пустые и повторяющиеся id: id должен быть глобально уникальным (требование якорей HTML). document.getElementById возвращает только первое совпадение — повторяющиеся id приводят к ошибкам перехода, core не может это проверить.
  • Якорь против оглавления-роутинга: якорь связан с позицией на странице; если каждый пункт «оглавления» — отдельная страница, то это menu, а не anchor — не смешивайте (якорь не перезагружает страницу, иначе управляется попадание в историю роутинга).
@lh-ui/share/core/anchor → resolveActiveAnchor( )
返回值
(填好参数后点运行)

Copyright © 2024-2026 FengMo WenXue