← 目录 08 / 11

08 · 交互模式:history 驱动的多级返回

提取自 soviet-press 的「报纸 → 海报 → 文章抽屉」两级返回。 这套模式解决的是一个很常见、但多数实现都会做错的问题: 点击驱动的状态机,返回键和 ESC 行为对不上。


1. 症状:为什么"点击驱动"会出问题

典型写法是让点击直接改状态:

btn.onclick = () => { state.drawer = true; render(); };   // ❌

于是:

问题 后果
返回键不知道有"抽屉展开"这个层级 用户按后退 → 直接退出站点,而不是收回抽屉
ESC 需要单独写一套判断逻辑 ESC 和返回键两套逻辑,早晚不一致
URL 不反映状态 无法分享"海报 2 + 文章展开"这个具体状态,刷新即丢

根因:状态的所有者错了。 让 history 当唯一事实来源,点击只是"请求一个新状态"。


2. 模式:状态由 history 驱动,ESC 就是 history.back()

// 状态形状
// { view: 'paper' | 'poster', index: number|null, drawer: boolean }
const hashFor = (s) => (s.view !== 'poster' ? '#/' : `#/p/${s.index}${s.drawer ? '/a' : ''}`);

// ESC 与浏览器后退键走同一条路径
document.addEventListener('keydown', (e) => {
  if (e.key !== 'Escape') return;
  if ((desired || state).view === 'paper') return;   // 顶层不再后退
  e.preventDefault();
  history.back();                                    // ← 关键:不自己实现"返回"
});

window.addEventListener('popstate', (e) => {
  transitionTo(e.state ?? stateFromHash(location.hash));
});

一旦这样写,ESC 与返回键的一致性是被结构保证的,不靠人去同步两套逻辑。

2.1 顶层必须有"底"

如果用户直接打开 #/p/1/a,此时 history 里只有一条记录,按返回会离开站点。所以初始化时要在栈底压一层:

if (initial.view !== 'paper') {
  history.replaceState(PAPER, '', '#/');                    // 栈底:报纸
  if (initial.drawer) {
    history.pushState({ ...initial, drawer: false }, '', '...'); // 中间层:海报
  }
  history.pushState(initial, '', hashFor(initial));         // 顶层:文章展开
}

这样从任意深链接进来,ESC / 返回都是一级一级退,而不是一步跳出。


3. 转场循环:用「最新状态胜出」,不要用「忙就丢弃」

这是最容易写错的地方。 直觉写法:

if (busy) return;        // ❌ history 已经 push 了,但 DOM 没跟着变 → 状态与视图脱节
busy = true;

用户连点两下,就会永久对不上。正确做法是让循环消费最新的 pending:

let pending = null, running = false, desired = null;

async function transitionTo(next) {
  desired = next;            // 立刻记录"期望状态",供 ESC / 点击判断用
  pending = next;
  if (running) return;       // 已有转场在跑,它会消费新的 pending

  running = true;
  try {
    while (pending) {
      const target = pending;
      pending = null;

      if (reduceMQ.matches) { apply(target); focusView(target); continue; }  // 降级:不做动画

      overlay.classList.add('is-in');
      await afterOverlayTransition(overlay);   // 覆盖屏
      apply(target);                            // ← 在"遮住"的瞬间换视图,看不到闪
      focusView(target);
      overlay.classList.remove('is-in');
      await afterOverlayTransition(overlay);   // 收场
    }
  } finally {
    running = false;
  }
}

两个要点: 1. desired 在函数开头赋值,而不是等 apply 之后 —— 否则转场途中用户的点击会读到过期状态。 2. while (pending) 让最新的状态一定被消费,不会丢。


4. 等待动画结束:必须有兜底定时器

function afterOverlayTransition(el, fallbackMs = 700) {
  return new Promise((resolve) => {
    let done = false;
    const finish = () => { if (!done) { done = true; el.removeEventListener('transitionend', onEnd); resolve(); } };
    const onEnd = (e) => { if (e.target === el && e.propertyName === 'clip-path') finish(); };
    el.addEventListener('transitionend', onEnd);
    setTimeout(finish, fallbackMs);   // ← 标签页不可见时 transitionend 可能永不触发
  });
}

🔴 setTimeout 兜底不是可选项。 标签页在后台、或用户开了 prefers-reduced-motion 之外的其他省电策略时,transitionend 可能不触发,此时整个状态机会永久卡住(running 永远是 true)。

另外 propertyName 必须和你实际动的属性一致:动 transform 就判 'transform',动 clip-path 就判 'clip-path'。换动画实现时最容易忘改这一处。


5. 焦点管理(多数实现直接不做)

时机 焦点去哪
进入子视图 子视图容器(给它 tabindex="-1",配 aria-label 让读屏播报)
返回父视图 回到当初触发进来的那个元素(存起来)
展开一个 disclosure(抽屉/折叠) 留在切换按钮上,不要跳进内容里
视图隐藏 用 hidden 属性,一次性移出 a11y 树与 Tab 序
let returnFocusEl = null;
// 点击进子视图前:returnFocusEl = a;
// 返回父视图时:
const t = (returnFocusEl && document.contains(returnFocusEl))
  ? returnFocusEl : document.querySelector('.fallback-focusable');
t?.focus();

hidden 属性比 visibility: hidden / aria-hidden 更省事:它同时移出 Tab 序、a11y 树和渲染。但要注意作者 CSS 里的 display 声明会覆盖它,所以显式写一条 .view[hidden] { display: none; }。


6. 状态播报

视图切换是纯视觉的,读屏用户完全无感。加一个 live region:

<p class="sr-only" role="status" aria-live="polite"></p>
liveEl.textContent = s.drawer
  ? `已展开文章:${title}。按 Escape 收回文章。`
  : `已进入海报:${title}。按 Escape 退回报纸首页。`;

把"下一步能按什么键"念出来,比只念"已进入 X"有用得多。


7. 无 JS 兜底

多级状态机天然依赖 JS。不要留一个"点了没反应的死链接",而是诚实告诉用户:

<noscript>
  <p>本页的交互(进入海报、展开文章、ESC 返回)需要 JavaScript。
     报纸首页与海报的静态排版不依赖脚本,但抽屉无法展开。</p>
</noscript>

8. 可直接抄的结构

<body data-view="paper" data-drawer="closed">
  <div class="overlay" id="overlay" aria-hidden="true"></div>

  <main id="main" tabindex="-1">
    <section class="view" id="view-paper" aria-label="首页">…</section>

    <section class="view view--sub" id="view-p0" data-index="0" tabindex="-1"
             aria-label="子视图" hidden>
      <div class="sub-content">…</div>

      <section class="drawer" aria-label="文章">
        <button class="drawer__bar" type="button"
                aria-expanded="false" aria-controls="p0-body">…</button>
        <div class="drawer__body" id="p0-body">…</div>
      </section>
    </section>
  </main>

  <p class="sr-only" id="live" role="status" aria-live="polite"></p>
  <script src="app.js"></script>
</body>

把抽屉放进子视图内部(而不是全局一个),这样每个视图天然拥有自己的抽屉内容,切换视图时不需要手动清空/重填内容。

抽屉的 CSS 要点

.drawer {
  position: fixed; left: 0; right: 0; bottom: 0;
  transform: translateY(calc(100% - var(--peek)));   /* 收起时只露出一条 */
  transition: transform var(--dur) var(--ease);
}
body[data-drawer="open"] .drawer { transform: translateY(0); }
body[data-drawer="open"] { overflow: hidden; }        /* 锁背景滚动 */

/* 收起时正文必须移出 Tab 序 —— 用 visibility,而不是 display:none
   (display:none 会让高度塌掉,translateY(calc(100% - peek)) 就算错了) */
.drawer__body { visibility: hidden; transition: visibility 0s linear var(--dur); }
body[data-drawer="open"] .drawer__body { visibility: visible; transition-delay: 0s; }

用 max-height 而不是固定 height:内容短时抽屉跟着变矮,不会在底部留一大片死白。真想让它"占屏大",正确做法是把内容做大(字号、行高、留白),不是把面板拉高。