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:内容短时抽屉跟着变矮,不会在底部留一大片死白。真想让它"占屏大",正确做法是把内容做大(字号、行高、留白),不是把面板拉高。