【TimePiker.RangePiker】时间范围选择组件封装与时间检验

一、前言

hello,大家好。

第一次认真地写文章,上篇文章是去年用md文档写的小码农日常思考,有兴趣的小伙伴可以移步2022年前端踩坑记。先自我介绍一下。本人是深圳某独角兽公司的一枚前端程序媛,今年立下了每两周发表一篇文章的flag,现在正在逐步兑现诺言,希望能一直坚持下去,为朋友们输出一些有价值的内容。文章主要包括如下这几个主题:js基础知识及难点;node、webpack、typescript、数据结构和算法等知识笔记整理;react编程重难点;项目中遇到的问题及解决方案;日常编程思考;外网文章翻译

欢迎有兴趣的小伙伴关注我,Thanks♪(・ω・)ノ。

希望共同进步,共勉!

话不多说,进入正题。

近期,我做了一个时间范围选择相关的动态增删表单需求,类似下图所示。此需求需要封装相关组件,用于以后批量使用该组件,细节上需要做时间范围相关的选择限制和时间冲突校验。

d3bada6a-ac85-4242-8120-9a4e182412a8.jpeg

二、解决思路

封装组件管理状态时,有如下三个方案:

  1. 直接使用antd上提供的动态增删表单项,antd直接为码农们提供了add和remove事件,直接调用即可,可见官网代码示范
  2. 使用ahooks中的useDynamicList封装组件,此hook相当实用,提供了merge、replace、getIndex、push、pop等一系列的方法,能帮助你快速管理动态列表状态。
  3. 运用useReducer自行编写方法管理状态,封装组件。

由于我常用material UI组件库,不选择方法1;由于此组件的状态并不特别复杂,不选择方法2。总之,方法3夺魁,正好也能锻炼一下编码能力。

三、核心代码

1、TimePicker.RangePicker设置结束时间大于开始时间

🥹🥹🥹很遗憾地发现,不像日历范围组件(DatePicker.RangePicker)直接做了限制,antd没有为时间范围组件(TimePicker.RangePicker)提供专门的api直接限制选择的结束时间是否大于开始时间或者选择的开始时间小于结束时间,不满足条件时不可选中数值或无法点击保存按钮。只能自己运用disabledTime去限制时间可选状态了。

因为onCalendarChange事件可以在用户选择并确认开始时间/结束时间时触发,通过该回调可获取当前选择的时间区间。但是细心的小伙伴发现,事情并没有这么简单……

6f35539767261d060e13efdbc61a0830.gif

由于时间范围组件没有选择鼠标失去焦点(onblur)时保存时间,即当用户选择开始时间/结束时间后,没有点击确认按钮的情况下,手动切换鼠标焦点至另一个输入框时,onCalendarChange事件并没有触发。这意味着无法获取当前的时间区间……

我通过查阅antd源码rc-picker源码发现,RangePicker组件将外层组件传入的id赋值于第一个input输入框,第二个输入框不赋值。于是,找到了突破点——可以通过时间组件的onFocus回调获取dom节点,并通过useRef.current保存两个输入框的dom,在限制时间时直接读取其值。

  • rc-picker相关源码

ee37b18a-9a20-4482-b454-e75ef5df3b6e.jpeg

d85e4452-3bd5-48f1-a3a9-f3eb8f3df434.jpeg

此法有一些繁琐,不过总归是实现了需求,读者们可以参考一下(也可考虑通过原生js获取两个输入框。我偷懒先不继续折腾了,有兴趣的小伙伴可以在评论区留下你的想法)。

代码参考如下

  • 一些时间限制相关的方法,可独立写在util文件中
// 批量生成数字范围, 如[0,1,2,3]
const range = (start: number, end: number): number[] => {
    const result = [];
    for (let i = start; i < end; i++) {
        result.push(i);
    }
    return result;
};

// 不可选的小时
const disabledHoursFun = ({ type, hourNum, minuteNum, secondNum }: { type: RangeType, hourNum: number, minuteNum: number, secondNum: number }): number[] => {
    return type === EnumRangeType.START ? range(minuteNum === 0 && secondNum === 0 ? hourNum : hourNum + 1, 24) : range(0, hourNum);
}

// 不可选的分钟
const disabledMinutesFun = ({ type, selectedHour, hourNum, minuteNum, secondNum }: { type: RangeType, selectedHour: number, hourNum: number, minuteNum: number, secondNum: number }): number[] => {
    if (selectedHour === -1) return range(0, 60);
    if (selectedHour !== hourNum) return [];
    return type === EnumRangeType.START ? range(secondNum === 0 ? minuteNum : minuteNum + 1, 60) : range(0, minuteNum);
}

// 不可选的秒数
const disabledSecondsFun = ({ type, selectedHour, selectedMinute, hourNum, minuteNum, secondNum }: { selectedHour: number, selectedMinute: number, type: RangeType, hourNum: number, minuteNum: number, secondNum: number }): number[] => {
    if (selectedHour === -1 || selectedMinute === -1) return range(0, 60);
    if (selectedHour !== hourNum || selectedMinute !== minuteNum) return [];
    return type === EnumRangeType.START ? range(secondNum, 60) : range(0, secondNum + 1);
}

// 将时间字符串(如'12:00:00')转化为时间戳,用于比较时间大小
const getTimeStamp = (timeString: string) => {
    const randomDate = '2022-05-01 ';
    return new Date(randomDate + timeString).getTime();
};

const getTimeStampArr = (arr: string[] = []) => {
    if (arr?.length !== 2 || !checkTimeValid(arr[0]) || !checkTimeValid(arr[1])) return {};
    return { start: getTimeStamp(arr[0]), end: getTimeStamp(arr[1]) };
}
  • 组件中编写disabledTime和onFocus方法
function RowComponent({defaultValue}:any) {
  const defaultStartTime = defaultValue.length > 1 ? defaultValue[0] : '';
    const defaultEndTime = defaultValue.length > 1 ? defaultValue[1] : '';
    const startInputRef = useRef<any>(null);
    const endInputRef = useRef<any>(null);

    // 时间可选状态
    const disabledTime = (date: EventValue<Moment>, type: RangeType): DisabledTimes => {
        const initDisabledTime = { disabledHours: () => [], disabledMinutes: () => [], disabledSeconds: () => [] };
        const startTime = (startInputRef?.current ? startInputRef.current?.value : defaultStartTime) || '';
        const endTime = (endInputRef?.current ? endInputRef.current?.value : defaultEndTime) || '';
        const _pickTimeString: [string, string] = [startTime, endTime];
        if (_pickTimeString[0] === '' && _pickTimeString[1] === '') return initDisabledTime;

        const index = type === EnumRangeType.START ? 1 : 0;
        let timeArr = _pickTimeString[index].split(':');

        if (timeArr.length < 3) return initDisabledTime;
        let hourNum = Number(timeArr[0]);
        let minuteNum = Number(timeArr[1]);
        let secondNum = Number(timeArr[2]);

        return ({
            disabledHours: () => disabledHoursFun({ type, hourNum, minuteNum, secondNum }),
            disabledMinutes: (selectedHour) => disabledMinutesFun({ type, selectedHour, hourNum, minuteNum, secondNum }),
            disabledSeconds: (selectedHour, selectedMinute) => disabledSecondsFun({ type, selectedHour, selectedMinute, hourNum, minuteNum, secondNum }),
        })
    }
    
      const onFocus = (e: React.FocusEvent<HTMLInputElement>) => {
        const { id } = e.target || {};
        if (id === START_INPUT_ID) {
            // 开始时间
            if (!startInputRef?.current) startInputRef.current = e.target;
            return;
        }
        if (!endInputRef?.current) endInputRef.current = e.target;
    }
    
  return <TimePicker.RangePicker id={START_INPUT_ID}  disabledTime={disabledTime}  onFocus={onFocus} />;
}
2、运用useReducer统一管理状态和更新状态的方法

由于动态增删表单项需要对同一数据源进行多种不同的操作,如果统一用useState进行处理比较繁琐,因此选用useReducer统一管理状态。
(useReducer的基本使用方式可以参考react官网。)

代码参考如下

  • 定义reducer
function reducer(state: any = {}, action: any = {}) {
    const { rowsData, onChange } = state;
    const { type, index, value = {} } = action;
    let newRowsData: TimeStringGroup[] = rowsData;

    switch (type) {
        case ActionType.ADD: {
            newRowsData.push(['', '']);
            break;
        }
        case ActionType.REMOVE:
            newRowsData.splice(index, 1);
            break;
        case ActionType.CHANGE:
            newRowsData.splice(index, 1, value.time);
            break;
        default:
            break;
    }

    onChange && onChange(newRowsData);
    return { ...state, rowsData: newRowsData };
}
  • 指定初始 state和调用dispatch
// 时间选择组件
const RangeTimePicker = (props: any) => {
    const { onChange, defaultValue = [] } = props;

    const initState = {
        rowsData: defaultValue?.length > 0 ? [...defaultValue] : [['', '']],
        onChange,
    };
    const [state, dispatch] = useReducer(reducer, initState);

    return (
        <ErrorBoundary component='TimePicker-Field'>
            {state.rowsData?.map((data: TimeStringGroup, index: number, arr: TimeStringGroup[]) => {
                return <>
                <button onClick={() => dispatch({type: ActionType.ADD})}>+</button>
                <button onClick={() => dispatch({type: ActionType.REMOVE, index})}>-</button>
                    </>;
            })}
        </ErrorBoundary>
    );
};
3、运用useRef实现光标自动定位

校验校验是否时间段冲突或不合理后,需要将光标自动定位于错误的组件,可以通过useRef获取该组件的dom,再调用focus方法即可。(useRef的基本使用方式可以参考react官网。)

代码参考如下

function RowComponent({wrongIndex, conflictIndex}:any) {
  const pickerRef = useRef(null);
  useEffect(() => {
        // 光标自动定位
        if (wrongIndex !== index && conflictIndex !== index) return;
        if (pickerRef && pickerRef.current) {
            pickerRef.current.focus();
        }
    }, [wrongIndex, conflictIndex])
  
  return <TimePicker.RangePicker ref={pickerRef} status={wrongIndex === index || conflictIndex === index ? 'error' : undefined} />;
}

四、遗留问题

不过,本文中限制可选时间的解决方案依旧有不足之处。例如,当用户选择了12:00:00作为开始时间时,在选择结束时间时,可只选择12作为小时,点击保存,两个时间便相等了。我曾尝试运用disabledDate去限制确认按钮的可用状态,但是因为调用弛缓问题,无法做到完美限制。

于是,在提交表单时,除了比较所有时间段是否重合,我还统一做了开始时间是否等于结束时间的校验。

代码参考如下

// 比较时间是否重合
const checkTimeConflict = (timeArr: any[]): any => {
    const length = timeArr?.length;
    if (length === 0) return;

    let conflictIndex = -1;
    for (let i = 0; i < length; i++) {
        if (conflictIndex !== -1) return;

        const { start: start1, end: end1 } = getTimeStampArr(timeArr[i]);
        if (!start1 || !end1) continue;

        for (let j = i + 1; j < length; j++) {
            const { start: start2, end: end2 } = getTimeStampArr(timeArr[j]);
            if (!start2 || !end2) continue;

            if (!(start1 > end2 || start2 > end1)) {
                conflictIndex = j;
                return conflictIndex;
            }
        }
    }
}

// 开始时间不小于结束时间
const checkTimeArrWrong = (timeArr: any[]) => {
    const length = timeArr?.length;
    if (length === 0) return;

    for (let i = 0; i < length; i++) {
        const { start, end } = getTimeStampArr(timeArr[i]);
        if (!start || !end) break;
        if (start === end || start > end) return i;
    }
}

五、总结

此次时间范围组件的探索之旅虽然磕磕绊绊的,但是总算告一段落,也给我带来了蛮多不错的挑战体验。希望能再接再厉,不断提升编码能力。

如有不足和错误之处,敬请大家评论区告诉我,谢谢🙏。

六、参考链接汇总

  1. antd的timePicker.RangePicker设置结束时间不可早于开始时间
  2. react-component/picker
  3. useRducer
  4. github issue
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值