Xem chi tiết về ngăn xếp

Tổng quan

Xem chi tiết ngăn xếp là một mẫu điều hướng phân cấp, phổ biến trong các ứng dụng di động, trong đó việc kích hoạt một đường liên kết sẽ đẩy một chế độ xem toàn màn hình mới lên trên chế độ xem trước đó. Nội dung của khung hiển thị do ứng dụng xác định, chẳng hạn như trang con của phần cài đặt, một chuỗi trong nguồn cấp dữ liệu, một thư mục trong trình duyệt tệp, trang thông tin chi tiết trong thư viện, v.v. Người dùng quay lại bằng cách vuốt khung hiển thị hiện tại ra khỏi màn hình sang bên phải hoặc bằng cách nhấn vào nút quay lại. Nhật ký trình duyệt luôn được đồng bộ hoá để cử chỉ Quay lại ở cấp hệ điều hành, đường liên kết sâu và thao tác điều hướng quay lại đều hoạt động nhất quán.

Hướng dẫn này triển khai ngăn xếp dưới dạng:

  • Một vùng chứa cố định vị trí cuộn CSS theo chiều ngang, trong đó mỗi thành phần hiển thị là một điểm dừng cố định. Thao tác xem chi tiết sẽ thêm một khung hiển thị mới và cuộn mượt mà đến khung hiển thị đó; cử chỉ vuốt về phía sau được trình duyệt xử lý theo cách gốc, giúp theo dõi động lượng, vận tốc và sự gián đoạn mà không cần JavaScript sự kiện con trỏ.
  • Trình nghe sự kiện scrollsnapchange trên ngăn xếp sẽ kích hoạt khi mục tiêu chụp nhanh thay đổi. Đây là nguồn đáng tin cậy duy nhất cho biết "Active View đã thay đổi" — vì vậy, thao tác vuốt, nhấp, cuộn theo lập trình và đường dẫn popstate đều hội tụ trong một lệnh gọi lại giúp cập nhật inert, khôi phục tiêu điểm, cắt bớt các chế độ xem mà người dùng đã vuốt qua và điều chỉnh nhật ký duyệt web.
  • Tích hợp pushState/popstate để mỗi lần xem chi tiết đều thêm một mục nhập nhật ký, cử chỉ Quay lại ở cấp hệ điều hành hoạt động và các đường liên kết sâu mở trực tiếp vào đúng chế độ xem.
  • Ảnh động view() dựa trên thao tác cuộn (không bắt buộc) trên mỗi khung hiển thị tạo ra hiệu ứng thị sai + làm mờ + đổ bóng liên kết trực tiếp với cử chỉ vuốt, nhờ đó, chuyển động hiển thị được điều khiển bằng ngón tay của người dùng chứ không phải hiệu ứng chuyển động giữa các khung hình.

Phương pháp này được ưu tiên hơn ảnh động transform dựa trên JavaScript vì cơ chế đính nhanh cho phép người dùng điều khiển trực tiếp vị trí của bảng điều khiển bằng cử chỉ (ngón tay của họ điều khiển chứ không phải ảnh động trung gian) và phù hợp với các mẫu tương tác mà người dùng mong đợi từ ứng dụng gốc dành cho thiết bị di động.

Triển khai

1. Markup (note: đây là tên ứng dụng)

HTML tĩnh chỉ là một vùng chứa ngăn xếp trống; các khung hiển thị được tạo bằng JavaScript và được thêm vào khi người dùng di chuyển.

<div class="Stack">
  <!-- Intentionally empty. The initial view is appended by JavaScript
       at init time (step 8). -->
</div>

Mỗi khung hiển thị là một .Stack-view con trực tiếp của .Stack (mục tiêu đính nhanh) có một trình bao bọc .Stack-viewContent bên trong (nơi chứa nội dung khung hiển thị và nơi áp dụng phép biến đổi thị sai — xem bước 2). Tại bất kỳ thời điểm nào, ngăn xếp cũng có một khung hiển thị cho mỗi mục nhật ký hoạt động đang hoạt động, từ trái sang phải theo thứ tự xem chi tiết. Sau khi người dùng xem chi tiết hai cấp từ gốc, DOM được kết xuất sẽ có dạng như sau:

<div class="Stack">
  <!-- Root view. Has whatever content makes sense as the entry point
       of this section of the app. No back button — there is nothing
       behind the root in the stack. -->
  <div class="Stack-view" inert>
    <div class="Stack-viewContent">
      <!-- Root content; includes <a href> links that drill in. -->
    </div>
  </div>

  <!-- First-level drill-down view. The user got here by activating a
       link in the root view. -->
  <div class="Stack-view" inert>
    <div class="Stack-viewContent">
      <header>
        <!-- DO include a back button. The swipe gesture only works on
             touch — keyboard and pointer users need an explicit control. -->
        <button class="back" aria-label="Back"></button>
        <!-- Title / breadcrumb / etc. -->
      </header>
      <main>
        <!-- View content; may include further drill-down <a href> links. -->
      </main>
    </div>
  </div>

  <!-- Second-level drill-down view. Currently visible — no `inert`
       attribute. Same shape as the first-level view. -->
  <div class="Stack-view">
    <div class="Stack-viewContent">
      <header>
        <button class="back" aria-label="Back"></button>
        <!-- ... -->
      </header>
      <main><!-- ... --></main>
    </div>
  </div>
</div>

Lưu ý:

  • Tất cả các thành phần hiển thị ngoại trừ thành phần hiển thị hiện tại đều có thuộc tính inert. Trình xử lý scrollsnapchange sẽ tự động áp dụng/xoá thuộc tính này ở bước 7 — đừng đặt thuộc tính này từ trình tạo khung hiển thị.
  • Các chế độ xem mà người dùng vuốt qua sẽ bị xoá khỏi DOM (cũng theo bước 7) để ngăn xếp không bao giờ vượt quá currentDepth + 1 phần tử con. Các trang này được tạo lại theo yêu cầu từ đường dẫn URL đã lưu vào bộ nhớ đệm nếu thao tác di chuyển tiến quay lại các trang đó.

2. Kiểu

Thanh cuộn ngăn xếp

Ngăn xếp là một lưới ngang, trong đó mỗi khung hiển thị con có chiều rộng chính xác bằng vùng chứa, với tính năng cố định vị trí cuộn CSS thực thi một khung hiển thị cho mỗi lần cố định vị trí. Điều này mang lại cảm giác tự nhiên cho cử chỉ vuốt để quay lại.

.Stack {
  /* Use dvh so the height tracks the dynamic viewport on mobile, where
     the address bar can show/hide. svh would clip during the address bar
     animation; vh leaks under it. */
  height: 100dvh;

  /* Lay views out left-to-right, each one full-width, so horizontal
     scrolling moves between them one at a time. */
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 100%;
  grid-template-rows: 100%;
  overflow-x: auto;

  /* `mandatory` guarantees the stack always settles fully on a view —
     never half-way between two. */
  scroll-snap-type: x mandatory;
  /* Prevent the swipe-back gesture from chaining into the browser's
     own history-back gesture (iOS, some Android) or the page's vertical
     scroll. The user is navigating the stack, not the page. */
  overscroll-behavior-x: none;
}

/* Hide the visual scrollbar — the snap and the parallax are the
   affordances; a horizontal scrollbar would look out of place. */
.Stack::-webkit-scrollbar {
  display: none;
}

/* MANDATORY: Opt into smooth programmatic scrolling via CSS, gated on
   prefers-reduced-motion. JS code calls scrollTo/scrollBy with
   behavior: 'auto' which defers to this rule, so the OS-level reduced-
   motion preference automatically downgrades to instant scrolling
   without any per-call JS branching. */
@media (prefers-reduced-motion: no-preference) {
  .Stack {
    scroll-behavior: smooth;
  }
}

.Stack-view {
  scroll-snap-align: start;
  /* `always` prevents the user from blowing through more than one view
     per gesture, so depth changes always happen one step at a time. */
  scroll-snap-stop: always;
}

/* MANDATORY: A separate inner element is required for the parallax
   transform below. Applying transforms directly to the snap target
   (.Stack-view) would feed back into the scroll container's snap
   geometry and the scroller would jump mid-gesture. */
.Stack-viewContent {
  width: 100%;
  height: 100%;
  background-color: #fff;
  /* Each view scrolls its own content vertically, independent of the
     stack's horizontal scroll. */
  overflow-y: auto;
}

Hiệu ứng "xếp chồng" (thị sai/làm mờ/bóng)

Ảnh động view(inline) dựa trên thao tác cuộn theo dõi tiến trình của từng khung hiển thị thông qua trình cuộn ngăn xếp và áp dụng hiệu ứng thị sai + làm mờ cho khung hiển thị thoát, cùng với bóng trên các khung hiển thị chi tiết sắp tới để chúng được đọc dưới dạng "thẻ" xếp chồng lên khung hiển thị trước đó.

/* MANDATORY: Wrap the animation block in @supports. Browsers without
   scroll-driven animations still parse the @keyframes and would
   apply the `to` state as a static style, leaving every view
   permanently transformed. The @supports gate confines the animation
   to browsers where it actually animates. */
@supports (animation-timeline: view()) {
  .Stack-viewContent {
    /* view(inline) tracks this element's progress through its nearest
       scrollable ancestor on the inline (x) axis. */
    animation: parallax linear both;
    animation-timeline: view(inline);
    /* Only animate the EXIT phase — when this view is being covered
       by a deeper one. During its own entry the view stays at rest,
       so the fresh content is fully bright and in position throughout. */
    animation-range: exit 0% exit 100%;
  }

  /* Drill-down views (everything except the root) also get a shadow on
     their left edge during the transition so they feel like cards
     stacking over the previous view. */
  .Stack-view:not(:first-child) .Stack-viewContent {
    animation: parallax linear both, shadow-fade linear both;
    animation-timeline: view(inline), view(inline);
    /* parallax: only exit (the view sliding back as a deeper one comes in).
       shadow-fade: entry through exit (visible the whole time the view is
       transitioning, not when it's at rest). */
    animation-range: exit 0% exit 100%, entry 0% exit 100%;
  }

  @keyframes parallax {
    /* translateX(75%) and brightness(0.8) are examples — adjust to taste. */
    to {
      transform: translateX(75%);
      filter: brightness(0.8);
    }
  }

  @keyframes shadow-fade {
    /* Shadow ramps in during entry, holds across the middle of the
       gesture, and ramps out during exit — so it's only visible while
       the view is mid-transition, not when at rest. */
    0%, 100% { box-shadow: 0 0 1.5rem #0000; }
    25%, 75% { box-shadow: 0 0 1.5rem #0004; }
  }
}

LƯU Ý: Hiệu ứng này phổ biến trong các ứng dụng ngăn xếp gốc hiện đại và là một điểm khởi đầu tốt, nhưng bạn có thể tuỳ chỉnh hiệu ứng hình ảnh chính xác cho phù hợp với các kiểu chuyển đổi hiện có nếu cần.

3. Trạng thái mô-đun

Ngăn xếp theo dõi 4 trạng thái trong phạm vi mô-đun:

const stack = document.querySelector('.Stack');

// Reference to the root view DOM element. The Stack starts empty, so
// this is null until the root view is created — either at init (step 8,
// when the URL is '/') or later by synthesizeRootEntry() (when the user
// landed on a deep link and then navigates back). Held as a mutable
// reference because other code (the scrollsnapchange handler, init, etc.)
// uses identity comparisons against it.
let rootView = null;

// Tracks which element to restore focus to when the user swipes back
// into a previous view. Keyed by the view element itself so entries
// are garbage-collected automatically when the view is pruned.
const returnFocus = new WeakMap();

// Maps history depth -> {urlPath, view}. We MUST maintain this map
// ourselves because the History API does not expose state for entries
// other than the current one — so when a view is pruned on swipe-back,
// we still need to remember which URL it represented in case the user
// later forward-navigates back into it.
const entriesByDepth = new Map();

// Tracked manually because history.state on a popstate event tells us
// the destination depth but not where we came from. We need both to
// compute the direction (back vs forward) and the distance.
let currentDepth = 0;

Cộng với ba trình trợ giúp dành riêng cho ứng dụng — những nơi duy nhất mà tính năng định tuyến và kết xuất khung hiển thị của ứng dụng được cắm vào:

// Resolve a URL path to the data your app needs to render the
// corresponding drill-down view, or return null for paths this section
// of the app does not handle (the root path '/', external links,
// unknown routes). resolveUrl() is for drill-down routes only — the
// root view is rendered separately by createRootView() below.
function resolveUrl(urlPath) {
  // Replace with your routing logic. For example, match `/view/:id`
  // and look the id up in your app state.
}

// Build the root (home) view of the stack. Application-specific content;
// preserve the .Stack-view / .Stack-viewContent wrapper structure (the
// inner element is required for the parallax — see step 2) and DO NOT
// render a back button — the root view has nothing behind it in the
// stack.
function createRootView() {
  const view = document.createElement('div');
  view.className = 'Stack-view';
  view.innerHTML = `
    <div class="Stack-viewContent">
      <!-- Root content. Include <a href> elements pointing at URL paths
           that resolveUrl() accepts, to enable drill-down from here. -->
    </div>
  `;
  return view;
}

// Build a drill-down view DOM element from the resolved route data.
// Customize the inner content freely, but DO preserve the .Stack-view
// / .Stack-viewContent wrapper structure and DO include a back button
// (the swipe gesture only works on touch).
function createDrillDownView(routeData) {
  const view = document.createElement('div');
  view.className = 'Stack-view';
  view.innerHTML = `
    <div class="Stack-viewContent">
      <header>
        <button class="back" aria-label="Back"></button>
        <!-- Title, breadcrumb, or other view chrome derived from
             routeData. -->
      </header>
      <main>
        <!-- View body, also from routeData. Include further <a href>
             elements pointing at URL paths that resolveUrl() also
             accepts, to enable additional drill-downs from this view. -->
      </main>
    </div>
  `;
  return view;
}

function getCurrentUrlPath() {
  return location.pathname;
}

4. Xem chi tiết

Thao tác xem chi tiết thực hiện bốn việc theo thứ tự sau: đẩy một mục nhật ký, tạo chế độ xem mới, thêm chế độ xem đó, cuộn mượt mà đến chế độ xem đó. Trình xử lý scrollsnapchange (bước 7) sẽ tiếp tục từ đó sau khi quá trình chụp nhanh ổn định.

function drillDown(urlPath) {
  const routeData = resolveUrl(urlPath);
  if (!routeData) return;

  const newDepth = currentDepth + 1;
  // Push BEFORE creating the view so the URL is correct if anything
  // observing history (analytics, etc.) reads it during view creation.
  history.pushState({depth: newDepth}, '', urlPath);

  // pushState truncates forward entries in real browser history;
  // mirror that truncation in our depth map so we don't hold references
  // to views the user can no longer reach.
  for (const d of entriesByDepth.keys()) {
    if (d >= newDepth) entriesByDepth.delete(d);
  }
  currentDepth = newDepth;

  const newView = createDrillDownView(routeData);
  stack.appendChild(newView);
  entriesByDepth.set(newDepth, {urlPath, view: newView});

  // Scroll one viewport-width to the right. behavior: 'auto' defers to
  // the CSS `scroll-behavior` set in step 2, which is smooth unless
  // prefers-reduced-motion is set. The snap container locks onto the
  // new view; the scrollsnapchange listener (step 7) fires when the
  // snap settles.
  stack.scrollBy({left: stack.clientWidth, behavior: 'auto'});
}

5. Xử lý thao tác nhấp và nút quay lại

Chặn các lượt nhấp vào đường liên kết bên trong ngăn xếp và chuyển đổi các lượt nhấp đó thành thao tác xem chi tiết. Giữ nguyên hành vi của phím bổ trợ để thao tác nhấp chuột giữa/cmd vẫn mở đường liên kết trong thẻ mới.

stack.addEventListener('click', (e) => {
  // Back button: defer to goBack() (defined below), which handles both
  // the normal in-app case and the deep-link case.
  if (e.target.closest('.back')) {
    goBack();
    return;
  }

  // Drill-down link.
  const link = e.target.closest('a');
  if (!link || !stack.contains(link)) return;
  // Let the browser handle modified clicks so users can open links
  // in new tabs / windows. e.button !== 0 filters out middle-clicks.
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;

  const urlPath = new URL(link.href).pathname;
  const parentView = link.closest('.Stack-view');
  // If the URL isn't handled by this section of the app (resolveUrl
  // returns null), fall through so the browser navigates normally.
  if (!resolveUrl(urlPath) || !parentView) return;

  e.preventDefault();
  // Record which link the user activated so focus can be restored to
  // it when they swipe (or click) back into this view.
  returnFocus.set(parentView, link);
  drillDown(urlPath);
});

// Going back is usually just history.back(), but there's an important
// edge case: when the user lands directly on a deep-linked URL, there
// is no in-app history entry behind it. Calling history.back() in that
// situation would take them out of the app entirely. MANDATORY: detect
// this case and synthesize a root entry instead, so an in-app Back from
// a deep link lands on the root view and the platform Back from there
// returns the user to where they came from.
function goBack() {
  const atDeepLinkRoot = currentDepth === 0
    && entriesByDepth.get(0)?.view !== rootView;
  if (atDeepLinkRoot) {
    synthesizeRootEntry();
  } else {
    // history.back() fires popstate, which routes through
    // updateFromHistoryState (step 6) and scrolls the stack — the
    // same path a swipe-back converges on.
    history.back();
  }
}

function synthesizeRootEntry() {
  // Push a new history entry pointing at the root URL. This becomes the
  // entry the user "came from"; the original deep-linked entry is now
  // behind us, so platform Back from the root view will return there.
  const newDepth = currentDepth + 1;
  history.pushState({depth: newDepth}, '', '/');

  // Create the root view if it doesn't exist yet (we landed on a deep
  // link and never needed it before now), and insert it at the LEFT end
  // of the stack. Adjust scrollLeft by one viewport width so the user's
  // view doesn't visually jump — they should still be looking at the
  // deep-linked view until the scroll animation below runs.
  if (!rootView) {
    rootView = createRootView();
    stack.prepend(rootView);
    stack.scrollLeft += stack.clientWidth;
  }
  entriesByDepth.set(newDepth, {urlPath: '/', view: rootView});

  // Now scroll to the new entry (the root view). updateFromHistoryState
  // smooth-scrolls one step left, the parallax plays, and
  // scrollsnapchange fires when the root view settles.
  updateFromHistoryState(history.state);
}

6. Đồng bộ hoá từ nhật ký (popstate)

popstate kích hoạt khi người dùng sử dụng nút quay lại hoặc tiến lên của trình duyệt/hệ điều hành, hoặc khi JavaScript gọi history.back()/.go(). Trình xử lý này là đường dẫn duy nhất cuộn ngăn xếp để phản hồi thay đổi về nhật ký.

window.addEventListener('popstate', (event) => {
  updateFromHistoryState(event.state);
});

function updateFromHistoryState(state, behaviorOverride) {
  const newDepth = state?.depth ?? 0;
  const urlPath = getCurrentUrlPath();

  // Ensure entriesByDepth has an entry for the destination depth.
  // If the URL changed (e.g. forward-nav into a previously-pruned
  // view), clear the cached view reference so the loop below rebuilds.
  const entry = entriesByDepth.get(newDepth) ?? {view: null};
  if (entry.urlPath !== urlPath) {
    entry.urlPath = urlPath;
    entry.view = urlPath === '/' ? rootView : null;
  }
  entriesByDepth.set(newDepth, entry);

  // Rebuild any views between root and the destination that were
  // pruned earlier (when the user swiped back past them). Without
  // this, forward-navigating to a previously-pruned view would have
  // no element to scroll to.
  for (let d = 0; d <= newDepth; d++) {
    const e = entriesByDepth.get(d);
    if (!e || e.view) continue;
    const routeData = resolveUrl(e.urlPath);
    if (!routeData) continue;
    const rebuilt = createDrillDownView(routeData);
    stack.appendChild(rebuilt);
    e.view = rebuilt;
  }

  currentDepth = newDepth;

  const targetView = entriesByDepth.get(newDepth)?.view;
  if (!targetView) return;

  // Compare destination index against current scroll position so we
  // can bail if they're already aligned. This is reached when the
  // scrollsnapchange handler below calls history.go() to sync history
  // after a swipe-back that already completed visually — there's
  // nothing more to scroll.
  const toIdx = [...stack.children].indexOf(targetView);
  const fromIdx = Math.round(stack.scrollLeft / stack.clientWidth);
  if (fromIdx === toIdx) return;

  // Pick a scroll behavior:
  //   - multi-step jumps (e.g. history.go(-3)): 'instant' to skip
  //     intermediate snap points — otherwise smooth-scrolling would
  //     fire scrollsnapchange for each one and do N rounds of
  //     state-transition work for no reason.
  //   - rightward (forward) single-step: 'instant'. Browser-forward is
  //     rare on the web and is often spurious (e.g. iOS Safari treats
  //     edge swipes as forward navigation, even with overscroll-behavior
  //     set). An instant swap reads as "snap" rather than a misleading
  //     drilldown animation the user didn't ask for. The user-initiated
  //     drill-down path (drillDown, step 4) is unaffected — it calls
  //     scrollBy directly and never reaches this code.
  //   - leftward (back) single-step: 'auto' so the CSS `scroll-behavior`
  //     (smooth unless prefers-reduced-motion is set — see step 2)
  //     applies. Back is the common, expected case and benefits from
  //     the animation.
  // NOTE: "forward" here means spatial direction (toIdx > fromIdx),
  // NOT depth direction. synthesizeRootEntry (step 5) pushes a new
  // depth but scrolls LEFT to the root view, which correctly reads as
  // back-style (smooth).
  const forward = toIdx > fromIdx;
  const multiStep = Math.abs(toIdx - fromIdx) > 1;
  const behavior = behaviorOverride ?? (forward || multiStep ? 'instant' : 'auto');
  stack.scrollTo({left: toIdx * stack.clientWidth, behavior});
}

7. scrollsnapchange: nguồn đáng tin cậy duy nhất

Sau mỗi lần cam kết chụp nhanh (cho dù được kích hoạt bằng thao tác vuốt, nhấp hay cuộn có lập trình), trình duyệt sẽ kích hoạt một sự kiện scrollsnapchange trên vùng chứa cuộn, trong đó phần tử mới chụp nhanh được hiển thị dưới dạng event.snapTargetInline (đối với thao tác chụp nhanh theo chiều ngang). Việc đặt tất cả các chuyển đổi trạng thái bên trong trình xử lý này giúp đường dẫn vuốt, đường dẫn nhấp và đường dẫn popstate nhất quán.

Trình xử lý được trích xuất thành một hàm onActiveViewChanged độc lập để phương án dự phòng (xem phần "Chiến lược dự phòng" bên dưới) có thể sử dụng lại mà không cần sao chép logic.

function onActiveViewChanged(currentView) {
  // Walk the stack in DOM order to update each view's role:
  //  - Views at or before currentView stay in the DOM but get
  //    `inert` (except currentView) so focus, pointer events, and
  //    AT navigation cannot leak into views hidden behind the
  //    parallax.
  //  - Views after currentView are unreachable (the user swiped
  //    back past them) so we drop them from the DOM to free memory.
  //    Their urlPath stays in entriesByDepth so a later forward
  //    navigation can rebuild the view from scratch.
  let seenCurrent = false;
  for (const view of [...stack.children]) {
    if (seenCurrent) {
      for (const e of entriesByDepth.values()) {
        if (e.view === view) e.view = null;
      }
      view.remove();
    } else {
      // MANDATORY: inert non-current views. Without this, tabbing
      // and screen-reader navigation can reach content hidden behind
      // the parallax — a severe accessibility failure that's
      // invisible to sighted users.
      view.toggleAttribute('inert', view !== currentView);
      if (view === currentView) seenCurrent = true;
    }
  }

  // If the visible view's depth doesn't match `currentDepth`, the
  // user got here by swiping (not clicking) — sync history so the
  // browser back/forward buttons stay coherent with what's on screen.
  let currentViewDepth;
  for (const [d, e] of entriesByDepth) {
    if (e.view === currentView) currentViewDepth = d;
  }
  if (currentViewDepth !== undefined && currentViewDepth !== currentDepth) {
    // history.go fires popstate, which re-enters updateFromHistoryState.
    // That call's fromIdx === toIdx check bails out without scrolling.
    history.go(currentViewDepth - currentDepth);
  }

  // Restore focus on the now-active view:
  //  - If we recorded which link the user activated to drill out
  //    of this view, return focus there so a swipe-back lands them
  //    exactly where they left off.
  //  - Otherwise (a freshly-pushed drill-down view), move focus to
  //    the back button so keyboard users have an obvious next action.
  //  - preventScroll is REQUIRED: without it, .focus() scrolls the
  //    snap container to bring the focused element into view, which
  //    fights the snap and can land the user mid-snap.
  const stored = returnFocus.get(currentView);
  if (stored) {
    stored.focus({preventScroll: true});
    returnFocus.delete(currentView);
  } else if (currentView !== rootView) {
    currentView.querySelector('.back')?.focus({preventScroll: true});
  }
}

stack.addEventListener('scrollsnapchange', (event) => {
  // snapTargetInline is the element that was just snapped to on the
  // inline (horizontal) axis. For this stack — where each view is one
  // horizontal snap stop — that's the new active view.
  onActiveViewChanged(event.snapTargetInline);
});

Khi trang tải, URL có thể đã trỏ đến một chế độ xem sâu (đường liên kết được chia sẻ, dấu trang, thao tác làm mới trên một trang sâu). Tạo bất kỳ khung hiển thị ban đầu nào khớp với URL (gốc hoặc liên kết sâu, nhưng không bao giờ là cả hai), thêm khung hiển thị đó vào ngăn xếp trống, tạo mục nhập nhật ký có độ sâu bằng 0 và chạy lượt cuộn ban đầu bằng behavior: 'instant' để hiệu ứng thị sai không tạo ảnh động trong lần vẽ đầu tiên.

const initialUrlPath = getCurrentUrlPath();
const initialRouteData = resolveUrl(initialUrlPath);

// Build the initial view: a drill-down view if the URL maps to one,
// otherwise the root view. Whichever it is, that's the only view in
// the stack right now — the other will be created lazily by
// synthesizeRootEntry (step 5) or drillDown (step 4) if the user
// navigates to it.
let initialView;
if (initialRouteData) {
  initialView = createDrillDownView(initialRouteData);
} else {
  rootView = createRootView();
  initialView = rootView;
}
stack.appendChild(initialView);

entriesByDepth.set(0, {urlPath: initialUrlPath, view: initialView});
// replaceState attaches a `depth` to the entry the user landed on, so
// any subsequent pushState / popstate has a base depth to count from.
history.replaceState({depth: 0}, '');

updateFromHistoryState(history.state, 'instant');

Các phương pháp hay nhất

  • NÊN sử dụng sự kiện scrollsnapchange (với phương án dự phòng IntersectionObserver — xem phần "Chiến lược dự phòng") làm nguồn đáng tin cậy cho "Active View đã thay đổi", chứ không phải toạ độ sự kiện cuộn. Cam kết chụp nhanh là sự kiện duy nhất kích hoạt nhất quán trên các đường dẫn vuốt, nhấp, cuộn có lập trình và popstate.
  • NÊN áp dụng các phép biến đổi cho phần tử con của mục tiêu chụp nhanh, không bao giờ áp dụng cho chính mục tiêu chụp nhanh. Phép biến đổi trên mục tiêu đính kèm sẽ phản hồi lại hình học đính kèm của vùng chứa cuộn và trình cuộn sẽ gặp lỗi giữa cử chỉ.
  • NÊN áp dụng inert cho mọi thành phần hiển thị, ngoại trừ thành phần hiển thị hiện đang xuất hiện. Nếu không có thuộc tính này, tiêu điểm và thao tác điều hướng của trình đọc màn hình sẽ rò rỉ vào các thành phần hiển thị bị ẩn sau hiệu ứng thị sai — không hiển thị với người dùng có thị lực bình thường nhưng lại là một lỗi nghiêm trọng về hỗ trợ tiếp cận.
  • NÊN đẩy một mục nhật ký trên mỗi thao tác xem chi tiết và xử lý popstate để cử chỉ Quay lại ở cấp hệ điều hành và các nút quay lại/tiến của trình duyệt hoạt động. Đây là điều khiến mẫu này có cảm giác như một ứng dụng gốc.
  • NÊN điều chỉnh nhật ký từ trình xử lý active-view-changed khi thao tác vuốt về phía sau chuyển đến một khung hiển thị có độ sâu không khớp với currentDepth. Nếu không có thao tác này, thao tác Quay lại tiếp theo của hệ điều hành sẽ đưa người dùng đến một nơi không mong muốn vì con trỏ nhật ký của trình duyệt không đồng bộ với nội dung trên màn hình.
  • NÊN cắt bớt các chế độ xem mà người dùng đã vuốt qua khỏi DOM. Nếu không, một phiên truy sâu dài có thể tích luỹ hàng chục cây con tách rời. Đường dẫn URL được lưu vào bộ nhớ đệm trong entriesByDepth là đủ để tạo lại mọi thành phần hiển thị nếu thao tác điều hướng tiến quay lại thành phần hiển thị đó.
  • NÊN gọi .focus({preventScroll: true}) khi khôi phục tiêu điểm bên trong ngăn xếp. preventScroll: false mặc định khiến trình duyệt cuộn mục tiêu lấy tiêu điểm vào khung hiển thị, điều này sẽ chống lại vùng chứa đính và có thể đưa người dùng vào giữa quá trình đính.
  • NÊN giữ nguyên thao tác nhấp chuột giữa/cmd/ctrl trên các đường liên kết nội bộ để URL vẫn có thể chia sẻ và mở được trong thẻ mới.
  • NÊN sử dụng behavior: 'instant' cho các bước nhảy lịch sử nhiều bước VÀ cho các chuyển đổi trạng thái bật lên chuyển tiếp không gian (toIdx > fromIdx). Nếu không, các bước nhảy nhiều bước sẽ kích hoạt scrollsnapchange ở mọi điểm chụp nhanh trung gian và thực hiện N vòng công việc không hoạt động/tiêu điểm/lịch sử. Các popstate chuyển tiếp thường không chính xác – iOS Safari coi thao tác vuốt từ cạnh là thao tác chuyển tiếp của trình duyệt ngay cả khi đã đặt overscroll-behavior-x: none và việc hoán đổi tức thì ít gây hiểu lầm hơn nhiều so với việc tạo ảnh động cho thao tác "truy sâu" mà người dùng không bắt đầu. Đường dẫn xem chi tiết do người dùng bắt đầu (drillDown) không bị ảnh hưởng vì đường dẫn này cuộn trực tiếp, không thông qua popstate.
  • NÊN tôn trọng prefers-reduced-motion: chỉ khai báo scroll-behavior: smooth bên trong @media (prefers-reduced-motion: no-preference) và gọi scrollTo / scrollBy bằng behavior: 'auto' (không phải 'smooth') để lựa chọn ưu tiên ở cấp hệ điều hành có hiệu lực mà không cần phân nhánh JS theo từng lệnh gọi. Việc cố định giá trị behavior: 'smooth' trong mã sẽ bỏ qua chế độ cài đặt của người dùng.
  • NÊN kết xuất các phần tử <a href> thực làm trình kích hoạt truy sâu, chứ không phải <button onclick> hoặc <div>. Thẻ liên kết thực tế có thể xem trước URL khi di chuột, có thể chia sẻ, nhấp chuột giữa, vai trò của trình đọc màn hình và SEO miễn phí.
  • NÊN thêm nút quay lại rõ ràng trong mọi chế độ xem chi tiết. Cử chỉ vuốt chỉ hoạt động khi chạm — người dùng bàn phím, con trỏ và máy tính cần có một khả năng hiển thị.
  • KHÔNG gọi history.pushState từ trình xử lý popstate — thao tác này sẽ đẩy các mục mới trong khi người dùng đang cố gắng quay lại và làm hỏng nút quay lại của trình duyệt.
  • KHÔNG điều khiển hiệu ứng thị sai bằng trình nghe sự kiện scroll khi có ảnh động dựa trên thao tác cuộn. Đường dẫn CSS chạy trên trình kết hợp; trình nghe cuộn JS chạy trên luồng chính và sẽ làm rớt khung hình một cách rõ ràng trong cử chỉ.
  • KHÔNG thay đổi các khung hiển thị mà bạn đã xoá khỏi DOM sau khi vuốt để quay lại. Coi entriesByDepth là bản ghi chính tắc: một mục nhập đã cắt bớt có view: null và được tạo lại theo yêu cầu trong updateFromHistoryState.

Chiến lược dự phòng

Các tính năng có thể cần đến phương án dự phòng là scroll-snap-events và scroll-driven-animations. Cả hai tính năng này đều có phương án dự phòng mạnh mẽ hoặc câu chuyện cải tiến tăng dần và an toàn để sử dụng cho trường hợp này:

Sự kiện cố định vị trí cuộn

Sự kiện scrollsnapchange là cách rõ ràng nhất để phát hiện "Active View đã thay đổi" – một trình nghe trên ngăn xếp, được kích hoạt chính xác một lần cho mỗi lần xác nhận nhanh. Trong các trình duyệt không có thuộc tính này, bạn có thể sử dụng polyfill để đạt được hiệu ứng tương tự bằng cách dùng IntersectionObserver để theo dõi từng khung hiển thị nhằm đảm bảo khả năng hiển thị đầy đủ bên trong ngăn xếp. Phương án dự phòng sẽ gửi vào cùng một hàm onActiveViewChanged mà đường dẫn chính sử dụng, vì vậy, tất cả logic chuyển đổi trạng thái đều nằm ở một nơi.

// MANDATORY when supporting browsers that haven't shipped scroll-snap-events
// yet. Check `HTMLElement.prototype` (not `window` or `document`) — the
// event handler IDL attribute is added to the prototype when the feature
// is supported, regardless of whether any element has the handler set.
if (!('onscrollsnapchange' in HTMLElement.prototype)) {
  const viewObserver = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      // threshold:1 only fires for fully-visible entries, but the
      // observer also emits a "leaving" entry per view that drops below
      // ratio 1. Filter to the entering side, which is the snap-commit
      // moment we're trying to detect.
      if (entry.intersectionRatio === 1) {
        onActiveViewChanged(entry.target);
      }
    }
  }, {root: stack, threshold: 1});

  // Auto-observe every .Stack-view as it's added to the stack, and stop
  // observing as it's removed. Using a MutationObserver lets the primary
  // code (drillDown, updateFromHistoryState, synthesizeRootEntry, init)
  // stay free of fallback wiring.
  new MutationObserver((mutations) => {
    for (const m of mutations) {
      for (const node of m.addedNodes) {
        if (node.classList?.contains('Stack-view')) viewObserver.observe(node);
      }
      for (const node of m.removedNodes) {
        if (node.classList?.contains('Stack-view')) viewObserver.unobserve(node);
      }
    }
  }).observe(stack, {childList: true});

  // Catch up to any views already in the stack at the time this code
  // runs (typically the initial view appended in step 8).
  for (const view of stack.children) viewObserver.observe(view);
}

Ảnh động dựa trên thao tác cuộn

Hiệu ứng thị sai/làm mờ/bóng đổ dựa trên thao tác cuộn là một tính năng cải tiến tăng dần dựa trên cốt lõi điều hướng. Cổng @supports (animation-timeline: view()) CSS (hiển thị ở bước 2) giới hạn ảnh động ở các trình duyệt hỗ trợ; ở mọi nơi khác, các khung hiển thị chỉ cần cắt giữa các điểm dừng mà không cần chuyển đổi. Thành phần này hoạt động đầy đủ mà không có hiệu ứng thị sai — tính năng chụp nhanh, đồng bộ hoá nhật ký, quản lý tiêu điểm và inert vẫn hoạt động.

Nếu cần có hiệu ứng thị sai dự phòng cho các mục tiêu cơ sở cũ, hãy đính kèm trình nghe scroll vào ngăn xếp và viết một thuộc tính tuỳ chỉnh CSS mô tả tiến trình của từng khung hiển thị thông qua cổng cuộn, sau đó điều khiển transform và filter từ thuộc tính đó:

if (!CSS.supports('animation-timeline: view()')) {
  stack.addEventListener('scroll', () => {
    const viewWidth = stack.clientWidth;
    for (const view of stack.children) {
      // Progress: 0 when this view is centered, 1 when it has fully
      // exited to the left. Matches the @keyframes mapping above.
      const offsetLeft = view.offsetLeft - stack.scrollLeft;
      const progress = Math.min(1, Math.max(0, -offsetLeft / viewWidth));
      const content = view.querySelector('.Stack-viewContent');
      content.style.transform = `translateX(${progress * 75}%)`;
      content.style.filter = `brightness(${1 - progress * 0.2})`;
    }
  });
}