Переходы: Link, префетч и роутер
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В статье «Зачем фреймворк поверх React» переход между экранами вы делали руками: pushState, своё событие, перерисовка из состояния. В Next.js для этого есть Link — обычная ссылка <a href>, у которой перехвачен клик, а код будущей страницы часто уже лежит в браузере до того, как по ней кликнули. Разберём, что именно Link делает за вас, где перехват клика ломает привычные жесты и как позвать переход из кода.
Доска задач из части II держала всё в памяти вкладки: массив задач, открытую карточку, позицию прокрутки. Пока человек ходит по экранам вашим кодом, эта память жива. Обычная ссылка ведёт себя иначе — браузер выбрасывает документ целиком и строит новый.
Обычный <a href> | Переход роутером | |
|---|---|---|
| Документ | новый, старый выброшен | тот же |
| Код приложения | скачивается и разбирается заново | уже разобран, догружается только новое |
| Состояние React | обнуляется целиком | переживает переход, кроме размонтированного хвоста |
| Шапка и меню | строятся заново, мигают | остаются смонтированными |
| Прокрутка | всегда наверх | наверх при переходе, прежняя при «назад» |
| Данные | запрашиваются с нуля | нужен только изменившийся кусок |
React в это не вмешивается: <a> в JSX — такой же тег, как в части II, и клик по нему уходит браузеру. Проверим, что React ничего не отменяет.
function Nav() { const [count, setCount] = React.useState(0); return ( <nav> <button id="inc" onClick={() => setCount(count + 1)}>нажали: {count}</button> {" · "} <a href="/about" onClick={(event) => { console.log("React отменил переход:", event.defaultPrevented); }} > О проекте </a> </nav> ); } ReactDOM.createRoot(document.getElementById("root")!).render(<Nav />); // первая отрисовка случится после этого скрипта, поэтому нажимаем с задержкой setTimeout(() => document.getElementById("inc")!.click(), 50); setTimeout(() => { console.log("в памяти вкладки:", document.getElementById("inc")!.textContent); // кадр учебника никуда уехать не должен, поэтому переход гасим на document — // то есть уже ПОСЛЕ того, как отработал обработчик React document.addEventListener("click", (event) => event.preventDefault()); document.querySelector("a")!.click(); }, 100);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Настоящую перезагрузку в кадре не показать: пример живёт в изолированном iframe и уехать никуда не может. Поэтому клик мы гасим сами — но гасим ниже по пути всплытия, и в выводе видно, что сам React событие не трогал.
Единица в кнопке — это состояние, живущее в памяти этой вкладки. Клик по ссылке в настоящем браузере выбросил бы его вместе с документом, и вернуть его было бы неоткуда. Отсюда правило: ссылка на свой маршрут внутри приложения — это Link. Обычный <a> остаётся для чужих сайтов, mailto:, tel: и файлов на скачивание — там полная загрузка как раз и нужна.
Link устроен проще, чем кажется: он рисует <a href> и вешает на него обработчик клика, который отменяет действие браузера и просит роутер показать другой кусок дерева. Соберём такой Link сами — с роутером на обычном состоянии, как в статье «Зачем фреймворк поверх React».
type LinkProps = { href: string; go: (href: string) => void; children: React.ReactNode }; // то же, что делает next/link: разметка остаётся ссылкой, клик забирает роутер function Link({ href, go, children }: LinkProps) { return ( <a href={href} onClick={(event) => { event.preventDefault(); go(href); }} > {children} </a> ); } function App() { const [path, setPath] = React.useState("/"); console.log("рендер, адрес " + path); return ( <div> <nav> <Link href="/" go={setPath}>Задачи</Link> {" · "} <Link href="/about" go={setPath}>О проекте</Link> </nav> <h1>{path === "/" ? "Задачи" : "О проекте"}</h1> </div> ); } ReactDOM.createRoot(document.getElementById("root")!).render(<App />); setTimeout(() => document.querySelectorAll("a")[1].click(), 50); setTimeout(() => { console.log(document.querySelector("h1")!.textContent); console.log(document.querySelectorAll("a")[1].getAttribute("href")); }, 100);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Заголовок сменился, документ остался прежним, а в разметке по-прежнему стоит настоящий адрес. Это и есть главная причина, почему Link — ссылка, а не <div onClick>: адрес нужен не только вашему коду. Его показывает строка состояния, его копируют правой кнопкой, по нему открывают новую вкладку, его объявляет скринридер словом «ссылка», по нему ходит поисковый робот. Кнопка с обработчиком не умеет ничего из этого.
А теперь сломаем то, чем пользуется каждый второй: Ctrl+клик (⌘+клик на маке), чтобы открыть страницу в соседней вкладке.
type LinkProps = { href: string; go: (href: string) => void; children: React.ReactNode }; function Link({ href, go, children }: LinkProps) { return ( <a href={href} onClick={(event) => { event.preventDefault(); go(href); }} > {children} </a> ); } function App() { return ( <nav> <Link href="/about" go={(href) => console.log("роутер увёл текущую вкладку на " + href)}> О проекте </Link> </nav> ); } ReactDOM.createRoot(document.getElementById("root")!).render(<App />); setTimeout(() => { // человек держит Ctrl и ждёт новую вкладку const ctrl = new MouseEvent("click", { bubbles: true, cancelable: true, ctrlKey: true }); document.querySelector("a")!.dispatchEvent(ctrl); console.log("браузеру дали сделать своё:", !ctrl.defaultPrevented); }, 50);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
preventDefault отменяет любое действие браузера по этому событию — вместе с открытием в новой вкладке. Поэтому настоящий Link перед тем, как вмешаться, смотрит на событие и на сам тег.
| Когда Link не вмешивается | Что происходит вместо перехода |
|---|---|
зажат Ctrl, Cmd, Shift или Alt | браузер открывает новую вкладку, окно или скачивает файл |
| клик средней кнопкой | браузер открывает вкладку в фоне |
у ссылки есть target, отличный от _self | браузер открывает в указанном окне |
у ссылки есть атрибут download | браузер скачивает файл |
| адрес ведёт на чужой домен | обычный переход: чужой сайт роутер отрисовать не может |
Во всех этих случаях обработчик просто выходит, не вызывая preventDefault, — и дальше всё делает браузер. Написать такую проверку самому предлагает первая задача в конце статьи.
Пропсы самого Link — это пропсы ссылки плюс несколько своих. Всё, что не перечислено, уезжает прямо на тег <a>: className, id, aria-label, target.
| Проп | Что делает |
|---|---|
href | адрес; кроме строки принимает объект вида { pathname, query } |
replace | заменить текущую запись истории, а не добавить новую |
scroll | false — не уводить страницу наверх после перехода |
prefetch | подкачивать ли страницу заранее — следующий раздел |
onNavigate | вызывается перед самим переходом; его preventDefault отменяет переход (например, «есть несохранённые изменения») |
// в проекте первой строкой файла стоит import Link from "next/link"; // в песочнице пакетов нет, поэтому Link здесь — заглушка с теми же пропсами type LinkProps = { href: string; replace?: boolean; scroll?: boolean; prefetch?: boolean | "auto" | null; children: React.ReactNode; }; function Link({ href, children }: LinkProps) { return <a href={href}>{children}</a>; } // app/tasks/page.tsx function TasksPage() { return ( <ul> <li><Link href="/tasks/1">Купить хлеб</Link></li> <li><Link href="/tasks/2" prefetch={false}>Архив за 2019 год</Link></li> <li><Link href="/login" replace>Войти</Link></li> <li><a href="https://developer.mozilla.org">Справочник MDN</a></li> </ul> ); } ReactDOM.createRoot(document.getElementById("root")!).render(<TasksPage />); setTimeout(() => { const hrefs = [...document.querySelectorAll("a")].map((link) => link.getAttribute("href")); console.log(hrefs.join(" ")); }, 50);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Link — клиентский компонент, но ставить его можно и в серверном: он приносит своё «use client» с собой. С useRouter так не выйдет — про это последний раздел.
До Next 13 Link не рисовал тег сам: внутрь клали <a>, а Link только подкладывал ему href и обработчик. Отсюда в старых примерах конструкция <Link href="/about"><a>О проекте</a></Link>.
Сейчас так писать не нужно и вредно: получится ссылка внутри ссылки. Проп legacyBehavior, включавший старое поведение, помечен устаревшим и доживает последние версии.
В прошлой статье мы собирали для каждого адреса цепочку файлов: от корневой раскладки вниз до страницы. Переход — это разница между двумя такими цепочками. Общее начало Next не трогает, меняется только различающийся хвост.
| Шаг | Что делает Next |
|---|---|
| 1 | смотрит на событие: модификаторы, средняя кнопка, target, download, чужой домен — тогда не вмешивается вовсе |
| 2 | отменяет действие браузера |
| 3 | берёт недостающие куски дерева из префетч-кеша или запрашивает у сервера |
| 4 | отрисовывает новый хвост внутри startTransition: общая часть цепочки остаётся смонтированной, а старый экран стоит на месте, пока новый готовится |
| 5 | меняет адрес через History API — тем же pushState, что вы писали руками |
| 6 | уводит прокрутку наверх (если не сказано scroll={false}), переносит фокус и объявляет новый экран скринридеру |
Четвёртый шаг — тот самый startTransition из статьи про срочное и несрочное: переход помечен несрочным, поэтому картинка не мигает пустотой, пока данные едут. Что показать, если ждать приходится долго, решает файл loading.tsx — следующая статья главы.
Практическое следствие четвёртого шага — состояние. Раскладка общей части не размонтируется, а значит, всё, что в ней живёт, переход переживает. Проверим это на паре счётчиков: один в раскладке, другой на странице.
function Layout({ children }: { children: React.ReactNode }) { const [menu, setMenu] = React.useState(0); return ( <div> <button id="menu" onClick={() => setMenu(menu + 1)}>меню открывали: {menu}</button> {children} </div> ); } function TasksPage() { const [draft, setDraft] = React.useState(0); return <button id="draft" onClick={() => setDraft(draft + 1)}>черновик правили: {draft}</button>; } function AboutPage() { return <p>О проекте</p>; } function App() { const [path, setPath] = React.useState("/"); const go = (href: string) => (event: React.MouseEvent) => { event.preventDefault(); setPath(href); }; return ( <Layout> <nav> <a href="/" onClick={go("/")}>Задачи</a> {" · "} <a href="/about" onClick={go("/about")}>О проекте</a> </nav> {path === "/" ? <TasksPage /> : <AboutPage />} </Layout> ); } ReactDOM.createRoot(document.getElementById("root")!).render(<App />); const click = (selector: string) => () => document.querySelector<HTMLElement>(selector)!.click(); const steps = [ click("#menu"), // открыли меню в раскладке click("#draft"), // и поправили черновик на странице click("nav a:last-child"), // ушли со страницы задач click("nav a:first-child"), // и сразу вернулись () => { console.log(document.getElementById("menu")!.textContent); console.log(document.getElementById("draft")!.textContent); } ]; // каждый шаг — в свой тик: React отрисовывает не мгновенно, и два нажатия // подряд увидели бы одно и то же состояние steps.forEach((step, i) => setTimeout(step, 50 * (i + 1)));Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Счётчик раскладки пережил переход, счётчик страницы обнулился: TasksPage размонтировали, и вместе с ним исчезло состояние — ровно по правилам из статьи «Когда состояние сохраняется, а когда сбрасывается». В Next то же самое, только позицию в дереве задаёте не вы, а папки: то, что должно пережить переход — открытое меню, проигрыватель, фильтр списка, — живёт в layout.tsx, а не в page.tsx.
Обратная сторона: раскладка не перезапускает и свои эффекты. Если в ней стоит useEffect, следящий за адресом, зависимость придётся указать явно — при переходе компонент не пересоздаётся.
Мгновенный переход не бывает мгновенным, если кода нужной страницы в браузере ещё нет: бандл разрезан по маршрутам, и после клика пришлось бы ждать сеть. Поэтому Link, попав в видимую область, заранее просит у сервера то, что понадобится после клика.
Механика простая, и её стоит собрать самому — инструменты знакомы ещё по части II. Ссылка считается видимой с запасом: пока она не на экране, качать нечего, а кеш не даёт скачать один адрес дважды.
const cache = new Map<string, string>(); function prefetch(href: string): void { if (cache.has(href)) return; // второй раз тянуть нечего — на этом Link и экономит cache.set(href, "код страницы " + href); console.log("префетч: " + href); } function check(): void { for (const link of document.querySelectorAll("a")) { // запас в 200 пикселей — ровно тот, что стоит в next/link: ссылку // считают видимой чуть раньше, чем она доедет до края экрана if (link.getBoundingClientRect().top < window.innerHeight + 200) { prefetch(link.getAttribute("href")!); } } console.log("в кеше: " + [...cache.keys()].join(", ")); } function App() { return ( <nav> <a href="/tasks">Задачи</a> <div style={{ height: 700 }} /> <a href="/archive">Архив за 2019 год</a> </nav> ); } ReactDOM.createRoot(document.getElementById("root")!).render(<App />); setTimeout(() => { check(); window.scrollTo(0, 800); check(); }, 50);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Пока «Архив» был за краем экрана, его никто не трогал; после прокрутки он подъехал — и подкачался. Повторный проход по «Задачам» промолчал: адрес уже в кеше.
Настоящий Link меряет не руками. За всеми ссылками сразу следит один общий IntersectionObserver с тем же запасом в 200 пикселей — тот самый наблюдатель, которого в части II советовали вместо ручных замеров прокрутки. Ссылка, уехавшая из вида, свою незаконченную задачу отменяет: на длинном списке качается только то, что на экране. Наведение мыши и касание — тоже сигнал: человек уже целится, и такой запрос получает приоритет повыше. А в режиме разработки подкачка по видимой области выключена вовсе: чтобы отдать соседнюю страницу, dev-серверу пришлось бы её собрать, и любой список ссылок ставил бы разработку на паузу.
Отсюда самый частый вопрос новичка: «у меня префетч не работает». Смотреть надо на next build и next start, а не на next dev — во вкладке «Сеть» там видны запросы, которых никто не просил.
Чем именно управляет проп prefetch:
| Значение | Что подкачивается |
|---|---|
не указан (null, "auto") | статический маршрут — целиком; динамический — только до ближайшего loading.tsx, чтобы не тянуть лишнего |
true | всё дерево маршрута целиком, даже если loading.tsx есть |
false | ничего — ни в видимой области, ни при наведении |
Одну ошибку с этим пропом типы ловят ещё до запуска — и она стоит того, чтобы увидеть её своими глазами.
// заглушка с типами пропсов настоящего Link type LinkProps = { href: string; prefetch?: boolean | "auto" | null; children: React.ReactNode; }; function Link({ href, children }: LinkProps) { return <a href={href}>{children}</a>; } function Nav() { return ( <nav> <Link href="/archive" prefetch="false">Архив за 2019 год</Link> </nav> ); } ReactDOM.createRoot(document.getElementById("root")!).render(<Nav />); setTimeout(() => console.log(document.querySelector("a")!.outerHTML), 50);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Кавычки превратили ложь в непустую строку, а непустая строка в JavaScript истинна: без типов такая ссылка подкачивалась бы как ни в чём не бывало, и найти это было бы нечем. Компилятор говорит ровно то, что случилось: строка "false" не подходит там, где ждут boolean | "auto" | null. Пример при этом всё равно выполняется — типы живут до сборки, а в браузер уезжает обычный JavaScript.
Префетч — обычный GET, сделанный заранее: он не делает ничего, чего не сделал бы клик. Из этого следует неприятное для чужого кода: если у вас есть адрес, который что-то меняет при переходе, префетч изменит это без всякого клика. Менять данные переходом нельзя — для этого есть действия, о них глава дальше.
Трафик тратится на страницы, которые могут и не понадобиться, — но только на видимые ссылки и только один раз на адрес. На списке из двухсот строк это две-три ссылки на экране, а не двести. Отключать префетч имеет смысл точечно: тяжёлая страница, на которую заходят редко, — prefetch={false}.
Скачанное лежит в клиентском кеше роутера недолго и по времени; срок хранения задаёт настройка experimental.staleTimes в next.config.
Link отвечает за то, по чему кликают. Но есть переходы без ссылки: задача сохранилась — уходим на её страницу; вход прошёл — уводим в кабинет; корзина опустела — возвращаем в каталог. Для них есть хук useRouter из next/navigation.
| Метод | Что делает |
|---|---|
push(href) | переход с новой записью в истории — как клик по ссылке |
replace(href) | переход, заменяющий текущую запись: «назад» вернёт не сюда |
back(), forward() | то же, что кнопки браузера |
refresh() | перезапросить данные текущего маршрута у сервера, не теряя состояние на клиенте |
prefetch(href) | попросить подкачку вручную — то, что Link делает сам |
Хук клиентский: он живёт в браузере и работает только там, где есть обработчики. Поэтому файл с ним начинается строкой "use client". Компонент в App Router по умолчанию выполняется на сервере, где нет ни состояния, ни обработчиков, и его код в браузер не попадает; эта строка помечает файл, код которого туда уезжает. Почему пометка распространяется и на всё, что файл импортирует, подробно — в статье «Граница 'use client' и почему она заразна».
// в проекте: первой строкой "use client", затем // import { useRouter } from "next/navigation"; // здесь useRouter — заглушка: настоящих маршрутов в песочнице нет, // зато видно, в какой момент и с чем его зовут function useRouter() { return { push: (href: string) => console.log("push → " + href), replace: (href: string) => console.log("replace → " + href), back: () => console.log("back"), refresh: () => console.log("refresh") }; } function NewTaskForm() { const router = useRouter(); const [title, setTitle] = React.useState("Купить хлеб"); return ( <form id="form" onSubmit={(event) => { event.preventDefault(); const id = 42; // здесь был бы ответ сервера // клика по ссылке не было, а уйти надо — вот он, случай для роутера router.push("/tasks/" + id); }} > <input value={title} onChange={(event) => setTitle(event.target.value)} /> <button type="submit">Создать</button> </form> ); } ReactDOM.createRoot(document.getElementById("root")!).render(<NewTaskForm />); setTimeout(() => (document.getElementById("form") as HTMLFormElement).requestSubmit(), 50);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Выбор между push и replace — это выбор того, что случится по кнопке «назад». После входа, после редиректа с промежуточной страницы, после «мы вас перекинули, потому что сюда нельзя» нужен replace: иначе «назад» вернёт человека на форму, с которой его только что увели, и он уйдёт по кругу.
Обратное тоже верно: если ссылка есть, звать роутер не надо. <div onClick={() => router.push("/about")}> — это ссылка, у которой отобрали адрес: её не открыть в новой вкладке, не скопировать, её не видит поисковик и не объявляет скринридер. Правило простое: кликают по ссылке — Link; переход случается после события — useRouter.
Соблазн написать «если сюда нельзя — сразу уводим» прямо в теле компонента велик. Но переход — это изменение состояния роутера, а менять чужое состояние во время рендера нельзя. Наш мини-роутер ведёт себя ровно как настоящий, потому что дело не в Next, а в React.
function Guard({ go }: { go: (href: string) => void }) { console.log("рендер Guard"); const authorized = false; if (!authorized) go("/login"); return <h1>Секретная страница</h1>; } function LoginPage() { console.log("рендер LoginPage"); return <h1>Форма входа</h1>; } function App() { const [path, setPath] = React.useState("/secret"); console.log("рендер App, адрес " + path); return path === "/secret" ? <Guard go={setPath} /> : <LoginPage />; } ReactDOM.createRoot(document.getElementById("root")!).render(<App />); setTimeout(() => console.log("на экране: " + document.querySelector("h1")!.textContent), 100);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Экран входа в итоге показан, но какой ценой: App отрисовался дважды, а «Секретная страница» построена впустую. Третья строка вывода — не наша: это React ругается на изменение чужого состояния во время рендера, «Cannot update a component while rendering a different component». Песочница подключает отладочную сборку React, поэтому предупреждение видно прямо здесь — как будет видно и в вашем проекте. В Next на месте go оказался бы router.push, и картина была бы той же.
Правильных мест два. Если решение принимает сервер — на сервере и уводить: для этого есть функция redirect, и о ней глава про серверные компоненты. Если решение зависит от того, что человек сделал в браузере, — переход живёт в обработчике события, а не в рендере.
Пока переход готовится, ссылка может показать индикатор: хук useLinkStatus() отдаёт { pending } внутри Link. Что показывать на месте самой страницы, решает loading.tsx — следующая статья.
- Обычный
<a href>внутри приложения — это перезапуск: новый документ, потерянное состояние, заново разобранный код. Link оставляет документ на месте. - Link — это настоящая ссылка: адрес виден браузеру, скринридеру и поисковику, работает «открыть в новой вкладке» и «скопировать адрес».
- Перехват клика делается с оглядкой: модификаторы, средняя кнопка,
target,downloadи чужие домены отдаются браузеру нетронутыми. - Попав в видимую область (с запасом в 200 пикселей), Link заранее просит код и данные будущей страницы; в режиме разработки этого не происходит.
- При переходе общая часть цепочки раскладок остаётся смонтированной — то, что должно пережить переход, живёт в
layout.tsx. - Переход без клика по ссылке делает
useRouter: push, replace, back, forward, refresh. В теле компонента его звать нельзя — только в обработчике.
1/4Свой Link отменяет действие браузера на каждом клике по ссылке. Что сломается первым?
2/4Почему Link рисует именно <a href>, а не кнопку с обработчиком?
3/4Вы запустили next dev, открыли страницу со списком ссылок и не увидели во вкладке «Сеть» ни одного префетча. В чём дело?
4/4После успешного входа код уводит человека в кабинет. push или replace?
Не мешать браузеру
Вот наивный Link из раздела про перехват клика: он отменяет действие браузера всегда. Допишите проверку так, чтобы Ctrl+клик и ссылка с target="_blank" уходили браузеру нетронутыми, а обычный левый клик по-прежнему доставался роутеру. Сейчас в выводе три строки от роутера, а должна остаться одна.
type LinkProps = { href: string; go: (href: string) => void; target?: string; children: React.ReactNode; }; function Link({ href, go, target, children }: LinkProps) { return ( <a href={href} target={target} onClick={(event) => { // ваш код: если это не обычный левый клик — выйти, ничего не отменяя event.preventDefault(); go(href); }} > {children} </a> ); } function App() { const go = (href: string) => console.log("роутер: " + href); return ( <nav> <Link href="/about" go={go}>О проекте</Link> {" · "} <Link href="/docs" go={go} target="_blank">Документация</Link> </nav> ); } ReactDOM.createRoot(document.getElementById("root")!).render(<App />); setTimeout(() => { // переход браузера гасим на document: кадру учебника уезжать некуда. // На проверку это не влияет — смотрим, до кого добрался роутер document.addEventListener("click", (event) => event.preventDefault()); const [about, docs] = document.querySelectorAll("a"); const click = (init: MouseEventInit) => new MouseEvent("click", { bubbles: true, cancelable: true, ...init }); about.dispatchEvent(click({})); about.dispatchEvent(click({ ctrlKey: true })); // «открыть в новой вкладке» docs.dispatchEvent(click({})); // у этой ссылки target="_blank" // сейчас роутер отвечает на все три клика, а нужно — только на первый }, 50);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Проверку удобно вынести отдельной функцией — в next/link она называется isModifiedEvent и устроена ровно так же.
function isModifiedEvent(event: React.MouseEvent<HTMLAnchorElement>): boolean { const target = event.currentTarget.getAttribute("target"); return ( (target !== null && target !== "_self") || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0 // не левая кнопка: средняя открывает вкладку в фоне ); } function Link({ href, go, target, children }: LinkProps) { return ( <a href={href} target={target} onClick={(event) => { // браузер знает, что делать с таким кликом, — не мешаем if (isModifiedEvent(event)) return; event.preventDefault(); go(href); }} > {children} </a> ); }Листинг только для чтения: этот код не запускается в браузере.
В выводе остаётся одна строка роутер: /about: обычный клик забрал роутер, два других ушли браузеру нетронутыми. В настоящем Link к этому списку добавлены атрибут download и проверка на чужой домен — отрисовать чужой сайт роутер всё равно не может.
Три кнопки и история
Три действия — три разных метода роутера. «Отмена» возвращает туда, откуда пришли. «Сохранить» уводит на страницу созданной задачи (её номер — 42). Кнопка входа уводит в кабинет, и форма входа не должна остаться в истории: «назад» из кабинета обязано вести на страницу, которая была до неё. Расставьте вызовы.
// заглушка вместо next/navigation: печатает, что было бы сделано function useRouter() { return { push: (href: string) => console.log("push → " + href), replace: (href: string) => console.log("replace → " + href), back: () => console.log("back") }; } function Toolbar() { const router = useRouter(); return ( <div> <button id="cancel" onClick={() => { /* ваш код */ }}>Отмена</button> <button id="save" onClick={() => { /* ваш код */ }}>Сохранить</button> <button id="login" onClick={() => { /* ваш код */ }}>Войти</button> </div> ); } ReactDOM.createRoot(document.getElementById("root")!).render(<Toolbar />); setTimeout(() => { for (const id of ["cancel", "save", "login"]) document.getElementById(id)!.click(); }, 50); // сейчас пусто, а нужно: // back // push → /tasks/42 // replace → /dashboardTab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
«Отмена» — это ровно кнопка браузера «назад». Сохранение добавляет запись в историю: с новой задачи «назад» должно возвращать к форме. А вход запись не добавляет, а заменяет.
<button id="cancel" onClick={() => router.back()}>Отмена</button> <button id="save" onClick={() => router.push("/tasks/42")}>Сохранить</button> <button id="login" onClick={() => router.replace("/dashboard")}>Войти</button>Листинг только для чтения: этот код не запускается в браузере.
Проверять такие вещи проще всего кнопкой «назад» в браузере: если после входа она возвращает на форму входа — где-то стоит push вместо replace.
Активная ссылка
Текущий пункт меню принято помечать не только цветом: скринридеру нужен атрибут aria-current="page". Текущий адрес отдаёт хук usePathname из того же next/navigation. Допишите NavLink.
// заглушка вместо next/navigation: в настоящем приложении адрес отдаёт роутер function usePathname(): string { return "/tasks"; } function NavLink({ href, children }: { href: string; children: React.ReactNode }) { // ваш код: текущему пункту нужен aria-current="page", остальным — ничего return <a href={href}>{children}</a>; } function Nav() { return ( <nav> <NavLink href="/">Главная</NavLink> {" · "} <NavLink href="/tasks">Задачи</NavLink> </nav> ); } ReactDOM.createRoot(document.getElementById("root")!).render(<Nav />); setTimeout(() => { const marks = [...document.querySelectorAll("a")].map( (link) => link.getAttribute("aria-current") ?? "нет" ); console.log(marks.join(" ")); // сейчас «нет нет», а нужно «нет page» }, 50);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Значение атрибута — undefined, а не false: React убирает атрибут целиком только для undefined и null, а false для aria-* отрисовался бы строкой «false».
function NavLink({ href, children }: { href: string; children: React.ReactNode }) { const pathname = usePathname(); return ( <a href={href} aria-current={pathname === href ? "page" : undefined}> {children} </a> ); }Листинг только для чтения: этот код не запускается в браузере.
Сравнение строк целиком годится для плоского меню, но не для вложенного: на адресе /tasks/42 пункт «Задачи» подсвечен не будет. Для разделов сравнивают начало пути — pathname.startsWith(href), — отдельно оговаривая корень "/", иначе он окажется активным всегда.