一起来为 Vue.js 写 Date/Time Picker 组件

3,795 阅读5分钟
原文链接: github.com

Date/Time Picker 是前端常用的组件, 这里向大家介绍如何写一个, 你可以在这里找到源代码. 在此感谢 Element-UI, Picker 本身和 Date 面板借鉴了他们的实现, 裁剪掉了 (对我的项目来说) 不需要的内容.

内容较长, 还要读很多源码, 建议空出一段时间阅读, 或者是在你需要开发类似组件的时候作为参考.


展示

datetime-pcierk-demo

这里有个 BUG, 当时间选择器收起时时间又蹦回去了, 我已经修复了这个问题, 但是我懒得再录了...

特性说明:

  • 选择日期, 可以设置某些日期为禁止选择
  • 可以选择是否支持时间选择, 支持的话, 会有一个 switch 进行日期和时间选择的切换
  • 快速浏览前后一年/一月
  • 时间选择有柔顺的动画
  • 简单的接口

API

有一些和项目耦合性比较强的 API, 比如 icon 就不介绍了, 也不重要.

  • :value: 传入一个能够被 Date() 支持的参数
  • :selectTime: 传入 true 就会支持时间选择
  • :placeholder: 文本框内显示的提示文字
  • @date-changed: 点击确认按钮之后派发的时事件 playload 是一个 Date 对象

分析

从 demo 中可以看到, 有以下要点:

  • 整个 Picker 分为以下四个模块: Picker 本身, 输入框, 包含日期选择器的下拉面板, 包含时间选择器的下拉面板
  • 日期选择器和时间选择器能够分别处理时间的一个部分, 因此要在 Picker 内部维护一个时间状态
  • 需要将内部的时间转换成用户友好的表示
  • 需要实现一个日历组件
  • 需要实现一个时间选择器组件
  • 要实现输入框, 下拉列表, segment 三个小组件

实现

接下来, 我们就按照四个模块来讲解它的实现, 小组件和一些辅助函数就不讲解了 (还有一些国际化的东西, 标志是 t 函数, 你也不用在意), 可以自己看源码. 我也不讲解样式表文件了.

date-picker

这是最终被用户所使用的组件, 要点如下:

  • 包括两个下拉面板(dropdown), 分别包含了时间选择器和时日选择器的挂载点
  • 包括一个输入框, 向输入框中传入用户友好的时间表示 visualValue, 并在输入框派发 focus 事件时打开面板
  • 有两个按钮, 分别对应取消和确认功能
  • 在自己初始化的时候, 以日期选择器创建一个 Vue 实例, 如果要选择时间的话, 再创建一个时间选择器的 Vue 实例
  • 当输入款 focus 的时候, visible 属性为 true, 使得 showPicker 方法被调用, 弹出日期选择器面板. 该方法会在面板第一次弹出的时候, 调用之前创建的实例的 $mount 方法, 将它们挂在到挂在点上, 同时设置它们的参数 (主要是把 value 传递过去), 同时监听它们派发的事件
  • 当选择器们派发事件的时候, 修改内部值 innerValue 同时根据响应式原理修改 visibleValue 的值
  • 当 segment 组件派发事件的时候, 就看是否要展开事件选择器的下拉面板
  • 当确认按钮触发的时候, 派发事件将内部值传递出去

如果你想结合代码体会, 可以看注释后的源码:

<div class="date-picker-component">
    <div class="dropdown-wrapper"
         @mousedown.stop.prevent>
      <!-- 如果不让 dropdown 拦截事件, input 就会失去焦点, 下拉面板就会收起 -->
      <dropdown ref="dropdown"
                :auto-hide="false">
        <div class="change-mode-header border-1px horizontal"
             v-if="selectTime">
          <segment :options="options"
                   @segment-changed="_handleModeChange" />
        </div>
        <div class="panel-container"
             @mousedown.stop.prevent>
          <dropdown ref="timeDropdown"
                    :auto-hide="false"
                    class="time-picker-wrapper">
            <div ref="timePicker"></div>
          </dropdown>
          <div ref="picker"></div>
        </div>
        <div class="footer border-1px horizontal">
          <wz-button size="small"
                     :text="cancelText"
                     type="inverse"
                     @click="handleCancel"></wz-button>
          <wz-button size="small"
                     :text="confirmText"
                     type="primary"
                     :has-value="true"
                     :value="visualValue"
                     @click="handleConfirm"></wz-button>
        </div>
      </dropdown>
    </div>
    <div class="input-wrapper">
      <input-box ref="input"
                 :border="false"
                 :classes="classes"
                 :clear-btn-flag="true"
                 :icon="icon"
                 :placeholder="placeholder"
                 :value="visualValue"
                 @blur="handleBlur"
                 @enter="handleBlur"
                 @focus="handleFocus"
                 @input-clear="handleClear" />
    </div>
  </div>

<script>
import Vue from 'vue'
import Dropdown from '../dropdown.vue'
import InputBox from '../input.vue'
import Segment from '../segment.vue'
import DatePanel from './panel/date.vue'
import TimePanel from './panel/time.vue'
import { defaultFormatter } from '../../utils/datetime' // 格式化时间
import WzButton from '../button'

import { t } from '../../locale'

export default {
  name: 'WzDatePicker',
  components: {
    Dropdown,
    InputBox,
    WzButton,
    Segment
  },
  props: {
    classes: {
      type: Array
    },
    icon: {
      type: String,
      default: 'fa-clock-o'
    },
    placeholder: {
      type: String,
      default: 'Pick a Date'
    },
    // set this to true to enable TimePicker
    selectTime: {
      type: Boolean,
      default: false
    },
    value: {}
  },
  data() {
    return {
      innerValue: this.value, // 内部值由传入的值进行初始化
      visible: false,
      visualValue: '',
      valueHolder: this.value, // hold the value for closing datepicker
      selectMode: 'date', // date or time
      options: [t('date'), t('time')],
      mode: 0
    }
  },
  computed: {
    confirmText() {
      return t('calendar.confirm')
    },
    cancelText() {
      return t('calendar.cancel')
    }
  },
  methods: {
    handleBlur() {
      this.visible = false
      this._reset()
    },
    handleClear() {
      this.$emit('date-changed')
      this.visualValue = ''
    },
    handleFocus() {
      this.visible = true
    },
    handleConfirm() {
      this.visible = false
      this.valueHolder = this.innerValue
      this.$emit('date-changed', this.innerValue)
    },
    handleCancel() {
      this.visible = false
      this._reset()
    },
    _handleModeChange(value) {
      const dp = this.$refs.timeDropdown
      value === 0 ? dp.hide() : dp.show()
      // everytime timepanel is opened, refresh it so it would tell
      // iscroll to refresh, otherwise iscroll won't work
      this.timePicker.refresh()
    },
    _reset() {
      this.innerValue = this.valueHolder
      this.$nextTick(() => {
        if (!this.valueHolder) {
          this.visualValue = ''
        }
      })
    },
    // 这个方法比较重要
    showPicker() {
      if (!this.picker) {
        this.picker = this.Panel.$mount(this.$refs.picker)
        // 在选择器挂载完毕之后, 向 value 赋值
        this.picker.value = this.value
        if (this.timePicker) this.timePicker.value = this.value

        // 当选择器派发事件时, 修改自己的内部值
        this.picker.$on('date-pick', date => {
          const newDate = new Date(date)
          if (this.innerValue) {
            const oldDate = new Date(this.innerValue)
            const hour = oldDate.getHours()
            const minute = oldDate.getMinutes()
            newDate.setHours(hour)
            newDate.setMinutes(minute)
          }
          this.picker.value = this.innerValue = newDate
        })
      }

      if (this.selectTime && !this.timePicker) {
        this.timePicker = this.timePanel.$mount(this.$refs.timePicker)
        this.timePicker.value = this.value
        this.timePicker.$on('hour-changed', hour => {
          this.timePicker.value = this.innerValue = new Date(this.innerValue).setHours(hour)
        })
        this.timePicker.$on('minute-changed', minute => {
          this.timePicker.value = this.innerValue = new Date(this.innerValue).setMinutes(minute)
        })
      }
    }
  },
  created() {
    this.Panel = new Vue(DatePanel)
    if (this.selectTime) {
      this.timePanel = new Vue(TimePanel)
    }
  },
  watch: {
    // 在 value 的值发生变化的时候同步到 innerValue
    value: {
      immediate: true,
      handler(newVal) {
        if (newVal) {
          this.innerValue = newVal
          // display visual value the next time panel gets opened
          this.visualValue = defaultFormatter(newVal, this.selectTime)
        }
      }
    },
    // 当内部值变化的时候修改显示值
    innerValue(newVal) {
      this.visualValue = defaultFormatter(newVal, this.selectTime)
    },
    visible(newVal) {
      if (newVal) {
        this.showPicker()
        this.$refs.dropdown.show()
      } else {
        this.$refs.dropdown.hide()
        this.$refs.input.blur()
      }
    }
  },
  beforeDestroy() {
    // 由于实例是手动创建的, 必须要手动销毁
    this.picker && this.picker.$destroy()
    this.timePicker && this.timePicker.$destroy()
  }
}

date-panel 日期选择器

该组件负责选择时间, 相对比较简单, 要点如下:

  • 分为两个部分, 头部分包含选择前后一年/月的按钮, 以及当前所处的年和月份, 主体部分包含一个日历表 date-table 组件和三个快捷方式
  • 有内部状态维持当前的年份和月份, 并传递给日历表组件
  • 有三个快捷方式 short-cut 可以选取比较常用的日期
  • 当日历表派发事件, 或者跨界方式被点击的时候, 就派发事件通知被选择的日期, 如果这个日期不属于当前的年份和月份, 就修改内部状态维持当前的年份和月份
  • 有一个 disabledDate() 方法, 这个方法将会被传递给日历表组件, 告诉日历表哪些日期不可选择
<template>
  <div class="date-panel">
    <div class="date-panel-sidebar border-1px vertical"
         v-if="shortcut">
      <ul class="shortcut-list">
        <li class="shortcut-item"
            v-for="shortcut in shortcuts"
            :key="shortcut.name"
            @click="handleShortcutClick(shortcut)">
          {{ shortcut.name }}
        </li>
      </ul>
    </div>
    <div class="date-panel-body">
      <div class="date-panel-body-header border-1px horizontal">
        <span class="fa fa-angle-double-left switcher"
              @click="_changeYear(-1)"></span>
        <span class="fa fa-angle-left switcher"
              @click="_changeMonth(-1)"></span>
        <span class="header-text">
          {{ headerText }}
        </span>
        <span class="fa fa-angle-right switcher"
              @click="_changeMonth(1)"></span>
        <span class="fa fa-angle-double-right switcher"
              @click="_changeYear(1)"></span>
      </div>
      <div class="date-panel-table-container">
        <date-table :date="date"
                    :disabled-date="disabledDate"
                    :month="month"
                    :value="value"
                    :year="year"
                    @date-pick="handleDatePick"></date-table>
      </div>
    </div>
  </div>
</template>

<script>
import DateTable from '../base/DateTable'

import { t } from '../../../locale'
import { getToday, switchMonth } from '../../../utils/datetime'

const ONE_DAY = 3600 * 1000 * 24
const ONE_WEEK = ONE_DAY * 7

export const shortcuts = [
  {
    name: t('calendar.today'),
    value: function() {
      return new Date()
    }
  },
  {
    name: t('calendar.tomorrow'),
    value: function() {
      const date = new Date()
      date.setTime(date.getTime() + ONE_DAY)
      return date
    }
  },
  {
    name: t('calendar.inAWeek'),
    value: function() {
      const date = new Date()
      date.setTime(date.getTime() + ONE_WEEK)
      return date
    }
  }
]

export default {
  name: 'wz-date-panel',
  components: { DateTable },
  props: {
    shortcut: {
      type: Boolean,
      default: true
    }
  },
  data() {
    return {
      date: new Date(getToday()),
      month: null,
      shortcuts,
      value: '',
      year: null
    }
  },
  computed: {
    headerText() {
      if (!this.year) return ''
      return `${this.year} / ${this.month + 1}`
    }
  },
  watch: {
    date(newVal) {
      this._setMonthYear(newVal)
    },
    value(newVal) {
      if (!newVal) return
      newVal = new Date(newVal)
      if (!isNaN(newVal)) {
        this.date = newVal
        this.year = newVal.getFullYear()
        this.month = newVal.getMonth()
      }
    }
  },
  methods: {
    _changeMonth(val) {
      this.date = switchMonth(this.date, val)
    },
    _changeYear(val) {
      this.date = switchMonth(this.date, val * 12)
    },
    // 处理日历表派发的事件
    handleDatePick(pickedDate) {
      this.$emit('date-pick', new Date(pickedDate.getTime()))
      this.date.setFullYear(pickedDate.getFullYear())
      this.date.setMonth(pickedDate.getMonth())
      this.date.setDate(pickedDate.getDate())
      this.date = new Date(this.date)
    },
    // 处理快捷方式被点击的事件
    handleShortcutClick(shortcut) {
      if (shortcut.value) {
        this.$emit('date-pick', shortcut.value())
      }
    },
    _setMonthYear(date) {
      this.month = date.getMonth()
      this.year = date.getFullYear()
    }
  },
  created() {
    // 初始化月份和年份
    if (this.date && !this.year) {
      this._setMonthYear(this.date)
    }
    // 今天之后的日期不可选择
    this.disabledDate = function(date) {
      return date && date.valueOf() < Date.now() - 86400000
    }
  }
}
</script>

date-table 日历表

日历表显示一个日历, 并在日期被点击的时候派发事件, 要点如下:

  • 有一个 rawCells 计算属性会根据月份和年份来计算这个月有多少天, 第一天从星期几开始, 之前的月份有多少天要显示在日历上, 之后的月份有多少天要显示, 哪些日期根据要求不能选择等等, 很重要, 请看源码的注释
  • 每一个日期都会根据 rawCells 计算的结果决定自己的样式, 这些样式包括是否禁用 disabled, 是否被选择 selected, 属于什么类型 (today normal 等等), 并在被点击的时候, 如果没有被禁用, 派发事件
<template>
  <div class="date-table">
    <div class="dayof-week">
      <span class="date"
            v-for="day in days"
            :key="day">
        {{ day }}</span>
    </div>
    <div class="cells-wrapper">
      <span class="cell"
            v-for="(cell, index) in cells"
            :class="_cellClass(cell)"
            :key="index"
            @click="handleClick(cell, index)">
        {{ cell.text }}</span>
    </div>
  </div>
</template>

<script>
import {
  clearHours,
  getDayCountOfMonth,
  getFirstDayOfMonth
} from '../../../utils/datetime'
import { deepCopy } from '../../../utils'
import { t } from '../../../locale'

export default {
  name: '',
  props: {
    date: {},
    disabledDate: {}, // a function to calculate disabled dates
    month: {},
    value: '',
    year: {}
  },
  data() {
    return {
      cells: []
    }
  },
  computed: {
    days() {
      return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => {
        return t(`datetable.${day}`)
      })
    },
    // "看我就好了, 其他人都不重要!"
    rawCells() {
      // 找到这个月第一天
      const date = new Date(this.year, this.month, 1)
      // 找到今天
      const today = clearHours(new Date())
      // 找到被选择的那一天
      const selectedDay = clearHours(new Date(this.value))

      // 找到这这个月的第一天是星期几
      let day = getFirstDayOfMonth(date)
      day = day === 0 ? 7 : day

      // 找到上一个月和上一年
      let prevMonth = this.month - 1
      let prevYear = this.year
      if (this.month === 0) {
        prevMonth = 11
        prevYear -= 1
      }

      // 找到下一个月和下一年
      let nextMonth = this.month + 1
      let nextYear = this.year
      if (this.month === 11) {
        nextMonth = 0
        nextYear += 1
      }

      // 获取上一个月和下一个月的天数 
      const dateCountOfMonth = getDayCountOfMonth(
        date.getFullYear(),
        date.getMonth()
      )
      const dateCountOfLastMonth = getDayCountOfMonth(
        date.getFullYear(),
        date.getMonth() === 0 ? 11 : date.getMonth() - 1
      )
      const disabledDate = this.disabledDate

      // 所有 cells 里的元素都会被渲染成日历中的一天
      let cells = []
      const cellTemplate = {
        text: '',
        type: '',
        selected: false,
        disabled: false
      }

      // 如果这个月第一天不是星期日, 那么我们就要把上一个月的最后几天放在日历的开头
      if (day !== 7) {
        for (let i = 0; i < day; i++) {
          const cell = deepCopy(cellTemplate)
          cell.type = 'prev-month'
          cell.text = dateCountOfLastMonth - (day - 1) + i
          const time = clearHours(new Date(prevYear, prevMonth, cell.text))
          cell.disabled =
            typeof disabledDate === 'function' && disabledDate(new Date(time))
          cells.push(cell)
        }
      }

      // 然后把这个月的日子放进数组
      for (let i = 1; i <= dateCountOfMonth; i++) {
        const cell = deepCopy(cellTemplate)
        const time = clearHours(new Date(this.year, this.month, i))
        cell.type = time === today ? 'today' : 'normal'
        cell.text = i
        cell.selected = time === selectedDay
        cell.disabled =
          typeof disabledDate === 'function' && disabledDate(new Date(time))
        cells.push(cell)
      }

      // 最后放上下个月的日子, 直到填满 42 天
      const nextMonthCount = 42 - cells.length
      for (let i = 1; i <= nextMonthCount; i++) {
        const cell = deepCopy(cellTemplate)
        const time = clearHours(new Date(nextYear, nextMonth, i))
        cell.type = 'next-month'
        cell.text = i
        cell.disabled =
          typeof disabledDate === 'function' && disabledDate(new Date(time))
        cells.push(cell)
      }

      return cells
    }
  },
  watch: {
    rawCells: {
      handler(newVal) {
        this.cells = newVal
      },
      immediate: true
    }
  },
  methods: {
    // "我负责生成每一天的 CSS 样式 class"
    _cellClass(cell) {
      const classes = []
      classes.push(cell.type)
      if (cell.disabled) {
        classes.push('disabled')
      }
      if (cell.selected) {
        classes.push('selected')
      }
      return classes
    },
    handleClick(cell, index) {
      if (cell.disabled) return
      this.$emit('date-pick', this._createDateFromCell(cell))
    },
    // 我负责根据 cell 的属性来计算它们代表哪一天
    _createDateFromCell(cell) {
      const date = this.date
      const hours = date.getHours()
      const minutes = date.getMinutes()
      const seconds = date.getSeconds()
      const pickedDate = cell.text

      let month = this.month
      let year = this.year

      if (cell.type === 'prev-month') {
        if (month === 0) {
          month = 11
          year--
        } else {
          month--
        }
      } else if (cell.type === 'next-month') {
        if (month === 11) {
          month = 0
          year++
        } else {
          month++
        }
      }

      return new Date(year, month, pickedDate, hours, minutes, seconds)
    }
  }
}
</script>

time-panel

让用户选择时间, 特性如下:

  • 通过 iScroll 包裹了可供滑动的区域
  • 当滑动终止的时候, 调整滑动的位置到和指示区域齐平, 并派发时间改变的事件
  • 当时间被点击的时候, 会滑动到点击的位置
<template>
  <div class="time-panel">
    <div class="ruler"></div>
    <div class="column "
         ref="hourWrapper">
      <ul class="container">
        <li class="cell border-1px horizontal"
            v-for="(i, index) in hours"
            :key="index"
            @click="changeHour(i)">
          {{ i }}
        </li>
      </ul>
    </div>
    <div class="column"
         ref="minuteWrapper">
      <ul class="container">
        <li class="cell"
            v-for="i in minutes"
            :key="i"
            @click="changeMinute(i)">
          {{ i }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
import IScroll from 'iscroll'

// 这是个很有趣的写法, 创建了一个整数数组
const createIntegerArray = function(length) {
  // three gaps for
  return Array.from(new Array(length), (x, i) => i)
}

const CELL_HEIGHT = 30
const BOUNCE_SPEED = 800

// 当滑动结束后, 要求滑块必须平齐, 而用 iScroll 提供的对齐, 则要么要在整数数组前后
// 各增加三个假元素, 要么设置 container 的 padding 但是会导致第 2 3 个选项没法选择,
// 所以只能自己写对齐方法

// 这个方法返回一个回调函数, 让 iScroll 在滚动结束的时候调用它使得平齐
const createRegulator = function(vm, scrollerName) {
  let lastDest = 0
  const scroller = vm[scrollerName] // 找到对应的滚动条

  return function() {
    const diff = Math.abs(scroller.y)
    let dest = parseInt(diff / CELL_HEIGHT)
    // 计算目标位置, 如果目标位置和上一次目标位置相同, 就直接返回
    // 因为用 iScroll 的 scrollTo 进行的滚动同样会触发 scrollEnd 事件
    // 所以必须规避这个问题
    if (dest === lastDest) return
    lastDest = dest
    scrollerName === 'hourScroll' ? vm.changeHour(dest) : vm.changeMinute(dest)
  }
}

export default {
  name: 'wz-time-panel',
  props: {
    value: {
      type: Date
    }
  },
  data: () => ({
    hours: createIntegerArray(24),
    minutes: createIntegerArray(60)
  }),
  methods: {
    changeHour(i) {
      this.hourScroll &&
        this.hourScroll.scrollTo(0, -i * CELL_HEIGHT, BOUNCE_SPEED)
      this.$emit('hour-changed', i)
    },
    changeMinute(i) {
      this.minuteScroll &&
        this.minuteScroll.scrollTo(0, -i * CELL_HEIGHT, BOUNCE_SPEED)
      this.$emit('minute-changed', i)
    },
    refresh() {
      // 当时间选择器面板打开的时候, 这个方法会被调用
      // 让 iScroll 重新计算, 否则不能正确滚动
      // 还要设置延时, 以让 DOM 渲染完毕
      setTimeout(() => {
        this.hourScroll.refresh()
        this.minuteScroll.refresh()
        const value = new Date(this.value)
        if (value) {
          this.changeHour(value.getHours())
          this.changeMinute(value.getMinutes())
        }
      }, 400)
    }
  },
  mounted() {
    // 当挂载完毕之后, 初始化 iScroll, 并为 iScroll 添加 scrollEnd 事件绑定
    // 同样要延时等待 DOM 渲染完毕
    setTimeout(() => {
      this.hourScroll = new IScroll(this.$refs.hourWrapper, {
        mouseWheel: true
      })
      this.minuteScroll = new IScroll(this.$refs.minuteWrapper, {
        mouseWheel: true
      })
      // when scroller finished to scroll, scroll to the nereast cell
      this.hourScroll.on('scrollEnd', createRegulator(this, 'hourScroll'))
      this.minuteScroll.on('scrollEnd', createRegulator(this, 'minuteScroll'))
    }, 200)
  }
}
</script>

相信到这里你已经对如何写一个 Picker 了然于胸了, 为了进一步加深了解, 你可以尝试:

  1. 以图表的形式画出这四个组件之间的数据流动和事件派发关系, 以及 DOM 的层级关系
  2. 想一想如何实现这个功能: 在输入框里输入一个时间字符串, 使得日期选择器能跳转到这个字符串所代表的日期
  3. 想一想如何更好地设计 API

欢迎和我交流, 或者到 Issues 里的留言板参与讨论.