一、日曆組件簡介
日曆組件主要是由一個文本輸入框組成,點擊文本輸入框後會在文本框下方顯示日曆面板,日曆面板包含三部分: 頭部區(主要顯示當面日曆面板對應的年月以及四個年月上下切換按鈕)、內容區(顯示星期、以及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><<</span>
<span><</span>
<span class="header_time">
<span>{{time.year}}年</span>
<span>{{time.month + 1}}月</span>
</span>
<span>></span>
<span>>></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) {
// 日曆面板上有12天,所以用戶有可能選擇了其他月份的時間,日曆面板也需要進行相應的更新
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"><<</span>
<span @click="preMonth"><</span>
<span class="header_time">
<span>{{time.year}}年</span>
<span>{{time.month + 1}}月</span>
</span>
<span @click="nextMonth">></span>
<span @click="nextYear">>></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());
}
}
⑯ 最終效果圖,如圖所示