帶你開發一個日曆控制元件

依韻宵音發表於2017-12-12

首發我的部落格 - https://blog.cdswyda.com/post/2017121010

日曆控制元件多的不勝列舉,為什麼我們還要再造一個輪子呢?

因為大多數日曆控制元件都是用於選擇日期的,有種需求是要在日曆上展示各種各樣的內容,這樣的日曆控制元件較少,而且試用下來並不滿意。

因此就再造一個輪子,現在帶你一起基於使用之前完成的元件機制來開發一個日曆控制元件。

需求

簡單把需求整理如下:

  • 月檢視
  • 支援在日曆中每一天中插入任意的內容
  • 相關點選事件
  • 獲取日曆當前檢視的開始和結束日期
  • 獲取設定選中的日期

實現分析

首先我們拿系統中自帶的日曆觀察一下,看看日曆的特徵到底是怎麼樣的。

帶你開發一個日曆控制元件

一個月中有 28 到 31 天不等,但是為了保證完整的結構,日曆中會有部分上一月和下一月的日期,總結下來,一個月中顯示的必定是整整6周的日期。

那麼只要得到當月的開始日期就可以繪製日曆了。

如何計算當月日曆檢視中的開始日期呢? 前面已經分析了,為了保證完整,它顯示了上一月的部分天數,那麼只用從當月的1號開始往前推算就可以了。

開始日期 = 當月1號的日期 - 當月1號的星期
結束日期 = 開始日期 + 42天
複製程式碼

這個問題搞清楚了,感覺實現這麼一個日曆就沒什麼大阻礙了,開始動工吧!

必要結構準備

首先構建如下所示的基本結構

帶你開發一個日曆控制元件

其中:

  • 頭部左右為個性化區域,用於實際使用時放置任意內容。中間用於顯示當前月份和切換按鈕
  • 主體區域中用繪製整個日曆
    • thead 中繪製週一至週日 或週日至週一的星期,這段內容是不會隨月份切換而改變的,可以直接準備好
    • tbody 中用於繪製可變的日期,準備好容器留空即可。
  • 腳部區域用於實際使用時放置任意各項化內容
  • menu區域用於切換日期時彈出的皮膚

繪製日曆

在初始化好日曆結構後就可以開始繪製日曆了。

計算一個月中的開始日期和結束日期

首先完成開始和結束時間的計算

{
    // 初始化當前月份的開始日期和結束日期
    _initStartEnd: function () {
        // 當月1號
        var currMonth = moment(this.currMonth, 'YYYY-MM'),
            // 當月1號是周幾 the ISO day of the week with 1 being Monday and 7 being Sunday.
            firstDay_weekday = currMonth.isoWeekday(),
            startDateOfMonth,
            endDateOfMonth;
        if (!this.dayStartFromSunday) {
            // 開始為週一 則向前減少周幾的天數-1即為 開始的日期
            startDateOfMonth = currMonth.subtract(firstDay_weekday - 1, 'day');
        } else {
            // 開始為週日 則直接向前周幾的天數即可
            startDateOfMonth = currMonth.subtract(firstDay_weekday, 'day');
        }

        endDateOfMonth = startDateOfMonth.clone().add(41, 'day');

        this.startDateOfMonth = startDateOfMonth;
        this.endDateOfMonth = endDateOfMonth;
    }
}
複製程式碼

由於要處理很多日期,而JavaScript中關於日期處理時,不同瀏覽器下差異較大,因此直接使用 moment.js 來對日期進行統一處理。

由於使用習慣不同,一週的開始到底是週一還是週日是不確定的,因此直接作為配置即可。

繪製一月中的日期

上面已經計算得到了一個月的開始日期和結束日期,那麼只用遍歷進行繪製即可。

由於我們使用了表格實現,因此需要按行繪製。

實現如下:

{
    // 日曆可變部分的渲染
    _render: function () {
        this._initStartEnd();

        var weeks = 6,
            days = 7,
            curDate = this.startDateOfMonth.clone(),
            tr;

        var start = this.startDateOfMonth.format('YYYY-MM-DD'),
            end = this.endDateOfMonth.format('YYYY-MM-DD');

        // 清空 並開始新的渲染
        this._clearDays();
        this._renderTitle();

        for (var i = 0; i < weeks; ++i) {
            tr = document.createElement('tr');
            tr.className = 'ep-calendar-week';
            this._daysBody.appendChild(tr);

            for (var j = 0; j < days; ++j) {
                // 渲染一天 並遞增
                this._renderDay(curDate, tr);
                curDate.add(1, 'day');
            }
        }
    },
    // 每天的渲染
    _renderDay: function (date, currTr) {
        var td = document.createElement('td'),
            tdInner = document.createElement('div'),
            text = document.createElement('span'),
            day = date.isoWeekday(),
            // 返回的月份是0-11
            month = date.month() + 1;

        tdInner.appendChild(text);
        td.appendChild(tdInner);

        td.className = 'ep-calendar-date';
        tdInner.className = 'ep-calendar-date-inner';
        // 完整日期
        td.setAttribute('data-date', date.format('YYYY-MM-DD'));
        // 對應的iso星期
        td.setAttribute('data-isoweekday', day);

        // 週末標記text.className
        if (day === 6 || day === 7) {
            td.className += ' ep-calenday-weekend';
        }
        // 非本月標記
        // substr 在ie8下有問題
        // if (month != parseInt(this.currMonth.substr(-2))) {
        if (month != parseInt(this.currMonth.substr(5), 10)) {
            td.className += ' ep-calendar-othermonth';
        }
        // 今天標記
        if (this.today == date.format('YYYY-MM-DD')) {
            td.className += ' ep-calendar-today';
        }

        // 每天渲染時發生 還未插入頁面
        var renderEvent = this.fire('cellRender', {
            // 當天的完整日期
            date: date.format('YYYY-MM-DD'),
            // 當天的iso星期
            isoWeekday: day,
            // 日曆dom
            el: this.el,
            // 當前單元格
            tdEl: td,
            // 日期文字
            dateText: date.date(),
            // 日期class
            dateCls: 'ep-calendar-date-text',
            // 需要注入的額外的html
            extraHtml: '',

            isHeader: false
        });

        // 處理對dayText內容和樣式的更改
        text.innerText = renderEvent.dateText;
        text.className = renderEvent.dateCls;

        // 新增新增內容
        if (renderEvent.extraHtml) {
            jQuery(renderEvent.extraHtml).appendTo(tdInner);
        }

        currTr.appendChild(renderEvent.tdEl);

        // 每天渲染後發生 插入到頁面
        this.fire('afterCellRender', {
            date: date.format('YYYY-MM-DD'),
            isoWeekday: day,
            el: this.el,
            tdEl: td,
            dateText: text.innerText,
            dateCls: text.className,
            extraHtml: renderEvent.extraHtml,
            isHeader: false
        });
    }
}
複製程式碼

直接從開始日期往後依次畫出42天即可。

為了靈活性,在繪製的不同時機觸發了不同的事件,在使用時可繫結相應的事件,在其中進行個性化操作。

也為了使用了方便和靈活性,直接在繪製日期時,在相應的dom上加入了所對應的日期和星期屬性。

在此過程中需要對日期是否週末、是否本月、是否是選中的、是否是今天等進行相應的標記處理。

繪製其他內容

除了上面所述之外此外還要繪製出年月選擇、標題等,這些實際就是給已經有的dom元素中更改內容而已,就不再展開了。

切換月份的實現

上面已經基本繪製出了一個日曆,切換月份實際就更簡單了,只用根據新的月份重新計算開始日期,清空原來的內容,重新進行繪製即可。

{
    // 設定月份
    setMonth: function (ym) {
        var date = moment(ym, 'YYYY-MM');

        if (date.isValid()) {
            var oldMonth = this.currMonth,
                aimMonth = date.format('YYYY-MM');

            // 月份變動前
            this.fire('beforeMonthChange', {
                el: this.el,
                oldMonth: oldMonth,
                newMonth: aimMonth
            });

            this.currMonth = aimMonth;
            this.render();

            // 月份變動後
            this.fire('afterMonthChange', {
                el: this.el,
                oldMonth: oldMonth,
                newMonth: aimMonth
            });

        } else {
            throw new Error(ym + '是一個不合法的日期');
        }
    }
}
複製程式碼

事件的處理

要處理的事件較多,此處僅僅以日期的點選作為示意。

{
    // 初始化事件
    _initEvent: function () {
        var my = this;
        jQuery(this.el)
            // 日期單元格
            .on('click', '.ep-calendar-date', function (e) {
                var date = this.getAttribute('data-date'),
                    ev = my.fire('dayClick', {
                        ev: e,
                        date: date,
                        day: this.getAttribute('data-isoweekday'),
                        el: my.el,
                        tdEl: this
                    });

                // 如果修改事件物件的cancel為true後 則不進行後續的選中操作
                if (!ev.cancel) {
                    my.setSelected(date);
                }
            })
    }
}
複製程式碼

由於日期所對應的dom元素始終會新增和移除,直接把事件繫結在日期的dom元素上,則必須在每次新增後重新繫結事件,十分麻煩。

直接使用事件代理機制,將事件繫結在整個日曆的dom上即可,這樣事件只用在建立時初始化一次即可,簡單、高效、省記憶體。

使用

我們新增這個控制元件的主要目的就是要支援在日曆中繪製任意內容,怎麼使用呢?

var testCalendar = epctrl.init('Calendar', {
    el: '#date',
    // 資源載入過程中的事件需要直接在這裡指定
    events: {
        beforeSourceLoad: function (e) {
            // 資源載入前,在加入我們的皮膚樣式檔案
            e.cssUrl.push('./test-skin.css');
        }
    }
});
// 日期部分渲染前 支援動態獲取資料
testCalendar.on('beforeDateRender', function (e) {
    var startDate = e.startDate,
        endDate = e.endDate;
    // 如果需要動態獲取資料
    // 則將獲取資料的ajax加到事件物件的ajax屬性上即可
    // 日期渲染的cellRender事件將在ajax成功獲取資料後執行
    e.ajax = $.ajax({
        url: 'getDateInfo.xxx',
        // 將當月檢視的開始和結束時間傳遞過去
        data: {
            start: startDate,
            end: endDate
        }
    });
});
// 控制渲染過程 可插入任意內容或修改原來的內容
testCalendar.on('cellRender', function (e) {
    if (!e.isHeader) {
        // 如:週五週六則插入週末 否則插入工作日
        e.extraHtml = '<div>' + (e.isoWeekday > 5 ? '週末': '工作日') + '</div>';
    }
});
複製程式碼

總結

以上就是關於一個月檢視日曆控制元件核心步驟了。

此日曆實現基於一個控制元件基類擴充套件而來,其必要功能僅為一套事件機制,可參考實現一套自定義事件機制

上面只分析了關鍵步驟,和核心程式碼,為了方便使用和擴充套件性,實際程式碼中還要處理很多問題。原始碼和文件如下,感興趣可以閱讀:月檢視日曆

相關文章