乐趣区

从零实现一个日历组件

一、日历组件简介

日历组件主要是由一个 文本输入框 组成,点击文本输入框后会在文本框下方显示 日历面板 ,日历面板包含三部分: 头部区 (主要显示当面日历面板对应的年月以及四个年月上下切换按钮)、 内容区 (显示星期、以及 42 天)、 底部区(今天快捷按钮,点击可以直接跳转到今天),同时点击日历面板外部可以关闭日历面板。

二、日历组件关键点

日历组件的关键点在于日历面板的显示,观察日历可以发现,每个日历面板上都会显示 42 天,但是一个月有 28~31 天,所以 这 42 天中肯定有些是非本月时间 ,这些 非本月时间就需要置灰显示 每行有 7 列 (因为每周有 7 天,每一天都会对应一个周几),总共有 6 行,至于为什么需要 6 行是因为, 第一行肯定是显示当月的 1 号 ,但是如果 某个月的 1 号是周六 ,那么 第一行 7 天中就只显示了当月的 1 号一天 ,而一个月可能会有 31 天, 如果后面只有 4 行,那么最多只能显示 1 + 28 = 29 天,无法显示 31 天,所以总共必须是 6 行才能完全显示出当月的全部天数。

观察日历还可以发现一个规律,就是 当月 1 号对应的是周几 ,那么 前面就要显示下一个月的几天 ,这样我们就可以 根据 1 号的时间向前移动几天 找到 42 天中的第一天对应的时间,然后进行遍历,遍历一次加一天,直到 42 天,就可以显示每月日历面板上的时间了。

三、从零实现一个日历组件

新建一个项目名为 calendar 的文件夹
进入 calendar 项目中,执行npm init --yes 进行项目初始化生成对应的 package.json 文件
这里使用快速原型开发模式,npm install -g @vue/cli-service-global
在 calendar 项目根目录下新建一个 App.vue 文件,如:

<template>
    <div id="app">
        hello calendar
    </div>
</template>

通过 vue serve 启动项目,会自动加载 calendar 项目根目录下的 App.vue 根组件并执行,在浏览器中输入 http://localhost:8080 如果打印出了hello calendar,表示环境搭建成功。

接下来我们开始编写日历组件了,首先在 calendar 项目根目录下新建一个 components 目录,然后在其中新建一个 calendar.vue 组件,日历组件 接收一个 value 属性 ,数据类型为Date 日期类型,默认值为 当前时间,内容如下:

<template>
    <div class="calendar">
        日历组件{{value}}
    </div>
</template>
<script>
export default {
    props: {
        value: {
            type: Date,
            default: () => new Date()
        }
    }
}
</script>

修改 App.vue,并引入 calendar.vue 日历组件,如:

<template>
    <div id="app">
        <calendar v-model="now"></calendar>
    </div>
</template>
<script>
import Calendar from "./components/calendar"
export default {
    components: {calendar: Calendar},
    data () {
        return {now: new Date()
        }
    }
}
</script>

此时我们的日历组件可以正常渲染了,接下来我们开始编写日历中的内容了,日历组件包括一个文本输入框和一个日历面板,日历面板中的内容我们后面实现,这一步先写文本框样式及日历面板非内容部分,如:
// 添加 iconfont 字体样式,主要用于文本框中的日历图标
// 在 components 文件夹中新建一个 css 文件夹,再新建一个 iconfont.css

@font-face {font-family: "iconfont";
    src:url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAALwAAsAAAAAB8QAAAKkAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHEIGVgCCcAqDUIMmATYCJAMICwYABCAFhG0HLhvKBhHVkz1kPwrj9qSlmDfJebNDpSCSnDR9XwTPox31fpKZzVo6SC3E6nqoP3dgB5dEPfs/Z9kkCxthinLICnUpv8BpduBOq3vTbgHwx73TvwIKZD6gnObY+KmLoy7cGtDeGEVWICmGmTeM3UR5ELchgB9JFCAdXZc7WAxgkQCyannogk3pMDXFgkVwS3Ya5BgOVu1XjwGO8vfLVygTCwpHA8pGlmwDaPmYB9P0Nu9vFkXgj2cBtH2ggQLAgEyU2obQYawAjZ8TM6TBuooFPuZ5H8pb7R8PBMQFFAYAkCDyzomPBadaqAAwrQYvA9d7FUNAjE0JAPM3ypkoP7adP3BRJICf6XcqgtUh6nRk8NnoOf4HL2C2nfcLKU1ztl/y9xfCyeoJlCWL6jga4tfK9kuT8TdMrd9Xo7LXufPOaEGhCaFBhR181BnHXefNP7jOrzDz3PP/oNCgD1jRIulutzbRt3aI1Ls/dTzaUODWxM88+8gjaAHAe2uoWPzAz3C/L2fd3GHDf+tvAHj17t4d7vHeBto5wN6mXeB38VvWGFcI9MrY/FKH4vJtL1SAH36AB7IrjPd9HZEQWwSr80VQ+JAIGksGaigF4OBPBbhYmsGPfLr3+xPOBjRifIE8dgsghHANFEHcAU0IT1BDeQcOUXwHlxDR4McUCT/RnyxJ4s6ayRUK0PvF2C8LhYzSCYqvFL4yl5NCTnsSN3EQLd3MJvdUEI+xpvkKbRGFisscd8J9lGUlVlwm5IseiVQjw1BlT9L9MocOtDO5QgHi/SKxXxaKNpdO7vVXCl+ZyzWkDvuTuImHRyx0zBboXla0Il3LI81XaCOiEMVljuwEC2UwViJV+bSEfNGJekSqEYZUT7WV6fMr8qfbBkAHgLrdgtUaw3EWAwA=') format('woff2')
  }
  
  .iconfont {
    font-family: "iconfont" !important;
    font-size: 16px;
    font-style: normal;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }
  
  .iconrili:before {content: "\e72a";}

// 修改 calendar.vue

<template>
    <div class="calendar">
        <input type="text" placeholder="选择日期" class="calendar_input"/>
        <span class="input_prefix">
            <i class="iconfont iconrili"></i>
        </span>
        <!-- 日历面板 -->
        <div class="calendar_box">
            <span class="triangle"></span> <!-- 面板上部三角形 -->
        </div>
    </div>
</template>
<script>
export default {
    props: {
        value: {
            type: Date,
            default: () => new Date()
        }
    }
}
</script>
<style scoped>
@import url("./css/iconfont.css");
.calendar {position: relative;}
.calendar_input {
    border: 1px solid #c0c4cc;
    padding: 0 30px;
    height: 40px;
    line-height: 40px;
    border-radius: 4px;
    outline: none;/* 去除边框外的轮廓 */
}
.calendar_input:focus {border: 1px solid #409eff;}
.input_prefix {
    height: 100%;
    width: 25px;
    text-align: center;
    position: absolute;
    left: 5px;
    top: 0;
    color: #c0c4cc;
}
.input_prefix i {line-height: 40px;}
.calendar_box {
    position: absolute;
    top: 50px;
    width: 400px;/* 暂时使用固定宽度和高度,后面会去除宽度和高度进行内容自适应现实 */
    height: 300px;
    border: 1px solid #e4e7ed;
    box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);
    border-radius: 4px;
}
.calendar_box .triangle {
    position: absolute;
    width: 0;
    height: 0;
    top: -14px;
    left: 25px;
    border: 7px solid transparent;
    border-bottom: 7px solid white;
}
.calendar_box::before {
    position: absolute;
    content: "";
    width: 0;
    height: 0;
    top: -16px;
    left: 24px;
    border: 8px solid transparent;
    border-bottom: 8px solid #e4e7ed;
} 
</style>

效果如图:

此时日历输入框和面板都已经绘制好了,接下来就是实现点击文本框显示日历面板,点击日历面板外部则关闭日历面板,要实现该功能需要通过自定义指令,因为指令就是对 DOM 操作进行封装,其主要是让 document 监听 click 事件,如果点击的元素在绑定指令的 DOM 内则打开日历面板,如果点击的元素不在绑定指令的 DOM 内则关闭日历面板,如:

<div class="calendar" v-click-outside> <!-- 绑定指令 -->
    ... 省略
</div>
export default {
    directives: { // 添加指令对象
        clickOutside: {bind(el, binding, vnode) {const handler = (e) => {if (el.contains(e.target)) { // 如果点击的文本框,需要显示日历面板
                        if (!vnode.context.isVisible) { // 如果 isVisible 为 false 则打开日历面板
                            vnode.context.focus();}
                    } else { // 如果点击的不是文本框,而是文本框的外部
                        if (vnode.context.isVisible) { // 如果 isVisible 为 true 则关闭日历面板
                            vnode.context.blur();}
                    }
                };
                el.handler = handler; // 将事件处理函数保存到 el 上,即指令所在 DOM 上,方便解绑移除事件处理函数
                document.addEventListener("click", handler);
            },
            unbind(el) {document.removeEventListener("click", el.handler);
            }
        } 
    }
}

总之,就是 点击了指令所在 DOM 元素内部 则打开日历面板,如果 点击了指令所在 DOM 外部 则关闭日历面板

此时已经实现了点击文本框显示日历面板,点击日历面板外部,则关闭日历面板,接下来就是需要显示日历面板中的具体内容了:
首先我们向日历组件中传递了一个当前日期 Date 对象,我们应该根据这个 Date 对象提取出对应的年、月、日,在根目录下新建一个 utils 目录,并新建一个 util.js 文件,内容如下:

const getYearMonthDay = (date) => {const year = date.getFullYear(); // 获取年
    const month = date.getMonth(); // 获取月
    const day = date.getDate(); // 获取日
    return {year, month , day};
}
export {getYearMonthDay}

然后我们在日历面板的头部,需要显示当前面板对应的年、月,在日历组件的 data 中调用 getYearMonthDay()方法获取到对应的年月即可,如:

export default {data () {const {year, month} = util.getYearMonthDay(this.value); // 获取传递时间对应的年、月
        return {
             isVisible : false, // 控制面板是否可见
             time: {year, month}, // 定义 time 对象显示当前年、月
             weekDays: ["日", "一", "二", "三", "四", "五", "六"],
        }
    }
}

// 日历面板

<div class="calendar_box" v-if="isVisible">
            <span class="triangle"></span> <!-- 面板上部三角形 -->
            <div class="calendar_header">
               <span>&lt;&lt;</span>
               <span>&lt;</span>
               <span class="header_time">
                   <span>{{time.year}}年 </span>
                   <span>{{time.month + 1}}月 </span>
               </span>
               <span>&gt;</span>
               <span>&gt;&gt;</span>
            </div>
            <div class="calendar_content">
                <span v-for="j in 7" :key="`_${j}`" class="cell">
                       {{weekDays[j - 1]}}
                </span>
            </div>
        </div>

// 对应 CSS 样式

.calendar_header {
    display: flex;
    justify-content: space-around;
    height: 30px;
    line-height: 30px;
    font-size: 14px;
    font-weight: 100;
}
.header_time {
    box-sizing: border-box;
    width: 50%;
    padding: 0 25px;
    height: 30px;
    line-height: 30px;
    color: #606266;
    font-size: 16px;
    font-weight: 500;
    display: flex;
    justify-content: space-between;
}
.calendar_content .cell {
    display: inline-flex;
    width: 41px;
    height: 41px;
    justify-content: center;
    align-items: center;
}

接下来就是计算当月中的 42 天,其思路就是,找到当月 1 号对应的是周几,然后往前移几天就是 42 天中的第一天,然后循环出 42 天即可,如:
// 添加一个计算属性用于计算当月显示的 42 天

export default {
    computed: {visibleDays() {
          // 获取当月第一天对应的 Date 对象
          const firstDayOfMonth = new Date(this.time.year, this.time.month, 1);
          // 获取当月第一天对应的是星期几
          const week = firstDayOfMonth.getDay();
          // 获取 42 天中的第一天对应的 Date 对象,即每月 1 号对应的时间减去 week 天
          const startDay = firstDayOfMonth - week * 60 * 60 * 1000 * 24; 
          const days = [];
          for (let i= 0; i< 42; i++) { // 循环出 42 天
             days.push(new Date(startDay + i * 60 * 60 * 1000 * 24));
          }
          return days;
      } 
}

// 遍历出这 42 天

<div class="calendar_content">
   <span v-for="j in 7" :key="`_${j}`" class="cell">
         {{weekDays[j - 1]}}
   </span>
   <div v-for="i in 6" :key="i"> <!-- 从 1 开始循环 -->
         <span v-for="j in 7" :key="j" class="cell">
            <!-- 获取到每一天对应的日期 date 值进行显示 -->
            {{visibleDays[(i -1) * 7 + (j -1)].getDate()}}
         </span>
   </div>
</div>

接下来我们就需要对不是当月的日期进行置灰显示,如果是今天,那么进行添加红色背景,其主要就是通过用当前日期对象进行判断,进行样式的动态变化,如:
// 添加两个方法

export default {
    methods: {isCurrentMonth(date) { // 判断传递的日期是否属于当月
            // 获取传递时间对应的年月
            const {year, month} = util.getYearMonthDay(date);
            // 与日历面板显示年、月进行比较,如果年月相同,那么是当月时间
            return year === this.time.year && month === this.time.month;
        },
        isToday(date) { // 判断传递的日期是否是今天
             // 获取传递时间对应的年月日
            const {year, month, day} = util.getYearMonthDay(date);
            // 获取今天时间对应的年月日
            const {year:y, month:m, day:d} = util.getYearMonthDay(new Date());
            return year === y && month === m && day === d;
        }
    }
}

// 动态添加上样式

<div class="calendar_content">
                <span v-for="j in 7" :key="`_${j}`" class="cell">
                       {{weekDays[j - 1]}}
                </span>
                <div v-for="i in 6" :key="i"> <!-- 从 1 开始循环 -->
                    <span v-for="j in 7" :key="j" class="cell" :class="[
                        {notCurrentMonth: !isCurrentMonth(visibleDays[(i -1) * 7 + (j -1)])
                        },
                        {today: isToday(visibleDays[(i -1) * 7 + (j -1)]) 
                        }
                    ]">
                       <!-- 获取到每一天对应的日期 date 值进行显示 -->
                       {{visibleDays[(i -1) * 7 + (j -1)].getDate()}}
                    </span>
                </div>
            </div>

// 添加 notCurrentMonth 和 today 样式

.notCurrentMonth {color: grey;}
.today {
    background: red;
    color: white;
    border-radius: 4px;
}

接下来就是选择时间了,当用户点击 42 天中的某个时间后,文本框中需要显示对应的时间,文本框中默认显示的是父组件传递过来的时间,由于子组件不能直接修改父组件传递过来的时间,所以选择日期后,需要通知父组件进行修改,父组件收到通知后对传递过来的时间进行修改,子组件也就可以拿到用户选择的时间进行显示了,如:
// 文本框中显示的时间是年 - 月 - 日,所以需要进行格式化
// 添加一个计算属性

export default {
    computed: {formatDate() {const {year, month, day} = util.getYearMonthDay(this.value);
            return `${year}-${month + 1}-${day}`;
      }
    },
    methods: {chooseDate(date) {
            // 日历面板上有 42 天,所以用户有可能选择了其他月份的时间,日历面板也需要进行相应的更新
            this.time = util.getYearMonthDay(date); // 更新 this.time 即可更新日历面板显示的年月,从而更新 42 天
            this.$emit("input", date);
            this.blur();}
    }
}
 <input type="text" placeholder="选择日期" class="calendar_input" :value="formatDate"/>
 <span v-for="j in 7" :key="j" class="cell" @click="chooseDate(visibleDays[(i -1) * 7 + (j -1)])">

用户选择好时间后,再次打开面板的时候,需要可以看到选择的是哪个日期,所以需要判断用户选择的日期,然后进行动态样式的动态切换,如:

export default {
    methods: {isSelect(date) { // 传递面板上的时间,判断是不是用户选择的日期
            // 获取面板上日期对应的年、月、日
            const {year, month, day} = util.getYearMonthDay(date);
            // 获取用户已选择时间对应的年、月、日
            const {year:y, month:m, day:d} = util.getYearMonthDay(this.value);
            return year===y && month === m && day === d;
        }
    }
}
.select {
    border:  1px solid pink;
    box-sizing: border-box;
    border-radius: 4px;
}

接下来就是上一个月、下一个月、上一年、下一年切换了,其非常简单,就是根据当前面板显示的年月,任意获取面板中的一天,比如每月的 1 号,然后创建一个 Date 对象,通过该 Date 对象获取到当前的月份或年份进行相应的加减 1 即可,如:

export default {
    methods: {preYear() {
            // 获取当前面板中的任意 1 天,比如当月 1 号对应的 Date 对象
            const someDayOfCurrentMonth = new Date(this.time.year, this.time.month, 1);
            const currentYear = someDayOfCurrentMonth.getFullYear();
            // 将当前面板中的某一天修改为上一个月中的某一天
            someDayOfCurrentMonth.setFullYear(currentYear - 1);
            // 从上一个月中的某一天获取对应的年月更新 this.time
            this.time = util.getYearMonthDay(someDayOfCurrentMonth);
        },
        preMonth() {
            // 获取当前面板中的任意 1 天,比如当月 1 号对应的 Date 对象
            const someDayOfCurrentMonth = new Date(this.time.year, this.time.month, 1);
            const currentMonth = someDayOfCurrentMonth.getMonth();
            // 将当前面板中的某一天修改为上一个月中的某一天
            someDayOfCurrentMonth.setMonth(currentMonth - 1);
            // 从上一个月中的某一天获取对应的年月更新 this.time
            this.time = util.getYearMonthDay(someDayOfCurrentMonth);

        },
        nextYear() {
            // 获取当前面板中的任意 1 天,比如当月 1 号对应的 Date 对象
            const someDayOfCurrentMonth = new Date(this.time.year, this.time.month, 1);
            const currentYear = someDayOfCurrentMonth.getFullYear();
            // 将当前面板中的某一天修改为上一个月中的某一天
            someDayOfCurrentMonth.setFullYear(currentYear + 1);
            // 从上一个月中的某一天获取对应的年月更新 this.time
            this.time = util.getYearMonthDay(someDayOfCurrentMonth);
        },
        nextMonth() {
            // 获取当前面板中的任意 1 天,比如当月 1 号对应的 Date 对象
            const someDayOfCurrentMonth = new Date(this.time.year, this.time.month, 1);
            const currentMonth = someDayOfCurrentMonth.getMonth();
            // 将当前面板中的某一天修改为上一个月中的某一天
            someDayOfCurrentMonth.setMonth(currentMonth + 1);
            // 从上一个月中的某一天获取对应的年月更新 this.time
            this.time = util.getYearMonthDay(someDayOfCurrentMonth);
        }
    }
}
<div class="calendar_header">
     <span @click="preYear">&lt;&lt;</span>
     <span @click="preMonth">&lt;</span>
     <span class="header_time">
           <span>{{time.year}}年 </span>
           <span>{{time.month + 1}}月 </span>
     </span>
     <span @click="nextMonth">&gt;</span>
     <span @click="nextYear">&gt;&gt;</span>
</div>

可以切换年、月后,如果用户切得比较远了,想要选择今天就会非常困难,所以需要提供一个快捷方式,点击即可回到今天,在面板底部添加一个 div 内容为今天,并添加一个事件,事件只需要获取到今天的时间,然后设置 this.time 的年月值即可,如:

<div class="calendar_footer" @click="toToday">
                今天
</div>
.calendar_footer {
    height: 30px;
    line-height: 30px;
    padding: 5px 0;
    border: 1px solid #e4e7ed;
    border-radius: 4px;
    text-align: center;
    cursor: pointer;
}
export default {toToday() {this.time = util.getYearMonthDay(new Date());
    }
}

最终效果图,如图所示

退出移动版