系统设计 / 路由设计 · 稳定入口与可变目标 / 页面栈的 vue-router 实现 待审核 20 / 23
页面栈 · vue-router

页面栈的 vue-router 实现

原生版自己手写了一台 reconcile 栈引擎。本页把完全相同的 scheme——基础层级 push、设置模态栈、返回销毁、直达合成背景——落到 vue-router 的现成原语上,看框架接管了哪几块、哪几块仍要自己补。

映射关系有四条:meta.layer 区分 push 与 modal 并决定滑动方向;<transition> 按导航是进还是退,给前后两层不同的进出场 class;<keep-alive :include> 的白名单就是「当前还活着的栈」,push 时上一层留在名单里即保活、pop 时把离开层移出名单即缓存剪除;最后一道 beforeEach 守卫在直达设置页时把背景补进历史。

图 0-1 · 同一套 scheme 的 vue-router 版。过渡与保活由 <transition><keep-alive :include> 真实驱动,导航与 history 栈走一套受控状态模型,以免与本站自身的路由抢地址栏。

整页版见 vue-router 整页 demo,对照的原生整页版是 page-stack-app

1 · 路由表与 meta

和原生版同一张表,只是规则挂到了 meta 上——路由本身不再「知道」自己是模态还是 push,由 meta.layer 告诉 <transition> 该怎么滑、告诉栈面板该怎么标;meta.level 决定层叠的 z-index。

const routes = [
  { path: '/',                 component: Home,    meta: { layer: 'base',  level: 1 } },
  { path: '/item',             component: Item,    meta: { layer: 'base',  level: 2 } },
  { path: '/item/reviews',     component: Reviews, meta: { layer: 'base',  level: 3 } },
  { path: '/settings',         component: SetRoot, meta: { layer: 'modal', level: 1 } },
  { path: '/settings/account', component: SetAcc,  meta: { layer: 'modal', level: 2 } },
];
const router = createRouter({ history: createWebHashHistory(), routes });

// 进设置前记下来源页, 供直达时合成背景 (对应原生版往 sessionStorage 写来源)
router.beforeEach((to, from) => {
  if (to.path.startsWith('/settings') && !from.path.startsWith('/settings'))
    sessionStorage.setItem('pageStackBg', from.path);
  return true;
});

2 · keep-alive 白名单与栈的对应

vue-router 同一时刻只渲染一条当前路由,<keep-alive> 把离场的组件连同它的 DOM 与滚动位置缓存起来,这就是「返回时上一页还在」。但默认会把所有走过的页都缓存住,与「pop 即销毁」相悖。

解法是用 :include 白名单精确控制谁被保活:每次导航重算 stackFromPath,白名单只留当前栈里的页,返回时离开的那层被移出名单、缓存剪除、组件销毁。

<router-view v-slot="{ Component }">
  <transition :enter-from-class="enterFrom" :leave-to-class="leaveTo">
    <keep-alive :include="include">              <!-- 白名单 = 当前还活着的栈 -->
      <component :is="Component" :key="$route.path" />
    </keep-alive>
  </transition>
</router-view>
// 每次导航: 算方向 (进/退) + 重算保活白名单。只有「模态根」进出走垂直, 其余走水平 push
const modalRoot = (p) => p === '/settings';
router.afterEach((to, from) => {
  const forward = stackFromPath(to.path).length >= stackFromPath(from.path).length;
  if (forward) {
    enterFrom.value = modalRoot(to.path) ? 'off-y' : 'off-x';  // 模态根从下浮起 / 其余从右
    leaveTo.value   = modalRoot(to.path) ? ''     : 'under';   // 背景留住 / 左移压暗
  } else {
    leaveTo.value   = modalRoot(from.path) ? 'off-y' : 'off-x'; // 模态根向下退出 / 其余向右
    enterFrom.value = modalRoot(from.path) ? ''     : 'under';  // 背景原地露出 / 从左滑回
  }
  include.value = stackFromPath(to.path).map(nameOf);  // 离开层被移出 → keep-alive 剪除 → 销毁
});

// 直达深层设置页: 守卫里重建历史 [背景, 设置root, ...], 返回才逐级有处可去
async function enterDirect(path) {
  const want = stackFromPath(path);          // 已含合成的背景
  await router.replace(want[0]);
  for (let i = 1; i < want.length; i++) await router.push(want[i]);
}

建议 · 框架接管的是 history 同步、<a> 拦截、缓存与复用(keep-alive)、过渡编排(transition)。仍要自己补的是「哪些该保活」的栈策略(include 白名单)、滑动方向判定(进或退)、以及直达时的背景合成(守卫里重建历史)。框架给的是积木,UIKit 式的页面栈语义仍要用积木拼。

keep-alive 的「外层复用、只换最内一格」与本页的「公共前缀保活」是同一直觉的两种体现,嵌套路由一页从另一个角度讲它。不依赖框架的从零实现见原生版