移动端页面栈 / 模态栈 · 返回时上一页还在
移动端那种「从 B 退回 A 时,A 原封不动(列表滚动位置、展开态都在)」的体感,关键只有一条:A 从来没被销毁过。Web 默认导航会把 A 卸载,于是要么让 A 一直活在 DOM 里,要么让浏览器替你冻住它。这页把前者做成一台可点的页面栈 (page stack)——和原生 App 的
UINavigationController / Android back stack 同一套语义:push 时下层全部保留(深度有限,内存无所谓)、pop 时等离场动画结束再销毁顶层 DOM。
再叠一条特殊栈:从首页 / 详情页打开设置,设置作为一个模态层从底部滑入、盖在原页面上——原页面是它的背景,不卸载。设置内部继续 push 仍是全保留、返回仍是销毁。最后一道坎是直达:直接用 URL
打开一个深层设置页时,它底下本没有背景——这台引擎会合成一个(默认首页,或从 sessionStorage 取回上次来源)。真实实现用浏览器 history(hash 模式)驱动,手机上系统返回手势 / 返回键直接弹栈——完整手感见下方整页独立
demo;本页嵌入的缩略版为不干扰讲解页自身的历史记录,改用一套受控模拟栈跑同一套逻辑。
下面是嵌在讲解里的缩略演示(左手机 + 右栈面板),用受控模拟栈跑通页面栈的全部逻辑。想整页全屏体验真实 history 驱动的手感,直接打开 整页独立 demo(占满视口、每屏自带顶栏、手机系统返回手势直接弹栈);框架版同理 vue-router 整页 demo。
试一试:在手机里点 查看商品详情 → 全部评价 一路 push 进去(右侧栈一直变长、下层都标着「保留」)→ 点顶栏 ‹ 返回,看离场动画结束后那一层从栈里消失(DOM 被销毁)。再回首页往下滚动一段、打开设置模态、在里面翻几页、关掉——回到首页滚动位置原样还在,因为首页一直没被卸载。最后点几个 ❄️ 冷启动按钮,看直达深层设置页时底下的背景被合成出来。
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 版。
4 · 相关页
- 本系列 · 页面栈的 vue-router 实现——同一套「基础层级 + 设置模态栈 + 直达合成背景」,用 vue-router 的 keep-alive / transition / meta / 守卫拼出来。
- 本系列 · 客户端路由 · history 栈——这页的引擎建立在 push / replace / back 与 history「条目 + 指针」模型之上——那页把这套栈本身讲透。
- 本系列 · 并行 / 拦截路由——「软导航开模态、硬导航(刷新 / 直达)走整页」是同一道「两副面孔」难题;本页的「直达合成背景」是它在页面栈里的解法。
- 本系列 · 软导航 · 守卫 / 滚动恢复——背景合成靠的就是一道导航守卫;而「返回时上一页滚动位置还在」在本页是 keep-alive 天然具备的,那页讲的是被卸载时怎么手动恢复。
- React Router · location state (background)——React 一侧的同型解法:用导航 state 记下「该显示在模态底下的那张页」,直达时回落整页。