使用Vue寫一個datepicker

前言

寫插件是頗有意思,也很鍛鍊人,由於這個過程當中能發現許多的細節問題。在前端發展的過程當中,jQuery無疑是一個重要的里程碑,圍繞着這個優秀項目也出現了不少優秀的插件能夠直接使用,大大節省了開發者們的時間。jQuery最重要的做用是跨瀏覽器,而如今瀏覽器市場雖不完美,但已遠沒有從前那麼慘,數據驅動視圖的思想倍受歡迎,你們開始使用前端框架取代jQuery,我我的比較喜歡Vue.js,因此想試着用Vue.js寫一個組件出來。html

爲了發佈到npm上,因此給項目地址更名字了,可是內部代碼沒有改,使用方法比以前方便。
Demo演示: Here
GitHub地址: Here
但願你們能給個star前端

功能&指望

這個datepicker目前僅實現了一些經常使用的功能:vue

  • 選擇時間(這話說得有點多餘)node

  • 最大/最小時間限制webpack

  • 中/英文切換(其實也就星期和月份須要切換)git

  • 能夠以.vue形式使用,也可在瀏覽器環境中直接使用github

  • 沒了。。。web

目錄結構

萬事的第一步依然是建立項目,只是單一組件,結構並不複雜,Datepicker.vue是最重要的組件文件,dist是webpack的輸出文件夾,index.js是webpack打包的入口文件,最後是webpack的配置文件,用來對咱們的庫文件進行打包用的。所以項目結構就是這樣:npm

.
├── Datepicker.vue
├── LICENSE
├── README.md
├── dist
│   └── vue-datepicker.js
├── index.js
├── package.json
└── webpack.config.js

從Datepicker.vue入手

.vue的方式寫Vue組件是一種特殊寫法,每一個Vue文件包括template, script, style三部分,template最好不要成爲片斷實例,因此最外層先套一層div,當作整個組件的根元素。一個datepicker通常由兩部分組成,一個用來顯示日期的input框,一個用來選擇日期的panel,由於我發現input在移動端會自動喚起鍵盤,因此沒有使用input,直接用了div模擬,經過點擊事件決定panel的顯隱。value是最終的結果,須要和父組件通訊,因此將value寫成了prop,在父組件中使用value.sync="xxx",datepicker的value就和父組件的xxx雙向綁定了。json

<template>
    <div class="date-picker">
        <div class="input" v-text="value" @click="panelState = !panelState">
    </div>
    <div class="date-panel" v-show="panelState">
    </div>
</template>

<scrip>
    export default {
        data () {
            return {
                panelState: false //初始值,默認panel關閉
            }
        },
        props: {
            value: String
        }
    }
</script>

渲染日期列表

一個月最少是28天,若是把週日排在開頭,那麼最少(1號剛好是週日)須要4行,可是每月天數30,31居多,並且1號又不必定是週日,我索性乾脆按最多的狀況設計了,共6行,當月日期沒填滿的地方用上個月或下個月的日期補齊,這樣就方便計算了,並且切換月份時候panel高度不會變化。日期列表的數組須要動態計算,Vue提供了computed這個屬性,因此直接將日期列表dateList寫成計算屬性。個人方法是將日期列表固定爲長度爲42的數組,而後將本月,上個月,下個月的日期依次填充。

computed: {
    dateList () {
        //獲取當月的天數
        let currentMonthLength = new Date(this.tmpMonth, 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.year, this.tmpMonth, 1).getDay()
        //確認上個月一共多少天
        let previousMongthLength = new Date(this.year, this.tmpMonth, 0).getDate()
    }
    //在1號前插入上個月日期
    for(let i = 0, len = startDay; i < len; i++){
        dateList = [{previousMonth: true, value: previousMongthLength - i}].concat(dateList)
    }
    //補全剩餘位置
    for(let i = 0, item = 1; i < 42; i++, item++){
        dateList[dateList.length] = {nextMonth: true, value: i}
    }
    return dateList
}

這裏用Array.from來初始化了一個數組,傳入一個Array Like,轉化成數組,在拼接字符串時候採用了arr[arr.length][{}].concat(arr)這種方式,由於在JsTips上學到這樣作性能更好,文章的最後會貼出相關連接。
這樣,日期列表就構建好了,在template中使用v-for循環渲染出來

<ul class="date-list">
    <li v-for="item in dateList"
        v-text="item.value" 
        :class="{preMonth: item.previousMonth, nextMonth: item.nextMonth,
            selected: date === item.value && month === tmpMonth && item.currentMonth, invalid: validateDate(item)}"
        @click="selectDate(item)">
    </li>
</ul>

樣式上就能夠本身發揮了,怎麼喜歡怎麼寫。須要注意的是循環日期可能會出現上個月或這個月的日期,我經過previuosMonth,currentMonthnextMonth分別作了標記,對其餘功能提供判斷條件。
年份和月份的列表都是差很少的道理,年份列表的初始值我直接寫在了data裏,以當前年份爲第一個,爲了和月份保持一致,每次顯示12個,都經過v-for渲染。

data () {
    return {
        yearList: Array.from({length: 12}, (value, index) => new Date().getFullYear() + index)
    }
}

選擇日期功能

選擇順序是:年 -> 月 -> 日,因此咱們能夠經過一個狀態變量來控制panel中顯示的內容,綁定適合的函數切換顯示狀態。

<div>
    <div class="type-year" v-show="panelType === 'year'">
        <ul class="year-list">
          <li v-for="item in yearList"
              v-text="item"
              :class="{selected: item === tmpYear, invalid: validateYear(item)}" 
              @click="selectYear(item)"
          >
          </li>
      </ul>
    </div>
    <div class="type-month" v-show="panelType === 'month'">
        <ul class="month-list">
          <li v-for="item in monthList"
              v-text="item | month language"
              :class="{selected: $index === tmpMonth && year === tmpYear, invalid: validateMonth($index)}" 
              @click="selectMonth($index)"
          >
          </li>
      </ul>
    </div>
    <div class="type-date" v-show="panelType === 'date'">
        <ul class="date-list">
          <li v-for="item in dateList"
              v-text="item.value" 
              track-by="$index" 
              :class="{preMonth: item.previousMonth, nextMonth: item.nextMonth,
                  selected: date === item.value && month === tmpMonth && item.currentMonth, invalid: validateDate(item)}"
              @click="selectDate(item)">
          </li>
      </ul>
    </div>
</div>

選擇日期的方法就不細說了,在selectYear,selectMonth中對年份,月份變量賦值,再分別將panelType推向下一步就實現了日期選擇功能。
不過在未選擇完日期以前,你可能不但願當前年月的真實值發生變化,因此在這些方法中可先將選擇的值賦給一個臨時變量,等到seletDate的時候再一次性所有賦值。

selectMonth (month) {
    if(this.validateMonth(month)){
        return
    }else{
        //臨時變量
        this.tmpMonth = month
        //切換panel狀態
        this.panelType = 'date'
    }
},
selectDate (date) {
    //validate logic above...
    //一次性所有賦值
    this.year = tmpYear
    this.month = tmpMonth
    this.date = date.value
    this.value = `${this.tmpYear}-${('0' + (this.month + 1)).slice(-2)}-${('0' + this.date).slice(-2)}`
    //選擇完日期後,panel自動隱藏
    this.panelState = false
}

最大/小時間限制

最大/小值是須要從父組件傳遞下來的,所以應該使用props,另外,這個值能夠是字符串,也應該能夠是變量(好比同時存在兩個datepicker,第二個的日期不能比第一個大這種邏輯),因此應該使用Dynamically bind的方式傳值。

<datepicker :value.sync="start"></datepicker>
<!-- 如今min的值會隨着start的變化而變化 -->
<datepicker :value.sync="end" :min="start" ></datepicker>

增長了限制條件,對於不合法的日期,其按鈕應該變爲置灰狀態,我用了比較時間戳的方式來判斷日期是否合法,由於就算當前panel中的日期是跨年或是跨月的,經過日期構造函數建立時都會幫你轉換成對應的合法值,省去不少判斷的麻煩:

new Date(2015, 0, 0).getTime() === new Date(2014, 11, 31).getTime() //true
new Date(2015, 12, 0).getTime() === new Date(2016, 0, 0).getTime() //true

所以驗證日期是否合法的函數是這樣的:

validateDate (date) {
  let mon = this.tmpMonth
  if(date.previousMonth){
      mon -= 1
  }else if(date.nextMonth){
      mon += 1
  }
  if(new Date(this.tmpYear, mon, date.value).getTime() >= new Date(this.minYear, this.minMonth - 1, this.minDate).getTime()
      && new Date(this.tmpYear, mon, date.value).getTime() <= new Date(this.maxYear, this.maxMonth - 1, this.maxDate).getTime()){
      return false
  }
  return true

}

動態計算位置

當頁面右側有足夠的空間顯示時,datepicker的panel會定位爲相對於父元素left: 0的位置,若是沒有足夠的空間,則應該置於right: 0的位置,這一點能夠經過Vue提供的動態樣式和樣式對象來實現(動態class和動態style其實只是動態props的特例),而計算位置的時刻,我放在了組件聲明週期的ready週期中,由於這時組件已經插入到DOM樹中,能夠獲取style進行動態計算:

ready () {
    if(this.$el.parentNode.offsetWidth + this.$el.parentNode.offsetLeft - this.$el.offsetLeft <= 300){
        this.coordinates = {right: '0', top: `${window.getComputedStyle(this.$el.children[0]).offsetHeight + 4}px`}
    }else{
        this.coordinates = {left: '0', top: `${window.getComputedStyle(this.$el.children[0]).offsetHeight + 4}px`}
    }
}
<!-- template中對應的動態style -->
<div :style="coordinates"></div>

爲了panel的顯隱能夠平滑過渡,可使用transition作過渡動畫,這裏我簡單地經過一個0.2秒的透明度過渡讓顯隱更平滑。

<div :style="this.coordinates" v-show="panelState" transition="toggle"></div>

//less syntax
.toggle{
    &-transition{
        transition: all ease .2s;
    }
    &-enter, &-leave{
        opacity: 0;
    }
}

中英文切換

這裏其實也很簡單,這種多語言切換實質就是一個key根據不一樣的type而輸出不一樣的value,因此使用filter能夠很容易的實現它!好比渲染星期的列表:

<ul class="weeks">
     <li v-for="item in weekList" v-text="item | week language"></li>
 </ul>
 
filters : {
    week (item, lang){
        switch (lang) {
          case 'en':
              return {0: 'Su', 1: 'Mo', 2: 'Tu', 3: 'We', 4: 'Th', 5: 'Fr', 6: 'Sa'}[item]
          case 'ch':
              return {0: '日', 1: '一', 2: '二', 3: '三', 4: '四', 5: '五', 6: '六'}[item]
          default:
              return item
      }
    }
}

多種使用方式

對於一個Vue組件,若是是使用webpack + vue-loader.vue單文件寫法,我但願這樣使用:

//App.vue
<script>
    import datepicker from 'path/to/datepicker.vue'
    export default {
        components: { datepicker}
    }
</script>

若是是直接在瀏覽器中使用,那麼我但願datepicker這個組件是暴露在全局下的,能夠這麼使用:

//index.html
<html>
    <script src="path/to/vue.js"></script>
    <script src="path/to/datepicker.js"></script>
    <body>
        <div id="app"></div>
        <script>
            new Vue({
                el: '#app',
                components: { datepicker }
            })
        </script>
    </body>
</html>

這裏我選擇了webpack做爲打包工具,使用webpack的output.libraryoutput.linraryTarget這兩個屬性就能夠把你的bundle文件做爲庫文件打包。library定義了庫的名字,libraryTarget定義了你想要打包的格式,具體能夠看文檔。我但願本身的庫能夠經過datepicker加載到,而且打包成umd格式,所以個人webpack.config.js是這樣的:

module.exports = {
    entry: './index.js',
    output: {
        path: './dist',
        library: 'datepicker',
        filename: 'vue-datepicker.js',
        libraryTarget: 'umd'
    },
    module: {
        loaders: [
            {test: /\.vue$/, loaders: ['vue']},
            {test: /\.js$/, exclude: /node_modules/, loaders: ['babel']}
        ]
    }
}

打包完成的模塊就是一個umd格式的模塊啦,能夠在瀏覽器中直接使用,也能夠配合require.js等模塊加載器使用!

適配 Vue 2.x

Vue 2.0已經發布有段時間了,如今把以前的組件適配到Vue 2.0。遷移過程仍是很順利的,核心API改動不大,能夠藉助vue-migration-helper來找出廢棄的API再逐步修改。這裏只列舉一些我須要修改的API。

filter

2.0中的filter只能在mustache綁定中使用,若是想在指令式綁定中綁定過濾後的值,能夠選擇計算屬性。我在月份和星期的顯示中使用到了過濾器來過濾語言類型,但我以前是在指令式綁定中使用的filter,因此須要以下修改,:

//修改前
<div class="month-box" @click="chType('month')" v-text="tmpMonth + 1 | month language"></div>
//修改後,filter傳參的方式也變了,變成了函數調用的風格
<div class="month-box" @click="chType('month')">{{tmpMonth + 1 | month(language)}}</div>

移除$index$key

這兩個屬性不會在v-for中被自動建立了,如需使用,要在v-for中自行聲明:

<li v-for="item in monthList" @click="selectMonth($index)"></li>
//
<li v-for="(item, index) in monthList" @click="selectMonth(index)"></li>

ready 生命週期移除

ready從生命週期鉤子中移除了,遷移方法很簡單,使用mountedthis.$nextTick來替換。

prop.sync棄用

propsync棄用了,遷移方案是使用自定義事件,並且Datepicker這種input類型組件,可使用表單輸入組件的自定義事件做爲替換方案。自定義組件也可使用v-model指令了,可是必須知足兩個條件:

  1. 接收一個valueprop

  2. 值發生變化時,觸發一個input事件,傳入新值。

因此Datepicker的使用方式也不是<datepicker value.sync="now"></datepicker>了,而是<datepicker v-model="now"></datepicker>。組件自身向父級傳值的方式也不同了:

//1.x版本,設置了value的值會同步到父級
this.value = `${this.tmpYear}-${('0' + (this.month + 1)).slice(-2)}-${('0' + this.date).slice(-2)}`

//2.x版本,須要本身觸發input事件,將新值做爲參數傳遞回去
let value = `${this.tmpYear}-${('0' + (this.month + 1)).slice(-2)}-${('0' + this.date).slice(-2)}`
this.$emit('input', value)

總結

以上就是我在寫這個datepicker時的大體思路,自己也是很簡單的事情,沒有到處展開來講,寫在這裏做爲本身的一個總結,若是有剛開始使用Vue的同窗也但願這篇文章能夠在思路上幫助到大家:P,對於各位老鳥若是有什麼指點的地方我也很感謝:D,那麼差很少就這樣,後面貼一些相關推薦閱讀。

推薦閱讀

高效地向數組中插值
Vue.js-片斷實例
Vue.js-動態綁定
Js日期對象基礎
Webpack: export bundle as library
UMD(universial Module Defination)
Migration from Vue 1.x

相關文章
相關標籤/搜索