微信小程序之ES6与事项助手

本文发表至今已有一段时间,错别字多、文笔混乱、内容过于陈旧。本人建议读者不必细究,大概浏览即可,最新的开发指南还是以官方文档为准,该博文的示例代码经过了重构,已经与官方文档同步,可能与文中的代码片段有较大差异,请以 Github 仓库上的代码为准。

好久没有写关于微信小程序的随笔了,其实是不知道写点什么好,之前的豆瓣图书和知乎日报已经把小程序的基础部分写的很详细了,高级部分的API有些还得不到IDE的调试支持。之前发表了知乎日报小例,有网友问我小程序有没有关于日历显示的组件,可以显示所有天数的,自己看了一遍,好像没有这个组件,所以打算那这个功能来练手,在准备期间,微信开发者工具已经升级了两三次,添加了部分功能和修改了部分功能,导致之前的例子的写法不兼容更新后的IDE,还得修改代码。随着小程序的不断更新,功能越来越完善,我想我也应该紧跟官方的升级步伐,这次的案例使用了IDE支持的ES6和新的API。

这次介绍的是一个比较简单的小应用事项助手,其实跟事项也不沾多少边,只是作为辅助功能,只有数据的添加和删除,主要内容是日历这块内容。日历组件在web应用中应用非常广泛,插件也非常丰富,但是小程序不支持传统的插件写法,而是以数据驱动内容。

大部分的日历选择器都是差不多的,能显示当前的年份、月份和天数,可以选择某天、某月或者某年,我们可以打开操作系统中自带的日历观察一番。

日历的布局大同小异,本次案例的布局也是中规中矩,比较传统,头部显示当前年份月份,头部的左右个显示一个翻页按钮,跳转到上一月和下一月,下半部分显示当月的天数列表,由于每月的天数可能不一样,列表的格数是固定的,所以当月的天数显示使用高亮,其余的使用偏灰色彩。

事项助手首页

预备

本次案例用到了ES6,先来了解一下案列中用到的几个写法。本人也是顺带学习顺带编写,可能代码中还存在部分老的写法。

变量

ES6中声明变量可以用let声明变量,用const声明常量,即不可改变的量。

let version = '1.0.0';
const weekday = 7;

version = '2.0.0';
weekday = 8; //错误,用const声明的常量,不能修改值

本习惯用大写字母和下划线的组合方式来声明全局的常量

const CONFIG_COLOR = '#FAFAFA';

对象方法属性

小程序的每一个页面都有一个相对应的js文件,里面必不可少的就是Page函数,Page函数接受的参数是一个对象,我们经常使用的写法就是:

Page({
    data: {
        userAvatar: './images/avatar.png',
        userName: 'Oopsguy'
    },
    onLoad: function() {
        //....
    },
    onReady: function() {
        //....
    }
});

现在换做ES6的写法,我们可以这样:

Page({
    data: {
        userAvatar: './images/avatar.png',
        userName: 'Oopsguy'
    },
    onLoad() {
        //....
    },
    onReady() {
        //....
    }
});

我们可以把以前的键值写法省略掉,而且function声明也不需要了。

ES6中拥有了这一概念,声明类的方式很简单,跟其他语言一样,差别不大:

class Animal {
    constructor() {

    }

    eat() {

    }

    static doSomething(param) {
        //...
    }
}

module.exports = Animal;

class关键字用于声明类,constructor是构造函数,static修饰静态方法。不能理解?我们看一下以前的js的简单写法:

var Animal = function() {

};

Animal.prototype.eat = function() {

};

Animal.doSomething = function(param) {

};

module.exports = Animal;

简单的调用示例

let animal = new Animal();
animal.eat();
//静态方法
Animal.doSomething('param');

这里只是简单的展示了一下不同点,更多的只是还是需要读者自己翻阅更多的资料来学习。

解构

其实本人对结构也不太懂怎样解释,简单的来说就是可以把一个数组的元素或者对象的属性分解出来,直接获取,哈哈,解释的比较勉强,还是看看示例吧。

let obj = {
    fullName: 'Xiao Ming',
    gender: 'male',
    role: 'admin'
};

let arr = ['elem1', 1, 30, 'arratElem3'];

let {fullName, role} = obj;
let [elem1, elem2] = arr;

console.log(fullName, role, elem1, elem2);

大家可能猜出了什么,看看输出结果:

> Xiao Ming admin elem1 1

我们只要把需要获取的属性或者元素别名指定解构体中,js会自动获取对应的属性或者下标对应的元素。这个新特性非常有用,比如我们需要在一个Pages data对象中一个属性获取对了属性值:

let year = this.data.year,
    month = this.data.month,
    day = this.data.day;

但是用解构的写法就很简洁:

let {year, month, day} = this.data;

再比如引入一个文件:

function getDate(dateStr) {
    if (dateStr) {
        return new Date(Date.parse(dateStr));
    }
    return new Date();
}

function log(msg) {
    if (!msg) return;
    if (getApp().settings['debug'])
    console.log(msg);
    let logs = wx.getStorageSync('logs') || [];
    logs.unshift(msg)
    wx.setStorageSync('logs', logs)
}

module.exports = {
    getDate: getDate,
    log: log
};

现在引入并调用外部文件的方法:

import {log} from '../../utils/util';

log('Application initialized !!');

import...from...是ES6的引入模块方式,等同于小程序总的require,但import可以选择导入哪些子模块。

箭头函数(Arrow Function)

刚开始我也不知道js的箭头函数到底是什么东西,用了才发现,这特么就是lambda表达式么。箭头函数简化了函数的写法,但是还是跟普通的function有区别,主要是在作用域上。

比如我们需要请求网络:

wx.request({
  url: 'url', 
  header: {
      'Content-Type': 'application/json'
  },
  success: function(res) {
    console.log(res.data)
  }
});

用函数还是可以简化一定的代码量,哈哈哈。

wx.request({
  url: 'url', 
  header: {
      'Content-Type': 'application/json'
  },
  success: (res) => {
    console.log(res.data)
  }
});

注意到那个success指向的回调函数了么,function关键字没了,被醒目的=>符号取代了。看到这里大家是不是认为以后我们写function就用箭头函数代替呢?答案是不一定,而且要非常小心!

function和箭头函数虽然看似一样,只是写法简化了,其实是不一样的,function声明的函数和箭头函数的作用域不同,这是一个不小心就变坑的地方。

Page({
    data: {
        windowHeight: 0
    },
    onLoad() {
        let _this = this;
        wx.getSystemInfo({
            success: function(res) {
                _this.setData({windowHeight: res.windowHeight});
            }
        });
    }
});

一般我们获取设备的屏幕高度差不多是这样的步骤,在页面刚加载的onLoad方法中通过wx.getSystemInfoAPI来获取设备的屏幕高度,由于success指向的回调函数作用域跟onLoad不一样,所以我们无法像onLoad函数体中直接写this.setData来设置值。我们可以定义一个临时变量指向this,然后再回调函数中调用。

哪箭头函数的写法有什么不一样呢?

Page({
    data: {
        windowHeight: 0
    },
    onLoad() {
        let _this = this;
        wx.getSystemInfo({
            success: (res) => {
                _this.setData({windowHeight: res.windowHeight});
            }
        });
    }
});

运行之后好像感觉没什么区别呀,都能正常执行,结果也一样。确实没什么区别,你甚至这样写都可以:

Page({
    data: {
        windowHeight: 0
    },
    onLoad() {
        wx.getSystemInfo({
            success: (res) => {
                this.setData({windowHeight: res.windowHeight});
            }
        });
    }
});

咦?这样写,this的指向的作用域不是不一样么?其实这就是要说明的,箭头函数是不绑定作用域的,不会改变当前this的作用域,既然这样,在箭头函数中的this就会根据作用域链来指向上一层的作用域,也就是onLoad的作用域,所以他们得到的结果都是一样的。

其实我个人的习惯是无论用普通的函数写法还是箭头函数的写法,都习惯声明临时的_this来指向需要的作用域,因为箭头函数没有绑定作用域,写的层次深了,感觉就会很乱,理解起来比较困难,在后面的案例中,我也会延续这个习惯。

Promise

写js经常写的东西除了数组对象就是回调函数,记不记得用jQueryajax用得特别爽,如果是多层嵌套调用的话,那些回调函数简直像盖楼梯一样壮观。现在Promise来了,我们再也不用为这些回调地狱发愁,用Promise来解决回调问题非常优雅,链式调用也非常的方便。

Promise是ES6内置的类,其使用简单,简化了异步编程的繁琐层次问题,比较简单的用法是:

new Promise((resolve, reject) => {
    //success
    //resolve();

    //error 
    //reject();
});

实例化一个Promise对象,它接受一个函数参数,此函数有两个回调参数,resolvereject,如果正常执行使用resolve执行传递,如果是失败或者错误可以用reject来执行传递,其实他们就是一个状态的转换。可以暂时理解为successfail

来看一下简单的示例:

let ret = true;
let pro = new Promise((resolve, reject) => {
    ret ? resolve('true') : reject('false');
}).then((res) => {
    console.log(res);
    return 'SUCCESS';
}, (rej) => {
    console.log(rej);
    return 'ERROR';
}).then((success) => {
    console.log(success);
    let value = 0 / 1;
}, (error) => {
    console.log(error);
}).catch((ex) => {
    console.log(ex);
});

或许我们已经看出些什么了,实例化出一个Promise,根据ret的布尔值决定是否resolve执行正常回调流程还是执行reject回调走意外的流程,显然ret是true,当执行resolve时,传递了一个字符串参数true,可以看到实例化出来的Promise对象后面链式调用了很多then方法,其实then方法同样也是有resolvereject两个回调参数,上层的Promise执行的回调传递到then函数中,Promiseresolve传递到thenresolve,同理reject也一样,之后我们发现最后一个catch函数,这是一个捕抓异常的函数,当流程发生异常,我们可以在catch方法中获取异常并处理。

可能解释的比较羞涩,看看下面例子,发出一个网络请求,获取用户头像,再把用户头像插入DOM中,再睡眠2000ms,再打印出SUCCESS,再睡眠3000ms,在alert出ERROR,再休眠1000ms,最后打印出ERROR。这...看起来有点丧心病狂,但只是举个例子:

$.get('/user/1/avatar', (data) => {
    $('#avatar img').attr('src', data['avatar']);
    setTimeout(() => {
        console.log('SUCCESS');
        setTimeout(() => {
            alert('ERROR');
            setTimeout(() => {
                console.log('ERROR');
            }, 1000);
        }, 3000)
    }, 2000);
});

一共有四个回调函数,也不算多,如果有十几个回调呢?直至是噩梦呀。一层一层的嵌套,看起来已经眼花了。那么Promise能做些什么改变呢?

function sleep(time) {
    return new Promise((resolve) => {
        setTimeout(resolve, time);
    });
}

new Promise((resolve) => {
    $.get('/user/1/avatar', resolve);
}).then((avatar) => {
    $('#avatar img').attr('src', avatar);
}).then(() => {
    return sleep(2000);
}).then(() => {
    console.log('SUCCESS');
    return sleep(3000);
}).then(() => {
    alert('ERROR');
    return sleep(1000);
}).then(() => {
    console.log('ERROR');
});

额...看起来怎么使用Promise代码量比不使用的还多呀。不要介意,嘿嘿,可能是我个人封装不精,但是使用Promise的代码可读性确实比上面的要好很多,而且我们不必写一堆的嵌套回调函数,在享受使用同步写法的待遇,又可以得到异步的功能,两全其美,这样的写法还是比较符合日常的思维方式,哈哈。

看看小程序中怎么应用,在小程序项目的app.js中,我们经常看见这段代码:

App({
    getUserInfo:function(cb){
        var that = this
        if(this.globalData.userInfo){
            typeof cb == "function" && cb(this.globalData.userInfo)
        }else{
            wx.login({
                success: function () {
                    wx.getUserInfo({
                        success: function (res) {
                            that.globalData.userInfo = res.userInfo
                            typeof cb == "function" && cb(that.globalData.userInfo)
                        }
                    })
                }
            })
        }
    }
});

这是个方法是获取当前用户的信息,首先先检查globalData对象中有没有缓存有userInfo对象(存储用户的信息),如果有就返回给用户传进来的回掉函数,否则就请求接口获取用用户信息,获取用户信息之前,微信小程序要求先调用wx.login认证,才能调用wx.getUserInfo接口。

看的出代码的层次已经有点深了,我们可以用Promise来简化一下(-_-|| 说的有点夸张,实际上这点嵌套还是可以的)

wx.getUserInfowx.login这两个接口都用共同的属性successfail,我们可以封装起来:

/**
 * @param {Function} func 接口
 * @param {Object} options 接口参数
 * @returns {Promise} Promise对象
*/
function promiseHandle(func, options) {
  options = options || {};
  return new Promise((resolve, reject) => {
    if (typeof func !== 'function')
        reject();
    options.success = resolve;
    options.fail = reject;
    func(options);
  });
}

App({
    getUserInfo(cb) {
        if (typeof cb !== "function") return;
        let that = this;
        if (that.globalData.userInfo) {
            cb(that.globalData.userInfo);
        } else {    
            promiseHandle(wx.login)
                .then(() => promiseHandle(wx.getUserInfo))
                .then((res) => {
                    that.globalData.userInfo = res.userInfo;
                    cb(that.globalData.userInfo);
                })
                .catch((err) => {
                    log(err);
                });
        }
    }
});  

可以看出,使用了Promise之后,代码简洁了不少,层次深度也降低了不少,好家伙,很管用!

其实本次代码中的回调嵌套很少的,为了尽量使用到ES6的新特性,少量的回调嵌套也使用了Promise处理。

介绍了那么多,主要了为了还不了解ES6的读者能够预热一下知识,为后面的案例做好准备,当然,肯定有同学已经对ES6了如指掌,本人也是刚刚学习,欢迎指正错误。

思路

在开工之前,我们先理一下思路,一个普通的日历显示功能应该怎么做,该怎样入手。

日期

获取日期相关的信息,肯定用到Date对象。

let date = new Date();
let day = date.getDate(); //当月的天
let month = date.getMonth() + 1; //月份,从0开始
let year = date.getFullYear(); //年份

我们需要知道当前展示月份的天数。

let dayCount = new Date(currentYear, currentMonth, 0).getDate();

得到可当月月份的天数,可以展示出所有的天数列表,但是我们一样要或者上一个页的天数和下一个页的天数,如果当前月份是1月或者12月,我们还需要额外判断上一页是上一年的12月,下一页是下一年的一月份。

我们可能需要获取足够多的日期信息来展示(不仅仅是当前月份,还有上一月或者上一年和下一月或者下一年)

data = {
    currentDate: currentDateObj.getDate(), //当天日期第几天
    currentYear: currentDateObj.getFullYear(), //当天年份
    currentDay: currentDateObj.getDay(), //当天星期
    currentMonth: currentDateObj.getMonth() + 1, //当天月份
    showMonth: showMonth, //当前显示月份
    showDate: showDate, //当前显示月份的第几天 
    showYear: showYear, //当前显示月份的年份
    beforeYear: beforeYear, //当前页上一页的年份
    beforMonth: beforMonth, //当前页上一页的月份
    afterYear: afterYear, //当前页下一页的年份
    afterMonth: afterMonth, //当前页下一页的月份
    selected: selected //当前被选择的日期信息
};

能显示日期之后,当然还没有完,我们需要一个选择日期的功能,即用户可以点击指定那一天,也可以选择哪一年或者哪一个月,选择年份和月份我们可以用Picker组件来展示,选择具体的哪天这就需要在日期列表上的每一天都要绑定一个点击事件来响应用户的点击动作,用户选择具体的日期后,可能会随意翻页,所以必须要保存好当前选择的日期。

存储

示例程序中用到了数据存储,关系到小程序中的数据缓存API,官方提供的API比较多,我只是用了两个异步的数据缓存API。

wx.setStorage({key: KEY, data: DATA});

let allData  =[{id: 1, title: 'title1'}, {id: 2, title: 'title2'}];
wx.setStorageSync({key: Config.ITEMS_SAVE_KEY, data: allData});
参数 说明
KEY 存储数据的键名
DATA 存储的数据

wx.getStorage({key: KEY});

let allData = wx.getStorage({
        key: Config.ITEMS_SAVE_KEY
        success: allData => {
            let obj1 = allData[0];
            console.log(obj1.title);
        }
    });
参数 说明
KEY 存储数据的键名

编码

建立工程的步骤就不讲了,直接进入主题,应用只有两个页面,一个首页,一个详情页,结构清晰,功能简单。

日历

先来看看首页,日历的wxml结构;

结构分为上中下三部分,header为头部,用于展示翻页按钮和当前日期信息。在.week.row.body.row元素中展示星期和天数列表,这里的布局采用了比较low的百分比分栏,总共有7栏,100/7哈哈,想高逼格的可以采用css的分栏布局和flex布局。

<view class="og-calendar">
    <view class="header">
        <view class="btn month-pre" bindtap="changeDateEvent" data-year="{{data.beforeYear}}" data-month="{{data.beforMonth}}"> 
            <image src="../../images/prepage.png"></image>
        </view>
        <view class="date-info">
            <picker mode="date" fields="month" value="{{pickerDateValue}}" bindchange="datePickerChangeEvent">
                <text>{{data.showYear}}年{{data.showMonth > 9 ? data.showMonth : ('0' + data.showMonth)}}月</text>
            </picker>
        </view>
        <view class="btn month-next" bindtap="changeDateEvent" data-year="{{data.afterYear}}" data-month="{{data.afterMonth}}">
            <image src="../../images/nextpage.png"></image>
        </view>
    </view>
    <view class="week row">
        <view class="col">
            <text>一</text>
        </view>
        <view class="col">
            <text>二</text>
        </view>
        <view class="col">
            <text>三</text>
        </view>
        <view class="col">
            <text>四</text>
        </view>
        <view class="col">
            <text>五</text>
        </view>
        <view class="col">
            <text>六</text>
        </view>
        <view class="col">
            <text>日</text>
        </view>
    </view>
    <view class="body row">  
        <block wx:for="{{data.dates}}" wx:key="_id">
            <view bindtap="dateClickEvent" data-year="{{item.year}}" data-month="{{item.month}}" data-date="{{item.date}}" class="col {{data.showMonth == item.month ? '' : 'old'}} {{data.currentDate == item.date && data.currentYear==item.year && data.currentMonth == item.month ? 'current' : ''}} {  {item.active ? 'active' : ''}}">
                <text>{{item.date}}</text>
            </view> 
        </block>
    </view>
</view>

.btn.month-pre.btn.month-next翻页按钮,都绑定了changeDateEvent的tap事件,各自都用自己的data-yeardata-mont属性,这两个属性是临时存值,当点击按钮翻页的时候,我们需要知道当前的年份和日期,以便可以更加方便地翻到上一页或者下一页。

changeDateEvent事件比较简单:

changeDateEvent(e) {
    const {year, month} = e.currentTarget.dataset;
    changeDate.call(this, new Date(year, parseInt(month) - 1, 1));
}

点击翻页按钮,根据回调进来的event对象来获取元素上的data-*属性,然后调用changeDate这个方法来更新日历数据,这个方法接收一个Date对象,代表要翻页后的日期。

暂且不关心changeDate具体干了些什么,看看.body.row里有一个循环,每一个元素都绑定了dateClickEvent事件,而且每一个元素都附带了自己所属的年份、月份和天数信息,这些信息是非常有用的,当点击了具体的某一天,可以通过获取元素上的data-*信息来知道我们具体选择的日期。除此之外,元素上的class属性包裹了一长串的判断表达式。这些语句最终的目的是为了给元素动态变更,.old代表当前的日期不是本月日期,因为每一版的日期除了当前月份的日期还可能包含上一月和下一月的部分日期,我们给予它灰色的样式显示,.current代表今天的日期,用实心填充颜色的背景样式修饰,.active即代表着当前选中的日期。

dateClickEvent事件其实也是调用了changeDate事件,本质上也是也是改变日期,额外的工作就是保存选中的日期到selected对象中。

dateClickEvent(e) {
    const {year, month, date} = e.currentTarget.dataset;
    const {data} = this.data;
    let selectDateText = '';

    data['selected']['year'] = year;
    data['selected']['month'] = month;
    data['selected']['date'] = date;

    this.setData({ data: data });

    changeDate.call(this, new Date(year, parseInt(month) - 1, date));
}

来看看重中之重的changeDate函数,这个函数的代码比较多,虽然堆砌大量在一个函数中是个不好的习惯,不过里面声明变量和赋值比较多,业务代码比较少:

/**
 * 变更日期数据
 * @param {Date} targetDate 当前日期对象
 */
function changeDate(targetDate) {
  let date = targetDate || new Date();
  let currentDateObj = new Date();

  let showMonth, //当天显示月份
    showYear, //当前显示年份
    showDay, //当前显示星期
    showDate, //当前显示第几天
    showMonthFirstDateDay, //当前显示月份第一天的星期
    showMonthLastDateDay, //当前显示月份最后一天的星期
    showMonthDateCount; //当前月份的总天数

  let data = [];

  showDate = date.getDate();
  showMonth = date.getMonth() + 1;
  showYear = date.getFullYear();
  showDay = date.getDay();

  showMonthDateCount = new Date(showYear, showMonth, 0).getDate();
  date.setDate(1);
  showMonthFirstDateDay = date.getDay(); //当前显示月份第一天的星期
  date.setDate(showMonthDateCount);
  showMonthLastDateDay = date.getDay(); //当前显示月份最后一天的星期  

  let beforeDayCount = 0,
    beforeYear, //上页月年份
    beforMonth, //上页月份
    afterYear, //下页年份
    afterMonth, //下页月份
    afterDayCount = 0, //上页显示天数
    beforeMonthDayCount = 0; //上页月份总天数

  //上一个月月份
  beforMonth = showMonth === 1 ? 12 : showMonth - 1;
  //上一个月年份
  beforeYear = showMonth === 1 ? showYear - 1 : showYear;
  //下个月月份
  afterMonth = showMonth === 12 ? 1 : showMonth + 1;
  //下个月年份
  afterYear = showMonth === 12 ? showYear + 1 : showYear;

  //获取上一页的显示天数
  if (showMonthFirstDateDay != 0)
    beforeDayCount = showMonthFirstDateDay - 1;
  else
    beforeDayCount = 6;

  //获取下页的显示天数
  if (showMonthLastDateDay != 0)
    afterDayCount = 7 - showMonthLastDateDay;
  else
    showMonthLastDateDay = 0;

  //如果天数不够6行,则补充完整
  let tDay = showMonthDateCount + beforeDayCount + afterDayCount;
  if (tDay <= 35)
    afterDayCount += (42 - tDay); //6行7列 = 42

  //虽然翻页了,但是保存用户选中的日期信息是非常有必要的  
  let selected = this.data.data['selected'] || { year: showYear, month: showMonth, date: showDate };
  let selectDateText = selected.year + '年' + formatNumber(selected.month) + '月' + formatNumber(selected.date) + '日';

  data = {
    currentDate: currentDateObj.getDate(), //当天日期第几天
    currentYear: currentDateObj.getFullYear(), //当天年份
    currentDay: currentDateObj.getDay(), //当天星期
    currentMonth: currentDateObj.getMonth() + 1, //当天月份
    showMonth: showMonth, //当前显示月份
    showDate: showDate, //当前显示月份的第几天 
    showYear: showYear, //当前显示月份的年份
    beforeYear: beforeYear, //当前页上一页的年份
    beforMonth: beforMonth, //当前页上一页的月份
    afterYear: afterYear, //当前页下一页的年份
    afterMonth: afterMonth, //当前页下一页的月份
    selected: selected,
    selectDateText: selectDateText
  };

  let dates = [];
  let _id = 0; //为wx:key指定

  //上一月的日期
  if (beforeDayCount > 0) {
    beforeMonthDayCount = new Date(beforeYear, beforMonth, 0).getDate();
    for (let fIdx = 0; fIdx < beforeDayCount; fIdx++) {
      dates.unshift({
        _id: _id,
        year: beforeYear,
        month: beforMonth,
        date: beforeMonthDayCount - fIdx
      });
      _id++;
    }
  }

  //当前月份的日期
  for (let cIdx = 1; cIdx <= showMonthDateCount; cIdx++) {
    dates.push({
      _id: _id,
      active: (selected['year'] == showYear && selected['month'] == showMonth && selected['date'] == cIdx), //选中状态判断
      year: showYear,
      month: showMonth,
      date: cIdx
    });
    _id++;
  }

  //下一月的日期
  if (afterDayCount > 0) {
    for (let lIdx = 1; lIdx <= afterDayCount; lIdx++) {
      dates.push({
        _id: _id,
        year: afterYear,
        month: afterMonth,
        date: lIdx
      });
      _id++;
    }
  }

  data.dates = dates;


  this.setData({ data: data, pickerDateValue: showYear + '-' + showMonth });
  loadItemListData.call(this);
}

虽然这段这段代码有点啰嗦,不过总结下来无非就是获取当前月的信息,上一页的信息和下一页的信息,这些信息包括具体的年月日和星期。

年月选择Picker

既然是日历,必不可少的功能就是让用户可以选择显示指定的年份和月份,用pciker组件来实现最合适不过了,官方更新的api,目前未知,picker组件已经支持mode = date模式的风格,即原生的日期选择。触发选择的区域关联在了日历的header上。

<view class="date-info">
    <picker mode="date" fields="month" value="{{pickerDateValue}}" bindchange="datePickerChangeEvent">
        <text>{{data.showYear}}年{{data.showMonth > 9 ? data.showMonth : ('0' + data.showMonth)}}月</text>
    </picker>
</view>

mode=date指定pciker是日期选择风格,fields=month则显示组件显示日期的精度显示当月份即可,组件初始化的值为pickerDateValue,绑定了datePickerChangeEvent事件,当选择的日期发生变化时,就会触发此事件。

datePickerChangeEvent(e) {
    const date = new Date(Date.parse(e.detail.value));
    changeDate.call(this, new Date(date.getFullYear(), date.getMonth(), 1));
}
Picker

事项存储

此应用还有小小的事项功能,可以添加事项条目,事项包括了标题、内容和等级,说白了其实就是一个功能不全的TODO应用...

既然涉及到存储,肯定需要操作缓存的方法,自己也是刚搞前端那不久,不太明白javascript的封装约定,借鉴之前在java所用的模式,分为了两个文件,一个是仓库类(数据的CURD操作),另一个是业务类(附带处理部分业务),缓存的配置放置于Config文件中,类中用到了异步的缓存操作API,所以使用Promise模式封装。

首先是把Promise封装成通用的方法,顺便封装部分经常用到的函数:

/**
 * 生成GUID序列号
 * @returns {string} GUID
 */
function guid() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
    let r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

/**
 * 记录日志
 * @param {Mixed} 记录的信息
 * @returns {Void}
 */
function log(msg) {
  if (!msg) return;
  if (getApp().settings['debug'])
    console.log(msg);
  let logs = wx.getStorageSync('logs') || [];
  logs.unshift(msg)
  wx.setStorageSync('logs', logs)
}

/**
 * @param {Function} func 接口
 * @param {Object} options 接口参数
 * @returns {Promise} Promise对象
*/
function promiseHandle(func, options) {
  options = options || {};
  return new Promise((resolve, reject) => {
    if (typeof func !== 'function')
        reject();
    options.success = resolve;
    options.fail = reject;
    func(options);
  });
}

module.exports = {
  guid: guid,
  log: log,
  promiseHandle: promiseHandle
}

guid方法用于生成每一个事项的id,方便查询,log方法用于日志记录,promiseHandle把小程序的大部分异步API封装到了Promise对象中。

具体的Config配置文件:

module.exports = {
    ITEMS_SAVE_KEY: 'todo_item_save_Key',
    //事项等级
    LEVEL: {
        normal: 1,
        warning: 2,
        danger: 3
    }
};

数据操作仓库类 DataRepository:

import Config from 'Config';
import {guid, log, promiseHandle} from '../utils/util';

class DataRepository {

    /**
     * 添加数据
     * @param {Object} 添加的数据
     * @returns {Promise} 
     */
    static addData(data) {
        if (!data) return false;
        data['_id'] = guid();
        return DataRepository.findAllData().then(allData => {
            allData = allData || [];
            allData.unshift(data);
            wx.setStorage({key:Config.ITEMS_SAVE_KEY, data: allData});
        });
    }

    /**
     * 删除数据
     * @param {string} id 数据项idid
     * @returns {Promise}
     */
    static removeData(id) {
        return DataRepository.findAllData().then(data => {
            if (!data) return;
            for (let idx = 0, len = data.length; idx < len; idx++) {
                if (data[idx] && data[idx]['_id'] == id) {
                    data.splice(idx, 1);
                    break;
                }
            }
            wx.setStorage({key: Config.ITEMS_SAVE_KEY, data: data});
        });
    }

    /**
     * 批量删除数据
     * @param {Array} range id集合
     * @returns {Promise}
     */
    static removeRange(range) {
        if (!range) return;
        return DataRepository.findAllData().then(data => {
            if (!data) return;
            let indexs = [];
            for (let rIdx = 0, rLen = range.length; rIdx < rLen; rIdx++) {
                for (let idx = 0, len = data.length; idx < len; idx++) {
                    if (data[idx] && data[idx]['_id'] == range[rIdx]) {
                        indexs.push(idx);
                        break;
                    }
                }
            }
            
            let tmpIdx = 0;
            indexs.forEach(item => {
                data.splice(item - tmpIdx, 1);
                tmpIdx++;
            });
            wx.setStorage({key: Config.ITEMS_SAVE_KEY, data: data});
        });
        
    }

    /**
     * 更新数据
     * @param {Object} data 数据
     * @returns {Promise} 
     */
    static saveData(data) {
        if (!data || !data['_id']) return false;
        return DataRepository.findAllData().then(allData => {
            if (!allData) return false;
            for (let idx = 0, len = allData.length; i < len; i++) {
                if (allData[i] && allData[i]['_id'] == data['_id']) {
                    allData[i] = data;
                    break;
                }
            }
            wx.setStorage({key: Config.ITEMS_SAVE_KEY, data: data});
        });
        
    }

    /**
     * 获取所有数据
     * @returns {Promise} Promise实例
     */
    static findAllData() {
        return promiseHandle(wx.getStorage, {key: Config.ITEMS_SAVE_KEY}).then(res => res.data ? res.data : []).catch(ex => {
            log(ex);
        });
    }

    /**
     * 查找数据
     * @param {Function} 回调
     * @returns {Promise} Promise实例
     */
    static findBy(predicate) {
        return DataRepository.findAllData().then(data => {
            if (data) {
                data = data.filter(item => predicate(item));
            }
            return data;
        });
    }
}

module.exports = DataRepository;

数据业务类 DataService:

import DataRepository from 'DataRepository';
import {promiseHandle} from '../utils/util';

/**
 * 数据业务类
 */
class DataSerivce {

    constructor(props) {
        props = props || {};
        this.id = props['_id'] || 0;
        this.content = props['content'] || '';
        this.date = props['date'] || '';
        this.month = props['month'] || '';
        this.year = props['year'] || '';
        this.level = props['level'] || '';
        this.title = props['title'] || '';
    }

    /**
     * 保存当前对象数据
     */
    save() {
        if (this._checkProps()) {
            return DataRepository.addData({
                title: this.title,
                content: this.content,
                year: this.year,
                month: this.month,
                date: this.date,
                level: this.level,
                addDate: new Date().getTime()
            });
        }
    }

    /**
     * 获取所有事项数据
     */
    static findAll() {
        return DataRepository.findAllData()
            .then(data => data.data ? data.data : []);
    }

    /**
     * 通过id获取事项
     */
    static findById(id) {
        return DataRepository.findBy(item => item['_id'] == id)
            .then(items => (items && items.length > 0) ? items[0] : null); 
    }

    /**
     * 根据id删除事项数据
     */
    delete() {
        return DataRepository.removeData(this.id);
    }

    /**
     * 批量删除数据
     * @param {Array} ids 事项Id集合
     */
    static deleteRange(...ids) {
        return DataRepository.removeRange(ids);
    }

    /**
     * 根据日期查找所有符合条件的事项记录
     * @param {Date} date 日期对象
     * @returns {Array} 事项集合
     */
    static findByDate(date) {
        if (!date) return [];
        return DataRepository.findBy(item => {
            return item && item['date'] == date.getDate() &&
                item['month'] == date.getMonth() &&
                item['year'] == date.getFullYear();
        }).then(data => data);
    }

    _checkProps() {
        return this.title && this.level && this.date && this.year && this.month;
    }
}

module.exports = DataSerivce;

本人的对数组的操作不是很熟悉,代码看起来有点臃肿,仅供参考。

好了,进入正题,每天的事项可以用一个列表来展示,列表方在日历下边,具体结构:

<view class="common-list">
    <view class="header" wx:if="{{itemList.length > 0}}">
        <text>事项信息</text>
    </view>

    <block wx:for="{{itemList}}" wx:key="id">
        <view class="item" bindtap="listItemClickEvent" data-id="{{item._id}}" bindlongtap="listItemLongTapEvent">
        <view class="inner {{isEditMode ? 'with-check' : ''}}">
            <view class="checker" wx:if="{{isEditMode}}">
                <icon type="circle" wx:if="{{!item.checked}}" color="#FFF" size="20" />
                <icon type="success" wx:else color="#E14848" size="20" />
            </view>
            <image wx:if="{{item.level == 1}}" class="icon" src="../../images/success.png" />
            <image wx:if="{{item.level == 2}}" class="icon" src="../../images/notice.png" />
            <image wx:if="{{item.level == 3}}" class="icon" src="../../images/fav-round.png" />
            <view class="content">
            <text class="title">{{item.title}}</text>
            </view>
        </view>
        </view>
    </block> 

    <view class="header text-center" wx:if="{{!itemList || itemList.length <= 0}}">
        <text>当前日期没有事项记录</text>
    </view>
</view>

列表的数据加载全靠这个方法loadItemListData

/**
 * 加载事项列表数据
 */
function loadItemListData() {
  const {year, month, date} = this.data.data.selected;
  let _this = this;
  DataService.findByDate(new Date(Date.parse([year, month, date].join('-')))).then((data) => {
    _this.setData({ itemList: data });
  });
}

DataService.findByDate这个方法通过传入一个日期来获取指定日期的事项。成功获取数据之后,在模板中遍历数据,根据level属性来显示不同颜色的图标,让事项等级一目了然。

既然有数据列表,数据从哪来?当然是需要一个数据的添加面板。

首页的有下表有FloatAction操作工具按钮,在这里添加一个添加数据按钮,添加的事项的日期属于用户选中的日期,添加面板默认是隐藏起来的,当点击添加按钮,面板就会向上滑动出现,可以用animationAPI实现动画效果,其实本质也是CSS3动画。

<view class="updatePanel" style="top: {{updatePanelTop}}px;height:{{updatePanelTop}}px" animation="{{updatePanelAnimationData}}">
  <input placeholder="请输入事项标题" value="{{todoInputValue}}" bindchange="todoInputChangeEvent" />
  <textarea placeholder="请输入事项内容" value="{{todoTextAreaValue}}" bindblur="todoTextAreaChangeEvent"></textarea>
  <view class="level">
    <block wx:for="{{levelSelectData}}" wx:key="*this">
      <view bindtap="levelClickEvent" data-level="{{item}}" class="item {{item == 1 ? 'border-normal' : ''}} {{item == 2 ? 'border-warning' : '' }} {{item == 3 ? 'border-danger' : ''}} {{item == levelSelectedValue && item == 1 ? 'bg-normal' : ''}} {{item == levelSelectedValue && item == 2 ? 'bg-warning' : ''}} {{item == levelSelectedValue && item == 3 ? 'bg-danger' : ''}}"></view>
    </block>
  </view>
  <view class="footer">
    <view class="btn" bindtap="closeUpdatePanelEvent">取消</view>
    <view class="btn primary" bindtap="saveDataEvent">保存</view>
  </view>
</view>

在我写到这个内容之前,官方还没有textarea组件,现在新增了,完美解决遗憾。

添加面板的动画控制:

/**
 * 显示事项数据添加更新面板
 */
function showUpdatePanel() {
  let animation = wx.createAnimation({
    duration: 600
  });
  animation.translateY('-100%').step();
  this.setData({
    updatePanelAnimationData: animation.export()
  });
}

/**
 * 显示模态窗口
 * @param {String} msg 显示消息
 */
function showModal(msg) {
  this.setData({
    isModalShow: true,
    isMaskShow: true,
    modalMsg: msg
  });
}

/**
 * 关闭模态窗口
 */
function closeModal() {
  this.setData({
    isModalShow: false,
    isMaskShow: false,
    modalMsg: ''
  });
}

/**
 * 关闭事项数据添加更新面板
 */
function closeUpdatePanel() {
  let animation = wx.createAnimation({
    duration: 600
  });
  animation.translateY('100%').step();
  this.setData({
    updatePanelAnimationData: animation.export()
  });
}

主要靠translateY来控制垂直方向的移动动画,刚进入页面的时候获取屏幕的高度,把面板的高度设置与屏幕高度一致,上滑的时候100%就刚好覆盖整个屏幕。

主要的添加事项逻辑:

  // 保存事项数据
  saveDataEvent() {
    const {todoInputValue, todoTextAreaValue, levelSelectedValue} = this.data;
    const {year, month, date} = this.data.data.selected;
    console.log(todoInputValue, todoTextAreaValue);
    if (todoInputValue !== '') {
      let promise = new DataService({
        title: todoInputValue,
        content: todoTextAreaValue,
        level: levelSelectedValue,
        year: year,
        month: parseInt(month) - 1,
        date: date
      }).save();
      promise && promise.then(() => {
        //清空表单
        this.setData({
          todoTextAreaValue: '',
          levelSelectedValue: '',
          todoInputValue: ''
        });
        loadItemListData.call(this);
      })
      closeUpdatePanel.call(this);
    } else {
      showModal.call(this, '请填写事项内容');
    }
  }

获取添加面板上的数据和当前选择的日期直接用DataSerivce对象保存即可。

由于篇幅有限,剩下的数据删除和数据查看逻辑也比较简单,不再细说,本文主要是介绍小程序的ES6开发。

批量编辑

写完这篇文章的时候,小程序已经公测了好久。本人是个人用户,没有资格参与公测,热情也减半了不少,接触小程序也有一个多月了,写了三个例子,感觉还好,至少能够写出点东西来,不枉这番努力。

效果图

最终效果图

源代码仓库

https://github.com/oopsguy/WechatSmallApps/tree/master/MatterAssistant

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容