vue 手寫一個時間選擇器
- 2019 年 11 月 5 日
- 筆記
最近研究了 DatePicker 的實現原理後做了一個 vue 的 DatePicker 組件,今天帶大家一步一步實現 DatePicker 的 vue 組件。
原理
DatePicker 的原理是——計算日曆面板中當月或選中月份的總天數及前後月份相近的日子,根據點擊事件計算日曆面板顯示內容,以及將所選值賦值給
<input/>
標籤。
實現
- CSS 程式碼於文章末尾處
1. 構思頁面結構
DatePicker 組件由輸入框和日曆面板組成,寫好頁面主體結構。
<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 布爾值控制日曆面板的顯示隱藏。
日曆面板由頂部條和面板兩部分組成,而面板則由年份選擇面板,月份選擇面板,日期選擇面板所組成,結構如下:
<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>
2. 頁面數據實現
DatePicker 所對應的 data 程式碼
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天。面板按周日至周六設計,最極端的情況如下:
最多的極端情況:
日 |
一 |
二 |
三 |
四 |
五 |
六 |
---|---|---|---|---|---|---|
* |
* |
* |
* |
* |
* |
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); } }
3. 實現頁面功能
(1)面板切換功能

- 點擊輸入框,除了打開日曆面板,同時也默認為日期面板
openPanel() { this.panelState = !this.panelState; this.panelType = "date"; },
- 點擊 2018 年份進入年份面板,點擊相對應年份顯示該年份並進入月份選擇面板
<span class="year" @click="panelType = 'year'">{{tmpYear}}</span>
selectYear(item) { this.tmpYear = item; this.panelType = "month"; },
- 點擊 Aug 月份進入月份面板,點擊相對應月份顯示該月份並進入日期選擇面板
<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; },
最後,貼上 CSS 程式碼…
- fadeDownBig 後面的樣式為 vue
<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; } }