Jonathan Peterson 的 bootstrap-datetimepicker 源码简析

濮佑运
2023-12-01

最近在使用 Jonathan Peterson 所写的 bootstrap-datetimepicker 这一插件,然而由于网页设计的一些问题,无法在静态元素上正常使用该插件,蹦出自己尝试修改源代码的想法。说干就干,还竟然成功了,现在再完整地研究一下该插件的源代码!

bootstrap-datetimepicker 源代码(version:4.17.45)一共2725行,总的来说,分为两个部分
第一个函数用来检查是否使用了AMD规范,以及所需要的 jQuery 和 moment.js 是否已经加载,最后调用factory。这个函数比较简单,代码如下:

function (factory) {
        'use strict';
        if (typeof define === 'function' && define.amd) {
            // AMD is used - Register as an anonymous module.
            define(['jquery', 'moment'], factory);
        } else if (typeof exports === 'object') {
            module.exports = factory(require('jquery'), require('moment'));
        } else {
            // Neither AMD nor CommonJS used. Use global variables.
            if (typeof jQuery === 'undefined') {
                throw 'bootstrap-datetimepicker requires jQuery to be loaded first';
            }
            if (typeof moment === 'undefined') {
                throw 'bootstrap-datetimepicker requires Moment.js to be loaded first';
            }
            factory(jQuery, moment);
        }
    }

重点来了,让我们仔细观察第二个函数的内容,简化为三个部分

function ($, moment) {
    'use strict';
    if (!moment) {
        throw new Error('bootstrap-datetimepicker requires Moment.js to be loaded first');
    }
    // Part one
    var dateTimePicker = function (element, options) {...}; 
    // Part two
    $.fn.datetimepicker = function (options) {...};
    // Part three
    $.fn.datetimepicker.defaults = {...}; 
    return $.fn.datetimepicker;
}

整体上,这个函数首先声明一个名为 dateTimePicker 的 JavaScript 对象,将 datetimepicker 这个方法拓展为 $.fn 的方法,接着给 datetimepicker 一个名为 defaults 的属性赋值,最后返回 $.fn.datetimepicker

接下来,我们对每一部分的代码进行解析
首先,让我们来看一下第一部分,这一部分的主要工作是返回一个对象,接受的参数是element以及options,部分代码如下:

var dateTimePicker = function (element, options) {
    var picker = {},
        .
        .
        .
    return picker;
}

那么这两个参数到底接受的是什么内容呢?在第二部分的代码中我们会找到答案,现在就一起先睹为快:

$.fn.datetimepicker = function (options) {
    options = options || {};

    var args = Array.prototype.slice.call(arguments, 1),
        isInstance = true,
        thisMethods = ['destroy', 'hide', 'show', 'toggle'],
        returnValue;

    if (typeof options === 'object') {
         return this.each(function () {
            var $this = $(this),
            _options;
            if (!$this.data('DateTimePicker')) {
                // create a private copy of the defaults object
                _options = $.extend(true, {}, $.fn.datetimepicker.defaults, options);
                $this.data('DateTimePicker', dateTimePicker($this, _options));
            }
        }); // 剩余部分省略

关键在于 dateTimePicker($this, _options),那么这个 $this又是何方圣神呢?继续向上溯源,发现var $this = $(this),也就是说,这个$this代表的就是调用 datetimepicker 的HTML元素所对应的 jQuery 对象,而这个 options 则是一个 JavaScript 对象,这个对象是由用户在使用插件时添加的一些选项所组成的。

明白这个函数的最终功能之后,我们看具体的代码。
首先定义了许多局部变量、函数以及 picker 的各种方法,这些方法比较简单,这里就不再赘述。
先来看具体的执行过程,主要代码如下:

// initializing element and component attributes
if (element.is('input')) {
    input = element;
} else {
    input = element.find(options.datepickerInput);
    if (input.length === 0) {
        input = element.find('input');
    } else if (!input.is('input')) {
        throw new Error('CSS class "' + options.datepickerInput + '" cannot be applied to non input element');
    }
}

if (element.hasClass('input-group')) {
    // in case there is more then one 'input-group-addon' Issue #48
    if (element.find('.datepickerbutton').length === 0) {
        component = element.find('.input-group-addon');
    } else {
        component = element.find('.datepickerbutton');
    }
}

if (!options.inline && !input.is('input')) {
    throw new Error('Could not initialize DateTimePicker without an input element');
}

// Set defaults for date here now instead of in var declaration
date = getMoment();
viewDate = date.clone();

$.extend(true, options, dataToOptions());

picker.options(options);

initFormatting();

attachDatePickerElementEvents();

if (input.prop('disabled')) {
    picker.disable();
}
if (input.is('input') && input.val().trim().length !== 0) {
    setValue(parseInputDate(input.val().trim()));
}
else if (options.defaultDate && input.attr('placeholder') === undefined) {
    setValue(options.defaultDate);
}
if (options.inline) {
    show();
}

一开始的三个 if 主要完成的是一些初始化,随后获得当前的时间,并且用户输入的option更新dateTimePicker中的值,这里用到了 dataToOptions()函数,我们来仔细看一看这个函数。

dataToOptions = function () {
    var eData,
        dataOptions = {};

    if (element.is('input') || options.inline) {
        eData = element.data();
    } else {
        eData = element.find('input').data();
    }

    if (eData.dateOptions && eData.dateOptions instanceof Object) {
        dataOptions = $.extend(true, dataOptions, eData.dateOptions);
    }

    $.each(options, function (key) {
        var attributeName = 'date' + key.charAt(0).toUpperCase() + key.slice(1);
        if (eData[attributeName] !== undefined) {
            dataOptions[key] = eData[attributeName];
        }
    });
    return dataOptions;
}

遍历用户输入的所有 option ,将其中有效的数据读取出来,紧接着调用 picker.options(),让我们来看一下这个函数

picker.options = function (newOptions) {
    if (arguments . length === 0) {
        return $.extend(true, {}, options);
    }

    if (!(newOptions instanceof Object)) {
        throw new TypeError('options() options parameter should be an object');
    }
    $.extend(true, options, newOptions);
    $.each(options, function (key, value) {
        if (picker[key] !== undefined) {
            picker[key](value);
        } else {
            throw new TypeError('option ' + key + ' is not recognized!');
        }
    });
    return picker;
};

这个函数主要功能是利用用户输入的option来更新picker中的数据:如果该选项是合法的,则更新数据;如果该选项是不合法的,则抛出异常,输出错误信息。这里用到了之前略过的那些函数,用户可以修改的option都对应了一个形如picker.x的函数,这个函数完成了对数据的检验以及数据更新,其中option可以是一个字符串(例如: format),也可以是一个类对象(例如:widgetPositioning)。

现在,让我们来看一看如何在这个插件上进行功能拓展。可以看到,判断用户输入的option是否合法的逻辑很简单,如果可以找到一个形如picker.x的函数,那么就认为这个option 是合法的。这也就是说,如果想新增加一个option选项new_option(此处的new_option只是选项名,可以自行修改),只需编写picker.new_option = function(new_option){}函数即可,这个函数需要实现的功能是接收到用户的new_option设置后进行检验和处理数据,并用处理后的数据更新原有数据。是不是很兴奋,原来功能拓展这么容易!但是,这仅仅是数据处理,具体的功能实现需要另写函数。

让我们接着来看代码,剩下的代码就比较简单:

initFormatting();

attachDatePickerElementEvents();

if (input.prop('disabled')) {
    picker.disable();
}
if (input.is('input') && input.val().trim().length !== 0) {
    setValue(parseInputDate(input.val().trim()));
}
else if (options.defaultDate && input.attr('placeholder') === undefined) {
    setValue(options.defaultDate);
}
if (options.inline) {
    show();
}
return picker;

初始化了时间格式,设置datetimepicker了基本行为,最后做一些判断。有兴趣的读者可以自己研读,这里贴出initFormatting()attachDatePickerElementEvents()的源代码

attachDatePickerElementEvents = function () {
    input.on({
        'change': change,
        'blur': options.debug ? '' : hide,
        'keydown': keydown,
        'keyup': keyup,
        'focus': options.allowInputToggle ? show : ''
    });

    if (element.is('input')) {
        input.on({
            'focus': show
        });
    } else if (component) {
        component.on('click', toggle);
        component.on('mousedown', false);
    }
}

initFormatting = function () {
    var format = options.format || 'L LT';

    actualFormat = format.replace(/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g, function (formatInput) {
        var newinput = date.localeData().longDateFormat(formatInput) || formatInput;
        return newinput.replace(/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g, function (formatInput2) { //temp fix for #740
            return date.localeData().longDateFormat(formatInput2) || formatInput2;
        });
    });


    parseFormats = options.extraFormats ? options.extraFormats.slice() : [];
    if (parseFormats.indexOf(format) < 0 && parseFormats.indexOf(actualFormat) < 0) {
        parseFormats.push(actualFormat);
    }

    use24Hours = (actualFormat.toLowerCase().indexOf('a') < 1 && actualFormat.replace(/\[.*?\]/g, '').indexOf('h') < 1);

    if (isEnabled('y')) {
        minViewModeNumber = 2;
    }
    if (isEnabled('M')) {
        minViewModeNumber = 1;
    }
    if (isEnabled('d')) {
        minViewModeNumber = 0;
    }

    currentViewMode = Math.max(minViewModeNumber, currentViewMode);

    if (!unset) {
        setValue(date);
    }
}

让我们进入第二部分的代码:

$.fn.datetimepicker = function (options) {
    options = options || {};

    var args = Array.prototype.slice.call(arguments, 1),
        isInstance = true,
        thisMethods = ['destroy', 'hide', 'show', 'toggle'],
        returnValue;

    if (typeof options === 'object') {
        return this.each(function () {
            var $this = $(this),
            _options;
            if (!$this.data('DateTimePicker')) {
            // create a private copy of the defaults object
                _options = $.extend(true, {}, $.fn.datetimepicker.defaults, options);
                $this.data('DateTimePicker', dateTimePicker($this, _options));
            }
        });
    } else if (typeof options === 'string') {
        this.each(function () {
            var $this = $(this),
            instance = $this.data('DateTimePicker');
            if (!instance) {
                throw new Error('bootstrap-datetimepicker("' + options + '") method was called on an element that is not using DateTimePicker');
            }

            returnValue = instance[options].apply(instance, args);
            isInstance = returnValue === instance;
        });
        if (isInstance || $.inArray(options, thisMethods) > -1) {
            return this;
        }

        return returnValue;
    }

    throw new TypeError('Invalid arguments for DateTimePicker: ' + options);
}

首先,对options进行初始化,并获得该函数对的所有参数。
如果options是对象,那么利用这个对象,更新datetimepicker.default中对应数据的值;如果options是字符串,则分两种情况,当options'destroy', 'hide', 'show', 'toggle'中的一个时,做相应的处理后,返回这个对象;如果不是,则判断options是否合法,如果合法则返回该option所对应数值,否则会报错。

最后一部分就是datatimepicker的default值,主要是赋初值,作为用户没有任何修改时的初始状态,如果有需要可以自己改动其中的初始化。另外,如果有需要,自己添加的功能的初始值也应该在这里一并赋值。下面是代码:

$.fn.datetimepicker.defaults = {
    timeZone: '',
    format: false,
    dayViewHeaderFormat: 'MMMM YYYY',
    extraFormats: false,
    stepping: 1,
    minDate: false,
    maxDate: false,
    useCurrent: true,
    collapse: true,
    locale: moment.locale(),
    defaultDate: false,
    disabledDates: false,
    enabledDates: false,
    icons: {
      time: 'glyphicon glyphicon-time',
      date: 'glyphicon glyphicon-calendar',
      up: 'glyphicon glyphicon-chevron-up',
      down: 'glyphicon glyphicon-chevron-down',
    previous: 'glyphicon glyphicon-chevron-left',
    next: 'glyphicon glyphicon-chevron-right',
    today: 'glyphicon glyphicon-screenshot',
    clear: 'glyphicon glyphicon-trash',
    close: 'glyphicon glyphicon-remove'
  },
  tooltips: {
    today: 'Go to today',
    clear: 'Clear selection',
    close: 'Close the picker',
    selectMonth: 'Select Month',
    prevMonth: 'Previous Month',
    nextMonth: 'Next Month',
    selectYear: 'Select Year',
    prevYear: 'Previous Year',
    nextYear: 'Next Year',
    selectDecade: 'Select Decade',
    prevDecade: 'Previous Decade',
    nextDecade: 'Next Decade',
    prevCentury: 'Previous Century',
    nextCentury: 'Next Century',
    pickHour: 'Pick Hour',
    incrementHour: 'Increment Hour',
    decrementHour: 'Decrement Hour',
    pickMinute: 'Pick Minute',
    incrementMinute: 'Increment Minute',
    decrementMinute: 'Decrement Minute',
    pickSecond: 'Pick Second',
    incrementSecond: 'Increment Second',
    decrementSecond: 'Decrement Second',
    togglePeriod: 'Toggle Period',
    selectTime: 'Select Time'
  },
  useStrict: false,
  sideBySide: false,
  daysOfWeekDisabled: false,
  calendarWeeks: false,
  viewMode: 'days',
  toolbarPlacement: 'default',
  showTodayButton: false,
  showClear: false,
  showClose: false,
  widgetPositioning: {
    horizontal: 'auto',
    vertical: 'auto'
  },
  widgetParent: null,
  ignoreReadonly: false,
  keepOpen: false,
  focusOnShow: true,
  inline: false,
  keepInvalid: false,
  datepickerInput: '.datepickerinput',
  keyBinds: {
    up: function(widget) {
      if (!widget) {
        return;
      }
      var d = this.date() || this.getMoment();
      if (widget.find('.datepicker').is(':visible')) {
        this.date(d.clone().subtract(7, 'd'));
      } else {
        this.date(d.clone().add(this.stepping(), 'm'));
      }
    },
    down: function(widget) {
      if (!widget) {
        this.show();
        return;
      }
      var d = this.date() || this.getMoment();
      if (widget.find('.datepicker').is(':visible')) {
        this.date(d.clone().add(7, 'd'));
      } else {
        this.date(d.clone().subtract(this.stepping(), 'm'));
      }
    },
    'control up': function(widget) {
      if (!widget) {
        return;
      }
      var d = this.date() || this.getMoment();
      if (widget.find('.datepicker').is(':visible')) {
        this.date(d.clone().subtract(1, 'y'));
      } else {
        this.date(d.clone().add(1, 'h'));
      }
    },
    'control down': function(widget) {
      if (!widget) {
        return;
      }
      var d = this.date() || this.getMoment();
      if (widget.find('.datepicker').is(':visible')) {
        this.date(d.clone().add(1, 'y'));
      } else {
        this.date(d.clone().subtract(1, 'h'));
      }
    },
    left: function(widget) {
      if (!widget) {
        return;
      }
      var d = this.date() || this.getMoment();
      if (widget.find('.datepicker').is(':visible')) {
        this.date(d.clone().subtract(1, 'd'));
      }
    },
    right: function(widget) {
      if (!widget) {
        return;
      }
      var d = this.date() || this.getMoment();
      if (widget.find('.datepicker').is(':visible')) {
        this.date(d.clone().add(1, 'd'));
      }
    },
    pageUp: function(widget) {
      if (!widget) {
        return;
      }
      var d = this.date() || this.getMoment();
      if (widget.find('.datepicker').is(':visible')) {
        this.date(d.clone().subtract(1, 'M'));
      }
    },
    pageDown: function(widget) {
      if (!widget) {
        return;
      }
      var d = this.date() || this.getMoment();
      if (widget.find('.datepicker').is(':visible')) {
        this.date(d.clone().add(1, 'M'));
      }
    },
    enter: function() {
      this.hide();
    },
    escape: function() {
      this.hide();
    },
    'control space': function(widget) {
      if (!widget) {
        return;
      }
      if (widget.find('.timepicker').is(':visible')) {
        widget.find('.btn[data-action="togglePeriod"]').click();
      }
    },
    t: function() {
      this.date(this.getMoment());
    },
    'delete': function() {
      this.clear();
    }
  },
  debug: false,
  allowInputToggle: false,
  disabledTimeIntervals: false,
  disabledHours: false,
  enabledHours: false,
  viewDate: false
};

至此,源码部分告一段落(PS:第一次看完2000多行的项目,感觉给自己补充了很多知识,看源码真的很有效果!)

最后,让我们回到令人兴奋的功能拓展部分。之前解决了数据更新的问题,现在,我们来看看实现了功能之后怎么办。源代码中有一个名为show()的函数,这个函数的作用是显示datetimepicker,那么,实现相应功能的函数在这里调用即可。如果加入的功能和是对现在已有功能的拓展,直接修改原功能对应的函数即可。

附录解释:
i. $.fn是 jQuery 中特别的用法,相当于$.prototype。每个对象都可以调用prototype(fn)下的方法,通过 $.fn = function(){}可以为每一个jQuery对象添加一个方法。

ii. 闭包:允许将函数看作对象,然后能像在对象中的操作搬在函数中定义实例(局部)变量,而这些变量能在函数中保存到函数的实例对象销毁为止,其它代码块能通过某种方式获取这些实例(局部)变量的值并进行应用扩展。

var abc=function(y){  
var x=y;// 这个是局部变量  
return function(){  
    alert(x++);// 就是这里调用了闭包特性中的一级函数局部变量的x,并对它进行操作  
    alert(y--);// 引用的参数变量也是自由变量  
}}(5);// 初始化  
abc();// "5" "5"  
abc();// "6" "4"  
abc();// "7" "3"  
alert(x);// 报错!“x”未定义!  

iii. call apply
call 的第一个功能是用于函数的调用

var test = 'Test'
function doSomething(){    
    alert(test);  
}    

// 以下两句语句可以实现相同的效果:弹出对话框,内容是 Test
doSomething();  
doSomething.call();

这仅仅是一个基础功能,更大的用处在于利用call可以改变函数中this所指示的对象,示例代码如下

var test= 'Test';    
function doSomething(){
    alert(this.test);  
}  

var obj = {
    test: 'Obj'
}
// 通过`.call(obj)`,this所指示的对象变为了obj
doSomething.call(obj); // 弹出对话框,内容是 Obj

apply()call()类似的,只是apply()要求第二个参数必须是一个数组。这个数组会作为参数传递给目标函数。
iv. arguments
JavaScript中每一个函数都是一个对象,每个函数的对象中都有着保存参数列表的arguments,它有一个代表长度的属性,并且可以像数组那样用下标来访问。然而,arguments实际上并不是数组,也就意味着没有split、push、pop等方法。
JavaScript允许函数接受比他声明时数量更多的参数,这些参数都存储在arguments中,下面的例子综合了apply和arguments

var o = {x : 15};
function f(message1, message2) {
    alert(message1 + ( this.x * this.x) + message2);
}
function g(object, func) {
    // arguments[0] = object
    // arguments[1] = func
    var args = [];
    for(var i = 2; i < arguments.length; i++) {
        args.push(arguments[i]);
    }
    func.apply(object, args);
}
g(o, f, 'The value of x squared = ', '. Wow!');
// 输出:The value of x squared = 225. Wow!

v.|| && 运算符
JavaScript的逻辑运算中,-0 0 "" null false undefined NaN都会判为false;另外,大多数语言都支持“短路”:对于&&,终止于第一个为false的表达式;对于||,终止于第一个为true的表达式。
JavaScript的逻辑运算表达式的返回结果不一定是简单的true或者false,准确来说,返回的是真值或假值,上述的-0 0 "" null false undefined NaN是假值,而除此之外的都是真值(包括对象)。对于&&,返回第一个假值或最后一个真值;对于||,返回第一个真值或最后一个假值。
正是因此,在源代码中有这样的赋值过程:

// 如果 options 是一个对象,则将 options 赋值给 options ,否则新创建一个对象。
options = options || {};
 类似资料: