无码帝 阅读(174) 评论(0)

之前项目运用到了这个时间控件,期间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是一致的。

 以上是本人的一点读码分析,不足之处还请指正。不胜感谢。