Skip to content
当前文档原生微信小程序版coolui-scroller 改用 uni-app 版 →

scroll-page 长列表分页组件 ​

介绍 ​

长列表解决方案:将数据分为多个 page 页面(如下图)。每个 page 页面包含列表项 item。计算每个 page 的高度。当 page1 已加载完并且已经划过到可视区上面时,将该 page1 设为当前的 page 高度,并删除里面的列表项 item。完成用一个空的 page 进行占位。这样就减少了过多的列表项 dom。当页面向下滚动时。page1 又进入可视区时,将列表项 item 再还原回来。

为了保证滚动时的连贯性。我们保存了当前可视区及可视区上下两个 page 页面中的列表项。其余 page 中的列表项全部删除,只有滚动到时才显示。这样就保证页面中有且只有三个 page 中有列表项。其余均是空白占位区域。

示例图一示例图二

引入 ​

在app.json或index.json中引入组件

json
"usingComponents": {
  "scroll-page": "coolui-scroller/scroll-page/index"
}

代码演示 ​

注意:需要引入组件提供的方法来计算每个 page 页面的高度

html
<scroller
  class="{{(loadMoreSetting.status == 'loading' && list.length == 0) ? 'isloading' : ''}}"
  id="scroller"
  bind:loadmore="getList"
  bind:refresh="refresh"
  isEmpty="{{isEmpty}}"
  background="#f2f2f2"
>
  <refresh slot="refresh" type="default" config="{{defaultSetting}}" />
  <!-- 列表 -->
  <!-- page组件循环页 -->
  <scroll-page
    wx:for="{{ list }}"
    id="wrp_{{pageIndex}}"
    wx:for-index="pageIndex"
    wx:for-item="listSingleItem"
    wx:key="index"
    pageList="{{ listSingleItem }}"
  >
    <!-- item组件循环项 -->
    <item
      wx:for="{{ listSingleItem }}"
      wx:for-index="index"
      wx:for-item="listItem"
      wx:key="index"
    >
      <view class="item">
        <image class="item-image" src="{{listItem.img}}"></image>
        <view class="item-title"
          >{{(pageIndex * 3) + listItem.id}}.{{listItem.title}}</view
        >
      </view>
    </item>
    <!-- item组件循环项 -->
  </scroll-page>
  <!-- page组件循环页 -->
  <!-- 列表 -->
  <!-- 加载更多组件 -->
  <loadmore
    slot="loadmore"
    status="{{loadMoreSetting.status}}"
    loadingText="{{loadMoreSetting.loadingText}}"
    noMoreText="{{loadMoreSetting.noMoreText}}"
    moreText="{{loadMoreSetting.moreText}}"
    color="{{loadMoreSetting.color}}"
  />
  <!-- 加载更多组件 -->
</scroller>
js
// 调用组件提供的方法文件
const utils = require('coolui-scroller/index.js')

Page({
  data: {
    isEmpty: false,
    list: [],
    defaultSetting: {
      shake: true,
      style: 'black', // 设置圆点深色还是浅色
    },
    loadMoreSetting: {
      status: 'more',
      moreText: '上拉加载更多',
      loadingText: '加载中...',
      noMoreText: '-- 到底啦 --',
      color: '#999',
    },
    emptySetting: {
      img: '../../img/empty.png',
      // img: 'http://www.365editor.com/images/nodata.png',
      text: '暂无文章',
    },
  },

  onLoad() {
    // 设置缓存全部数据
    this.wholeList = []
    // 设置当前渲染第几页
    this.currentRenderIndex = 0
    // 设置缓存每一页页面高度
    this.pageHeightArr = []
    // 设置总页数
    this.totalPageNum = 0
    // 设置分页
    this.param = {
      limit: 4,
      page: 0,
    }

    this.getList()
  },
  getList() {
    const than = this
    const loadMoreSetting = than.data.loadMoreSetting
    // 判断当前是否为加载状态 防止页面重复添加数据
    if (loadMoreSetting.status !== 'loading') {
      loadMoreSetting.status = 'loading'
      than.setData({
        loadMoreSetting,
      })
      const page = this.param.page
      this.currentRenderIndex = page
      if (than.totalPageNum > 0 && page == than.totalPageNum) {
        const loadMoreSetting = than.data.loadMoreSetting
        loadMoreSetting.status = 'noMore'
        than.setData({
          loadMoreSetting,
        })
      } else {
        //  获取远程数据可换成自己封装的请求方法
        wx.request({
          url: 'https://coolui.coolwl.cn',
          data: {
            page: page + 1,
            isempty: 0, // 设置为1可测试空数据
            limit: 10,
            pagenum: 10,
            islong: 1,
          },
          method: 'get',
          success(res) {
            if (res.data.code === 200) {
              than.totalPageNum = res.data.data.last
              if (res.data.data.list.length === 0 && page === 0) {
                const loadMoreSetting = than.data.loadMoreSetting
                loadMoreSetting.status = 'noMore'
                than.setData({
                  isEmpty: true,
                  loadMoreSetting,
                })
              } else {
                than.wholeList[page] = res.data.data.list
                const datas = {}
                datas['list[' + page + ']'] = res.data.data.list
                than.setData(datas, () => {
                  // 调用方法设置page页面高度
                  utils.setHeight(than)
                  const loadMoreSetting = than.data.loadMoreSetting
                  loadMoreSetting.status = 'more'
                  than.setData({
                    loadMoreSetting,
                  })
                  than.param.page += 1
                })
              }
            }
          },
        })
      }
    }
  },
  refresh() {
    // 初始化缓存数据
    const that = this
    this.wholeList = []
    this.currentRenderIndex = 0
    this.pageHeightArr = []
    this.param = {
      limit: 4,
      page: 0,
    }
    that.setData({
      list: [],
    })
    // 重新拉取数据
    that.getList()
  },
})

配置 ​

参数说明类型默认值版本
pageList传入当前 page 分页面的列表数组Array无3.0.0

v4.0.0 更新 ​

v4.0.0 起长列表改为窗口化渲染:只渲染窗口内的页,窗口外折叠为上下两个占位块。

介绍 ​

上面 v3 的写法为「每一页都保留一个 page 组件实例,滚出视口后把列表项 item 换成占位」。当加载的页数足够多时,page 结构本身也会成为卡顿来源:页组件实例与 IntersectionObserver 的数量都会随总页数线性增长(各 N 个)。

v4.0.0 把「窗口」从 item 层扩展到 page 层:

  • 只真实渲染窗口内的页(当前可见页 ± 相邻页),窗口外统一折叠为上下两个占位块;
  • 页高定位不再使用 IntersectionObserver,改为「滚动距离 + 页高前缀和二分」,O(log N);
  • 未测量的页用「已测量页的平均值」估算,滚过后测量回填并自动收敛。

优化了什么 ​

项目v3.xv4.0.0
页组件实例每一页一个,随总页数增长(O(N))只渲染窗口内的页,约 3~5 个(O(窗口))
页高定位每页一个 IntersectionObserver(N 个观察者)滚动距离 + 页高前缀和二分,0 个观察者
窗口外节点每页一个占位节点(N 个)折叠为上下两个占位块
窗口重算由观察者回调触发滚动时按需重算,O(log N)

页数越多收益越明显:加载 50 页时,节点数由「50 个页组件 + 50 个观察者」降为「约 5 个页组件 + 2 个占位块」;滚动的卡顿主要来自这两项,因此页数多时提升最明显。

使用上的变化 ​

  1. 不再调用 utils.setHeight:页高测量、占位折叠、窗口计算都由新接口自动完成;
  2. 不再维护 wholeList / pageHeightArr / currentRenderIndex:页面只保留 list(分页数据)即可;
  3. 需要多的三个渲染字段:visiblePages(要真实渲染的页码)、topHeight / bottomHeight(上下占位块高度);
  4. 需要把滚动距离透出:<scroller bind:scroll="onScroll">,在 onScroll 中记录 scrollTop 并重算窗口;
  5. 追加一页数据后调用 updateWindow()(原来是 utils.setHeight(this))。

参数变化 ​

新增 utils.computeLonglistWindow(options):纯函数,负责由「滚动距离 + 页高缓存」算出渲染窗口,旧版 setHeight 承担的这部分工作由它接手。

参数说明类型默认值
heights已测量的每页高度(未测量项用 estimate 代替)Array[]
count总页数Number0
scrollTop当前滚动距离Number0
viewportHeight可视区高度Number0
overscan前后各多渲染几页Number1
estimate未测量页的估算高度Number0
返回值{ start, end, topHeight, bottomHeight }Object-
  • heights 不需要手工维护:按下面的示例把它挂在 this.pageHeights 上,测量完成后自动回填;
  • estimate 一般不用传:不传时用「已测量页的平均值」估算,一页都没测量过时按 300px 兜底。

代码演示 ​

html
<scroller
  class="{{(loadMoreSetting.status == 'loading' && list.length == 0) ? 'isloading' : ''}}"
  id="scroller"
  bind:loadmore="getList"
  bind:refresh="refresh"
  bind:scroll="onScroll"
  isEmpty="{{isEmpty}}"
  background="#f2f2f2"
>
  <refresh slot="refresh" type="default" config="{{defaultSetting}}" />

  <!-- 列表:窗口上方占位 -->
  <view wx:if="{{topHeight > 0}}" style="height: {{topHeight}}px"></view>

  <!-- 只渲染窗口内的页 -->
  <scroll-page
    wx:for="{{ visiblePages }}"
    wx:for-item="pageIndex"
    wx:key="*this"
    id="wrp_{{pageIndex}}"
    pageList="{{ list[pageIndex] }}"
  >
    <!-- item组件循环项 -->
    <item
      wx:for="{{ list[pageIndex] }}"
      wx:for-index="index"
      wx:for-item="listItem"
      wx:key="index"
    >
      <view class="item">
        <image class="item-image" src="{{listItem.img}}"></image>
        <view class="item-title"
          >{{(pageIndex * 3) + listItem.id}}.{{listItem.title}}</view
        >
      </view>
    </item>
    <!-- item组件循环项 -->
  </scroll-page>

  <!-- 列表:窗口下方占位 -->
  <view wx:if="{{bottomHeight > 0}}" style="height: {{bottomHeight}}px"></view>

  <!-- 加载更多组件 -->
  <loadmore
    slot="loadmore"
    status="{{loadMoreSetting.status}}"
    loadingText="{{loadMoreSetting.loadingText}}"
    noMoreText="{{loadMoreSetting.noMoreText}}"
    moreText="{{loadMoreSetting.moreText}}"
    color="{{loadMoreSetting.color}}"
  />
</scroller>
js
// 调用组件提供的方法文件
const utils = require('coolui-scroller/index.js')

Page({
  data: {
    isEmpty: false,
    list: [],
    // 窗口化渲染:只渲染这些页,其余高度由上下两个占位块撑起
    visiblePages: [],
    topHeight: 0,
    bottomHeight: 0,
    defaultSetting: {
      shake: true,
      style: 'black', // 设置圆点深色还是浅色
    },
    loadMoreSetting: {
      status: 'more',
      moreText: '上拉加载更多',
      loadingText: '加载中...',
      noMoreText: '-- 到底啦 --',
      color: '#999',
    },
    emptySetting: {
      img: '../../img/empty.png',
      // img: 'http://www.365editor.com/images/nodata.png',
      text: '暂无文章',
    },
  },

  onLoad() {
    // 设置总页数
    this.totalPageNum = 0
    // 设置分页
    this.param = {
      limit: 4,
      page: 0,
    }
    // 每页真实高度缓存(下标 = 页码),由 measureVisible 自动回填
    this.pageHeights = []
    // 当前滚动距离与可视区高度
    this.scrollTop = 0
    this.windowHeight = (wx.getWindowInfo && wx.getWindowInfo().windowHeight) || 0

    this.getList()
  },

  // 滚动时重算渲染窗口
  onScroll(e) {
    this.scrollTop = (e.detail && e.detail.scrollTop) || 0
    this.updateWindow()
  },

  // 计算窗口:只渲染窗口内的页,窗口外折叠成上下两个占位块
  updateWindow() {
    const count = this.data.list.length
    const heights = this.pageHeights || []

    // 未测量页的估算高度:用已测量页的平均值(都没有时按 300px 兜底)
    let sum = 0
    let num = 0
    for (let i = 0; i < heights.length; i++) {
      if (heights[i] > 0) {
        sum += heights[i]
        num += 1
      }
    }
    const estimate = num > 0 ? sum / num : 300

    const state = utils.computeLonglistWindow({
      heights: heights,
      count: count,
      scrollTop: this.scrollTop || 0,
      viewportHeight: this.windowHeight || 0,
      overscan: 1,
      estimate: estimate,
    })

    const visiblePages = []
    for (let i = state.start; i <= state.end; i++) {
      visiblePages.push(i)
    }

    this.setData(
      {
        visiblePages: visiblePages,
        topHeight: state.topHeight,
        bottomHeight: state.bottomHeight,
      },
      () => {
        this.measureVisible(visiblePages)
      }
    )
  },

  // 测量窗口内各页真实高度并回填缓存
  measureVisible(indexes) {
    if (!indexes || !indexes.length) {
      return
    }
    const query = wx.createSelectorQuery().in(this)
    indexes.forEach((pageIndex) => {
      query.select('#wrp_' + pageIndex).boundingClientRect()
    })
    query.exec((res) => {
      let changed = false
      indexes.forEach((pageIndex, idx) => {
        const rect = res && res[idx]
        const h = rect && rect.height
        if (h && Math.abs((this.pageHeights[pageIndex] || 0) - h) > 0.5) {
          this.pageHeights[pageIndex] = h
          changed = true
        }
      })
      // 高度变化会移动窗口位置,重算一次(测量稳定后自然收敛)
      if (changed) {
        this.updateWindow()
      }
    })
  },

  getList() {
    const than = this
    const loadMoreSetting = than.data.loadMoreSetting
    // 判断当前是否为加载状态 防止页面重复添加数据
    if (loadMoreSetting.status !== 'loading') {
      loadMoreSetting.status = 'loading'
      than.setData({
        loadMoreSetting,
      })
      const page = this.param.page
      if (than.totalPageNum > 0 && page == than.totalPageNum) {
        const loadMoreSetting = than.data.loadMoreSetting
        loadMoreSetting.status = 'noMore'
        than.setData({
          loadMoreSetting,
        })
      } else {
        //  获取远程数据可换成自己封装的请求方法
        wx.request({
          url: 'https://api.wzs.pub/mock/21/list',
          data: {
            page: page + 1,
            isempty: 0, // 设置为1可测试空数据
            limit: 10,
            pagenum: 10,
            islong: 1,
          },
          method: 'get',
          success(res) {
            if (res.data.code === 200) {
              than.totalPageNum = res.data.data.last
              if (res.data.data.list.length === 0 && page === 0) {
                const loadMoreSetting = than.data.loadMoreSetting
                loadMoreSetting.status = 'noMore'
                than.setData({
                  isEmpty: true,
                  loadMoreSetting,
                })
              } else {
                // 追加一页数据即可:页高测量与渲染窗口由 updateWindow 内部处理
                const datas = {}
                datas['list[' + page + ']'] = res.data.data.list
                than.setData(datas, () => {
                  than.updateWindow()
                  const loadMoreSetting = than.data.loadMoreSetting
                  loadMoreSetting.status = 'more'
                  than.setData({
                    loadMoreSetting,
                  })
                  than.param.page += 1
                })
              }
            }
          },
        })
      }
    }
  },

  refresh() {
    // 初始化缓存数据
    const that = this
    this.pageHeights = []
    this.scrollTop = 0
    this.param = {
      limit: 4,
      page: 0,
    }
    that.setData({
      list: [],
      visiblePages: [],
      topHeight: 0,
      bottomHeight: 0,
    })
    // 重新拉取数据
    that.getList()
  },
})

升级说明(v3.x → v4.0.0) ​

v3.x 的写法v4.0.0 的写法
utils.setHeight(this)this.updateWindow()
维护 this.pageHeightArr / this.wholeList只维护 this.pageHeights(由组件回填,页面不读它)
list 中已滚出的页被替换成 [{ height }] 占位list 始终保存原始分页数据,占位由 topHeight / bottomHeight 负责
<scroll-page> 的 wx:for 遍历 listwx:for 改为遍历 visiblePages,pageList 绑定 list[pageIndex]

旧接口 utils.setHeight 仍然保留可用(不再推荐),新项目请按上面的写法接入。