之前项目运用到了这个时间控件,期间bug还是一些。抽个时间,简单地看一下。
先看一下datetimepicker.js的结构
var DateTimePicker = function(element, options){}//构造器 var dateToDate = function(dt){} DateTimePicker.prototype ={}//构造器的原型 $.fn.datetimepicker = function ( option, val ){}//jQuery原型对象上的方法 $.fn.datetimepicker.defaults ={}//默认配置参数 $.fn.datetimepicker.Constructor = DateTimePicker; //以下是一些默认信息和配置内容 var dpgId = 0; var dates = $.fn.datetimepicker.dates = {} var dateFormatComponents = {} function escapeRegExp(str){} ....//自定义方法 var DPGlobal ={} DPGlobal.template ='' //日期控件页面 var TPGlobal = {} TPGlobal.getTemplate = function(is12Hours, showSeconds) {}//时分秒控件页面模版
来看一下HTML的例子
<!DOCTYPE HTML> <html> <head> <link href="http://netdna.bootstrapcdn.com/twitter-bootstrap/2.2.2/css/bootstrap-combined.min.css" rel="stylesheet"> <link rel="stylesheet" type="text/css" media="screen" href="datepicker.css"> </head> <body> <div id="datetimepicker" class="input-append date"> <input type="text"/> <span class="add-on"> <i data-time-icon="icon-time" data-date-icon="icon-calendar"></i> </span> </div> <script type="text/javascript" src="http://cdnjs.cloudflare.com/ajax/libs/jquery/1.8.3/jquery.min.js"> </script> <script type="text/javascript" src="bootstrap.js"> </script> <script type="text/javascript" src="bootstrap-dateTimePicker.js"> </script> <script type="text/javascript"> $('#datetimepicker').datetimepicker({ format: 'MM/dd/yyyy hh:mm', language: 'en', pickDate: true, pickTime: true, hourStep: 1, minuteStep: 15, secondStep: 30, inputMask: true }); </script> </body> <html>
可以看到页面上调用了datetimepicker方法,这个插件在页面使用时,需要手动初始化。下面简单地列出插件运作流程。
在我们看init方法之前,先看一下传入default信息
我们再来看一下init方法
init: function(element, options) { var icon; if (!(options.pickTime || options.pickDate)) throw new Error('Must choose at least one picker'); this.options = options; this.$element = $(element); this.language = options.language in dates ? options.language : 'en'; this.pickDate = options.pickDate;//true this.pickTime = options.pickTime;//true this.isInput = this.$element.is('input');//判断是否为input控件 this.component = false; if (this.$element.find('.input-append') || this.$element.find('.input-prepend')) this.component = this.$element.find('.add-on');//获得触发时间控件的按钮 this.format = options.format;//控件显示日期的格式 if (!this.format) { if (this.isInput) this.format = this.$element.data('format'); else this.format = this.$element.find('input').data('format');//寻找input控件data属性定义的时间格式 if (!this.format) this.format = 'MM/dd/yyyy';//如果都没有定义,采用系统默认格式MM/dd/yyyy } this._compileFormat();//根据日期显示格式,封装正则表达式,拼接正则表达式 if (this.component) { icon = this.component.find('i');//找到控件上的时间小标签(图标) } if (this.pickTime) { if (icon && icon.length) this.timeIcon = icon.data('time-icon'); if (!this.timeIcon) this.timeIcon = 'icon-time';//如果页面上没有写data-time-icon, icon.addClass(this.timeIcon);//这里系统默认帮你填上,类名为icon-time } if (this.pickDate) { if (icon && icon.length) this.dateIcon = icon.data('date-icon'); if (!this.dateIcon) this.dateIcon = 'icon-calendar';//如果页面上没有写data-date-icon属性。 icon.removeClass(this.timeIcon);//系统将统一添加icon-calendar类,删除icon-time类 icon.addClass(this.dateIcon);//类名为icon-calendar } //拼接完控件页面插入body中,返回拼接的jQuery的dom对象 this.widget = $(getTemplate(this.timeIcon, options.pickDate, options.pickTime, options.pick12HourFormat, options.pickSeconds, options.collapse)).appendTo('body'); this.minViewMode = options.minViewMode||this.$element.data('date-minviewmode')||0; if (typeof this.minViewMode === 'string') { switch (this.minViewMode) { case 'months': this.minViewMode = 1; break; case 'years': this.minViewMode = 2; break; default: this.minViewMode = 0; break; } } this.viewMode = options.viewMode||this.$element.data('date-viewmode')||0; if (typeof this.viewMode === 'string') { switch (this.viewMode) { case 'months': this.viewMode = 1; break; case 'years': this.viewMode = 2; break; default: this.viewMode = 0; break; } } this.startViewMode = this.viewMode; this.weekStart = options.weekStart||this.$element.data('date-weekstart')||0; this.weekEnd = this.weekStart === 0 ? 6 : this.weekStart - 1; this.setStartDate(options.startDate || this.$element.data('date-startdate'));//设置StartDate this.setEndDate(options.endDate || this.$element.data('date-enddate'));// 设置endDate this.fillDow();//生成星期标题 this.fillMonths();//生成月份标题 this.fillHours();//生成小时面板 this.fillMinutes();//生成分钟面板 this.fillSeconds();//生成秒钟面板 this.update();//填写面板 this.showMode();//显示默认面板 this._attachDatePickerEvents();//绑定触发事件 }
再看一下_compileFormat()方法
_compileFormat: function () { var match, component, components = [], mask = [], str = this.format, propertiesByIndex = {}, i = 0, pos = 0; while (match = formatComponent.exec(str)) { component = match[0]; if (component in dateFormatComponents) { i++; propertiesByIndex[i] = dateFormatComponents[component].property;//取property属性 components.push('\s*' + dateFormatComponents[component].getPattern( //根据component取到getPattern中返回字符串正则,加进行拼接 this) + '\s*'); mask.push({ //重新装箱 pattern: new RegExp(dateFormatComponents[component].getPattern( this)), property: dateFormatComponents[component].property, start: pos,//日期格式长度从第0位开始 end: pos += component.length//结束位置,是该结构的长度 }); } else { components.push(escapeRegExp(component)); mask.push({ pattern: new RegExp(escapeRegExp(component)), character: component, start: pos, end: ++pos//特殊字符,一般都是一位 }); } str = str.slice(component.length);//删掉已经匹配处理过的字符串,然后继续循环,这个匹配完这个字符串 } this._mask = mask;//将封装过的信息传给实例 this._maskPos = 0; this._formatPattern = new RegExp( '^\s*' + components.join('') + '\s*$');//最后将加工过的正则再拼成一个大的正则,传给实例 this._propertiesByIndex = propertiesByIndex; }
以上的内容还是比较简单,我们需要配合默认参数来看。
dateFormatComponents:
最后拼接成一个大的正则表达式。正则比较长,但比较简单
再看一下getTemplate方法
//获取时间控件页面(这里将时间控件页面分成两块,1是日期页面,2是时分秒页面) function getTemplate(timeIcon, pickDate, pickTime, is12Hours, showSeconds, collapse) { //这里是可以选择的是否使用date或time,通过配置pickDate和pickTime来控制 if (pickDate && pickTime) { return (//拼接时间控件的html '<div class="bootstrap-datetimepicker-widget dropdown-menu">' + '<ul>' + '<li' + (collapse ? ' class="collapse in"' : '') + '>' + '<div class="datepicker">' + DPGlobal.template + //DPGlobal.template是一个日期页面 '</div>' + '</li>' + '<li class="picker-switch accordion-toggle"><a><i class="' + timeIcon + '"></i></a></li>' + '<li' + (collapse ? ' class="collapse"' : '') + '>' + '<div class="timepicker">' + TPGlobal.getTemplate(is12Hours, showSeconds) + '</div>' + '</li>' + '</ul>' + '</div>' ); } else if (pickTime) { return ( '<div class="bootstrap-datetimepicker-widget dropdown-menu">' + '<div class="timepicker">' + TPGlobal.getTemplate(is12Hours, showSeconds) + '</div>' + '</div>' ); } else { return ( '<div class="bootstrap-datetimepicker-widget dropdown-menu">' + '<div class="datepicker">' + DPGlobal.template + '</div>' + '</div>' ); } }
以上的代码是生成插件模版,以上分为两种控件页面模版(日期控件页面和时分秒控件页面模版)
//日期控件页面 DPGlobal.template = '<div class="datepicker-days">' + '<table class="table-condensed">' + DPGlobal.headTemplate + '<tbody></tbody>' + '</table>' + '</div>' + '<div class="datepicker-months">' + '<table class="table-condensed">' + DPGlobal.headTemplate + DPGlobal.contTemplate+ '</table>'+ '</div>'+ '<div class="datepicker-years">'+ '<table class="table-condensed">'+ DPGlobal.headTemplate+ DPGlobal.contTemplate+ '</table>'+ '</div>';
上图就是日期控件,注意,这里只是生成标题,类似Su,Mo,Tu,We,Th,Fr,Sa。具体的下面的日期需要靠别的方法往里面填写,init方法里还有几个方法是创建方法
fillDow方法
//生成星期标题 fillDow: function() { var dowCnt = this.weekStart; var html = $('<tr>'); while (dowCnt < this.weekStart + 7) { html.append('<th class="dow">' + dates[this.language].daysMin[(dowCnt++) % 7] + '</th>'); }//生成 this.widget.find('.datepicker-days thead').append(html);//找到thead插入 }
注意这里while的循环的,可以看到循环7次。以上的代码,可以生成如下的插件内容:
fillMonths方法
//生成月份标题 fillMonths: function() { var html = ''; var i = 0; while (i < 12) { html += '<span class="month">' + dates[this.language].monthsShort[i++] + '</span>'; } this.widget.find('.datepicker-months td').append(html); }
生成的方式跟星期标题一致,循环了12次,再看一下生成的插件内容:
fillHours方法
//生成小时选择标题 fillHours: function() { var table = this.widget.find( '.timepicker .timepicker-hours table'); table.parent().hide();//将小时选择面板隐藏 var html = ''; if (this.options.pick12HourFormat) { var current = 1; for (var i = 0; i < 3; i += 1) { html += '<tr>'; for (var j = 0; j < 4; j += 1) { var c = current.toString(); html += '<td class="hour">' + padLeft(c, 2, '0') + '</td>'; current++; } html += '</tr>' } } else { var current = 0; for (var i = 0; i < 6; i += 1) {//循环24次,完成小时面板上小时显示 html += '<tr>'; for (var j = 0; j < 4; j += 1) { var c = current.toString();//转成字符串 html += '<td class="hour">' + padLeft(c, 2, '0') + '</td>'; current++;//js是弱类型语言 } html += '</tr>' } } table.html(html); }
这里有一个padLeft方法,我们来看一下:
function padLeft(s, l, c) {//如何长度是1为,就采用0与数字组合,如果数字长度为两位,则直接返回这个数字 if (l < s.length) return s; else return Array(l - s.length + 1).join(c || ' ') + s; }
看一下这个fillHour方法,两个for循环一共运行了24次,大家可以想到。一天是24个小时,将类似1,2的小时点数通过padLeft方法转成01,02..。然后往table中插入,给出以上代码生成的插件内容
fillMinutes方法
//生成分钟选择标题 fillMinutes: function() { var table = this.widget.find( '.timepicker .timepicker-minutes table'); table.parent().hide(); var html = ''; var current = 0; for (var i = 0; i < 5; i++) {//循环20次,每次添加3,完成60的遍历 html += '<tr>'; for (var j = 0; j < 4; j += 1) { var c = current.toString(); html += '<td class="minute">' + padLeft(c, 2, '0') + '</td>'; current += 3; } html += '</tr>'; } table.html(html); }
基本和fillHour方法一致。遍历20次,每次添加3,完成60的遍历,正好对应1个小时是60分钟。生成的插件内容。
fillSecond方法
//生成秒钟选择标题 fillSeconds: function() { var table = this.widget.find( '.timepicker .timepicker-seconds table'); table.parent().hide(); var html = ''; var current = 0;//给分钟类似 for (var i = 0; i < 5; i++) { html += '<tr>'; for (var j = 0; j < 4; j += 1) { var c = current.toString(); html += '<td class="second">' + padLeft(c, 2, '0') + '</td>'; current += 3; } html += '</tr>'; } table.html(html); }
生成的插件内容为:
以上的内容跟分钟内容是一致的。但它们是两个页面。
再来看一下update方法
//填写面板 update: function(newDate){ var dateStr = newDate; if (!dateStr) { if (this.isInput) {//是否是input控件 dateStr = this.$element.val(); } else { dateStr = this.$element.find('input').val();//取到input里的内容信息 } if (dateStr) { this._date = this.parseDate(dateStr); } if (!this._date) { var tmp = new Date() this._date = UTCDate(tmp.getFullYear(), tmp.getMonth(), tmp.getDate(), tmp.getHours(), tmp.getMinutes(), tmp.getSeconds(), tmp.getMilliseconds()) } } this.viewDate = UTCDate(this._date.getUTCFullYear(), this._date.getUTCMonth(), 1, 0, 0, 0, 0); this.fillDate();//填写月份面板和年份面板 this.fillTime();//填写小时面板 }
这里我们可以看到this._date是input(插件中)里的内容,如果我们第一次使用插件,肯定没有任何时间信息,那这个时候this._date则等于当前时间(new Date()),这里我们再看一下UTCDate()方法
function UTCDate() { return new Date(Date.UTC.apply(Date, arguments)); }
Date.UTC方法是可根据世界时返回 1970 年 1 月 1 日 到指定日期的毫秒数。在将这个毫秒数传入Date中,再转成UTC格式的时间,这个方法的作用是通过传入年,月,日,小时,分钟,秒钟,最后转成UTC格式的日期。
fillDate方法,这个方法我们分成两个部分来看。
先看第一部分:
//生成月份面板和年份面板 fillDate: function() { var year = this.viewDate.getUTCFullYear();//获取当前日期的年份 var month = this.viewDate.getUTCMonth();//获取当前日期所在的月份 var currentDate = UTCDate( this._date.getUTCFullYear(), this._date.getUTCMonth(), this._date.getUTCDate(), 0, 0, 0, 0 );//获取当前日期 var startYear = typeof this.startDate === 'object' ? this.startDate.getUTCFullYear() : -Infinity; var startMonth = typeof this.startDate === 'object' ? this.startDate.getUTCMonth() : -1; var endYear = typeof this.endDate === 'object' ? this.endDate.getUTCFullYear() : Infinity; var endMonth = typeof this.endDate === 'object' ? this.endDate.getUTCMonth() : 12; this.widget.find('.datepicker-days').find('.disabled').removeClass('disabled'); this.widget.find('.datepicker-months').find('.disabled').removeClass('disabled'); this.widget.find('.datepicker-years').find('.disabled').removeClass('disabled'); this.widget.find('.datepicker-days th:eq(1)').text( dates[this.language].months[month] + ' ' + year);//根据input控件里所填写的信息生成日期标题 var prevMonth = UTCDate(year, month-1, 28, 0, 0, 0, 0);//获取上一个月的内容 var day = DPGlobal.getDaysInMonth( prevMonth.getUTCFullYear(), prevMonth.getUTCMonth()); prevMonth.setUTCDate(day);//获得的前一个月的天数,将这个天数赋给这个prevMonth prevMonth.setUTCDate(day - (prevMonth.getUTCDay() - this.weekStart + 7) % 7); if ((year == startYear && month <= startMonth) || year < startYear) { this.widget.find('.datepicker-days th:eq(0)').addClass('disabled'); } if ((year == endYear && month >= endMonth) || year > endYear) { this.widget.find('.datepicker-days th:eq(2)').addClass('disabled'); } var nextMonth = new Date(prevMonth.valueOf()); nextMonth.setUTCDate(nextMonth.getUTCDate() + 42);//设置下一个月 nextMonth = nextMonth.valueOf(); var html = []; var row; var clsName; while (prevMonth.valueOf() < nextMonth) { if (prevMonth.getUTCDay() === this.weekStart) { row = $('<tr>'); html.push(row); } clsName = ''; if (prevMonth.getUTCFullYear() < year || (prevMonth.getUTCFullYear() == year && prevMonth.getUTCMonth() < month)) {//如果不是当前月的日期时,是上一个月则需要加上old类,灰化效果 clsName += ' old'; } else if (prevMonth.getUTCFullYear() > year || (prevMonth.getUTCFullYear() == year && prevMonth.getUTCMonth() > month)) {//如果是下一个月的则需要加上new类,也是灰化效果 clsName += ' new'; } if (prevMonth.valueOf() === currentDate.valueOf()) {//将当前被选中的日期加上active类,高亮效果 clsName += ' active'; } if ((prevMonth.valueOf() + 86400000) <= this.startDate) { clsName += ' disabled'; } if (prevMonth.valueOf() > this.endDate) { clsName += ' disabled'; } row.append('<td class="day' + clsName + '">' + prevMonth.getUTCDate() + '</td>');//这里循环生成日期内容 prevMonth.setUTCDate(prevMonth.getUTCDate() + 1);//依次加1 } this.widget.find('.datepicker-days tbody').empty().append(html);//先清空后再添加上信息
this.widget.find('.datepicker-days th:eq(1)').text(dates[this.language].months[month] + ' ' + year);这里生成的插件内容
其中生成October,是通过dates[this.language].month寻找对应的英语内容,下面是语言包内容
var dates = $.fn.datetimepicker.dates = { //语言包,可以自己定义 en: { days: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], daysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], daysMin: ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"], months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], monthsShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] } }
while循环中使用html去保存生成的dom内容,使用prevMonth.getUTCDate()+1进行迭代。依次生成日期内容,插件这里提供了一个非常不错的思路:通过UTCDate生成标准的UTC日期,然后再通过DPGlobal.getDaysInMonth()获取这个月的天数,然后再去遍历天数显示出这个月的日期,至于页面上效果,通过比较当前月,设置出较之当前月的前一个月和后一个月灰化效果。那我们再看一下DPGlobal.getDaysInMonth()方法
isLeapYear: function (year) {//判断是否是闰年 return (((year % 4 === 0) && (year % 100 !== 0)) || (year % 400 === 0)) }, getDaysInMonth: function (year, month) {//获取某月的天数 //简单地将十二个月的天数写在一个数组里,通过month标签获取该月数的天数 return [31, (DPGlobal.isLeapYear(year) ? 29 : 28), 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month] }
先比较是否是闰年,将一年的月份放入一个数组中,通过我们的month下标去获取月数的天数。
ok,以下我们来看一下生成效果:
至于高亮部分,是通过以下代码实现
if (prevMonth.valueOf() === currentDate.valueOf()) {//将当前被选中的日期加上active类,高亮效果 clsName += ' active'; }
row.append('<td class="day' + clsName + '">' + prevMonth.getUTCDate() + '</td>')
再来看另一部分
html = '';//清空html year = parseInt(year/10, 10) * 10; var yearCont = this.widget.find('.datepicker-years').find( 'th:eq(1)').text(year + '-' + (year + 9)).end().find('td'); this.widget.find('.datepicker-years').find('th').removeClass('disabled'); if (startYear > year) { this.widget.find('.datepicker-years').find('th:eq(0)').addClass('disabled'); } if (endYear < year+9) { this.widget.find('.datepicker-years').find('th:eq(2)').addClass('disabled'); } year -= 1; for (var i = -1; i < 11; i++) { html += '<span class="year' + (i === -1 || i === 10 ? ' old' : '') + (currentYear === year ? ' active' : '') + ((year < startYear || year > endYear) ? ' disabled' : '') + '">' + year + '</span>'; year += 1; } //每一个年份面板上,将第一个和最后一个灰化,这里对于disabled不可用的情况,我们需要自己设定startYear和endYear的值才可以 yearCont.html(html);//填入内容
这里使用html保存dom内容,最后通过yearCont.html(html)插入html文档中,看一下生成内容
高亮效果是通过如下代码实现:
html += '<span class="year' + (i === -1 || i === 10 ? ' old' : '') + (currentYear === year ? ' active' : '') + ((year < startYear || year > endYear) ? ' disabled' : '') + '">' + year + '</span>';
继续看fillTime方法
//生成小时面板 fillTime: function() { if (!this._date) return; var timeComponents = this.widget.find('.timepicker span[data-time-component]'); var table = timeComponents.closest('table'); var is12HourFormat = this.options.pick12HourFormat; var hour = this._date.getUTCHours();//获取input控件中的小时数值 var period = 'AM'; if (is12HourFormat) { //判断AM和PM if (hour >= 12) period = 'PM'; if (hour === 0) hour = 12; else if (hour != 12) hour = hour % 12; this.widget.find( '.timepicker [data-action=togglePeriod]').text(period); } hour = padLeft(hour.toString(), 2, '0'); var minute = padLeft(this._date.getUTCMinutes().toString(), 2, '0'); var second = padLeft(this._date.getUTCSeconds().toString(), 2, '0'); //填入相应的小时,分钟和秒钟 timeComponents.filter('[data-time-component=hours]').text(hour); timeComponents.filter('[data-time-component=minutes]').text(minute); timeComponents.filter('[data-time-component=seconds]').text(second); }
这个this._date我们之前认识过,它可以是input控件里的内容,如过控件中没有信息,那它则默认是当前系统日期。通过padLeft将1,2等字符串转成01,02等,最后将hour,minute和second写如dom中,看一下生成内容
下面我们再来看init中的倒数第二个方法showMode方法
showMode: function(dir) { if (dir) { this.viewMode = Math.max(this.minViewMode, Math.min( 2, this.viewMode + dir)); } this.widget.find('.datepicker > div').hide().filter(//这里依旧是先将year面板,month面板和days面板都隐藏了 '.datepicker-'+DPGlobal.modes[this.viewMode].clsName).show();//默认显示day面板 }
下面是最后一个方法_attachDatePickerEvents方法
//绑定事件 _attachDatePickerEvents: function() { var self = this; // this handles date picker clicks this.widget.on('click', '.datepicker *', $.proxy(this.click, this));//为datepicker下面所有的标签,绑定了this.click // this handles time picker clicks this.widget.on('click', '[data-action]', $.proxy(this.doAction, this));//这里存在bug,这里data-action属性存在小时面板也存在于小时(分钟,秒钟)详细面板 //这里绑定了this.doAction this.widget.on('mousedown', $.proxy(this.stopEvent, this));//mousedown事件 if (this.pickDate && this.pickTime) { this.widget.on('click.togglePicker', '.accordion-toggle', function(e) { e.stopPropagation(); var $this = $(this); var $parent = $this.closest('ul'); var expanded = $parent.find('.collapse.in'); //这里主要是控制显示切换 var closed = $parent.find('.collapse:not(.in)'); if (expanded && expanded.length) { var collapseData = expanded.data('collapse'); if (collapseData && collapseData.transitioning) return; expanded.collapse('hide');//切换显示 closed.collapse('show'); $this.find('i').toggleClass(self.timeIcon + ' ' + self.dateIcon); self.$element.find('.add-on i').toggleClass(self.timeIcon + ' ' + self.dateIcon);//修改 } }); } if (this.isInput) { this.$element.on({ 'focus': $.proxy(this.show, this), 'change': $.proxy(this.change, this) }); if (this.options.maskInput) { this.$element.on({ 'keydown': $.proxy(this.keydown, this), 'keypress': $.proxy(this.keypress, this) }); } } else { this.$element.on({ 'change': $.proxy(this.change, this)//为控件绑定change事件,调用了这个this.change方法 }, 'input'); if (this.options.maskInput) { this.$element.on({ 'keydown': $.proxy(this.keydown, this), 'keypress': $.proxy(this.keypress, this) }, 'input'); } if (this.component){ this.component.on('click', $.proxy(this.show, this));//为add-on标签绑定事件,触发this.show } else { this.$element.on('click', $.proxy(this.show, this)); } } }
这里出现了插件第一个比较大的bug
this.widget.on('click', '[data-action]', $.proxy(this.doAction, this));
点击空白处,导致出现这个问题:
这个bug主要是由于事件绑定而导致的我在注释中也说明了。如果想修改这个bug,方法也非常多,可以添加新class,在重新绑定新class事件。也有其他方法,大家酌情自己修改吧。
关于this.widget.on('click.togglePicker', '.accordion-toggle', function(e) {})这句的作用,其实就是小时面板跟日期面板的切换,如下图:
仔细的话,大家可以发现,只要我们点击了a标签,其外的li就会套上in类,让其显示,其他的则删去in类,让其隐藏,这一做法跟bootstrap的其他插件差不多。
ok,到此绑定完事件,我们基本结束了datetimepicker的初始化工作了,下面就是简单地看一下触发事件了
我们先列举attachDatePickerEvents方法中所出现过的触发事件方法
1.this.click
2.this.doAction
3.this.stopEvent
4.this.show
5.this.change
6.this.keydown
7.this.keypress
触发事件
1.click
在我们看click源码之前,我们有必要先看一下,这个插件为哪些控件绑定了click事件。
this.widget.on('click', '.datepicker *', $.proxy(this.click, this));可以看出为类datepicker 以下的所有标签绑定click事件,那么这个拥有类datepicker的面板主要有哪些呢?
日期面板: 月份面板: 年份面板:
从简单开始,我们可以尝试点击每个面板上的标题,左右按钮,具体的哪个日期(月份,年份)
1.1 面板标题
如果我们点击了任意面板的标题,就会进入这个插件设置好的层级菜单(面板),如上图,我们将日期,月份,年份分别列出,实际上在datetimepicker这个插件内部,将这三个面板分别定义成三个层级,日期面板为0级菜单(面板),月份面板为1级菜单(面板),年份面板为2级菜单(面板),也就是说当我们点击日期面板上的October 2010这个标题时,就会进入1级菜单(月份面板),依次类推,如果我们在年份面板上选择了2010,那插件会带我们从2级菜单回到1级菜单选择月份。这就是这个插件的升降级的处理流程。具体看一下代码:
case 'switch': this.showMode(1); break;
如果点击的是面板标题,我们会进入showMode这个方法,并传入1这个参数
//在日期面板,月份面板和年份面板间切换时,会调用这个方法,其传入的参数,作为降级处理 showMode: function(dir) { if (dir) { this.viewMode = Math.max(this.minViewMode, Math.min( 2, this.viewMode + dir));//这段代码完成降级处理,何为降级处理,即你选择完年份之后,会自动进入月份面板。选择完月份之后,会自动进入日期面板,层级递减 } this.widget.find('.datepicker > div').hide().filter(//这里依旧是先将year面板,month面板和days面板都隐藏了 '.datepicker-'+DPGlobal.modes[this.viewMode].clsName).show();//默认显示day面板 //根据降级处理过的viewMode数值在DPGlobal.model查找到对应的需要跳转的面板名称,跳转到这个面板 }
这里简单说一下代码如何控制升降级的,我们首先传入参数1,默认this.viewMode为0,当我们点击了日期的标题时,this.viewMode+1为1进入1级菜单,我们再点击月份面板的标题时,传入的参数为1,则这时的this.viewMode为2,进入2级菜单,如果此时我们再点击年份面板的标题时,传入参数依旧是1,但是注意了这里this.viewMode还是2,如果this.viewMode超过2了,就会选择2.
Math.min(2, this.viewMode + dir)
试想一下,如果this.viewMode无限减一怎么办,也没有关系,如果this.viewMode为0级时,也就是说你已经跳到日期面板时,将无法往下跳级了。
Math.max(this.minViewMode, Math.min(2, this.viewMode + dir))
代码中,最小取0,避免了无限往下跳级的情况,这里出现这个插件第二个比较蛋疼的bug,就是插件没有为0级菜单绑定关闭插件的方法,导致用户需要点击网页空白处才能完成。
1.2 左右按钮
这个比较好理解,看一下代码:
case 'prev'://左右切换月份,年份,或者一起切换 case 'next': var vd = this.viewDate;//获取当前时间(input里的或者是系统时间) var navFnc = DPGlobal.modes[this.viewMode].navFnc;//根据层级,选择需要切换是月份还是年份 var step = DPGlobal.modes[this.viewMode].navStep; if (target[0].className === 'prev') step = step * -1; vd['set' + navFnc](vd['get' + navFnc]() + step);//进行月份或着年份的递减或递增 this.fillDate();//显示在插件面板上 this.set();//显示在input中 break;
因为3个面板都存在左右切换的按钮,所以我们在使用的时候需要告诉插件,是哪一个面板,插件如何办到了?很简单,插件通过层级this.viewMode来在DPGlobal中查找相应的面板名称。经过fillDate处理在页面显示,再经过set在input框中显示
//将选中信息填入input控件中 set: function() { var formatted = ''; if (!this._unset) formatted = this.formatDate(this._date); if (!this.isInput) { if (this.component){ var input = this.$element.find('input'); input.val(formatted);//将选择出来的信息写入input框中 this._resetMaskPos(input); } this.$element.data('date', formatted);//保存选中信息 } else { this.$element.val(formatted); this._resetMaskPos(this.$element); } }
1.3 具体那个日期(月份,年份)
1.3.1 日期
case 'td'://点击日期 if (target.is('.day')) { var day = parseInt(target.text(), 10) || 1;//转成整型= var month = this.viewDate.getUTCMonth();//当前月份-1(以下可以为系统默认当前时间) var year = this.viewDate.getUTCFullYear();//当前年数 if (target.is('.old')) {//如果你选择旧的日期 if (month === 0) {//如果当前日期为1月,那我们点击上一个月的日期时,月份需要变为11(即12月份),年数则需要减一 month = 11; year -= 1; } else { month -= 1;//如果当前日期不为1月,那我们点击一个月的日期时,月份只需要减一 } } else if (target.is('.new')) {//如果你选择新的日期 if (month == 11) {//如果当前日期为12月时,那当我们点击下一个月的日期时,月份需要变为0(即1月份),年数则需要加一 month = 0; year += 1; } else { month += 1;//如果当前日期不为12月时,那当我们点击一下月的日期时,月份则只需要加一即可 } } this._date = UTCDate( year, month, day, this._date.getUTCHours(), this._date.getUTCMinutes(), this._date.getUTCSeconds(), this._date.getUTCMilliseconds() ); this.viewDate = UTCDate( year, month, Math.min(28, day) , 0, 0, 0, 0); this.fillDate(); this.set(); this.notifyChange(); }
逻辑在注释上写的很清楚了,不是很难。最后经过fillDate渲染,set显示在input框中。
1.3.2 月份和年份
case 'span': if (target.is('.month')) {//这里控制的是月份面板 var month = target.parent().find('span').index(target); this.viewDate.setUTCMonth(month);//将你选择的月份赋给viewDate,等会处理完显示在input控件上 } else {//这里是年份面板 var year = parseInt(target.text(), 10) || 0; this.viewDate.setUTCFullYear(year);//将你选择的年份赋给viewDate,等会处理完显示在input控件上 } if (this.viewMode !== 0) {//考虑几级菜单,这个插件认为日期面板属于0级菜单,月份属于1级,年份属于2级 this._date = UTCDate( this.viewDate.getUTCFullYear(), this.viewDate.getUTCMonth(), this.viewDate.getUTCDate(), this._date.getUTCHours(), this._date.getUTCMinutes(), this._date.getUTCSeconds(), this._date.getUTCMilliseconds() ); this.notifyChange(); } this.showMode(-1);//降级操作 this.fillDate();//降级完,将该面板数据呈现出来 this.set(); break;
这里出现了降级处理。
2. doAction
先看一下,插件中哪些部分绑定了doAction方法。this.widget.on('click', '[data-action]', $.proxy(this.doAction, this));这里我们可以知道是拥有data-action属性的标签拥有可以触发这个doAction方法,具体到插件的显示部分,我们来看一下
小时面板 小时子菜单 分钟子菜单 秒钟子菜单
之前说过这里存在bug,都在子菜单中存在,点击空白处会出现Nan,对于修改,我们最后统一修改
//关于小时面板的层级跳转控制 doAction: function(e) { e.stopPropagation(); e.preventDefault(); if (!this._date) this._date = UTCDate(1970, 0, 0, 0, 0, 0, 0); var action = $(e.currentTarget).data('action'); var rv = this.actions[action].apply(this, arguments); this.set(); this.fillTime();//通过这个fillTime显示出来 this.notifyChange(); return rv; }
如果我们仔细看一下小时面板和各个子菜单。我们可以看到它们的data-action后面的内容都不相同,这就是插件去判断到底是谁点击了,响应谁的一个标记。看一下action,这里我给出结构
action:{ //小时加1(当前时间) incrementHours: function(e) {} //分钟加1(当前时间) incrementMinutes: function(e) {} //秒钟加1(当前时间) incrementSeconds: function(e) {} //小时减1(当前时间) decrementHours: function(e) {} //分钟减1(当前时间) decrementMinutes: function(e){} //秒钟减1(当前时间) decrementSeconds: function(e){} togglePeriod: function(e) {} //将所有从小时,分钟,秒钟的子菜单跳转到小时面板 showPicker: function() {} //显示关于小时的子菜单(面板) showHours: function() {} //显示关于分钟的子菜单(面板) showMinutes: function() {} //显示关于秒针的子菜单(面板) showSeconds: function() {} //小时子菜单中获取用户选择的小时信息 selectHour: function(e) {} //分钟子菜单中获取用户选择的分钟信息 selectMinute: function(e) {} //秒钟子菜单中获取用户选择的秒钟信息 selectSecond: function(e) {} }
action中每一个属性名对应了data-action=后面的值,这样可以调用相应的方法,上面的分为show,select,增减三大类。增减总要是到时分秒进行增减,最后还是要通过fillTime显示出来。show之类的方法主要是跳转,因为小时面板中存在3个子菜单,如果我们在某个子菜单中选择了一个值,那就需要跳转到小时面板上,这里没有之前通过层级控制,而是简单的show和hide实现。其中showHours,showMinutes,showSeconds是跳转到相应的子菜单的,而showPicker方法是从任何子菜单跳回到小时面板。最后是select类的方法,主要是获取各个子菜单上的选择的信息。调用showPicker方法,返回小时面板。
3. stopEvent
//阻止冒泡和默认行为 stopEvent: function(e) { e.stopPropagation(); e.preventDefault(); }
。。很刚很生猛的方法。
4. show
//显示 show: function(e) { this.widget.show();//整个插件显示 this.height = this.component ? this.component.outerHeight() : this.$element.outerHeight(); this.place(); this.$element.trigger({ type: 'show', date: this._date }); this._attachDatePickerGlobalEvents(); if (e) { e.stopPropagation(); e.preventDefault(); } }
首先这个show方法,先将整个插件显示出来。这个有一个place方法和_attachDatePickerGlobalEvents方法
这里的place方法,主要是控制控件的显示位置,_attachDatePickerGlobalEvents则主要是绑定hide方法和resize事件
//在show方法绑定的事件 _attachDatePickerGlobalEvents: function() { $(window).on( 'resize.datetimepicker' + this.id, $.proxy(this.place, this)); if (!this.isInput) { $(document).on( 'mousedown.datetimepicker' + this.id, $.proxy(this.hide, this));//将关闭事件绑定到了文档中 } }
这里可以看到,我们只有点击网页空白处,才能完成关闭插件的效果。这里刚才我们提到的第二个bug了。既然说到了show方法,就提一下hide方法
hide: function() { // Ignore event if in the middle of a picker transition var collapse = this.widget.find('.collapse') for (var i = 0; i < collapse.length; i++) { var collapseData = collapse.eq(i).data('collapse'); if (collapseData && collapseData.transitioning) return; } this.widget.hide();//隐藏掉整个控件 this.viewMode = this.startViewMode;//层级归零 this.showMode();//下次点击进入时,应该是零级面板 this.set(); this.$element.trigger({ type: 'hide', date: this._date }); this._detachDatePickerGlobalEvents();//删除datetimepicker下的mousedown绑定事件 }
基本都是擦屁股的事情,看一下_detachDatePickerGlobalEvents
_detachDatePickerGlobalEvents: function () { $(window).off('resize.datetimepicker' + this.id); if (!this.isInput) { $(document).off('mousedown.datetimepicker' + this.id); } }
5. change
这个方法主要是为了防止用户手动自定义修改input框中的内容,其中这个插件如此多此一举的行为,给了不好的用户体验,导致我直接在input中写入任意信息,鼠标点击空白处时,input框中自动转成当前日期,算是一个bug吧。建议整个input框不可以自定义填写。
//控制用户自定义修改input内容 change: function(e) { var input = $(e.target); var val = input.val(); if (this._formatPattern.test(val)) {//满足一个之前定义好的标准的时间格式 this.update(); this.setValue(this._date.getTime()); this.notifyChange(); this.set(); } else if (val && val.trim()) {//不满足时,用户修改了input中信息时,将修改为系统当前时间,个人觉得这个功能不好,这个input应该是不可自定义填写的 this.setValue(this._date.getTime()); if (this._date) this.set();//显示在input中 else input.val(''); } else { if (this._date) { this.setValue(null); // unset the date when the input is // erased this.notifyChange(); this._unset = true; } } this._resetMaskPos(input); }
这里有个setValue方法
//如果input框中被修改了,如果不满足时间格式,将默认修改为系统时间 setValue: function(newDate) { if (!newDate) { this._unset = true; } else { this._unset = false; } if (typeof newDate === 'string') {//如果是字符串类型的使用parseDate转 this._date = this.parseDate(newDate); } else if(newDate) { this._date = new Date(newDate);//否则使用Date转 } this.set(); this.viewDate = UTCDate(this._date.getUTCFullYear(), this._date.getUTCMonth(), 1, 0, 0, 0, 0); this.fillDate(); this.fillTime(); }
通过正则判断,发现input框中的信息不符合时间格式,那插件强行修改为当前日期,setValue方法的主要功能主要是更新时间。
6. keydown
7. keypress这里暂时不讨论
至此整个插件算是勉强看完,下面留下了一些这个插件的bug,我们来总结一下:
1.时分秒子菜单存在点击出现Nan的bug
2.插件没有为0级菜单绑定关闭插件的方法,导致用户需要点击网页空白处才能完成。
3.change事件监听较为繁琐,可以直接输入任何字符显示系统日期,建议去除。
修改bootstrap-datetimepicker.js
以下的修改是本人的一点想法,读者如果有更好的想法可以分享一下,我这里就抛砖引玉了,另外这个插件如果还有别的bug,也希望能够提出来,大家一起解决。
对于第一个bug,我们知道是因为data-action属性放置的地方不对,不应该放置在div上,而是放置在div内的table上。所以我们只需要修改时分秒子菜单的模版即可
TPGlobal.getTemplate = function(is12Hours, showSeconds) { ...... '</tr>' + '</table>' + '</div>' + '<div class="timepicker-hours" data-action="selectHour">' + '<table class="table-condensed">' + '</table>'+ '</div>'+ '<div class="timepicker-minutes" data-action="selectMinute">' + '<table class="table-condensed">' + '</table>'+ '</div>'+ (showSeconds ? '<div class="timepicker-seconds" data-action="selectSecond">' + '<table class="table-condensed">' + '</table>'+ '</div>': '') } 修改为 '</tr>' + '</table>' + '</div>' + '<div class="timepicker-hours">' + '<table class="table-condensed" data-action="selectHour">' + '</table>'+ '</div>'+ '<div class="timepicker-minutes">' + '<table class="table-condensed" data-action="selectMinute">' + '</table>'+ '</div>'+ (showSeconds ? '<div class="timepicker-seconds">' + '<table class="table-condensed" data-action="selectSecond">' + '</table>'+ '</div>': '') );
对于第二个bug,对于点击0级菜单不能关闭插件,我们找到day部分绑定了click触发事件,我们只要在click方法最后加上一个原型上的hide方法,就可以帮插件关闭。看一下:
click: function(e) { .... case 'td'://点击日期 if (target.is('.day')) { .... this.viewDate = UTCDate( year, month, Math.min(28, day) , 0, 0, 0, 0); this.fillDate(); this.set(); this.notifyChange(); this.hide();//这里加上hide方法 } break; }
最后一个bug,这个我们可以直接在input上修改,将其改为只读就行
<input type="text" disabled="disabled"/>
ok,几个比较明显的bug改完,这个插件依旧还有一点东西需要我们再看一下。
使用时间插件,会有一种情况就是,需要控制用户输入的日期,比如不让用户选择超过当今日期的,不得小于2012年10月1日的,前面的日期必须大于后面的日期等等,解决方法有很多,可以直接由插件控制,也可以在input框触发事件,脱离时间控件控制。bootstrap-datetimepicker.js提供了内部控制。我们只需要做的,仅仅在初始化时传入的参数中多一个startDate或者是endDate即可。这里插件还有一个不足之处,就是这传入的开始时间和结束时间需要格式化,js时间的格式话比较麻烦,插件本身拥有这个格式化的方法,但是没有公共出来,你可以自己写一个格式化方法,也可以将插件内的格式化方法公共出来。源码之后添加如下代码:
window.UTCDate = UTCDate;
看一下例子:
//UTCDate(year, month, date, hours, minutes, seconds, milliseconds) var date1 = new Date(); var date = UTCDate(2013,9,10); $('#datetimepicker').datetimepicker({ format: 'MM/dd/yyyy hh:mm', language: 'en', pickDate: true, pickTime: true, hourStep: 1, minuteStep: 15, secondStep: 30, inputMask: true, startDate: date });
插件内部提供了UTCDate方法来格式化时间,如例子所写的插件必须选择大于2013年10月9日的,注意月份会加1。endDate的道理和startDate是一致的。
以上是本人的一点读码分析,不足之处还请指正。不胜感谢。