3

自从学习了react后一直琢磨写点项目什么之类的来练练手,恰好公司现在的项目还不是很大,足够我用react进行重构。最开始的时候是想自己去开发组件,后来发现有更好的antd这个东东就放弃了自己开发组件直接将antd的组件拿过来用,感觉挺好用的,直到最近对时间选择器TimePicker这个组件进行实践的时候,发现这个组件的api特别难用(貌似还有bug,应该是我的错觉。。。),所以决定自己开发一个类似的时间选择器来锻炼自己。
言归正传,下面讲解一下我自定义的时间选择器TimePicker开发过程中遇到的坑和目前最终的实现思路(嗯,本人很菜,不吝啬指点...)。
首先构建静态的时间结构,我这里只支持小时和分钟。结构分为上下两层,上层输入框支持直接输入时间,下层选择栏用于给用户提供点击选择,关键代码实现如下:

<div className="my-TimePicker-wrapper">
    <div className="my-TimePicker-header">
        <span class="ant-time-picker ">
            <input className="ant-time-picker-input" />
        </span>
    </div>
    <div className="my-TimePicker-content">
        <div className="my-TimePicker-content-box">
            <ul className="my-TimePicker-content-menu" >
            {
                // 你的小时循环遍历
            }
            </ul>
        </div>
        <div className="my-TimePicker-content-box">
            <ul className="my-TimePicker-content-menu">
            {
                // 你的分钟循环遍历
            }
            </ul>
        </div>
    </div>
</div>

很简单的一段静态结构,css方面抄了antd的一点,自己写了一点,此处不在过多的去描述,各位童鞋可以自己去写适合自己的。嗯,静态结构搭建完毕后下面就是关键的三个部分的描述,也就是输入框的匹配,点击小时和分钟后的事件描述。
第一块:首先来讲解一下小时和分钟的实现的思路和步骤:

  1. 当用户点击小时或分钟时,优先需要判断用户点击值是否合法的有效的,比如:你将这个小时和分钟禁用掉了,这时候用户点击肯定是无效的;

  2. 其次就是需要判断用户在点击时是否已经点击过其他的小时或着分钟的元素,如果存在点击过的元素,我们需要将上一次存放点击的元素的变量的选中效果进行清除,然后将此次的点击设为选中,并且将此次点击的元素对象和元素值使用变量进行存放;

  3. 然后我们玩一个简易的点击选中的元素速度动画(个人兴趣,你也可以不用动画)将选中的元素置顶,
    顺手判断一下小时和分钟是否同时存在点击,存在则设置输入框的值,不推荐使用state设置值,存放的变量也不推荐使用state(结束时解释);

  4. 最后由于我向用户提供了一个onchange方法,所以需要判断一下用户是否设置了onchange,若设置则执行用户的onchange。

关键代码实现如下:

// 以小时为例:

    hourClick = (e) => {
        let target = e.target, obj = this, 
            selectedHour = target.innerHTML, 
            selectedMinute = obj.selectedMinute; 
        if(target.className === "my-TimePicker-time-selected-disabled"){
            // 判断用户点击值是否合法,此处我时判断是否为禁用状态
            return ;
        }
        if(obj.prevHourSelected){
            // 判断用户是否已经点击过其他元素,有则将选中清空
            obj.prevHourSelected.className = "";
        }
        // 将此次点击设为选中
        target.className = "你的选中类名";
    
        // 将此次点击设置为上一次选中
        obj.prevHourSelected = target;
        obj.selectedHour = selectedHour;
        obj.animateTop(selectedHour, selectedMinute, obj.time); // 简易的点击速度动画
    
        if(selectedMinute !== ""){
            // 判断用户的是否同时点击过分钟,若存在则设置输入框的值
        }
    
        if(obj.props.onChange){
            // 用户的回调函数执行
        }
    }

第二块,输入框输入时间时的实现思路和步骤:

  1. 去除用户输入的值的前后空格,然后判断用户值的长度是否为0,如果输入为0则用户执行了删除操作,此时我们将用户之前输入的值设置的对应的存放变量和选中效果统统清空,然后执行用户回调(如果有的话);

  2. 对用户值的长度进行判断是否等于5,因为我是HH:mm的格式,所以长度为5,为什么要进行长度验证,因为input的onchange方法当用户改变值时都会执行方法,我做了正则判断,不通过就会清空,所以需要在长度等于5时才进行正则验证;

  3. 通过长度验证后,进行正则验证,不通过就清空值,通过后开始进行设置小时和分钟的选中,同时清掉上一次选中的效果和存放的变量值,接着判断输入的值是否合法,比如你不能输入已经禁用的值,通过后将此次输入的值设置为选中,并存放到变量中,又是判断一下是否有回调;
    关键代码实现如下:


    if(value.length !== 0){
        if(value.trim().length === 5){ // 符合长度要求后进行正则验证
            if(obj.reg.test(value)){
                let hour = +value.split(":")[0], minute = +value.split(":")[1];
                // 获取输入值所在的选项的样式
                    ...
    
                if(obj.prevHourSelected){ // 如果上一次存在选中的时间就清空选中样式
                    xxx.className = "";
                }
                if(obj.prevMinuteSelected){ // 如果上一次存在选中的时间就清空选中样式
                    xxx.className = "";
                }
                // 如果所选值不是禁止选中,就添加选中样式,并且启用滚动效果
                if(hourClass.indexOf("my-TimePicker-time-selected-disabled") === -1 && minuteClass.indexOf("my-TimePicker-time-selected-disabled") === -1){
                    xxx.className = hourClass + " my-TimePicker-time-selected";
                    xxx.className = minuteClass + " my-TimePicker-time-selected";
                    obj.animateTop(hour, minute, obj.time);
                    //添加此次输入为上一次选中
                    obj.prevHourSelected = xxx; 
                    obj.prevMinuteSelected = xxx; 
                    obj.selectedHour = hour; 
                    obj.selectedMinute = minute; 
    
                    if(obj.props.onChange){ // 用户是否绑定了自定义onChange
                        // 执行回调
                    }
                }else{ // 输入为禁用数时清空值
                    input(输入框的对象).value = "";
                }
            }
        }
    }else{
        // 删除操作,清空所有值,选中和变量存放到值
    }

第三块,用户设置默认值的设置,componentDidMount进行判断和设置,然后每次用户更新state是在shouldComponentUpdate判断是否值是否合法(此处代码和之前较相似不在贴出关键代码实现):

  1. componentDidMount设置值,并将值设置为选中和存放到已经为选中的变量中,然后判断是否回调;

  2. shouldComponentUpdate中判断用户的值是否合法,合法返回true并执行componentWillReceiveProps,否则返回false;

第四块,componentWillReceiveProps,用户在外部更新组件到state后的操作,根据个人需求来,我这里主要描述用户设置或选择的值是否为禁用的值

  1. 拿到用户设置或选择的小时和分钟的值(怎么拿?之前强调过,用户每次输入或者选择的时候我会将值存放到对应的变量中,此处直接获取对应变量的值);

  2. 判断用户设置的小时和分钟是否处于禁用状态(怎么知道是禁用,我要求用户传入要禁用的值的数组),非禁用则执行setState来更新小时和分钟的列表,否则清空所有的操作状态和值;

解释不用state设置值:使用state设置文本框值,会出现无法删除文本框的值(不知道你们会不会有这种情况,我这里会有,所以我没有用state),应该是跟react的虚拟dom有关,用户在界面上的操作在js没有更新state的情况下应该会始终保持state值不变。

写在最后:本人react菜鸟一枚,第一次分享自己做的东西的思路难免出现大量的纰漏或者疏忽,望谅解,所以也是希望和各位相互交流,如有大神指点本人表示热烈欢迎。本人不吝啬各位指点,无论水平高低,只要有好的想法或者更好的实现方式都可以和本人交流。。。欢迎回复我。。。

组件代码示例:

import React from 'react';
import ReactDOM from 'react-dom';

class MyTimePicker extends React.Component {  
    constructor(props){
        super(props);
        this.valueHeight = 28; // 时间值的高度
        this.reg = /^((20|21|22|23|[0-1]\d)\:[0-5][0-9])$/; // 验证时间格式是否正确
        this.handleHour = null; // 小时滚动定时器
        this.handleMinute = null; // 分钟滚动定时器
        this.prevHourSelected = null; // 上一级选择的小时
        this.prevMinuteSelected = null; // 上一次选择的分钟
        this.selectedHour = ""; // 选中的小时
        this.selectedMinute = ""; // 选中的分钟
        this.time = 50; // 滚动时间频率
        if(props.onChange){ // 如果用户使用了自定义onChange,就将用户的onChange赋值给本地的setChange属性
            this.setChange = props.onChange; 
        }
    }
    state = {
        disabledHours: this.props.disabledHours, // 禁用的小时数组
        disabledMinutes: this.props.disabledMinutes, // 禁用的分钟数组
        isShow: false, // 是否显示选择框
    }
    setChange(hour, minute){} // 构造本地的change方法接受用户自定义方法
    range(start, end){ // 构造时间
        if(typeof start !== "number" || typeof end !== "number" || start >= end){
            return [];
        }
        let arr = [];
        for(let i = start; i <= end; i++){
            arr.push(i);
        }
        return arr;
    }
    onChange(e){
        e.stopPropagation();
        let obj = this, value = e.target.value.trim();
        if(value.length !== 0){
            if(value.trim().length === 5){ // 符合长度要求后进行正则验证
                if(obj.reg.test(value)){
                    let hour = +value.split(":")[0], minute = +value.split(":")[1];
                    // 获取输入值所在的选项的样式
                    let hourClass = obj.refs['my-Timepicker-hour'].children[0].children[hour].className;
                    let minuteClass = obj.refs['my-Timepicker-minute'].children[0].children[minute].className;

                    if(obj.prevHourSelected){ // 如果上一次存在选中的时间就清空选中样式
                        obj.refs['my-Timepicker-hour'].children[0].children[+obj.prevHourSelected.innerHTML].className = "";
                    }
                    if(obj.prevMinuteSelected){ // 如果上一次存在选中的时间就清空选中样式
                        obj.refs['my-Timepicker-minute'].children[0].children[+obj.prevMinuteSelected.innerHTML].className = "";
                    }

                    // 如果所选值不是禁止选中,就添加选中样式,并且启用滚动效果
                    if(hourClass.indexOf("my-TimePicker-time-selected-disabled") === -1 && 
                       minuteClass.indexOf("my-TimePicker-time-selected-disabled") === -1){
                           obj.refs['my-Timepicker-hour'].children[0].children[hour].className = hourClass + " my-TimePicker-time-selected";
                           obj.refs['my-Timepicker-minute'].children[0].children[minute].className = minuteClass + " my-TimePicker-time-selected";
                        obj.animateTop(hour, minute, obj.time);

                        //添加此次输入为上一次选中
                        obj.prevHourSelected = obj.refs['my-Timepicker-hour'].children[0].children[hour]; 
                        obj.prevMinuteSelected = obj.refs['my-Timepicker-minute'].children[0].children[minute]; 
                        obj.selectedHour = hour; 
                        obj.selectedMinute = minute; 

                        if(obj.props.onChange){ // 用户是否绑定了自定义onChange
                            obj.setChange(hour, minute);
                        }
                    }else{ // 输入为禁用数时清空值
                        obj.refs['my-Timepicker-text'].value = "";
                    }
                }else{ // 置空
                    e.target.value = "";
                }
            }
        }else{
            obj.prevHourSelected = null; // 上一级选择的小时
            obj.prevMinuteSelected = null; // 上一次选择的分钟
            obj.selectedHour = ""; // 选中的小时
            obj.selectedMinute = ""; // 选中的分钟
            let hourList = obj.refs['my-Timepicker-hour'].children[0].children, 
                minuteList = obj.refs['my-Timepicker-minute'].children[0].children; 
            for(let i = 0, len = hourList.length; i < len; i++){
                if(hourList[i].className === "my-TimePicker-time-selected"){
                    hourList[i].className = "";
                    break;
                }
            }
            for(let i = 0, len = minuteList.length; i < len; i++){
                if(minuteList[i].className === "my-TimePicker-time-selected"){
                    minuteList[i].className = "";
                    break;
                }
            }
            obj.animateTop(0, 0, obj.time);
            if(obj.props.onChange){ // 用户是否绑定了自定义onChange
                obj.setChange("", "");
            }
        }
    }
    /*
     * 小时/分钟点击效果
     * 参数&变量说明:
     *    参数:e -> 小时选中对象(当前或者上一次)
     *    变量:selectedHour -> 当前选中小时,selectedMinute -> 当前选中分钟
     * 1、如果是禁止选择的时间,return
     * 2、如果上一次存在选中的时间就清空选中样式
     * 3、通过前两次判断后添加当前选择为选中,设置当前选中小时
     * 4、添加本次选中为上次选中
     * 5、添加选中动画
     * 6、如果小时和分钟同时选中,则设置文本框值
     * 7、如果小时和分钟同时选中则启用回调
     */
    hourClick = (e) => {
        let target = e.target, obj = this, 
            selectedHour = target.innerHTML, 
            selectedMinute = obj.selectedMinute; 
        if(target.className === "my-TimePicker-time-selected-disabled"){
            return ;
        }
        if(obj.prevHourSelected){
            obj.prevHourSelected.className = "";
        }
        target.className = "my-TimePicker-time-selected";

        obj.prevHourSelected = target;
        obj.selectedHour = selectedHour;
        obj.animateTop(selectedHour, selectedMinute, obj.time);

        if(selectedMinute !== ""){
            obj.refs['my-Timepicker-text'].value = selectedHour+":"+selectedMinute;
        }

        if(obj.props.onChange){
            obj.setChange(+selectedHour, selectedMinute);
        }
    }
    minuteClick = (e) => {
        let target = e.target, obj = this, 
            selectedHour = obj.selectedHour, 
            selectedMinute = target.innerHTML; 
        if(target.className === "my-TimePicker-time-selected-disabled"){
            return ;
        }
        if(obj.prevMinuteSelected){
            obj.prevMinuteSelected.className = "";
        }
        target.className = "my-TimePicker-time-selected";

        obj.prevMinuteSelected = target;
        obj.selectedMinute = selectedMinute;
        obj.animateTop(selectedHour, selectedMinute, obj.time);

        if(selectedHour !== ""){
            obj.refs['my-Timepicker-text'].value = selectedHour+":"+selectedMinute;
        }

        if(obj.props.onChange){
            obj.setChange(selectedHour, +selectedMinute);
        }
    }
    animateTop(hour, minute, time){ // 时间滚动效果
        let obj = this, 
            curHourTop = obj.refs['my-Timepicker-hour'].scrollTop, // 当前小时的滚动高度
            curMinuteTop = obj.refs['my-Timepicker-minute'].scrollTop; // 当前分钟的滚动高度
        clearInterval(obj.handleHour);
        clearInterval(obj.handleMinute);

        // 不为空就转成数字
        hour = (hour === "")?"":+hour;
        minute = (minute === "")?"":+minute;

        // 判断是否进行动画
        if(curHourTop !== obj.valueHeight*hour && hour !== ""){
            obj.handleHour = setInterval(() => {
                let getTop = obj.refs['my-Timepicker-hour'].scrollTop, // 实时获取滚动高度
                    result = getTop - obj.valueHeight*hour, // 实时计算滚动高度差
                    speed = Math.floor(result/3); // 实时计算滚动的正负速度
                if(speed !== 0){ // 如果滚动高度差绝对值大于0,始终滚动
                    obj.refs['my-Timepicker-hour'].scrollTop = getTop - speed;
                }else{ // 反之,停止滚动
                    obj.refs['my-Timepicker-hour'].scrollTop = obj.valueHeight*hour; // 速度等于0时,手动修正动画误差
                    clearInterval(obj.handleHour);
                }
            }, time);
        }
        if(curMinuteTop !== obj.valueHeight*minute && minute !== ""){
            obj.handleMinute = setInterval(() => {
                let getTop = obj.refs['my-Timepicker-minute'].scrollTop, // 实时获取滚动高度
                    result = getTop - obj.valueHeight*minute, // 实时计算滚动高度差
                    speed = Math.floor(result/2); // 实时计算滚动的正负速度
                if(speed !== 0){ // 如果速度不等于0,始终滚动
                    obj.refs['my-Timepicker-minute'].scrollTop = getTop - speed;
                }else{ // 反之,停止滚动
                    obj.refs['my-Timepicker-minute'].scrollTop = obj.valueHeight*minute; // 速度等于0时,手动修正动画误差
                    clearInterval(obj.handleMinute);
                }
            }, time);
        }
    }
    componentDidMount(){
        let obj = this, value = obj.props.defaultValue;
        if(value !== "" && obj.reg.test(value)){
            let hour = +value.split(":")[0], 
                minute = +value.split(":")[1], 
                hourObj = obj.refs['my-Timepicker-hour'].children[0].children[hour],
                minuteObj = obj.refs['my-Timepicker-minute'].children[0].children[minute], 
                hourClass = hourObj.className, 
                minuteClass = minuteObj.className; 

            obj.refs['my-Timepicker-text'].value = obj.props.defaultValue;

            obj.prevHourSelected = hourObj; // 上一级选择的小时
            obj.prevMinuteSelected = minuteObj; // 上一次选择的分钟
            obj.selectedHour = hour; // 选中的小时
            obj.selectedMinute = minute; // 选中的分钟

            obj.animateTop(hour, minute, obj.time);

            if(obj.props.onChange){
                obj.setChange(hour, minute);
            }
        }
    }
    // 已加载组件首次渲染完毕后,父组件更新state自动调用次方法重新传入props,接受值后刷新禁用值, 判断选中的值是否在禁用中存在
    componentWillReceiveProps(nextProps){
        let obj = this, getHour = obj.selectedHour, getMinute = obj.selectedMinute;
        if(nextProps.disabledHours.indexOf(getHour) === -1 && nextProps.disabledMinutes.indexOf(getMinute) === -1){
            this.setState({ 
                disabledHours: nextProps.disabledHours,
                disabledMinutes: nextProps.disabledMinutes, 
            });
        }else{
            obj.reset();
        }
    }
    reset = () => { // 重置所有值和选择
        let obj = this;
        obj.refs['my-Timepicker-text'].value = "";
        obj.prevHourSelected = null; // 上一级选择的小时
        obj.prevMinuteSelected = null; // 上一次选择的分钟
        obj.selectedHour = ""; // 选中的小时
        obj.selectedMinute = ""; // 选中的分钟
        let hourList = obj.refs['my-Timepicker-hour'].children[0].children, 
            minuteList = obj.refs['my-Timepicker-minute'].children[0].children;
        obj.setState({ 
            disabledHours: [],
            disabledMinutes: [], 
        }); 
        obj.animateTop(0, 0, obj.time);
    }
    onMouseMove = (e) => {
        this.setState({ isShow: true });
    }
    onMouseOut = (e) => {
        this.setState({ isShow: false });
    }
    render(){
        let obj = this;
        return(
            <div className="my-TimePicker-wrapper" onMouseMove={obj.onMouseMove} onMouseOut={obj.onMouseOut}>
                <div className="my-TimePicker-header">
                    <span class="ant-time-picker ">
                        <input id={obj.props.id} placeholder={obj.props.placeholder}
                            ref="my-Timepicker-text" className="ant-time-picker-input"
                            onChange={obj.onChange.bind(this)} />
                    </span>
                </div>
                <div style={{ height:(obj.state.isShow)?"138px":"0px" }} ref="my-Timepicker-content" className="my-TimePicker-content">
                    <div ref="my-Timepicker-hour" className="my-TimePicker-content-box">
                        <ul className="my-TimePicker-content-menu" onClick={obj.hourClick} >
                        {
                            obj.range(0, 23).map((cur, index) => {
                                let getHour = cur, setClass = "";
                                obj.state.disabledHours.map((cur1, index1) => {
                                    if(getHour === cur1){
                                        setClass = "my-TimePicker-time-selected-disabled"
                                    }
                                })
                                return <li className={ setClass } key={index} >{(cur < 10)?"0"+cur:cur}</li>
                            })
                        }
                        </ul>
                    </div>
                    <div ref="my-Timepicker-minute" className="my-TimePicker-content-box">
                        <ul className="my-TimePicker-content-menu" onClick={obj.minuteClick} >
                        {
                            obj.range(0, 59).map((cur, index) => {
                                let getHour = cur, setClass = "";
                                obj.state.disabledMinutes.map((cur1, index1) => {
                                    if(getHour === cur1){
                                        setClass = "my-TimePicker-time-selected-disabled"
                                    }
                                })
                                return <li className={ setClass } key={index} >{(cur < 10)?"0"+cur:cur}</li>
                            })
                        }
                        </ul>
                    </div>
                </div>
            </div>
        )
    }
}

MyTimePicker.propTypes = {
     id: React.PropTypes.string,
     defaultValue: React.PropTypes.string, // 默认值
     placeholder: React.PropTypes.string,
     disabledHours: React.PropTypes.array, // 禁用的小时数组
     disabledMinutes: React.PropTypes.array // 禁用的分钟数组
};
MyTimePicker.defaultProps = {
    id: "",
    defaultValue: "",
    placeholder: "请选择时间",
    disabledHours: [],
    disabledMinutes: []
}

export default MyTimePicker;

使用实例:

/*
     * 参数说明:hour -> 回调小时,minute -> 回调分钟
     * 变量说明:obj -> 当前上下文, endHourArr -> 禁用结束小时数组,endMinuteArr -> 禁用结束分钟数组,
     *           startMinuteArr -> 禁用开始分钟数组,preEndHour -> 上一次选中的结束小时
     *           preEndMinute -> 上一次选中的结束分钟
     * 方法说明:选中时间后的回调方法,start和end是开始与结束,两个方法功能相同
     *      1、只选中时间时,返回禁用的小时(不包括选中的小时)
     *      2、判断时间和分钟是否同时选中,返回禁用的小时数组,根据分钟的位置决定小时是否进退
     *      3、在时间和分钟同时选中时,继续判断是否存在已经选中的开始/结束小时并判断是否和当前选中小时相等,返回禁用的分钟数组
     *      4、判断开始/结束小时和分钟是否选中,并判断开始/结束小时否和当前小时相等,返回当前元素需要禁用的分钟数组
     */
    changeStartTime(hour, minute){ 
        let obj = this, 
            endHourArr = [], 
            endMinuteArr = [], 
            startMinuteArr = [], 
            preEndHour = obj.endHour, 
            preEndMinute = obj.endMinute; 

        if(hour !== ""){
            endHourArr = obj.range(0, +hour, "<");
        }
        if(hour !== "" && minute !== ""){
            endHourArr = (+minute === 59)?obj.range(0, +hour):obj.range(0, +hour, "<");
            if(preEndHour !== "" && parseInt(preEndHour) === parseInt(hour)){
                endMinuteArr = obj.range(0, +minute);
            }
        }
        if(hour !== "" && preEndHour !== "" && preEndMinute !== "" && parseInt(preEndHour) === parseInt(hour)){
            startMinuteArr = obj.range(+preEndMinute, 59);
        }

        obj.startHour = hour;
        obj.startMinute = minute;
        obj.setState({ 
            disabledStartMinutes: startMinuteArr, 
            disabledEndHours: endHourArr,
            disabledEndMinutes: endMinuteArr, 
        });
    }

暗夜的风雪
216 声望4 粉丝

只值白菜价。