夜猫子的知识栈 夜猫子的知识栈
首页
  • 前端文章

    • JavaScript
  • 学习笔记

    • 《JavaScript教程》
    • 《Web Api》
    • 《ES6教程》
    • 《Vue》
    • 《React》
    • 《TypeScript》
    • 《Git》
    • 《Uniapp》
    • 小程序笔记
    • 《Electron》
    • JS设计模式总结
  • 《前端架构》

    • 《微前端》
    • 《权限控制》
    • monorepo
  • 全栈项目

    • 任务管理日历
    • 无代码平台
    • 图书管理系统
  • HTML
  • CSS
  • Nodejs
  • Midway
  • Nest
  • MySql
  • 其他
  • 技术文档
  • GitHub技巧
  • 博客搭建
  • Ajax
  • Vite
  • Vitest
  • Nuxt
  • UI库文章
  • Docker
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 友情链接
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

夜猫子

前端练习生
首页
  • 前端文章

    • JavaScript
  • 学习笔记

    • 《JavaScript教程》
    • 《Web Api》
    • 《ES6教程》
    • 《Vue》
    • 《React》
    • 《TypeScript》
    • 《Git》
    • 《Uniapp》
    • 小程序笔记
    • 《Electron》
    • JS设计模式总结
  • 《前端架构》

    • 《微前端》
    • 《权限控制》
    • monorepo
  • 全栈项目

    • 任务管理日历
    • 无代码平台
    • 图书管理系统
  • HTML
  • CSS
  • Nodejs
  • Midway
  • Nest
  • MySql
  • 其他
  • 技术文档
  • GitHub技巧
  • 博客搭建
  • Ajax
  • Vite
  • Vitest
  • Nuxt
  • UI库文章
  • Docker
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 友情链接
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • 封装路由跳转
  • 调用微信小程序位置插件
  • Uniapp请求的ts封装
  • I18国际化
  • uniapp路径与base64互相转化
  • 使用webview渲染html字符串
  • 移动端IOS安全区兼容
  • H5调用微信jssdk
  • uni统计使用
  • 原生三方SDK集成探索
  • UNiAPP中使用虚拟列表
  • 《Uniapp》笔记
夜猫子
2026-01-09

UNiAPP中使用虚拟列表

# UNiAPP中使用虚拟列表

<template>
  <view class="pagination-list">
    <scroll-view
      scroll-y="true"
      class="scroll-container"
      v-if="items.length > 0 || loading"
      @scrolltolower="onScrollToLower"
      @scroll="onScroll"
      :lower-threshold="lowerThreshold"
      :refresher-enabled="false"
      :show-scrollbar="false"
      :scroll-top="scrollToTop"
    >
      <!-- 虚拟渲染模式下渲染顶部占位、可见项、底部占位 -->
      <template v-if="virtual">
        <view class="phantom" :style="{ height: listHeight + 'px' }"></view>
        <view
          class="content"
          :style="{ transform: `translate3d(0, ${currentOffset}px, 0)` }"
        >
          <slot
            :items="visibleItems"
            :loading="loading"
            :has-more="hasMore"
            :refresh="refresh"
            :load-more="loadMore"
          ></slot>
          <!-- 加载提示 -->
          <view class="loading-footer">
            <template v-if="loading && !isRefreshing">
              <image
                class="loadingImg"
                src="https://img.lulufulo.com/front/img/small-loading.gif"
              />
            </template>
            <template v-if="!hasMore && !loading && items.length > 0">
              <view class="loadmore-line">
                <text class="loadmore-tips">{{
                  noMoreText || __('没有更多数据啦!')
                }}</text>
              </view>
            </template>
          </view>
        </view>
      </template>

      <!-- 非虚拟模式保持原行为 -->
      <template v-else>
        <!-- 默认插槽用于渲染列表项 -->
        <slot
          :items="items"
          :start-index="startIndex"
          :loading="loading"
          :has-more="hasMore"
          :refresh="refresh"
          :load-more="loadMore"
        ></slot>
        <!-- 加载提示 -->
        <view class="loading-footer">
          <template v-if="loading && !isRefreshing">
            <image
              class="loadingImg"
              src="https://img.lulufulo.com/front/img/small-loading.gif"
            />
          </template>
          <template v-if="!hasMore && !loading && items.length > 0">
            <view class="loadmore-line">
              <text class="loadmore-tips">{{
                noMoreText || __('没有更多数据啦!')
              }}</text>
            </view>
          </template>
        </view>
      </template>
    </scroll-view>

    <LEmpty v-else />
  </view>
</template>

<script>
  import LEmpty from '../l-empty/l-empty.vue';
  export default {
    name: 'l-list',
    components: {
      LEmpty,
    },
    props: {
      // 请求函数,需要返回Promise并包含分页数据
      request: {
        type: Function,
        required: true,
      },
      // 每页数据条数
      pageSize: {
        type: Number,
        default: 20,
      },
      // 默认传递给请求函数的额外参数
      defaultParams: {
        type: Object,
        default: () => ({}),
      },
      // 自定义无更多数据文本
      noMoreText: {
        type: String,
        default: '',
      },
      // 触底触发加载更多的阈值(单位px)
      lowerThreshold: {
        type: Number,
        default: 50,
      },
      // 是否开启虚拟渲染(只渲染可见项)
      virtual: {
        type: Boolean,
        default: false,
      },
      // 单项高度(px),若不准确可能导致位置偏差,建议传入固定高度
      itemHeight: {
        type: Number,
        default: 80,
      },
      // 预渲染缓冲项个数
      buffer: {
        type: Number,
        default: 5,
      },
    },
    data() {
      return {
        items: [],
        pageNum: 1,
        loading: false,
        hasMore: true,
        isRefreshing: false,
        // 虚拟列表相关状态
        scrollTop: 0,
        containerHeight: 0,
        startIndex: 0,
        visibleItems: [],
        totalHeight: 0,
        // 新增虚拟列表状态
        currentOffset: 0,
        start: 0,
        end: 0,
        listHeight: 0,
        visibleCount: 0,
        // 添加一个变量来控制scroll-view的滚动位置
        scrollToTop: 0,
      };
    },
    mounted() {
      this.loadData();
      // 若开启虚拟渲染,测量容器高度并初始化可视范围
      if (this.virtual) {
        this.$nextTick(() => {
          this.measureContainer();
        });
      }
    },

    computed: {},
    watch: {
      items: {
        handler() {
          if (this.virtual) {
            this.updateVirtualList();
          }
        },
        deep: true,
      },
    },
    methods: {
      // 更新虚拟列表
      updateVirtualList() {
        if (!this.virtual) return;

        this.listHeight = this.items.length * this.itemHeight;
        this.visibleCount =
          Math.ceil(this.containerHeight / this.itemHeight) + this.buffer * 2;

        // 重新计算可见项
        this.startIndex = Math.max(
          0,
          Math.floor(this.scrollTop / this.itemHeight) - this.buffer
        );
        this.end = Math.min(
          this.items.length,
          this.startIndex + this.visibleCount
        );
        this.visibleItems = this.items.slice(this.startIndex, this.end);

        // 计算偏移量
        this.currentOffset = this.startIndex * this.itemHeight;

        // 检查是否接近底部,如果是则加载更多数据
        this.checkBottom();
      },

      // 检查是否接近底部
      checkBottom() {
        if (!this.virtual) return;

        // 计算是否接近底部
        const totalHeight = this.items.length * this.itemHeight;
        const scrollBottom = this.scrollTop + this.containerHeight;
        const threshold = this.lowerThreshold;

        // 如果滚动到底部附近,触发加载更多
        if (
          totalHeight - scrollBottom <= threshold &&
          this.hasMore &&
          !this.loading
        ) {
          this.loadMore();
        }
      },

      // 在需要时测量 scroll 容器高度,尝试多个选择器作为回退
      measureContainer() {
        try {
          const query = uni.createSelectorQuery().in(this);
          // 多选器尝试:优先 scroll-container,其次 pagination-list 减去 top-box
          query
            .select('.scroll-container')
            .boundingClientRect((scRect) => {
              if (scRect && scRect.height) {
                this.containerHeight = scRect.height;
                this.updateVirtualList(); // 使用统一的更新方法
              } else {
                // 尝试测量整个组件高度
                const sys = uni.getSystemInfoSync && uni.getSystemInfoSync();
                if (sys && sys.windowHeight) {
                  // 使用屏幕高度减去页面其他部分的高度
                  this.containerHeight = sys.windowHeight - 100; // 预留100px给其他UI元素
                  this.updateVirtualList(); // 使用统一的更新方法
                }
              }
            })
            .exec();
        } catch (e) {
          // ignore measurement failure
          console.warn('measureContainer failed', e);
        }
      },
      // 首次加载数据
      loadData() {
        this.pageNum = 1;
        this.items = [];
        this.hasMore = true;
        this.getList();
      },

      // 刷新数据
      refresh() {
        this.isRefreshing = true;
        this.loadData();
      },

      // 加载下一页
      loadMore() {
        if (this.hasMore && !this.loading) {
          this.getList();
        }
      },

      // 滚动到底部时触发
      onScrollToLower() {
        // 对于虚拟列表,这个事件可能不会被正确触发,所以我们主要依赖 checkBottom 方法
        if (!this.virtual) {
          // 非虚拟模式按原逻辑触底加载
          this.loadMore();
        }
      },

      // 通用滚动事件处理(用于虚拟渲染)
      onScroll(e) {
        if (!this.virtual) {
          // 非虚拟模式,使用原始的滚动处理
          const top = (e && e.detail && e.detail.scrollTop) || 0;
          this.scrollTop = top;
          return;
        }

        // 虚拟模式下的滚动处理
        const top = (e && e.detail && e.detail.scrollTop) || 0;
        this.scrollTop = top;

        // 立即更新虚拟列表,确保 visibleItems 与 transform 同步
        this.updateVirtualList();
      },

      // 获取列表数据
      getList() {
        if (this.loading) return;

        this.loading = true;

        // 合并分页参数和默认参数
        const params = {
          pageNum: this.pageNum,
          pageSize: this.pageSize,
          ...this.defaultParams,
        };

        this.request(params)
          .then((response) => {
            // 处理响应数据
            const data = response.data || response;
            let newItems = data.items || data;
            const startIndex = this.items.length;
            newItems = newItems.map((item, index) => ({
              ...item,
              __index__: startIndex + index, // 添加真实下标
            }));
            if (this.isRefreshing) {
              this.items = [...newItems];
              this.isRefreshing = false;
              // 重置虚拟渲染位置
              if (this.virtual) {
                this.scrollTop = 0;
              }
              // 确保在 DOM 更新后计算可见区
              this.$nextTick(() => {
                if (
                  this.virtual &&
                  (!this.containerHeight || this.containerHeight === 0)
                ) {
                  this.measureContainer();
                } else {
                  this.updateVirtualList();
                }
              });
            } else {
              this.items = [...this.items, ...newItems];
              // 更新可见区以便虚拟渲染显示新项
              if (this.virtual) {
                this.$nextTick(() => {
                  if (!this.containerHeight || this.containerHeight === 0) {
                    this.measureContainer();
                  } else {
                    this.updateVirtualList();
                  }
                });
              }
            }

            // 判断是否还有更多数据
            if (Array.isArray(newItems) && newItems.length > 0) {
              // 如果返回的数据少于请求的pageSize,则认为没有更多数据
              if (newItems.length < this.pageSize) {
                this.hasMore = false;
              } else {
                this.pageNum++;
              }
            } else {
              this.hasMore = false;
            }

            this.$emit('load-success', data);
          })
          .catch((error) => {
            this.$emit('load-error', error);
            console.error('PaginationList load error:', error);
          })
          .finally(() => {
            this.loading = false;
          });
      },
    },
  };
</script>

<style lang="scss">
  .pagination-list {
    height: 100%;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }

  .scroll-container {
    flex: 1;
    height: 100%;
  }

  .phantom {
    position: relative;
    width: 100%;
  }

  .content {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    will-change: transform;
  }

  .loading-footer {
    padding: 15px 0;
    display: flex;
    justify-content: center;

    .loadmore-line {
      border-top: 1px solid #e5e5e5;
      margin: 0 50px;
      margin-top: 30rpx;
      display: flex;
      justify-content: center;
      box-sizing: border-box;
      width: 100%;
    }

    .loadmore-tips {
      position: relative;
      top: -0.9em;
      padding: 0 0.55em;
      background-color: #f5f5f5;
      color: #999999;
    }

    .loadingImg {
      width: 50px;
      height: 50px;
    }
  }
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
编辑 (opens new window)
上次更新: 2026/1/26 16:37:02
原生三方SDK集成探索

← 原生三方SDK集成探索

最近更新
01
uni统计使用
11-12
02
原生三方SDK集成探索
11-12
03
前端性能优化
11-04
更多文章>
Copyright © 2019-2026 Study | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式