微信小程序开发学习 中级实战 列表篇

§ 列表 - 开发准备

开始前请把 ch3-1 分支中的 code/ 目录导入微信开发工具
这一章主要会教大家如何用小程序制作一个可以无限加载的列表。希望大家能通过这个例子掌握制作各种列表的原理。

无限列表加载的原理

其实所谓的无限列表就是将所有的数据分成一页一页的展示给用户看。我们每次只请求一页数据。当我们判断用户阅读完了这一页之后,立马请求下一页的数据,然后渲染出来给用户看,这样在用户看来,就感觉一直有内容可看。

当然,这其中很重要的一点就是,涉及到请求就肯定会有等待,处理好请求时的 加载状态,给用户以良好的体验也是非常重要的,否则如果网络状况不佳,而且没有给用户提示程序正在努力加载的话,用户很容易就以为他看完了,或者程序死掉了。

我们的列表所提供的功能

  1. 静默加载
  2. 标记已读
  3. 提供分享

涉及的核心技术和 API

  1. wx:for 的用法
  2. onReachBottom 的用法
  3. wx.storage 的用法
  4. wx.request 的用法
  5. Promise
  6. onShareAppMessage 的用法

我们将正式投入开发中,在这之前,我们修改 app.json 文件,并修改如下:

  1. 修改 pages 字段,为小程序增加页面配置
  2. 修改 window 字段,调整小程序的头部样式,也就是 navigationBar
{
  "pages":[
    "pages/index/index",
    "pages/detail/detail"
  ],
  "window":{
    "backgroundTextStyle":"light",
    "navigationBarBackgroundColor": "#4abb3b",
    "navigationBarTitleText": "iKcamp英语学习",
    "backgroundColor": "#f8f8f8",
    "navigationBarTextStyle":"white"
  },
  "netWorkTimeout": {
    "request": 10000,
    "connectSocket": 10000,
    "uploadFile": 10000,
    "downloadFile": 10000
  },
  "debug": true
}

现在准备工作已经全部到位,我们开始列表页面的制作过程。

可以预览下我们的最终制作效果图:

1557857894266

分析下页面,很明显,日期是一个页面结构单位,一个单位里面的每篇文章也是一个小的单位。制作我们的页面如下,过程很简单,就不再复述了,修改 index.wxml 文件:

<view class="wrapper">
    <view class="group">
        <view class="group-bar">
            <view class="group-title on">今日</view>
        </view>
        <view class="group-content">
            <view class="group-content-item">
                <view class="group-content-item-desc ellipsis-multi-line ellipsis-line-3">为什么聪明人总能保持冷静?</view>
                <image class="group-content-item-img" mode="aspectFill" src="https://n1image.hjfile.cn/mh/2017/06/26/9ffa8c56cfd76cf5159011f4017f022e.jpg"/>
            </view>
        </view>
    </view>
    <view class="group">
        <view class="group-bar">
            <view class="group-title">0627</view>
        </view>
        <view class="group-content">
            <view class="group-content-item">
                <view class="group-content-item-desc ellipsis-multi-line ellipsis-line-3">为什么聪明人总能保持冷静?</view>
                <image class="group-content-item-img" mode="aspectFill" src="https://n1image.hjfile.cn/mh/2017/06/26/9ffa8c56cfd76cf5159011f4017f022e.jpg"/>
            </view>
        </view>
    </view>
    <view class="no-more" hidden="">暂时没有更多内容</view>
</view>   

修改 index.wxss 文件:

.wrapper .group {
  padding: 0 36rpx 10rpx 36rpx;
  background: #fff;
  margin-bottom: 16rpx
}

.wrapper .group-bar {
  height: 114rpx;
  text-align: center
}

.wrapper .group-title {
  position: relative;
  display: inline-block;
  padding: 0 12rpx;
  height: 40rpx;
  line-height: 40rpx;
  border-radius: 4rpx;
  border: solid 1rpx #e0e0e2;
  font-size: 28rpx;
  color: #ccc;
  margin-top: 38rpx;
  overflow: visible
}

.wrapper .group-title:after,.wrapper .group-title:before {
  content: '';
  top: 18rpx;
  position: absolute;
  width: 32rpx;
  height: 1rpx;
  transform: scaleY(.5);
  border-bottom: solid 1px #efefef
}

.wrapper .group-title:before {
  left: -56rpx
}

.wrapper .group-title:after {
  right: -56rpx
}

.wrapper .group-title.on {
  border: solid 1rpx #ffc60e;
  color: #ffc60e
}

.wrapper .group-title.on:after,.wrapper .group-title.on:before {
  border-bottom: solid 1px #ffc60e
}

.wrapper .group-content-item {
  position: relative;
  width: 100%;
  height: 194rpx;
  margin-bottom: 28rpx
}

.wrapper .group-content-item-desc {
  font-size: 36rpx;
  font-weight: 500;
  height: 156rpx;
  line-height: 52rpx;
  margin-right: 300rpx;
  margin-top: 8rpx;
  overflow: hidden;
  color: #333
}

.wrapper .group-content-item-img {
  position: absolute;
  right: 0;
  top: 0;
  vertical-align: top;
  width: 260rpx;
  height: 194rpx
}

.wrapper .group-content-item.visited .group-content-item-desc {
  color: #999
}

.wrapper .no-more {
  height: 44rpx;
  line-height: 44rpx;
  font-size: 32rpx;
  color: #ccc;
  text-align: center;
  padding: 20rpx 0
}

静态页面已经制作完成,下一篇中,我们将带着大家开发业务流程

修改 index.js 文件,引入我们需要的外部资源

'use strict';

import util from '../../utils/index';
import config from '../../utils/config';

let app = getApp();
let isDEV = config.isDev;

// 后继的代码都会放在此对象中
let handler = {

}
Page(handler)

数据绑定

我们首先挖出和渲染相关的数据,并添加在 handler 对象的 data 字段中(Model 层)
修改 index.js 中的 handler 对象:

// 此处省略部分代码
let handler = {
  data: {
    page: 1, //当前加载第几页的数据
    days: 3,
    pageSize: 4,
    totalSize: 0,
    hasMore: true,// 用来判断下拉加载更多内容操作
    articleList: [], // 存放文章列表数据,与视图相关联
    defaultImg: config.defaultImg
  },
}

注意: 后续添加的代码都是放在 handler 对象中,它会传递到 Page 函数中用来初始化页面组件

获取数据

然后要做的就是获取列表的数据,初始化数据的工作我们一般放在生命周期的 onLoad() 里:

let handler = {
  onLoad (options) {
    this.requestArticle()
  },
  /*
   * 获取文章列表数据
   */
  requestArticle () {
    util.request({
      url: 'list',
      mock: true,
      data: {
        tag:'微信热门',
        start: this.data.page || 1,
        days: this.data.days || 3,
        pageSize: this.data.pageSize,
        langs: config.appLang || 'en'
      }
    })
    .then(res => {
      console.log( res )  
    });
  } 
}

.then这里就是一个异步的结果,成功了就输出res结果

数据加载完成之后,我们需要对接口返回的数据进行业务方面的容错处理

修改 requestArticle 函数:

let handler = {
  // 此处省略部分代码
  requestArticle () {
    util.request({
      url: 'list',
      mock: true,
      data: {
        tag:'微信热门',
        start: this.data.page || 1,
        days: this.data.days || 3,
        pageSize: this.data.pageSize,
        langs: config.appLang || 'en'
      }
    })
    .then(res => {
      // 数据正常返回
      if (res && res.status === 0 && res.data && res.data.length) {
          // 正常数据 do something
          console.log(res)
      } 
      /*
      * 如果加载第一页就没有数据,说明数据存在异常情况
      * 处理方式:弹出异常提示信息(默认提示信息)并设置下拉加载功能不可用
      */ 
      else if (this.data.page === 1 && res.data && res.data.length === 0) {
          util.alert();
          this.setData({
              hasMore: false
          });
      } 
      /*
      * 如果非第一页没有数据,那说明没有数据了,停用下拉加载功能即可
      */ 
      else if (this.data.page !== 1 && res.data && res.data.length === 0) {
          this.setData({
              hasMore: false
          });
      } 
      /*
      * 返回异常错误
      * 展示后端返回的错误信息,并设置下拉加载功能不可用
      */ 
      else {
          util.alert('提示', res);
          this.setData({
              hasMore: false
          });
          return null;
      }
    })
  } 
}

上面我们把 wx.request 重新包装成了 Promise 的形式,其实我们是请求的 mock 数据。但是接口请求到的数据绝大部分情况下都不会直接适用于 UI 展示,所以我们需要做一层数据转换,把接口数据转换成视图数据。

格式化数据

先看下后端返回的数据结构

1557859068913

我们需要做两件事情

  1. 遍历 data 数组,对返回的日期格式化,当天的显示 今天,如果是今年的文章,显示月日格式 08-21 ;如果是往年的文章,显示标准的年月日格式 2015-06-12
  2. 遍历 articles 数组,判断此篇文章的 contentId 是否已经在全局变量 visitedArticles 中,如果存在,说明已经访问过。

修改 app.js,增加全局变量 visitedArticles

globalData: {
  user: {
    name: '',
    avator: ''
  },
  visitedArticles: ''
}

修改 index.js 中的 requestArticle 函数:

let handler = {
  // 此处省略部分代码
  requestArticle () {
    // 注意:修改此处代码
    if (res && res.status === 0 && res.data && res.data.length) {
      let articleData = res.data;
      //格式化原始数据
      let formatData = this.formatArticleData(articleData);
      console.log( formatData )
    } 
  }
}

增加对列表数据格式化的代码:

let handler = {
  // 此处省略部分代码
  /*
  * 格式化文章列表数据
  */
  formatArticleData (data) {
      let formatData = undefined;
      if (data && data.length) {
          formatData = data.map((group) => {
              // 格式化日期
              group.formateDate = this.dateConvert(group.date);
              if (group && group.articles) {
                  let formatArticleItems = group.articles.map((item) => {
                      // 判断是否已经访问过
                      item.hasVisited = this.isVisited(item.contentId);
                      return item;
                  }) || [];
                  group.articles = formatArticleItems;
              }
              return group
          })
      }
      return formatData;
  },
  /*
  * 将原始日期字符串格式化 '2017-06-12'
  * return '今日' / 08-21 / 2017-06-12
  */
  dateConvert (dateStr) {
      if (!dateStr) {
          return '';
      }
      let today = new Date(),
          todayYear = today.getFullYear(),
          todayMonth = ('0' + (today.getMonth() + 1)).slice(-2),
          todayDay = ('0' + today.getDate()).slice(-2);
      let convertStr = '';
      let originYear = +dateStr.slice(0,4);
      let todayFormat = </span><span class="p">${</span><span class="nx">todayYear</span><span class="p">}</span><span class="s2">-</span><span class="p">${</span><span class="nx">todayMonth</span><span class="p">}</span><span class="s2">-</span><span class="p">${</span><span class="nx">todayDay</span><span class="p">}</span><span class="s2">;
      if (dateStr === todayFormat) {
          convertStr = '今日';
      } else if (originYear < todayYear) {
          let splitStr = dateStr.split('-');
          convertStr = </span><span class="p">${</span><span class="nx">splitStr</span><span class="p">[</span><span class="mi">0</span><span class="p">]}</span><span class="s2">年</span><span class="p">${</span><span class="nx">splitStr</span><span class="p">[</span><span class="mi">1</span><span class="p">]}</span><span class="s2">月</span><span class="p">${</span><span class="nx">splitStr</span><span class="p">[</span><span class="mi">2</span><span class="p">]}</span><span class="s2">日;
      } else {
          convertStr = dateStr.slice(5).replace('-', '月') + '日'
      }
      return convertStr;
  },
  /*
  * 判断文章是否访问过
  * @param contentId
  */
  isVisited (contentId) {
      let visitedArticles = app.globalData && app.globalData.visitedArticles || '';
      return visitedArticles.indexOf(</span><span class="p">${</span><span class="nx">contentId</span><span class="p">}</span><span class="s2">) > -1;
  },
}

正常情况下,这个时候控制台打印出来的数据,是经过格式化的标准数据了,下一步,我们需要把它添加到 data 中的 articleList 字段里面,这样视图才有了渲染的数据

修改 index.js,增加 renderArticle 函数。由于每次请求的都是某一页的数据,所以在函数中,我们需要把每次请求过来的列表数据都 concat(拼接)到 articleList中:

let handler = {
  // 此处省略部分代码
  renderArticle (data) {
      if (data && data.length) {
          let newList = this.data.articleList.concat(data);
          this.setData({
              articleList: newList
          })
      }
  }
}

requestArticle 函数中调用 renderArticle:

let handler = {
  // 此处省略部分代码
  requestArticle () {
    // 注意:修改此处代码
    if (res && res.status === 0 && res.data && res.data.length) {
      let articleData = res.data;
      //格式化原始数据
      let formatData = this.formatArticleData(articleData);
      this.renderArticle( formatData )
    } 
  }
}

最终结果

最终的 index.js 文件就是这样的:

'use strict';

import util from '../../utils/index'
import config from '../../utils/config'

let app = getApp()
let isDEV = config.isDev

// 后继的代码都会放在此对象中
let handler = {
  data: {
    page: 1, //当前的页数
    days: 3,
    pageSize: 4,
    totalSize: 0,
    hasMore: true,// 用来判断下拉加载更多内容操作
    articleList: [], // 存放文章列表数据
    defaultImg: config.defaultImg
  },
  onLoad(options) {
    this.requestArticle();
  },
  /*
  * 获取文章列表数据
  */
  requestArticle() {
    util.request({
      url: 'list',
      mock: true,
      data: {
        tag: '微信热门',
        start: this.data.page || 1,
        days: this.data.days || 3,
        pageSize: this.data.pageSize,
        langs: config.appLang || 'en'
      }
    })
      .then(res => {
        // 数据正常返回
        if (res && res.status === 0 && res.data && res.data.length) {
          let articleData = res.data;
          //格式化原始数据
          let formatData = this.formatArticleData(articleData);
          this.renderArticle(formatData)
        }
        /*
        * 如果加载第一页就没有数据,说明数据存在异常情况
        * 处理方式:弹出异常提示信息(默认提示信息)并设置下拉加载功能不可用
        */
        else if (this.data.page === 1 && res.data && res.data.length === 0) {
          util.alert();
          this.setData({
            hasMore: false
          });
        }
        /*
        * 如果非第一页没有数据,那说明没有数据了,停用下拉加载功能即可
        */
        else if (this.data.page !== 1 && res.data && res.data.length === 0) {
          this.setData({
            hasMore: false
          });
        }
        /*
        * 返回异常错误
        * 展示后端返回的错误信息,并设置下拉加载功能不可用
        */
        else {
          util.alert('提示', res);
          this.setData({
            hasMore: false
          });
          return null;
        }
      })
  },
  /*
  * 格式化文章列表数据
  */
  formatArticleData(data) {
    let formatData = undefined;
    if (data && data.length) {
      formatData = data.map((group) => {
        // 格式化日期
        group.formateDate = this.dateConvert(group.date);
        if (group && group.articles) {
          let formatArticleItems = group.articles.map((item) => {
            // 判断是否已经访问过
            item.hasVisited = this.isVisited(item.contentId);
            return item;
          }) || [];
          group.articles = formatArticleItems;
        }
        return group
      })
    }
    return formatData;
  },
  /*
  * 将原始日期字符串格式化 '2017-06-12'
  * return '今日' / 08-21 / 2017-06-12
  */
  dateConvert(dateStr) {
    if (!dateStr) {
      return '';
    }
    let today = new Date(),
      todayYear = today.getFullYear(),
      todayMonth = ('0' + (today.getMonth() + 1)).slice(-2),
      todayDay = ('0' + today.getDate()).slice(-2);
    let convertStr = '';
    let originYear = +dateStr.slice(0, 4);
    let todayFormat = ${todayYear}-${todayMonth}-${todayDay};
    if (dateStr === todayFormat) {
      convertStr = '今日';
    } else if (originYear < todayYear) {
      let splitStr = dateStr.split('-');
      convertStr = ${splitStr[0]}年${splitStr[1]}月${splitStr[2]}日;
    } else {
      convertStr = dateStr.slice(5).replace('-', '月') + '日'
    }
    return convertStr;
  },
  /*
  * 判断文章是否访问过
  * @param contentId
  */
  isVisited(contentId) {
    let visitedArticles = app.globalData && app.globalData.visitedArticles || '';
    return visitedArticles.indexOf(${contentId}) > -1;
  },
  renderArticle(data) {
    if (data && data.length) {
      let newList = this.data.articleList.concat(data);
      this.setData({
        articleList: newList
      })
    }
  }
}
Page(handler)

下一篇中,我们将会把数据与视图层结合在一起,动态的展示视图层

下拉更新、分享、阅读标识

开始前请把 ch3-4 分支中的 code/ 目录导入微信开发工具
这一篇中,我们把列表这块的剩余功能做完:下拉更新、分享、阅读标识。

下拉更新功能

下拉更新这个功能与我们在第一章中写的小 demo 所用方法一致:onReachBottom

当用户滚动过程中触发了 上拉 这个动作时候,微信小程序会自动监听到并执行 onReachBottom 这个函数,所以我们只需要把这个监听事件写好就行了

修改 index.js,增加 onReachBottom 函数:

let handler = {
    // 此处省略部分代码

    /*
    * 每次触发,我们都会先判断是否还可以『加载更多』
    * 如果满足条件,那说明可以请求下一页列表数据,这时候把 data.page 累加 1
    * 然后调用公用的请求函数
    */
    onReachBottom () {
        if (this.data.hasMore) {
            let nextPage = this.data.page + 1;
            this.setData({
                page: nextPage
            });
            this.requestArticle();
        }
    },   
}

分享功能

类似于 onReachBottom,分享功能也是微信自带的一个监听事件回调函数 onShareAppMessage,它返回一个对象,对象中定义了分享的各种信息及分享成功和分享失败的回调,具体细节可以查看分享接口官方文档

修改 index.js,增加分享的回调事件:

let handler = {
    // 此处省略部分代码

    /*
    * 分享
    */
    onShareAppMessage () {
        let title = config.defaultShareText || '';
        return {
            title: title,
            path: /pages/index/index,
            success: function(res) {
                // 转发成功
            },
            fail: function(res) {
                // 转发失败
            }
        }
    },
}

阅读标识

如何实现阅读标识呢?其实思路也简单。如果用户从列表中点击某篇文章阅读,此篇文章肯定是需要标识的。所以我们只需要在跳转到文章详情之前,把此篇文章的 contentId 缓存起来

修改 index.wxml,视图中绑定点击事件 bindtap="showDetail",同时增加三元判断,如果文章已经阅读过,我们给它增加一个 class="visited" 标识:

<view class="wrapper">
    <!--repeat-->
    <view wx:for="" wx:for-item="group" wx:key="" class="group">
        <view class="group-bar">
            <view class="group-title "></view>
        </view>
        <view class="group-content">
            <!--repeat-->
            <!-- 增加点击事件 bindtap="showDetail"  -->
            <view wx:for="" wx:for-item="item" wx:key="" data-item="" bindtap="showDetail" class="group-content-item ">
                <view class="group-content-item-desc ellipsis-multi-line ellipsis-line-3"></view>
                <image mode="aspectFill" class="group-content-item-img" src="" ></image>
            </view>
        </view>
    </view>

    <view hidden="" class="no-more">暂时没有更多内容</view>
</view>

修改 index.js,增加点击事件的回调函数 showDetail:

let handler = {
    // 此处省略部分代码

    /*
    * 通过点击事件,我们可以获取到当前的节点对象
    * 同样也可以获取到节点对象上绑定的 data-X 数据
    * 获取方法: e.currentTarget.dataset
    * 此处我们先获取到 item 对象,它包含了文章 id
    * 然后带着参数 id 跳转到详情页面
    */
    showDetail (e) {
        let dataset = e.currentTarget.dataset
        let item = dataset && dataset.item
        let contentId = item.contentId || 0
        wx.navigateTo({
            url: ../detail/detail?contentId=${contentId}
        });
    },
}

修改 index.js,增加处理标识功能的函数 markRead,并在上面的 showDetail 函数中调用:

let handler = {
    // 此处省略部分代码

    showDetail (e) {
        let dataset = e.currentTarget.dataset
        let item = dataset && dataset.item
        let contentId = item.contentId || 0
        // 调用实现阅读标识的函数
        this.markRead( contentId )
        wx.navigateTo({
            url: ../detail/detail?contentId=${contentId}
        });
    },
    /*
    * 如果我们只是把阅读过的文章contentId保存在globalData中,则重新打开小程序后,记录就不存在了
    * 所以,如果想要实现下次进入小程序依然能看到阅读标识,我们还需要在缓存中保存同样的数据
    * 当进入小程序时候,从缓存中查找,如果有缓存数据,就同步到 globalData 中
    */
    markRead (contentId) {
        //先从缓存中查找 visited 字段对应的所有文章 contentId 数据
        util.getStorageData('visited', (data)=> {
            let newStorage = data;
            if (data) {
                //如果当前的文章 contentId 不存在,也就是还没有阅读,就把当前的文章 contentId 拼接进去
                if (data.indexOf(contentId) === -1) {
                    newStorage = ${data},${contentId};
                }
            }
            // 如果还没有阅读 visited 的数据,那说明当前的文章是用户阅读的第一篇,直接赋值就行了 
            else {
                newStorage = ${contentId};
            }

            /*
            * 处理过后,如果 data(老数据) 与 newStorage(新数据) 不一样,说明阅读记录发生了变化
            * 不一样的话,我们就需要把新的记录重新存入缓存和 globalData 中 
            */
            if (data !== newStorage) {
                if (app.globalData) {
                    app.globalData.visitedArticles = newStorage;
                }
                util.setStorageData('visited', newStorage, ()=>{
                    this.resetArticles();
                });
            }
        });
    },
    resetArticles () {
        let old = this.data.articleList;
        let newArticles = this.formatArticleData(old);
        this.setData({
            articleList: newArticles
        });
    },
}

别急,写到这里,还没有结束呢,差最后一步了。

修改 app.js,小程序初始化时候,我们从缓存中把数据拿出来,赋值给 globalData,这样就做到了真正的阅读标识

'use strict';

// 引入工具类库 
import util from './utils/index';

let handler = {
    onLaunch () {
        this.getDevideInfo();

        // 增加初始化缓存数据功能
        util.getStorageData('visited', (data)=> {
            this.globalData.visitedArticles = data; 
        });
    },
    alert (title = '提示', content = '好像哪里出了小问题~请再试一次~') {
        wx.showModal({
            title: title,
            content: content
        })
    },
    getDevideInfo () {
        let self = this;
        wx.getSystemInfo({
            success: function (res) {
                self.globalData.deviceInfo = res;
            }
        })
    },
    globalData: {
        user: {
            openId: null
        },
        visitedArticles: '',
        deviceInfo: {}
    }
};

App(handler);

到此,列表页面的功能开发完成,下一篇我们将开始详情页面的开发