(來啦,老弟)從零實現一個日曆組件

  • 2019 年 11 月 5 日
  • 筆記

一、日曆組件簡介

日曆組件主要是由一個文本輸入框組成,點擊文本輸入框後會在文本框下方顯示日曆面板,日曆面板包含三部分:頭部區(主要顯示當面日曆面板對應的年月以及四個年月上下切換按鈕)、內容區(顯示星期、以及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內則關閉日曆面板,如:

探討的全部代碼

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

動態效果如下