最近研究了 DatePicker 的實現原理後作了一個 vue 的 DatePicker 組件,今天帶你們一步一步實現 DatePicker 的 vue 組件。javascript
DatePicker 的原理是——計算日曆面板中當月或選中月份的總天數及先後月份相近的日子,根據點擊事件計算日曆面板顯示內容,以及將所選值賦值給
<input/>
標籤。
DatePicker 組件由輸入框和日曆面板組成,寫好頁面主體結構。html
<div class="date-picker"> <input class="input" v-model="dateValue" @click="openPanel"/> <transition name="fadeDownBig"> <div class="date-panel" v-show="panelState"></div> </transiton> </div>
輸入框<input>
點擊顯示或隱藏日曆面板,openPanel()方法改變 panelState 布爾值控制日曆面板的顯示隱藏。vue
日曆面板由頂部條和麪板兩部分組成,而面板則由年份選擇面板,月份選擇面板,日期選擇面板所組成,結構以下:java
<div class="date-panel" v-show="panelState"> <!-- 頂部按鈕及年月顯示條 --> <div class="topbar"> <span @click="leftBig"><<</span> <span @click="left"><</span> <span class="year" @click="panelType = 'year'">{{tmpYear}}</span> <span class="month" @click="panelType = 'month'">{{changeTmpMonth}}</span> <span @click="right">></span> <span @click="rightBig">>></span> </div> <!-- 年面板 --> <div class="type-year" v-show="panelType === 'year'"> <ul class="year-list"> <li v-for="(item, index) in yearList" :key="index" @click="selectYear(item)" > <span :class="{selected: item === tmpYear}" >{{item}}</span> </li> </ul> </div> <!-- 月面板 --> <div class="type-year" v-show="panelType === 'month'"> <ul class="year-list"> <li v-for="(item, index) in monthList" :key="index" @click="selectMonth(item)" > <span :class="{selected: item.value === tmpMonth}" >{{item.label}}</span> </li> </ul> </div> <!-- 日期面板 --> <div class="date-group" v-show="panelType === 'date'"> <span v-for="(item, index) in weekList" :key="index" class="weekday">{{item.label}}</span> <ul class="date-list"> <li v-for="(item, index) in dateList" v-text="item.value" :class="{preMonth: item.previousMonth, nextMonth: item.nextMonth, selected: date === item.value && month === tmpMonth && item.currentMonth, invalid: validateDate(item)}" :key="index" @click="selectDate(item)"> </li> </ul> </div> </div>
DatePicker 所對應的 data 代碼git
data() { return { dateValue: "", // 輸入框顯示日期 date: new Date().getDate(), // 當前日期 panelState: false, // 初始值,默認panel關閉 tmpMonth: new Date().getMonth(), // 臨時月份,可修改 month: new Date().getMonth(), tmpYear: new Date().getFullYear(), // 臨時年份,可修改 weekList: [ { label: "Sun", value: 0 }, { label: "Mon", value: 1 }, { label: "Tue", value: 2 }, { label: "Wed", value: 3 }, { label: "Thu", value: 4 }, { label: "Fri", value: 5 }, { label: "Sat", value: 6 } ], // 周 monthList: [ { label: "Jan", value: 0 }, { label: "Feb", value: 1 }, { label: "Mar", value: 2 }, { label: "Apr", value: 3 }, { label: "May", value: 4 }, { label: "Jun", value: 5 }, { label: "Jul", value: 6 }, { label: "Aug", value: 7 }, { label: "Sept", value: 8 }, { label: "Oct", value: 9 }, { label: "Nov", value: 10 }, { label: "Dec", value: 11 } ], // 月 nowValue: 0, // 當前選中日期值 panelType: "date" // 面板狀態 }; },
DatePicker 的核心在於日期面板的數據。咱們知道,一個月最多31天,最少28天。面板按週日至週六設計,最極端的狀況以下:github
最多的極端狀況:web
日 | 一 | 二 | 三 | 四 | 五 | 六 |
---|---|---|---|---|---|---|
* | * | * | * | * | * | 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | 1 | 2 | 3 | 4 | 5 |
最少的極端狀況:數組
日 | 一 | 二 | 三 | 四 | 五 | 六 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
根據上表咱們能夠得知一個月最多佔六個星期,最少四個星期,因此日曆面板必須設計爲 6 行,剩餘的用下個月的日期補上,最多補14天。所以日期數組能夠這麼設計:函數
computed: { dateList() { //獲取當月的天數 let currentMonthLength = new Date( this.tmpYear, this.tmpMonth + 1, 0 ).getDate(); //先將當月的日期塞入dateList let dateList = Array.from( { length: currentMonthLength }, (val, index) => { return { currentMonth: true, value: index + 1 }; } ); // 獲取當月1號的星期是爲了肯定在1號前須要插多少天 let startDay = new Date(this.tmpYear, this.tmpMonth, 1).getDay(); // 確認上個月一共多少天 let previousMongthLength = new Date( this.tmpYear, this.tmpMonth, 0 ).getDate(); // 在1號前插入上個月日期 for (let i = 0, len = startDay; i < len; i++) { dateList = [ { previousMonth: true, value: previousMongthLength - i } ].concat(dateList); } // 補全剩餘位置,至少14天,則 i < 15 for (let i = 1, item = 1; i < 15; i++, item++) { dateList[dateList.length] = { nextMonth: true, value: i }; } return dateList; }, }
changeTmpMonth 爲選擇月份後顯示的文案,yearList 爲年份列表,爲了與月份數量保持一致,咱們也設長度爲12.動畫
computed: { changeTmpMonth() { return this.monthList[this.tmpMonth].label; }, // 經過改變this.tmpYear則能夠改變年份數組 yearList() { return Array.from({ length: 12 }, (value, index) => this.tmpYear + index); } }
(1)面板切換功能
openPanel() { this.panelState = !this.panelState; this.panelType = "date"; },
<span class="year" @click="panelType = 'year'">{{tmpYear}}</span>
selectYear(item) { this.tmpYear = item; this.panelType = "month"; },
<span class="month" @click="panelType = 'month'">{{changeTmpMonth}}</span>
selectMonth(item) { this.tmpMonth = item.value; this.panelType = "date"; },
點擊日期選擇日期,關閉面板同時賦值給輸入框
// methods selectDate(item) { // 賦值 當前 nowValue,用於控制樣式突出顯示當前月份日期 this.nowValue = item.value; // 選擇了上個月 if (item.previousMonth) this.tmpMonth--; // 選擇了下個月 if (item.nextMonth) this.tmpMonth++; // 獲取選中日期的 date let selectDay = new Date(this.tmpYear, this.tmpMonth, this.nowValue); // 格式日期爲字符串後,賦值給 input this.dateValue = this.formatDate(selectDay.getTime()); // 關閉面板 this.panelState = !this.panelState; }, // 日期格式方法 formatDate(date, fmt = this.format) { if (date === null || date === "null") { return "--"; } date = new Date(Number(date)); var o = { "M+": date.getMonth() + 1, // 月份 "d+": date.getDate(), // 日 "h+": date.getHours(), // 小時 "m+": date.getMinutes(), // 分 "s+": date.getSeconds(), // 秒 "q+": Math.floor((date.getMonth() + 3) / 3), // 季度 S: date.getMilliseconds() // 毫秒 }; if (/(y+)/.test(fmt)) fmt = fmt.replace( RegExp.$1, (date.getFullYear() + "").substr(4 - RegExp.$1.length) ); for (var k in o) { if (new RegExp("(" + k + ")").test(fmt)) fmt = fmt.replace( RegExp.$1, RegExp.$1.length === 1 ? o[k] : ("00" + o[k]).substr(("" + o[k]).length) ); } return fmt; }, // 確認是否爲當前月份 validateDate(item) { if (this.nowValue === item.value && item.currentMonth) return true; },
(2)topbar 中左右箭頭功能,具體詳看下面方法
// < left() { if (this.panelType === "year") this.tmpYear--; else { if (this.tmpMonth === 0) { this.tmpYear--; this.tmpMonth = 11; } else this.tmpMonth--; } }, // << leftBig() { if (this.panelType === "year") this.tmpYear -= 12; else this.tmpYear--; }, // > right() { if (this.panelType === "year") this.tmpYear++; else { if (this.tmpMonth === 11) { this.tmpYear++; this.tmpMonth = 0; } else this.tmpMonth++; } }, // >> rightBig() { if (this.panelType === "year") this.tmpYear += 12; else this.tmpYear++; },
(3) 實現輸入框的雙向綁定及格式規定
props
props: { value: { type: [Date, String], default: "" }, format: { type: String, default: "yyyy-MM-dd" } },
其中 value 支持日期格式和字符串,當設置了props時,則需在monted鉤子函數中初始化input 值。format 默認值爲 "yyyy-MM-dd", 固然你也能夠設置爲 "dd-MM-yyyy"等。
mounted() { if (this.value) { this.dateValue = this.formatDate(new Date(this.value).getTime()); } },
雙向綁定父組件賦值 props 爲 value, 子組件傳遞的事件爲input, 所以需在 selectDate 方法中 emit 事件及數據給父組件
selectDate(item) { ... this.$emit("input", selectDay); },
這樣,父組件即可以進行雙向綁定了
<Datepicker v-model="time" format="dd-MM-yyyy"/>
(4)點擊頁面其餘位置收起日曆面板
原理監聽頁面的點擊事件,檢測到有點擊事件時關閉面板,但點擊組件內容時也會觸發點擊事件,所以須要在組件內部阻止冒泡。同時,當組件銷燬時,也要及時清除該監聽器。
組件最外層阻止冒泡
<div class="date-picker" @click.stop></div>
頁面建立設置監聽
mounted() { ... window.addEventListener("click", this.eventListener); }
頁面銷燬清除監聽
destroyed() { window.removeEventListener("click", this.eventListener); }
公共方法
eventListener() { this.panelState = false; },
有用就點個讚唄~
<transiton>
的動畫特效..topbar { padding-top: 8px; } .topbar span { display: inline-block; width: 20px; height: 30px; line-height: 30px; color: #515a6e; cursor: pointer; } .topbar span:hover { color: #2d8cf0; } .topbar .year, .topbar .month { width: 60px; } .year-list { height: 200px; width: 210px; } .year-list .selected { background: #2d8cf0; border-radius: 4px; color: #fff; } .year-list li { display: inline-block; width: 70px; height: 50px; line-height: 50px; border-radius: 10px; cursor: pointer; } .year-list span { display: inline-block; line-height: 16px; padding: 8px; } .year-list span:hover { background: #e1f0fe; } .weekday { display: inline-block; font-size: 13px; width: 30px; color: #c5c8ce; text-align: center; } .date-picker { width: 210px; text-align: center; font-family: "Avenir", Helvetica, Arial, sans-serif; } .date-panel { width: 210px; box-shadow: 0 0 8px #ccc; background: #fff; } ul { list-style: none; padding: 0; margin: 0; } .date-list { width: 210px; text-align: left; height: 180px; overflow: hidden; margin-top: 4px; } .date-list li { display: inline-block; width: 28px; height: 28px; line-height: 30px; text-align: center; cursor: pointer; color: #000; border: 1px solid #fff; border-radius: 4px; } .date-list .selected { border: 1px solid #2d8cf0; } .date-list .invalid { background: #2d8cf0; color: #fff; } .date-list .preMonth, .date-list .nextMonth { color: #c5c8ce; } .date-list li:hover { background: #e1f0fe; } input { display: inline-block; box-sizing: border-box; width: 100%; height: 32px; line-height: 1.5; padding: 4px 7px; font-size: 12px; border: 1px solid #dcdee2; border-radius: 4px; color: #515a6e; background-color: #fff; background-image: none; position: relative; cursor: text; transition: border 0.2s ease-in-out, background 0.2s ease-in-out, box-shadow 0.2s ease-in-out; margin-bottom: 6px; } .fadeDownBig-enter-active, .fadeDownBig-leave-active, .fadeInDownBig { -webkit-animation-duration: 0.5s; animation-duration: 0.5s; -webkit-animation-fill-mode: both; animation-fill-mode: both; } .fadeDownBig-enter-active { -webkit-animation-name: fadeInDownBig; animation-name: fadeInDownBig; } .fadeDownBig-leave-active { -webkit-animation-name: fadeOutDownBig; animation-name: fadeOutDownBig; } @-webkit-keyframes fadeInDownBig { from { opacity: 0.8; -webkit-transform: translate3d(0, -4px, 0); transform: translate3d(0, -4px, 0); } to { opacity: 1; -webkit-transform: none; transform: none; } } @keyframes fadeInDownBig { from { opacity: 0.8; -webkit-transform: translate3d(0, -4px, 0); transform: translate3d(0, -4px, 0); } to { opacity: 1; -webkit-transform: none; transform: none; } } @-webkit-keyframes fadeOutDownBig { from { opacity: 1; } to { opacity: 0.8; -webkit-transform: translate3d(0, -4px, 0); transform: translate3d(0, -4px, 0); } } @keyframes fadeOutDownBig { from { opacity: 1; } to { opacity: 0; } }