ข้ามไปยังเนื้อหา

View Transitions

เว็บ multi-page แบบดั้งเดิม reload เต็มหน้าทุกครั้งที่ navigate — เชื่อถือได้ แต่จอกระพริบขาวและ scroll position รีเซ็ต SPA เลี่ยงปัญหานี้ได้แต่ต้องแลกกับ JavaScript router ที่หนัก view transitions ของ Astro ให้ความรู้สึกลื่นแบบ SPA บน MPA ปกติ ด้วย script แค่ไม่กี่ kilobyte

เปิดใช้ด้วยการเพิ่ม component เดียว <ClientRouter /> ใน <head> ของหน้า — ปกติวางใน layout ที่ใช้ร่วมกันเพื่อให้มีผลทั้งเว็บ ตรวจสอบตรงจาก virtual-module source ที่ติดตั้งจริงใน 7.3.5 (astro/dist/transitions/vite-plugin-transitions.js): path import จริงตอนนี้คือ astro:transitions:

---
import { ClientRouter } from 'astro:transitions';
---
<html lang="en">
<head>
<title>My site</title>
<ClientRouter />
</head>
<body>
<slot />
</body>
</html>

เมื่อมีสิ่งนี้แล้ว การ navigate ระหว่างหน้าในเว็บของเราจะถูก intercept และจัดการฝั่ง client: Astro fetch หน้าถัดไป สลับเนื้อหาเข้าไป แล้ว animate การเปลี่ยน — ไม่มี reload เต็มหน้า browser ที่ไม่รองรับ API เบื้องหลังจะ fallback ไป navigate ปกติเฉย ๆ ไม่มีอะไรพัง

Astro รุ่นเก่า component นี้ชื่อ <ViewTransitions /> มันไม่ใช่แค่ deprecated หรือ alias ใน 7.3.5 — มันไม่ export ออกมาเลย พิสูจน์ด้วย build จริง:

---
import { ViewTransitions } from 'astro:transitions';
---
<ViewTransitions />
[ERROR] [vite] ✗ Build failed
[MISSING_EXPORT] "ViewTransitions" is not exported by "\0astro:transitions".
╭─[ src/pages/vt-bad-import.astro:2:10 ]
2 │ import { ViewTransitions } from "astro:transitions";
│ ───────┬───────
│ ╰───────── Missing export

ถ้าเจอ <ViewTransitions /> ใน tutorial เก่า ทางแก้คือเปลี่ยนชื่อตรง ๆ เป็น <ClientRouter /> — import specifier เดิม พฤติกรรมเดิม

<ClientRouter /> ของ Astro เป็น client-side router: มัน intercept การคลิก link ด้วย JavaScript, fetch หน้าถัดไปเอง แล้วสลับ DOM — View Transitions API คือสิ่งที่มันใช้animateการสลับนั้นให้ลื่น browser สมัยใหม่ตอนนี้รองรับ cross-document view transitions แบบ native ด้วย ไม่ต้องใช้ JavaScript เลย ล้วน ๆ ด้วย CSS:

@view-transition {
navigation: auto;
}

declaration เดียวนี้เปิด transition แบบ full-page-navigation ของ browser เอง — ไม่มีการ intercept ไม่มีการ fetch ฝั่ง client ไม่มี <ClientRouter /> เลย; browser ยัง navigate ปกติ แค่ animate การสลับระหว่างเอกสารเก่ากับใหม่ ใช้แบบ CSS-only นี้เมื่ออยากได้แค่ความลื่นทาง visual ไม่มีอะไรมากกว่านั้น (ไม่มี shared-element transition:name morph, ไม่มี transition:persist เก็บ state, ไม่มี lifecycle event ให้ hook) และอยากให้หน้าไม่มี JavaScript เพิ่มเลย ใช้ <ClientRouter /> เมื่อต้องการ island ที่ persist, shared-element transition ที่ตั้งชื่อได้ หรือ hook เข้า lifecycle ของการ navigate (astro:page-load ฯลฯ) — สิ่งเหล่านี้ form CSS-only แบบ native ให้ไม่ได้เลย

flowchart LR
  click["User clicks a link"] --> intercept["ClientRouter intercepts (no full reload)"]
  intercept --> fetch["Fetch the next page HTML"]
  fetch --> swap["Swap in the new content, animate"]
  swap --> event["Fire astro:page-load"]
ClientRouter สลับเนื้อหาแทนที่จะ reload

โดย default Astro cross-fade หน้าให้ เราคุม animation ต่อ element ได้ด้วย directive:

  • transition:name — ให้ element ชื่อเดียวกันทั้งสองหน้า Astro จะ morph ตัวหนึ่งไปเป็นอีกตัว (shared-element transition แบบ thumbnail โตขึ้นเป็น hero image)
  • transition:animate — เลือก animation (fade, slide หรือแบบ custom) import helper จาก astro:transitions
  • transition:persist — เก็บ DOM และ state ของ component ไว้ข้าม navigation แทนที่จะ re-render ใหม่ เหมาะกับ video ที่กำลังเล่นหรือ audio player ที่ไม่ควรเริ่มใหม่
---
import { ClientRouter } from 'astro:transitions';
import { slide } from 'astro:transitions';
---
<html>
<head><title>A</title><ClientRouter /></head>
<body>
<h1 transition:name="page-title" transition:animate={slide({ duration: 200 })}>Title</h1>
<video controls autoplay transition:name="player" transition:persist></video>
</body>
</html>

output จริงที่ build ออกมา (astro build) — Astro เขียน element ที่ตั้งชื่อไว้แต่ละตัวใหม่ให้เป็น id data-astro-transition-scope มาร์คตัวที่ persist ด้วย data-astro-transition-persist และใส่ keyframe ของ slide() เป็น CSS แบบ scoped ต่อ element ผูกกับ scope เดียวกัน พร้อม fallback CSS สำหรับ browser ที่ไม่มี native View Transitions API:

<body>
<h1 data-astro-transition-scope="astro-cqr5yetz-1">Title</h1>
<video data-astro-transition-persist="player" data-astro-transition-scope="astro-vnhdmk2z-2" controls autoplay></video>
</body>
/* generated once per transition:name, real fence trimmed to the essentials */
[data-astro-transition-scope="astro-cqr5yetz-1"] { view-transition-name: page-title; }
@layer astro {
::view-transition-old(page-title) { animation-name: astroFadeOut, astroSlideToLeft; animation-duration: 200ms, 200ms; }
::view-transition-new(page-title) { animation-name: astroFadeIn, astroSlideFromRight; animation-duration: 200ms, 200ms; }
}
/* [data-astro-transition-fallback="old"/"new"] rules duplicate the same animation-name/-duration for browsers without the native API */

transition:persist บน <video> ไม่ได้แค่ข้าม animation — มันคือ attribute จริงแยกต่างหาก (data-astro-transition-persist) ที่บอก client router ให้เก็บ DOM node ตัวนั้นเป๊ะ ๆ (พร้อม state ที่กำลังทำงานอยู่ — ตำแหน่งเล่น, state ของ <audio>/<video>, ค่าใน form, state ในหน่วยความจำของ island) ข้าม swap แทนที่จะแทนที่ด้วย copy ของหน้าใหม่

Lifecycle event เป็นชื่อ string ไม่ใช่ constant ที่ export — พิสูจน์แล้ว

หัวข้อที่มีชื่อว่า “Lifecycle event เป็นชื่อ string ไม่ใช่ constant ที่ export — พิสูจน์แล้ว”

เพราะ navigation ไม่ trigger full page load อีกต่อไป script ที่รันตอน DOMContentLoaded จะไม่รันซ้ำตอน client-side navigation Astro ยิง event ของตัวเอง และใน 7.3.5 มันเป็น string event name ธรรมดา ที่ dispatch ด้วย document.dispatchEvent(new Event(name)) — ตรวจสอบตรงจาก astro/dist/transitions/events.js และ router.js:

  • astro:before-preparation
  • astro:after-preparation
  • astro:before-swap
  • astro:after-swap — ทันทีหลัง DOM ใหม่ถูกสลับเข้ามา ก่อน paint (เหมาะกับ restore theme/state เพื่อเลี่ยงการกระพริบ)
  • astro:page-load — ยิงหลังทุก navigation (โหลดครั้งแรก และ การสลับฝั่ง client)

ไม่มี constant สไตล์ TRANSITION_PAGE_LOAD ให้ import เลย — มีแค่ TypeScript class export สองตัว (TransitionBeforePreparationEvent, TransitionBeforeSwapEvent) ไว้ type event object เอง ไม่ใช่ type ชื่อของมัน พิสูจน์ด้วย build จริง:

---
import { TRANSITION_PAGE_LOAD } from 'astro:transitions/client';
---
<p>{TRANSITION_PAGE_LOAD}</p>
[ERROR] [vite] ✗ Build failed
[MISSING_EXPORT] "TRANSITION_PAGE_LOAD" is not exported by "\0astro:transitions/client".

ฟังด้วย string ธรรมดา แล้ว re-initialize ตรงนี้แทนที่จะเป็น DOMContentLoaded:

document.addEventListener('astro:page-load', () => {
// runs on first load AND after every client-side navigation
initAnalytics();
});

นี่คือกับดักเรื่อง view-transitions ที่พบบ่อยที่สุด: init script ที่ทำงานตอนโหลดครั้งแรกแต่ “หยุดทำงาน” หลัง navigate เพราะมันผูกกับ DOMContentLoaded ย้ายไปที่ astro:page-load

astro:transitions/client (ตรวจสอบตรงจาก virtual-module source เดียวกัน) ยัง export function navigate(href, options) จริง — เป็นการ navigate แบบเขียน script เอง ที่ไปผ่าน client router ตัวเดียวกับที่คลิก link ปกติ จึง animate และยิง lifecycle event เดียวกัน:

---
import { ClientRouter } from 'astro:transitions';
---
<html><head><ClientRouter /></head>
<body>
<button id="go">Go</button>
<script>
import { navigate } from 'astro:transitions/client';
document.getElementById('go')?.addEventListener('click', () => navigate('/next-page'));
</script>
</body></html>

การเรียก navigate() ตอน render ฝั่ง server (แทนที่จะเป็นการตอบสนอง event จริงใน browser) จะ log warning ใน source ที่ติดตั้งจริง (router.js: “The view transitions client API was called during a server side render…”) — มันตั้งใจให้รันฝั่ง client เพื่อตอบสนอง user interaction

ถ้า browser ของผู้ใช้ไม่รองรับ View Transitions API เลย <ClientRouter /> ยัง intercept และสลับฝั่ง client ให้ — แค่ข้าม animation ไป (หรือใช้ CSS-fallback rule ที่เห็นข้างบน ซึ่ง browser บางตัวยังรันได้แม้ไม่มี native API) มีแต่ browser ที่ปิด JavaScript ไปเลยเท่านั้นที่ fallback ไป navigate เต็มหน้าธรรมดา ไม่มี animation

transition:persist บน island เก็บ DOM และ live component state ของ island นั้นไว้ข้าม client-side navigation — component ไม่ถูก unmount แล้ว remount ใหม่ มันคือ instance เดิมเป๊ะ ๆ ที่ถูกส่งต่อไป นี่ต่างจาก element ธรรมดาอย่าง <video>: สำหรับ island “persist” หมายถึง state ภายในของ framework component (ตัวนับ, ค่าใน form, อะไรก็ตามที่อยู่ในหน่วยความจำ) รอดไปด้วย ไม่ใช่แค่ DOM node กับตำแหน่งเล่นแบบ native

  • snippet import { ViewTransitions } from 'astro:transitions' หรือเปล่า? พิสูจน์ข้างบนแล้วว่า: นี่คือ MISSING_EXPORT build error จริงใน 7.3.5 ไม่ใช่แค่ deprecation เบา ๆ — เปลี่ยนเป็น ClientRouter
  • มันตั้ง caching header บนหน้าที่มี <ClientRouter /> ด้วย แล้วคาดหวังว่าเนื้อหาที่สลับเข้ามาจะ “สด” หรือเปล่า? shell ที่ cache จาก edge ก็ยังเป็นแค่ HTML — <ClientRouter /> ไม่ได้เปลี่ยน lesson เรื่อง caching ที่ใช้กับ response ข้างใต้มันเลย
  • มัน import lifecycle event เป็น named constant (TRANSITION_PAGE_LOAD, AFTER_SWAP ฯลฯ) แทนที่จะใช้ string ธรรมดาหรือเปล่า? พิสูจน์ข้างบนแล้วว่า: export แบบนั้นไม่มีอยู่จริง — ใช้ string ตรง ๆ กับ addEventListener('astro:page-load', ...)
  • client:load ถูกใช้กับ component ที่เป็น transition:persist ด้วย ทั้งที่ component ไม่ได้ต้องการ hydrate ทันทีจริง ๆ หรือเปล่า? transition:persist กับ timing ของ hydration เป็นอิสระจากกัน — persist ไม่ได้ต้องการ directive ที่หนักที่สุด
  • init script ยังผูกกับ DOMContentLoaded บนเว็บที่ใช้ <ClientRouter /> อยู่หรือเปล่า? มันจะหยุด re-run เงียบ ๆ หลัง client-side navigation ครั้งแรก — ย้ายไปที่ astro:page-load

🐛 หา bug ในโค้ดนี้

src/layouts/Layout.astro
---
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head><ViewTransitions /></head>
<body><slot /></body>
</html>
เฉลย

นี่ไม่ใช่แค่ความชอบเรื่อง style — มันคือ build error จริงใน Astro 7.3.5 ที่ติดตั้งจริง พิสูจน์ข้างบนแล้ว: [MISSING_EXPORT] "ViewTransitions" is not exported by "astro:transitions" component นี้ถูกเปลี่ยนชื่อเป็น ClientRouter ใน Astro ปัจจุบัน ทางแก้คือ import { ClientRouter } from 'astro:transitions' แล้วใช้ <ClientRouter /> ใน template — module specifier เดิม ตำแหน่งเดิมใน <head> แค่เปลี่ยนชื่อใหม่

<ClientRouter /> คือ client-side interceptor ที่วางซ้อนบน MPA navigation ปกติ ไม่ใช่ router ใหม่ที่มี state model ของตัวเอง — มัน fetch HTML ของหน้าจริง สลับ DOM จริง และยิง event จริงให้เรา hook เข้าไป (astro:page-load ไม่ใช่ re-render มายากล) transition:persist คือทางออกเดียวจาก “สลับทุกอย่าง”: มันเก็บ element (หรือ island พร้อม live state) ตัวเดียวไว้เป็น node เดิมเป๊ะ ๆ แทนที่จะเอา copy ของหน้าใหม่มาแทน

<ClientRouter /> Native @view-transition { navigation: auto }
JavaScript ที่ส่งไป ไม่กี่ KB (router + swap logic) ศูนย์
shared-element transition:name morph มี ไม่มี
transition:persist (เก็บ state ของ island) มี ไม่มี — ทุก navigation คือ document swap เต็มรูปแบบจริง ๆ
Lifecycle hook (astro:page-load ฯลฯ) มี ไม่มี
Fallback สำหรับ browser ที่ไม่รองรับ สลับฝั่ง client ธรรมดา ไม่ animate navigate เต็มหน้าธรรมดา ไม่ animate
เหมาะกับ เว็บที่ต้อง persist state หรือคุม animation ละเอียด ความลื่นทาง visual แบบง่าย ๆ ไม่มี JS เพิ่ม
  • “<ViewTransitions /> ยังใช้ได้ แค่ deprecated” พิสูจน์ข้างบนแล้วด้วย build จริง: มันคือ MISSING_EXPORT error จริงใน 7.3.5 ไม่ใช่แค่ warning เบา ๆ
  • “lifecycle event เป็น constant ที่ import ได้ อย่าง TRANSITION_PAGE_LOAD” พิสูจน์ข้างบนแล้วว่า: export แบบนั้นไม่มีอยู่จริง (MISSING_EXPORT บน astro:transitions/client) — มันคือ string event name ธรรมดา ('astro:page-load' ฯลฯ) ที่ dispatch บน document
  • “transition:persist บน element ธรรมดา persist state ของหน้าได้ทุกอย่าง” มันเก็บแค่ DOM node ตัวนั้น (และสำหรับ island ก็ state ของ component) ข้าม swap — ไม่ได้ทำให้ state อื่นที่ไม่เกี่ยวข้อง (เช่นตัวแปรใน script อื่น) รอดจาก navigation ไปด้วย
  • “ไม่มี <ClientRouter /> แล้ว view transitions ไม่มีอยู่เลย” browser สมัยใหม่รองรับ cross-document view transitions แบบ native ผ่าน @view-transition { navigation: auto } ใน CSS ล้วน ๆ — ไม่มี component ไม่มี JavaScript เลย
import จริงในปัจจุบันของ client router component ใน Astro 7.3.5 คืออะไร?
เกิดอะไรขึ้นตอน build กับ [email protected]?
---
import { ViewTransitions } from 'astro:transitions';
---
<ViewTransitions />
lifecycle event ของ view-transition ใน Astro 7.3.5 คืออะไร ตรวจสอบจาก source แล้ว?
HTML ที่ build ออกมาจริงหน้าตาเป็นยังไง และ transition:persist ทำอะไรจริง ๆ?
<video controls autoplay transition:name="player" transition:persist></video>
init script ที่ผูกกับ DOMContentLoaded หยุดทำงานหลัง client-side navigation ทางแก้คืออะไร?
ควรใช้ CSS แบบ native @view-transition { navigation: auto } แทน <ClientRouter /> เมื่อไร?