本文复盘一个体验类 Bug:用户在长列表中翻了很久找到目标数据,点进详情看了一眼,按返回键回到列表 —— 列表刷新了,滚动位置回到顶部,用户只能从头再翻一遍。根因是 onShow 中无差别刷新列表,修复方案是引入 needRefresh 标记实现按需刷新。

一、Bug 现场

现象

小程序中有一个带 Tab 切换的列表页(Tab A / Tab B),列表支持分页加载。用户操作流程:

  1. 进入列表页,往下滑动加载了好几页数据
  2. 在列表中间位置找到一条记录,点击进入详情页
  3. 看完详情,点击左上角返回
  4. 列表回到了顶部,之前翻到的位置全部丢失

当列表有几十上百条数据时,用户要反复翻找,体验极差。

期望行为

场景 是否刷新列表
从详情页返回(只看不改) 不刷新
新增数据后返回 刷新
编辑/处理/驳回后返回 刷新
切换 Tab 刷新
下拉刷新 刷新
切换组织/社区后返回 刷新

二、问题分析

根因:onShow 中无条件刷新

在小程序中,页面导航基于页面栈。从详情页 navigateBack() 回到列表页时,列表页的 onShow() 生命周期会触发。

问题代码:

javascript

onShow(() => {
  // 每次页面显示都刷新 —— 这就是 Bug 的根源
  refreshList();
});

每次 onShow 都调 refreshList(),意味着:

  • 从详情页返回 → 刷新 → 滚动位置丢失
  • 从新增页返回 → 刷新 → 正确
  • 从后台切回前台 → 刷新 → 没必要

本质问题:onShow 不等于"需要刷新"。 它只表示"页面可见了",但不表示"数据变了"。

滚动位置为什么会丢失?

列表刷新时通常会:

  1. 重置分页参数 pageNum = 1
  2. 清空列表数据 list = []
  3. 重新请求第一页数据
  4. DOM 重新渲染,滚动容器回到顶部

即使刷新后数据和之前一样,滚动位置也已经回到原点了。

三、修复方案:needRefresh 按需刷新

核心思路

引入一个 needRefresh 标记,只有在数据确实发生变化时才设为 trueonShow 中检查这个标记,决定是否刷新。

text

详情页(只读)  →  返回  →  needRefresh = false  →  不刷新,保持位置
新增页(创建)  →  返回  →  needRefresh = true   →  刷新列表

3.1 列表页:按需刷新逻辑

vue

<!-- list.vue -->
<template>
  <view class="page">
    <!-- Tab 栏 -->
    <view class="tabs">
      <view
        v-for="(tab, idx) in ['Tab A', 'Tab B']"
        :key="idx"
        :class="['tab-item', { active: activeTab === idx }]"
        @click="activeTab = idx"
      >
        {{ tab }}
      </view>
    </view>

    <!-- 关键:用 v-show 而不是 v-if -->
    <view v-show="activeTab === 0" class="tab-content">
      <page-list ref="listARef" :api="fetchListA" />
    </view>
    <view v-show="activeTab === 1" class="tab-content">
      <page-list ref="listBRef" :api="fetchListB" />
    </view>
  </view>
</template>

<script setup>
import { ref, watch, nextTick } from 'vue';
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app';

const activeTab = ref(0);
const listARef = ref(null);
const listBRef = ref(null);

// ===== 核心:按需刷新标记 =====
const needRefresh = ref(false);
const lastOrgId = ref(null);

onLoad(() => {
  lastOrgId.value = getCurrentOrgId();

  // 监听刷新事件(由新增/编辑/处理页面触发)
  uni.$on('list-refresh', (data) => {
    if (data?.tab !== undefined) {
      activeTab.value = Number(data.tab);
    }
    needRefresh.value = true;  // 只在这里设为 true
  });
});

onShow(() => {
  // 场景 1:组织切换检测
  const currentOrgId = getCurrentOrgId();
  if (lastOrgId.value !== null && lastOrgId.value !== currentOrgId) {
    needRefresh.value = true;
  }
  lastOrgId.value = currentOrgId;

  // 场景 2:按需刷新
  if (needRefresh.value) {
    needRefresh.value = false;
    refreshCurrentTab();
  }
  // 如果 needRefresh 为 false(从详情页返回),什么都不做
  // 列表 DOM 保持原样,滚动位置自然保留
});

onUnload(() => {
  uni.$off('list-refresh');
});

// Tab 切换时刷新
watch(activeTab, () => {
  nextTick(() => refreshCurrentTab());
});

const refreshCurrentTab = () => {
  nextTick(() => {
    if (activeTab.value === 0) {
      listARef.value?.loadData(true);
    } else {
      listBRef.value?.loadData(true);
    }
  });
};

const getCurrentOrgId = () => {
  // 从全局状态获取当前组织 ID
  return useUserStore().orgId;
};
</script>

3.2 详情页:只有改数据时才发事件

javascript

// detail.vue —— 只读详情页

// 用户只是查看详情,按返回键
// 不触发任何事件 → needRefresh 保持 false → 列表不刷新

// 用户执行了操作(如:处理、驳回)
const handleSubmit = async () => {
  const res = await processItemApi(formData);
  if (res) {
    uni.showToast({ title: '操作成功', icon: 'success' });
    // 只在数据变更时才触发刷新事件
    uni.$emit('list-refresh', { tab: 1 });
    setTimeout(() => uni.navigateBack(), 1500);
  }
};

3.3 新增页:创建成功后触发刷新

javascript

// add.vue
const handleSubmit = async () => {
  const res = await createItemApi(formData);
  if (res) {
    uni.showToast({ title: '创建成功', icon: 'success' });
    uni.$emit('list-refresh', { tab: 0 });
    setTimeout(() => uni.navigateBack(), 1500);
  }
};

四、关键细节:v-show vs v-if

列表页模板中使用 v-show 而不是 v-if 来切换 Tab 内容,这是滚动位置保持的关键:

vue

<!-- 正确:v-show —— 隐藏 DOM 但不销毁,保留滚动位置和组件状态 -->
<view v-show="activeTab === 0">
  <page-list ref="listARef" :api="fetchListA" />
</view>

<!-- 错误:v-if —— 切换时销毁并重建 DOM,滚动位置和数据全部丢失 -->
<view v-if="activeTab === 0">
  <page-list ref="listARef" :api="fetchListA" />
</view>
特性 v-show v-if
DOM 行为 display:none 隐藏 完全移除/重建 DOM
组件状态 保留(列表数据、分页、滚动位置) 丢失(每次重建都是全新状态)
切换性能 快(只改 CSS) 慢(重新挂载组件 + 请求数据)
首次渲染 两个 Tab 都渲染(初始稍慢) 只渲染当前 Tab(初始快)
适合场景 频繁切换、需要保留状态 条件很少成立、不需要保留状态

对于列表 Tab 切换场景,v-show 是正确选择

五、完整 Demo

可复用的分页列表组件

vue

<!-- components/page-list.vue -->
<template>
  <scroll-view
    scroll-y
    class="list-container"
    :refresher-enabled="true"
    :refresher-triggered="isRefreshing"
    @refresherrefresh="onPullDownRefresh"
    @scrolltolower="onLoadMore"
  >
    <view v-for="item in list" :key="item.id" class="list-item">
      <slot :item="item" />
    </view>

    <view v-if="loading && list.length > 0" class="load-tip">加载中...</view>
    <view v-if="finished && list.length > 0" class="load-tip">没有更多了</view>
    <view v-if="!loading && list.length === 0" class="empty-tip">暂无数据</view>
  </scroll-view>
</template>

<script setup>
import { ref, onMounted } from 'vue';

const props = defineProps({
  api: { type: Function, required: true },
  pageSize: { type: Number, default: 20 },
});

const list = ref([]);
const pageNum = ref(1);
const loading = ref(false);
const finished = ref(false);
const isRefreshing = ref(false);

/**
 * 加载数据
 * @param {boolean} reset - true: 重置到第一页(刷新); false: 加载下一页(追加)
 */
const loadData = async (reset = false) => {
  if (loading.value) return;
  if (!reset && finished.value) return;

  if (reset) {
    pageNum.value = 1;
    finished.value = false;
    // 注意:reset 时清空列表,滚动位置会回到顶部
    // 这是预期行为 —— 只有主动刷新才会走到这里
  }

  loading.value = true;
  try {
    const res = await props.api({
      pageNum: pageNum.value,
      pageSize: props.pageSize,
    });

    const newItems = res?.data?.list || [];

    if (reset) {
      list.value = newItems;
    } else {
      list.value.push(...newItems);
    }

    if (newItems.length < props.pageSize) {
      finished.value = true;
    }

    pageNum.value++;
  } catch (err) {
    console.error('列表加载失败:', err);
  } finally {
    loading.value = false;
    isRefreshing.value = false;
  }
};

// 下拉刷新
const onPullDownRefresh = () => {
  isRefreshing.value = true;
  loadData(true);
};

// 触底加载更多
const onLoadMore = () => {
  loadData(false);
};

// 首次加载
onMounted(() => {
  loadData(true);
});

// 暴露给父组件通过 ref 调用
defineExpose({ loadData });
</script>

<style scoped>
.list-container {
  height: 100%;
}
.load-tip,
.empty-tip {
  text-align: center;
  padding: 24rpx;
  color: #999;
  font-size: 26rpx;
}
</style>

列表页(带按需刷新)

vue

<!-- pages/list.vue -->
<template>
  <view class="page">
    <!-- Tab 栏 -->
    <view class="tab-bar">
      <view
        v-for="(tab, idx) in tabList"
        :key="idx"
        :class="['tab-item', { active: activeTab === idx }]"
        @click="activeTab = idx"
      >
        {{ tab.name }}
      </view>
    </view>

    <!-- Tab 内容:用 v-show 保留 DOM 和滚动位置 -->
    <view v-show="activeTab === 0" class="tab-panel">
      <page-list ref="listARef" :api="fetchListA">
        <template #default="{ item }">
          <view class="card" @click="goDetail(item.id, 'typeA')">
            <text class="card-title">{{ item.title }}</text>
            <text class="card-desc">{{ item.createTime }}</text>
          </view>
        </template>
      </page-list>
    </view>

    <view v-show="activeTab === 1" class="tab-panel">
      <page-list ref="listBRef" :api="fetchListB">
        <template #default="{ item }">
          <view class="card" @click="goDetail(item.id, 'typeB')">
            <text class="card-title">{{ item.title }}</text>
            <text class="card-desc">{{ item.createTime }}</text>
          </view>
        </template>
      </page-list>
    </view>

    <!-- 新增按钮 -->
    <view class="fab" @click="goAdd">+</view>
  </view>
</template>

<script setup>
import { ref, watch, nextTick } from 'vue';
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app';
import PageList from '@/components/page-list.vue';

const tabList = [
  { name: 'Tab A', key: 'typeA' },
  { name: 'Tab B', key: 'typeB' },
];

const activeTab = ref(0);
const listARef = ref(null);
const listBRef = ref(null);
const needRefresh = ref(false);
const lastOrgId = ref(null);

// ========== 生命周期 ==========

onLoad(() => {
  lastOrgId.value = getOrgId();

  // 只在收到明确的刷新事件时,才标记需要刷新
  uni.$on('list-refresh', (data) => {
    if (data?.tab !== undefined) {
      activeTab.value = Number(data.tab);
    }
    needRefresh.value = true;
  });
});

onShow(() => {
  // 检测组织/社区切换
  const currentOrgId = getOrgId();
  if (lastOrgId.value !== null && lastOrgId.value !== currentOrgId) {
    needRefresh.value = true;
  }
  lastOrgId.value = currentOrgId;

  // 核心逻辑:只在需要时刷新
  if (needRefresh.value) {
    needRefresh.value = false;
    refreshCurrentTab();
  }
  // 从详情页返回时,needRefresh 为 false
  // 什么都不做 → DOM 保持原样 → 滚动位置自然保留
});

onUnload(() => {
  uni.$off('list-refresh');
});

// Tab 切换时刷新对应列表
watch(activeTab, () => {
  nextTick(() => refreshCurrentTab());
});

// ========== 方法 ==========

const refreshCurrentTab = () => {
  nextTick(() => {
    if (activeTab.value === 0) {
      listARef.value?.loadData(true);
    } else {
      listBRef.value?.loadData(true);
    }
  });
};

const getOrgId = () => {
  // 从全局状态获取当前组织 ID(示例)
  return uni.getStorageSync('orgId') || '';
};

const goDetail = (id, type) => {
  // 跳转详情页 —— 不设置 needRefresh
  uni.navigateTo({
    url: `/pages/detail?id=${id}&type=${type}`,
  });
};

const goAdd = () => {
  uni.navigateTo({
    url: `/pages/add?type=${tabList[activeTab.value].key}`,
  });
};
</script>

<style scoped>
.page {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background: #f5f6fa;
}

.tab-bar {
  display: flex;
  background: #fff;
  border-bottom: 1rpx solid #eee;
}

.tab-item {
  flex: 1;
  text-align: center;
  padding: 24rpx 0;
  font-size: 28rpx;
  color: #666;
  position: relative;
}

.tab-item.active {
  color: #009999;
  font-weight: bold;
}

.tab-item.active::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 48rpx;
  height: 4rpx;
  background: #009999;
  border-radius: 2rpx;
}

.tab-panel {
  flex: 1;
  overflow: hidden;
}

.card {
  margin: 16rpx 24rpx;
  padding: 24rpx;
  background: #fff;
  border-radius: 12rpx;
}

.card-title {
  font-size: 30rpx;
  color: #333;
  font-weight: 500;
}

.card-desc {
  font-size: 24rpx;
  color: #999;
  margin-top: 8rpx;
}

.fab {
  position: fixed;
  right: 40rpx;
  bottom: 120rpx;
  width: 96rpx;
  height: 96rpx;
  border-radius: 50%;
  background: #009999;
  color: #fff;
  font-size: 48rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 4rpx 16rpx rgba(0, 153, 153, 0.3);
}
</style>

详情页(只读返回不刷新,操作后刷新)

vue

<!-- pages/detail.vue -->
<script setup>
import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';

const detail = ref(null);
const itemId = ref('');
const itemType = ref('');

onLoad((options) => {
  itemId.value = options.id;
  itemType.value = options.type;
  fetchDetail();
});

const fetchDetail = async () => {
  const res = await getDetailApi(itemId.value);
  detail.value = res?.data;
};

// ===== 只读查看:用户直接按返回 =====
// 不触发任何事件 → 列表页 needRefresh 保持 false → 不刷新

// ===== 执行操作:处理/驳回 =====
const handleProcess = async () => {
  const res = await processItemApi({ id: itemId.value });
  if (res) {
    uni.showToast({ title: '处理成功', icon: 'success' });

    // 数据变更了,通知列表页刷新
    const tabIndex = itemType.value === 'typeA' ? 0 : 1;
    uni.$emit('list-refresh', { tab: tabIndex });

    setTimeout(() => uni.navigateBack(), 1500);
  }
};

const handleReject = async () => {
  const res = await rejectItemApi({ id: itemId.value });
  if (res) {
    uni.showToast({ title: '已驳回', icon: 'success' });
    uni.$emit('list-refresh', { tab: 1 });
    setTimeout(() => uni.navigateBack(), 1500);
  }
};
</script>

六、刷新策略决策流程图

text

            onShow() 触发
                │
                ▼
    ┌───── needRefresh? ─────┐
    │                        │
   true                    false
    │                        │
    ▼                        ▼
 刷新列表              不做任何事
 (重置分页              (DOM 原样保留)
  请求数据              (滚动位置不变)
  回到顶部)             (列表数据不变)
    │                        │
    ▼                        ▼
 用户看到最新数据       用户继续浏览


  谁会设置 needRefresh = true?
  ─────────────────────────────
  ✓ uni.$emit('list-refresh')    ← 新增/编辑/处理/驳回后
  ✓ 组织 ID 变化                 ← 切换了社区/组织
  ✗ 从详情页返回(只读)          ← 不设置,保持 false
  ✗ 从后台切回前台               ← 不设置,保持 false

七、常见误区

误区 1:在 onShow 中无条件刷新

javascript

// 错误:每次页面可见都刷新
onShow(() => {
  refreshList();
});

这会导致:从详情页返回 → 列表刷新 → 滚动位置丢失。

误区 2:用 v-if 切换 Tab

vue

<!-- 错误:v-if 会销毁并重建组件 -->
<page-list v-if="activeTab === 0" ref="listARef" />
<page-list v-if="activeTab === 1" ref="listBRef" />

切换回来时组件重建,数据和滚动位置全部丢失,还会触发额外的 API 请求。

误区 3:用 scroll-into-view 手动恢复位置

javascript

// 复杂且不可靠的方案
onShow(() => {
  refreshList();
  // 刷新完后尝试滚动到之前的位置
  nextTick(() => scrollTo(savedPosition));
});

问题:刷新后数据可能变化,之前记住的位置不一定对应同一条数据。不刷新才是最好的"恢复位置"方案。

八、经验总结

要点 说明
onShow ≠ 需要刷新 onShow 只表示"页面可见",不意味着"数据变了"
用标记控制刷新 needRefresh 标记由数据变更方(新增/编辑/删除页面)设置,列表页只检查标记
不刷新 = 保持位置 最简单的滚动位置保持方案是:不动 DOM,不清数据
v-show 保状态 Tab 切换场景用 v-show 保留组件状态和 DOM,用 v-if 会丢失一切
组织切换要检测 全局上下文变化(如切换租户/社区)也需要触发刷新
事件用完要清理 onUnloaduni.$off 移除监听,防止内存泄漏和重复注册

九、一句话总结

小程序列表页返回后滚动位置丢失,本质是 onShow 中无差别刷新导致 DOM 重建。修复方案并非"记住位置再恢复",而是不刷新就不会丢失 —— 通过 needRefresh 标记区分"数据变了要刷新"和"只是看了一眼不需要动",配合 v-show 保留 DOM 状态,用最小的改动实现最自然的体验。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐