小程序触底加载更多内容的实现

2,837 阅读8分钟

首先看看展示效果

Tips:下面gif图2.7MB左右,网络不好可能加载有问题(无法打开请点击此图片链接单独查看

实现思路

上拉加载更多的细节:

  1. 触底: 监测触底事件在触底之后执行一系列动作
  2. 加载数据: 在触底后需要向服务器请求数据,如果已经请求到了所有数据,应该不再发送请求。
  3. 加载状态: 请求数据的等待时间,需要更新状态为加载中,数据渲染完成后取消该状态的显示
  4. 数据渲染: 将请求到的数据显示在视图中
  5. 没有更多数据的提示

优化项

  1. 防止连续的多次请求
  2. 封装:如何在多个页面应用同一套实现代码

功能的实现

1 数据结构的确定

数据结构来源7七月老师的(风袖API文档)

{
    "total":1,
    "count":10,
    "page":0,
    "total_page":1,
    "items":[
        {
            "id":8,
            "title":"ins复古翠绿NoteBook",
            "subtitle":"林白默默的掏出小本本,将她说的话一次不漏的记了下来。",
            "img":"",
            "for_theme_img":"",
            "price":"29.99",
            "discount_price":"27.8",
            "description":null,
            "tags":"林白推荐",
            "sketch_spec_id":"1",
            "max_purchase_quantity":null,
            "min_purchase_quantity":null
        }
    ]
}

2 ajax与后端的模拟

// /model/Products.js
class Products {
  static store = [
    {
      id: 'P001',
      title: '人间值得',
      subtitle: '愿你遍历山河,仍觉人间值得!',
      img: '/images/人间值得.png',
      price: "49.90",
      discount_price: "46.30",
      labels: ['人间值得', '恒子奶奶'],
      for_theme_img: "",
    },
    // .....本文这里给出一条数据,其余的省略
  ]
  constructor() {
    this.total = Products.store.length;
  }

  async getPorductList({ count = 5, page = 1 }) {
    this.count = count;
    this.page = page;
    this.total_page = Math.ceil(this.total / this.count);
    const start = (this.page - 1) * this.count;
    const end = this.page * this.count;
    this.items = Products.store.slice(start, end);
    
    return new Promise((resolve) => {
      resolve(this._getDataTemplate())
    })
  }

  _getDataTemplate() {
    return {
      total: this.total,
      count: this.count,
      total_page: this.total_page,
      page: this.page,
      items: this.items
    }
  }
}

通过构造一个Products类,模拟数据库以及对数据库的请求。

  • 静态属性store代表数据库中的数据,
  • _getDataTemplate对数据格式进行组装,模拟后端对数据的处理,
  • getPorductList方法模拟请求后端数据,每次请求默认5条数据,可以配置请求数据条数与请求页数,最终将数据进行包装后返回一个promise。

3 loading组件的封装

一个项目的loading风格是统一的,这里选择了易用性而舍弃了灵活性。

<view class="loading-container" wx:if="{{show}}">
  <view class="loading" wx:if="{{loading}}">
    <image class="loading-img" src="/images/loading.gif"></image>
    <text class="loading-text">加载中</text>
  </view>
  <view class="done" wx:else>
    我也是有底线的~
  </view>
</view>

通过设置show属性来显示或隐藏loading组件,通过设置loading属性来选择显示loading的状态

4 data的确定

data: {
    loadingStatus: true,  // loading状态(加载中/无数据)的控制
    loadingShow: false, // loading组件的显示控制
    products: [], // 展示的数据
    productModel: null, // Products类创建的对象模型
    currentPage: 1, // 当请求页的设置
    pageCount: 5 // 每页请求数据的数量
  },

5 第一组数据的获取

async onLoad (options) {
    const productModel = new Products();
    const products = await productModel.getPorductList({
      count: this.data.pageCount, 
      page: this.data.currentPage
    })
    this.setData({
      productModel,
      products: products,
    });
    this.renderWaterFlow();
  },

  renderWaterFlow() {
    wx.lin.renderWaterFlow(this.data.products.items, false, () => {
      this.setData({
        loadingShow: false,
      })
    })
  },

进入页面在没有触发触底事件时,应当加载一组数据进行正常的显示。所以选择在onLoad生命周期中进行。

这里创建了Products类的实例productModel方便后续向后端发送请求获取数据。紧接着调用该实例的getPorductList方法,并传入请求页与每页显示数据条数获取第一组数据,并将其更新到data中。

最后调用lin-ui提供的瀑布流组件进行数据的渲染。

6 触底加载更多数据与请求的优化

  onReachBottom: function () {
    console.log('触底')
    if(!this.data.loadingShow) {
      console.log('请求')
      if (this.data.currentPage >= this.data.productModel.total_page) {
        this.setData({
          loadingShow: true,
          loadingStatus: false
        })
      } else {
        this.setData({
          loadingShow: true,
          currentPage: this.data.currentPage + 1,
        })
        setTimeout(() => {
          this.getPorductList()
        }, 3000)
      }
    }
  },
  
  async getPorductList() {
    const products = await this.data.productModel.getPorductList({
      count: this.data.pageCount,
      page: this.data.currentPage
    })
    this.setData({
      products,
    })
    this.renderWaterFlow();
  },

onReachBottom是小程序提供的触底事件处理方法,我们可以将触底后需要做的操作放在此函数中运行。

在这个函数中先忽略最外层的if语句,剩余代码判断了当前展示的数据是不是最后一页的数据:

  • 如果是的话就不再进行数据的请求,并将loading组件显示出来,loading状态设为false,进行没有更多数据提示的相关展示。
  • 如果当前展示的数据没有到最后一页,则应请求下一页数据,并将loading组件加载出来,loading状态为加载状态。这里使用setTimeout模拟了发送和接收请求这段等待的时间。

getPorductList方法里对(模拟的)后端进行了请求并做了数据设置,之后调用renderWaterFlow进行瀑布流的展示,在lin-ui瀑布流函数的回调中,可以设置将loadingShowfalse隐藏loading组件。

连续请求的优化

上面提到先忽略onReachBottom最外层的if语句,这里来看看这个if语句解决了什么问题,上面代码中可以看到有两个console打印语句,一个是触底一个是请求,当网络稍微差的时候,我们可以在没有接收到请求数据的时候触发多次触底事件,这是不合理的,所以加了这个if语句,判断是否在loading了,如果在loading,则表明正在请求数据,就不应该再发送请求,否则再继续进行请求的逻辑。

7 封装的考虑

上面的功能已经完成,但还可以做很多优化,比如最起码在onLoadonReachBottom写很多代码看起来让人很不舒服。 但更重要的并不是这个问题,而是我们这个触底请求数据可能要在多个页面中用到,如何只写一份代码就能让不同的页面使用这个功能就显得很重要了。

在接触这个作业时对提及的封装自己感觉并没有什么地方值得封装,因为事实上代码量并不是很多,对量不是很多的代码不能为了封装而封装吧,想了想它的需求,才明白应该提取公用的部分,将其封装起来。

这里的方式是使用behaviorsbehavior就类似vue中的mixin(自己没写过小程序,看了文档后感觉这两种东西作用非常相似,将两者做类比可以更方便自己对它的理解)

8 借封装优化代码(踩坑之旅)

  1. behavior 不能在Page中使用
  2. Page有自己的用处,Component不能替代它,如触底函数在Component中是没有的
  3. behaviorComponent的生命周期函数不同于Page

虽然只列举了这几个问题,可能对于开发过小程序的人还不是坑,但是对自己来说就算坑了,踩坑和解决也花了不少功夫。

behavior的封装

// /behaviors/loadmore.js
import { Products } from '../model/ProductsTest.js';
module.exports = Behavior({
  behaviors: [],
  data: {
    loadingStatus: true,
    loadingShow: false,
    products: [],
    productModel: null,
    currentPage: 1,
    pageCount: 5
  },
  async attached() {
    this.initData()
  },
  methods: {
    async initData() {
      const productModel = new Products();
      const products = await productModel.getPorductList({
        count: this.data.pageCount,
        page: this.data.currentPage
      })
      this.setData({
        productModel,
        products: products,
      });
      this.renderWaterFlow();
    },
    renderWaterFlow() {
      wx.lin.renderWaterFlow(this.data.products.items, false, () => {
        this.setData({
          loadingShow: false,
        })
      })
    },
    handleReachBottom() {
      if (!this.data.loadingShow) {
        if (this.data.currentPage >= this.data.productModel.total_page) {
          this.setData({
            loadingShow: true,
            loadingStatus: false
          })
        } else {
          this.setData({
            loadingShow: true,
            currentPage: this.data.currentPage + 1,
          })
          setTimeout(() => {
            this.getPorductList()
          }, 3000)
        }
      }
    },
    async getPorductList() {
      const products = await this.data.productModel.getPorductList({
        count: this.data.pageCount,
        page: this.data.currentPage
      })
      this.setData({
        products,
      })
      this.renderWaterFlow();
    }
  },
})

这里的封装其实就是将之前页面中的函数进行移植,首先将原来的数据可以完全剪切过来,之前页面的renderWaterFlowgetPorductList方法复制到behaviormethods字段中,onLoad中的代码可以完全提取出来写一个initData()方法,onReachBottom中的代码提取出来写成一个handleReachBottom()方法,将这两个方法也复制到behavior中的methods字段。到这里代码的移植工作就做完大部分了。

然后看看获取第一组数据的执行时机,在Page中的时候是放在onLoad方法中的,现在应该放到attached方法中,attachedbehavior的一个生命周期方法,Component中也有这个方法。

然后比较重要的一件事就出现了,之前我们的代码都放到了behavior中,但是在Page中无法使用behavior,如果直接将Page变成Component,这将导致我们无法监听触底事件,所以只能创建一个Compoent,将Page中的组件复制过去。然后更改页面js的逻辑,传播触底事件

<f-loadmore reachBottom="{{reachBottom}}"></f-loadmore>
  // /views/waterflow/waterflow.js
  data: {
    reachBottom: false
  },
  onReachBottom: function () {
    this.setData({
      reachBottom: true
    })
  },
  
  // /components/loadmore/index.js
  properties: {
    reachBottom: Boolean
  },
  observers: {
    'reachBottom': function(val) {
      console.log(val)
      if(val) {
        this.handleReachBottom();
      }
    }
  },

说来也巧,微信中的数据的更新与Vue不同,当属性reachBottom更改为true之后,再次触底触发reachBottom重新setData,仍然设置为true,在组件监听reachBottom属性变化时仍然能够监听到。

所以第一次设置reachBottom为false,虽然会触发组件中事件的监听,但由于触底时候会设置reachBottomtrue,我们就可以将这次false过滤掉,如果触发过来的都是true,我们就认为触发了触底事件,然后执行handleReachBottom

这里可以直接在Component中执行引入过来的behavior,引入过来的behavior一旦被注册到当前组件,其中的各配置将都会与组件的配置项进行合并,所以可以直接使用。

总结

当前的做法只是一个简单的例子,有时候我们会很多地方需要调用不同的接口,但以上例子中的做法并不支持,你可以根据自己需要,将这个封装做得更加灵活,以达到适应自己项目的目的。