页面缓存与滚动位置 · 技术原理(知识库)
页面缓存与滚动位置 · 技术原理(知识库)
技术储备文档:讲清 Vue 3 + Vue Router 下「列表 keep-alive + 滚动还原 + 手动销毁刷新」如何落地。
工程用法见 页面缓存使用指南.md。
实现参考:LegacyApp App(clearCacheView+scrollEls+notAliveViews),在 GOMS App(Vue 3.5 / Router 5 / Pinia)上重做。
1. 问题域
移动端列表常见诉求:
- 前进详情再返回:列表不要重新请求,滚动停在离开前的位置。
- 提交/删除后再返回:列表必须看到新数据。
- 回到首页/菜单:丢掉业务页缓存,避免脏状态。
- 多层列表(父 → 子 → 详情):每一层策略不同,清错一层会导致「只有二层滚不动 / 每次重建」。
纯「列表自己 onActivated 里永远 refresh」能满足 2,但会毁掉 1。需要把「保留」和「刷新」拆成两条路径。
2. 总体架构
┌─────────────────────────────────────────────────────────┐
│ App.vue │
│ <keep-alive :include="routeCaches" :exclude="notAlive">│
│ <component :is="Page" :key="route.name" /> │
│ </keep-alive> │
└─────────────────────────────────────────────────────────┘
▲ include / exclude ▲
│ │
┌────────┴─────────┐ ┌──────────┴──────────┐
│ routeCache store │◄── clearCache │ router before/after │
│ routeCaches[] │ View() │ 登记 addRoute │
│ notAliveViews[] │ │ 进 Work 清全量 │
│ scrollPositions │◄── save/restore │ scrollEls 存取(主) │
└──────────────────┘ └─────────────────────┘
▲
│ 可选;须合并快照,禁止整表覆盖
┌────────┴────────────────┐
│ useListScrollCache(ref) │ onDeactivated / onActivated
└─────────────────────────┘
| 能力 | 手段 |
|---|---|
| 实例级缓存 | Vue <keep-alive> + 动态 include |
| 主动销毁(刷新) | 短暂写入 exclude,并同步从 include 移除 |
| 滚动(主) | meta.scrollEls + 路由守卫存取 |
| 滚动(辅) | useListScrollCache(可选;合并快照) |
| 入口清场 | 进 Work / LookMoreMenu 时 clearCommonCaches |
本仓验证结论:一层 wlSum、二层 wlWorkOpRecList 均以 scrollEls 为主即可;不必默认双通道。
3. keep-alive 命中条件
<keep-alive include> 匹配的是组件的 name 选项,不是路由 path。
因此必须:
defineOptions({ name: 'WlWorkOpRecList' })
// 与
// <route name: 'WlWorkOpRecList' meta.keepAlive: true>
一致。beforeEach 里对 meta.keepAlive 的路由执行 addRoute,把 name 推进 routeCaches,再映射到 include。
3.1 为什么用 route.name 作 :key
<component :is="Component" :key="String(viewRoute.name)" />
- 用
name:同一列表不同 query 仍同一缓存槽,列表态可保留。 - 若用
fullPath:每次加密q变化都新实例,缓存形同虚设。 - 不用 key:个别场景会出现 URL 已变、视图仍停在旧页的问题(本仓历史上踩过)。
3.2 include 与 exclude 的配合
| 操作 | include (routeCaches) | exclude (notAliveViews) |
|---|---|---|
| 正常进入列表 | 加入 name | 无 |
clearCacheView(['X']) | 去掉 X | 加入 X(约 500ms 后清空) |
再次进入已被清的 X | addRoute 加回 | 若仍在 exclude 则先移出 |
exclude 优先于 include。LegacyApp 用「exclude 一段时间再清空」迫使 keep-alive 丢掉实例;GOMS 额外在清除时移出 include,销毁更干净。
关键修复(二层列表进不了缓存):addRoute(to) 时若 to.name 仍在 notAliveViews 中,必须先移出。否则:
- 进入父页时误把子页 name 放进 exclude;
- 用户 500ms 内点进子页;
- 子页处于 exclude → 不会进入缓存;
- 子 → 详情 → 回子 = 整页重建,滚动丢失;
- 父页本身正常缓存 → 表现为「只有二层有问题」。
正确的父子清理时机:在 from=子 & to=父 时清子,而不是「每次 to=父」都清。
4. 滚动位置:为什么 keep-alive 还不够
理想情况:内部 overflow: auto 节点的 scrollTop 随 DOM 一起被 keep-alive 保住。实际还会丢,因为:
- 组件从文档卸下再挂回时,浏览器常把该节点
scrollTop清零。 - 返回后列表异步撑开:先 restore 再出高度,或 van-list 二次布局,需要延迟补写。
- 误刷新清空了列表:DOM 高度变矮,看起来像「回顶」(见 §5)。
- (次要)若页面高度链没锁死,滚动落在壳层
.app-main;此时应把.app-main写入scrollEls,或修正布局让列表自滚。本仓wlSum证明height:calc+ 列表overflow:auto+scrollEls可行。
4.1 存取通道(主 / 辅)
主通道 — 路由守卫 + meta.scrollEls(推荐默认)
// beforeEach:离开前,按 meta.scrollEls 读可见节点 scrollTop(组件仍在文档中)
// afterEach:进入后写回(nextTick + rAF + setTimeout 补写)
时序关键:beforeEach 早于组件 deactivate,能读到真实 scrollTop。queryVisibleScrollEl 会跳过 display:none / 无尺寸节点,降低「命中已缓存隐藏 DOM」的概率。afterEach 须跳过非法选择器(如历史 __ref__ 标记)。
辅通道 — useListScrollCache(可选)
onDeactivated(() => save scrollRef.scrollTop)
onActivated(() => restore with nextTick + rAF + setTimeout)
绑定真实 ref,不依赖 querySelector。
硬约束:保存时必须合并已有 scrollEls 快照,禁止 saveScrollPosition(name, [{ el: '__ref__', top }]) 整表覆盖。
覆盖后 afterEach 无法按 class 还原,而 onDeactivated 又可能读到卸下后的 0 → 「有缓存、无滚位」。
本仓约定:标准列表(含二层)只接主通道即可;见 wlSum、wlWorkOpRecList。
4.2 列表布局约束
推荐结构(与 wlSum 对齐):
page (height:100%; overflow:hidden)
header (fixed height)
body (flex column; height:calc(100% - nav); overflow:hidden)
filters / tabs (flex:none)
scroll (flex:1; min-height:0; overflow:auto) ← scrollEls 指这一层
min-height: 0 是 flex 子项可收缩滚动的关键点。
滚动 class 可直接挂在列表子组件根上(如 <WlSumList class="wl-sum-page__list" />)。
5. 「返回刷新」的正确模型
5.1 两条路径
| 路径 | 做法 | 结果 |
|---|---|---|
| 保留 | 不调 clearCacheView,只 router.back() | 激活旧实例,滚动可还原 |
| 刷新 | 先 clearCacheView([列表 name]) 再 back | 实例销毁,返回时重新 mount + 拉数 |
不要用「所有 onActivated 都 refreshList」统一处理——那是用刷新覆盖了缓存收益。
5.2 clearCacheView 时序
详情提交成功
→ clearCacheView(['ListA']) // include 去掉;exclude 加入;删滚动快照
→ Vue 响应式更新 keep-alive // 修剪缓存中的 ListA
→ router.back()
→ beforeEach: addRoute(ListA) // 移出 exclude,加回 include
→ ListA 新实例 mount // onMounted 拉数(顶部可接受)
→ ~500ms 后 exclude 数组清空 // 不影响已挂上的新实例后续再缓存
对外 API:src/utils/goms/routeCache.ts → clearCacheView。
5.3 致命反模式:列表里 watch 全局 route.query
// 错误
watch(() => route.query.q, () => {
refreshList() // 清空 + 重拉 → 滚动归零
})
route 是全局当前路由。链路:
- 列表 keepAlive,用户已下滑;
- 进详情,
query.q变成详情参数; - 列表已
deactivated,但 watch 仍触发; refreshList在后台把数据清掉;- 返回时即使实例还在,内容已变或高度不够,表现为回顶。
同路由换参应使用:
onBeforeRouteUpdate((to) => { /* 仅本组件仍作为匹配组件被复用时 */ })
从详情返回列表是 activate,不是 beforeRouteUpdate,因此不会误刷新。
5.4 致命反模式:滚动 composable 覆盖守卫快照
// 错误:整表覆盖
cacheStore.saveScrollPosition(name, [{ el: '__ref__', top }])
// 正确:合并 scrollEls 已有项;或根本不接 composable
现象对比:
| 父列表 wlSum | 子列表曾错误接 composable | |
|---|---|---|
| 通道 | 仅 scrollEls | scrollEls + 覆盖式 composable |
| 结果 | 详情返回滚位正常 | 数据在、滚到顶 |
排查时勿先归因「多层列表有毒」——先查子页是否多了一条覆盖快照的保存路径。
6. 与 LegacyApp 旧方案对照
| 点 | LegacyApp | GOMS App |
|---|---|---|
| 缓存名单 | 启动扫路由 keepAlive → Vuex | 运行时 addRoute → Pinia |
| 销毁 | notAliveViews + 500ms 清空 | 同左,并移出 include |
| 滚动 | meta.scrollEls + 守卫 | 主:scrollEls;辅:可选 composable(须合并) |
| 业务清缓存 | this.$clearCacheView([...]) | clearCacheView([...]) |
| 回首页清 | Work / LookMoreMenu beforeEnter | 守卫里 CLEAR_CACHE_ON_ENTER |
| 技术栈 | Vue 2 | Vue 3 <script setup> + defineOptions |
思想同源:include 管长期可缓存集合,exclude 管一次性销毁。
7. 状态机(单列表视角)
meta.keepAlive
│
┌───────────────▼───────────────┐
│ 进入列表 addRoute → include │
│ onMounted 首次拉数 │
└───────────────┬───────────────┘
│ 去详情(只读)
┌───────────────▼───────────────┐
│ beforeEach:按 scrollEls 存位 │
│ 实例留在 keep-alive │
└───────────────┬───────────────┘
│ back
┌───────────────▼───────────────┐
│ afterEach:按快照还 scrollTop │
│ 不重拉(除非 clear 过) │
└───────────────────────────────┘
提交成功路径:
clearCacheView → 实例销毁 → back → 新 mount → 重拉
多层补充:
父(WlSum) → 子(OpRecList) → 详情
子→详情→回子:不清子;靠 scrollEls 还滚位
子→回父:clearCacheView(子) // 下次进子重新拉
8. 调试建议
| 现象 | 优先查 |
|---|---|
| 每次返回都像新页面 | name 是否与 include 一致;是否被 exclude;:key 是否误用 fullPath |
| 只有二层列表整页重建 | 父页是否「每次进入」都 clear 子页;addRoute 是否未移出 exclude |
| 只有二层数据在、滚到顶 | 子页是否 composable 覆盖了 scrollEls 快照;scrollEls 是否指到真实滚动节点 |
| 返回瞬间闪加载 | 是否还有 watch(route.query) / onActivated 强刷 |
| 提交后仍是旧数据 | 是否忘记 clearCacheView;name 是否写错 |
可在开发时临时 watch(routeCaches / notAliveViews / scrollPositions) 打日志,观察进退栈时名单与快照变化。
9. 设计取舍(可写进个人笔记的结论)
- 缓存粒度 = 路由组件 name,不要用 path/fullPath 当 keep-alive 键。
- 刷新 = 销毁实例,比「激活后偷偷 refresh」更符合「有时要缓存有时不要」的产品语义。
- 滚动主通道 =
scrollEls+ 守卫;composable 仅作辅,且禁止覆盖快照。 - 失活组件里不要订阅全局 route 做副作用;keep-alive 下 watch 不会自动停。
- 清缓存的时机要站在导航边上看(from/to),不要只看 to,否则会误伤即将进入的页面。
- exclude 窗口与快速连点 冲突时,以「正在进入的页面移出 exclude」为准。
- 「只有二层有问题」要拆成两种:进不了缓存 vs 仅丢滚位——前者查 exclude/清缓存时机,后者查滚动快照通道。
10. 本仓库代码索引
| 路径 | 内容 |
|---|---|
src/App.vue | keep-alive 壳 |
src/stores/modules/routeCache.ts | 状态与 clearCacheView 实现 |
src/utils/goms/routeCache.ts | 业务调用入口 |
src/router/index.ts | 守卫:清全量 / 父子清子 / scrollEls |
src/composables/useListScrollCache.ts | 可选滚动辅通道 |
src/pages/wlWork/wlSum/index.vue | 一层列表黄金样例 |
src/pages/wlWork/wlWorkOpRec/wlWorkOpRecList.vue | 二层列表(已对齐 scrollEls) |
ai-doc/Wiki/页面缓存使用指南.md | 业务接入步骤 |
ai-doc/changes/route-cache-list-scroll/ | 缓存方案落地 |
ai-doc/changes/wl-work-op-rec-list-scroll/ | 二层滚位纠偏 |
11. 可迁移到其他项目的最小集合
若在别的 Vue 3 项目复用,至少需要:
- 根布局
<keep-alive :include :exclude>+ 稳定:key(建议 route name); - 一个 store:
include[]、exclude[]、scrollMap、clear(names)、add(route); - 列表页:
name+keepAlive+ 内部滚动容器 +meta.scrollEls; - 详情写成功:
clear(names)后back; - 首页类入口:一次性
clear(全部列表名); - 文档约定:禁止 keepAlive 列表
watch全局route.query做刷新;滚动辅通道不得覆盖守卫快照。
以上六条即可覆盖「记滚动 / 可刷新 / 可清场」三类需求。