顶部 Tabs 持久化、切换联动与样式优化
概述
这一节把顶部 Tabs 从“能显示”推进到“能真正使用”。核心补齐三件事:用 pinia-plugin-persistedstate 让 Tabs 刷新后仍在;把 tab-click 从基础组件透传到外层,通过命名路由实现点击切换;让 el-tabs 的 v-model 与 tabsStore.current 成为同一份状态源,保证激活态、路由、store 三处一致。最后对 Tabs 默认样式做结构化收口。关闭 Tabs 与删除策略留到下一节。
学习目标
- 掌握
pinia-plugin-persistedstate的最小接入:注册插件 + store 开persist。 - 理解
persist: true是把 store 交给插件托管生命周期,而非普通布尔字段。 - 封装基础组件后必须透传
tab-click等原生事件,否则外层交互接不起来。 - 用
immediate: true补齐首屏current,并区分持久化“数据回来”与“激活态对齐路由”。 - 用
defineModel()统一v-model/current/paneName为同一套route.name标识。
一、刷新丢失是内存态问题,必须接持久化层
上一节已打通收集链路(路由变化→写入 tabsStore→渲染),但一刷新访问过的 Tabs 全消失。这不是 addRoute 有 bug,而是 Pinia store 默认只存在内存里,刷新后整份 store 重新初始化。只要需求是“刷新后还在”,就一定要考虑持久化层,而不是在组件里补临时逻辑。
二、持久化插件分两步:注册插件 + 给 store 开 persist
pinia-plugin-persistedstate 的最小接入流程:安装后在创建好的 pinia 实例上 pinia.use(plugin),再给需要持久化的 store 配 persist: true。完成后插件会在 state 变化时自动写入缓存,并在刷新后自动回填。
import { createPinia } from "pinia"
import piniaPluginPersistedstate from "pinia-plugin-persistedstate"
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)export const useTabsStore = defineStore("tabs-state", () => {
// ...
}, { persist: true })少任何一步都不生效:只注册插件不写 persist 不行,只写 persist 没 pinia.use() 也不行。这是最小可用接入,后续可继续配置存储位置和字段白名单。
三、persist: true 是把 store 交给插件托管生命周期
这行配置表面简单,含义是:此后每次 state 变化,插件同步到浏览器存储,刷新时再把缓存重新注入回 store。它是对 store state 生效,不是对组件局部变量。如果只想持久化部分字段,应改成对象配置而非布尔值。对 Tabs 这类导航状态通常很合适;对敏感数据则要谨慎。
四、Tabs 点不动,根因是 tab-click 没从基础组件透传
Tabs 渲染出来了但点击没切换页面——Element Plus Tabs 官方支持 tab-click、tab-change、tab-remove、tab-add 等事件。当前没反应,是因为封装了 HeaderTabs 基础组件却没把这些事件向外透传。一旦做了业务组件封装,就要重新思考原生事件还能否被外层拿到;交互组件通常至少透传 tab-click、tab-remove、tab-change。
<el-tabs @tab-click="(tab, event) => emit('tabClick', tab, event)" />五、tab-click 回调最终要映射回 store 中的路由记录
handleTabClick 里真正需要的,是能定位路由的标识。当前实现里 el-tab-pane 的 name 已绑定成 item.name,所以优先直接用 paneName 走命名路由,不必再绕一次数组索引:
function handleTabClick(tab: TabsPaneContext) {
router.push({ name: tab.paneName as string })
}Tabs 组件只是视图层,真正的页面切换目标仍在 store 里维护。当前 Element Plus Tabs 的 v-model 和 tab-click 都围绕 pane name 工作,直接用 name 更稳。
六、watch(..., { immediate: true }) 双作用:首屏补 current + 持久化恢复后对齐
刷新后顶部当前激活项没正确高亮,不是“数据没在”,而是 current 没在页面初始化时立刻和当前路由对齐。给监听路由的 watch 加 immediate: true,进入页面就先执行一次,把 tabsStore.current 设成当前路由对应的 name。
watch(
() => route.name,
(value) => { tabsStore.current = value as string },
{ immediate: true }
)这里的 immediate 和前面面包屑初始化是同一类问题:持久化只保证数据回来,不保证当前激活项自动对齐路由;当前项是谁,最终还是以当前路由为准。
七、激活态稳定要求 v-model 与 store current 同一份状态源
只让点击事件能跳转不够,还要让当前激活 Tabs 和 tabsStore.current 双向绑定,这决定了当前路由变化时 Tabs 是否自动切过去、点击时 store 当前状态是否同步变化。稳定方案是外层 v-model="tabsStore.current"、内层基础组件 defineModel()、再把这个 model 绑给 el-tabs。
<HeaderTabs v-model="tabsStore.current" :data="tabsStore.tabs" />const modelValue = defineModel<string>()只有把 name 统一成稳定字符串标识,v-model 才能可靠工作;外层用 store、内层用 defineModel() 是这类基础组件做双向绑定的简洁方案。
八、el-tab-pane 的 name 应绑定 item.name 而非 item.path
把 name 从 item.path 改成 item.name as string,原因和前面几节一致:嵌套路由里 name 更稳定,current 本来按 name 存,点击切换也适合走命名路由。到这一节,系统导航标识已基本统一成 route.name。如果 name 可能为空,生成 Tabs 数据时就应该先过滤;激活态、点击切换、持久化恢复三件事最好共用同一套标识。
九、样式优化按 Tabs 三层结构收口,而非零散覆盖
目标:去掉默认下边线、去掉外层边框、把每个 Tab 做成卡片方块。关键是先看清 Element Plus Tabs 的 DOM 结构,至少有三层:el-tabs__header、el-tabs__nav、el-tabs__item,再用 :deep() 在作用域下有针对性覆写。
.my-tabs {
:deep(.el-tabs__header) { margin: 0; border-bottom: none; }
:deep(.el-tabs__nav) { border: none; }
:deep(.el-tabs__item) { border-radius: 6px; }
}这类调整先看 DOM 层级再写覆盖效率更高;作用域样式里覆写 Element Plus 内部结构通常都要用 :deep()。样式优化应服务于交互识别,不只是“改得像别的模板”。
十、本节已完成基础可用版,关闭与增强留待后续
到这一节,顶部 Tabs 已具备:路由访问生成 Tabs、刷新保留、当前项正确激活、点击切换页面、样式可用。剩下没做的是关闭 Tabs、删除后当前页如何切换、更多操作。复杂交互应分阶段推进,先保证主链路稳定;关闭策略会牵扯“删当前项后跳到哪”,不适合混在本节一起做。
常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 刷新后 Tabs 还是全丢 | 只写 persist: true,没在 pinia 注册插件 | 在 main.ts 执行 pinia.use(piniaPluginPersistedstate) |
| Tabs 能显示但点击没反应 | tab-click 没从基础组件透传外层 | 基础组件接事件并经 emit / 透传工具抛出 |
| 列表恢复但当前 Tabs 没高亮 | current 没在首屏同步当前路由 | 路由 watch 加 { immediate: true } |
| 激活态和当前路由不一致 | pane name、v-model current、导航目标不是同一标识 | 全部统一为 route.name / item.name |
| 样式改一半还有边线/外框残留 | 只改某一层 class | 结合 :deep() 分层覆写 header/nav/item |
paneName 类型报错 | Element Plus name 类型允许多种 | 当前项目约束下统一转 string 并兜底 |