← 路由设计 · 稳定入口与可变目标 / 移动端页面栈 / 模态栈 · 返回时上一页还在 待审核 18 / 23

移动端页面栈 / 模态栈 · 返回时上一页还在

移动端那种「从 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 不卸载的根因);实际多出来的尾部弹栈销毁(顶层播放离场动画、transitionendremove());期望多出来的尾部挂载入场。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 · 相关页