第22集:uni-app 列表开发

章节概览

在本章节中,我们将学习 uni-app 列表开发的核心知识点和最佳实践。列表是移动应用中最常见的界面元素之一,用于展示大量数据。通过本章节的学习,您将掌握列表组件的使用、下拉刷新和上拉加载的实现、数据分页加载的方法,以及如何构建高性能的列表界面。

核心知识点

1. 列表组件使用

uni-app 提供了多种列表相关的组件,包括:

  • view:基础容器组件,可用于构建自定义列表
  • scroll-view:可滚动视图组件,支持横向和纵向滚动
  • list:高性能列表组件,适用于长列表
  • recycle-view:回收式列表组件,优化内存使用
  • cell:列表项组件,与 list 组件配合使用

2. 下拉刷新

下拉刷新是列表开发中的常用功能,用于刷新列表数据,包括:

  • onPullDownRefresh:页面生命周期函数,用于监听下拉刷新事件
  • uni.startPullDownRefresh:主动触发下拉刷新
  • uni.stopPullDownRefresh:停止下拉刷新
  • 自定义下拉刷新:实现自定义的下拉刷新动画和逻辑

3. 上拉加载

上拉加载是列表开发中的常用功能,用于加载更多数据,包括:

  • onReachBottom:页面生命周期函数,用于监听触底事件
  • 上拉加载状态管理:处理加载中、加载完成、加载失败等状态
  • 数据分页:实现数据的分页加载和管理
  • 节流处理:避免频繁触发上拉加载事件

4. 列表性能优化

列表性能优化是确保列表流畅运行的重要环节,包括:

  • 虚拟列表:只渲染可视区域内的列表项,优化内存使用
  • 列表项复用:复用列表项组件,减少 DOM 操作
  • 数据缓存:缓存列表数据,减少重复请求
  • 图片懒加载:延迟加载可视区域外的图片
  • 避免复杂计算:减少列表渲染时的复杂计算

实用案例

实现商品列表页

我们将实现一个完整的商品列表页,包括:

  1. 商品列表展示:使用自定义列表项展示商品信息
  2. 下拉刷新:刷新商品列表数据
  3. 上拉加载:加载更多商品数据
  4. 性能优化:实现图片懒加载和列表项复用
  5. 交互体验:添加加载状态和错误提示

代码示例

1. 基础列表实现

使用 view 组件构建列表

<template>
  <view class="basic-list">
    <view v-for="(item, index) in listData" :key="item.id" class="list-item">
      <image :src="item.image" :alt="item.title" class="item-image" />
      <view class="item-content">
        <text class="item-title">{{ item.title }}</text>
        <text class="item-desc">{{ item.description }}</text>
        <text class="item-price">¥{{ item.price }}</text>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      listData: [
        {
          id: 1,
          title: '商品1',
description: '这是商品1的描述',
          price: 99.9,
          image: 'https://via.placeholder.com/100'
        },
        {
          id: 2,
          title: '商品2',
description: '这是商品2的描述',
          price: 199.9,
          image: 'https://via.placeholder.com/100'
        },
        {
          id: 3,
          title: '商品3',
description: '这是商品3的描述',
          price: 299.9,
          image: 'https://via.placeholder.com/100'
        }
      ]
    }
  }
}
</script>

<style scoped>
.basic-list {
  padding: 20rpx;
}

.list-item {
  display: flex;
  padding: 20rpx;
  margin-bottom: 20rpx;
  background-color: #fff;
  border-radius: 10rpx;
  box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
}

.item-image {
  width: 150rpx;
  height: 150rpx;
  border-radius: 5rpx;
}

.item-content {
  flex: 1;
  margin-left: 20rpx;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.item-title {
  font-size: 32rpx;
  font-weight: 500;
  margin-bottom: 10rpx;
}

.item-desc {
  font-size: 26rpx;
  color: #666;
  margin-bottom: 10rpx;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.item-price {
  font-size: 30rpx;
  font-weight: bold;
  color: #ff4d4f;
}
</style>

使用 scroll-view 组件实现可滚动列表

<template>
  <scroll-view 
    class="scroll-list" 
    scroll-y 
    :scroll-top="scrollTop"
    @scroll="handleScroll"
    @scrolltolower="handleScrollToLower"
  >
    <view v-for="(item, index) in listData" :key="item.id" class="list-item">
      <image :src="item.image" :alt="item.title" class="item-image" />
      <view class="item-content">
        <text class="item-title">{{ item.title }}</text>
        <text class="item-desc">{{ item.description }}</text>
        <text class="item-price">¥{{ item.price }}</text>
      </view>
    </view>
    <view v-if="loading" class="loading">加载中...</view>
    <view v-else-if="!hasMore" class="no-more">没有更多数据了</view>
  </scroll-view>
</template>

<script>
export default {
  data() {
    return {
      listData: [],
      scrollTop: 0,
      page: 1,
      pageSize: 10,
      loading: false,
      hasMore: true
    }
  },
  onLoad() {
    this.loadData()
  },
  methods: {
    // 加载数据
    async loadData() {
      if (this.loading) return
      
      this.loading = true
      
      try {
        // 模拟网络请求
        await new Promise(resolve => setTimeout(resolve, 1000))
        
        const newData = Array.from({ length: this.pageSize }, (_, i) => ({
          id: (this.page - 1) * this.pageSize + i + 1,
          title: `商品${(this.page - 1) * this.pageSize + i + 1}`,
description: `这是商品${(this.page - 1) * this.pageSize + i + 1}的描述`,
          price: Math.random() * 1000,
          image: 'https://via.placeholder.com/100'
        }))
        
        if (this.page === 1) {
          this.listData = newData
        } else {
          this.listData = [...this.listData, ...newData]
        }
        
        // 模拟没有更多数据
        if (this.page >= 3) {
          this.hasMore = false
        }
        
        this.page++
      } catch (error) {
        console.error('加载数据失败:', error)
      } finally {
        this.loading = false
      }
    },
    
    // 滚动事件
    handleScroll(e) {
      this.scrollTop = e.detail.scrollTop
    },
    
    // 滚动到底部
    handleScrollToLower() {
      if (!this.loading && this.hasMore) {
        this.loadData()
      }
    }
  }
}
</script>

<style scoped>
.scroll-list {
  height: 100vh;
  padding: 20rpx;
}

.list-item {
  display: flex;
  padding: 20rpx;
  margin-bottom: 20rpx;
  background-color: #fff;
  border-radius: 10rpx;
  box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
}

.item-image {
  width: 150rpx;
  height: 150rpx;
  border-radius: 5rpx;
}

.item-content {
  flex: 1;
  margin-left: 20rpx;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.item-title {
  font-size: 32rpx;
  font-weight: 500;
  margin-bottom: 10rpx;
}

.item-desc {
  font-size: 26rpx;
  color: #666;
  margin-bottom: 10rpx;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.item-price {
  font-size: 30rpx;
  font-weight: bold;
  color: #ff4d4f;
}

.loading,
.no-more {
  text-align: center;
  padding: 20rpx;
  color: #999;
  font-size: 28rpx;
}
</style>

2. 下拉刷新和上拉加载

实现下拉刷新

<template>
  <view class="pull-refresh-list">
    <view v-for="(item, index) in listData" :key="item.id" class="list-item">
      <image :src="item.image" :alt="item.title" class="item-image" />
      <view class="item-content">
        <text class="item-title">{{ item.title }}</text>
        <text class="item-desc">{{ item.description }}</text>
        <text class="item-price">¥{{ item.price }}</text>
      </view>
    </view>
    <view v-if="loading" class="loading">加载中...</view>
    <view v-else-if="!hasMore" class="no-more">没有更多数据了</view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      listData: [],
      page: 1,
      pageSize: 10,
      loading: false,
      hasMore: true
    }
  },
  onLoad() {
    this.loadData()
  },
  // 下拉刷新
  onPullDownRefresh() {
    console.log('触发下拉刷新')
    this.page = 1
    this.hasMore = true
    this.loadData().finally(() => {
      uni.stopPullDownRefresh()
    })
  },
  // 上拉加载
  onReachBottom() {
    console.log('触发上拉加载')
    if (!this.loading && this.hasMore) {
      this.loadData()
    }
  },
  methods: {
    // 加载数据
    async loadData() {
      if (this.loading) return
      
      this.loading = true
      
      try {
        // 模拟网络请求
        await new Promise(resolve => setTimeout(resolve, 1000))
        
        const newData = Array.from({ length: this.pageSize }, (_, i) => ({
          id: (this.page - 1) * this.pageSize + i + 1,
          title: `商品${(this.page - 1) * this.pageSize + i + 1}`,
description: `这是商品${(this.page - 1) * this.pageSize + i + 1}的描述`,
          price: Math.random() * 1000,
          image: 'https://via.placeholder.com/100'
        }))
        
        if (this.page === 1) {
          this.listData = newData
        } else {
          this.listData = [...this.listData, ...newData]
        }
        
        // 模拟没有更多数据
        if (this.page >= 3) {
          this.hasMore = false
        }
        
        this.page++
      } catch (error) {
        console.error('加载数据失败:', error)
        uni.showToast({
          title: '加载失败,请稍后重试',
          icon: 'none'
        })
      } finally {
        this.loading = false
      }
    }
  }
}
</script>

<style scoped>
.pull-refresh-list {
  padding: 20rpx;
}

.list-item {
  display: flex;
  padding: 20rpx;
  margin-bottom: 20rpx;
  background-color: #fff;
  border-radius: 10rpx;
  box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
}

.item-image {
  width: 150rpx;
  height: 150rpx;
  border-radius: 5rpx;
}

.item-content {
  flex: 1;
  margin-left: 20rpx;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.item-title {
  font-size: 32rpx;
  font-weight: 500;
  margin-bottom: 10rpx;
}

.item-desc {
  font-size: 26rpx;
  color: #666;
  margin-bottom: 10rpx;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.item-price {
  font-size: 30rpx;
  font-weight: bold;
  color: #ff4d4f;
}

.loading,
.no-more {
  text-align: center;
  padding: 20rpx;
  color: #999;
  font-size: 28rpx;
}
</style>

实现上拉加载更多

<template>
  <view class="load-more-list">
    <view v-for="(item, index) in listData" :key="item.id" class="list-item">
      <image :src="item.image" :alt="item.title" class="item-image" />
      <view class="item-content">
        <text class="item-title">{{ item.title }}</text>
        <text class="item-desc">{{ item.description }}</text>
        <text class="item-price">¥{{ item.price }}</text>
      </view>
    </view>
    <view v-if="loading" class="loading">
      <uni-icons type="spinner" size="24" color="#007aff" animation="spin"></uni-icons>
      <text>加载中...</text>
    </view>
    <view v-else-if="!hasMore" class="no-more">没有更多数据了</view>
    <view v-else-if="loadError" class="load-error" @click="loadData">
      <text>加载失败,点击重试</text>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      listData: [],
      page: 1,
      pageSize: 10,
      loading: false,
      loadError: false,
      hasMore: true
    }
  },
  onLoad() {
    this.loadData()
  },
  onReachBottom() {
    if (!this.loading && this.hasMore && !this.loadError) {
      this.loadData()
    }
  },
  methods: {
    // 加载数据
    async loadData() {
      if (this.loading) return
      
      this.loading = true
      this.loadError = false
      
      try {
        // 模拟网络请求
        await new Promise(resolve => setTimeout(resolve, 1000))
        
        // 模拟加载失败
        if (Math.random() > 0.8) {
          throw new Error('网络错误')
        }
        
        const newData = Array.from({ length: this.pageSize }, (_, i) => ({
          id: (this.page - 1) * this.pageSize + i + 1,
          title: `商品${(this.page - 1) * this.pageSize + i + 1}`,
description: `这是商品${(this.page - 1) * this.pageSize + i + 1}的描述`,
          price: Math.random() * 1000,
          image: 'https://via.placeholder.com/100'
        }))
        
        if (this.page === 1) {
          this.listData = newData
        } else {
          this.listData = [...this.listData, ...newData]
        }
        
        // 模拟没有更多数据
        if (this.page >= 3) {
          this.hasMore = false
        }
        
        this.page++
      } catch (error) {
        console.error('加载数据失败:', error)
        this.loadError = true
      } finally {
        this.loading = false
      }
    }
  }
}
</script>

<style scoped>
.load-more-list {
  padding: 20rpx;
}

.list-item {
  display: flex;
  padding: 20rpx;
  margin-bottom: 20rpx;
  background-color: #fff;
  border-radius: 10rpx;
  box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
}

.item-image {
  width: 150rpx;
  height: 150rpx;
  border-radius: 5rpx;
}

.item-content {
  flex: 1;
  margin-left: 20rpx;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.item-title {
  font-size: 32rpx;
  font-weight: 500;
  margin-bottom: 10rpx;
}

.item-desc {
  font-size: 26rpx;
  color: #666;
  margin-bottom: 10rpx;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.item-price {
  font-size: 30rpx;
  font-weight: bold;
  color: #ff4d4f;
}

.loading,
.no-more,
.load-error {
  text-align: center;
  padding: 20rpx;
  color: #999;
  font-size: 28rpx;
}

.loading {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10rpx;
}

.load-error {
  color: #ff4d4f;
}

.load-error:active {
  opacity: 0.8;
}
</style>

3. 商品列表页实战

完整的商品列表页

<template>
  <view class="goods-list-page">
    <!-- 搜索栏 -->
    <view class="search-bar">
      <view class="search-input">
        <uni-icons type="search" size="24" color="#999"></uni-icons>
        <input type="text" v-model="searchKeyword" placeholder="搜索商品" @confirm="handleSearch" />
      </view>
      <button @click="handleSearch" class="search-btn">搜索</button>
    </view>
    
    <!-- 分类筛选 -->
    <view class="category-filter">
      <scroll-view scroll-x enable-flex>
        <view 
          v-for="category in categories" 
          :key="category.id" 
          class="category-item"
          :class="{ active: selectedCategory === category.id }"
          @click="selectCategory(category.id)"
        >
          {{ category.name }}
        </view>
      </scroll-view>
    </view>
    
    <!-- 商品列表 -->
    <view class="goods-list">
      <view 
        v-for="(goods, index) in goodsList" 
        :key="goods.id" 
        class="goods-item"
        @click="navigateToDetail(goods.id)"
      >
        <image 
          :src="goods.image" 
          :alt="goods.name" 
          class="goods-image"
          lazy-load
        />
        <view class="goods-info">
          <text class="goods-name">{{ goods.name }}</text>
          <text class="goods-desc">{{ goods.description }}</text>
          <view class="goods-bottom">
            <text class="goods-price">¥{{ goods.price }}</text>
            <text class="goods-sales">销量 {{ goods.sales }}</text>
          </view>
        </view>
      </view>
      
      <!-- 加载状态 -->
      <view v-if="loading" class="loading">
        <uni-icons type="spinner" size="24" color="#007aff" animation="spin"></uni-icons>
        <text>加载中...</text>
      </view>
      <view v-else-if="!hasMore" class="no-more">没有更多商品了</view>
      <view v-else-if="loadError" class="load-error" @click="loadGoods">
        <text>加载失败,点击重试</text>
      </view>
      <view v-else-if="goodsList.length === 0" class="empty">
        <uni-icons type="empty" size="64" color="#999"></uni-icons>
        <text>暂无商品</text>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      // 搜索和筛选
      searchKeyword: '',
      categories: [
        { id: 0, name: '全部' },
        { id: 1, name: '手机' },
        { id: 2, name: '电脑' },
        { id: 3, name: '平板' },
        { id: 4, name: '耳机' },
        { id: 5, name: '手表' }
      ],
      selectedCategory: 0,
      
      // 商品列表
      goodsList: [],
      page: 1,
      pageSize: 10,
      loading: false,
      loadError: false,
      hasMore: true
    }
  },
  onLoad() {
    this.loadGoods()
  },
  onPullDownRefresh() {
    this.page = 1
    this.hasMore = true
    this.loadGoods().finally(() => {
      uni.stopPullDownRefresh()
    })
  },
  onReachBottom() {
    if (!this.loading && this.hasMore && !this.loadError) {
      this.loadGoods()
    }
  },
  methods: {
    // 加载商品数据
    async loadGoods() {
      if (this.loading) return
      
      this.loading = true
      this.loadError = false
      
      try {
        // 模拟网络请求
        await new Promise(resolve => setTimeout(resolve, 1000))
        
        // 模拟根据分类和关键词筛选
        const filteredGoods = Array.from({ length: this.pageSize }, (_, i) => {
          const id = (this.page - 1) * this.pageSize + i + 1
          return {
            id,
            name: `${this.getSelectedCategoryName()}商品${id}${this.searchKeyword ? `_${this.searchKeyword}` : ''}`,
description: `这是${this.getSelectedCategoryName()}商品${id}的详细描述,包含商品的各种特性和优势`,
            price: Math.random() * 1000 + 100,
            sales: Math.floor(Math.random() * 10000),
            image: `https://via.placeholder.com/200?text=Goods${id}`
          }
        })
        
        if (this.page === 1) {
          this.goodsList = filteredGoods
        } else {
          this.goodsList = [...this.goodsList, ...filteredGoods]
        }
        
        // 模拟没有更多数据
        if (this.page >= 3) {
          this.hasMore = false
        }
        
        this.page++
      } catch (error) {
        console.error('加载商品失败:', error)
        this.loadError = true
        uni.showToast({
          title: '加载失败,请稍后重试',
          icon: 'none'
        })
      } finally {
        this.loading = false
      }
    },
    
    // 搜索商品
    handleSearch() {
      this.page = 1
      this.hasMore = true
      this.loadGoods()
    },
    
    // 选择分类
    selectCategory(categoryId) {
      this.selectedCategory = categoryId
      this.page = 1
      this.hasMore = true
      this.loadGoods()
    },
    
    // 获取选中的分类名称
    getSelectedCategoryName() {
      const category = this.categories.find(c => c.id === this.selectedCategory)
      return category ? category.name : ''
    },
    
    // 跳转到商品详情
    navigateToDetail(goodsId) {
      uni.navigateTo({
        url: `/pages/goods/detail?id=${goodsId}`
      })
    }
  }
}
</script>

<style scoped>
.goods-list-page {
  min-height: 100vh;
  background-color: #f5f5f5;
}

/* 搜索栏 */
.search-bar {
  display: flex;
  padding: 10rpx 20rpx;
  background-color: #fff;
  align-items: center;
  gap: 10rpx;
}

.search-input {
  flex: 1;
  display: flex;
  align-items: center;
  background-color: #f0f0f0;
  border-radius: 20rpx;
  padding: 0 20rpx;
  height: 60rpx;
}

.search-input input {
  flex: 1;
  margin-left: 10rpx;
  font-size: 28rpx;
}

.search-btn {
  height: 60rpx;
  padding: 0 30rpx;
  background-color: #007aff;
  color: #fff;
  border-radius: 20rpx;
  font-size: 28rpx;
}

/* 分类筛选 */
.category-filter {
  background-color: #fff;
  margin-bottom: 10rpx;
}

.category-filter scroll-view {
  display: flex;
  padding: 15rpx 0;
}

.category-item {
  padding: 0 30rpx;
  font-size: 28rpx;
  color: #666;
  position: relative;
}

.category-item.active {
  color: #007aff;
  font-weight: 500;
}

.category-item.active::after {
  content: '';
  position: absolute;
  bottom: -15rpx;
  left: 50%;
  transform: translateX(-50%);
  width: 20rpx;
  height: 4rpx;
  background-color: #007aff;
  border-radius: 2rpx;
}

/* 商品列表 */
.goods-list {
  padding: 10rpx;
}

.goods-item {
  background-color: #fff;
  border-radius: 10rpx;
  padding: 20rpx;
  margin-bottom: 10rpx;
  display: flex;
  gap: 20rpx;
}

.goods-item:active {
  opacity: 0.8;
}

.goods-image {
  width: 160rpx;
  height: 160rpx;
  border-radius: 5rpx;
  flex-shrink: 0;
}

.goods-info {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.goods-name {
  font-size: 32rpx;
  font-weight: 500;
  margin-bottom: 10rpx;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.goods-desc {
  font-size: 26rpx;
  color: #666;
  margin-bottom: 10rpx;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.goods-bottom {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.goods-price {
  font-size: 30rpx;
  font-weight: bold;
  color: #ff4d4f;
}

.goods-sales {
  font-size: 24rpx;
  color: #999;
}

/* 加载状态 */
.loading,
.no-more,
.load-error,
.empty {
  text-align: center;
  padding: 30rpx;
  color: #999;
  font-size: 28rpx;
}

.loading {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10rpx;
}

.load-error {
  color: #ff4d4f;
}

.load-error:active {
  opacity: 0.8;
}

.empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20rpx;
  padding: 100rpx 0;
}
</style>

4. 列表性能优化

虚拟列表实现

<template>
  <view class="virtual-list" :style="{ height: containerHeight + 'px' }">
    <view 
      class="virtual-list-content"
      :style="{ transform: `translateY(${offsetTop}px)` }"
    >
      <view 
        v-for="(item, index) in visibleItems" 
        :key="item.id"
        class="list-item"
        :style="{ height: itemHeight + 'px' }"
      >
        <text>{{ item.content }}</text>
      </view>
    </view>
    <scroll-view 
      class="scroll-container"
      scroll-y
      :scroll-top="scrollTop"
      @scroll="handleScroll"
    >
      <view class="scroll-placeholder" :style="{ height: totalHeight + 'px' }"></view>
    </scroll-view>
  </view>
</template>

<script>
export default {
  props: {
    // 列表数据
    data: {
      type: Array,
      default: () => []
    },
    // 列表项高度
    itemHeight: {
      type: Number,
      default: 100
    },
    // 容器高度
    containerHeight: {
      type: Number,
      default: 500
    }
  },
  data() {
    return {
      scrollTop: 0,
      offsetTop: 0,
      startIndex: 0,
      endIndex: 0
    }
  },
  computed: {
    // 总高度
    totalHeight() {
      return this.data.length * this.itemHeight
    },
    // 可见项数量
    visibleCount() {
      return Math.ceil(this.containerHeight / this.itemHeight) + 2 // 额外渲染2个以确保滚动流畅
    },
    // 可见项
    visibleItems() {
      return this.data.slice(this.startIndex, this.endIndex)
    }
  },
  watch: {
    // 监听滚动位置
    scrollTop: {
      handler(newVal) {
        // 计算起始索引
        this.startIndex = Math.floor(newVal / this.itemHeight)
        // 计算结束索引
        this.endIndex = Math.min(this.startIndex + this.visibleCount, this.data.length)
        // 计算偏移量
        this.offsetTop = this.startIndex * this.itemHeight
      },
      immediate: true
    },
    // 监听数据变化
    data() {
      this.updateVisibleRange()
    }
  },
  methods: {
    // 处理滚动事件
    handleScroll(e) {
      this.scrollTop = e.detail.scrollTop
    },
    // 更新可见范围
    updateVisibleRange() {
      this.startIndex = Math.floor(this.scrollTop / this.itemHeight)
      this.endIndex = Math.min(this.startIndex + this.visibleCount, this.data.length)
      this.offsetTop = this.startIndex * this.itemHeight
    }
  }
}
</script>

<style scoped>
.virtual-list {
  position: relative;
  overflow: hidden;
}

.virtual-list-content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

.list-item {
  display: flex;
  align-items: center;
  padding: 0 20rpx;
  border-bottom: 1rpx solid #e5e5e5;
  background-color: #fff;
}

.scroll-container {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
}

.scroll-placeholder {
  width: 100%;
}
</style>

图片懒加载

<template>
  <view class="lazy-load-list">
    <view v-for="(item, index) in listData" :key="item.id" class="list-item">
      <image 
        :src="item.image" 
        :alt="item.title" 
        class="item-image"
        lazy-load
        @load="handleImageLoad"
        @error="handleImageError"
      />
      <view class="item-content">
        <text class="item-title">{{ item.title }}</text>
        <text class="item-desc">{{ item.description }}</text>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      listData: Array.from({ length: 50 }, (_, i) => ({
        id: i + 1,
        title: `Item ${i + 1}`,
description: `This is the description for item ${i + 1}`,
        image: `https://via.placeholder.com/200?text=Image${i + 1}`,
        imageLoaded: false
      }))
    }
  },
  methods: {
    // 图片加载成功
    handleImageLoad(e) {
      console.log('Image loaded:', e.target.dataset.src)
    },
    // 图片加载失败
    handleImageError(e) {
      console.log('Image load error:', e.target.dataset.src)
      // 可以设置默认图片
      e.target.src = 'https://via.placeholder.com/200?text=Error'
    }
  }
}
</script>

<style scoped>
.lazy-load-list {
  padding: 20rpx;
}

.list-item {
  display: flex;
  padding: 20rpx;
  margin-bottom: 20rpx;
  background-color: #fff;
  border-radius: 10rpx;
  box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
}

.item-image {
  width: 150rpx;
  height: 150rpx;
  border-radius: 5rpx;
}

.item-content {
  flex: 1;
  margin-left: 20rpx;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.item-title {
  font-size: 32rpx;
  font-weight: 500;
  margin-bottom: 10rpx;
}

.item-desc {
  font-size: 26rpx;
  color: #666;
}
</style>

常见问题与解决方案

1. 列表性能问题

问题:长列表渲染卡顿,内存占用过高

解决方案

  • 使用虚拟列表,只渲染可视区域内的列表项
  • 实现图片懒加载,延迟加载可视区域外的图片
  • 使用 list 和 cell 组件,利用组件复用机制
  • 减少列表项内的复杂计算和 DOM 操作
  • 避免在列表渲染时使用复杂的过滤器和计算属性

2. 下拉刷新和上拉加载冲突

问题:下拉刷新和上拉加载同时触发,或者触发时机不正确

解决方案

  • 使用状态变量管理加载状态,避免重复触发
  • 在上拉加载时禁用下拉刷新,或在下拉刷新时禁用上拉加载
  • 调整下拉刷新和上拉加载的触发阈值
  • 使用节流函数处理滚动事件,避免频繁触发

3. 数据分页问题

问题:数据分页加载时出现重复数据或数据丢失

解决方案

  • 确保后端 API 正确实现分页逻辑,返回正确的分页数据
  • 在前端维护正确的分页状态,包括当前页码、每页大小等
  • 在下拉刷新时重置分页状态,确保重新从第一页加载
  • 处理边界情况,如最后一页数据不足一页的情况

4. 列表项点击事件问题

问题:列表项点击事件不触发,或触发区域不正确

解决方案

  • 确保列表项使用了正确的点击事件绑定(@click)
  • 检查列表项内是否有阻止事件冒泡的元素
  • 为列表项添加合适的触摸反馈样式,提高用户体验
  • 对于复杂的列表项,考虑使用 catchtap 替代 tap 以避免事件冒泡

学习总结

通过本章节的学习,您已经掌握了以下内容:

  1. 列表组件使用:学会了使用 view、scroll-view 等组件构建列表,以及如何使用高性能列表组件
  2. 下拉刷新和上拉加载:掌握了下拉刷新和上拉加载的实现方法,以及如何处理加载状态
  3. 数据分页加载:学会了如何实现数据的分页加载和管理,包括页码控制和数据合并
  4. 列表性能优化:掌握了虚拟列表、图片懒加载、列表项复用等性能优化技巧
  5. 商品列表页实战:实现了一个完整的商品列表页,包括搜索、分类筛选、商品展示等功能

列表开发是移动应用开发中的重要部分,良好的列表设计可以提高用户体验和应用性能。在实际项目中,您应该根据具体的业务需求和数据量大小,选择合适的列表实现方案,并应用性能优化技巧,确保列表流畅运行。

通过本章节的学习,您已经具备了开发各种类型列表的能力,可以应对不同场景下的列表开发需求,为用户提供流畅、高效的列表体验。

« 上一篇 uni-app 表单开发 下一篇 » uni-app 动画效果