- Opublikowano
Przestań używać Router.replace() do kanonikalizacji adresów — sięgnij po History.replaceState()
- Autorzy
Adres, który renderuje się dwa razy
Każdy produkt w naszej platformie e-commerce ma slug wygenerowany z nazwy. Bateria kuchenna mieszka na przykład pod /product/corsa-gold-brushed-BEA_F53G. Ale użytkownicy trafiają na karty produktów również krótszymi adresami — /product/BEA_F53G — z kodów QR na opakowaniach, z wewnętrznych dashboardów albo z systemów magazynowych, które znają wyłącznie kod produktu.
Kiedy ktoś wchodzi na /product/BEA_F53G, strona pobiera dane produktu z API, dowiaduje się, że pełny slug to corsa-gold-brushed-BEA_F53G, i musi zaktualizować adres, żeby to odzwierciedlić. Ta sama strona, ten sam produkt, ta sama treść — po prostu ładniejszy, przyjazny SEO adres w pasku przeglądarki.
Przez wiele miesięcy załatwialiśmy to przez router.replace() z Vue Routera:
watch(status, async (status) => {
if (status === 'success' && data.value) {
const slug = data.value.product.slug
const currentSlug = params.slug
if (!currentSlug?.includes('-') || currentSlug !== slug) {
router.replace({
path: localePath({
name: 'productDetail',
params: { slug },
}),
});
}
}
}, { immediate: true });
Działało. Adres się aktualizował. Strona pokazywała właściwy produkt. Wypuszczamy i lecimy dalej.
Tyle że za tę aktualizację adresu płaciliśmy pełnym cyklem nawigacji Vue Routera.
Co tak naprawdę robi Router.replace()
Kiedy większość developerów słyszy „podmień adres bez dodawania wpisu do historii”, myśli o lekkiej operacji przeglądarki. Przeglądarka zamienia jeden adres na drugi i życie toczy się dalej. Dokładnie to robi history.replaceState().
router.replace() robi coś zupełnie innego. Odpala pełny pipeline nawigacji Vue Routera:
- Odpalają się navigation guardy. Każdy
beforeEach,beforeResolvei komponentowybeforeRouteUpdate. U nas obejmowało to wykrywanie locale w i18n, sprawdzanie autentykacji i middleware analityczny. - Następuje matching trasy. Vue Router od nowa parsuje adres, dopasowuje go do tablicy tras, wyciąga parametry i rozwiązuje obiekt trasy. Dla adresu wskazującego tę samą stronę z minimalnie innym slugiem to praca kompletnie na marne.
- Komponenty przeliczają się od nowa. Odpala się każdy
watchnaroute.params. Computed zależne od parametrów trasy liczą się ponownie. Komponenty korzystające zuseRoute()mogą się przerenderować. - Watcher pobierania danych odpala się drugi raz. To był ten kosztowny. Karta produktu obserwowała
status, żeby wykryć moment poprawnego załadowania danych. Kiedyrouter.replace()zaktualizował trasę, watcher odpalał się ponownie, bo zmieniły się parametry — mimo że dane już mieliśmy.
Przy kanonikalizacji zamieniającej /product/BEA_F53G na /product/corsa-gold-brushed-BEA_F53G żaden z tych kroków nie jest potrzebny. Strona jest już wyrenderowana. Dane są pobrane. Drzewo komponentów jest poprawne. Chcemy tylko, żeby pasek adresu pokazał inny ciąg znaków.
Używaliśmy pełnej nawigacji do roboty, którą załatwia podmiana stringa.
Problem pomnożony przez typy stron
Karta produktu nie była jedynym miejscem z tym wzorcem. Tę samą kanonikalizację przez router.replace() mieliśmy na czterech typach stron:
| Typ strony | Co wyzwala kanonikalizację | Ile razy odpala się Router.replace() |
|---|---|---|
| Karta produktu | Slug z samym kodem zamieniany na pełny slug z nazwą | Każde wejście z krótkiego adresu |
| Profil projektanta | Slug z samym ID zamieniany na slug z nazwą i ID | Każde wejście ze starego adresu |
| Artykuł | Podmiana sluga na wersję dla danego języka | Każdy artykuł, gdy slug dla locale się różni |
| Strona kolekcji | Podmiana sluga na wersję dla danego języka | Każda kolekcja, gdy slug dla locale się różni |
Strony contentowe (aktualności i kolekcje) miały dodatkowy haczyk. Nasz CMS trzyma treści ze slugami per język. Artykuł może mieć slug nowoczesne-baterie po polsku i modern-faucets po angielsku. Kiedy użytkownik przełącza język, strona pobiera tę samą treść, ale musi podmienić adres na slug właściwy dla danego locale.
Obsługiwaliśmy to watchem na pobranych danych:
watch(data, async (newData) => {
if (newData?.slug && newData.slug !== params.slug) {
await router.replace(localePath({
name: 'newsArticle',
params: { slug: newData.slug },
}));
}
}, { deep: true, immediate: true });
Watcher z deep: true na całym obiekcie danych oznaczał, że każda zmiana danych — nie tylko zmiana sluga — wyzwalała porównanie. A immediate: true sprawiał, że odpalał się już przy montowaniu, w trakcie pierwszego renderu, zanim strona w ogóle była widoczna.
Na stronach contentowych z bogatymi, zagnieżdżonymi strukturami danych ten watcher przeliczał się przy każdej reaktywnej aktualizacji obiektu. W większości przypadków slug się zgadzał i nic się nie działo. Ale watcher i tak się odpalał, i tak porównywał, i tak palił cykle.
Problem z SEO, którego nikt nie zauważył
Kiedy skupialiśmy się na koszcie wydajnościowym router.replace(), w naszych metadanych SEO czaił się dużo podstępniejszy problem.
Strony ustawiały adresy kanoniczne i OpenGraph przez useSeoMeta():
useSeoMeta({
ogImage: () => `https://cdn.example.com${data.value?.product.mainPhoto?.fullpath}`,
twitterCard: 'summary_large_image',
});
Widzisz, czego brakuje? Nie ma jawnego ogUrl. Nie ma tagu canonical. Polegaliśmy na tym, że aktualny adres w przeglądarce posłuży za de facto adres kanoniczny.
I tu jest problem: przy SSR adresem kanonicznym było to, co serwer dostał w żądaniu. Jeśli Googlebot wszedł na /product/BEA_F53G (krótki adres), odpowiedź z SSR zawierała:
- HTML wyrenderowany z danymi właściwego produktu
- Meta tagi wygenerowane z tych danych
- Adres w pasku przeglądarki w formie skróconej, a nie kanoniczny slug
router.replace() działa wyłącznie na kliencie. Przy SSR żadna nawigacja się nie odbywa — serwer renderuje raz i wysyła HTML. Googlebot dostaje stronę z niekanonicznym adresem wpieczonym we wszystkie meta tagi zależne od adresu. Kliencki router.replace() odpala się po hydracji, ale wtedy odpowiedź z SSR jest już zaindeksowana z błędnym adresem.
Nasze karty produktów wysyłały więc niespójne sygnały kanoniczne:
| Sygnał | Wartość przy SSR | Wartość po hydracji |
|---|---|---|
| Pasek adresu | /product/BEA_F53G | /product/corsa-gold-brushed-BEA_F53G |
| Tag canonical | Brak | Nadal brak |
| og:url | Brak | Nadal brak |
| Adres w Schema.org | https://example.com/product/BEA_F53G | https://example.com/product/BEA_F53G |
Zero tagu canonical, zero og:url i adres w Schema.org wskazujący na skróconą formę. Przy każdym produkcie otwartym z krótkiego adresu wysyłaliśmy Google'owi sprzeczne sygnały.
Poprawka: jedna linijka zamiast cyklu nawigacji
Zamiennik jest aż wstyd jak prosty. Zamiast prosić Vue Router o nawigację pod nowy adres, mówimy przeglądarce, żeby dla bieżącej strony wyświetliła inny adres:
if (import.meta.client && correctPath && route.path !== correctPath) {
window.history.replaceState(history.state, '', correctPath)
}
I tyle. Jedna linijka. Żadnych guardów. Żadnego matchowania tras. Żadnego przeliczania komponentów. Żadnego ponownego pobierania danych. Przeglądarka aktualizuje pasek adresu i nic więcej się nie dzieje.
Argument history.state jest kluczowy — zachowuje wewnętrzny obiekt stanu Vue Routera. Jeśli przekażesz null albo {}, Vue Router gubi bieżący stan nawigacji, co potrafi popsuć przyciski wstecz i dalej oraz przywracanie pozycji scrolla. Przekazując istniejący history.state, aktualizujemy adres, zostawiając stan routera nietknięty.
Guard import.meta.client gwarantuje, że ten kod nigdy nie odpali się przy SSR. Na serwerze window nie istnieje, a i tak nie chcemy tam ruszać adresów. Adres kanoniczny dla SSR obsługujemy osobno, porządnymi meta tagami.
Naprawa SEO przez jawne tagi canonical
Skoro router.replace() zniknął, potrzebowaliśmy porządnej strategii adresów kanonicznych. Zamiast polegać na pasku adresu (nad którym przy SSR nie mamy kontroli), zaczęliśmy jawnie ustawiać canonical w headzie strony:
const canonicalPath = computed(() => {
if (!data.value) return null
return localePath({
name: 'productDetail',
params: { slug: data.value.product.slug }
})
})
useHead({
link: [
{
rel: 'canonical',
href: computed(() =>
canonicalPath.value
? `${fullSitePath}${canonicalPath.value}`
: undefined
),
},
],
})
useSeoMeta({
ogUrl: () =>
canonicalPath.value
? `${fullSitePath}${canonicalPath.value}`
: undefined,
})
Teraz sygnały kanoniczne są spójne niezależnie od tego, którym adresem ktoś wszedł na stronę:
| Sygnał | Wartość przy SSR | Wartość po hydracji |
|---|---|---|
| Pasek adresu | /product/BEA_F53G | /product/corsa-gold-brushed-BEA_F53G |
| Tag canonical | /product/corsa-gold-brushed-BEA_F53G | /product/corsa-gold-brushed-BEA_F53G |
| og:url | /product/corsa-gold-brushed-BEA_F53G | /product/corsa-gold-brushed-BEA_F53G |
| Adres w Schema.org | /product/corsa-gold-brushed-BEA_F53G | /product/corsa-gold-brushed-BEA_F53G |
Tag canonical i og:url liczone są z danych z API, a nie z bieżącej trasy. Przy SSR dane są dostępne (pobierane po stronie serwera), więc adres kanoniczny jest poprawny już w pierwszej odpowiedzi HTML. Googlebot widzi właściwy canonical przy pierwszym renderze, zanim odpali się jakikolwiek kliencki JavaScript.
Wzorzec dla stron contentowych
Na stronach contentowych (artykuły i kolekcje) wzorzec wyglądał trochę inaczej, bo podmiana sluga może nastąpić przy przełączeniu języka, nie tylko przy pierwszym wejściu.
Stare podejście używało watcha z deep: true na całym obiekcie danych:
watch(data, async (newData) => {
if (newData?.slug && newData.slug !== params.slug) {
await router.replace(localePath({
name: 'newsArticle',
params: { slug: newData.slug },
}));
}
}, { deep: true, immediate: true });
Nowe rozbija robotę na dwie części — computed canonical, który zawsze jest poprawny, oraz kliencką aktualizację adresu, która odpala się tylko wtedy, gdy trzeba:
const routeName = variant === 'news' ? 'newsArticle' : 'collectionSlug'
const canonicalPath = localePath({
name: routeName,
params: { slug: data.value.slug }
})
useHead({
link: [
{
rel: 'canonical',
href: `${fullSitePath}${canonicalPath}`,
},
],
})
useSeoMeta({
ogUrl: `${fullSitePath}${canonicalPath}`,
})
if (import.meta.client && data.value.slug !== params.slug && route.path !== canonicalPath) {
window.history.replaceState(history.state, '', canonicalPath)
}
Watcher z deep: true zniknął całkowicie. Ścieżka kanoniczna wyliczana jest z danych raz. Wywołanie replaceState odpala się jeden raz przy montowaniu, jeśli slug się nie zgadza. Zero narzutu reaktywności, zero powtarzanych porównań, zero pipeline'u nawigacji.
Co usunęliśmy
W czterech typach stron refactor usunął:
| Usunięte | Ile | Dlaczego to ważne |
|---|---|---|
Wywołania router.replace() | 4 | Każde odpalało pełny cykl nawigacji |
Importy useRouter() | 3 | Już niepotrzebne (jeden plik nadal używa go gdzie indziej) |
Watchery danych z deep: true | 2 | Koniec ze zbędnym śledzeniem reaktywności |
| Asynchroniczne callbacki watcherów | 4 | replaceState jest synchroniczny |
A dodał:
| Dodane | Ile | Dlaczego to ważne |
|---|---|---|
| Jawne tagi canonical | 4 | Adresy kanoniczne poprawne już przy SSR |
| Jawne meta tagi og:url | 4 | Spójne adresy przy udostępnianiu w social mediach |
Wywołania history.replaceState() | 4 | Lekkie aktualizacje adresu |
Użycie configu fullSitePath | 4 | Bezwzględne adresy kanoniczne |
Efekt netto to 4 cykle nawigacji mniej na wejście (gdy kanonikalizacja się odpala), 4 nowe sygnały kanoniczne działające poprawnie przy SSR oraz zero dodatkowych operacji na DOM ponad to, co przeglądarka i tak robi natywnie przy replaceState.
Kiedy które podejście
Wybór między router.replace() a history.replaceState() sprowadza się do jednego pytania: czy cel wymaga innego drzewa komponentów?
| Scenariusz | Czego użyć | Dlaczego |
|---|---|---|
| Kosmetyczna poprawka adresu (kanonikalizacja sluga) | history.replaceState() | Ta sama strona, te same dane, tylko ładniejszy adres |
| Przełączenie języka na tej samej treści | history.replaceState() | Ten sam komponent, inny slug dla tej samej treści |
| Przekierowanie na inną stronę | router.replace() | Inna trasa, inny komponent, inne dane |
| Przekierowanie na logowanie | router.replace() | Kompletnie inna strona |
| Stan filtrów i sortowania w adresie | history.replaceState() | Ta sama strona, adres odzwierciedla stan UI |
| Kreator krokowy śledzony w adresie | router.replace() | Każdy krok to inne komponenty |
Zasada jest prosta: jeśli drzewo komponentów zostaje takie samo, a dane już masz, właściwym narzędziem jest replaceState. Jeśli potrzebujesz, żeby Vue Router rozwiązał inną trasę i zamontował inne komponenty, właściwy jest router.replace().
Pułapka replaceState: zachowaj stan routera
Jest jeden błąd, który przy history.replaceState() w aplikacji na Vue Routerze robi się bardzo łatwo, a jest na tyle subtelny, że podstawowe testy go nie wyłapią.
// This looks reasonable but breaks back/forward navigation
window.history.replaceState(null, '', correctPath)
// This preserves Vue Router's internal tracking
window.history.replaceState(history.state, '', correctPath)
Vue Router trzyma w history.state metadane nawigacji — pozycję scrolla, kierunek nawigacji i wewnętrzne dane, na których stoją router.back() i router.forward(). Jeśli podmienisz stan na null, Vue Router gubi swoją pozycję na stosie nawigacji. Przy kolejnym kliknięciu w przycisk wstecz zachowanie robi się nieprzewidywalne.
Przekazując history.state jako pierwszy argument, mówimy przeglądarce: „zaktualizuj adres, ale zostaw wszystko inne w tym wpisie historii bez zmian”. Vue Router dalej działa poprawnie, bo jego obiekt stanu zostaje nietknięty.
Sprawdziliśmy to następującą sekwencją: wejście na produkt krótkim adresem, potwierdzenie, że adres zmienia się na kanoniczny slug, kliknięcie wstecz i potwierdzenie, że wracamy na poprzednią stronę — a nie do pustego stanu albo zduplikowanego wpisu historii.
Dlaczego nie przekierowanie po stronie serwera?
Zanim zdecydowaliśmy się na kliencki replaceState, rozważaliśmy obsługę kanonikalizacji w całości na serwerze, przez przekierowania 301. Gdy ktoś żąda /product/BEA_F53G, serwer mógłby znaleźć pełny slug i zwrócić 301 Moved Permanently na /product/corsa-gold-brushed-BEA_F53G.
Z punktu widzenia SEO byłoby to najczystsze rozwiązanie — Googlebot podąża za 301 i konsoliduje moc linków na adresie kanonicznym. W naszej architekturze ma to jednak dwa poważne koszty.
Po pierwsze, wymaga dodatkowego zapytania do bazy jeszcze przed renderem. Serwer dostaje żądanie, szuka produktu po kodzie, żeby poznać pełny slug, decyduje, czy przekierować, i ewentualnie wysyła 301. To wyszukiwanie dzieje się przed wyrenderowaniem strony. W obecnym przepływie strona renderuje się z tym slugiem, który dostała, pobiera dane produktu (i tak potrzebne do treści) i aktualizuje adres po stronie klienta. To samo pobranie danych służy dwóm celom naraz — renderowaniu treści i rozwiązaniu sluga — zamiast wymuszać osobne zapytanie przed renderem.
Po drugie, przekierowania 301 kiepsko współgrają z naszą strategią cache'owania SSR. Cache'ujemy wyrenderowane strony po pełnej ścieżce adresu. Przekierowanie z /product/BEA_F53G na /product/corsa-gold-brushed-BEA_F53G oznacza, że krótki adres nigdy nie trafia do cache jako wyrenderowana strona — każde żądanie na krótki adres uderza w serwer, wykonuje wyszukiwanie i odsyła przekierowanie. W sklepie z dużym ruchem, gdzie kody produktów są nadrukowane na opakowaniach i siedzą w systemach partnerskich, to spory ruch całkowicie omijający cache.
Podejście z replaceState pozwala cache'ować oba warianty adresu (renderują tę samą stronę), a jednocześnie gwarantuje, że kanoniczne meta tagi zawsze wskazują właściwy slug. Teoretycznie nie jest tak czyste jak przekierowanie serwerowe, ale przy naszym profilu ruchu i infrastrukturze jest zwyczajnie praktyczniejsze.
Argument wydajnościowy, szczerze
Bądźmy szczerzy co do wpływu na wydajność. Na nowoczesnym urządzeniu z szybkim łączem różnica między router.replace() a history.replaceState() to jednocyfrowa liczba milisekund. Nie zobaczysz jej w Lighthouse. Użytkownicy jej nie poczują.
Argument za replaceState nie dotyczy surowej szybkości — dotyczy poprawności i marnotrawstwa.
Poprawność: router.replace() do kanonikalizacji adresu był semantycznym nieporozumieniem. Mówiliśmy Vue Routerowi „nawiguj do nowej lokalizacji”, mając na myśli „wyświetl ten adres dla bieżącej lokalizacji”. Ta różnica ma znaczenie dla SSR, dla meta tagów SEO, dla watcherów reagujących na zmiany trasy i dla każdego middleware'u odpalanego przy nawigacji.
Marnotrawstwo: Każdy zbędny cykl nawigacji to praca, która nie musi się wydarzyć. Guardy przeliczają się i zwracają „przechodź dalej”. Matching trasy rozwiązuje się do tego samego komponentu. Watchery odpalają się i odkrywają, że nic się nie zmieniło. To nie jest wolne — to jest bezcelowe. A bezcelowa praca ma tendencję do kumulowania się. Cztery strony robiące zbędne nawigacje przy każdej kanonikalizacji. Pomnóż to przez ruch sklepu internetowego. Zsumowany czas CPU zjadany na ponowne przeliczanie guardów i matchowanie tras dla kosmetycznych zmian adresu robi się zauważalny, nawet jeśli pojedyncze przypadki są pomijalne.
Prawdziwą wygraną była poprawka SEO. Jawne tagi canonical i og:url renderujące się poprawnie przy SSR, niezależnie od tego, który wariant adresu odwiedzi Googlebot. To nie jest optymalizacja wydajności — to naprawa poprawności, niemożliwa przy podejściu z router.replace().
Czego się nauczyliśmy
Dobierz narzędzie do zadania. router.replace() służy do nawigacji. history.replaceState() służy do kosmetyki adresu. Użycie API nawigacyjnego do zadania niezwiązanego z nawigacją tworzyło efekty uboczne (odpalanie guardów, wyzwalanie watcherów, przerenderowania), które potem trzeba było obchodzić.
Meta tagi w SSR nie mogą zależeć od nawigacji po stronie klienta. Każdy istotny sygnał SEO musi być poprawny w HTML-u wyrenderowanym na serwerze. Jeśli twoja strategia adresów kanonicznych opiera się na JavaScripcie odpalanym po hydracji, Googlebot może jej nigdy nie zobaczyć. Ustawiaj canonical z danych, nie ze stanu trasy.
Deep watchery to code smell przy prostych porównaniach. Obserwowanie całego obiektu danych z deep: true, żeby wykryć zmianę sluga, to jak skanowanie wszystkich plików w katalogu, żeby sprawdzić, czy istnieje jeden konkretny. Celne porównanie w odpowiednim momencie zastępuje reaktywny watcher odpalany przy każdej mutacji danych.
Zachowuj stan routera, gdy grzebiesz w historii. We frameworku zarządzającym stosem historii zawsze przekazuj history.state do replaceState(). Przeglądarki wewnętrzny stan twojego routera nie obchodzi, ale twojego routera — owszem.
Cały diff to 91 dodanych i 53 usunięte linie w czterech plikach. Największy efekt dała jedna linijka: window.history.replaceState(history.state, '', correctPath). Cała reszta to dodanie tagów canonical, które powinniśmy mieć od samego początku.
Czasem najlepszą nawigacją jest brak nawigacji.