Day 79 · 路由进阶 — 守卫、过渡与轮播联动:ScreenRouter 退役日
昨天路由表跑通,但轮播还握在手写 ScreenRouter 手里。今天写一个纯决策的轮播调度器(composable):它只回答"何时该切、切到哪",切换动作统一走
router.push——职责分离后,守卫(beforeEach)成为所有切换的"关卡",过渡(Transition)成为所有切换的"皮肤"。今天结束,screen-router.ts零引用,光荣退役。
目录
- 一、轮播调度器:只决策,不执行
- 二、调度器实现:useCarousel
- 三、路由守卫:所有切换的关卡
- 四、路由过渡:所有切换的皮肤
- 五、退役仪式:删除手写 ScreenRouter
- 六、常见坑点
- 七、自测挑战
- 八、总结
一、轮播调度器:只决策,不执行
1.1 原生版的职责纠缠
Day 64 的 ScreenRouter 身兼四职:屏幕注册(数据)+ 切换执行(DOM)+ 轮播计时(策略)+ 事件广播(通信)。四职纠缠的代价:
- 想改"切屏动画"要动它,想改"停留时长"要动它,想加"守卫拦截"还是得动它
- 它直接操作 classList——切换的"执行"没有统一关卡,无法拦截
1.2 Vue 版的职责分配
┌─────────────────────────────────────────────┐
│ useCarousel(composable)—— 只做决策 │
│ 职责:计时、算下一屏、暂停/恢复 │
│ 动作:决策出结果后调 router.push │
├─────────────────────────────────────────────┤
│ Vue Router —— 只做执行 │
│ 职责:URL 变更、组件挂载切换、守卫关卡 │
├─────────────────────────────────────────────┤
│ Transition —— 只做皮肤 │
│ 职责:切换时的淡入淡出 │
└─────────────────────────────────────────────┘
分工收益:守卫成为"所有切换的必经之路"——无论是轮播触发、点击圆点、还是用户按后退键,都会被守卫统一审查。这在原生版里做不到(三条路径三个入口)。
二、调度器实现:useCarousel
// src/composables/useCarousel.ts
import { onMounted, onUnmounted, ref } from "vue";
import { useRouter } from "vue-router";
import { SCREENS } from "../components/ScreenNav.vue"; // Day 78 的单一来源
/**
* 轮播调度器(组合式函数)
* 迁移自 Day 64 ScreenRouter 的 scheduleNext/dwellMs/setAutoPlay
*
* 设计要点:
* 1. 只决策不执行:到点调 router.push,不碰任何 DOM
* 2. 手动切换重置计时:用户点了圆点,当前屏的停留从头算(原生版行为)
* 3. 页面隐藏自动暂停:visibilitychange(Day 64 的省 CPU 策略)
* 4. 成对纪律:定时器在 onUnmounted 清理
*/
export function useCarousel() {
const router = useRouter();
const autoplay = ref(true);
let timer = 0;
/** 停留时长查表(SCREENS 单一来源) */
const dwellOf = (name: string): number =>
(SCREENS.find((s) => s.name === name)?.dwell ?? 15) * 1000;
/** 到点切下一屏:按 SCREENS 顺序循环 */
const next = (): void => {
const idx = SCREENS.findIndex((s) => s.name === route.name);
const target = SCREENS[(idx + 1) % SCREENS.length];
router.push({ name: target.name });
};
/** 重排计时:路由每次变化后调用(守卫 afterEach 里挂——见第三节) */
const reschedule = (): void => {
clearTimeout(timer);
if (!autoplay.value) return;
timer = window.setTimeout(next, dwellOf(String(route.name)));
};
/** 暂停/恢复(用户交互、Tab 切换时用) */
const setAutoplay = (on: boolean): void => {
autoplay.value = on;
if (on) reschedule();
else clearTimeout(timer);
};
// 页面隐藏暂停(省 CPU——值班室大屏的常驻场景)
const onVisibility = (): void => setAutoplay(!document.hidden);
onMounted(() => document.addEventListener("visibilitychange", onVisibility));
onUnmounted(() => {
document.removeEventListener("visibilitychange", onVisibility);
clearTimeout(timer);
});
return { autoplay, setAutoplay };
}
注意 reschedule 需要感知路由变化——正确挂法在守卫的 afterEach(下一节),而不是在调度器里 watch route:让"路由变了要重排计时"成为路由系统的官方副作用,而非调度器的私有联动。
三、路由守卫:所有切换的关卡
// src/router/index.ts(在 Day 78 基础上追加守卫)
import { createRouter, createWebHistory } from "vue-router";
import { useCarousel } from "../composables/useCarousel";
export const router = createRouter({ /* Day 78 路由表原样 */ });
// —— 守卫链:每次切换依次过三关 ——
/**
* 关 1:beforeEach —— 拦截/放行决策
* 大屏场景的用武之地:告警确认弹窗未关闭时阻止切屏(Day 82 接入弹窗后启用)
*/
router.beforeEach((to) => {
// 本周先放行全部;Day 82 在这里挂"告警确认中不许切屏"
return true;
});
/**
* 关 2:afterEach —— 切换完成的副作用
* 轮播计时重排挂在这里:无论是轮播自切、点击、后退键,统一重排
*/
router.afterEach(() => {
// 惰性获取调度器实例(避免循环依赖:composable import router)
carousel?.reschedule();
});
// 调度器实例的持有:在 App.vue 里 useCarousel() 创建一次,
// 通过模块级变量注入(见坑 2 的更优解:provide/inject 或直接模块单例)
import { useCarousel } from "../composables/useCarousel";
let carousel: ReturnType<typeof useCarousel> | null = null;
export function bindCarousel(c: ReturnType<typeof useCarousel>): void {
carousel = c;
}
App.vue 里的接线:
<script setup lang="ts">
import { useCarousel } from "./composables/useCarousel";
import { bindCarousel } from "./router";
// 创建调度器并注入路由层(App 只创建一次)
bindCarousel(useCarousel());
</script>
🎯 对照原生版:Day 64 的
switchTo里"切屏 + 广播 + 重排计时"是揉在一起的;现在三个关注点分居守卫链、Transition、afterEach——每个改动都有唯一的位置。
四、路由过渡:所有切换的皮肤
<!-- App.vue:router-view 套 Transition -->
<template>
<div class="screen-canvas" :style="{ transform: `scale(${scale})` }">
<!-- mode="out-in":旧的淡出完再淡入(原生版 opacity 互斥的过渡版) -->
<router-view v-slot="{ Component }">
<Transition name="screen" mode="out-in">
<component :is="Component" />
</Transition>
</router-view>
</div>
</template>
<style>
/* 屏幕切换过渡:与原生版 Day 64 的 opacity 过渡参数一致(视觉零变化) */
.screen-enter-active,
.screen-leave-active { transition: opacity 0.3s ease; }
.screen-enter-from,
.screen-leave-to { opacity: 0; }
</style>
v-slot 写法的必要性:<Transition> 要求单一子元素,而 router-view 是动态组件出口——v-slot="{ Component }" 把当前组件取出来包进 Transition,这是官方推荐的组合姿势。
五、退役仪式:删除手写 ScreenRouter
三步走完绞杀者模式:
1. 全局搜索 screen-router 的 import —— 应为零引用(屏组件已走 router-view)
2. 删除 src/core/screen-router.ts
3. npm run build —— 绿了才算退役完成
对照表补四行(本周作业):
| 场景 | 原生 ScreenRouter | Vue Router + useCarousel |
|---|---|---|
| 切换执行 | classList 操作 | router.push + Transition |
| 轮播计时 | switchTo 内联重排 | afterEach 统一重排 |
| 拦截时机 | 无 | beforeEach 关卡 |
| URL 状态 | 无 | 刷新停留/后退可用 |
六、常见坑点
坑 1:调度器里 watch route 重排计时
路由变化 → watch 触发重排。功能上可行,但后退键、手动点击、轮播自切三条路径的时序差异会带来边缘 bug(watch 是异步批处理的)。规范挂法:afterEach(同步、必经、语义明确)。
坑 2:循环依赖——composable import router,router 又 import composable
Day 79 的 bindCarousel 就是为此而生(路由模块不 import composable,靠注入)。替代方案:调度器挪到独立模块、只 import router 单例。任何方向都行,禁止双向 import。
坑 3:Transition 与 Day 67 入场动效打架
屏组件内部卡片还有 card-enter 入场动画(Day 67 迁移版),与屏级 screen 过渡叠加出现"双重渐入"(Day 69 坑 1 的路由版)。规则:屏级过渡管屏、卡片入场管内容,卡片入场只在首次挂载播(KeepAlive 后只在首次激活播——明天展开)。
坑 4:mode=“out-in” 切换变慢
淡出 0.3s + 淡入 0.3s = 0.6s 的"换屏空窗"。若嫌慢改 mode 为默认(同时进出)并接受短暂叠加,或缩短 leave 到 0.15s。大屏演示节奏 0.3/0.3 是安全值,改动后要观察与轮播 dwell 的观感配合。
七、自测挑战
T1 · 调度器 + 守卫 + 过渡(80 分钟)
完成第二至四节:轮播按 dwell 自动循环(A 15s → B 20s → C 15s)、点击圆点后当前屏计时重置、后退键触发过渡且轮播重排、Tab 切走暂停/切回恢复。
T2 · 退役验证(20 分钟)
删除 screen-router.ts,build 绿;跑 Day 69 联调清单路由部分(快速连切、数据链路断点)。行为零变化(URL 能力除外)是替换成功的判据。
T3 · 守卫演练(20 分钟)
在 beforeEach 里临时加"5 秒内只许切一次屏"的节流守卫,体验拦截:快速连点圆点时部分切换被拦下(Console 打印拦截日志)。理解后删除——这是为 Day 82 告警拦截做的彩排。
八、总结
| 环节 | 要点 |
|---|---|
| 职责分离 | 调度器只决策、路由只执行、Transition 只管皮肤 |
| 守卫链 | beforeEach 拦截 / afterEach 重排——所有切换必经 |
| 过渡 | v-slot + out-in,参数对齐原生版 |
| 退役判据 | 零 import + build 绿 + 行为零变化 |
| 对照表 | 四行入账,本周任务过半 |
路由层替换完毕。明天开始第二刀:Pinia 接管全局状态。