在日常工作中需要填写日期的时候,会用到日期选择器,来方便的进行日、月、年的选择。这里我们会用Vue来实现一个日期选择器,效果如下:
实现功能:日期选择弹出层
选择天面板
选择月面版
选择年面版
支持用户输入
CSS样式美化
组件的使用方式很简单,只需要传入对应的日期对象value即可:
export default {
name: 'DatePicker',
data () {
return {
value: undefined
};
},
};
下面就开始一步步实现组件吧 !
日期选择弹出层
当用户点击输入框时,会弹出日期选择面板。在组件内部,会通过visible来控制弹出层的显示隐藏:
class="go-date-picker-input"
@focus="visible=true"
prefix="calendar"
placeholder="请选择时间"
>
export default {
name: 'GoDatePicker',
props: {
value: {
type: Date,
default: () => new Date()
}
},
data () {
return {
visible: false,
};
},
mounted () {
document.body.addEventListener('click', this.onClickBody);
},
beforeDestroy () {
document.body.removeEventListener('click', this.onClickBody);
},
methods: {
onClickBody (e) { // Vue内部会自动帮我们修改this指向 const { picker} = this.$refs;
// 过滤掉弹出层和日期选择器内的元素 if (picker.contains(e.target)) {
return;
}
this.visible = false;
},
}
};
当输入框激活时,显示弹出层,当点击外部区域时,会隐藏弹出层。需要注意的是当点击date-picker内部,弹出层并不会隐藏。
Node.contains(otherNode)可以用来判断otherNode是否是Node的后代节点(包括Node本身),返回Boolean。这里我们通过这个api来判断点击的元素e.target是否在date-picker内部,如果是的话不会隐藏弹出层,可以让用户在date-picker中进行相应的操作。
展示天面板
当用户点击输入框后,首先弹出的是天面板,面板头部会显示当前的年月信息。面板主体有 6 行,会分别包括上月、当前月、下月的天数:
显示头部信息
我们会对传入的value进行拷贝,在内部通过tempValue来进行保存,并且监听value的变化,保证tempValue可以获取到value的最新值。当我们在内部切换日期面板而没有选中某个日期时,就不会更新value,而只是更新内部的tempValue属性:
export default {
name: 'GoDatePicker',
props: {
value: {
type: Date,
default: () => new Date()
}
},
components: { PickerDays, PickerMonths, PickerYears },
data () {
return {
visible: false,
mode: 'picker-days',
tempValue: cloneDate(this.value),
};
},
computed: {
formatDate () {
const [year, month, day] = getYearMonthDay(this.tempValue);
return { year, month: month + 1, day };
},
},
watch: {
value (val) {
this.tempValue = cloneDate(val);
}
},
// some code ...};
formatDate计算属性会通过tempValue计算出当前的年、月、日,方便展示。
显示内容区域
内容区域的展示会复杂很多,实现的思路如下:获取当前月第一天是星期几,推导出前一个月展示的天数
获取当月的展示总天数
总共要展示的天数为 42,减去前一个月和当前月展示的天数即为下个月展示的天数
export default {
name: 'PickerDays',
data () {
return {
weeks: ['一', '二', '三', '四', '五', '六', '日']
};
},
// some code ... computed: {
getDays () {
const [year, month] = getYearMonthDay(this.tempValue);
// 0 ~ 6, 需要将0转换为7 let startWeek = new Date(year, month, 1).getDay();
if (startWeek === 0) {
startWeek = 7;
}
const prevLastDay = getPrevMonthLastDay(year, month);
const curLastDay = getCurrentMonthLastDay(year, month);
const days = [...this.getPrevMonthDays(prevLastDay, startWeek), ...this.getCurrentMonthDays(curLastDay), ...this.getNextMonthDays(curLastDay, startWeek)];
// 转换成二维数组 return toMatrix(days, 7);
},
},
methods: {
// 获取前一个月天数 getPrevMonthDays (prevLastDay, startWeek) {
const [year, month] = getYearMonthDay(this.tempValue);
const prevMonthDays = [];
for (let i = prevLastDay - startWeek + 1; i <= prevLastDay; i++) {
prevMonthDays.push({
date: new Date(year, month - 1, i),
status: 'prev'
});
}
return prevMonthDays;
},
// 获取当前月天数 getCurrentMonthDays (curLastDay) {
const [year, month] = getYearMonthDay(this.tempValue);
const curMonthDays = [];
for (let i = 1; i <= curLastDay; i++) {
curMonthDays.push({
date: new Date(year, month, i),
status: 'current'
});
}
return curMonthDays;
},
// 获取下一个月天数 getNextMonthDays (curLastDay, startWeek) {
const [year, month] = getYearMonthDay(this.tempValue);
const nextMonthDays = [];
for (let i = 1; i <= 42 - startWeek - curLastDay; i++) {
nextMonthDays.push({
date: new Date(year, month + 1, i),
status: 'next'
});
}
return nextMonthDays;
},
getDay (cell) {
return cell.date.getDate();
},
}
};
我们将前一个月、当前月、下一个月的日期信息组成一个数组,然后转换位为拥有 6 个子数组,每个子数组中有 7 条信息的二维数组,方便遍历展示:
class="go-date-picker-days-cell"
v-for="(cell,j) in row"
:key="`${cell}-${j}`"
>
{{ getDay(cell) }}
数组的格式如下:
在计算日期时,如果传入的天数为 0,则表示前一个月的最后一天。利用这个特性,可以节省我们很多的计算逻辑:
export const getCurrentMonthLastDay = (year, month) => {
return new Date(year, month + 1, 0).getDate();
};
export const getPrevMonthLastDay = (year, month) => {
return new Date(year, month, 0).getDate();
};
在遍历展示的天的过程中,还可以通过日期信息来为其设置样式:
class="go-date-picker-days-cell"
:class="dayClasses(cell)"
v-for="(cell,j) in row"
:key="`${cell}-${j}`"
>
{{ getDay(cell) }}
export default {
// some code ... methods: {
dayClasses (cell) {
return {
prev: cell.status === 'prev',
next: cell.status === 'next',
active: this.isSameDay(cell.date, this.value),
today: this.isToday(cell.date)
};
},
// 是否是选中的天 isSameDay (date1, date2) {
const [y1, m1, d1] = getYearMonthDay(date1);
const [y2, m2, d2] = getYearMonthDay(date2);
return y1 === y2 && m1 === m2 && d1 === d2;
},
// 是否是今天 isToday (date) {
const [y1, m1, d1] = getYearMonthDay(date);
const [y2, m2, d2] = getYearMonthDay();
return y1 === y2 && m1 === m2 && d1 === d2;
}
}
};
}
通过dayClasses方法,我们分别添加如下class:prev: 前一个月
next: 下一个月
active: 选中的日期
today: 今天
之后便可以根据class来为这些不同状态分别添加不同的样式了。
月份切换
在面板的头部,支持点击左右箭头进行月份切换。其实现利用了Date.prototype.setMonth方法:
‹
{{ formatDate.year }}年{{ formatDate.month }}月{{ formatDate.day }}日
›
export default {
name: 'PickerDays',
methods: {
changeMonth (value) {
const [, month] = getYearMonthDay(this.tempValue);
const timestamp = cloneDate(this.tempValue).setMonth(month + value);
// 通过.sync修饰符绑定,使用update:xxx来进行修改值 this.$emit('update:tempValue', new Date(timestamp));
}
}
};
内部会传入设置的月份,如果值为-1 或者 13 的话,会自动切换到前一年或后一年,而不用担心时间混乱。
选择天
当点击面板中的某天后,需要更新用户传入的value。而在value更新后,由于在组件内我们watch了value,所以也会同时更新tempValue,使页面中的数据和value保持一致:
class="go-date-picker-days-cell"
:class="dayClasses(cell)"
v-for="(cell,j) in row"
:key="`${cell}-${j}`"
@click="onClickDay(cell)"
>
{{ getDay(cell) }}
export default {
name: 'PickerDays',
// 引入混合器 mixins: [emitter],
// some code ... methods: {
onClickDay (cell) {
this.dispatch('input', cell.date, 'GoDatePicker');
},
// some code... }
};
这里进行了跨组件调用this.$emit('input')事件,需要从子到父一直通过@进行事件监听,并使用this.$emit('input')继续向上触发事件。为了简化这个过程,在混合器内封装了dispatch方法,方便跨组件之间的方法触发:
// src/mixins/emitter.jsconst emitter = {
methods: {
dispatch (event, params, componentName) {
let parent = this.$parent;
while (parent) {
if (parent.$options.name === componentName) {
return parent.$emit(event, params);
}
parent = parent.$parent;
}
}
}
};
export default emitter;如果不理解dispatch的实现过程的话,可以参考笔者的
展示月面板代码中将年月日面板分别拆分成了不同的组件,然后通过动态组件来进行展示。
月面板的界面效果如下:
我们在代码内部定义了数组months来代表所有月份,并且通过toMatrix将其转换为拥有 3 个子数组的二维数组,方便进行遍历:
class="go-date-picker-months-cell"
v-for="(cell,j) in row" :key="`${cell}-${j}`"
:class="monthClasses(i,j)"
@click="onClickMonth(i,j)"
>
{{ cell }}
const MONTHS = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
export default {
name: 'PickerMonths',
data () {
return {
months: toMatrix(MONTHS, 4)
};
},
methods: {
monthClasses (i, j) {
const month = j + i * 4;
return {
active: this.isSameMonth(month),
current: this.isCurrentMonth(month)
};
},
onClickMonth (i, j) {
const month = j + i * 4;
const { year, day } = this.formatDate;
this.dispatch('input', new Date(year, month, day), 'GoDatePicker');
this.$emit('mode-change', 'picker-years');
},
isCurrentMonth (month) {
const year = this.formatDate.year;
const [year2, month2] = getYearMonthDay(new Date());
return year === year2 && month === month2;
},
isSameMonth (month) {
const year = this.formatDate.year;
const [year2, month2] = getYearMonthDay(this.value);
return year === year2 && month === month2;
}
}
};
在遍历过程中可以通过i,j来获取到对应项的真实月份,根据月份和formatDate得到的tempValue所对应的当前面板的年份,可以添加不同的类名,从而设置不同的样式。
在点击月份后,会更新用户传入的value,然后跳转到年面板,下面我们来介绍年面板的实现。
展示年面板
年面板会展示 10 年的年份列表,可以通过左右箭头后退或前进 10 年,其效果如下:
我们需要计算出开始年份和结束年份,然后生成拥有 4 个子数组的二维数组在页面中遍历展示:
‹
{{ startYear }}-{{ endYear }}
›
class="go-date-picker-years-cell"
v-for="(cell,j) in row" :key="`${cell}-${j}`"
:class="yearClasses(cell)"
@click="onClickYear(cell)"
>
{{ cell }}
export default {
name: 'PickerYears',
computed: {
startYear () {
const { year } = this.formatDate;
return year - year % 10;
},
endYear () {
return this.startYear + 9;
},
years () {
const arr = [];
for (let i = this.startYear; i <= this.endYear; i++) {
arr.push(i);
}
return toMatrix(arr, 4);
}
},
};
在生成年份列表后,可以根据列表中的年份信息来为其设置不同的样式:
export default {
methods: {
yearClasses (year) {
return {
active: this.isSameYear(year),
current: this.isCurrentYear(year)
};
},
// 当前所处年分 isCurrentYear (year) {
const [year2] = getYearMonthDay(new Date());
return year === year2;
},
// 与用户传入的value相同的激活年份 isSameYear (year) {
const [year2] = getYearMonthDay(this.value);
return year === year2;
}
}
}
当点击左右箭头时,会调用Date.prototype.setFullYear来进行年份的切换:
export default {
methods: {
changeYear (value) {
const [year] = getYearMonthDay(this.tempValue);
const timestamp = cloneDate(this.tempValue).setFullYear(year + value);
this.$emit('update:tempValue', new Date(timestamp));
},
}
}
在点击对应的年份后,会更新value并切换到选择天面板:
export default {
methods: {
onClickYear (year) {
const { month, day } = this.formatDate;
this.dispatch('input', new Date(year, month, day), 'GoDatePicker');
this.$emit('mode-change', 'picker-days');
},
}
}
到这里我们已经实现年、月、天的选择,日期选择器的基本功能已经全部实现 。
输入当前日期
用户不仅可以通过面板选择时间,也可以通过输入框来输入时间。
当用户在输入框中输入内容后,会将用户输入的内容与正则进行匹配,如果匹配不成功将会忽略用户的输入内容。如果匹配成功,会通过正则单元将用户填写的年月日拿到,然后用它们更新用户传入的value,进而更新整个日期选择器的数据。
上述逻辑的代码如下:
class="go-date-picker-input"
@focus="visible=true"
v-model="displayValue"
prefix="calendar"
placeholder="请选择时间"
>
export default {
name: 'GoDatePicker',
computed: {
displayValue: {
get () {
const [year, month, day] = getYearMonthDay(this.value);
return `${year}-${month + 1}-${day}`;
},
set (e) { // 为计算属性绑定set方法,在更新值的时候会调用 if (e?.target?.value) {
const reg = /(\d+)-(\d+)-(\d+)/;
const value = e.target.value;
const matched = value.match(reg);
if (matched) { // 如果匹配到的话,通过正则单元获取到年月日更新value const [, year, month, day] = matched;
this.$emit('input', new Date(year, month - 1, day));
}
}
}
},
},
};
在输入框中输入内容的时候,由于为计算属性displayValue设置了v-model,所以需要为其设置set方法。在set方法中通过String.prototype.match获取匹配结果,进而更新value。
这个功能可以让我们直接输入日期信息,而不用为了选择某个跨度比较大的时间而进行不停的前进后退操作。
结语
日期选择器的难点在于年、月、天列表的展示,需要我们对Date的一些api有一定的了解,否则会导致很多没有必要的计算逻辑。剩下的一些CSS样式比较简单,需要花些耐心多去调试。
希望这篇文章能够帮助你了解日期选择器的实现原理,在工作和面试时更加游刃有余!