Date/Time Picker 是前端常用的组件, 这里向大家介绍如何写一个, 你可以在这里找到源代码. 在此感谢 Element-UI, Picker 本身和 Date 面板借鉴了他们的实现, 裁剪掉了 (对我的项目来说) 不需要的内容.
内容较长, 还要读很多源码, 建议空出一段时间阅读, 或者是在你需要开发类似组件的时候作为参考.
展示
这里有个 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 了然于胸了, 为了进一步加深了解, 你可以尝试:
- 以图表的形式画出这四个组件之间的数据流动和事件派发关系, 以及 DOM 的层级关系
- 想一想如何实现这个功能: 在输入框里输入一个时间字符串, 使得日期选择器能跳转到这个字符串所代表的日期
- 想一想如何更好地设计 API
欢迎和我交流, 或者到 Issues 里的留言板参与讨论.