页面缓存与滚动位置 · 技术原理(知识库)

lishihuan大约 8 分钟

页面缓存与滚动位置 · 技术原理(知识库)

技术储备文档:讲清 Vue 3 + Vue Router 下「列表 keep-alive + 滚动还原 + 手动销毁刷新」如何落地。
工程用法见 页面缓存使用指南.md
实现参考:LegacyApp App(clearCacheView + scrollEls + notAliveViews),在 GOMS App(Vue 3.5 / Router 5 / Pinia)上重做。


1. 问题域

移动端列表常见诉求:

  1. 前进详情再返回:列表不要重新请求,滚动停在离开前的位置。
  2. 提交/删除后再返回:列表必须看到新数据。
  3. 回到首页/菜单:丢掉业务页缓存,避免脏状态。
  4. 多层列表(父 → 子 → 详情):每一层策略不同,清错一层会导致「只有二层滚不动 / 每次重建」。

纯「列表自己 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 / LookMoreMenuclearCommonCaches

本仓验证结论:一层 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 后清空)
再次进入已被清的 XaddRoute 加回若仍在 exclude 则先移出

exclude 优先于 include。LegacyApp 用「exclude 一段时间再清空」迫使 keep-alive 丢掉实例;GOMS 额外在清除时移出 include,销毁更干净。

关键修复(二层列表进不了缓存)addRoute(to) 时若 to.name 仍在 notAliveViews 中,必须先移出。否则:

  1. 进入父页时误把子页 name 放进 exclude;
  2. 用户 500ms 内点进子页;
  3. 子页处于 exclude → 不会进入缓存
  4. 子 → 详情 → 回子 = 整页重建,滚动丢失;
  5. 父页本身正常缓存 → 表现为「只有二层有问题」。

正确的父子清理时机:在 from=子 & to=父 时清子,而不是「每次 to=父」都清。


4. 滚动位置:为什么 keep-alive 还不够

理想情况:内部 overflow: auto 节点的 scrollTop 随 DOM 一起被 keep-alive 保住。实际还会丢,因为:

  1. 组件从文档卸下再挂回时,浏览器常把该节点 scrollTop 清零。
  2. 返回后列表异步撑开:先 restore 再出高度,或 van-list 二次布局,需要延迟补写。
  3. 误刷新清空了列表:DOM 高度变矮,看起来像「回顶」(见 §5)。
  4. (次要)若页面高度链没锁死,滚动落在壳层 .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 → 「有缓存、无滚位」。

本仓约定:标准列表(含二层)只接主通道即可;见 wlSumwlWorkOpRecList

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 + 拉数

不要用「所有 onActivatedrefreshList」统一处理——那是用刷新覆盖了缓存收益。

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.tsclearCacheView

5.3 致命反模式:列表里 watch 全局 route.query

// 错误
watch(() => route.query.q, () => {
  refreshList() // 清空 + 重拉 → 滚动归零
})

route 是全局当前路由。链路:

  1. 列表 keepAlive,用户已下滑;
  2. 进详情,query.q 变成详情参数;
  3. 列表已 deactivated,但 watch 仍触发
  4. refreshList 在后台把数据清掉;
  5. 返回时即使实例还在,内容已变或高度不够,表现为回顶。

同路由换参应使用:

onBeforeRouteUpdate((to) => { /* 仅本组件仍作为匹配组件被复用时 */ })

从详情返回列表是 activate,不是 beforeRouteUpdate,因此不会误刷新。

5.4 致命反模式:滚动 composable 覆盖守卫快照

// 错误:整表覆盖
cacheStore.saveScrollPosition(name, [{ el: '__ref__', top }])

// 正确:合并 scrollEls 已有项;或根本不接 composable

现象对比:

父列表 wlSum子列表曾错误接 composable
通道仅 scrollElsscrollEls + 覆盖式 composable
结果详情返回滚位正常数据在、滚到顶

排查时勿先归因「多层列表有毒」——先查子页是否多了一条覆盖快照的保存路径。


6. 与 LegacyApp 旧方案对照

LegacyAppGOMS App
缓存名单启动扫路由 keepAlive → Vuex运行时 addRoute → Pinia
销毁notAliveViews + 500ms 清空同左,并移出 include
滚动meta.scrollEls + 守卫主:scrollEls;辅:可选 composable(须合并)
业务清缓存this.$clearCacheView([...])clearCacheView([...])
回首页清Work / LookMoreMenu beforeEnter守卫里 CLEAR_CACHE_ON_ENTER
技术栈Vue 2Vue 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. 设计取舍(可写进个人笔记的结论)

  1. 缓存粒度 = 路由组件 name,不要用 path/fullPath 当 keep-alive 键。
  2. 刷新 = 销毁实例,比「激活后偷偷 refresh」更符合「有时要缓存有时不要」的产品语义。
  3. 滚动主通道 = scrollEls + 守卫;composable 仅作辅,且禁止覆盖快照。
  4. 失活组件里不要订阅全局 route 做副作用;keep-alive 下 watch 不会自动停。
  5. 清缓存的时机要站在导航边上看(from/to),不要只看 to,否则会误伤即将进入的页面。
  6. exclude 窗口与快速连点 冲突时,以「正在进入的页面移出 exclude」为准。
  7. 「只有二层有问题」要拆成两种:进不了缓存 vs 仅丢滚位——前者查 exclude/清缓存时机,后者查滚动快照通道。

10. 本仓库代码索引

路径内容
src/App.vuekeep-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 项目复用,至少需要:

  1. 根布局 <keep-alive :include :exclude> + 稳定 :key(建议 route name);
  2. 一个 store:include[]exclude[]scrollMapclear(names)add(route)
  3. 列表页:name + keepAlive + 内部滚动容器 + meta.scrollEls
  4. 详情写成功:clear(names)back
  5. 首页类入口:一次性 clear(全部列表名)
  6. 文档约定:禁止 keepAlive 列表 watch 全局 route.query 做刷新;滚动辅通道不得覆盖守卫快照。

以上六条即可覆盖「记滚动 / 可刷新 / 可清场」三类需求。