移动端页面栈与模态栈
移动端那种「从 B 退回 A 时,A 原封不动,列表滚动位置与展开态都在」的体感,关键只有一条:A 从来没被销毁过。Web 默认导航会把 A 卸载,于是要么让 A 一直活在 DOM 里,要么让浏览器替它冻结状态。本页把前者做成一台页面栈(page stack),和原生 App 的
UINavigationController 与 Android back stack 同一套语义:push 时下层全部保留(深度有限,内存无所谓),pop 时等离场动画结束再销毁顶层 DOM。
其上再叠一条模态栈:从首页或详情页打开设置,设置作为模态层从底部滑入、盖在原页面上,原页面是它的背景、不卸载。设置内部继续 push 仍是全保留,返回仍是销毁。
最后一道坎是直达:直接用 URL 打开一个深层设置页时,它底下本没有背景,引擎需要合成一个——默认首页,或从 sessionStorage 取回上次来源。
真实实现用浏览器 history(hash 模式)驱动,手机上系统返回手势与返回键直接弹栈,完整手感见整页独立 demo。图 0-1 这个嵌入版为不干扰讲解页自身的历史记录,改用一套受控模拟栈跑同一套逻辑;框架版同理见
vue-router 整页 demo。
1 · URL 作为唯一真相源
与其命令式地写一堆 push() / pop() 去增删 DOM,不如把它写成声明式的:给定当前 URL,算出「此刻栈里应该挂着哪几层」,再拿它和「实际挂着哪几层」做 diff。这样四种入口——点击导航、系统返回、初次加载、刷新——全部收敛到同一个 render()。
stackFromPath 把一条 URL 翻译成期望的页面栈,含直达时的背景合成:
const ROUTES = {
'/': { level: 1, layer: 'base' }, // 栈底, 永不销毁
'/item': { level: 2, layer: 'base' }, // push: 水平滑入
'/item/reviews': { level: 3, layer: 'base' },
'/settings': { level: 1, layer: 'modal' }, // modal: 底部滑入, 盖住背景
'/settings/account': { level: 2, layer: 'modal' },
};
// 路径 → 累积前缀链。'/item/reviews' → ['/', '/item', '/item/reviews']
const chainOf = (path) => {
const out = ['/']; let cur = '';
for (const seg of path.split('/').filter(Boolean)) out.push((cur += '/' + seg));
return out;
};
// URL → 这一刻「应该」挂着的栈。直达深层设置页时, 底下的背景在此被「合成」出来
function stackFromPath(path) {
if (path.startsWith('/settings')) {
const bg = sessionStorage.getItem('pageStackBg') || '/'; // 来源, 否则兜底首页
const base = chainOf(bg); // 背景层的链
const sett = chainOf(path).filter(p => p.startsWith('/settings'));
return [...base, ...sett]; // 背景 + 设置自己的链
}
return chainOf(path);
}
stackFromPath 把规则写死在数据里:基础层级(/ → /item → /item/reviews)按路径前缀累积成链;设置路径则先铺一段背景(默认 /,或开了开关就读
sessionStorage 取回来源页的链),再接上设置自己的累积链。直达深层设置页就靠这一步把底下的背景补出来,返回才有处可去。
2 · reconcile 的三种结果
拿到「期望栈」后,和当前挂载的栈逐层比对:公共前缀那几层原样不动(这就是 keep-alive——A 不卸载的根因);实际多出来的尾部弹栈销毁(顶层播放离场动画、transitionend 后 remove());期望多出来的尾部挂载入场。push / pop / 直达 / 刷新 全是这一个 diff 的不同结果。
同一个 render() 接住全部四种入口——点击、系统返回、初次加载、刷新:
// 唯一的真相源: 给定 URL, 把「期望栈」和「实际挂载栈」做 diff
function render() {
const target = stackFromPath(currentPath()); // 期望
let common = 0; // 公共前缀长度
while (mounted[common]?.path === target[common]) common++;
// 公共前缀那几层原样不动 —— 这就是 keep-alive, A 不卸载的根因
// 实际多出来的尾部 → 弹栈: 顶层播离场动画, transitionend 后才 remove()
for (const m of mounted.slice(common).reverse())
animateOut(m.el).then(() => m.el.remove()); // pop 即销毁 DOM
mounted = mounted.slice(0, common);
// 期望多出来的尾部 → 挂载入场 (modal 从下、push 从右)
for (const path of target.slice(common))
mounted.push({ path, el: animateIn(buildScreen(path)) });
}
// 四种入口全部收敛到 render():
addEventListener('popstate', render); // 系统返回手势 / 返回键
function navigate(to) { history.pushState({}, '', '#' + to); render(); } // 点击
// 初次加载 / 刷新: 直接 render(), stackFromPath 替你把背景补出来
警示 · 三个容易出错的点。其一,动画期间要上锁:离场 DOM 还没移除时又来一次导航,会让 DOM 栈和 history 错位——真实实现在过渡中强制结清上一段动画再继续(本页嵌入的缩略版改由框架的过渡系统统一调度,天然不会错位)。其二,pop 即销毁是有代价的:被销毁的层若用
forward 再次前进,只能重新挂载、状态丢失(这正是「destroy-on-pop」策略的取舍,符合预期)。其三,sessionStorage 按标签页隔离:别人分享的链接在新标签页打开时它是空的,所以背景合成必须有默认回退(本 demo 回退到首页)。
3 · 换成 vue-router 的原语
上面是「自己手写一台栈引擎」。换成框架,同一套 scheme 可以拆到 vue-router 的几样原语上:meta.layer 区分 push / modal、<transition> 按导航方向给滑动、<keep-alive :include> 的白名单做「push 保活 / pop 销毁」、再用一个
beforeEach 守卫做直达时的背景合成。对照实现见 同一套页面栈 · vue-router 版。
这台引擎建立在 push、replace、back 与 history「条目加指针」模型之上,客户端路由一页把这套栈本身讲透。「软导航开模态、硬导航走整页」与并行与拦截路由是同一道两副面孔的难题,本页的直达合成背景是它在页面栈里的解法;而背景合成本身靠的就是一道导航守卫。