一、日历组件简介

日历组件主要是由一个文本输入框组成,点击文本输入框后会在文本框下方显示日历面板,日历面板包含三部分:头部区(主要显示当面日历面板对应的年月以及四个年月上下切换按钮)、内容区(显示星期、以及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.scss

@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";
 }
}



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

(来啦,老弟)从零实现一个日历组件_java

探讨的全部代码

<template>
 <div class="calendar"
      v-click-outside>
   <input type="text"
          placeholder="选择日期"
          class="calendar_input"
          :value="formatDate"
          ref="input" />
   <span class="input_prefix">
     <i class="iconfont iconrili"></i>
   </span>

   <!-- 日历面板 -->
   <div class="calendar_box"
        v-if="isVisible">
     <span class="triangle"></span>
     <!--面板上部三角形-->
     <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 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)])
                       },
                       {
                         select: isSelect(visibleDays[(i -1) * 7 + (j -1)])
                       }
                   ]"
               @click="chooseDate(visibleDays[(i -1) * 7 + (j -1)])">
           <!--获取到每一天对应的日期date值进行显示-->
           {{visibleDays[(i -1) * 7 + (j -1)].getDate()}}
         </span>
       </div>

     </div>
     <!-- 日历底部 -->
     <div class="calendar_footer"
          @click="toToday">
       今天
     </div>
   </div>

 </div>
</template>
<script>
import util from '../utils/util'
export default {
 name: 'calendar',
 props: {
   value: {
     type: Date,
     default: function() {
       return new Date()
     },
   },
 },
 methods: {
   toToday() {
     this.time = util.getYearMonthDay(new Date())
   },
   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)
   },
   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
   },
   chooseDate(date) {
     // 日历面板上有42天,所以用户有可能选择了其他月份的时间,日历面板也需要进行相应的更新
     this.time = util.getYearMonthDay(date) // 更新this.time即可更新日历面板显示的年月,从而更新42天
     this.$emit('input', date)
     // this.blur()
   },
   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
   },
 },
 computed: {
   formatDate() {
     const { year, month, day } = util.getYearMonthDay(this.value)
     return `${year}-${month + 1}-${day}`
   },
   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
   },
 },
 data() {
   const { year, month } = util.getYearMonthDay(this.value) // 获取传递时间对应的年、月
   return {
     isVisible: false, // 控制面板是否可见
     time: { year, month }, // 定义time对象显示当前年、月
     weekDays: ['日', '一', '二', '三', '四', '五', '六'],
   }
 },
 directives: {
   // 添加指令对象
   clickOutside: {
     bind(el, binding, vnode) {
       const handler = e => {
         if (el.contains(e.target)) {
           // 如果点击的文本框,需要显示日历面板
           if (!vnode.context.isVisible) {
             // 如果isVisible为false则打开日历面板
             // console.log("vnode===>", vnode)
             vnode.context.isVisible = true
             e.target.focus()
           }
         } else {
           // 如果点击的不是文本框,而是文本框的外部
           if (vnode.context.isVisible) {
             // 如果isVisible为true则关闭日历面板
             vnode.context.isVisible = false
             e.target.blur()
           }
         }
       }
       el.handler = handler // 将事件处理函数保存到el上,即指令所在DOM上,方便解绑移除事件处理函数
       document.addEventListener('click', handler)
     },
     unbind(el) {
       document.removeEventListener('click', el.handler)
     },
   },
 },
}
</script>
<style scoped>
@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 {
 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, 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;
}

.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;
}

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

.select {
 border: 1px solid pink;
 box-sizing: border-box;
 border-radius: 4px;
}

.calendar_footer {
 height: 30px;
 line-height: 30px;
 padding: 5px 0;
 border: 1px solid #e4e7ed;
 border-radius: 0 0 4px 4px;
 text-align: center;
 cursor: pointer;
}
</style>

其中 utils/util.js 如下

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


动态效果如下

(来啦,老弟)从零实现一个日历组件_java_02