在手机上,从列表打开详情,当前页面被替换是很自然的交互。到了宽屏或折叠屏上,用户往往希望列表留在左边,详情出现在右边,同时还能继续用浏览器的返回和前进。
vue-split-screen 做的事情很集中:它保留一套 Vue Router,把导航过程中的页面记录成一条轨迹,再把轨迹末尾的两个页面呈现在两个面板里。列表 / 详情、父子页面和折叠屏展开后的双栏视图,都可以沿用同一套路由配置。
先把双栏问题还原成一条导航轨迹
直接放两套 RouterView,很快就会遇到两个地址、两份当前路由和两套浏览器历史的问题。这个项目没有再维护一个平行路由,而是把每次成功导航后的页面关系保存下来:
text
trail: A -> B -> C
view: B | C
每个节点只有两个字段:
ts
interface SplitRouteNode {
id: string;
fullPath: string;
}
fullPath 表示页面实际地址,包含查询参数和 hash。id 表示这一次页面实例在轨迹中的身份,所以两次访问相同地址,也可以对应两个不同节点。这样处理后,路由地址和页面实例就不会混在一起。
双栏只取轨迹末尾的两个节点。假设当前显示的是 B | C,从不同页面发起导航,结果并不一样:
| 操作 | 新轨迹 |
|---|---|
C.push(D) |
[A, B, C, D] |
B.push(D) |
[A, B, D] |
C.replace(D) |
[A, B, D] |
B.replace(D) |
[A, D] |
push 会保留发起导航的节点,replace 会把它也替换掉。这个规则由纯 TypeScript 函数 navigateTrail 完成,和 Vue 组件、浏览器 API 都没有关系,因此可以单独测试。
从哪个面板点击,就从哪个节点继续导航
双栏场景需要知道按钮属于哪个页面,否则在左侧面板点击跳转时,很容易把右侧页面也当成导航起点。SplitScreen 会在页面上下文里为 useRouter() 注入面板局部的 push 和 replace,项目另外提供了 useSplitRouter(),让这层依赖更明确。
页面组件里可以使用项目提供的 useSplitRouter():
vue
<script setup lang="ts">
import { useSplitRouter } from 'vue-split-screen';
const router = useSplitRouter();
</script>
<template>
<button @click="router.push('/detail/42')">
Open detail
</button>
</template>
SplitScreen 为每个页面代理注入了自己的路由上下文。这个上下文里,push 和 replace 会把当前节点的 id 一起传给历史控制器,控制器再根据节点位置计算下一条轨迹。对象形式的导航也会以发起导航的页面作为解析基准,所以从左侧页面只修改查询参数时,目标仍然落在正确的节点上。
同一个上下文还提供了 useSplitRouteNode(),可以拿到当前节点的 id 和 fullPath。项目推荐在页面逻辑里使用这些组合式函数,把"页面属于哪一个面板"这件事写得明确。
把轨迹写进每一个浏览器历史条目
导航轨迹如果只放在组件内存里,浏览器返回后就无法知道之前显示过哪些页面。vue-split-screen 把它序列化到 history.state:
ts
{
__vueSplitScreen: {
version: 1,
trail: [
{ id: 'A', fullPath: '/list' },
{ id: 'B', fullPath: '/detail/42' },
],
},
}
Vue Router 仍然负责地址栏、导航守卫以及浏览器的 pushState 和 replaceState。项目只在调用路由方法前准备好下一条轨迹,并在 afterEach 中确认导航结果。
这里有几个容易被忽略的时机:
- 重定向成功后,最后一个节点会更新为重定向后的最终地址。
- 守卫中止、导航取消或重复导航失败时,轨迹不会被提前修改。
- 浏览器执行 Back 或 Forward 时,控制器直接读取目标历史条目里的轨迹。
- 没有项目状态的初始历史条目会补上一条包含当前路由的初始轨迹,格式不正确的外部状态则会被忽略。
因此,Back 和 Forward 恢复的是当时的页面关系,而不只是某个 URL。对于需要保留用户浏览上下文的列表 / 详情页面,这个差别很重要。
用一个 RouterView 渲染当前页和伴随页
接入时,SplitScreen 放在 RouterView 的插槽里,当前页面继续使用 Vue Router 提供的 Component:
vue
<script setup lang="ts">
import { ref } from 'vue';
import { SplitScreen } from 'vue-split-screen';
const split = ref(true);
const reverse = ref(false);
const maxInactivePages = ref(0);
</script>
<template>
<RouterView v-slot="{ Component }">
<SplitScreen
:turn-on="split"
:split-reverse="reverse"
:max-inactive-pages="maxInactivePages"
>
<component :is="Component" />
<template #placeholder>
Select a page to open the second pane.
</template>
</SplitScreen>
</RouterView>
</template>
当前节点直接渲染插槽里的组件,伴随节点则通过已解析的路由记录渲染对应组件。每个页面外面都有一个 ScreenProxy,它负责提供面板自己的 route、router 和节点信息。
这里有一个实际的接入边界:默认插槽需要直接渲染 RouterView 提供的 Component。任意额外的包装节点和兄弟节点不会被项目重新构造,独立嵌套的 RouterView 也不属于稳定契约。全局组件属性上的 $router 和 $route 仍然是应用级对象,页面逻辑应使用组合式函数获取面板上下文。
页面离开视野后,要不要保留状态
双栏只展示最后两个页面,前面的页面会进入非活动状态。默认的 maxInactivePages 是 0,页面一旦离开可见的两个位置就会卸载。需要保留输入框草稿、滚动位置或局部组件状态时,可以设置一个正整数:
vue
<SplitScreen :max-inactive-pages="2">
...
</SplitScreen>
这个数字只计算非活动页面,当前两个面板不占用名额。保留策略按最近使用顺序淘汰,重新回到缓存中的页面时会触发 Vue 的 activated,离开可见区域时会触发 deactivated。项目的演示页把这些生命周期计数直接显示出来,便于观察卸载、缓存和重新激活的区别。
Playground:用可操作的行为说明验证路由
仓库里的 playground 提供了 A、B、C、D 四个页面,可以从任意面板执行 push、replace、重定向和被守卫拦截的导航。顶部还可以切换双栏方向、单页模式、保留数量,以及浏览器的 Back 和 Forward。

运行演示:
sh
pnpm play
实现里的模型测试覆盖轨迹转移、页面呈现和 LRU 保留选择,浏览器测试则直接验证真实的 Vue 组件行为,包括以下场景:
- 从伴随页面发起
push,轨迹按发起节点分支。 - 返回和前进恢复精确的历史轨迹。
- 重定向只提交最终地址,失败导航不改变页面。
- 页面默认及时卸载,开启保留后按 LRU 淘汰并重新激活缓存页面。
- 页面守卫和
beforeRouteEnter仍然拿到正确的组件实例。
项目根目录的完整检查命令是:
sh
pnpm check
pnpm test:browser
其中 pnpm check 会依次执行 lint、类型检查、单元测试、库构建和 playground 构建,浏览器行为测试单独通过 Vitest Browser Mode 在 Chromium 中运行。
适合什么场景
如果应用已经使用 Vue Router,又希望在宽屏上同时保留列表和详情,或者让折叠屏展开后展示两个有关联的页面,这个项目提供了一种比较窄的接入方式:路由仍只有一套,页面关系变成可序列化的轨迹,布局层只负责呈现轨迹末尾的节点。
它不试图把 Vue Router 变成两个互相独立的路由器。需要多个地址栏、多个完全独立的导航历史,或者复杂的嵌套路由树时,仍然应该按应用自身的路由模型设计。
项目地址:github.com/zcf0508/vue...,安装命令如下:
sh
pnpm add vue-split-screen