由于官方IDE更新到了0.11.112301版本,移除了对Promise的支持,造成事项助手不能正常运行,解决此问题,在项目中引入第三方兼容库Bluebird支持Promise,代码已经整合到项目代码中。
好久没有写关于微信小程序的随笔了,其实是不知道写点什么好,之前的豆瓣图书和知乎日报已经把小程序的基础部分写的很详细了,高级部分的API有些还得不到IDE的调试支持。之前发表了知乎日报小例,有网友问我小程序有没有关于日历显示的组件,可以显示所有天数的,自己看了一遍,好像没有这个组件,所以打算那这个功能来练手,在准备期间,微信开发者工具已经升级了两三次,添加了部分功能和修改了部分功能,导致之前的例子的写法不兼容更新后的IDE,还得修改代码。随着小程序的不断更新,功能越来越完善,我想我也应该紧跟官方的升级步伐,这次的案例使用了IDE支持的ES6和新的API。
这次介绍的是一个比较简单的小应用事项助手,其实跟事项也不沾多少边,只是作为辅助功能,只有数据的添加和删除,主要内容是日历这块内容。日历组件在web应用中应用非常广泛,插件也非常丰富,但是小程序不支持传统的插件写法,而是以数据驱动内容。
大部分的日历选择器都是差不多的,能显示当前的年份、月份和天数,可以选择某天、某月或者某年,我们可以打开操作系统中自带的日历观察一番。
日历的布局大同小异,本次案例的布局也是中规中矩,比较传统,头部显示当前年份月份,头部的左右个显示一个翻页按钮,跳转到上一月和下一月,下半部分显示当月的天数列表,由于每月的天数可能不一样,列表的格数是固定的,所以当月的天数显示使用高亮,其余的使用偏灰色彩。
预备
本次案例用到了ES6,先来了解一下案列中用到的几个写法。本人也是顺带学习顺带编写,可能代码中还存在部分老的写法。
变量
ES6中声明变量可以用let声明变量,用const声明常量,即不可改变的量。
本习惯用大写字母和下划线的组合方式来声明全局的常量
对象方法属性
小程序的每一个页面都有一个相对应的js文件,里面必不可少的就是Page函数,Page函数接受的参数是一个对象,我们经常使用的写法就是:
现在换做ES6的写法,我们可以这样:
我们可以把以前的键值写法省略掉,而且function声明也不需要了。
类
ES6中拥有了类这一概念,声明类的方式很简单,跟其他语言一样,差别不大:
eat() {
}
static doSomething(param) {
//...
}
}
module.exports = Animal;
class关键字用于声明类,constructor是构造函数,static修饰静态方法。不能理解?我们看一下以前的js的简单写法:
Animal.prototype.eat = function() {
};
Animal.doSomething = function(param) {
};
module.exports = Animal;
简单的调用示例
这里只是简单的展示了一下不同点,更多的只是还是需要读者自己翻阅更多的资料来学习。
解构
其实本人对结构也不太懂怎样解释,简单的来说就是可以把一个数组的元素或者对象的属性分解出来,直接获取,哈哈,解释的比较勉强,还是看看示例吧。
let {fullName,role} = obj;
let [elem1,elem2] = arr;
console.log(fullName,role,elem1,elem2);
大家可能猜出了什么,看看输出结果:
> Xiao Ming admin elem1 1
我们只要把需要获取的属性或者元素别名指定解构体中,js会自动获取对应的属性或者下标对应的元素。这个新特性非常有用,比如我们需要在一个Pages data对象中一个属性获取对了属性值:
但是用解构的写法就很简洁:
再比如引入一个文件:
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...from...是ES6的引入模块方式,等同于小程序总的require,但import可以选择导入哪些子模块。
箭头函数(Arrow Function)
刚开始我也不知道js的箭头函数到底是什么东西,用了才发现,这特么就是lambda表达式么。箭头函数简化了函数的写法,但是还是跟普通的function有区别,主要是在作用域上。
比如我们需要请求网络:
注意到那个success指向的回调函数了么,function关键字没了,被醒目的=>符号取代了。看到这里大家是不是认为以后我们写function就用箭头函数代替呢?答案是不一定,而且要非常小心!
function和箭头函数虽然看似一样,只是写法简化了,其实是不一样的,function声明的函数和箭头函数的作用域不同,这是一个不小心就变坑的地方。
一般我们获取设备的屏幕高度差不多是这样的步骤,在页面刚加载的onLoad方法中通过wx.getSystemInfoAPI来获取设备的屏幕高度,由于success指向的回调函数作用域跟onLoad不一样,所以我们无法像onLoad函数体中直接写this.setData来设置值。我们可以定义一个临时变量指向this,然后再回调函数中调用。
哪箭头函数的写法有什么不一样呢?
运行之后好像感觉没什么区别呀,都能正常执行,结果也一样。确实没什么区别,你甚至这样写都可以:
咦?这样写,this的指向的作用域不是不一样么?其实这就是要说明的,箭头函数是不绑定作用域的,不会改变当前this的作用域,既然这样,在箭头函数中的this就会根据作用域链来指向上一层的作用域,也就是onLoad的作用域,所以他们得到的结果都是一样的。
其实我个人的习惯是无论用普通的函数写法还是箭头函数的写法,都习惯声明临时的_this来指向需要的作用域,因为箭头函数没有绑定作用域,写的层次深了,感觉就会很乱,理解起来比较困难,在后面的案例中,我也会延续这个习惯。
Promise
写js经常写的东西除了数组对象就是回调函数,记不记得用jQuery的ajax用得特别爽,如果是多层嵌套调用的话,那些回调函数简直像盖楼梯一样壮观。现在Promise来了,我们再也不用为这些回调地狱发愁,用Promise来解决回调问题非常优雅,链式调用也非常的方便。
Promise是ES6内置的类,其使用简单,简化了异步编程的繁琐层次问题,比较简单的用法是:
//error
//reject();
});
实例化一个Promise对象,它接受一个函数参数,此函数有两个回调参数,resolve和reject,如果正常执行使用resolve执行传递,如果是失败或者错误可以用reject来执行传递,其实他们就是一个状态的转换。可以暂时理解为success和fail。
来看一下简单的示例:
或许我们已经看出些什么了,实例化出一个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。这...看起来有点丧心病狂,但只是举个例子:
一共有四个回调函数,也不算多,如果有十几个回调呢?直至是噩梦呀。一层一层的嵌套,看起来已经眼花了。那么Promise能做些什么改变呢?
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中,我们经常看见这段代码:
这是个方法是获取当前用户的信息,首先先检查globalData对象中有没有缓存有userInfo对象(存储用户的信息),如果有就返回给用户传进来的回掉函数,否则就请求接口获取用用户信息,获取用户信息之前,微信小程序要求先调用wx.login认证,才能调用wx.getUserInfo接口。
看的出代码的层次已经有点深了,我们可以用Promise来简化一下(-_-|| 说的有点夸张,实际上这点嵌套还是可以的)
wx.getUserInfo和wx.login这两个接口都用共同的属性success和fail,我们可以封装起来:
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月或者12月,我们还需要额外判断上一页是上一年的12月,下一页是下一年的一月份。
我们可能需要获取足够多的日期信息来展示(不仅仅是当前月份,还有上一月或者上一年和下一月或者下一年)
能显示日期之后,当然还没有完,我们需要一个选择日期的功能,即用户可以点击指定那一天,也可以选择哪一年或者哪一个月,选择年份和月份我们可以用Picker组件来展示,选择具体的哪天这就需要在日期列表上的每一天都要绑定一个点击事件来响应用户的点击动作,用户选择具体的日期后,可能会随意翻页,所以必须要保存好当前选择的日期。
存储
示例程序中用到了数据存储,关系到小程序中的数据缓存API,官方提供的API比较多,我只是用了两个异步的数据缓存API。
wx.setStorage({key: KEY,data: DATA});
说明 |
---|
wx.getStorage({key: KEY});
说明 |
---|
编码
建立工程的步骤就不讲了,直接进入主题,应用只有两个页面,一个首页,一个详情页,结构清晰,功能简单。
日历
先来看看首页,日历的wxml结构;
结构分为上中下三部分,header为头部,用于展示翻页按钮和当前日期信息。在.week.row和.body.row元素中展示星期和天数列表,这里的布局采用了比较low的百分比分栏,总共有7栏,100/7哈哈,想高逼格的可以采用css的分栏布局和flex布局。
.btn.month-pre和.btn.month-next翻页按钮,都绑定了changeDateEvent的tap事件,各自都用自己的data-year和data-mont属性,这两个属性是临时存值,当点击按钮翻页的时候,我们需要知道当前的年份和日期,以便可以更加方便地翻到上一页或者下一页。
changeDateEvent事件比较简单:
点击翻页按钮,根据回调进来的event对象来获取元素上的data-*属性,然后调用changeDate这个方法来更新日历数据,这个方法接收一个Date对象,代表要翻页后的日期。
暂且不关心changeDate具体干了些什么,看看.body.row里有一个循环,每一个元素都绑定了dateClickEvent事件,而且每一个元素都附带了自己所属的年份、月份和天数信息,这些信息是非常有用的,当点击了具体的某一天,可以通过获取元素上的data-*信息来知道我们具体选择的日期。除此之外,元素上的class属性包裹了一长串的判断表达式。这些语句最终的目的是为了给元素动态变更,.old代表当前的日期不是本月日期,因为每一版的日期除了当前月份的日期还可能包含上一月和下一月的部分日期,我们给予它灰色的样式显示,.current代表今天的日期,用实心填充颜色的背景样式修饰,.active即代表着当前选中的日期。
dateClickEvent事件其实也是调用了changeDate事件,本质上也是也是改变日期,额外的工作就是保存选中的日期到selected对象中。
data['selected']['month'] = month;
data['selected']['date'] = date;
this.setData({ data: data });
changeDate.call(this,date));
}
来看看重中之重的changeDate函数,这个函数的代码比较多,虽然堆砌大量在一个函数中是个不好的习惯,不过里面声明变量和赋值比较多,业务代码比较少:
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(),//当前页下一页的月份
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,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上。
mode=date指定pciker是日期选择风格,fields=month则显示组件显示日期的精度显示当月份即可,组件初始化的值为pickerDateValue,绑定了datePickerChangeEvent事件,当选择的日期发生变化时,就会触发此事件。
事项存储
此应用还有小小的事项功能,可以添加事项条目,事项包括了标题、内容和等级,说白了其实就是一个功能不全的TODO应用...
既然涉及到存储,肯定需要操作缓存的方法,自己也是刚搞前端那不久,不太明白javascript的封装约定,借鉴之前在java所用的模式,分为了两个文件,一个是仓库类(数据的CURD操作),另一个是业务类(附带处理部分业务),缓存的配置放置于Config文件中,类中用到了异步的缓存操作API,所以使用Promise模式封装。
首先是把Promise封装成通用的方法,顺便封装部分经常用到的函数:
- 记录日志
- @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,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配置文件:
数据操作仓库类 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:
- 数据业务类
*/
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;
本人的对数组的操作不是很熟悉,代码看起来有点臃肿,仅供参考。
好了,进入正题,每天的事项可以用一个列表来展示,列表方在日历下边,具体结构:
<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}}">
<view class="header text-center" wx:if="{{!itemList || itemList.length <= 0}}">
列表的数据加载全靠这个方法loadItemListData:
DataService.findByDate这个方法通过传入一个日期来获取指定日期的事项。成功获取数据之后,在模板中遍历数据,根据level属性来显示不同颜色的图标,让事项等级一目了然。
既然有数据列表,数据从哪来?当然是需要一个数据的添加面板。
首页的有下表有FloatAction操作工具按钮,在这里添加一个添加数据按钮,添加的事项的日期属于用户选中的日期,添加面板默认是隐藏起来的,当点击添加按钮,面板就会向上滑动出现,可以用animationAPI实现动画效果,其实本质也是CSS3动画。