本文发表至今已有一段时间,错别字多、文笔混乱、内容过于陈旧。本人建议读者不必细究,大概浏览即可,最新的开发指南还是以官方文档为准,该博文的示例代码经过了重构,已经与官方文档同步,可能与文章中的代码有较大差异,请以 [Github](https://github.com/oopsguy/wechat-miniprogram-examples) 仓库上的代码为准。
好久没有写关于微信小程序的随笔了,其实是不知道写点什么好,之前的豆瓣图书和知乎日报已经把小程序的基础部分写的很详细了,高级部分的API有些还得不到IDE的调试支持。之前发表了知乎日报小例,有网友问我小程序有没有关于日历显示的组件,可以显示所有天数的,自己看了一遍,好像没有这个组件,所以打算那这个功能来练手,在准备期间,微信开发者工具已经升级了两三次,添加了部分功能和修改了部分功能,导致之前的例子的写法不兼容更新后的IDE,还得修改代码。随着小程序的不断更新,功能越来越完善,我想我也应该紧跟官方的升级步伐,这次的案例使用了IDE支持的ES6和新的API。
这次介绍的是一个比较简单的小应用事项助手
,其实跟事项也不沾多少边,只是作为辅助功能,只有数据的添加和删除,主要内容是日历这块内容。日历组件在web应用中应用非常广泛,插件也非常丰富,但是小程序不支持传统的插件写法,而是以数据驱动内容。
大部分的日历选择器都是差不多的,能显示当前的年份、月份和天数,可以选择某天、某月或者某年,我们可以打开操作系统中自带的日历观察一番。
日历的布局大同小异,本次案例的布局也是中规中矩,比较传统,头部显示当前年份月份,头部的左右个显示一个翻页按钮,跳转到上一月和下一月,下半部分显示当月的天数列表,由于每月的天数可能不一样,列表的格数是固定的,所以当月的天数显示使用高亮,其余的使用偏灰色彩。

预备
本次案例用到了ES6,先来了解一下案列中用到的几个写法。本人也是顺带学习顺带编写,可能代码中还存在部分老的写法。
变量
ES6中声明变量可以用let
声明变量,用const
声明常量,即不可改变的量。
1 2 3 4 5
| let version = '1.0.0'; const weekday = 7;
version = '2.0.0'; weekday = 8;
|
本习惯用大写字母和下划线的组合方式来声明全局的常量
1
| const CONFIG_COLOR = '#FAFAFA';
|
对象方法属性
小程序的每一个页面都有一个相对应的js文件,里面必不可少的就是Page
函数,Page
函数接受的参数是一个对象,我们经常使用的写法就是:
1 2 3 4 5 6 7 8 9 10 11 12
| Page({ data: { userAvatar: './images/avatar.png', userName: 'Oopsguy' }, onLoad: function() { }, onReady: function() { } });
|
现在换做ES6的写法,我们可以这样:
1 2 3 4 5 6 7 8 9 10 11 12
| Page({ data: { userAvatar: './images/avatar.png', userName: 'Oopsguy' }, onLoad() { }, onReady() { } });
|
我们可以把以前的键值写法省略掉,而且function
声明也不需要了。
类
ES6中拥有了类
这一概念,声明类的方式很简单,跟其他语言一样,差别不大:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class Animal { constructor() {
}
eat() {
}
static doSomething(param) { } }
module.exports = Animal;
|
class
关键字用于声明类,constructor
是构造函数,static
修饰静态方法。不能理解?我们看一下以前的js的简单写法:
1 2 3 4 5 6 7 8 9 10 11 12 13
| var Animal = function() {
};
Animal.prototype.eat = function() {
};
Animal.doSomething = function(param) {
};
module.exports = Animal;
|
简单的调用示例
1 2 3 4
| let animal = new Animal(); animal.eat();
Animal.doSomething('param');
|
这里只是简单的展示了一下不同点,更多的只是还是需要读者自己翻阅更多的资料来学习。
解构
其实本人对结构也不太懂怎样解释,简单的来说就是可以把一个数组的元素或者对象的属性分解出来,直接获取,哈哈,解释的比较勉强,还是看看示例吧。
1 2 3 4 5 6 7 8 9 10 11 12
| 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);
|
大家可能猜出了什么,看看输出结果:
1
| > Xiao Ming admin elem1 1
|
我们只要把需要获取的属性或者元素别名指定解构体中,js会自动获取对应的属性或者下标对应的元素。这个新特性非常有用,比如我们需要在一个Pages data对象中一个属性获取对了属性值:
1 2 3
| let year = this.data.year, month = this.data.month, day = this.data.day;
|
但是用解构的写法就很简洁:
1
| let {year, month, day} = this.data;
|
再比如引入一个文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| 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 };
|
现在引入并调用外部文件的方法:
1 2 3
| import {log} from '../../utils/util';
log('Application initialized !!');
|
import...from...
是ES6的引入模块方式,等同于小程序总的require
,但import
可以选择导入哪些子模块。
箭头函数(Arrow Function)
刚开始我也不知道js的箭头函数到底是什么东西,用了才发现,这特么就是lambda
表达式么。箭头函数简化了函数的写法,但是还是跟普通的function
有区别,主要是在作用域上。
比如我们需要请求网络:
1 2 3 4 5 6 7 8 9
| wx.request({ url: 'url', header: { 'Content-Type': 'application/json' }, success: function(res) { console.log(res.data) } });
|
用函数还是可以简化一定的代码量,哈哈哈。
1 2 3 4 5 6 7 8 9
| wx.request({ url: 'url', header: { 'Content-Type': 'application/json' }, success: (res) => { console.log(res.data) } });
|
注意到那个success
指向的回调函数了么,function
关键字没了,被醒目的=>
符号取代了。看到这里大家是不是认为以后我们写function
就用箭头函数代替呢?答案是不一定,而且要非常小心!
function
和箭头函数虽然看似一样,只是写法简化了,其实是不一样的,function
声明的函数和箭头函数的作用域
不同,这是一个不小心就变坑的地方。
1 2 3 4 5 6 7 8 9 10 11 12 13
| Page({ data: { windowHeight: 0 }, onLoad() { let _this = this; wx.getSystemInfo({ success: function(res) { _this.setData({windowHeight: res.windowHeight}); } }); } });
|
一般我们获取设备的屏幕高度差不多是这样的步骤,在页面刚加载的onLoad
方法中通过wx.getSystemInfo
API来获取设备的屏幕高度,由于success
指向的回调函数作用域跟onLoad
不一样,所以我们无法像onLoad
函数体中直接写this.setData
来设置值。我们可以定义一个临时变量指向this
,然后再回调函数中调用。
哪箭头函数的写法有什么不一样呢?
1 2 3 4 5 6 7 8 9 10 11 12 13
| Page({ data: { windowHeight: 0 }, onLoad() { let _this = this; wx.getSystemInfo({ success: (res) => { _this.setData({windowHeight: res.windowHeight}); } }); } });
|
运行之后好像感觉没什么区别呀,都能正常执行,结果也一样。确实没什么区别,你甚至这样写都可以:
1 2 3 4 5 6 7 8 9 10 11 12
| Page({ data: { windowHeight: 0 }, onLoad() { wx.getSystemInfo({ success: (res) => { this.setData({windowHeight: res.windowHeight}); } }); } });
|
咦?这样写,this
的指向的作用域不是不一样么?其实这就是要说明的,箭头函数是不绑定作用域的,不会改变当前this
的作用域,既然这样,在箭头函数中的this
就会根据作用域链来指向上一层的作用域,也就是onLoad
的作用域,所以他们得到的结果都是一样的。
其实我个人的习惯是无论用普通的函数写法还是箭头函数的写法,都习惯声明临时的_this
来指向需要的作用域,因为箭头函数没有绑定作用域,写的层次深了,感觉就会很乱,理解起来比较困难,在后面的案例中,我也会延续这个习惯。
Promise
写js经常写的东西除了数组对象就是回调函数,记不记得用jQuery
的ajax
用得特别爽,如果是多层嵌套调用的话,那些回调函数简直像盖楼梯一样壮观。现在Promise
来了,我们再也不用为这些回调地狱发愁,用Promise
来解决回调问题非常优雅,链式调用也非常的方便。
Promise
是ES6内置的类,其使用简单,简化了异步编程的繁琐层次问题,比较简单的用法是:
1 2 3 4 5 6 7
| new Promise((resolve, reject) => {
});
|
实例化一个Promise
对象,它接受一个函数参数,此函数有两个回调参数,resolve
和reject
,如果正常执行使用resolve
执行传递,如果是失败或者错误可以用reject
来执行传递,其实他们就是一个状态的转换。可以暂时理解为success
和fail
。
来看一下简单的示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 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
方法同样也是有resolve
和reject
两个回调参数,上层的Promise
执行的回调传递到then
函数中,Promise
的resolve
传递到then
的resolve
,同理reject
也一样,之后我们发现最后一个catch
函数,这是一个捕抓异常的函数,当流程发生异常,我们可以在catch
方法中获取异常并处理。
可能解释的比较羞涩,看看下面例子,发出一个网络请求,获取用户头像,再把用户头像插入DOM中,再睡眠2000ms,再打印出SUCCESS,再睡眠3000ms,在alert出ERROR,再休眠1000ms,最后打印出ERROR。这…看起来有点丧心病狂,但只是举个例子:
1 2 3 4 5 6 7 8 9 10 11 12
| $.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
能做些什么改变呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| 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
中,我们经常看见这段代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 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.getUserInfo
和wx.login
这两个接口都用共同的属性success
和fail
,我们可以封装起来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
|
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
对象。
1 2 3 4
| let date = new Date(); let day = date.getDate(); let month = date.getMonth() + 1; let year = date.getFullYear();
|
我们需要知道当前展示月份的天数。
1
| let dayCount = new Date(currentYear, currentMonth, 0).getDate();
|
得到可当月月份的天数,可以展示出所有的天数列表,但是我们一样要或者上一个页的天数和下一个页的天数,如果当前月份是1月或者12月,我们还需要额外判断上一页是上一年的12月,下一页是下一年的一月份。
我们可能需要获取足够多的日期信息来展示(不仅仅是当前月份,还有上一月或者上一年和下一月或者下一年)
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 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});
1 2
| 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});
1 2 3 4 5 6 7
| let allData = wx.getStorage({ key: Config.ITEMS_SAVE_KEY success: allData => { let obj1 = allData[0]; console.log(obj1.title); } });
|
编码
建立工程的步骤就不讲了,直接进入主题,应用只有两个页面,一个首页,一个详情页,结构清晰,功能简单。
日历
先来看看首页,日历的wxml结构;
结构分为上中下三部分,header
为头部,用于展示翻页按钮和当前日期信息。在.week.row
和.body.row
元素中展示星期和天数列表,这里的布局采用了比较low的百分比分栏,总共有7栏,100/7哈哈,想高逼格的可以采用css的分栏布局和flex布局。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| <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-year
和data-mont
属性,这两个属性是临时存值,当点击按钮翻页的时候,我们需要知道当前的年份和日期,以便可以更加方便地翻到上一页或者下一页。
changeDateEvent
事件比较简单:
1 2 3 4
| 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
对象中。
1 2 3 4 5 6 7 8 9 10 11 12 13
| 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
函数,这个函数的代码比较多,虽然堆砌大量在一个函数中是个不好的习惯,不过里面声明变量和赋值比较多,业务代码比较少:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131
|
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;
let tDay = showMonthDateCount + beforeDayCount + afterDayCount; if (tDay <= 35) afterDayCount += (42 - tDay);
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;
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
上。
1 2 3 4 5
| <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
事件,当选择的日期发生变化时,就会触发此事件。
1 2 3 4
| datePickerChangeEvent(e) { const date = new Date(Date.parse(e.detail.value)); changeDate.call(this, new Date(date.getFullYear(), date.getMonth(), 1)); }
|

事项存储
此应用还有小小的事项功能,可以添加事项条目,事项包括了标题、内容和等级,说白了其实就是一个功能不全的TODO应用…
既然涉及到存储,肯定需要操作缓存的方法,自己也是刚搞前端那不久,不太明白javascript的封装约定,借鉴之前在java所用的模式,分为了两个文件,一个是仓库类(数据的CURD操作),另一个是业务类(附带处理部分业务),缓存的配置放置于Config
文件中,类中用到了异步的缓存操作API,所以使用Promise
模式封装。
首先是把Promise封装成通用的方法,顺便封装部分经常用到的函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
|
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); }); }
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) }
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
配置文件:
1 2 3 4 5 6 7 8 9
| module.exports = { ITEMS_SAVE_KEY: 'todo_item_save_Key', LEVEL: { normal: 1, warning: 2, danger: 3 } };
|
数据操作仓库类 DataRepository:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113
| import Config from 'Config'; import {guid, log, promiseHandle} from '../utils/util';
class DataRepository {
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}); }); }
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}); }); }
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}); }); }
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}); }); }
static findAllData() { return promiseHandle(wx.getStorage, {key: Config.ITEMS_SAVE_KEY}).then(res => res.data ? res.data : []).catch(ex => { log(ex); }); }
static findBy(predicate) { return DataRepository.findAllData().then(data => { if (data) { data = data.filter(item => predicate(item)); } return data; }); } }
module.exports = DataRepository;
|
数据业务类 DataService:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
| 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 : []); }
static findById(id) { return DataRepository.findBy(item => item['_id'] == id) .then(items => (items && items.length > 0) ? items[0] : null); }
delete() { return DataRepository.removeData(this.id); }
static deleteRange(...ids) { return DataRepository.removeRange(ids); }
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;
|
本人的对数组的操作不是很熟悉,代码看起来有点臃肿,仅供参考。
好了,进入正题,每天的事项可以用一个列表来展示,列表方在日历下边,具体结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| <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
:
1 2 3 4 5 6 7 8 9 10
|
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
操作工具按钮,在这里添加一个添加数据按钮,添加的事项的日期属于用户选中的日期,添加面板默认是隐藏起来的,当点击添加按钮,面板就会向上滑动出现,可以用animation
API实现动画效果,其实本质也是CSS3动画。
1 2 3 4 5 6 7 8 9 10 11 12 13
| <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
组件,现在新增了,完美解决遗憾。
添加面板的动画控制:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
|
function showUpdatePanel() { let animation = wx.createAnimation({ duration: 600 }); animation.translateY('-100%').step(); this.setData({ updatePanelAnimationData: animation.export() }); }
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%
就刚好覆盖整个屏幕。
主要的添加事项逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| 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