用 TypeScript 写一个轻量级的 UI 框架之十:表单控件之日历组件

开门见山,先说明一下这日历组件的核心算法不是笔者原创的,而是来自于早期大神 cloudgamer 的作品,在这里向前辈致敬! 组件本身的原理不算复杂难懂,也就寥寥 140 行代码(原 JavaScript),足以展现日历渲染的原理。

完工后的日历控件如下动图。

在这里插入图片描述

在线例子:https://framework.ajaxjs.com/demo/form/calendar.html
源码在:https://gitee.com/sp42_admin/ajaxjs/blob/master/aj-ts/src/form/html-editor.ts

使用方法

该控件 aj-form-calendar 一般不应单独使用,应作为子组件嵌入到父组件,例如带输入框的组件 aj-form-calendar-input,已经封装好。当用户选择日期后,会触发 pick-date 事件,并传入 date 日期的参数,形如 2018-8-8

<div class="aj-form-calendar-input">
	<aj-form-calendar-input field-name="date" field-value="2018-8-2"></aj-form-calendar-input>
</div>

属性值:

属性含义类型是否必填,默认值
field-name表单 name,字段名Stringy
field-value表单值,可选的Stringn

单独使用:

<div class="aj-form-calendar calendar1">
	<aj-form-calendar></aj-form-calendar>
</div>

绘制组件及声明属性

组件标签

我们声明组件名字为 aj-form-calendartemplate 标签部分,主要是绘制出基本的日历轮廓,源码如下。

/**
 * 日期选择器
 */
export class Calendar extends VueComponent {
    name = "aj-form-calendar";

    template = html`
        <div class="aj-form-calendar">
            <div class="selectYearMonth">
                <a href="###" @click="getDate('preYear')" class="preYear" title="上一年">&lt;</a>
                <select @change="setMonth" v-model="month">
                    <option value="1">一月</option>
                    <option value="2">二月</option>
                    <option value="3">三月</option>
                    <option value="4">四月</option>
                    <option value="5">五月</option>
                    <option value="6">六月</option>
                    <option value="7">七月</option>
                    <option value="8">八月</option>
                    <option value="9">九月</option>
                    <option value="10">十月</option>
                    <option value="11">十一月</option>
                    <option value="12">十二月</option>
                </select>
                <a href="###" @click="getDate('nextYear')" class="nextYear" title="下一年">&gt;</a>
            </div>
            <div class="showCurrentYearMonth">
                <span class="showYear">{{year}}</span>/<span class="showMonth">{{month}}</span>
            </div>
            <table>
                <thead>
                    <tr>
                        <td>日</td>
                        <td>一</td>
                        <td>二</td>
                        <td>三</td>
                        <td>四</td>
                        <td>五</td>
                        <td>六</td>
                    </tr>
                </thead>
                <tbody @click="pickDay"></tbody>
            </table>
            <div v-if="showTime" class="showTime">
                时 <select class="hour aj-select">
                    <option v-for="n in 24">{{n}}</option>
                </select>
                分 <select class="minute aj-select">
                    <option v-for="n in 61">{{n - 1}}</option>
                </select>
                <a href="#" @click="pickupTime">选择时间</a>
            </div>
        </div>`;
        ……
 }

一个月有 4 个星期 7 天共 28-31 日,但日历展示的话,为了预留头尾日子需要1+4+1 = 6个星期,即最大 6 x 7 = 42 格子,用 <table> 元素绘制。年月日各部分说明如下。

  • 选择年份用 <a> 标签绘制成为左右两个“箭头”;
  • 选择月份用 <select> 下拉绘制。相关标签的事件都有对应的绑定;
  • 表格显示日子,<thead> 是显示星期 x 的部分,<tbody > 就是 42 个待填充的格子。

在这里插入图片描述

组件属性

日历顾名思义是选择日期的,它最终返回一个日期。日期对象展开来说无非三种数据:年份 year、月份 month、日期 day(天)。时分秒的暂且不表。显然,我们应该设置组件一个 date: Date 的核心属性,设置这个 date 可以改变日历显示日期,获取它就是读取当前选择的日期。

我们知道 Vue 的 data 项为组件的属性列表,但在组件中必须被包裹一层 function 然后返回。缺省值是当前现在的日期。而 year、month 的值都是根据 date 获取的。月份 date.getMonth() + 1 这里比较奇葩,浏览器 API 月份是从零开始算的,故加上 1。日期天数则写死为 1——为什么是 1 呢?下文再说。

data() {
    let date: Date = new Date;
    
    return {
        date: date,
        year: date.getFullYear(),
        month: date.getMonth() + 1,
        day: 1
    };
}

同时我们约定,只要 date 属性一发生变化,立刻同步修改 year、month 的值,于是设置一个 watch 监视项。

watch = {
    date(this: Calendar): void {
        this.year = this.date.getFullYear();
        this.month = this.date.getMonth() + 1;
        this.render();
    }
};

值得注意的是,修改年份不会影响其月份,但天数就会重置为 1 号。举例子说,当前是 2019-3-12,用户修改了年份,从 2019 修改为 2018,那么控件的日期是 2018-3-1,月份不变,天数变为 1;月份也是同样道理,修改月份而年份不变;但天数的话,一般重置为 1 号,为什么会变为1 呢?因为如果当前选择了 31 号,而后来选择的月份没有 31 号的话,就出错了,处理起来比较麻烦,所以干脆统一设为 1 号。

渲染算法

日历组件中,render()getDateArr() 是重要的渲染的方法。思路是:1、算出这个月1号距离前面的星期天有多少天;2、这个月有多少天;3、剩余的用空的 <td> 填充(其实前面的也是<td> 填充,如下图红色框的空白)。

在这里插入图片描述

生成日子(days)列表数组

第一个问题,浏览器 API 的 Date.getDay() 就是返回该日是礼拜几的,0=礼拜天,1=星期一……如此类推。换个意思就是距离前面星期天有多少个日子,正好符合第一个问题的需求,故 Date.getDay() 返回的就是上图中红色框的空白 <td> 的个数。我们将要显示的 day 日子保存到一个数组中(arr: number[] = []),0 表示渲染空白的单元格 <td>

/**
 * 获取空白的非上月天数 + 当月天数
 * 
 * @param this 
 */
private getDateArr(): number[] {
    let arr: number[] = [];

    // 算出这个月1号距离前面的星期天有多少天
    for (let i = 1, firstDay: number = new Date(this.year, this.month - 1, 1).getDay(); i <= firstDay; i++)
        arr.push(0);

    // 这个月有多少天。用上个月然后设置日子参数为 0,就可以得到本月有多天
    for (let i = 1, monthDay: number = new Date(this.year, this.month, 0).getDate(); i <= monthDay; i++)
        arr.push(i);

    return arr;
}

注意我们前面对月份 this.mouth 加了 1,故这里获取目标月份要减去 1 才正确。

至于第二问题,也是浏览器 API 的方法,不过就比较奇葩。要算出这个月有多少天,用上个月然后设置日子参数为 0,就可以得到本月有多天:new Date(this.year, this.month, 0).getDate()。注意这里的 this.month 虽然没有减 1 了,但返回的仍然是本月,和上面的一样是一个月。

我们看看浏览器控制台打印的数组比较清楚,

在这里插入图片描述

渲染

有了显示内容,就要把它们打印(渲染)出来,这部分不能用 Vue 的 MVVM 渲染,只能依靠传统的 DOM 写出。

/**
 * 画日历
 */
render(): void {
    let arr: number[] = this.getDateArr(),// 用来保存日期列表
        frag: DocumentFragment = document.createDocumentFragment();// 插入日期

    while (arr.length) {
        let row: HTMLTableRowElement = document.createElement("tr"); // 每个星期插入一个 tr

        for (let i = 1; i <= 7; i++) { // 每个星期有7天
            let cell: HTMLTableDataCellElement = document.createElement("td");

            if (arr.length) {
                let d: number | undefined = arr.shift();

                if (d) {
                    let text: string = this.year + '-' + this.month + '-' + d;
                    cell.title = text;  // 保存日期在 title 属性
                    cell.className = 'day day_' + text;
                    cell.innerHTML = d + "";

                    let on: Date = new Date(this.year, this.month - 1, d);

                    // 判断是否今日
                    if (isSameDay(on, this.date)) {
                        cell.classList.add('onToday');
                        // this.onToday && this.onToday(cell);// 点击 今天 时候触发的事件
                    }
                    // 判断是否选择日期
                    // this.selectDay && this.onSelectDay && this.isSameDay(on, this.selectDay) && this.onSelectDay(cell);
                }
            }

            row.appendChild(cell);
        }

        frag.appendChild(row);
    }

    // 先清空内容再插入
    let tbody = <HTMLElement>this.$el.$("table tbody");
    tbody.innerHTML = '';
    tbody.appendChild(frag);
}

要生成的有 42个 <td>,少一个也不行,否则是非法 HTML 结构。这里用 while 循环 + for 循环消耗 arr 队列(arr.shift() 弹出第一个元素),详见代码。注意保存日期在 title 属性。

结语

本文重点探讨了日历组件渲染的问题,希望能给读者带来"拨开云雾见月明"的收获感觉。

其他就是围绕组件的一些方法和事件,大家可以直接看代码,没啥特别难的地方了。还有例如显示时间的 API,大家看看文档和例子就知道了。

相关推荐
©️2020 CSDN 皮肤主题: 数字20 设计师:CSDN官方博客 返回首页