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">&lt;&lt;</span>      <span @click="left">&lt;</span>      <span class="year" @click="panelType = 'year'">{{tmpYear}}</span>      <span class="month" @click="panelType = 'month'">{{changeTmpMonth}}</span>      <span @click="right">&gt;</span>      <span @click="rightBig">&gt;&gt;</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;  },  

項目Demo

項目源碼

最後,貼上 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;    }  }