| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
| 4 年前 | ||
| 4 年前 | ||
| 4 年前 | ||
| 4 年前 | ||
| 2 年前 | ||
| 4 年前 | ||
| 4 年前 | ||
| 4 年前 |
DatePicker设计思路与实现
1. 需求梳理
1.1 主要结构
直观上分析,组件主要包括两部分:
- 日期面板
- 日期显示面板
- 跳转到
今天按钮
- 顶部操作栏
1.2 交互分析
交互上分析,大致包括:
- 日期面板操作
- 单日选择
- 区域选择
- 开始日期(select start)
- 区间日期(start + hover)
- 结束日期(select end)
- 跳转到
今天
- 顶栏操作
- 显示当前日期
- 年/月翻页功能
1.3 事件响应
消息传递(事件接口)上分析,应该具备以下几种能力:
- 日期面板
onSelected单板日期值被选中onSelectStart区域日期开始选择onSelecting区域中间值发生变化onSelectEnd区域日期值选择结束onToday跳转当天onChange日期值输出变化onReset需要重置状态
- 顶部操作
onNextYear跳到下一年onNextMonth跳到下一月onPreviousYear跳上一年onPreviousMonth跳上一月
1.4 状态管理
- 日期面板
- 当月/非当月日期的显示和响应
- 限制区域外日期的显示和响应
- 顶部操作
- 区域双板翻页的互作用
- 区域限制的显示和响应
1.5 样式实现
- 使用
flex布局
2. 实现思路
整体上,容器组件 + 子组件的设计思路,将视觉、交互按组件拆分,化整为零,简化问题。
2.1 子组件拆解
根据1.1的结构,将DatePicker拆解为4个子组件:
calendarpaneltoday
toolbar
其中panel和today都是孙级子组件。考虑到代码的可维护性,所有组件在文件存储路径上平级。
2.2 子组件开发
每个子孙组件都是纯展示组件,无内部状态;DatePicker作为容器组件,统一管理状态和属性派发。
每个子组件,按1.3创建事件接口;容器组件订阅这些接口消息,实现子组件与容器组件间通信。
2.2.1 单项数据流的函数式组件
函数式组件,是最佳的单向数据流模式,没有之一。来看一下today组件代码:
type TProps = {
onSelected?: (date: Date) => void
disabled?: boolean
}
export default (props: TProps) => {
const { onSelected = () => 0, disabled = false } = props
return (
<div class={`today-container ${disabled ? 'disabled' : ''}`}>
<button
class="today-button"
disabled={disabled}
onClick={disabled ? undefined : () => onSelected(new Date())}
>今天</button>
</div>
)
}
该组件当中,没有任何的内置状态管理,所有属性都来自props,也就是父组件的传递值。
disabled属性,考虑到在配置日期区域限制后,today有可能不在区域内,此时today按钮应当不可用,通过该属性控制;onSelected事件,用于容器组件订阅button的点击事件,来实现约定的onToday事件接口。
2.2.2 在VUE3中使用TSX函数式组件
由于VSCode对t.ds的良好支持——即便是受益于React,以及由此带来的开发侧良好体验,DatePicker的子孙组件(单向数据流组件)的开发全部使用纯函数+TSX。
实现详情和webpack支持,详见我的另外两篇文章:
2.3 组件样式
- 所有布局使用
flex scss+ 全局样式复用。
3 日历面板组件 panel
3.1 具体需求:
- 3.1.1 统一使用7x6的面板,共42天。(虽然有些月份35天面板即可完整显示,但会导致高度不一致)
- 3.1.2 面板补齐
- 3.1.2.1 一周以周日作为起点(国际默认)
- 3.1.2.2 当月1日非周日的话,向前补齐上月尾巴到周日。
- 3.1.2.3 使用下月日期向后补齐42天。
- 3.1.3 非当月日期
- 3.1.3.1 上月、下月用于补齐面板的日期格虚化显示
- 3.1.3.2 单板选择后,面板跳转到对应月
- 3.1.3.3 双板选择后
- 3.1.3.3.1 双面板按日期大小排序,左板为较早时间
- 3.1.3.3.2 双板分别跳转到对应月
- 3.1.3.3.3 当选择区域没有跨月,显示于用户操作面板,另一面板保持。
- 3.1.4 非限制区域内日期
- 3.1.4.1 虚化显示
- 3.1.4.2 鼠标悬停时cursor更改为禁用
- 3.1.5 交互
- 3.1.5.1 单板日期选择,触发
onSelected,通知更新dateStart,然后触发onChange - 3.1.5.2 单板
today点击,触发onToday,通知更新dateStart - 3.1.5.3 双板第一次点击,触发
onSelectStart,通知更新区间日期开始时间dateStart - 3.1.5.4 双板第一次点击后,面板内触发
onSelecting,通知更新鼠标位置所在日期dateHover - 3.1.5.5 双板第二次点击,触发
onSelectEnd,通知更新区间日期结束时间dateEnd,然后触发onChange - 3.1.5.6 如果
dateStart和dateEnd都已在props中定义,则触发onReset,通知父组件重置状态。 - 3.1.5.7 限制区域外日期格禁止交互(不绑定交互事件方法)
- 3.1.5.1 单板日期选择,触发
3.2 工具准备
由于需要大量的日期类型操作和比较,为了方便开发,所以需要先准备一些工具。
3.2.1 获取月份42天面板(3.1.2)
const getMonthDays = (year: number, month: number) => {
// 当月第一天
const first = new Date(year, month - 1, 1)
// 当月最后一天
const last = new Date(year, month, 0)
// 输出结构
const dates: TDateCell[] = []
// 第一天的星期几,0~6,0为周日
let day = first.getDay()
// 向左补齐星期日期
while (day > 0) {
day -= 1
dates.push({ date: new Date(year, month - 1, -day), current: -1 })
}
// 填充当月日期
day = last.getDate() - first.getDate()
for (let i = 0; i <= day; i++) {
const date = new Date(first)
date.setDate(i + 1)
dates.push({ date, current: 0 })
}
// 向右补齐星期
day = last.getDay()
let tail: Date = last
for (let i = day; i < 6; i++) {
tail = new Date(year, month, i - day + 1)
dates.push({ date: tail, current: 1 })
}
// 按42天补齐日期
// 这里不统一补齐方式,为35天动态面板预留。
if(dates.length < 42) {
day = tail.getDate()
for (let i = 1; i <= 7; i++) {
tail = new Date(year, month, day + i)
dates.push({ date: tail, current: 1 })
}
}
return dates
}
这里的一个技巧,是获取月末日期。
因为月份涉及大小月、闰年等变量因素,月末可能是28/29/30/31共4种情况,如果直接计算(尤其是闰年的计算)会比较麻烦。这里使用取下个月第一天的前一天的方式,来获取当月月末日期:
const last = new Date(2000, 2, 0) // 获取2000年2月最后一天
// -> Tue Feb 29 2000 00:00:00 GMT+0800 (中国标准时间)
这里Date的第3个参数为0,相当于:
let last = new Date(2000, 2, 1) // 获取2000年3月1日
last = last.setDate(-1) // last设置为3月1日前一天,即2月最后一天,具体日期Date会自己算。
同样的方法我们也可以获取到年度的最后一天:
const last = new Date(2000, 12, 0) // 获取2000年12月最后一天,也就是年度最后一天。
// -> Sun Dec 31 2000 00:00:00 GMT+0800 (中国标准时间)
计算一年天数的时候,有可能用到。
注意月份取值是索引值
3.2.2 日期比较和区间判断(3.1.5.7)
/** 一天毫秒数 */
const ONE_DAY = 1000 * 60 * 60 * 24
/** 按年、月、日三种颗粒度计算一个整数值 */
export const dateCounter = (date: Date, type: 'y' | 'm' | 'd') => {
switch(type) {
case 'y': return date.getFullYear()
case 'm': return date.getFullYear() * 12 + date.getMonth()
}
return date.getTime() / ONE_DAY >> 0
}
/** 按年、月、日三种颗粒比较两个日期 */
export const compareDateSort = (d1: Date, d2: Date, type: 'y' | 'm' | 'd') => {
const t1 = dateCounter(d1, type), t2 = dateCounter(d2, type)
return t1 < t2 ? -1 : t1 > t2 ? 1 : 0
}
/**
* d 是否在 [left, right] 区间
* @param date 日期
* @param left 最小日期
* @param right 最大日期
* @returns boolean
*/
export const betweenDate = (date: Date, left: any, right: any): boolean => {
if(left instanceof Date && compareDateSort(left, date, 'd') > 0) {
return false
}
if(right instanceof Date && compareDateSort(date, right, 'd') > 0) {
return false
}
return true
}
3.2.3 日期转换(3.1.5.7)
/** 字符串转日期 */
export const parseDate = (str?: string) : Date | null => {
if(!str || typeof str !== 'string') {
return null
}
const [dateStr = '', timeStr = ''] = str.split(/([ ]|T)+/)
if(!dateStr) {
return null
}
const [y, m, d] = dateStr.split(/[^\d]+/)
const year = _parseInt(y), month = _parseInt(m), date = _parseInt(d) || 1
if(!year || !month) {
return null
}
const time = parseTime(timeStr)
return new Date(year, month - 1, date, ...time)
}