Skip to content

问题背景

这类需求非常常见:

  • 列表页有多个 Tab
  • 每个 Tab 都有自己的分页、筛选条件
  • 用户在某个 Tab 的第 N 页点击进入详情页
  • 从详情页返回后,希望还停留在原来的 Tab、原来的页码,最好连筛选条件也一起保留

这次页面里的现象也属于这一类:activeTabpagination 都只保存在组件本地,分页变化时只改本地状态,进入详情时只带了 uuid,组件重新进入后又按默认值初始化,所以返回时自然回到默认 Tab 和第一页。这个问题从当前页面代码里是能直接看出来的。


整体实现思路

这类问题本质上不是“表格组件有 bug”,而是:

列表页视图状态没有持久化。

这里的“视图状态”一般包括:

  • 当前 Tab
  • 当前页码
  • 每页条数
  • 筛选条件
  • 排序字段
  • 时间范围

只要这些状态只存在组件内存里,一旦发生下面这些场景,状态就容易丢:

  • 进入详情页再返回
  • 刷新页面
  • 打开新标签页
  • 复制链接给别人查看同样的数据视图

所以要解决这个问题,核心思路不是去拦截“返回动作”,而是:

让列表页当前状态有一个外部可恢复的承载位置。

常见有三种方案:

方案一:放到路由 query

把 tab/page/page_size 写进当前 URL query(router.replace) 去详情页后,浏览器回退会回到那条带 query 的列表地址 列表页初始化时从 route.query 读取并恢复 activeTab 和 pagination 然后按恢复后的值请求数据 所以本质是:用地址作为状态容器,而不是只靠组件内存。这样刷新、回退、前进都更稳定

例如:

text
/pamm/pamm-assets?tab=1&page=4&page_size=10

优点:

  • 返回能恢复
  • 刷新能恢复
  • 链接可分享
  • 状态来源清晰

这是列表页最推荐的方式。

方案二:放到 Pinia / store

优点:

  • 跨页面共享方便
  • 适合复杂业务状态

缺点:

  • 刷新后不一定还在
  • URL 看不出当前视图
  • 不适合作为列表页“可分享的视图状态”主来源

方案三:用 keep-alive

优点:

  • 改动小
  • 页面切来切去体验不错

缺点:

  • 只是缓存组件实例
  • 刷新后仍会丢
  • 一旦缓存失效,状态还是没了

为什么优先选择路由 query

对于“列表页状态恢复”这种需求,路由 query 最自然。

因为这类状态本来就是当前页面视图的一部分,不是纯业务数据。

例如:

  • tab=2 表示当前看的是第 3 个标签页
  • page=4 表示当前是第 4 页
  • keyword=btc 表示当前筛选关键词是 btc

这些信息本来就应该体现在地址栏里。

所以更合理的做法是:

  • 路由 query 作为单一事实来源
  • 组件里的 ref 只是 query 的运行时映射
  • keep-alive 和 store 只做辅助,不做真相来源

这类需求的标准落地流程

第一步:定义要持久化哪些状态

先收口,不要一上来把所有 ref 都往 query 里塞。

通常优先保留这些:

  • tab
  • page
  • page_size

如果还要更完整,再加:

  • keyword
  • status
  • time_start
  • time_end
  • sort_field
  • sort_order

第二步:页面初始化时,从 query 还原状态

不要进入页面就直接按默认值请求。 而是先做这件事:

  1. 读取 route.query
  2. 做合法性校验
  3. 还原到 activeTabpagination
  4. 再发请求

这样浏览器回退、页面刷新时,才能恢复原视图。

第三步:Tab / 分页 / 搜索变化时,同步写回 query

这一步很关键。

很多人只做了“初始化从 query 读”,却没做“状态变化时写回 query”。 结果页面第一次可以恢复,后续操作又丢了。

正确顺序是:

  1. 用户切换 tab / 分页 / 搜索
  2. 更新本地状态
  3. 把状态同步到当前 URL
  4. 再请求数据

第四步:进入详情页时,不再额外保存一份来源状态

只要列表页自己的 URL 已经带着完整 query,进入详情页后,浏览器回退就能自然回到原来的列表视图。

也就是说:

  • 列表页负责维护自己的状态
  • 详情页不用专门再管列表页分页

这会比“手动传一个 from 参数”更清晰。


实现时几个容易忽略的点

1. push 还是 replace

对于分页、切 tab、筛选变化这种行为,更建议用 router.replace

原因很简单:

如果你用 push,用户从第 1 页点到第 2 页、第 3 页、第 4 页,浏览器历史里会塞满分页记录。 按返回键时,用户不是回详情前一页,而是要一页一页倒回去,体验很差。

所以:

  • 进入详情页:用 push
  • 更新列表页当前视图状态:用 replace

2. query 一定要做合法性校验

URL 里的值永远别直接拿来用。

例如:

text
?page=abc
?page=-10
?tab=999

都要兜底回默认值。

3. 每个 Tab 的分页要独立管理

如果一个页面有多个 Tab,不建议共用同一个分页对象。

因为常见需求是:

  • Tab A 停在第 4 页
  • 切到 Tab B 时是第 1 页
  • 再切回 Tab A,希望还是第 4 页

所以分页应该按 Tab 分开存。

4. 搜索时通常要重置页码

用户换了搜索条件,一般应该回到第一页,否则会出现:

  • 原来在第 5 页
  • 搜索后数据量很少
  • 第 5 页没数据
  • 用户误以为“搜索没结果”

所以搜索动作里通常要:

ts
page = 1;

最推荐的项目级方案

如果这是项目里的常见页面模式,我建议沉淀成统一规范:

规范一:列表页状态统一走 URL query

包括:

  • tab
  • page
  • page_size
  • filters
  • sorter

规范二:进入页面先恢复 query,再请求

不要一进页面就按默认值拉数据。

规范三:列表页状态更新统一用 replace

避免污染 history。

规范四:详情页返回优先用浏览器回退

这样列表页自己恢复最自然。


最简代码示例

下面给一版最小可用思路,核心只保留:

  • 从 query 初始化
  • 切 tab / 分页时同步 query
  • 初始化时请求
  • 返回时自然恢复
vue
<script setup>
import { ref, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";

const route = useRoute();
const router = useRouter();

// 当前 tab
const activeTab = ref(0);

// 每个 tab 各自维护自己的分页状态
const pagination = ref({
  0: { page: 1, page_size: 10, total: 0, data: [] },
  1: { page: 1, page_size: 10, total: 0, data: [] },
  2: { page: 1, page_size: 10, total: 0, data: [] },
});

// 统一读取 query,避免 query 值是数组或非法字符串
const getQueryValue = (value) => {
  return Array.isArray(value) ? value[0] : value;
};

const getValidTab = (value) => {
  const tab = Number(getQueryValue(value));
  return [0, 1, 2].includes(tab) ? tab : 0;
};

const getPositiveInt = (value, fallback) => {
  const num = Number(getQueryValue(value));
  return Number.isInteger(num) && num > 0 ? num : fallback;
};

// 从 URL 恢复当前页面状态
const restoreRouteState = () => {
  const tab = getValidTab(route.query.tab);
  const page = getPositiveInt(route.query.page, 1);
  const pageSize = getPositiveInt(route.query.page_size, 10);

  activeTab.value = tab;
  pagination.value[tab].page = page;
  pagination.value[tab].page_size = pageSize;
};

// 把当前页面状态写回 URL,返回时就能恢复
const syncRouteState = (tabIndex = activeTab.value) => {
  const currentPagination = pagination.value[tabIndex];

  router.replace({
    query: {
      ...route.query,
      tab: String(tabIndex),
      page: String(currentPagination.page),
      page_size: String(currentPagination.page_size),
    },
  });
};

// 请求当前 tab 数据
const fetchData = async (tabIndex) => {
  console.log("fetch tab data =>", {
    tab: tabIndex,
    page: pagination.value[tabIndex].page,
    page_size: pagination.value[tabIndex].page_size,
  });

  // 这里换成你的真实请求
};

// 切换分页
const handleChange = (paginationConfig) => {
  const tabIndex = activeTab.value;

  pagination.value[tabIndex].page = paginationConfig.current;
  pagination.value[tabIndex].page_size = paginationConfig.pageSize;

  syncRouteState(tabIndex);
  fetchData(tabIndex);
};

// 切换 tab
watch(activeTab, (newVal) => {
  syncRouteState(newVal);
  fetchData(newVal);
});

onMounted(() => {
  restoreRouteState();
  syncRouteState(activeTab.value);
  fetchData(activeTab.value);
});

// 进入详情
const handleDetail = (record) => {
  router.push({
    name: "Detail",
    query: {
      uuid: record.uuid,
    },
  });
};
</script>

如果还要保留筛选条件,怎么扩展

思路完全一样。

例如你有:

  • keyword
  • status
  • start_time
  • end_time

那就:

初始化时从 query 恢复

ts
const searchParams = ref({
  keyword: "",
  status: "",
});

const restoreSearchState = () => {
  searchParams.value.keyword = getQueryValue(route.query.keyword) || "";
  searchParams.value.status = getQueryValue(route.query.status) || "";
};

搜索变化时同步回 query

ts
// 搜索条件变化后重置到第一页,并把条件写进 URL
const handleSearch = () => {
  pagination.value[activeTab.value].page = 1;

  router.replace({
    query: {
      ...route.query,
      tab: String(activeTab.value),
      page: "1",
      page_size: String(pagination.value[activeTab.value].page_size),
      keyword: searchParams.value.keyword || undefined,
      status: searchParams.value.status || undefined,
    },
  });

  fetchData(activeTab.value);
};

这样不仅能记住分页,还能记住整套列表视图。


什么时候不适合只用 query

如果你的列表状态特别复杂,比如:

  • 多层级联筛选
  • 临时勾选数据量很大
  • 需要跨多个页面共享
  • 有很多不适合暴露在 URL 的业务状态

这时可以考虑:

  • query 保存核心视图状态
  • store 保存补充状态

也就是“query 负责可恢复、可分享;store 负责补充复杂状态”。


总结

这类“带 Tab 的列表页,进入详情再返回,要记住原状态”的需求,本质不是分页组件问题,而是:

列表页状态没有持久化。

最稳的解决思路是:

  1. tab / page / page_size / filters 放进 URL query
  2. 进入页面时先从 query 恢复
  3. tab / 分页 / 搜索变化时同步写回 query
  4. 进入详情页正常 push
  5. 返回列表时自然恢复原状态

一句话记住:

列表页的当前视图状态,优先放路由;不要只放组件内存。

如果你愿意,下一篇我可以继续接着写成: “基于你这份 PAMM 页面代码,逐段改造成可复用的 useListRouteState composable”

本站总访问